a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
337 lines
10 KiB
Go
337 lines
10 KiB
Go
package forge
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-github/v88/github"
|
|
)
|
|
|
|
// newTestClient builds a *ghClient whose go-github client points at the
|
|
// given test-server base URL. It exercises the real go-github transport;
|
|
// only the base URL (and token + slug) are injected.
|
|
func newTestClient(t *testing.T, baseURL string) *ghClient {
|
|
t.Helper()
|
|
base := strings.TrimSuffix(baseURL, "/") + "/"
|
|
gh, err := github.NewClient(github.WithAuthToken("test-token"), github.WithURLs(&base, &base))
|
|
if err != nil {
|
|
t.Fatalf("building github client: %v", err)
|
|
}
|
|
return &ghClient{gh: gh, owner: "octo", repo: "gortex", timeout: 5 * time.Second}
|
|
}
|
|
|
|
// withTestSeam swaps newClient so the free functions resolve to c, then
|
|
// restores it. It returns a cleanup the caller defers.
|
|
func withTestSeam(t *testing.T, c *ghClient) {
|
|
t.Helper()
|
|
prev := newClient
|
|
newClient = func(ctx context.Context, repoDir string) (*ghClient, error) { return c, nil }
|
|
t.Cleanup(func() { newClient = prev })
|
|
}
|
|
|
|
// fixtureServer routes the recorded GitHub REST endpoints used by forge.
|
|
func fixtureServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/repos/octo/gortex/pulls", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(prListJSON))
|
|
})
|
|
mux.HandleFunc("/repos/octo/gortex/pulls/7", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(prGetJSON))
|
|
})
|
|
mux.HandleFunc("/repos/octo/gortex/pulls/7/files", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(prFilesJSON))
|
|
})
|
|
mux.HandleFunc("/repos/octo/gortex/pulls/7/reviews", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.Method == http.MethodPost {
|
|
// CreateReview
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"id":1,"state":"COMMENTED"}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(prReviewsJSON))
|
|
})
|
|
mux.HandleFunc("/repos/octo/gortex/commits/headsha/check-runs", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(checkRunsJSON))
|
|
})
|
|
mux.HandleFunc("/repos/octo/gortex/commits/headsha/status", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(combinedStatusJSON))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestListPRs_FilesEmpty(t *testing.T) {
|
|
srv := fixtureServer(t)
|
|
c := newTestClient(t, srv.URL)
|
|
withTestSeam(t, c)
|
|
|
|
prs, err := ListPRs(context.Background(), ".", ListOpts{State: "open"})
|
|
if err != nil {
|
|
t.Fatalf("ListPRs: %v", err)
|
|
}
|
|
if len(prs) != 1 {
|
|
t.Fatalf("got %d PRs, want 1", len(prs))
|
|
}
|
|
p := prs[0]
|
|
if p.Number != 7 {
|
|
t.Errorf("Number = %d, want 7", p.Number)
|
|
}
|
|
if p.Title != "Add forge" {
|
|
t.Errorf("Title = %q", p.Title)
|
|
}
|
|
if p.Author != "alice" {
|
|
t.Errorf("Author = %q, want alice", p.Author)
|
|
}
|
|
if p.BaseRef != "main" || p.HeadRef != "feature" {
|
|
t.Errorf("Base/Head = %q/%q", p.BaseRef, p.HeadRef)
|
|
}
|
|
if len(p.Files) != 0 {
|
|
t.Errorf("Files must be EMPTY after ListPRs, got %v", p.Files)
|
|
}
|
|
// No aggregates requested → unset.
|
|
if p.ReviewDecision != "" || p.CIRollup != "" {
|
|
t.Errorf("aggregates filled without opt-in: decision=%q ci=%q", p.ReviewDecision, p.CIRollup)
|
|
}
|
|
}
|
|
|
|
func TestListPRs_WithDecisionAndCI(t *testing.T) {
|
|
srv := fixtureServer(t)
|
|
c := newTestClient(t, srv.URL)
|
|
withTestSeam(t, c)
|
|
|
|
prs, err := ListPRs(context.Background(), ".", ListOpts{State: "open", WithDecision: true, WithCI: true})
|
|
if err != nil {
|
|
t.Fatalf("ListPRs: %v", err)
|
|
}
|
|
if len(prs) != 1 {
|
|
t.Fatalf("got %d PRs, want 1", len(prs))
|
|
}
|
|
p := prs[0]
|
|
if p.ReviewDecision != "CHANGES_REQUESTED" {
|
|
t.Errorf("ReviewDecision = %q, want CHANGES_REQUESTED", p.ReviewDecision)
|
|
}
|
|
if p.CIRollup != "FAILURE" {
|
|
t.Errorf("CIRollup = %q, want FAILURE", p.CIRollup)
|
|
}
|
|
}
|
|
|
|
func TestViewPR_HydratesFilesAndAggregates(t *testing.T) {
|
|
srv := fixtureServer(t)
|
|
c := newTestClient(t, srv.URL)
|
|
withTestSeam(t, c)
|
|
|
|
pr, err := ViewPR(context.Background(), ".", 7)
|
|
if err != nil {
|
|
t.Fatalf("ViewPR: %v", err)
|
|
}
|
|
if len(pr.Files) != 2 {
|
|
t.Fatalf("Files = %v, want 2 entries", pr.Files)
|
|
}
|
|
if pr.Files[0] != "internal/forge/forge.go" {
|
|
t.Errorf("Files[0] = %q", pr.Files[0])
|
|
}
|
|
if pr.ReviewDecision != "CHANGES_REQUESTED" {
|
|
t.Errorf("ReviewDecision = %q", pr.ReviewDecision)
|
|
}
|
|
if pr.CIRollup != "FAILURE" {
|
|
t.Errorf("CIRollup = %q", pr.CIRollup)
|
|
}
|
|
}
|
|
|
|
func TestPRFiles(t *testing.T) {
|
|
srv := fixtureServer(t)
|
|
c := newTestClient(t, srv.URL)
|
|
withTestSeam(t, c)
|
|
|
|
files, err := PRFiles(context.Background(), ".", 7)
|
|
if err != nil {
|
|
t.Fatalf("PRFiles: %v", err)
|
|
}
|
|
if len(files) != 2 {
|
|
t.Fatalf("got %d files, want 2", len(files))
|
|
}
|
|
}
|
|
|
|
func TestDiffPR_HunksParsed(t *testing.T) {
|
|
srv := fixtureServer(t)
|
|
c := newTestClient(t, srv.URL)
|
|
withTestSeam(t, c)
|
|
|
|
diff, err := DiffPR(context.Background(), ".", 7)
|
|
if err != nil {
|
|
t.Fatalf("DiffPR: %v", err)
|
|
}
|
|
if diff.Number != 7 || diff.BaseRef != "main" || diff.HeadRef != "feature" {
|
|
t.Errorf("diff meta = %d %q %q", diff.Number, diff.BaseRef, diff.HeadRef)
|
|
}
|
|
if len(diff.Files) != 2 {
|
|
t.Fatalf("got %d files, want 2", len(diff.Files))
|
|
}
|
|
// First file's patch must parse into at least one hunk anchored to it.
|
|
var hunked *PRFile
|
|
for i := range diff.Files {
|
|
if len(diff.Files[i].Hunks) > 0 {
|
|
hunked = &diff.Files[i]
|
|
break
|
|
}
|
|
}
|
|
if hunked == nil {
|
|
t.Fatalf("no file produced hunks from its patch")
|
|
}
|
|
if hunked.Hunks[0].FilePath != hunked.Path {
|
|
t.Errorf("hunk FilePath = %q, want %q", hunked.Hunks[0].FilePath, hunked.Path)
|
|
}
|
|
if hunked.Hunks[0].StartLine == 0 {
|
|
t.Errorf("hunk StartLine not parsed: %+v", hunked.Hunks[0])
|
|
}
|
|
if diff.Raw == "" {
|
|
t.Errorf("Raw diff is empty")
|
|
}
|
|
}
|
|
|
|
func TestPostReviewComments(t *testing.T) {
|
|
srv := fixtureServer(t)
|
|
c := newTestClient(t, srv.URL)
|
|
withTestSeam(t, c)
|
|
|
|
err := PostReviewComments(context.Background(), ".", 7, []ReviewComment{
|
|
{Path: "internal/forge/forge.go", Line: 12, Body: "nit"},
|
|
{Path: "internal/forge/ghrest.go", StartLine: 4, Line: 9, Side: "RIGHT", Body: "range"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PostReviewComments: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewGHClient_NoToken(t *testing.T) {
|
|
t.Setenv("GH_TOKEN", "")
|
|
t.Setenv("GITHUB_TOKEN", "")
|
|
_, err := newGHClient(context.Background(), ".")
|
|
if !errors.Is(err, ErrNotAuthenticated) {
|
|
t.Fatalf("err = %v, want ErrNotAuthenticated", err)
|
|
}
|
|
}
|
|
|
|
func TestAvailable(t *testing.T) {
|
|
t.Setenv("GH_TOKEN", "")
|
|
t.Setenv("GITHUB_TOKEN", "")
|
|
if Available(context.Background()) {
|
|
t.Errorf("Available = true with no token")
|
|
}
|
|
t.Setenv("GITHUB_TOKEN", "tok")
|
|
if !Available(context.Background()) {
|
|
t.Errorf("Available = false with GITHUB_TOKEN set")
|
|
}
|
|
}
|
|
|
|
func TestRateLimited(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/repos/octo/gortex/pulls", func(w http.ResponseWriter, r *http.Request) {
|
|
reset := time.Now().Add(42 * time.Second).Unix()
|
|
w.Header().Set("X-RateLimit-Limit", "60")
|
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
|
w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(reset, 10))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"message":"API rate limit exceeded","documentation_url":"https://docs.github.com"}`))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := newTestClient(t, srv.URL)
|
|
withTestSeam(t, c)
|
|
|
|
_, err := ListPRs(context.Background(), ".", ListOpts{})
|
|
if !errors.Is(err, ErrRateLimited) {
|
|
t.Fatalf("err = %v, want ErrRateLimited", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "retry after") {
|
|
t.Errorf("rate-limit error missing Retry-After hint: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDefaultBranch_Fallback(t *testing.T) {
|
|
// A server with no repos/.../GET handler → API errors → fall back to
|
|
// the local probe (which returns "" outside a repo root).
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/repos/octo/gortex", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message":"Not Found"}`))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
c := newTestClient(t, srv.URL)
|
|
// Should not panic; returns the local-probe fallback ("" here).
|
|
_ = c.DefaultBranch(context.Background())
|
|
}
|
|
|
|
func TestDefaultBranch_FromAPI(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/repos/octo/gortex", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"name":"gortex","default_branch":"trunk"}`))
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
c := newTestClient(t, srv.URL)
|
|
if got := c.DefaultBranch(context.Background()); got != "trunk" {
|
|
t.Errorf("DefaultBranch = %q, want trunk", got)
|
|
}
|
|
}
|
|
|
|
func TestOwnerRepoFrom(t *testing.T) {
|
|
tests := []struct {
|
|
in, owner, repo string
|
|
ok bool
|
|
}{
|
|
{"github.com/octo/gortex", "octo", "gortex", true},
|
|
{"github.com/octo/gortex.git", "octo", "gortex", true},
|
|
{"octo/gortex", "octo", "gortex", true},
|
|
{"gortex", "", "", false},
|
|
{"", "", "", false},
|
|
{"ghe.example.com/org/team/repo", "team", "repo", true},
|
|
}
|
|
for _, tt := range tests {
|
|
o, r, ok := ownerRepoFrom(tt.in)
|
|
if ok != tt.ok || o != tt.owner || r != tt.repo {
|
|
t.Errorf("ownerRepoFrom(%q) = %q,%q,%v; want %q,%q,%v", tt.in, o, r, ok, tt.owner, tt.repo, tt.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnterpriseBase(t *testing.T) {
|
|
t.Setenv("GITHUB_API_URL", "")
|
|
t.Setenv("GH_HOST", "")
|
|
if got := enterpriseBase(); got != "" {
|
|
t.Errorf("enterpriseBase with no env = %q, want empty", got)
|
|
}
|
|
t.Setenv("GITHUB_API_URL", "https://github.com/api/v3")
|
|
if got := enterpriseBase(); got != "" {
|
|
t.Errorf("public github.com must not be enterprise: %q", got)
|
|
}
|
|
t.Setenv("GITHUB_API_URL", "https://ghe.corp.example/api/v3")
|
|
if got := enterpriseBase(); got != "https://ghe.corp.example/api/v3/" {
|
|
t.Errorf("enterpriseBase = %q", got)
|
|
}
|
|
t.Setenv("GITHUB_API_URL", "")
|
|
t.Setenv("GH_HOST", "ghe.corp.example")
|
|
if got := enterpriseBase(); got != "https://ghe.corp.example/api/v3/" {
|
|
t.Errorf("enterpriseBase from GH_HOST = %q", got)
|
|
}
|
|
}
|