Compare commits

..

3 Commits

Author SHA1 Message Date
claw 329d68e4b4 fix: address review feedback on PR #102
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 40s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 59s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m19s
- Separate maxPages into maxFilesPages and maxCheckRunPages constants
  for clarity (sonnet MINOR #1)
- Add parallel to CheckRunConclusions subtests (sonnet MINOR #2)
- Add TestGetCommitStatuses_CheckRunsErrorAfterStatusesSucceed test
  covering check-runs 500 after statuses succeed (sonnet MINOR #2)
- Expand mapCheckRunStatus doc comment with full mapping rules including
  cancelled/skipped/neutral rationale and unknown value behavior
  (sonnet MINOR #3, gpt MINOR #1)
- Expand GetPullRequest doc comment to mention error types returned
  (sonnet NIT #4)
- Add inline comment on Description field clarifying it holds raw
  conclusion value (gpt NIT #3)
2026-05-12 21:36:45 -07:00
claw 3995fa3136 fix(github): add GetFileContentAtRef and fix conformance test
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 58s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m51s
- Implement GetFileContentAtRef on *Client to satisfy vcs.PRReader interface
- Add escapePath and decodeBase64Content helpers
- Fix conformance_test.go to properly import and qualify github.Client
  (was using unqualified Client in package github_test)

Fixes CI failure: the PRReader interface requires GetFileContentAtRef
but it was missing from this PR (only present in the file-reader PR).
2026-05-12 21:21:01 -07:00
aweiker d468ea6022 feat(github): implement PRReader interface (#80)
CI / test (pull_request) Failing after 12s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Has been skipped
Implement PRReader conformance on the GitHub client: GetPullRequest,
GetPullRequestDiff, GetPullRequestFiles (paginated, populates Patch),
GetCommitStatuses (merges commit statuses + check runs).
Adds compile-time PRReader conformance check.

Requires PR A. Part 2 of 3 for #80.
2026-05-13 04:12:13 +00:00
26 changed files with 509 additions and 2509 deletions
+175 -136
View File
@@ -13,7 +13,6 @@ 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"
@@ -55,15 +54,12 @@ 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")
// VCS flags // CLI flags
provider := flag.String("provider", envOrDefault("VCS_PROVIDER", "gitea"), "VCS provider: gitea or github") giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", envOrDefault("GITHUB_SERVER_URL", "")), "Gitea instance URL")
baseURL := flag.String("base-url", envOrDefault("VCS_BASE_URL", ""), "VCS API base URL (for github provider; defaults to https://api.github.com)") repo := flag.String("repo", envOrDefault("GITEA_REPO", envOrDefault("GITHUB_REPOSITORY", "")), "Repository (owner/name)")
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", ""), "VCS token for posting review") reviewerToken := flag.String("reviewer-token", envOrDefault("REVIEWER_TOKEN", ""), "Gitea 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")
@@ -84,11 +80,6 @@ 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)")
// Backward-compatible alias: --gitea-url shares vcsURL's pointer (last flag wins).
// Must use *vcsURL as default: StringVar sets *p=value at registration, so empty
// string would overwrite the env-resolved value from the --vcs-url declaration.
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
flag.Parse() flag.Parse()
if *versionFlag { if *versionFlag {
@@ -101,23 +92,12 @@ func main() {
slog.Info("review-bot starting", "version", version) slog.Info("review-bot starting", "version", version)
// Validate VCS provider
vcsProvider := vcs.VCSProvider(*provider)
if !vcsProvider.Valid() {
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 *repo == "" || *prNum == "" || *reviewerToken == "" || *llmModel == "" { if *giteaURL == "" || *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: --repo, --pr, --reviewer-token, --llm-model\n") fmt.Fprintf(os.Stderr, "Required: --gitea-url, --repo, --pr, --reviewer-token, --llm-model\n")
os.Exit(1)
}
// --vcs-url is required only for gitea provider
if vcsProvider == vcs.ProviderGitea && *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 == "") {
@@ -136,6 +116,8 @@ 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)
@@ -157,20 +139,8 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Initialize VCS client // Initialize clients
var client vcs.Client giteaClient := gitea.NewClient(*giteaURL, *reviewerToken)
switch vcsProvider {
case vcs.ProviderGitea:
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
client = gitea.NewAdapter(giteaClient)
case vcs.ProviderGitHub:
client = github.NewClient(*reviewerToken, *baseURL)
default:
panic("unreachable: provider validation should have caught " + vcsProvider.String())
}
slog.Info("VCS client initialized", "provider", vcsProvider)
// 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")
@@ -204,13 +174,16 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), overallTimeout) ctx, cancel := context.WithTimeout(context.Background(), overallTimeout)
defer cancel() defer cancel()
// Load persona if specified // Load persona if specified (after Gitea client init to support repo personas)
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, client, owner, repoName) repoPersonas, err := review.LoadRepoPersonas(ctx, newGiteaClientAdapter(giteaClient), 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
@@ -241,7 +214,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 := client.GetPullRequest(ctx, owner, repoName, prNumber) pr, err := giteaClient.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)
@@ -249,7 +222,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 := client.GetPullRequestDiff(ctx, owner, repoName, prNumber) diff, err := giteaClient.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)
@@ -258,21 +231,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 := client.GetPullRequestFiles(ctx, owner, repoName, prNumber) files, err := giteaClient.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, client, owner, repoName, pr.Head.Ref, files) fileContext = fetchFileContext(ctx, giteaClient, 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 := client.GetCommitStatuses(ctx, owner, repoName, pr.Head.SHA) statuses, err := giteaClient.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)
@@ -282,7 +255,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 := client.GetFileContent(ctx, owner, repoName, *conventionsFile, "") content, err := giteaClient.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 {
@@ -294,7 +267,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, client, *patternsRepo, *patternsFiles) patterns = fetchPatterns(ctx, giteaClient, *patternsRepo, *patternsFiles)
slog.Debug("loaded patterns", "repo", *patternsRepo, "bytes", len(patterns)) slog.Debug("loaded patterns", "repo", *patternsRepo, "bytes", len(patterns))
} }
@@ -387,16 +360,15 @@ 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)
} }
// Map verdict to canonical review event event := review.GiteaEvent(result.Verdict)
event := verdictToEvent(result.Verdict)
if *dryRun { if *dryRun {
fmt.Println("--- DRY RUN ---") fmt.Println("--- DRY RUN ---")
@@ -408,41 +380,35 @@ 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 := client.GetPullRequest(ctx, owner, repoName, prNumber) currentPR, err := giteaClient.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",
"evaluated", evaluatedSHA, "evaluated", evaluatedSHA,
"current", currentSHA, "current", currentSHA,
"pr", prNumber) "pr", prNumber)
return return
} }
// Build line→position map for inline comments // Map findings to inline comments for lines present in the diff
lineToPosition := vcs.BuildLineToPositionMap(diff) diffRanges := gitea.ParseDiffNewLines(diff)
var inlineComments []vcs.ReviewComment var inlineComments []gitea.ReviewComment
for _, f := range result.Findings { for _, f := range result.Findings {
if f.File == "" || f.Line <= 0 { if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) {
continue inlineComments = append(inlineComments, gitea.ReviewComment{
}
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,
Position: pos, NewPosition: int64(f.Line),
CommitID: evaluatedSHA,
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))
} }
@@ -450,9 +416,10 @@ 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
var oldReviews []vcs.Review // Order matters: post first so we have the new review's URL for the supersede message.
var oldReviews []gitea.Review
if *reviewerName != "" { if *reviewerName != "" {
existingReviews, err := client.ListReviews(ctx, owner, repoName, prNumber) existingReviews, err := giteaClient.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 {
@@ -464,64 +431,74 @@ func main() {
} }
} }
// Self-request as reviewer (Gitea-specific; ensures we appear in required-reviewer checks) // Self-request as reviewer (ensures we appear in required-reviewer checks)
if selfReq, ok := client.(vcs.ReviewerSelfRequester); ok { authUser, err := giteaClient.GetAuthenticatedUser(ctx)
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 := selfReq.RequestReviewerSelf(ctx, owner, repoName, prNumber, authUser); err != nil { if err := giteaClient.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)
reviewReq := vcs.ReviewRequest{ posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
Body: reviewBody,
Event: event,
CommitID: evaluatedSHA,
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 via optional interface // Supersede all old reviews with link to the new one
if len(oldReviews) > 0 { if len(oldReviews) > 0 {
if superseder, ok := client.(vcs.ReviewSuperseder); ok { newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(*giteaURL, "/"), owner, repoName, prNumber, posted.ID)
if err := superseder.SupersedeReviews(ctx, owner, repoName, prNumber, oldReviews, posted.ID, *vcsURL, sentinel); err != nil { for _, oldReview := range oldReviews {
slog.Error("failed to supersede old reviews", "error", err) cid, err := giteaClient.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, prNumber, oldReview.ID)
os.Exit(1) if err != nil {
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
continue
} }
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
if err := giteaClient.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)
continue
}
slog.Info("marked old review as superseded", "review_id", oldReview.ID, "new_review_id", posted.ID, "pr", prNumber)
// Resolve old review's inline comments
oldComments, err := giteaClient.ListReviewComments(ctx, owner, repoName, prNumber, oldReview.ID)
if err != nil {
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
continue
}
resolved, failed := 0, 0
for _, c := range oldComments {
if c.ID == 0 {
continue
}
if err := giteaClient.ResolveComment(ctx, owner, repoName, c.ID); err != nil {
slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err)
failed++
} else { } else {
slog.Error("provider does not support review superseding", "provider", vcsProvider) resolved++
}
}
if resolved > 0 {
slog.Info("resolved old inline comments", "review_id", oldReview.ID, "count", resolved, "pr", prNumber)
}
if failed > 0 {
slog.Warn("some inline comments could not be resolved", "review_id", oldReview.ID, "failed", failed, "pr", prNumber)
} }
} }
} }
// 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
}
} }
// 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 vcs.PRReader, owner, repo, ref string, files []vcs.ChangedFile) string { func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, ref string, files []gitea.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 {
@@ -530,7 +507,7 @@ func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref
if f.Status == "removed" { if f.Status == "removed" {
continue // Skip deleted files continue // Skip deleted files
} }
content, err := client.GetFileContentAtRef(ctx, owner, repo, f.Filename, ref) content, err := client.GetFileContentRef(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
@@ -547,8 +524,7 @@ func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref
// 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.
// Empty entries in patternsFiles are skipped (no implicit repo-root fetch). 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, ",")
@@ -578,7 +554,7 @@ func fetchPatterns(ctx context.Context, client vcs.FileReader, patternsRepo, pat
continue continue
} }
files, err := vcs.GetAllFilesInPath(ctx, client, owner, repo, path) files, err := client.GetAllFilesInPath(ctx, 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
@@ -617,20 +593,18 @@ func isPatternFile(path string) bool {
} }
// evaluateCIStatus checks if all CI statuses indicate success. // evaluateCIStatus checks if all CI statuses indicate success.
// Returns passed=true if no checks have failed (pending checks are not treated as failures). func evaluateCIStatus(statuses []gitea.CommitStatus) (passed bool, details string) {
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":
pending++ // treat pending as not-failed
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))
} }
@@ -639,9 +613,6 @@ func evaluateCIStatus(statuses []vcs.CommitStatus) (passed bool, details string)
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"
} }
@@ -672,6 +643,14 @@ func envOrDefaultInt(key string, defaultVal int) int {
return defaultVal return defaultVal
} }
func envOrDefaultBool(key string, defaultVal bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if v == "" {
return defaultVal
}
return v == "true" || v == "1" || v == "yes"
}
// validateReviewerName checks that the name contains only safe characters // validateReviewerName checks that the name contains only safe characters
// for embedding in an HTML comment sentinel ([a-zA-Z0-9_-]). // for embedding in an HTML comment sentinel ([a-zA-Z0-9_-]).
func validateReviewerName(name string) error { func validateReviewerName(name string) error {
@@ -723,11 +702,36 @@ func validateWorkspacePath(path, pathName string) (string, error) {
return resolvedPath, nil return resolvedPath, nil
} }
// buildSupersededBody creates the body for a superseded review: struck-through banner
// with collapsed original content and the commit it was evaluated against.
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
shortSHA := commitSHA
if len(shortSHA) > 8 {
shortSHA = shortSHA[:8]
}
var sb strings.Builder
sb.WriteString("~~Original review~~\n\n")
sb.WriteString("**Superseded** \u2014 [see current review](")
sb.WriteString(newReviewURL)
sb.WriteString(") for up-to-date findings.\n\n")
if shortSHA != "" {
sb.WriteString("<details><summary>Previous findings (commit ")
sb.WriteString(shortSHA)
sb.WriteString(")</summary>\n\n")
} else {
sb.WriteString("<details><summary>Previous findings</summary>\n\n")
}
sb.WriteString(originalBody)
sb.WriteString("\n\n</details>\n\n")
sb.WriteString(sentinel)
return sb.String()
}
// hasSharedToken detects if another review-bot role posted under the same // hasSharedToken detects if another review-bot role posted under the same
// VCS user. This indicates misconfiguration where two roles share a token // Gitea user. This indicates misconfiguration where two roles share a token
// instead of having separate accounts. Returns true if shared token // instead of having separate Gitea 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 []vcs.Review, ownSentinel string) bool { func hasSharedToken(reviews []gitea.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) {
@@ -740,7 +744,7 @@ func hasSharedToken(reviews []vcs.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 VCS user", slog.Warn("shared token detected another review-bot role is using the same Gitea user",
"sibling_role", extractSentinelName(r.Body), "user", ownLogin) "sibling_role", extractSentinelName(r.Body), "user", ownLogin)
return true return true
} }
@@ -761,26 +765,29 @@ func extractSentinelName(body string) string {
if end < 0 { if end < 0 {
return "unknown" return "unknown"
} }
name := rest[:end] return 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) // findOwnReview locates the most recent non-superseded review matching the sentinel.
if len(name) > 64 { func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review {
name = name[:64] var best *gitea.Review
for i := range reviews {
if !strings.Contains(reviews[i].Body, sentinel) {
continue
} }
if name == "" { if strings.Contains(reviews[i].Body, "~~Original review~~") {
return "unknown" continue
} }
return name 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 []vcs.Review, sentinel string) []vcs.Review { func findAllOwnReviews(reviews []gitea.Review, sentinel string) []gitea.Review {
var result []vcs.Review var result []gitea.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
@@ -805,3 +812,35 @@ 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)
}
+215 -86
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/vcs" "gitea.weiker.me/rodin/review-bot/gitea"
) )
func TestValidateReviewerName(t *testing.T) { func TestValidateReviewerName(t *testing.T) {
@@ -107,6 +107,8 @@ 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",
}, },
{ {
@@ -152,20 +154,155 @@ func TestValidateWorkspacePath(t *testing.T) {
} }
} }
func makeReview(id int64, login, state string, stale bool, body string) vcs.Review { func makeReview(id int64, login, state string, stale bool, body string) gitea.Review {
return vcs.Review{ r := gitea.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) {
original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->"
sentinel := "<!-- review-bot:sonnet -->"
newURL := "https://gitea.example.com/owner/repo/pulls/1#pullrequestreview-99"
result := buildSupersededBody(original, "abcdef1234567890", newURL, sentinel)
// Should contain the struck-through banner
if !strings.Contains(result, "~~Original review~~") {
t.Error("missing struck-through banner")
}
// Should contain superseded notice with link
if !strings.Contains(result, "**Superseded**") {
t.Error("missing superseded notice")
}
if !strings.Contains(result, "[see current review]("+newURL+")") {
t.Error("missing link to new review")
}
// Should contain collapsed original
if !strings.Contains(result, "<details>") {
t.Error("missing details/collapse")
}
// Should contain short commit SHA
if !strings.Contains(result, "abcdef12") {
t.Error("missing short SHA")
}
// Should NOT contain full SHA
if strings.Contains(result, "abcdef1234567890") {
t.Error("should truncate SHA to 8 chars")
}
// Should contain the original body inside details
if !strings.Contains(result, original) {
t.Error("original body not preserved in collapsed section")
}
// Should end with sentinel
if !strings.Contains(result, sentinel) {
t.Error("missing sentinel")
}
}
func TestBuildSupersededBodyShortSHA(t *testing.T) {
// Short SHA should pass through without panic
result := buildSupersededBody("body", "abc", "https://example.com/review", "<!-- review-bot:x -->")
if !strings.Contains(result, "abc") {
t.Error("short SHA not preserved")
}
}
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 []vcs.Review reviews []gitea.Review
sentinel string sentinel string
want bool want bool
}{ }{
@@ -177,36 +314,36 @@ func TestHasSharedToken(t *testing.T) {
}, },
{ {
name: "no own review yet - cannot detect", name: "no own review yet - cannot detect",
reviews: []vcs.Review{ reviews: []gitea.Review{
makeReview(1, "other", "APPROVED", false, "<!-- review-bot:gpt --> body"), {ID: 1, User: struct{ Login string `json:"login"` }{Login: "other"}, Body: "<!-- 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: []vcs.Review{ reviews: []gitea.Review{
makeReview(1, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"), {ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
makeReview(2, "security-review-bot", "APPROVED", false, "<!-- review-bot:security --> body"), {ID: 2, User: struct{ Login string `json:"login"` }{Login: "security-review-bot"}, Body: "<!-- 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: []vcs.Review{ reviews: []gitea.Review{
makeReview(1, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"), {ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
makeReview(2, "sonnet-review-bot", "APPROVED", false, "<!-- review-bot:security --> body"), {ID: 2, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- 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: []vcs.Review{ reviews: []gitea.Review{
makeReview(1, "bot", "APPROVED", false, "<!-- review-bot:sonnet --> body"), {ID: 1, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:sonnet --> body"},
makeReview(2, "bot", "APPROVED", false, "<!-- review-bot:security --> body"), {ID: 2, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:security --> body"},
makeReview(3, "bot", "APPROVED", false, "<!-- review-bot:gpt --> body"), {ID: 3, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:gpt --> body"},
}, },
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: true, want: true,
@@ -370,7 +507,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 []vcs.CommitStatus statuses []gitea.CommitStatus
wantPassed bool wantPassed bool
wantSubstr string wantSubstr string
}{ }{
@@ -382,7 +519,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "all success", name: "all success",
statuses: []vcs.CommitStatus{ statuses: []gitea.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"},
}, },
@@ -391,7 +528,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "one failure", name: "one failure",
statuses: []vcs.CommitStatus{ statuses: []gitea.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"},
}, },
@@ -400,7 +537,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "error status", name: "error status",
statuses: []vcs.CommitStatus{ statuses: []gitea.CommitStatus{
{Status: "error", Context: "ci/lint", Description: "Lint error"}, {Status: "error", Context: "ci/lint", Description: "Lint error"},
}, },
wantPassed: false, wantPassed: false,
@@ -408,16 +545,16 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "pending treated as not-failed", name: "pending treated as not-failed",
statuses: []vcs.CommitStatus{ statuses: []gitea.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: "no failures", wantSubstr: "all checks passed",
}, },
{ {
name: "multiple failures", name: "multiple failures",
statuses: []vcs.CommitStatus{ statuses: []gitea.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"},
}, },
@@ -426,7 +563,7 @@ func TestEvaluateCIStatus(t *testing.T) {
}, },
{ {
name: "mixed with pending and failure", name: "mixed with pending and failure",
statuses: []vcs.CommitStatus{ statuses: []gitea.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"},
@@ -548,6 +685,47 @@ func TestEnvOrDefaultInt(t *testing.T) {
} }
} }
func TestEnvOrDefaultBool(t *testing.T) {
tests := []struct {
name string
envVal string
setEnv bool
defaultVal bool
want bool
}{
{"unset returns default true", "", false, true, true},
{"unset returns default false", "", false, false, false},
{"true", "true", true, false, true},
{"TRUE", "TRUE", true, false, true},
{"True", "True", true, false, true},
{"1", "1", true, false, true},
{"yes", "yes", true, false, true},
{"YES", "YES", true, false, true},
{"false", "false", true, true, false},
{"0", "0", true, true, false},
{"no", "no", true, true, false},
{"random string", "random", true, true, false},
{"empty string returns default", "", true, true, true},
{"whitespace true", " true ", true, false, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
envKey := "TEST_ENV_BOOL_" + strings.ReplaceAll(tc.name, " ", "_")
if tc.setEnv {
os.Setenv(envKey, tc.envVal)
defer os.Unsetenv(envKey)
} else {
os.Unsetenv(envKey)
}
got := envOrDefaultBool(envKey, tc.defaultVal)
if got != tc.want {
t.Errorf("envOrDefaultBool(%q, %v) = %v, want %v", tc.envVal, tc.defaultVal, got, tc.want)
}
})
}
}
func TestExtractSentinelName_EdgeCases(t *testing.T) { func TestExtractSentinelName_EdgeCases(t *testing.T) {
tests := []struct { tests := []struct {
body string body string
@@ -614,7 +792,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",
"--vcs-url", "http://localhost", "--gitea-url", "http://localhost",
"--repo", "owner/repo", "--repo", "owner/repo",
"--pr", "1", "--pr", "1",
"--reviewer-name", "invalid name", "--reviewer-name", "invalid name",
@@ -642,7 +820,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",
"--vcs-url", "http://localhost", "--gitea-url", "http://localhost",
"--repo", "invalidrepo", "--repo", "invalidrepo",
"--pr", "1", "--pr", "1",
"--reviewer-token", "tok", "--reviewer-token", "tok",
@@ -669,7 +847,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",
"--vcs-url", "http://localhost", "--gitea-url", "http://localhost",
"--repo", "owner/repo", "--repo", "owner/repo",
"--pr", "notanumber", "--pr", "notanumber",
"--reviewer-token", "tok", "--reviewer-token", "tok",
@@ -696,7 +874,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",
"--vcs-url", "http://localhost", "--gitea-url", "http://localhost",
"--repo", "owner/repo", "--repo", "owner/repo",
"--pr", "1", "--pr", "1",
"--reviewer-token", "tok", "--reviewer-token", "tok",
@@ -724,7 +902,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",
"--vcs-url", "http://localhost", "--gitea-url", "http://localhost",
"--repo", "owner/repo", "--repo", "owner/repo",
"--pr", "1", "--pr", "1",
"--reviewer-token", "tok", "--reviewer-token", "tok",
@@ -748,35 +926,7 @@ func TestMainSubprocess_InvalidProvider(t *testing.T) {
} }
} }
func TestMainSubprocess_InvalidVCSProvider(t *testing.T) { // cleanEnv returns environ without any GITEA/LLM/REVIEWER env vars that would
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
@@ -784,7 +934,6 @@ 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_"),
@@ -802,12 +951,12 @@ func cleanEnv() []string {
} }
func TestFindAllOwnReviews(t *testing.T) { func TestFindAllOwnReviews(t *testing.T) {
reviews := []vcs.Review{ reviews := []gitea.Review{
makeReview(1, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nfirst review"), {ID: 1, Body: "<!-- review-bot:sonnet -->\nfirst review"},
makeReview(2, "bot", "APPROVED", false, "<!-- review-bot:gpt -->\nother bot"), {ID: 2, Body: "<!-- review-bot:gpt -->\nother bot"},
makeReview(3, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nsecond review"), {ID: 3, Body: "<!-- review-bot:sonnet -->\nsecond review"},
makeReview(4, "bot", "APPROVED", false, "~~Original review~~\n<!-- review-bot:sonnet -->\nsuperseded"), {ID: 4, Body: "~~Original review~~\n<!-- review-bot:sonnet -->\nsuperseded"},
makeReview(5, "bot", "APPROVED", false, "<!-- review-bot:sonnet -->\nthird review"), {ID: 5, Body: "<!-- review-bot:sonnet -->\nthird review"},
} }
got := findAllOwnReviews(reviews, "<!-- review-bot:sonnet -->") got := findAllOwnReviews(reviews, "<!-- review-bot:sonnet -->")
@@ -871,23 +1020,3 @@ 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)
}
}
}
+4 -88
View File
@@ -3,8 +3,6 @@ package gitea
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"strings"
"gitea.weiker.me/rodin/review-bot/vcs" "gitea.weiker.me/rodin/review-bot/vcs"
) )
@@ -18,7 +16,6 @@ type Adapter struct {
// Compile-time interface conformance assertion. // Compile-time interface conformance assertion.
var _ vcs.Client = (*Adapter)(nil) var _ vcs.Client = (*Adapter)(nil)
var _ vcs.ReviewerSelfRequester = (*Adapter)(nil)
// NewAdapter creates a new Adapter wrapping the given gitea Client. // NewAdapter creates a new Adapter wrapping the given gitea Client.
func NewAdapter(client *Client) *Adapter { func NewAdapter(client *Client) *Adapter {
@@ -170,9 +167,9 @@ func (a *Adapter) PostReview(ctx context.Context, owner, repo string, number int
if err != nil { if err != nil {
return nil, fmt.Errorf("translate position %d in %s: %w", c.Position, c.Path, err) return nil, fmt.Errorf("translate position %d in %s: %w", c.Position, c.Path, err)
} }
// Per-comment CommitID is not forwarded to Gitea inline comments: // CommitID from vcs.ReviewComment is intentionally not forwarded:
// Gitea's CreatePullReview API has no per-comment commit_id field. // Gitea review comments are pinned to the PR head SHA automatically,
// The review-level commit anchor is set via req.CommitID instead. // and the CreatePullReview API has no per-comment commit_id field.
giteaComments = append(giteaComments, ReviewComment{ giteaComments = append(giteaComments, ReviewComment{
Path: c.Path, Path: c.Path,
NewPosition: int64(lineNum), NewPosition: int64(lineNum),
@@ -181,7 +178,7 @@ func (a *Adapter) PostReview(ctx context.Context, owner, repo string, number int
} }
} }
review, err := a.client.PostReview(ctx, owner, repo, number, event, req.Body, req.CommitID, giteaComments) review, err := a.client.PostReview(ctx, owner, repo, number, event, req.Body, giteaComments)
if err != nil { if err != nil {
return nil, fmt.Errorf("post review: %w", err) return nil, fmt.Errorf("post review: %w", err)
} }
@@ -233,84 +230,3 @@ func (a *Adapter) DismissReview(ctx context.Context, owner, repo string, number
func (a *Adapter) GetAuthenticatedUser(ctx context.Context) (string, error) { func (a *Adapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
return a.client.GetAuthenticatedUser(ctx) return a.client.GetAuthenticatedUser(ctx)
} }
// RequestReviewerSelf adds the given user as a requested reviewer on a pull request.
// This implements vcs.ReviewerSelfRequester for the Gitea adapter.
func (a *Adapter) RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error {
return a.client.RequestReviewer(ctx, owner, repo, number, user)
}
// Compile-time interface conformance assertion for ReviewSuperseder.
var _ vcs.ReviewSuperseder = (*Adapter)(nil)
// SupersedeReviews marks prior reviews as superseded by editing their body with a
// link to the new review and resolving their inline comments. This is Gitea-specific
// behavior that has no GitHub equivalent (GitHub uses DismissReview instead).
//
// baseURL is the Gitea instance URL used to construct review permalink URLs.
// sentinel is the HTML comment sentinel that identifies reviews belonging to this reviewer.
func (a *Adapter) SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []vcs.Review, newReviewID int64, baseURL, sentinel string) error {
// Validate baseURL scheme before embedding in Markdown link (defense-in-depth).
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return fmt.Errorf("SupersedeReviews: baseURL must have http or https scheme, got %q", baseURL)
}
underlying := a.client
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d",
strings.TrimRight(baseURL, "/"), owner, repo, prNumber, newReviewID)
for _, oldReview := range oldReviews {
cid, err := underlying.GetTimelineReviewCommentIDForReview(ctx, owner, repo, prNumber, oldReview.ID)
if err != nil {
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
continue
}
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
if err := underlying.EditComment(ctx, owner, repo, cid, supersededBody); err != nil {
slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "error", err)
continue
}
// Resolve old review's inline comments
oldComments, err := underlying.ListReviewComments(ctx, owner, repo, prNumber, oldReview.ID)
if err != nil {
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
continue
}
for _, c := range oldComments {
if c.ID == 0 {
continue
}
if err := underlying.ResolveComment(ctx, owner, repo, c.ID); err != nil {
slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err)
}
}
}
return nil
}
// buildSupersededBody creates the body for a superseded review: struck-through banner
// with collapsed original content and the commit it was evaluated against.
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
shortSHA := commitSHA
if len(shortSHA) > 8 {
shortSHA = shortSHA[:8]
}
var sb strings.Builder
sb.WriteString("~~Original review~~\n\n")
sb.WriteString("**Superseded** \u2014 [see current review](")
sb.WriteString(newReviewURL)
sb.WriteString(") for up-to-date findings.\n\n")
if shortSHA != "" {
sb.WriteString("<details><summary>Previous findings (commit ")
sb.WriteString(shortSHA)
sb.WriteString(")</summary>\n\n")
} else {
sb.WriteString("<details><summary>Previous findings</summary>\n\n")
}
sb.WriteString(originalBody)
sb.WriteString("\n\n</details>\n\n")
sb.WriteString(sentinel)
return sb.String()
}
-92
View File
@@ -386,95 +386,3 @@ func TestAdapter_GetFileContent_RefRouting(t *testing.T) {
t.Errorf("GetFileContent(ref=\"abc123\") = %q, want %q", got, "content-at-ref") t.Errorf("GetFileContent(ref=\"abc123\") = %q, want %q", got, "content-at-ref")
} }
} }
func TestAdapter_RequestReviewerSelf(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
expected := "/api/v1/repos/owner/repo/pulls/5/requested_reviewers"
if r.URL.Path != expected {
t.Errorf("path = %q, want %q", r.URL.Path, expected)
}
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := gitea.NewClient(server.URL, "token")
adapter := gitea.NewAdapter(client)
err := adapter.RequestReviewerSelf(context.Background(), "owner", "repo", 5, "bot-user")
if err != nil {
t.Fatalf("RequestReviewerSelf() error = %v", err)
}
}
func TestAdapter_PostReview_CommitID(t *testing.T) {
var gotPayload struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewDecoder(r.Body).Decode(&gotPayload)
json.NewEncoder(w).Encode(map[string]any{
"id": 1,
"body": "test",
"user": map[string]any{"login": "bot"},
"commit_id": "abc123def456",
})
}))
defer server.Close()
client := gitea.NewClient(server.URL, "token")
adapter := gitea.NewAdapter(client)
review, err := adapter.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
CommitID: "abc123def456",
// No comments → no diff fetch needed
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPayload.CommitID != "abc123def456" {
t.Errorf("commit_id = %q, want %q", gotPayload.CommitID, "abc123def456")
}
if review.CommitID != "abc123def456" {
t.Errorf("review.CommitID = %q, want %q", review.CommitID, "abc123def456")
}
}
func TestAdapter_PostReview_EmptyCommitID_Omitted(t *testing.T) {
var gotRawPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewDecoder(r.Body).Decode(&gotRawPayload)
json.NewEncoder(w).Encode(map[string]any{
"id": 1,
"body": "test",
"user": map[string]any{"login": "bot"},
})
}))
defer server.Close()
client := gitea.NewClient(server.URL, "token")
adapter := gitea.NewAdapter(client)
_, err := adapter.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "looks good",
Event: vcs.ReviewEventComment,
// CommitID intentionally empty
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// With empty CommitID and omitempty tag, the field should not appear in JSON
if _, exists := gotRawPayload["commit_id"]; exists {
t.Errorf("commit_id should be omitted when empty, but was present: %v", gotRawPayload["commit_id"])
}
}
+2 -6
View File
@@ -186,22 +186,18 @@ func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, r
} }
// PostReview submits a review to a PR and returns the created review. // PostReview submits a review to a PR and returns the created review.
// event should be one of "APPROVED", "REQUEST_CHANGES", or "COMMENT". // event should be "APPROVED" or "REQUEST_CHANGES".
// commitID anchors the review to a specific commit SHA. If empty, Gitea
// defaults to the current PR head.
// comments are optional inline comments attached to specific lines. // comments are optional inline comments attached to specific lines.
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []ReviewComment) (*Review, error) { func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string, comments []ReviewComment) (*Review, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number) reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
payload := struct { payload := struct {
Body string `json:"body"` Body string `json:"body"`
Event string `json:"event"` Event string `json:"event"`
CommitID string `json:"commit_id,omitempty"`
Comments []ReviewComment `json:"comments,omitempty"` Comments []ReviewComment `json:"comments,omitempty"`
}{ }{
Body: body, Body: body,
Event: event, Event: event,
CommitID: commitID,
Comments: comments, Comments: comments,
} }
+2 -101
View File
@@ -135,7 +135,7 @@ func TestPostReview(t *testing.T) {
defer server.Close() defer server.Close()
client := NewClient(server.URL, "test-token") client := NewClient(server.URL, "test-token")
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "", nil) review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -147,46 +147,6 @@ func TestPostReview(t *testing.T) {
} }
} }
func TestPostReview_CommitID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
var payload struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload.CommitID != "deadbeef123" {
t.Errorf("expected commit_id %q, got %q", "deadbeef123", payload.CommitID)
}
if payload.Event != "APPROVED" {
t.Errorf("expected event APPROVED, got %q", payload.Event)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":101,"user":{"login":"review-bot"},"state":"APPROVED","stale":false,"commit_id":"deadbeef123"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "deadbeef123", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.ID != 101 {
t.Errorf("expected review ID 101, got %d", review.ID)
}
if review.CommitID != "deadbeef123" {
t.Errorf("expected commit_id %q, got %q", "deadbeef123", review.CommitID)
}
}
func TestGetPullRequest_Non200(t *testing.T) { func TestGetPullRequest_Non200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@@ -222,7 +182,7 @@ func TestPostReview_Non200(t *testing.T) {
defer server.Close() defer server.Close()
client := NewClient(server.URL, "test-token") client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", "", nil) _, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", nil)
if err == nil { if err == nil {
t.Fatal("expected error for 403, got nil") t.Fatal("expected error for 403, got nil")
} }
@@ -1184,62 +1144,3 @@ func TestSanitizeErrorForLog(t *testing.T) {
}) })
} }
} }
func TestPostReview_CommitID_InPayload(t *testing.T) {
var gotPayload struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotPayload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]any{
"id": 200,
"body": "LGTM",
"user": map[string]any{"login": "bot"},
"state": "APPROVED",
"commit_id": "deadbeef1234",
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
review, err := client.PostReview(context.Background(), "owner", "repo", 5, "APPROVED", "LGTM", "deadbeef1234", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPayload.CommitID != "deadbeef1234" {
t.Errorf("sent commit_id = %q, want %q", gotPayload.CommitID, "deadbeef1234")
}
if review.CommitID != "deadbeef1234" {
t.Errorf("response commit_id = %q, want %q", review.CommitID, "deadbeef1234")
}
}
func TestPostReview_EmptyCommitID_OmittedFromPayload(t *testing.T) {
var gotRaw map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotRaw)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]any{
"id": 201,
"body": "ok",
"user": map[string]any{"login": "bot"},
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 5, "COMMENT", "ok", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, exists := gotRaw["commit_id"]; exists {
t.Errorf("commit_id should be omitted when empty, but was present: %v", gotRaw["commit_id"])
}
}
+4 -11
View File
@@ -11,7 +11,6 @@ import (
type PositionMap struct { type PositionMap struct {
// files maps filename → (position → new-file line number). // files maps filename → (position → new-file line number).
// Deletion lines are mapped to -1 (no new-file line). // Deletion lines are mapped to -1 (no new-file line).
// Hunk-header lines are mapped to 0 (no new-file line).
files map[string]map[int]int files map[string]map[int]int
// maxPositions caches the highest position number per file, // maxPositions caches the highest position number per file,
// tracked during construction to avoid O(n) scans at translate time. // tracked during construction to avoid O(n) scans at translate time.
@@ -20,8 +19,8 @@ type PositionMap struct {
// Translate converts a GitHub diff-position to a new-file line number for a given file. // Translate converts a GitHub diff-position to a new-file line number for a given file.
// Returns an error if the file is not in the diff or the position is out of range. // Returns an error if the file is not in the diff or the position is out of range.
// If the position targets a deletion or hunk-header line, it maps to the nearest // If the position targets a deletion line, it maps to the nearest non-deletion line below;
// context/addition line below; if no such line exists, returns an error. // if no such line exists, returns an error.
func (pm *PositionMap) Translate(file string, position int) (int, error) { func (pm *PositionMap) Translate(file string, position int) (int, error) {
if pm == nil || pm.files == nil { if pm == nil || pm.files == nil {
return 0, fmt.Errorf("empty position map") return 0, fmt.Errorf("empty position map")
@@ -42,18 +41,14 @@ func (pm *PositionMap) Translate(file string, position int) (int, error) {
} }
// lineNum == -1 means this position is a deletion line. // lineNum == -1 means this position is a deletion line.
// lineNum == 0 means this position is a hunk-header line. // Map to the nearest non-deletion line below.
// Both map to the nearest context/addition line below. if lineNum == -1 {
if lineNum <= 0 {
maxPos := pm.maxPosition(file) maxPos := pm.maxPosition(file)
for p := position + 1; p <= maxPos; p++ { for p := position + 1; p <= maxPos; p++ {
if ln, exists := fileMap[p]; exists && ln > 0 { if ln, exists := fileMap[p]; exists && ln > 0 {
return ln, nil return ln, nil
} }
} }
if lineNum == 0 {
return 0, fmt.Errorf("position %d targets a hunk-header line with no subsequent new-file line in %q", position, file)
}
return 0, fmt.Errorf("position %d targets a deletion line with no subsequent new-file line in %q", position, file) return 0, fmt.Errorf("position %d targets a deletion line with no subsequent new-file line in %q", position, file)
} }
@@ -75,7 +70,6 @@ func (pm *PositionMap) maxPosition(file string) int {
// - A new @@ hunk within the same file continues incrementing (does not reset) // - A new @@ hunk within the same file continues incrementing (does not reset)
// - Position maps to the new file line number for additions and context lines // - Position maps to the new file line number for additions and context lines
// - Deletion lines have a position but no new-file line number (stored as -1) // - Deletion lines have a position but no new-file line number (stored as -1)
// - Hunk-header lines have a position but no new-file line number (stored as 0)
func BuildPositionToLineMap(diff string) *PositionMap { func BuildPositionToLineMap(diff string) *PositionMap {
pm := &PositionMap{ pm := &PositionMap{
files: make(map[string]map[int]int), files: make(map[string]map[int]int),
@@ -132,7 +126,6 @@ func BuildPositionToLineMap(diff string) *PositionMap {
// Parse hunk headers // Parse hunk headers
if strings.HasPrefix(line, "@@") && currentFile != "" { if strings.HasPrefix(line, "@@") && currentFile != "" {
position++ position++
pm.files[currentFile][position] = 0 // sentinel: hunk-header has no new-file line
pm.maxPositions[currentFile] = position pm.maxPositions[currentFile] = position
newLine = parseHunkStart(line) newLine = parseHunkStart(line)
continue continue
-109
View File
@@ -272,112 +272,3 @@ diff --git a/b.go b/b.go
t.Errorf("Translate(b.go, 3) = %d, want 2", got) t.Errorf("Translate(b.go, 3) = %d, want 2", got)
} }
} }
func TestTranslate_HunkHeaderPosition_SingleHunk(t *testing.T) {
// Position 1 is the @@ hunk-header line.
// It should resolve to the first context/addition line below (new line 16).
diff := `diff --git a/file.go b/file.go
index abc..def 100644
--- a/file.go
+++ b/file.go
@@ -16,4 +16,5 @@ func example() {
context line
-deleted line
+added line
context after
`
pm := BuildPositionToLineMap(diff)
got, err := pm.Translate("file.go", 1)
if err != nil {
t.Fatalf("Translate(file.go, 1): unexpected error: %v", err)
}
if got != 16 {
t.Errorf("Translate(file.go, 1) = %d, want 16 (first context/addition line in hunk)", got)
}
}
func TestTranslate_HunkHeaderPosition_MultiHunk(t *testing.T) {
// First hunk: @@ is pos 1, then " line1" (pos 2), "-old" (pos 3), "+new" (pos 4)
// Second hunk: @@ is pos 5, then " func foo() {" (pos 6), "+// added" (pos 7), etc.
// Translating position 5 (second @@) should resolve to new line 10.
diff := `diff --git a/file.go b/file.go
--- a/file.go
+++ b/file.go
@@ -1,3 +1,3 @@ package main
line1
-old
+new
@@ -10,3 +10,4 @@ func foo() {
func foo() {
+ // added
return
}
`
pm := BuildPositionToLineMap(diff)
// Position 5 is the second @@ hunk-header — should resolve to new line 10
got, err := pm.Translate("file.go", 5)
if err != nil {
t.Fatalf("Translate(file.go, 5): unexpected error: %v", err)
}
if got != 10 {
t.Errorf("Translate(file.go, 5) = %d, want 10 (first context/addition line in second hunk)", got)
}
// Also verify first hunk header at position 1 resolves to new line 1
got, err = pm.Translate("file.go", 1)
if err != nil {
t.Fatalf("Translate(file.go, 1): unexpected error: %v", err)
}
if got != 1 {
t.Errorf("Translate(file.go, 1) = %d, want 1 (first context/addition line in first hunk)", got)
}
}
func TestTranslate_HunkHeaderPosition_NewFile(t *testing.T) {
// New file: @@ -0,0 +1,3 @@ is position 1.
// Should resolve to new line 1 (the first addition).
diff := `diff --git a/new.go b/new.go
new file mode 100644
--- /dev/null
+++ b/new.go
@@ -0,0 +1,3 @@
+package main
+
+func init() {}
`
pm := BuildPositionToLineMap(diff)
got, err := pm.Translate("new.go", 1)
if err != nil {
t.Fatalf("Translate(new.go, 1): unexpected error: %v", err)
}
if got != 1 {
t.Errorf("Translate(new.go, 1) = %d, want 1 (first addition line)", got)
}
}
func TestTranslate_HunkHeaderAtEnd(t *testing.T) {
// A hunk-header at the last position with no subsequent new-file line should error.
// This is the hunk-header equivalent of TestBuildPositionToLineMap_DeletionAtEnd.
diff := `diff --git a/file.go b/file.go
--- a/file.go
+++ b/file.go
@@ -1,2 +1,2 @@ package main
line1
-old
+new
@@ -10,2 +10,1 @@ func foo() {
-removed
`
pm := BuildPositionToLineMap(diff)
// Position 5 is the second @@ hunk-header; the only line after it (pos 6) is a
// deletion (lineNum == -1), so there's no positive new-file line to resolve to.
// The hunk-header lookup should fail.
_, err := pm.Translate("file.go", 5)
if err == nil {
t.Error("expected error for hunk-header at end with no subsequent new-file line")
}
}
+2 -2
View File
@@ -37,7 +37,7 @@ func TestPostReview_WithComments(t *testing.T) {
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"}, {Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
} }
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", "", comments) _, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", comments)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -72,7 +72,7 @@ func TestPostReview_NilComments(t *testing.T) {
defer server.Close() defer server.Close()
client := NewClient(server.URL, "test-token") client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", "", nil) _, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
-54
View File
@@ -1,54 +0,0 @@
package gitea
import (
"strings"
"testing"
)
func TestBuildSupersededBody(t *testing.T) {
original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->"
sentinel := "<!-- review-bot:sonnet -->"
newURL := "https://gitea.example.com/owner/repo/pulls/1#pullrequestreview-99"
result := buildSupersededBody(original, "abcdef1234567890", newURL, sentinel)
// Should contain the struck-through banner
if !strings.Contains(result, "~~Original review~~") {
t.Error("missing struck-through banner")
}
// Should contain superseded notice with link
if !strings.Contains(result, "**Superseded**") {
t.Error("missing superseded notice")
}
if !strings.Contains(result, "[see current review]("+newURL+")") {
t.Error("missing link to new review")
}
// Should contain collapsed original
if !strings.Contains(result, "<details>") {
t.Error("missing details/collapse")
}
// Should contain short commit SHA
if !strings.Contains(result, "abcdef12") {
t.Error("missing short SHA")
}
// Should NOT contain full SHA in summary (it's truncated to 8)
if strings.Contains(result, "abcdef1234567890") {
t.Error("should truncate SHA to 8 chars")
}
// Should contain the original body inside details
if !strings.Contains(result, original) {
t.Error("original body not preserved in collapsed section")
}
// Should end with sentinel
if !strings.Contains(result, sentinel) {
t.Error("missing sentinel")
}
}
func TestBuildSupersededBodyShortSHA(t *testing.T) {
// Short SHA should pass through without panic
result := buildSupersededBody("body", "abc", "https://example.com/review", "<!-- review-bot:x -->")
if !strings.Contains(result, "abc") {
t.Error("short SHA not preserved")
}
}
+24 -108
View File
@@ -4,9 +4,7 @@
package github package github
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -23,10 +21,6 @@ const (
// maxResponseBytes limits successful response body reads to 10 MiB. // maxResponseBytes limits successful response body reads to 10 MiB.
maxResponseBytes = 10 * 1024 * 1024 maxResponseBytes = 10 * 1024 * 1024
// maxRetryAttempts is the number of times doRequest will attempt a request.
// The retry backoff slice must have length maxRetryAttempts-1.
maxRetryAttempts = 3
) )
// APIError represents an HTTP error response from the GitHub API. // APIError represents an HTTP error response from the GitHub API.
@@ -110,11 +104,6 @@ type Client struct {
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails). // retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
// If nil, defaults to {1s, 2s}. Set to shorter durations in tests via SetRetryBackoff. // If nil, defaults to {1s, 2s}. Set to shorter durations in tests via SetRetryBackoff.
retryBackoff []time.Duration retryBackoff []time.Duration
// reviewPageSize overrides reviewsPerPage for testing. Zero means use default.
reviewPageSize int
// reviewMaxPages overrides maxReviewPages for testing. Zero means use default.
reviewMaxPages int
} }
// defaultCheckRedirect is the redirect policy used by NewClient and SetHTTPClient(nil). // defaultCheckRedirect is the redirect policy used by NewClient and SetHTTPClient(nil).
@@ -189,62 +178,31 @@ func (c *Client) SetHTTPClient(hc *http.Client) {
// SetRetryBackoff configures the retry backoff durations for testing. // SetRetryBackoff configures the retry backoff durations for testing.
// It must be called before any goroutines issue requests. // It must be called before any goroutines issue requests.
// The slice must have exactly maxRetryAttempts-1 entries (one delay per retry gap).
// In production the default {1s, 2s} applies. // In production the default {1s, 2s} applies.
func (c *Client) SetRetryBackoff(d []time.Duration) error { func (c *Client) SetRetryBackoff(d []time.Duration) {
if len(d) != maxRetryAttempts-1 {
return fmt.Errorf("github: backoff length %d does not match maxRetryAttempts-1 (%d)", len(d), maxRetryAttempts-1)
}
c.retryBackoff = d c.retryBackoff = d
return nil
} }
// SetReviewPagination overrides the page size and max pages for ListReviews. // doRequest performs an HTTP request with retry on 429 rate limit responses.
// Intended for testing only; must be called before any goroutines issue requests. // It respects the Retry-After header when present (capped at maxRetryAfter).
func (c *Client) SetReviewPagination(pageSize, maxPages int) { // Transport errors (network failures, context cancellation) are not retried.
c.reviewPageSize = pageSize func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
c.reviewMaxPages = maxPages const maxAttempts = 3
}
// requestOptions holds per-request configuration for doRequestCore.
type requestOptions struct {
// bodyFn returns a fresh io.Reader for the request body on each attempt.
// Must be non-nil for any request that carries a body (POST, PUT, PATCH,
// or DELETE when a body is required by the API).
// Returning a fresh reader on each call allows retries to re-send the body.
bodyFn func() io.Reader
// accept overrides the default Accept header. Empty means "application/vnd.github+json".
accept string
// extraHeaders are additional headers to set on each request attempt.
extraHeaders map[string]string
}
// doRequestCore is the shared implementation for all HTTP requests with retry
// on 429 rate limit responses. It respects the Retry-After header when present
// (capped at maxRetryAfter). Transport errors are not retried.
func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts requestOptions) ([]byte, error) {
const maxRetryAfter = 120 * time.Second const maxRetryAfter = 120 * time.Second
var backoff []time.Duration
if c.retryBackoff != nil {
backoff = make([]time.Duration, len(c.retryBackoff))
copy(backoff, c.retryBackoff)
} else {
backoff = []time.Duration{1 * time.Second, 2 * time.Second}
}
// maxErrorBodyBytes limits how much of an error response body is stored. // 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 // Kept small (4 KiB) to reduce the risk of sensitive data leakage if callers
// log APIError.Body directly. Error() further truncates to 200 bytes. // log APIError.Body directly. Error() further truncates to 200 bytes.
const maxErrorBodyBytes = 4 * 1024 const maxErrorBodyBytes = 4 * 1024
// backoff holds per-attempt delays: backoff[i] is the delay before attempt i+1.
// Length must be maxRetryAttempts-1 (one entry per retry gap).
// SetRetryBackoff validates at configuration time; the default is always valid.
defaultBackoff := []time.Duration{1 * time.Second, 2 * time.Second}
var backoff []time.Duration
if c.retryBackoff != nil && len(c.retryBackoff) == maxRetryAttempts-1 {
backoff = make([]time.Duration, len(c.retryBackoff))
copy(backoff, c.retryBackoff)
} else {
backoff = make([]time.Duration, len(defaultBackoff))
copy(backoff, defaultBackoff)
}
// 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)
@@ -257,7 +215,7 @@ func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts
} }
var lastErr error var lastErr error
for attempt := 0; attempt < maxRetryAttempts; attempt++ { for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 { if attempt > 0 {
var delay time.Duration var delay time.Duration
if attempt-1 < len(backoff) { if attempt-1 < len(backoff) {
@@ -275,11 +233,7 @@ func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts
} }
} }
var body io.Reader req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
if opts.bodyFn != nil {
body = opts.bodyFn()
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil { if err != nil {
return nil, fmt.Errorf("create request: %w", err) return nil, fmt.Errorf("create request: %w", err)
} }
@@ -290,35 +244,28 @@ func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts
req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Authorization", "Bearer "+c.token)
} }
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
if opts.accept != "" { if accept != "" {
req.Header.Set("Accept", opts.accept) req.Header.Set("Accept", accept)
} else { } else {
req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("Accept", "application/vnd.github+json")
} }
for k, v := range opts.extraHeaders {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("do request: %w", err) return nil, fmt.Errorf("do request: %w", err)
} }
// Capture response metadata before handleResponse takes body ownership. body, done, err := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
respStatus := resp.StatusCode
retryAfterHeader := resp.Header.Get("Retry-After")
respBody, done, handleErr := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
if done { if done {
return respBody, handleErr return body, err
} }
lastErr = handleErr lastErr = err
// Retry on 429 rate limit // Retry on 429 rate limit
if respStatus == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 { if resp.StatusCode == http.StatusTooManyRequests && attempt < maxAttempts-1 {
// Check for Retry-After header and override backoff if present. // Check for Retry-After header and override backoff if present.
// Supports both integer seconds (common) and HTTP-date format (RFC 7231). // Supports both integer seconds (common) and HTTP-date format (RFC 7231).
if ra := retryAfterHeader; ra != "" { if ra := resp.Header.Get("Retry-After"); ra != "" {
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 { if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
delay := time.Duration(seconds) * time.Second delay := time.Duration(seconds) * time.Second
if delay > maxRetryAfter { if delay > maxRetryAfter {
@@ -350,13 +297,6 @@ func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts
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) {
return c.doRequestCore(ctx, method, reqURL, requestOptions{accept: accept})
}
// 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.
@@ -369,7 +309,7 @@ func (c *Client) handleResponse(resp *http.Response, maxRespBytes int, maxErrByt
return nil, true, fmt.Errorf("read response body: %w", err) return nil, true, fmt.Errorf("read response body: %w", err)
} }
if len(body) > maxRespBytes { if len(body) > maxRespBytes {
return nil, true, fmt.Errorf("response body exceeded %d bytes", maxRespBytes) return nil, true, fmt.Errorf("response body exceeded %d bytes (truncated)", maxRespBytes)
} }
return body, true, nil return body, true, nil
} }
@@ -385,27 +325,3 @@ 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, "")
} }
// doRequestWithBody is like doRequest but sends a request body.
// It accepts the raw body bytes and sets Content-Type to application/json.
// Retry semantics match doRequest (retries on 429 with Retry-After support).
func (c *Client) doRequestWithBody(ctx context.Context, method, reqURL string, reqBody []byte) ([]byte, error) {
var opts requestOptions
if reqBody != nil {
opts.bodyFn = func() io.Reader { return bytes.NewReader(reqBody) }
opts.extraHeaders = map[string]string{"Content-Type": "application/json"}
}
return c.doRequestCore(ctx, method, reqURL, opts)
}
// doJSONRequest performs an HTTP request with a JSON body and returns the response body.
// It delegates retry/backoff/429 handling to doRequestWithBody.
// 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)
}
return c.doRequestWithBody(ctx, method, reqURL, jsonBody)
}
+6 -100
View File
@@ -2,7 +2,6 @@ package github
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -84,9 +83,7 @@ func TestDoRequest_429Retry(t *testing.T) {
c := NewClient("token", srv.URL, AllowInsecureHTTP()) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond}); err != nil { c.SetRetryBackoff([]time.Duration{10 * time.Millisecond, 10 * time.Millisecond})
t.Fatalf("SetRetryBackoff: %v", err)
}
body, err := c.doGet(context.Background(), srv.URL+"/test") body, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil { if err != nil {
@@ -111,9 +108,7 @@ func TestDoRequest_429ExhaustsRetries(t *testing.T) {
c := NewClient("token", srv.URL, AllowInsecureHTTP()) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil { c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
t.Fatalf("SetRetryBackoff: %v", err)
}
_, err := c.doGet(context.Background(), srv.URL+"/test") _, err := c.doGet(context.Background(), srv.URL+"/test")
if err == nil { if err == nil {
@@ -223,9 +218,7 @@ func TestDoRequest_429RetryAfterHeader(t *testing.T) {
c := NewClient("token", srv.URL, AllowInsecureHTTP()) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
// Use short backoff; Retry-After should override // Use short backoff; Retry-After should override
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil { c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
t.Fatalf("SetRetryBackoff: %v", err)
}
start := time.Now() start := time.Now()
body, err := c.doGet(context.Background(), srv.URL+"/test") body, err := c.doGet(context.Background(), srv.URL+"/test")
@@ -266,9 +259,7 @@ func TestDoRequest_RetryAfterDoesNotMutateBackoff(t *testing.T) {
c := NewClient("token", srv.URL, AllowInsecureHTTP()) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil { c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
t.Fatalf("SetRetryBackoff: %v", err)
}
_, err := c.doGet(context.Background(), srv.URL+"/test") _, err := c.doGet(context.Background(), srv.URL+"/test")
if err != nil { if err != nil {
@@ -306,9 +297,7 @@ func TestDoRequest_429RetryAfterHTTPDate(t *testing.T) {
c := NewClient("token", srv.URL, AllowInsecureHTTP()) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil { c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond})
t.Fatalf("SetRetryBackoff: %v", err)
}
start := time.Now() start := time.Now()
body, err := c.doGet(context.Background(), srv.URL+"/test") body, err := c.doGet(context.Background(), srv.URL+"/test")
@@ -349,9 +338,7 @@ func TestDoRequest_429RetryAfterHTTPDateInPast(t *testing.T) {
c := NewClient("token", srv.URL, AllowInsecureHTTP()) c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client()) c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{5 * time.Second, 5 * time.Second}); err != nil { c.SetRetryBackoff([]time.Duration{5 * time.Second, 5 * time.Second})
t.Fatalf("SetRetryBackoff: %v", err)
}
start := time.Now() start := time.Now()
_, err := c.doGet(context.Background(), srv.URL+"/test") _, err := c.doGet(context.Background(), srv.URL+"/test")
@@ -567,84 +554,3 @@ func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)") t.Fatal("expected CheckRedirect policy after SetHTTPClient(nil)")
} }
} }
func TestSetRetryBackoff_RejectsInvalidLength(t *testing.T) {
c := NewClient("token", "https://api.github.com")
// Too short
err := c.SetRetryBackoff([]time.Duration{1 * time.Second})
if err == nil {
t.Fatal("expected error for backoff length 1")
}
if !strings.Contains(err.Error(), "backoff length 1") {
t.Errorf("unexpected error message: %v", err)
}
// Too long
err = c.SetRetryBackoff([]time.Duration{1 * time.Second, 2 * time.Second, 3 * time.Second})
if err == nil {
t.Fatal("expected error for backoff length 3")
}
// Correct length succeeds
err = c.SetRetryBackoff([]time.Duration{1 * time.Second, 2 * time.Second})
if err != nil {
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)
}
}
+2 -6
View File
@@ -6,9 +6,5 @@ import (
) )
// Compile-time interface conformance assertion. // Compile-time interface conformance assertion.
// This verifies github.Client satisfies the full vcs.Client interface // Verifies github.Client satisfies vcs.PRReader.
// (PRReader, FileReader, Reviewer, Identity). var _ vcs.PRReader = (*github.Client)(nil)
var _ vcs.Client = (*github.Client)(nil)
// Verify github.Client implements ReviewSuperseder.
var _ vcs.ReviewSuperseder = (*github.Client)(nil)
+16 -108
View File
@@ -6,36 +6,24 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"path"
"strings" "strings"
"gitea.weiker.me/rodin/review-bot/vcs"
) )
// GetFileContent fetches a file from a repo at the given ref.
// Delegates to GetFileContentAtRef with the provided ref.
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filePath, ref string) (string, error) {
return c.GetFileContentAtRef(ctx, owner, repo, filePath, ref)
}
// GetFileContentAtRef fetches a file at a specific ref from a repo. // GetFileContentAtRef fetches a file at a specific ref from a repo.
// If ref is empty, the query parameter is omitted (uses default branch). // If ref is empty, the query parameter is omitted (uses default branch).
// //
// Returns an error if the path contains dot-segments (".", "..") or // Note: dot-segments ("." and "..") in the path are silently removed to
// attempts to traverse above the repository root. // prevent path traversal. This means a path like "foo/../bar" resolves
func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, filePath, ref string) (string, error) { // to "foo/bar" rather than "bar".
escaped, err := escapePath(filePath) func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error) {
if err != nil {
return "", fmt.Errorf("invalid file path: %w", err)
}
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s", reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escaped) c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
if ref != "" { if ref != "" {
reqURL += "?ref=" + url.QueryEscape(ref) reqURL += "?ref=" + url.QueryEscape(ref)
} }
body, err := c.doGet(ctx, reqURL) body, err := c.doGet(ctx, reqURL)
if err != nil { if err != nil {
return "", fmt.Errorf("fetch file %s: %w", filePath, err) return "", fmt.Errorf("fetch file %s: %w", path, err)
} }
var resp struct { var resp struct {
Content string `json:"content"` Content string `json:"content"`
@@ -45,116 +33,36 @@ func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, filePath,
return "", fmt.Errorf("parse file content JSON: %w", err) return "", fmt.Errorf("parse file content JSON: %w", err)
} }
if resp.Encoding != "base64" { if resp.Encoding != "base64" {
return "", fmt.Errorf("unexpected encoding %q for file %s", resp.Encoding, filePath) return "", fmt.Errorf("unexpected encoding %q for file %s", resp.Encoding, path)
} }
decoded, err := decodeBase64Content(resp.Content) decoded, err := decodeBase64Content(resp.Content)
if err != nil { if err != nil {
return "", fmt.Errorf("decode base64 content for %s: %w", filePath, err) return "", fmt.Errorf("decode base64 content for %s: %w", path, err)
} }
return decoded, nil return decoded, nil
} }
// ListContents lists files and directories at a given path in a repo. // escapePath encodes each segment of a slash-separated path, stripping
// Returns the directory listing from the GitHub contents API. // dot-segments to prevent path traversal.
// If the path points to a single file (not a directory), the API returns func escapePath(p string) string {
// a JSON object instead of an array; this is handled by returning a parts := strings.Split(p, "/")
// single-element slice. var clean []string
func (c *Client) ListContents(ctx context.Context, owner, repo, filePath string) ([]vcs.ContentEntry, error) {
escaped, err := escapePath(filePath)
if err != nil {
return nil, fmt.Errorf("invalid file path: %w", err)
}
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escaped)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list contents %s: %w", filePath, err)
}
type entry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
}
// The GitHub contents API returns an array for directories and an object
// for single files. Try array first (common case), then fall back to object.
// An empty array ([]) is valid — it represents an empty directory — and
// results in a zero-length slice returned without error.
var entries []entry
if err := json.Unmarshal(body, &entries); err != nil {
var single entry
if err2 := json.Unmarshal(body, &single); err2 != nil {
return nil, fmt.Errorf("parse contents JSON: as array: %v; as object: %w", err, err2)
}
// Guard against empty objects ({}) or unexpected shapes that
// unmarshal successfully but carry no useful data.
if single.Name == "" && single.Path == "" && single.Type == "" {
return nil, fmt.Errorf("parse contents JSON: unexpected response format")
}
entries = []entry{single}
}
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
}
// escapePath validates and encodes a slash-separated file path for use in
// GitHub API URLs. Returns an error if the path contains dot-segments ("."
// or "..") or resolves to a path outside the repository root.
func escapePath(p string) (string, error) {
// Reject paths containing dot-segments rather than silently rewriting them.
for _, seg := range strings.Split(p, "/") {
if seg == "." || seg == ".." {
return "", fmt.Errorf("path contains dot-segment %q: %s", seg, p)
}
}
// Use path.Clean for canonical form, then verify it doesn't escape root.
cleaned := path.Clean(p)
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
return "", fmt.Errorf("path resolves outside repository root: %s", p)
}
// Encode each segment individually.
parts := strings.Split(cleaned, "/")
var encoded []string
for _, part := range parts { for _, part := range parts {
if part == "" { if part == "." || part == ".." || part == "" {
continue continue
} }
encoded = append(encoded, url.PathEscape(part)) clean = append(clean, url.PathEscape(part))
} }
return strings.Join(encoded, "/"), nil return strings.Join(clean, "/")
} }
// maxFileContentSize is the maximum decoded file size (10 MB) to prevent
// resource exhaustion when decoding base64 content from the API.
const maxFileContentSize = 10 * 1024 * 1024
// decodeBase64Content decodes base64-encoded content from the GitHub contents API. // decodeBase64Content decodes base64-encoded content from the GitHub contents API.
// GitHub returns base64 content with line breaks for formatting; we strip \r and \n before decoding. // GitHub returns base64 content with line breaks for formatting; we strip \r and \n before decoding.
// Returns an error if the decoded content exceeds maxFileContentSize.
func decodeBase64Content(encoded string) (string, error) { func decodeBase64Content(encoded string) (string, error) {
cleaned := strings.NewReplacer("\n", "", "\r", "").Replace(encoded) cleaned := strings.NewReplacer("\n", "", "\r", "").Replace(encoded)
// Check estimated decoded size before allocating.
// Base64 encodes 3 bytes into 4 chars, so decoded ~ len*3/4.
if len(cleaned)*3/4 > maxFileContentSize {
return "", fmt.Errorf("file content too large: estimated %d bytes exceeds limit of %d", len(cleaned)*3/4, maxFileContentSize)
}
decoded, err := base64.StdEncoding.DecodeString(cleaned) decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil { if err != nil {
return "", err return "", err
} }
if len(decoded) > maxFileContentSize {
return "", fmt.Errorf("file content too large: %d bytes exceeds limit of %d", len(decoded), maxFileContentSize)
}
return string(decoded), nil return string(decoded), nil
} }
-405
View File
@@ -1,405 +0,0 @@
package github
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestGetFileContent_DelegatesToGetFileContentAtRef(t *testing.T) {
var gotRef string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRef = r.URL.Query().Get("ref")
json.NewEncoder(w).Encode(map[string]string{
"content": "dGVzdA==", // "test" in base64
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
// Call with empty ref — should not include ref param
content, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "test" {
t.Errorf("expected 'test', got %q", content)
}
if gotRef != "" {
t.Errorf("expected empty ref, got %q", gotRef)
}
}
func TestGetFileContent_WithRef(t *testing.T) {
var gotRef string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRef = r.URL.Query().Get("ref")
json.NewEncoder(w).Encode(map[string]string{
"content": "dGVzdA==",
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotRef != "abc123" {
t.Errorf("expected ref 'abc123', got %q", gotRef)
}
}
func TestGetFileContent_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "missing.go", "")
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestGetFileContent_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestGetFileContent_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
json.NewEncoder(w).Encode(map[string]string{
"content": "b2s=",
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
t.Fatalf("SetRetryBackoff: %v", err)
}
content, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "ok" {
t.Errorf("expected 'ok', got %q", content)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestGetFileContent_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestListContents_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/src" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
json.NewEncoder(w).Encode([]map[string]string{
{"name": "main.go", "path": "src/main.go", "type": "file"},
{"name": "lib", "path": "src/lib", "type": "dir"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
entries, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].Name != "main.go" {
t.Errorf("expected name 'main.go', got %q", entries[0].Name)
}
if entries[0].Path != "src/main.go" {
t.Errorf("expected path 'src/main.go', got %q", entries[0].Path)
}
if entries[0].Type != "file" {
t.Errorf("expected type 'file', got %q", entries[0].Type)
}
if entries[1].Name != "lib" {
t.Errorf("expected name 'lib', got %q", entries[1].Name)
}
if entries[1].Type != "dir" {
t.Errorf("expected type 'dir', got %q", entries[1].Type)
}
}
func TestListContents_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "missing")
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestListContents_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestListContents_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
json.NewEncoder(w).Encode([]map[string]string{
{"name": "file.go", "path": "file.go", "type": "file"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
t.Fatalf("SetRetryBackoff: %v", err)
}
entries, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestListContents_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestListContents_SingleFile(t *testing.T) {
// GitHub Contents API returns a JSON object (not array) for single-file paths
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"name":"README.md","path":"README.md","type":"file"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
entries, err := c.ListContents(context.Background(), "owner", "repo", "README.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Name != "README.md" {
t.Errorf("expected name 'README.md', got %q", entries[0].Name)
}
if entries[0].Type != "file" {
t.Errorf("expected type 'file', got %q", entries[0].Type)
}
}
func TestEscapePath_ValidPaths(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
want string
}{
{"simple file", "file.go", "file.go"},
{"nested path", "path/to/file.go", "path/to/file.go"},
{"special chars", "path/to/my file.go", "path/to/my%20file.go"},
{"leading slash stripped", "/path/to/file.go", "path/to/file.go"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := escapePath(tt.path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("escapePath(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}
func TestEscapePath_DotSegments(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
}{
{"single dot", "./file.go"},
{"double dot", "../file.go"},
{"dot in middle", "path/./file.go"},
{"parent traversal", "path/../file.go"},
{"only dots", ".."},
{"nested parent traversal", "a/b/../../c"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := escapePath(tt.path)
if err == nil {
t.Fatalf("expected error for path %q, got nil", tt.path)
}
if !strings.Contains(err.Error(), "dot-segment") {
t.Errorf("expected error about dot-segment, got: %v", err)
}
})
}
}
func TestGetFileContentAtRef_DotSegmentError(t *testing.T) {
// Server should never be called — the error is caught before the request.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("server should not have been called")
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
_, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "foo/../bar.go", "main")
if err == nil {
t.Fatal("expected error for path with dot-segments")
}
if !strings.Contains(err.Error(), "invalid file path") {
t.Errorf("expected 'invalid file path' error, got: %v", err)
}
}
func TestDecodeBase64Content(t *testing.T) {
// Test with newlines (GitHub's format)
encoded := "cGFja2FnZSBt\nYWlu"
decoded, err := decodeBase64Content(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decoded != "package main" {
t.Errorf("expected 'package main', got %q", decoded)
}
}
func TestDecodeBase64Content_Invalid(t *testing.T) {
_, err := decodeBase64Content("not!!!valid!!!base64")
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestDecodeBase64Content_CRLF(t *testing.T) {
// Base64 of "hello world" with CRLF line breaks inserted
encoded := "aGVs\r\nbG8g\r\nd29y\r\nbGQ="
decoded, err := decodeBase64Content(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decoded != "hello world" {
t.Errorf("expected 'hello world', got %q", decoded)
}
}
func TestDecodeBase64Content_SizeLimit(t *testing.T) {
t.Parallel()
// Create base64 content that would decode to > maxFileContentSize.
// maxFileContentSize is 10MB. Base64 of 11MB worth of zeros.
// We just need something big enough to trigger the estimated size check.
// 14MB of base64 chars (decodes to ~10.5MB).
huge := strings.Repeat("A", 14*1024*1024)
_, err := decodeBase64Content(huge)
if err == nil {
t.Fatal("expected error for oversized content")
}
if !strings.Contains(err.Error(), "too large") {
t.Errorf("expected 'too large' error, got: %v", err)
}
}
-23
View File
@@ -1,23 +0,0 @@
package github
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// newTestClient creates a *Client backed by an httptest.Server running the
// given handler. The server is automatically closed when the test finishes.
// Shared across test files in package github.
func newTestClient(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
c := NewClient("test-token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
t.Fatalf("SetRetryBackoff: %v", err)
}
return c
}
-46
View File
@@ -1,46 +0,0 @@
package github
import (
"context"
"encoding/json"
"net/http"
"testing"
)
func TestGetAuthenticatedUser_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Errorf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/user" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization"))
}
json.NewEncoder(w).Encode(map[string]string{"login": "review-bot"})
})
login, err := c.GetAuthenticatedUser(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if login != "review-bot" {
t.Errorf("expected login 'review-bot', got %q", login)
}
}
func TestGetAuthenticatedUser_401(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
})
_, err := c.GetAuthenticatedUser(context.Background())
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
+10 -3
View File
@@ -178,7 +178,7 @@ func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string)
result = append(result, vcs.CommitStatus{ result = append(result, vcs.CommitStatus{
Context: cr.Name, Context: cr.Name,
Status: mapCheckRunStatus(cr.Conclusion), Status: mapCheckRunStatus(cr.Conclusion),
Description: "", // check runs have no human-readable description; conclusion is captured in Status Description: derefString(cr.Conclusion), // raw conclusion value (e.g. "success", "failure", "skipped")
TargetURL: cr.HTMLURL, TargetURL: cr.HTMLURL,
}) })
} }
@@ -199,7 +199,7 @@ func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string)
// - "success" → "success" // - "success" → "success"
// - "failure", "action_required", "timed_out" → "failure" // - "failure", "action_required", "timed_out" → "failure"
// - "cancelled", "skipped", "neutral" → "success" (non-blocking per GitHub check suite semantics) // - "cancelled", "skipped", "neutral" → "success" (non-blocking per GitHub check suite semantics)
// - "stale" → "pending" (check run became stale before completing) // - "stale", "waiting" → "pending"
// - unknown values → "pending" (conservative: treat unrecognized conclusions as incomplete) // - unknown values → "pending" (conservative: treat unrecognized conclusions as incomplete)
func mapCheckRunStatus(conclusion *string) string { func mapCheckRunStatus(conclusion *string) string {
if conclusion == nil { if conclusion == nil {
@@ -213,10 +213,17 @@ func mapCheckRunStatus(conclusion *string) string {
return "failure" return "failure"
case "cancelled", "skipped", "neutral": case "cancelled", "skipped", "neutral":
return "success" // non-blocking: these do not indicate a blocking failure per GitHub check suite semantics return "success" // non-blocking: these do not indicate a blocking failure per GitHub check suite semantics
case "stale": case "stale", "waiting":
return "pending" return "pending"
default: default:
return "pending" return "pending"
} }
} }
// derefString safely dereferences a string pointer, returning empty string if nil.
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
-677
View File
@@ -1,677 +0,0 @@
package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// --- PostReview tests ---
func TestPostReview_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/5/reviews" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type"))
}
// Verify request body
body, _ := io.ReadAll(r.Body)
var req postReviewRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if req.Event != "APPROVE" {
t.Errorf("expected event APPROVE, got %q", req.Event)
}
if req.Body != "LGTM" {
t.Errorf("expected body 'LGTM', got %q", req.Body)
}
if req.CommitID != "abc123" {
t.Errorf("expected commit_id 'abc123', got %q", req.CommitID)
}
if len(req.Comments) != 1 {
t.Fatalf("expected 1 comment, got %d", len(req.Comments))
}
if req.Comments[0].Path != "main.go" {
t.Errorf("expected comment path 'main.go', got %q", req.Comments[0].Path)
}
if req.Comments[0].Position != 4 {
t.Errorf("expected comment position 4, got %d", req.Comments[0].Position)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 100,
"body": "LGTM",
"state": "APPROVED",
"commit_id": "abc123",
"user": map[string]string{"login": "reviewer"},
})
})
review, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
Comments: []vcs.ReviewComment{
{Path: "main.go", Position: 4, CommitID: "abc123", Body: "nit: rename"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.ID != 100 {
t.Errorf("expected ID 100, got %d", review.ID)
}
if review.Body != "LGTM" {
t.Errorf("expected body 'LGTM', got %q", review.Body)
}
if review.State != "APPROVED" {
t.Errorf("expected state 'APPROVED', got %q", review.State)
}
if review.User.Login != "reviewer" {
t.Errorf("expected user 'reviewer', got %q", review.User.Login)
}
if review.CommitID != "abc123" {
t.Errorf("expected commit_id 'abc123', got %q", review.CommitID)
}
}
func TestPostReview_401(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
})
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
func TestPostReview_422(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(422)
w.Write([]byte(`{"message":"Unprocessable Entity"}`))
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
})
if err == nil {
t.Fatal("expected error for 422")
}
// 422 should surface as a wrapped APIError
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.StatusCode != 422 {
t.Errorf("expected status 422, got %d", apiErr.StatusCode)
}
}
func TestPostReview_MalformedResponse(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`not json`))
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
})
if err == nil {
t.Fatal("expected error for malformed response")
}
if !strings.Contains(err.Error(), "parse review response") {
t.Errorf("expected parse error, got: %v", err)
}
}
// --- ListReviews tests ---
func TestListReviews_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/3/reviews" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": 1,
"body": "Approved",
"state": "APPROVED",
"commit_id": "sha1",
"user": map[string]string{"login": "user1"},
},
{
"id": 2,
"body": "Needs work",
"state": "CHANGES_REQUESTED",
"commit_id": "sha2",
"user": map[string]string{"login": "user2"},
},
{
"id": 3,
"body": "Comment only",
"state": "COMMENTED",
"commit_id": "sha3",
"user": map[string]string{"login": "user3"},
},
{
"id": 4,
"body": "Old review",
"state": "DISMISSED",
"commit_id": "sha4",
"user": map[string]string{"login": "user4"},
},
})
})
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 4 {
t.Fatalf("expected 4 reviews, got %d", len(reviews))
}
// Check state translation
expected := []struct {
id int64
state string
}{
{1, "APPROVED"},
{2, "REQUEST_CHANGES"},
{3, "COMMENT"},
{4, "DISMISSED"},
}
for i, e := range expected {
if reviews[i].ID != e.id {
t.Errorf("review[%d]: expected ID %d, got %d", i, e.id, reviews[i].ID)
}
if reviews[i].State != e.state {
t.Errorf("review[%d]: expected state %q, got %q", i, e.state, reviews[i].State)
}
}
}
func TestListReviews_404(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
})
_, err := c.ListReviews(context.Background(), "owner", "repo", 999)
if err == nil {
t.Fatal("expected error for 404")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound=true, got error: %v", err)
}
}
func TestListReviews_401(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
})
_, err := c.ListReviews(context.Background(), "owner", "repo", 3)
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
// --- DeleteReview tests ---
func TestDeleteReview_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/5/reviews/42" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(204)
})
err := c.DeleteReview(context.Background(), "owner", "repo", 5, 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteReview_422_SubmittedReview(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(422)
w.Write([]byte(`{"message":"Can not delete a non pending review"}`))
})
err := c.DeleteReview(context.Background(), "owner", "repo", 5, 42)
if err == nil {
t.Fatal("expected error for 422")
}
if !errors.Is(err, ErrCannotDeleteSubmittedReview) {
t.Errorf("expected ErrCannotDeleteSubmittedReview, got: %v", err)
}
}
// --- DismissReview tests ---
func TestDismissReview_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
t.Fatalf("expected PUT, got %s", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/5/reviews/10/dismissals" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
var req dismissReviewRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if req.Message != "Superseded by new review" {
t.Errorf("expected message 'Superseded by new review', got %q", req.Message)
}
if req.Event != "DISMISS" {
t.Errorf("expected event 'DISMISS', got %q", req.Event)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 10,
"state": "DISMISSED",
})
})
err := c.DismissReview(context.Background(), "owner", "repo", 5, 10, "Superseded by new review")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDismissReview_404(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
})
err := c.DismissReview(context.Background(), "owner", "repo", 5, 999, "dismiss")
if err == nil {
t.Fatal("expected error for 404")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound=true, got error: %v", err)
}
}
func TestDismissReview_401(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
})
err := c.DismissReview(context.Background(), "owner", "repo", 5, 10, "dismiss")
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
// --- State translation tests ---
func TestTranslateGitHubReviewState(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"approved passes through", "APPROVED", "APPROVED"},
{"changes_requested maps to REQUEST_CHANGES", "CHANGES_REQUESTED", "REQUEST_CHANGES"},
{"commented maps to COMMENT", "COMMENTED", "COMMENT"},
{"dismissed passes through", "DISMISSED", "DISMISSED"},
{"unknown state passes through", "UNKNOWN_STATE", "UNKNOWN_STATE"},
{"empty string passes through", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := translateGitHubReviewState(tt.input)
if got != tt.want {
t.Errorf("translateGitHubReviewState(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestPostReview_ConflictingCommitIDs(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not be sent when commit IDs conflict")
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "Review",
Event: vcs.ReviewEventComment,
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "sha-1", Body: "first"},
{Path: "b.go", Position: 2, CommitID: "sha-2", Body: "second"},
},
})
if err == nil {
t.Fatal("expected error for conflicting commit IDs")
}
if !errors.Is(err, ErrConflictingCommitIDs) {
t.Errorf("expected ErrConflictingCommitIDs, got: %v", err)
}
}
func TestPostReview_RequestCommitID_TakesPriority(t *testing.T) {
var gotPayload struct {
CommitID string `json:"commit_id"`
Body string `json:"body"`
}
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotPayload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": 42,
"body": "LGTM",
"state": "APPROVED",
"commit_id": "req-level-sha",
"user": map[string]any{"login": "bot"},
})
})
review, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
CommitID: "req-level-sha",
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "req-level-sha", Body: "looks good"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPayload.CommitID != "req-level-sha" {
t.Errorf("sent commit_id = %q, want %q", gotPayload.CommitID, "req-level-sha")
}
if review.CommitID != "req-level-sha" {
t.Errorf("review.CommitID = %q, want %q", review.CommitID, "req-level-sha")
}
}
func TestPostReview_RequestCommitID_ConflictsWithComment(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not be sent when commit IDs conflict")
})
// req.CommitID is set, and a comment has a different CommitID → conflict
_, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "Review",
Event: vcs.ReviewEventComment,
CommitID: "req-sha",
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "different-sha", Body: "nit"},
},
})
if err == nil {
t.Fatal("expected error for conflicting commit IDs")
}
if !errors.Is(err, ErrConflictingCommitIDs) {
t.Errorf("expected ErrConflictingCommitIDs, got: %v", err)
}
}
func TestPostReview_RequestCommitID_FallbackToComment(t *testing.T) {
var gotPayload struct {
CommitID string `json:"commit_id"`
}
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotPayload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": 43,
"body": "ok",
"state": "COMMENTED",
"commit_id": "comment-sha",
"user": map[string]any{"login": "bot"},
})
})
// req.CommitID is empty, so it falls back to the comment's CommitID
_, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "ok",
Event: vcs.ReviewEventComment,
// CommitID intentionally empty
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "comment-sha", Body: "note"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPayload.CommitID != "comment-sha" {
t.Errorf("sent commit_id = %q, want %q (fallback from comment)", gotPayload.CommitID, "comment-sha")
}
}
// --- ListReviews pagination tests ---
func TestListReviews_MultiPage(t *testing.T) {
// Test multi-page pagination: 2 full pages + 1 partial page.
// pageSize=3, so pages return [3, 3, 2] reviews = 8 total.
const pageSize = 3
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
callCount++
page := r.URL.Query().Get("page")
var reviews []map[string]interface{}
switch page {
case "1":
for i := 1; i <= pageSize; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "APPROVED", "commit_id": "sha1",
"user": map[string]string{"login": "user1"},
})
}
case "2":
for i := pageSize + 1; i <= pageSize*2; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "COMMENTED", "commit_id": "sha1",
"user": map[string]string{"login": "user2"},
})
}
case "3":
// Partial page: only 2 reviews (less than pageSize)
for i := pageSize*2 + 1; i <= pageSize*2+2; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "CHANGES_REQUESTED", "commit_id": "sha1",
"user": map[string]string{"login": "user3"},
})
}
default:
t.Fatalf("unexpected page: %s", page)
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, 10)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 8 {
t.Fatalf("expected 8 reviews, got %d", len(reviews))
}
if callCount != 3 {
t.Errorf("expected 3 API calls, got %d", callCount)
}
// Verify reviews are correctly concatenated in order
for i, r := range reviews {
expectedID := int64(i + 1)
if r.ID != expectedID {
t.Errorf("review[%d]: expected ID %d, got %d", i, expectedID, r.ID)
}
}
}
func TestListReviews_ExactMultipleOfPageSize(t *testing.T) {
// When total reviews is an exact multiple of pageSize, an extra request
// returning 0 results terminates the loop. No truncation warning.
const pageSize = 2
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
page := r.URL.Query().Get("page")
var reviews []map[string]interface{}
switch page {
case "1":
reviews = []map[string]interface{}{
{"id": 1, "body": "r1", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u1"}},
{"id": 2, "body": "r2", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u2"}},
}
case "2":
reviews = []map[string]interface{}{
{"id": 3, "body": "r3", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u3"}},
{"id": 4, "body": "r4", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u4"}},
}
case "3":
// Empty page — signals end of data
reviews = []map[string]interface{}{}
default:
t.Fatalf("unexpected page: %s", page)
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, 10)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 4 {
t.Fatalf("expected 4 reviews, got %d", len(reviews))
}
// 3 calls: page 1 (full), page 2 (full), page 3 (empty)
if callCount != 3 {
t.Errorf("expected 3 API calls, got %d", callCount)
}
}
func TestListReviews_MaxPagesCutoff(t *testing.T) {
// When maxPages is hit and the last page is full, results are truncated
// and a warning would fire (we verify the reviews are still returned).
const pageSize = 2
const maxPages = 2
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
page := r.URL.Query().Get("page")
// Always return a full page (simulating more data exists)
var reviews []map[string]interface{}
var baseID int
switch page {
case "1":
baseID = 0
case "2":
baseID = pageSize
default:
t.Fatalf("unexpected page %s (should not exceed maxPages)", page)
}
for i := 1; i <= pageSize; i++ {
reviews = append(reviews, map[string]interface{}{
"id": baseID + i, "body": fmt.Sprintf("r%d", baseID+i),
"state": "APPROVED", "commit_id": "sha1",
"user": map[string]string{"login": "user"},
})
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, maxPages)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should return all reviews fetched within the cap
expectedCount := pageSize * maxPages
if len(reviews) != expectedCount {
t.Fatalf("expected %d reviews, got %d", expectedCount, len(reviews))
}
if callCount != maxPages {
t.Errorf("expected %d API calls, got %d", maxPages, callCount)
}
// Verify concatenation order
for i, r := range reviews {
if r.ID != int64(i+1) {
t.Errorf("review[%d]: expected ID %d, got %d", i, i+1, r.ID)
}
}
}
func TestListReviews_EmptyFirstPage(t *testing.T) {
// PR with no reviews: first page returns empty array.
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
json.NewEncoder(w).Encode([]map[string]interface{}{})
})
c.SetReviewPagination(10, 5)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 0 {
t.Fatalf("expected 0 reviews, got %d", len(reviews))
}
if callCount != 1 {
t.Errorf("expected 1 API call, got %d", callCount)
}
}
-284
View File
@@ -1,284 +0,0 @@
package github
import (
"context"
"encoding/json"
"errors"
"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
)
// ErrCannotDeleteSubmittedReview is returned when DeleteReview is called on
// a review that has already been submitted (APPROVED, REQUEST_CHANGES, COMMENT).
// GitHub only allows deletion of PENDING reviews. Callers that need to replace
// a submitted review should use DismissReview instead.
var ErrCannotDeleteSubmittedReview = errors.New("cannot delete submitted review: use DismissReview instead")
// ErrConflictingCommitIDs is returned when PostReview receives comments with
// differing non-empty CommitIDs. The GitHub API accepts only a single commit_id
// per review submission; callers must ensure all comments target the same commit.
var ErrConflictingCommitIDs = errors.New("comments contain conflicting commit IDs: all must target the same commit")
// postReviewRequest is the GitHub API request body for creating a review.
type postReviewRequest struct {
CommitID string `json:"commit_id,omitempty"`
Body string `json:"body"`
Event string `json:"event"`
Comments []reviewCommentEntry `json:"comments,omitempty"`
}
// reviewCommentEntry is a single inline comment in a review creation request.
type reviewCommentEntry struct {
Path string `json:"path"`
Position int `json:"position"`
Body string `json:"body"`
}
// reviewResponse is the GitHub API response for a review.
type reviewResponse struct {
ID int64 `json:"id"`
Body string `json:"body"`
State string `json:"state"`
CommitID string `json:"commit_id"`
User struct {
Login string `json:"login"`
} `json:"user"`
}
// dismissReviewRequest is the GitHub API request body for dismissing a review.
type dismissReviewRequest struct {
Message string `json:"message"`
Event string `json:"event"`
}
// userResponse is the GitHub API response for the authenticated user.
type userResponse struct {
Login string `json:"login"`
}
// translateGitHubReviewState translates a GitHub API review state to the
// canonical vcs.Review.State value.
func translateGitHubReviewState(state string) string {
switch state {
case "CHANGES_REQUESTED":
return "REQUEST_CHANGES"
case "COMMENTED":
return "COMMENT"
default:
// States like APPROVED, DISMISSED, and PENDING pass through unchanged
// as they already match the canonical vcs representation. PENDING appears
// on draft reviews that have not yet been submitted via the GitHub UI or API.
return state
}
}
// PostReview submits a review on a pull request.
//
// The vcs.ReviewEvent constants (ReviewEventApprove, ReviewEventRequestChanges,
// ReviewEventComment) have string values that match GitHub's wire-format event
// strings (APPROVE, REQUEST_CHANGES, COMMENT), so Event is cast directly to
// string without translation.
//
// ReviewComment.Position maps directly to the GitHub API position field.
// When req.Comments is empty, the payload omits the comments field entirely
// (via the omitempty tag on postReviewRequest.Comments).
//
// The GitHub API accepts a single commit_id per review submission. PostReview
// uses req.CommitID as the primary commit anchor. If req.CommitID is empty,
// it falls back to extracting from the first comment with a non-empty CommitID.
// If any subsequent comment specifies a different CommitID, PostReview returns
// ErrConflictingCommitIDs. Comments with an empty CommitID are allowed and
// inherit the review-level value.
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 := postReviewRequest{
Body: req.Body,
Event: string(req.Event),
CommitID: req.CommitID,
}
// Build the payload in one pass. The GitHub API accepts a single commit_id
// per review. req.CommitID is the primary source; if empty, we extract from
// the first comment that supplies one. Reject if any comment disagrees with
// the resolved commit_id.
for _, comment := range req.Comments {
if comment.CommitID != "" {
if payload.CommitID == "" { // only reachable when req.CommitID is empty
payload.CommitID = comment.CommitID
} else if payload.CommitID != comment.CommitID {
return nil, ErrConflictingCommitIDs
}
// else: matching SHA is a no-op by design
}
payload.Comments = append(payload.Comments, reviewCommentEntry{
Path: comment.Path,
Position: comment.Position,
Body: comment.Body,
})
}
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: translateGitHubReviewState(resp.State),
CommitID: resp.CommitID,
}, nil
}
// ListReviews retrieves all reviews for a pull request with pagination.
// GitHub review states are translated to canonical vcs values.
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
perPage := reviewsPerPage
if c.reviewPageSize > 0 {
perPage = c.reviewPageSize
}
maxPages := maxReviewPages
if c.reviewMaxPages > 0 {
maxPages = c.reviewMaxPages
}
var allReviews []vcs.Review
truncated := false
for page := 1; page <= maxPages; 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, perPage, page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list reviews page %d: %w", page, err)
}
var responses []reviewResponse
if err := json.Unmarshal(body, &responses); err != nil {
return nil, fmt.Errorf("parse reviews response: %w", err)
}
if len(responses) == 0 {
break
}
for _, r := range responses {
allReviews = append(allReviews, vcs.Review{
ID: r.ID,
Body: r.Body,
User: vcs.UserInfo{Login: r.User.Login},
State: translateGitHubReviewState(r.State),
CommitID: r.CommitID,
})
}
if len(responses) < perPage {
break
}
// Truncation detection: this runs on the final allowed iteration
// (page == maxPages) only when the page was full (the len < perPage
// early-break above didn't fire). A full final page means additional
// reviews likely exist beyond our pagination limit.
if page == maxPages {
truncated = true
}
}
if truncated {
slog.Warn("ListReviews hit page limit; results may be truncated",
"owner", owner, "repo", repo, "pr", number,
"maxPages", maxPages, "reviewsFetched", len(allReviews))
}
return allReviews, nil
}
// DeleteReview deletes a pull request review.
// Only PENDING reviews can be deleted; attempting to delete a submitted review
// (APPROVED, CHANGES_REQUESTED, or COMMENTED per GitHub API naming) returns
// ErrCannotDeleteSubmittedReview.
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)
// nil body: the GitHub DELETE endpoint for reviews requires no request body.
_, err := c.doRequestWithBody(ctx, http.MethodDelete, reqURL, nil)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == 422 {
return fmt.Errorf("delete review: %w", ErrCannotDeleteSubmittedReview)
}
return fmt.Errorf("delete review: %w", err)
}
return nil
}
// DismissReview dismisses a submitted review on a pull request.
// This is the correct way to "remove" a submitted review (APPROVED, REQUEST_CHANGES).
// GitHub does not allow deleting submitted reviews — they must be dismissed.
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,
// Event is required by the GitHub API for dismissal requests, even though
// "DISMISS" is the only valid value for this endpoint.
Event: "DISMISS",
}
_, err := c.doJSONRequest(ctx, http.MethodPut, reqURL, payload)
if err != nil {
return fmt.Errorf("dismiss review: %w", err)
}
return nil
}
// SupersedeReviews marks prior reviews as superseded by dismissing them.
// This implements vcs.ReviewSuperseder for the GitHub adapter.
// The baseURL and sentinel parameters are unused for GitHub (dismissal is the mechanism).
func (c *Client) SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []vcs.Review, newReviewID int64, _, _ string) error {
var errs []error
for _, old := range oldReviews {
if err := c.DismissReview(ctx, owner, repo, prNumber, old.ID, "Superseded by new review"); err != nil {
errs = append(errs, fmt.Errorf("dismiss review %d: %w", old.ID, err))
}
}
return errors.Join(errs...)
}
// 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
}
+12
View File
@@ -10,6 +10,18 @@ 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).
+19
View File
@@ -98,6 +98,25 @@ 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",
-17
View File
@@ -41,20 +41,3 @@ type Client interface {
Reviewer Reviewer
Identity Identity
} }
// ReviewerSelfRequester is an optional interface implemented by adapters that support
// requesting the authenticated user as a reviewer on a pull request. This is used for
// Gitea-specific behavior (ensuring the bot appears in required-reviewer checks).
// Consumers should use interface assertion: if sr, ok := client.(ReviewerSelfRequester); ok { ... }
type ReviewerSelfRequester interface {
RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error
}
// ReviewSuperseder is an optional interface implemented by adapters that support
// marking old reviews as superseded. For Gitea this means editing the review body
// with a link to the new review and resolving inline comments. For GitHub this
// means dismissing old reviews.
// Consumers should use interface assertion: if rs, ok := client.(ReviewSuperseder); ok { ... }
type ReviewSuperseder interface {
SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []Review, newReviewID int64, baseURL, sentinel string) error
}
-26
View File
@@ -1,26 +0,0 @@
package vcs
// VCSProvider identifies a VCS platform. Using a typed string instead of bare
// strings makes provider values compiler-checkable and prevents typos from
// silently passing validation.
type VCSProvider string
const (
ProviderGitea VCSProvider = "gitea"
ProviderGitHub VCSProvider = "github"
)
// Valid reports whether p is a known VCS provider.
func (p VCSProvider) Valid() bool {
switch p {
case ProviderGitea, ProviderGitHub:
return true
default:
return false
}
}
// String returns the string representation of the provider.
func (p VCSProvider) String() string {
return string(p)
}
-5
View File
@@ -94,10 +94,5 @@ type ReviewRequest struct {
Body string `json:"body"` Body string `json:"body"`
// Event is the review action (approve, request changes, or comment). // Event is the review action (approve, request changes, or comment).
Event ReviewEvent `json:"event"` Event ReviewEvent `json:"event"`
// CommitID anchors the review to a specific commit SHA.
// If empty, the platform defaults to the current PR head.
// Adapters use this as the primary commit anchor for the review submission.
CommitID string `json:"commit_id,omitempty"`
Comments []ReviewComment `json:"comments,omitempty"` Comments []ReviewComment `json:"comments,omitempty"`
} }