From 7e3b6ec8f1c73fcad9c3c400c05ad96cd71b46c6 Mon Sep 17 00:00:00 2001 From: claw Date: Wed, 13 May 2026 13:30:48 -0700 Subject: [PATCH] fix(vcs): thread CommitID through abstraction layer (#114) 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 --- cmd/review-bot/main.go | 1 + gitea/adapter.go | 8 +-- gitea/adapter_test.go | 70 ++++++++++++++++++++++ gitea/client.go | 8 ++- gitea/client_test.go | 63 +++++++++++++++++++- gitea/post_review_comments_test.go | 4 +- github/review.go | 18 +++--- github/review_test.go | 93 ++++++++++++++++++++++++++++++ vcs/types.go | 6 +- 9 files changed, 253 insertions(+), 18 deletions(-) diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index d84afb3..fa7630e 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -485,6 +485,7 @@ func main() { reviewReq := vcs.ReviewRequest{ Body: reviewBody, Event: event, + CommitID: pr.Head.SHA, Comments: inlineComments, } posted, err := client.PostReview(ctx, owner, repoName, prNumber, reviewReq) diff --git a/gitea/adapter.go b/gitea/adapter.go index 176c722..82d61f5 100644 --- a/gitea/adapter.go +++ b/gitea/adapter.go @@ -170,9 +170,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) } - // 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. + // 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. giteaComments = append(giteaComments, ReviewComment{ Path: c.Path, NewPosition: int64(lineNum), @@ -181,7 +181,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, giteaComments) + review, err := a.client.PostReview(ctx, owner, repo, number, event, req.Body, req.CommitID, giteaComments) if err != nil { return nil, fmt.Errorf("post review: %w", err) } diff --git a/gitea/adapter_test.go b/gitea/adapter_test.go index a4aea9d..1c59b22 100644 --- a/gitea/adapter_test.go +++ b/gitea/adapter_test.go @@ -408,3 +408,73 @@ func TestAdapter_RequestReviewerSelf(t *testing.T) { 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"]) + } +} diff --git a/gitea/client.go b/gitea/client.go index 2fec35d..64447b4 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -186,18 +186,22 @@ 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 "APPROVED" or "REQUEST_CHANGES". +// 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. // comments are optional inline comments attached to specific lines. -func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string, comments []ReviewComment) (*Review, error) { +func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body, commitID 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, } diff --git a/gitea/client_test.go b/gitea/client_test.go index 2637c2e..b06d204 100644 --- a/gitea/client_test.go +++ b/gitea/client_test.go @@ -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) } @@ -182,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") } @@ -1144,3 +1144,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"]) + } +} diff --git a/gitea/post_review_comments_test.go b/gitea/post_review_comments_test.go index e58d05d..7a8bf57 100644 --- a/gitea/post_review_comments_test.go +++ b/gitea/post_review_comments_test.go @@ -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) } diff --git a/github/review.go b/github/review.go index 467f890..97fa4c1 100644 --- a/github/review.go +++ b/github/review.go @@ -82,21 +82,25 @@ func translateGitHubReviewState(state string) string { // (via the omitempty tag on postReviewRequest.Comments). // // The GitHub API accepts a single commit_id per review submission. PostReview -// extracts it 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. +// uses req.CommitID as the primary commit anchor. If req.CommitID is empty, +// it falls back to extracting from the first comment with a non-empty CommitID. +// If any subsequent comment specifies a different CommitID, PostReview returns +// ErrConflictingCommitIDs. Comments with an empty CommitID are allowed and +// inherit the review-level value. func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, req vcs.ReviewRequest) (*vcs.Review, error) { reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number) payload := postReviewRequest{ - Body: req.Body, - Event: string(req.Event), + Body: req.Body, + Event: string(req.Event), + CommitID: req.CommitID, } // Build the payload in one pass. The GitHub API accepts a single commit_id - // per review; we extract it from the first comment that supplies one and - // reject the request if any other comment disagrees. + // 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 == "" { diff --git a/github/review_test.go b/github/review_test.go index da17d8e..ad14b9f 100644 --- a/github/review_test.go +++ b/github/review_test.go @@ -389,3 +389,96 @@ func TestPostReview_ConflictingCommitIDs(t *testing.T) { 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") + } +} diff --git a/vcs/types.go b/vcs/types.go index 608ad27..dff3e8c 100644 --- a/vcs/types.go +++ b/vcs/types.go @@ -93,6 +93,10 @@ 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"` + Event ReviewEvent `json:"event"` + // CommitID anchors the review to a specific commit SHA. + // If empty, the platform defaults to the current PR head. + // Adapters use this as the primary commit anchor for the review submission. + CommitID string `json:"commit_id,omitempty"` Comments []ReviewComment `json:"comments,omitempty"` }