Forge flavor on the credential; GitHub tracker client¶
The MVP shipped the Tracker seam with two implementations — the Forgejo REST client and the built-in tracker — and deferred GitHub to the first fast-follow (issue #1, brief D10/D16). The seam, labctl, the scoped run tokens, and the AFK engine were all tracker-agnostic by design, so GitHub is one new implementation, not a refactor. A design interview widened the issue from "GitHub client only" to mechanism + client, one PR: GitHub Enterprise comes in for free through the same credential mechanism, and the client must implement the ADR-0014 triage surface the seam has grown since the issue was filed.
The mechanics, pinned:
-
Tracker-only, full current interface. One new implementation
internal/tracker/githubbehind the pinnedtracker.Trackerseam — including the ADR-0014 surface:CreateIssue,AddIssueLabels/RemoveIssueLabels,Labels,EnsureLabel,CloseIssue, with strictErrUnknownLabelsemantics. GitHub auto-creates unknown labels on some write paths, so the client pre-resolves names against the repo's label set to keep typos loud (GitHub addresses labels by name, so the verified names go on the wire — the resolution is the guard, not an id translation). NoTrackerinterface changes. No webhooks — lab stays polling. Git push auth is untouched (a separate git credential, already forge-agnostic). -
Hand-rolled twin package. A structural twin of
internal/tracker/forgejo: onedo()transport (per-request timeout, auth header, bounded error snippet, token never in errors or the URL),statusErrorunwrapping totracker.ErrNotFoundon 404. No SDK dependency. GitHub deltas:Authorization: Bearer,X-GitHub-Api-Version: 2022-11-28, thevnd.github+jsonAccept;mergedderived frommerged_at != null; and/issuesfolds PRs into the list, filtered client-side on thepull_requestfield. -
Forge flavor lives in the credential payload.
ForgeTokenPayloadgainsforge: "forgejo"|"github". An absent field decodes asforgejo— correct by construction for every credential created before flavors existed; covered by a decode test. Payloads stay write-only (design §12); no schema migration, no new column. -
Host field = API origin, verbatim. For the
githubflavor the UI prefillsapi.github.comand the registry buildsBaseURL = "https://" + value. GHE operators enter their real API root (ghe.example.com/api/v3, orapi.ghe.example.comunder subdomain isolation) — no derivation heuristics, because GHE's URL layouts make any heuristic silently wrong. Theforgejoflavor is unchanged: a bare host, the registry appends/api/v1, existing credentials untouched. Host shape is validated at create time (400), flavor-aware: a path is allowed for thegithubflavor only; https-only, no userinfo/query/CRLF for both. Create-time validation and resolve-time BaseURL assembly share one routine (tracker.NormalizeForgeHost), so a host that passes create can never be rejected at resolve. -
Credential flavor is the routing authority.
Registry.forgeTrackerroutes on the decrypted payload's flavor, notrepos.forge_kind. Detection demotes to (a) the auto-binding hint, unchanged — GHE and other-instance remotes auto-resolve tobuiltin, and the operator picksforgeexplicitly; and (b) a mismatch tripwire atTrackerFor: a detected kind that is neithernonenor equal to the flavor is a loud config error (ErrForgeFlavorMismatch, in theErrForgeCredentialKindstyle). The reposvc explicit-forge gate relaxes: a forge credential alone suffices — the detected-forge-host requirement is dropped. Side effect, intended: arbitrary Forgejo instances (codeberg, a second private instance) unlock too. Known blind spot, accepted: agithub.comremote with agithub-flavored credential pointing at a GHE host passes both checks and surfaces as a loud 404 on first use. -
Rate limits: typed error, no retry. A
403/429withX-RateLimit-Remaining: 0or aRetry-Afterheader becomestracker.ErrRateLimited(a sentinel; the message carries the reset time), unwrapped likeErrNotFound; a plain403(e.g. a token that cannot see a private repo) stays an ordinary upstream error. The scheduler and reaper already log-and-skip per tick, so behavior self-heals after the window resets — no in-client sleeps, no proactive backoff, and a spent budget never counts as a run failure. Budget math: ~480 req/h per active GitHub repo at 30s ticks vs 5000/h per user. Fast-follows if it ever pinches: proactive backoff, ETag conditional requests (304s do not count against the budget). -
Pagination. Terminate on an absent
Link: rel="next"(no empty-page probe),per_page=100, with the samemaxPagesloud-truncation guard as the Forgejo client.ReadyIssueskeeps the client-side label recheck for parity, and drops PRs folded into/issues.
Status¶
Accepted. Implements the fast-follow that ADR-0009 and ADR-0014 pointed at ("the GitHub REST binding must implement the new seam ops when it lands"). No other ADR is revised.
Considered options¶
- A GitHub SDK dependency (
go-github). Rejected: the seam needs seven read/write shapes and one transport; a hand-rolled twin of the Forgejo client is smaller, has no dependency surface, and keeps the two backends legible side by side. - Route on
repos.forge_kind. Rejected:forge_kindis host detection, and GHE / arbitrary Forgejo hosts detect asnone, which would strand them. The credential the operator deliberately attached carries the truth, so it routes; detection demotes to a hint and a mismatch tripwire. - Derive the GHE API base from the host with a heuristic (append
/api/v3, or strip agithub.prefix). Rejected: GHE's path-style and subdomain-isolation layouts make any single heuristic silently wrong; the operator enters the API origin verbatim. - In-client rate-limit backoff / retry. Rejected: the scheduler and reaper already log-and-skip per tick, so a typed
ErrRateLimitedplus their existing self-healing is enough; in-client sleeps would block a shared HTTP client and hide the condition from metrics. - Webhooks for the done-signal. Rejected here: lab stays polling (one reaper, one contract across both bindings); a push model is a separate, larger change.
- A new
forge_kind = 'ghe'column, or a separate credential kind for GitHub. Rejected: the flavor is a property of the token, not the repo, and lives in the write-only payload — no migration, no new column, and existing credentials keep working by construction.