Compare commits

..

7 Commits

Author SHA1 Message Date
claw be68e51898 fix(vcs): address self-review NITs - gofmt alignment and comment clarity
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 29s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 57s
2026-05-13 16:24:49 -07:00
claw 49db84fb82 fix(test): add missing blank line between test functions in adapter_test.go 2026-05-13 16:24:49 -07:00
claw 08b5d4051b style: remove double blank lines in test files 2026-05-13 16:24:49 -07:00
claw d606d0a202 feat(vcs): add CommitID to ReviewRequest (#115)
Add CommitID string field to vcs.ReviewRequest so both platform
adapters can thread the commit anchor through the abstraction layer.

Changes:
- vcs/types.go: Add CommitID field with json tag commit_id,omitempty
- gitea/client.go: Re-add commitID parameter to PostReview (was
  removed during PR #112 refactoring)
- gitea/adapter.go: Forward req.CommitID to underlying client
- github/review.go: Use req.CommitID as primary anchor, fall back to
  comment-derived CommitID when empty, reject on conflict
- cmd/review-bot/main.go: Set ReviewRequest.CommitID = evaluatedSHA

Fixes #115
2026-05-13 16:24:40 -07:00
aweiker b2c83c00bc Merge pull request 'fix(vcs): thread CommitID through abstraction layer (#114)' (#117) from review-bot-issue-114 into feature/github-support
Reviewed-on: #117
Reviewed-by: security-review-bot <10+security-review-bot@noreply.gitea.weiker.me>
Reviewed-by: Aaron Weiker <aaron@weiker.org>
2026-05-13 23:13:21 +00:00
claw 25cb55449e fix(nit): align CommitID field in vcs/types.go and document no-op in github/review.go
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 29s
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 1m38s
2026-05-13 13:49:41 -07:00
claw 7e3b6ec8f1 fix(vcs): thread CommitID through abstraction layer (#114)
CI / test (pull_request) Successful in 20s
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 46s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m24s
Add CommitID field to vcs.ReviewRequest so the commit anchor propagates
through the vcs.Client interface to platform adapters.

Changes:
- vcs/types.go: Add CommitID string field to ReviewRequest
- gitea/client.go: Add commitID parameter to PostReview, include in API payload
- gitea/adapter.go: Pass req.CommitID to underlying client
- github/review.go: Use req.CommitID as primary, fall back to comment-level
- cmd/review-bot/main.go: Set CommitID on ReviewRequest from pr.Head.SHA

Fixes #114
2026-05-13 13:30:48 -07:00
7 changed files with 200 additions and 118 deletions
+3 -3
View File
@@ -170,9 +170,9 @@ func (a *Adapter) PostReview(ctx context.Context, owner, repo string, number int
if err != nil { if err != nil {
return nil, fmt.Errorf("translate position %d in %s: %w", c.Position, c.Path, err) return nil, fmt.Errorf("translate position %d in %s: %w", c.Position, c.Path, err)
} }
// Per-comment CommitID is not forwarded: // Per-comment CommitID is not forwarded to Gitea inline comments:
// Gitea review comments are pinned to the PR head SHA automatically, // Gitea's CreatePullReview API has no per-comment commit_id field.
// and the CreatePullReview API has no per-comment commit_id field. // The review-level commit anchor is set via req.CommitID instead.
giteaComments = append(giteaComments, ReviewComment{ giteaComments = append(giteaComments, ReviewComment{
Path: c.Path, Path: c.Path,
NewPosition: int64(lineNum), NewPosition: int64(lineNum),
+70
View File
@@ -442,3 +442,73 @@ func TestAdapter_RequestReviewerSelf(t *testing.T) {
t.Fatalf("RequestReviewerSelf() error = %v", err) t.Fatalf("RequestReviewerSelf() error = %v", err)
} }
} }
func TestAdapter_PostReview_CommitID_Threading(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"])
}
}
+60 -20
View File
@@ -187,26 +187,6 @@ func TestPostReview_CommitID(t *testing.T) {
} }
} }
func TestPostReview_EmptyCommitID_OmittedFromPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
// When commit_id is empty, it should not appear in JSON (omitempty)
if strings.Contains(string(body), "commit_id") {
t.Errorf("expected commit_id to be omitted from payload, got: %s", body)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":102,"user":{"login":"review-bot"},"state":"APPROVED","stale":false}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
_, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetPullRequest_Non200(t *testing.T) { func TestGetPullRequest_Non200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@@ -1005,6 +985,7 @@ func TestDoGet_RespectsContextCancellation(t *testing.T) {
} }
} }
// mockTransport is a test helper that returns errors for the first N calls, // mockTransport is a test helper that returns errors for the first N calls,
// then delegates to a real server. // then delegates to a real server.
type mockTransport struct { type mockTransport struct {
@@ -1203,3 +1184,62 @@ 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"])
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ func TestPostReview_WithComments(t *testing.T) {
{Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"}, {Path: "util.go", NewPosition: 10, Body: "[MINOR] Style issue"},
} }
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", "abc123", comments) _, err := client.PostReview(context.Background(), "owner", "repo", 1, "REQUEST_CHANGES", "summary", "", comments)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
+10 -8
View File
@@ -82,24 +82,25 @@ func translateGitHubReviewState(state string) string {
// (via the omitempty tag on postReviewRequest.Comments). // (via the omitempty tag on postReviewRequest.Comments).
// //
// The GitHub API accepts a single commit_id per review submission. PostReview // The GitHub API accepts a single commit_id per review submission. PostReview
// uses req.CommitID as the primary anchor when set. If req.CommitID is empty, // uses req.CommitID as the primary commit anchor. If req.CommitID is empty,
// it falls back to extracting commit_id from the first comment with a non-empty // it falls back to extracting from the first comment with a non-empty CommitID.
// CommitID. If any comment specifies a CommitID that conflicts with the // If any subsequent comment specifies a different CommitID, PostReview returns
// resolved value, PostReview returns ErrConflictingCommitIDs. // ErrConflictingCommitIDs. Comments with an empty CommitID are allowed and
// Comments with an empty CommitID are allowed and inherit the review-level value. // inherit the review-level value.
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, req vcs.ReviewRequest) (*vcs.Review, error) { 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", reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number) c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
payload := postReviewRequest{ payload := postReviewRequest{
CommitID: req.CommitID,
Body: req.Body, Body: req.Body,
Event: string(req.Event), Event: string(req.Event),
CommitID: req.CommitID,
} }
// Build the payload in one pass. The GitHub API accepts a single commit_id // Build the payload in one pass. The GitHub API accepts a single commit_id
// per review. We use req.CommitID as the primary anchor; if any comment // per review. req.CommitID is the primary source; if empty, we extract from
// specifies a different non-empty CommitID, we reject the request. // the first comment that supplies one. Reject if any comment disagrees with
// the resolved commit_id.
for _, comment := range req.Comments { for _, comment := range req.Comments {
if comment.CommitID != "" { if comment.CommitID != "" {
if payload.CommitID == "" { // only reachable when req.CommitID is empty if payload.CommitID == "" { // only reachable when req.CommitID is empty
@@ -107,6 +108,7 @@ func (c *Client) PostReview(ctx context.Context, owner, repo string, number int,
} else if payload.CommitID != comment.CommitID { } else if payload.CommitID != comment.CommitID {
return nil, ErrConflictingCommitIDs return nil, ErrConflictingCommitIDs
} }
// else: matching SHA is a no-op by design
} }
payload.Comments = append(payload.Comments, reviewCommentEntry{ payload.Comments = append(payload.Comments, reviewCommentEntry{
Path: comment.Path, Path: comment.Path,
+53 -82
View File
@@ -390,124 +390,95 @@ func TestPostReview_ConflictingCommitIDs(t *testing.T) {
} }
} }
func TestPostReview_CommitIDFromRequest(t *testing.T) { func TestPostReview_RequestCommitID_TakesPriority(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { var gotPayload struct {
body, _ := io.ReadAll(r.Body) CommitID string `json:"commit_id"`
var req postReviewRequest Body string `json:"body"`
if err := json.Unmarshal(body, &req); err != nil { }
t.Fatalf("unmarshal request: %v", err)
}
if req.CommitID != "req-level-sha" {
t.Errorf("expected commit_id %q, got %q", "req-level-sha", req.CommitID)
}
json.NewEncoder(w).Encode(map[string]interface{}{ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
"id": 200, json.NewDecoder(r.Body).Decode(&gotPayload)
"body": "ok", w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": 42,
"body": "LGTM",
"state": "APPROVED", "state": "APPROVED",
"commit_id": "req-level-sha", "commit_id": "req-level-sha",
"user": map[string]string{"login": "bot"}, "user": map[string]any{"login": "bot"},
}) })
}) })
// CommitID set at request level, no comments review, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
review, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{ Body: "LGTM",
Body: "ok",
Event: vcs.ReviewEventApprove, Event: vcs.ReviewEventApprove,
CommitID: "req-level-sha", CommitID: "req-level-sha",
Comments: []vcs.ReviewComment{
{Path: "a.go", Position: 1, CommitID: "req-level-sha", Body: "looks good"},
},
}) })
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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" { if review.CommitID != "req-level-sha" {
t.Errorf("expected commit_id %q, got %q", "req-level-sha", review.CommitID) t.Errorf("review.CommitID = %q, want %q", review.CommitID, "req-level-sha")
} }
} }
func TestPostReview_CommitIDFallbackToComment(t *testing.T) { func TestPostReview_RequestCommitID_ConflictsWithComment(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body) t.Fatal("request should not be sent when commit IDs conflict")
var req postReviewRequest
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
// When req.CommitID is empty, should fall back to comment's CommitID
if req.CommitID != "comment-sha" {
t.Errorf("expected commit_id %q (from comment fallback), got %q", "comment-sha", req.CommitID)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 201,
"body": "ok",
"state": "APPROVED",
"commit_id": "comment-sha",
"user": map[string]string{"login": "bot"},
})
}) })
review, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{ // req.CommitID is set, and a comment has a different CommitID → conflict
Body: "ok", _, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Event: vcs.ReviewEventApprove, Body: "Review",
// CommitID intentionally empty — should fall back to comment Event: vcs.ReviewEventComment,
Comments: []vcs.ReviewComment{
{Path: "main.go", Position: 1, CommitID: "comment-sha", Body: "nit"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.CommitID != "comment-sha" {
t.Errorf("expected commit_id %q, got %q", "comment-sha", review.CommitID)
}
}
func TestPostReview_CommitIDConflictBetweenRequestAndComment(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("request should not have been sent")
})
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{
Body: "ok",
Event: vcs.ReviewEventApprove,
CommitID: "req-sha", CommitID: "req-sha",
Comments: []vcs.ReviewComment{ Comments: []vcs.ReviewComment{
{Path: "main.go", Position: 1, CommitID: "different-sha", Body: "nit"}, {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) { if !errors.Is(err, ErrConflictingCommitIDs) {
t.Errorf("expected ErrConflictingCommitIDs, got %v", err) t.Errorf("expected ErrConflictingCommitIDs, got: %v", err)
} }
} }
func TestPostReview_CommitIDMatchBetweenRequestAndComment(t *testing.T) { func TestPostReview_RequestCommitID_FallbackToComment(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { var gotPayload struct {
body, _ := io.ReadAll(r.Body) CommitID string `json:"commit_id"`
var req postReviewRequest }
if err := json.Unmarshal(body, &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if req.CommitID != "same-sha" {
t.Errorf("expected commit_id %q, got %q", "same-sha", req.CommitID)
}
json.NewEncoder(w).Encode(map[string]interface{}{ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
"id": 202, json.NewDecoder(r.Body).Decode(&gotPayload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": 43,
"body": "ok", "body": "ok",
"state": "APPROVED", "state": "COMMENTED",
"commit_id": "same-sha", "commit_id": "comment-sha",
"user": map[string]string{"login": "bot"}, "user": map[string]any{"login": "bot"},
}) })
}) })
_, err := c.PostReview(context.Background(), "owner", "repo", 5, vcs.ReviewRequest{ // req.CommitID is empty, so it falls back to the comment's CommitID
Body: "ok", _, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
Event: vcs.ReviewEventApprove, Body: "ok",
CommitID: "same-sha", Event: vcs.ReviewEventComment,
// CommitID intentionally empty
Comments: []vcs.ReviewComment{ Comments: []vcs.ReviewComment{
{Path: "main.go", Position: 1, CommitID: "same-sha", Body: "nit"}, {Path: "a.go", Position: 1, CommitID: "comment-sha", Body: "note"},
}, },
}) })
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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")
}
} }
+3 -4
View File
@@ -95,9 +95,8 @@ type ReviewRequest struct {
// Event is the review action (approve, request changes, or comment). // Event is the review action (approve, request changes, or comment).
Event ReviewEvent `json:"event"` Event ReviewEvent `json:"event"`
// CommitID anchors the review to a specific commit SHA. // CommitID anchors the review to a specific commit SHA.
// Both GitHub and Gitea accept this at the review level. // If empty, the platform defaults to the current PR head.
// If empty, each platform applies its default behavior (Gitea uses PR // Adapters use this as the primary commit anchor for the review submission.
// head; GitHub derives from comments or omits). CommitID string `json:"commit_id,omitempty"`
CommitID string `json:"commit_id,omitempty"`
Comments []ReviewComment `json:"comments,omitempty"` Comments []ReviewComment `json:"comments,omitempty"`
} }