Files
qwen-code/packages/web-shell/client/components/SessionPrStateIcon.module.css
T
Shaojin Wen 409ae7b053 feat(web-shell): derive session issue bindings from the closing references of bound PRs (#10425)
* feat(web-shell): derive session issue bindings from the closing references of bound PRs

Sessions could be found by the PR they produced, but not by the issue they were working on — the maintainer flow starts from an issue and ends in a PR. GitHub already knows that link: the PR body's "Fixes #N" is its closing reference. Snapshot those references, with the issue state, onto each bound PR entry in the existing sidecar instead of adding a client-side binding, a second sidecar, or a prompt/branch heuristic that would misbind across the shared PR/issue number space.

The refresh sweep gains a second, by-number GraphQL lookup: every non-merged binding (closing references change while a PR is open) and every merged binding that predates the snapshot (one catch-up) is queried in batches, and the result is written in place together with the PR state — order and createdAt untouched, url-mismatched (foreign repository) entries never touched, unchanged sidecars never rewritten. The slim PR list query stays as it was: nesting closing references under it measurably slows it and still carries no issue state, while a by-number lookup also reaches PRs outside the 500-entry window. gh exits non-zero over a NOT_FOUND alias yet still prints the other aliases, so the wrapper parses that payload rather than failing the batch.

Every projection from the sidecar to the wire now goes through one shared helper, the bridge keeps the daemon-derived snapshot across a client re-bind, and the SDK guard validates issue entries with the same url rules as PRs. The Web Shell tooltip lists the issues under the PR rows (deduped across stacked PRs) with GitHub-style state icons, and sidebar search matches an issue number with or without the hash.

* fix(serve): converge foreign merged bindings and gate every snapshot carry on the PR's url

Review round 1 on the issue-snapshot sweep. A merged binding the repository cannot resolve — another repository's same-numbered PR — could never receive a snapshot, so it re-entered the by-number lookup on every sweep and broke the "all merged with snapshots costs no call" invariant; the sweep now writes it a converging empty snapshot once the lookup has succeeded, and only ever stores issues the lookup actually resolved for that url. The session-list merge of persisted and live bindings compared numbers alone, the one carry site that did not check the canonical url, so a cross-repository re-bind could briefly wear the previous repository's issues; it now applies the same url gate as the sidecar and bridge. The closing-references fetch bound and the sidecar's per-PR cap were two literals tied by a comment (raising one would void every sidecar on read); they are one constant now, declared in the utils layer the sidecar service imports. The GraphQL wrapper passes its own timeout to the error formatter, and the write-side binding types on the bridge and the SDK omit issues so a client-bound issue list is a compile error, matching the runtime drop.

Tests pin each of these plus the gaps the review found: the killed-timeout message, the list-query failure still snapshotting issues, client-supplied issues dropped by the bridge, the cross-repository re-bind dropping the snapshot, a closed issue with no state reason mapping to completed, the open issue-state rendering, the full `-f query=` argument, and the SDK guard's number and non-object checks.

* fix(serve): retire unresolvable bindings from the issue sweep and reject non-safe PR numbers

Review round 2 on the issue-snapshot sweep. A merged binding no lookup can ever snapshot now converges in every case where that is knowable — the lookup succeeded without resolving it, the lookup is structurally impossible (no gh binary, no git root), or the platform has no closing references at all (Aone) — while a transient failure still retries, since it says nothing about the PR's references. A non-merged binding to another repository stays out of the lookup altogether: the list query names this workspace's repository, and a binding outside that canonical prefix can never resolve here, the GitHub twin of the Aone refreshability filter. A workspace with only unresolvable bindings therefore costs nothing after one sweep, and a mixed workspace still refreshes its open bindings' state.

The GraphQL wrapper accepts safe integers only: a positive integer-valued double at or beyond 1e21 stringifies in exponential notation, an invalid Int literal that failed the whole document instead of one alias — one such binding would have disabled issue refresh for the entire workspace. The session-list merge finds the persisted entry by url rather than a last-wins number map, so a hand-edited sidecar with two same-numbered entries cannot shadow the live binding's own snapshot.

Tests cover each path (lookup resolving nothing, foreign open binding never queried, gh unavailable converging, transient failure retrying, mixed workspace running the list query, generation closing between the two queries, merged-only Aone workspace going quiet, the repository scope in the query, and the safe-integer bound), and the E2E plan's bridge filter now selects the client-supplied-issues test.

* fix(serve): reject partial GraphQL errors and match issue lookups by PR identity

Review round 3. The response parser read only the repository data and dropped the top-level GraphQL errors, so a server error nulling one alias, or a sub-field error nulling a resolved PR's closing references, came back as a successful lookup — and the sweep then retired a merged binding on that "absence" with a permanent empty snapshot. Only an alias-level NOT_FOUND now counts as absence; every other partial error fails the call, which keeps retrying. A repository gh cannot resolve at all (no git remotes, or none on a GitHub host) is a structural failure, not a transient one, and converges like a missing gh binary; execFile's error carries no stderr of its own, so the wrapper attaches it before classifying.

Lookup results are matched to bindings by host, owner, repository, and number rather than by canonical url, so a binding spelled with `www.`, `http:`, or a `/files` suffix receives the issues fetched for it — written under the binding's own url, which the sidecar's canonical write gate requires — instead of being mistaken for a foreign repository and retired empty. The SDK guard caps issue lists at the sidecar's ten, matching the design.

Tests: partial-error rejection with the NOT_FOUND tolerance kept, the repository-unresolved kind, the variant-url binding keeping its fetched issues, convergence under not_a_repo and repo_unresolved, a foreign merged binding converging during a lookup outage, the Aone workspace never reaching the GitHub lookup and never re-resolving its origin once quiet, the canonical-equal url spelling inheriting the sidecar snapshot in the session list, the sanitized env dropping GH_REPO, the exact safe-integer literal, and a state-less issue accepted by the SDK guard.

* fix(core): treat a repository GitHub no longer serves as structurally unresolved

Review round 4. A renamed, deleted, or access-revoked repository answers the lookup with a NOT_FOUND on the repository itself rather than on an alias; that was thrown as a partial error and reported as a transient failure, so merged bindings in such a workspace never converged and the sweep kept paying the failing lookup. It now classifies as the same structural kind as a missing GitHub remote, which the sweep already retires bindings on.

Test strengthening the round asked for: one fixture per accepted gh diagnostic, the repository-level NOT_FOUND payload, the SDK guard accepting exactly ten issues and rejecting a control character in an issue url, and the tooltip fixture reworked so binding order contradicts number order, a filtered PR's issues disappear with it, a same-numbered issue from another repository stays distinct, the merged PR contributes an observable issue, non-openable issues leave no text behind, and the state-less icon check reads the class token list instead of jsdom's SVGAnimatedString.

* fix(serve): keep GH_HOST mismatches transient and match every GitHub PR url spelling

Review round 5. gh's "error parsing owner value" prefix also fronts its GH_HOST-mismatch diagnostic — an environment problem, not a missing repository — so the structural classification is narrowed to the two wordings that really mean no usable GitHub remote, and the mismatch stays a transient failure that never retires bindings. PR identity now also recognizes the `.diff` and `.patch` spellings GitHub serves, so such a binding receives its fetched issues instead of a permanent empty snapshot. And the write under a binding's own url no longer waits for a successful issue lookup: during an outage the list query's state still lands on a non-canonically spelled binding instead of being dropped by the sidecar's canonical gate.

Tests: the GH_HOST diagnostic stays `failed`, the three structural wordings converge, `.diff` and `.patch` bindings keep their fetched issues alongside the `/files` spelling, and a `www.`/`/files`-spelled open binding turns merged while the lookup is failing.
2026-09-01 01:35:04 +00:00

28 lines
864 B
CSS

/* GitHub-style PR state: open green, merged purple, closed red. The class is
doubled because consumers like `.sessionDetailsRow svg` set a default muted
icon color at specificity (0,1,1); these rules must outrank it. */
.sessionPrStateOpen.sessionPrStateOpen {
color: var(--success-color, #22c55e);
}
.sessionPrStateMerged.sessionPrStateMerged {
color: var(--color-accent-fg, #8b5cf6);
}
.sessionPrStateClosed.sessionPrStateClosed {
color: var(--destructive, #ef4444);
}
/* GitHub-style issue state: open green, completed purple, not planned muted. */
.sessionIssueStateOpen.sessionIssueStateOpen {
color: var(--success-color, #22c55e);
}
.sessionIssueStateCompleted.sessionIssueStateCompleted {
color: var(--color-accent-fg, #8b5cf6);
}
.sessionIssueStateNotPlanned.sessionIssueStateNotPlanned {
color: var(--muted-foreground, #6b7280);
}