forge

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

Documentation

Overview

Package forge is the code-host boundary (GitHub today; AGENTS.md §11 leaves GitLab as an interface with no adaptor until a GitLab repo needs one). Forge is activity-shaped (AGENTS.md §4.3): every method is func(ctx, In) (Out, error) with JSON-serialisable, secret-free inputs and outputs — the credential itself never crosses this boundary, only its effect. pkg/forge/github resolves auth from the user's own `gh` login via go-gh; this package never reads a token, an env var, or a flag naming one.

Index

Constants

This section is empty.

Variables

View Source
var ErrStaleHead = errors.New("forge: PR head does not match the expected sha")

ErrStaleHead is returned (wrapped) by MergePR when the PR's current head sha no longer matches expectedHeadSHA: something else moved the branch since this promotion last observed it pushed, and merging blind could squash-merge content this promotion never verified (AGENTS.md named adversary; R-003's neighbor). Callers distinguish this from a plain transport error with errors.Is.

View Source
var ErrUnknownRef = errors.New("forge: no such ref")

ErrUnknownRef is returned (wrapped) by Compare when base or head names nothing the forge knows — a tag that was deleted, a sha only ever pushed to a fork, or a private repo the token cannot see (a GitHub 404 is the same for all three). Callers distinguish it from a transport error with errors.Is: pkg/migrate turns it into "not in the app repo" rather than "unknown", since the revision itself was resolved and it is the repo that lacks it.

Functions

This section is empty.

Types

type CheckSummary

type CheckSummary struct {
	Total, Pending, Success, Failure, Skipped int
	FailedNames, SkippedNames                 []string
}

CheckSummary is the combined check-run AND commit-status rollup for one commit sha (GitHub reports CI through two genuinely separate mechanisms — check-runs and the older Statuses API — and a repository can use either or both; an implementation must query and fold in both, never just one). FailedNames lists the name of every check-run or status context (not empty run titles) that concluded in something other than success/neutral, so CIGreenStep can name which checks failed rather than just reporting a count (M4). Skipped has no equivalent among commit statuses (the Statuses API has no "skipped" state) — SkippedNames only ever names check-runs.

Skipped is its own bucket, not folded into Success: a `skipped` conclusion means the check never actually ran (a path filter, a conditional job) — GitHub reports it the same way whether the check was optional or required, and this type carries no required-vs-optional distinction at all, so there is no way to tell "safely skipped" from "a required gate that silently never ran" at this layer. CIGreenStep therefore treats any Skipped>0 as blocking, the same as Failure>0 (AGENTS.md §2 principle 5: "warn, don't block, except where the runbook blocks" — a required check that never ran is exactly that exception). SkippedNames mirrors FailedNames.

type Comment

type Comment struct {
	ID         int64
	Author     string
	AuthorType string
	Body       string
	CreatedAt  time.Time
}

Comment is one issue/PR comment. AuthorType is the GitHub account "type" of the commenter ("User", "Organization", "Bot", …) — added in M4 so a caller can tell a bot apart from a legitimate non-"User" account (an org-owned login is real, per the precedent in pkg/forge/github's Comments doc comment) without trusting the comment body for anything.

type Commit

type Commit struct {
	SHA     string
	Subject string
	Body    string
	Author  string
	Date    time.Time // author date
}

Commit is one commit as Compare reports it (M10, for the migration delta and the commit history the confirm screens lead with). Subject is the first line of the message and Body everything after the first blank line ("" for a one-line message). Author is the commit author's display name, not a login — a squash-merged commit's author may have no account. Every string here is upstream text nothing in hoist wrote; adaptors pass each through pkg/redact before returning it, the same way Checks does for check-run names.

type Comparison

type Comparison struct {
	Status         string
	AheadBy        int
	BehindBy       int
	Commits        []Commit // oldest first, as the forge orders them
	Total          int
	Truncated      bool
	Files          []string
	FilesTruncated bool
}

Comparison is base...head, three-dot: the commits reachable from head but not from base. Status is the forge's own word for the relationship ("ahead", "behind", "diverged", "identical"); a caller that asked base...head and gets "behind" is looking at a rollback and should compare the other way to see what is being un-applied. Total is the forge's own count of commits in the range even when Commits is shorter, and Truncated says so explicitly — an adaptor pages to its own bound rather than crawl an unbounded range. Files is every path changed across the whole range (not per commit; use CommitFiles for that), FilesTruncated when the forge capped that list.

type Fake

type Fake struct {
	HeadSHAs map[string]string
	// GitTags is what Tags returns; TagsErr, when set, is returned instead.
	GitTags []GitTag
	TagsErr error
	// CreateErr, FindErr and GetErr, when set, are returned by every call to the matching method.
	CreateErr error
	FindErr   error
	GetErr    error
	// ChecksBySHA and ChecksErr configure Checks: a sha not present in the map reports the
	// zero CheckSummary (no checks reported at all), exactly like a real repo with no CI
	// configured; ChecksErr, when set, is returned instead for every call (simulating a 404 or
	// permissions hiccup a caller must retry rather than treat as authoritative absence).
	ChecksBySHA map[string]CheckSummary
	ChecksErr   error
	// CommentsByPR and CommentsErr configure Comments. AddComment is the intended way tests
	// populate CommentsByPR (thread-safe); the field itself may also be set directly before any
	// concurrent use begins.
	CommentsByPR map[int][]Comment
	CommentsErr  error
	// Allowed configures IsAllowedAuthor: logins mapped to true are collaborators with write
	// permission; anything absent is not. AllowedErr, when set, is returned instead — simulating
	// a token missing the scope IsAllowedAuthor needs.
	Allowed    map[string]bool
	AllowedErr error
	// MergeErr, when set, is returned by every call to MergePR regardless of head sha.
	MergeErr error

	// M10 (pkg/migrate) configuration. Refs maps a ref name (tag, branch, full or abbreviated
	// sha) to the full sha ResolveRef answers with; absent means ok=false. Comparisons is keyed
	// "base...head" exactly as a caller would write the range. FilesBySHA feeds CommitFiles;
	// Touching is keyed "ref path" (one space) and feeds CommitsTouching, whose since filter is
	// NOT applied by the fake (the caller is asserting on attribution, not on dates — a test
	// that needs the filter seeds only the shas it expects). Blames is keyed "ref path" and maps
	// line -> LineOrigin. Files is keyed "ref path" and feeds ReadFile. Each *Err, when set, is
	// returned by every call to the matching method.
	Refs        map[string]string
	ResolveErr  error
	Comparisons map[string]Comparison
	CompareErr  error
	FilesBySHA  map[string][]string
	FilesCapped map[string]bool
	FilesErr    error
	Touching    map[string][]string
	TouchingErr error
	Blames      map[string]map[int]LineOrigin
	BlameErr    error
	Files       map[string][]byte
	ReadErr     error

	// Calls records every method invocation, in order, for tests asserting call counts
	// ("CreatePR was called exactly once across both kill/resume attempts").
	Calls []string
	// contains filtered or unexported fields
}

Fake is an in-memory Forge for tests in other packages (internal/engine's resume tests in particular): no test anywhere points at a real GitHub repo (AGENTS.md hard constraints). HeadSHAs, keyed by branch name, lets a test tell the Fake what a real forge would have discovered on its own (the pushed tip) — Fake has no access to git, so it cannot compute this itself; a test that cares about PR.HeadSHA sets it before calling CreatePR.

func (*Fake) AddComment

func (f *Fake) AddComment(prNumber int, c Comment)

AddComment appends c to prNumber's comments, thread-safely — the way a test simulates a human commenting on the PR mid-poll.

func (*Fake) BlameLines

func (f *Fake) BlameLines(_ context.Context, ref, path string, lines []int) (map[int]LineOrigin, error)

BlameLines implements Forge against Blames (keyed "ref path"). Lines the test never seeded are absent from the result, exactly like a line past the file's end on a real forge.

func (*Fake) Checks

func (f *Fake) Checks(_ context.Context, sha string) (CheckSummary, error)

Checks implements Forge: sha's configured CheckSummary (ChecksBySHA), or the zero value (no checks reported) when nothing was configured for it.

func (*Fake) Comments

func (f *Fake) Comments(_ context.Context, prNumber int, since time.Time) ([]Comment, error)

Comments implements Forge: prNumber's configured comments (CommentsByPR) whose CreatedAt is at or after since, oldest first — exactly the ordering and filter the real adaptor's own "since" query param gives.

func (*Fake) CommitFiles

func (f *Fake) CommitFiles(_ context.Context, sha string) ([]string, bool, error)

CommitFiles implements Forge against FilesBySHA; a sha listed in FilesCapped is reported truncated, the forge's "300 files and counting" answer.

func (*Fake) CommitsTouching

func (f *Fake) CommitsTouching(_ context.Context, ref, path string, since time.Time) ([]string, error)

CommitsTouching implements Forge against Touching (keyed "ref path"); since is recorded in Calls but not applied — see the field's doc comment.

func (*Fake) Compare

func (f *Fake) Compare(_ context.Context, base, head string) (Comparison, error)

Compare implements Forge against Comparisons. A range the test never seeded is reported as ErrUnknownRef — the same answer a real forge gives for a ref it does not have — so a test asserting the unknown-ref path needs no special hook, and a test that forgot to seed a range fails loudly rather than getting an empty, plausible-looking comparison.

func (*Fake) CreatePR

func (f *Fake) CreatePR(_ context.Context, spec PRSpec) (PR, error)

CreatePR implements Forge. As a safety net for tests exercising the resume property, it refuses to open a second open PR for a head branch that already has one — the real GitHub API enforces the same thing (422 "A pull request already exists"), so this is not a behavior unique to the fake, just enforced here too rather than only against production.

func (*Fake) FindPR

func (f *Fake) FindPR(_ context.Context, headBranch, bodyMarker string) (PR, bool, error)

FindPR implements Forge: first by head branch, then by searching every stored PR's body for bodyMarker (mirroring the real adaptor's fallback for a renamed or recreated branch).

func (*Fake) GetPR

func (f *Fake) GetPR(_ context.Context, number int) (PR, bool, error)

GetPR implements Forge: the stored PR with that number, ok=false for any other. GetErr, when set, is returned instead.

func (*Fake) IsAllowedAuthor

func (f *Fake) IsAllowedAuthor(_ context.Context, login string) (bool, error)

IsAllowedAuthor implements Forge against the Allowed map.

func (*Fake) MergePR

func (f *Fake) MergePR(_ context.Context, prNumber int, expectedHeadSHA string) (PR, error)

MergePR implements Forge: squash-merges prNumber only if its recorded HeadSHA still equals expectedHeadSHA (simulating the real API's atomic "merge iff head is X" — Known bug classes: a stale head must be refused, not raced). Merging an already-merged PR returns an error, the same way a second real merge call against an already-merged PR would 405 — exercising the caller's "re-check FindPR before concluding failure" path (the named adversary: a process killed mid-merge-call).

func (*Fake) PRs

func (f *Fake) PRs() []PR

PRs returns a snapshot of every PR the fake has created, for a test asserting "exactly one PR exists".

func (*Fake) ReadFile

func (f *Fake) ReadFile(_ context.Context, ref, path string) ([]byte, bool, error)

ReadFile implements Forge against Files (keyed "ref path").

func (*Fake) ResolveRef

func (f *Fake) ResolveRef(_ context.Context, ref string) (string, bool, error)

ResolveRef implements Forge against the Refs map.

func (*Fake) SetBase

func (f *Fake) SetBase(prNumber int, base string)

SetBase is the test-only hook standing in for another actor retargeting a PR's base after this promotion last observed it — the same rare, real race MergedStep's own success-path base check (round-6 hardening) exists to catch (mirrors SetHeadSHA's own stale-head test hook).

func (*Fake) SetChecks

func (f *Fake) SetChecks(sha string, sum CheckSummary)

SetChecks records sha's CheckSummary, thread-safely — the way a test simulates CI going from pending to green (or to a named failure) between polls.

func (*Fake) SetClosed

func (f *Fake) SetClosed(prNumber int, closed bool)

SetClosed is the test-only hook standing in for an operator closing a PR on GitHub without merging it (round-9 finding: PROpenedStep must refuse to adopt one of these as satisfied).

func (*Fake) SetHeadSHA

func (f *Fake) SetHeadSHA(prNumber int, sha string)

SetHeadSHA overwrites prNumber's recorded HeadSHA — how a test simulates "something else moved the branch after this promotion last observed it pushed" (the stale-head-at-merge adversary) without needing a second real git push.

func (*Fake) Tags

func (f *Fake) Tags(_ context.Context) ([]GitTag, error)

Tags implements Forge.

type Forge

type Forge interface {
	// CreatePR opens a new pull request from spec.Head into spec.Base. The caller (internal/
	// engine's PROpened step) always calls FindPR first — CreatePR is not itself required to
	// deduplicate, though implementations may refuse an obviously duplicate head branch as a
	// safety net (Fake does; the real GitHub API does this on its own, returning 422).
	CreatePR(ctx context.Context, spec PRSpec) (PR, error)
	// FindPR looks for an existing pull request for this promotion: first by headBranch, then
	// — so that a PR survives its branch being renamed or the branch being deleted and
	// recreated — by searching bodyMarker (the exact "<!-- hoist:id=... -->" line) across
	// open PRs and, if that search is empty, recently closed/merged ones within a bound.
	// ok=false means no matching PR exists yet, not an error.
	FindPR(ctx context.Context, headBranch, bodyMarker string) (PR, bool, error)
	// GetPR fetches one pull request by number, ok=false when no such PR exists. It is the
	// exact lookup a caller uses once a promotion has recorded its PR's number (the state file
	// as an index of where to look — AGENTS.md §4.1), in place of FindPR's bounded body-marker
	// scan, which cannot find a merged, branch-deleted PR once enough later PRs have closed
	// past its window (issue #45). The caller still checks the returned PR is its own.
	GetPR(ctx context.Context, number int) (PR, bool, error)
	// Checks reports the check-run rollup for sha. Stubbed/minimal is fine for M3; M4 extends
	// it into a gate.
	Checks(ctx context.Context, sha string) (CheckSummary, error)
	// Comments lists comments on prNumber posted at or after since. Stubbed/minimal is fine
	// for M3; M4 scans these for the approval magic comment.
	Comments(ctx context.Context, prNumber int, since time.Time) ([]Comment, error)
	// IsAllowedAuthor reports whether login is a collaborator with write (or higher) permission
	// on the repo — the second way (besides RepoConfig.Approvers) an Approved comment's author
	// can be accepted (R-001). A permission-scope error (the gh token lacking what this needs)
	// must be returned as an error, never silently folded into false — AGENTS.md §6.1's "the gh
	// token may be missing the repo scope this needs" gotcha applies here exactly as it does to
	// every other adaptor call.
	IsAllowedAuthor(ctx context.Context, login string) (bool, error)
	// MergePR squash-merges prNumber, but only if the PR's current head sha still equals
	// expectedHeadSHA — using the forge's own atomic "merge iff head is X" primitive, never a
	// client-side check-then-merge (a race the caller cannot close on its own). A stale head is
	// reported as an error satisfying errors.Is(err, ErrStaleHead). Merging an already-merged PR
	// is not an error the caller must avoid causing — MergePR may report it either way, and a
	// caller must re-check FindPR before concluding a merge failed outright (a killed process
	// cannot always tell whether its own call landed server-side).
	MergePR(ctx context.Context, prNumber int, expectedHeadSHA string) (PR, error)
	// Tags lists this forge's repo's git tags, each with the date of the commit it points to
	// — bounded, not exhaustive: an adaptor may cap how many it returns rather than crawl an
	// unbounded tag list in full (pkg/forge/github's own Client.Tags stops after
	// maxTagPages*100 = 300 tags today). Added for M6: the tag picker prefers the app repo's
	// own git tags for ordering registry tags by recency (AGENTS.md invariant 3) over the
	// registry's own unordered, timestamp-free tag list (AGENTS.md §6.1 item 3). Order is
	// unspecified — callers sort by Date themselves; a repo with no tags returns an empty
	// slice, not an error.
	Tags(ctx context.Context) ([]GitTag, error)

	// ResolveRef resolves a tag name, branch name, or (possibly abbreviated) commit sha to a
	// full commit sha. ok=false means the forge has no such ref, or the abbreviation is
	// ambiguous — not an error. A scope or permission failure IS an error, never folded into
	// ok=false: pkg/migrate reads ok=false as "try the next source" and an error as "stop", and
	// a token gap read as "unknown" would silently degrade every revision to unresolved
	// (AGENTS.md §6.1 item 1's scope gotcha, at this boundary). M10.
	ResolveRef(ctx context.Context, ref string) (sha string, ok bool, err error)
	// Compare lists the commits and changed files between base and head, three-dot (reachable
	// from head, not from base). Bounded: an adaptor pages up to its own cap and reports
	// Comparison.Truncated rather than crawl further. Either ref unknown to the forge is an
	// error satisfying errors.Is(err, ErrUnknownRef). M10.
	Compare(ctx context.Context, base, head string) (Comparison, error)
	// CommitFiles lists the paths one commit changed, bounded the same way (truncated=true
	// when the forge capped the list). M10.
	CommitFiles(ctx context.Context, sha string) (files []string, truncated bool, err error)
	// CommitsTouching lists, newest first, the shas of commits reachable from ref that changed
	// anything under path (a directory prefix or a file), committed at or after since. Bounded
	// by the adaptor's page cap. M10: how pkg/migrate attributes migration files to the commits
	// that added them without reading every commit in a range.
	CommitsTouching(ctx context.Context, ref, path string, since time.Time) ([]string, error)
	// BlameLines reports, for each requested 1-based line of path at ref, the commit that last
	// changed it. A line past the file's end is absent from the map, not an error. M10: "how
	// long has the image this promotion replaces been live" is the age of the manifest line,
	// not of the file — one file holds several image lines and any one of them moving would
	// reset a file-level date.
	BlameLines(ctx context.Context, ref, path string, lines []int) (map[int]LineOrigin, error)
	// ReadFile returns the content of path at ref. ok=false means the file does not exist
	// there — not an error, since the one caller (pkg/migrate's per-app-repo `.hoist.yaml`)
	// treats absence as "fall back to config". M10.
	ReadFile(ctx context.Context, ref, path string) (content []byte, ok bool, err error)
}

Forge is the seam between internal/engine and the code host. Every adaptor (pkg/forge/github, and Fake for tests) implements the same interface, so internal/engine never knows which one it is talking to.

type GitTag

type GitTag struct {
	Name string
	Date time.Time
}

GitTag is one git tag on the forge as Tags reports it: the tag name and the date of the commit it points to. Date is deliberately the *commit's* date, not an annotated tag object's own creation date — "when was this released" (M6's tag-ordering use, AGENTS.md invariant 3) means when the code was committed, not when someone got around to tagging it, and a lightweight tag has no object date to read anyway.

type LineOrigin

type LineOrigin struct {
	SHA  string
	Date time.Time
}

LineOrigin is the commit that last changed one line of one file at one ref, as BlameLines reports it. Date is the committer date.

type PR

type PR struct {
	Number     int
	URL        string
	HeadBranch string
	HeadSHA    string
	Base       string
	Merged     bool
	MergeSHA   string
	CreatedAt  time.Time
	// Closed is true for a PR someone closed WITHOUT merging (GitHub's own "state": "closed"
	// with Merged still false) — a dead PR a merge call can never succeed against (a real 405).
	// FindPR's query (state=all, so a renamed-branch/body-marker fallback can still find a
	// promotion's own history) can return one of these, so a caller adopting a found PR must
	// check this explicitly rather than assuming "found" means "usable."
	Closed bool
}

PR is a pull request as this package cares about it: identity, where it points, and its merge state. Body is deliberately not part of PR — only PRSpec carries it in, since nothing downstream of CreatePR/FindPR needs to read it back (FindPR searches it internally).

type PRSpec

type PRSpec struct {
	Title, Body, Head, Base string
}

PRSpec is what CreatePR needs to open a pull request. Head and Base are branch names, not refs (no "refs/heads/" prefix).

Directories

Path Synopsis
Package github implements pkg/forge.Forge against the real GitHub REST API, using github.com/cli/go-gh/v2 (AGENTS.md §4.7's one sanctioned new dependency for M3 — not google/go-github, not a hand-rolled REST client, not the full gh CLI as a library).
Package github implements pkg/forge.Forge against the real GitHub REST API, using github.com/cli/go-gh/v2 (AGENTS.md §4.7's one sanctioned new dependency for M3 — not google/go-github, not a hand-rolled REST client, not the full gh CLI as a library).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL