a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
570 lines
16 KiB
Go
570 lines
16 KiB
Go
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
|
|
}
|