Compare commits

..

32 Commits

Author SHA1 Message Date
rodin 23443ef378 Merge pull request 'feat: resolve old inline comments when superseding review' (#42) from feat/27-resolve-inline-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, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
Release / release (push) Successful in 32s
2026-05-02 19:18:41 +00:00
Rodin bc5a4a1dcd feat: resolve old inline comments when superseding review
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, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 44s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 46s
Closes #27

After superseding an old review, resolves all its inline comments via
POST /pulls/comments/{id}/resolve. This clears unresolved conversation
markers from the PR timeline and diff view.

New API methods:
- ListReviewComments: paginated GET /repos/.../pulls/{n}/reviews/{id}/comments
- ResolveComment: POST /repos/.../pulls/comments/{id}/resolve

Behavior:
- Only resolves after successful supersede (gated on supersedeOK)
- Aggregates failures and logs at warn level
- Truncates error bodies to 256 bytes (security)
- Non-fatal: review still posts even if resolution fails
2026-05-02 12:15:52 -07:00
rodin d30f3d4278 Merge pull request 'feat: self-request as reviewer before posting' (#41) from feat/35-self-request-reviewer 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, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
2026-05-02 19:11:15 +00:00
Rodin 2507ee22e7 fix: address review findings on RequestReviewer
CI / test (pull_request) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 21s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 35s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m3s
- Accept 204 No Content as success (idempotent operations)
- Truncate error response body to 256 bytes (prevent log leakage)
- Add unit tests for GetAuthenticatedUser and RequestReviewer
2026-05-02 12:09:25 -07:00
Rodin c39845ca03 feat: self-request as reviewer before posting
CI / test (pull_request) Successful in 13s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 21s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 48s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 58s
Closes #35

Before posting a review, the bot:
1. Discovers its own Gitea login via GET /user
2. Calls POST /requested_reviewers to add itself

This ensures the bot appears in the required-reviewers list without
manual configuration on the repo. The call is idempotent (no-op if
already requested).

Both failures are non-fatal (warn + continue) — the review still posts
even if the self-request fails.
2026-05-02 12:04:55 -07:00
rodin cd601bdcf4 Merge pull request 'fix: trim trailing slash from giteaURL when building review link' (#40) from fix/url-normalization 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:52:13 +00:00
Rodin 50091941e1 fix: trim trailing slash from giteaURL when building review link
CI / test (pull_request) Successful in 14s
CI / review (gpt-4.1, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 20s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 28s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 41s
Prevents double-slash in supersede URL if GITEA_URL ends with '/'.
Aligns with how gitea.NewClient already normalizes the base URL.
2026-05-02 11:49:24 -07:00
rodin ed06cdd942 Merge pull request 'fix: post new review first, then supersede old with link' (#39) from fix/34-supersede-order 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, SECURITY_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
2026-05-02 18:46:50 +00:00
Rodin ed69d26e87 fix: post new review first, then supersede old with link
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 44s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m8s
Changes the order of operations:
1. POST new review (gets non-stale badge immediately)
2. PATCH old review with superseded message linking to the new one

This gives the superseded comment a clickable link to the current
review, making navigation between review iterations easy.

buildSupersededBody now accepts a newReviewURL parameter.
2026-05-02 11:43:53 -07:00
rodin da586a512a Merge pull request 'feat: always post fresh review, supersede old with collapsed body' (#38) from feat/34-always-post-fresh 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:36:42 +00:00
Rodin f6baa41b2c fix: remove findOwnReviewStrict, use findOwnReview directly
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 42s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m31s
The strict authorship check compared reviewer-name to User.Login which
could mismatch. The sentinel is already role-specific (e.g.
<!-- review-bot:sonnet -->) and Gitea's API blocks editing others'
comments (403). Defense-in-depth via login comparison is unnecessary
complexity that introduced a bug. Removed.
2026-05-02 11:33:57 -07:00
Rodin ecbae332f4 fix: address review findings
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 1m17s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m22s
- findOwnReview: skip superseded reviews, pick highest ID (most recent)
- findOwnReviewStrict: verify authorship before superseding (defense-in-depth)
- buildSupersededBody: handle empty commitSHA gracefully
- Tests: add cases for superseded skip, highest-ID selection
2026-05-02 11:30:34 -07:00
Rodin fdd75699d9 feat: always post fresh review, supersede old with collapsed body
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 1m2s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m25s
Closes #34

- Remove reviewUnchanged() skip logic — every push gets a fresh review
- Remove edit-in-place (PATCH same body) — always POST new
- Supersede old review: PATCH with struck-through banner + collapsed
  original body in <details> for historical reference
- Add commit footer to every review: 'Evaluated against <sha>'
- Remove --update-existing flag (no longer needed)
- Add CommitID field to Review struct
- Add TestBuildSupersededBody tests
2026-05-02 11:26:06 -07:00
rodin dc450f7771 Merge pull request 'feat: improve test coverage for cmd/review-bot' (#37) from feat/32-test-coverage 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, SECURITY_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
2026-05-02 18:21:17 +00:00
Rodin 3a3c60a3c6 chore: retrigger reviews
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 1m20s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m24s
2026-05-02 11:19:04 -07:00
Rodin 504f616e99 fix: add coverage to .PHONY in Makefile (NIT)
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, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 33s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m3s
2026-05-02 11:17:05 -07:00
Rodin bb596db3c1 feat: improve test coverage for cmd/review-bot
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 39s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m6s
Partially addresses #32

- Tests for setupLogger, isPatternFile, evaluateCIStatus, envOrDefault*, validateReviewerName
- Subprocess tests for main() error paths (version, missing flags, invalid inputs)
- Integration test scaffold (build tag: integration)
- Makefile with build/test/lint/coverage targets
- Coverage: 16.7% → 42.3% for cmd/review-bot
2026-05-02 11:13:59 -07:00
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
10 changed files with 1562 additions and 326 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
+1
View File
@@ -1 +1,2 @@
/review-bot /review-bot
coverage.out
+20
View File
@@ -0,0 +1,20 @@
.PHONY: build test test-integration lint clean coverage
build:
go build -o review-bot ./cmd/review-bot/
test:
go test ./...
test-integration:
go test -tags integration -v ./cmd/review-bot/
lint:
go vet ./...
clean:
rm -f review-bot
coverage:
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
+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:
+161
View File
@@ -0,0 +1,161 @@
//go:build integration
package main
import (
"context"
"os"
"strconv"
"strings"
"testing"
"gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/llm"
"gitea.weiker.me/rodin/review-bot/review"
)
// Integration test requires a running Gitea instance and LLM endpoint.
// Set environment variables:
//
// INTEGRATION_GITEA_URL - Gitea base URL
// INTEGRATION_GITEA_TOKEN - Gitea API token with repo access
// INTEGRATION_GITEA_REPO - owner/repo with an open PR
// INTEGRATION_PR_NUMBER - PR number to test against
// INTEGRATION_LLM_BASE_URL - LLM API base URL
// INTEGRATION_LLM_API_KEY - LLM API key
// INTEGRATION_LLM_MODEL - Model name
func TestIntegration_FullReviewFlow(t *testing.T) {
giteaURL := os.Getenv("INTEGRATION_GITEA_URL")
giteaToken := os.Getenv("INTEGRATION_GITEA_TOKEN")
giteaRepo := os.Getenv("INTEGRATION_GITEA_REPO")
prNumStr := os.Getenv("INTEGRATION_PR_NUMBER")
llmBaseURL := os.Getenv("INTEGRATION_LLM_BASE_URL")
llmAPIKey := os.Getenv("INTEGRATION_LLM_API_KEY")
llmModel := os.Getenv("INTEGRATION_LLM_MODEL")
if giteaURL == "" || giteaToken == "" || giteaRepo == "" || prNumStr == "" ||
llmBaseURL == "" || llmAPIKey == "" || llmModel == "" {
t.Skip("Integration test env vars not set, skipping")
}
prNumber, err := strconv.Atoi(prNumStr)
if err != nil {
t.Fatalf("Invalid PR number %q: %v", prNumStr, err)
}
// Parse owner/repo
parts := strings.SplitN(giteaRepo, "/", 2)
if len(parts) != 2 {
t.Fatalf("Invalid repo format %q", giteaRepo)
}
owner, repoName := parts[0], parts[1]
if owner == "" || repoName == "" {
t.Fatalf("Invalid repo format %q", giteaRepo)
}
ctx := context.Background()
// Step 1: Fetch PR
giteaClient := gitea.NewClient(giteaURL, giteaToken)
pr, err := giteaClient.GetPullRequest(ctx, owner, repoName, prNumber)
if err != nil {
t.Fatalf("GetPullRequest: %v", err)
}
t.Logf("PR: %s (sha: %s)", pr.Title, pr.Head.Sha)
// Step 2: Fetch diff
diff, err := giteaClient.GetPullRequestDiff(ctx, owner, repoName, prNumber)
if err != nil {
t.Fatalf("GetPullRequestDiff: %v", err)
}
if diff == "" {
t.Fatal("diff is empty")
}
t.Logf("Diff size: %d bytes", len(diff))
// Step 3: Build prompts
systemPrompt := review.BuildSystemPrompt("", "")
userPrompt := review.BuildUserPrompt(pr.Title, pr.Body, diff, "", true, "")
// Step 4: Call LLM
llmClient := llm.NewClient(llmBaseURL, llmAPIKey, llmModel)
response, err := llmClient.Complete(ctx, []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
})
if err != nil {
t.Fatalf("LLM Complete: %v", err)
}
t.Logf("LLM response: %d bytes", len(response))
// Step 5: Parse response
result, err := review.ParseResponse(response)
if err != nil {
t.Fatalf("ParseResponse: %v", err)
}
t.Logf("Verdict: %s, Findings: %d", result.Verdict, len(result.Findings))
// Step 6: Format (dry-run validation)
body := review.FormatMarkdown(result, "integration-test")
if body == "" {
t.Fatal("formatted review body is empty")
}
t.Logf("Review body:\n%s", body)
}
func TestIntegration_PostAndCleanup(t *testing.T) {
giteaURL := os.Getenv("INTEGRATION_GITEA_URL")
giteaToken := os.Getenv("INTEGRATION_GITEA_TOKEN")
giteaRepo := os.Getenv("INTEGRATION_GITEA_REPO")
prNumStr := os.Getenv("INTEGRATION_PR_NUMBER")
if giteaURL == "" || giteaToken == "" || giteaRepo == "" || prNumStr == "" {
t.Skip("Integration test env vars not set, skipping")
}
prNumber, err := strconv.Atoi(prNumStr)
if err != nil {
t.Fatalf("Invalid PR number %q: %v", prNumStr, err)
}
parts := strings.SplitN(giteaRepo, "/", 2)
if len(parts) != 2 {
t.Fatalf("Invalid repo format %q", giteaRepo)
}
owner, repoName := parts[0], parts[1]
ctx := context.Background()
giteaClient := gitea.NewClient(giteaURL, giteaToken)
// Post a test review
sentinel := "<!-- review-bot:integration-test -->"
testBody := "# Integration Test Review\n\nThis is a test review.\n\n" + sentinel
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, "COMMENT", testBody, nil)
if err != nil {
t.Fatalf("PostReview: %v", err)
}
t.Logf("Posted review ID: %d", posted.ID)
// Verify it appears in listing
reviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
if err != nil {
t.Fatalf("ListReviews: %v", err)
}
found := false
for _, r := range reviews {
if r.ID == posted.ID && strings.Contains(r.Body, sentinel) {
found = true
break
}
}
if !found {
t.Error("posted review not found in listing")
}
// Cleanup: delete the test review
err = giteaClient.DeleteReview(ctx, owner, repoName, prNumber, posted.ID)
if err != nil {
t.Logf("Warning: could not delete test review %d: %v", posted.ID, err)
}
}
+228 -131
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)")
@@ -35,7 +67,6 @@ func main() {
patternsRepo := flag.String("patterns-repo", envOrDefault("PATTERNS_REPO", ""), "Repo with language patterns (e.g. rodin/elixir-patterns)") patternsRepo := flag.String("patterns-repo", envOrDefault("PATTERNS_REPO", ""), "Repo with language patterns (e.g. rodin/elixir-patterns)")
patternsFiles := flag.String("patterns-files", envOrDefault("PATTERNS_FILES", "README.md"), "Comma-separated file paths to fetch from patterns repo") patternsFiles := flag.String("patterns-files", envOrDefault("PATTERNS_FILES", "README.md"), "Comma-separated file paths to fetch from patterns repo")
dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting") dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting")
updateExisting := flag.Bool("update-existing", envOrDefaultBool("UPDATE_EXISTING", true), "Delete previous review from same bot before posting (default true)")
llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)") llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)")
llmTimeout := flag.Int("llm-timeout", envOrDefaultInt("LLM_TIMEOUT", 300), "LLM request timeout in seconds (default 300)") llmTimeout := flag.Int("llm-timeout", envOrDefaultInt("LLM_TIMEOUT", 300), "LLM request timeout in seconds (default 300)")
llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai or anthropic") llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai or anthropic")
@@ -47,7 +78,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 +93,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 +126,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 +138,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 +172,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 +184,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 +195,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 +207,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 +249,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,19 +263,31 @@ 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)
// Add commit footer so readers know which commit was evaluated
if pr.Head.Sha != "" {
shortSHA := pr.Head.Sha
if len(shortSHA) > 8 {
shortSHA = shortSHA[:8]
}
reviewBody += fmt.Sprintf("\n\n---\n*Evaluated against %s*", shortSHA)
}
event := review.GiteaEvent(result.Verdict) event := review.GiteaEvent(result.Verdict)
if *dryRun { if *dryRun {
@@ -254,99 +312,101 @@ func main() {
} }
} }
if len(inlineComments) > 0 { if len(inlineComments) > 0 {
log.Printf("Attaching %d inline comments", len(inlineComments)) slog.Debug("attaching inline comments", "count", len(inlineComments))
} }
// --- Review update strategy --- // --- Review update strategy ---
// 1. No existing review → POST new // 1. POST new review first (gets non-stale approval badge on HEAD)
// 2. Existing review, same state → PATCH body in place (preserves threads) // 2. Then supersede old review with link to the new one
// 3. Existing review, state change → PATCH old to "Superseded", POST new // Order matters: post first so we have the new review's URL for the supersede message.
if *updateExisting && *reviewerName != "" { var existingReview *gitea.Review
var existingCommentID int64
if *reviewerName != "" {
existingReviews, 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 {
// Worst-wins: escalate if a sibling blocks (need own login from existing review) sharedToken := hasSharedToken(existingReviews, sentinel)
ownLogin := "" if !sharedToken {
existing := findOwnReview(existingReviews, sentinel) existingReview = findOwnReview(existingReviews, sentinel)
if existing != nil { if existingReview != nil {
ownLogin = existing.User.Login cid, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
}
if event == "APPROVED" && shouldEscalate(existingReviews, 0, ownLogin, sentinel) {
log.Printf("Sibling review has REQUEST_CHANGES; escalating to REQUEST_CHANGES")
event = "REQUEST_CHANGES"
}
if existing != nil {
if reviewUnchanged(existingReviews, reviewBody, event, sentinel) {
log.Printf("Review unchanged from previous run; skipping to preserve threads")
return
}
// Same state → PATCH in place
if existing.State == event {
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
if err != nil { if err != nil {
log.Printf("Warning: could not find review comment ID, falling back to new post: %v", err) slog.Warn("could not find old review comment ID for supersede", "error", err)
existingReview = nil // can't supersede without comment ID
} else { } else {
if err := giteaClient.EditComment(ctx, owner, repoName, commentID, reviewBody); err != nil { existingCommentID = cid
log.Printf("Warning: could not edit review, falling back to new post: %v", err)
} else {
log.Printf("Review updated in place (comment_id=%d)", commentID)
return
} }
} }
} else { } else {
// State change → mark old as superseded, post new below slog.Warn("shared token mode: skipping supersede to avoid clobbering sibling review")
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
if err != nil {
log.Printf("Warning: could not find old review comment ID: %v", 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 {
log.Printf("Warning: could not mark old review as superseded: %v", err)
} else {
log.Printf("Marked old review as superseded (state was %s, now %s)", existing.State, event)
}
}
}
} }
} }
} }
// POST new review (first run, or state transition fallthrough) // Self-request as reviewer (ensures we appear in required-reviewer checks)
log.Printf("Posting review (event=%s)...", event) authUser, err := giteaClient.GetAuthenticatedUser(ctx)
if err != nil {
slog.Warn("could not determine authenticated user for reviewer self-request", "error", err)
} else if authUser != "" {
if err := giteaClient.RequestReviewer(ctx, owner, repoName, prNumber, authUser); err != nil {
slog.Warn("could not self-request as reviewer", "user", authUser, "error", err)
} else {
slog.Debug("self-requested as reviewer", "user", authUser, "pr", prNumber)
}
}
// POST new review
slog.Info("posting review", "event", event, "pr", prNumber)
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments) posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
if err != nil { if err != nil {
log.Fatalf("Failed to post review: %v", err) slog.Error("failed to post review", "pr", prNumber, "event", event, "error", err)
os.Exit(1)
} }
log.Printf("Review posted (id=%d, user=%s)", posted.ID, posted.User.Login) slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
// Post-posting escalation: if we just posted APPROVED but a sibling // Supersede old review with link to the new one
// from the same user has REQUEST_CHANGES, mark ours as superseded and if existingReview != nil && existingCommentID > 0 {
// re-post as REQUEST_CHANGES. This handles the first-run case where newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(*giteaURL, "/"), owner, repoName, prNumber, posted.ID)
// we don't know our login until after posting. supersededBody := buildSupersededBody(existingReview.Body, existingReview.CommitID, newReviewURL, sentinel)
if event == "APPROVED" && *updateExisting && *reviewerName != "" { supersedeOK := false
reviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber) if err := giteaClient.EditComment(ctx, owner, repoName, existingCommentID, supersededBody); err != nil {
if err == nil && shouldEscalate(reviews, posted.ID, posted.User.Login, sentinel) { slog.Warn("could not mark old review as superseded", "comment_id", existingCommentID, "error", err)
log.Printf("Post-posting escalation: sibling has REQUEST_CHANGES")
// Mark our just-posted review as superseded
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
if err == nil {
supersededBody := fmt.Sprintf("~~*This review has been superseded by a newer review below.*~~\n\n%s", sentinel)
giteaClient.EditComment(ctx, owner, repoName, commentID, supersededBody)
}
// Re-post as REQUEST_CHANGES
_, err = giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody, inlineComments)
if err != nil {
log.Printf("Warning: could not re-post as REQUEST_CHANGES: %v", err)
} else { } else {
log.Printf("Review escalated to REQUEST_CHANGES") slog.Info("marked old review as superseded", "old_state", existingReview.State, "new_review_id", posted.ID, "pr", prNumber)
supersedeOK = true
}
// Resolve old review's inline comments only after successful supersede
if supersedeOK {
oldComments, err := giteaClient.ListReviewComments(ctx, owner, repoName, prNumber, existingReview.ID)
if err != nil {
slog.Warn("could not list old review comments for resolution", "review_id", existingReview.ID, "error", err)
} else {
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 {
resolved++
}
}
if resolved > 0 {
slog.Info("resolved old inline comments", "count", resolved, "pr", prNumber)
}
if failed > 0 {
slog.Warn("some inline comments could not be resolved", "failed", failed, "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.
func fetchFileContext(ctx context.Context, client *gitea.Client, owner, repo, ref string, files []gitea.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
@@ -359,7 +419,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))
@@ -390,7 +450,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]
@@ -403,7 +463,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
} }
@@ -501,50 +561,87 @@ func validateReviewerName(name string) error {
return nil return nil
} }
// shouldEscalate checks if any sibling bot review from the same user // buildSupersededBody creates the body for a superseded review: struck-through banner
// (different sentinel, same token) has REQUEST_CHANGES. // with collapsed original content and the commit it was evaluated against.
// ownLogin is the bot user login; if empty, escalation check is skipped. func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
// postedID is excluded from consideration (0 means no exclusion needed). shortSHA := commitSHA
func shouldEscalate(reviews []gitea.Review, postedID int64, ownLogin, ownSentinel string) bool { 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
// 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 == "" { if ownLogin == "" {
return false return false
} }
for _, r := range reviews { for _, r := range reviews {
if r.ID == postedID || r.Stale { if r.User.Login == ownLogin && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
continue slog.Warn("shared token detected — another review-bot role is using the same Gitea user",
} "sibling_role", extractSentinelName(r.Body), "user", ownLogin)
// Sibling = same user, has a review-bot sentinel, but not OUR sentinel
if r.User.Login == ownLogin && r.State == "REQUEST_CHANGES" && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
return true return true
} }
} }
return false return false
} }
// reviewUnchanged checks if an existing review with the same sentinel // extractSentinelName pulls the reviewer name from a sentinel comment.
// already has identical body and state. Returns true if a re-post would func extractSentinelName(body string) string {
// produce the same result (skip to preserve conversation threads). const prefix = "<!-- review-bot:"
func reviewUnchanged(reviews []gitea.Review, newBody, newEvent, sentinel string) bool { const suffix = " -->"
for _, r := range reviews { idx := strings.Index(body, prefix)
if r.Stale { if idx < 0 {
continue return "unknown"
} }
if !strings.Contains(r.Body, sentinel) { rest := body[idx+len(prefix):]
continue end := strings.Index(rest, suffix)
if end < 0 {
return "unknown"
} }
if r.State == newEvent && r.Body == newBody { return rest[:end]
return true
}
}
return false
} }
// findOwnReview locates a review matching the given sentinel in its body. // findOwnReview locates a review matching the given sentinel in its body.
func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review { func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review {
var best *gitea.Review
for i := range reviews { for i := range reviews {
if strings.Contains(reviews[i].Body, sentinel) { if !strings.Contains(reviews[i].Body, sentinel) {
return &reviews[i] continue
}
// Skip superseded reviews (they contain our sentinel in the collapsed body)
if strings.Contains(reviews[i].Body, "~~Original review~~") {
continue
}
// Take the highest ID (most recent)
if best == nil || reviews[i].ID > best.ID {
best = &reviews[i]
} }
} }
return nil return best
} }
+723 -170
View File
@@ -1,6 +1,12 @@
package main package main
import ( import (
"bytes"
"flag"
"log/slog"
"os"
"os/exec"
"strings"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/gitea" "gitea.weiker.me/rodin/review-bot/gitea"
@@ -50,182 +56,52 @@ func makeReview(id int64, login, state string, stale bool, body string) gitea.Re
return r return r
} }
func TestShouldEscalate(t *testing.T) {
tests := []struct {
name string
reviews []gitea.Review
postedID int64
ownLogin string
ownSentinel string func TestBuildSupersededBody(t *testing.T) {
want bool 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"
name: "no reviews",
reviews: nil,
postedID: 100,
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "sibling same user has REQUEST_CHANGES",
reviews: []gitea.Review{
makeReview(101, "bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:security -->"),
},
postedID: 100,
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: true,
},
{
name: "sibling different user has REQUEST_CHANGES (should NOT escalate)",
reviews: []gitea.Review{
makeReview(101, "other-bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:gpt -->"),
},
postedID: 100,
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "same user REQUEST_CHANGES but stale (should NOT escalate)",
reviews: []gitea.Review{
makeReview(101, "bot", "REQUEST_CHANGES", true, "old\n<!-- review-bot:security -->"),
},
postedID: 100,
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "same user same sentinel (own stale review, should NOT escalate)",
reviews: []gitea.Review{
makeReview(101, "bot", "REQUEST_CHANGES", false, "old\n<!-- review-bot:sonnet -->"),
},
postedID: 100,
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "same user APPROVED sibling (should NOT escalate)",
reviews: []gitea.Review{
makeReview(101, "bot", "APPROVED", false, "good\n<!-- review-bot:security -->"),
},
postedID: 100,
ownLogin: "bot",
ownSentinel: "<!-- 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,
ownLogin: "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,
ownLogin: "bot",
ownSentinel: "<!-- review-bot:sonnet -->",
want: false,
},
}
for _, tc := range tests { result := buildSupersededBody(original, "abcdef1234567890", newURL, sentinel)
t.Run(tc.name, func(t *testing.T) {
got := shouldEscalate(tc.reviews, tc.postedID, tc.ownLogin, tc.ownSentinel) // Should contain the struck-through banner
if got != tc.want { if !strings.Contains(result, "~~Original review~~") {
t.Errorf("shouldEscalate() = %v, want %v", got, tc.want) 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 TestReviewUnchanged(t *testing.T) { func TestBuildSupersededBodyShortSHA(t *testing.T) {
tests := []struct { // Short SHA should pass through without panic
name string result := buildSupersededBody("body", "abc", "https://example.com/review", "<!-- review-bot:x -->")
existing []gitea.Review if !strings.Contains(result, "abc") {
newBody string t.Error("short SHA not preserved")
newEvent string
sentinel string
want bool
}{
{
name: "no existing review",
existing: nil,
newBody: "new review",
newEvent: "APPROVED",
sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "identical body and state",
existing: []gitea.Review{
makeReview(100, "bot", "APPROVED", false, "same body\n<!-- review-bot:sonnet -->"),
},
newBody: "same body\n<!-- review-bot:sonnet -->",
newEvent: "APPROVED",
sentinel: "<!-- review-bot:sonnet -->",
want: true,
},
{
name: "same body but different state",
existing: []gitea.Review{
makeReview(100, "bot", "APPROVED", false, "body\n<!-- review-bot:sonnet -->"),
},
newBody: "body\n<!-- review-bot:sonnet -->",
newEvent: "REQUEST_CHANGES",
sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "different body same state",
existing: []gitea.Review{
makeReview(100, "bot", "APPROVED", false, "old body\n<!-- review-bot:sonnet -->"),
},
newBody: "new body\n<!-- review-bot:sonnet -->",
newEvent: "APPROVED",
sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "stale review with same body (should still post)",
existing: []gitea.Review{
makeReview(100, "bot", "APPROVED", true, "same\n<!-- review-bot:sonnet -->"),
},
newBody: "same\n<!-- review-bot:sonnet -->",
newEvent: "APPROVED",
sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
{
name: "different sentinel (not our review)",
existing: []gitea.Review{
makeReview(100, "bot", "APPROVED", false, "body\n<!-- review-bot:gpt -->"),
},
newBody: "body\n<!-- review-bot:sonnet -->",
newEvent: "APPROVED",
sentinel: "<!-- review-bot:sonnet -->",
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := reviewUnchanged(tc.existing, tc.newBody, tc.newEvent, tc.sentinel)
if got != tc.want {
t.Errorf("reviewUnchanged() = %v, want %v", got, tc.want)
}
})
} }
} }
@@ -268,6 +144,32 @@ func TestFindOwnReview(t *testing.T) {
sentinel: "<!-- review-bot:sonnet -->", sentinel: "<!-- review-bot:sonnet -->",
wantID: 20, 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 { for _, tc := range tests {
@@ -288,3 +190,654 @@ func TestFindOwnReview(t *testing.T) {
}) })
} }
} }
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
}
func TestIsPatternFile(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"README.md", true},
{"docs/GUIDE.MD", true},
{"config.yml", true},
{"config.yaml", true},
{"notes.txt", true},
{"NOTES.TXT", true},
{"main.go", false},
{"lib.rs", false},
{"index.js", false},
{"Makefile", false},
{"", false},
{"doc.pdf", false},
{"patterns.Yml", true},
{"deep/path/file.yaml", true},
}
for _, tc := range tests {
t.Run(tc.path, func(t *testing.T) {
got := isPatternFile(tc.path)
if got != tc.want {
t.Errorf("isPatternFile(%q) = %v, want %v", tc.path, got, tc.want)
}
})
}
}
func TestEvaluateCIStatus(t *testing.T) {
tests := []struct {
name string
statuses []gitea.CommitStatus
wantPassed bool
wantSubstr string
}{
{
name: "empty statuses",
statuses: nil,
wantPassed: true,
wantSubstr: "no CI statuses",
},
{
name: "all success",
statuses: []gitea.CommitStatus{
{Status: "success", Context: "ci/build", Description: "Build passed"},
{Status: "success", Context: "ci/test", Description: "Tests passed"},
},
wantPassed: true,
wantSubstr: "all checks passed",
},
{
name: "one failure",
statuses: []gitea.CommitStatus{
{Status: "success", Context: "ci/build", Description: "Build passed"},
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
},
wantPassed: false,
wantSubstr: "ci/test",
},
{
name: "error status",
statuses: []gitea.CommitStatus{
{Status: "error", Context: "ci/lint", Description: "Lint error"},
},
wantPassed: false,
wantSubstr: "ci/lint",
},
{
name: "pending treated as not-failed",
statuses: []gitea.CommitStatus{
{Status: "pending", Context: "ci/build", Description: "In progress"},
{Status: "success", Context: "ci/test", Description: "Tests passed"},
},
wantPassed: true,
wantSubstr: "all checks passed",
},
{
name: "multiple failures",
statuses: []gitea.CommitStatus{
{Status: "failure", Context: "ci/build", Description: "Build failed"},
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
},
wantPassed: false,
wantSubstr: "ci/build",
},
{
name: "mixed with pending and failure",
statuses: []gitea.CommitStatus{
{Status: "success", Context: "ci/build", Description: "Build passed"},
{Status: "pending", Context: "ci/deploy", Description: "Deploying"},
{Status: "failure", Context: "ci/test", Description: "Tests failed"},
},
wantPassed: false,
wantSubstr: "ci/test",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
passed, details := evaluateCIStatus(tc.statuses)
if passed != tc.wantPassed {
t.Errorf("evaluateCIStatus() passed = %v, want %v", passed, tc.wantPassed)
}
if !strings.Contains(details, tc.wantSubstr) {
t.Errorf("evaluateCIStatus() details = %q, want substring %q", details, tc.wantSubstr)
}
})
}
}
func TestEnvOrDefault(t *testing.T) {
// Test with unset env var
os.Unsetenv("TEST_ENV_OR_DEFAULT_UNSET")
got := envOrDefault("TEST_ENV_OR_DEFAULT_UNSET", "fallback")
if got != "fallback" {
t.Errorf("envOrDefault(unset) = %q, want %q", got, "fallback")
}
// Test with set env var
os.Setenv("TEST_ENV_OR_DEFAULT_SET", "custom")
defer os.Unsetenv("TEST_ENV_OR_DEFAULT_SET")
got = envOrDefault("TEST_ENV_OR_DEFAULT_SET", "fallback")
if got != "custom" {
t.Errorf("envOrDefault(set) = %q, want %q", got, "custom")
}
// Test with empty env var (should return default)
os.Setenv("TEST_ENV_OR_DEFAULT_EMPTY", "")
defer os.Unsetenv("TEST_ENV_OR_DEFAULT_EMPTY")
got = envOrDefault("TEST_ENV_OR_DEFAULT_EMPTY", "fallback")
if got != "fallback" {
t.Errorf("envOrDefault(empty) = %q, want %q", got, "fallback")
}
}
func TestEnvOrDefaultFloat(t *testing.T) {
// Test with unset env var
os.Unsetenv("TEST_ENV_FLOAT_UNSET")
got := envOrDefaultFloat("TEST_ENV_FLOAT_UNSET", 1.5)
if got != 1.5 {
t.Errorf("envOrDefaultFloat(unset) = %f, want %f", got, 1.5)
}
// Test with valid float
os.Setenv("TEST_ENV_FLOAT_SET", "2.7")
defer os.Unsetenv("TEST_ENV_FLOAT_SET")
got = envOrDefaultFloat("TEST_ENV_FLOAT_SET", 1.5)
if got != 2.7 {
t.Errorf("envOrDefaultFloat(set) = %f, want %f", got, 2.7)
}
// Test with invalid float (should return default)
os.Setenv("TEST_ENV_FLOAT_INVALID", "not-a-number")
defer os.Unsetenv("TEST_ENV_FLOAT_INVALID")
got = envOrDefaultFloat("TEST_ENV_FLOAT_INVALID", 3.14)
if got != 3.14 {
t.Errorf("envOrDefaultFloat(invalid) = %f, want %f", got, 3.14)
}
// Test with empty string (should return default)
os.Setenv("TEST_ENV_FLOAT_EMPTY", "")
defer os.Unsetenv("TEST_ENV_FLOAT_EMPTY")
got = envOrDefaultFloat("TEST_ENV_FLOAT_EMPTY", 0.5)
if got != 0.5 {
t.Errorf("envOrDefaultFloat(empty) = %f, want %f", got, 0.5)
}
}
func TestEnvOrDefaultInt(t *testing.T) {
// Test with unset env var
os.Unsetenv("TEST_ENV_INT_UNSET")
got := envOrDefaultInt("TEST_ENV_INT_UNSET", 42)
if got != 42 {
t.Errorf("envOrDefaultInt(unset) = %d, want %d", got, 42)
}
// Test with valid int
os.Setenv("TEST_ENV_INT_SET", "100")
defer os.Unsetenv("TEST_ENV_INT_SET")
got = envOrDefaultInt("TEST_ENV_INT_SET", 42)
if got != 100 {
t.Errorf("envOrDefaultInt(set) = %d, want %d", got, 100)
}
// Test with invalid int (should return default)
os.Setenv("TEST_ENV_INT_INVALID", "abc")
defer os.Unsetenv("TEST_ENV_INT_INVALID")
got = envOrDefaultInt("TEST_ENV_INT_INVALID", 42)
if got != 42 {
t.Errorf("envOrDefaultInt(invalid) = %d, want %d", got, 42)
}
// Test with empty string (should return default)
os.Setenv("TEST_ENV_INT_EMPTY", "")
defer os.Unsetenv("TEST_ENV_INT_EMPTY")
got = envOrDefaultInt("TEST_ENV_INT_EMPTY", 99)
if got != 99 {
t.Errorf("envOrDefaultInt(empty) = %d, want %d", got, 99)
}
// Test with negative int
os.Setenv("TEST_ENV_INT_NEG", "-5")
defer os.Unsetenv("TEST_ENV_INT_NEG")
got = envOrDefaultInt("TEST_ENV_INT_NEG", 42)
if got != -5 {
t.Errorf("envOrDefaultInt(negative) = %d, want %d", got, -5)
}
}
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) {
tests := []struct {
body string
want string
}{
{"<!-- review-bot:sonnet --> rest", "sonnet"},
{"<!-- review-bot:gpt-review --> rest", "gpt-review"},
{"no sentinel here", "unknown"},
{"<!-- review-bot:", "unknown"}, // prefix but no suffix
{"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text
}
for _, tc := range tests {
got := extractSentinelName(tc.body)
if got != tc.want {
t.Errorf("extractSentinelName(%q) = %q, want %q", tc.body, got, tc.want)
}
}
}
// TestMainSubprocess runs main() as a subprocess using the test binary itself.
// This allows coverage to be captured for main() code paths.
func TestMainSubprocess_Version(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
// Reset flags for main()
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot", "--version"}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_Version")
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("--version subprocess failed: %v\n%s", err, out)
}
if !strings.Contains(string(out), "review-bot") {
t.Errorf("--version output = %q, want to contain 'review-bot'", string(out))
}
}
func TestMainSubprocess_MissingFlags(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
// Reset flags for main()
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot"}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_MissingFlags")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit with no flags, got success")
}
if !strings.Contains(string(out), "missing required") {
t.Errorf("expected error about missing flags, got: %s", out)
}
}
func TestMainSubprocess_InvalidReviewerName(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot",
"--gitea-url", "http://localhost",
"--repo", "owner/repo",
"--pr", "1",
"--reviewer-name", "invalid name",
"--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_InvalidReviewerName")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit with invalid reviewer-name, got success")
}
if !strings.Contains(string(out), "invalid reviewer name") {
t.Errorf("expected error about invalid reviewer name, got: %s", out)
}
}
func TestMainSubprocess_InvalidRepo(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot",
"--gitea-url", "http://localhost",
"--repo", "invalidrepo",
"--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_InvalidRepo")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit with invalid repo format")
}
if !strings.Contains(string(out), "invalid repo format") {
t.Errorf("expected error about invalid repo, got: %s", out)
}
}
func TestMainSubprocess_InvalidPRNumber(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot",
"--gitea-url", "http://localhost",
"--repo", "owner/repo",
"--pr", "notanumber",
"--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_InvalidPRNumber")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit with invalid PR number")
}
if !strings.Contains(string(out), "invalid PR number") {
t.Errorf("expected error about invalid PR number, got: %s", out)
}
}
func TestMainSubprocess_InvalidTemperature(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot",
"--gitea-url", "http://localhost",
"--repo", "owner/repo",
"--pr", "1",
"--reviewer-token", "tok",
"--llm-base-url", "http://localhost",
"--llm-api-key", "key",
"--llm-model", "model",
"--llm-temperature", "5.0",
}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidTemperature")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit with invalid temperature")
}
if !strings.Contains(string(out), "invalid LLM temperature") {
t.Errorf("expected error about invalid temperature, got: %s", out)
}
}
func TestMainSubprocess_InvalidProvider(t *testing.T) {
if os.Getenv("TEST_SUBPROCESS_MAIN") == "1" {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"review-bot",
"--gitea-url", "http://localhost",
"--repo", "owner/repo",
"--pr", "1",
"--reviewer-token", "tok",
"--llm-base-url", "http://localhost",
"--llm-api-key", "key",
"--llm-model", "model",
"--llm-provider", "invalid-provider",
}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestMainSubprocess_InvalidProvider")
cmd.Env = append(cleanEnv(), "TEST_SUBPROCESS_MAIN=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("expected non-zero exit with invalid provider")
}
if !strings.Contains(string(out), "invalid LLM provider") {
t.Errorf("expected error about invalid provider, got: %s", out)
}
}
// cleanEnv returns environ without any GITEA/LLM/REVIEWER env vars that would
// interfere with testing missing-flag scenarios.
func cleanEnv() []string {
var env []string
for _, e := range os.Environ() {
key := strings.SplitN(e, "=", 2)[0]
switch {
case strings.HasPrefix(key, "GITEA_"),
strings.HasPrefix(key, "LLM_"),
strings.HasPrefix(key, "REVIEWER_"),
strings.HasPrefix(key, "PR_"),
strings.HasPrefix(key, "LOG_"),
strings.HasPrefix(key, "CONVENTIONS_"),
strings.HasPrefix(key, "SYSTEM_PROMPT_"),
strings.HasPrefix(key, "PATTERNS_"),
strings.HasPrefix(key, "UPDATE_"):
continue
default:
env = append(env, e)
}
}
return env
}
+155 -15
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 {
@@ -59,6 +82,7 @@ type ChangedFile struct {
// ReviewComment represents an inline comment to attach to a review. // ReviewComment represents an inline comment to attach to a review.
type ReviewComment struct { type ReviewComment struct {
ID int64 `json:"id,omitempty"`
Path string `json:"path"` Path string `json:"path"`
NewPosition int64 `json:"new_position"` NewPosition int64 `json:"new_position"`
Body string `json:"body"` Body string `json:"body"`
@@ -66,7 +90,7 @@ type ReviewComment struct {
// 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)
@@ -80,7 +104,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)
@@ -90,7 +114,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)
@@ -104,7 +128,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)
@@ -118,7 +142,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)
@@ -128,7 +152,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)
@@ -140,7 +164,7 @@ func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, r
// event should be "APPROVED" or "REQUEST_CHANGES". // event should be "APPROVED" or "REQUEST_CHANGES".
// 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 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, owner, 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"`
@@ -201,7 +225,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)
} }
@@ -230,9 +254,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 {
@@ -254,10 +278,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
@@ -268,14 +297,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 {
@@ -295,6 +324,7 @@ type Review struct {
} `json:"user"` } `json:"user"`
State string `json:"state"` State string `json:"state"`
Stale bool `json:"stale"` Stale bool `json:"stale"`
CommitID string `json:"commit_id"`
} }
// ListReviews returns all reviews on a pull request. // ListReviews returns all reviews on a pull request.
@@ -431,3 +461,113 @@ func (c *Client) EditComment(ctx context.Context, owner, repo string, commentID
} }
return nil return nil
} }
// GetAuthenticatedUser returns the login of the user authenticated by the token.
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
reqURL := fmt.Sprintf("%s/api/v1/user", c.baseURL)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("get authenticated user: %w", err)
}
var result struct {
Login string `json:"login"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse user response: %w", err)
}
return result.Login, nil
}
// RequestReviewer adds the given user as a requested reviewer on a pull request.
// This is idempotent — requesting an already-requested reviewer is a no-op.
func (c *Client) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/requested_reviewers",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
number)
payload := struct {
Reviewers []string `json:"reviewers"`
}{Reviewers: []string{reviewer}}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal reviewer request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create reviewer 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("request reviewer: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("request reviewer failed (status %d): %s", resp.StatusCode, body)
}
return nil
}
// ListReviewComments returns the inline comments attached to a specific review.
// Paginates through all pages.
func (c *Client) ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]ReviewComment, error) {
const pageSize = 50
var all []ReviewComment
for page := 1; ; page++ {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews/%d/comments?limit=%d&page=%d",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
prNumber,
reviewID,
pageSize,
page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list review comments (page %d): %w", page, err)
}
var batch []ReviewComment
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("parse review comments (page %d): %w", page, err)
}
all = append(all, batch...)
if len(batch) < pageSize {
break
}
}
return all, nil
}
// ResolveComment marks an inline review comment as resolved.
func (c *Client) ResolveComment(ctx context.Context, owner, repo string, commentID int64) error {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/comments/%d/resolve",
c.baseURL,
url.PathEscape(owner),
url.PathEscape(repo),
commentID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
if err != nil {
return fmt.Errorf("create resolve request: %w", err)
}
req.Header.Set("Authorization", "token "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("resolve comment: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return fmt.Errorf("resolve comment failed (status %d): %s", resp.StatusCode, body)
}
return nil
}
+238
View File
@@ -3,9 +3,12 @@ package gitea
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@@ -505,3 +508,238 @@ func TestGetTimelineReviewCommentID_NotFound(t *testing.T) {
t.Fatal("expected error when sentinel not found") 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)
}
})
}
}
func TestGetAuthenticatedUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "token test-token" {
t.Error("missing or wrong auth header")
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"login":"my-bot","id":42}`)
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
login, err := client.GetAuthenticatedUser(context.Background())
if err != nil {
t.Fatalf("GetAuthenticatedUser() error = %v", err)
}
if login != "my-bot" {
t.Errorf("login = %q, want %q", login, "my-bot")
}
}
func TestRequestReviewer(t *testing.T) {
var gotBody []byte
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/7/requested_reviewers"
if r.URL.Path != expected {
t.Errorf("path = %q, want %q", r.URL.Path, expected)
}
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.RequestReviewer(context.Background(), "owner", "repo", 7, "bot-user")
if err != nil {
t.Fatalf("RequestReviewer() error = %v", err)
}
if !strings.Contains(string(gotBody), `"bot-user"`) {
t.Errorf("body = %s, want to contain bot-user", gotBody)
}
}
func TestRequestReviewer_204(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
if err != nil {
t.Fatalf("RequestReviewer() should accept 204, got error = %v", err)
}
}
func TestRequestReviewer_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, "no permission")
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
if err == nil {
t.Fatal("expected error for 403 response")
}
if !strings.Contains(err.Error(), "403") {
t.Errorf("error should mention status code: %v", err)
}
}
func TestListReviewComments(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/pulls/1/reviews/42/comments") {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `[{"id":100,"path":"main.go","new_position":5,"body":"finding"},{"id":101,"path":"lib.go","new_position":10,"body":"another"}]`)
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
comments, err := client.ListReviewComments(context.Background(), "owner", "repo", 1, 42)
if err != nil {
t.Fatalf("ListReviewComments() error = %v", err)
}
if len(comments) != 2 {
t.Fatalf("got %d comments, want 2", len(comments))
}
if comments[0].ID != 100 {
t.Errorf("comments[0].ID = %d, want 100", comments[0].ID)
}
if comments[1].Path != "lib.go" {
t.Errorf("comments[1].Path = %q, want %q", comments[1].Path, "lib.go")
}
}
func TestResolveComment(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)
}
if !strings.Contains(r.URL.Path, "/pulls/comments/99/resolve") {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
if err != nil {
t.Fatalf("ResolveComment() error = %v", err)
}
}
func TestResolveComment_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, "not found")
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
if err == nil {
t.Fatal("expected error for 404 response")
}
}