feat(github): implement Reviewer and Identity interfaces (#81) #105
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -75,6 +80,11 @@ func translateGitHubReviewState(state string) string {
|
||||
// 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).
|
||||
//
|
||||
|
sonnet-review-bot
commented
[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
|
||||
|
sonnet-review-bot
commented
[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 == "" {
|
||||
|
gpt-review-bot
commented
[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,
|
||||
|
||||
@@ -6,26 +6,12 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.weiker.me/rodin/review-bot/vcs"
|
||||
)
|
||||
|
sonnet-review-bot
commented
[MINOR] The table-driven test for TestTranslateGitHubReviewState uses field name **[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.
|
||||
|
||||
|
sonnet-review-bot
commented
[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 ---
|
||||
|
sonnet-review-bot
commented
[NIT] Tests use **[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.
sonnet-review-bot
commented
[NIT] Several test handlers use **[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.
rodin
commented
Fixed in 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) {
|
||||
@@ -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"},
|
||||
},
|
||||
|
sonnet-review-bot
commented
[MINOR] containsStr and containsSubstring reimplement strings.Contains from the standard library. The repository conventions do not restrict use of the standard library; **[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.
|
||||
})
|
||||
|
sonnet-review-bot
commented
[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)
|
||||
}
|
||||
}
|
||||
|
||||
[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.Commentswill be nil (not assigned), which is fine since it'somitempty, but this is an implicit assumption worth noting.