feat: load personas from target repo .review-bot/personas/
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 8m12s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 8m15s
CI / test (pull_request) Successful in 15s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Failing after 42s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 8m12s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 8m15s
CI / test (pull_request) Successful in 15s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Failing after 42s
Adds support for repository-specific personas. When --persona is specified, review-bot now: 1. Checks the target repo's .review-bot/personas/<name>.yaml directory 2. Falls back to built-in persona if not found in repo This allows repos to define domain-specific personas (trading, regulatory, etc.) or override built-in personas with project-specific rules, without requiring changes to CI configuration. Implementation: - New review.PersonaFetcher interface for abstracting Gitea API access - review.LoadRemotePersonas() with graceful fallback on 404 - review.MergePersonas() for combining remote and built-in personas - giteaFetcher adapter in main.go to bridge gitea.Client The feature follows a partial-success model: invalid YAML files or network errors for individual persona files are logged and skipped, allowing other valid personas to load. Closes #60
This commit is contained in:
+64
-22
@@ -116,29 +116,10 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load persona if specified
|
||||
// Persona loading is deferred until after giteaClient is initialized,
|
||||
// so we can try loading from the target repo first.
|
||||
var persona *review.Persona
|
||||
if *personaName != "" {
|
||||
var err error
|
||||
persona, err = review.LoadBuiltinPersona(*personaName)
|
||||
if err != nil {
|
||||
slog.Error("failed to load persona", "persona", *personaName, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("loaded built-in persona", "persona", persona.Name, "display", persona.DisplayName)
|
||||
} else if *personaFile != "" {
|
||||
resolvedPath, err := validateWorkspacePath(*personaFile, "persona-file")
|
||||
if err != nil {
|
||||
slog.Error("invalid persona-file path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
persona, err = review.LoadPersona(resolvedPath)
|
||||
if err != nil {
|
||||
slog.Error("failed to load persona file", "file", *personaFile, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("loaded persona from file", "file", *personaFile, "persona", persona.Name)
|
||||
}
|
||||
var personaErr error
|
||||
|
||||
// Validate reviewer-name: only safe characters allowed in sentinel
|
||||
if err := validateReviewerName(*reviewerName); err != nil {
|
||||
@@ -196,6 +177,41 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), overallTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Load persona: try remote repo first, then fall back to built-in
|
||||
if *personaName != "" {
|
||||
// Try loading from target repo's .review-bot/personas/ directory
|
||||
fetcher := &giteaFetcher{client: giteaClient}
|
||||
remotePersonas, err := review.LoadRemotePersonas(ctx, fetcher, owner, repoName)
|
||||
if err != nil {
|
||||
slog.Warn("could not load remote personas", "repo", fmt.Sprintf("%s/%s", owner, repoName), "error", err)
|
||||
}
|
||||
|
||||
if p, ok := remotePersonas[*personaName]; ok {
|
||||
persona = p
|
||||
slog.Info("loaded persona from target repo", "persona", persona.Name, "display", persona.DisplayName)
|
||||
} else {
|
||||
// Fall back to built-in persona
|
||||
persona, personaErr = review.LoadBuiltinPersona(*personaName)
|
||||
if personaErr != nil {
|
||||
slog.Error("failed to load persona", "persona", *personaName, "error", personaErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("loaded built-in persona", "persona", persona.Name, "display", persona.DisplayName)
|
||||
}
|
||||
} else if *personaFile != "" {
|
||||
resolvedPath, err := validateWorkspacePath(*personaFile, "persona-file")
|
||||
if err != nil {
|
||||
slog.Error("invalid persona-file path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
persona, personaErr = review.LoadPersona(resolvedPath)
|
||||
if personaErr != nil {
|
||||
slog.Error("failed to load persona file", "file", *personaFile, "error", personaErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("loaded persona from file", "file", *personaFile, "persona", persona.Name)
|
||||
}
|
||||
|
||||
slog.Info("reviewing pull request", "pr", prNumber, "repo", fmt.Sprintf("%s/%s", owner, repoName))
|
||||
|
||||
// Step 1: Fetch PR metadata
|
||||
@@ -783,3 +799,29 @@ func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
|
||||
}
|
||||
return evaluatedSHA != currentSHA
|
||||
}
|
||||
|
||||
// giteaFetcher adapts gitea.Client to review.PersonaFetcher interface.
|
||||
type giteaFetcher struct {
|
||||
client *gitea.Client
|
||||
}
|
||||
|
||||
func (f *giteaFetcher) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
|
||||
entries, err := f.client.ListContents(ctx, owner, repo, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Convert gitea.ContentEntry to review.ContentEntry
|
||||
result := make([]review.ContentEntry, len(entries))
|
||||
for i, e := range entries {
|
||||
result[i] = review.ContentEntry{
|
||||
Name: e.Name,
|
||||
Path: e.Path,
|
||||
Type: e.Type,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *giteaFetcher) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
||||
return f.client.GetFileContent(ctx, owner, repo, filepath)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user