feat(github): implement Reviewer and Identity interfaces (#81) #105
@@ -4,6 +4,7 @@
|
|||||||
package github
|
package github
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -192,10 +193,25 @@ func (c *Client) SetRetryBackoff(d []time.Duration) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
// requestOptions holds per-request configuration for doRequestCore.
|
||||||
// It respects the Retry-After header when present (capped at maxRetryAfter).
|
type requestOptions struct {
|
||||||
|
|
|||||||
// Transport errors (network failures, context cancellation) are not retried.
|
// bodyFn returns a fresh io.Reader for the request body on each attempt.
|
||||||
func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept string) ([]byte, error) {
|
// 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) {
|
||||||
const maxRetryAfter = 120 * time.Second
|
const maxRetryAfter = 120 * time.Second
|
||||||
|
|
||||||
// backoff holds per-attempt delays: backoff[i] is the delay before attempt i+1.
|
// backoff holds per-attempt delays: backoff[i] is the delay before attempt i+1.
|
||||||
@@ -246,7 +262,11 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, nil)
|
var body io.Reader
|
||||||
|
if opts.bodyFn != nil {
|
||||||
|
body = opts.bodyFn()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -257,11 +277,14 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
if accept != "" {
|
if opts.accept != "" {
|
||||||
req.Header.Set("Accept", accept)
|
req.Header.Set("Accept", opts.accept)
|
||||||
} else {
|
} else {
|
||||||
req.Header.Set("Accept", "application/vnd.github+json")
|
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)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -272,11 +295,11 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
respStatus := resp.StatusCode
|
respStatus := resp.StatusCode
|
||||||
retryAfterHeader := resp.Header.Get("Retry-After")
|
retryAfterHeader := resp.Header.Get("Retry-After")
|
||||||
|
|
||||||
body, done, err := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
respBody, done, handleErr := c.handleResponse(resp, maxResponseBytes, maxErrorBodyBytes)
|
||||||
if done {
|
if done {
|
||||||
return body, err
|
return respBody, handleErr
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = handleErr
|
||||||
|
|
||||||
// Retry on 429 rate limit
|
// Retry on 429 rate limit
|
||||||
if respStatus == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
if respStatus == http.StatusTooManyRequests && attempt < maxRetryAttempts-1 {
|
||||||
@@ -314,6 +337,13 @@ func (c *Client) doRequest(ctx context.Context, method, reqURL string, accept st
|
|||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doRequest performs an HTTP request with retry on 429 rate limit responses.
|
||||||
|
// 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})
|
||||||
|
}
|
||||||
|
|
||||||
|
sonnet-review-bot
commented
[MAJOR] doRequestWithBody duplicates ~90% of doRequest's implementation. Both functions share identical backoff setup, HTTPS validation, retry loop, timer/select pattern, Retry-After parsing, and handleResponse invocation. The only difference is that doRequestWithBody constructs a **[MAJOR]** doRequestWithBody duplicates ~90% of doRequest's implementation. Both functions share identical backoff setup, HTTPS validation, retry loop, timer/select pattern, Retry-After parsing, and handleResponse invocation. The only difference is that doRequestWithBody constructs a `bytes.NewReader(body)` and sets `Content-Type: application/json`. This violates DRY and makes the retry logic a two-place maintenance burden. The fix is to refactor doRequest to accept an optional body (e.g. add an `io.Reader` or `[]byte` parameter, or introduce a private `doRequestInternal` that both exported helpers delegate to). The pattern to follow is the existing Layered API pattern: `doGet` calls `doRequest`, and `doRequestWithBody` should also call a single underlying implementation.
gpt-review-bot
commented
[MINOR] doRequestWithBody largely duplicates doRequest logic (retry/backoff, HTTPS checks, headers). Consider refactoring to a single helper that accepts an optional body and optional Accept header to avoid drift and ease maintenance. **[MINOR]** doRequestWithBody largely duplicates doRequest logic (retry/backoff, HTTPS checks, headers). Consider refactoring to a single helper that accepts an optional body and optional Accept header to avoid drift and ease maintenance.
|
|||||||
// handleResponse reads and closes the response body, returning the result.
|
// handleResponse reads and closes the response body, returning the result.
|
||||||
// It uses defer to ensure the body is always closed regardless of code path.
|
// It uses defer to ensure the body is always closed regardless of code path.
|
||||||
// Returns (body, done, err) where done=true means the caller should return immediately.
|
// Returns (body, done, err) where done=true means the caller should return immediately.
|
||||||
@@ -342,3 +372,15 @@ func (c *Client) handleResponse(resp *http.Response, maxRespBytes int, maxErrByt
|
|||||||
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
||||||
return c.doRequest(ctx, http.MethodGet, reqURL, "")
|
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"}
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `doRequestWithBody` function only sets `Content-Type: application/json` when `reqBody != nil`. For the nil-body DELETE case (used by DeleteReview), no Content-Type is set, which is correct. However, the function's doc comment says "sets Content-Type to application/json" without qualifying the nil case. Minor doc improvement: "sets Content-Type to application/json when body is non-nil".
|
|||||||
|
}
|
||||||
|
return c.doRequestCore(ctx, method, reqURL, opts)
|
||||||
|
}
|
||||||
|
sonnet-review-bot
commented
[NIT] doRequestWithBody sets Content-Type: application/json only when reqBody is non-nil. For DELETE requests (like DeleteReview), reqBody is nil so no Content-Type is sent, which is correct. However, the function signature accepts nil to mean 'no body', which conflates 'empty body' with 'no body'. This is fine for current uses but worth noting in the doc comment — currently the doc says 'accepts the raw body bytes' without mentioning the nil case explicitly. **[NIT]** doRequestWithBody sets Content-Type: application/json only when reqBody is non-nil. For DELETE requests (like DeleteReview), reqBody is nil so no Content-Type is sent, which is correct. However, the function signature accepts nil to mean 'no body', which conflates 'empty body' with 'no body'. This is fine for current uses but worth noting in the doc comment — currently the doc says 'accepts the raw body bytes' without mentioning the nil case explicitly.
|
|||||||
|
|||||||
@@ -5,9 +5,7 @@ import (
|
|||||||
"gitea.weiker.me/rodin/review-bot/vcs"
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compile-time interface conformance assertions.
|
// Compile-time interface conformance assertion.
|
||||||
// These verify github.Client satisfies vcs.PRReader and vcs.FileReader.
|
// This verifies github.Client satisfies the full vcs.Client interface
|
||||||
var (
|
// (PRReader, FileReader, Reviewer, Identity).
|
||||||
_ vcs.PRReader = (*github.Client)(nil)
|
var _ vcs.Client = (*github.Client)(nil)
|
||||||
_ vcs.FileReader = (*github.Client)(nil)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// userResponse is the GitHub API response for the authenticated user.
|
||||||
|
type userResponse struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthenticatedUser returns the login of the currently authenticated user.
|
||||||
|
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/user", c.baseURL)
|
||||||
|
|
||||||
|
body, err := c.doGet(ctx, reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get authenticated user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp userResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("parse user response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Login, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package github
|
||||||
|
sonnet-review-bot
commented
[NIT] identity_test.go is in package **[NIT]** identity_test.go is in package `github` (white-box test) while conformance_test.go is in package `github_test` (black-box test). The `newTestClient` helper is defined in review_test.go (package `github`), so identity_test.go can use it — this is consistent. However, the `httptest` import in identity_test.go is unused since it uses `newTestClient`. Actually looking at the full file, `net/http/httptest` is NOT imported in identity_test.go — it only imports `net/http`. This is correct.
|
|||||||
|
|
||||||
|
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"})
|
||||||
|
})
|
||||||
|
sonnet-review-bot
commented
[NIT] json.NewEncoder(w).Encode(...) in the test handler ignores its return error. This is a minor inconsistency with the project's convention to check all error returns, although it is common practice in httptest handlers. Low priority but worth noting for consistency. **[NIT]** json.NewEncoder(w).Encode(...) in the test handler ignores its return error. This is a minor inconsistency with the project's convention to check all error returns, although it is common practice in httptest handlers. Low priority but worth noting for consistency.
|
|||||||
|
|
||||||
|
rodin marked this conversation as resolved
Outdated
sonnet-review-bot
commented
[NIT] json.NewEncoder(w).Encode(...) error return is silently discarded in several test handlers across identity_test.go and review_test.go. While this is common in test handlers and unlikely to cause issues, it means a JSON encoding failure would produce a 200 with an empty body and the test would fail with a confusing error message rather than a clear indication of where the problem is. Consistent with patterns elsewhere in test code, so this is just a nit. **[NIT]** json.NewEncoder(w).Encode(...) error return is silently discarded in several test handlers across identity_test.go and review_test.go. While this is common in test handlers and unlikely to cause issues, it means a JSON encoding failure would produce a 200 with an empty body and the test would fail with a confusing error message rather than a clear indication of where the problem is. Consistent with patterns elsewhere in test code, so this is just a nit.
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package github
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviewCommentEntry is a single inline comment in a review creation request.
|
||||||
|
type reviewCommentEntry 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
sonnet-review-bot
commented
[NIT] The comment on translateGitHubReviewState says 'States like APPROVED, DISMISSED, and PENDING pass through unchanged as they already match the canonical vcs representation.' — but PENDING is also a GitHub state that maps to itself. Worth noting whether PENDING is expected to appear in responses from ListReviews, and if vcs.Review.State accepts it. This is a documentation clarity issue, not a bug. **[NIT]** The comment on translateGitHubReviewState says 'States like APPROVED, DISMISSED, and PENDING pass through unchanged as they already match the canonical vcs representation.' — but PENDING is also a GitHub state that maps to itself. Worth noting whether PENDING is expected to appear in responses from ListReviews, and if vcs.Review.State accepts it. This is a documentation clarity issue, not a bug.
|
|||||||
|
// translateGitHubReviewState translates a GitHub API review state to the
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `dismissReviewRequest.Event` field is always hardcoded to "DISMISS" and never varies. Since it's an implementation detail of the GitHub API (not exposed to callers), the struct could be simplified to a single-field struct, or the Event field removed and the JSON marshaling handled inline. This is very minor — the current approach is clear and extensible.
|
|||||||
|
// canonical vcs.Review.State value.
|
||||||
|
func translateGitHubReviewState(state string) string {
|
||||||
|
switch state {
|
||||||
|
case "CHANGES_REQUESTED":
|
||||||
|
return "REQUEST_CHANGES"
|
||||||
|
sonnet-review-bot
commented
[MINOR] translateGitHubReviewState maps **[MINOR]** translateGitHubReviewState maps `"APPROVED"` → `"APPROVED"` and `"DISMISSED"` → `"DISMISSED"` explicitly, which is identical to the default passthrough case. These two cases can be removed, leaving only the two non-trivial translations (CHANGES_REQUESTED and COMMENTED). This simplifies the function and makes the intent clearer: only translate states that differ from their canonical representation.
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
sonnet-review-bot
commented
[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, **[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).
|
||||||
|
//
|
||||||
|
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)
|
||||||
|
|
||||||
|
payload := postReviewRequest{
|
||||||
|
Body: req.Body,
|
||||||
|
Event: string(req.Event),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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,
|
||||||
|
Position: comment.Position,
|
||||||
|
sonnet-review-bot
commented
[MINOR] The HTTP method is passed as a raw string literal **[MINOR]** The HTTP method is passed as a raw string literal `"POST"` to doRequestWithBody instead of using the `net/http` package constant `http.MethodPost`. The existing `doGet` wrapper uses `http.MethodGet`. For consistency, use `http.MethodPost`, `http.MethodDelete`, and `http.MethodPut` in the PostReview, DeleteReview, and DismissReview callers respectively.
|
|||||||
|
Body: comment.Body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
gpt-review-bot
commented
[NIT] Comment in DeleteReview mentions state "COMMENTED" while the canonical internal state mapping uses "COMMENT". Consider clarifying the wording to avoid confusion (e.g., "COMMENTED (mapped to COMMENT)"). **[NIT]** Comment in DeleteReview mentions state "COMMENTED" while the canonical internal state mapping uses "COMMENT". Consider clarifying the wording to avoid confusion (e.g., "COMMENTED (mapped to COMMENT)").
|
|||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal review request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("post review: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp reviewResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse review response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &vcs.Review{
|
||||||
|
ID: resp.ID,
|
||||||
|
Body: resp.Body,
|
||||||
|
User: vcs.UserInfo{Login: resp.User.Login},
|
||||||
|
State: translateGitHubReviewState(resp.State),
|
||||||
|
CommitID: resp.CommitID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReviews retrieves all reviews for a pull request.
|
||||||
|
// GitHub review states are translated to canonical vcs values.
|
||||||
|
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
|
||||||
|
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
|
|
||||||
|
body, err := c.doGet(ctx, reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list reviews: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses []reviewResponse
|
||||||
|
gpt-review-bot
commented
[MINOR] DismissReview payload includes Event: "DISMISS". GitHub's dismissals endpoint typically requires a message; verify that including the event field is supported by the API. If unnecessary, removing it reduces risk of server-side validation issues. **[MINOR]** DismissReview payload includes Event: "DISMISS". GitHub's dismissals endpoint typically requires a message; verify that including the event field is supported by the API. If unnecessary, removing it reduces risk of server-side validation issues.
|
|||||||
|
if err := json.Unmarshal(body, &responses); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse reviews response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reviews := make([]vcs.Review, len(responses))
|
||||||
|
for i, r := range responses {
|
||||||
|
reviews[i] = vcs.Review{
|
||||||
|
ID: r.ID,
|
||||||
|
Body: r.Body,
|
||||||
|
User: vcs.UserInfo{Login: r.User.Login},
|
||||||
|
State: translateGitHubReviewState(r.State),
|
||||||
|
CommitID: r.CommitID,
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `DeleteReview` method passes `nil` to `doRequestWithBody`, which is handled correctly by the nil check in that function. However, for a DELETE with no body it would be slightly more idiomatic (and self-documenting at the call site) to call `doRequest` or `doGet` with `http.MethodDelete` directly. This is a minor style point; the current approach works correctly and the inline comment explains the intent.
sonnet-review-bot
commented
[NIT] DeleteReview calls **[NIT]** DeleteReview calls `doRequestWithBody` with a nil body. The existing `doGet` wrapper (which calls `doRequest` → `doRequestCore`) would work just as well for a bodyless DELETE, but since DELETE with a body is unusual and `doRequestWithBody(ctx, DELETE, url, nil)` correctly produces a bodyless request (the nil-body branch in doRequestWithBody), this is functionally fine. Consider adding a brief comment clarifying that the nil is intentional and not an oversight (the existing inline comment already does this, so this is truly minor).
rodin
commented
Pushing back on this one. The current approach works correctly, is tested, and the nil-body contract is already documented via the inline comment added in a prior revision ( Pushing back on this one. The current approach works correctly, is tested, and the nil-body contract is already documented via the inline comment added in a prior revision (`// nil body: the GitHub DELETE endpoint for reviews requires no request body.`). Additionally, `doGet` does not accept a method parameter — it always uses GET — so refactoring to use it for DELETE is not trivial without introducing a new helper. Leaving as-is.
|
|||||||
|
}
|
||||||
|
}
|
||||||
|
return reviews, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReview deletes a pull request review.
|
||||||
|
rodin marked this conversation as resolved
Outdated
sonnet-review-bot
commented
[MINOR] DeleteReview passes nil to doRequestWithBody for a DELETE request with no body. The GitHub API DELETE /repos/{owner}/{repo}/pulls/{number}/reviews/{review_id} does not require a body, but some HTTP stacks behave differently with nil vs empty body on DELETE. This is currently fine but worth noting: when reqBody is nil, doRequestWithBody sets neither bodyFn nor Content-Type header, so the DELETE goes out with no body at all — which is correct for this endpoint. No bug, just worth documenting in the call site comment that nil body is intentional. **[MINOR]** DeleteReview passes nil to doRequestWithBody for a DELETE request with no body. The GitHub API DELETE /repos/{owner}/{repo}/pulls/{number}/reviews/{review_id} does not require a body, but some HTTP stacks behave differently with nil vs empty body on DELETE. This is currently fine but worth noting: when reqBody is nil, doRequestWithBody sets neither bodyFn nor Content-Type header, so the DELETE goes out with no body at all — which is correct for this endpoint. No bug, just worth documenting in the call site comment that nil body is intentional.
|
|||||||
|
// Only PENDING reviews can be deleted; attempting to delete a submitted review
|
||||||
|
// (APPROVED, CHANGES_REQUESTED, or COMMENTED per GitHub API naming) returns
|
||||||
|
sonnet-review-bot
commented
[NIT] DeleteReview passes nil to doRequestWithBody, which results in no Content-Type header being set. This is correct behaviour per the comment, but it also means no body is sent at all. The DELETE endpoint for GitHub reviews does not require a body, so this is fine — but it's slightly unusual to call doRequestWithBody with nil when doGet (a GET-only wrapper) pattern exists. Consider whether a doDelete or doRequestNoBody wrapper would be clearer, or at minimum a brief doc comment on why doRequestWithBody(nil) is the right call here (a comment is already present, so this is purely cosmetic). **[NIT]** DeleteReview passes nil to doRequestWithBody, which results in no Content-Type header being set. This is correct behaviour per the comment, but it also means no body is sent at all. The DELETE endpoint for GitHub reviews does not require a body, so this is fine — but it's slightly unusual to call doRequestWithBody with nil when doGet (a GET-only wrapper) pattern exists. Consider whether a doDelete or doRequestNoBody wrapper would be clearer, or at minimum a brief doc comment on why doRequestWithBody(nil) is the right call here (a comment is already present, so this is purely cosmetic).
|
|||||||
|
// ErrCannotDeleteSubmittedReview.
|
||||||
|
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)
|
||||||
|
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.
|
||||||
|
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{
|
||||||
|
sonnet-review-bot
commented
[NIT] The **[NIT]** The `DismissReview` method hardcodes `Event: "DISMISS"` in the payload struct. Since this is the only valid value for a dismissal, it's not a bug, but a small comment noting why the field is included (GitHub API requires it) would improve readability for future maintainers.
rodin
commented
Fixed in Fixed in dbc25f4 — added a comment explaining that the GitHub API requires the `Event` field for dismissal requests even though `DISMISS` is the only valid value.
|
|||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal dismiss request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.doRequestWithBody(ctx, http.MethodPut, reqURL, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dismiss review: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
package github
|
||||||
|
rodin marked this conversation as resolved
Outdated
sonnet-review-bot
commented
[MINOR] TestTranslateGitHubReviewState does not use t.Run() subtests, so on failure you get no subtest name in the output. This is consistent with the broader pattern in the repo but the table is large enough (6 entries) that t.Run would improve diagnosability. Per the testing-advanced pattern, named subtests are preferred when there are multiple distinct input/output pairs. Low priority since the test data includes the input in the error message anyway. **[MINOR]** TestTranslateGitHubReviewState does not use t.Run() subtests, so on failure you get no subtest name in the output. This is consistent with the broader pattern in the repo but the table is large enough (6 entries) that t.Run would improve diagnosability. Per the testing-advanced pattern, named subtests are preferred when there are multiple distinct input/output pairs. Low priority since the test data includes the input in the error message anyway.
|
|||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"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.
|
|||||||
|
// --- 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) {
|
||||||
|
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" {
|
||||||
|
sonnet-review-bot
commented
[NIT] In TestPostReview_HappyPath, the test handler ignores the error from io.ReadAll ( **[NIT]** In TestPostReview_HappyPath, the test handler ignores the error from io.ReadAll (`body, _ := io.ReadAll(r.Body)`). While unlikely to fail in a test server context, following the project convention of checking all error returns would be more consistent. The test would still exercise the same logic with `body, err := io.ReadAll(r.Body); if err != nil { t.Fatalf(...) }`.
|
|||||||
|
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
|
||||||
|
}{
|
||||||
|
sonnet-review-bot
commented
[NIT] TestTranslateGitHubReviewState doesn't use t.Run for subtests, while the table iteration is fine for this trivial case, the project convention (and the Go patterns) favor t.Run with named subtests for table-driven tests to aid filtering and output. Minor inconsistency with the rest of the test style. **[NIT]** TestTranslateGitHubReviewState doesn't use t.Run for subtests, while the table iteration is fine for this trivial case, the project convention (and the Go patterns) favor t.Run with named subtests for table-driven tests to aid filtering and output. Minor inconsistency with the rest of the test style.
|
|||||||
|
{"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", "", ""},
|
||||||
|
gpt-review-bot
commented
[NIT] containsStr/containsSubstring reimplement substring search. Prefer using strings.Contains for clarity and to reduce helper code. **[NIT]** containsStr/containsSubstring reimplement substring search. Prefer using strings.Contains for clarity and to reduce helper code.
|
|||||||
|
}
|
||||||
|
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{
|
||||||
|
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.
|
|||||||
|
{Path: "a.go", Position: 1, CommitID: "sha-1", Body: "first"},
|
||||||
|
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.
|
|||||||
|
{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)
|
||||||
|
}
|
||||||
|
}
|
||||||
[NIT] The requestOptions.bodyFn comment lists POST, PUT, PATCH as body-carrying methods but omits DELETE-with-body. Update the comment to reflect that it must be non-nil for any request that carries a body (including DELETE when applicable) to avoid confusion.