Compare commits

..

22 Commits

Author SHA1 Message Date
rodin cdd4f4fdf4 Merge pull request 'feat: replace log.Printf with structured slog logging' (#36) from feat/23-structured-logging into main
CI / test (push) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
2026-05-02 18:07:13 +00:00
Rodin d83ea4f726 feat: replace log.Printf with structured slog logging
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 52s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m5s
- Add --log-format flag (text/json) and --verbosity flag (debug/info/warn/error)
- Replace all log.Printf with slog.Info/Debug/Warn with structured key-value attrs
- Replace all log.Fatalf with slog.Error + os.Exit(1)
- Convert gitea/client.go warnings to slog.Warn
- Add comprehensive tests for logger initialization and level filtering

Closes #23
Partially addresses #32
2026-05-02 11:01:55 -07:00
Rodin 6c46220a53 docs: document runner requirements for composite action
CI / test (push) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
Add a Runner Requirements section to the README documenting that
the composite action needs python3, sha256sum, and curl on the
runner. All are pre-installed on ubuntu-* runners but custom
images need to provide them.

Closes #12
2026-05-02 10:21:53 -07:00
rodin d640eb6e71 Merge pull request 'fix: distinguish 404 in GetAllFilesInPath, make uploads idempotent' (#33) from fix/8-10-error-handling-idempotent-upload into main
CI / test (push) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
2026-05-02 17:07:22 +00:00
Rodin 2339999d37 fix: URL-encode asset filename, truncate error body in APIError
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 51s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m21s
- URL-encode filename in release upload query param (MINOR)
- Truncate APIError.Body to 200 chars in Error() to avoid leaking
  verbose server responses into logs (NIT)
2026-05-02 10:02:03 -07:00
Rodin bfca28b2b2 fix: address review findings from PR #33
CI / test (pull_request) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 22s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m9s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m42s
- Wrap fileErr instead of err in GetAllFilesInPath fallback (MINOR)
- Use env var for asset name in release workflow to avoid quoting issues (NIT)
2026-05-02 09:58:41 -07:00
Rodin f047c994bf fix: distinguish 404 in GetAllFilesInPath, make uploads idempotent
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 22s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 58s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m7s
- Add APIError type with StatusCode field so callers can inspect HTTP
  status codes from Gitea API responses
- Add IsNotFound helper for ergonomic 404 checks
- GetAllFilesInPath now only falls back to single-file fetch on 404;
  all other errors (auth failures, server errors, rate limits) propagate
- Release workflow asset uploads are now idempotent: existing assets
  with the same name are deleted before re-upload on workflow re-runs

Closes #8
Closes #10
2026-05-02 09:50:35 -07:00
rodin b51a19d8b9 Merge pull request 'fix: remove worst-wins escalation logic' (#31) from fix/28-remove-escalation into main
CI / test (push) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
2026-05-02 16:46:05 +00:00
Rodin ceefa4c2e0 ci: use separate SECURITY_REVIEW_TOKEN for security reviewer
CI / test (pull_request) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 41s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 58s
The security-review-bot Gitea user now has its own token. This
completes the token separation so each reviewer role posts under
its own identity, enabling native Gitea multi-reviewer blocking.
2026-05-02 07:25:43 -07:00
Rodin b1f5dd4b5f fix: skip update-in-place when shared token detected
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 22s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m21s
When hasSharedToken() detects two roles sharing the same Gitea user,
the bot now skips ALL update logic (PATCH, supersede) and always POSTs
a fresh review. This prevents clobbering a sibling's review body or
state when misconfigured.

Tests now assert return values (true/false) rather than just verifying
no panic. Added additional test case for three-roles-same-user scenario.

Addresses review feedback: update logic and review state must not
interact with sibling reviews under the same user.
2026-05-02 07:21:46 -07:00
Rodin fd179b891b fix: detect shared-token misconfiguration and warn
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 24s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 42s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 55s
When two review-bot roles share the same Gitea user token (misconfiguration),
log a WARNING identifying which sibling is sharing. The bot continues normally
with its own honest verdict — no escalation, no deadlock. Operators see the
warning in CI logs and can fix the token setup.

Addresses Aaron's review feedback on #28: graceful degradation when someone
doesn't follow the separate-token deployment instructions.
2026-05-02 07:11:57 -07:00
Rodin b78d9972ac fix: remove worst-wins escalation logic (#28)
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 36s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
2026-05-02 07:04:33 -07:00
rodin 3c785c5502 Merge pull request 'fix: consistent url.PathEscape across all Gitea client endpoints' (#30) from fix/consistent-path-escape into main
CI / test (push) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
2026-05-02 14:01:53 +00:00
Rodin c2595d0263 fix: consistent url.PathEscape across all Gitea client endpoints
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 21s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 34s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 35s
Apply url.PathEscape to owner, repo, and sha path segments in all
methods that were previously interpolating raw values. Methods already
using PathEscape (ListReviews, DeleteReview, GetTimelineReviewCommentID,
EditComment) are unchanged.

This eliminates an inconsistency flagged in PRs #17, #20, and #22 and
prevents potential path-injection bugs for names with special characters.

Closes #24
2026-05-02 02:41:51 -07:00
rodin d80d6a23a2 Merge pull request 'feat: inline review comments on specific lines' (#26) from feat/inline-review-comments into main
CI / test (push) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
Release / release (push) Successful in 31s
2026-05-02 06:13:02 +00:00
Rodin a9c8ecfb0b docs: add review update strategy with state transition diagram
CI / test (pull_request) Successful in 12s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 24s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m17s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m22s
Explains the edit-in-place approach, state transition rules, worst-wins
escalation, and inline comment lifecycle. Includes a Mermaid state
diagram for visual reference.
2026-05-01 23:01:32 -07:00
Rodin ec19622133 fix: address review findings (escalation, marshal error, redundant check)
CI / test (pull_request) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m11s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m21s
1. First-run escalation regression (MAJOR): Add post-posting escalation
   fallback. After posting APPROVED on first run, check if a sibling
   from the same user has REQUEST_CHANGES — if so, mark ours as
   superseded and re-post as REQUEST_CHANGES.

2. json.Marshal error handling (MINOR): Return error from EditComment
   instead of ignoring it with blank identifier.

3. Redundant condition (NIT): Remove dead assignment in reviewUnchanged
   where existingEvent was assigned from r.State then compared to itself.
2026-05-01 22:50:13 -07:00
Rodin e261976dd8 feat: edit-in-place review updates (no more delete)
CI / test (pull_request) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m37s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m43s
Replace the delete-and-repost strategy with edit-in-place:

1. No existing review → POST new (first run)
2. Same state, same body → skip entirely (threads preserved)
3. Same state, body changed → PATCH body in place via timeline API
4. State change needed → PATCH old body to "Superseded", POST new

This preserves conversation threads on inline comments. Replies to
findings are never lost. The only time a new review is posted is on
first run or when the state transitions (APPROVED ↔ REQUEST_CHANGES).

New Gitea client methods:
- EditComment: PATCH /repos/{owner}/{repo}/issues/comments/{id}
- GetTimelineReviewCommentID: finds the comment ID for a review body
  by scanning the issue timeline for the sentinel

Also simplifies shouldEscalate: removes the login parameter requirement
for pre-posting scenarios (uses findOwnReview to get login from existing
review instead).

Tests: findOwnReview (4 cases), EditComment (2 cases),
GetTimelineReviewCommentID (2 cases), shouldEscalate (8 cases updated).
2026-05-01 22:46:45 -07:00
Rodin 1c2292265b feat: skip re-posting when review is unchanged (preserve threads)
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m1s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m28s
Before posting, compare the new review body+event against the existing
review with the same sentinel. If identical, skip entirely — this
preserves conversation threads on inline comments and avoids
re-notifying reviewers for findings they already know about.

Only re-posts when findings actually change (fixed, new, or different).

Tests: 6 cases covering identical, different body, different state,
stale reviews, and different sentinels.
2026-05-01 22:17:36 -07:00
Rodin b0dc6d0c09 fix: handle single-line hunks and no-newline markers in diff parser
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 55s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m42s
- Hunk headers without comma ("@@ -1 +1 @@") now parse correctly by
  splitting on comma OR space instead of comma only
- Explicit skip for "\ No newline at end of file" lines (was already
  safe but now documents intent)
- Tests added for both edge cases (TDD: tests written first, confirmed
  failure, then fixed)

Addresses sonnet findings #1 and #2 from PR #26 review.
2026-05-01 22:10:49 -07:00
Rodin 2ac7f55396 feat: inline review comments on specific lines
CI / test (pull_request) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 23s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (pull_request) Successful in 43s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m29s
Findings that reference a file+line within the diff are now posted as
inline comments directly on that line, in addition to appearing in the
summary table. Findings outside the diff range stay in the body only.

Implementation:
- gitea/diff.go: ParseDiffNewLines extracts new-file line numbers from
  each hunk in the unified diff
- gitea/client.go: PostReview accepts optional []ReviewComment with
  path + new_position + body (omitempty when nil)
- cmd/review-bot/main.go: maps findings → inline comments when the line
  exists in the diff, passes them to PostReview

Tests:
- diff parser: multi-hunk, new files, empty diff, boundary lines
- PostReview: with comments, nil comments (omitted from payload)
2026-05-01 21:59:21 -07:00
aweiker 177d56f218 Merge pull request 'feat: delete previous review before posting new one (#6)' (#22) from feat/6-update-existing-review into main
CI / test (push) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
Reviewed-on: #22
2026-05-02 04:50:16 +00:00
11 changed files with 1229 additions and 164 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
token_secret: GPT_REVIEW_TOKEN token_secret: GPT_REVIEW_TOKEN
model: gpt-4.1 model: gpt-4.1
- name: security - name: security
token_secret: SONNET_REVIEW_TOKEN token_secret: SECURITY_REVIEW_TOKEN
model: gpt-5 model: gpt-5
system_prompt_file: SECURITY_REVIEW.md system_prompt_file: SECURITY_REVIEW.md
steps: steps:
+16 -2
View File
@@ -69,14 +69,28 @@ jobs:
echo "Release ID: ${RELEASE_ID}" echo "Release ID: ${RELEASE_ID}"
# Upload each asset # Upload each asset (idempotent: delete existing asset with same name first)
for file in dist/*; do for file in dist/*; do
filename=$(basename "$file") filename=$(basename "$file")
echo "Uploading ${filename}..." echo "Uploading ${filename}..."
# Check if asset already exists and delete it
EXISTING_ID=$(export ASSET_NAME="${filename}"; curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys,os; name=os.environ['ASSET_NAME']; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']==name),''))" 2>/dev/null)
if [ -n "$EXISTING_ID" ]; then
echo " Asset ${filename} already exists (id=${EXISTING_ID}), deleting..."
curl -sSf -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
curl -sSf -X POST \ curl -sSf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$(printf '%s' "${filename}" | jq -sRr @uri)" \
--data-binary "@${file}" --data-binary "@${file}"
done done
+12
View File
@@ -188,6 +188,18 @@ Prints the review to CI logs without posting to the PR. Useful for testing promp
| `update-existing` | No | `true` | Delete previous review from same bot before posting. Accepts: true/1/yes or false/0/no | | `update-existing` | No | `true` | Delete previous review from same bot before posting. Accepts: true/1/yes or false/0/no |
| `version` | No | `latest` | review-bot version to install | | `version` | No | `latest` | review-bot version to install |
## Runner Requirements
The composite action requires these tools on the runner:
| Tool | Used For |
|------|----------|
| `python3` | JSON parsing during version detection |
| `sha256sum` | Checksum verification of downloaded binary |
| `curl` | Downloading releases and querying the API |
All three are pre-installed on `ubuntu-*` runners (e.g. `ubuntu-24.04`). If you use a custom runner image, ensure these are available.
## How Review Cleanup Works ## How Review Cleanup Works
When `reviewer-name` is set, the bot embeds a hidden sentinel in each review: When `reviewer-name` is set, the bot embeds a hidden sentinel in each review:
+214 -69
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -19,8 +19,40 @@ import (
var version = "dev" var version = "dev"
// setupLogger configures the global slog default logger based on format and verbosity.
func setupLogger(format, verbosity string) {
var level slog.Level
switch strings.ToLower(verbosity) {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
default:
level = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: level}
var handler slog.Handler
switch strings.ToLower(format) {
case "json":
handler = slog.NewJSONHandler(os.Stderr, opts)
default:
handler = slog.NewTextHandler(os.Stderr, opts)
}
slog.SetDefault(slog.New(handler))
}
func main() { func main() {
versionFlag := flag.Bool("version", false, "Print version and exit") versionFlag := flag.Bool("version", false, "Print version and exit")
// Logging flags
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")
// CLI flags // CLI flags
giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL") giteaURL := flag.String("gitea-url", envOrDefault("GITEA_URL", ""), "Gitea instance URL")
repo := flag.String("repo", envOrDefault("GITEA_REPO", ""), "Repository (owner/name)") repo := flag.String("repo", envOrDefault("GITEA_REPO", ""), "Repository (owner/name)")
@@ -47,7 +79,10 @@ func main() {
os.Exit(0) os.Exit(0)
} }
log.Printf("review-bot %s", version) // Initialize structured logger
setupLogger(*logFormat, *verbosity)
slog.Info("review-bot starting", "version", version)
// Validate required fields // Validate required fields
if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" || if *giteaURL == "" || *repo == "" || *prNum == "" || *reviewerToken == "" ||
@@ -59,27 +94,31 @@ func main() {
// 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 {
log.Fatalf("%v", err) slog.Error("invalid reviewer name", "error", err)
os.Exit(1)
} }
// Parse repo owner/name // Parse repo owner/name
parts := strings.SplitN(*repo, "/", 2) parts := strings.SplitN(*repo, "/", 2)
if len(parts) != 2 { if len(parts) != 2 {
log.Fatalf("Invalid repo format %q, expected owner/name", *repo) slog.Error("invalid repo format", "repo", *repo, "expected", "owner/name")
os.Exit(1)
} }
owner, repoName := parts[0], parts[1] owner, repoName := parts[0], parts[1]
// Parse PR number // Parse PR number
prNumber, err := strconv.Atoi(*prNum) prNumber, err := strconv.Atoi(*prNum)
if err != nil { if err != nil {
log.Fatalf("Invalid PR number %q: %v", *prNum, err) slog.Error("invalid PR number", "pr", *prNum, "error", err)
os.Exit(1)
} }
// Initialize clients // Initialize clients
giteaClient := gitea.NewClient(*giteaURL, *reviewerToken) giteaClient := gitea.NewClient(*giteaURL, *reviewerToken)
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel) llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
if *llmTemp < 0 || *llmTemp > 2 { if *llmTemp < 0 || *llmTemp > 2 {
log.Fatal("--llm-temperature must be between 0 and 2") slog.Error("invalid LLM temperature", "temperature", *llmTemp, "range", "0-2")
os.Exit(1)
} }
if *llmTemp > 0 { if *llmTemp > 0 {
llmClient.WithTemperature(*llmTemp) llmClient.WithTemperature(*llmTemp)
@@ -88,7 +127,8 @@ func main() {
case llm.ProviderOpenAI, llm.ProviderAnthropic: case llm.ProviderOpenAI, llm.ProviderAnthropic:
llmClient.WithProvider(llm.Provider(*llmProvider)) llmClient.WithProvider(llm.Provider(*llmProvider))
default: default:
log.Fatalf("Invalid --llm-provider %q, must be openai or anthropic", *llmProvider) slog.Error("invalid LLM provider", "provider", *llmProvider, "valid", "openai, anthropic")
os.Exit(1)
} }
if *llmTimeout > 0 { if *llmTimeout > 0 {
llmClient.WithTimeout(time.Duration(*llmTimeout) * time.Second) llmClient.WithTimeout(time.Duration(*llmTimeout) * time.Second)
@@ -99,30 +139,32 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), overallTimeout) ctx, cancel := context.WithTimeout(context.Background(), overallTimeout)
defer cancel() defer cancel()
log.Printf("Reviewing PR #%d on %s/%s", prNumber, owner, repoName) slog.Info("reviewing pull request", "pr", prNumber, "repo", fmt.Sprintf("%s/%s", owner, repoName))
// Step 1: Fetch PR metadata // Step 1: Fetch PR metadata
pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber) pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
log.Fatalf("Failed to fetch PR: %v", err) slog.Error("failed to fetch PR", "pr", prNumber, "error", err)
os.Exit(1)
} }
log.Printf("PR: %s", pr.Title) slog.Info("fetched PR metadata", "pr", prNumber, "title", pr.Title)
// Step 2: Fetch diff // Step 2: Fetch diff
diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber) diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
log.Fatalf("Failed to fetch diff: %v", err) slog.Error("failed to fetch diff", "pr", prNumber, "error", err)
os.Exit(1)
} }
log.Printf("Diff size: %d bytes", len(diff)) slog.Info("fetched diff", "bytes", len(diff))
// Step 3: Fetch full file content for modified files // Step 3: Fetch full file content for modified files
fileContext := "" fileContext := ""
files, err := giteaClient.GetPullRequestFiles(ctx, owner, repoName, prNumber) files, err := giteaClient.GetPullRequestFiles(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
log.Printf("Warning: could not fetch PR files list: %v", err) slog.Warn("could not fetch PR files list", "pr", prNumber, "error", err)
} else { } else {
fileContext = fetchFileContext(ctx, giteaClient, owner, repoName, pr.Head.Ref, files) fileContext = fetchFileContext(ctx, giteaClient, owner, repoName, pr.Head.Ref, files)
log.Printf("Fetched full context for %d files", len(files)) slog.Debug("fetched file context", "files", len(files))
} }
// Step 4: Check CI status // Step 4: Check CI status
@@ -131,10 +173,10 @@ func main() {
if pr.Head.Sha != "" { if pr.Head.Sha != "" {
statuses, err := giteaClient.GetCommitStatuses(ctx, owner, repoName, pr.Head.Sha) statuses, err := giteaClient.GetCommitStatuses(ctx, owner, repoName, pr.Head.Sha)
if err != nil { if err != nil {
log.Printf("Warning: could not fetch CI status: %v", err) slog.Warn("could not fetch CI status", "sha", pr.Head.Sha, "error", err)
} else { } else {
ciPassed, ciDetails = evaluateCIStatus(statuses) ciPassed, ciDetails = evaluateCIStatus(statuses)
log.Printf("CI status: passed=%v", ciPassed) slog.Info("CI status checked", "passed", ciPassed)
} }
} }
@@ -143,10 +185,10 @@ func main() {
if *conventionsFile != "" { if *conventionsFile != "" {
content, err := giteaClient.GetFileContent(ctx, owner, repoName, *conventionsFile) content, err := giteaClient.GetFileContent(ctx, owner, repoName, *conventionsFile)
if err != nil { if err != nil {
log.Printf("Warning: could not load conventions file %q: %v", *conventionsFile, err) slog.Warn("could not load conventions file", "file", *conventionsFile, "error", err)
} else { } else {
conventions = content conventions = content
log.Printf("Loaded conventions file: %s (%d bytes)", *conventionsFile, len(conventions)) slog.Debug("loaded conventions file", "file", *conventionsFile, "bytes", len(conventions))
} }
} }
@@ -154,7 +196,7 @@ func main() {
patterns := "" patterns := ""
if *patternsRepo != "" { if *patternsRepo != "" {
patterns = fetchPatterns(ctx, giteaClient, *patternsRepo, *patternsFiles) patterns = fetchPatterns(ctx, giteaClient, *patternsRepo, *patternsFiles)
log.Printf("Loaded patterns from %s (%d bytes)", *patternsRepo, len(patterns)) slog.Debug("loaded patterns", "repo", *patternsRepo, "bytes", len(patterns))
} }
// Step 6b: Load additional system prompt if specified // Step 6b: Load additional system prompt if specified
@@ -166,27 +208,32 @@ func main() {
} }
absWorkspace, err := filepath.Abs(workspace) absWorkspace, err := filepath.Abs(workspace)
if err != nil { if err != nil {
log.Fatalf("Failed to resolve workspace path: %v", err) slog.Error("failed to resolve workspace path", "error", err)
os.Exit(1)
} }
promptPath := filepath.Join(absWorkspace, *systemPromptFile) promptPath := filepath.Join(absWorkspace, *systemPromptFile)
promptPath = filepath.Clean(promptPath) promptPath = filepath.Clean(promptPath)
if !strings.HasPrefix(promptPath, absWorkspace+string(filepath.Separator)) && promptPath != absWorkspace { if !strings.HasPrefix(promptPath, absWorkspace+string(filepath.Separator)) && promptPath != absWorkspace {
log.Fatalf("system-prompt-file resolves outside workspace (got %q, workspace %q)", promptPath, absWorkspace) slog.Error("system-prompt-file resolves outside workspace", "path", promptPath, "workspace", absWorkspace)
os.Exit(1)
} }
// Resolve symlinks and re-validate to prevent symlink traversal // Resolve symlinks and re-validate to prevent symlink traversal
resolvedPath, err := filepath.EvalSymlinks(promptPath) resolvedPath, err := filepath.EvalSymlinks(promptPath)
if err != nil { if err != nil {
log.Fatalf("Failed to resolve system prompt file %q: %v", promptPath, err) slog.Error("failed to resolve system prompt file", "path", promptPath, "error", err)
os.Exit(1)
} }
if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace { if !strings.HasPrefix(resolvedPath, absWorkspace+string(filepath.Separator)) && resolvedPath != absWorkspace {
log.Fatalf("system-prompt-file symlink resolves outside workspace (got %q, workspace %q)", resolvedPath, absWorkspace) slog.Error("system-prompt-file symlink resolves outside workspace", "resolved", resolvedPath, "workspace", absWorkspace)
os.Exit(1)
} }
data, err := os.ReadFile(resolvedPath) data, err := os.ReadFile(resolvedPath)
if err != nil { if err != nil {
log.Fatalf("Failed to read system prompt file %q: %v", promptPath, err) slog.Error("failed to read system prompt file", "path", promptPath, "error", err)
os.Exit(1)
} }
additionalPrompt = string(data) additionalPrompt = string(data)
log.Printf("Loaded system prompt file: %s (%d bytes)", *systemPromptFile, len(additionalPrompt)) slog.Debug("loaded system prompt file", "file", *systemPromptFile, "bytes", len(additionalPrompt))
} }
// Step 7: Budget-aware prompt assembly // Step 7: Budget-aware prompt assembly
@@ -203,13 +250,13 @@ func main() {
UserMeta: review.BuildUserMeta(pr.Title, pr.Body, ciPassed, ciDetails), UserMeta: review.BuildUserMeta(pr.Title, pr.Body, ciPassed, ciDetails),
} }
budgetResult := budget.Fit(*llmModel, sections) budgetResult := budget.Fit(*llmModel, sections)
log.Printf("Token estimate: ~%dK (limit: %dK)", budgetResult.EstTokens/1000, budget.LimitForModel(*llmModel)/1000) slog.Info("token budget calculated", "tokens", budgetResult.EstTokens, "limit", budget.LimitForModel(*llmModel), "model", *llmModel)
if len(budgetResult.Trimmed) > 0 { if len(budgetResult.Trimmed) > 0 {
log.Printf("Context trimmed: %v", budgetResult.Trimmed) slog.Warn("context trimmed to fit budget", "trimmed", budgetResult.Trimmed)
} }
// Step 8: Call LLM // Step 8: Call LLM
log.Printf("Sending to LLM (%s)...", *llmModel) slog.Info("sending request to LLM", "model", *llmModel)
messages := []llm.Message{ messages := []llm.Message{
{Role: "system", Content: budgetResult.SystemPrompt}, {Role: "system", Content: budgetResult.SystemPrompt},
{Role: "user", Content: budgetResult.UserPrompt}, {Role: "user", Content: budgetResult.UserPrompt},
@@ -217,16 +264,18 @@ func main() {
response, err := llmClient.Complete(ctx, messages) response, err := llmClient.Complete(ctx, messages)
if err != nil { if err != nil {
log.Fatalf("LLM request failed: %v", err) slog.Error("LLM request failed", "model", *llmModel, "error", err)
os.Exit(1)
} }
log.Printf("LLM response received (%d bytes)", len(response)) slog.Info("LLM response received", "bytes", len(response))
// Step 9: Parse response // Step 9: Parse response
result, err := review.ParseResponse(response) result, err := review.ParseResponse(response)
if err != nil { if err != nil {
log.Fatalf("Failed to parse LLM response: %v", err) slog.Error("failed to parse LLM response", "error", err)
os.Exit(1)
} }
log.Printf("Verdict: %s (%d findings)", result.Verdict, len(result.Findings)) slog.Info("review parsed", "verdict", result.Verdict, "findings", len(result.Findings))
// Step 10: Format and post review // Step 10: Format and post review
reviewBody := review.FormatMarkdown(result, *reviewerName) reviewBody := review.FormatMarkdown(result, *reviewerName)
@@ -241,47 +290,86 @@ func main() {
sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName) sentinel := fmt.Sprintf("<!-- review-bot:%s -->", *reviewerName)
log.Printf("Posting review (event=%s)...", event) // Map findings to inline comments for lines present in the diff
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody) diffRanges := gitea.ParseDiffNewLines(diff)
if err != nil { var inlineComments []gitea.ReviewComment
log.Fatalf("Failed to post review: %v", err) for _, f := range result.Findings {
if f.File != "" && f.Line > 0 && diffRanges.Contains(f.File, f.Line) {
inlineComments = append(inlineComments, gitea.ReviewComment{
Path: f.File,
NewPosition: int64(f.Line),
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
})
}
}
if len(inlineComments) > 0 {
slog.Debug("attaching inline comments", "count", len(inlineComments))
} }
log.Printf("Review posted (id=%d, user=%s)", posted.ID, posted.User.Login)
// Delete stale reviews from this bot using sentinel matching // --- Review update strategy ---
// 1. No existing review → POST new
// 2. Existing review, same state → PATCH body in place (preserves threads)
// 3. Existing review, state change → PATCH old to "Superseded", POST new
if *updateExisting && *reviewerName != "" { if *updateExisting && *reviewerName != "" {
reviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber) existingReviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
if err != nil { if err != nil {
log.Printf("Warning: could not list existing reviews: %v", err) slog.Warn("could not list existing reviews", "pr", prNumber, "error", err)
} else { } else {
for _, r := range reviews { // Detect shared-token misconfiguration: if detected, skip all
if r.ID != posted.ID && r.User.Login == posted.User.Login && strings.Contains(r.Body, sentinel) { // update logic (PATCH/supersede) to avoid clobbering a sibling's review.
if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, r.ID); err != nil { sharedToken := hasSharedToken(existingReviews, sentinel)
log.Printf("Warning: could not delete old review %d: %v", r.ID, err) if sharedToken {
slog.Warn("shared token mode: skipping update-in-place logic to avoid clobbering sibling review")
} else { } else {
log.Printf("Deleted stale review %d", r.ID) existing := findOwnReview(existingReviews, sentinel)
if existing != nil {
if reviewUnchanged(existingReviews, reviewBody, event, sentinel) {
slog.Info("review unchanged from previous run; skipping to preserve threads", "pr", prNumber)
return
}
// Same state → PATCH in place
if existing.State == event {
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
if err != nil {
slog.Warn("could not find review comment ID, falling back to new post", "error", err)
} else {
if err := giteaClient.EditComment(ctx, owner, repoName, commentID, reviewBody); err != nil {
slog.Warn("could not edit review, falling back to new post", "comment_id", commentID, "error", err)
} else {
slog.Info("review updated in place", "comment_id", commentID, "pr", prNumber)
return
}
}
} else {
// State change → mark old as superseded, post new below
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
if err != nil {
slog.Warn("could not find old review comment ID", "error", err)
} else {
supersededBody := fmt.Sprintf("~~*This review has been superseded by a newer review below.*~~\n\n%s", sentinel)
if err := giteaClient.EditComment(ctx, owner, repoName, commentID, supersededBody); err != nil {
slog.Warn("could not mark old review as superseded", "comment_id", commentID, "error", err)
} else {
slog.Info("marked old review as superseded", "old_state", existing.State, "new_state", event, "pr", prNumber)
}
}
}
}
} }
} }
} }
// Worst-wins: if we posted APPROVE but a sibling review from the // POST new review (first run, or state transition fallthrough)
// same user (same token, different role) has REQUEST_CHANGES, slog.Info("posting review", "event", event, "pr", prNumber)
// delete ours and re-post as REQUEST_CHANGES to maintain the block. posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
if event == "APPROVED" && shouldEscalate(reviews, posted.ID, posted.User.Login, sentinel) {
log.Printf("Sibling review has REQUEST_CHANGES; escalating")
if err := giteaClient.DeleteReview(ctx, owner, repoName, prNumber, posted.ID); err != nil {
log.Printf("Warning: could not delete review for escalation: %v", err)
} else {
_, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody)
if err != nil { if err != nil {
log.Printf("Warning: could not re-post as REQUEST_CHANGES: %v", err) slog.Error("failed to post review", "pr", prNumber, "event", event, "error", err)
} else { os.Exit(1)
log.Printf("Review escalated to REQUEST_CHANGES")
}
}
}
}
} }
slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
} }
// fetchFileContext fetches the full content of modified files from the PR branch. // fetchFileContext fetches the full content of modified files from the PR branch.
@@ -296,7 +384,7 @@ func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, re
} }
content, err := client.GetFileContentRef(ctx, owner, repo, f.Filename, ref) content, err := client.GetFileContentRef(ctx, owner, repo, f.Filename, ref)
if err != nil { if err != nil {
log.Printf("Warning: could not fetch %s: %v", f.Filename, err) slog.Warn("could not fetch file content", "file", f.Filename, "error", err)
continue continue
} }
sb.WriteString(fmt.Sprintf("--- %s ---\n", f.Filename)) sb.WriteString(fmt.Sprintf("--- %s ---\n", f.Filename))
@@ -327,7 +415,7 @@ func fetchPatterns(ctx context.Context, client *gitea.Client, patternsRepo, patt
} }
parts := strings.SplitN(repoRef, "/", 2) parts := strings.SplitN(repoRef, "/", 2)
if len(parts) != 2 { if len(parts) != 2 {
log.Printf("Warning: invalid patterns-repo format %q, expected owner/name", repoRef) slog.Warn("invalid patterns-repo format", "repo", repoRef, "expected", "owner/name")
continue continue
} }
owner, repo := parts[0], parts[1] owner, repo := parts[0], parts[1]
@@ -340,7 +428,7 @@ func fetchPatterns(ctx context.Context, client *gitea.Client, patternsRepo, patt
files, err := client.GetAllFilesInPath(ctx, owner, repo, path) files, err := client.GetAllFilesInPath(ctx, owner, repo, path)
if err != nil { if err != nil {
log.Printf("Warning: could not fetch %s from %s: %v", path, repoRef, err) slog.Warn("could not fetch patterns", "path", path, "repo", repoRef, "error", err)
continue continue
} }
@@ -438,14 +526,71 @@ func validateReviewerName(name string) error {
return nil return nil
} }
// shouldEscalate checks if the current APPROVED review should be escalated // reviewUnchanged checks if an existing review with the same sentinel
// to REQUEST_CHANGES because a sibling bot review (same user, different role) // already has identical body and state. Returns true if a re-post would
// already has REQUEST_CHANGES. // produce the same result (skip to preserve conversation threads).
func shouldEscalate(reviews []gitea.Review, postedID int64, postedLogin, ownSentinel string) bool { func reviewUnchanged(reviews []gitea.Review, newBody, newEvent, sentinel string) bool {
for _, r := range reviews { for _, r := range reviews {
if r.ID != postedID && !r.Stale && r.User.Login == postedLogin && r.State == "REQUEST_CHANGES" && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) { if r.Stale {
continue
}
if !strings.Contains(r.Body, sentinel) {
continue
}
if r.State == newEvent && r.Body == newBody {
return true return true
} }
} }
return false return false
} }
// hasSharedToken detects if another review-bot role posted under the same
// Gitea user. This indicates misconfiguration where two roles share a token
// instead of having separate Gitea accounts. Returns true if shared token
// detected (caller should skip update-in-place logic to avoid clobbering).
func hasSharedToken(reviews []gitea.Review, ownSentinel string) bool {
ownLogin := ""
for _, r := range reviews {
if strings.Contains(r.Body, ownSentinel) {
ownLogin = r.User.Login
break
}
}
if ownLogin == "" {
return false
}
for _, r := range reviews {
if r.User.Login == ownLogin && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
slog.Warn("shared token detected — another review-bot role is using the same Gitea user",
"sibling_role", extractSentinelName(r.Body), "user", ownLogin)
return true
}
}
return false
}
// extractSentinelName pulls the reviewer name from a sentinel comment.
func extractSentinelName(body string) string {
const prefix = "<!-- review-bot:"
const suffix = " -->"
idx := strings.Index(body, prefix)
if idx < 0 {
return "unknown"
}
rest := body[idx+len(prefix):]
end := strings.Index(rest, suffix)
if end < 0 {
return "unknown"
}
return rest[:end]
}
// findOwnReview locates a review matching the given sentinel in its body.
func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review {
for i := range reviews {
if strings.Contains(reviews[i].Body, sentinel) {
return &reviews[i]
}
}
return nil
}
+279 -62
View File
@@ -1,6 +1,9 @@
package main package main
import ( import (
"bytes"
"log/slog"
"strings"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/gitea" "gitea.weiker.me/rodin/review-bot/gitea"
@@ -50,101 +53,315 @@ func makeReview(id int64, login, state string, stale bool, body string) gitea.Re
return r return r
} }
func TestShouldEscalate(t *testing.T) { func TestReviewUnchanged(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
reviews []gitea.Review existing []gitea.Review
postedID int64 newBody string
postedLogin string newEvent string
ownSentinel string sentinel string
want bool want bool
}{ }{
{ {
name: "no reviews", name: "no existing review",
reviews: nil, existing: nil,
postedID: 100, newBody: "new review",
postedLogin: "bot", newEvent: "APPROVED",
ownSentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: false, want: false,
}, },
{ {
name: "sibling same user has REQUEST_CHANGES", name: "identical body and state",
reviews: []gitea.Review{ existing: []gitea.Review{
makeReview(101, "bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:security -->"), makeReview(100, "bot", "APPROVED", false, "same body\n<!-- review-bot:sonnet -->"),
}, },
postedID: 100, newBody: "same body\n<!-- review-bot:sonnet -->",
postedLogin: "bot", newEvent: "APPROVED",
ownSentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: true, want: true,
}, },
{ {
name: "sibling different user has REQUEST_CHANGES (should NOT escalate)", name: "same body but different state",
reviews: []gitea.Review{ existing: []gitea.Review{
makeReview(101, "other-bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:gpt -->"), makeReview(100, "bot", "APPROVED", false, "body\n<!-- review-bot:sonnet -->"),
}, },
postedID: 100, newBody: "body\n<!-- review-bot:sonnet -->",
postedLogin: "bot", newEvent: "REQUEST_CHANGES",
ownSentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: false, want: false,
}, },
{ {
name: "same user REQUEST_CHANGES but stale (should NOT escalate)", name: "different body same state",
reviews: []gitea.Review{ existing: []gitea.Review{
makeReview(101, "bot", "REQUEST_CHANGES", true, "old\n<!-- review-bot:security -->"), makeReview(100, "bot", "APPROVED", false, "old body\n<!-- review-bot:sonnet -->"),
}, },
postedID: 100, newBody: "new body\n<!-- review-bot:sonnet -->",
postedLogin: "bot", newEvent: "APPROVED",
ownSentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: false, want: false,
}, },
{ {
name: "same user same sentinel (own stale review, should NOT escalate)", name: "stale review with same body (should still post)",
reviews: []gitea.Review{ existing: []gitea.Review{
makeReview(101, "bot", "REQUEST_CHANGES", false, "old\n<!-- review-bot:sonnet -->"), makeReview(100, "bot", "APPROVED", true, "same\n<!-- review-bot:sonnet -->"),
}, },
postedID: 100, newBody: "same\n<!-- review-bot:sonnet -->",
postedLogin: "bot", newEvent: "APPROVED",
ownSentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: false, want: false,
}, },
{ {
name: "same user APPROVED sibling (should NOT escalate)", name: "different sentinel (not our review)",
reviews: []gitea.Review{ existing: []gitea.Review{
makeReview(101, "bot", "APPROVED", false, "good\n<!-- review-bot:security -->"), makeReview(100, "bot", "APPROVED", false, "body\n<!-- review-bot:gpt -->"),
}, },
postedID: 100, newBody: "body\n<!-- review-bot:sonnet -->",
postedLogin: "bot", newEvent: "APPROVED",
ownSentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "human REQUEST_CHANGES no sentinel (should NOT escalate)",
reviews: []gitea.Review{
makeReview(101, "bot", "REQUEST_CHANGES", false, "please fix this"),
},
postedID: 100,
postedLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "skip own posted ID",
reviews: []gitea.Review{
makeReview(100, "bot", "REQUEST_CHANGES", false, "x\n<!-- review-bot:security -->"),
},
postedID: 100,
postedLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false, want: false,
}, },
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
got := shouldEscalate(tc.reviews, tc.postedID, tc.postedLogin, tc.ownSentinel) got := reviewUnchanged(tc.existing, tc.newBody, tc.newEvent, tc.sentinel)
if got != tc.want { if got != tc.want {
t.Errorf("shouldEscalate() = %v, want %v", got, tc.want) t.Errorf("reviewUnchanged() = %v, want %v", got, tc.want)
} }
}) })
} }
} }
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,
},
}
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) {
tests := []struct {
name string
reviews []gitea.Review
sentinel string
want bool
}{
{
name: "no reviews",
reviews: nil,
sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "no own review yet - cannot detect",
reviews: []gitea.Review{
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "other"}, Body: "<!-- review-bot:gpt --> body"},
},
sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "separate users - no shared token",
reviews: []gitea.Review{
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "security-review-bot"}, Body: "<!-- review-bot:security --> body"},
},
sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "shared token detected - same user different sentinels",
reviews: []gitea.Review{
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:security --> body"},
},
sentinel: "<!-- review-bot:sonnet -->",
want: true,
},
{
name: "three roles same user",
reviews: []gitea.Review{
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:sonnet --> body"},
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:security --> body"},
{ID: 3, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:gpt --> body"},
},
sentinel: "<!-- review-bot:sonnet -->",
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := hasSharedToken(tc.reviews, tc.sentinel)
if got != tc.want {
t.Errorf("hasSharedToken() = %v, want %v", got, tc.want)
}
})
}
}
func TestExtractSentinelName(t *testing.T) {
tests := []struct {
body string
want string
}{
{"<!-- review-bot:sonnet --> rest", "sonnet"},
{"<!-- review-bot:security --> rest", "security"},
{"no sentinel here", "unknown"},
{"<!-- review-bot:gpt-review --> rest", "gpt-review"},
}
for _, tc := range tests {
got := extractSentinelName(tc.body)
if got != tc.want {
t.Errorf("extractSentinelName(%q) = %q, want %q", tc.body, got, tc.want)
}
}
}
func TestSetupLogger_JSONFormat(t *testing.T) {
// Capture output by creating a logger manually with the same logic
var buf bytes.Buffer
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
handler := slog.NewJSONHandler(&buf, opts)
logger := slog.New(handler)
logger.Info("test message", "key", "value")
output := buf.String()
if !strings.Contains(output, `"msg":"test message"`) {
t.Errorf("expected JSON msg field, got: %s", output)
}
if !strings.Contains(output, `"key":"value"`) {
t.Errorf("expected JSON key field, got: %s", output)
}
if !strings.Contains(output, `"level":"INFO"`) {
t.Errorf("expected JSON level field, got: %s", output)
}
}
func TestSetupLogger_TextFormat(t *testing.T) {
var buf bytes.Buffer
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
handler := slog.NewTextHandler(&buf, opts)
logger := slog.New(handler)
logger.Info("test message", "key", "value")
output := buf.String()
if !strings.Contains(output, "msg=\"test message\"") && !strings.Contains(output, "msg=test") {
t.Errorf("expected text msg field, got: %s", output)
}
if !strings.Contains(output, "key=value") {
t.Errorf("expected text key field, got: %s", output)
}
}
func TestSetupLogger_LevelFiltering(t *testing.T) {
tests := []struct {
name string
verbosity string
logLevel slog.Level
expected bool // should the message appear
}{
{"info logger shows info", "info", slog.LevelInfo, true},
{"info logger hides debug", "info", slog.LevelDebug, false},
{"debug logger shows debug", "debug", slog.LevelDebug, true},
{"warn logger hides info", "warn", slog.LevelInfo, false},
{"warn logger shows warn", "warn", slog.LevelWarn, true},
{"error logger hides warn", "error", slog.LevelWarn, false},
{"error logger shows error", "error", slog.LevelError, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var level slog.Level
switch tc.verbosity {
case "debug":
level = slog.LevelDebug
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
var buf bytes.Buffer
opts := &slog.HandlerOptions{Level: level}
handler := slog.NewTextHandler(&buf, opts)
logger := slog.New(handler)
logger.Log(nil, tc.logLevel, "test")
hasOutput := buf.Len() > 0
if hasOutput != tc.expected {
t.Errorf("verbosity=%s, logLevel=%s: got output=%v, want %v",
tc.verbosity, tc.logLevel, hasOutput, tc.expected)
}
})
}
}
func TestSetupLogger_Integration(t *testing.T) {
// Test that setupLogger doesn't panic for valid inputs
setupLogger("text", "info")
setupLogger("json", "debug")
setupLogger("text", "warn")
setupLogger("json", "error")
setupLogger("text", "unknown") // should default to info
setupLogger("invalid", "info") // should default to text
}
+97
View File
@@ -0,0 +1,97 @@
# Review Update Strategy
review-bot uses an **edit-in-place** strategy for updating reviews. Reviews are never deleted — this preserves conversation threads on inline comments.
## State Transition Diagram
```mermaid
stateDiagram-v2
[*] --> NoExistingReview: First run
NoExistingReview --> POST_Review: Generate findings + event
POST_Review --> PostEscalationCheck: event == APPROVED?
PostEscalationCheck --> Done: No sibling blocks
PostEscalationCheck --> Supersede_And_Repost: Sibling has REQUEST_CHANGES
Supersede_And_Repost --> Done: Posted as REQUEST_CHANGES
[*] --> ExistingReviewFound: Subsequent run (sentinel match)
ExistingReviewFound --> CheckEscalation: Determine final event
CheckEscalation --> CompareState: Apply worst-wins if needed
CompareState --> SameState: existing.state == new event
CompareState --> StateChange: existing.state != new event
SameState --> Skip: Body unchanged
SameState --> PatchBody: Body changed → PATCH in place
StateChange --> Escalate: APPROVED → REQUEST_CHANGES
StateChange --> Downgrade: REQUEST_CHANGES → APPROVED
Escalate --> Supersede: PATCH old body → "Superseded"
Supersede --> POST_New_RC: POST new REQUEST_CHANGES
Downgrade --> POST_New_Approve: POST new APPROVED (old stays intact)
Skip --> Done
PatchBody --> Done
POST_New_RC --> Done
POST_New_Approve --> Done
```
## Rules
| Scenario | Action | Reason |
|----------|--------|--------|
| No existing review | POST new | First run |
| Same state, same body | Skip | Nothing changed — preserve threads |
| Same state, body changed | PATCH body | Update findings without losing threads |
| APPROVED → REQUEST_CHANGES | Supersede old + POST new | Can always escalate; old APPROVED is no longer valid |
| REQUEST_CHANGES → APPROVED | POST new APPROVED | Can't edit state; old REQUEST_CHANGES stays as historical record |
| Sibling has REQUEST_CHANGES (worst-wins) | Escalate to REQUEST_CHANGES | PR must stay blocked if ANY reviewer blocks |
## Key Constraints
1. **Review state is immutable after POST** — Gitea has no API to change APPROVED ↔ REQUEST_CHANGES
2. **Never delete reviews** — Deleting cascades to inline comments and reply threads
3. **"Last review per user" wins** — Gitea uses the most recent review from a user for merge decisions
4. **REQUEST_CHANGES reviews are never touched** — Their inline comments and threads are preserved as historical record
5. **APPROVED reviews can be superseded** — When escalation is needed, mark old as superseded and POST new
## Worst-Wins (Shared Token)
When multiple reviewer roles share a token (e.g., `sonnet` and `security` both use `sonnet-review-bot`):
```
CI Matrix Run:
sonnet → REQUEST_CHANGES (findings)
security → APPROVED (no security issues)
security sees sibling REQUEST_CHANGES
security escalates → REQUEST_CHANGES
PR stays blocked ✓
```
The **first-run case** (no existing review to read login from) uses a post-posting fallback:
POST APPROVED → check siblings → if blocked, supersede own APPROVED → re-POST as REQUEST_CHANGES.
## Edit Mechanism
Reviews are edited via `PATCH /repos/{owner}/{repo}/issues/comments/{id}`:
- **Review body**: ID obtained from the timeline API (`/issues/{index}/timeline`, type `"review"`)
- **Inline comments**: IDs obtained from `/pulls/{index}/reviews/{id}/comments`
- **Both are editable** by the token that created them
- **ListReviews always returns the original body** (reads from review table, not comment table) — sentinel matching works regardless of edits
## Inline Comments Lifecycle
| Event | Inline comments behavior |
|-------|--------------------------|
| First POST | Created on specific diff lines |
| PATCH body (same state) | Unchanged — still current findings |
| Supersede (state change) | Old inline comments stay (readable but on outdated code) |
| New POST after supersede | Fresh inline comments on current diff |
+132 -16
View File
@@ -7,15 +7,38 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
) )
// APIError represents an HTTP error response from the Gitea API.
// It carries the status code so callers can distinguish between
// different failure modes (e.g. 404 vs 500).
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
body := e.Body
if len(body) > 200 {
body = body[:200] + "...(truncated)"
}
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
}
// IsNotFound reports whether an error is an API 404 response.
func IsNotFound(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
}
// Client interacts with the Gitea API. // Client interacts with the Gitea API.
// A Client is safe for concurrent use by multiple goroutines. // A Client is safe for concurrent use by multiple goroutines.
type Client struct { type Client struct {
@@ -57,9 +80,16 @@ type ChangedFile struct {
Status string `json:"status"` Status string `json:"status"`
} }
// ReviewComment represents an inline comment to attach to a review.
type ReviewComment struct {
Path string `json:"path"`
NewPosition int64 `json:"new_position"`
Body string `json:"body"`
}
// GetPullRequest fetches PR metadata. // GetPullRequest fetches PR metadata.
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) { func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.baseURL, owner, repo, number) reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
body, err := c.doGet(ctx, reqURL) body, err := c.doGet(ctx, reqURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch PR: %w", err) return nil, fmt.Errorf("fetch PR: %w", err)
@@ -73,7 +103,7 @@ func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number
// GetPullRequestDiff fetches the unified diff for a PR. // GetPullRequestDiff fetches the unified diff for a PR.
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) { func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, owner, repo, number) reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
body, err := c.doGet(ctx, reqURL) body, err := c.doGet(ctx, reqURL)
if err != nil { if err != nil {
return "", fmt.Errorf("fetch diff: %w", err) return "", fmt.Errorf("fetch diff: %w", err)
@@ -83,7 +113,7 @@ func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, num
// GetPullRequestFiles fetches the list of files changed in a PR. // GetPullRequestFiles fetches the list of files changed in a PR.
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) { func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, owner, repo, number) reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
body, err := c.doGet(ctx, reqURL) body, err := c.doGet(ctx, reqURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch PR files: %w", err) return nil, fmt.Errorf("fetch PR files: %w", err)
@@ -97,7 +127,7 @@ func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, nu
// GetCommitStatuses fetches CI statuses for a commit SHA. // GetCommitStatuses fetches CI statuses for a commit SHA.
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) { func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.baseURL, owner, repo, sha) reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
body, err := c.doGet(ctx, reqURL) body, err := c.doGet(ctx, reqURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch commit statuses: %w", err) return nil, fmt.Errorf("fetch commit statuses: %w", err)
@@ -111,7 +141,7 @@ func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string)
// GetFileContent fetches a file from the default branch of a repo. // GetFileContent fetches a file from the default branch of a repo.
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) { func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.baseURL, owner, repo, escapePath(filepath)) reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(filepath))
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", filepath, err)
@@ -121,7 +151,7 @@ func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath strin
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo. // GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo.
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) { func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.baseURL, owner, repo, escapePath(filepath), url.QueryEscape(ref)) reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(filepath), 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@%s: %w", filepath, ref, err) return "", fmt.Errorf("fetch file %s@%s: %w", filepath, ref, err)
@@ -131,15 +161,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 "APPROVED" or "REQUEST_CHANGES". // event should be "APPROVED" or "REQUEST_CHANGES".
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string) (*Review, error) { // comments are optional inline comments attached to specific lines.
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, owner, repo, number) 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)
payload := struct { payload := struct {
Body string `json:"body"` Body string `json:"body"`
Event string `json:"event"` Event string `json:"event"`
Comments []ReviewComment `json:"comments,omitempty"`
}{ }{
Body: body, Body: body,
Event: event, Event: event,
Comments: comments,
} }
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
@@ -191,7 +224,7 @@ func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
} }
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
@@ -220,9 +253,9 @@ type ContentEntry struct {
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) { func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
var reqURL string var reqURL string
if path == "" { if path == "" {
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents", c.baseURL, owner, repo) reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents", c.baseURL, url.PathEscape(owner), url.PathEscape(repo))
} else { } else {
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, escapePath(path)) reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
} }
body, err := c.doGet(ctx, reqURL) body, err := c.doGet(ctx, reqURL)
if err != nil { if err != nil {
@@ -244,10 +277,15 @@ func (c *Client) GetAllFilesInPath(ctx context.Context, owner, repo, path string
// Try listing as directory first // Try listing as directory first
entries, err := c.ListContents(ctx, owner, repo, path) entries, err := c.ListContents(ctx, owner, repo, path)
if err != nil { if err != nil {
// Might be a file, try fetching directly // Only fall back to single-file fetch on 404 (path is a file, not a dir).
// Propagate all other errors (auth failures, server errors, rate limits).
if !IsNotFound(err) {
return nil, fmt.Errorf("list contents %q: %w", path, err)
}
// 404 means the path might be a file — try fetching directly
content, fileErr := c.GetFileContent(ctx, owner, repo, path) content, fileErr := c.GetFileContent(ctx, owner, repo, path)
if fileErr != nil { if fileErr != nil {
return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, err) return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, fileErr)
} }
results[path] = content results[path] = content
return results, nil return results, nil
@@ -258,14 +296,14 @@ func (c *Client) GetAllFilesInPath(ctx context.Context, owner, repo, path string
case "file": case "file":
content, err := c.GetFileContent(ctx, owner, repo, entry.Path) content, err := c.GetFileContent(ctx, owner, repo, entry.Path)
if err != nil { if err != nil {
log.Printf("Warning: could not fetch file %s: %v", entry.Path, err) slog.Warn("could not fetch file from patterns repo", "file", entry.Path, "error", err)
continue continue
} }
results[entry.Path] = content results[entry.Path] = content
case "dir": case "dir":
subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path) subResults, err := c.GetAllFilesInPath(ctx, owner, repo, entry.Path)
if err != nil { if err != nil {
log.Printf("Warning: could not recurse into %s: %v", entry.Path, err) slog.Warn("could not recurse into directory", "dir", entry.Path, "error", err)
continue continue
} }
for k, v := range subResults { for k, v := range subResults {
@@ -343,3 +381,81 @@ func (c *Client) DeleteReview(ctx context.Context, owner, repo string, number in
} }
return nil return nil
} }
// TimelineEvent represents an entry from the issue timeline API.
type TimelineEvent struct {
ID int64 `json:"id"`
Type string `json:"type"`
Body string `json:"body"`
User struct {
Login string `json:"login"`
} `json:"user"`
}
// GetTimelineReviewCommentID finds the comment ID for a review body by
// scanning the issue timeline for a review event containing the sentinel.
func (c *Client) GetTimelineReviewCommentID(ctx context.Context, owner, repo string, number int, sentinel string) (int64, error) {
const pageSize = 50
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline?limit=%d&page=%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
number,
pageSize,
page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return 0, fmt.Errorf("get timeline (page %d): %w", page, err)
}
var events []TimelineEvent
if err := json.Unmarshal(body, &events); err != nil {
return 0, fmt.Errorf("parse timeline (page %d): %w", page, err)
}
for _, ev := range events {
if ev.Type == "review" && strings.Contains(ev.Body, sentinel) {
return ev.ID, nil
}
}
if len(events) < pageSize {
break
}
}
return 0, fmt.Errorf("no timeline event found with sentinel")
}
// EditComment updates the body of an issue/review comment.
func (c *Client) EditComment(ctx context.Context, owner, repo string, commentID int64, newBody string) error {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/comments/%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
commentID)
payload := struct {
Body string `json:"body"`
}{Body: newBody}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal edit payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create edit request: %w", err)
}
req.Header.Set("Authorization", "token "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("edit comment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("edit comment failed (status %d): %s", resp.StatusCode, body)
}
return nil
}
+178 -2
View File
@@ -3,6 +3,7 @@ package gitea
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -128,7 +129,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") 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)
} }
@@ -175,7 +176,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") _, 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")
} }
@@ -426,3 +427,178 @@ func TestDeleteReview_Forbidden(t *testing.T) {
t.Fatal("expected error for 403, got nil") t.Fatal("expected error for 403, got nil")
} }
} }
func TestEditComment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner/repo/issues/comments/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var payload struct {
Body string `json:"body"`
}
json.NewDecoder(r.Body).Decode(&payload)
if payload.Body != "updated body" {
t.Errorf("unexpected body: %s", payload.Body)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id": 42, "body": "updated body"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.EditComment(context.Background(), "owner", "repo", 42, "updated body")
if err != nil {
t.Fatalf("EditComment() error = %v", err)
}
}
func TestEditComment_Forbidden(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"message": "not allowed"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.EditComment(context.Background(), "owner", "repo", 42, "new body")
if err == nil {
t.Fatal("expected error for 403 response")
}
}
func TestGetTimelineReviewCommentID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/owner/repo/issues/5/timeline" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Write([]byte(`[
{"id": 100, "type": "comment", "body": "random"},
{"id": 200, "type": "review", "body": "other review <!-- review-bot:gpt -->"},
{"id": 300, "type": "review", "body": "our review <!-- review-bot:sonnet -->"}
]`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
id, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
if err != nil {
t.Fatalf("GetTimelineReviewCommentID() error = %v", err)
}
if id != 300 {
t.Errorf("got id=%d, want 300", id)
}
}
func TestGetTimelineReviewCommentID_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[{"id": 100, "type": "review", "body": "no match"}]`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
if err == nil {
t.Fatal("expected error when sentinel not found")
}
}
func TestGetAllFilesInPath_404FallsBackToFile(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/owner/repo/contents/README.md":
// Contents API returns 404 for files (not a directory)
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message":"not found"}`))
case "/api/v1/repos/owner/repo/raw/README.md":
w.Write([]byte("# Hello\n"))
default:
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message":"not found"}`))
}
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
if err != nil {
t.Fatalf("expected fallback to file on 404, got error: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if files["README.md"] != "# Hello\n" {
t.Errorf("unexpected content: %q", files["README.md"])
}
}
func TestGetAllFilesInPath_500Propagates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate a server error from ListContents
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"message":"internal server error"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "somepath")
if err == nil {
t.Fatal("expected error to propagate for 500, got nil")
}
// Should NOT fall back to file fetch — error should propagate
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError in chain, got: %v", err)
}
if apiErr.StatusCode != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", apiErr.StatusCode)
}
}
func TestGetAllFilesInPath_403Propagates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"message":"token has insufficient scope"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "private/stuff")
if err == nil {
t.Fatal("expected error to propagate for 403, got nil")
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError in chain, got: %v", err)
}
if apiErr.StatusCode != http.StatusForbidden {
t.Errorf("expected status 403, got %d", apiErr.StatusCode)
}
}
func TestIsNotFound(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"non-API error", fmt.Errorf("network timeout"), false},
{"404 APIError", &APIError{StatusCode: 404, Body: "not found"}, true},
{"500 APIError", &APIError{StatusCode: 500, Body: "server error"}, false},
{"wrapped 404", fmt.Errorf("list contents: %w", &APIError{StatusCode: 404, Body: "not found"}), true},
{"wrapped 500", fmt.Errorf("list contents: %w", &APIError{StatusCode: 500, Body: "err"}), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsNotFound(tt.err)
if got != tt.want {
t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+85
View File
@@ -0,0 +1,85 @@
package gitea
import (
"strconv"
"strings"
)
// DiffLineRanges maps filenames to the set of new-file line numbers present in the diff.
type DiffLineRanges struct {
files map[string]map[int]bool
}
// Contains reports whether the given file+line is within the diff hunks.
func (d *DiffLineRanges) Contains(file string, line int) bool {
if d == nil || d.files == nil {
return false
}
lines, ok := d.files[file]
if !ok {
return false
}
return lines[line]
}
// ParseDiffNewLines parses a unified diff and extracts the new-file line numbers
// that appear in each hunk (both added and context lines).
func ParseDiffNewLines(diff string) *DiffLineRanges {
result := &DiffLineRanges{files: make(map[string]map[int]bool)}
var currentFile string
var newLine int
for _, line := range strings.Split(diff, "\n") {
// Track current file from +++ header
if strings.HasPrefix(line, "+++ b/") {
currentFile = strings.TrimPrefix(line, "+++ b/")
if result.files[currentFile] == nil {
result.files[currentFile] = make(map[int]bool)
}
continue
}
if strings.HasPrefix(line, "+++ /dev/null") {
currentFile = ""
continue
}
// Parse hunk header: @@ -old,count +new,count @@ or @@ -old +new @@
if strings.HasPrefix(line, "@@") && currentFile != "" {
// Extract the +N part — handle both "+10,8" and "+1" forms
parts := strings.Split(line, "+")
if len(parts) >= 2 {
// Take everything before comma or space
numStr := parts[1]
if idx := strings.IndexAny(numStr, ", "); idx != -1 {
numStr = numStr[:idx]
}
n, err := strconv.Atoi(numStr)
if err == nil {
newLine = n
}
}
continue
}
if currentFile == "" {
continue
}
// Skip diff metadata lines
if strings.HasPrefix(line, "\\") {
continue
}
// Count lines in hunk
if strings.HasPrefix(line, "+") || strings.HasPrefix(line, " ") {
result.files[currentFile][newLine] = true
newLine++
} else if strings.HasPrefix(line, "-") {
// Removed lines don't advance new line counter
continue
}
}
return result
}
+115
View File
@@ -0,0 +1,115 @@
package gitea
import (
"testing"
)
func TestParseDiffLineRanges(t *testing.T) {
diff := `diff --git a/main.go b/main.go
index abc1234..def5678 100644
--- a/main.go
+++ b/main.go
@@ -10,6 +10,8 @@ func main() {
fmt.Println("hello")
+ fmt.Println("new line 11")
+ fmt.Println("new line 12")
fmt.Println("existing")
}
@@ -30,4 +32,5 @@ func other() {
return nil
+ // added at line 33
}
diff --git a/util.go b/util.go
new file mode 100644
--- /dev/null
+++ b/util.go
@@ -0,0 +1,5 @@
+package main
+
+func helper() string {
+ return "hi"
+}
`
ranges := ParseDiffNewLines(diff)
// main.go should have lines 10-17 (first hunk) and 32-36 (second hunk)
if !ranges.Contains("main.go", 11) {
t.Error("expected main.go:11 to be in diff")
}
if !ranges.Contains("main.go", 12) {
t.Error("expected main.go:12 to be in diff")
}
if !ranges.Contains("main.go", 10) {
t.Error("expected main.go:10 to be in diff (context line)")
}
if !ranges.Contains("main.go", 33) {
t.Error("expected main.go:33 to be in diff")
}
if ranges.Contains("main.go", 25) {
t.Error("main.go:25 should NOT be in diff")
}
// util.go is entirely new, lines 1-5
if !ranges.Contains("util.go", 1) {
t.Error("expected util.go:1 to be in diff")
}
if !ranges.Contains("util.go", 5) {
t.Error("expected util.go:5 to be in diff")
}
if ranges.Contains("util.go", 6) {
t.Error("util.go:6 should NOT be in diff")
}
// Unknown file
if ranges.Contains("unknown.go", 1) {
t.Error("unknown.go should not be in diff")
}
}
func TestParseDiffNewLines_Empty(t *testing.T) {
ranges := ParseDiffNewLines("")
if ranges.Contains("any.go", 1) {
t.Error("empty diff should contain nothing")
}
}
func TestParseDiffNewLines_NoCommaHunk(t *testing.T) {
// Single-line hunks omit the comma: @@ -1 +1 @@
diff := `diff --git a/single.go b/single.go
--- a/single.go
+++ b/single.go
@@ -1 +1 @@
-old line
+new line
`
ranges := ParseDiffNewLines(diff)
if !ranges.Contains("single.go", 1) {
t.Error("expected single.go:1 to be in diff (no-comma hunk)")
}
if ranges.Contains("single.go", 2) {
t.Error("single.go:2 should NOT be in diff")
}
}
func TestParseDiffNewLines_NoNewlineMarker(t *testing.T) {
// "\ No newline at end of file" should not advance line counter
diff := `diff --git a/noeof.go b/noeof.go
--- a/noeof.go
+++ b/noeof.go
@@ -1,2 +1,2 @@
+line one
+line two
\ No newline at end of file
`
ranges := ParseDiffNewLines(diff)
if !ranges.Contains("noeof.go", 1) {
t.Error("expected noeof.go:1")
}
if !ranges.Contains("noeof.go", 2) {
t.Error("expected noeof.go:2")
}
if ranges.Contains("noeof.go", 3) {
t.Error("noeof.go:3 should NOT be in diff (no-newline marker)")
}
}
+88
View File
@@ -0,0 +1,88 @@
package gitea
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestPostReview_WithComments(t *testing.T) {
var gotPayload struct {
Body string `json:"body"`
Event string `json:"event"`
Comments []struct {
Path string `json:"path"`
NewPosition int64 `json:"new_position"`
Body string `json:"body"`
} `json:"comments"`
}
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": 99,
"body": gotPayload.Body,
"user": map[string]any{"login": "bot"},
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
comments := []ReviewComment{
{Path: "main.go", NewPosition: 42, Body: "[MAJOR] Something bad"},
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
}
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", comments)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(gotPayload.Comments) != 2 {
t.Fatalf("expected 2 comments, got %d", len(gotPayload.Comments))
}
if gotPayload.Comments[0].Path != "main.go" {
t.Errorf("expected path main.go, got %s", gotPayload.Comments[0].Path)
}
if gotPayload.Comments[0].NewPosition != 42 {
t.Errorf("expected new_position 42, got %d", gotPayload.Comments[0].NewPosition)
}
if gotPayload.Comments[1].Body != "[MINOR] Style issue" {
t.Errorf("unexpected body: %s", gotPayload.Comments[1].Body)
}
}
func TestPostReview_NilComments(t *testing.T) {
var gotPayload map[string]any
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": 100,
"body": "test",
"user": map[string]any{"login": "bot"},
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// With nil comments, the field should be omitted (omitempty)
comments, ok := gotPayload["comments"]
if ok && comments != nil {
arr, isArr := comments.([]any)
if isArr && len(arr) > 0 {
t.Error("expected no comments in payload when nil passed")
}
}
}