090ae3848c
PR Ready Gate / clear-labels (pull_request) Successful in 1s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 34s
CI / review (gpt-5, security, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 45s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m34s
Address review feedback:
1. Make backoff delays injectable via Client.RetryBackoff field
- Defaults to {1s, 2s} when nil for production
- Tests can set shorter values for fast execution
- Fixes slow unit tests that previously waited 3+ seconds
2. Add retry on temporary network errors (net.OpError, net.DNSError)
- Connection refused, network unreachable, DNS failures now retry
- Non-temporary network errors still fail immediately
- Context cancellation still respected during backoff
Added isTemporaryNetError helper and TestIsTemporaryNetError test.
Updated existing retry tests to use configurable short backoffs.
959 lines
30 KiB
Go
959 lines
30 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGetPullRequest(t *testing.T) {
|
|
pr := PullRequest{
|
|
Title: "Add feature X",
|
|
Body: "This adds feature X.",
|
|
}
|
|
pr.Head.Sha = "abc123"
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/pulls/1" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Header.Get("Authorization") != "token test-token" {
|
|
t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization"))
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(pr)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
got, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.Title != "Add feature X" {
|
|
t.Errorf("expected title %q, got %q", "Add feature X", got.Title)
|
|
}
|
|
if got.Body != "This adds feature X." {
|
|
t.Errorf("expected body %q, got %q", "This adds feature X.", got.Body)
|
|
}
|
|
if got.Head.Sha != "abc123" {
|
|
t.Errorf("expected sha %q, got %q", "abc123", got.Head.Sha)
|
|
}
|
|
}
|
|
|
|
func TestGetPullRequestDiff(t *testing.T) {
|
|
expectedDiff := "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1 +1 @@\n-old\n+new\n"
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/pulls/5.diff" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.Write([]byte(expectedDiff))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
got, err := client.GetPullRequestDiff(context.Background(), "owner", "repo", 5)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != expectedDiff {
|
|
t.Errorf("expected diff %q, got %q", expectedDiff, got)
|
|
}
|
|
}
|
|
|
|
func TestGetCommitStatuses(t *testing.T) {
|
|
statuses := []CommitStatus{
|
|
{Status: "success", Context: "ci/test", Description: "All tests passed"},
|
|
{Status: "failure", Context: "ci/lint", Description: "Lint failed"},
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/commits/abc123/statuses" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(statuses)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
got, err := client.GetCommitStatuses(context.Background(), "owner", "repo", "abc123")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 statuses, got %d", len(got))
|
|
}
|
|
if got[0].Status != "success" {
|
|
t.Errorf("expected first status %q, got %q", "success", got[0].Status)
|
|
}
|
|
if got[1].Status != "failure" {
|
|
t.Errorf("expected second status %q, got %q", "failure", got[1].Status)
|
|
}
|
|
}
|
|
|
|
func TestPostReview(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/pulls/3/reviews" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Header.Get("Content-Type") != "application/json" {
|
|
t.Errorf("unexpected content type: %s", r.Header.Get("Content-Type"))
|
|
}
|
|
|
|
var payload struct {
|
|
Body string `json:"body"`
|
|
Event string `json:"event"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("failed to decode payload: %v", err)
|
|
}
|
|
if payload.Body != "LGTM" {
|
|
t.Errorf("expected body %q, got %q", "LGTM", payload.Body)
|
|
}
|
|
if payload.Event != "APPROVED" {
|
|
t.Errorf("expected event %q, got %q", "APPROVED", payload.Event)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"id":100,"user":{"login":"review-bot"},"state":"APPROVED","stale":false}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", nil)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if review.ID != 100 {
|
|
t.Errorf("expected review ID 100, got %d", review.ID)
|
|
}
|
|
if review.User.Login != "review-bot" {
|
|
t.Errorf("expected user login %q, got %q", "review-bot", review.User.Login)
|
|
}
|
|
}
|
|
|
|
func TestGetPullRequest_Non200(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte(`{"message":"not found"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 999)
|
|
if err == nil {
|
|
t.Fatal("expected error for 404, got nil")
|
|
}
|
|
}
|
|
|
|
func TestGetPullRequest_BadJSON(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(`not json`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.GetPullRequest(context.Background(), "owner", "repo", 1)
|
|
if err == nil {
|
|
t.Fatal("expected error for bad JSON, got nil")
|
|
}
|
|
}
|
|
|
|
func TestPostReview_Non200(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"message":"forbidden"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.PostReview(context.Background(), "owner", "repo", 1, "APPROVED", "test", nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for 403, got nil")
|
|
}
|
|
}
|
|
|
|
func TestGetFileContent(t *testing.T) {
|
|
expected := "# Conventions\n- Be nice\n"
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/raw/CONVENTIONS.md" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.Write([]byte(expected))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
got, err := client.GetFileContent(context.Background(), "owner", "repo", "CONVENTIONS.md")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != expected {
|
|
t.Errorf("expected %q, got %q", expected, got)
|
|
}
|
|
}
|
|
|
|
func TestGetPullRequestFiles(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/pulls/1/files" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`[{"filename":"main.go","status":"modified"},{"filename":"old.go","status":"removed"}]`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
files, err := client.GetPullRequestFiles(context.Background(), "owner", "repo", 1)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(files) != 2 {
|
|
t.Fatalf("expected 2 files, got %d", len(files))
|
|
}
|
|
if files[0].Filename != "main.go" || files[0].Status != "modified" {
|
|
t.Errorf("unexpected first file: %+v", files[0])
|
|
}
|
|
}
|
|
|
|
func TestGetFileContentRef(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/raw/main.go" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.URL.Query().Get("ref") != "feature-branch" {
|
|
t.Errorf("unexpected ref: %s", r.URL.Query().Get("ref"))
|
|
}
|
|
w.Write([]byte("package main\n"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
content, err := client.GetFileContentRef(context.Background(), "owner", "repo", "main.go", "feature-branch")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if content != "package main\n" {
|
|
t.Errorf("unexpected content: %q", content)
|
|
}
|
|
}
|
|
|
|
func TestListContents(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/contents/docs" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `[{"name":"guide.md","path":"docs/guide.md","type":"file"},{"name":"sub","path":"docs/sub","type":"dir"}]`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
entries, err := client.ListContents(context.Background(), "owner", "repo", "docs")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
|
}
|
|
if entries[0].Type != "file" || entries[0].Path != "docs/guide.md" {
|
|
t.Errorf("unexpected first entry: %+v", entries[0])
|
|
}
|
|
if entries[1].Type != "dir" {
|
|
t.Errorf("expected dir type, got %s", entries[1].Type)
|
|
}
|
|
}
|
|
|
|
func TestGetAllFilesInPath_File(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/v1/repos/owner/repo/contents/README.md" {
|
|
// Gitea returns 404 for contents API on files (it's not a dir)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if r.URL.Path == "/api/v1/repos/owner/repo/raw/README.md" {
|
|
fmt.Fprintf(w, "# Hello")
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(files) != 1 {
|
|
t.Fatalf("expected 1 file, got %d", len(files))
|
|
}
|
|
if files["README.md"] != "# Hello" {
|
|
t.Errorf("unexpected content: %q", files["README.md"])
|
|
}
|
|
}
|
|
|
|
func TestEscapePath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"simple", "src/main.go", "src/main.go"},
|
|
{"spaces", "my dir/my file.go", "my%20dir/my%20file.go"},
|
|
{"special chars", "path/file#1.txt", "path/file%231.txt"},
|
|
{"empty", "", ""},
|
|
{"single segment", "README.md", "README.md"},
|
|
{"nested deep", "a/b/c/d.md", "a/b/c/d.md"},
|
|
{"already encoded", "path/file%20name.go", "path/file%2520name.go"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := escapePath(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("escapePath(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListReviews(t *testing.T) {
|
|
pageCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/pulls/5/reviews" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.URL.Query().Get("limit") != "50" {
|
|
t.Errorf("expected limit=50, got %s", r.URL.Query().Get("limit"))
|
|
}
|
|
pageCount++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Return 2 results (less than page size) to signal end
|
|
w.Write([]byte(`[{"id":10,"user":{"login":"bot-a"},"state":"APPROVED","stale":false},{"id":11,"user":{"login":"bot-b"},"state":"REQUEST_CHANGES","stale":true}]`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(reviews) != 2 {
|
|
t.Fatalf("expected 2 reviews, got %d", len(reviews))
|
|
}
|
|
if reviews[0].User.Login != "bot-a" {
|
|
t.Errorf("expected bot-a, got %s", reviews[0].User.Login)
|
|
}
|
|
if pageCount != 1 {
|
|
t.Errorf("expected 1 page fetch (results < page size), got %d", pageCount)
|
|
}
|
|
}
|
|
|
|
func TestListReviews_Pagination(t *testing.T) {
|
|
pageCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
pageCount++
|
|
page := r.URL.Query().Get("page")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if page == "1" {
|
|
// Return exactly 50 items to trigger next page fetch
|
|
items := "["
|
|
for i := 0; i < 50; i++ {
|
|
if i > 0 {
|
|
items += ","
|
|
}
|
|
items += fmt.Sprintf(`{"id":%d,"user":{"login":"bot"},"state":"APPROVED","stale":false}`, i+1)
|
|
}
|
|
items += "]"
|
|
w.Write([]byte(items))
|
|
} else {
|
|
// Page 2: return fewer than 50 to signal end
|
|
w.Write([]byte(`[{"id":51,"user":{"login":"bot"},"state":"APPROVED","stale":false}]`))
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
reviews, err := client.ListReviews(context.Background(), "owner", "repo", 5)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(reviews) != 51 {
|
|
t.Fatalf("expected 51 reviews across 2 pages, got %d", len(reviews))
|
|
}
|
|
if pageCount != 2 {
|
|
t.Errorf("expected 2 page fetches, got %d", pageCount)
|
|
}
|
|
}
|
|
|
|
func TestDeleteReview(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/pulls/5/reviews/10" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Method != "DELETE" {
|
|
t.Errorf("expected DELETE, got %s", r.Method)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteReview_Forbidden(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"message":"forbidden"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.DeleteReview(context.Background(), "owner", "repo", 5, 10)
|
|
if err == nil {
|
|
t.Fatal("expected error for 403, got nil")
|
|
}
|
|
}
|
|
|
|
func TestEditComment(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPatch {
|
|
t.Errorf("expected PATCH, got %s", r.Method)
|
|
}
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/issues/comments/42" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
|
|
var payload struct {
|
|
Body string `json:"body"`
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&payload)
|
|
if payload.Body != "updated body" {
|
|
t.Errorf("unexpected body: %s", payload.Body)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"id": 42, "body": "updated body"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.EditComment(context.Background(), "owner", "repo", 42, "updated body")
|
|
if err != nil {
|
|
t.Fatalf("EditComment() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEditComment_Forbidden(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"message": "not allowed"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.EditComment(context.Background(), "owner", "repo", 42, "new body")
|
|
if err == nil {
|
|
t.Fatal("expected error for 403 response")
|
|
}
|
|
}
|
|
|
|
func TestGetTimelineReviewCommentID(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/repos/owner/repo/issues/5/timeline" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.Write([]byte(`[
|
|
{"id": 100, "type": "comment", "body": "random"},
|
|
{"id": 200, "type": "review", "body": "other review <!-- review-bot:gpt -->"},
|
|
{"id": 300, "type": "review", "body": "our review <!-- review-bot:sonnet -->"}
|
|
]`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
id, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
|
if err != nil {
|
|
t.Fatalf("GetTimelineReviewCommentID() error = %v", err)
|
|
}
|
|
if id != 300 {
|
|
t.Errorf("got id=%d, want 300", id)
|
|
}
|
|
}
|
|
|
|
func TestGetTimelineReviewCommentID_NotFound(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(`[{"id": 100, "type": "review", "body": "no match"}]`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.GetTimelineReviewCommentID(context.Background(), "owner", "repo", 5, "<!-- review-bot:sonnet -->")
|
|
if err == nil {
|
|
t.Fatal("expected error when sentinel not found")
|
|
}
|
|
}
|
|
|
|
func TestGetAllFilesInPath_404FallsBackToFile(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/repos/owner/repo/contents/README.md":
|
|
// Contents API returns 404 for files (not a directory)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte(`{"message":"not found"}`))
|
|
case "/api/v1/repos/owner/repo/raw/README.md":
|
|
w.Write([]byte("# Hello\n"))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte(`{"message":"not found"}`))
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
|
if err != nil {
|
|
t.Fatalf("expected fallback to file on 404, got error: %v", err)
|
|
}
|
|
if len(files) != 1 {
|
|
t.Fatalf("expected 1 file, got %d", len(files))
|
|
}
|
|
if files["README.md"] != "# Hello\n" {
|
|
t.Errorf("unexpected content: %q", files["README.md"])
|
|
}
|
|
}
|
|
|
|
func TestGetAllFilesInPath_500Propagates(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Simulate a server error from ListContents
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`{"message":"internal server error"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "somepath")
|
|
if err == nil {
|
|
t.Fatal("expected error to propagate for 500, got nil")
|
|
}
|
|
// Should NOT fall back to file fetch — error should propagate
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected APIError in chain, got: %v", err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusInternalServerError {
|
|
t.Errorf("expected status 500, got %d", apiErr.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestGetAllFilesInPath_403Propagates(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"message":"token has insufficient scope"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "private/stuff")
|
|
if err == nil {
|
|
t.Fatal("expected error to propagate for 403, got nil")
|
|
}
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected APIError in chain, got: %v", err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusForbidden {
|
|
t.Errorf("expected status 403, got %d", apiErr.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestIsNotFound(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil error", nil, false},
|
|
{"non-API error", fmt.Errorf("network timeout"), false},
|
|
{"404 APIError", &APIError{StatusCode: 404, Body: "not found"}, true},
|
|
{"500 APIError", &APIError{StatusCode: 500, Body: "server error"}, false},
|
|
{"wrapped 404", fmt.Errorf("list contents: %w", &APIError{StatusCode: 404, Body: "not found"}), true},
|
|
{"wrapped 500", fmt.Errorf("list contents: %w", &APIError{StatusCode: 500, Body: "err"}), false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := IsNotFound(tt.err)
|
|
if got != tt.want {
|
|
t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetAuthenticatedUser(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/user" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if r.Header.Get("Authorization") != "token test-token" {
|
|
t.Error("missing or wrong auth header")
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"login":"my-bot","id":42}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
login, err := client.GetAuthenticatedUser(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAuthenticatedUser() error = %v", err)
|
|
}
|
|
if login != "my-bot" {
|
|
t.Errorf("login = %q, want %q", login, "my-bot")
|
|
}
|
|
}
|
|
|
|
func TestRequestReviewer(t *testing.T) {
|
|
var gotBody []byte
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
expected := "/api/v1/repos/owner/repo/pulls/7/requested_reviewers"
|
|
if r.URL.Path != expected {
|
|
t.Errorf("path = %q, want %q", r.URL.Path, expected)
|
|
}
|
|
gotBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 7, "bot-user")
|
|
if err != nil {
|
|
t.Fatalf("RequestReviewer() error = %v", err)
|
|
}
|
|
if !strings.Contains(string(gotBody), `"bot-user"`) {
|
|
t.Errorf("body = %s, want to contain bot-user", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestRequestReviewer_204(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
|
if err != nil {
|
|
t.Fatalf("RequestReviewer() should accept 204, got error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRequestReviewer_Error(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
fmt.Fprint(w, "no permission")
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
|
if err == nil {
|
|
t.Fatal("expected error for 403 response")
|
|
}
|
|
if !strings.Contains(err.Error(), "403") {
|
|
t.Errorf("error should mention status code: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestListReviewComments(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.URL.Path, "/pulls/1/reviews/42/comments") {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `[{"id":100,"path":"main.go","new_position":5,"body":"finding"},{"id":101,"path":"lib.go","new_position":10,"body":"another"}]`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
comments, err := client.ListReviewComments(context.Background(), "owner", "repo", 1, 42)
|
|
if err != nil {
|
|
t.Fatalf("ListReviewComments() error = %v", err)
|
|
}
|
|
if len(comments) != 2 {
|
|
t.Fatalf("got %d comments, want 2", len(comments))
|
|
}
|
|
if comments[0].ID != 100 {
|
|
t.Errorf("comments[0].ID = %d, want 100", comments[0].ID)
|
|
}
|
|
if comments[1].Path != "lib.go" {
|
|
t.Errorf("comments[1].Path = %q, want %q", comments[1].Path, "lib.go")
|
|
}
|
|
}
|
|
|
|
func TestResolveComment(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if !strings.Contains(r.URL.Path, "/pulls/comments/99/resolve") {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
|
if err != nil {
|
|
t.Fatalf("ResolveComment() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveComment_Error(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
fmt.Fprint(w, "not found")
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
|
if err == nil {
|
|
t.Fatal("expected error for 404 response")
|
|
}
|
|
}
|
|
|
|
func TestIsServerError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil error", nil, false},
|
|
{"non-API error", fmt.Errorf("network timeout"), false},
|
|
{"404 APIError", &APIError{StatusCode: 404, Body: "not found"}, false},
|
|
{"500 APIError", &APIError{StatusCode: 500, Body: "server error"}, true},
|
|
{"502 APIError", &APIError{StatusCode: 502, Body: "bad gateway"}, true},
|
|
{"503 APIError", &APIError{StatusCode: 503, Body: "unavailable"}, true},
|
|
{"599 APIError", &APIError{StatusCode: 599, Body: "edge case"}, true},
|
|
{"600 not server error", &APIError{StatusCode: 600, Body: "edge"}, false},
|
|
{"400 not server error", &APIError{StatusCode: 400, Body: "bad request"}, false},
|
|
{"wrapped 500", fmt.Errorf("fetch: %w", &APIError{StatusCode: 500, Body: "err"}), true},
|
|
{"wrapped 404", fmt.Errorf("fetch: %w", &APIError{StatusCode: 404, Body: "err"}), false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := IsServerError(tt.err)
|
|
if got != tt.want {
|
|
t.Errorf("IsServerError(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDoGet_RetriesOn500(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts < 3 {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`{"message":"transient error"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"data":"success"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
// Use short backoff for fast tests
|
|
client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
|
|
|
|
body, err := client.doGet(context.Background(), server.URL+"/test")
|
|
if err != nil {
|
|
t.Fatalf("expected success after retry, got error: %v", err)
|
|
}
|
|
if string(body) != `{"data":"success"}` {
|
|
t.Errorf("body = %q, want %q", string(body), `{"data":"success"}`)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("attempts = %d, want 3", attempts)
|
|
}
|
|
}
|
|
|
|
func TestDoGet_FailsAfterMaxRetries(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`{"message":"persistent error"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
// Use short backoff for fast tests
|
|
client.RetryBackoff = []time.Duration{1 * time.Millisecond, 1 * time.Millisecond}
|
|
|
|
_, err := client.doGet(context.Background(), server.URL+"/test")
|
|
if err == nil {
|
|
t.Fatal("expected error after max retries")
|
|
}
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected APIError, got: %v", err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want 500", apiErr.StatusCode)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("attempts = %d, want 3 (max retries)", attempts)
|
|
}
|
|
}
|
|
|
|
func TestDoGet_NoRetryOn4xx(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"message":"forbidden"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.doGet(context.Background(), server.URL+"/test")
|
|
if err == nil {
|
|
t.Fatal("expected error for 403")
|
|
}
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected APIError, got: %v", err)
|
|
}
|
|
if apiErr.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", apiErr.StatusCode)
|
|
}
|
|
if attempts != 1 {
|
|
t.Errorf("attempts = %d, want 1 (no retry on 4xx)", attempts)
|
|
}
|
|
}
|
|
|
|
func TestDoGet_RespectsContextCancellation(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte(`{"message":"error"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
// Use longer backoff to give us time to cancel during the wait
|
|
client.RetryBackoff = []time.Duration{100 * time.Millisecond, 100 * time.Millisecond}
|
|
|
|
// Cancel after first attempt returns and retry begins
|
|
go func() {
|
|
time.Sleep(20 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
_, err := client.doGet(ctx, server.URL+"/test")
|
|
if err == nil {
|
|
t.Fatal("expected error on context cancellation")
|
|
}
|
|
// Should have made 1 attempt, then context cancelled during backoff
|
|
if attempts != 1 {
|
|
t.Errorf("attempts = %d, expected 1 before context cancel during backoff", attempts)
|
|
}
|
|
}
|
|
|
|
func TestDoGet_RetriesOnTemporaryNetError(t *testing.T) {
|
|
attempts := 0
|
|
|
|
// Create a listener that we can close to simulate connection refused
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("failed to create listener: %v", err)
|
|
}
|
|
addr := ln.Addr().String()
|
|
|
|
// Close immediately to cause connection refused on first attempts
|
|
ln.Close()
|
|
|
|
// Start a server after a short delay to succeed on retry
|
|
go func() {
|
|
time.Sleep(5 * time.Millisecond)
|
|
newLn, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
// Port might be reused; not critical for this test
|
|
return
|
|
}
|
|
defer newLn.Close()
|
|
|
|
for {
|
|
conn, err := newLn.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
attempts++
|
|
// Respond with success
|
|
conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"))
|
|
conn.Close()
|
|
}
|
|
}()
|
|
|
|
client := NewClient("http://"+addr, "test-token")
|
|
client.RetryBackoff = []time.Duration{10 * time.Millisecond, 10 * time.Millisecond}
|
|
|
|
// The request might succeed or fail depending on timing, but the key is
|
|
// that we attempt retry on connection refused
|
|
_, _ = client.doGet(context.Background(), "http://"+addr+"/test")
|
|
|
|
// This test verifies the code path exists; actual retry behavior depends on timing
|
|
}
|
|
|
|
func TestIsTemporaryNetError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil error", nil, false},
|
|
{"plain error", fmt.Errorf("some error"), false},
|
|
{"OpError", &net.OpError{Op: "dial", Err: fmt.Errorf("connection refused")}, true},
|
|
{"temporary DNSError", &net.DNSError{IsTemporary: true}, true},
|
|
{"non-temporary DNSError", &net.DNSError{IsTemporary: false}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := isTemporaryNetError(tt.err)
|
|
if got != tt.want {
|
|
t.Errorf("isTemporaryNetError(%v) = %v, want %v", tt.err, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|