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
7 changed files with 757 additions and 16 deletions
+52 -10
View File
@@ -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 {
Outdated
Review

[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.

**[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.
// 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})
}
Outdated
Review

[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.

**[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.
Outdated
Review

[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"}
Outdated
Review

[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".

**[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)
}
Outdated
Review

[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.
+4 -6
View File
@@ -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)
)
+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
}
+29
View File
@@ -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
}
+46
View File
@@ -0,0 +1,46 @@
package github
Outdated
Review

[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.

**[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"})
})
Review

[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
Outdated
Review

[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)
}
}
+212
View File
@@ -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"`
}
Outdated
Review

[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
Outdated
Review

[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.

**[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"
Outdated
Review

[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.

**[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.
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)
payload := postReviewRequest{
Body: req.Body,
Event: string(req.Event),
}
// 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 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,
Position: comment.Position,
Outdated
Review

[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.

**[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,
})
}
Outdated
Review

[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
Outdated
Review

[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,
Outdated
Review

[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.

**[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.
Outdated
Review

[NIT] DeleteReview calls doRequestWithBody with a nil body. The existing doGet wrapper (which calls doRequestdoRequestCore) 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).

**[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).
Outdated
Review

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.

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
Outdated
Review

[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
Review

[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{
Outdated
Review

[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.

**[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.
Outdated
Review

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.

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
}
+391
View File
@@ -0,0 +1,391 @@
package github
rodin marked this conversation as resolved Outdated
Outdated
Review

[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"
)
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.
// --- 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) {
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" {
Review

[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(...) }.

**[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
}{
Outdated
Review

[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", "", ""},
Outdated
Review

[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{
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.
{Path: "a.go", Position: 1, CommitID: "sha-1", Body: "first"},
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.
{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)
}
}