Skip to content

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/github behind the pinned tracker.Tracker seam — including the ADR-0014 surface: CreateIssue, AddIssueLabels/RemoveIssueLabels, Labels, EnsureLabel, CloseIssue, with strict ErrUnknownLabel semantics. 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). No Tracker interface 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: one do() transport (per-request timeout, auth header, bounded error snippet, token never in errors or the URL), statusError unwrapping to tracker.ErrNotFound on 404. No SDK dependency. GitHub deltas: Authorization: Bearer, X-GitHub-Api-Version: 2022-11-28, the vnd.github+json Accept; merged derived from merged_at != null; and /issues folds PRs into the list, filtered client-side on the pull_request field.

  • Forge flavor lives in the credential payload. ForgeTokenPayload gains forge: "forgejo"|"github". An absent field decodes as forgejo — 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 github flavor the UI prefills api.github.com and the registry builds BaseURL = "https://" + value. GHE operators enter their real API root (ghe.example.com/api/v3, or api.ghe.example.com under subdomain isolation) — no derivation heuristics, because GHE's URL layouts make any heuristic silently wrong. The forgejo flavor 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 the github flavor 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.forgeTracker routes on the decrypted payload's flavor, not repos.forge_kind. Detection demotes to (a) the auto-binding hint, unchanged — GHE and other-instance remotes auto-resolve to builtin, and the operator picks forge explicitly; and (b) a mismatch tripwire at TrackerFor: a detected kind that is neither none nor equal to the flavor is a loud config error (ErrForgeFlavorMismatch, in the ErrForgeCredentialKind style). 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: a github.com remote with a github-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/429 with X-RateLimit-Remaining: 0 or a Retry-After header becomes tracker.ErrRateLimited (a sentinel; the message carries the reset time), unwrapped like ErrNotFound; a plain 403 (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 same maxPages loud-truncation guard as the Forgejo client. ReadyIssues keeps 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_kind is host detection, and GHE / arbitrary Forgejo hosts detect as none, 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 a github. 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 ErrRateLimited plus 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.