forge

package
v0.9.3 Latest Latest
Warning

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

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package forge is spectackle's one seam onto a pull-request host: open a draft PR for a branch, flip it ready, merge it, or find the one already open for a branch — nothing else. Two implementations share the Forge interface: GitHub (net/http against the REST pulls API — no gh binary, no new module dependency, see GitHub and Token) and Offline (no network at all; Merge performs a real local merge rather than a no-op, see Offline) so the lifecycle, and its tests, never need a live forge or a stub server.

Merge method is always "merge" — NEVER squash (ADR-01KYDB, and the sibling never-squash policy: work.md T-01KYD8M955E9NBPH27D21E4J9J). Squashing a branch collapses every per-edge decision commit into one blob on main, destroying exactly the trail those commits exist to leave.

Index

Constants

View Source
const ReasonNoPermission = "no_permission"

ReasonNoPermission is the MergeResult.Reason set when Merge is blocked by insufficient permission. It exists so that outcome is a value a caller can branch on, not an error string a caller has to pattern-match to tell apart from a real bug.

View Source
const ReasonNotReady = "not_ready"

ReasonNotReady is the MergeResult.Reason for a merge the forge refused as TRANSIENTLY unready: GitHub answers 405 while it recomputes mergeability and 409 when the head advanced moments before — both routine seconds after a push, both resolved by asking again shortly. Distinguished from ReasonNoPermission so callers retry the one and never the other.

Variables

This section is empty.

Functions

func ParseRemote

func ParseRemote(remote string) (owner, repo string, err error)

ParseRemote extracts owner/repo from a git remote URL. It accepts the two forms `git remote get-url origin` actually produces: HTTPS (https://host/owner/repo, with or without a trailing .git) and the scp- like SSH form (git@host:owner/repo, with or without a trailing .git). Anything else is rejected rather than guessed at — a wrong owner/repo silently talks to the wrong repository's API.

func Token

func Token(host string, runner CredentialRunner) (string, error)

Token resolves credentials for host, first hit wins: GITHUB_TOKEN, then GH_TOKEN from the environment, then git's credential helper (runner; nil uses execCredential). The environment always wins over the helper — it is the explicit, no-prompt override CI sets — and the helper is the fallback that needs no new configuration from anyone who can already push to the remote.

Types

type CheckState

type CheckState string

CheckState is the reduced CI verdict for a head commit. Four states, not a bool, because the caller's obligations differ for each: Passing merges, Failing refuses loudly, Pending waits within a bounded budget, and None — a repository with no CI at all — proceeds, since waiting for checks that will never arrive is a hang, not a gate.

const (
	ChecksPassing CheckState = "passing"
	ChecksFailing CheckState = "failing"
	ChecksPending CheckState = "pending"
	ChecksNone    CheckState = "none"
)

type CredentialRunner

type CredentialRunner func(input string) (output string, err error)

CredentialRunner executes `git credential fill`, writing input to stdin and returning its stdout. Production uses execCredential; tests inject a stub (see Token) so credential resolution never shells out to a real git process — there is nothing for a unit test to authenticate against, and a real invocation could hang waiting on a prompt or leak a developer's actual token into test output.

type Forge

type Forge interface {
	// Open creates a draft pull request for branch onto base.
	Open(branch, base, title, body string) (PR, error)
	// Ready flips a draft PR to ready for review — called exactly once
	// per lifecycle, at the archive edge (PR-DRAFT-001).
	Ready(pr PR) (PR, error)
	// Draft converts a ready PR back to draft. No lifecycle edge calls it
	// since PR-DRAFT-001 retired the two-way mirror (T-01KYDKNR8) — it
	// stays as forge capability for operators and tests.
	Draft(pr PR) (PR, error)
	// Merge merges pr with a merge commit — never squash, never a bare
	// fast-forward (see package doc and MergeResult).
	Merge(pr PR) (MergeResult, error)
	// Find returns the pull request already open for branch, if any.
	Find(branch string) (PR, bool, error)
	// Checks reports the CI verdict for the pull request's head. The merge
	// step consults it BEFORE merging: a red head must never be merged
	// mechanically, and a pending one is waited for — see CheckState for the
	// four possible answers and what each obliges the caller to do.
	Checks(pr PR) (CheckState, error)
}

Forge is the seam: open a draft, ready it, merge it, or find the PR already open for a branch. Find is what makes callers idempotent — a caller unsure whether it already opened a PR calls Find first and never Open blindly. Open itself always creates rather than deduping: GitHub's own create-PR endpoint 422s on a duplicate head/base pair, and Offline mirrors that error rather than silently returning the existing PR, so both implementations tell a misused caller the same story.

type GitHub

type GitHub struct {
	Owner   string
	Repo    string
	Token   string
	BaseURL string // API root; empty means defaultAPIBase
	// GraphQLURL is the GraphQL endpoint; empty means GitHub's own. Ready is
	// the only caller (see there), and tests point it at an httptest server.
	GraphQLURL string

	Client *http.Client // empty means http.DefaultClient
	// contains filtered or unexported fields
}

GitHub implements Forge over the REST pulls API using net/http only — no gh binary, no new module dependency. A second auth system plus a binary dependency is exactly what Token's credential-helper fallback exists to make unnecessary.

func NewGitHub

func NewGitHub(remote string, runner CredentialRunner) (*GitHub, error)

NewGitHub builds a GitHub forge from a git remote URL (as returned by `git remote get-url origin`, either the https or the ssh form ParseRemote accepts) and resolves credentials via Token. The credential host is fixed to github.com: that is the only host this implementation ever talks to, GitHub Enterprise is out of scope here.

func (*GitHub) Checks

func (g *GitHub) Checks(pr PR) (CheckState, error)

Checks reduces the head's check runs to one CheckState.

The reduction order matters: any failed run makes the whole verdict Failing even while others still run — a red that is already certain must not be waited on; any still-running run with no failure yet is Pending; all-concluded-benign is Passing; and zero runs is None, the repository-without-CI case, which must be distinguishable from Passing because it is the caller's cue that there is nothing to wait FOR.

func (*GitHub) Draft

func (g *GitHub) Draft(pr PR) (PR, error)

Draft converts a ready pull request back to draft — the exact mirror of Ready. No lifecycle edge calls it anymore (PR-DRAFT-001 retired the T-01KYDKNR8 reopen mirror); it remains forge capability. Same GraphQL-only constraint as Ready (REST cannot un-ready), same node-ID addressing, and the same verify-don't-claim discipline: a 200 whose payload still shows isDraft false is an error, never a success (B-01KYDE).

func (*GitHub) Find

func (g *GitHub) Find(branch string) (PR, bool, error)

Find returns the pull request already open for branch, if any — the lookup that makes a second Open call unnecessary (see Forge doc).

func (*GitHub) Merge

func (g *GitHub) Merge(pr PR) (MergeResult, error)

Merge merges pr with merge_method=merge — NEVER squash (ADR-01KYDB and the sibling never-squash policy; see package doc). A 403 means the credential lacks merge permission: that is a degrade, not a failure — the PR is left open and MergeResult reports ReasonNoPermission so the caller can act on it instead of mistaking it for a bug.

func (*GitHub) Open

func (g *GitHub) Open(branch, base, title, body string) (PR, error)

Open creates a draft pull request for branch onto base.

func (*GitHub) Ready

func (g *GitHub) Ready(pr PR) (PR, error)

Ready flips a draft PR to ready for review (PATCH draft:false — GitHub has no dedicated "ready" endpoint, only this backdoor through the update call). Ready flips a draft pull request to ready for review, via GRAPHQL.

It has to be GraphQL, and this is the trap: the REST pulls endpoint accepts a PATCH carrying "draft": false, answers 200, and leaves the pull request a draft. Nothing fails, nothing warns — the field is simply not writable there. The first version of this used that PATCH, reported success, and the pull request stayed a draft; the automation then claimed to have readied it, which is the one thing this package must never do.

markPullRequestReadyForReview is the only supported way, and it takes the GraphQL node ID rather than the number, which is why ghPull carries NodeID.

The result is verified rather than assumed: if the pull request is still a draft after the mutation, that is an error, not a success to report.

type MergeResult

type MergeResult struct {
	Merged bool
	SHA    string // merge commit sha; set only when Merged
	Reason string // empty when Merged; ReasonNoPermission otherwise
}

MergeResult reports what Merge actually did. Merged=false is not itself failure: a merge blocked by insufficient permission must leave the PR open and say so plainly (Reason=ReasonNoPermission), degrading rather than failing — the automation has to be safe to run as an identity that cannot merge, and the difference between "didn't merge because no permission" and "didn't merge because something broke" has to be visible, not silent.

type Offline

type Offline struct {
	RepoRoot string // repository checkout the merge runs against
	Base     string // default branch merged into

	// StatePath is where the tracked pull requests are persisted, so the
	// lifecycle survives across PROCESSES.
	//
	// In-memory alone is not enough and integration proved it: every
	// `spectackle call` is a fresh process, so a PR opened by the move into
	// active was already forgotten by the move into done, which then reported
	// "no open pr" for a branch it had just created. A resident server would
	// lose it on restart for the same reason. Empty disables persistence,
	// which is what the unit tests use.
	StatePath string
	// contains filtered or unexported fields
}

Offline implements Forge with no network at all: PRs are tracked in-process, and Merge performs a REAL local merge into the repository's default branch — a merge commit, never squash, never a bare fast-forward — rather than a no-op that silently pretends.

SINCE T-01KYHAH1GJ this type is a TEST DOUBLE only: `git: mode: offline` runs commit-only edges and constructs no forge at all, so no production path reaches here. It survives because the hermetic online-shape tests (Server.forgeOverride + a local bare remote) exercise the full branch/PR/merge mapping through it, with no network and no stub server standing in for GitHub.

func NewOffline

func NewOffline(repoRoot, base string) *Offline

NewOffline builds an Offline forge that merges into base inside repoRoot.

func NewOfflinePersistent

func NewOfflinePersistent(repoRoot, base, statePath string) *Offline

NewOfflinePersistent is NewOffline whose tracked pull requests survive across processes, which every real caller needs (see Offline.StatePath).

func (*Offline) Checks

func (o *Offline) Checks(pr PR) (CheckState, error)

Checks answers ChecksNone always: offline has no CI, and None is exactly the state that tells the caller there is nothing to wait for. Answering Passing instead would be a claim about checks that never ran.

func (*Offline) Draft

func (o *Offline) Draft(pr PR) (PR, error)

Draft converts the tracked pull request back to draft — the mirror of Ready, kept as forge capability (no lifecycle edge calls it since PR-DRAFT-001). Saved like every other mutation (B-01KYDV): an unsaved flip would be invisible to the next process's Find.

func (*Offline) Find

func (o *Offline) Find(branch string) (PR, bool, error)

Find returns the tracked PR for branch, if any.

func (*Offline) Merge

func (o *Offline) Merge(pr PR) (MergeResult, error)

Merge performs a genuine `git merge --no-ff` of pr.Branch into o.Base inside o.RepoRoot, then drops the tracked PR (matching GitHub's own pulls API, where a merged PR no longer shows up as open). --no-ff is not cosmetic: without it a fast-forwardable branch merges with NO commit at all, and the whole point of this offline path is exercising the same merge-commit shape (two parents) that merge_method=merge produces on the GitHub side — see the test that reads git history rather than trusting this return value.

func (*Offline) Open

func (o *Offline) Open(branch, base, title, body string) (PR, error)

Open records a draft PR for branch. Mirrors GitHub's create endpoint, which 422s on a duplicate head/base pair: a second Open for a branch already tracked is a caller bug (it should have called Find), not a silent dedupe, so both implementations answer misuse the same way.

func (*Offline) Ready

func (o *Offline) Ready(pr PR) (PR, error)

Ready flips the tracked draft to ready for review. The flip is saved like Open's and Merge's mutations are (B-01KYDV): every `spectackle call` is its own process, so an unsaved flip is invisible to the next Find, which then reports a stale draft and makes the archive path flip (and gate) a second time.

type PR

type PR struct {
	Number int    // PR number
	Branch string // head branch
	Base   string // target (base) branch
	URL    string // human-followable link; Offline gives a synthetic one
	Draft  bool

	// NodeID is the forge's GraphQL identifier, when it has one. Ready needs
	// it: un-drafting a pull request is only possible through the GraphQL
	// mutation, which addresses the pull request by node ID rather than by
	// number. Empty for Offline, which has no such namespace.
	NodeID string `json:",omitempty"`

	// HeadSHA is the exact commit the caller wants a CI verdict FOR. Checks
	// polls it in preference to the branch name, because a branch ref asked
	// seconds after a push can still resolve to the previous head — whose
	// concluded green then merges work that was never tested (observed live:
	// a nine-second "verdict" on a branch whose CI takes two minutes). The
	// pusher knows the SHA it pushed; a caller that fills this cannot be
	// answered about any other commit.
	HeadSHA string `json:",omitempty"`
}

PR identifies one pull request across the four operations. Number is the host-assigned PR number for GitHub, or an in-process sequence number for Offline — either way it is what Ready and Merge take back to name "the same PR" without re-deriving it from the branch.

Jump to

Keyboard shortcuts

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