GitHub adapter: client and core Forge methods #84
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
lazyforge can't talk to GitHub. This ticket builds the adapter's foundation: the API client and every core
Forgemethod, so a GitHub session can list repos, show PRs and issues with CI status, merge, approve, comment and close. Connecting from onboarding is #86, and runs, logs and labels are #85. Parent: #83.Acceptance criteria
internal/forge/githubimplementsforge.Forgeandforge.Approver, and passesforgetest.RunContract(ADR 0002: every adapter passes the contract suite).https://github.comtalks tohttps://api.github.com, and any other root talks to<root>/api/v3.Info().URLstays the web root, sooopens github.com pages (ticket #83; design.md → Keymapo).ErrHeadChanged(ADR 0006; ADR 0011).ErrRateLimitedand never as a bad-token error (architecture.md → How the forges differ: rate limits).rand the five-minute refresh).docs/architecture.md's GitHub column is updated to the verified endpoints.Plan
New package
internal/forge/github, shaped likeinternal/forge/gitea:github.go): one*http.Client, per-callctx. HeadersAuthorization: Bearer <token>,Accept: application/vnd.github+json,X-GitHub-Api-Version: 2022-11-28.New(ctx, webURL, token, hc)readsGET /user(andGET /metaon GHES forinstalled_version) so a bad token fails at connect.Probe(ctx, webURL, hc)does an unauthenticatedGET /meta: 2xx returnsforge.KindGitHub, and a non-JSON reply or 404 returnsErrNotFound. #86 wires it in.ErrUnauthorized; 403 withX-RateLimit-Remaining: 0or a "secondary rate limit" message →ErrRateLimited; any other 403 →ErrUnauthorized; 404 →ErrNotFound; 429 →ErrRateLimited; merge 409 →ErrHeadChanged; 405 and 422 →ErrRefusedcarrying GitHub'smessage. Verify the merge 409 and 405 bodies against the docs and write them in as synthesized fixtures, as the gitea adapter does.per_page=100, followLink: rel="next"to the end. Never stop on a short page.If-None-Match. A 304 replays the cached body. GitHub doesn't count 304s against the limit. Cap the cache at about 1,000 entries and evict the oldest. It lives in the adapter: core's cache can't do this, because it never sees HTTP.ListRepos:GET /user/repos?affiliation=owner,collaborator,organization_member&sort=pushed. Access: admin → admin, push or maintain → write, pull or triage → read, and archived → read.LastActivityispushed_at, sorted client-side as the contract requires.ListChangeRequests/GetChangeRequest:GET /repos/{o}/{r}/pulls?state=…. For closed, foldmerged_at != nullintoStateMerged. CI comes fromGET …/commits/{sha}/check-runs(paginated,{total_count, check_runs}) plusGET …/commits/{sha}/status.total_count: 0contributes nothing (GitHub reportspendingthere). Precedence fail > cancelled > running > pending > pass > skipped > none. Check-runfailure,timed_out,action_requiredandstartup_failureare fail.cancelledis cancelled.in_progressis running.queued,waiting,requestedandpendingare pending.successandneutralare pass.skippedandstaleare skipped. Statuserrorandfailureare fail.Merge:PUT …/pulls/{n}/mergewith{sha, merge_method}. An emptyMethodreadsGET /repos/{o}/{r}forallow_merge_commit,allow_squash_mergeandallow_rebase_merge, which/user/reposdoesn't return (verified 2026-10-09: null there, set on the single-repo GET). SetRepo.MergeStylethe same way where the dialog reads it. Check how the dialog gets it today and keep that path working with one extra GET per repo at most.Approve:POST …/pulls/{n}/reviews{event: "APPROVE"}. Approving your own PR is a 422, which maps toErrRefused.ListIssues:GET …/issues?state=…, dropping items with apull_requestkey. Merged returns nil with no request, as gitea does.EditIssueBody:PATCH …/issues/{n}.ListComments/Comment:…/issues/{n}/commentsfor both kinds (paginated).Close:PATCH …/pulls/{n}or…/issues/{n}{state: closed}.ListReleases:GET …/releases.Gate: always nil.Attachments: nil, since GitHub has no attachment list (#87 covers images).gh apiintotestdata/, with a README in the gitea format (source, date, hand edits). A statefulhttptestserver likegitea/server_test.gobacksRunContract. Unit tests cover the CI fold table, the rate-limit 403,Linkpaging across three pages, the 304 replay, and merge-method selection.net/httpclient and nogoogle/go-github, for consistency with ADR 0005, control over the ETag cache, and no new module. It also records the CI fold, the merge-method rule and the conditional-request cache.Context
internal/forge/forge.go. Availability:internal/forge/can.go. Contract:internal/forge/forgetest/contract.go.internal/forge/gitea/gitea.go(client,statusError,walk),forge.go(methods) andserver_test.go(stateful fake server).architecture.mdAPI table has the GitHub column marked unverified. Verify each endpoint against current docs while building.ETag,X-RateLimit-Limit: 5000, and/user/reposreturnspermissionswithmaintainandtriagebut nullallow_*merge flags.Out of scope
Onboarding and connect wiring (#86).
RunLister,LogReaderandLabeler(#85). Images (#87). Re-run, review comments, and GitHub App auth.🤖 Variance: GitHub's /user/repos omits the allow_* merge flags, so the adapter leaves Repo.MergeStyle empty and the confirm dialog shows "repo default" (its existing text for forges that don't report one). Merge still resolves the method with one GET /repos/{o}/{r} at merge time. Fetching it per repo in the list would cost one request per repo on every load.
🤖 merged: #88