Compare commits

..

52 Commits

Author SHA1 Message Date
claw 8c3c9968de fix(cmd): remove duplicate doc comment and double blank line
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 19s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 1m16s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m46s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 4m20s
2026-05-13 06:43:37 -07:00
claw d4d369798c docs(cmd,github): clarify type assertion and parameter usage in review superseding
Address sonnet-review feedback on PR #106:

- Document that the type assertion in supersedeOldReviews is guaranteed to
  succeed given the caller's provider switch, with the !ok branch guarding
  against future refactors (comment 18889).
- Clarify that vcsURL is only used in the Gitea path for constructing
  review permalink URLs (comment 18890).
- Add note explaining why the page-limit warning in ListReviews only fires
  when the final page is full, confirming the logic is intentional
  (comment 18891).
2026-05-13 06:43:37 -07:00
claw ce1d9760d9 fix: address self-review findings
- Remove dead code: findOwnReview (replaced by findAllOwnReviews)
- Check SetRetryBackoff return value in doJSONRequest tests
- Extract doWithRetry shared helper to eliminate ~100 lines of
  duplicated 429-retry/backoff/Retry-After logic between doRequest
  and doJSONRequest
- Fix import order: context before encoding/json (goimports)
- Add slog.Warn when ListReviews hits maxReviewPages limit
2026-05-13 06:43:37 -07:00
claw c81698efb1 fix: address review feedback on PR #106
- Add 429 rate-limit retry logic to doJSONRequest (matching doRequest
  behavior) so write operations (PostReview, DismissReview) properly
  retry when rate-limited by GitHub
- Remove redundant explicit case for ReviewEventComment in
  translateReviewEvent (default already handles it)
- Add ordering comment on --gitea-url alias registration explaining
  the dependency on registration-before-parse evaluation order
- Add tests for doJSONRequest retry/exhaust behavior
2026-05-13 06:43:37 -07:00
claw 83a538f138 fix(review): address bot review feedback on PR #106
- Document --gitea-url/--vcs-url last-one-wins behavior when both flags
  are passed simultaneously (sonnet MINOR #1)
- Move doJSONRequest from github/reviews.go to github/client.go where
  other HTTP helpers live (sonnet MINOR #2)
- Return joined error from supersedeOldReviews GitHub case instead of
  silently swallowing DismissReview failures (sonnet MINOR #3)
- Fix evaluateCIStatus to distinguish 'all checks passed' from 'no
  failures (N pending)' to avoid misleading status (gpt MINOR #2)
- Extract reviewsPerPage and maxReviewPages named constants for
  ListReviews pagination (gpt NIT #3)
2026-05-13 06:43:37 -07:00
claw e3b9ade5cd fix(review): address inline review feedback on PR #106
- Reword misleading 'Fall through' comment to 'Continue to' in
  supersedeOldReviews (comment #18704)
- Add shared-pointer explanation comment for --gitea-url alias
  registration (comment #18703)
- Add comment clarifying CommitID same-commit expectation in
  PostReview (comment #18705)
- Rename 'hidden alias' to 'backward-compatible alias' in flag
  comment (comment #18708)
2026-05-13 06:43:37 -07:00
claw 64ff7cfe2b fix(cmd): clarify empty gitea case control flow in supersedeOldReviews
The empty case "gitea": body exits the switch and continues to the
Gitea-specific logic below. Replace the vague comment with an explicit
note about the fall-through intent, per self-review feedback.
2026-05-13 06:43:37 -07:00
claw 5d1abaa0e7 fix(cmd,github): address review feedback on PR #106
- Replace panic() with fmt.Fprintf+os.Exit(1) in provider switch default
  (repo convention: never panic)
- Remove spurious 'event' field from DismissReview payload (GitHub dismiss
  endpoint only documents 'message')
- Change translateReviewEvent default to return 'COMMENT' as canonical
  fallback instead of passing unknown events through to GitHub API
- Refactor supersedeOldReviews to use explicit switch/case with default
  error for exhaustiveness
2026-05-13 06:43:37 -07:00
claw b7acd8a2ea fix: address review feedback on PR #106
- Replace interface{} with any in github/reviews.go (Go 1.18+ idiom)
- Add default panic case to VCS client init switch
- Refactor supersedeOldReviews to return error instead of os.Exit(1)
- Remove spurious blank lines in formatter.go and formatter_test.go
- Add doc comment to DeleteReview explaining when to use vs DismissReview
- Sanitize extractSentinelName output to prevent log injection
2026-05-13 06:43:37 -07:00
claw cd4521feb1 feat(cmd): wire --provider and --base-url flags into CLI
- Add --provider flag (gitea|github) for VCS backend selection
- Add --base-url flag for GitHub API endpoint configuration
- Rename --gitea-url to --vcs-url with backward-compatible alias
- Replace direct gitea.Client usage with vcs.Client interface
- Create vcs.Client via factory switch based on --provider value
- Implement Reviewer + Identity interfaces on github.Client
- Add verdictToEvent() using canonical vcs.ReviewEvent types
- Remove review.GiteaEvent() (replaced by verdictToEvent)
- GitHub supersede uses DismissReview; Gitea keeps EditComment flow
- Add VCS_PROVIDER, VCS_BASE_URL, VCS_URL env var support

Closes #82
2026-05-13 06:43:37 -07:00
claw 943b862dd2 fix(test): SetRetryBackoff with correct slice length
Pass 2 elements to SetRetryBackoff (matching maxRetryAttempts-1 = 2)
and check the error return. Previously passing 1 element silently
failed, causing tests to fall back to default {1s, 2s} backoffs.

Fixes self-review finding: 429Retry tests now run in <10ms instead
of ~1s.
2026-05-13 06:42:39 -07:00
aweiker 25888a71ea feat(github): implement FileReader interface (#80)
Implement FileReader conformance on the GitHub client: GetFileContent,
ListContents, path helpers, base64 decode. Includes compile-time
conformance checks for both PRReader and FileReader.

Requires PR B (#102). Part 3 of 3 for #80.
2026-05-13 06:42:39 -07:00
claw e3b11909e7 fix: address review feedback on PRReader implementation
- Add maxFileContentSize (10 MB) limit to decodeBase64Content to prevent
  resource exhaustion from oversized file content (security MINOR)
- Fix reversed NewClient arg order in TestGetFileContentAtRef_DotSegmentError
  (GPT MINOR + Sonnet NIT)
- Remove 'waiting' from mapCheckRunStatus conclusion cases since it is a
  status value not a conclusion, update comment (GPT NIT)
- Add TestDecodeBase64Content_SizeLimit test
2026-05-13 06:42:39 -07:00
claw 217bc60084 fix(github): escapePath returns error on dot-segments, fix Description semantics
- escapePath now returns an error when paths contain dot-segments
  (".", "..") instead of silently rewriting them. This prevents
  subtle API misses where callers pass "foo/../bar" expecting to
  hit "bar" but the old code produced "foo/bar".
- Uses path.Clean for canonical form after validation.
- CommitStatus.Description for check runs is now empty string
  instead of the raw conclusion enum. The conclusion is already
  captured in the Status field via mapCheckRunStatus; storing it
  again in Description was semantically inconsistent with commit
  statuses where Description carries a human-readable narrative.
- Removed unused derefString helper.
- Added tests for escapePath valid paths, dot-segment rejection,
  and GetFileContentAtRef dot-segment error propagation.
2026-05-13 06:42:39 -07:00
claw 70d9815bf5 fix: address review feedback on PR #102
- Separate maxPages into maxFilesPages and maxCheckRunPages constants
  for clarity (sonnet MINOR #1)
- Add parallel to CheckRunConclusions subtests (sonnet MINOR #2)
- Add TestGetCommitStatuses_CheckRunsErrorAfterStatusesSucceed test
  covering check-runs 500 after statuses succeed (sonnet MINOR #2)
- Expand mapCheckRunStatus doc comment with full mapping rules including
  cancelled/skipped/neutral rationale and unknown value behavior
  (sonnet MINOR #3, gpt MINOR #1)
- Expand GetPullRequest doc comment to mention error types returned
  (sonnet NIT #4)
- Add inline comment on Description field clarifying it holds raw
  conclusion value (gpt NIT #3)
2026-05-13 06:42:39 -07:00
claw 330532bc1a fix(github): add GetFileContentAtRef and fix conformance test
- Implement GetFileContentAtRef on *Client to satisfy vcs.PRReader interface
- Add escapePath and decodeBase64Content helpers
- Fix conformance_test.go to properly import and qualify github.Client
  (was using unqualified Client in package github_test)

Fixes CI failure: the PRReader interface requires GetFileContentAtRef
but it was missing from this PR (only present in the file-reader PR).
2026-05-13 06:42:39 -07:00
aweiker 8080b7a03c feat(github): implement PRReader interface (#80)
Implement PRReader conformance on the GitHub client: GetPullRequest,
GetPullRequestDiff, GetPullRequestFiles (paginated, populates Patch),
GetCommitStatuses (merges commit statuses + check runs).
Adds compile-time PRReader conformance check.

Requires PR A. Part 2 of 3 for #80.
2026-05-13 06:42:39 -07:00
claw 780ad3c22e fix(github): address review findings - remove panic, validate at config time
- MAJOR #1: Replace panic in doRequest with safe default fallback.
  Validation now happens in SetRetryBackoff (returns error on invalid
  length). doRequest gracefully falls back to default backoff if the
  configured slice is somehow invalid.

- MINOR #2: SetRetryBackoff validates slice length at configuration
  time, making the coupling between maxRetryAttempts and backoff
  explicit and catching mismatches early with a clear error.

- MINOR #4: Reword oversized response error to remove '(truncated)'
  which implied truncated data was returned when actually only an
  error is returned.

- MINOR #5: Functional options kept as-is - idiomatic Go pattern
  that allows future growth without breaking the API.
2026-05-13 06:42:39 -07:00
claw cdc660c15f fix(github): clarify response ownership and validate backoff length
Address review feedback on PR #101:

1. Capture resp.StatusCode and Retry-After header *before* passing resp
   to handleResponse, making ownership transfer explicit. Previously the
   caller read resp.StatusCode after handleResponse had closed the body —
   correct but fragile coupling.

2. Add panic guard ensuring backoff slice length equals maxAttempts-1.
   Previously the relationship was implicit and could silently break if
   maxAttempts were changed without updating the default backoff.
2026-05-13 06:42:39 -07:00
aweiker eb5457f51e feat(github): implement GitHub API client foundation (#80)
Add GitHub API client with configurable base URL and GHE support,
HTTP helpers with 429 retry and Retry-After handling.
Also adds Patch field to vcs.ChangedFile.

Part 1 of 3 for #80.
2026-05-13 06:42:39 -07:00
claw 3245fecd44 Wrap errors in GetPullRequest and PostReview for consistency
Add fmt.Errorf wrapping to the two remaining unwrapped error returns
in the adapter:
- GetPullRequest: 'get pull request: %w'
- PostReview (final client call): 'post review: %w'

This makes all error paths in the adapter consistent with the wrapping
pattern used by the diff-fetch and position-translation errors.

Addresses self-review findings #1 and #2 from b2eea502.
2026-05-13 06:42:39 -07:00
claw c86510b65d refactor(gitea): address review feedback on PR #90
- position.go: Replace O(n) maxPosition scan with O(1) lookup by
  tracking max position during map construction. Also eliminates
  shadowing of the builtin max identifier (Go 1.21+).
- position.go: Add comment clarifying +++ prefix ordering intent.
- adapter.go: Document diff-fetch tradeoff in PostReview.
- adapter_test.go: Remove extra blank line between test functions.
2026-05-13 06:42:39 -07:00
claw 701f0bed64 fix: address self-review findings on PR #90
- Remove unused error return from BuildPositionToLineMap (always nil)
- Add comment explaining intentional CommitID drop in PostReview
- Refactor TestAdapter_PostReview_WithComments to route by URL path
- Add TestAdapter_GetFileContent_RefRouting test
- Acknowledge maxPosition O(n) with code comment
- Remove redundant TestAdapter_CompileTimeCheck (compile-time var _ exists)
- Fix GetPullRequestFiles comment (Patch field is omitted, not 'set to empty')
- Acknowledge translateEvent fallback as intentional design
2026-05-13 06:42:39 -07:00
claw 928e2fa182 feat(vcs): Gitea adapter with diff-position translation
Implements the Gitea adapter (gitea.Adapter) that satisfies vcs.Client.

Key components:
- gitea/adapter.go: Adapter struct wrapping *Client with all vcs.Client methods
- gitea/position.go: BuildPositionToLineMap for diff-position → line translation
- gitea/adapter_test.go: Tests for all mapping methods and event translation
- gitea/position_test.go: Tests for position translation edge cases

Translation details:
- ReviewEvent: APPROVE → APPROVED (Gitea-native)
- PostReview: fetches diff, builds position map, translates each comment
- Deletion-targeted positions map to nearest non-deletion line below
- All field-mapping methods tested (GetPullRequest, GetPullRequestFiles,
  ListReviews, GetCommitStatuses, ListContents)

Also:
- Added Base field to gitea.PullRequest struct
- Updated conformance tests to assert Adapter (not raw Client) satisfies vcs.Client
- Removed phase2 build tag from conformance tests

Closes #79
2026-05-13 06:42:39 -07:00
claw 7b0bfea336 fix: address remaining review findings (interface assertions, DismissReview ctx, import order, filepath param, spelling) 2026-05-13 06:42:39 -07:00
claw b5a5533070 fix(vcs): address review findings on PR #88
Findings addressed:
- F1/G1: Add doc comment to GetAllFilesInPath documenting fail-fast contract
- F2/G2: Add explicit backslash-prefix guard to skip '\ No newline' markers
- F3: Add comment explaining position > 0 guard (skip lines before first hunk)
- F4: Refactor parseHunkNewStart to use strconv.Atoi instead of per-char concat
- F5: Add error propagation tests (ListContents, GetFileContent, nested, ctx cancel)
- F6: Wrap errors.ErrUnsupported in DismissReview for programmatic checking
- S1: Add ctx.Err() checks + max file count/byte constants with clear errors
- S2: Addressed by S1 — input bounds are now enforced via the same constants
2026-05-13 06:42:39 -07:00
claw cf0a33ccb2 feat(vcs): complete Phase 1 — util.go, type cleanup, interface additions (fixes #84, #85, #86)
- Create vcs/util.go with GetAllFilesInPath and BuildLineToPositionMap
- Create vcs/util_test.go with comprehensive tests for both functions
- Remove review.ContentEntry type, replace with vcs.ContentEntry
- Remove review.GiteaClient interface, replace with vcs.FileReader
- Update review/repo_persona.go to use vcs.FileReader
- Update review/repo_persona_test.go to use vcs.ContentEntry
- Update cmd/review-bot/main.go adapter to implement vcs.FileReader
- Add Number and Base fields to vcs.PullRequest
- Add CommitStatus type to vcs/types.go
- Add GetFileContentAtRef to vcs.PRReader interface
- Add GetCommitStatuses to vcs.PRReader interface
- Add DismissReview to vcs.Reviewer interface
- Add stub implementations on gitea.Client for new interface methods

Closes #84, Closes #85, Closes #86
2026-05-13 06:42:39 -07:00
claw 359f73c395 fix(vcs): address PR #83 review findings (round 2)
- Extract named HeadRef and UserInfo structs from anonymous structs
  in PullRequest and Review (comments 16615, 16616)
- Change ReviewEventApprove value from "APPROVED" to "APPROVE" to
  represent the action, not the state; document adapter translation
  responsibility (comment 16621)
- Add doc comment on ReviewComment.CommitID noting optionality (16531)
- Move compile-time assertion from check.go (//go:build ignore) to
  check_test.go with a "phase2" build tag — removes gitea adapter
  import from the vcs package (comment 16622)
- check.go misleading comment was already fixed in prior commit (16532, 16539)
- Sha→SHA, typed ReviewEvent, duplicate package doc already resolved (16537, 16538, 16530)
2026-05-13 06:42:39 -07:00
claw 99ec06dd15 fix(vcs): address Phase 1 review findings
- Rename PullRequest.Head.Sha → SHA (Go acronym convention)
- Add typed ReviewEvent alias with exported constants
- Remove duplicate package doc from types.go (kept in interfaces.go)
- Fix misleading comment in check.go
2026-05-13 06:42:39 -07:00
claw c7a33b000d docs(vcs): add package doc to interfaces.go 2026-05-13 06:42:39 -07:00
claw 7cc1c08943 feat(vcs): extract interfaces and types from gitea/ (Phase 1, #78)
Add vcs/interfaces.go and vcs/types.go as the foundation for multi-platform
VCS support. Interfaces are discovered from working gitea/client.go code,
not invented in a vacuum.

vcs/interfaces.go — role-based interfaces:
- PRReader: GetPullRequest, GetPullRequestDiff, GetPullRequestFiles
- FileReader: GetFileContent (path + ref), ListContents
- Reviewer: PostReview (ReviewRequest), ListReviews, DeleteReview
- Identity: GetAuthenticatedUser
- Client: all four composed

vcs/types.go — types extracted from gitea/:
- PullRequest, ChangedFile, ContentEntry, Review (identical to gitea/)
- ReviewComment: uses GitHub diff-position convention (Position int,
  CommitID string) instead of Gitea's NewPosition int64
- ReviewRequest: new type wrapping Body, Event, Comments

vcs/check.go (//go:build ignore) — documents the gaps gitea.Client
must bridge in Phase 2:
1. PostReview signature mismatch (event+body+[]ReviewComment vs ReviewRequest)
2. GetFileContent missing ref parameter
3. ReviewComment type mismatch (NewPosition vs Position/CommitID)

No behavior changes. All existing tests pass.
2026-05-13 06:42:39 -07:00
Rodin a641395f6b docs: flip design — extract interfaces from working gitea/ code
Key changes:
- Interface discovered from gitea/, not invented
- Gitea adapter first (Phase 1-2), GitHub second (Phase 3-5)
- Removed 'Open Questions' — all resolved:
  - Token: workflow GITHUB_TOKEN
  - Binary: GitHub releases on aweiker/ai-core-review-bot
  - Comment schema: adapter responsibility
- 8 phases with clear exit criteria
- Platform-specific features (resolve, timeline) stay on concrete client

Issue: #76
2026-05-13 06:42:39 -07:00
Rodin f944f1cd45 docs: rewrite design doc — feature-first, testable, phased
- Goal: AI code reviews on GitHub with AI Core
- Feature inventory with API mapping
- Small interfaces (PRReader, FileReader, Reviewer, Identity)
- Test plan: unit (mock HTTP) + integration (real GitHub)
- 7 implementation phases with exit criteria

Issue: #76
2026-05-13 06:42:39 -07:00
Rodin 45d94e7c53 docs: add VCS abstraction design doc
Outlines phased approach for GitHub support:
- Phase 1: Port github/ package from strat fork
- Phase 2: Add vcs/ interface with runtime detection
- Phase 3: Wire up cmd/review-bot

Issue: #76
2026-05-13 06:42:39 -07:00
Rodin 21cfb3493d feat: add GitHub Actions support
- Copy .gitea/ to .github/ for GitHub Actions compatibility
- Update .github/workflows to use GITHUB_SERVER_URL/GITHUB_REPOSITORY
- Update main.go to accept both GITEA_* and GITHUB_* env vars

Works on both Gitea and GitHub without code changes.
2026-05-13 06:42:39 -07:00
aweiker 881ce232eb Merge pull request 'docs(deps): update CONVENTIONS.md allowlist for go-yaml' (#108) from review-bot-issue-91 into main
CI / test (push) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
Reviewed-on: #108
Reviewed-by: security-review-bot <10+security-review-bot@noreply.gitea.weiker.me>
Reviewed-by: Aaron Weiker <aaron@weiker.org>
2026-05-13 13:16:40 +00:00
claw bf52fceea0 docs(deps): update CONVENTIONS.md allowlist for go-yaml
CI / test (pull_request) Successful in 20s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 20s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 48s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 51s
Update the approved dependency table to document go-yaml subpackage
usage (ast, parser) and remove the deviation comment now that the
proper allowlist process is being followed.

Closes #91
2026-05-13 02:56:06 -07:00
aweiker d722035629 Merge pull request 'fix(deps): replace gopkg.in/yaml.v3 with github.com/goccy/go-yaml' (#89) from review-bot-issue-87 into main
CI / test (push) Successful in 20s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
Reviewed-on: #89
Reviewed-by: security-review-bot <10+security-review-bot@noreply.gitea.weiker.me>
Reviewed-by: Aaron Weiker <aaron@weiker.org>
2026-05-13 03:47:01 +00:00
claw b9b7be3b4e fix: address review #2888 findings (comment clarity, test cleanup)
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 37s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m0s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m11s
- Clarify depth-aware short-circuit comment to unambiguously describe
  the relationship between current depth and previous validation depth
- Add comment to MappingValueNode case explaining intentional depth+2
  behavior from parent MappingNode perspective
- Restructure unmarshalYAMLWithDepthLimit doc comment as bullet list
  covering all three safety checks (depth, multi-doc, strict fields)
- Replace t.Error with t.Fatal in TestYAMLEmptyFileRejection to remove
  redundant nil guard on subsequent err.Error() call
2026-05-12 19:06:52 -07:00
claw baa917f228 fix: handle MergeKeyNode explicitly in depth check, add size limit to ParsePersonaBytes
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 35s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 58s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m16s
- Add explicit case for *ast.MergeKeyNode in checkYAMLDepth switch to
  make it clear this is an intentional leaf (no children to recurse)
  rather than relying on the default case. Prevents future library
  changes from silently bypassing depth checks.

- Add MaxPersonaFileSize bound check at the top of ParsePersonaBytes.
  While callers already check size, the public API should defend itself
  (defense in depth) against arbitrarily large inputs that could cause
  excessive memory/CPU before AST validation runs.

- Add tests for both behaviors.

Addresses review #2879 findings.
2026-05-12 18:45:48 -07:00
claw b0352ba1c9 docs: address review findings on YAML depth validation
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 34s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m20s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m49s
- Add safety note on Strict() decoder not expanding aliases recursively,
  since alias resolution uses the pre-validated AST (finding #1)
- Document that ast.Node map keys rely on pointer identity, which holds
  because all goccy/go-yaml AST types are pointer receivers (finding #2)
- Clarify AnchorNode comment: effective depth budget is reduced for
  anchor+alias pairs, not literally halved (finding #3)
- Improve test depth trace comment for accuracy (finding #4)
- Add HTML comment in CONVENTIONS.md referencing #91 for the two-step
  process deviation (finding #5)
2026-05-12 17:39:38 -07:00
claw 0b16c4143a test: use per-subtest TempDir in TestYAMLEmptyFileRejection
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 44s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m42s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m9s
Move t.TempDir() inside each subtest for idiomatic test isolation,
as suggested by reviewers.
2026-05-12 15:22:27 -07:00
claw 493349e11a fix: correct comment accuracy and improve trailing-content check clarity
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 19s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 34s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m10s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m47s
- Fix validated map comment: says 'minimum depth' but stores the maximum
  depth at which a node was validated (overwritten on deeper visits).
- Replace dec.More() with explicit dec.Decode check for trailing JSON
  content. More() is documented for use inside arrays/objects; the
  explicit EOF check is clearer at the top-level stream.
2026-05-12 14:51:49 -07:00
claw 5cedeee9f4 address self-review findings on PR #89
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m12s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m33s
MINOR fixes:
- docs/DESIGN-57-yaml-persona.md: fix Error Cases table entry to reflect
  custom AST walk (checkYAMLDepth) instead of stale library-level reference
- review/persona.go: add EOF check after JSON decode to reject trailing
  garbage after a valid JSON object (prevents silent acceptance of malformed
  input like '{"name":"x"}garbage')
- review/persona_test.go: add TestJSONTrailingContentRejected test

NIT fixes:
- review/persona.go: add default case to checkYAMLDepth switch with
  explanatory comment about scalar leaf nodes
- review/persona.go: document AnchorNode depth+1 conservative asymmetry
- review/persona.go: simplify redundant if-guard in ListBuiltinPersonas
2026-05-12 14:42:22 -07:00
claw 01b6af03a8 fix(review): address review 2792 feedback
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 33s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m11s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m53s
- Document nodeCount overcounting as intentional conservative behavior
  (bounds total validation work, not unique nodes)
- Improve TestYAMLDeeplyNestedRejection comment with concrete depth trace
- Replace outdated gopkg.in/yaml.v3 pseudocode in design doc with
  reference to authoritative implementation
- Update PR description to clarify pre-approval via issue #57
2026-05-12 14:24:06 -07:00
claw 80091fb080 fix(review): address feedback from reviews 2788, 2789, 2791
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 23s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m45s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m7s
- Move nodeCount increment after cycle detection to avoid over-counting
  cyclic references (sonnet #2)
- Use underscores in test case names used as filenames (sonnet #3)
- Fix function comment: 'prevent silent data loss' → 'prevent confusing
  behavior where additional documents are silently ignored' (sonnet #4)
- Mark design doc pseudocode as historical since implementation uses
  goccy/go-yaml ast.Node, not gopkg.in/yaml.v3 yaml.Node (sonnet #5)
2026-05-12 14:13:59 -07:00
claw b5f17ddfc4 fix(security): prevent alias depth bypass in YAML validator
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 38s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m18s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m20s
The global 'seen' set allowed anchored subtrees validated at a shallow
depth to be skipped when later referenced via alias at a greater depth.
This could let effective nesting exceed MaxYAMLDepth, enabling DoS.

Fix: replace the single 'seen' set with two tracking maps:
- validated (node -> min depth): only short-circuits when current depth
  <= previously validated depth; re-checks at deeper contexts.
- visiting (node -> bool): per-path recursion stack for true cycle
  detection (breaks alias loops without suppressing depth checks).

Add TestYAMLAliasDepthBypass that constructs a document with an
anchored 15-level subtree referenced via alias under 6 levels of
nesting, verifying the combined effective depth (22) is rejected.

Addresses security-review-bot findings on review #2774.
2026-05-12 14:07:05 -07:00
rodin 144a36a2a7 docs: update DESIGN-57 to reflect goccy/go-yaml as the supported YAML library
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 15s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 31s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m19s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m57s
2026-05-12 20:52:37 +00:00
rodin 12f5f5a5e4 docs: update YAML library to github.com/goccy/go-yaml in CONVENTIONS.md
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 16s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 28s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m4s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 2m16s
2026-05-12 20:52:31 +00:00
claw 45d009dd06 fix(review): address review feedback on persona YAML handling
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 30s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m5s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m8s
- Reorder empty doc check before multi-doc check for natural flow
- Detect nil-body docs (whitespace-only, comment-only input)
- Add explanatory comment on pointer identity for cycle detection map
- Improve depth-counting test comment with AST walker specifics
- Add TestYAMLEmptyFileRejection covering empty/whitespace/comment inputs

Addresses MINOR and NIT findings from sonnet, gpt, and security reviews.
MAJOR (allowlist violation) tracked in issue #91.
2026-05-12 13:38:48 -07:00
claw 8991260333 fix(deps): replace gopkg.in/yaml.v3 with github.com/goccy/go-yaml
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 46s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m38s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m49s
Fixes #87.

PR #58 incorrectly added gopkg.in/yaml.v3 (abandoned library) instead of
github.com/goccy/go-yaml as required by issue #57.

Changes:
- Replace gopkg.in/yaml.v3 with github.com/goccy/go-yaml v1.19.2
- Update review/persona.go to use goccy/go-yaml API:
  - parser.ParseBytes for AST-based depth/node count checking
  - yaml.Strict() decoder option instead of KnownFields(true)
  - ast.Node types instead of yaml.Node for tree walking
- Update review/persona_test.go to use ast types for cycle tests
- Remove gopkg.in/yaml.v3 from go.mod and go.sum

All existing YAML tests pass with the new library.
2026-05-12 13:27:30 -07:00
rodin 6f86e66943 fix(patterns): default patterns-files to empty (fetch all) (#77)
CI / test (push) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (push) Has been skipped
2026-05-11 17:45:19 +00:00
26 changed files with 810 additions and 1633 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
| Package | Use Case | Scope |
|---------|----------|-------|
| `gopkg.in/yaml.v3` | YAML parsing (persona files, config) | production |
| `github.com/goccy/go-yaml` | YAML parsing and AST inspection (subpkgs: `ast`, `parser`) | production |
| `github.com/google/go-cmp` | Test comparisons (`cmp.Diff`) | test only |
**Any import not in this table or the Go standard library is forbidden.**
+164 -34
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
@@ -70,7 +71,7 @@ func main() {
conventionsFile := flag.String("conventions-file", envOrDefault("CONVENTIONS_FILE", ""), "Conventions file path in repo (e.g. CLAUDE.md)")
systemPromptFile := flag.String("system-prompt-file", envOrDefault("SYSTEM_PROMPT_FILE", ""), "Local file with additional system prompt instructions")
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", ""), "Comma-separated file paths to fetch from patterns repo (empty = all files)")
dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting")
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)")
@@ -84,9 +85,16 @@ func main() {
aicoreAPIURL := flag.String("aicore-api-url", envOrDefault("AICORE_API_URL", ""), "SAP AI Core API URL (for provider=aicore)")
aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)")
// Backward-compatible alias: --gitea-url shares vcsURL's pointer (last flag wins).
// Must use *vcsURL as default: StringVar sets *p=value at registration, so empty
// string would overwrite the env-resolved value from the --vcs-url declaration.
// Register --gitea-url as a backward-compatible alias for --vcs-url.
// StringVar shares the *string pointer with vcsURL, so whichever flag is
// set last by flag.Parse wins — both point to the same underlying value.
// NOTE: If a user passes both --vcs-url and --gitea-url, the last one on
// the command line takes effect (standard flag package behavior). This is
// acceptable since --gitea-url is deprecated and both serve the same purpose.
//
// ORDERING: This must remain AFTER vcsURL's flag.String declaration and BEFORE
// flag.Parse(). The *vcsURL dereference captures the env-var-resolved default
// at registration time; moving flag.Parse() above this line would break it.
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
flag.Parse()
@@ -102,8 +110,10 @@ func main() {
slog.Info("review-bot starting", "version", version)
// Validate VCS provider
vcsProvider := vcs.VCSProvider(*provider)
if !vcsProvider.Valid() {
switch *provider {
case "gitea", "github":
// valid
default:
fmt.Fprintf(os.Stderr, "Error: invalid --provider %q (valid: gitea, github)\n", *provider)
os.Exit(1)
}
@@ -116,7 +126,7 @@ func main() {
os.Exit(1)
}
// --vcs-url is required only for gitea provider
if vcsProvider == vcs.ProviderGitea && *vcsURL == "" {
if *provider == "gitea" && *vcsURL == "" {
fmt.Fprintf(os.Stderr, "Error: --vcs-url (or --gitea-url) is required for provider=gitea\n")
os.Exit(1)
}
@@ -159,16 +169,21 @@ func main() {
// Initialize VCS client
var client vcs.Client
switch vcsProvider {
case vcs.ProviderGitea:
switch *provider {
case "gitea":
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
client = gitea.NewAdapter(giteaClient)
case vcs.ProviderGitHub:
client = github.NewClient(*reviewerToken, *baseURL)
case "github":
ghBaseURL := *baseURL
if ghBaseURL == "" {
ghBaseURL = "https://api.github.com"
}
client = github.NewClient(*reviewerToken, ghBaseURL)
default:
panic("unreachable: provider validation should have caught " + vcsProvider.String())
fmt.Fprintf(os.Stderr, "Error: unhandled provider %q\n", *provider)
os.Exit(1)
}
slog.Info("VCS client initialized", "provider", vcsProvider)
slog.Info("VCS client initialized", "provider", *provider)
// Initialize LLM client
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
@@ -417,7 +432,7 @@ func main() {
currentSHA = currentPR.Head.SHA
}
if shouldSkipStaleReview(evaluatedSHA, currentSHA) {
slog.Warn("HEAD moved during review -- skipping stale review",
slog.Warn("HEAD moved during review skipping stale review",
"evaluated", evaluatedSHA,
"current", currentSHA,
"pr", prNumber)
@@ -439,7 +454,7 @@ func main() {
inlineComments = append(inlineComments, vcs.ReviewComment{
Path: f.File,
Position: pos,
CommitID: evaluatedSHA,
CommitID: pr.Head.SHA,
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
})
}
@@ -465,12 +480,12 @@ func main() {
}
// Self-request as reviewer (Gitea-specific; ensures we appear in required-reviewer checks)
if selfReq, ok := client.(vcs.ReviewerSelfRequester); ok {
if giteaAdapter, ok := client.(*gitea.Adapter); ok {
authUser, err := client.GetAuthenticatedUser(ctx)
if err != nil {
slog.Warn("could not determine authenticated user for reviewer self-request", "error", err)
} else if authUser != "" {
if err := selfReq.RequestReviewerSelf(ctx, owner, repoName, prNumber, authUser); err != nil {
if err := giteaAdapter.Underlying().RequestReviewer(ctx, owner, repoName, prNumber, authUser); err != nil {
slog.Warn("could not self-request as reviewer", "user", authUser, "error", err)
} else {
slog.Debug("self-requested as reviewer", "user", authUser, "pr", prNumber)
@@ -485,7 +500,6 @@ func main() {
reviewReq := vcs.ReviewRequest{
Body: reviewBody,
Event: event,
CommitID: evaluatedSHA,
Comments: inlineComments,
}
posted, err := client.PostReview(ctx, owner, repoName, prNumber, reviewReq)
@@ -495,15 +509,11 @@ func main() {
}
slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
// Supersede all old reviews via optional interface
// Supersede all old reviews
if len(oldReviews) > 0 {
if superseder, ok := client.(vcs.ReviewSuperseder); ok {
if err := superseder.SupersedeReviews(ctx, owner, repoName, prNumber, oldReviews, posted.ID, *vcsURL, sentinel); err != nil {
slog.Error("failed to supersede old reviews", "error", err)
os.Exit(1)
}
} else {
slog.Error("provider does not support review superseding", "provider", vcsProvider)
if err := supersedeOldReviews(ctx, client, *provider, *vcsURL, owner, repoName, prNumber, oldReviews, posted.ID, sentinel); err != nil {
slog.Error("failed to supersede old reviews", "error", err)
os.Exit(1)
}
}
}
@@ -520,6 +530,84 @@ func verdictToEvent(verdict string) vcs.ReviewEvent {
}
}
// supersedeOldReviews marks prior reviews as superseded so only the latest review is visible.
// For GitHub: dismisses old reviews (vcsURL is unused in this path).
// For Gitea: edits the review body with a link to the new review and resolves inline comments.
//
// The vcsURL parameter is only used in the Gitea path to construct review permalink URLs;
// it is accepted unconditionally to keep the function signature uniform across providers.
func supersedeOldReviews(ctx context.Context, client vcs.Client, provider, vcsURL, owner, repoName string, prNumber int, oldReviews []vcs.Review, newReviewID int64, sentinel string) error {
switch provider {
case "github":
// Best-effort dismissal: attempt all reviews, join any errors.
var errs []error
for _, old := range oldReviews {
if err := client.DismissReview(ctx, owner, repoName, prNumber, old.ID, "Superseded by new review"); err != nil {
slog.Warn("failed to dismiss review", "id", old.ID, "error", err)
errs = append(errs, fmt.Errorf("dismiss review %d: %w", old.ID, err))
} else {
slog.Info("dismissed old review", "review_id", old.ID, "new_review_id", newReviewID, "pr", prNumber)
}
}
return errors.Join(errs...)
case "gitea":
// Continue to Gitea-specific logic below the switch.
default:
return fmt.Errorf("supersedeOldReviews: unsupported provider %q", provider)
}
// The type assertion below is guaranteed to succeed: the caller's provider switch
// ensures we only reach this point when provider == "gitea", and the gitea provider
// always constructs a *gitea.Adapter. The !ok branch guards against future refactors
// (e.g. wrapping the adapter in a decorator) that would silently break this path.
giteaAdapter, ok := client.(*gitea.Adapter)
if !ok {
return fmt.Errorf("expected gitea.Adapter for gitea provider, got %T", client)
}
underlying := giteaAdapter.Underlying()
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(vcsURL, "/"), owner, repoName, prNumber, newReviewID)
for _, oldReview := range oldReviews {
cid, err := underlying.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, prNumber, oldReview.ID)
if err != nil {
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
continue
}
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
if err := underlying.EditComment(ctx, owner, repoName, cid, supersededBody); err != nil {
slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "comment_id", cid, "error", err)
continue
}
slog.Info("marked old review as superseded", "review_id", oldReview.ID, "new_review_id", newReviewID, "pr", prNumber)
// Resolve old review's inline comments
oldComments, err := underlying.ListReviewComments(ctx, owner, repoName, prNumber, oldReview.ID)
if err != nil {
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
continue
}
resolved, failed := 0, 0
for _, c := range oldComments {
if c.ID == 0 {
continue
}
if err := underlying.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", "review_id", oldReview.ID, "count", resolved, "pr", prNumber)
}
if failed > 0 {
slog.Warn("some inline comments could not be resolved", "review_id", oldReview.ID, "failed", failed, "pr", prNumber)
}
}
return nil
}
// fetchFileContext fetches the full content of modified files from the PR branch.
func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref string, files []vcs.ChangedFile) string {
var sb strings.Builder
@@ -547,12 +635,25 @@ func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref
// patternsRepo is comma-separated list of owner/name repos.
// patternsFiles is comma-separated list of file paths or directories.
// If a path ends with / or is a directory, all files within it are fetched recursively.
// Empty entries in patternsFiles are skipped (no implicit repo-root fetch).
// If patternsFiles is empty, all files from the repo root are fetched.
func fetchPatterns(ctx context.Context, client vcs.FileReader, patternsRepo, patternsFiles string) string {
var sb strings.Builder
repos := strings.Split(patternsRepo, ",")
paths := strings.Split(patternsFiles, ",")
// Build the list of paths to fetch
var paths []string
if patternsFiles == "" {
// Empty patternsFiles means "fetch all files from repo root"
paths = []string{""}
} else {
for _, p := range strings.Split(patternsFiles, ",") {
p = strings.TrimSpace(p)
if p != "" {
paths = append(paths, p)
}
}
}
for _, repoRef := range repos {
if ctx.Err() != nil {
@@ -573,11 +674,6 @@ func fetchPatterns(ctx context.Context, client vcs.FileReader, patternsRepo, pat
var repoSkippedFiles []string
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
files, err := vcs.GetAllFilesInPath(ctx, client, owner, repo, path)
if err != nil {
slog.Warn("could not fetch patterns", "path", path, "repo", repoRef, "error", err)
@@ -672,6 +768,14 @@ func envOrDefaultInt(key string, defaultVal int) int {
return defaultVal
}
func envOrDefaultBool(key string, defaultVal bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
if v == "" {
return defaultVal
}
return v == "true" || v == "1" || v == "yes"
}
// validateReviewerName checks that the name contains only safe characters
// for embedding in an HTML comment sentinel ([a-zA-Z0-9_-]).
func validateReviewerName(name string) error {
@@ -723,6 +827,31 @@ func validateWorkspacePath(path, pathName string) (string, error) {
return resolvedPath, nil
}
// buildSupersededBody creates the body for a superseded review: struck-through banner
// with collapsed original content and the commit it was evaluated against.
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
shortSHA := commitSHA
if len(shortSHA) > 8 {
shortSHA = shortSHA[:8]
}
var sb strings.Builder
sb.WriteString("~~Original review~~\n\n")
sb.WriteString("**Superseded** \u2014 [see current review](")
sb.WriteString(newReviewURL)
sb.WriteString(") for up-to-date findings.\n\n")
if shortSHA != "" {
sb.WriteString("<details><summary>Previous findings (commit ")
sb.WriteString(shortSHA)
sb.WriteString(")</summary>\n\n")
} else {
sb.WriteString("<details><summary>Previous findings</summary>\n\n")
}
sb.WriteString(originalBody)
sb.WriteString("\n\n</details>\n\n")
sb.WriteString(sentinel)
return sb.String()
}
// hasSharedToken detects if another review-bot role posted under the same
// VCS user. This indicates misconfiguration where two roles share a token
// instead of having separate accounts. Returns true if shared token
@@ -740,7 +869,7 @@ func hasSharedToken(reviews []vcs.Review, ownSentinel string) bool {
}
for _, r := range reviews {
if r.User.Login == ownLogin && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
slog.Warn("shared token detected -- another review-bot role is using the same VCS user",
slog.Warn("shared token detected another review-bot role is using the same VCS user",
"sibling_role", extractSentinelName(r.Body), "user", ownLogin)
return true
}
@@ -778,6 +907,7 @@ func extractSentinelName(body string) string {
return name
}
// findAllOwnReviews returns all non-superseded reviews matching the sentinel.
func findAllOwnReviews(reviews []vcs.Review, sentinel string) []vcs.Review {
var result []vcs.Review
+138 -2
View File
@@ -162,6 +162,55 @@ func makeReview(id int64, login, state string, stale bool, body string) vcs.Revi
}
}
func TestBuildSupersededBody(t *testing.T) {
original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->"
sentinel := "<!-- review-bot:sonnet -->"
newURL := "https://gitea.example.com/owner/repo/pulls/1#pullrequestreview-99"
result := buildSupersededBody(original, "abcdef1234567890", newURL, sentinel)
// Should contain the struck-through banner
if !strings.Contains(result, "~~Original review~~") {
t.Error("missing struck-through banner")
}
// Should contain superseded notice with link
if !strings.Contains(result, "**Superseded**") {
t.Error("missing superseded notice")
}
if !strings.Contains(result, "[see current review]("+newURL+")") {
t.Error("missing link to new review")
}
// Should contain collapsed original
if !strings.Contains(result, "<details>") {
t.Error("missing details/collapse")
}
// Should contain short commit SHA
if !strings.Contains(result, "abcdef12") {
t.Error("missing short SHA")
}
// Should NOT contain full SHA
if strings.Contains(result, "abcdef1234567890") {
t.Error("should truncate SHA to 8 chars")
}
// Should contain the original body inside details
if !strings.Contains(result, original) {
t.Error("original body not preserved in collapsed section")
}
// Should end with sentinel
if !strings.Contains(result, sentinel) {
t.Error("missing sentinel")
}
}
func TestBuildSupersededBodyShortSHA(t *testing.T) {
// Short SHA should pass through without panic
result := buildSupersededBody("body", "abc", "https://example.com/review", "<!-- review-bot:x -->")
if !strings.Contains(result, "abc") {
t.Error("short SHA not preserved")
}
}
func TestHasSharedToken(t *testing.T) {
tests := []struct {
name string
@@ -367,6 +416,52 @@ func TestIsPatternFile(t *testing.T) {
}
}
// TestBuildPatternPaths verifies the path-building logic for fetchPatterns.
// Empty patternsFiles means "fetch all from root" (represented as [""]).
func TestBuildPatternPaths(t *testing.T) {
buildPaths := func(patternsFiles string) []string {
if patternsFiles == "" {
return []string{""}
}
var paths []string
for _, p := range strings.Split(patternsFiles, ",") {
p = strings.TrimSpace(p)
if p != "" {
paths = append(paths, p)
}
}
return paths
}
tests := []struct {
name string
input string
want []string
}{
{"empty fetches root", "", []string{""}},
{"single file", "README.md", []string{"README.md"}},
{"multiple files", "README.md,PATTERNS.md", []string{"README.md", "PATTERNS.md"}},
{"trims whitespace", " foo.md , bar.md ", []string{"foo.md", "bar.md"}},
{"skips empty between commas", "foo.md,,bar.md", []string{"foo.md", "bar.md"}},
{"directory path", "patterns/", []string{"patterns/"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := buildPaths(tc.input)
if len(got) != len(tc.want) {
t.Errorf("buildPaths(%q) = %v, want %v", tc.input, got, tc.want)
return
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("buildPaths(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i])
}
}
})
}
}
func TestEvaluateCIStatus(t *testing.T) {
tests := []struct {
name string
@@ -548,6 +643,47 @@ func TestEnvOrDefaultInt(t *testing.T) {
}
}
func TestEnvOrDefaultBool(t *testing.T) {
tests := []struct {
name string
envVal string
setEnv bool
defaultVal bool
want bool
}{
{"unset returns default true", "", false, true, true},
{"unset returns default false", "", false, false, false},
{"true", "true", true, false, true},
{"TRUE", "TRUE", true, false, true},
{"True", "True", true, false, true},
{"1", "1", true, false, true},
{"yes", "yes", true, false, true},
{"YES", "YES", true, false, true},
{"false", "false", true, true, false},
{"0", "0", true, true, false},
{"no", "no", true, true, false},
{"random string", "random", true, true, false},
{"empty string returns default", "", true, true, true},
{"whitespace true", " true ", true, false, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
envKey := "TEST_ENV_BOOL_" + strings.ReplaceAll(tc.name, " ", "_")
if tc.setEnv {
os.Setenv(envKey, tc.envVal)
defer os.Unsetenv(envKey)
} else {
os.Unsetenv(envKey)
}
got := envOrDefaultBool(envKey, tc.defaultVal)
if got != tc.want {
t.Errorf("envOrDefaultBool(%q, %v) = %v, want %v", tc.envVal, tc.defaultVal, got, tc.want)
}
})
}
}
func TestExtractSentinelName_EdgeCases(t *testing.T) {
tests := []struct {
body string
@@ -556,8 +692,8 @@ func TestExtractSentinelName_EdgeCases(t *testing.T) {
{"<!-- 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
{"<!-- review-bot:", "unknown"}, // prefix but no suffix
{"prefix <!-- review-bot:abc --> end", "abc"}, // embedded in text
}
for _, tc := range tests {
+10 -31
View File
@@ -9,7 +9,7 @@ JSON is awkward for persona files that contain multi-line text (identity, severi
- Backwards compatibility: existing JSON personas must continue to work
- Security: protect against DoS via deeply nested YAML (AIKIDO-2024-10486)
- Consistency: use `.yaml` extension (not `.yml`)
- Library: use `gopkg.in/yaml.v3` (approved in CONVENTIONS.md) with explicit depth limiting
- Library: use `github.com/goccy/go-yaml` v1.16.0+ (approved in CONVENTIONS.md); we implement custom AST-based depth/node-count checks for precise alias-aware validation
## Proposed Approach
@@ -33,37 +33,16 @@ func parsePersona(data []byte, source string) (*Persona, error) {
### YAML Parsing with Depth Protection
```go
func unmarshalYAMLWithDepthLimit(data []byte, out any, maxDepth int) error {
var node yaml.Node
dec := yaml.NewDecoder(bytes.NewReader(data))
if err := dec.Decode(&node); err != nil {
return err
}
if err := checkYAMLDepth(&node, 0, maxDepth); err != nil {
return err
}
return node.Decode(out)
}
We implement a custom AST-based depth/node-count walk (`checkYAMLDepth` in
`review/persona.go`) rather than relying on library decoder options. Key design
decisions:
func checkYAMLDepth(node *yaml.Node, depth, maxDepth int) error {
if depth > maxDepth {
return fmt.Errorf("YAML nesting depth exceeds maximum (%d)", maxDepth)
}
// Handle alias nodes by following the Alias pointer
if node.Kind == yaml.AliasNode && node.Alias != nil {
return checkYAMLDepth(node.Alias, depth, maxDepth)
}
for _, child := range node.Content {
if err := checkYAMLDepth(child, depth+1, maxDepth); err != nil {
return err
}
}
return nil
}
```
- **Library:** `github.com/goccy/go-yaml` with `ast.Node`-based traversal
- **Dual-map tracking:** `validated` (depth-aware short-circuit) + `visiting` (cycle detection)
- **Node-count limit:** Conservative overcounting bounds total validation work
- **Alias-aware depth:** Aliases increment depth and are re-checked when encountered at greater depths
The `gopkg.in/yaml.v3` library does not have built-in depth protection, so we implement explicit depth checking by first decoding into a `yaml.Node`, walking the tree to verify depth (including alias resolution), then decoding into the target struct.
See `review/persona.go:checkYAMLDepth` for the authoritative implementation.
## State/Data Model
@@ -74,7 +53,7 @@ No new state. Same `Persona` struct, just different parsing.
| Error | Handling |
|-------|----------|
| Invalid YAML syntax | Return parse error with source file |
| Deeply nested YAML | Library rejects (v1.16.0+ fix) |
| Deeply nested YAML | Custom AST walk (`checkYAMLDepth`) rejects before decode |
| Unknown extension | Fall back to JSON parsing |
| Missing required fields | Validation rejects after parse |
+4 -88
View File
@@ -3,8 +3,6 @@ package gitea
import (
"context"
"fmt"
"log/slog"
"strings"
"gitea.weiker.me/rodin/review-bot/vcs"
)
@@ -18,7 +16,6 @@ type Adapter struct {
// Compile-time interface conformance assertion.
var _ vcs.Client = (*Adapter)(nil)
var _ vcs.ReviewerSelfRequester = (*Adapter)(nil)
// NewAdapter creates a new Adapter wrapping the given gitea Client.
func NewAdapter(client *Client) *Adapter {
@@ -170,9 +167,9 @@ func (a *Adapter) PostReview(ctx context.Context, owner, repo string, number int
if err != nil {
return nil, fmt.Errorf("translate position %d in %s: %w", c.Position, c.Path, err)
}
// Per-comment CommitID is not forwarded to Gitea inline comments:
// Gitea's CreatePullReview API has no per-comment commit_id field.
// The review-level commit anchor is set via req.CommitID instead.
// CommitID from vcs.ReviewComment is intentionally not forwarded:
// Gitea review comments are pinned to the PR head SHA automatically,
// and the CreatePullReview API has no per-comment commit_id field.
giteaComments = append(giteaComments, ReviewComment{
Path: c.Path,
NewPosition: int64(lineNum),
@@ -181,7 +178,7 @@ func (a *Adapter) PostReview(ctx context.Context, owner, repo string, number int
}
}
review, err := a.client.PostReview(ctx, owner, repo, number, event, req.Body, req.CommitID, giteaComments)
review, err := a.client.PostReview(ctx, owner, repo, number, event, req.Body, giteaComments)
if err != nil {
return nil, fmt.Errorf("post review: %w", err)
}
@@ -233,84 +230,3 @@ func (a *Adapter) DismissReview(ctx context.Context, owner, repo string, number
func (a *Adapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
return a.client.GetAuthenticatedUser(ctx)
}
// RequestReviewerSelf adds the given user as a requested reviewer on a pull request.
// This implements vcs.ReviewerSelfRequester for the Gitea adapter.
func (a *Adapter) RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error {
return a.client.RequestReviewer(ctx, owner, repo, number, user)
}
// Compile-time interface conformance assertion for ReviewSuperseder.
var _ vcs.ReviewSuperseder = (*Adapter)(nil)
// SupersedeReviews marks prior reviews as superseded by editing their body with a
// link to the new review and resolving their inline comments. This is Gitea-specific
// behavior that has no GitHub equivalent (GitHub uses DismissReview instead).
//
// baseURL is the Gitea instance URL used to construct review permalink URLs.
// sentinel is the HTML comment sentinel that identifies reviews belonging to this reviewer.
func (a *Adapter) SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []vcs.Review, newReviewID int64, baseURL, sentinel string) error {
// Validate baseURL scheme before embedding in Markdown link (defense-in-depth).
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return fmt.Errorf("SupersedeReviews: baseURL must have http or https scheme, got %q", baseURL)
}
underlying := a.client
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d",
strings.TrimRight(baseURL, "/"), owner, repo, prNumber, newReviewID)
for _, oldReview := range oldReviews {
cid, err := underlying.GetTimelineReviewCommentIDForReview(ctx, owner, repo, prNumber, oldReview.ID)
if err != nil {
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
continue
}
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
if err := underlying.EditComment(ctx, owner, repo, cid, supersededBody); err != nil {
slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "error", err)
continue
}
// Resolve old review's inline comments
oldComments, err := underlying.ListReviewComments(ctx, owner, repo, prNumber, oldReview.ID)
if err != nil {
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
continue
}
for _, c := range oldComments {
if c.ID == 0 {
continue
}
if err := underlying.ResolveComment(ctx, owner, repo, c.ID); err != nil {
slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err)
}
}
}
return nil
}
// buildSupersededBody creates the body for a superseded review: struck-through banner
// with collapsed original content and the commit it was evaluated against.
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
shortSHA := commitSHA
if len(shortSHA) > 8 {
shortSHA = shortSHA[:8]
}
var sb strings.Builder
sb.WriteString("~~Original review~~\n\n")
sb.WriteString("**Superseded** \u2014 [see current review](")
sb.WriteString(newReviewURL)
sb.WriteString(") for up-to-date findings.\n\n")
if shortSHA != "" {
sb.WriteString("<details><summary>Previous findings (commit ")
sb.WriteString(shortSHA)
sb.WriteString(")</summary>\n\n")
} else {
sb.WriteString("<details><summary>Previous findings</summary>\n\n")
}
sb.WriteString(originalBody)
sb.WriteString("\n\n</details>\n\n")
sb.WriteString(sentinel)
return sb.String()
}
-92
View File
@@ -386,95 +386,3 @@ func TestAdapter_GetFileContent_RefRouting(t *testing.T) {
t.Errorf("GetFileContent(ref=\"abc123\") = %q, want %q", got, "content-at-ref")
}
}
func TestAdapter_RequestReviewerSelf(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
expected := "/api/v1/repos/owner/repo/pulls/5/requested_reviewers"
if r.URL.Path != expected {
t.Errorf("path = %q, want %q", r.URL.Path, expected)
}
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := gitea.NewClient(server.URL, "token")
adapter := gitea.NewAdapter(client)
err := adapter.RequestReviewerSelf(context.Background(), "owner", "repo", 5, "bot-user")
if err != nil {
t.Fatalf("RequestReviewerSelf() error = %v", err)
}
}
func TestAdapter_PostReview_CommitID(t *testing.T) {
var gotPayload struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewDecoder(r.Body).Decode(&gotPayload)
json.NewEncoder(w).Encode(map[string]any{
"id": 1,
"body": "test",
"user": map[string]any{"login": "bot"},
"commit_id": "abc123def456",
})
}))
defer server.Close()
client := gitea.NewClient(server.URL, "token")
adapter := gitea.NewAdapter(client)
review, err := adapter.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
CommitID: "abc123def456",
// No comments → no diff fetch needed
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPayload.CommitID != "abc123def456" {
t.Errorf("commit_id = %q, want %q", gotPayload.CommitID, "abc123def456")
}
if review.CommitID != "abc123def456" {
t.Errorf("review.CommitID = %q, want %q", review.CommitID, "abc123def456")
}
}
func TestAdapter_PostReview_EmptyCommitID_Omitted(t *testing.T) {
var gotRawPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewDecoder(r.Body).Decode(&gotRawPayload)
json.NewEncoder(w).Encode(map[string]any{
"id": 1,
"body": "test",
"user": map[string]any{"login": "bot"},
})
}))
defer server.Close()
client := gitea.NewClient(server.URL, "token")
adapter := gitea.NewAdapter(client)
_, err := adapter.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "looks good",
Event: vcs.ReviewEventComment,
// CommitID intentionally empty
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// With empty CommitID and omitempty tag, the field should not appear in JSON
if _, exists := gotRawPayload["commit_id"]; exists {
t.Errorf("commit_id should be omitted when empty, but was present: %v", gotRawPayload["commit_id"])
}
}
+2 -6
View File
@@ -186,22 +186,18 @@ func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, r
}
// PostReview submits a review to a PR and returns the created review.
// event should be one of "APPROVED", "REQUEST_CHANGES", or "COMMENT".
// commitID anchors the review to a specific commit SHA. If empty, Gitea
// defaults to the current PR head.
// event should be "APPROVED" or "REQUEST_CHANGES".
// comments are optional inline comments attached to specific lines.
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID string, comments []ReviewComment) (*Review, error) {
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string, comments []ReviewComment) (*Review, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
payload := struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id,omitempty"`
Comments []ReviewComment `json:"comments,omitempty"`
}{
Body: body,
Event: event,
CommitID: commitID,
Comments: comments,
}
+2 -101
View File
@@ -135,7 +135,7 @@ func TestPostReview(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, "test-token")
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "", nil)
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -147,46 +147,6 @@ func TestPostReview(t *testing.T) {
}
}
func TestPostReview_CommitID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
var payload struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload.CommitID != "deadbeef123" {
t.Errorf("expected commit_id %q, got %q", "deadbeef123", payload.CommitID)
}
if payload.Event != "APPROVED" {
t.Errorf("expected event APPROVED, got %q", payload.Event)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":101,"user":{"login":"review-bot"},"state":"APPROVED","stale":false,"commit_id":"deadbeef123"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "deadbeef123", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.ID != 101 {
t.Errorf("expected review ID 101, got %d", review.ID)
}
if review.CommitID != "deadbeef123" {
t.Errorf("expected commit_id %q, got %q", "deadbeef123", review.CommitID)
}
}
func TestGetPullRequest_Non200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
@@ -222,7 +182,7 @@ func TestPostReview_Non200(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", "", nil)
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", nil)
if err == nil {
t.Fatal("expected error for 403, got nil")
}
@@ -1184,62 +1144,3 @@ func TestSanitizeErrorForLog(t *testing.T) {
})
}
}
func TestPostReview_CommitID_InPayload(t *testing.T) {
var gotPayload struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotPayload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]any{
"id": 200,
"body": "LGTM",
"user": map[string]any{"login": "bot"},
"state": "APPROVED",
"commit_id": "deadbeef1234",
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
review, err := client.PostReview(context.Background(), "owner", "repo", 5, "APPROVED", "LGTM", "deadbeef1234", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPayload.CommitID != "deadbeef1234" {
t.Errorf("sent commit_id = %q, want %q", gotPayload.CommitID, "deadbeef1234")
}
if review.CommitID != "deadbeef1234" {
t.Errorf("response commit_id = %q, want %q", review.CommitID, "deadbeef1234")
}
}
func TestPostReview_EmptyCommitID_OmittedFromPayload(t *testing.T) {
var gotRaw map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotRaw)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]any{
"id": 201,
"body": "ok",
"user": map[string]any{"login": "bot"},
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 5, "COMMENT", "ok", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, exists := gotRaw["commit_id"]; exists {
t.Errorf("commit_id should be omitted when empty, but was present: %v", gotRaw["commit_id"])
}
}
+4 -11
View File
@@ -11,7 +11,6 @@ import (
type PositionMap struct {
// files maps filename → (position → new-file line number).
// Deletion lines are mapped to -1 (no new-file line).
// Hunk-header lines are mapped to 0 (no new-file line).
files map[string]map[int]int
// maxPositions caches the highest position number per file,
// tracked during construction to avoid O(n) scans at translate time.
@@ -20,8 +19,8 @@ type PositionMap struct {
// Translate converts a GitHub diff-position to a new-file line number for a given file.
// Returns an error if the file is not in the diff or the position is out of range.
// If the position targets a deletion or hunk-header line, it maps to the nearest
// context/addition line below; if no such line exists, returns an error.
// If the position targets a deletion line, it maps to the nearest non-deletion line below;
// if no such line exists, returns an error.
func (pm *PositionMap) Translate(file string, position int) (int, error) {
if pm == nil || pm.files == nil {
return 0, fmt.Errorf("empty position map")
@@ -42,18 +41,14 @@ func (pm *PositionMap) Translate(file string, position int) (int, error) {
}
// lineNum == -1 means this position is a deletion line.
// lineNum == 0 means this position is a hunk-header line.
// Both map to the nearest context/addition line below.
if lineNum <= 0 {
// Map to the nearest non-deletion line below.
if lineNum == -1 {
maxPos := pm.maxPosition(file)
for p := position + 1; p <= maxPos; p++ {
if ln, exists := fileMap[p]; exists && ln > 0 {
return ln, nil
}
}
if lineNum == 0 {
return 0, fmt.Errorf("position %d targets a hunk-header line with no subsequent new-file line in %q", position, file)
}
return 0, fmt.Errorf("position %d targets a deletion line with no subsequent new-file line in %q", position, file)
}
@@ -75,7 +70,6 @@ func (pm *PositionMap) maxPosition(file string) int {
// - A new @@ hunk within the same file continues incrementing (does not reset)
// - Position maps to the new file line number for additions and context lines
// - Deletion lines have a position but no new-file line number (stored as -1)
// - Hunk-header lines have a position but no new-file line number (stored as 0)
func BuildPositionToLineMap(diff string) *PositionMap {
pm := &PositionMap{
files: make(map[string]map[int]int),
@@ -132,7 +126,6 @@ func BuildPositionToLineMap(diff string) *PositionMap {
// Parse hunk headers
if strings.HasPrefix(line, "@@") && currentFile != "" {
position++
pm.files[currentFile][position] = 0 // sentinel: hunk-header has no new-file line
pm.maxPositions[currentFile] = position
newLine = parseHunkStart(line)
continue
-109
View File
@@ -272,112 +272,3 @@ diff --git a/b.go b/b.go
t.Errorf("Translate(b.go, 3) = %d, want 2", got)
}
}
func TestTranslate_HunkHeaderPosition_SingleHunk(t *testing.T) {
// Position 1 is the @@ hunk-header line.
// It should resolve to the first context/addition line below (new line 16).
diff := `diff --git a/file.go b/file.go
index abc..def 100644
--- a/file.go
+++ b/file.go
@@ -16,4 +16,5 @@ func example() {
context line
-deleted line
+added line
context after
`
pm := BuildPositionToLineMap(diff)
got, err := pm.Translate("file.go", 1)
if err != nil {
t.Fatalf("Translate(file.go, 1): unexpected error: %v", err)
}
if got != 16 {
t.Errorf("Translate(file.go, 1) = %d, want 16 (first context/addition line in hunk)", got)
}
}
func TestTranslate_HunkHeaderPosition_MultiHunk(t *testing.T) {
// First hunk: @@ is pos 1, then " line1" (pos 2), "-old" (pos 3), "+new" (pos 4)
// Second hunk: @@ is pos 5, then " func foo() {" (pos 6), "+// added" (pos 7), etc.
// Translating position 5 (second @@) should resolve to new line 10.
diff := `diff --git a/file.go b/file.go
--- a/file.go
+++ b/file.go
@@ -1,3 +1,3 @@ package main
line1
-old
+new
@@ -10,3 +10,4 @@ func foo() {
func foo() {
+ // added
return
}
`
pm := BuildPositionToLineMap(diff)
// Position 5 is the second @@ hunk-header — should resolve to new line 10
got, err := pm.Translate("file.go", 5)
if err != nil {
t.Fatalf("Translate(file.go, 5): unexpected error: %v", err)
}
if got != 10 {
t.Errorf("Translate(file.go, 5) = %d, want 10 (first context/addition line in second hunk)", got)
}
// Also verify first hunk header at position 1 resolves to new line 1
got, err = pm.Translate("file.go", 1)
if err != nil {
t.Fatalf("Translate(file.go, 1): unexpected error: %v", err)
}
if got != 1 {
t.Errorf("Translate(file.go, 1) = %d, want 1 (first context/addition line in first hunk)", got)
}
}
func TestTranslate_HunkHeaderPosition_NewFile(t *testing.T) {
// New file: @@ -0,0 +1,3 @@ is position 1.
// Should resolve to new line 1 (the first addition).
diff := `diff --git a/new.go b/new.go
new file mode 100644
--- /dev/null
+++ b/new.go
@@ -0,0 +1,3 @@
+package main
+
+func init() {}
`
pm := BuildPositionToLineMap(diff)
got, err := pm.Translate("new.go", 1)
if err != nil {
t.Fatalf("Translate(new.go, 1): unexpected error: %v", err)
}
if got != 1 {
t.Errorf("Translate(new.go, 1) = %d, want 1 (first addition line)", got)
}
}
func TestTranslate_HunkHeaderAtEnd(t *testing.T) {
// A hunk-header at the last position with no subsequent new-file line should error.
// This is the hunk-header equivalent of TestBuildPositionToLineMap_DeletionAtEnd.
diff := `diff --git a/file.go b/file.go
--- a/file.go
+++ b/file.go
@@ -1,2 +1,2 @@ package main
line1
-old
+new
@@ -10,2 +10,1 @@ func foo() {
-removed
`
pm := BuildPositionToLineMap(diff)
// Position 5 is the second @@ hunk-header; the only line after it (pos 6) is a
// deletion (lineNum == -1), so there's no positive new-file line to resolve to.
// The hunk-header lookup should fail.
_, err := pm.Translate("file.go", 5)
if err == nil {
t.Error("expected error for hunk-header at end with no subsequent new-file line")
}
}
+2 -2
View File
@@ -37,7 +37,7 @@ func TestPostReview_WithComments(t *testing.T) {
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
}
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", "", comments)
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", comments)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -72,7 +72,7 @@ func TestPostReview_NilComments(t *testing.T) {
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", "", nil)
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "all good", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
-54
View File
@@ -1,54 +0,0 @@
package gitea
import (
"strings"
"testing"
)
func TestBuildSupersededBody(t *testing.T) {
original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->"
sentinel := "<!-- review-bot:sonnet -->"
newURL := "https://gitea.example.com/owner/repo/pulls/1#pullrequestreview-99"
result := buildSupersededBody(original, "abcdef1234567890", newURL, sentinel)
// Should contain the struck-through banner
if !strings.Contains(result, "~~Original review~~") {
t.Error("missing struck-through banner")
}
// Should contain superseded notice with link
if !strings.Contains(result, "**Superseded**") {
t.Error("missing superseded notice")
}
if !strings.Contains(result, "[see current review]("+newURL+")") {
t.Error("missing link to new review")
}
// Should contain collapsed original
if !strings.Contains(result, "<details>") {
t.Error("missing details/collapse")
}
// Should contain short commit SHA
if !strings.Contains(result, "abcdef12") {
t.Error("missing short SHA")
}
// Should NOT contain full SHA in summary (it's truncated to 8)
if strings.Contains(result, "abcdef1234567890") {
t.Error("should truncate SHA to 8 chars")
}
// Should contain the original body inside details
if !strings.Contains(result, original) {
t.Error("original body not preserved in collapsed section")
}
// Should end with sentinel
if !strings.Contains(result, sentinel) {
t.Error("missing sentinel")
}
}
func TestBuildSupersededBodyShortSHA(t *testing.T) {
// Short SHA should pass through without panic
result := buildSupersededBody("body", "abc", "https://example.com/review", "<!-- review-bot:x -->")
if !strings.Contains(result, "abc") {
t.Error("short SHA not preserved")
}
}
+45 -68
View File
@@ -110,11 +110,6 @@ type Client struct {
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
// If nil, defaults to {1s, 2s}. Set to shorter durations in tests via SetRetryBackoff.
retryBackoff []time.Duration
// reviewPageSize overrides reviewsPerPage for testing. Zero means use default.
reviewPageSize int
// reviewMaxPages overrides maxReviewPages for testing. Zero means use default.
reviewMaxPages int
}
// defaultCheckRedirect is the redirect policy used by NewClient and SetHTTPClient(nil).
@@ -199,32 +194,12 @@ func (c *Client) SetRetryBackoff(d []time.Duration) error {
return nil
}
// SetReviewPagination overrides the page size and max pages for ListReviews.
// Intended for testing only; must be called before any goroutines issue requests.
func (c *Client) SetReviewPagination(pageSize, maxPages int) {
c.reviewPageSize = pageSize
c.reviewMaxPages = maxPages
}
// requestOptions holds per-request configuration for doRequestCore.
type requestOptions struct {
// bodyFn returns a fresh io.Reader for the request body on each attempt.
// Must be non-nil for any request that carries a body (POST, PUT, PATCH,
// or DELETE when a body is required by the API).
// Returning a fresh reader on each call allows retries to re-send the body.
bodyFn func() io.Reader
// accept overrides the default Accept header. Empty means "application/vnd.github+json".
accept string
// extraHeaders are additional headers to set on each request attempt.
extraHeaders map[string]string
}
// doRequestCore is the shared implementation for all HTTP requests with retry
// on 429 rate limit responses. It respects the Retry-After header when present
// (capped at maxRetryAfter). Transport errors are not retried.
func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts requestOptions) ([]byte, error) {
// doWithRetry performs an HTTP request with retry on 429 rate limit responses.
// It delegates request construction to buildReq, which is called on each attempt
// to produce a fresh *http.Request (allowing body re-reads for POST/PUT).
// It respects the Retry-After header when present (capped at maxRetryAfter).
// Transport errors (network failures, context cancellation) are not retried.
func (c *Client) doWithRetry(ctx context.Context, reqURL string, buildReq func() (*http.Request, error)) ([]byte, error) {
const maxRetryAfter = 120 * time.Second
// maxErrorBodyBytes limits how much of an error response body is stored.
@@ -275,29 +250,10 @@ func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts
}
}
var body io.Reader
if opts.bodyFn != nil {
body = opts.bodyFn()
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
req, err := buildReq()
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if c.token != "" {
// Bearer is the OAuth2 standard and is accepted by GitHub for both
// classic PATs and fine-grained tokens. The alternative "token" scheme
// is GitHub-specific and offers no additional compatibility.
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("User-Agent", userAgent)
if opts.accept != "" {
req.Header.Set("Accept", opts.accept)
} else {
req.Header.Set("Accept", "application/vnd.github+json")
}
for k, v := range opts.extraHeaders {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -308,9 +264,9 @@ func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts
respStatus := resp.StatusCode
retryAfterHeader := resp.Header.Get("Retry-After")
respBody, done, handleErr := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
body, done, handleErr := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
if done {
return respBody, handleErr
return body, handleErr
}
lastErr = handleErr
@@ -354,7 +310,26 @@ func (c *Client) doRequestCore(ctx context.Context, method, reqURL string, opts
// It respects the Retry-After header when present (capped at maxRetryAfter).
// Transport errors (network failures, context cancellation) are not retried.
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
return c.doRequestCore(ctx, method, reqURL, requestOptions{accept: accept})
buildReq := func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
if err != nil {
return nil, err
}
if c.token != "" {
// Bearer is the OAuth2 standard and is accepted by GitHub for both
// classic PATs and fine-grained tokens. The alternative "token" scheme
// is GitHub-specific and offers no additional compatibility.
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("User-Agent", userAgent)
if accept != "" {
req.Header.Set("Accept", accept)
} else {
req.Header.Set("Accept", "application/vnd.github+json")
}
return req, nil
}
return c.doWithRetry(ctx, reqURL, buildReq)
}
// handleResponse reads and closes the response body, returning the result.
@@ -386,20 +361,8 @@ func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
return c.doRequest(ctx, http.MethodGet, reqURL, "")
}
// doRequestWithBody is like doRequest but sends a request body.
// It accepts the raw body bytes and sets Content-Type to application/json.
// Retry semantics match doRequest (retries on 429 with Retry-After support).
func (c *Client) doRequestWithBody(ctx context.Context, method, reqURL string, reqBody []byte) ([]byte, error) {
var opts requestOptions
if reqBody != nil {
opts.bodyFn = func() io.Reader { return bytes.NewReader(reqBody) }
opts.extraHeaders = map[string]string{"Content-Type": "application/json"}
}
return c.doRequestCore(ctx, method, reqURL, opts)
}
// doJSONRequest performs an HTTP request with a JSON body and returns the response body.
// It delegates retry/backoff/429 handling to doRequestWithBody.
// It delegates retry/backoff/429 handling to doWithRetry.
// This is a general-purpose helper used by any method that needs to send JSON payloads
// (e.g. PostReview, DismissReview).
func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, payload any) ([]byte, error) {
@@ -407,5 +370,19 @@ func (c *Client) doJSONRequest(ctx context.Context, method, reqURL string, paylo
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
return c.doRequestWithBody(ctx, method, reqURL, jsonBody)
buildReq := func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(jsonBody))
if err != nil {
return nil, err
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Content-Type", "application/json")
return req, nil
}
return c.doWithRetry(ctx, reqURL, buildReq)
}
+1
View File
@@ -568,6 +568,7 @@ func TestSetHTTPClient_NilRestoresDefault(t *testing.T) {
}
}
func TestSetRetryBackoff_RejectsInvalidLength(t *testing.T) {
c := NewClient("token", "https://api.github.com")
+2 -6
View File
@@ -5,10 +5,6 @@ import (
"gitea.weiker.me/rodin/review-bot/vcs"
)
// Compile-time interface conformance assertion.
// This verifies github.Client satisfies the full vcs.Client interface
// (PRReader, FileReader, Reviewer, Identity).
// Compile-time interface conformance assertions.
// These verify github.Client satisfies vcs.Client (the full interface).
var _ vcs.Client = (*github.Client)(nil)
// Verify github.Client implements ReviewSuperseder.
var _ vcs.ReviewSuperseder = (*github.Client)(nil)
-23
View File
@@ -1,23 +0,0 @@
package github
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// newTestClient creates a *Client backed by an httptest.Server running the
// given handler. The server is automatically closed when the test finishes.
// Shared across test files in package github.
func newTestClient(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
c := NewClient("test-token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
t.Fatalf("SetRetryBackoff: %v", err)
}
return c
}
-46
View File
@@ -1,46 +0,0 @@
package github
import (
"context"
"encoding/json"
"net/http"
"testing"
)
func TestGetAuthenticatedUser_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Errorf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/user" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization"))
}
json.NewEncoder(w).Encode(map[string]string{"login": "review-bot"})
})
login, err := c.GetAuthenticatedUser(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if login != "review-bot" {
t.Errorf("expected login 'review-bot', got %q", login)
}
}
func TestGetAuthenticatedUser_401(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
})
_, err := c.GetAuthenticatedUser(context.Background())
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
-677
View File
@@ -1,677 +0,0 @@
package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// --- PostReview tests ---
func TestPostReview_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/5/reviews" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected Content-Type application/json, got %q", r.Header.Get("Content-Type"))
}
// Verify request body
body, _ := io.ReadAll(r.Body)
var req postReviewRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if req.Event != "APPROVE" {
t.Errorf("expected event APPROVE, got %q", req.Event)
}
if req.Body != "LGTM" {
t.Errorf("expected body 'LGTM', got %q", req.Body)
}
if req.CommitID != "abc123" {
t.Errorf("expected commit_id 'abc123', got %q", req.CommitID)
}
if len(req.Comments) != 1 {
t.Fatalf("expected 1 comment, got %d", len(req.Comments))
}
if req.Comments[0].Path != "main.go" {
t.Errorf("expected comment path 'main.go', got %q", req.Comments[0].Path)
}
if req.Comments[0].Position != 4 {
t.Errorf("expected comment position 4, got %d", req.Comments[0].Position)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 100,
"body": "LGTM",
"state": "APPROVED",
"commit_id": "abc123",
"user": map[string]string{"login": "reviewer"},
})
})
review, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
Comments: []vcs.ReviewComment{
{Path: "main.go", Position: 4, CommitID: "abc123", Body: "nit: rename"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.ID != 100 {
t.Errorf("expected ID 100, got %d", review.ID)
}
if review.Body != "LGTM" {
t.Errorf("expected body 'LGTM', got %q", review.Body)
}
if review.State != "APPROVED" {
t.Errorf("expected state 'APPROVED', got %q", review.State)
}
if review.User.Login != "reviewer" {
t.Errorf("expected user 'reviewer', got %q", review.User.Login)
}
if review.CommitID != "abc123" {
t.Errorf("expected commit_id 'abc123', got %q", review.CommitID)
}
}
func TestPostReview_401(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
})
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
func TestPostReview_422(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(422)
w.Write([]byte(`{"message":"Unprocessable Entity"}`))
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
})
if err == nil {
t.Fatal("expected error for 422")
}
// 422 should surface as a wrapped APIError
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.StatusCode != 422 {
t.Errorf("expected status 422, got %d", apiErr.StatusCode)
}
}
func TestPostReview_MalformedResponse(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`not json`))
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
})
if err == nil {
t.Fatal("expected error for malformed response")
}
if !strings.Contains(err.Error(), "parse review response") {
t.Errorf("expected parse error, got: %v", err)
}
}
// --- ListReviews tests ---
func TestListReviews_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/3/reviews" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": 1,
"body": "Approved",
"state": "APPROVED",
"commit_id": "sha1",
"user": map[string]string{"login": "user1"},
},
{
"id": 2,
"body": "Needs work",
"state": "CHANGES_REQUESTED",
"commit_id": "sha2",
"user": map[string]string{"login": "user2"},
},
{
"id": 3,
"body": "Comment only",
"state": "COMMENTED",
"commit_id": "sha3",
"user": map[string]string{"login": "user3"},
},
{
"id": 4,
"body": "Old review",
"state": "DISMISSED",
"commit_id": "sha4",
"user": map[string]string{"login": "user4"},
},
})
})
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 4 {
t.Fatalf("expected 4 reviews, got %d", len(reviews))
}
// Check state translation
expected := []struct {
id int64
state string
}{
{1, "APPROVED"},
{2, "REQUEST_CHANGES"},
{3, "COMMENT"},
{4, "DISMISSED"},
}
for i, e := range expected {
if reviews[i].ID != e.id {
t.Errorf("review[%d]: expected ID %d, got %d", i, e.id, reviews[i].ID)
}
if reviews[i].State != e.state {
t.Errorf("review[%d]: expected state %q, got %q", i, e.state, reviews[i].State)
}
}
}
func TestListReviews_404(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
})
_, err := c.ListReviews(context.Background(), "owner", "repo", 999)
if err == nil {
t.Fatal("expected error for 404")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound=true, got error: %v", err)
}
}
func TestListReviews_401(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
})
_, err := c.ListReviews(context.Background(), "owner", "repo", 3)
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
// --- DeleteReview tests ---
func TestDeleteReview_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
t.Fatalf("expected DELETE, got %s", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/5/reviews/42" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(204)
})
err := c.DeleteReview(context.Background(), "owner", "repo", 5, 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteReview_422_SubmittedReview(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(422)
w.Write([]byte(`{"message":"Can not delete a non pending review"}`))
})
err := c.DeleteReview(context.Background(), "owner", "repo", 5, 42)
if err == nil {
t.Fatal("expected error for 422")
}
if !errors.Is(err, ErrCannotDeleteSubmittedReview) {
t.Errorf("expected ErrCannotDeleteSubmittedReview, got: %v", err)
}
}
// --- DismissReview tests ---
func TestDismissReview_HappyPath(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
t.Fatalf("expected PUT, got %s", r.Method)
}
if r.URL.Path != "/repos/owner/repo/pulls/5/reviews/10/dismissals" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
var req dismissReviewRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if req.Message != "Superseded by new review" {
t.Errorf("expected message 'Superseded by new review', got %q", req.Message)
}
if req.Event != "DISMISS" {
t.Errorf("expected event 'DISMISS', got %q", req.Event)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 10,
"state": "DISMISSED",
})
})
err := c.DismissReview(context.Background(), "owner", "repo", 5, 10, "Superseded by new review")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDismissReview_404(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
})
err := c.DismissReview(context.Background(), "owner", "repo", 5, 999, "dismiss")
if err == nil {
t.Fatal("expected error for 404")
}
if !IsNotFound(err) {
t.Errorf("expected IsNotFound=true, got error: %v", err)
}
}
func TestDismissReview_401(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
})
err := c.DismissReview(context.Background(), "owner", "repo", 5, 10, "dismiss")
if err == nil {
t.Fatal("expected error for 401")
}
if !IsUnauthorized(err) {
t.Errorf("expected IsUnauthorized=true, got error: %v", err)
}
}
// --- State translation tests ---
func TestTranslateGitHubReviewState(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"approved passes through", "APPROVED", "APPROVED"},
{"changes_requested maps to REQUEST_CHANGES", "CHANGES_REQUESTED", "REQUEST_CHANGES"},
{"commented maps to COMMENT", "COMMENTED", "COMMENT"},
{"dismissed passes through", "DISMISSED", "DISMISSED"},
{"unknown state passes through", "UNKNOWN_STATE", "UNKNOWN_STATE"},
{"empty string passes through", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := translateGitHubReviewState(tt.input)
if got != tt.want {
t.Errorf("translateGitHubReviewState(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestPostReview_ConflictingCommitIDs(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not be sent when commit IDs conflict")
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "Review",
Event: vcs.ReviewEventComment,
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "sha-1", Body: "first"},
{Path: "b.go", Position: 2, CommitID: "sha-2", Body: "second"},
},
})
if err == nil {
t.Fatal("expected error for conflicting commit IDs")
}
if !errors.Is(err, ErrConflictingCommitIDs) {
t.Errorf("expected ErrConflictingCommitIDs, got: %v", err)
}
}
func TestPostReview_RequestCommitID_TakesPriority(t *testing.T) {
var gotPayload struct {
CommitID string `json:"commit_id"`
Body string `json:"body"`
}
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotPayload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": 42,
"body": "LGTM",
"state": "APPROVED",
"commit_id": "req-level-sha",
"user": map[string]any{"login": "bot"},
})
})
review, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "LGTM",
Event: vcs.ReviewEventApprove,
CommitID: "req-level-sha",
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "req-level-sha", Body: "looks good"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPayload.CommitID != "req-level-sha" {
t.Errorf("sent commit_id = %q, want %q", gotPayload.CommitID, "req-level-sha")
}
if review.CommitID != "req-level-sha" {
t.Errorf("review.CommitID = %q, want %q", review.CommitID, "req-level-sha")
}
}
func TestPostReview_RequestCommitID_ConflictsWithComment(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not be sent when commit IDs conflict")
})
// req.CommitID is set, and a comment has a different CommitID → conflict
_, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "Review",
Event: vcs.ReviewEventComment,
CommitID: "req-sha",
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "different-sha", Body: "nit"},
},
})
if err == nil {
t.Fatal("expected error for conflicting commit IDs")
}
if !errors.Is(err, ErrConflictingCommitIDs) {
t.Errorf("expected ErrConflictingCommitIDs, got: %v", err)
}
}
func TestPostReview_RequestCommitID_FallbackToComment(t *testing.T) {
var gotPayload struct {
CommitID string `json:"commit_id"`
}
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotPayload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": 43,
"body": "ok",
"state": "COMMENTED",
"commit_id": "comment-sha",
"user": map[string]any{"login": "bot"},
})
})
// req.CommitID is empty, so it falls back to the comment's CommitID
_, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Body: "ok",
Event: vcs.ReviewEventComment,
// CommitID intentionally empty
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "comment-sha", Body: "note"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPayload.CommitID != "comment-sha" {
t.Errorf("sent commit_id = %q, want %q (fallback from comment)", gotPayload.CommitID, "comment-sha")
}
}
// --- ListReviews pagination tests ---
func TestListReviews_MultiPage(t *testing.T) {
// Test multi-page pagination: 2 full pages + 1 partial page.
// pageSize=3, so pages return [3, 3, 2] reviews = 8 total.
const pageSize = 3
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
callCount++
page := r.URL.Query().Get("page")
var reviews []map[string]interface{}
switch page {
case "1":
for i := 1; i <= pageSize; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "APPROVED", "commit_id": "sha1",
"user": map[string]string{"login": "user1"},
})
}
case "2":
for i := pageSize + 1; i <= pageSize*2; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "COMMENTED", "commit_id": "sha1",
"user": map[string]string{"login": "user2"},
})
}
case "3":
// Partial page: only 2 reviews (less than pageSize)
for i := pageSize*2 + 1; i <= pageSize*2+2; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "CHANGES_REQUESTED", "commit_id": "sha1",
"user": map[string]string{"login": "user3"},
})
}
default:
t.Fatalf("unexpected page: %s", page)
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, 10)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 8 {
t.Fatalf("expected 8 reviews, got %d", len(reviews))
}
if callCount != 3 {
t.Errorf("expected 3 API calls, got %d", callCount)
}
// Verify reviews are correctly concatenated in order
for i, r := range reviews {
expectedID := int64(i + 1)
if r.ID != expectedID {
t.Errorf("review[%d]: expected ID %d, got %d", i, expectedID, r.ID)
}
}
}
func TestListReviews_ExactMultipleOfPageSize(t *testing.T) {
// When total reviews is an exact multiple of pageSize, an extra request
// returning 0 results terminates the loop. No truncation warning.
const pageSize = 2
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
page := r.URL.Query().Get("page")
var reviews []map[string]interface{}
switch page {
case "1":
reviews = []map[string]interface{}{
{"id": 1, "body": "r1", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u1"}},
{"id": 2, "body": "r2", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u2"}},
}
case "2":
reviews = []map[string]interface{}{
{"id": 3, "body": "r3", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u3"}},
{"id": 4, "body": "r4", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u4"}},
}
case "3":
// Empty page — signals end of data
reviews = []map[string]interface{}{}
default:
t.Fatalf("unexpected page: %s", page)
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, 10)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 4 {
t.Fatalf("expected 4 reviews, got %d", len(reviews))
}
// 3 calls: page 1 (full), page 2 (full), page 3 (empty)
if callCount != 3 {
t.Errorf("expected 3 API calls, got %d", callCount)
}
}
func TestListReviews_MaxPagesCutoff(t *testing.T) {
// When maxPages is hit and the last page is full, results are truncated
// and a warning would fire (we verify the reviews are still returned).
const pageSize = 2
const maxPages = 2
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
page := r.URL.Query().Get("page")
// Always return a full page (simulating more data exists)
var reviews []map[string]interface{}
var baseID int
switch page {
case "1":
baseID = 0
case "2":
baseID = pageSize
default:
t.Fatalf("unexpected page %s (should not exceed maxPages)", page)
}
for i := 1; i <= pageSize; i++ {
reviews = append(reviews, map[string]interface{}{
"id": baseID + i, "body": fmt.Sprintf("r%d", baseID+i),
"state": "APPROVED", "commit_id": "sha1",
"user": map[string]string{"login": "user"},
})
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, maxPages)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should return all reviews fetched within the cap
expectedCount := pageSize * maxPages
if len(reviews) != expectedCount {
t.Fatalf("expected %d reviews, got %d", expectedCount, len(reviews))
}
if callCount != maxPages {
t.Errorf("expected %d API calls, got %d", maxPages, callCount)
}
// Verify concatenation order
for i, r := range reviews {
if r.ID != int64(i+1) {
t.Errorf("review[%d]: expected ID %d, got %d", i, i+1, r.ID)
}
}
}
func TestListReviews_EmptyFirstPage(t *testing.T) {
// PR with no reviews: first page returns empty array.
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
json.NewEncoder(w).Encode([]map[string]interface{}{})
})
c.SetReviewPagination(10, 5)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 0 {
t.Fatalf("expected 0 reviews, got %d", len(reviews))
}
if callCount != 1 {
t.Errorf("expected 1 API call, got %d", callCount)
}
}
+62 -148
View File
@@ -3,7 +3,6 @@ package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -20,47 +19,35 @@ const (
maxReviewPages = 100
)
// ErrCannotDeleteSubmittedReview is returned when DeleteReview is called on
// a review that has already been submitted (APPROVED, REQUEST_CHANGES, COMMENT).
// GitHub only allows deletion of PENDING reviews. Callers that need to replace
// a submitted review should use DismissReview instead.
var ErrCannotDeleteSubmittedReview = errors.New("cannot delete submitted review: use DismissReview instead")
// ErrConflictingCommitIDs is returned when PostReview receives comments with
// differing non-empty CommitIDs. The GitHub API accepts only a single commit_id
// per review submission; callers must ensure all comments target the same commit.
var ErrConflictingCommitIDs = errors.New("comments contain conflicting commit IDs: all must target the same commit")
// postReviewRequest is the GitHub API request body for creating a review.
type postReviewRequest struct {
CommitID string `json:"commit_id,omitempty"`
Body string `json:"body"`
Event string `json:"event"`
Comments []reviewCommentEntry `json:"comments,omitempty"`
// reviewResponse is the GitHub API response for a pull request review.
type reviewResponse struct {
ID int64 `json:"id"`
Body string `json:"body"`
User struct {
Login string `json:"login"`
} `json:"user"`
State string `json:"state"`
CommitID string `json:"commit_id"`
}
// reviewCommentEntry is a single inline comment in a review creation request.
type reviewCommentEntry struct {
// reviewCreateRequest is the GitHub API request body for creating a pull request review.
type reviewCreateRequest struct {
Body string `json:"body"`
Event string `json:"event"`
Comments []reviewCommentCreate `json:"comments,omitempty"`
CommitID string `json:"commit_id,omitempty"`
}
// reviewCommentCreate is a single inline comment in a review creation request.
type reviewCommentCreate struct {
Path string `json:"path"`
Position int `json:"position"`
Body string `json:"body"`
}
// reviewResponse is the GitHub API response for a review.
type reviewResponse struct {
ID int64 `json:"id"`
Body string `json:"body"`
State string `json:"state"`
CommitID string `json:"commit_id"`
User struct {
Login string `json:"login"`
} `json:"user"`
}
// dismissReviewRequest is the GitHub API request body for dismissing a review.
type dismissReviewRequest struct {
Message string `json:"message"`
Event string `json:"event"`
}
// userResponse is the GitHub API response for the authenticated user.
@@ -68,67 +55,40 @@ type userResponse struct {
Login string `json:"login"`
}
// translateGitHubReviewState translates a GitHub API review state to the
// canonical vcs.Review.State value.
func translateGitHubReviewState(state string) string {
switch state {
case "CHANGES_REQUESTED":
// translateReviewEvent converts a vcs.ReviewEvent to the GitHub API event string.
func translateReviewEvent(event vcs.ReviewEvent) string {
switch event {
case vcs.ReviewEventApprove:
return "APPROVE"
case vcs.ReviewEventRequestChanges:
return "REQUEST_CHANGES"
case "COMMENTED":
return "COMMENT"
default:
// States like APPROVED, DISMISSED, and PENDING pass through unchanged
// as they already match the canonical vcs representation. PENDING appears
// on draft reviews that have not yet been submitted via the GitHub UI or API.
return state
return "COMMENT"
}
}
// PostReview submits a review on a pull request.
//
// The vcs.ReviewEvent constants (ReviewEventApprove, ReviewEventRequestChanges,
// ReviewEventComment) have string values that match GitHub's wire-format event
// strings (APPROVE, REQUEST_CHANGES, COMMENT), so Event is cast directly to
// string without translation.
//
// ReviewComment.Position maps directly to the GitHub API position field.
// When req.Comments is empty, the payload omits the comments field entirely
// (via the omitempty tag on postReviewRequest.Comments).
//
// The GitHub API accepts a single commit_id per review submission. PostReview
// uses req.CommitID as the primary commit anchor. If req.CommitID is empty,
// it falls back to extracting from the first comment with a non-empty CommitID.
// If any subsequent comment specifies a different CommitID, PostReview returns
// ErrConflictingCommitIDs. Comments with an empty CommitID are allowed and
// inherit the review-level value.
// PostReview creates a new review on a pull request.
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, req vcs.ReviewRequest) (*vcs.Review, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
payload := postReviewRequest{
Body: req.Body,
Event: string(req.Event),
CommitID: req.CommitID,
payload := reviewCreateRequest{
Body: req.Body,
Event: translateReviewEvent(req.Event),
}
// Build the payload in one pass. The GitHub API accepts a single commit_id
// per review. req.CommitID is the primary source; if empty, we extract from
// the first comment that supplies one. Reject if any comment disagrees with
// the resolved commit_id.
for _, comment := range req.Comments {
if comment.CommitID != "" {
if payload.CommitID == "" { // only reachable when req.CommitID is empty
payload.CommitID = comment.CommitID
} else if payload.CommitID != comment.CommitID {
return nil, ErrConflictingCommitIDs
}
// else: matching SHA is a no-op by design
}
payload.Comments = append(payload.Comments, reviewCommentEntry{
rc := reviewCommentCreate{
Path: comment.Path,
Position: comment.Position,
Body: comment.Body,
})
}
payload.Comments = append(payload.Comments, rc)
// Use CommitID from the first comment that has one.
// All comments in a single review are expected to reference the same commit.
if payload.CommitID == "" && comment.CommitID != "" {
payload.CommitID = comment.CommitID
}
}
body, err := c.doJSONRequest(ctx, http.MethodPost, reqURL, payload)
@@ -145,108 +105,75 @@ func (c *Client) PostReview(ctx context.Context, owner, repo string, number int,
ID: resp.ID,
Body: resp.Body,
User: vcs.UserInfo{Login: resp.User.Login},
State: translateGitHubReviewState(resp.State),
State: resp.State,
CommitID: resp.CommitID,
}, nil
}
// ListReviews retrieves all reviews for a pull request with pagination.
// GitHub review states are translated to canonical vcs values.
// ListReviews lists all reviews on a pull request.
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
perPage := reviewsPerPage
if c.reviewPageSize > 0 {
perPage = c.reviewPageSize
}
maxPages := maxReviewPages
if c.reviewMaxPages > 0 {
maxPages = c.reviewMaxPages
}
var allReviews []vcs.Review
truncated := false
for page := 1; page <= maxPages; page++ {
for page := 1; page <= maxReviewPages; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews?per_page=%d&page=%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, perPage, page)
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewsPerPage, page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list reviews page %d: %w", page, err)
}
var responses []reviewResponse
if err := json.Unmarshal(body, &responses); err != nil {
return nil, fmt.Errorf("parse reviews response: %w", err)
var reviews []reviewResponse
if err := json.Unmarshal(body, &reviews); err != nil {
return nil, fmt.Errorf("parse reviews JSON: %w", err)
}
if len(responses) == 0 {
if len(reviews) == 0 {
break
}
for _, r := range responses {
for _, r := range reviews {
allReviews = append(allReviews, vcs.Review{
ID: r.ID,
Body: r.Body,
User: vcs.UserInfo{Login: r.User.Login},
State: translateGitHubReviewState(r.State),
State: r.State,
CommitID: r.CommitID,
})
}
if len(responses) < perPage {
if len(reviews) < reviewsPerPage {
break
}
// Truncation detection: this runs on the final allowed iteration
// (page == maxPages) only when the page was full (the len < perPage
// early-break above didn't fire). A full final page means additional
// reviews likely exist beyond our pagination limit.
if page == maxPages {
truncated = true
// NOTE: This warning only fires when the final page was full (the short-page
// break above did not trigger), meaning additional reviews likely exist beyond
// our page limit. The loop naturally exits after this iteration since page
// increments past maxReviewPages.
if page == maxReviewPages {
slog.Warn("ListReviews hit page limit; results may be truncated",
"owner", owner, "repo", repo, "pr", number,
"maxPages", maxReviewPages, "reviewsFetched", len(allReviews))
}
}
if truncated {
slog.Warn("ListReviews hit page limit; results may be truncated",
"owner", owner, "repo", repo, "pr", number,
"maxPages", maxPages, "reviewsFetched", len(allReviews))
}
return allReviews, nil
}
// DeleteReview deletes a pull request review.
// Only PENDING reviews can be deleted; attempting to delete a submitted review
// (APPROVED, CHANGES_REQUESTED, or COMMENTED per GitHub API naming) returns
// ErrCannotDeleteSubmittedReview.
// DeleteReview permanently deletes a review from a pull request.
// Use DismissReview instead when the review should remain visible but marked as dismissed
// (e.g., superseding an outdated review while preserving history).
func (c *Client) DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
// nil body: the GitHub DELETE endpoint for reviews requires no request body.
_, err := c.doRequestWithBody(ctx, http.MethodDelete, reqURL, nil)
_, err := c.doRequest(ctx, http.MethodDelete, reqURL, "")
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == 422 {
return fmt.Errorf("delete review: %w", ErrCannotDeleteSubmittedReview)
}
return fmt.Errorf("delete review: %w", err)
}
return nil
}
// DismissReview dismisses a submitted review on a pull request.
// This is the correct way to "remove" a submitted review (APPROVED, REQUEST_CHANGES).
// GitHub does not allow deleting submitted reviews — they must be dismissed.
// DismissReview dismisses a review on a pull request with a message.
func (c *Client) DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews/%d/dismissals",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, reviewID)
payload := dismissReviewRequest{
Message: message,
// Event is required by the GitHub API for dismissal requests, even though
// "DISMISS" is the only valid value for this endpoint.
Event: "DISMISS",
}
_, err := c.doJSONRequest(ctx, http.MethodPut, reqURL, payload)
@@ -256,19 +183,6 @@ func (c *Client) DismissReview(ctx context.Context, owner, repo string, number i
return nil
}
// SupersedeReviews marks prior reviews as superseded by dismissing them.
// This implements vcs.ReviewSuperseder for the GitHub adapter.
// The baseURL and sentinel parameters are unused for GitHub (dismissal is the mechanism).
func (c *Client) SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []vcs.Review, newReviewID int64, _, _ string) error {
var errs []error
for _, old := range oldReviews {
if err := c.DismissReview(ctx, owner, repo, prNumber, old.ID, "Superseded by new review"); err != nil {
errs = append(errs, fmt.Errorf("dismiss review %d: %w", old.ID, err))
}
}
return errors.Join(errs...)
}
// GetAuthenticatedUser returns the login name of the authenticated user.
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
reqURL := fmt.Sprintf("%s/user", c.baseURL)
+1 -1
View File
@@ -2,4 +2,4 @@ module gitea.weiker.me/rodin/review-bot
go 1.26.2
require gopkg.in/yaml.v3 v3.0.1
require github.com/goccy/go-yaml v1.19.2
+2 -4
View File
@@ -1,4 +1,2 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+146 -38
View File
@@ -5,12 +5,15 @@ import (
"embed"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"unicode/utf8"
"gopkg.in/yaml.v3"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"github.com/goccy/go-yaml/parser"
)
//go:embed personas/*.yaml
@@ -118,9 +121,7 @@ func ListBuiltinPersonas() []string {
default:
continue
}
if !seen[personaName] {
seen[personaName] = true
}
seen[personaName] = true
}
names := make([]string, 0, len(seen))
for name := range seen {
@@ -142,10 +143,19 @@ func parsePersona(data []byte, source string) (*Persona, error) {
err = unmarshalYAMLWithDepthLimit(data, &p, MaxYAMLDepth)
} else {
// Use json.Decoder with DisallowUnknownFields for consistency with
// YAML's KnownFields(true) - both reject unknown fields to catch typos.
// YAML's Strict() - both reject unknown fields to catch typos.
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
err = dec.Decode(&p)
if err == nil {
// Reject trailing content after the first valid JSON object.
// Without this check, input like `{"name":"x"}garbage` would
// silently succeed because Decoder stops after one object.
var dummy json.RawMessage
if err2 := dec.Decode(&dummy); err2 != io.EOF {
err = fmt.Errorf("unexpected trailing content after JSON object")
}
}
}
if err != nil {
return nil, fmt.Errorf("parse persona %s: %w", source, err)
@@ -156,70 +166,164 @@ func parsePersona(data []byte, source string) (*Persona, error) {
return &p, nil
}
// unmarshalYAMLWithDepthLimit unmarshals YAML data with explicit depth limiting
// and strict field checking. This protects against stack exhaustion from deeply
// nested structures and catches typos in field names.
// Multi-document YAML files are rejected to prevent silent data loss.
// unmarshalYAMLWithDepthLimit unmarshals YAML data with three safety checks:
// - Depth limiting: rejects AST trees exceeding maxDepth to prevent stack exhaustion.
// - Multi-document rejection: prevents silent data loss from ignored extra documents.
// - Strict field checking: rejects unknown YAML keys to catch typos early.
func unmarshalYAMLWithDepthLimit(data []byte, out any, maxDepth int) error {
// First pass: decode into a yaml.Node to check depth limits and node counts.
// This prevents stack exhaustion before we attempt to decode into structs.
var node yaml.Node
dec := yaml.NewDecoder(bytes.NewReader(data))
if err := dec.Decode(&node); err != nil {
// First pass: parse into AST to check depth limits, node counts, and
// multi-document rejection. This prevents stack exhaustion before we
// attempt to decode into structs.
file, err := parser.ParseBytes(data, 0)
if err != nil {
return err
}
// Reject empty YAML input (whitespace-only, comment-only, or truly empty files).
// The parser returns a single doc with nil body for these cases.
if len(file.Docs) == 0 || file.Docs[0].Body == nil {
return fmt.Errorf("empty YAML document")
}
// Reject multi-document YAML files - silently ignoring additional documents
// could lead to confusing behavior where users think their changes take effect.
var extra yaml.Node
if dec.Decode(&extra) == nil {
if len(file.Docs) > 1 {
return fmt.Errorf("multi-document YAML is not supported; only single-document files are allowed")
}
nodeCount := 0
if err := checkYAMLDepth(&node, 0, maxDepth, MaxYAMLNodes, make(map[*yaml.Node]struct{}), &nodeCount); err != nil {
if err := checkYAMLDepth(file.Docs[0].Body, 0, maxDepth, MaxYAMLNodes, make(map[ast.Node]int), make(map[ast.Node]bool), &nodeCount); err != nil {
return err
}
// Second pass: decode with strict field checking enabled.
// KnownFields(true) rejects unknown keys, catching typos like "focuss" or "identiy".
// We must re-decode from the original data because yaml.Node.Decode() doesn't
// support the KnownFields option.
strictDec := yaml.NewDecoder(bytes.NewReader(data))
strictDec.KnownFields(true)
return strictDec.Decode(out)
// Strict() rejects unknown keys, catching typos like "focuss" or "identiy".
//
// Safety note: goccy/go-yaml's decoder does not expand YAML aliases
// recursively — it resolves them via the pre-built AST, which our first
// pass already depth-checked. Alias chains that would exceed depth limits
// are caught above; the decoder merely reads the resolved scalar values.
dec := yaml.NewDecoder(bytes.NewReader(data), yaml.Strict())
return dec.Decode(out)
}
// checkYAMLDepth recursively checks that YAML nodes don't exceed the depth limit
// or the total node count limit. It also detects alias cycles to prevent infinite
// recursion from crafted YAML with self-referential aliases.
func checkYAMLDepth(node *yaml.Node, depth, maxDepth, maxNodes int, seen map[*yaml.Node]struct{}, nodeCount *int) error {
// checkYAMLDepth recursively checks that YAML AST nodes don't exceed the depth
// limit or the total node count limit. It uses two tracking maps:
// - validated: maps each node to the maximum depth at which it was previously
// checked. If a node is revisited at a deeper depth (e.g., via an alias),
// we re-check it to ensure the combined effective depth doesn't exceed limits.
// - visiting: per-path recursion stack for true cycle detection. A node on the
// current path is a cycle (alias loop); we return nil to avoid infinite recursion.
//
// This design prevents the alias depth bypass where an anchored subtree validated
// at a shallow depth could be referenced via alias at a greater depth, effectively
// exceeding MaxYAMLDepth.
func checkYAMLDepth(node ast.Node, depth, maxDepth, maxNodes int, validated map[ast.Node]int, visiting map[ast.Node]bool, nodeCount *int) error {
if node == nil {
return nil
}
if depth > maxDepth {
return fmt.Errorf("YAML nesting depth exceeds maximum (%d)", maxDepth)
}
// Cycle detection: if we're currently visiting this node on the current
// recursion path, it's a cycle (e.g., alias pointing to an ancestor).
// Return nil to break the cycle without error — cycles are a structural
// property, not a depth violation.
if visiting[node] {
return nil
}
// Track total nodes visited as defense-in-depth against wide-but-shallow attacks.
// Placed after cycle detection but before the depth-aware short-circuit. This means
// nodes revisited at shallower depths (via aliases) are counted each time they are
// encountered — intentional conservative overcounting. This bounds the total work
// performed during validation rather than tracking unique nodes, which is the safer
// security posture for untrusted YAML input.
*nodeCount++
if *nodeCount > maxNodes {
return fmt.Errorf("YAML node count exceeds maximum (%d)", maxNodes)
}
// Cycle detection: if we've seen this node before, we're in a cycle.
if _, ok := seen[node]; ok {
return nil // Already validated this subtree, skip to avoid infinite recursion.
// Depth-aware short-circuit: skip re-validation only when the current visit
// depth is the same or shallower than the depth at which this node was
// previously validated. A shallower (or equal) current depth means the
// prior, deeper validation already covered any subtree depth violations.
// If the current depth exceeds the previous validation depth (e.g., an alias
// references this node deeper in the tree), we must re-traverse to ensure
// the combined effective depth doesn't exceed maxDepth.
//
// Note: using ast.Node (interface) as map key relies on pointer identity,
// which is correct because all goccy/go-yaml AST node types are pointer
// receivers (*MappingNode, *SequenceNode, etc.), never value types.
if prevDepth, ok := validated[node]; ok && depth <= prevDepth {
return nil
}
seen[node] = struct{}{}
validated[node] = depth
// Handle alias nodes: follow the alias to its anchor target.
// Increment depth when following aliases since they expand the effective structure.
if node.Kind == yaml.AliasNode && node.Alias != nil {
return checkYAMLDepth(node.Alias, depth+1, maxDepth, maxNodes, seen, nodeCount)
}
// Mark as visiting (on the current recursion path) for cycle detection.
visiting[node] = true
defer func() { visiting[node] = false }()
for _, child := range node.Content {
if err := checkYAMLDepth(child, depth+1, maxDepth, maxNodes, seen, nodeCount); err != nil {
// Walk children based on node type.
switch n := node.(type) {
case *ast.MappingNode:
for _, value := range n.Values {
if err := checkYAMLDepth(value, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil {
return err
}
}
case *ast.MappingValueNode:
// Both Key and Value are visited at depth+1 relative to this
// MappingValueNode. Since MappingNode visits its MappingValueNode
// children at depth+1 as well, keys and values end up at depth+2
// from the parent MappingNode. This is intentional: it mirrors the
// actual nesting structure (mapping → key-value pair → key/value).
if err := checkYAMLDepth(n.Key, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil {
return err
}
if err := checkYAMLDepth(n.Value, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil {
return err
}
case *ast.SequenceNode:
for _, value := range n.Values {
if err := checkYAMLDepth(value, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil {
return err
}
}
case *ast.AliasNode:
// Follow alias to its target, incrementing depth since aliases expand
// the effective structure.
if err := checkYAMLDepth(n.Value, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil {
return err
}
case *ast.AnchorNode:
// Increment depth for anchor values as a conservative measure: the
// anchor definition itself is structural, and treating it as a depth
// level ensures that deeply nested anchors are caught at definition
// time rather than only when referenced via alias. This +1 is
// asymmetric with alias (which also increments) — by design, the
// effective depth budget for anchored-then-aliased content is reduced
// because both the definition site and the reference site each consume
// a level, making deeply nested anchor/alias pairs hit the limit sooner.
if err := checkYAMLDepth(n.Value, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil {
return err
}
case *ast.TagNode:
if err := checkYAMLDepth(n.Value, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil {
return err
}
case *ast.MergeKeyNode:
// MergeKeyNode represents the literal "<<" merge key token. It has no
// child nodes — the value side of a merge (e.g., *alias) lives in the
// parent MappingValueNode.Value, which is already recursed into above.
// Explicitly listed here (rather than in the default case) to prevent
// future library changes from silently bypassing depth checks.
default:
// Scalar leaf nodes (StringNode, IntegerNode, FloatNode, BoolNode,
// NullNode, InfinityNode, NanNode, LiteralNode) have no children to
// recurse into.
}
return nil
}
@@ -227,7 +331,11 @@ func checkYAMLDepth(node *yaml.Node, depth, maxDepth, maxNodes int, seen map[*ya
// ParsePersonaBytes parses persona data from bytes with a source label for errors.
// This is useful for parsing personas fetched from external sources (e.g., Gitea API)
// without requiring filesystem access. Format is detected by source extension.
// Input is bounded by MaxPersonaFileSize to prevent resource exhaustion.
func ParsePersonaBytes(data []byte, source string) (*Persona, error) {
if len(data) > MaxPersonaFileSize {
return nil, fmt.Errorf("persona data from %s exceeds maximum size (%d bytes, limit %d)", source, len(data), MaxPersonaFileSize)
}
return parsePersona(data, source)
}
+223 -42
View File
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
"gopkg.in/yaml.v3"
"github.com/goccy/go-yaml/ast"
)
func TestLoadBuiltinPersona(t *testing.T) {
@@ -355,7 +355,7 @@ func TestCapitalizeFirst(t *testing.T) {
{"HELLO", "HELLO"},
{"a", "A"},
{"", ""},
{"日本語", "日本語"}, // Non-ASCII: Japanese doesn't have case
{"日本語", "日本語"}, // Non-ASCII: Japanese doesn't have case
{"über", "Über"}, // German umlaut
{"élève", "Élève"}, // French accent
}
@@ -459,7 +459,14 @@ func TestYAMLDeeplyNestedRejection(t *testing.T) {
path := filepath.Join(dir, "deeply-nested.yaml")
// Build a deeply nested YAML structure that exceeds MaxYAMLDepth (20).
// Each level adds 2 to the depth count (key + value mapping).
// Depth accumulation trace for "nested: \n level0: \n level1: ...":
// - Document root parsed at depth 0
// - Root MappingNode children (MappingValueNodes) visited at depth 1
// - "nested" MappingValueNode: key at depth 2, value at depth 2
// - Each levelN adds depth via MappingValueNode traversal (key + value)
// - Exact depth per level depends on AST structure (MappingNode wrapping),
// but 25 levels reliably exceeds MaxYAMLDepth (20) with comfortable margin.
// The test uses 25 levels rather than exactly 21 to avoid brittleness.
var sb strings.Builder
sb.WriteString("name: test\nidentity: test\nnested:\n")
indent := " "
@@ -483,6 +490,35 @@ func TestYAMLDeeplyNestedRejection(t *testing.T) {
}
}
func TestYAMLEmptyFileRejection(t *testing.T) {
tests := []struct {
name string
content string
}{
{"completely_empty", ""},
{"whitespace_only", " \n\n "},
{"comment_only", "# just a comment\n"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, tc.name+".yaml")
if err := os.WriteFile(path, []byte(tc.content), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
_, err := LoadPersona(path)
if err == nil {
t.Fatal("expected error for empty YAML input, got nil")
}
if !strings.Contains(err.Error(), "empty YAML document") {
t.Errorf("expected error containing %q, got: %v", "empty YAML document", err)
}
})
}
}
func TestYAMLFileSizeLimit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "huge.yaml")
@@ -504,41 +540,41 @@ func TestYAMLFileSizeLimit(t *testing.T) {
func TestYAMLAliasCycleDetection(t *testing.T) {
// Test that our checkYAMLDepth function handles alias cycles gracefully
// by using the seen map to prevent infinite recursion.
// We test this directly because go-yaml's parser handles most cycles
// at parse time, but we need to ensure our checker is robust.
// by using the visiting map to prevent infinite recursion.
// Create a node structure where an alias points to a parent node,
// simulating what could happen with malicious input that bypasses
// go-yaml's cycle detection.
parent := &yaml.Node{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "name"},
{Kind: yaml.ScalarNode, Value: "test"},
{Kind: yaml.ScalarNode, Value: "nested"},
// simulating what could happen with crafted input.
parent := &ast.MappingNode{
Values: []*ast.MappingValueNode{
{
Key: &ast.StringNode{Value: "name"},
Value: &ast.StringNode{Value: "test"},
},
},
}
// Create a child that aliases back to the parent (artificial cycle)
aliasToParent := &yaml.Node{
Kind: yaml.AliasNode,
Alias: parent,
aliasToParent := &ast.AliasNode{
Value: parent,
}
parent.Content = append(parent.Content, aliasToParent)
parent.Values = append(parent.Values, &ast.MappingValueNode{
Key: &ast.StringNode{Value: "nested"},
Value: aliasToParent,
})
nodeCount := 0
seen := make(map[*yaml.Node]struct{})
validated := make(map[ast.Node]int)
visiting := make(map[ast.Node]bool)
// This should NOT hang or stack overflow - the seen map prevents infinite recursion
err := checkYAMLDepth(parent, 0, MaxYAMLDepth, MaxYAMLNodes, seen, &nodeCount)
// This should NOT hang or stack overflow - cycle detection prevents infinite recursion
err := checkYAMLDepth(parent, 0, MaxYAMLDepth, MaxYAMLNodes, validated, visiting, &nodeCount)
if err != nil {
t.Errorf("unexpected error traversing cyclic structure: %v", err)
}
// Verify we tracked the parent in the seen map
if _, ok := seen[parent]; !ok {
t.Error("parent node not tracked in seen map")
// Verify we tracked the parent in the validated map
if _, ok := validated[parent]; !ok {
t.Error("parent node not tracked in validated map")
}
}
@@ -594,36 +630,82 @@ func TestYAMLNodeCountLimit(t *testing.T) {
func TestCheckYAMLDepthCycleDetectionDirect(t *testing.T) {
// Direct test of cycle detection in checkYAMLDepth by creating
// a node structure with an artificial cycle.
// This tests the seen map logic independent of go-yaml's parsing.
node := &yaml.Node{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "key"},
{Kind: yaml.ScalarNode, Value: "value"},
node := &ast.MappingNode{
Values: []*ast.MappingValueNode{
{
Key: &ast.StringNode{Value: "key"},
Value: &ast.StringNode{Value: "value"},
},
},
}
// Create a cycle by making a child reference the parent
cycleChild := &yaml.Node{
Kind: yaml.AliasNode,
Alias: node, // Points back to the parent
cycleChild := &ast.AliasNode{
Value: node, // Points back to the parent
}
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "cyclic"},
cycleChild,
)
node.Values = append(node.Values, &ast.MappingValueNode{
Key: &ast.StringNode{Value: "cyclic"},
Value: cycleChild,
})
nodeCount := 0
seen := make(map[*yaml.Node]struct{})
err := checkYAMLDepth(node, 0, MaxYAMLDepth, MaxYAMLNodes, seen, &nodeCount)
validated := make(map[ast.Node]int)
visiting := make(map[ast.Node]bool)
err := checkYAMLDepth(node, 0, MaxYAMLDepth, MaxYAMLNodes, validated, visiting, &nodeCount)
// Should complete without infinite recursion due to cycle detection
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// The seen map should contain multiple entries
if len(seen) < 2 {
t.Errorf("seen map has %d entries, expected at least 2", len(seen))
// The validated map should contain multiple entries
if len(validated) < 2 {
t.Errorf("validated map has %d entries, expected at least 2", len(validated))
}
}
func TestYAMLAliasDepthBypass(t *testing.T) {
// Test that an anchored subtree first validated at a shallow depth is
// re-checked when referenced via alias at a deeper position. Without the
// depth-aware validated map, the alias reference would skip re-checking
// and allow the effective nesting to exceed MaxYAMLDepth.
dir := t.TempDir()
path := filepath.Join(dir, "alias-depth-bypass.yaml")
// Build YAML with an anchor at shallow depth containing a subtree near the limit,
// then reference it via alias deep enough that effective depth exceeds MaxYAMLDepth.
var sb strings.Builder
sb.WriteString("name: test\nidentity: test\n")
// Create the anchored subtree at depth 1 (key level) that nests 15 levels deep.
sb.WriteString("anchor_key: &deep_anchor\n")
for i := 0; i < 15; i++ {
sb.WriteString(strings.Repeat(" ", i+1))
sb.WriteString(fmt.Sprintf("level%d:\n", i))
}
sb.WriteString(strings.Repeat(" ", 16))
sb.WriteString("leaf: value\n")
// Create a wrapper that nests 6 levels deep, then references the anchor.
// Effective depth at alias target = 6 (wrapper nesting) + 1 (alias) + 15 (subtree) = 22 > 20
sb.WriteString("wrapper:\n")
for i := 0; i < 6; i++ {
sb.WriteString(strings.Repeat(" ", i+1))
sb.WriteString(fmt.Sprintf("n%d:\n", i))
}
sb.WriteString(strings.Repeat(" ", 7))
sb.WriteString("alias_ref: *deep_anchor\n")
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
_, err := LoadPersona(path)
if err == nil {
t.Fatal("expected error for alias depth bypass, got nil")
}
if !strings.Contains(err.Error(), "nesting depth exceeds") {
t.Errorf("error = %q, want containing 'nesting depth exceeds'", err.Error())
}
}
@@ -776,3 +858,102 @@ identity: test identity
t.Errorf("Name = %q, want %q", p.Name, "test")
}
}
func TestJSONTrailingContentRejected(t *testing.T) {
tests := []struct {
name string
content string
}{
{
name: "trailing garbage after object",
content: `{"name":"test","identity":"test identity"}garbage`,
},
{
name: "two JSON objects",
content: `{"name":"test","identity":"test identity"}{"name":"other"}`,
},
{
name: "trailing array",
content: `{"name":"test","identity":"test identity"}[]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.json")
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
_, err := LoadPersona(path)
if err == nil {
t.Fatal("expected error for trailing content, got nil")
}
if !strings.Contains(err.Error(), "trailing content") {
t.Errorf("error = %q, want to contain 'trailing content'", err.Error())
}
})
}
}
func TestParsePersonaBytesSizeLimit(t *testing.T) {
// ParsePersonaBytes should reject input exceeding MaxPersonaFileSize
oversized := make([]byte, MaxPersonaFileSize+1)
for i := range oversized {
oversized[i] = 'x'
}
_, err := ParsePersonaBytes(oversized, "oversized.yaml")
if err == nil {
t.Fatal("expected error for oversized input, got nil")
}
if !strings.Contains(err.Error(), "exceeds maximum size") {
t.Errorf("error = %q, want to contain 'exceeds maximum size'", err.Error())
}
// Just under the limit should not trigger size error (may fail parse, but not size)
underLimit := []byte("name: test\nidentity: test persona\n")
p, err := ParsePersonaBytes(underLimit, "valid.yaml")
if err != nil {
t.Fatalf("unexpected error for valid input: %v", err)
}
if p.Name != "test" {
t.Errorf("Name = %q, want %q", p.Name, "test")
}
}
func TestYAMLMergeKeyDepthCheck(t *testing.T) {
// Verify that YAML merge keys (<<: *alias) are properly handled by the
// depth checker. The merge key content is in the MappingValueNode.Value
// (an AliasNode), not in the MergeKeyNode itself.
p, err := ParsePersonaBytes([]byte("name: merge-test\nidentity: test\n"), "merge.yaml")
if err != nil {
t.Fatalf("basic parse failed: %v", err)
}
if p.Name != "merge-test" {
t.Errorf("Name = %q, want %q", p.Name, "merge-test")
}
// Test that deeply nested merge keys still hit depth limit.
// Build YAML with merge key content nested beyond MaxYAMLDepth.
var sb strings.Builder
sb.WriteString("name: deep-merge\nidentity: deep merge persona\n")
sb.WriteString("anchor: &deep\n")
indent := " "
for i := 0; i < MaxYAMLDepth+5; i++ {
sb.WriteString(indent)
sb.WriteString(fmt.Sprintf("level%d:\n", i))
indent += " "
}
sb.WriteString(indent + "leaf: value\n")
sb.WriteString("target:\n <<: *deep\n")
_, err = ParsePersonaBytes([]byte(sb.String()), "deep-merge.yaml")
if err == nil {
t.Fatal("expected error for deeply nested merge key content, got nil")
}
if !strings.Contains(err.Error(), "depth") {
t.Errorf("error = %q, want to contain 'depth'", err.Error())
}
}
-17
View File
@@ -41,20 +41,3 @@ type Client interface {
Reviewer
Identity
}
// ReviewerSelfRequester is an optional interface implemented by adapters that support
// requesting the authenticated user as a reviewer on a pull request. This is used for
// Gitea-specific behavior (ensuring the bot appears in required-reviewer checks).
// Consumers should use interface assertion: if sr, ok := client.(ReviewerSelfRequester); ok { ... }
type ReviewerSelfRequester interface {
RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error
}
// ReviewSuperseder is an optional interface implemented by adapters that support
// marking old reviews as superseded. For Gitea this means editing the review body
// with a link to the new review and resolving inline comments. For GitHub this
// means dismissing old reviews.
// Consumers should use interface assertion: if rs, ok := client.(ReviewSuperseder); ok { ... }
type ReviewSuperseder interface {
SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []Review, newReviewID int64, baseURL, sentinel string) error
}
-26
View File
@@ -1,26 +0,0 @@
package vcs
// VCSProvider identifies a VCS platform. Using a typed string instead of bare
// strings makes provider values compiler-checkable and prevents typos from
// silently passing validation.
type VCSProvider string
const (
ProviderGitea VCSProvider = "gitea"
ProviderGitHub VCSProvider = "github"
)
// Valid reports whether p is a known VCS provider.
func (p VCSProvider) Valid() bool {
switch p {
case ProviderGitea, ProviderGitHub:
return true
default:
return false
}
}
// String returns the string representation of the provider.
func (p VCSProvider) String() string {
return string(p)
}
+1 -6
View File
@@ -93,11 +93,6 @@ type ReviewRequest struct {
// Body is the top-level review comment.
Body string `json:"body"`
// Event is the review action (approve, request changes, or comment).
Event ReviewEvent `json:"event"`
// CommitID anchors the review to a specific commit SHA.
// If empty, the platform defaults to the current PR head.
// Adapters use this as the primary commit anchor for the review submission.
CommitID string `json:"commit_id,omitempty"`
Event ReviewEvent `json:"event"`
Comments []ReviewComment `json:"comments,omitempty"`
}