Skip to content

Git providers

The interface is deliberately small: it carries what the workflow needs and nothing more. The methods group into four jobs: read a pull request, read what has been said on it, say something, and push a fix. gitprovider/provider.go is the authoritative list, and the listing below goes stale the moment a method is added.

ADR 0004 committed to four methods. Reading a pull request, discovering open ones, editing a comment and publishing a commit status all turned out to be workflow needs too, which is the growth its “lowest common denominator” cost paragraph predicted.

On identity. A token grants the right access and the wrong name: it belongs to whoever minted it, so the agent’s comments arrive under that person’s avatar and read like a colleague’s until the footer. On GitHub, set git.app.appId and give it a private key; an App comments as yourapp[bot], with a face of its own, and its installation tokens expire hourly instead of never. On Gitea, create a dedicated bot user and mint the token as that user, which is what local/ does.

// read a pull request
GetPullRequest(ctx, number) // title, branch, head SHA, labels
ListOpenPullRequests(ctx) // how the gate discovers work: no webhook, no CI event
// read what has been said on it
ListComments(ctx, number) // every comment, see below
CheckStatus(ctx, sha, checkName) // pending | success | failure | missing
// say something
Comment(ctx, number, body)
UpdateComment(ctx, id, body) // edit in place rather than appending per push
SetCommitStatus(ctx, sha, name, state, description)
AddLabel(ctx, number, label) // the attempt cap lives here
// push a fix
PushFix(ctx, pr, root, message) // to the PR's branch, never the default
Name() string
ProviderStatus
GitHubimplemented, exercised
Giteaimplemented, exercised against a live instance
GitLabextension point
Bitbucketextension point

GIT_API_BASE means different things per provider, because the providers do. On GitHub it is the API root (.../api/v3 for Enterprise); on Gitea it is the instance root, because the client appends /api/v1 itself.

GIT_API_BASE is required for Gitea and the process refuses to start without it, because there is no public Gitea to default to. GitHub defaults to the public API.

Reads and writes must name the same host. The GitHub client builds its push remote from GIT_REPO_URL, the same value the checkouts clone, so an Enterprise deployment pushes where it read. Deriving the remote from owner/repo against github.com sends the fix, and a live installation token with it, to whatever repository happens to hold that name on the public host.

No credential reaches git’s argv, the push token’s or the operator’s. Both providers used to spell it into the remote URL, https://x-access-token:<token>@host/..., and hand that to git push as an argument. Nothing persisted it and the error text was redacted, but /proc/<pid>/cmdline is world-readable, so for the length of the push a live token was there for ps and for every other process in the namespace. git -c http.<url>.extraHeader=… is the obvious repair and is the same mistake, because -c is argv too; the credential goes through GIT_CONFIG_COUNT/KEY/VALUE instead, where /proc/<pid>/environ is readable only by the process owner. The key is scoped to the remote’s own URL and never the bare http.extraHeader, which would attach a bearer credential for one host to whatever host git ended up talking to.

The same applies to a credential an operator writes into GIT_REPO_URL, which bosun used to hand git as part of the URL on every clone. gitprovider.Remote splits it the same way, and the remote stored as origin carries no credential. Bosun attaches the credential per command, to the commands that contact a host, which is also why it passes the remote to EnsureHead and MergeBase instead of letting them find it in the checkout. Only gitprovider may run a git command that contacts a remote, and a test derives that from git’s own remote-facing subcommands instead of from a list of call sites.

Things a new implementation has to get right

Section titled “Things a new implementation has to get right”

The gate’s report is a comment. A comment is the only artifact surface every git host has, so the gate publishes there rather than into a provider-specific artifact store. ListComments finds it by an HTML marker.

ListComments must return every comment, not the first page. The gate’s report is found by scanning that list, so a client that silently stops at one hundred makes the report vanish on a busy pull request, and the agent cannot tell that from a gate that published nothing, which is a different situation with a different answer.

Check state has two surfaces. On GitHub a gate may report as a check run (Actions) or a legacy commit status, and a repository can use either. Reading only one makes a gate you did not look at indistinguishable from no gate. Expect the same split elsewhere.

Pushes must not re-trigger nothing. Most hosts suppress workflow triggers for pushes made with the CI system’s own token. If the agent pushes with that token, the gate never re-runs, the status stays red at its previous conclusion, and the promotion waits on a result that will never change. Use a separate credential.

Never implement a merge or a close. The interface deliberately has neither. The agent proposes; the gate and the merge policy dispose.

For GitHub, a fine-grained token scoped to the repository:

PermissionLevelWhy
Contentsread & writepush the fix to the bot branch
Pull requestsread & writecomment
Issuesread & writelabels, which carry the attempt cap
Commit statusesread and writeread the gate, and publish the agent’s own verdict beside it
Metadatareadrequired baseline
Workflowsnonewithout it GitHub rejects any push touching .github/workflows/**, making “the agent cannot edit the gate” a server-side guarantee as well as a local one

That last row is worth keeping even though edits.DefaultDeny already refuses those paths. Two independent mechanisms, one of which is enforced by someone else’s server.