docs/design/2026-08-13-review-platform-provider-abstraction.md
Status: draft. Scope: make
/reviewwork against non-GitHub review platforms, starting with Aone Code (Alibaba's internal GitLab-based platform), without regressing the GitHub path.
/review today is GitHub-only. Every platform operation goes through the gh
CLI, and GitHub concepts (the /pull/<n> URL grammar, the pull/<n>/head
refspec, the Create Review API, closingIssuesReferences, GitHub Actions
check-run vocabulary) are hardcoded across ~12 command files, the SKILL.md
prose, and two agent briefs.
The motivating target is the internal odps_src repository (MaxCompute engine,
hosted on Aone Code at gitlab.alibaba-inc.com, reviewed on
code.alibaba-inc.com). Its review model differs from GitHub in ways that
matter to the skill:
git push origin HEAD:refs/for/master/<feature>);
one CR = one commit, amended in place on update (multi-commit CRs are CI-rejected).[to/fix #AONE_ID] + AI-Ratio trailers.isAiComment/isAiSummary flags, and there is a merge gate requiring all AI
comments to be addressed.Everything below was confirmed by running the commands, not from docs.
| Capability | GitHub (gh) | Aone Code (a1 CLI, v0.1.90, already authed) |
|---|---|---|
| Review ref | refs/pull/<n>/head | refs/merge-requests/<global-id>/head — global id, NOT iid (8402 refs present) |
| Canonical web URL | https://<host>/<o>/<r>/pull/<n> | https://code.alibaba-inc.com/<group>/<repo>/codereview/<id> (from mr view's detailUrl) |
| Git host vs web host | same host | differ: git gitlab.alibaba-inc.com, web code.alibaba-inc.com — needs host-alias handling |
| Metadata | gh pr view --json … | a1 repo mr view <id> -f json → id, iid, title, description, state, sourceBranch (= head SHA under AGit-Flow), targetBranch, author, assignees, detailUrl. No additions/deletions stats — compute locally from git |
| Diff | gh pr diff | Prefer local git diff after fetching the ref; a1 repo mr diff <id> [file] as fallback (file list without file arg) |
| Inline comments (read) | pulls/<n>/comments | a1 repo mr comment list --mr <id> -f json → id, note, author, closed, outdated, path, line, side ("right"/"left"), parentNoteId, isAiComment, isDraft |
| Inline comment (write) | Create Review API, one batched call | a1 repo mr comment create --mr <id> -m <body> [--file <path> --line <n>] [--reply-to <id>] — one call per comment |
| Review verdict | events APPROVE/REQUEST_CHANGES/COMMENT | a1 repo mr approve <id> exists; no native reject observed |
| Merge readiness / CI | check-runs + combined status API | a1 repo mr status <id> -f json → checks[] (discussion, approver_number, test, ai_comment) + readyToMerge |
| Linked issues | closingIssuesReferences + gh issue view --json title,body,comments | a1 repo mr workitem list --mr <id> → ids; a1 project workitem get <id> --format json (title + fields array; body is a team-defined field) + a1 project workitem comment |
| Whoami | gh api user --jq .login | a1 auth whoami -f json → account |
| Repo identity for bare numbers | gh repo view --json owner,name,url | remote URL path (group/repo) + a1 repo view; a1 repo link binding if present |
Goals
/review <aone-cr-url> and /review <n> inside an Aone-hosted clone run the
full pipeline (worktree fetch, context, agents, verification, terminal report)
with the same behavior contract as GitHub.--comment posts the review to Aone (inline comments + summary + verdict),
with the same write-discipline invariants (compose-then-post once, no
throwaway posts, auditable afterwards).glab) without
reshaping.Non-goals
refs/changes/) support, Bitbucket, etc.a1 for the user; absence is a clean error.publish-assets (GitHub Contents API) to Aone — feature-gated off
on non-GitHub in v1.lib/path-rules.ts GitHub Actions security
rules, script-lint/extract-step workflow parsing) — they key off
.github/workflows files and simply never fire in Aone repos. No change.lib/gh.ts is already a single transport choke point (exec, retry, pagination,
GH_HOST routing, auth check). A "wrap the CLI" abstraction would leak GitHub's
API shape into every call site. Instead, the interface captures review
operations. The sketch below is the end-state interface the write
operations join in Phase 3; Phase 1 (the meta / issue-context /
fetch-diff / comment-body PR, #9096) ships a synchronous, read-only subset
named ReviewPlatformReader with exactly the operations those four subcommands
consume (resolveRepo, getPrMeta, getClosingIssues, getIssue,
fetchDiff, getCommentBody) plus the ensureAuthenticated gate every one
of them calls first, and a no-arg getPlatformReader() registry — the subset
keeps the interface honest (every member has a consumer), and detection
arrives with the second provider:
// packages/cli/src/commands/review/lib/platform/types.ts
interface ReviewPlatform {
readonly kind: 'github' | 'aone';
// Step 1 — target & repo resolution
parseReviewUrl(url: string): ParsedReviewTarget | null;
resolveRepo(cwd: string): Promise<RepoIdentity>; // absorbs `gh repo view`
matchRemote(remotes: GitRemote[], id: RepoIdentity): RemoteMatch;
// Fetch & context
ensureAuthenticated(): void;
fetchReview(req: FetchRequest): Promise<FetchReviewResult>; // refspec + metadata + base
getContext(req: ReviewRef): Promise<ReviewContext>; // description, comments, verdicts, self
// Issue Fidelity (Agent 0)
getLinkedIssueEvidence(req: ReviewRef): Promise<IssueEvidence[]>;
// Gates
getCommentStatus(req: ReviewRef): Promise<CommentStatusFacts>;
presubmit(req: ReviewRef): Promise<PresubmitFacts>; // head drift, CI, prior qwen comments
// Write (Step 7) & audit (Step 9)
submitReview(req: SubmitRequest): Promise<SubmitReceipt>;
composeUrl(ref: ReviewRef, commentId?: string): string;
auditWrites(req: ReviewRef, window: AuditWindow): Promise<WriteAuditFacts>;
}
github.ts is an extraction of existing code (no behavior change);
aone.ts implements the same operations over a1.
gh commands into subcommands firstThe skill's own history: logic carried in prompt prose ships bugs; the tested
implementation is a subcommand. Today the following are prose the model
executes, and each becomes a subcommand (or folds into one) so that SKILL.md
carries zero platform-specific command syntax the model executes (the
write-discipline prohibitions that name gh … by design, the subcommand-internal
descriptions like "queries gh pr view", and Step 4's scratch-repo
render-adjudication carve-out — a deliberately raw gh api call, GitHub-specific
by nature — remain, to be re-authored or gated in Phase 3):
| Prose today | New home |
|---|---|
gh repo view owner/repo/host derivation (bare PR numbers; Step 1 & 7) | qwen review meta <n> — one call returning {platform, ownerRepo, host, headSha, webUrl} |
gh pr view --json headRefOid head-SHA fallbacks (Step 7, 422 recovery) | same meta subcommand |
Agent 0's closingIssuesReferences + gh issue view pair | qwen review issue-context <n> --out <file> — emits the evidence markdown; GitHub: closing issues + bodies + comments; Aone: workitems + fields + comments |
gh pr diff (lightweight cross-repo mode) | qwen review fetch-diff <target> |
gh api repos/…/pulls/comments/<id> refetch refs that pr-context emits into context.md | emit qwen review comment-body <id> commands instead (provider-routed) |
GH_HOST=<host> prefixing rule for all model-run gh calls | gone for every call; the Step 4 carve-out (the one remaining model-run gh api) carries no host routing of its own — it routes at the Enterprise host only when GH_HOST is exported in the environment (subagent shells inherit it), and is unavailable otherwise. Phase 3 re-authors it |
This phase is GitHub-only behavior-preserving and independently shippable: it removes the exact class of prose-carried failures the skill has measured, even before Aone lands.
a1 CLI, not raw HTTPa1 owns authentication (a1 auth login, token storage in
~/.config/a1/config.yaml), exposes -f json everywhere we need, and is
already the org-standard tool. Raw HTTP would mean re-implementing auth and
tracking an unstable internal API. The a1 invocations sit behind a thin
aone-client.ts mirroring lib/gh.ts's shape (execFileSync('a1', …), no
shell, JSON parse, transient-retry on idempotent reads, no retry on writes), so
a future HTTP client replaces one file. Provider checks a1 presence + version
at ensureAuthenticated() and fails with an actionable message otherwise.
parse-args gains two URL grammars: …/codereview/<id> (Aone canonical) and
…/merge_requests/<n> (GitLab-shaped; accepted and routed to the Aone
provider when the host matches an Aone mapping, refused with a clear message
otherwise — reserving the grammar for a future glab provider). The verdict
carries platform.github.com, GHE via
GH_HOST/--host) → GitHub; hosts matching the Aone mapping (initially the
*.alibaba-inc.com pair, configurable) → Aone, repo path from the remote URL.code.alibaba-inc.com ↔ git gitlab.alibaba-inc.com)
lives in a small mapping table in the Aone provider, overridable via settings
(review.platforms[]) so other Aone-hosted pairs need no code change.match-remote becomes platform-aware: on Aone, match by repo path
(group/repo) after alias-normalizing the host.id, never the iidEverything on Aone keys on the global id: the web URL, the git ref, and every
a1 repo mr subcommand. The iid appears only in list output and is
display-only. parse-args treats the number in a /codereview/<id> URL as the
id directly; no id↔iid mapping is needed anywhere in the pipeline.
APPROVE → a1 repo mr approve (after the summary comment lands).COMMENT → summary comment only.REQUEST_CHANGES → no native reject exists on Aone. Post the summary
comment with an explicit blocking header (**Request changes** + marker).
The merge gate already blocks on unresolved discussions, so inline Critical
comments left unresolved carry the blocking semantics. This is a semantic
difference from GitHub and is called out in the terminal report.comment create sets isAiComment
automatically or needs a flag; qwen-posted comments SHOULD carry it, because
Aone has a dedicated ai_comment merge gate. (Open question Q4.)Under AGit-Flow, updating a CR amends the single commit: the old head SHA is
orphaned, so an ancestry test (merge-base --is-ancestor <cached> <new>) fails
for every update — the amend's H2 has H1's parent, never H1 itself. The
incremental rule for Aone therefore does not test ancestry at all: both heads
are local after fetch, so git diff <cachedSha>..<newSha> is the update's
delta (for a pure amend, exactly the amended lines; if the author also rebased
onto newer master, the range additionally carries the rebase drift, which the
re-review should see anyway). presubmit's head-drift check likewise compares
the live sourceBranch SHA (it is the head) against the reviewed SHA, with
local git, not a platform compare API — none exists on Aone.
publish-assets (Contents API) is GitHub-only in v1: on Aone, steps that would
publish image assets degrade to embedding nothing and noting the skip.
cleanup's bypass audit maps to comment list filtered by
author.account == whoami() within the audit window. Everything else
(capture-local, findings, verification, reverse audit, build-test,
save-artifact, cost-ledger) is platform-neutral already — with one
qualification: plan-diff gains a --host option in Phase 1 (recorded into
the plan as the host carrier for lightweight runs, read by the welded Agent 0
command), so its platform dimension is the recorded host, not any API call.
fetch-pr, pr-context, pr-number target types, and the SKILL.md step
structure keep their names; "PR" remains the user-facing vocabulary. The
provider is an internal parameter. Renaming everything to neutral terms would
double the diff for no behavioral gain.
packages/cli/src/commands/review/lib/platform/
types.ts — ReviewPlatform + shared request/result types
registry.ts — detect(target, cwd, settings) → platform
github.ts — extraction of today's logic (Phase 1 note: lib/gh.ts
gained the untouched-bytes ghRaw transport and empty-flag
host normalisation, and github.ts consumes ghRaw;
existing call behavior otherwise unchanged)
aone-client.ts — a1 exec wrapper (execFileSync, -f json, retry policy)
aone.ts — Aone implementation
New/changed subcommands: meta (new), issue-context (new), fetch-diff
(new), comment-body (new); parse-args, match-remote, fetch-pr,
pr-context, comment-status, presubmit, submit, compose-review,
cleanup, test-plan route through the registry; plan-diff gains --host
(recorded into the plan — see D8).
agent-briefs.ts (Agent 0 brief, scratch-repo carve-out) and agent-prompt.ts
(gh pr view fallback warning) are re-authored to reference subcommands only —
with one deliberate exception: the Step 4 render-adjudication carve-out stays a
raw gh api repos/$QWEN_REVIEW_SCRATCH_REPO/issues/<n>/comments call inside the
verifier brief, because what it adjudicates is GitHub's own rendering; it is
GitHub-specific by nature and gains a host-routing note in SKILL.md's
Enterprise paragraph.
github.ts behind the interface;
behavior identical; existing tests pin behavior. SKILL.md untouched.aone-client, detection, fetch, context,
issue-context, comment-status, presubmit (read-only parts). Full local review
of an Aone CR works; --comment on an Aone target refuses with a clear
message. E2E: review a real odps_src CR locally.submit (batched inline + summary + verdict),
composeUrl, cleanup audit, AI-comment marking. Also owns the deferred
render-adjudication carve-out: either re-author it per provider (the
Enterprise host must reach the verifier subagent — SKILL.md currently says
exported-GH_HOST only, and "unavailable otherwise"), or gate it off
explicitly on non-github.com runs. E2E: --comment against a
scratch/test CR.
submit slice. submitAoneReview in
lib/platform/aone.ts posts the review as N+1 calls — one
a1 repo mr comment create per inline finding, the summary comment
last (Q5 order), a1 repo mr approve on APPROVE (D6); writes ride a
no-retry transport (a1Once) so a transient retry can never
double-post. The commit_id gate GitHub enforces server-side lives in
the provider as a pre-write head-drift refusal; a mid-batch failure
throws AonePartialPostError naming exactly what landed, and
submit reports it exit-3 with do-not-re-run advice (a retry would
duplicate). REQUEST_CHANGES posts the blocking summary header (D6);
the recorded-but-hostless refusal stays fail-closed, now between two
WRITABLE platforms. The created-comment read-back is tolerant: an
exec failure still propagates, but an ACCEPTED write whose answer
fails to parse degrades to "landed, id unknown" — counting it as
unposted would re-post it on a retry. Two deliberate trade-offs to
revisit when the Q4-era response changes land: the head-drift gate is
fail-OPEN on an empty sourceBranch (a mr view shape regression
must not brick posting), and the id read-back parses a set of
tolerated shapes best-effort. Still open: composeUrl, cleanup
audit, AI-comment marking (Q4), the render-adjudication carve-out.target-platform-unbound
refusal now HONOURS its own remedy — an explicit --host on the
re-run is platform proof and lifts it, instead of refusing again.
(2) The write gate binds hosts through hostsEquivalent, not raw
equality — Aone's web/git host pair is one platform. (3) Write
routing keys on the CANONICAL Aone pair (isAoneCanonicalHost),
never the family wildcard (a *.alibaba-inc.com GHE host is not
Aone), never the ambient GH_HOST (reads never detect from it), and
an explicit --host outranks the recorded binding in both
directions. (4) A size gate refuses any message over the
131072-byte single-argv-element limit a1 must pass it as, BEFORE
any write lands (a long CJK summary is inside compose-review's
char cap and outside the OS byte limit). (5) An exec failure counts
as possibly-landed (ambiguous), so submit's do-not-re-run advisory
fires even when the count is zero — an accepted-then-died write must
never read back as a clean total failure.--user-authorized publish invoked from another directory finds
nothing, and the cwd probe alone must not pick the platform of an
irreversible write. (7) The gh write rebinds its routing host to the
same evidence that selected it (explicitHost ?? recordedHost), so a
recorded non-canonical host (a GHE instance) no longer posts wherever
the ambient env pointed. (8) The REQUEST_CHANGES terminal note is
conditioned on the inline Criticals actually posted — a body-only
Critical posts no discussion threads, so nothing mechanically blocks
the merge and the note says so. (9) a1Cause reads the captured
stderr, not the execFileSync message — the message embeds the FULL
argv (the multi-line comment body), so parsing it surfaced the
operator's review text instead of a1's error. (10) The summary
skip-guard keys on the posted summaryMessage, not the raw body — an
empty-body REQUEST_CHANGES still posts its blocking header, the
verdict's sole carrier. Host comparison is normalised once
(normalizeHostSpelling: case/port/trailing-dot) and shared by
hostsEquivalent and isAoneCanonicalHost; the fast-path repo axis
binds case-insensitively; the cross-session scan is last-writer-wins
by mtime, and the newest same-PR recording decides (host or unbound)
instead of harvesting an older session's stale host.ghe.alibaba-inc.com origin no longer takes the a1
path. (12) submit FORCES context-unavailable into the compose input
on the Aone path — the cap no longer rides the model-written state,
so an omitted field cannot buy a real platform approval; the docs now
say the native approve does not fire this phase. (13) A mid-batch
failure now emits "partial": true with the landed counts/ids —
posted: false alone invited a wrapper retry that double-posts; and
a deliberate pre-write refusal (drift, oversized) reads as
aone-post-refused, while an UNEXPECTED pre-write error rethrows
(gh parity — nothing landed, a re-run is safe). (14) The floor
recovery's host axis binds to the host the write routes at
(explicit ?? recorded ?? gh fallback), so a flagless Aone post no
longer drops the operator's recorded floor. (15) The batch re-reads
the head once after posting and discloses a mid-batch amend
(headMovedDuringPost) instead of claiming the pins held. The
approve-failure and oversized refusals name the USER as the manual
actor; the completion contract reads partial/approved; and the
repeat-round caveats (no dedup backing, no self-PR detection) are
documented for the user. Still open: dedup/self-PR backing for Aone,
composeUrl, cleanup audit, AI-comment marking (Q4), the
render-adjudication carve-out.github.ts with gh
mocked and aone.ts with a1 mocked (fixture JSON captured from real calls
— the shapes in the facts table). The mock seam is the transport choke point
(lib/gh.ts today, aone-client.ts for Aone); full-pipeline E2E without a
model remains covered by the existing mock-provider.ts LLM endpoint.gh api … text — they now assert the comment-body
command); each such modification is called out in the phase's PR. Everything
else passing unmodified is the no-regression evidence.a1 version introduced mr comment create --file/--line and -f json stability? Provider version floor TBD.--line accept only new-side lines?
How are removed-line (side: left) comments posted? Needs a controlled
experiment on a scratch CR.comment create auto-set isAiComment
for bot/token identities, or is there a flag? Determines whether qwen
comments fall under the ai_comment merge gate or the discussion gate.project workitem get returns a team-defined
fields[] array; the description identifier varies by project. The
issue-context extractor must locate the body heuristically (label match
like 描述/description) — validate across a few ODPSSQL* workitem types.glab): Aone Code is GitLab-based, so glab
might half-work — but workitem linkage, AGit-Flow refs, AI-comment gates, and
the /codereview/ URL form are Aone-specific, and glab isn't installed or
authed on the target machines while a1 is. The interface admits glab later;
starting there serves no current user.