feat(github): implement Reviewer and Identity interfaces (#81) #105

Merged
aweiker merged 7 commits from review-bot-issue-81 into feature/github-support 2026-05-13 13:39:14 +00:00
3 changed files with 63 additions and 18 deletions
Showing only changes of commit 9dd5e8dbac - Show all commits
+23
View File
@@ -0,0 +1,23 @@
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
}
+19 -4
View File
@@ -17,6 +17,11 @@ import (
// 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"`
3
@@ -75,6 +80,11 @@ func translateGitHubReviewState(state string) string {
// ReviewComment.Position maps directly to the GitHub API position field.
Outdated
Review

[MINOR] PostReview doc comment says 'ReviewRequest.Event values (APPROVE, REQUEST_CHANGES, COMMENT) are sent directly — they match GitHub's canonical event strings.' This is misleading: GitHub's canonical event strings are actually APPROVE, REQUEST_CHANGES, and COMMENT, but the vcs type uses ReviewEventApprove etc. which the test confirms maps to 'APPROVE'. The comment is correct about the wire format but conflates the vcs abstraction with the GitHub API. More importantly, when a review has no comments, payload.Comments will be nil (not assigned), which is fine since it's omitempty, but this is an implicit assumption worth noting.

**[MINOR]** PostReview doc comment says 'ReviewRequest.Event values (APPROVE, REQUEST_CHANGES, COMMENT) are sent directly — they match GitHub's canonical event strings.' This is misleading: GitHub's canonical event strings are actually APPROVE, REQUEST_CHANGES, and COMMENT, but the vcs type uses ReviewEventApprove etc. which the test confirms maps to 'APPROVE'. The comment is correct about the wire format but conflates the vcs abstraction with the GitHub API. More importantly, when a review has no comments, `payload.Comments` will be nil (not assigned), which is fine since it's `omitempty`, but this is an implicit assumption worth noting.
// When req.Comments is empty, the payload omits the comments field entirely
// (via the omitempty tag on postReviewRequest.Comments).
//
Outdated
Review

[NIT] PostReview iterates over req.Comments twice: once to find the CommitID and once to build the payload.Comments slice. This could be combined into a single loop. Minor readability/efficiency issue on the common path.

**[NIT]** PostReview iterates over req.Comments twice: once to find the CommitID and once to build the payload.Comments slice. This could be combined into a single loop. Minor readability/efficiency issue on the common path.
// 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.
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)
@@ -84,11 +94,16 @@ func (c *Client) PostReview(ctx context.Context, owner, repo string, number int,
Event: string(req.Event),
}
// Populate CommitID from the first comment and build the payload in one pass.
// All comments in a single review share the same commit_id.
// Build the payload in one pass. The GitHub API accepts a single commit_id
Outdated
Review

[MINOR] The commit_id extraction loop iterates all comments to find the first non-empty CommitID, but the comment in the code says 'build the payload in one pass'. This is accurate, but the behavior silently drops CommitID from subsequent comments when they differ — if comments belong to different commits (which GitHub technically allows per-comment), only the first commit's ID is used. This may be acceptable for your use case but isn't documented as a constraint. Consider either asserting all CommitIDs match, or documenting that mixed-commit reviews aren't supported.

**[MINOR]** The commit_id extraction loop iterates all comments to find the first non-empty CommitID, but the comment in the code says 'build the payload in one pass'. This is accurate, but the behavior silently drops CommitID from subsequent comments when they differ — if comments belong to different commits (which GitHub technically allows per-comment), only the first commit's ID is used. This may be acceptable for your use case but isn't documented as a constraint. Consider either asserting all CommitIDs match, or documenting that mixed-commit reviews aren't supported.
// per review; we extract it from the first comment that supplies one and
// reject the request if any other comment disagrees.
for _, comment := range req.Comments {
if payload.CommitID == "" && comment.CommitID != "" {
payload.CommitID = comment.CommitID
if comment.CommitID != "" {
if payload.CommitID == "" {
Outdated
Review

[MINOR] Returning ErrCannotDeleteSubmittedReview directly drops operation context. Consider wrapping the sentinel with context (e.g., fmt.Errorf("delete review: %w", ErrCannotDeleteSubmittedReview)) so callers get both the sentinel identity and helpful context, aligning with the documented wrapping pattern.

**[MINOR]** Returning ErrCannotDeleteSubmittedReview directly drops operation context. Consider wrapping the sentinel with context (e.g., fmt.Errorf("delete review: %w", ErrCannotDeleteSubmittedReview)) so callers get both the sentinel identity and helpful context, aligning with the documented wrapping pattern.
payload.CommitID = comment.CommitID
} else if payload.CommitID != comment.CommitID {
return nil, ErrConflictingCommitIDs
}
}
payload.Comments = append(payload.Comments, reviewCommentEntry{
Path: comment.Path,
10
+21 -14
View File
1
@@ -6,26 +6,12 @@ import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.weiker.me/rodin/review-bot/vcs"
)
Outdated
Review

[MINOR] The table-driven test for TestTranslateGitHubReviewState uses field name expected instead of the idiomatic want used by the rest of the test suite. Minor inconsistency with the project's established naming convention.

**[MINOR]** The table-driven test for TestTranslateGitHubReviewState uses field name `expected` instead of the idiomatic `want` used by the rest of the test suite. Minor inconsistency with the project's established naming convention.
Outdated
Review

[MINOR] newTestClient is defined in review_test.go but also used by identity_test.go. Since both are in package github (internal test package), this works fine. However, it would be marginally cleaner to extract this into a test helper file (e.g., export_test.go or helpers_test.go) to make the sharing explicit. This is a nit about code organization, not correctness.

**[MINOR]** newTestClient is defined in review_test.go but also used by identity_test.go. Since both are in package github (internal test package), this works fine. However, it would be marginally cleaner to extract this into a test helper file (e.g., export_test.go or helpers_test.go) to make the sharing explicit. This is a nit about code organization, not correctness.
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
}
// --- PostReview tests ---
Outdated
Review

[NIT] Tests use t.Errorf (non-fatal) for the r.Method and r.URL.Path checks inside the handler, then continue executing the handler body. If the method or path is wrong the handler will still attempt to encode a response, which masks the root failure. Using t.Fatalf (or http.Error + early return) for these routing assertions would make failures clearer. This is a test-quality nit, not a functional problem.

**[NIT]** Tests use `t.Errorf` (non-fatal) for the `r.Method` and `r.URL.Path` checks inside the handler, then continue executing the handler body. If the method or path is wrong the handler will still attempt to encode a response, which masks the root failure. Using `t.Fatalf` (or `http.Error` + early return) for these routing assertions would make failures clearer. This is a test-quality nit, not a functional problem.
Outdated
Review

[NIT] Several test handlers use t.Fatalf inside the handler goroutine (e.g. t.Fatalf("expected POST, got %s", r.Method)). Calling t.Fatalf from a goroutine other than the test goroutine is technically not permitted per the testing package docs — it calls runtime.Goexit() on the wrong goroutine. The handler runs in the httptest server's goroutine, not the test's goroutine. For assertions in handlers that must stop the test, t.Errorf + return is safer. This is a pre-existing pattern in the codebase (not introduced here) so it's a low-risk nit, but new test files could use the safer t.Errorf + return pattern.

**[NIT]** Several test handlers use `t.Fatalf` inside the handler goroutine (e.g. `t.Fatalf("expected POST, got %s", r.Method)`). Calling `t.Fatalf` from a goroutine other than the test goroutine is technically not permitted per the `testing` package docs — it calls `runtime.Goexit()` on the wrong goroutine. The handler runs in the httptest server's goroutine, not the test's goroutine. For assertions in handlers that must stop the test, `t.Errorf` + return is safer. This is a pre-existing pattern in the codebase (not introduced here) so it's a low-risk nit, but new test files could use the safer `t.Errorf` + return pattern.
Outdated
Review

Fixed in dbc25f4 — changed all t.Errorf routing assertions (method and path checks) in test handlers to t.Fatalf so failures are immediately fatal instead of continuing handler execution with an incorrect request.

Fixed in dbc25f4 — changed all `t.Errorf` routing assertions (method and path checks) in test handlers to `t.Fatalf` so failures are immediately fatal instead of continuing handler execution with an incorrect request.
func TestPostReview_HappyPath(t *testing.T) {
3
@@ -379,3 +365,24 @@ func TestTranslateGitHubReviewState(t *testing.T) {
}
}
}
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"},
},
Outdated
Review

[MINOR] containsStr and containsSubstring reimplement strings.Contains from the standard library. The repository conventions do not restrict use of the standard library; strings.Contains(s, substr) should be used directly. The current implementation is also slightly incorrect: containsStr returns true when s == substr even without going through containsSubstring, but the logic mixing len checks and || chains is harder to read and maintain than a direct strings.Contains call.

**[MINOR]** containsStr and containsSubstring reimplement strings.Contains from the standard library. The repository conventions do not restrict use of the standard library; `strings.Contains(s, substr)` should be used directly. The current implementation is also slightly incorrect: `containsStr` returns true when `s == substr` even without going through `containsSubstring`, but the logic mixing `len` checks and `||` chains is harder to read and maintain than a direct `strings.Contains` call.
})
Outdated
Review

[NIT] There are two trailing blank lines at the end of review_test.go. gofmt typically produces a single trailing newline; this is minor but inconsistent with the style pattern.

**[NIT]** There are two trailing blank lines at the end of review_test.go. gofmt typically produces a single trailing newline; this is minor but inconsistent with the style pattern.
if err == nil {
t.Fatal("expected error for conflicting commit IDs")
}
if !errors.Is(err, ErrConflictingCommitIDs) {
t.Errorf("expected ErrConflictingCommitIDs, got: %v", err)
}
}