Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 696b986ec7 | |||
| ae94ba7be6 | |||
| 48658ca6a4 | |||
| 94cf894c49 | |||
| 5c7c7d2250 | |||
| 7d6fe27239 | |||
| 12c1d3e984 | |||
| 0faa1b6017 | |||
| d8e4167ab5 | |||
| 7d0d3ea885 | |||
| 1960d987ed |
+198
-116
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"gitea.weiker.me/rodin/review-bot/budget"
|
"gitea.weiker.me/rodin/review-bot/budget"
|
||||||
"gitea.weiker.me/rodin/review-bot/gitea"
|
"gitea.weiker.me/rodin/review-bot/gitea"
|
||||||
|
"gitea.weiker.me/rodin/review-bot/github"
|
||||||
"gitea.weiker.me/rodin/review-bot/llm"
|
"gitea.weiker.me/rodin/review-bot/llm"
|
||||||
"gitea.weiker.me/rodin/review-bot/review"
|
"gitea.weiker.me/rodin/review-bot/review"
|
||||||
"gitea.weiker.me/rodin/review-bot/vcs"
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
||||||
@@ -54,12 +56,15 @@ func main() {
|
|||||||
// Logging flags
|
// Logging flags
|
||||||
logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json")
|
logFormat := flag.String("log-format", envOrDefault("LOG_FORMAT", "text"), "Log output format: text or json")
|
||||||
verbosity := flag.String("verbosity", envOrDefault("LOG_VERBOSITY", "info"), "Log verbosity: debug, info, warn, error")
|
verbosity := flag.String("verbosity", envOrDefault("LOG_VERBOSITY", "info"), "Log verbosity: debug, info, warn, error")
|
||||||
// CLI flags
|
// VCS flags
|
||||||
giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", envOrDefault("GITHUB_SERVER_URL", "")), "Gitea instance URL")
|
provider := flag.String("provider", envOrDefault("VCS_PROVIDER", "gitea"), "VCS provider: gitea or github")
|
||||||
repo := flag.String("repo", envOrDefault("GITEA_REPO", envOrDefault("GITHUB_REPOSITORY", "")), "Repository (owner/name)")
|
baseURL := flag.String("base-url", envOrDefault("VCS_BASE_URL", ""), "VCS API base URL (for github provider; defaults to https://api.github.com)")
|
||||||
|
vcsURL := flag.String("vcs-url", envOrDefault("VCS_URL", envOrDefault("GITEA_URL", envOrDefault("GITHUB_SERVER_URL", ""))), "VCS instance URL (Gitea) [deprecated alias: --gitea-url]")
|
||||||
|
// Keep --gitea-url as backward-compatible alias (flag package doesn't support aliases natively, handle below)
|
||||||
|
repo := flag.String("repo", envOrDefault("VCS_REPO", envOrDefault("GITEA_REPO", envOrDefault("GITHUB_REPOSITORY", ""))), "Repository (owner/name)")
|
||||||
prNum := flag.String("pr", envOrDefault("PR_NUMBER", ""), "Pull request number")
|
prNum := flag.String("pr", envOrDefault("PR_NUMBER", ""), "Pull request number")
|
||||||
reviewerName := flag.String("reviewer-name", envOrDefault("REVIEWER_NAME", ""), "Reviewer display name")
|
reviewerName := flag.String("reviewer-name", envOrDefault("REVIEWER_NAME", ""), "Reviewer display name")
|
||||||
reviewerToken := flag.String("reviewer-token", envOrDefault("REVIEWER_TOKEN", ""), "Gitea token for posting review")
|
reviewerToken := flag.String("reviewer-token", envOrDefault("REVIEWER_TOKEN", ""), "VCS token for posting review")
|
||||||
llmBaseURL := flag.String("llm-base-url", envOrDefault("LLM_BASE_URL", ""), "LLM API base URL")
|
llmBaseURL := flag.String("llm-base-url", envOrDefault("LLM_BASE_URL", ""), "LLM API base URL")
|
||||||
llmAPIKey := flag.String("llm-api-key", envOrDefault("LLM_API_KEY", ""), "LLM API key")
|
llmAPIKey := flag.String("llm-api-key", envOrDefault("LLM_API_KEY", ""), "LLM API key")
|
||||||
llmModel := flag.String("llm-model", envOrDefault("LLM_MODEL", ""), "LLM model name")
|
llmModel := flag.String("llm-model", envOrDefault("LLM_MODEL", ""), "LLM model name")
|
||||||
@@ -80,6 +85,18 @@ func main() {
|
|||||||
aicoreAPIURL := flag.String("aicore-api-url", envOrDefault("AICORE_API_URL", ""), "SAP AI Core API URL (for provider=aicore)")
|
aicoreAPIURL := flag.String("aicore-api-url", envOrDefault("AICORE_API_URL", ""), "SAP AI Core API URL (for provider=aicore)")
|
||||||
aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)")
|
aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)")
|
||||||
|
|
||||||
|
// Register --gitea-url as a backward-compatible alias for --vcs-url.
|
||||||
|
// StringVar shares the *string pointer with vcsURL, so whichever flag is
|
||||||
|
// set last by flag.Parse wins — both point to the same underlying value.
|
||||||
|
// NOTE: If a user passes both --vcs-url and --gitea-url, the last one on
|
||||||
|
// the command line takes effect (standard flag package behavior). This is
|
||||||
|
// acceptable since --gitea-url is deprecated and both serve the same purpose.
|
||||||
|
//
|
||||||
|
// ORDERING: This must remain AFTER vcsURL's flag.String declaration and BEFORE
|
||||||
|
// flag.Parse(). The *vcsURL dereference captures the env-var-resolved default
|
||||||
|
// at registration time; moving flag.Parse() above this line would break it.
|
||||||
|
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *versionFlag {
|
if *versionFlag {
|
||||||
@@ -92,12 +109,25 @@ func main() {
|
|||||||
|
|
||||||
slog.Info("review-bot starting", "version", version)
|
slog.Info("review-bot starting", "version", version)
|
||||||
|
|
||||||
|
// Validate VCS provider
|
||||||
|
switch *provider {
|
||||||
|
case "gitea", "github":
|
||||||
|
// valid
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: invalid --provider %q (valid: gitea, github)\n", *provider)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
// For aicore provider, llm-base-url and llm-api-key are not required
|
|
||||||
isAICore := llm.Provider(*llmProvider) == llm.ProviderAICore
|
isAICore := llm.Provider(*llmProvider) == llm.ProviderAICore
|
||||||
if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" || *llmModel == "" {
|
if *repo == "" || *prNum == "" || *reviewerToken == "" || *llmModel == "" {
|
||||||
fmt.Fprintf(os.Stderr, "Error: missing required flags or environment variables\n\n")
|
fmt.Fprintf(os.Stderr, "Error: missing required flags or environment variables\n\n")
|
||||||
fmt.Fprintf(os.Stderr, "Required: --gitea-url, --repo, --pr, --reviewer-token, --llm-model\n")
|
fmt.Fprintf(os.Stderr, "Required: --repo, --pr, --reviewer-token, --llm-model\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// --vcs-url is required only for gitea provider
|
||||||
|
if *provider == "gitea" && *vcsURL == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: --vcs-url (or --gitea-url) is required for provider=gitea\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if !isAICore && (*llmBaseURL == "" || *llmAPIKey == "") {
|
if !isAICore && (*llmBaseURL == "" || *llmAPIKey == "") {
|
||||||
@@ -116,8 +146,6 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Persona loading deferred until after Gitea client init to support repo personas
|
|
||||||
|
|
||||||
// Validate reviewer-name: only safe characters allowed in sentinel
|
// Validate reviewer-name: only safe characters allowed in sentinel
|
||||||
if err := validateReviewerName(*reviewerName); err != nil {
|
if err := validateReviewerName(*reviewerName); err != nil {
|
||||||
slog.Error("invalid reviewer name", "error", err)
|
slog.Error("invalid reviewer name", "error", err)
|
||||||
@@ -139,8 +167,25 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize clients
|
// Initialize VCS client
|
||||||
giteaClient := gitea.NewClient(*giteaURL, *reviewerToken)
|
var client vcs.Client
|
||||||
|
switch *provider {
|
||||||
|
case "gitea":
|
||||||
|
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
|
||||||
|
client = gitea.NewAdapter(giteaClient)
|
||||||
|
case "github":
|
||||||
|
ghBaseURL := *baseURL
|
||||||
|
if ghBaseURL == "" {
|
||||||
|
ghBaseURL = "https://api.github.com"
|
||||||
|
}
|
||||||
|
client = github.NewClient(*reviewerToken, ghBaseURL)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: unhandled provider %q\n", *provider)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("VCS client initialized", "provider", *provider)
|
||||||
|
|
||||||
|
// Initialize LLM client
|
||||||
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
|
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
|
||||||
if *llmTemp < 0 || *llmTemp > 2 {
|
if *llmTemp < 0 || *llmTemp > 2 {
|
||||||
slog.Error("invalid LLM temperature", "temperature", *llmTemp, "range", "0-2")
|
slog.Error("invalid LLM temperature", "temperature", *llmTemp, "range", "0-2")
|
||||||
@@ -174,16 +219,13 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), overallTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), overallTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Load persona if specified (after Gitea client init to support repo personas)
|
// Load persona if specified
|
||||||
var persona *review.Persona
|
var persona *review.Persona
|
||||||
if *personaName != "" {
|
if *personaName != "" {
|
||||||
// Try loading from repo first, then fall back to built-in
|
// Try loading from repo first, then fall back to built-in
|
||||||
repoPersonas, err := review.LoadRepoPersonas(ctx, newGiteaClientAdapter(giteaClient), owner, repoName)
|
repoPersonas, err := review.LoadRepoPersonas(ctx, client, owner, repoName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not load repo personas", "repo", owner+"/"+repoName, "error", err)
|
slog.Warn("could not load repo personas", "repo", owner+"/"+repoName, "error", err)
|
||||||
// Continue with built-in personas only.
|
|
||||||
// NOTE: repoPersonas is nil here, but map indexing on a nil map is safe in Go
|
|
||||||
// (returns the zero value), so the fallback to built-in below works correctly.
|
|
||||||
}
|
}
|
||||||
if p, ok := repoPersonas[*personaName]; ok {
|
if p, ok := repoPersonas[*personaName]; ok {
|
||||||
persona = p
|
persona = p
|
||||||
@@ -214,7 +256,7 @@ func main() {
|
|||||||
slog.Info("reviewing pull request", "pr", prNumber, "repo", fmt.Sprintf("%s/%s", owner, repoName))
|
slog.Info("reviewing pull request", "pr", prNumber, "repo", fmt.Sprintf("%s/%s", owner, repoName))
|
||||||
|
|
||||||
// Step 1: Fetch PR metadata
|
// Step 1: Fetch PR metadata
|
||||||
pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
|
pr, err := client.GetPullRequest(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to fetch PR", "pr", prNumber, "error", err)
|
slog.Error("failed to fetch PR", "pr", prNumber, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -222,7 +264,7 @@ func main() {
|
|||||||
slog.Info("fetched PR metadata", "pr", prNumber, "title", pr.Title)
|
slog.Info("fetched PR metadata", "pr", prNumber, "title", pr.Title)
|
||||||
|
|
||||||
// Step 2: Fetch diff
|
// Step 2: Fetch diff
|
||||||
diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber)
|
diff, err := client.GetPullRequestDiff(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to fetch diff", "pr", prNumber, "error", err)
|
slog.Error("failed to fetch diff", "pr", prNumber, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -231,21 +273,21 @@ func main() {
|
|||||||
|
|
||||||
// Step 3: Fetch full file content for modified files
|
// Step 3: Fetch full file content for modified files
|
||||||
fileContext := ""
|
fileContext := ""
|
||||||
files, err := giteaClient.GetPullRequestFiles(ctx, owner, repoName, prNumber)
|
files, err := client.GetPullRequestFiles(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not fetch PR files list", "pr", prNumber, "error", err)
|
slog.Warn("could not fetch PR files list", "pr", prNumber, "error", err)
|
||||||
} else {
|
} else {
|
||||||
fileContext = fetchFileContext(ctx, giteaClient, owner, repoName, pr.Head.Ref, files)
|
fileContext = fetchFileContext(ctx, client, owner, repoName, pr.Head.Ref, files)
|
||||||
slog.Debug("fetched file context", "files", len(files))
|
slog.Debug("fetched file context", "files", len(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Check CI status
|
// Step 4: Check CI status
|
||||||
ciPassed := true
|
ciPassed := true
|
||||||
ciDetails := ""
|
ciDetails := ""
|
||||||
if pr.Head.Sha != "" {
|
if pr.Head.SHA != "" {
|
||||||
statuses, err := giteaClient.GetCommitStatuses(ctx, owner, repoName, pr.Head.Sha)
|
statuses, err := client.GetCommitStatuses(ctx, owner, repoName, pr.Head.SHA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not fetch CI status", "sha", pr.Head.Sha, "error", err)
|
slog.Warn("could not fetch CI status", "sha", pr.Head.SHA, "error", err)
|
||||||
} else {
|
} else {
|
||||||
ciPassed, ciDetails = evaluateCIStatus(statuses)
|
ciPassed, ciDetails = evaluateCIStatus(statuses)
|
||||||
slog.Info("CI status checked", "passed", ciPassed)
|
slog.Info("CI status checked", "passed", ciPassed)
|
||||||
@@ -255,7 +297,7 @@ func main() {
|
|||||||
// Step 5: Load conventions file if specified
|
// Step 5: Load conventions file if specified
|
||||||
conventions := ""
|
conventions := ""
|
||||||
if *conventionsFile != "" {
|
if *conventionsFile != "" {
|
||||||
content, err := giteaClient.GetFileContent(ctx, owner, repoName, *conventionsFile)
|
content, err := client.GetFileContent(ctx, owner, repoName, *conventionsFile, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not load conventions file", "file", *conventionsFile, "error", err)
|
slog.Warn("could not load conventions file", "file", *conventionsFile, "error", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -267,7 +309,7 @@ func main() {
|
|||||||
// Step 6: Load patterns from external repo if specified
|
// Step 6: Load patterns from external repo if specified
|
||||||
patterns := ""
|
patterns := ""
|
||||||
if *patternsRepo != "" {
|
if *patternsRepo != "" {
|
||||||
patterns = fetchPatterns(ctx, giteaClient, *patternsRepo, *patternsFiles)
|
patterns = fetchPatterns(ctx, client, *patternsRepo, *patternsFiles)
|
||||||
slog.Debug("loaded patterns", "repo", *patternsRepo, "bytes", len(patterns))
|
slog.Debug("loaded patterns", "repo", *patternsRepo, "bytes", len(patterns))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,15 +402,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add commit footer so readers know which commit was evaluated
|
// Add commit footer so readers know which commit was evaluated
|
||||||
if pr.Head.Sha != "" {
|
if pr.Head.SHA != "" {
|
||||||
shortSHA := pr.Head.Sha
|
shortSHA := pr.Head.SHA
|
||||||
if len(shortSHA) > 8 {
|
if len(shortSHA) > 8 {
|
||||||
shortSHA = shortSHA[:8]
|
shortSHA = shortSHA[:8]
|
||||||
}
|
}
|
||||||
reviewBody += fmt.Sprintf("\n\n---\n*Evaluated against %s*", shortSHA)
|
reviewBody += fmt.Sprintf("\n\n---\n*Evaluated against %s*", shortSHA)
|
||||||
}
|
}
|
||||||
|
|
||||||
event := review.GiteaEvent(result.Verdict)
|
// Map verdict to canonical review event
|
||||||
|
event := verdictToEvent(result.Verdict)
|
||||||
|
|
||||||
if *dryRun {
|
if *dryRun {
|
||||||
fmt.Println("--- DRY RUN ---")
|
fmt.Println("--- DRY RUN ---")
|
||||||
@@ -380,14 +423,13 @@ func main() {
|
|||||||
sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
|
sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
|
||||||
|
|
||||||
// Stale check: verify HEAD hasn't moved since we started
|
// Stale check: verify HEAD hasn't moved since we started
|
||||||
evaluatedSHA := pr.Head.Sha
|
evaluatedSHA := pr.Head.SHA
|
||||||
var currentSHA string
|
var currentSHA string
|
||||||
currentPR, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
|
currentPR, err := client.GetPullRequest(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not re-fetch PR for stale check", "pr", prNumber, "error", err)
|
slog.Warn("could not re-fetch PR for stale check", "pr", prNumber, "error", err)
|
||||||
// currentSHA stays empty — shouldSkipStaleReview will return false
|
|
||||||
} else {
|
} else {
|
||||||
currentSHA = currentPR.Head.Sha
|
currentSHA = currentPR.Head.SHA
|
||||||
}
|
}
|
||||||
if shouldSkipStaleReview(evaluatedSHA, currentSHA) {
|
if shouldSkipStaleReview(evaluatedSHA, currentSHA) {
|
||||||
slog.Warn("HEAD moved during review — skipping stale review",
|
slog.Warn("HEAD moved during review — skipping stale review",
|
||||||
@@ -397,18 +439,25 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map findings to inline comments for lines present in the diff
|
// Build line→position map for inline comments
|
||||||
diffRanges := gitea.ParseDiffNewLines(diff)
|
lineToPosition := vcs.BuildLineToPositionMap(diff)
|
||||||
var inlineComments []gitea.ReviewComment
|
var inlineComments []vcs.ReviewComment
|
||||||
for _, f := range result.Findings {
|
for _, f := range result.Findings {
|
||||||
if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) {
|
if f.File == "" || f.Line <= 0 {
|
||||||
inlineComments = append(inlineComments, gitea.ReviewComment{
|
continue
|
||||||
|
}
|
||||||
|
pos, ok := lineToPosition[f.File][f.Line]
|
||||||
|
if !ok {
|
||||||
|
slog.Warn("line not in diff, skipping comment", "file", f.File, "line", f.Line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inlineComments = append(inlineComments, vcs.ReviewComment{
|
||||||
Path: f.File,
|
Path: f.File,
|
||||||
NewPosition: int64(f.Line),
|
Position: pos,
|
||||||
|
CommitID: pr.Head.SHA,
|
||||||
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
|
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if len(inlineComments) > 0 {
|
if len(inlineComments) > 0 {
|
||||||
slog.Debug("attaching inline comments", "count", len(inlineComments))
|
slog.Debug("attaching inline comments", "count", len(inlineComments))
|
||||||
}
|
}
|
||||||
@@ -416,10 +465,9 @@ func main() {
|
|||||||
// --- Review update strategy ---
|
// --- Review update strategy ---
|
||||||
// 1. POST new review first (gets non-stale approval badge on HEAD)
|
// 1. POST new review first (gets non-stale approval badge on HEAD)
|
||||||
// 2. Then supersede old review with link to the new one
|
// 2. Then supersede old review with link to the new one
|
||||||
// Order matters: post first so we have the new review's URL for the supersede message.
|
var oldReviews []vcs.Review
|
||||||
var oldReviews []gitea.Review
|
|
||||||
if *reviewerName != "" {
|
if *reviewerName != "" {
|
||||||
existingReviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
|
existingReviews, err := client.ListReviews(ctx, owner, repoName, prNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not list existing reviews", "pr", prNumber, "error", err)
|
slog.Warn("could not list existing reviews", "pr", prNumber, "error", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -431,45 +479,109 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-request as reviewer (ensures we appear in required-reviewer checks)
|
// Self-request as reviewer (Gitea-specific; ensures we appear in required-reviewer checks)
|
||||||
authUser, err := giteaClient.GetAuthenticatedUser(ctx)
|
if giteaAdapter, ok := client.(*gitea.Adapter); ok {
|
||||||
|
authUser, err := client.GetAuthenticatedUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not determine authenticated user for reviewer self-request", "error", err)
|
slog.Warn("could not determine authenticated user for reviewer self-request", "error", err)
|
||||||
} else if authUser != "" {
|
} else if authUser != "" {
|
||||||
if err := giteaClient.RequestReviewer(ctx, owner, repoName, prNumber, authUser); err != nil {
|
if err := giteaAdapter.Underlying().RequestReviewer(ctx, owner, repoName, prNumber, authUser); err != nil {
|
||||||
slog.Warn("could not self-request as reviewer", "user", authUser, "error", err)
|
slog.Warn("could not self-request as reviewer", "user", authUser, "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("self-requested as reviewer", "user", authUser, "pr", prNumber)
|
slog.Debug("self-requested as reviewer", "user", authUser, "pr", prNumber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
slog.Debug("RequestReviewer not supported for provider, skipping")
|
||||||
|
}
|
||||||
|
|
||||||
// POST new review
|
// POST new review
|
||||||
slog.Info("posting review", "event", event, "pr", prNumber)
|
slog.Info("posting review", "event", event, "pr", prNumber)
|
||||||
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
|
reviewReq := vcs.ReviewRequest{
|
||||||
|
Body: reviewBody,
|
||||||
|
Event: event,
|
||||||
|
Comments: inlineComments,
|
||||||
|
}
|
||||||
|
posted, err := client.PostReview(ctx, owner, repoName, prNumber, reviewReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to post review", "pr", prNumber, "event", event, "error", err)
|
slog.Error("failed to post review", "pr", prNumber, "event", event, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
|
slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
|
||||||
|
|
||||||
// Supersede all old reviews with link to the new one
|
// Supersede all old reviews
|
||||||
if len(oldReviews) > 0 {
|
if len(oldReviews) > 0 {
|
||||||
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(*giteaURL, "/"), owner, repoName, prNumber, posted.ID)
|
if err := supersedeOldReviews(ctx, client, *provider, *vcsURL, owner, repoName, prNumber, oldReviews, posted.ID, sentinel); err != nil {
|
||||||
|
slog.Error("failed to supersede old reviews", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verdictToEvent maps a verdict string from the LLM response to a canonical vcs.ReviewEvent.
|
||||||
|
func verdictToEvent(verdict string) vcs.ReviewEvent {
|
||||||
|
switch verdict {
|
||||||
|
case "APPROVE":
|
||||||
|
return vcs.ReviewEventApprove
|
||||||
|
case "REQUEST_CHANGES":
|
||||||
|
return vcs.ReviewEventRequestChanges
|
||||||
|
default:
|
||||||
|
return vcs.ReviewEventComment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// supersedeOldReviews marks prior reviews as superseded so only the latest review is visible.
|
||||||
|
// For GitHub: dismisses old reviews (vcsURL is unused in this path).
|
||||||
|
// For Gitea: edits the review body with a link to the new review and resolves inline comments.
|
||||||
|
//
|
||||||
|
// The vcsURL parameter is only used in the Gitea path to construct review permalink URLs;
|
||||||
|
// it is accepted unconditionally to keep the function signature uniform across providers.
|
||||||
|
func supersedeOldReviews(ctx context.Context, client vcs.Client, provider, vcsURL, owner, repoName string, prNumber int, oldReviews []vcs.Review, newReviewID int64, sentinel string) error {
|
||||||
|
switch provider {
|
||||||
|
case "github":
|
||||||
|
// Best-effort dismissal: attempt all reviews, join any errors.
|
||||||
|
var errs []error
|
||||||
|
for _, old := range oldReviews {
|
||||||
|
if err := client.DismissReview(ctx, owner, repoName, prNumber, old.ID, "Superseded by new review"); err != nil {
|
||||||
|
slog.Warn("failed to dismiss review", "id", old.ID, "error", err)
|
||||||
|
errs = append(errs, fmt.Errorf("dismiss review %d: %w", old.ID, err))
|
||||||
|
} else {
|
||||||
|
slog.Info("dismissed old review", "review_id", old.ID, "new_review_id", newReviewID, "pr", prNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
case "gitea":
|
||||||
|
// Continue to Gitea-specific logic below the switch.
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("supersedeOldReviews: unsupported provider %q", provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The type assertion below is guaranteed to succeed: the caller's provider switch
|
||||||
|
// ensures we only reach this point when provider == "gitea", and the gitea provider
|
||||||
|
// always constructs a *gitea.Adapter. The !ok branch guards against future refactors
|
||||||
|
// (e.g. wrapping the adapter in a decorator) that would silently break this path.
|
||||||
|
giteaAdapter, ok := client.(*gitea.Adapter)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("expected gitea.Adapter for gitea provider, got %T", client)
|
||||||
|
}
|
||||||
|
underlying := giteaAdapter.Underlying()
|
||||||
|
|
||||||
|
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(vcsURL, "/"), owner, repoName, prNumber, newReviewID)
|
||||||
for _, oldReview := range oldReviews {
|
for _, oldReview := range oldReviews {
|
||||||
cid, err := giteaClient.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, prNumber, oldReview.ID)
|
cid, err := underlying.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, prNumber, oldReview.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
|
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
|
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
|
||||||
if err := giteaClient.EditComment(ctx, owner, repoName, cid, supersededBody); err != nil {
|
if err := underlying.EditComment(ctx, owner, repoName, cid, supersededBody); err != nil {
|
||||||
slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "comment_id", cid, "error", err)
|
slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "comment_id", cid, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Info("marked old review as superseded", "review_id", oldReview.ID, "new_review_id", posted.ID, "pr", prNumber)
|
slog.Info("marked old review as superseded", "review_id", oldReview.ID, "new_review_id", newReviewID, "pr", prNumber)
|
||||||
|
|
||||||
// Resolve old review's inline comments
|
// Resolve old review's inline comments
|
||||||
oldComments, err := giteaClient.ListReviewComments(ctx, owner, repoName, prNumber, oldReview.ID)
|
oldComments, err := underlying.ListReviewComments(ctx, owner, repoName, prNumber, oldReview.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
|
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
|
||||||
continue
|
continue
|
||||||
@@ -479,7 +591,7 @@ func main() {
|
|||||||
if c.ID == 0 {
|
if c.ID == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := giteaClient.ResolveComment(ctx, owner, repoName, c.ID); err != nil {
|
if err := underlying.ResolveComment(ctx, owner, repoName, c.ID); err != nil {
|
||||||
slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err)
|
slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err)
|
||||||
failed++
|
failed++
|
||||||
} else {
|
} else {
|
||||||
@@ -493,12 +605,11 @@ func main() {
|
|||||||
slog.Warn("some inline comments could not be resolved", "review_id", oldReview.ID, "failed", failed, "pr", prNumber)
|
slog.Warn("some inline comments could not be resolved", "review_id", oldReview.ID, "failed", failed, "pr", prNumber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchFileContext fetches the full content of modified files from the PR branch.
|
// fetchFileContext fetches the full content of modified files from the PR branch.
|
||||||
func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, ref string, files []gitea.ChangedFile) string {
|
func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref string, files []vcs.ChangedFile) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -507,7 +618,7 @@ func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, re
|
|||||||
if f.Status == "removed" {
|
if f.Status == "removed" {
|
||||||
continue // Skip deleted files
|
continue // Skip deleted files
|
||||||
}
|
}
|
||||||
content, err := client.GetFileContentRef(ctx, owner, repo, f.Filename, ref)
|
content, err := client.GetFileContentAtRef(ctx, owner, repo, f.Filename, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not fetch file content", "file", f.Filename, "error", err)
|
slog.Warn("could not fetch file content", "file", f.Filename, "error", err)
|
||||||
continue
|
continue
|
||||||
@@ -524,7 +635,7 @@ func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, re
|
|||||||
// patternsRepo is comma-separated list of owner/name repos.
|
// patternsRepo is comma-separated list of owner/name repos.
|
||||||
// patternsFiles is comma-separated list of file paths or directories.
|
// patternsFiles is comma-separated list of file paths or directories.
|
||||||
// If a path ends with / or is a directory, all files within it are fetched recursively.
|
// If a path ends with / or is a directory, all files within it are fetched recursively.
|
||||||
func fetchPatterns(ctx context.Context, client *gitea.Client, patternsRepo, patternsFiles string) string {
|
func fetchPatterns(ctx context.Context, client vcs.FileReader, patternsRepo, patternsFiles string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
repos := strings.Split(patternsRepo, ",")
|
repos := strings.Split(patternsRepo, ",")
|
||||||
@@ -554,7 +665,7 @@ func fetchPatterns(ctx context.Context, client *gitea.Client, patternsRepo, patt
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := client.GetAllFilesInPath(ctx, owner, repo, path)
|
files, err := vcs.GetAllFilesInPath(ctx, client, owner, repo, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("could not fetch patterns", "path", path, "repo", repoRef, "error", err)
|
slog.Warn("could not fetch patterns", "path", path, "repo", repoRef, "error", err)
|
||||||
continue
|
continue
|
||||||
@@ -593,18 +704,20 @@ func isPatternFile(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// evaluateCIStatus checks if all CI statuses indicate success.
|
// evaluateCIStatus checks if all CI statuses indicate success.
|
||||||
func evaluateCIStatus(statuses []gitea.CommitStatus) (passed bool, details string) {
|
// Returns passed=true if no checks have failed (pending checks are not treated as failures).
|
||||||
|
func evaluateCIStatus(statuses []vcs.CommitStatus) (passed bool, details string) {
|
||||||
if len(statuses) == 0 {
|
if len(statuses) == 0 {
|
||||||
return true, "no CI statuses found"
|
return true, "no CI statuses found"
|
||||||
}
|
}
|
||||||
|
|
||||||
var failed []string
|
var failed []string
|
||||||
|
var pending int
|
||||||
for _, s := range statuses {
|
for _, s := range statuses {
|
||||||
switch s.Status {
|
switch s.Status {
|
||||||
case "success":
|
case "success":
|
||||||
// good
|
// good
|
||||||
case "pending":
|
case "pending":
|
||||||
// treat pending as not-failed
|
pending++
|
||||||
case "failure", "error":
|
case "failure", "error":
|
||||||
failed = append(failed, fmt.Sprintf("%s: %s", s.Context, s.Description))
|
failed = append(failed, fmt.Sprintf("%s: %s", s.Context, s.Description))
|
||||||
}
|
}
|
||||||
@@ -613,6 +726,9 @@ func evaluateCIStatus(statuses []gitea.CommitStatus) (passed bool, details strin
|
|||||||
if len(failed) > 0 {
|
if len(failed) > 0 {
|
||||||
return false, strings.Join(failed, "; ")
|
return false, strings.Join(failed, "; ")
|
||||||
}
|
}
|
||||||
|
if pending > 0 {
|
||||||
|
return true, fmt.Sprintf("no failures (%d pending)", pending)
|
||||||
|
}
|
||||||
return true, "all checks passed"
|
return true, "all checks passed"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,10 +844,10 @@ func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hasSharedToken detects if another review-bot role posted under the same
|
// hasSharedToken detects if another review-bot role posted under the same
|
||||||
// Gitea user. This indicates misconfiguration where two roles share a token
|
// VCS user. This indicates misconfiguration where two roles share a token
|
||||||
// instead of having separate Gitea accounts. Returns true if shared token
|
// instead of having separate accounts. Returns true if shared token
|
||||||
// detected (caller should skip update-in-place logic to avoid clobbering).
|
// detected (caller should skip update-in-place logic to avoid clobbering).
|
||||||
func hasSharedToken(reviews []gitea.Review, ownSentinel string) bool {
|
func hasSharedToken(reviews []vcs.Review, ownSentinel string) bool {
|
||||||
ownLogin := ""
|
ownLogin := ""
|
||||||
for _, r := range reviews {
|
for _, r := range reviews {
|
||||||
if strings.Contains(r.Body, ownSentinel) {
|
if strings.Contains(r.Body, ownSentinel) {
|
||||||
@@ -744,7 +860,7 @@ func hasSharedToken(reviews []gitea.Review, ownSentinel string) bool {
|
|||||||
}
|
}
|
||||||
for _, r := range reviews {
|
for _, r := range reviews {
|
||||||
if r.User.Login == ownLogin && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
|
if r.User.Login == ownLogin && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
|
||||||
slog.Warn("shared token detected — another review-bot role is using the same Gitea user",
|
slog.Warn("shared token detected — another review-bot role is using the same VCS user",
|
||||||
"sibling_role", extractSentinelName(r.Body), "user", ownLogin)
|
"sibling_role", extractSentinelName(r.Body), "user", ownLogin)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -765,29 +881,27 @@ func extractSentinelName(body string) string {
|
|||||||
if end < 0 {
|
if end < 0 {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
return rest[:end]
|
name := rest[:end]
|
||||||
|
// Sanitize: strip control characters to prevent log injection.
|
||||||
|
name = strings.Map(func(r rune) rune {
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, name)
|
||||||
|
if len(name) > 64 {
|
||||||
|
name = name[:64]
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// findOwnReview locates the most recent non-superseded review matching the sentinel.
|
|
||||||
func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review {
|
|
||||||
var best *gitea.Review
|
|
||||||
for i := range reviews {
|
|
||||||
if !strings.Contains(reviews[i].Body, sentinel) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(reviews[i].Body, "~~Original review~~") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if best == nil || reviews[i].ID > best.ID {
|
|
||||||
best = &reviews[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// findAllOwnReviews returns all non-superseded reviews matching the sentinel.
|
// findAllOwnReviews returns all non-superseded reviews matching the sentinel.
|
||||||
func findAllOwnReviews(reviews []gitea.Review, sentinel string) []gitea.Review {
|
func findAllOwnReviews(reviews []vcs.Review, sentinel string) []vcs.Review {
|
||||||
var result []gitea.Review
|
var result []vcs.Review
|
||||||
for i := range reviews {
|
for i := range reviews {
|
||||||
if !strings.Contains(reviews[i].Body, sentinel) {
|
if !strings.Contains(reviews[i].Body, sentinel) {
|
||||||
continue
|
continue
|
||||||
@@ -812,35 +926,3 @@ func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
|
|||||||
}
|
}
|
||||||
return evaluatedSHA != currentSHA
|
return evaluatedSHA != currentSHA
|
||||||
}
|
}
|
||||||
|
|
||||||
// giteaClientAdapter adapts gitea.Client to vcs.FileReader interface.
|
|
||||||
type giteaClientAdapter struct {
|
|
||||||
client *gitea.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func newGiteaClientAdapter(c *gitea.Client) *giteaClientAdapter {
|
|
||||||
return &giteaClientAdapter{client: c}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
|
|
||||||
entries, err := a.client.ListContents(ctx, owner, repo, path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result := make([]vcs.ContentEntry, len(entries))
|
|
||||||
for i, e := range entries {
|
|
||||||
result[i] = vcs.ContentEntry{
|
|
||||||
Name: e.Name,
|
|
||||||
Path: e.Path,
|
|
||||||
Type: e.Type,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *giteaClientAdapter) GetFileContent(ctx context.Context, owner, repo, filePath, ref string) (string, error) {
|
|
||||||
if ref != "" {
|
|
||||||
return a.client.GetFileContentRef(ctx, owner, repo, filePath, ref)
|
|
||||||
}
|
|
||||||
return a.client.GetFileContent(ctx, owner, repo, filePath)
|
|
||||||
}
|
|
||||||
|
|||||||
+86
-125
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.weiker.me/rodin/review-bot/gitea"
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateReviewerName(t *testing.T) {
|
func TestValidateReviewerName(t *testing.T) {
|
||||||
@@ -107,8 +107,6 @@ func TestValidateWorkspacePath(t *testing.T) {
|
|||||||
workspace: tmpDir,
|
workspace: tmpDir,
|
||||||
path: "/etc/passwd",
|
path: "/etc/passwd",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
// Go 1.21+ filepath.Join normalizes absolute paths: Join("/tmp/x", "/etc/passwd")
|
|
||||||
// becomes "/tmp/x/etc/passwd", which is within workspace but doesn't exist.
|
|
||||||
errMatch: "failed to resolve",
|
errMatch: "failed to resolve",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -154,15 +152,14 @@ func TestValidateWorkspacePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeReview(id int64, login, state string, stale bool, body string) gitea.Review {
|
func makeReview(id int64, login, state string, stale bool, body string) vcs.Review {
|
||||||
r := gitea.Review{
|
return vcs.Review{
|
||||||
ID: id,
|
ID: id,
|
||||||
Body: body,
|
Body: body,
|
||||||
|
User: vcs.UserInfo{Login: login},
|
||||||
State: state,
|
State: state,
|
||||||
Stale: stale,
|
Stale: stale,
|
||||||
}
|
}
|
||||||
r.User.Login = login
|
|
||||||
return r
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildSupersededBody(t *testing.T) {
|
func TestBuildSupersededBody(t *testing.T) {
|
||||||
@@ -213,96 +210,11 @@ func TestBuildSupersededBodyShortSHA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindOwnReview(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
reviews []gitea.Review
|
|
||||||
sentinel string
|
|
||||||
wantID int64
|
|
||||||
wantNil bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no reviews",
|
|
||||||
reviews: nil,
|
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
wantNil: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "found by sentinel",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(42, "bot", "APPROVED", false, "review body\n<!-- review-bot:sonnet -->"),
|
|
||||||
},
|
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
wantID: 42,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong sentinel",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(42, "bot", "APPROVED", false, "body\n<!-- review-bot:gpt -->"),
|
|
||||||
},
|
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
wantNil: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple reviews, returns first match",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(10, "bot", "APPROVED", false, "old\n<!-- review-bot:gpt -->"),
|
|
||||||
makeReview(20, "bot", "APPROVED", false, "new\n<!-- review-bot:sonnet -->"),
|
|
||||||
},
|
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
wantID: 20,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "skips superseded review",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(10, "bot", "APPROVED", false, "~~Original review~~\n\n**Superseded**\n<!-- review-bot:sonnet -->"),
|
|
||||||
makeReview(20, "bot", "APPROVED", false, "fresh review\n<!-- review-bot:sonnet -->"),
|
|
||||||
},
|
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
wantID: 20,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only superseded reviews exist",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(10, "bot", "APPROVED", false, "~~Original review~~\n\n<!-- review-bot:sonnet -->"),
|
|
||||||
},
|
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
wantNil: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "picks highest ID among matches",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(50, "bot", "APPROVED", false, "v1\n<!-- review-bot:sonnet -->"),
|
|
||||||
makeReview(30, "bot", "APPROVED", false, "v0\n<!-- review-bot:sonnet -->"),
|
|
||||||
},
|
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
wantID: 50,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := findOwnReview(tc.reviews, tc.sentinel)
|
|
||||||
if tc.wantNil {
|
|
||||||
if got != nil {
|
|
||||||
t.Errorf("findOwnReview() = %v, want nil", got)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("findOwnReview() = nil, want non-nil")
|
|
||||||
}
|
|
||||||
if got.ID != tc.wantID {
|
|
||||||
t.Errorf("findOwnReview().ID = %d, want %d", got.ID, tc.wantID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasSharedToken(t *testing.T) {
|
func TestHasSharedToken(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
reviews []gitea.Review
|
reviews []vcs.Review
|
||||||
sentinel string
|
sentinel string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
@@ -314,36 +226,36 @@ func TestHasSharedToken(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no own review yet - cannot detect",
|
name: "no own review yet - cannot detect",
|
||||||
reviews: []gitea.Review{
|
reviews: []vcs.Review{
|
||||||
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "other"}, Body: "<!-- review-bot:gpt --> body"},
|
makeReview(1, "other", "APPROVED", false, "<!-- review-bot:gpt --> body"),
|
||||||
},
|
},
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "separate users - no shared token",
|
name: "separate users - no shared token",
|
||||||
reviews: []gitea.Review{
|
reviews: []vcs.Review{
|
||||||
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
makeReview(1, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"),
|
||||||
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "security-review-bot"}, Body: "<!-- review-bot:security --> body"},
|
makeReview(2, "security-review-bot", "APPROVED", false, "<!-- review-bot:security --> body"),
|
||||||
},
|
},
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "shared token detected - same user different sentinels",
|
name: "shared token detected - same user different sentinels",
|
||||||
reviews: []gitea.Review{
|
reviews: []vcs.Review{
|
||||||
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
makeReview(1, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"),
|
||||||
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:security --> body"},
|
makeReview(2, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:security --> body"),
|
||||||
},
|
},
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "three roles same user",
|
name: "three roles same user",
|
||||||
reviews: []gitea.Review{
|
reviews: []vcs.Review{
|
||||||
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
makeReview(1, "bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"),
|
||||||
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:security --> body"},
|
makeReview(2, "bot", "APPROVED", false, "<!-- review-bot:security --> body"),
|
||||||
{ID: 3, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:gpt --> body"},
|
makeReview(3, "bot", "APPROVED", false, "<!-- review-bot:gpt --> body"),
|
||||||
},
|
},
|
||||||
sentinel: "<!-- review-bot:sonnet -->",
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
want: true,
|
want: true,
|
||||||
@@ -507,7 +419,7 @@ func TestIsPatternFile(t *testing.T) {
|
|||||||
func TestEvaluateCIStatus(t *testing.T) {
|
func TestEvaluateCIStatus(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
statuses []gitea.CommitStatus
|
statuses []vcs.CommitStatus
|
||||||
wantPassed bool
|
wantPassed bool
|
||||||
wantSubstr string
|
wantSubstr string
|
||||||
}{
|
}{
|
||||||
@@ -519,7 +431,7 @@ func TestEvaluateCIStatus(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "all success",
|
name: "all success",
|
||||||
statuses: []gitea.CommitStatus{
|
statuses: []vcs.CommitStatus{
|
||||||
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
||||||
{Status: "success", Context: "ci/test", Description: "Tests passed"},
|
{Status: "success", Context: "ci/test", Description: "Tests passed"},
|
||||||
},
|
},
|
||||||
@@ -528,7 +440,7 @@ func TestEvaluateCIStatus(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "one failure",
|
name: "one failure",
|
||||||
statuses: []gitea.CommitStatus{
|
statuses: []vcs.CommitStatus{
|
||||||
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
||||||
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
||||||
},
|
},
|
||||||
@@ -537,7 +449,7 @@ func TestEvaluateCIStatus(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "error status",
|
name: "error status",
|
||||||
statuses: []gitea.CommitStatus{
|
statuses: []vcs.CommitStatus{
|
||||||
{Status: "error", Context: "ci/lint", Description: "Lint error"},
|
{Status: "error", Context: "ci/lint", Description: "Lint error"},
|
||||||
},
|
},
|
||||||
wantPassed: false,
|
wantPassed: false,
|
||||||
@@ -545,16 +457,16 @@ func TestEvaluateCIStatus(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pending treated as not-failed",
|
name: "pending treated as not-failed",
|
||||||
statuses: []gitea.CommitStatus{
|
statuses: []vcs.CommitStatus{
|
||||||
{Status: "pending", Context: "ci/build", Description: "In progress"},
|
{Status: "pending", Context: "ci/build", Description: "In progress"},
|
||||||
{Status: "success", Context: "ci/test", Description: "Tests passed"},
|
{Status: "success", Context: "ci/test", Description: "Tests passed"},
|
||||||
},
|
},
|
||||||
wantPassed: true,
|
wantPassed: true,
|
||||||
wantSubstr: "all checks passed",
|
wantSubstr: "no failures",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple failures",
|
name: "multiple failures",
|
||||||
statuses: []gitea.CommitStatus{
|
statuses: []vcs.CommitStatus{
|
||||||
{Status: "failure", Context: "ci/build", Description: "Build failed"},
|
{Status: "failure", Context: "ci/build", Description: "Build failed"},
|
||||||
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
||||||
},
|
},
|
||||||
@@ -563,7 +475,7 @@ func TestEvaluateCIStatus(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mixed with pending and failure",
|
name: "mixed with pending and failure",
|
||||||
statuses: []gitea.CommitStatus{
|
statuses: []vcs.CommitStatus{
|
||||||
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
{Status: "success", Context: "ci/build", Description: "Build passed"},
|
||||||
{Status: "pending", Context: "ci/deploy", Description: "Deploying"},
|
{Status: "pending", Context: "ci/deploy", Description: "Deploying"},
|
||||||
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
|
||||||
@@ -792,7 +704,7 @@ func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
|
|||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--gitea-url", "http://localhost",
|
"--vcs-url", "http://localhost",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
"--pr", "1",
|
"--pr", "1",
|
||||||
"--reviewer-name", "invalid name",
|
"--reviewer-name", "invalid name",
|
||||||
@@ -820,7 +732,7 @@ func TestMainSubprocess_InvalidRepo(t *testing.T) {
|
|||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--gitea-url", "http://localhost",
|
"--vcs-url", "http://localhost",
|
||||||
"--repo", "invalidrepo",
|
"--repo", "invalidrepo",
|
||||||
"--pr", "1",
|
"--pr", "1",
|
||||||
"--reviewer-token", "tok",
|
"--reviewer-token", "tok",
|
||||||
@@ -847,7 +759,7 @@ func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
|
|||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--gitea-url", "http://localhost",
|
"--vcs-url", "http://localhost",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
"--pr", "notanumber",
|
"--pr", "notanumber",
|
||||||
"--reviewer-token", "tok",
|
"--reviewer-token", "tok",
|
||||||
@@ -874,7 +786,7 @@ func TestMainSubprocess_InvalidTemperature(t *testing.T) {
|
|||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--gitea-url", "http://localhost",
|
"--vcs-url", "http://localhost",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
"--pr", "1",
|
"--pr", "1",
|
||||||
"--reviewer-token", "tok",
|
"--reviewer-token", "tok",
|
||||||
@@ -902,7 +814,7 @@ func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
|||||||
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
os.Args = []string{"review-bot",
|
os.Args = []string{"review-bot",
|
||||||
"--gitea-url", "http://localhost",
|
"--vcs-url", "http://localhost",
|
||||||
"--repo", "owner/repo",
|
"--repo", "owner/repo",
|
||||||
"--pr", "1",
|
"--pr", "1",
|
||||||
"--reviewer-token", "tok",
|
"--reviewer-token", "tok",
|
||||||
@@ -926,7 +838,35 @@ func TestMainSubprocess_InvalidProvider(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanEnv returns environ without any GITEA/LLM/REVIEWER env vars that would
|
func TestMainSubprocess_InvalidVCSProvider(t *testing.T) {
|
||||||
|
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
|
||||||
|
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
|
||||||
|
os.Args = []string{"review-bot",
|
||||||
|
"--provider", "invalid",
|
||||||
|
"--vcs-url", "http://localhost",
|
||||||
|
"--repo", "owner/repo",
|
||||||
|
"--pr", "1",
|
||||||
|
"--reviewer-token", "tok",
|
||||||
|
"--llm-base-url", "http://localhost",
|
||||||
|
"--llm-api-key", "key",
|
||||||
|
"--llm-model", "model",
|
||||||
|
}
|
||||||
|
main()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidVCSProvider")
|
||||||
|
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected non-zero exit with invalid VCS provider")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "invalid --provider") {
|
||||||
|
t.Errorf("expected error about invalid --provider, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanEnv returns environ without any GITEA/LLM/REVIEWER/VCS env vars that would
|
||||||
// interfere with testing missing-flag scenarios.
|
// interfere with testing missing-flag scenarios.
|
||||||
func cleanEnv() []string {
|
func cleanEnv() []string {
|
||||||
var env []string
|
var env []string
|
||||||
@@ -934,6 +874,7 @@ func cleanEnv() []string {
|
|||||||
key := strings.SplitN(e, "=", 2)[0]
|
key := strings.SplitN(e, "=", 2)[0]
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(key, "GITEA_"),
|
case strings.HasPrefix(key, "GITEA_"),
|
||||||
|
strings.HasPrefix(key, "VCS_"),
|
||||||
strings.HasPrefix(key, "LLM_"),
|
strings.HasPrefix(key, "LLM_"),
|
||||||
strings.HasPrefix(key, "REVIEWER_"),
|
strings.HasPrefix(key, "REVIEWER_"),
|
||||||
strings.HasPrefix(key, "PR_"),
|
strings.HasPrefix(key, "PR_"),
|
||||||
@@ -951,12 +892,12 @@ func cleanEnv() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFindAllOwnReviews(t *testing.T) {
|
func TestFindAllOwnReviews(t *testing.T) {
|
||||||
reviews := []gitea.Review{
|
reviews := []vcs.Review{
|
||||||
{ID: 1, Body: "<!-- review-bot:sonnet -->\nfirst review"},
|
makeReview(1, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nfirst review"),
|
||||||
{ID: 2, Body: "<!-- review-bot:gpt -->\nother bot"},
|
makeReview(2, "bot", "APPROVED", false, "<!-- review-bot:gpt -->\nother bot"),
|
||||||
{ID: 3, Body: "<!-- review-bot:sonnet -->\nsecond review"},
|
makeReview(3, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nsecond review"),
|
||||||
{ID: 4, Body: "~~Original review~~\n<!-- review-bot:sonnet -->\nsuperseded"},
|
makeReview(4, "bot", "APPROVED", false, "~~Original review~~\n<!-- review-bot:sonnet -->\nsuperseded"),
|
||||||
{ID: 5, Body: "<!-- review-bot:sonnet -->\nthird review"},
|
makeReview(5, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nthird review"),
|
||||||
}
|
}
|
||||||
|
|
||||||
got := findAllOwnReviews(reviews, "<!-- review-bot:sonnet -->")
|
got := findAllOwnReviews(reviews, "<!-- review-bot:sonnet -->")
|
||||||
@@ -1020,3 +961,23 @@ func TestShouldSkipStaleReview(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerdictToEvent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
verdict string
|
||||||
|
want vcs.ReviewEvent
|
||||||
|
}{
|
||||||
|
{"APPROVE", vcs.ReviewEventApprove},
|
||||||
|
{"REQUEST_CHANGES", vcs.ReviewEventRequestChanges},
|
||||||
|
{"COMMENT", vcs.ReviewEventComment},
|
||||||
|
{"other", vcs.ReviewEventComment},
|
||||||
|
{"", vcs.ReviewEventComment},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := verdictToEvent(tc.verdict)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("verdictToEvent(%q) = %q, want %q", tc.verdict, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+67
-23
@@ -4,7 +4,9 @@
|
|||||||
package github
|
package github
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -192,12 +194,19 @@ func (c *Client) SetRetryBackoff(d []time.Duration) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
// doWithRetry performs an HTTP request with retry on 429 rate limit responses.
|
||||||
|
// It delegates request construction to buildReq, which is called on each attempt
|
||||||
|
// to produce a fresh *http.Request (allowing body re-reads for POST/PUT).
|
||||||
// It respects the Retry-After header when present (capped at maxRetryAfter).
|
// It respects the Retry-After header when present (capped at maxRetryAfter).
|
||||||
// Transport errors (network failures, context cancellation) are not retried.
|
// Transport errors (network failures, context cancellation) are not retried.
|
||||||
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
func (c *Client) doWithRetry(ctx context.Context, reqURL string, buildReq func() (*http.Request, error)) ([]byte, error) {
|
||||||
const maxRetryAfter = 120 * time.Second
|
const maxRetryAfter = 120 * time.Second
|
||||||
|
|
||||||
|
// maxErrorBodyBytes limits how much of an error response body is stored.
|
||||||
|
// Kept small (4 KiB) to reduce the risk of sensitive data leakage if callers
|
||||||
|
// log APIError.Body directly. Error() further truncates to 200 bytes.
|
||||||
|
const maxErrorBodyBytes = 4 * 1024
|
||||||
|
|
||||||
// backoff holds per-attempt delays: backoff[i] is the delay before attempt i+1.
|
// backoff holds per-attempt delays: backoff[i] is the delay before attempt i+1.
|
||||||
// Length must be maxRetryAttempts-1 (one entry per retry gap).
|
// Length must be maxRetryAttempts-1 (one entry per retry gap).
|
||||||
// SetRetryBackoff validates at configuration time; the default is always valid.
|
// SetRetryBackoff validates at configuration time; the default is always valid.
|
||||||
@@ -211,11 +220,6 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
copy(backoff, defaultBackoff)
|
copy(backoff, defaultBackoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxErrorBodyBytes limits how much of an error response body is stored.
|
|
||||||
// Kept small (4 KiB) to reduce the risk of sensitive data leakage if callers
|
|
||||||
// log APIError.Body directly. Error() further truncates to 200 bytes.
|
|
||||||
const maxErrorBodyBytes = 4 * 1024
|
|
||||||
|
|
||||||
// Reject non-HTTPS URLs early since the URL is immutable across retries.
|
// Reject non-HTTPS URLs early since the URL is immutable across retries.
|
||||||
if c.token != "" && !c.allowInsecureHTTP {
|
if c.token != "" && !c.allowInsecureHTTP {
|
||||||
parsed, err := url.Parse(reqURL)
|
parsed, err := url.Parse(reqURL)
|
||||||
@@ -246,22 +250,10 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
|
req, err := buildReq()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
if c.token != "" {
|
|
||||||
// Bearer is the OAuth2 standard and is accepted by GitHub for both
|
|
||||||
// classic PATs and fine-grained tokens. The alternative "token" scheme
|
|
||||||
// is GitHub-specific and offers no additional compatibility.
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", userAgent)
|
|
||||||
if accept != "" {
|
|
||||||
req.Header.Set("Accept", accept)
|
|
||||||
} else {
|
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -272,11 +264,11 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
respStatus := resp.StatusCode
|
respStatus := resp.StatusCode
|
||||||
retryAfterHeader := resp.Header.Get("Retry-After")
|
retryAfterHeader := resp.Header.Get("Retry-After")
|
||||||
|
|
||||||
body, done, err := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
body, done, handleErr := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
||||||
if done {
|
if done {
|
||||||
return body, err
|
return body, handleErr
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = handleErr
|
||||||
|
|
||||||
// Retry on 429 rate limit
|
// Retry on 429 rate limit
|
||||||
if respStatus == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
if respStatus == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
||||||
@@ -314,6 +306,32 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
||||||
|
// It respects the Retry-After header when present (capped at maxRetryAfter).
|
||||||
|
// Transport errors (network failures, context cancellation) are not retried.
|
||||||
|
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
||||||
|
buildReq := func() (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
// Bearer is the OAuth2 standard and is accepted by GitHub for both
|
||||||
|
// classic PATs and fine-grained tokens. The alternative "token" scheme
|
||||||
|
// is GitHub-specific and offers no additional compatibility.
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
if accept != "" {
|
||||||
|
req.Header.Set("Accept", accept)
|
||||||
|
} else {
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
return c.doWithRetry(ctx, reqURL, buildReq)
|
||||||
|
}
|
||||||
|
|
||||||
// handleResponse reads and closes the response body, returning the result.
|
// handleResponse reads and closes the response body, returning the result.
|
||||||
// It uses defer to ensure the body is always closed regardless of code path.
|
// It uses defer to ensure the body is always closed regardless of code path.
|
||||||
// Returns (body, done, err) where done=true means the caller should return immediately.
|
// Returns (body, done, err) where done=true means the caller should return immediately.
|
||||||
@@ -342,3 +360,29 @@ func (c *Client) handleResponse(resp *http.Response, maxRespBytes int, maxErrByt
|
|||||||
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
||||||
return c.doRequest(ctx, http.MethodGet, reqURL, "")
|
return c.doRequest(ctx, http.MethodGet, reqURL, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doJSONRequest performs an HTTP request with a JSON body and returns the response body.
|
||||||
|
// It delegates retry/backoff/429 handling to doWithRetry.
|
||||||
|
// This is a general-purpose helper used by any method that needs to send JSON payloads
|
||||||
|
// (e.g. PostReview, DismissReview).
|
||||||
|
func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, payload any) ([]byte, error) {
|
||||||
|
jsonBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildReq := func() (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
return c.doWithRetry(ctx, reqURL, buildReq)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package github
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -592,3 +593,59 @@ func TestSetRetryBackoff_RejectsInvalidLength(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error for valid backoff: %v", err)
|
t.Fatalf("unexpected error for valid backoff: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDoJSONRequest_429Retry(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts < 3 {
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit exceeded"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write([]byte(`{"id":1}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", ts.URL, AllowInsecureHTTP())
|
||||||
|
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
||||||
|
t.Fatalf("SetRetryBackoff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := c.doJSONRequest(context.Background(), http.MethodPost, ts.URL+"/test", map[string]string{"key": "val"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if attempts != 3 {
|
||||||
|
t.Errorf("expected 3 attempts, got %d", attempts)
|
||||||
|
}
|
||||||
|
if string(body) != `{"id":1}` {
|
||||||
|
t.Errorf("unexpected body: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoJSONRequest_429ExhaustsRetries(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(429)
|
||||||
|
w.Write([]byte(`{"message":"rate limit"}`))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
c := NewClient("token", ts.URL, AllowInsecureHTTP())
|
||||||
|
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
|
||||||
|
t.Fatalf("SetRetryBackoff: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.doJSONRequest(context.Background(), http.MethodPost, ts.URL+"/test", map[string]string{"key": "val"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error after exhausting retries")
|
||||||
|
}
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("expected APIError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 429 {
|
||||||
|
t.Errorf("expected 429, got %d", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,5 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Compile-time interface conformance assertions.
|
// Compile-time interface conformance assertions.
|
||||||
// These verify github.Client satisfies vcs.PRReader and vcs.FileReader.
|
// These verify github.Client satisfies vcs.Client (the full interface).
|
||||||
var (
|
var _ vcs.Client = (*github.Client)(nil)
|
||||||
_ vcs.PRReader = (*github.Client)(nil)
|
|
||||||
_ vcs.FileReader = (*github.Client)(nil)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// reviewsPerPage is the number of reviews to fetch per API page.
|
||||||
|
reviewsPerPage = 100
|
||||||
|
// maxReviewPages is the maximum number of pages to paginate through
|
||||||
|
// when listing reviews. Acts as a safeguard against infinite pagination.
|
||||||
|
maxReviewPages = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// reviewResponse is the GitHub API response for a pull request review.
|
||||||
|
type reviewResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
User struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
} `json:"user"`
|
||||||
|
State string `json:"state"`
|
||||||
|
CommitID string `json:"commit_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewCreateRequest is the GitHub API request body for creating a pull request review.
|
||||||
|
type reviewCreateRequest struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Comments []reviewCommentCreate `json:"comments,omitempty"`
|
||||||
|
CommitID string `json:"commit_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewCommentCreate is a single inline comment in a review creation request.
|
||||||
|
type reviewCommentCreate struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dismissReviewRequest is the GitHub API request body for dismissing a review.
|
||||||
|
type dismissReviewRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// userResponse is the GitHub API response for the authenticated user.
|
||||||
|
type userResponse struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateReviewEvent converts a vcs.ReviewEvent to the GitHub API event string.
|
||||||
|
func translateReviewEvent(event vcs.ReviewEvent) string {
|
||||||
|
switch event {
|
||||||
|
case vcs.ReviewEventApprove:
|
||||||
|
return "APPROVE"
|
||||||
|
case vcs.ReviewEventRequestChanges:
|
||||||
|
return "REQUEST_CHANGES"
|
||||||
|
default:
|
||||||
|
return "COMMENT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostReview creates a new review on a pull request.
|
||||||
|
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, req vcs.ReviewRequest) (*vcs.Review, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
|
||||||
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
|
|
||||||
|
payload := reviewCreateRequest{
|
||||||
|
Body: req.Body,
|
||||||
|
Event: translateReviewEvent(req.Event),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, comment := range req.Comments {
|
||||||
|
rc := reviewCommentCreate{
|
||||||
|
Path: comment.Path,
|
||||||
|
Position: comment.Position,
|
||||||
|
Body: comment.Body,
|
||||||
|
}
|
||||||
|
payload.Comments = append(payload.Comments, rc)
|
||||||
|
// Use CommitID from the first comment that has one.
|
||||||
|
// All comments in a single review are expected to reference the same commit.
|
||||||
|
if payload.CommitID == "" && comment.CommitID != "" {
|
||||||
|
payload.CommitID = comment.CommitID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := c.doJSONRequest(ctx, http.MethodPost, reqURL, payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("post review: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp reviewResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse review response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &vcs.Review{
|
||||||
|
ID: resp.ID,
|
||||||
|
Body: resp.Body,
|
||||||
|
User: vcs.UserInfo{Login: resp.User.Login},
|
||||||
|
State: resp.State,
|
||||||
|
CommitID: resp.CommitID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReviews lists all reviews on a pull request.
|
||||||
|
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
|
||||||
|
var allReviews []vcs.Review
|
||||||
|
|
||||||
|
for page := 1; page <= maxReviewPages; page++ {
|
||||||
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews?per_page=%d&page=%d",
|
||||||
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewsPerPage, page)
|
||||||
|
body, err := c.doGet(ctx, reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list reviews page %d: %w", page, err)
|
||||||
|
}
|
||||||
|
var reviews []reviewResponse
|
||||||
|
if err := json.Unmarshal(body, &reviews); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse reviews JSON: %w", err)
|
||||||
|
}
|
||||||
|
if len(reviews) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, r := range reviews {
|
||||||
|
allReviews = append(allReviews, vcs.Review{
|
||||||
|
ID: r.ID,
|
||||||
|
Body: r.Body,
|
||||||
|
User: vcs.UserInfo{Login: r.User.Login},
|
||||||
|
State: r.State,
|
||||||
|
CommitID: r.CommitID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(reviews) < reviewsPerPage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// NOTE: This warning only fires when the final page was full (the short-page
|
||||||
|
// break above did not trigger), meaning additional reviews likely exist beyond
|
||||||
|
// our page limit. The loop naturally exits after this iteration since page
|
||||||
|
// increments past maxReviewPages.
|
||||||
|
if page == maxReviewPages {
|
||||||
|
slog.Warn("ListReviews hit page limit; results may be truncated",
|
||||||
|
"owner", owner, "repo", repo, "pr", number,
|
||||||
|
"maxPages", maxReviewPages, "reviewsFetched", len(allReviews))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allReviews, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReview permanently deletes a review from a pull request.
|
||||||
|
// Use DismissReview instead when the review should remain visible but marked as dismissed
|
||||||
|
// (e.g., superseding an outdated review while preserving history).
|
||||||
|
func (c *Client) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
|
||||||
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d",
|
||||||
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
|
||||||
|
_, err := c.doRequest(ctx, http.MethodDelete, reqURL, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete review: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissReview dismisses a review on a pull request with a message.
|
||||||
|
func (c *Client) DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error {
|
||||||
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d/dismissals",
|
||||||
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
|
||||||
|
|
||||||
|
payload := dismissReviewRequest{
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.doJSONRequest(ctx, http.MethodPut, reqURL, payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dismiss review: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthenticatedUser returns the login name of the authenticated user.
|
||||||
|
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/user", c.baseURL)
|
||||||
|
body, err := c.doGet(ctx, reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get authenticated user: %w", err)
|
||||||
|
}
|
||||||
|
var resp userResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse user response: %w", err)
|
||||||
|
}
|
||||||
|
return resp.Login, nil
|
||||||
|
}
|
||||||
@@ -10,18 +10,6 @@ func FormatMarkdown(result *ReviewResult, reviewerName string) string {
|
|||||||
return FormatMarkdownWithDisplay(result, reviewerName, reviewerName)
|
return FormatMarkdownWithDisplay(result, reviewerName, reviewerName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GiteaEvent converts the verdict to the Gitea API event string.
|
|
||||||
func GiteaEvent(verdict string) string {
|
|
||||||
switch verdict {
|
|
||||||
case "APPROVE":
|
|
||||||
return "APPROVED"
|
|
||||||
case "REQUEST_CHANGES":
|
|
||||||
return "REQUEST_CHANGES"
|
|
||||||
default:
|
|
||||||
return "COMMENT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatMarkdownWithDisplay formats a ReviewResult with separate display name and sentinel name.
|
// FormatMarkdownWithDisplay formats a ReviewResult with separate display name and sentinel name.
|
||||||
// Note: displayName is not HTML-escaped as Gitea sanitizes rendered Markdown.
|
// Note: displayName is not HTML-escaped as Gitea sanitizes rendered Markdown.
|
||||||
// Persona display names are controlled by repo owners (trusted input).
|
// Persona display names are controlled by repo owners (trusted input).
|
||||||
|
|||||||
@@ -98,25 +98,6 @@ func TestFormatMarkdown_SpecialChars(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGiteaEvent(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
verdict string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"APPROVE", "APPROVED"},
|
|
||||||
{"REQUEST_CHANGES", "REQUEST_CHANGES"},
|
|
||||||
{"UNKNOWN", "COMMENT"},
|
|
||||||
{"", "COMMENT"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
got := GiteaEvent(tc.verdict)
|
|
||||||
if got != tc.expected {
|
|
||||||
t.Errorf("GiteaEvent(%q) = %q, want %q", tc.verdict, got, tc.expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatMarkdown_Sentinel(t *testing.T) {
|
func TestFormatMarkdown_Sentinel(t *testing.T) {
|
||||||
result := &ReviewResult{
|
result := &ReviewResult{
|
||||||
Verdict: "APPROVE",
|
Verdict: "APPROVE",
|
||||||
|
|||||||
Reference in New Issue
Block a user