feat(scm): GitHub provider adapter — Observe(prURL) → PRObservation#69
Conversation
A fresh GitHub SCM provider adapter under
backend/internal/adapters/scm/github/ exposing one method:
(*Provider).Observe(ctx, prURL) (ports.PRObservation, error)
It performs a REST GET on /repos/{o}/{r}/pulls/{n} for the authoritative
draft/merged/closed/head-SHA, one GraphQL query for the reviewDecision +
mergeStateStatus + statusCheckRollup + unresolved review threads, and
(only for failure-class CheckRuns) a REST GET on
/actions/jobs/{job_id}/logs to splice the last 20 lines of the failed
job into the observation.
The package is the observation primitive; the polling loop, cadence
selection, daemon wiring, persistence and webhook receiver are all
intentionally out of scope (separate PRs / lanes).
Closes #27 — this supersedes PR #28's attempt, which targeted types
(domain.SCMProvider / SCMSnapshot / ports.SCMObserveRequest) that the
PR #62 simplification refactor has since removed. The GraphQL queries
and mergeability composition logic are credited to @whoisasx from
PR #28's provider.go; the package was re-implemented against the
current ports.PRObservation seam (post-#62) rather than rebased.
Bot-author detection uses ONLY GitHub's typed signal (__typename
"Bot" / User.Type "Bot"). The strings.Contains(login, "bot") fallback
from PR #28 was intentionally dropped — aa-18's review flagged it as
a false-positive magnet for logins like "robothon" / "lambot123".
46 table-driven tests against httptest.NewServer cover happy path,
draft, merged, closed (not merged), CI passing/failing/pending,
StatusContext legacy, log-tail extraction (and the best-effort
log-fetch failure case), mergeability mergeable/conflicting/blocked
(including ci-failing → blocked even when GitHub still says CLEAN —
the load-bearing aa-18 contract)/unstable/unknown, review
approved/changes-requested/required/none, bot-author filtering
(including the robothon false-positive guard), unresolved-only
threads, all-bots → empty Comments, ETag-304 cache hit, primary +
secondary rate-limit (with errors.As → *RateLimitError), 401 →
ErrAuthFailed, malformed JSON → Fetched:false, network error →
Fetched:false, Authorization Bearer header injection,
StaticTokenSource blank/whitespace rejection, GHTokenSource memoize
+ invalidate.
Verification:
- go build ./... clean
- go vet ./... clean
- gofmt -l backend/internal/adapters/scm/ clean
- golangci-lint run ./... (v2.12, repo .golangci.yml) 0 issues
- go test -race ./internal/adapters/scm/github/... 46/46 PASS
References:
- aa-18 review of PR #28: ~/.ao/agent-reports/aa-18.md
- aa-26 tracker adapter (sibling Go-adapter pattern): #36 / agent-reports/aa-26.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Greptile SummaryAdds a new
Confidence Score: 5/5Safe to merge — all required network calls fail safely to Fetched=false, the CI-pagination guard and aa-18 mergeability contract are both exercised by tests, and the previously flagged issues (pagination truncation, empty GraphQL fragment, REST merge_state_status ghost field, fetchPlainText Accept header) are all resolved in this revision. The only new findings are two inert dead-code/cleanup items: an unreachable author["type"] branch in isBotAuthor (the GraphQL query never requests that field) and a url selection in the comment fragment that has nowhere to land in PRCommentObservation. Neither affects runtime behavior. The core observation path, error classification, ETag caching, and bot-filtering logic are all correct and well-tested. backend/internal/adapters/scm/github/provider.go — the dead author["type"] branch in isBotAuthor and the unused url comment field are cosmetic but worth tidying before the poller builds on top of this package. Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller as PR Manager / LCM
participant P as Provider.Observe
participant R as GitHub REST API
participant G as GitHub GraphQL API
participant L as GitHub Actions Logs
Caller->>P: Observe(ctx, prURL)
P->>R: "GET /repos/{o}/{r}/pulls/{n}"
alt 304 Not Modified
R-->>P: 304 + replay cached body
else 200 OK
R-->>P: restPull (draft/merged/state/head SHA)
end
P->>G: POST /graphql
G-->>P: PR graph data
loop Each failing CheckRun
P->>L: "GET /repos/{o}/{r}/actions/jobs/{id}/logs"
L-->>P: plain-text log (tail 20 lines)
end
P-->>Caller: "PRObservation{Fetched:true}"
Reviews (2): Last reviewed commit: "fix(scm): address greptile review on #69" | Re-trigger Greptile |
Four fixes from the greptile review of PR #69: 1. CI rollup pagination (P1) — when GraphQL reports pageInfo.hasNextPage=true for the statusCheckRollup contexts, a visible "all passing" set could be hiding a failing context on the next page. ciSummaryFromGraphQL now degrades Passing / Pending / Unknown to CIUnknown in that case; a known CIFailing on the visible page is still safe and is NOT degraded. Also bumped the per-page limit from 50 to 100 (GraphQL's documented max for the contexts connection). Two new tests pin both branches. 2. Empty GraphQL inline fragment (P2) — dropped `... on User { }` from the reviewThreads author selection. The empty selection set was technically invalid GraphQL and a future API tightening could reject the query. __typename already tells us whether the actor is a Bot, so the fragment carried no information. 3. rest.MergeStateStatus dead-code (P2) — the field decoded from the non-existent REST `merge_state_status` was always empty, making the firstNonEmpty fallback dead code. Removed the field and switched the tiebreaker to rest.MergeableState (the actual REST field, upper- cased so the same switch covers both GraphQL and REST shapes). 4. Wrong Accept header on /actions/jobs/{id}/logs (P2) — GitHub's REST API validates the Accept header before issuing the 302 to the log blob; sending text/plain risks a 406. Switched to the canonical application/vnd.github+json; the redirected blob serves text/plain regardless. Verification: - go build ./... clean - go vet ./... clean - golangci-lint run ./... 0 issues - go test -race ./internal/adapters/scm/github/... 48 / 48 PASS Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Re-integrate the agent-adapter layer onto main's rewritten session lane. main (#62/#66/#67/#68/#69) moved the session manager into session_manager plus a service layer, reworked the domain (Activity, IsTerminated), and removed the tmux runtime. This merge: - Keeps the rich, hooks-capable ports.Agent (in internal/ports) as the canonical agent contract; session_manager.Manager now resolves a real adapter per session via ports.AgentResolver (from cfg.Harness on Spawn, the stored harness on Restore) and builds RuntimeConfig.Argv. - Drops the tmux runtime; standardizes on zellij. - Re-adds the daemon's per-session agent resolver wiring (buildAgentResolver over the claude-code + codex registry, AO_AGENT default), ready to plug into the httpd session-route slot. go build, go vet, and go test -race are green; the zellij/tmux real integration tests are environment-gated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds
backend/internal/adapters/scm/github/, a new package exposing onemethod:
It is the observation primitive the PR Manager / LCM pipeline already
consumes (
ports.PRObservation). The poller loop, cadence selection,daemon wiring, persistence, webhook receiver and Linear/GitLab
providers are intentionally out of scope — separate PRs / lanes.
Closes #27.
Why a fresh package vs a rebase of PR #28
PR #28 (head
feat/27, @whoisasx) tried to land the SCM flow againstthe old
domain.SCMProvider/SCMSnapshot/ports.SCMObserveRequestcontract. PR #62 ("simplify session lifecycle", merged 2026-06-01)
refactored that whole surface away —
git grepfor any of those typeson
mainreturns zero hits. A rebase would have meant rewriting most ofthe package against the new
ports.PRObservationseam anyway, so thisbranch starts fresh against the current contract.
What is carried forward verbatim from PR #28 (credited to
@whoisasx):
statusCheckRollup + reviewThreads in one round trip)
ciFailureLogTailLines = 20constant and tail-extraction shapeWhat is deliberately not carried forward:
strings.Contains(login, "bot")fallback in bot-authordetection — aa-18's review flagged it as a false-positive magnet on
logins like
robothon/lambot123. This package uses onlyGitHub's typed signal (
__typename == \"Bot\"/User.Type == \"Bot\"). Seeprovider.go:isBotAuthor.aa-18's review notes:
[~/.ao/agent-reports/aa-18.md (local)] — the three load-bearing
contracts (draft encoding, pending-comments + bot routing,
mergeability composition) are exercised by tests in this package.
What's in the package
auth.goTokenSourceinterface +EnvTokenSource+StaticTokenSource+GHTokenSource(with injectableGH func(ctx) (string, error)so tests never shell out).client.goErrNotFound/ErrAuthFailed/ErrRateLimited),RateLimitError{ResetAt, RetryAfter}matchable viaerrors.As. Mirrors the tracker adapter's pattern.provider.goProvider.Observe(prURL) → PRObservation. Failed fetch →Fetched:false(no fabricated state).provider_test.gohttptest.NewServer.doc.goTest coverage (46 tests / 46 pass)
Happy path · draft · merged · closed (not merged) · CI
passing/failing/pending · StatusContext (legacy) failure · log-tail
extraction (tail to 20 lines + best-effort fetch failure stamps a
synthetic sentinel without flipping Fetched) · mergeability
mergeable / conflicting (DIRTY) / blocked (BLOCKED) / unstable (UNSTABLE) /
unknown / CI-failing → MergeBlocked even when GitHub still says
CLEAN (the load-bearing aa-18 contract) · review
approved / changes_requested / review_required / null / unrecognized ·
bot-author filtering (typename only, robothon false-positive guard,
resolved threads skipped) · all-bot threads → empty Comments + Fetched:true ·
ETag 304 cache hit (sends If-None-Match, replays body) · primary 403
rate-limit (Remaining=0 + Reset) · secondary 403 rate-limit (Retry-After) ·
401 →
ErrAuthFailed· malformed JSON →Fetched:false·network error →
Fetched:false· Authorization Bearer header injection ·StaticTokenSourceblank/whitespace rejection ·GHTokenSourcememoize +invalidate.
Verification
go build ./...go vet ./...gofmt -l backend/internal/adapters/scm/golangci-lint run ./...(v2.12, repo.golangci.ymlstrong-ruleset) — 0 issuesgo test -race ./internal/adapters/scm/github/...— 46 / 46 PASSSelf-review
Reviewed via
superpowers:code-reviewerbefore opening. Strong-recommenditems addressed inline:
a tie-breaker, not co-primary).
GET(mutating methods would mis-replay).ci-failing → MergeBlockedtest row.*.github.com/*.ghe.io(wasapi.*).Out of scope (intentionally — separate PRs / lanes)
internal/adapters/tracker/lane (PR feat(tracker): Tracker port (Get + List + Preflight) + GitHub adapter #36)Test plan
go test -race ./internal/adapters/scm/github/...locally — 46 PASSgolangci-lint run ./...locally — 0 issues🤖 Generated with Claude Code