7e3b6ec8f1
CI / test (pull_request) Successful in 20s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 46s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m24s
Add CommitID field to vcs.ReviewRequest so the commit anchor propagates through the vcs.Client interface to platform adapters. Changes: - vcs/types.go: Add CommitID string field to ReviewRequest - gitea/client.go: Add commitID parameter to PostReview, include in API payload - gitea/adapter.go: Pass req.CommitID to underlying client - github/review.go: Use req.CommitID as primary, fall back to comment-level - cmd/review-bot/main.go: Set CommitID on ReviewRequest from pr.Head.SHA Fixes #114
485 lines
14 KiB
Go
485 lines
14 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
)
|
|
|
|
// --- PostReview tests ---
|
|
|
|
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" {
|
|
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
|
|
}{
|
|
{"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", "", ""},
|
|
}
|
|
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{
|
|
{Path: "a.go", Position: 1, CommitID: "sha-1", Body: "first"},
|
|
{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)
|
|
}
|
|
}
|
|
|
|
func TestPostReview_RequestCommitID_TakesPriority(t *testing.T) {
|
|
var gotPayload struct {
|
|
CommitID string `json:"commit_id"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewDecoder(r.Body).Decode(&gotPayload)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": 42,
|
|
"body": "LGTM",
|
|
"state": "APPROVED",
|
|
"commit_id": "req-level-sha",
|
|
"user": map[string]any{"login": "bot"},
|
|
})
|
|
})
|
|
|
|
review, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
|
|
Body: "LGTM",
|
|
Event: vcs.ReviewEventApprove,
|
|
CommitID: "req-level-sha",
|
|
Comments: []vcs.ReviewComment{
|
|
{Path: "a.go", Position: 1, CommitID: "req-level-sha", Body: "looks good"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotPayload.CommitID != "req-level-sha" {
|
|
t.Errorf("sent commit_id = %q, want %q", gotPayload.CommitID, "req-level-sha")
|
|
}
|
|
if review.CommitID != "req-level-sha" {
|
|
t.Errorf("review.CommitID = %q, want %q", review.CommitID, "req-level-sha")
|
|
}
|
|
}
|
|
|
|
func TestPostReview_RequestCommitID_ConflictsWithComment(t *testing.T) {
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("request should not be sent when commit IDs conflict")
|
|
})
|
|
|
|
// req.CommitID is set, and a comment has a different CommitID → conflict
|
|
_, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
|
|
Body: "Review",
|
|
Event: vcs.ReviewEventComment,
|
|
CommitID: "req-sha",
|
|
Comments: []vcs.ReviewComment{
|
|
{Path: "a.go", Position: 1, CommitID: "different-sha", Body: "nit"},
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for conflicting commit IDs")
|
|
}
|
|
if !errors.Is(err, ErrConflictingCommitIDs) {
|
|
t.Errorf("expected ErrConflictingCommitIDs, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPostReview_RequestCommitID_FallbackToComment(t *testing.T) {
|
|
var gotPayload struct {
|
|
CommitID string `json:"commit_id"`
|
|
}
|
|
|
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewDecoder(r.Body).Decode(&gotPayload)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"id": 43,
|
|
"body": "ok",
|
|
"state": "COMMENTED",
|
|
"commit_id": "comment-sha",
|
|
"user": map[string]any{"login": "bot"},
|
|
})
|
|
})
|
|
|
|
// req.CommitID is empty, so it falls back to the comment's CommitID
|
|
_, err := c.PostReview(context.Background(), "owner", "repo", 1, vcs.ReviewRequest{
|
|
Body: "ok",
|
|
Event: vcs.ReviewEventComment,
|
|
// CommitID intentionally empty
|
|
Comments: []vcs.ReviewComment{
|
|
{Path: "a.go", Position: 1, CommitID: "comment-sha", Body: "note"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotPayload.CommitID != "comment-sha" {
|
|
t.Errorf("sent commit_id = %q, want %q (fallback from comment)", gotPayload.CommitID, "comment-sha")
|
|
}
|
|
}
|