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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+85
View File
@@ -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)
}
+146
View File
@@ -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
}
+175
View File
@@ -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() != ""
}
+336
View File
@@ -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)
}
}
+569
View File
@@ -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
}
+179
View File
@@ -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)
}
}
+79
View File
@@ -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
}