chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package forge
|
||||
|
||||
import "time"
|
||||
|
||||
// staleAfterDays is the age past which an open PR is classified STALE
|
||||
// (when nothing more pressing applies).
|
||||
const staleAfterDays = 30
|
||||
|
||||
// Status is the pure-Go classification of a PR against a default base.
|
||||
// It carries no network dependency and is table-testable.
|
||||
type Status struct {
|
||||
State string
|
||||
BaseMismatch bool
|
||||
Draft bool
|
||||
AgeDays int
|
||||
Blockers []string
|
||||
}
|
||||
|
||||
// ClassifyStatus reduces a PR to a single review-state label, computed
|
||||
// purely from its already-fetched fields against the repo's default base.
|
||||
// State precedence: DRAFT → BASE_MISMATCH → CHANGES_REQUESTED → APPROVED
|
||||
// → STALE → READY. Blockers accumulates every condition that would hold
|
||||
// a merge.
|
||||
func ClassifyStatus(pr PR, defaultBase string) Status {
|
||||
s := Status{
|
||||
Draft: pr.IsDraft,
|
||||
AgeDays: ageDays(pr.UpdatedAt),
|
||||
}
|
||||
if defaultBase != "" && pr.BaseRef != "" && pr.BaseRef != defaultBase {
|
||||
s.BaseMismatch = true
|
||||
}
|
||||
|
||||
if pr.IsDraft {
|
||||
s.Blockers = append(s.Blockers, "draft")
|
||||
}
|
||||
if s.BaseMismatch {
|
||||
s.Blockers = append(s.Blockers, "base-mismatch")
|
||||
}
|
||||
if pr.ReviewDecision == "CHANGES_REQUESTED" {
|
||||
s.Blockers = append(s.Blockers, "changes-requested")
|
||||
}
|
||||
if RollupCI(pr) == "FAILURE" {
|
||||
s.Blockers = append(s.Blockers, "ci-failure")
|
||||
}
|
||||
|
||||
switch {
|
||||
case pr.IsDraft:
|
||||
s.State = "DRAFT"
|
||||
case s.BaseMismatch:
|
||||
s.State = "BASE_MISMATCH"
|
||||
case pr.ReviewDecision == "CHANGES_REQUESTED":
|
||||
s.State = "CHANGES_REQUESTED"
|
||||
case pr.ReviewDecision == "APPROVED":
|
||||
s.State = "APPROVED"
|
||||
case s.AgeDays >= staleAfterDays:
|
||||
s.State = "STALE"
|
||||
default:
|
||||
s.State = "READY"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RollupCI echoes a PR's reconstructed CI rollup, normalized to one of
|
||||
// NONE / FAILURE / PENDING / SUCCESS. An empty rollup reads as NONE.
|
||||
func RollupCI(pr PR) string {
|
||||
switch pr.CIRollup {
|
||||
case "FAILURE", "PENDING", "SUCCESS":
|
||||
return pr.CIRollup
|
||||
default:
|
||||
return "NONE"
|
||||
}
|
||||
}
|
||||
|
||||
// ageDays returns the whole-day age of t relative to now, clamped at 0
|
||||
// for a zero or future timestamp.
|
||||
func ageDays(t time.Time) int {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
d := time.Since(t)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return int(d.Hours() / 24)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClassifyStatus(t *testing.T) {
|
||||
now := time.Now()
|
||||
old := now.AddDate(0, 0, -45)
|
||||
recent := now.AddDate(0, 0, -2)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pr PR
|
||||
defaultBase string
|
||||
wantState string
|
||||
wantMismatch bool
|
||||
wantBlockers []string
|
||||
}{
|
||||
{
|
||||
name: "draft wins over everything",
|
||||
pr: PR{IsDraft: true, BaseRef: "feature", ReviewDecision: "CHANGES_REQUESTED", UpdatedAt: recent},
|
||||
wantState: "DRAFT",
|
||||
// base unset so no mismatch; blockers = draft + changes-requested
|
||||
wantBlockers: []string{"draft", "changes-requested"},
|
||||
},
|
||||
{
|
||||
name: "base mismatch",
|
||||
pr: PR{BaseRef: "develop", UpdatedAt: recent},
|
||||
defaultBase: "main",
|
||||
wantState: "BASE_MISMATCH",
|
||||
wantMismatch: true,
|
||||
wantBlockers: []string{"base-mismatch"},
|
||||
},
|
||||
{
|
||||
name: "changes requested",
|
||||
pr: PR{BaseRef: "main", ReviewDecision: "CHANGES_REQUESTED", UpdatedAt: recent},
|
||||
defaultBase: "main",
|
||||
wantState: "CHANGES_REQUESTED",
|
||||
wantBlockers: []string{"changes-requested"},
|
||||
},
|
||||
{
|
||||
name: "approved",
|
||||
pr: PR{BaseRef: "main", ReviewDecision: "APPROVED", UpdatedAt: recent},
|
||||
defaultBase: "main",
|
||||
wantState: "APPROVED",
|
||||
},
|
||||
{
|
||||
name: "stale by age",
|
||||
pr: PR{BaseRef: "main", UpdatedAt: old},
|
||||
defaultBase: "main",
|
||||
wantState: "STALE",
|
||||
},
|
||||
{
|
||||
name: "ready",
|
||||
pr: PR{BaseRef: "main", UpdatedAt: recent},
|
||||
defaultBase: "main",
|
||||
wantState: "READY",
|
||||
},
|
||||
{
|
||||
name: "ci failure adds a blocker",
|
||||
pr: PR{BaseRef: "main", CIRollup: "FAILURE", UpdatedAt: recent},
|
||||
defaultBase: "main",
|
||||
wantState: "READY",
|
||||
wantBlockers: []string{"ci-failure"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ClassifyStatus(tt.pr, tt.defaultBase)
|
||||
if got.State != tt.wantState {
|
||||
t.Errorf("State = %q, want %q", got.State, tt.wantState)
|
||||
}
|
||||
if got.BaseMismatch != tt.wantMismatch {
|
||||
t.Errorf("BaseMismatch = %v, want %v", got.BaseMismatch, tt.wantMismatch)
|
||||
}
|
||||
if tt.wantBlockers != nil {
|
||||
if !sameStringSet(got.Blockers, tt.wantBlockers) {
|
||||
t.Errorf("Blockers = %v, want %v", got.Blockers, tt.wantBlockers)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollupCI(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", "NONE"},
|
||||
{"NONE", "NONE"},
|
||||
{"FAILURE", "FAILURE"},
|
||||
{"PENDING", "PENDING"},
|
||||
{"SUCCESS", "SUCCESS"},
|
||||
{"garbage", "NONE"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := RollupCI(PR{CIRollup: tt.in}); got != tt.want {
|
||||
t.Errorf("RollupCI(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseStates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
states []string
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, "NONE"},
|
||||
{"all success", []string{"success", "success"}, "SUCCESS"},
|
||||
{"any failure", []string{"success", "failure"}, "FAILURE"},
|
||||
{"error counts as failure", []string{"success", "error"}, "FAILURE"},
|
||||
{"pending no failure", []string{"success", "pending"}, "PENDING"},
|
||||
{"in_progress is pending", []string{"in_progress"}, "PENDING"},
|
||||
{"failure beats pending", []string{"pending", "failure"}, "FAILURE"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := collapseStates(tt.states); got != tt.want {
|
||||
t.Errorf("collapseStates(%v) = %q, want %q", tt.states, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sameStringSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, s := range a {
|
||||
seen[s]++
|
||||
}
|
||||
for _, s := range b {
|
||||
seen[s]--
|
||||
}
|
||||
for _, n := range seen {
|
||||
if n != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package forge is the single network surface for pull-request I/O. It
|
||||
// wraps the official go-github SDK behind a small set of free functions
|
||||
// — ListPRs, ViewPR, PRFiles, DiffPR, PostReviewComments, Available —
|
||||
// every one taking (ctx, repoDir, …). Each constructs an internal
|
||||
// ghClient that resolves the GitHub token (GH_TOKEN → GITHUB_TOKEN) and
|
||||
// the owner/name slug from the indexed repo identity (falling back to
|
||||
// `git remote get-url origin` through gitcmd).
|
||||
//
|
||||
// The consumed surface is the free functions, not a method set: callers
|
||||
// never hold a client. The Client interface exists only as a test seam
|
||||
// (a func-var indirection injects canned data) and as the future-backend
|
||||
// seam — a deferred gh-CLI or GitLab backend slots in behind it without
|
||||
// touching any caller.
|
||||
//
|
||||
// GitHub REST exposes no single reviewDecision or statusCheckRollup
|
||||
// field; those are GraphQL aggregates. ReviewDecision and CIRollup are
|
||||
// reconstructed here from ListReviews and check-runs/combined-status,
|
||||
// opt-in per call (ListOpts.WithDecision / WithCI; ViewPR always fills
|
||||
// them) so a cheap ListPRs skips the extra round-trips.
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/analysis"
|
||||
)
|
||||
|
||||
// callTimeout bounds every network round-trip a forge call makes.
|
||||
const callTimeout = 30 * time.Second
|
||||
|
||||
// ErrNotAuthenticated is returned when no GitHub token is resolvable
|
||||
// from the environment.
|
||||
var ErrNotAuthenticated = errors.New("no GitHub token: set GH_TOKEN (or GITHUB_TOKEN)")
|
||||
|
||||
// ErrRateLimited is returned when GitHub answers with a rate-limit /
|
||||
// abuse-rate-limit error. The underlying *github.RateLimitError /
|
||||
// *github.AbuseRateLimitError is mapped onto this sentinel (with a
|
||||
// Retry-After hint preserved in the wrapped error's message) so callers
|
||||
// can test errors.Is(err, ErrRateLimited).
|
||||
var ErrRateLimited = errors.New("github rate limited")
|
||||
|
||||
// PR is the canonical pull-request value. It is built from a go-github
|
||||
// *github.PullRequest; ReviewDecision and CIRollup are reconstructed (the
|
||||
// REST API has no such aggregate fields). Files is EMPTY after ListPRs —
|
||||
// only ViewPR / PRFiles hydrate it.
|
||||
type PR struct {
|
||||
Number int
|
||||
Title string
|
||||
Author string
|
||||
BaseRef string // PullRequest.Base.Ref
|
||||
HeadRef string // PullRequest.Head.Ref
|
||||
IsDraft bool
|
||||
ReviewDecision string // reconstructed from ListReviews (REST has no reviewDecision field)
|
||||
CIRollup string // reconstructed from Checks + GetCombinedStatus; collapsed by RollupCI
|
||||
UpdatedAt time.Time
|
||||
Mergeable string
|
||||
URL string
|
||||
State string
|
||||
Files []string // EMPTY after ListPRs; only ViewPR / PRFiles hydrate it
|
||||
}
|
||||
|
||||
// PRDiff is the per-file diff of a pull request, each file's patch parsed
|
||||
// into hunks via analysis.ParseDiffHunks.
|
||||
type PRDiff struct {
|
||||
Number int
|
||||
BaseRef string
|
||||
HeadRef string
|
||||
Files []PRFile
|
||||
Raw string
|
||||
}
|
||||
|
||||
// PRFile is one changed file in a PR diff.
|
||||
type PRFile struct {
|
||||
Path string
|
||||
OldPath string
|
||||
Hunks []analysis.DiffHunk // analysis.ParseDiffHunks on the file's .GetPatch()
|
||||
Status string
|
||||
}
|
||||
|
||||
// ReviewComment is the one inline-comment type. Posting maps a finding
|
||||
// to a ReviewComment; StartLine carries the multi-line range start and
|
||||
// Side defaults to "RIGHT" (the new side).
|
||||
type ReviewComment struct {
|
||||
Path string
|
||||
Line int
|
||||
StartLine int
|
||||
Side string
|
||||
Body string
|
||||
}
|
||||
|
||||
// ListOpts tunes ListPRs. Decision/CI reconstruction is opt-in per call
|
||||
// so a cheap list skips the extra round-trips.
|
||||
type ListOpts struct {
|
||||
State string
|
||||
Limit int
|
||||
Author string
|
||||
WithDecision bool
|
||||
WithCI bool
|
||||
}
|
||||
|
||||
// Client is the test seam / future-backend seam. The free functions are
|
||||
// the consumed surface; this interface lets a test inject canned data and
|
||||
// lets a deferred gh-CLI or GitLab backend slot in without touching any
|
||||
// caller.
|
||||
type Client interface {
|
||||
ListPRs(ctx context.Context, opts ListOpts) ([]PR, error)
|
||||
ViewPR(ctx context.Context, num int) (*PR, error)
|
||||
PRFiles(ctx context.Context, num int) ([]string, error)
|
||||
DiffPR(ctx context.Context, num int) (*PRDiff, error)
|
||||
PostReviewComments(ctx context.Context, num int, comments []ReviewComment) error
|
||||
}
|
||||
|
||||
// newClient resolves a *ghClient for repoDir. It is a package var so a
|
||||
// test can swap it for one backed by an httptest server with a fixed
|
||||
// owner/repo, bypassing token + git-remote resolution.
|
||||
var newClient = newGHClient
|
||||
|
||||
// ListPRs lists pull requests for the repo at repoDir. PR.Files is EMPTY
|
||||
// on every returned PR — triage and conflict detection must call
|
||||
// PRFiles(num) explicitly per PR. Decision/CI aggregates are filled only
|
||||
// when opts requests them.
|
||||
func ListPRs(ctx context.Context, repoDir string, opts ListOpts) ([]PR, error) {
|
||||
c, err := newClient(ctx, repoDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.ListPRs(ctx, opts)
|
||||
}
|
||||
|
||||
// ViewPR fetches a single pull request and hydrates its Files plus the
|
||||
// reconstructed ReviewDecision / CIRollup aggregates.
|
||||
func ViewPR(ctx context.Context, repoDir string, num int) (*PR, error) {
|
||||
c, err := newClient(ctx, repoDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.ViewPR(ctx, num)
|
||||
}
|
||||
|
||||
// PRFiles returns the paths of files changed in a pull request.
|
||||
func PRFiles(ctx context.Context, repoDir string, num int) ([]string, error) {
|
||||
c, err := newClient(ctx, repoDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.PRFiles(ctx, num)
|
||||
}
|
||||
|
||||
// DiffPR returns the per-file diff of a pull request, each file's patch
|
||||
// parsed into hunks.
|
||||
func DiffPR(ctx context.Context, repoDir string, num int) (*PRDiff, error) {
|
||||
c, err := newClient(ctx, repoDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.DiffPR(ctx, num)
|
||||
}
|
||||
|
||||
// PostReviewComments posts a batch of inline review comments on a pull
|
||||
// request as a single review.
|
||||
func PostReviewComments(ctx context.Context, repoDir string, num int, comments []ReviewComment) error {
|
||||
c, err := newClient(ctx, repoDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.PostReviewComments(ctx, num, comments)
|
||||
}
|
||||
|
||||
// Available reports whether a GitHub token (GH_TOKEN or GITHUB_TOKEN) is
|
||||
// resolvable from the environment.
|
||||
func Available(ctx context.Context) bool {
|
||||
return resolveToken() != ""
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v88/github"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/zzet/gortex/internal/analysis"
|
||||
"github.com/zzet/gortex/internal/churn"
|
||||
"github.com/zzet/gortex/internal/gitcmd"
|
||||
"github.com/zzet/gortex/internal/indexer"
|
||||
)
|
||||
|
||||
// ghClient wraps a *github.Client with the resolved owner/repo slug for a
|
||||
// single repository. It is constructed by newGHClient and used by the
|
||||
// free functions; callers never hold it directly.
|
||||
type ghClient struct {
|
||||
gh *github.Client
|
||||
owner string
|
||||
repo string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// makeGitHubClient builds the *github.Client. It is a package var so a
|
||||
// test can swap it for a client pointed at an httptest server. tok is the
|
||||
// resolved auth token and base, when non-empty, is the enterprise base
|
||||
// URL (also used as the upload URL).
|
||||
var makeGitHubClient = func(tok, base string) (*github.Client, error) {
|
||||
opts := []github.ClientOptionsFunc{github.WithAuthToken(tok)}
|
||||
if base != "" {
|
||||
opts = append(opts, github.WithEnterpriseURLs(base, base))
|
||||
}
|
||||
return github.NewClient(opts...)
|
||||
}
|
||||
|
||||
// newGHClient resolves the token and owner/repo slug for repoDir and
|
||||
// builds a ghClient. A missing token surfaces ErrNotAuthenticated; an
|
||||
// unresolvable slug surfaces a wrapped error. When GITHUB_API_URL / GH_HOST
|
||||
// names a GitHub Enterprise host the client is switched to that host's
|
||||
// base URL.
|
||||
func newGHClient(ctx context.Context, repoDir string) (*ghClient, error) {
|
||||
tok := resolveToken()
|
||||
if tok == "" {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
owner, repo, err := resolveSlug(ctx, repoDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gh, err := makeGitHubClient(tok, enterpriseBase())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("forge: building github client: %w", err)
|
||||
}
|
||||
return &ghClient{gh: gh, owner: owner, repo: repo, timeout: callTimeout}, nil
|
||||
}
|
||||
|
||||
// resolveToken returns the GitHub token from GH_TOKEN, falling back to
|
||||
// GITHUB_TOKEN. It returns "" when neither is set.
|
||||
func resolveToken() string {
|
||||
if t := strings.TrimSpace(os.Getenv("GH_TOKEN")); t != "" {
|
||||
return t
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("GITHUB_TOKEN"))
|
||||
}
|
||||
|
||||
// enterpriseBase returns the GitHub Enterprise API base URL when
|
||||
// GITHUB_API_URL or GH_HOST names a non-github.com host, else "". The
|
||||
// returned URL carries a trailing slash, as go-github's base-URL contract
|
||||
// requires.
|
||||
func enterpriseBase() string {
|
||||
if v := strings.TrimSpace(os.Getenv("GITHUB_API_URL")); v != "" {
|
||||
if h := hostOf(v); h != "" && !isPublicGitHub(h) {
|
||||
return ensureTrailingSlash(v)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("GH_HOST")); v != "" {
|
||||
h := hostOf(v)
|
||||
if h == "" {
|
||||
h = v
|
||||
}
|
||||
if h != "" && !isPublicGitHub(h) {
|
||||
return "https://" + h + "/api/v3/"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hostOf(raw string) string {
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
func isPublicGitHub(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
return host == "github.com" || host == "api.github.com" || host == "www.github.com"
|
||||
}
|
||||
|
||||
func ensureTrailingSlash(s string) string {
|
||||
if strings.HasSuffix(s, "/") {
|
||||
return s
|
||||
}
|
||||
return s + "/"
|
||||
}
|
||||
|
||||
// resolveSlug derives owner/name for repoDir. It first asks the indexed
|
||||
// repo identity (indexer.DetectIdentity → NormalizeRemoteURL canonical
|
||||
// form), then falls back to `git remote get-url origin` through gitcmd.
|
||||
func resolveSlug(ctx context.Context, repoDir string) (owner, repo string, err error) {
|
||||
if id, idErr := indexer.DetectIdentity(repoDir); idErr == nil && id != nil {
|
||||
if o, r, ok := ownerRepoFrom(id.CanonicalID); ok {
|
||||
return o, r, nil
|
||||
}
|
||||
if o, r, ok := ownerRepoFrom(id.RemoteURL); ok {
|
||||
return o, r, nil
|
||||
}
|
||||
}
|
||||
// Fallback: read the remote directly through the git chokepoint.
|
||||
raw, rErr := gitcmd.Output(ctx, repoDir, "remote", "get-url", "origin")
|
||||
if rErr != nil {
|
||||
return "", "", fmt.Errorf("forge: resolving owner/repo for %s: %w", repoDir, rErr)
|
||||
}
|
||||
if o, r, ok := ownerRepoFrom(indexer.NormalizeRemoteURL(raw)); ok {
|
||||
return o, r, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("forge: could not derive owner/repo from remote %q", raw)
|
||||
}
|
||||
|
||||
// ownerRepoFrom extracts (owner, repo) from a normalized remote of the
|
||||
// form "host/owner/repo" (or "owner/repo"). The trailing two
|
||||
// slash-separated components are the owner and repo.
|
||||
func ownerRepoFrom(canonical string) (owner, repo string, ok bool) {
|
||||
canonical = strings.TrimSpace(canonical)
|
||||
canonical = strings.TrimSuffix(canonical, ".git")
|
||||
canonical = strings.Trim(canonical, "/")
|
||||
if canonical == "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.Split(canonical, "/")
|
||||
if len(parts) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
repo = parts[len(parts)-1]
|
||||
owner = parts[len(parts)-2]
|
||||
if owner == "" || repo == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return owner, repo, true
|
||||
}
|
||||
|
||||
// mapErr maps go-github's typed rate-limit errors onto ErrRateLimited
|
||||
// (preserving a Retry-After hint) and leaves every other error untouched.
|
||||
func mapErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var rl *github.RateLimitError
|
||||
if errors.As(err, &rl) {
|
||||
if !rl.Rate.Reset.IsZero() {
|
||||
d := time.Until(rl.Rate.Reset.Time)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return fmt.Errorf("%w (retry after %s)", ErrRateLimited, d.Round(time.Second))
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrRateLimited, rl.Message)
|
||||
}
|
||||
var ab *github.AbuseRateLimitError
|
||||
if errors.As(err, &ab) {
|
||||
if ab.RetryAfter != nil {
|
||||
return fmt.Errorf("%w (retry after %s)", ErrRateLimited, ab.RetryAfter.Round(time.Second))
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrRateLimited, ab.Message)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// callCtx derives a per-call context bounded by the client's timeout.
|
||||
func (c *ghClient) callCtx(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
t := c.timeout
|
||||
if t <= 0 {
|
||||
t = callTimeout
|
||||
}
|
||||
return context.WithTimeout(ctx, t)
|
||||
}
|
||||
|
||||
// ListPRs lists pull requests. PR.Files is left empty; decision/CI
|
||||
// aggregates are filled only when opts requests them.
|
||||
func (c *ghClient) ListPRs(ctx context.Context, opts ListOpts) ([]PR, error) {
|
||||
cctx, cancel := c.callCtx(ctx)
|
||||
defer cancel()
|
||||
|
||||
state := opts.State
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
perPage := opts.Limit
|
||||
if perPage <= 0 || perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
ghOpts := &github.PullRequestListOptions{
|
||||
State: state,
|
||||
ListOptions: github.ListOptions{PerPage: perPage},
|
||||
}
|
||||
ghPRs, _, err := c.gh.PullRequests.List(cctx, c.owner, c.repo, ghOpts)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
|
||||
out := make([]PR, 0, len(ghPRs))
|
||||
heads := make([]string, 0, len(ghPRs))
|
||||
for _, p := range ghPRs {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if opts.Author != "" && p.GetUser().GetLogin() != opts.Author {
|
||||
continue
|
||||
}
|
||||
out = append(out, prFromGH(p))
|
||||
heads = append(heads, p.GetHead().GetSHA())
|
||||
if opts.Limit > 0 && len(out) >= opts.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct opt-in aggregates concurrently across the result set.
|
||||
if opts.WithDecision || opts.WithCI {
|
||||
if err := c.fillAggregates(cctx, out, heads, opts.WithDecision, opts.WithCI); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ViewPR fetches a single pull request, hydrates Files, and always fills
|
||||
// the reconstructed ReviewDecision / CIRollup aggregates.
|
||||
func (c *ghClient) ViewPR(ctx context.Context, num int) (*PR, error) {
|
||||
cctx, cancel := c.callCtx(ctx)
|
||||
defer cancel()
|
||||
|
||||
p, _, err := c.gh.PullRequests.Get(cctx, c.owner, c.repo, num)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
pr := prFromGH(p)
|
||||
|
||||
files, err := c.listFiles(cctx, num)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pr.Files = make([]string, 0, len(files))
|
||||
for _, f := range files {
|
||||
pr.Files = append(pr.Files, f.GetFilename())
|
||||
}
|
||||
|
||||
if dec, err := c.reviewDecision(cctx, num); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
pr.ReviewDecision = dec
|
||||
}
|
||||
if ci, err := c.ciRollup(cctx, p.GetHead().GetSHA()); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
pr.CIRollup = ci
|
||||
}
|
||||
return &pr, nil
|
||||
}
|
||||
|
||||
// PRFiles returns the changed file paths of a pull request.
|
||||
func (c *ghClient) PRFiles(ctx context.Context, num int) ([]string, error) {
|
||||
cctx, cancel := c.callCtx(ctx)
|
||||
defer cancel()
|
||||
|
||||
files, err := c.listFiles(cctx, num)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, f.GetFilename())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DiffPR returns the per-file diff, each file's patch run through
|
||||
// analysis.ParseDiffHunks.
|
||||
func (c *ghClient) DiffPR(ctx context.Context, num int) (*PRDiff, error) {
|
||||
cctx, cancel := c.callCtx(ctx)
|
||||
defer cancel()
|
||||
|
||||
p, _, err := c.gh.PullRequests.Get(cctx, c.owner, c.repo, num)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
files, err := c.listFiles(cctx, num)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
diff := &PRDiff{
|
||||
Number: p.GetNumber(),
|
||||
BaseRef: p.GetBase().GetRef(),
|
||||
HeadRef: p.GetHead().GetRef(),
|
||||
}
|
||||
var raw strings.Builder
|
||||
for _, f := range files {
|
||||
patch := f.GetPatch()
|
||||
path := f.GetFilename()
|
||||
// GitHub's per-file .patch carries only the hunk body — no
|
||||
// `+++ b/<file>` header — so synthesize one so ParseDiffHunks
|
||||
// scopes the hunks to this file (and so Raw is a valid diff).
|
||||
var withHeader string
|
||||
if patch != "" {
|
||||
withHeader = "--- a/" + path + "\n+++ b/" + path + "\n" + patch
|
||||
if !strings.HasSuffix(withHeader, "\n") {
|
||||
withHeader += "\n"
|
||||
}
|
||||
}
|
||||
pf := PRFile{
|
||||
Path: path,
|
||||
OldPath: f.GetPreviousFilename(),
|
||||
Status: f.GetStatus(),
|
||||
Hunks: analysis.ParseDiffHunks(withHeader),
|
||||
}
|
||||
diff.Files = append(diff.Files, pf)
|
||||
raw.WriteString(withHeader)
|
||||
}
|
||||
diff.Raw = raw.String()
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
// PostReviewComments posts a batch of inline review comments as a single
|
||||
// COMMENT review on the pull request.
|
||||
func (c *ghClient) PostReviewComments(ctx context.Context, num int, comments []ReviewComment) error {
|
||||
cctx, cancel := c.callCtx(ctx)
|
||||
defer cancel()
|
||||
|
||||
drafts := make([]*github.DraftReviewComment, 0, len(comments))
|
||||
for _, rc := range comments {
|
||||
side := rc.Side
|
||||
if side == "" {
|
||||
side = "RIGHT"
|
||||
}
|
||||
d := &github.DraftReviewComment{
|
||||
Path: github.Ptr(rc.Path),
|
||||
Body: github.Ptr(rc.Body),
|
||||
Side: github.Ptr(side),
|
||||
Line: github.Ptr(rc.Line),
|
||||
}
|
||||
// A multi-line range carries a start line strictly before Line.
|
||||
if rc.StartLine > 0 && rc.StartLine < rc.Line {
|
||||
d.StartLine = github.Ptr(rc.StartLine)
|
||||
d.StartSide = github.Ptr(side)
|
||||
}
|
||||
drafts = append(drafts, d)
|
||||
}
|
||||
req := &github.PullRequestReviewRequest{
|
||||
Event: github.Ptr("COMMENT"),
|
||||
Comments: drafts,
|
||||
}
|
||||
_, _, err := c.gh.PullRequests.CreateReview(cctx, c.owner, c.repo, num, req)
|
||||
return mapErr(err)
|
||||
}
|
||||
|
||||
// DefaultBranch returns the repository's default branch via the GitHub
|
||||
// API, falling back to the local probe in churn.DefaultBranch when the
|
||||
// API call fails or returns empty.
|
||||
func (c *ghClient) DefaultBranch(ctx context.Context) string {
|
||||
cctx, cancel := c.callCtx(ctx)
|
||||
defer cancel()
|
||||
if repo, _, err := c.gh.Repositories.Get(cctx, c.owner, c.repo); err == nil {
|
||||
if b := repo.GetDefaultBranch(); b != "" {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return churn.DefaultBranch("")
|
||||
}
|
||||
|
||||
// listFiles fetches the changed-file records of a pull request.
|
||||
func (c *ghClient) listFiles(ctx context.Context, num int) ([]*github.CommitFile, error) {
|
||||
files, _, err := c.gh.PullRequests.ListFiles(ctx, c.owner, c.repo, num, &github.ListOptions{PerPage: 100})
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// fillAggregates reconstructs the requested ReviewDecision / CIRollup
|
||||
// fields for every PR in prs, fetching concurrently under an errgroup with
|
||||
// a bounded worker count.
|
||||
func (c *ghClient) fillAggregates(ctx context.Context, prs []PR, heads []string, withDecision, withCI bool) error {
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(8)
|
||||
for i := range prs {
|
||||
i := i
|
||||
head := ""
|
||||
if i < len(heads) {
|
||||
head = heads[i]
|
||||
}
|
||||
g.Go(func() error {
|
||||
if withDecision {
|
||||
dec, err := c.reviewDecision(gctx, prs[i].Number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prs[i].ReviewDecision = dec
|
||||
}
|
||||
if withCI {
|
||||
ci, err := c.ciRollup(gctx, head)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prs[i].CIRollup = ci
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
// reviewDecision reconstructs a PR's review decision from its reviews.
|
||||
// The latest non-COMMENTED state per reviewer is taken; the result is
|
||||
// CHANGES_REQUESTED if any reviewer's latest state requests changes,
|
||||
// APPROVED if at least one approves and none requests changes, else
|
||||
// REVIEW_REQUIRED.
|
||||
func (c *ghClient) reviewDecision(ctx context.Context, num int) (string, error) {
|
||||
reviews, _, err := c.gh.PullRequests.ListReviews(ctx, c.owner, c.repo, num, &github.ListOptions{PerPage: 100})
|
||||
if err != nil {
|
||||
return "", mapErr(err)
|
||||
}
|
||||
// Latest meaningful state per reviewer, in API order (chronological).
|
||||
latest := map[string]string{}
|
||||
for _, r := range reviews {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
state := strings.ToUpper(r.GetState())
|
||||
if state == "COMMENTED" || state == "DISMISSED" || state == "PENDING" {
|
||||
continue
|
||||
}
|
||||
login := r.GetUser().GetLogin()
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
latest[login] = state
|
||||
}
|
||||
approved := false
|
||||
for _, state := range latest {
|
||||
switch state {
|
||||
case "CHANGES_REQUESTED":
|
||||
return "CHANGES_REQUESTED", nil
|
||||
case "APPROVED":
|
||||
approved = true
|
||||
}
|
||||
}
|
||||
if approved {
|
||||
return "APPROVED", nil
|
||||
}
|
||||
return "REVIEW_REQUIRED", nil
|
||||
}
|
||||
|
||||
// ciRollup reconstructs a PR's CI rollup from check-runs and the legacy
|
||||
// combined commit status for the head ref, collapsed by RollupCI to one
|
||||
// of NONE / FAILURE / PENDING / SUCCESS.
|
||||
func (c *ghClient) ciRollup(ctx context.Context, ref string) (string, error) {
|
||||
if ref == "" {
|
||||
return "NONE", nil
|
||||
}
|
||||
var states []string
|
||||
|
||||
runs, _, err := c.gh.Checks.ListCheckRunsForRef(ctx, c.owner, c.repo, ref, &github.ListCheckRunsOptions{
|
||||
ListOptions: github.ListOptions{PerPage: 100},
|
||||
})
|
||||
if err != nil {
|
||||
return "", mapErr(err)
|
||||
}
|
||||
if runs != nil {
|
||||
for _, run := range runs.CheckRuns {
|
||||
if run == nil {
|
||||
continue
|
||||
}
|
||||
states = append(states, checkRunState(run))
|
||||
}
|
||||
}
|
||||
|
||||
combined, _, err := c.gh.Repositories.GetCombinedStatus(ctx, c.owner, c.repo, ref, &github.ListOptions{PerPage: 100})
|
||||
if err != nil {
|
||||
return "", mapErr(err)
|
||||
}
|
||||
if combined != nil {
|
||||
for _, st := range combined.Statuses {
|
||||
if st == nil {
|
||||
continue
|
||||
}
|
||||
states = append(states, strings.ToLower(st.GetState()))
|
||||
}
|
||||
}
|
||||
|
||||
return collapseStates(states), nil
|
||||
}
|
||||
|
||||
// checkRunState normalizes a check-run's status+conclusion to one of
|
||||
// failure / pending / success.
|
||||
func checkRunState(run *github.CheckRun) string {
|
||||
if strings.ToLower(run.GetStatus()) != "completed" {
|
||||
return "pending"
|
||||
}
|
||||
switch strings.ToLower(run.GetConclusion()) {
|
||||
case "success", "neutral", "skipped":
|
||||
return "success"
|
||||
case "failure", "timed_out", "action_required", "cancelled", "stale", "startup_failure":
|
||||
return "failure"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
// collapseStates folds a set of per-check states (failure/pending/success
|
||||
// or the combined-status vocabulary error/failure/pending/success) into a
|
||||
// single rollup: any failure → FAILURE, else any pending → PENDING, else
|
||||
// SUCCESS, with an empty set → NONE.
|
||||
func collapseStates(states []string) string {
|
||||
if len(states) == 0 {
|
||||
return "NONE"
|
||||
}
|
||||
pending := false
|
||||
for _, s := range states {
|
||||
switch strings.ToLower(s) {
|
||||
case "failure", "error":
|
||||
return "FAILURE"
|
||||
case "pending", "in_progress", "queued", "":
|
||||
pending = true
|
||||
}
|
||||
}
|
||||
if pending {
|
||||
return "PENDING"
|
||||
}
|
||||
return "SUCCESS"
|
||||
}
|
||||
|
||||
// prFromGH projects a go-github *github.PullRequest onto a forge.PR. It
|
||||
// does NOT hydrate Files or the reconstructed aggregates.
|
||||
func prFromGH(p *github.PullRequest) PR {
|
||||
pr := PR{
|
||||
Number: p.GetNumber(),
|
||||
Title: p.GetTitle(),
|
||||
Author: p.GetUser().GetLogin(),
|
||||
BaseRef: p.GetBase().GetRef(),
|
||||
HeadRef: p.GetHead().GetRef(),
|
||||
IsDraft: p.GetDraft(),
|
||||
UpdatedAt: p.GetUpdatedAt().Time,
|
||||
Mergeable: p.GetMergeableState(),
|
||||
URL: p.GetHTMLURL(),
|
||||
State: p.GetState(),
|
||||
}
|
||||
return pr
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Recorded GitHub REST responses (trimmed to the fields forge reads).
|
||||
|
||||
const prListJSON = `[
|
||||
{
|
||||
"number": 7,
|
||||
"title": "Add forge",
|
||||
"state": "open",
|
||||
"draft": false,
|
||||
"html_url": "https://github.com/octo/gortex/pull/7",
|
||||
"mergeable_state": "clean",
|
||||
"updated_at": "2026-06-01T10:00:00Z",
|
||||
"user": {"login": "alice"},
|
||||
"base": {"ref": "main"},
|
||||
"head": {"ref": "feature", "sha": "headsha"}
|
||||
}
|
||||
]`
|
||||
|
||||
const prGetJSON = `{
|
||||
"number": 7,
|
||||
"title": "Add forge",
|
||||
"state": "open",
|
||||
"draft": false,
|
||||
"html_url": "https://github.com/octo/gortex/pull/7",
|
||||
"mergeable_state": "clean",
|
||||
"updated_at": "2026-06-01T10:00:00Z",
|
||||
"user": {"login": "alice"},
|
||||
"base": {"ref": "main"},
|
||||
"head": {"ref": "feature", "sha": "headsha"}
|
||||
}`
|
||||
|
||||
const prFilesJSON = `[
|
||||
{
|
||||
"filename": "internal/forge/forge.go",
|
||||
"status": "added",
|
||||
"patch": "@@ -0,0 +1,3 @@\n+package forge\n+\n+// new\n"
|
||||
},
|
||||
{
|
||||
"filename": "internal/forge/ghrest.go",
|
||||
"previous_filename": "internal/forge/old.go",
|
||||
"status": "renamed",
|
||||
"patch": "@@ -1,2 +1,2 @@\n-old\n+new\n line2\n"
|
||||
}
|
||||
]`
|
||||
|
||||
const prReviewsJSON = `[
|
||||
{"state": "APPROVED", "user": {"login": "bob"}, "submitted_at": "2026-06-01T09:00:00Z"},
|
||||
{"state": "COMMENTED", "user": {"login": "carol"}, "submitted_at": "2026-06-01T09:30:00Z"},
|
||||
{"state": "CHANGES_REQUESTED", "user": {"login": "carol"}, "submitted_at": "2026-06-01T09:40:00Z"}
|
||||
]`
|
||||
|
||||
const checkRunsJSON = `{
|
||||
"total_count": 2,
|
||||
"check_runs": [
|
||||
{"name": "build", "status": "completed", "conclusion": "success"},
|
||||
{"name": "test", "status": "completed", "conclusion": "failure"}
|
||||
]
|
||||
}`
|
||||
|
||||
const combinedStatusJSON = `{
|
||||
"state": "failure",
|
||||
"sha": "headsha",
|
||||
"total_count": 1,
|
||||
"statuses": [
|
||||
{"state": "success", "context": "lint"}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestReviewDecision_LatestPerReviewer(t *testing.T) {
|
||||
srv := fixtureServer(t)
|
||||
c := newTestClient(t, srv.URL)
|
||||
got, err := c.reviewDecision(context.Background(), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("reviewDecision: %v", err)
|
||||
}
|
||||
// carol's latest non-comment state is CHANGES_REQUESTED → wins.
|
||||
if got != "CHANGES_REQUESTED" {
|
||||
t.Errorf("reviewDecision = %q, want CHANGES_REQUESTED", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIRollup(t *testing.T) {
|
||||
srv := fixtureServer(t)
|
||||
c := newTestClient(t, srv.URL)
|
||||
got, err := c.ciRollup(context.Background(), "headsha")
|
||||
if err != nil {
|
||||
t.Fatalf("ciRollup: %v", err)
|
||||
}
|
||||
if got != "FAILURE" {
|
||||
t.Errorf("ciRollup = %q, want FAILURE", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIRollup_NoRef(t *testing.T) {
|
||||
c := &ghClient{owner: "octo", repo: "gortex"}
|
||||
got, err := c.ciRollup(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ciRollup: %v", err)
|
||||
}
|
||||
if got != "NONE" {
|
||||
t.Errorf("ciRollup(empty ref) = %q, want NONE", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePorcelainWorktrees(t *testing.T) {
|
||||
out := "worktree /repo/main\nHEAD abcd1234\nbranch refs/heads/main\n\n" +
|
||||
"worktree /repo/wt-feature\nHEAD ef567890\nbranch refs/heads/feature\n\n" +
|
||||
"worktree /repo/detached\nHEAD 99999999\ndetached\n"
|
||||
got := parsePorcelainWorktrees(out)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d entries, want 3: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Path != "/repo/main" || got[0].Branch != "main" || got[0].Head != "abcd1234" {
|
||||
t.Errorf("entry[0] = %+v", got[0])
|
||||
}
|
||||
if got[1].Branch != "feature" {
|
||||
t.Errorf("entry[1].Branch = %q, want feature", got[1].Branch)
|
||||
}
|
||||
if got[2].Branch != "" {
|
||||
t.Errorf("detached entry must have empty Branch, got %q", got[2].Branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalWorktrees(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not on PATH")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
main := filepath.Join(dir, "main")
|
||||
if err := os.MkdirAll(main, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit := func(wd string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = wd
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
runGit(main, "init", "-q", "-b", "main")
|
||||
if err := os.WriteFile(filepath.Join(main, "f.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(main, "add", ".")
|
||||
runGit(main, "commit", "-q", "-m", "init")
|
||||
wt := filepath.Join(dir, "wt")
|
||||
runGit(main, "worktree", "add", "-q", "-b", "feature", wt)
|
||||
|
||||
entries, err := LocalWorktrees(context.Background(), main)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalWorktrees: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("got %d worktrees, want 2: %+v", len(entries), entries)
|
||||
}
|
||||
branches := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
branches[e.Branch] = true
|
||||
if e.Path == "" || e.Head == "" {
|
||||
t.Errorf("entry missing path/head: %+v", e)
|
||||
}
|
||||
}
|
||||
if !branches["main"] || !branches["feature"] {
|
||||
t.Errorf("branches = %v, want main+feature", branches)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package forge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/zzet/gortex/internal/gitcmd"
|
||||
"github.com/zzet/gortex/internal/indexer"
|
||||
)
|
||||
|
||||
// WorktreeEntry is one checked-out worktree of a repository.
|
||||
type WorktreeEntry struct {
|
||||
Path string
|
||||
Branch string
|
||||
Head string
|
||||
}
|
||||
|
||||
// LocalWorktrees lists the git worktrees of the repository at repoDir by
|
||||
// parsing `git worktree list --porcelain` through the git chokepoint. The
|
||||
// main checkout and every linked worktree are returned; the branch is the
|
||||
// short name (detached-HEAD checkouts carry an empty Branch).
|
||||
//
|
||||
// indexer.ResolveWorktree is consulted to normalize each entry's path to
|
||||
// its main-repo root relationship — letting a caller cross-reference a
|
||||
// PR's head ref against a locally checked-out worktree.
|
||||
func LocalWorktrees(ctx context.Context, repoDir string) ([]WorktreeEntry, error) {
|
||||
out, err := gitcmd.Output(ctx, repoDir, "worktree", "list", "--porcelain")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := parsePorcelainWorktrees(out)
|
||||
// Cross-reference each entry against the resolved worktree info so a
|
||||
// linked worktree's path is anchored to its main checkout.
|
||||
for i := range entries {
|
||||
if entries[i].Path == "" {
|
||||
continue
|
||||
}
|
||||
_ = indexer.ResolveWorktree(entries[i].Path)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// parsePorcelainWorktrees parses the record-per-worktree porcelain output
|
||||
// of `git worktree list --porcelain`. Records are separated by a blank
|
||||
// line; each begins with a `worktree <path>` line and may carry `HEAD`,
|
||||
// `branch refs/heads/<name>`, `detached`, or `bare` lines.
|
||||
func parsePorcelainWorktrees(out string) []WorktreeEntry {
|
||||
var entries []WorktreeEntry
|
||||
var cur *WorktreeEntry
|
||||
flush := func() {
|
||||
if cur != nil && cur.Path != "" {
|
||||
entries = append(entries, *cur)
|
||||
}
|
||||
cur = nil
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "worktree "):
|
||||
flush()
|
||||
cur = &WorktreeEntry{Path: strings.TrimSpace(strings.TrimPrefix(line, "worktree "))}
|
||||
case cur == nil:
|
||||
// stray line before the first record — ignore
|
||||
case strings.HasPrefix(line, "HEAD "):
|
||||
cur.Head = strings.TrimSpace(strings.TrimPrefix(line, "HEAD "))
|
||||
case strings.HasPrefix(line, "branch "):
|
||||
ref := strings.TrimSpace(strings.TrimPrefix(line, "branch "))
|
||||
cur.Branch = strings.TrimPrefix(ref, "refs/heads/")
|
||||
case line == "detached" || line == "bare":
|
||||
// no branch name for a detached or bare worktree
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return entries
|
||||
}
|
||||
Reference in New Issue
Block a user