diff --git a/.agents/skills/quality/SKILL.md b/.agents/skills/quality/SKILL.md index 7f0b125c2..32792b7cb 100644 --- a/.agents/skills/quality/SKILL.md +++ b/.agents/skills/quality/SKILL.md @@ -4,8 +4,10 @@ description: >- Make the code correct, clean, and current. A thermo dual-review: a correctness/security track and a maintainability/code-judo track run in parallel, then a synthesis step dedupes, severity-ranks (Blocker/High/Medium/ - Low), verifies each finding against the real code, auto-applies the safe - behavior-preserving fixes, re-reviews, and gates the rest. Grounded in ADE's + Low), verifies each finding against the real code, and FIXES EVERY VERIFIED + FINDING at any severity — re-reviewing until clean. Only findings needing a + product decision, or a behavior change this branch was not asked to make, + reach the merge-blocking gate. Grounded in ADE's own bug classes (runtime-backed null services, daemon action-domain wiring, cr-sqlite CRR, IPC contract drift, fast-tier loading). --- @@ -107,15 +109,37 @@ are handled by the synthesis step below, not a separate phase. 5. **Sweep the bug class.** When an accepted finding is a repeated pattern, scan the diff scope for sibling instances and fix them together — stop at touched surfaces and owner boundaries; no refactor beyond the class. -6. **Apply** the fixes that are **unambiguous and behavior-preserving** — safe - correctness fixes and Track B judo moves. Every applied change must be - verifiable by reading the diff; do not change behavior. -7. **Re-review until clean.** If step 6 changed code, re-run Track A on the *new* - diff. New accepted findings → verify (4), apply (6), re-check. Stop when a - pass yields no new accepted findings (cap 2 extra passes; anything still open - goes to the gate). Catches fix-induced regressions before `/test` or `/ship`. -8. **Gate** — do NOT auto-apply Blockers or judgment-call findings. Surface them - in the report for the author and for `/ship` to gate the merge on. +6. **Apply every finding you accepted in step 4 — all of them, whatever the + severity.** Verified means valid; valid means fix it. Medium and Low are not a + backlog, and "behavior-preserving" describes *how* you apply a fix, not which + findings earn one. This is the entire point of the skill: a run that surfaces + real problems and leaves them in the code has cost the user tokens and + returned nothing. + + Fix correctness findings and Track B judo moves alike. If a fix is genuinely + large (a multi-file extraction, a schema migration), it is still yours to do — + do it here, in this run, not "as a follow-up". +7. **Re-review until clean.** If step 6 changed code, re-run **both mandatory + tracks, A and B,** on the *new* diff. New accepted findings → verify (4), + apply (6), re-check with both tracks again. Stop only when the same pass + yields no new accepted findings from either track. A re-review count is never + a reason to defer a verified finding or move it to the gate. This catches + fix-induced correctness regressions and maintainability debt before `/test` + or `/ship`. +8. **Gate — the narrow exception, not the escape hatch.** Only two kinds of + accepted finding may go to the gate unfixed: + - it needs a **product decision you cannot make** (which of two valid + behaviors the user wants), or + - the fix is **not behavior-preserving** and changing behavior is not what + this branch was asked to do. + + "Structural", "large", "risky", "pre-existing", "out of scope for this PR", + and "worth doing deliberately" are **not** gate reasons — those are fixes you + owe. If you gate a finding, the report must say which of the two reasons + applies and what decision you need. Anything in the Gate table blocks the + merge until the author resolves it; `/ship` treats a non-empty gate as a stop. + + A finding you neither fixed nor gated is a bug in your run. 9. **Reconcile (optional)** — if a PR exists, *after* the independent audit, read the PR discussion and review-bot comments (`gh pr view --comments`, or the `ade-pr-workflows` skill). ADE's review bots are `@copilot` (first push) and @@ -126,8 +150,9 @@ are handled by the synthesis step below, not a separate phase. ## Completion -Output a summary. The **Gate** section is what `/test` and `/ship` consume — list -every Blocker and High finding that was NOT auto-fixed. +Output a summary. The **Gate** section is what `/test` and `/ship` consume, and a +non-empty gate blocks the merge. List only findings you could not fix for one of +the two permitted reasons — not findings you chose to defer. ```markdown ## Quality Summary @@ -137,11 +162,24 @@ every Blocker and High finding that was NOT auto-fixed. - Auto-applied: [count] (safe correctness fixes + structural judo moves) - Re-review passes: [n] -### Gate (not auto-fixed — for /test regression targets and /ship merge gate) -| Severity | file:line | Finding | Why not auto-fixed | -|---|---|---|---| -| Blocker | ... | ... | needs human judgment / product intent | -| High | ... | ... | ... | +### Gate (MERGE-BLOCKING — every row needs an author decision) +Only two reasons belong here: a product decision you cannot make, or a fix that +is not behavior-preserving on a branch that was not asked to change behavior. +Empty is the expected outcome. "Structural / large / out of scope" is not a +gate reason — those get fixed above. -Next: /test (turn each Blocker/High above into a named regression test). +When empty, print exactly: + +- Empty. + +Do not print a table. When non-empty, replace `- Empty.` with a table containing +only real findings and these columns: Severity, file:line, Finding, Which gate +reason, Decision needed. Never leave an example or placeholder row that another +skill could mistake for a live gate. + +Next: /test (itemize every accepted correctness finding and give each a named +regression test or explicit alternate verification). + +**Before you print this:** every accepted finding is either in "Auto-applied" or +the Gate section. If one is in neither, go back to step 6 and fix it. ``` diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 14d11fb49..05b2be90e 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -4,8 +4,11 @@ description: >- Autonomous PR-to-merge loop. Polls CI and review bots, fixes failures, rebases only on real conflicts, and lands the PR on main. Soft cap of 5 normal iterations plus one force-finalize iteration that bypasses review and fixes - only CI. Pure loop — it does NOT run /quality or /test; run those first. Full - phase logic lives in docs/playbooks/ship-lane.md. + only CI. Pure loop — it does not replace the baseline /quality or /test runs; + run those first. It does revalidate quality after any ship-loop mutation so + the final result is bound to the exact reviewed PR head and content tree. + Full phase logic lives + in docs/playbooks/ship-lane.md. --- # Ship Skill — Autonomous Merge Loop @@ -34,9 +37,46 @@ runtime-neutral entrypoint and the ADE-specific deltas below. If re-invoked by a scheduled wake, read the state file first; if `status == running`, skip Phase 0 and go to Phase 1. -The playbook's Phase 0 is **commit → push → open PR** only. Test generation and -the local-CI gate are NOT part of ship — that's `/test` (and optionally -`/finalize`) before you reach this skill. +The playbook's Phase 0 is **checkpoint → commit-bound quality revalidation → +push → open PR**. Baseline test generation and the local-CI gate are NOT part +of ship — that's `/test` (and optionally `/finalize`) before you reach this +skill. + +## Precondition: `/quality` must be empty and bound to the final tree + +Before Phase 0, require a completed `/quality` result with an empty gate. Before +Phase 3c, run the playbook's single canonical **Validate the current quality +binding** procedure. It binds the reviewed head, content tree, and base so +GitHub's squash/merge/rebase result has the reviewed tree. Green CI on a later +head or base does not preserve this binding. + +A non-empty gate **blocks the merge** — every row in it is a finding that was +verified as real and left unfixed, and by `/quality`'s contract the only two +things that may be there are a product decision the author owes, or a behavior +change this branch was not asked to make. Both need the author. + +- Gate rows exist → do not merge. Surface them, state the decision needed, and + stop with `blocked`. Do not merge and mention them afterwards. +- If `/quality` was never run on this lane, or its final gate result is not + available in the lane handoff, stop with `blocked`; unknown is not empty. +- Any base movement, rebase, conflict resolution, Phase 3b edit, or + force-finalize edit clears all three quality binding fields. Run the + playbook's single canonical **Commit-bound + quality revalidation** procedure before pushing that mutation. +- Never enter Phase 3c with a missing or mismatched binding. Revalidate first; + do not merge and disclose stale quality evidence afterwards. +- Bind every normal or admin merge attempt with + `--match-head-commit "$QUALITY_VALIDATED_SHA"`. Persistent auto-merge is not + allowed because a later push can replace the validated head while it remains + armed. +- GitHub creates a new commit for squash/merge/rebase. The validation claim is + deliberately about its exact content tree, not its not-yet-created commit + OID. After merge, run the playbook's canonical **Confirm the validated merge + result** procedure; a mismatch is never `done-clean`. + +Severity is irrelevant here: a Medium in the gate blocks exactly as hard as a +Blocker, because presence in the gate means it needed a human, not that it was +minor. --- @@ -95,11 +135,11 @@ terminal-neutral, and continue. Record it under `inactiveReviewBots`, never If branch protection requires an absent check, Phase 3c will surface that as a merge-policy block. -**Rebase only on real conflicts.** `behindMain` alone does NOT trigger a rebase. -Only rebase/merge `main` when there is an actual conflict (`mergeStateStatus` -shows the PR is dirty/conflicting). If the branch is merely behind but cleanly -mergeable, skip the rebase and let the merge handle it — needless rebases burn -iterations and CI. +**Rebase only on real conflicts or a stale quality base.** `behindMain` alone +does not normally trigger a rebase. The one safety exception is base movement +after quality validation: the final tree is no longer the reviewed head tree, +so rebase and rerun the canonical quality procedure even when GitHub reports a +clean merge. Otherwise, skip needless rebases. **Bot pings by iteration.** Never ping GitHub Copilot and never treat Copilot as an expected review signal; quota exhaustion otherwise leaves the loop waiting @@ -109,11 +149,15 @@ fix-iteration re-pushes → `@codex review`. For a >250-file diff, also ping expected review signals to settle before fixing. This is the playbook's Phase 4 rule — defer to it for exact bodies. -**Merge needs admin.** `main` is ruleset-guarded — `gh pr merge --squash` will -show BLOCKED. Retry with `gh pr merge --admin --squash`; the ruleset's -non-linear-history rule can still reject `--admin`, in which case fall back to a -local merge + admin-bypass push (per AGENTS.md). Do NOT pass `--delete-branch` -(it fails from a worktree); delete the head ref server-side via +**Merge needs admin.** `main` is ruleset-guarded — +`gh pr merge --squash --match-head-commit "$QUALITY_VALIDATED_SHA"` will show +BLOCKED. Retry with +`gh pr merge --admin --squash --match-head-commit "$QUALITY_VALIDATED_SHA"`; +the ruleset's non-linear-history rule can still reject `--admin`. Do not fall +back to a locally-created commit: it would not be the reviewed and CI-tested PR +merge result. Exit blocked if both direct `gh` paths fail. After a successful +merge, run **Confirm the validated merge result**. Do NOT +pass `--delete-branch` (it fails from a worktree); delete the head ref server-side via `gh api -X DELETE "repos/{owner}/{repo}/git/refs/heads/"`. **Fix discipline (every fix agent must follow):** (1) Fix CI and review together @@ -160,14 +204,16 @@ self-resume signal. Either: ## The loop (summary — full detail in the playbook) - **Phase 0 (first run):** safety rails (clean tree, GitHub origin, refuse - `main`) → commit → push → open PR (`ade`, gh fallback) → write state → + `main`) → checkpoint → canonical commit-bound quality revalidation → push → + open PR (`ade`, gh fallback) → verify the provisional binding → write state → schedule first wake. - **Phase 1 — Poll:** wait for CI terminal and every bot that actually started to become terminal. After one 12-minute grace window, classify bots with zero evidence as inactive/terminal-neutral. Return a structured summary (merged / conflicting / ciFailed / newComments). Don't fix on a partial signal. -- **Phase 2 — Decide:** merged → `done-clean`. Real conflict → Phase 3a rebase - (rebate). CI or bots running → reschedule. Both terminal, no work → 3c merge. +- **Phase 2 — Decide:** merged → run **Confirm the validated merge result** and + only then set `done-clean`. Real conflict → Phase 3a rebase (rebate). CI or + bots running → reschedule. Both terminal, no work → 3c merge. Both terminal, work exists, `iter < 5` → 3b fix. `iter >= 5`, not merged → 3d force-finalize. - **Phase 3a Rebase / 3b Fix / 3c Merge / 3d Force-finalize** — per the playbook. @@ -184,7 +230,7 @@ self-resume signal. Either: |--------|---------| | `done-clean` | PR merged on main | | `done-max` | 5 normal + 1 force-finalize exhausted, merge genuinely blocked | -| `blocked` | Unrecoverable conflict, gate failure, API error, or force-finalize CI failed | +| `blocked` | Unrecoverable conflict, gate failure, API error, force-finalize CI failed, or a non-empty `/quality` gate awaiting an author decision | Always print the final summary (PR, branch, iterations, status, reason, per-iteration log, unaddressed items) on exit. Do NOT schedule a wake when diff --git a/.agents/skills/test/SKILL.md b/.agents/skills/test/SKILL.md index 2c8aceb25..1c805b341 100644 --- a/.agents/skills/test/SKILL.md +++ b/.agents/skills/test/SKILL.md @@ -1,6 +1,6 @@ --- name: test -description: 'Prove the new code works: enforce the logging/PostHog ground truth, prune dead tests, consolidate fragments, add only tests that prove new contracts, turn each /quality Blocker/High finding into a named regression test, then run CI-mirrored shards. Also keeps docs/mobile/CLI/TUI parity in lockstep.' +description: 'Prove the new code works: enforce the logging/PostHog ground truth, prune dead tests, consolidate fragments, add only tests that prove new contracts, turn accepted /quality correctness findings into named regression tests, then run CI-mirrored shards. Also keeps docs/mobile/CLI/TUI parity in lockstep.' --- # /test — Test Suite Steward @@ -17,7 +17,27 @@ The suite has bloated for three reasons. You exist to fight all three: Every run does three passes in this order: **PRUNE → CONSOLIDATE → ADD**. You may finish at any pass — adding is optional. -**Consume the `/quality` gate.** If `/quality` ran on this lane, take its Summary's **Gate** section — every Blocker/High it surfaced but did not auto-fix. Each is a named regression-test target for the ADD pass: a test that fails on the bug and passes once it's fixed. A finding isn't "handled" until a test pins it. No gate available → derive the same targets from the diff. +**Consume the `/quality` result.** A non-empty `/quality` gate blocks this skill: +the branch still contains a verified finding awaiting an author decision, and +committing a knowingly failing test is not a substitute for fixing it. Resume +after the decision and the corresponding `/quality` fix. + +Build a correctness inventory from the completed quality summary. Itemize +**every accepted correctness finding** by a stable finding name and original +`file:line`; aggregate counts such as "5 findings covered" are not sufficient. +For each item, provide exactly one of: + +- a named regression test that pins the public contract and would fail on the + pre-fix behavior, or +- an explicit alternate verification: the exact command/check, the observed + evidence, and why a regression test is not appropriate. + +Existing coverage counts only when you name the specific test and confirm that +it exercises the finding's failure mode. Structural maintainability findings do +not require artificial tests when existing coverage already proves the +behavior-preserving move. No quality result available → derive the same +itemized correctness inventory from the diff and state that quality evidence +was unavailable. **Run the way CI would.** After the suite work, run only the affected shards, never the full suite (that's `/finalize`'s local gate and `/ship`'s remote CI). Verify every new/edited test file matches a vitest workspace glob so CI actually picks it up. @@ -571,6 +591,11 @@ Added: - - Or "none — feature was visual / fully covered by consolidation" +Quality correctness findings: +- (`file:line`) — regression: ` :: ` +- (`file:line`) — alternate verification: `` → ``; no regression test because +- Or "none — /quality accepted no correctness findings" + Parity: - Logging/PostHog: — privacy + cost gate PASS / blocked - Docs: — validation PASS / blocked @@ -604,3 +629,5 @@ Mark **completed** only if all of: 5. Every new test file matches a vitest workspace glob. 6. The summary is the *only* thing you output. 7. `docs/logging.md` exists, was read, and every analytics-applicable change is covered or has an explicit not-applicable rationale. +8. Every accepted correctness finding from `/quality` appears individually in + the summary with a named regression test or explicit alternate verification. diff --git a/AGENTS.md b/AGENTS.md index 75eee514d..efa0e0507 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,15 +17,15 @@ Day-to-day work follows a five-stage loop, each stage an agent-folder skill unde `/context` → work → `/quality` → `/test` → `/ship` - **/context** — session primer: detects the lane's area and loads only the matching docs + perf skill (never a broad dump). -- **/quality** — dual-track review (correctness/security + maintainability/code-judo); auto-fixes the safe findings, gates Blockers. The loop's bug-finding and cleanup engine. -- **/test** — test steward: prune/consolidate/add + docs/mobile/CLI/TUI parity + CI-mirrored shards; turns each `/quality` gate finding into a named regression test. -- **/ship** — pure autonomous PR→merge loop (poll → fix → rebase → merge). Does NOT run quality/test — run those first. Wraps `docs/playbooks/ship-lane.md`. +- **/quality** — dual-track review (correctness/security + maintainability/code-judo); fixes every verified finding at every severity. It gates only a product decision the agent cannot make or a behavior change the branch was not authorized to make. +- **/test** — test steward: prune/consolidate/add + docs/mobile/CLI/TUI parity + CI-mirrored shards; records a named regression test or exact alternate verification for every accepted correctness finding. +- **/ship** — autonomous PR→merge loop (poll → fix → rebase → merge). Run baseline `/quality` and `/test` first; after any ship-loop mutation, ship reruns commit-bound `/quality` revalidation before pushing or merging. Wraps `docs/playbooks/ship-lane.md`. Utilities (run when relevant, not part of the core loop): **/audit** (targeted bug hunt), **/finalize** (optional pre-push local-CI gate), **/optimize** (perf profiling), **/release** (cut a release). ## Playbooks -- `docs/playbooks/ship-lane.md` — autonomous PR-to-merge driver (poll → fix → rebase → merge; `/quality` and `/test` run *before* it, not inside it). Any agent CLI can follow it directly; Claude Code invokes it via the `/ship` skill. +- `docs/playbooks/ship-lane.md` — autonomous PR-to-merge driver (poll → fix → rebase → merge). Baseline `/quality` and `/test` run before it; mutation-specific commit-bound quality revalidation runs inside it. Any agent CLI can follow it directly; Claude Code invokes it via the `/ship` skill. ## Working norms diff --git a/apps/desktop/src/main/services/prs/prRowMetadata.test.ts b/apps/desktop/src/main/services/prs/prRowMetadata.test.ts new file mode 100644 index 000000000..3038be3a5 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prRowMetadata.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + deriveGithubSnapshotLaneLink, + deriveGithubSnapshotMergeFacts, + normalizeCount, + type PullRequestRowMetadata, +} from "./prRowMetadata"; + +const LANE_ID = "lane-42"; + +function makePrRowMetadata( + overrides: Partial = {}, +): PullRequestRowMetadata { + return { + lane_id: LANE_ID, + merged_at: null, + additions: null, + deletions: null, + ...overrides, + }; +} + +describe("PR row metadata mappers", () => { + it("accepts only non-negative integer count values", () => { + expect(normalizeCount(0)).toBe(0); + expect(normalizeCount(12)).toBe(12); + expect(normalizeCount("")).toBeNull(); + expect(normalizeCount(false)).toBeNull(); + expect(normalizeCount(1.5)).toBeNull(); + }); + + it("separates a live lane mapping from frozen detached-lane provenance", () => { + const laneById = new Map([[LANE_ID, { name: "my-feature" }]]); + + expect(deriveGithubSnapshotLaneLink(makePrRowMetadata(), laneById)).toEqual({ + linkedLaneId: LANE_ID, + linkedLaneName: "my-feature", + detached: null, + }); + + expect(deriveGithubSnapshotLaneLink(makePrRowMetadata({ + detached_at: "2026-07-30T00:00:00Z", + detached_lane_name: "retired-lane", + detached_lane_color: "#4ADE80", + detached_provenance: JSON.stringify({ chats: 3, artifacts: 2, checkpoints: 5 }), + }), laneById)).toEqual({ + linkedLaneId: null, + linkedLaneName: null, + detached: { + at: "2026-07-30T00:00:00Z", + laneName: "retired-lane", + laneColor: "#4ADE80", + chats: 3, + artifacts: 2, + checkpoints: 5, + }, + }); + }); + + it("normalizes persisted merge facts for a GitHub list row", () => { + expect(deriveGithubSnapshotMergeFacts(makePrRowMetadata({ + merged_at: "2026-07-29T00:00:00Z", + merged_by_login: " octocat ", + merged_by_avatar_url: "https://example.com/octocat.png", + merge_method: "squash", + additions: 12, + deletions: 4, + commit_count: 3, + changed_files: 2, + }))).toEqual({ + mergedAt: "2026-07-29T00:00:00Z", + mergedBy: { login: "octocat", avatarUrl: "https://example.com/octocat.png" }, + mergeMethod: "squash", + additions: 12, + deletions: 4, + commitCount: 3, + changedFiles: 2, + }); + + expect(deriveGithubSnapshotMergeFacts(makePrRowMetadata({ + merge_method: "unsupported", + additions: -1, + commit_count: Number.NaN, + }))).toMatchObject({ + mergeMethod: null, + additions: null, + commitCount: null, + }); + }); +}); diff --git a/apps/desktop/src/main/services/prs/prRowMetadata.ts b/apps/desktop/src/main/services/prs/prRowMetadata.ts new file mode 100644 index 000000000..7c0a5e75a --- /dev/null +++ b/apps/desktop/src/main/services/prs/prRowMetadata.ts @@ -0,0 +1,102 @@ +import type { + GitHubPrListItem, + MergeMethod, + PrDetachedLane, + PrMergedBy, +} from "../../../shared/types"; + +/** Persisted PR-row fields used to build list and summary metadata. */ +export type PullRequestRowMetadata = { + lane_id: string; + detached_at?: string | null; + detached_lane_name?: string | null; + detached_lane_color?: string | null; + detached_provenance?: string | null; + merged_at?: string | null; + merged_by_login?: string | null; + merged_by_avatar_url?: string | null; + merge_method?: string | null; + additions?: number | null; + deletions?: number | null; + commit_count?: number | null; + changed_files?: number | null; +}; + +/** Normalize a persisted non-negative count, returning null for absent or invalid data. */ +export function normalizeCount(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : null; +} + +/** Narrow persisted merge-method data to the supported public union. */ +export function normalizeMergeMethod(value: unknown): MergeMethod | null { + return value === "squash" || value === "merge" || value === "rebase" ? value : null; +} + +/** Rehydrate the GitHub actor that merged a persisted PR row. */ +export function rowMergedBy(row: PullRequestRowMetadata): PrMergedBy | null { + const login = String(row.merged_by_login ?? "").trim(); + if (!login) return null; + return { login, avatarUrl: row.merged_by_avatar_url ?? null }; +} + +/** + * Rehydrate the lane provenance frozen at detach time. Returns null for live rows. + * Malformed provenance still yields a usable lane record with zeroed counts. + */ +export function rowDetachedLane(row: PullRequestRowMetadata): PrDetachedLane | null { + const at = String(row.detached_at ?? "").trim(); + if (!at) return null; + let counts: { chats?: unknown; artifacts?: unknown; checkpoints?: unknown } = {}; + try { + const parsed = JSON.parse(String(row.detached_provenance ?? "{}")); + if (parsed && typeof parsed === "object") counts = parsed as typeof counts; + } catch { + /* a corrupt blob must not hide the lane name */ + } + return { + at, + laneName: row.detached_lane_name ?? null, + laneColor: row.detached_lane_color ?? null, + chats: normalizeCount(counts.chats) ?? 0, + artifacts: normalizeCount(counts.artifacts) ?? 0, + checkpoints: normalizeCount(counts.checkpoints) ?? 0, + }; +} + +/** + * Resolve the lane columns of a GitHub list row. Detached rows carry frozen + * provenance instead of exposing a live lane mapping. + */ +export function deriveGithubSnapshotLaneLink( + linked: PullRequestRowMetadata | null, + laneById: ReadonlyMap | undefined, +): Pick { + if (!linked) return { linkedLaneId: null, linkedLaneName: null, detached: null }; + const detached = rowDetachedLane(linked); + if (detached) return { linkedLaneId: null, linkedLaneName: null, detached }; + return { + linkedLaneId: linked.lane_id, + linkedLaneName: laneById?.get(linked.lane_id)?.name ?? null, + detached: null, + }; +} + +/** Build the merge outcome and size fields shown by a GitHub list row. */ +export function deriveGithubSnapshotMergeFacts( + linked: PullRequestRowMetadata | null, +): Pick< + GitHubPrListItem, + "mergedAt" | "mergedBy" | "mergeMethod" | "additions" | "deletions" | "commitCount" | "changedFiles" +> { + return { + mergedAt: linked?.merged_at ?? null, + mergedBy: linked ? rowMergedBy(linked) : null, + mergeMethod: normalizeMergeMethod(linked?.merge_method), + additions: normalizeCount(linked?.additions), + deletions: normalizeCount(linked?.deletions), + commitCount: normalizeCount(linked?.commit_count), + changedFiles: normalizeCount(linked?.changed_files), + }; +} diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 07d9a6726..7c2ace028 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -56,8 +56,6 @@ import type { PrCommit, PrConflictAnalysis, PrCreationStrategy, - PrDetachedLane, - PrMergedBy, PrEventPayload, PrGroupMemberRole, PrHealth, @@ -161,6 +159,14 @@ import { spawn } from "node:child_process"; import { runGit, runGitMergeTree, runGitOrThrow } from "../git/git"; import { shouldAttemptAdminMergeForRestError } from "./resolverUtils"; import { deletePullRequestRowsByIds } from "./pullRequestRowCleanup"; +import { + deriveGithubSnapshotLaneLink, + deriveGithubSnapshotMergeFacts, + normalizeCount, + normalizeMergeMethod, + rowDetachedLane, + rowMergedBy, +} from "./prRowMetadata"; import { createGithubStackStore } from "./githubStackStore"; import { extractFirstJsonObject } from "../ai/utils"; import { buildIntegrationPreflight } from "./integrationPlanning"; @@ -1068,47 +1074,6 @@ function rowToSummary(row: PullRequestRow): PrSummary { }; } -function normalizeCount(value: unknown): number | null { - if (value == null) return null; - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; -} - -function normalizeMergeMethod(value: unknown): MergeMethod | null { - return value === "squash" || value === "merge" || value === "rebase" ? value : null; -} - -function rowMergedBy(row: PullRequestRow): PrMergedBy | null { - const login = String(row.merged_by_login ?? "").trim(); - if (!login) return null; - return { login, avatarUrl: row.merged_by_avatar_url ?? null }; -} - -/** - * Rehydrate the lane provenance frozen at detach time. Returns null for live rows. - * A malformed or missing provenance blob still yields a usable record — the lane name - * is the part the UI leads with, and zeroed counts are simply not rendered. - */ -function rowDetachedLane(row: PullRequestRow): PrDetachedLane | null { - const at = String(row.detached_at ?? "").trim(); - if (!at) return null; - let counts: { chats?: unknown; artifacts?: unknown; checkpoints?: unknown } = {}; - try { - const parsed = JSON.parse(String(row.detached_provenance ?? "{}")); - if (parsed && typeof parsed === "object") counts = parsed as typeof counts; - } catch { - /* a corrupt blob must not hide the lane name */ - } - return { - at, - laneName: row.detached_lane_name ?? null, - laneColor: row.detached_lane_color ?? null, - chats: normalizeCount(counts.chats) ?? 0, - artifacts: normalizeCount(counts.artifacts) ?? 0, - checkpoints: normalizeCount(counts.checkpoints) ?? 0, - }; -} - const BACKGROUND_REFRESH_MAX_PRS = 4; const REFRESH_CONCURRENCY = 4; const TARGETED_LANE_PR_BRANCH_LOOKUP_CONCURRENCY = 4; @@ -8625,54 +8590,6 @@ export function createPrService({ }; }; - /** - * Resolve the lane columns of a list row. - * - * A detached row reports NO live lane (`linkedLaneId`/`linkedLaneName` stay null) and - * carries `detached` instead, so the renderer shows it as history rather than as a - * mapping it could act on. Previously the lane name fell back to the raw lane UUID - * when the lane row was missing, which surfaced a bare id in the list. - */ - const deriveGithubSnapshotLaneLink = ( - linked: PullRequestRow | null, - laneById: Map | undefined, - ): Pick => { - if (!linked) return { linkedLaneId: null, linkedLaneName: null, detached: null }; - const detached = rowDetachedLane(linked); - if (detached) return { linkedLaneId: null, linkedLaneName: null, detached }; - const laneName = laneById?.get(linked.lane_id)?.name ?? null; - return { linkedLaneId: linked.lane_id, linkedLaneName: laneName, detached: null }; - }; - - /** Merge outcome + size, so a merged row can describe itself without a GitHub call. */ - const deriveGithubSnapshotMergeFacts = ( - linked: PullRequestRow | null, - ): Pick< - GitHubPrListItem, - "mergedAt" | "mergedBy" | "mergeMethod" | "additions" | "deletions" | "commitCount" | "changedFiles" - > => { - if (!linked) { - return { - mergedAt: null, - mergedBy: null, - mergeMethod: null, - additions: null, - deletions: null, - commitCount: null, - changedFiles: null, - }; - } - return { - mergedAt: linked.merged_at ?? null, - mergedBy: rowMergedBy(linked), - mergeMethod: normalizeMergeMethod(linked.merge_method), - additions: normalizeCount(linked.additions), - deletions: normalizeCount(linked.deletions), - commitCount: normalizeCount(linked.commit_count), - changedFiles: normalizeCount(linked.changed_files), - }; - }; - /** * Persist how a PR shipped. Written once at merge time and again by the poller when * it observes the merge; `coalesce` keeps whichever arrived first rather than letting diff --git a/apps/desktop/src/main/services/prs/pullRequestRowCleanup.test.ts b/apps/desktop/src/main/services/prs/pullRequestRowCleanup.test.ts index fa3e519c9..b726bbc28 100644 --- a/apps/desktop/src/main/services/prs/pullRequestRowCleanup.test.ts +++ b/apps/desktop/src/main/services/prs/pullRequestRowCleanup.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { openKvDb } from "../state/kvDb"; import { countLaneProvenance, + deletePullRequestRowsByIds, detachPullRequestRowsByIds, detachPullRequestRowsForLane, } from "./pullRequestRowCleanup"; @@ -14,14 +15,21 @@ function createLogger() { } const PROJECT_ID = "proj-1"; +const OTHER_PROJECT_ID = "proj-2"; const LANE_ID = "lane-1"; const DETACHED_AT = "2026-07-31T12:00:00.000Z"; +type PrOverrides = { + projectId?: string; + laneId?: string; + state?: string; +}; + describe("pullRequestRowCleanup detach", () => { let dir: string; let db: Awaited>; - const insertPr = (id: string, overrides: Record = {}) => { + const insertPr = (id: string, overrides: PrOverrides = {}) => { db.run( `insert into pull_requests (id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, @@ -30,9 +38,9 @@ describe("pullRequestRowCleanup detach", () => { ?, 'main', 'ade/feature', 412, 88, ?, ?)`, [ id, - PROJECT_ID, - (overrides.lane_id as string) ?? LANE_ID, - (overrides.state as string) ?? "merged", + overrides.projectId ?? PROJECT_ID, + overrides.laneId ?? LANE_ID, + overrides.state ?? "merged", DETACHED_AT, DETACHED_AT, ], @@ -175,6 +183,80 @@ describe("pullRequestRowCleanup detach", () => { ).toBeNull(); }); + it("does not purge snapshots or group membership for ids owned by another project", () => { + insertPr("pr-local"); + insertPr("pr-foreign", { projectId: OTHER_PROJECT_ID, laneId: "lane-foreign" }); + insertSnapshot("pr-local"); + insertSnapshot("pr-foreign"); + db.run( + "insert into pr_groups(id, project_id, group_type, created_at) values (?, ?, 'integration', ?)", + ["group-local", PROJECT_ID, DETACHED_AT], + ); + db.run( + "insert into pr_groups(id, project_id, group_type, created_at) values (?, ?, 'integration', ?)", + ["group-foreign", OTHER_PROJECT_ID, DETACHED_AT], + ); + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) + values ('member-local', 'group-local', 'pr-local', ?, 0, 'source')`, + [LANE_ID], + ); + db.run( + `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) + values ('member-foreign', 'group-foreign', 'pr-foreign', 'lane-foreign', 0, 'source')`, + ); + + detachPullRequestRowsByIds(db, { + projectId: PROJECT_ID, + laneId: LANE_ID, + laneName: "local-lane", + laneColor: null, + detachedAt: DETACHED_AT, + prIds: ["pr-local", "pr-foreign"], + }); + + expect( + db.get<{ checks_json: string | null }>("select checks_json from pull_request_snapshots where pr_id = ?", [ + "pr-local", + ])?.checks_json, + ).toBeNull(); + expect( + db.get<{ checks_json: string | null }>("select checks_json from pull_request_snapshots where pr_id = ?", [ + "pr-foreign", + ])?.checks_json, + ).not.toBeNull(); + expect(db.get("select id from pr_group_members where id = 'member-local'")).toBeNull(); + expect(db.get("select id from pr_group_members where id = 'member-foreign'")).not.toBeNull(); + expect( + db.get<{ detached_at: string | null }>("select detached_at from pull_requests where id = 'pr-foreign'") + ?.detached_at, + ).toBeNull(); + }); + + it("keeps another project's child rows when hard-deleting a mixed id list", () => { + insertPr("pr-local"); + insertPr("pr-foreign", { projectId: OTHER_PROJECT_ID, laneId: "lane-foreign" }); + insertSnapshot("pr-local"); + insertSnapshot("pr-foreign"); + db.run( + "insert into pull_request_ai_summaries(pr_id, head_sha, summary_json, generated_at) values (?, 'head', '{}', ?)", + ["pr-local", DETACHED_AT], + ); + db.run( + "insert into pull_request_ai_summaries(pr_id, head_sha, summary_json, generated_at) values (?, 'head', '{}', ?)", + ["pr-foreign", DETACHED_AT], + ); + + deletePullRequestRowsByIds(db, PROJECT_ID, ["pr-local", "pr-foreign"]); + + expect(db.get("select id from pull_requests where id = 'pr-local'")).toBeNull(); + expect(db.get("select pr_id from pull_request_snapshots where pr_id = 'pr-local'")).toBeNull(); + expect(db.get("select pr_id from pull_request_ai_summaries where pr_id = 'pr-local'")).toBeNull(); + expect(db.get("select id from pull_requests where id = 'pr-foreign'")).not.toBeNull(); + expect(db.get("select pr_id from pull_request_snapshots where pr_id = 'pr-foreign'")).not.toBeNull(); + expect(db.get("select pr_id from pull_request_ai_summaries where pr_id = 'pr-foreign'")).not.toBeNull(); + }); + it("counts zero provenance for a lane with no recorded activity", () => { expect(countLaneProvenance(db, PROJECT_ID, LANE_ID)).toEqual({ chats: 0, diff --git a/apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts b/apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts index be47478c9..4f3634cce 100644 --- a/apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts +++ b/apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts @@ -4,13 +4,17 @@ type DbLike = { /** * Reads are only needed by the detach path, which must count before rows vanish. - * Signature mirrors `AdeDb.get` so the concrete db satisfies it structurally. + * Signature mirrors `AdeDb.get`/`AdeDb.all` so the concrete db satisfies it structurally. */ type ReadableDbLike = DbLike & { get = Record>( sql: string, - params?: any[], + params?: unknown[], ): T | null; + all = Record>( + sql: string, + params?: unknown[], + ): T[]; }; /** @@ -69,6 +73,19 @@ function uniqueIds(ids: string[]): string[] { return [...new Set(ids.map((id) => id.trim()).filter(Boolean))]; } +function projectPrScope(projectId: string, prIds: string[]): { + params: unknown[]; + selectSql: string; +} | null { + const ids = uniqueIds(prIds); + if (ids.length === 0) return null; + const placeholders = ids.map(() => "?").join(", "); + return { + params: [projectId, ...ids], + selectSql: `select id from pull_requests where project_id = ? and id in (${placeholders})`, + }; +} + function pruneEmptyPrGroups(db: DbLike, projectId: string): void { db.run( ` @@ -88,14 +105,25 @@ function pruneEmptyPrGroups(db: DbLike, projectId: string): void { } export function deletePullRequestRowsByIds(db: DbLike, projectId: string, prIds: string[]): void { - const ids = uniqueIds(prIds); - if (ids.length === 0) return; - const placeholders = ids.map(() => "?").join(", "); + const scope = projectPrScope(projectId, prIds); + if (!scope) return; - db.run(`delete from pr_group_members where pr_id in (${placeholders})`, ids); - db.run(`delete from pull_request_ai_summaries where pr_id in (${placeholders})`, ids); - db.run(`delete from pull_request_snapshots where pr_id in (${placeholders})`, ids); - db.run(`delete from pull_requests where project_id = ? and id in (${placeholders})`, [projectId, ...ids]); + db.run( + `delete from pr_group_members + where pr_id in (${scope.selectSql})`, + scope.params, + ); + db.run( + `delete from pull_request_ai_summaries + where pr_id in (${scope.selectSql})`, + scope.params, + ); + db.run( + `delete from pull_request_snapshots + where pr_id in (${scope.selectSql})`, + scope.params, + ); + db.run(`delete from pull_requests where id in (${scope.selectSql})`, scope.params); pruneEmptyPrGroups(db, projectId); } @@ -113,22 +141,24 @@ export function deletePullRequestRowsByIds(db: DbLike, projectId: string, prIds: * Storage does not grow: the heavy snapshot columns are nulled here, which frees more * than the retained row costs. `commit_count` / `changed_files` are lifted onto the row * first so the merged view survives the purge. + * + * The mutation takes explicit PR ids rather than a SQL predicate, keeping one parameter + * list for all four statements below instead of making them agree positionally. */ function detachRows( db: ReadableDbLike, args: { projectId: string; - /** SQL predicate over `pull_requests`, e.g. `lane_id = ? and project_id = ?`. */ - predicate: string; - predicateParams: unknown[]; + prIds: string[]; laneName: string | null; laneColor: string | null; detachedAt: string; provenance: DetachedLaneProvenance; }, ): void { - const { projectId, predicate, predicateParams, laneName, laneColor, detachedAt, provenance } = args; - const prSelect = `select id from pull_requests where ${predicate}`; + const { projectId, prIds, laneName, laneColor, detachedAt, provenance } = args; + const scope = projectPrScope(projectId, prIds); + if (!scope) return; // Lift counts off the snapshot before nulling it, so the merged row can still say // "12 commits · 9 files" once the JSON is gone. @@ -147,9 +177,9 @@ function detachRows( from pull_request_snapshots s where s.pr_id = pull_requests.id and json_valid(s.files_json)) ) - where ${predicate} + where id in (${scope.selectSql}) `, - predicateParams, + scope.params, ); // `detached_at is null` keeps the first detach authoritative: re-detaching an already @@ -161,9 +191,9 @@ function detachRows( detached_lane_name = ?, detached_lane_color = ?, detached_provenance = ? - where ${predicate} and detached_at is null + where id in (${scope.selectSql}) and detached_at is null `, - [detachedAt, laneName, laneColor, JSON.stringify(provenance), ...predicateParams], + [detachedAt, laneName, laneColor, JSON.stringify(provenance), ...scope.params], ); // Keep detail/status/commits (small, and what the merged view reads); drop the bulky @@ -175,13 +205,17 @@ function detachRows( checks_json = null, comments_json = null, reviews_json = null - where pr_id in (${prSelect}) + where pr_id in (${scope.selectSql}) `, - predicateParams, + scope.params, ); // Group membership is lane-scoped work-in-progress, not history — it goes. - db.run(`delete from pr_group_members where pr_id in (${prSelect})`, predicateParams); + db.run( + `delete from pr_group_members + where pr_id in (${scope.selectSql})`, + scope.params, + ); pruneEmptyPrGroups(db, projectId); } @@ -190,16 +224,25 @@ export function detachPullRequestRowsForLane( args: DetachPullRequestRowsArgs, ): void { const { projectId, laneId, laneName, laneColor, detachedAt } = args; + const prIds = db + .all<{ id: string }>("select id from pull_requests where lane_id = ? and project_id = ?", [laneId, projectId]) + .map((row) => String(row.id)); detachRows(db, { projectId, - predicate: "lane_id = ? and project_id = ?", - predicateParams: [laneId, projectId], + prIds, laneName, laneColor, detachedAt, provenance: countLaneProvenance(db, projectId, laneId), }); - db.run("delete from pr_group_members where lane_id = ?", [laneId]); + // Lane-scoped membership survives the id lookup above (a group member can outlive its + // PR row), so clear it explicitly and prune once. + db.run( + `delete from pr_group_members + where lane_id = ? + and group_id in (select id from pr_groups where project_id = ?)`, + [laneId, projectId], + ); pruneEmptyPrGroups(db, projectId); } @@ -214,17 +257,12 @@ export function detachPullRequestRowsByIds( args: DetachPullRequestRowsArgs & { prIds: string[] }, ): void { const { projectId, laneId, laneName, laneColor, detachedAt } = args; - const ids = uniqueIds(args.prIds); - if (ids.length === 0) return; - const placeholders = ids.map(() => "?").join(", "); detachRows(db, { projectId, - predicate: `project_id = ? and id in (${placeholders})`, - predicateParams: [projectId, ...ids], + prIds: uniqueIds(args.prIds), laneName, laneColor, detachedAt, provenance: countLaneProvenance(db, projectId, laneId), }); } - diff --git a/apps/desktop/src/renderer/components/prs/shared/GitHubTabPrRow.tsx b/apps/desktop/src/renderer/components/prs/shared/GitHubTabPrRow.tsx new file mode 100644 index 000000000..9f83e7194 --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/shared/GitHubTabPrRow.tsx @@ -0,0 +1,550 @@ +import React from "react"; +import { ArrowSquareOut, ChatText, CheckCircle, GitBranch, XCircle } from "@phosphor-icons/react"; + +import type { GitHubPrListItem, PrSummary } from "../../../../shared/types/prs"; +import { COLORS, MONO_FONT, SANS_FONT, inlineBadge } from "../../lanes/laneDesignTokens"; +import { LaneAccentDot } from "../../lanes/LaneAccentDot"; +import { useAppStore } from "../../../state/appStore"; +import { isTerminalPrState } from "../../../lib/prState"; +import { formatTimeAgoCompact } from "./prFormatters"; +import { PrCiRunningIndicator } from "./prVisuals"; +import { GitHubStackBadge } from "./GitHubStackBadge"; +import { formatPrListGroupDiff, type PrListGroupHeader as PrListGroupHeaderModel } from "./prListGrouping"; +import { branchNameFromRef } from "../tabs/githubPrBranch"; + +/** + * Presentation for one row of the GitHub PR list, and the period header that groups + * them. Split out of `GitHubTab.tsx` because none of it depends on that component's + * state — `useLaneColorById` reads the store directly — so the tab is left as a + * coordinator rather than a coordinator plus 450 lines of row markup. + */ + +/* -- Color-coded state badge with distinct colors per state -- */ +function stateColor(state: string): { bg: string; border: string; text: string } { + switch (state) { + case "open": + return { bg: "rgba(59,130,246,0.10)", border: "rgba(59,130,246,0.20)", text: "#60A5FA" }; + case "draft": + return { bg: "rgba(245,158,11,0.10)", border: "rgba(245,158,11,0.20)", text: "#FBBF24" }; + case "merged": + return { bg: "rgba(34,197,94,0.10)", border: "rgba(34,197,94,0.20)", text: "#4ADE80" }; + default: + return { bg: "rgba(161,161,170,0.08)", border: "rgba(161,161,170,0.15)", text: "#A1A1AA" }; + } +} + +function stateBadgeStyle(item: GitHubPrListItem): React.CSSProperties { + const c = stateColor(item.state); + return { + display: "inline-flex", + alignItems: "center", + padding: "2px 7px", + fontSize: 10, + fontWeight: 600, + fontFamily: SANS_FONT, + color: c.text, + background: c.bg, + border: `1px solid ${c.border}`, + borderRadius: 5, + textTransform: "capitalize", + }; +} + +function PrRowCiStatus({ status }: { status: PrSummary["checksStatus"] | null }) { + switch (status) { + case "passing": + return ( + + + + ); + case "failing": + return ( + + + + ); + case "pending": + return ( + + + + ); + default: + return null; + } +} + +/* -- Review status indicator -- */ +function reviewIndicator(linkedPr: PrSummary | null): { color: string; label: string } | null { + if (!linkedPr) return null; + switch (linkedPr.reviewStatus) { + case "approved": + return { color: COLORS.success, label: "Approved" }; + case "changes_requested": + return { color: COLORS.danger, label: "Changes" }; + case "requested": + return { color: COLORS.warning, label: "Review required" }; + default: + return null; + } +} + +/* -- adeKind badge with distinctive styling -- */ +const ADE_KIND_STYLES: Record = { + integration: { + color: "#FBBF24", + background: "linear-gradient(135deg, rgba(245,158,11,0.14) 0%, rgba(217,119,6,0.06) 100%)", + border: "1px solid rgba(245,158,11,0.22)", + }, +}; + +function AdeKindBadge({ kind }: { kind: GitHubPrListItem["adeKind"] }): React.ReactElement | null { + if (!kind || kind === "single") return null; + const style = ADE_KIND_STYLES[kind]; + if (!style) return null; + return {kind}; +} + +function adeKindBadgeStyle(style: { color: string; background: string; border: string }): React.CSSProperties { + return { + display: "inline-flex", + alignItems: "center", + padding: "2px 7px", + fontSize: 10, + fontWeight: 600, + fontFamily: SANS_FONT, + color: style.color, + background: style.background, + border: style.border, + borderRadius: 5, + }; +} + +/* -- Label text color from hex background (luminance-aware) -- */ +function labelTextColor(hexColor: string): string { + const hex = hexColor.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16) || 0; + const g = parseInt(hex.substring(2, 4), 16) || 0; + const b = parseInt(hex.substring(4, 6), 16) || 0; + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? "#1a1a2e" : "#f0f0f0"; +} + + +/** + * Whether the "unmapped" badge would actually lead somewhere. Selecting the row offers + * one of two actions depending on whether a lane already tracks the head branch — map + * to it, or create one — so the presence of a usable local branch is the real gate. + * Fork PRs have no local branch to work with, and a terminal PR cannot be mapped at all. + * + * Drives the amber-vs-neutral choice, so the warning colour is only ever spent on rows + * the user can do something about. + */ +function isPrRowMappable(item: GitHubPrListItem): boolean { + if (item.linkedPrId || item.scope !== "repo") return false; + if (item.state !== "open" && item.state !== "draft") return false; + return Boolean(branchNameFromRef(item.headBranch)); +} + +/** + * The lane column of a PR row. + * + * Three states, deliberately distinct: + * - **mapped** — the lane chip, in the lane's colour. + * - **detached** — `was: ` plus the activity frozen when the lane was deleted. + * This is history, so it is dim and carries no call to action. + * - **no lane** — nothing at all in terminal buckets (absence already reads as "no + * lane"), and a neutral chip in Open. It only turns amber when there is genuinely + * something to do, so amber keeps meaning "act on this". + */ +function PrRowLaneChip({ + item, + linkedLaneColor, + mappable, +}: { + item: GitHubPrListItem; + linkedLaneColor: string | null; + mappable: boolean; +}) { + if (item.linkedLaneName) { + return ( + + {linkedLaneColor ? : null} + {item.linkedLaneName} + + ); + } + + if (item.detached) return ; + + // A linked id is internal identity, not user-facing copy. If the lane metadata is + // temporarily unavailable, omit the chip instead of leaking a UUID or claiming the + // PR is unmapped. + if (item.linkedLaneId) return null; + + // Terminal PRs have no mapping story worth telling — the lane is gone and mapping one + // now would do nothing. Showing a badge here is what made Merged a wall of warnings. + if (isTerminalPrState(item.state)) return null; + + const actionable = mappable; + return ( + + unmapped + + ); +} + +/** + * `arul · squash → main` — how a terminal PR shipped, folded onto the meta line in + * place of the branch row. Every part is optional: PRs merged before ADE recorded + * merge metadata simply show less, rather than showing placeholders. + */ +function PrRowMergeFacts({ item }: { item: GitHubPrListItem }) { + const parts = [ + item.mergedBy?.login ?? null, + item.mergeMethod, + item.baseBranch ? `→ ${item.baseBranch}` : null, + ].filter(Boolean) as string[]; + if (parts.length === 0) return null; + return ( + + {parts.join(" · ")} + + ); +} + +function PrRowDiffStat({ additions, deletions }: { additions: number | null; deletions: number | null }) { + if (additions == null && deletions == null) return null; + return ( + + +{additions ?? 0} + -{deletions ?? 0} + + ); +} + +/** `was: · 3 chats · 2 proof` — what ADE knows that GitHub cannot show. */ +function PrRowGhostLaneChip({ detached }: { detached: NonNullable }) { + const counts = [ + detached.chats > 0 ? `${detached.chats} chat${detached.chats === 1 ? "" : "s"}` : null, + detached.artifacts > 0 ? `${detached.artifacts} proof` : null, + ].filter(Boolean) as string[]; + const name = detached.laneName?.trim(); + const detachedAgo = formatTimeAgoCompact(detached.at); + if (!name && counts.length === 0) return null; + return ( + + {detached.laneColor ? : null} + {name ? was: {name} : null} + {counts.length > 0 ? · {counts.join(" · ")} : null} + + ); +} + +/* ---- PR row (shared between list and virtualizer) ---- */ +function useLaneColorById(laneId: string | null | undefined): string | null { + return useAppStore((s) => { + if (!laneId) return null; + return s.lanes.find((l) => l.id === laneId)?.color ?? null; + }); +} + +export function GitHubTabPrRow({ + item, + selected, + linkedPr, + onSelect, +}: { + item: GitHubPrListItem; + selected: boolean; + linkedPr: PrSummary | null; + onSelect: (item: GitHubPrListItem) => void; +}) { + const sc = stateColor(item.state); + // A merged PR is a record, not a queue item: CI outcome, "review required" and the + // state badge are all answered by the fact that it merged. Dropping them is what lets + // the row collapse to two lines and stops the list reading as a wall of signals. + const terminal = isTerminalPrState(item.state); + const review = terminal ? null : reviewIndicator(linkedPr); + // Open rows are about how long something has been waiting; merged rows are about + // when it shipped. + const ago = formatTimeAgoCompact(terminal ? (item.mergedAt ?? item.updatedAt) : item.createdAt); + const labels = item.labels ?? []; + const visibleLabels = labels.slice(0, 4); + const overflowCount = labels.length - 4; + const rowLinkedLaneColor = useLaneColorById(item.linkedLaneId ?? null); + const mappable = isPrRowMappable(item); + return ( +
+ + +
+ ); +} + +/** + * Period header for the merged/closed log. Announced as a heading so screen readers get + * the same structure sighted users do, instead of an undifferentiated pile of buttons. + */ +export function PrListGroupHeaderRow({ header }: { header: PrListGroupHeaderModel }) { + const diff = formatPrListGroupDiff(header.additions, header.deletions); + return ( +
+ {header.label} + + {header.count} {header.outcome}{diff ? ` · ${diff}` : ""} + +
+ ); +} diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx index 3b8a08d50..d48db9dee 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx @@ -2,319 +2,51 @@ import React from "react"; import { MemoryRouter } from "react-router-dom"; -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CreateLaneFromPrBranchPreflightResult, GitHubPrSnapshot, LaneSummary, MergeMethod, PrWithConflicts } from "../../../../shared/types"; - -vi.mock("react-resizable-panels", () => ({ - Group: ({ children }: { children: React.ReactNode }) =>
{children}
, - Panel: ({ children, ...props }: React.HTMLAttributes & { id?: string }) => ( -
- {children} -
- ), - Separator: (props: React.HTMLAttributes) =>
, -})); - -const mockUsePrs = vi.fn(); - -vi.mock("../state/PrsContext", () => ({ - usePrs: () => mockUsePrs(), -})); - -type MockUnmappedAffordance = { - linkableLanes: Array<{ id: string; name: string }>; - selectedLaneId: string; - onSelectLane: (laneId: string) => void; - onLink: () => void; - linkBusy: boolean; - canCreateLane: boolean; - onCreateLane: () => void; - scope: "repo" | "external"; -}; - -vi.mock("../detail/PrDetailPane", () => ({ - PrDetailPane: ({ - pr, - onUnmap, - unmapped, - unmappedAffordance, - }: { - pr: { id: string }; - onUnmap?: () => void; - unmapped?: boolean; - unmappedAffordance?: MockUnmappedAffordance | null; - }) => ( -
- {pr.id} - {onUnmap ? : null} - {unmappedAffordance ? ( -
- {unmappedAffordance.canCreateLane ? ( - - ) : null} - {unmappedAffordance.linkableLanes.length > 0 ? ( - <> - - - - ) : null} -
- ) : null} -
- ), -})); +import type { GitHubPrSnapshot, LaneSummary, MergeMethod } from "../../../../shared/types"; -import { GitHubTab } from "./GitHubTab"; - -function makeGitHubPr(overrides: Partial = {}): GitHubPrSnapshot["repoPullRequests"][number] { +vi.mock("react-resizable-panels", async () => { + const harness = await import("./GitHubTab.testHarness"); return { - id: "repo-open", - scope: "repo", - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 101, - githubUrl: "https://github.com/ade-dev/ade/pull/101", - title: "Open PR", - state: "open", - isDraft: false, - baseBranch: "main", - headBranch: "feature/open", - author: "octocat", - createdAt: "2026-03-13T11:00:00.000Z", - updatedAt: "2026-03-13T11:30:00.000Z", - linkedPrId: "pr-open", - linkedGroupId: null, - linkedLaneId: "lane-open", - linkedLaneName: "lane-open", - adeKind: "single", - workflowDisplayState: null, - cleanupState: null, - labels: [], - isBot: false, - commentCount: 0, - ...overrides, + Group: harness.MockPanelGroup, + Panel: harness.MockPanel, + Separator: harness.MockSeparator, }; -} +}); -function makeLaneSummary(overrides: Partial = {}): LaneSummary { - return { - id: "lane-open", - name: "lane-open", - description: null, - laneType: "worktree", - baseRef: "main", - branchRef: "refs/heads/feature/open", - worktreePath: "/tmp/lane-open", - attachedRootPath: null, - parentLaneId: null, - childCount: 0, - stackDepth: 0, - parentStatus: null, - isEditProtected: false, - status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, - color: null, - icon: null, - tags: [], - folder: null, - createdAt: "2026-03-13T10:00:00.000Z", - archivedAt: null, - ...overrides, - }; -} - -const snapshot: GitHubPrSnapshot = { - repo: { owner: "ade-dev", name: "ade" }, - viewerLogin: "octocat", - syncedAt: "2026-03-13T12:00:00.000Z", - repoPullRequests: [ - makeGitHubPr(), - makeGitHubPr({ - id: "repo-merged", - githubPrNumber: 102, - githubUrl: "https://github.com/ade-dev/ade/pull/102", - title: "Merged PR", - state: "merged", - headBranch: "feature/merged", - createdAt: "2026-03-13T09:00:00.000Z", - updatedAt: "2026-03-13T10:00:00.000Z", - linkedPrId: "pr-merged", - linkedLaneId: "lane-merged", - linkedLaneName: "lane-merged", - }), - ], - externalPullRequests: [], -}; - -type Deferred = { - promise: Promise; - resolve: (value: T) => void; - reject: (error: unknown) => void; -}; - -function createDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function makePreflightResult(args: { - githubPrNumber: number; - title: string; - headBranch: string; - remoteBranch: string; -}): CreateLaneFromPrBranchPreflightResult { - return { - preflight: { - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: args.githubPrNumber, - githubUrl: `https://github.com/ade-dev/ade/pull/${args.githubPrNumber}`, - title: args.title, - headBranch: args.headBranch, - headRepoOwner: "ade-dev", - headRepoName: "ade", - headSha: "head-sha", - remoteBranch: args.remoteBranch, - importBranchRef: args.remoteBranch, - targetLaneName: args.title, - baseBranch: "main", - canCreate: true, - status: "ready", - blockingConflict: null, - blockingConflicts: [], - }, - lane: null, - pr: null, - }; -} +vi.mock("../state/PrsContext", async () => { + const { mockUsePrs } = await import("./GitHubTab.testHarness"); + return { usePrs: () => mockUsePrs() }; +}); -describe("GitHubTab", () => { - beforeEach(() => { - mockUsePrs.mockReturnValue({ - prs: [ - { id: "pr-open", state: "open", checksStatus: "pending", reviewStatus: "requested", additions: 12, deletions: 3 }, - { id: "pr-merged", state: "merged", checksStatus: "passing", reviewStatus: "approved", additions: 5, deletions: 1 }, - ] satisfies Partial[], - mergeContextByPrId: {}, - detailStatus: null, - detailChecks: [], - detailReviews: [], - detailComments: [], - detailBusy: false, - loading: false, - setViewerLogin: vi.fn(), - }); +vi.mock("../detail/PrDetailPane", async () => { + const { MockPrDetailPane } = await import("./GitHubTab.testHarness"); + return { PrDetailPane: MockPrDetailPane }; +}); - Object.assign(window, { - ade: { - prs: { - getGitHubSnapshot: vi.fn().mockResolvedValue(snapshot), - onEvent: vi.fn(() => () => {}), - linkToLane: vi.fn(), - preflightCreateLaneFromPrBranch: vi.fn().mockResolvedValue({ - preflight: { - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "Unlinked PR", - headBranch: "feature/open", - headRepoOwner: "ade-dev", - headRepoName: "ade", - remoteBranch: "origin/feature/open", - importBranchRef: "origin/feature/open", - targetLaneName: "Unlinked PR", - baseBranch: "main", - canCreate: true, - status: "ready", - blockingConflict: null, - blockingConflicts: [], - }, - lane: null, - pr: null, - }), - createLaneFromPrBranch: vi.fn().mockResolvedValue({ - preflight: { - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "Unlinked PR", - headBranch: "feature/open", - headRepoOwner: "ade-dev", - headRepoName: "ade", - remoteBranch: "origin/feature/open", - importBranchRef: "origin/feature/open", - targetLaneName: "Unlinked PR", - baseBranch: "main", - canCreate: true, - status: "ready", - blockingConflict: null, - blockingConflicts: [], - }, - lane: { id: "lane-created", name: "Unlinked PR" }, - pr: { id: "pr-created", laneId: "lane-created" }, - }), - addGitHubStackPullRequests: vi.fn().mockResolvedValue(null), - unstackGitHubStack: vi.fn().mockResolvedValue(null), - delete: vi.fn().mockResolvedValue(undefined), - }, - github: { - getStatus: vi.fn().mockResolvedValue({ - tokenStored: true, - patTokenStored: true, - tokenDecryptionFailed: false, - storageScope: "app", - authSource: "pat", - tokenType: "classic", - repo: { owner: "ade-dev", name: "ade" }, - hasOrigin: true, - userLogin: "octocat", - scopes: [], - ghCliPath: null, - ghAuthError: null, - checkedAt: "2026-03-13T12:00:00.000Z", - repoAccessOk: null, - repoAccessError: null, - connected: true, - }), - }, - app: { - openExternal: vi.fn(), - }, - lanes: { - list: vi.fn().mockResolvedValue([]), - }, - }, - }); +import { GitHubTab } from "./GitHubTab"; +import { + cleanupGitHubTabTest, + mockUsePrs, + renderGitHubTab, + setupGitHubTabTest, +} from "./GitHubTab.testHarness"; +import { + createDeferred, + makeGitHubPr, + makePrsContext, + snapshot, +} from "./GitHubTab.testFixtures"; + +describe("GitHubTab snapshot lifecycle", () => { + beforeEach(() => { + setupGitHubTabTest(); }); afterEach(() => { - cleanup(); - vi.useRealTimers(); + cleanupGitHubTabTest(); }); function renderTab(overrides: Partial<{ @@ -323,20 +55,21 @@ describe("GitHubTab", () => { onRefreshAll: ReturnType; lanes: LaneSummary[]; }> = {}) { - const onSelectPr = overrides.onSelectPr ?? vi.fn(); - const onRefreshAll = overrides.onRefreshAll ?? vi.fn().mockResolvedValue(undefined); - render( + return renderGitHubTab(GitHubTab, overrides); + } + + function renderTabEl(selectedPrId: string) { + return ( - , + ); - return { onSelectPr, onRefreshAll }; } it("shows and manages the selected GitHub stack from the cached snapshot", async () => { @@ -433,41 +166,13 @@ describe("GitHubTab", () => { expect(screen.queryByTestId("pr-detail-pane")).toBeNull(); }); - function prsContext(prs: Array & { id: string }>) { - return { - prs, - mergeContextByPrId: {}, - detailStatus: null, - detailChecks: [], - detailReviews: [], - detailComments: [], - detailBusy: false, - loading: false, - setViewerLogin: vi.fn(), - }; - } - - function renderTabEl(selectedPrId: string) { - return ( - - - - ); - } - it("follows a selected PR into the merged bucket when its linked state transitions", async () => { (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue({ ...snapshot, repoPullRequests: [makeGitHubPr()], externalPullRequests: [], }); - mockUsePrs.mockReturnValue(prsContext([ + mockUsePrs.mockReturnValue(makePrsContext([ { id: "pr-open", state: "open", repoOwner: "ade-dev", repoName: "ade", githubPrNumber: 101 }, ])); @@ -480,7 +185,7 @@ describe("GitHubTab", () => { expect((screen.getByRole("button", { name: /^open/i }) as HTMLButtonElement).style.fontWeight).toBe("600"); // The linked ADE PR transitions open -> merged. - mockUsePrs.mockReturnValue(prsContext([ + mockUsePrs.mockReturnValue(makePrsContext([ { id: "pr-open", state: "merged", repoOwner: "ade-dev", repoName: "ade", githubPrNumber: 101 }, ])); view.rerender(renderTabEl("pr-open")); @@ -503,7 +208,7 @@ describe("GitHubTab", () => { return () => {}; }); - mockUsePrs.mockReturnValue(prsContext([ + mockUsePrs.mockReturnValue(makePrsContext([ { id: "pr-open", state: "open", repoOwner: "ade-dev", repoName: "ade", githubPrNumber: 101 }, ])); @@ -515,7 +220,7 @@ describe("GitHubTab", () => { expect(screen.getByRole("button", { name: /#101 Open PR/i })).toBeTruthy(); // The PR merges AND drops out of the open-only snapshot. - mockUsePrs.mockReturnValue(prsContext([ + mockUsePrs.mockReturnValue(makePrsContext([ { id: "pr-open", state: "merged", repoOwner: "ade-dev", repoName: "ade", githubPrNumber: 101 }, ])); getSnap.mockResolvedValue({ ...openOnly, repoPullRequests: [] }); @@ -680,27 +385,6 @@ describe("GitHubTab", () => { expect(screen.getByRole("button", { name: /connect github/i })).toBeTruthy(); }); - it("requires confirmation before unmapping a GitHub PR from its lane", async () => { - const user = userEvent.setup(); - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); - try { - renderTab(); - - await waitFor(() => { - expect(screen.getByText("Open PR")).toBeTruthy(); - }); - await user.click(screen.getByRole("button", { name: /#101 Open PR/i })); - await waitFor(() => { - expect(screen.getByTestId("pr-detail-pane").textContent).toContain("pr-open"); - }); - await user.click(screen.getByRole("button", { name: /unmap from lane/i })); - - expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining("Unmap PR #101")); - expect(window.ade.prs.delete).not.toHaveBeenCalled(); - } finally { - confirmSpy.mockRestore(); - } - }); it("renders a cached ADE detail shell while a linked PR hydrates", async () => { mockUsePrs.mockReturnValue({ @@ -729,13 +413,6 @@ describe("GitHubTab", () => { }); }); - it("shows a running CI indicator for PR cards with pending checks", async () => { - renderTab(); - - await waitFor(() => { - expect(screen.getAllByLabelText("CI running").length).toBeGreaterThan(0); - }); - }); it("does not force-refresh the GitHub snapshot when linked PRs hydrate after mount", async () => { const emptyContext = { @@ -1156,629 +833,4 @@ describe("GitHubTab", () => { expect(screen.getByText("Stale open snapshot")).toBeTruthy(); }); }); - - it("shows linked and unmapped PRs together under the status tabs", async () => { - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - ...snapshot.repoPullRequests, - makeGitHubPr({ - id: "repo-unlinked", - githubPrNumber: 200, - title: "Unlinked PR", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); - renderTab(); - - await waitFor(() => { - expect(screen.getByText("Open PR")).not.toBeNull(); - expect(screen.getByText("Unlinked PR")).not.toBeNull(); - }); - }); - - it("marks unlinked PRs as unmapped", async () => { - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - ...snapshot.repoPullRequests, - makeGitHubPr({ - id: "repo-unlinked", - githubPrNumber: 200, - title: "Unlinked PR", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); - renderTab(); - - await waitFor(() => { - expect(screen.getByText("Unlinked PR")).not.toBeNull(); - }); - expect(screen.getAllByText("unmapped").length).toBeGreaterThan(0); - }); - - it("does not mark unlinked PRs as unmapped in the merged bucket", async () => { - const user = userEvent.setup(); - const snapshotWithMergedUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - ...snapshot.repoPullRequests, - makeGitHubPr({ - id: "repo-merged-unlinked", - githubPrNumber: 201, - title: "Merged after lane deleted", - state: "merged", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-12T12:00:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithMergedUnlinked); - renderTab(); - - await user.click(await screen.findByRole("button", { name: /merged/i })); - await waitFor(() => { - expect(screen.getByText("Merged after lane deleted")).not.toBeNull(); - }); - // Mapping is a live-work concept: on a merged PR the lane is gone and mapping one - // would do nothing, so the badge must not appear. - expect(screen.queryByText("unmapped")).toBeNull(); - }); - - it("shows frozen lane provenance on a detached merged PR", async () => { - const user = userEvent.setup(); - const snapshotWithDetached: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - ...snapshot.repoPullRequests, - makeGitHubPr({ - id: "repo-detached", - githubPrNumber: 202, - title: "Shipped from a deleted lane", - state: "merged", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-11T12:00:00.000Z", - detached: { - at: "2026-03-12T09:00:00.000Z", - laneName: "auto-naming", - laneColor: "#4ADE80", - chats: 3, - artifacts: 2, - checkpoints: 5, - }, - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithDetached); - renderTab(); - - await user.click(await screen.findByRole("button", { name: /merged/i })); - await waitFor(() => { - expect(screen.getByText("Shipped from a deleted lane")).not.toBeNull(); - }); - expect(screen.getByText("was: auto-naming")).not.toBeNull(); - expect(screen.getByText("· 3 chats · 2 proof")).not.toBeNull(); - expect(screen.queryByText("unmapped")).toBeNull(); - }); - - it("shows merge facts instead of CI and review signals on a merged row", async () => { - const user = userEvent.setup(); - const snapshotWithMerged: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - ...snapshot.repoPullRequests, - makeGitHubPr({ - id: "repo-merged-facts", - githubPrNumber: 203, - title: "Merged with facts", - state: "merged", - baseBranch: "main", - mergedAt: "2026-03-12T10:00:00.000Z", - mergedBy: { login: "arul", avatarUrl: null }, - mergeMethod: "squash", - createdAt: "2026-03-10T12:00:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithMerged); - renderTab(); - - await user.click(await screen.findByRole("button", { name: /merged/i })); - await waitFor(() => { - expect(screen.getByText("Merged with facts")).not.toBeNull(); - }); - expect(screen.getByText("arul · squash · → main")).not.toBeNull(); - }); - - it("renders the full PR detail pane (with create/map affordance) for a selected unmapped PR", async () => { - const user = userEvent.setup(); - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "repo-unlinked", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "Unlinked PR", - headBranch: "feature/no-lane", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:05:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); - // No lane owns the PR head branch → the "Create lane from PR branch" action - // is offered (and there is no matching lane to map to). - renderTab({ lanes: [] }); - - await user.click(await screen.findByText("Unlinked PR")); - - // The full detail pane renders (not the legacy read-only gate), keyed by a - // stable synthetic id derived from the GitHub coordinates. - const pane = await screen.findByTestId("pr-detail-pane"); - expect(pane.getAttribute("data-unmapped")).toBe("true"); - expect(pane.textContent).toContain("gh:ade-dev/ade#200"); - - // The create/map affordance is present (no read-only gate). - const affordance = within(pane).getByTestId("pr-unmapped-affordance"); - expect(within(affordance).getByRole("button", { name: /create lane from pr branch/i })).toBeTruthy(); - }); - - it("maps an unmapped PR to a lane via the in-pane affordance", async () => { - const user = userEvent.setup(); - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "repo-unlinked", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "Unlinked PR", - headBranch: "feature/lane-match", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:05:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); - renderTab({ - lanes: [makeLaneSummary({ id: "lane-match", name: "Matching lane", branchRef: "refs/heads/feature/lane-match" })], - }); - - await user.click(await screen.findByText("Unlinked PR")); - const pane = await screen.findByTestId("pr-detail-pane"); - const affordance = within(pane).getByTestId("pr-unmapped-affordance"); - - await user.selectOptions(within(affordance).getByLabelText("Select lane to map"), "lane-match"); - await user.click(within(affordance).getByRole("button", { name: /^map$/i })); - - await waitFor(() => { - expect(window.ade.prs.linkToLane).toHaveBeenCalledWith({ - laneId: "lane-match", - prUrlOrNumber: "https://github.com/ade-dev/ade/pull/200", - }); - }); - }); - - it("opens a preflight dialog for an unmapped PR branch", async () => { - const user = userEvent.setup(); - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "repo-unlinked", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "Unlinked PR", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:05:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); - renderTab(); - - await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); - - expect(window.ade.prs.preflightCreateLaneFromPrBranch).toHaveBeenCalledWith({ - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 200, - }); - const dialog = await screen.findByRole("dialog", { name: /create lane from pr branch/i }); - expect(within(dialog).getByText(/#200 Unlinked PR/)).toBeTruthy(); - expect(within(dialog).getAllByText("origin/feature/open").length).toBeGreaterThan(0); - expect(within(dialog).getAllByText("Unlinked PR").length).toBeGreaterThan(0); - expect(within(dialog).getAllByText("main").length).toBeGreaterThan(0); - }); - - it("ignores stale create-lane preflight results from a previous PR", async () => { - const user = userEvent.setup(); - const firstPreflight = createDeferred(); - const secondPreflight = createDeferred(); - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "repo-unlinked-first", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "First PR", - headBranch: "feature/first", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:10:00.000Z", - }), - makeGitHubPr({ - id: "repo-unlinked-second", - githubPrNumber: 201, - githubUrl: "https://github.com/ade-dev/ade/pull/201", - title: "Second PR", - headBranch: "feature/second", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:05:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); - (window.ade.prs.preflightCreateLaneFromPrBranch as ReturnType) - .mockImplementation((args: { githubPrNumber: number }) => - args.githubPrNumber === 200 ? firstPreflight.promise : secondPreflight.promise); - renderTab(); - - await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); - expect(window.ade.prs.preflightCreateLaneFromPrBranch).toHaveBeenCalledWith({ - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 200, - }); - await user.click(screen.getByRole("button", { name: /cancel/i })); - await user.click(await screen.findByText("Second PR")); - await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); - expect(window.ade.prs.preflightCreateLaneFromPrBranch).toHaveBeenCalledWith({ - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 201, - }); - - await act(async () => { - firstPreflight.resolve(makePreflightResult({ - githubPrNumber: 200, - title: "First PR", - headBranch: "feature/first", - remoteBranch: "origin/feature/first", - })); - await firstPreflight.promise; - }); - expect(screen.queryByText(/#200 First PR/)).toBeNull(); - expect(screen.getByText(/checking branch ownership/i)).toBeTruthy(); - - await act(async () => { - secondPreflight.resolve(makePreflightResult({ - githubPrNumber: 201, - title: "Second PR", - headBranch: "feature/second", - remoteBranch: "origin/feature/second", - })); - await secondPreflight.promise; - }); - - expect(await screen.findByText(/#201 Second PR/)).toBeTruthy(); - expect(screen.queryByText(/#200 First PR/)).toBeNull(); - const secondDialog = await screen.findByRole("dialog", { name: /create lane from pr branch/i }); - expect(within(secondDialog).getAllByText("origin/feature/second").length).toBeGreaterThan(0); - }); - - it("shows blocking preflight conflicts before creating a lane", async () => { - const user = userEvent.setup(); - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "repo-unlinked", - githubPrNumber: 200, - title: "Unlinked PR", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:05:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); - (window.ade.prs.preflightCreateLaneFromPrBranch as ReturnType).mockResolvedValueOnce({ - preflight: { - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "Unlinked PR", - headBranch: "feature/open", - headRepoOwner: "ade-dev", - headRepoName: "ade", - remoteBranch: "origin/feature/open", - importBranchRef: "origin/feature/open", - targetLaneName: "Unlinked PR", - baseBranch: "main", - canCreate: false, - status: "blocked", - blockingConflict: { - code: "branch_owned", - message: "Branch 'feature/open' is already owned by lane 'Existing lane'.", - laneId: "lane-existing", - laneName: "Existing lane", - }, - blockingConflicts: [], - }, - lane: null, - pr: null, - }); - renderTab(); - - await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); - - expect(await screen.findByText(/already owned by lane 'Existing lane'/i)).toBeTruthy(); - expect(screen.getByRole("button", { name: /^create lane$/i })).toHaveProperty("disabled", true); - expect(window.ade.prs.createLaneFromPrBranch).not.toHaveBeenCalled(); - }); - - it("does not let an archived branch match hide the create-lane action", async () => { - const user = userEvent.setup(); - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "repo-unlinked", - githubPrNumber: 200, - title: "Unlinked PR", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:05:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); - (window.ade.prs.preflightCreateLaneFromPrBranch as ReturnType).mockResolvedValueOnce({ - preflight: { - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "Unlinked PR", - headBranch: "feature/open", - headRepoOwner: "ade-dev", - headRepoName: "ade", - remoteBranch: "origin/feature/open", - importBranchRef: "origin/feature/open", - targetLaneName: "Unlinked PR", - baseBranch: "main", - canCreate: false, - status: "blocked", - blockingConflict: { - code: "branch_owned", - message: "Branch 'feature/open' is already owned by archived lane 'Archived lane'.", - laneId: "lane-archived", - laneName: "Archived lane", - }, - blockingConflicts: [], - }, - lane: null, - pr: null, - }); - - renderTab({ - lanes: [ - makeLaneSummary({ - id: "lane-archived", - name: "Archived lane", - branchRef: "refs/heads/feature/open", - archivedAt: "2026-03-12T12:00:00.000Z", - }), - ], - }); - - expect(await screen.findByRole("button", { name: /create lane from pr branch/i })).toBeTruthy(); - expect(screen.queryByRole("option", { name: "Archived lane" })).toBeNull(); - - await user.click(screen.getByRole("button", { name: /create lane from pr branch/i })); - - expect(await screen.findByText(/already owned by archived lane 'Archived lane'/i)).toBeTruthy(); - expect(screen.getByRole("button", { name: /^create lane$/i })).toHaveProperty("disabled", true); - }); - - it("creates a lane from an unmapped PR branch, refreshes lanes, and selects the mapped PR", async () => { - const user = userEvent.setup(); - const onSelectPr = vi.fn(); - const onRefreshAll = vi.fn().mockResolvedValue(undefined); - const forcedSnapshot = createDeferred(); - const snapshotWithUnlinked: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "repo-unlinked", - githubPrNumber: 200, - githubUrl: "https://github.com/ade-dev/ade/pull/200", - title: "Unlinked PR", - linkedPrId: null, - linkedLaneId: null, - linkedLaneName: null, - adeKind: null, - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:05:00.000Z", - }), - ], - externalPullRequests: [], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType) - .mockResolvedValueOnce(snapshotWithUnlinked) - .mockReturnValueOnce(forcedSnapshot.promise); - renderTab({ onSelectPr, onRefreshAll }); - - await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); - await user.click(await screen.findByRole("button", { name: /^create lane$/i })); - - await waitFor(() => { - expect(window.ade.prs.createLaneFromPrBranch).toHaveBeenCalledWith({ - repoOwner: "ade-dev", - repoName: "ade", - githubPrNumber: 200, - }); - }); - await waitFor(() => { - expect(onSelectPr).toHaveBeenCalledWith("pr-created"); - }); - expect(onRefreshAll).toHaveBeenCalledWith({ prId: "pr-created" }); - expect(window.ade.lanes.list).toHaveBeenCalledWith({ - includeArchived: false, - includeStatus: false, - }); - expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledWith({ force: true }); - await act(async () => { - forcedSnapshot.resolve(snapshotWithUnlinked); - await forcedSnapshot.promise; - }); - }); - - it("renders bot badge when isBot is true", async () => { - const snapshotWithBot: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "bot-pr", - githubPrNumber: 300, - title: "Bot PR", - author: "dependabot[bot]", - isBot: true, - createdAt: "2026-03-13T12:00:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithBot); - renderTab(); - - await waitFor(() => { - expect(screen.getByText("bot")).not.toBeNull(); - }); - }); - - it("renders labels when present", async () => { - const snapshotWithLabels: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "labeled-pr", - githubPrNumber: 400, - title: "Labeled PR", - labels: [ - { name: "bug", color: "d73a4a", description: null }, - { name: "enhancement", color: "a2eeef", description: null }, - ], - createdAt: "2026-03-13T12:00:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithLabels); - renderTab(); - - await waitFor(() => { - expect(screen.getByText("bug")).not.toBeNull(); - expect(screen.getByText("enhancement")).not.toBeNull(); - }); - }); - - it("renders comment count when greater than zero", async () => { - const snapshotWithComments: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "commented-pr", - githubPrNumber: 500, - title: "Commented PR", - commentCount: 42, - createdAt: "2026-03-13T12:00:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithComments); - renderTab(); - - await waitFor(() => { - expect(screen.getByText("42")).not.toBeNull(); - }); - }); - - it("sorts PRs by updatedAt descending", async () => { - const snapshotOrdered: GitHubPrSnapshot = { - ...snapshot, - repoPullRequests: [ - makeGitHubPr({ - id: "pr-old", - githubPrNumber: 50, - title: "Old PR", - createdAt: "2026-03-13T12:00:00.000Z", - updatedAt: "2026-03-13T12:10:00.000Z", - }), - makeGitHubPr({ - id: "pr-new", - githubPrNumber: 150, - title: "New PR", - createdAt: "2026-03-13T08:00:00.000Z", - updatedAt: "2026-03-13T12:30:00.000Z", - }), - ], - }; - (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotOrdered); - renderTab(); - - await waitFor(() => { - const buttons = screen.getAllByRole("button").filter((btn) => - btn.textContent?.includes("PR") && (btn.textContent?.includes("Old") || btn.textContent?.includes("New")), - ); - expect(buttons.length).toBe(2); - expect(buttons[0]!.textContent).toContain("New PR"); - expect(buttons[1]!.textContent).toContain("Old PR"); - }); - }); }); diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.testFixtures.ts b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.testFixtures.ts new file mode 100644 index 000000000..be4ac5fbf --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.testFixtures.ts @@ -0,0 +1,232 @@ +import { vi } from "vitest"; +import type { + CreateLaneFromPrBranchPreflightResult, + GitHubPrSnapshot, + LaneSummary, + PrWithConflicts, +} from "../../../../shared/types"; + +export function makeGitHubPr(overrides: Partial = {}): GitHubPrSnapshot["repoPullRequests"][number] { + return { + id: "repo-open", + scope: "repo", + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 101, + githubUrl: "https://github.com/ade-dev/ade/pull/101", + title: "Open PR", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "feature/open", + author: "octocat", + createdAt: "2026-03-13T11:00:00.000Z", + updatedAt: "2026-03-13T11:30:00.000Z", + linkedPrId: "pr-open", + linkedGroupId: null, + linkedLaneId: "lane-open", + linkedLaneName: "lane-open", + adeKind: "single", + workflowDisplayState: null, + cleanupState: null, + labels: [], + isBot: false, + commentCount: 0, + ...overrides, + }; +} + +export function makeLaneSummary(overrides: Partial = {}): LaneSummary { + return { + id: "lane-open", + name: "lane-open", + description: null, + laneType: "worktree", + baseRef: "main", + branchRef: "refs/heads/feature/open", + worktreePath: "/tmp/lane-open", + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-13T10:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +export const snapshot: GitHubPrSnapshot = { + repo: { owner: "ade-dev", name: "ade" }, + viewerLogin: "octocat", + syncedAt: "2026-03-13T12:00:00.000Z", + repoPullRequests: [ + makeGitHubPr(), + makeGitHubPr({ + id: "repo-merged", + githubPrNumber: 102, + githubUrl: "https://github.com/ade-dev/ade/pull/102", + title: "Merged PR", + state: "merged", + headBranch: "feature/merged", + createdAt: "2026-03-13T09:00:00.000Z", + updatedAt: "2026-03-13T10:00:00.000Z", + linkedPrId: "pr-merged", + linkedLaneId: "lane-merged", + linkedLaneName: "lane-merged", + }), + ], + externalPullRequests: [], +}; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +export function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +export function makePreflightResult(args: { + githubPrNumber: number; + title: string; + headBranch: string; + remoteBranch: string; +}): CreateLaneFromPrBranchPreflightResult { + return { + preflight: { + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: args.githubPrNumber, + githubUrl: `https://github.com/ade-dev/ade/pull/${args.githubPrNumber}`, + title: args.title, + headBranch: args.headBranch, + headRepoOwner: "ade-dev", + headRepoName: "ade", + headSha: "head-sha", + remoteBranch: args.remoteBranch, + importBranchRef: args.remoteBranch, + targetLaneName: args.title, + baseBranch: "main", + canCreate: true, + status: "ready", + blockingConflict: null, + blockingConflicts: [], + }, + lane: null, + pr: null, + }; +} + +export function makePrsContext(prs: Array & { id: string }>) { + return { + prs, + mergeContextByPrId: {}, + detailStatus: null, + detailChecks: [], + detailReviews: [], + detailComments: [], + detailBusy: false, + loading: false, + setViewerLogin: vi.fn(), + }; +} + +export function installGitHubTabWindowMocks(): void { + Object.assign(window, { + ade: { + prs: { + getGitHubSnapshot: vi.fn().mockResolvedValue(snapshot), + onEvent: vi.fn(() => () => {}), + linkToLane: vi.fn(), + preflightCreateLaneFromPrBranch: vi.fn().mockResolvedValue({ + preflight: { + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "Unlinked PR", + headBranch: "feature/open", + headRepoOwner: "ade-dev", + headRepoName: "ade", + remoteBranch: "origin/feature/open", + importBranchRef: "origin/feature/open", + targetLaneName: "Unlinked PR", + baseBranch: "main", + canCreate: true, + status: "ready", + blockingConflict: null, + blockingConflicts: [], + }, + lane: null, + pr: null, + }), + createLaneFromPrBranch: vi.fn().mockResolvedValue({ + preflight: { + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "Unlinked PR", + headBranch: "feature/open", + headRepoOwner: "ade-dev", + headRepoName: "ade", + remoteBranch: "origin/feature/open", + importBranchRef: "origin/feature/open", + targetLaneName: "Unlinked PR", + baseBranch: "main", + canCreate: true, + status: "ready", + blockingConflict: null, + blockingConflicts: [], + }, + lane: { id: "lane-created", name: "Unlinked PR" }, + pr: { id: "pr-created", laneId: "lane-created" }, + }), + addGitHubStackPullRequests: vi.fn().mockResolvedValue(null), + unstackGitHubStack: vi.fn().mockResolvedValue(null), + delete: vi.fn().mockResolvedValue(undefined), + }, + github: { + getStatus: vi.fn().mockResolvedValue({ + tokenStored: true, + patTokenStored: true, + tokenDecryptionFailed: false, + storageScope: "app", + authSource: "pat", + tokenType: "classic", + repo: { owner: "ade-dev", name: "ade" }, + hasOrigin: true, + userLogin: "octocat", + scopes: [], + ghCliPath: null, + ghAuthError: null, + checkedAt: "2026-03-13T12:00:00.000Z", + repoAccessOk: null, + repoAccessError: null, + connected: true, + }), + }, + app: { + openExternal: vi.fn(), + }, + lanes: { + list: vi.fn().mockResolvedValue([]), + }, + }, + }); +} diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.testHarness.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.testHarness.tsx new file mode 100644 index 000000000..70334e03b --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.testHarness.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { MemoryRouter } from "react-router-dom"; +import { cleanup, render } from "@testing-library/react"; +import { vi } from "vitest"; +import type { LaneSummary, MergeMethod } from "../../../../shared/types"; +import type { GitHubTabProps } from "./GitHubTab"; +import { installGitHubTabWindowMocks, makePrsContext } from "./GitHubTab.testFixtures"; + +export const mockUsePrs = vi.fn(); + +export function MockPanelGroup({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function MockPanel({ + children, + id, + defaultSize: _defaultSize, + minSize: _minSize, + maxSize: _maxSize, + ...props +}: React.HTMLAttributes & { + id?: string; + defaultSize?: unknown; + minSize?: unknown; + maxSize?: unknown; +}) { + return
{children}
; +} + +export function MockSeparator(props: React.HTMLAttributes) { + return
; +} + +type MockUnmappedAffordance = { + linkableLanes: Array<{ id: string; name: string }>; + selectedLaneId: string; + onSelectLane: (laneId: string) => void; + onLink: () => void; + linkBusy: boolean; + canCreateLane: boolean; + onCreateLane: () => void; + scope: "repo" | "external"; +}; + +export function MockPrDetailPane({ + pr, + onUnmap, + unmapped, + unmappedAffordance, +}: { + pr: { id: string }; + onUnmap?: () => void; + unmapped?: boolean; + unmappedAffordance?: MockUnmappedAffordance | null; +}) { + return ( +
+ {pr.id} + {onUnmap ? : null} + {unmappedAffordance ? ( +
+ {unmappedAffordance.canCreateLane ? ( + + ) : null} + {unmappedAffordance.linkableLanes.length > 0 ? ( + <> + + + + ) : null} +
+ ) : null} +
+ ); +} + +export function setupGitHubTabTest(): void { + mockUsePrs.mockReturnValue(makePrsContext([ + { id: "pr-open", state: "open", checksStatus: "pending", reviewStatus: "requested", additions: 12, deletions: 3 }, + { id: "pr-merged", state: "merged", checksStatus: "passing", reviewStatus: "approved", additions: 5, deletions: 1 }, + ])); + installGitHubTabWindowMocks(); +} + +export function cleanupGitHubTabTest(): void { + cleanup(); + vi.useRealTimers(); +} + +export function renderGitHubTab( + Component: React.ComponentType, + overrides: Partial<{ + selectedPrId: string | null; + onSelectPr: ReturnType; + onRefreshAll: ReturnType; + lanes: LaneSummary[]; + }> = {}, +) { + const onSelectPr = overrides.onSelectPr ?? vi.fn(); + const onRefreshAll = overrides.onRefreshAll ?? vi.fn().mockResolvedValue(undefined); + render( + + + , + ); + return { onSelectPr, onRefreshAll }; +} diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx index 683732073..a7535bb08 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx @@ -1,13 +1,7 @@ import React from "react"; -import { ArrowSquareOut, ChatText, CheckCircle, CircleNotch, GitBranch, GitMerge, GithubLogo, Warning, XCircle } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; -import { Group, Panel } from "react-resizable-panels"; -import { useVirtualizer } from "@tanstack/react-virtual"; import type { - CreateLaneFromPrBranchArgs, - CreateLaneFromPrBranchPreflight, CreateLaneFromPrBranchPreflightResult, - CreateLaneFromPrBranchResult, GitHubPrListItem, GitHubPrSnapshot, LaneSummary, @@ -16,69 +10,54 @@ import type { PrSummary, PrWithConflicts, } from "../../../../shared/types"; -import { syntheticGithubPrId } from "../../../../shared/types/prs"; -import { EmptyState } from "../../ui/EmptyState"; -import { ResizeGutter } from "../../ui/ResizeGutter"; -import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, primaryButton } from "../../lanes/laneDesignTokens"; -import { LaneAccentDot } from "../../lanes/LaneAccentDot"; import { selectActiveProjectRoot, useAppStore, useAppStoreApi } from "../../../state/appStore"; -import { PrDetailPane, type UnmappedAffordance } from "../detail/PrDetailPane"; -import { formatTimeAgoCompact } from "../shared/prFormatters"; -import { PrCiRunningIndicator } from "../shared/prVisuals"; +import type { UnmappedAffordance } from "../detail/PrDetailPane"; import { usePrs } from "../state/PrsContext"; import type { PrDetailRouteTab } from "../prsRouteState"; -import { GitHubRepoSyncBar } from "../shared/GitHubRepoSyncBar"; -import { GitHubPrSearchInput } from "../shared/GitHubPrSearchInput"; -import { GitHubStackBadge } from "../shared/GitHubStackBadge"; -import { GitHubStackInspector } from "../shared/GitHubStackInspector"; import { getGitHubSnapshotCoalesced } from "../../../lib/prReadCache"; -import { isTerminalPrState } from "../../../lib/prState"; import { - buildPrListRows, - formatPrListGroupDiff, - prListHeaderIndices, - type PrListGroupHeader as PrListGroupHeaderModel, - type PrListRow, -} from "../shared/prListGrouping"; - -const VIRTUALIZE_AT = 50; -const LINKED_HYDRATION_LIMIT = 8; -const GITHUB_TAB_REVISIT_CACHE_TTL_MS = 60_000; -const GITHUB_TAB_SNAPSHOT_FRESH_MS = 30_000; -const GITHUB_TAB_HOT_REFRESH_DELAY_MS = 30_000; -const GITHUB_TAB_HISTORY_INITIAL_PAGE_LIMIT = 2; -const GITHUB_TAB_HISTORY_PAGE_INCREMENT = 2; -const GITHUB_TAB_HISTORY_MAX_PAGE_LIMIT = 10; -const GITHUB_TAB_CACHE_DISABLED = import.meta.env.MODE === "test"; -const GITHUB_PR_LIST_WIDTH_KEY = "ade.prs.githubListWidth"; -const GITHUB_PR_LIST_MIN_PX = 260; -const GITHUB_PR_LIST_MAX_PX = 560; -const GITHUB_PR_LIST_DEFAULT_PX = 380; - -function readPersistedGithubPrListPx(): number { - try { - const raw = localStorage.getItem(GITHUB_PR_LIST_WIDTH_KEY); - if (raw) { - const value = Number(raw); - if (Number.isFinite(value) && value >= GITHUB_PR_LIST_MIN_PX && value <= GITHUB_PR_LIST_MAX_PX) { - return value; - } - } - } catch { - /* ignore */ - } - return GITHUB_PR_LIST_DEFAULT_PX; -} - -function persistGithubPrListPx(px: number): void { - try { - localStorage.setItem(GITHUB_PR_LIST_WIDTH_KEY, String(Math.round(px))); - } catch { - /* ignore */ - } -} - -type GitHubTabProps = { + GITHUB_TAB_HISTORY_INITIAL_PAGE_LIMIT, + GITHUB_TAB_HISTORY_MAX_PAGE_LIMIT, + GITHUB_TAB_HISTORY_PAGE_INCREMENT, + GITHUB_TAB_HOT_REFRESH_DELAY_MS, + GITHUB_TAB_REVISIT_CACHE_TTL_MS, + GITHUB_TAB_SNAPSHOT_FRESH_MS, + LINKED_HYDRATION_LIMIT, + buildSyntheticUnmappedPr, + bucketForState, + formatGitHubSnapshotError, + initialGitHubFilterSelections, + matchesFilter, + normalizeGitHubFilter, + normalizeHistoryPageLimit, + readGitHubTabWarmCache, + snapshotRequestKey, + snapshotRequestSatisfies, + syntheticUnmappedPrId, + writeGitHubTabWarmCache, + type GitHubFilter, + type GitHubFilterSelectionMap, + type GitHubSnapshotRequestKey, + type GitHubTabWarmCache, +} from "./githubTabModel"; +import { + CreateLaneFromPrBranchDialog, + canCreateLaneFromPrBranch, + createLaneFromPrBranchApi, + createLaneFromPrBranchArgs, + createLaneFromPrBranchRequestKey, + createLaneMappedLaneId, + createLaneMappedLaneName, + createLaneMappedPrId, + formatActionError, + patchSnapshotWithMappedPr, + upsertLaneSummary, +} from "./GitHubTabCreateLaneDialog"; +import { GitHubTabView } from "./GitHubTabView"; +import { branchNameFromRef } from "./githubPrBranch"; +import { useGitHubTabListModel } from "./useGitHubTabListModel"; + +export type GitHubTabProps = { lanes: LaneSummary[]; mergeMethod: MergeMethod; selectedPrId: string | null; @@ -100,643 +79,6 @@ export type GitHubHeaderChromeState = { onSearchQueryChange: (value: string) => void; }; -type GitHubFilter = "open" | "closed" | "merged"; -type GitHubFilterSelectionMap = Partial>; - -type GitHubTabWarmCache = { - projectRoot: string; - snapshot: GitHubPrSnapshot | null; - filter: GitHubFilter; - selectedItemId: string | null; - selectedItemIdsByFilter?: GitHubFilterSelectionMap; - searchQuery: string; - externalHistoryLoaded: boolean; - cachedAt: number; -}; - -type GitHubSnapshotRequestKey = { - includeExternalClosed: boolean; - historyPageLimit: number; -}; - -type CreateLaneFromPrBranchApi = { - preflightCreateLaneFromPrBranch: ( - args: CreateLaneFromPrBranchArgs, - ) => Promise; - createLaneFromPrBranch: ( - args: CreateLaneFromPrBranchArgs, - ) => Promise; -}; - -let githubTabWarmCache: GitHubTabWarmCache | null = null; - -function normalizeGitHubFilter(value: unknown): GitHubFilter { - return value === "open" || value === "closed" || value === "merged" ? value : "open"; -} - -function initialGitHubFilterSelections(cache: GitHubTabWarmCache | null): GitHubFilterSelectionMap { - const selections: GitHubFilterSelectionMap = { ...(cache?.selectedItemIdsByFilter ?? {}) }; - if (cache?.selectedItemId) { - selections[normalizeGitHubFilter(cache.filter)] = cache.selectedItemId; - } - return selections; -} - -function normalizeHistoryPageLimit(value: unknown): number { - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric <= 0) { - return GITHUB_TAB_HISTORY_INITIAL_PAGE_LIMIT; - } - return Math.min( - GITHUB_TAB_HISTORY_MAX_PAGE_LIMIT, - Math.max(GITHUB_TAB_HISTORY_INITIAL_PAGE_LIMIT, Math.floor(numeric)), - ); -} - -function snapshotRequestKey(options?: { - includeExternalClosed?: boolean; - historyPageLimit?: number; -}): GitHubSnapshotRequestKey { - const includeExternalClosed = options?.includeExternalClosed === true; - return { - includeExternalClosed, - historyPageLimit: includeExternalClosed ? normalizeHistoryPageLimit(options?.historyPageLimit) : 0, - }; -} - -function snapshotRequestSatisfies( - current: GitHubSnapshotRequestKey | null, - requested: GitHubSnapshotRequestKey, -): boolean { - if (!current) return false; - if (!requested.includeExternalClosed) return true; - return current.includeExternalClosed && current.historyPageLimit >= requested.historyPageLimit; -} - -function readGitHubTabWarmCache(projectRoot: string | null): GitHubTabWarmCache | null { - if (GITHUB_TAB_CACHE_DISABLED) return null; - if (!projectRoot) return null; - if (githubTabWarmCache?.projectRoot !== projectRoot) return null; - const filter = normalizeGitHubFilter((githubTabWarmCache as { filter?: unknown }).filter); - return filter === githubTabWarmCache.filter ? githubTabWarmCache : { ...githubTabWarmCache, filter }; -} - -function writeGitHubTabWarmCache(cache: GitHubTabWarmCache): void { - if (GITHUB_TAB_CACHE_DISABLED) return; - if (!cache.projectRoot) return; - githubTabWarmCache = cache; -} - -function formatGitHubSnapshotError(err: unknown): string { - const raw = err instanceof Error ? err.message : String(err ?? ""); - const message = raw - .replace(/^Error invoking remote method '[^']+':\s*/i, "") - .replace(/^Error:\s*/i, "") - .trim(); - if (/github (token|auth) missing/i.test(message)) { - return "Connect GitHub in Settings with gh auth or a PAT to sync pull requests."; - } - return message || "Unable to sync pull requests."; -} - -function formatActionError(err: unknown): string { - const raw = err instanceof Error ? err.message : String(err ?? ""); - return raw - .replace(/^Error invoking remote method '[^']+':\s*/i, "") - .replace(/^Error:\s*/i, "") - .trim() || "Action failed."; -} - -function createLaneFromPrBranchApi(): CreateLaneFromPrBranchApi { - return window.ade.prs as typeof window.ade.prs & CreateLaneFromPrBranchApi; -} - -function createLaneFromPrBranchArgs(item: GitHubPrListItem): CreateLaneFromPrBranchArgs { - return { - repoOwner: item.repoOwner, - repoName: item.repoName, - githubPrNumber: item.githubPrNumber, - }; -} - -function createLaneFromPrBranchRequestKey(item: GitHubPrListItem): string { - return `${item.repoOwner}/${item.repoName}#${Number(item.githubPrNumber)}`; -} - -function preflightText(value: unknown): string | null { - if (value == null) return null; - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed || null; - } - if (typeof value === "number" || typeof value === "boolean") return String(value); - if (typeof value === "object") { - const record = value as Record; - const message = preflightText(record.message) ?? preflightText(record.reason) ?? preflightText(record.summary); - if (message) return message; - try { - return JSON.stringify(value); - } catch { - return "Conflict details unavailable."; - } - } - return String(value); -} - -function preflightPrNumber(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): number { - return Number(preflight?.githubPrNumber ?? item.githubPrNumber); -} - -function preflightTitle(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): string { - return preflightText(preflight?.title) ?? item.title; -} - -function preflightRemoteBranch(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): string { - return preflightText(preflight?.remoteBranch) ?? preflightText(preflight?.headBranch) ?? item.headBranch ?? "---"; -} - -function preflightImportRef(preflight: CreateLaneFromPrBranchPreflight | null): string | null { - return preflightText(preflight?.importBranchRef); -} - -function preflightTargetLaneName(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): string { - const remoteBranch = preflightRemoteBranch(preflight, item); - const fallback = branchNameFromRef(remoteBranch); - return preflightText(preflight?.targetLaneName) ?? (fallback || "New lane"); -} - -function preflightBaseBranch(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): string { - return preflightText(preflight?.baseBranch) ?? item.baseBranch ?? "---"; -} - -function preflightBlockingConflict(preflight: CreateLaneFromPrBranchPreflight | null): string | null { - return preflightText(preflight?.blockingConflict); -} - -function createLaneMappedPrId(result: CreateLaneFromPrBranchResult): string | null { - return preflightText(result.pr?.id); -} - -function createLaneMappedLaneId(result: CreateLaneFromPrBranchResult): string | null { - return preflightText(result.pr?.laneId) ?? preflightText(result.lane?.id); -} - -function createLaneMappedLaneName(result: CreateLaneFromPrBranchResult): string | null { - return preflightText(result.lane?.name); -} - -function upsertLaneSummary(lanes: LaneSummary[], lane: LaneSummary): LaneSummary[] { - const index = lanes.findIndex((entry) => entry.id === lane.id); - if (index === -1) return [lane, ...lanes]; - const next = lanes.slice(); - next[index] = lane; - return next; -} - -function isKnownPrState(value: unknown): value is PrSummary["state"] { - return value === "draft" || value === "open" || value === "merged" || value === "closed"; -} - -function reconcileLinkedPrState(item: GitHubPrListItem, linkedPr: PrSummary | null | undefined): GitHubPrListItem { - if (!isKnownPrState(linkedPr?.state)) return item; - if (!isTerminalPrState(linkedPr.state) || isTerminalPrState(item.state)) return item; - return { - ...item, - state: linkedPr.state, - isDraft: false, - title: linkedPr.title || item.title, - updatedAt: linkedPr.updatedAt || item.updatedAt, - }; -} - -function matchesFilter(item: GitHubPrListItem, filter: GitHubFilter): boolean { - if (filter === "open") return item.state === "open" || item.state === "draft"; - return item.state === filter; -} - -/** The filter bucket a PR row belongs in based on its (reconciled) state. */ -function bucketForState(state: GitHubPrListItem["state"]): GitHubFilter { - return state === "merged" ? "merged" : state === "closed" ? "closed" : "open"; -} - -/** Stable repo/owner/number coordinate key, shared by the terminal-row overlay. */ -function githubCoordKey(item: { repoOwner: string; repoName: string; githubPrNumber: number }): string { - return `${item.repoOwner}/${item.repoName}#${Number(item.githubPrNumber)}`; -} - -/** - * Build a terminal-bucket overlay row for a linked PR that has dropped out of an - * open-only GitHub snapshot. Reuses the last-seen row (its lane info, labels, and - * — crucially — its `id`, so the current selection stays attached) and stamps the - * authoritative terminal state from the linked ADE PR. - */ -function buildOverlayRowFromLastSeen(lastSeen: GitHubPrListItem, linkedPr: PrSummary): GitHubPrListItem { - return { - ...lastSeen, - state: linkedPr.state, - isDraft: false, - title: linkedPr.title || lastSeen.title, - updatedAt: linkedPr.updatedAt || lastSeen.updatedAt, - linkedPrId: linkedPr.id, - }; -} - -/** - * Terminal-bucket overlay rows: linked ADE PRs that have gone merged/closed but - * have dropped out of the open-only GitHub snapshot. We keep their last-seen row - * visible under the terminal bucket until a full-history fetch reintroduces the - * authoritative row (at which point it is `present` and produces no overlay). - * Reopened PRs (linked state back to open) are non-terminal, so they produce no - * overlay and naturally drop. Rows never previously displayed are skipped — we - * have no lane/label info to synthesize from. - */ -function computeTerminalOverlayItems( - reconciledItems: GitHubPrListItem[], - prsById: Map, - lastSeenByCoord: Map, -): GitHubPrListItem[] { - if (prsById.size === 0) return []; - const presentCoords = new Set(reconciledItems.map((item) => githubCoordKey(item))); - const overlays: GitHubPrListItem[] = []; - const usedLinkedIds = new Set(); - for (const pr of prsById.values()) { - if (!isTerminalPrState(pr.state)) continue; - if (usedLinkedIds.has(pr.id)) continue; - const key = githubCoordKey(pr); - if (presentCoords.has(key)) continue; - const lastSeen = lastSeenByCoord.get(key); - if (!lastSeen) continue; - usedLinkedIds.add(pr.id); - overlays.push(buildOverlayRowFromLastSeen(lastSeen, pr)); - } - return overlays; -} - -function mergeGitHubListItems(snapshot: GitHubPrSnapshot): GitHubPrListItem[] { - const combined = [...snapshot.repoPullRequests, ...snapshot.externalPullRequests]; - const seen = new Set(); - return combined.filter((item) => { - const key = `${item.scope}:${item.repoOwner}/${item.repoName}#${item.githubPrNumber}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -type GitHubFilterCounts = Record; - -function countGitHubItemsByState(items: GitHubPrListItem[]): GitHubFilterCounts { - return { - open: items.filter((item) => item.state === "open" || item.state === "draft").length, - closed: items.filter((item) => item.state === "closed").length, - merged: items.filter((item) => item.state === "merged").length, - }; -} - -/* -- Color-coded state badge with distinct colors per state -- */ -function stateColor(state: string): { bg: string; border: string; text: string } { - switch (state) { - case "open": - return { bg: "rgba(59,130,246,0.10)", border: "rgba(59,130,246,0.20)", text: "#60A5FA" }; - case "draft": - return { bg: "rgba(245,158,11,0.10)", border: "rgba(245,158,11,0.20)", text: "#FBBF24" }; - case "merged": - return { bg: "rgba(34,197,94,0.10)", border: "rgba(34,197,94,0.20)", text: "#4ADE80" }; - default: - return { bg: "rgba(161,161,170,0.08)", border: "rgba(161,161,170,0.15)", text: "#A1A1AA" }; - } -} - -function stateBadgeStyle(item: GitHubPrListItem): React.CSSProperties { - const c = stateColor(item.state); - return { - display: "inline-flex", - alignItems: "center", - padding: "2px 7px", - fontSize: 10, - fontWeight: 600, - fontFamily: SANS_FONT, - color: c.text, - background: c.bg, - border: `1px solid ${c.border}`, - borderRadius: 5, - textTransform: "capitalize", - }; -} - -/* -- CI status dot color -- */ -function ciDotColor(linkedPr: PrSummary | null): { color: string; title: string } | null { - if (!linkedPr) return null; - switch (linkedPr.checksStatus) { - case "passing": - return { color: COLORS.success, title: "CI passing" }; - case "failing": - return { color: COLORS.danger, title: "CI failing" }; - case "pending": - return { color: COLORS.warning, title: "CI pending" }; - default: - return null; - } -} - -/* -- Review status indicator -- */ -function reviewIndicator(linkedPr: PrSummary | null): { color: string; label: string } | null { - if (!linkedPr) return null; - switch (linkedPr.reviewStatus) { - case "approved": - return { color: COLORS.success, label: "Approved" }; - case "changes_requested": - return { color: COLORS.danger, label: "Changes" }; - case "requested": - return { color: COLORS.warning, label: "Review required" }; - default: - return null; - } -} - -/* -- adeKind badge with distinctive styling -- */ -const ADE_KIND_STYLES: Record = { - integration: { - color: "#FBBF24", - background: "linear-gradient(135deg, rgba(245,158,11,0.14) 0%, rgba(217,119,6,0.06) 100%)", - border: "1px solid rgba(245,158,11,0.22)", - }, -}; - -function AdeKindBadge({ kind }: { kind: GitHubPrListItem["adeKind"] }): React.ReactElement | null { - if (!kind || kind === "single") return null; - const style = ADE_KIND_STYLES[kind]; - if (!style) return null; - return {kind}; -} - -function adeKindBadgeStyle(style: { color: string; background: string; border: string }): React.CSSProperties { - return { - display: "inline-flex", - alignItems: "center", - padding: "2px 7px", - fontSize: 10, - fontWeight: 600, - fontFamily: SANS_FONT, - color: style.color, - background: style.background, - border: style.border, - borderRadius: 5, - }; -} - -/* -- Label text color from hex background (luminance-aware) -- */ -function labelTextColor(hexColor: string): string { - const hex = hexColor.replace("#", ""); - const r = parseInt(hex.substring(0, 2), 16) || 0; - const g = parseInt(hex.substring(2, 4), 16) || 0; - const b = parseInt(hex.substring(4, 6), 16) || 0; - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance > 0.5 ? "#1a1a2e" : "#f0f0f0"; -} - -/* -- Filter button styles -- */ -const FILTER_COLORS: Record = { - open: { - active: { bg: "linear-gradient(135deg, rgba(59,130,246,0.16) 0%, rgba(37,99,235,0.08) 100%)", border: "rgba(59,130,246,0.30)", text: "#60A5FA", shadow: "0 0 10px rgba(59,130,246,0.12)" }, - inactive: { text: "#60A5FA" }, - }, - closed: { - active: { bg: "linear-gradient(135deg, rgba(161,161,170,0.14) 0%, rgba(113,113,122,0.06) 100%)", border: "rgba(161,161,170,0.25)", text: "#A1A1AA", shadow: "0 0 10px rgba(161,161,170,0.08)" }, - inactive: { text: "#71717A" }, - }, - merged: { - active: { bg: "linear-gradient(135deg, rgba(34,197,94,0.14) 0%, rgba(22,163,74,0.06) 100%)", border: "rgba(34,197,94,0.28)", text: "#4ADE80", shadow: "0 0 10px rgba(34,197,94,0.10)" }, - inactive: { text: "#4ADE80" }, - }, -}; - -function useLaneColorById(laneId: string | null | undefined): string | null { - return useAppStore((s) => { - if (!laneId) return null; - return s.lanes.find((l) => l.id === laneId)?.color ?? null; - }); -} - -/** - * Whether the "unmapped" badge would actually lead somewhere. Selecting the row offers - * one of two actions depending on whether a lane already tracks the head branch — map - * to it, or create one — so the presence of a usable local branch is the real gate. - * Fork PRs have no local branch to work with, and a terminal PR cannot be mapped at all. - * - * Drives the amber-vs-neutral choice, so the warning colour is only ever spent on rows - * the user can do something about. - */ -function isPrRowMappable(item: GitHubPrListItem): boolean { - if (item.linkedPrId || item.scope !== "repo") return false; - if (item.state !== "open" && item.state !== "draft") return false; - return Boolean(branchNameFromRef(item.headBranch)); -} - -function branchNameFromRef(ref: string | null | undefined): string { - return String(ref ?? "").replace(/^refs\/heads\//, "").trim(); -} - -function canCreateLaneFromPrBranch(item: GitHubPrListItem, lanes: LaneSummary[]): boolean { - if (item.linkedPrId || item.scope !== "repo") return false; - if (item.state !== "open" && item.state !== "draft") return false; - const headBranch = branchNameFromRef(item.headBranch); - if (!headBranch) return false; - return !lanes.some((lane) => !lane.archivedAt && branchNameFromRef(lane.branchRef) === headBranch); -} - -function sameGitHubPr(left: GitHubPrListItem, right: GitHubPrListItem): boolean { - return left.repoOwner === right.repoOwner - && left.repoName === right.repoName - && Number(left.githubPrNumber) === Number(right.githubPrNumber); -} - -/** - * Stable synthetic PR id for an unmapped GitHub PR (no ADE lane / DB row). - * Must be deterministic across renders — it keys both per-id effects in the - * pane and the React `key`. - */ -function syntheticUnmappedPrId(item: GitHubPrListItem): string { - return syntheticGithubPrId(item); -} - -/** - * Build a referentially-stable synthetic `PrWithConflicts` for an unmapped - * GitHub PR so it can flow through the full `PrDetailPane`. `laneId` is empty - * (no lane) and the id is derived deterministically from the GitHub item. - */ -function buildSyntheticUnmappedPr(item: GitHubPrListItem, projectId: string): PrWithConflicts { - return { - id: syntheticUnmappedPrId(item), - laneId: "", - projectId, - repoOwner: item.repoOwner, - repoName: item.repoName, - githubPrNumber: item.githubPrNumber, - githubUrl: item.githubUrl, - githubNodeId: null, - title: item.title, - state: item.state, - baseBranch: item.baseBranch ?? "", - headBranch: item.headBranch ?? "", - checksStatus: "none", - reviewStatus: "none", - additions: 0, - deletions: 0, - lastSyncedAt: null, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - stack: item.stack ?? null, - conflictAnalysis: null, - }; -} - -function patchSnapshotWithMappedPr( - snapshot: GitHubPrSnapshot, - item: GitHubPrListItem, - args: { - mappedPrId: string; - laneId: string | null; - laneName: string | null; - }, -): GitHubPrSnapshot { - const patchItems = (items: GitHubPrListItem[]) => items.map((candidate) => { - if (candidate.id !== item.id && !sameGitHubPr(candidate, item)) return candidate; - return { - ...candidate, - linkedPrId: args.mappedPrId, - linkedLaneId: args.laneId ?? candidate.linkedLaneId, - linkedLaneName: args.laneName ?? candidate.linkedLaneName, - adeKind: candidate.adeKind ?? "single", - }; - }); - return { - ...snapshot, - repoPullRequests: patchItems(snapshot.repoPullRequests), - externalPullRequests: patchItems(snapshot.externalPullRequests), - }; -} - -function CreateLaneFromPrBranchDialog({ - item, - preflight, - loading, - busy, - error, - onCancel, - onConfirm, -}: { - item: GitHubPrListItem; - preflight: CreateLaneFromPrBranchPreflight | null; - loading: boolean; - busy: boolean; - error: string | null; - onCancel: () => void; - onConfirm: () => void; -}) { - const blockingConflict = preflightBlockingConflict(preflight); - const canConfirm = Boolean(preflight?.canCreate) && !loading && !busy; - const sourceBranch = preflightRemoteBranch(preflight, item); - const importRef = preflightImportRef(preflight); - const rows = [ - ["PR", `#${preflightPrNumber(preflight, item)} ${preflightTitle(preflight, item)}`], - ["Source branch", sourceBranch], - ...(importRef && importRef !== sourceBranch ? [["Import ref", importRef] as const] : []), - ["Target lane", preflightTargetLaneName(preflight, item)], - ["Base branch", preflightBaseBranch(preflight, item)], - ] as const; - - return ( -
-
-
-
- Create lane from PR branch -
-
-
- {loading ? ( -
- Checking branch ownership and PR head availability... -
- ) : ( -
- {rows.map(([label, value]) => ( -
-
{label}
-
- {value} -
-
- ))} -
- )} - {blockingConflict ? ( -
- - {blockingConflict} -
- ) : null} - {error ? ( -
- {error} -
- ) : null} -
-
- - -
-
-
- ); -} - export function GitHubTab({ lanes, mergeMethod, @@ -753,7 +95,6 @@ export function GitHubTab({ const appStore = useAppStoreApi(); const { prs, - mergeContextByPrId, detailStatus, detailChecks, detailReviews, @@ -818,7 +159,6 @@ export function GitHubTab({ const externalHistoryLoadedRef = React.useRef(externalHistoryLoaded); const projectRootRef = React.useRef(projectRoot); const listRef = React.useRef(null); - const defaultListPx = React.useMemo(() => readPersistedGithubPrListPx(), []); snapshotRef.current = snapshot; filterRef.current = filter; externalHistoryLoadedRef.current = externalHistoryLoaded; @@ -1034,87 +374,22 @@ export function GitHubTab({ }); }, [currentHistoryPageLimit, loadSnapshot, prs, prsContextLoading, startHotRefreshWindow]); - const matchesSearch = React.useCallback((item: GitHubPrListItem) => { - if (!searchQuery.trim()) return true; - const q = searchQuery.trim().toLowerCase(); - return ( - item.title.toLowerCase().includes(q) || - (item.author?.toLowerCase().includes(q) ?? false) || - (item.headBranch?.toLowerCase().includes(q) ?? false) || - String(item.githubPrNumber).includes(q) - ); - }, [searchQuery]); - - const allItems = React.useMemo( - () => (snapshot ? mergeGitHubListItems(snapshot) : []), - [snapshot], - ); - const reconciledItems = React.useMemo( - () => allItems.map((item) => - reconcileLinkedPrState(item, item.linkedPrId ? prsByIdMap.get(item.linkedPrId) : null) - ), - [allItems, prsByIdMap], - ); - - // Remember every row we have actually shown so the terminal-row overlay can - // resurrect one that later drops out of an open-only snapshot. The map only - // grows within a session (PR counts are small), and is read during render by - // the overlay memo below — recorded after commit so the overlay memo still - // sees the prior row on the render where a PR first disappears. - React.useEffect(() => { - const map = lastSeenRowByCoordRef.current; - for (const item of allItems) { - map.set(githubCoordKey(item), item); - } - }, [allItems]); - - const overlayItems = React.useMemo( - () => computeTerminalOverlayItems(reconciledItems, prsByIdMap, lastSeenRowByCoordRef.current), - [reconciledItems, prsByIdMap], - ); - - const displayedItems = React.useMemo( - () => (overlayItems.length === 0 ? reconciledItems : [...reconciledItems, ...overlayItems]), - [reconciledItems, overlayItems], - ); - - const filteredItems = React.useMemo( - () => displayedItems - .filter((item) => matchesFilter(item, filter) && matchesSearch(item)) - .sort((a, b) => - new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime(), - ), - [displayedItems, filter, matchesSearch], - ); - const hydrationItems = filteredItems.length > VIRTUALIZE_AT ? renderedHydrationItems : filteredItems; - - // Period headers only in the terminal buckets. `filteredItems` stays a pure item - // array so selection, hydration and sorting keep operating on rows alone. - const listRows = React.useMemo( - () => buildPrListRows(filteredItems, { grouped: filter === "merged" || filter === "closed" }), - [filteredItems, filter], - ); - - const filterCounts = React.useMemo(() => { - const listedCounts = countGitHubItemsByState(displayedItems); - const snapshotCounts = snapshot?.history?.repoPullRequestCounts; - // Snapshot totals were computed server-side from the raw snapshot, so a - // row that reconciliation moved between states (stale open → merged) - // must move in the badge totals too — otherwise the item changes tabs - // while the counts still bucket it under its stale state. - const rawCounts = countGitHubItemsByState(allItems); - const withReconcileDelta = (base: number | null | undefined, key: keyof GitHubFilterCounts, fallback: number): number => - base == null ? fallback : Math.max(0, base + listedCounts[key] - rawCounts[key]); - return { - open: withReconcileDelta(snapshotCounts?.open, "open", listedCounts.open), - closed: withReconcileDelta(snapshotCounts?.closed, "closed", listedCounts.closed), - merged: withReconcileDelta(snapshotCounts?.merged, "merged", listedCounts.merged), - }; - }, [allItems, displayedItems, snapshot?.history?.repoPullRequestCounts]); - const canLoadOlderHistory = - filter !== "open" - && Boolean(snapshot?.history?.repoPullRequestsMayHaveMore) - && currentHistoryPageLimit() < GITHUB_TAB_HISTORY_MAX_PAGE_LIMIT; + const { + displayedItems, + filteredItems, + hydrationItems, + listRows, + filterCounts, + canLoadOlderHistory, + } = useGitHubTabListModel({ + snapshot, + searchQuery, + prsByIdMap, + filter, + renderedHydrationItems, + lastSeenRowByCoordRef, + currentHistoryPageLimit, + }); const showListLoadingIndicator = loading || syncing || loadingFilter !== null; React.useEffect(() => { @@ -1624,296 +899,80 @@ export function GitHubTab({ selectedItem, ]); - if (error && !snapshot) { - return ( - - - - ); - } + const detailPaneProps = selectedItem && selectedDisplayPr ? { + pr: selectedDisplayPr, + status: selectedLinkedPr ? detailStatus : null, + checks: selectedLinkedPr ? detailChecks : [], + reviews: selectedLinkedPr ? detailReviews : [], + comments: selectedLinkedPr ? detailComments : [], + snapshotHydration: selectedLinkedPr + ? (detailSnapshot?.prId === selectedDisplayPr.id + ? detailSnapshot + : detailSnapshotsByPrId[selectedDisplayPr.id] ?? null) + : null, + snapshotHydrationOwnedByContext: Boolean(selectedLinkedPr), + liveDetailReady: Boolean(selectedLinkedPr) && detailLiveDataPrId === selectedDisplayPr.id, + detailBusy, + lanes, + mergeMethod, + onRefresh: handleSync, + onNavigate: navigate, + onOpenRebaseTab, + initialDetailTab: selectedDetailTab, + onDetailTabChange, + onUnmap: selectedItem.linkedPrId ? () => handleUnlink(selectedItem) : undefined, + unmapBusy: Boolean(selectedItem.linkedPrId) && unlinkingPrId === selectedItem.linkedPrId, + unmapped: !selectedItem.linkedPrId, + githubCoords: selectedItem.linkedPrId ? null : selectedGithubCoords, + unmappedAffordance: selectedItem.linkedPrId ? null : unmappedAffordance, + } : null; return ( -
- {/* Search / sync chrome only renders inline when it hasn't been hoisted to - the shared PRs header. The Open/Merged/Closed tabs now live at the top of - the list column (below) so the detail pane can rise to sit level with them. */} - {!relocateHeaderChrome ? ( -
- - { - void handleSync(); - }} - /> -
- ) : null} - - {error ? ( -
- {error} -
- ) : null} - -
- - persistGithubPrListPx(size.inPixels)} - className="min-h-0 min-w-0" - style={{ overflow: "hidden", borderRight: "1px solid rgba(255,255,255,0.06)" }} - > -
- {/* Filter tabs (Open / Merged / Closed) — fixed header capping the - list column. The detail pane to the right rises to sit level with - these; the list min width keeps the right edge of "Closed" aligned - with the list/detail divider. */} -
- {(["open", "merged", "closed"] as GitHubFilter[]).map((state) => { - const active = filter === state; - const fc = FILTER_COLORS[state]; - const count = filterCounts[state]; - const icon = state === "merged" ? : null; - const tabLoading = active && (loading || syncing || loadingFilter === state || loadingOlderHistory); - return ( - - ); - })} -
- {showListLoadingIndicator ? ( - - - - ) : null} -
-
- {filteredItems.length === 0 ? ( -
- -
- ) : filteredItems.length > VIRTUALIZE_AT ? ( - - ) : ( - listRows.map((row) => ( - row.kind === "header" ? ( - - ) : ( - - ) - )) - )} - {canLoadOlderHistory ? ( -
- -
- ) : null} -
-
- - - - {selectedItem && selectedDisplayPr ? ( -
- {selectedBucketMismatch ? ( -
- handleFilterChange(bucketForState(selectedItem.state))} - /> -
- ) : null} - {selectedStack ? ( - item.repoOwner === selectedStack.repoOwner - && item.repoName === selectedStack.repoName, - )} - selectedPrNumber={selectedItem.githubPrNumber} - syncing={syncing} - onSelectPr={handleSelectItem} - onOpenGitHub={() => { - void window.ade.app.openExternal(selectedItem.githubUrl); - }} - onSync={() => { - void handleSync(); - }} - onAddPullRequests={handleAddStackPullRequests} - onUnstack={handleUnstack} - /> - ) : null} -
- handleUnlink(selectedItem) : undefined} - unmapBusy={Boolean(selectedItem.linkedPrId) && unlinkingPrId === selectedItem.linkedPrId} - unmapped={!selectedItem.linkedPrId} - githubCoords={selectedItem.linkedPrId ? null : selectedGithubCoords} - unmappedAffordance={selectedItem.linkedPrId ? null : unmappedAffordance} - /> -
-
- ) : ( -
- -
- )} -
- -
+ <> + { void handleSync(); }, + error, + onConnectGitHub: () => navigate("/settings?tab=general#github-connection"), + }} + list={{ + parentRef: listRef, + filter, + filterCounts, + loading, + loadingFilter, + loadingOlderHistory, + showLoadingIndicator: showListLoadingIndicator, + hasSnapshot: Boolean(snapshot), + filteredItems, + rows: listRows, + selectedItemId, + prsByIdMap, + canLoadOlderHistory, + onFilterChange: handleFilterChange, + onSelect: handleSelectItem, + onHydrationItemsChange: handleHydrationItemsChange, + onLoadOlderHistory: () => { void handleLoadOlderHistory(); }, + }} + detail={{ + selectedItem, + selectedBucketMismatch, + selectedStack, + displayedItems, + paneProps: detailPaneProps, + onSelect: handleSelectItem, + onSync: () => { void handleSync(); }, + onAddStackPullRequests: handleAddStackPullRequests, + onUnstack: handleUnstack, + onFilterChange: handleFilterChange, + }} + /> {createLaneItem ? ( ) : null} -
- ); -} - -/** - * Slim banner shown at the top of the detail pane when the selected PR's state - * has moved out of the active filter's bucket (transiently, or after a manual - * filter change). Matches the neighboring lane/rebase banner idiom — no new - * colors or gradients. - */ -function PrBucketTransitionBanner({ - state, - onShow, -}: { - state: GitHubPrListItem["state"]; - onShow: () => void; -}) { - const isMerged = state === "merged"; - const label = isMerged ? "Merged" : "Closed"; - const accent = isMerged ? COLORS.success : COLORS.danger; - return ( -
-
-
- {isMerged ? ( - - ) : ( - - )} - - This PR is now {label} - -
- -
-
- ); -} - -/** - * The lane column of a PR row. - * - * Three states, deliberately distinct: - * - **mapped** — the lane chip, in the lane's colour. - * - **detached** — `was: ` plus the activity frozen when the lane was deleted. - * This is history, so it is dim and carries no call to action. - * - **no lane** — nothing at all in terminal buckets (absence already reads as "no - * lane"), and a neutral chip in Open. It only turns amber when there is genuinely - * something to do, so amber keeps meaning "act on this". - */ -function PrRowLaneChip({ - item, - linkedLaneColor, - mappable, -}: { - item: GitHubPrListItem; - linkedLaneColor: string | null; - mappable: boolean; -}) { - if (item.linkedLaneName || item.linkedLaneId) { - return ( - - {linkedLaneColor ? : null} - {item.linkedLaneName ?? item.linkedLaneId} - - ); - } - - if (item.detached) return ; - - // Terminal PRs have no mapping story worth telling — the lane is gone and mapping one - // now would do nothing. Showing a badge here is what made Merged a wall of warnings. - if (isTerminalPrState(item.state)) return null; - - const actionable = mappable; - return ( - - unmapped - - ); -} - -/** - * `arul · squash → main` — how a terminal PR shipped, folded onto the meta line in - * place of the branch row. Every part is optional: PRs merged before ADE recorded - * merge metadata simply show less, rather than showing placeholders. - */ -function PrRowMergeFacts({ item }: { item: GitHubPrListItem }) { - const parts = [ - item.mergedBy?.login ?? null, - item.mergeMethod, - item.baseBranch ? `→ ${item.baseBranch}` : null, - ].filter(Boolean) as string[]; - if (parts.length === 0) return null; - return ( - - {parts.join(" · ")} - - ); -} - -function PrRowDiffStat({ additions, deletions }: { additions: number | null; deletions: number | null }) { - if (additions == null && deletions == null) return null; - return ( - - +{additions ?? 0} - -{deletions ?? 0} - - ); -} - -/** `was: · 3 chats · 2 proof` — what ADE knows that GitHub cannot show. */ -function PrRowGhostLaneChip({ detached }: { detached: NonNullable }) { - const counts = [ - detached.chats > 0 ? `${detached.chats} chat${detached.chats === 1 ? "" : "s"}` : null, - detached.artifacts > 0 ? `${detached.artifacts} proof` : null, - ].filter(Boolean) as string[]; - const name = detached.laneName?.trim(); - const detachedAgo = formatTimeAgoCompact(detached.at); - if (!name && counts.length === 0) return null; - return ( - - {detached.laneColor ? : null} - {name ? was: {name} : null} - {counts.length > 0 ? · {counts.join(" · ")} : null} - - ); -} - -/* ---- PR row (shared between list and virtualizer) ---- */ -function GitHubTabPrRow({ - item, - selected, - linkedPr, - onSelect, -}: { - item: GitHubPrListItem; - selected: boolean; - linkedPr: PrSummary | null; - onSelect: (item: GitHubPrListItem) => void; -}) { - const sc = stateColor(item.state); - // A merged PR is a record, not a queue item: CI outcome, "review required" and the - // state badge are all answered by the fact that it merged. Dropping them is what lets - // the row collapse to two lines and stops the list reading as a wall of signals. - const terminal = isTerminalPrState(item.state); - const ci = terminal ? null : ciDotColor(linkedPr); - const ciRunning = linkedPr?.checksStatus === "pending"; - const review = terminal ? null : reviewIndicator(linkedPr); - // Open rows are about how long something has been waiting; merged rows are about - // when it shipped. - const ago = formatTimeAgoCompact(terminal ? (item.mergedAt ?? item.updatedAt) : item.createdAt); - const labels = item.labels ?? []; - const visibleLabels = labels.slice(0, 4); - const overflowCount = labels.length - 4; - const rowLinkedLaneColor = useLaneColorById(item.linkedLaneId ?? null); - const mappable = isPrRowMappable(item); - return ( - - ); -} - -/* ---- Virtual list for GitHub PR sidebar (activated above VIRTUALIZE_AT) ---- */ -function GitHubTabVirtualList({ - parentRef, - rows, - selectedItemId, - prsByIdMap, - onSelect, - onHydrationItemsChange, -}: { - parentRef: React.RefObject; - rows: PrListRow[]; - selectedItemId: string | null; - prsByIdMap: Map; - onSelect: (item: GitHubPrListItem) => void; - onHydrationItemsChange: (items: GitHubPrListItem[]) => void; -}) { - const headerIndices = React.useMemo(() => prListHeaderIndices(rows), [rows]); - // The header governing the current scroll position. Kept in a ref as well as state - // so `rangeExtractor` (called during measurement) can read it without re-subscribing. - const activeHeaderRef = React.useRef(headerIndices[0] ?? null); - const [activeHeaderIndex, setActiveHeaderIndex] = React.useState( - headerIndices[0] ?? null, - ); - - const virtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => parentRef.current, - // Headers are much shorter than rows; a bad estimate here makes the scrollbar jump. - estimateSize: (index) => (rows[index]?.kind === "header" ? 30 : 108), - overscan: 6, - rangeExtractor: React.useCallback( - (range: { startIndex: number; endIndex: number; overscan: number; count: number }) => { - // Pin the header for the topmost visible row so the period stays legible while - // scrolling deep into history. - const pinned = headerIndices.filter((index) => index <= range.startIndex).pop() ?? null; - activeHeaderRef.current = pinned; - const start = Math.max(0, range.startIndex - range.overscan); - const end = Math.min(range.count - 1, range.endIndex + range.overscan); - const indices = new Set(); - if (pinned != null) indices.add(pinned); - for (let index = start; index <= end; index += 1) indices.add(index); - return [...indices].sort((a, b) => a - b); - }, - [headerIndices], - ), - }); - - const virtualItems = virtualizer.getVirtualItems(); - - React.useEffect(() => { - if (activeHeaderRef.current !== activeHeaderIndex) setActiveHeaderIndex(activeHeaderRef.current); - }, [activeHeaderIndex, virtualItems]); - - React.useEffect(() => { - // Only PR rows may be hydrated — a header has nothing to fetch. - onHydrationItemsChange( - virtualItems - .map((virtualRow) => rows[virtualRow.index]) - .filter((row): row is Extract => row?.kind === "item") - .map((row) => row.item), - ); - }, [rows, onHydrationItemsChange, virtualItems]); - - return ( -
- {virtualItems.map((virtualRow) => { - const row = rows[virtualRow.index]!; - const pinned = row.kind === "header" && virtualRow.index === activeHeaderIndex; - return ( -
- {row.kind === "header" ? ( - - ) : ( - - )} -
- ); - })} -
- ); -} - -/** - * Period header for the merged/closed log. Announced as a heading so screen readers get - * the same structure sighted users do, instead of an undifferentiated pile of buttons. - */ -function PrListGroupHeaderRow({ header }: { header: PrListGroupHeaderModel }) { - const diff = formatPrListGroupDiff(header.additions, header.deletions); - return ( -
- {header.label} - - {header.count} {header.outcome}{diff ? ` · ${diff}` : ""} - -
+ ); } diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTabCreateLaneDialog.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTabCreateLaneDialog.tsx new file mode 100644 index 000000000..33b49de43 --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTabCreateLaneDialog.tsx @@ -0,0 +1,312 @@ +import { GitBranch, Warning } from "@phosphor-icons/react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { useEffect, useRef } from "react"; +import type { + CreateLaneFromPrBranchArgs, + CreateLaneFromPrBranchPreflight, + CreateLaneFromPrBranchPreflightResult, + CreateLaneFromPrBranchResult, + GitHubPrListItem, + GitHubPrSnapshot, + LaneSummary, +} from "../../../../shared/types"; +import { + COLORS, + LABEL_STYLE, + MONO_FONT, + SANS_FONT, + outlineButton, + primaryButton, +} from "../../lanes/laneDesignTokens"; +import { branchNameFromRef } from "./githubPrBranch"; + +type CreateLaneFromPrBranchApi = { + preflightCreateLaneFromPrBranch: ( + args: CreateLaneFromPrBranchArgs, + ) => Promise; + createLaneFromPrBranch: ( + args: CreateLaneFromPrBranchArgs, + ) => Promise; +}; + +export function formatActionError(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err ?? ""); + return raw + .replace(/^Error invoking remote method '[^']+':\s*/i, "") + .replace(/^Error:\s*/i, "") + .trim() || "Action failed."; +} + +export function createLaneFromPrBranchApi(): CreateLaneFromPrBranchApi { + return window.ade.prs as typeof window.ade.prs & CreateLaneFromPrBranchApi; +} + +export function createLaneFromPrBranchArgs(item: GitHubPrListItem): CreateLaneFromPrBranchArgs { + return { + repoOwner: item.repoOwner, + repoName: item.repoName, + githubPrNumber: item.githubPrNumber, + }; +} + +export function createLaneFromPrBranchRequestKey(item: GitHubPrListItem): string { + return `${item.repoOwner}/${item.repoName}#${Number(item.githubPrNumber)}`; +} + +function preflightText(value: unknown): string | null { + if (value == null) return null; + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || null; + } + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "object") { + const record = value as Record; + const message = preflightText(record.message) ?? preflightText(record.reason) ?? preflightText(record.summary); + if (message) return message; + try { + return JSON.stringify(value); + } catch { + return "Conflict details unavailable."; + } + } + return String(value); +} + +function preflightPrNumber(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): number { + return Number(preflight?.githubPrNumber ?? item.githubPrNumber); +} + +function preflightTitle(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): string { + return preflightText(preflight?.title) ?? item.title; +} + +function preflightRemoteBranch(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): string { + return preflightText(preflight?.remoteBranch) ?? preflightText(preflight?.headBranch) ?? item.headBranch ?? "---"; +} + +function preflightImportRef(preflight: CreateLaneFromPrBranchPreflight | null): string | null { + return preflightText(preflight?.importBranchRef); +} + +function preflightTargetLaneName(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): string { + const remoteBranch = preflightRemoteBranch(preflight, item); + const fallback = branchNameFromRef(remoteBranch); + return preflightText(preflight?.targetLaneName) ?? (fallback || "New lane"); +} + +function preflightBaseBranch(preflight: CreateLaneFromPrBranchPreflight | null, item: GitHubPrListItem): string { + return preflightText(preflight?.baseBranch) ?? item.baseBranch ?? "---"; +} + +function preflightBlockingConflict(preflight: CreateLaneFromPrBranchPreflight | null): string | null { + return preflightText(preflight?.blockingConflict); +} + +export function createLaneMappedPrId(result: CreateLaneFromPrBranchResult): string | null { + return preflightText(result.pr?.id); +} + +export function createLaneMappedLaneId(result: CreateLaneFromPrBranchResult): string | null { + return preflightText(result.pr?.laneId) ?? preflightText(result.lane?.id); +} + +export function createLaneMappedLaneName(result: CreateLaneFromPrBranchResult): string | null { + return preflightText(result.lane?.name); +} + +export function upsertLaneSummary(lanes: LaneSummary[], lane: LaneSummary): LaneSummary[] { + const index = lanes.findIndex((entry) => entry.id === lane.id); + if (index === -1) return [lane, ...lanes]; + const next = lanes.slice(); + next[index] = lane; + return next; +} + +export function canCreateLaneFromPrBranch(item: GitHubPrListItem, lanes: LaneSummary[]): boolean { + if (item.linkedPrId || item.scope !== "repo") return false; + if (item.state !== "open" && item.state !== "draft") return false; + const headBranch = branchNameFromRef(item.headBranch); + if (!headBranch) return false; + return !lanes.some((lane) => !lane.archivedAt && branchNameFromRef(lane.branchRef) === headBranch); +} + +function sameGitHubPr(left: GitHubPrListItem, right: GitHubPrListItem): boolean { + return left.repoOwner === right.repoOwner + && left.repoName === right.repoName + && Number(left.githubPrNumber) === Number(right.githubPrNumber); +} + +export function patchSnapshotWithMappedPr( + snapshot: GitHubPrSnapshot, + item: GitHubPrListItem, + args: { + mappedPrId: string; + laneId: string | null; + laneName: string | null; + }, +): GitHubPrSnapshot { + const patchItems = (items: GitHubPrListItem[]) => items.map((candidate) => { + if (candidate.id !== item.id && !sameGitHubPr(candidate, item)) return candidate; + return { + ...candidate, + linkedPrId: args.mappedPrId, + linkedLaneId: args.laneId ?? candidate.linkedLaneId, + linkedLaneName: args.laneName ?? candidate.linkedLaneName, + adeKind: candidate.adeKind ?? "single", + }; + }); + return { + ...snapshot, + repoPullRequests: patchItems(snapshot.repoPullRequests), + externalPullRequests: patchItems(snapshot.externalPullRequests), + }; +} + +export function CreateLaneFromPrBranchDialog({ + item, + preflight, + loading, + busy, + error, + onCancel, + onConfirm, +}: { + item: GitHubPrListItem; + preflight: CreateLaneFromPrBranchPreflight | null; + loading: boolean; + busy: boolean; + error: string | null; + onCancel: () => void; + onConfirm: () => void; +}) { + const returnFocusRef = useRef( + document.activeElement instanceof HTMLElement ? document.activeElement : null, + ); + const cancelRef = useRef(null); + const blockingConflict = preflightBlockingConflict(preflight); + const canConfirm = Boolean(preflight?.canCreate) && !loading && !busy; + const sourceBranch = preflightRemoteBranch(preflight, item); + const importRef = preflightImportRef(preflight); + const rows = [ + ["PR", `#${preflightPrNumber(preflight, item)} ${preflightTitle(preflight, item)}`], + ["Source branch", sourceBranch], + ...(importRef && importRef !== sourceBranch ? [["Import ref", importRef] as const] : []), + ["Target lane", preflightTargetLaneName(preflight, item)], + ["Base branch", preflightBaseBranch(preflight, item)], + ] as const; + + useEffect(() => { + if (busy) cancelRef.current?.focus(); + }, [busy]); + + return ( + { if (!open && !busy) onCancel(); }}> + + + { + event.preventDefault(); + returnFocusRef.current?.focus(); + }} + onEscapeKeyDown={(event) => { if (busy) event.preventDefault(); }} + style={{ + position: "fixed", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + zIndex: 101, + width: "min(560px, 100%)", + maxWidth: "calc(100vw - 40px)", + borderRadius: 12, + border: `1px solid ${COLORS.border}`, + background: COLORS.cardBgSolid, + boxShadow: "0 24px 80px rgba(0,0,0,0.45)", + overflow: "hidden", + }} + > +
+ + Create lane from PR branch + +
+
+ {loading ? ( +
+ Checking branch ownership and PR head availability... +
+ ) : ( +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ )} + {blockingConflict ? ( +
+ + {blockingConflict} +
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTabRowsAndMapping.test.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTabRowsAndMapping.test.tsx new file mode 100644 index 000000000..a4d3acdb0 --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTabRowsAndMapping.test.tsx @@ -0,0 +1,793 @@ +// @vitest-environment jsdom + +import React from "react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + CreateLaneFromPrBranchResult, + CreateLaneFromPrBranchPreflightResult, + GitHubPrSnapshot, +} from "../../../../shared/types"; + +vi.mock("react-resizable-panels", async () => { + const harness = await import("./GitHubTab.testHarness"); + return { + Group: harness.MockPanelGroup, + Panel: harness.MockPanel, + Separator: harness.MockSeparator, + }; +}); + +vi.mock("../state/PrsContext", async () => { + const { mockUsePrs } = await import("./GitHubTab.testHarness"); + return { usePrs: () => mockUsePrs() }; +}); + +vi.mock("../detail/PrDetailPane", async () => { + const { MockPrDetailPane } = await import("./GitHubTab.testHarness"); + return { PrDetailPane: MockPrDetailPane }; +}); + +import { GitHubTabPrRow } from "../shared/GitHubTabPrRow"; +import { GitHubTab } from "./GitHubTab"; +import { + cleanupGitHubTabTest, + renderGitHubTab, + setupGitHubTabTest, +} from "./GitHubTab.testHarness"; +import { + createDeferred, + makeGitHubPr, + makeLaneSummary, + makePreflightResult, + snapshot, +} from "./GitHubTab.testFixtures"; + +describe("GitHubTab rows and mapping", () => { + beforeEach(() => { + setupGitHubTabTest(); + }); + + afterEach(() => { + cleanupGitHubTabTest(); + }); + + function renderTab(overrides: Parameters[1] = {}) { + return renderGitHubTab(GitHubTab, overrides); + } + + it("keeps the GitHub action outside the row button and does not expose a lane id as its label", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + const item = makeGitHubPr({ + linkedLaneId: "lane-internal-uuid", + linkedLaneName: null, + }); + const { container } = render( + , + ); + + expect(screen.queryByText("lane-internal-uuid")).toBeNull(); + expect(screen.queryByText("unmapped")).toBeNull(); + + const rowButton = container.querySelector('[data-tour="prs.listRow"]'); + const githubButton = screen.getByRole("button", { name: "View on GitHub" }); + expect(rowButton).not.toBeNull(); + expect(rowButton?.contains(githubButton)).toBe(false); + + await user.click(rowButton!); + expect(onSelect).toHaveBeenCalledWith(item); + await user.click(githubButton); + expect(window.ade.app.openExternal).toHaveBeenCalledWith(item.githubUrl); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it("shows a running CI indicator for PR cards with pending checks", async () => { + renderTab(); + + await waitFor(() => { + expect(screen.getAllByLabelText("CI running").length).toBeGreaterThan(0); + }); + }); + + it("shows linked and unmapped PRs together under the status tabs", async () => { + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + ...snapshot.repoPullRequests, + makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + title: "Unlinked PR", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); + renderTab(); + + await waitFor(() => { + expect(screen.getByText("Open PR")).not.toBeNull(); + expect(screen.getByText("Unlinked PR")).not.toBeNull(); + }); + }); + + it("marks unlinked PRs as unmapped", async () => { + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + ...snapshot.repoPullRequests, + makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + title: "Unlinked PR", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); + renderTab(); + + await waitFor(() => { + expect(screen.getByText("Unlinked PR")).not.toBeNull(); + }); + expect(screen.getAllByText("unmapped").length).toBeGreaterThan(0); + }); + + it("does not mark unlinked PRs as unmapped in the merged bucket", async () => { + const user = userEvent.setup(); + const snapshotWithMergedUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + ...snapshot.repoPullRequests, + makeGitHubPr({ + id: "repo-merged-unlinked", + githubPrNumber: 201, + title: "Merged after lane deleted", + state: "merged", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-12T12:00:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithMergedUnlinked); + renderTab(); + + await user.click(await screen.findByRole("button", { name: /merged/i })); + await waitFor(() => { + expect(screen.getByText("Merged after lane deleted")).not.toBeNull(); + }); + // Mapping is a live-work concept: on a merged PR the lane is gone and mapping one + // would do nothing, so the badge must not appear. + expect(screen.queryByText("unmapped")).toBeNull(); + }); + + it("shows frozen lane provenance on a detached merged PR", async () => { + const user = userEvent.setup(); + const snapshotWithDetached: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + ...snapshot.repoPullRequests, + makeGitHubPr({ + id: "repo-detached", + githubPrNumber: 202, + title: "Shipped from a deleted lane", + state: "merged", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-11T12:00:00.000Z", + detached: { + at: "2026-03-12T09:00:00.000Z", + laneName: "auto-naming", + laneColor: "#4ADE80", + chats: 3, + artifacts: 2, + checkpoints: 5, + }, + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithDetached); + renderTab(); + + await user.click(await screen.findByRole("button", { name: /merged/i })); + await waitFor(() => { + expect(screen.getByText("Shipped from a deleted lane")).not.toBeNull(); + }); + expect(screen.getByText("was: auto-naming")).not.toBeNull(); + expect(screen.getByText("· 3 chats · 2 proof")).not.toBeNull(); + expect(screen.queryByText("unmapped")).toBeNull(); + }); + + it("shows merge facts instead of CI and review signals on a merged row", async () => { + const user = userEvent.setup(); + const snapshotWithMerged: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + ...snapshot.repoPullRequests, + makeGitHubPr({ + id: "repo-merged-facts", + githubPrNumber: 203, + title: "Merged with facts", + state: "merged", + baseBranch: "main", + mergedAt: "2026-03-12T10:00:00.000Z", + mergedBy: { login: "arul", avatarUrl: null }, + mergeMethod: "squash", + createdAt: "2026-03-10T12:00:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithMerged); + renderTab(); + + await user.click(await screen.findByRole("button", { name: /merged/i })); + await waitFor(() => { + expect(screen.getByText("Merged with facts")).not.toBeNull(); + }); + expect(screen.getByText("arul · squash · → main")).not.toBeNull(); + }); + + it("renders bot badge when isBot is true", async () => { + const snapshotWithBot: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "bot-pr", + githubPrNumber: 300, + title: "Bot PR", + author: "dependabot[bot]", + isBot: true, + createdAt: "2026-03-13T12:00:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithBot); + renderTab(); + + await waitFor(() => { + expect(screen.getByText("bot")).not.toBeNull(); + }); + }); + + it("renders labels when present", async () => { + const snapshotWithLabels: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "labeled-pr", + githubPrNumber: 400, + title: "Labeled PR", + labels: [ + { name: "bug", color: "d73a4a", description: null }, + { name: "enhancement", color: "a2eeef", description: null }, + ], + createdAt: "2026-03-13T12:00:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithLabels); + renderTab(); + + await waitFor(() => { + expect(screen.getByText("bug")).not.toBeNull(); + expect(screen.getByText("enhancement")).not.toBeNull(); + }); + }); + + it("renders comment count when greater than zero", async () => { + const snapshotWithComments: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "commented-pr", + githubPrNumber: 500, + title: "Commented PR", + commentCount: 42, + createdAt: "2026-03-13T12:00:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithComments); + renderTab(); + + await waitFor(() => { + expect(screen.getByText("42")).not.toBeNull(); + }); + }); + + it("sorts PRs by updatedAt descending", async () => { + const snapshotOrdered: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "pr-old", + githubPrNumber: 50, + title: "Old PR", + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:10:00.000Z", + }), + makeGitHubPr({ + id: "pr-new", + githubPrNumber: 150, + title: "New PR", + createdAt: "2026-03-13T08:00:00.000Z", + updatedAt: "2026-03-13T12:30:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotOrdered); + renderTab(); + + await waitFor(() => { + const buttons = screen.getAllByRole("button").filter((btn) => + btn.textContent?.includes("PR") && (btn.textContent?.includes("Old") || btn.textContent?.includes("New")), + ); + expect(buttons.length).toBe(2); + expect(buttons[0]!.textContent).toContain("New PR"); + expect(buttons[1]!.textContent).toContain("Old PR"); + }); + }); + + it("requires confirmation before unmapping a GitHub PR from its lane", async () => { + const user = userEvent.setup(); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + try { + renderTab(); + + await waitFor(() => { + expect(screen.getByText("Open PR")).toBeTruthy(); + }); + await user.click(screen.getByRole("button", { name: /#101 Open PR/i })); + await waitFor(() => { + expect(screen.getByTestId("pr-detail-pane").textContent).toContain("pr-open"); + }); + await user.click(screen.getByRole("button", { name: /unmap from lane/i })); + + expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining("Unmap PR #101")); + expect(window.ade.prs.delete).not.toHaveBeenCalled(); + } finally { + confirmSpy.mockRestore(); + } + }); + + it("renders the full PR detail pane (with create/map affordance) for a selected unmapped PR", async () => { + const user = userEvent.setup(); + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "Unlinked PR", + headBranch: "feature/no-lane", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:05:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); + // No lane owns the PR head branch → the "Create lane from PR branch" action + // is offered (and there is no matching lane to map to). + renderTab({ lanes: [] }); + + await user.click(await screen.findByText("Unlinked PR")); + + // The full detail pane renders (not the legacy read-only gate), keyed by a + // stable synthetic id derived from the GitHub coordinates. + const pane = await screen.findByTestId("pr-detail-pane"); + expect(pane.getAttribute("data-unmapped")).toBe("true"); + expect(pane.textContent).toContain("gh:ade-dev/ade#200"); + + // The create/map affordance is present (no read-only gate). + const affordance = within(pane).getByTestId("pr-unmapped-affordance"); + expect(within(affordance).getByRole("button", { name: /create lane from pr branch/i })).toBeTruthy(); + }); + + it("maps an unmapped PR to a lane via the in-pane affordance", async () => { + const user = userEvent.setup(); + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "Unlinked PR", + headBranch: "feature/lane-match", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:05:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); + renderTab({ + lanes: [makeLaneSummary({ id: "lane-match", name: "Matching lane", branchRef: "refs/heads/feature/lane-match" })], + }); + + await user.click(await screen.findByText("Unlinked PR")); + const pane = await screen.findByTestId("pr-detail-pane"); + const affordance = within(pane).getByTestId("pr-unmapped-affordance"); + + await user.selectOptions(within(affordance).getByLabelText("Select lane to map"), "lane-match"); + await user.click(within(affordance).getByRole("button", { name: /^map$/i })); + + await waitFor(() => { + expect(window.ade.prs.linkToLane).toHaveBeenCalledWith({ + laneId: "lane-match", + prUrlOrNumber: "https://github.com/ade-dev/ade/pull/200", + }); + }); + }); + + it("opens a preflight dialog for an unmapped PR branch", async () => { + const user = userEvent.setup(); + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "Unlinked PR", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:05:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); + renderTab(); + + const trigger = await screen.findByRole("button", { name: /create lane from pr branch/i }); + await user.click(trigger); + + expect(window.ade.prs.preflightCreateLaneFromPrBranch).toHaveBeenCalledWith({ + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 200, + }); + const dialog = await screen.findByRole("dialog", { name: /create lane from pr branch/i }); + const cancel = within(dialog).getByRole("button", { name: /cancel/i }); + const confirm = within(dialog).getByRole("button", { name: /create lane/i }); + expect(document.activeElement).toBe(cancel); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(confirm); + await user.tab(); + expect(document.activeElement).toBe(cancel); + expect(within(dialog).getByText(/#200 Unlinked PR/)).toBeTruthy(); + expect(within(dialog).getAllByText("origin/feature/open").length).toBeGreaterThan(0); + expect(within(dialog).getAllByText("Unlinked PR").length).toBeGreaterThan(0); + expect(within(dialog).getAllByText("main").length).toBeGreaterThan(0); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog", { name: /create lane from pr branch/i })).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it("keeps focus in the dialog while creation is busy and restores an action after failure", async () => { + const user = userEvent.setup(); + const createResult = createDeferred(); + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + title: "Unlinked PR", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + })], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType) + .mockResolvedValue(snapshotWithUnlinked); + (window.ade.prs.createLaneFromPrBranch as ReturnType) + .mockReturnValueOnce(createResult.promise); + renderTab(); + + await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); + const dialog = await screen.findByRole("dialog", { name: /create lane from pr branch/i }); + await user.click(within(dialog).getByRole("button", { name: /^create lane$/i })); + + await user.keyboard("{Escape}"); + expect(screen.getByRole("dialog", { name: /create lane from pr branch/i })).toBe(dialog); + await user.tab(); + expect(dialog.contains(document.activeElement)).toBe(true); + + await act(async () => { + createResult.reject(new Error("creation failed")); + await Promise.resolve(); + }); + expect(await within(dialog).findByText("creation failed")).toBeTruthy(); + await user.tab(); + const activeControl = document.activeElement; + expect(dialog.contains(activeControl)).toBe(true); + expect(activeControl).toBeInstanceOf(HTMLButtonElement); + expect((activeControl as HTMLButtonElement).disabled).toBe(false); + }); + + it("ignores stale create-lane preflight results from a previous PR", async () => { + const user = userEvent.setup(); + const firstPreflight = createDeferred(); + const secondPreflight = createDeferred(); + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "repo-unlinked-first", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "First PR", + headBranch: "feature/first", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:10:00.000Z", + }), + makeGitHubPr({ + id: "repo-unlinked-second", + githubPrNumber: 201, + githubUrl: "https://github.com/ade-dev/ade/pull/201", + title: "Second PR", + headBranch: "feature/second", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:05:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); + (window.ade.prs.preflightCreateLaneFromPrBranch as ReturnType) + .mockImplementation((args: { githubPrNumber: number }) => + args.githubPrNumber === 200 ? firstPreflight.promise : secondPreflight.promise); + renderTab(); + + await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); + expect(window.ade.prs.preflightCreateLaneFromPrBranch).toHaveBeenCalledWith({ + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 200, + }); + await user.click(screen.getByRole("button", { name: /cancel/i })); + await user.click(await screen.findByText("Second PR")); + await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); + expect(window.ade.prs.preflightCreateLaneFromPrBranch).toHaveBeenCalledWith({ + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 201, + }); + + await act(async () => { + firstPreflight.resolve(makePreflightResult({ + githubPrNumber: 200, + title: "First PR", + headBranch: "feature/first", + remoteBranch: "origin/feature/first", + })); + await firstPreflight.promise; + }); + expect(screen.queryByText(/#200 First PR/)).toBeNull(); + expect(screen.getByText(/checking branch ownership/i)).toBeTruthy(); + + await act(async () => { + secondPreflight.resolve(makePreflightResult({ + githubPrNumber: 201, + title: "Second PR", + headBranch: "feature/second", + remoteBranch: "origin/feature/second", + })); + await secondPreflight.promise; + }); + + expect(await screen.findByText(/#201 Second PR/)).toBeTruthy(); + expect(screen.queryByText(/#200 First PR/)).toBeNull(); + const secondDialog = await screen.findByRole("dialog", { name: /create lane from pr branch/i }); + expect(within(secondDialog).getAllByText("origin/feature/second").length).toBeGreaterThan(0); + }); + + it("shows blocking preflight conflicts before creating a lane", async () => { + const user = userEvent.setup(); + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + title: "Unlinked PR", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:05:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); + (window.ade.prs.preflightCreateLaneFromPrBranch as ReturnType).mockResolvedValueOnce({ + preflight: { + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "Unlinked PR", + headBranch: "feature/open", + headRepoOwner: "ade-dev", + headRepoName: "ade", + remoteBranch: "origin/feature/open", + importBranchRef: "origin/feature/open", + targetLaneName: "Unlinked PR", + baseBranch: "main", + canCreate: false, + status: "blocked", + blockingConflict: { + code: "branch_owned", + message: "Branch 'feature/open' is already owned by lane 'Existing lane'.", + laneId: "lane-existing", + laneName: "Existing lane", + }, + blockingConflicts: [], + }, + lane: null, + pr: null, + }); + renderTab(); + + await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); + + expect(await screen.findByText(/already owned by lane 'Existing lane'/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /^create lane$/i })).toHaveProperty("disabled", true); + expect(window.ade.prs.createLaneFromPrBranch).not.toHaveBeenCalled(); + }); + + it("does not let an archived branch match hide the create-lane action", async () => { + const user = userEvent.setup(); + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + title: "Unlinked PR", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:05:00.000Z", + }), + ], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType).mockResolvedValue(snapshotWithUnlinked); + (window.ade.prs.preflightCreateLaneFromPrBranch as ReturnType).mockResolvedValueOnce({ + preflight: { + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "Unlinked PR", + headBranch: "feature/open", + headRepoOwner: "ade-dev", + headRepoName: "ade", + remoteBranch: "origin/feature/open", + importBranchRef: "origin/feature/open", + targetLaneName: "Unlinked PR", + baseBranch: "main", + canCreate: false, + status: "blocked", + blockingConflict: { + code: "branch_owned", + message: "Branch 'feature/open' is already owned by archived lane 'Archived lane'.", + laneId: "lane-archived", + laneName: "Archived lane", + }, + blockingConflicts: [], + }, + lane: null, + pr: null, + }); + + renderTab({ + lanes: [ + makeLaneSummary({ + id: "lane-archived", + name: "Archived lane", + branchRef: "refs/heads/feature/open", + archivedAt: "2026-03-12T12:00:00.000Z", + }), + ], + }); + + expect(await screen.findByRole("button", { name: /create lane from pr branch/i })).toBeTruthy(); + expect(screen.queryByRole("option", { name: "Archived lane" })).toBeNull(); + + await user.click(screen.getByRole("button", { name: /create lane from pr branch/i })); + + expect(await screen.findByText(/already owned by archived lane 'Archived lane'/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /^create lane$/i })).toHaveProperty("disabled", true); + }); + + it("creates a lane from an unmapped PR branch, refreshes lanes, and selects the mapped PR", async () => { + const user = userEvent.setup(); + const onSelectPr = vi.fn(); + const onRefreshAll = vi.fn().mockResolvedValue(undefined); + const forcedSnapshot = createDeferred(); + const snapshotWithUnlinked: GitHubPrSnapshot = { + ...snapshot, + repoPullRequests: [ + makeGitHubPr({ + id: "repo-unlinked", + githubPrNumber: 200, + githubUrl: "https://github.com/ade-dev/ade/pull/200", + title: "Unlinked PR", + linkedPrId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + createdAt: "2026-03-13T12:00:00.000Z", + updatedAt: "2026-03-13T12:05:00.000Z", + }), + ], + externalPullRequests: [], + }; + (window.ade.prs.getGitHubSnapshot as ReturnType) + .mockResolvedValueOnce(snapshotWithUnlinked) + .mockReturnValueOnce(forcedSnapshot.promise); + renderTab({ onSelectPr, onRefreshAll }); + + await user.click(await screen.findByRole("button", { name: /create lane from pr branch/i })); + await user.click(await screen.findByRole("button", { name: /^create lane$/i })); + + await waitFor(() => { + expect(window.ade.prs.createLaneFromPrBranch).toHaveBeenCalledWith({ + repoOwner: "ade-dev", + repoName: "ade", + githubPrNumber: 200, + }); + }); + await waitFor(() => { + expect(onSelectPr).toHaveBeenCalledWith("pr-created"); + }); + expect(onRefreshAll).toHaveBeenCalledWith({ prId: "pr-created" }); + expect(window.ade.lanes.list).toHaveBeenCalledWith({ + includeArchived: false, + includeStatus: false, + }); + expect(window.ade.prs.getGitHubSnapshot).toHaveBeenCalledWith({ force: true }); + await act(async () => { + forcedSnapshot.resolve(snapshotWithUnlinked); + await forcedSnapshot.promise; + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTabView.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTabView.tsx new file mode 100644 index 000000000..e90a7d85e --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTabView.tsx @@ -0,0 +1,521 @@ +import React from "react"; +import { CircleNotch, GitMerge, GithubLogo, XCircle } from "@phosphor-icons/react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Group, Panel } from "react-resizable-panels"; +import type { + GitHubPrListItem, + GitHubPrStack, + PrSummary, +} from "../../../../shared/types"; +import { EmptyState } from "../../ui/EmptyState"; +import { ResizeGutter } from "../../ui/ResizeGutter"; +import { + COLORS, + MONO_FONT, + SANS_FONT, + cardStyle, + outlineButton, + primaryButton, +} from "../../lanes/laneDesignTokens"; +import { PrDetailPane } from "../detail/PrDetailPane"; +import { GitHubPrSearchInput } from "../shared/GitHubPrSearchInput"; +import { GitHubRepoSyncBar } from "../shared/GitHubRepoSyncBar"; +import { GitHubStackInspector } from "../shared/GitHubStackInspector"; +import { GitHubTabPrRow, PrListGroupHeaderRow } from "../shared/GitHubTabPrRow"; +import { + prListHeaderIndices, + type PrListRow, +} from "../shared/prListGrouping"; +import { + GITHUB_TAB_VIRTUALIZE_AT, + bucketForState, + type GitHubFilter, + type GitHubFilterCounts, +} from "./githubTabModel"; + +const FILTER_ACCENTS: Record = { + open: "#60A5FA", + closed: "#A1A1AA", + merged: "#4ADE80", +}; + +const GITHUB_PR_LIST_WIDTH_KEY = "ade.prs.githubListWidth"; +const GITHUB_PR_LIST_MIN_PX = 260; +const GITHUB_PR_LIST_MAX_PX = 560; +const GITHUB_PR_LIST_DEFAULT_PX = 380; + +function readPersistedGithubPrListPx(): number { + try { + const raw = localStorage.getItem(GITHUB_PR_LIST_WIDTH_KEY); + if (raw) { + const value = Number(raw); + if (Number.isFinite(value) && value >= GITHUB_PR_LIST_MIN_PX && value <= GITHUB_PR_LIST_MAX_PX) { + return value; + } + } + } catch { + /* ignore */ + } + return GITHUB_PR_LIST_DEFAULT_PX; +} + +function persistGithubPrListPx(px: number): void { + try { + localStorage.setItem(GITHUB_PR_LIST_WIDTH_KEY, String(Math.round(px))); + } catch { + /* ignore */ + } +} + +type GitHubTabViewChrome = { + relocated: boolean; + searchQuery: string; + onSearchQueryChange: (value: string) => void; + repoLabel: string; + syncing: boolean; + syncedAt: string | null; + onSync: () => void; + error: string | null; + onConnectGitHub: () => void; +}; + +type GitHubTabViewList = { + parentRef: React.RefObject; + filter: GitHubFilter; + filterCounts: GitHubFilterCounts; + loading: boolean; + loadingFilter: GitHubFilter | null; + loadingOlderHistory: boolean; + showLoadingIndicator: boolean; + hasSnapshot: boolean; + filteredItems: GitHubPrListItem[]; + rows: PrListRow[]; + selectedItemId: string | null; + prsByIdMap: Map; + canLoadOlderHistory: boolean; + onFilterChange: (filter: GitHubFilter) => void; + onSelect: (item: GitHubPrListItem) => void; + onHydrationItemsChange: (items: GitHubPrListItem[]) => void; + onLoadOlderHistory: () => void; +}; + +type GitHubTabViewDetail = { + selectedItem: GitHubPrListItem | null; + selectedBucketMismatch: boolean; + selectedStack: GitHubPrStack | null; + displayedItems: GitHubPrListItem[]; + paneProps: React.ComponentProps | null; + onSelect: (item: GitHubPrListItem) => void; + onSync: () => void; + onAddStackPullRequests: (pullRequests: number[]) => Promise; + onUnstack: () => Promise; + onFilterChange: (filter: GitHubFilter) => void; +}; + +export type GitHubTabViewProps = { + chrome: GitHubTabViewChrome; + list: GitHubTabViewList; + detail: GitHubTabViewDetail; +}; + +export function GitHubTabView({ chrome, list, detail }: GitHubTabViewProps) { + const selectedItem = detail.selectedItem; + const selectedStack = detail.selectedStack; + const defaultListPx = React.useMemo(() => readPersistedGithubPrListPx(), []); + + if (chrome.error && !list.hasSnapshot) { + return ( + + + + ); + } + + return ( +
+ {!chrome.relocated ? ( +
+ + +
+ ) : null} + + {chrome.error ? ( +
+ {chrome.error} +
+ ) : null} + +
+ + persistGithubPrListPx(size.inPixels)} + className="min-h-0 min-w-0" + style={{ overflow: "hidden", borderRight: "1px solid rgba(255,255,255,0.06)" }} + > +
+
+ {(["open", "merged", "closed"] as GitHubFilter[]).map((state) => { + const active = list.filter === state; + const accent = FILTER_ACCENTS[state]; + const count = list.filterCounts[state]; + const icon = state === "merged" ? : null; + const tabLoading = active && ( + list.loading + || chrome.syncing + || list.loadingFilter === state + || list.loadingOlderHistory + ); + return ( + + ); + })} +
+ {list.showLoadingIndicator ? ( + + + + ) : null} +
+
+ {list.filteredItems.length === 0 ? ( +
+ +
+ ) : list.filteredItems.length > GITHUB_TAB_VIRTUALIZE_AT ? ( + + ) : ( + list.rows.map((row) => ( + row.kind === "header" ? ( + + ) : ( + + ) + )) + )} + {list.canLoadOlderHistory ? ( +
+ +
+ ) : null} +
+
+ + + + {selectedItem && detail.paneProps ? ( +
+ {detail.selectedBucketMismatch ? ( +
+ detail.onFilterChange(bucketForState(selectedItem.state))} + /> +
+ ) : null} + {selectedStack ? ( + item.repoOwner === selectedStack.repoOwner + && item.repoName === selectedStack.repoName, + )} + selectedPrNumber={selectedItem.githubPrNumber} + syncing={chrome.syncing} + onSelectPr={detail.onSelect} + onOpenGitHub={() => { + void window.ade.app.openExternal(selectedItem.githubUrl); + }} + onSync={detail.onSync} + onAddPullRequests={detail.onAddStackPullRequests} + onUnstack={detail.onUnstack} + /> + ) : null} +
+ +
+
+ ) : ( +
+ +
+ )} +
+ +
+
+ ); +} + +function PrBucketTransitionBanner({ + state, + onShow, +}: { + state: GitHubPrListItem["state"]; + onShow: () => void; +}) { + const isMerged = state === "merged"; + const label = isMerged ? "Merged" : "Closed"; + const accent = isMerged ? COLORS.success : COLORS.danger; + return ( +
+
+
+ {isMerged ? ( + + ) : ( + + )} + + This PR is now {label} + +
+ +
+
+ ); +} + +function GitHubTabVirtualList({ + parentRef, + rows, + selectedItemId, + prsByIdMap, + onSelect, + onHydrationItemsChange, +}: { + parentRef: React.RefObject; + rows: PrListRow[]; + selectedItemId: string | null; + prsByIdMap: Map; + onSelect: (item: GitHubPrListItem) => void; + onHydrationItemsChange: (items: GitHubPrListItem[]) => void; +}) { + const headerIndices = React.useMemo(() => prListHeaderIndices(rows), [rows]); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => (rows[index]?.kind === "header" ? 30 : 108), + overscan: 6, + rangeExtractor: React.useCallback( + (range: { startIndex: number; endIndex: number; overscan: number; count: number }) => { + const pinned = activeHeaderFor(headerIndices, range.startIndex); + const start = Math.max(0, range.startIndex - range.overscan); + const end = Math.min(range.count - 1, range.endIndex + range.overscan); + const indices = new Set(); + if (pinned != null) indices.add(pinned); + for (let index = start; index <= end; index += 1) indices.add(index); + return [...indices].sort((a, b) => a - b); + }, + [headerIndices], + ), + }); + + const virtualItems = virtualizer.getVirtualItems(); + const activeHeaderIndex = activeHeaderFor(headerIndices, virtualizer.range?.startIndex ?? 0); + + React.useEffect(() => { + onHydrationItemsChange( + virtualItems + .map((virtualRow) => rows[virtualRow.index]) + .filter((row): row is Extract => row?.kind === "item") + .map((row) => row.item), + ); + }, [rows, onHydrationItemsChange, virtualItems]); + + return ( +
+ {virtualItems.map((virtualRow) => { + const row = rows[virtualRow.index]!; + const pinned = row.kind === "header" && virtualRow.index === activeHeaderIndex; + return ( +
+ {row.kind === "header" ? ( + + ) : ( + + )} +
+ ); + })} +
+ ); +} + +function activeHeaderFor(headerIndices: number[], startIndex: number): number | null { + let active: number | null = null; + for (const index of headerIndices) { + if (index > startIndex) break; + active = index; + } + return active; +} diff --git a/apps/desktop/src/renderer/components/prs/tabs/githubPrBranch.ts b/apps/desktop/src/renderer/components/prs/tabs/githubPrBranch.ts new file mode 100644 index 000000000..46d5ec015 --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/githubPrBranch.ts @@ -0,0 +1,4 @@ +/** Strip the `refs/heads/` prefix so lane refs and PR head branches compare directly. */ +export function branchNameFromRef(ref: string | null | undefined): string { + return String(ref ?? "").replace(/^refs\/heads\//, "").trim(); +} diff --git a/apps/desktop/src/renderer/components/prs/tabs/githubTabModel.ts b/apps/desktop/src/renderer/components/prs/tabs/githubTabModel.ts new file mode 100644 index 000000000..52164585b --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/githubTabModel.ts @@ -0,0 +1,231 @@ +import type { + GitHubPrListItem, + GitHubPrSnapshot, + PrSummary, + PrWithConflicts, +} from "../../../../shared/types"; +import { syntheticGithubPrId } from "../../../../shared/types/prs"; +import { isTerminalPrState } from "../../../lib/prState"; + +export const LINKED_HYDRATION_LIMIT = 8; +export const GITHUB_TAB_VIRTUALIZE_AT = 50; +export const GITHUB_TAB_REVISIT_CACHE_TTL_MS = 60_000; +export const GITHUB_TAB_SNAPSHOT_FRESH_MS = 30_000; +export const GITHUB_TAB_HOT_REFRESH_DELAY_MS = 30_000; +export const GITHUB_TAB_HISTORY_INITIAL_PAGE_LIMIT = 2; +export const GITHUB_TAB_HISTORY_PAGE_INCREMENT = 2; +export const GITHUB_TAB_HISTORY_MAX_PAGE_LIMIT = 10; + +const GITHUB_TAB_CACHE_DISABLED = import.meta.env.MODE === "test"; + +export type GitHubFilter = "open" | "closed" | "merged"; +export type GitHubFilterSelectionMap = Partial>; + +export type GitHubTabWarmCache = { + projectRoot: string; + snapshot: GitHubPrSnapshot | null; + filter: GitHubFilter; + selectedItemId: string | null; + selectedItemIdsByFilter?: GitHubFilterSelectionMap; + searchQuery: string; + externalHistoryLoaded: boolean; + cachedAt: number; +}; + +export type GitHubSnapshotRequestKey = { + includeExternalClosed: boolean; + historyPageLimit: number; +}; + +export type GitHubFilterCounts = Record; + +let githubTabWarmCache: GitHubTabWarmCache | null = null; + +export function normalizeGitHubFilter(value: unknown): GitHubFilter { + return value === "open" || value === "closed" || value === "merged" ? value : "open"; +} + +export function initialGitHubFilterSelections(cache: GitHubTabWarmCache | null): GitHubFilterSelectionMap { + const selections: GitHubFilterSelectionMap = { ...(cache?.selectedItemIdsByFilter ?? {}) }; + if (cache?.selectedItemId) { + selections[normalizeGitHubFilter(cache.filter)] = cache.selectedItemId; + } + return selections; +} + +export function normalizeHistoryPageLimit(value: unknown): number { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return GITHUB_TAB_HISTORY_INITIAL_PAGE_LIMIT; + } + return Math.min( + GITHUB_TAB_HISTORY_MAX_PAGE_LIMIT, + Math.max(GITHUB_TAB_HISTORY_INITIAL_PAGE_LIMIT, Math.floor(numeric)), + ); +} + +export function snapshotRequestKey(options?: { + includeExternalClosed?: boolean; + historyPageLimit?: number; +}): GitHubSnapshotRequestKey { + const includeExternalClosed = options?.includeExternalClosed === true; + return { + includeExternalClosed, + historyPageLimit: includeExternalClosed ? normalizeHistoryPageLimit(options?.historyPageLimit) : 0, + }; +} + +export function snapshotRequestSatisfies( + current: GitHubSnapshotRequestKey | null, + requested: GitHubSnapshotRequestKey, +): boolean { + if (!current) return false; + if (!requested.includeExternalClosed) return true; + return current.includeExternalClosed && current.historyPageLimit >= requested.historyPageLimit; +} + +export function readGitHubTabWarmCache(projectRoot: string | null): GitHubTabWarmCache | null { + if (GITHUB_TAB_CACHE_DISABLED) return null; + if (!projectRoot) return null; + if (githubTabWarmCache?.projectRoot !== projectRoot) return null; + const filter = normalizeGitHubFilter((githubTabWarmCache as { filter?: unknown }).filter); + return filter === githubTabWarmCache.filter ? githubTabWarmCache : { ...githubTabWarmCache, filter }; +} + +export function writeGitHubTabWarmCache(cache: GitHubTabWarmCache): void { + if (GITHUB_TAB_CACHE_DISABLED) return; + if (!cache.projectRoot) return; + githubTabWarmCache = cache; +} + +export function formatGitHubSnapshotError(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err ?? ""); + const message = raw + .replace(/^Error invoking remote method '[^']+':\s*/i, "") + .replace(/^Error:\s*/i, "") + .trim(); + if (/github (token|auth) missing/i.test(message)) { + return "Connect GitHub in Settings with gh auth or a PAT to sync pull requests."; + } + return message || "Unable to sync pull requests."; +} + +function isKnownPrState(value: unknown): value is PrSummary["state"] { + return value === "draft" || value === "open" || value === "merged" || value === "closed"; +} + +export function reconcileLinkedPrState( + item: GitHubPrListItem, + linkedPr: PrSummary | null | undefined, +): GitHubPrListItem { + if (!isKnownPrState(linkedPr?.state)) return item; + if (!isTerminalPrState(linkedPr.state) || isTerminalPrState(item.state)) return item; + return { + ...item, + state: linkedPr.state, + isDraft: false, + title: linkedPr.title || item.title, + updatedAt: linkedPr.updatedAt || item.updatedAt, + }; +} + +export function matchesFilter(item: GitHubPrListItem, filter: GitHubFilter): boolean { + if (filter === "open") return item.state === "open" || item.state === "draft"; + return item.state === filter; +} + +export function bucketForState(state: GitHubPrListItem["state"]): GitHubFilter { + if (state === "merged") return "merged"; + if (state === "closed") return "closed"; + return "open"; +} + +export function githubCoordKey(item: { + repoOwner: string; + repoName: string; + githubPrNumber: number; +}): string { + return `${item.repoOwner}/${item.repoName}#${Number(item.githubPrNumber)}`; +} + +function buildOverlayRowFromLastSeen(lastSeen: GitHubPrListItem, linkedPr: PrSummary): GitHubPrListItem { + return { + ...lastSeen, + state: linkedPr.state, + isDraft: false, + title: linkedPr.title || lastSeen.title, + updatedAt: linkedPr.updatedAt || lastSeen.updatedAt, + linkedPrId: linkedPr.id, + }; +} + +export function computeTerminalOverlayItems( + reconciledItems: GitHubPrListItem[], + prsById: Map, + lastSeenByCoord: Map, +): GitHubPrListItem[] { + if (prsById.size === 0) return []; + const presentCoords = new Set(reconciledItems.map((item) => githubCoordKey(item))); + const overlays: GitHubPrListItem[] = []; + const usedLinkedIds = new Set(); + for (const pr of prsById.values()) { + if (!isTerminalPrState(pr.state)) continue; + if (usedLinkedIds.has(pr.id)) continue; + const key = githubCoordKey(pr); + if (presentCoords.has(key)) continue; + const lastSeen = lastSeenByCoord.get(key); + if (!lastSeen) continue; + usedLinkedIds.add(pr.id); + overlays.push(buildOverlayRowFromLastSeen(lastSeen, pr)); + } + return overlays; +} + +export function mergeGitHubListItems(snapshot: GitHubPrSnapshot): GitHubPrListItem[] { + const combined = [...snapshot.repoPullRequests, ...snapshot.externalPullRequests]; + const seen = new Set(); + return combined.filter((item) => { + const key = `${item.scope}:${item.repoOwner}/${item.repoName}#${item.githubPrNumber}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function countGitHubItemsByState(items: GitHubPrListItem[]): GitHubFilterCounts { + return { + open: items.filter((item) => item.state === "open" || item.state === "draft").length, + closed: items.filter((item) => item.state === "closed").length, + merged: items.filter((item) => item.state === "merged").length, + }; +} + +export function syntheticUnmappedPrId(item: GitHubPrListItem): string { + return syntheticGithubPrId(item); +} + +export function buildSyntheticUnmappedPr(item: GitHubPrListItem, projectId: string): PrWithConflicts { + return { + id: syntheticUnmappedPrId(item), + laneId: "", + projectId, + repoOwner: item.repoOwner, + repoName: item.repoName, + githubPrNumber: item.githubPrNumber, + githubUrl: item.githubUrl, + githubNodeId: null, + title: item.title, + state: item.state, + baseBranch: item.baseBranch ?? "", + headBranch: item.headBranch ?? "", + checksStatus: "none", + reviewStatus: "none", + additions: 0, + deletions: 0, + lastSyncedAt: null, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + stack: item.stack ?? null, + conflictAnalysis: null, + }; +} diff --git a/apps/desktop/src/renderer/components/prs/tabs/useGitHubTabListModel.ts b/apps/desktop/src/renderer/components/prs/tabs/useGitHubTabListModel.ts new file mode 100644 index 000000000..dfcf67ce6 --- /dev/null +++ b/apps/desktop/src/renderer/components/prs/tabs/useGitHubTabListModel.ts @@ -0,0 +1,117 @@ +import React from "react"; +import type { + GitHubPrListItem, + GitHubPrSnapshot, + PrSummary, +} from "../../../../shared/types"; +import { buildPrListRows } from "../shared/prListGrouping"; +import { + GITHUB_TAB_HISTORY_MAX_PAGE_LIMIT, + GITHUB_TAB_VIRTUALIZE_AT, + computeTerminalOverlayItems, + countGitHubItemsByState, + githubCoordKey, + matchesFilter, + mergeGitHubListItems, + reconcileLinkedPrState, + type GitHubFilter, + type GitHubFilterCounts, +} from "./githubTabModel"; + +export function useGitHubTabListModel({ + snapshot, + searchQuery, + prsByIdMap, + filter, + renderedHydrationItems, + lastSeenRowByCoordRef, + currentHistoryPageLimit, +}: { + snapshot: GitHubPrSnapshot | null; + searchQuery: string; + prsByIdMap: Map; + filter: GitHubFilter; + renderedHydrationItems: GitHubPrListItem[]; + lastSeenRowByCoordRef: React.MutableRefObject>; + currentHistoryPageLimit: () => number; +}) { + const matchesSearch = React.useCallback((item: GitHubPrListItem) => { + if (!searchQuery.trim()) return true; + const q = searchQuery.trim().toLowerCase(); + return ( + item.title.toLowerCase().includes(q) + || (item.author?.toLowerCase().includes(q) ?? false) + || (item.headBranch?.toLowerCase().includes(q) ?? false) + || String(item.githubPrNumber).includes(q) + ); + }, [searchQuery]); + + const allItems = React.useMemo( + () => (snapshot ? mergeGitHubListItems(snapshot) : []), + [snapshot], + ); + const reconciledItems = React.useMemo( + () => allItems.map((item) => + reconcileLinkedPrState(item, item.linkedPrId ? prsByIdMap.get(item.linkedPrId) : null) + ), + [allItems, prsByIdMap], + ); + + React.useEffect(() => { + const map = lastSeenRowByCoordRef.current; + for (const item of allItems) { + map.set(githubCoordKey(item), item); + } + }, [allItems, lastSeenRowByCoordRef]); + + const overlayItems = React.useMemo( + () => computeTerminalOverlayItems(reconciledItems, prsByIdMap, lastSeenRowByCoordRef.current), + [lastSeenRowByCoordRef, reconciledItems, prsByIdMap], + ); + const displayedItems = React.useMemo( + () => (overlayItems.length === 0 ? reconciledItems : [...reconciledItems, ...overlayItems]), + [reconciledItems, overlayItems], + ); + const filteredItems = React.useMemo( + () => displayedItems + .filter((item) => matchesFilter(item, filter) && matchesSearch(item)) + .sort((a, b) => + new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime() + ), + [displayedItems, filter, matchesSearch], + ); + const hydrationItems = filteredItems.length > GITHUB_TAB_VIRTUALIZE_AT + ? renderedHydrationItems + : filteredItems; + const listRows = React.useMemo( + () => buildPrListRows(filteredItems, { grouped: filter === "merged" || filter === "closed" }), + [filteredItems, filter], + ); + const filterCounts = React.useMemo(() => { + const listedCounts = countGitHubItemsByState(displayedItems); + const snapshotCounts = snapshot?.history?.repoPullRequestCounts; + const rawCounts = countGitHubItemsByState(allItems); + const withReconcileDelta = ( + base: number | null | undefined, + key: keyof GitHubFilterCounts, + fallback: number, + ): number => base == null ? fallback : Math.max(0, base + listedCounts[key] - rawCounts[key]); + return { + open: withReconcileDelta(snapshotCounts?.open, "open", listedCounts.open), + closed: withReconcileDelta(snapshotCounts?.closed, "closed", listedCounts.closed), + merged: withReconcileDelta(snapshotCounts?.merged, "merged", listedCounts.merged), + }; + }, [allItems, displayedItems, snapshot?.history?.repoPullRequestCounts]); + const canLoadOlderHistory = filter !== "open" + && Boolean(snapshot?.history?.repoPullRequestsMayHaveMore) + && currentHistoryPageLimit() < GITHUB_TAB_HISTORY_MAX_PAGE_LIMIT; + + return { + displayedItems, + filteredItems, + hydrationItems, + listRows, + filterCounts, + canLoadOlderHistory, + }; +} diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md index e9ada0f8d..df75e1aae 100644 --- a/docs/playbooks/ship-lane.md +++ b/docs/playbooks/ship-lane.md @@ -15,7 +15,7 @@ Run this playbook once per lane, when the code on the branch is done (or nearly ## Execution contract - **Autonomous.** Do not pause for user confirmation mid-loop. -- **Bounded with a force-finalize escape hatch.** Soft cap: 5 normal iterations of fix-and-poll. Exit earlier if clean or blocked. **At the cap, the loop must land the lane**: if the PR is not merged after iteration 5, run **one** additional force-finalize iteration (Phase 3d) that ignores all open review comments, fixes only CI failures so every required check goes green, then routes through Phase 3c (auto-merge). Only if iteration 6 cannot make CI green, or Phase 3c is genuinely blocked (base-branch policy + no admin rights + no auto-merge enabled), do you stop and leave a handoff comment for a human. The playbook's exit contract is "PR merged into `main`, or merge genuinely impossible" — never "PR green and parked". +- **Bounded with a force-finalize escape hatch.** Soft cap: 5 normal iterations of fix-and-poll. Exit earlier if clean or blocked. **At the cap, the loop must land the lane**: if the PR is not merged after iteration 5, run **one** additional force-finalize iteration (Phase 3d) that ignores all open review comments, fixes only CI failures so every required check goes green, then routes through Phase 3c. Only if iteration 6 cannot make CI green, or Phase 3c is genuinely blocked by base-branch policy with no authorized direct/admin path, do you stop and leave a handoff comment for a human. The playbook's exit contract is "PR merged into `main`, or merge genuinely impossible" — never "PR green and parked". - **Rebase budget rebate.** A rebase, merge-from-main, or conflict-resolution pass moves the current iteration count down by 2 before the next cap check, with a floor of 0. Example: if the lane is on iteration 4 and must rebase because `main` moved, record the rebase and continue as iteration 2. - **Scoped checks.** Never run the full test suite between iterations. For CI, fix and rerun only the failing test file(s) or failing check target. For review-only changes, rerun only directly affected existing tests, plus the narrow package typecheck/lint when the touched surface needs it. - **One push per iteration. Wait for BOTH signals before fixing anything.** Never push a CI-only fix while review bots are still running, and never push a review-only fix while CI is still running. Both signals must be **terminal** before the iteration commits — that is, every required check has a final conclusion AND every review bot with current-head start evidence has posted or settled. This is not just an efficiency rule: **review-comment fixes routinely introduce new CI failures**, so applying them on a partial signal means the next push fails and you've thrown away the prior CI cycle. Wait for both, then dispatch ci-fix-agent and review-fix-agent in parallel with full knowledge of both, and combine their edits into one commit. If only one signal has landed when you wake, do not iterate — reschedule and sleep. @@ -80,9 +80,8 @@ These are operational mistakes this playbook explicitly guards against: UTC timestamps. Normalize before comparing in `jq`, otherwise old comments can look new and trigger duplicate review-fix work. 3. **`done-clean` still merges.** A green PR with resolved review - comments should route through Phase 3c auto-merge. Only mark - `done-max` when normal merge, admin merge, and auto-merge are all - genuinely blocked. + comments should route through Phase 3c. Only mark `done-max` when normal and + authorized admin merge paths are genuinely blocked. 4. **Do not pass `--delete-branch` to `gh pr merge`.** It can try to checkout the base branch locally and fail when `main` is already checked out by another worktree. Delete the remote head ref after a @@ -106,6 +105,9 @@ Path: `.ade/shipLane/.json` (sanitize by replacing `/` with `_ "prNumber": 1234, "iteration": 2, "lastPushSha": "abc123...", + "qualityValidatedSha": "abc123...", + "qualityValidatedTree": "def456...", + "qualityValidatedBaseSha": "789abc...", "addressedCommentIds": [987654, 987655], "status": "running", "startedAt": "2026-04-23T14:30:00Z", @@ -118,6 +120,79 @@ Path: `.ade/shipLane/.json` (sanitize by replacing `/` with `_ The `iteration` value is the active turn budget counter, not a raw count of pushes. Normal fix iterations increment it by 1. Rebase/merge/conflict recovery decrements it by 2 first, then the current pass records its result. Never let it go below 0. +## Commit-bound quality revalidation + +This is the single canonical procedure for every ship-loop mutation. Phase 0, +post-rebase, Phase 3b, and force-finalize supply only their commit message, diff +base, narrow test targets, and push command; they do not restate this algorithm. + +1. Clear `qualityValidatedSha`, `qualityValidatedTree`, and + `qualityValidatedBaseSha` before editing. Fetch the intended base, set + `QUALITY_VALIDATED_BASE_SHA` to that fetched commit, and require it to be an + ancestor of the candidate head. If it is not, preserve the work, route + through Phase 3a, and restart this procedure after the rebase; do not bind a + behind-base tree. This works before PR creation; Phase 0 uses `main` as the + intended base. +2. Run both `/quality` tracks on the final combined diff, fix every accepted + finding, and repeat both tracks until the same pass is clean. +3. Build the validation scope from the union of committed, unstaged, staged, + and untracked changes, so quality-created files cannot evade narrow tests: + + ```bash + CHANGED=$( + { + git diff --name-only "${QUALITY_DIFF_BASE:-HEAD}...HEAD" + git diff --name-only + git diff --cached --name-only + git ls-files --others --exclude-standard + } | sort -u + ) + ``` + + Run only the directly affected tests/checks required by Fix discipline. +4. Stage the clean reviewed tree, capture its identity, commit once, and prove + the commit contains exactly that tree: + + ```bash + git add -A + QUALITY_VALIDATED_TREE=$(git write-tree) + git diff --cached --quiet || git commit -m "$QUALITY_COMMIT_MESSAGE" + test "$(git rev-parse HEAD^{tree})" = "$QUALITY_VALIDATED_TREE" + QUALITY_VALIDATED_SHA=$(git rev-parse HEAD) + ``` + +5. Push with the phase-appropriate command and verify the remote branch head + equals `QUALITY_VALIDATED_SHA`. If a PR already exists, also require its + `headRefOid` and `baseRefOid` to equal `QUALITY_VALIDATED_SHA` and + `QUALITY_VALIDATED_BASE_SHA`. Before the PR exists, retain the fetched + intended base as a provisional binding; Phase 0.5 performs the PR-field + verification immediately after creation. In both modes, require the base to + be an ancestor of the head and `HEAD^{tree}` to still equal + `QUALITY_VALIDATED_TREE` before writing all three values to ship state. Any + later edit, head mismatch, or base movement clears the binding and restarts + this procedure. + +The exact head/base/tree checks in Phase 3c are the second half of this +contract; a green CI result never substitutes for them. GitHub creates the +final squash/merge/rebase commit, so this procedure binds the reviewed content +tree rather than claiming to know or validate that future commit OID. + +### Validate the current quality binding + +This is the only pre-merge binding check. Read the three values from ship state, +then require the local head SHA/tree and the PR head/base SHAs to match them, +require the base to be an ancestor of HEAD, and require the quality gate to be +explicitly empty. Any mismatch clears the binding and routes back through the +canonical revalidation procedure (via Phase 3a first when the base moved). + +### Confirm the validated merge result + +This is the only clean-merge confirmation. Require the PR's final `headRefOid` +to equal `qualityValidatedSha`, resolve `mergeCommit.oid`, read that commit's +tree through the GitHub API, and require it to equal `qualityValidatedTree`. +Only then may state become `done-clean`. A head mismatch is +`merged-unvalidated-head`; a tree mismatch is `merged-unvalidated-tree`. + --- ## Phase 0 — Setup (first invocation only) @@ -137,30 +212,55 @@ If a PR exists for the current branch, skip to 0.4 (bot pings) with `prNumber` c ### 0.2 Pre-push expectation (no existing PR) -Ship is a **pure merge loop** — it does NOT run `/quality`, `/test`, or -`/finalize`. Those are separate steps in the dev loop +Ship is a **pure merge loop** — it does not replace the baseline `/quality`, +`/test`, or `/finalize` runs. Those are separate steps in the dev loop (`/context → work → /quality → /test → /ship`) that the author runs *before* reaching ship. Ship assumes the lane is already reviewed, tested, and -(optionally) finalized; it never generates tests or simplifies code itself. +(optionally) finalized; its only quality work is revalidation after a ship-loop +mutation so the result stays bound to the exact reviewed head and content tree. -Sanity-check only here: +Preconditions here: - `git status` must be clean of foreign changes. If uncommitted changes belong to this lane, commit them with `ship: checkpoint before ship`. If they're unrelated, exit `blocked` with `exitReason: "dirty-working-tree"`. -- If the lane was never run through `/quality` or `/test`, that's the author's - call — ship does not block on it, but note it in the first iteration summary. +- The lane must have completed `/quality` and `/test`. Read the final quality + result from the current conversation or lane handoff. A non-empty gate, a + missing result, or an ambiguous placeholder row blocks ship; set + `exitReason: "quality-gate-nonempty"` or `"quality-result-missing"` rather + than assuming unknown means clean. Ship does not replace either baseline + skill run. +- The quality result must be bound to the exact PR head, base, and content tree + that will merge by the + canonical Commit-bound quality revalidation procedure above. Do not infer a + binding from a green gate summary that predates the commit. ### 0.3 Commit + push + create PR -Commit and push first — both paths below need the remote branch to exist: +Create the checkpoint commit first: ```bash git add -A git diff --cached --quiet || git commit -m "ship: prepare lane for review" +``` + +Fetch `origin/main`, bind the intended base, and set `QUALITY_DIFF_BASE` to the +feature merge-base before running the canonical Commit-bound quality +revalidation procedure. This includes every committed feature file rather than +only edits made after the checkpoint: + +```bash +git fetch origin main +QUALITY_VALIDATED_BASE_SHA=$(git rev-parse origin/main) +QUALITY_DIFF_BASE=$(git merge-base HEAD "$QUALITY_VALIDATED_BASE_SHA") +QUALITY_COMMIT_MESSAGE="ship: apply initial quality revalidation" +# Push command for canonical step 5: git push -u origin "$CURRENT_BRANCH" ``` +If anything changes after the clean pass, discard all three binding values and +revalidate again. + **PR creation: prefer the ADE CLI.** Opening the PR via `ade` registers it in ADE's PR tracking (lane ↔ PR link, check/comment inventory, review-thread state). `gh pr create` is the fallback, not the default. Falling back too eagerly defeats the purpose — do it only after you've genuinely confirmed the ADE path is broken. ### Discovery protocol (for the agent — not a script) @@ -201,12 +301,19 @@ only when the diff touches more than 250 files. ### 0.5 Write initial state +After PR creation, verify its `headRefOid` and `baseRefOid` match the quality +binding. If either differs, clear all quality binding fields and revalidate the +actual PR head and base. + ```json { "branch": "", "prNumber": , "iteration": 0, "lastPushSha": "", + "qualityValidatedSha": "", + "qualityValidatedTree": "", + "qualityValidatedBaseSha": "", "addressedCommentIds": [], "status": "running", "startedAt": "", @@ -325,10 +432,10 @@ Pure logic on the poll summary: | Condition | Action | | --- | --- | -| `merged == true` | Exit `done-clean`; clear state file. | +| `merged == true` | Run **Confirm the validated merge result**; exit `done-clean` only when it succeeds. | | `behindMain == true` | Go to Phase 3a (rebase), apply the rebase budget rebate, then schedule/poll according to Phase 5. | | `ciRunning == true` OR `reviewBotsRunning == true` | Do NOT iterate on a partial signal. Go to Phase 5 (schedule next wake). This applies even if the other signal already shows failures/comments — pushing a fix now means the next CI+review cycle races the fix and you likely re-push for the other half. | -| `ciFailed` empty, `newComments` empty, `ciRunning == false`, `reviewBotsRunning == false` | Go to **Phase 3c (auto-merge)**. Done-clean does not mean "stop and leave for human" — it means everything is green, and the lane should land on `main`. | +| `ciFailed` empty, `newComments` empty, `ciRunning == false`, `reviewBotsRunning == false` | Go to **Phase 3c**. Done-clean does not mean "stop and leave for human" — it means everything is green, and the lane should land on `main`. | | Otherwise (both signals terminal, fix work exists) | Go to Phase 3b (fix). Fix CI failures and review comments **in the same iteration / same push**. | --- @@ -358,28 +465,37 @@ Resolve merge conflicts the same way. If the merge is **still** unrecoverable, e ### Post-resolution validation -Before pushing, run tests scoped to touched files only: +The rebase or merge invalidates the old quality binding. Run the canonical +Commit-bound quality revalidation procedure with `ORIG_HEAD` as +`QUALITY_DIFF_BASE`, the post-rebase commit message, and +`git push --force-with-lease` as the push command. Use its `CHANGED` union for +the scoped checks below: ```bash -# Touched since rebase started -CHANGED=$(git diff --name-only ORIG_HEAD HEAD | grep -E '\.(ts|tsx)$') - # Run colocated test files that exist -for f in $CHANGED; do - TEST="${f%.ts}.test.ts" - [ -f "$TEST" ] && echo "$TEST" -done | sort -u | xargs -r -I{} sh -c 'cd apps/desktop && npx vitest run {}' +for f in $(echo "$CHANGED" | grep -E '\.(ts|tsx)$'); do + case "$f" in + *.test.ts|*.test.tsx) echo "$f" ;; + *.tsx) + TEST="${f%.tsx}.test.tsx" + [ -f "$TEST" ] && echo "$TEST" + ;; + *.ts) + TEST="${f%.ts}.test.ts" + [ -f "$TEST" ] && echo "$TEST" + ;; + esac +done | sort -u | xargs -r -n1 sh -c ' + TEST_FILE=${1#apps/desktop/} + cd apps/desktop && npx vitest run "$TEST_FILE" +' sh # If typescript touched, typecheck the package echo "$CHANGED" | grep -q "^apps/desktop/" && (cd apps/desktop && npx tsc --noEmit -p .) echo "$CHANGED" | grep -q "^apps/ade-cli/" && (cd apps/ade-cli && npm run typecheck) ``` -Then: - -```bash -git push --force-with-lease -``` +Use `QUALITY_COMMIT_MESSAGE="ship: apply post-rebase quality revalidation"`. Before bookkeeping, apply the **rebase budget rebate**: @@ -456,6 +572,10 @@ git diff --stat The lead reviews the combined diff. If anything is surprising (unrelated files touched, enormous diffs), the lead can revert specific hunks with `git checkout -- ` before committing. +The old quality binding is now invalid. Run the canonical Commit-bound quality +revalidation procedure on the final combined tree. This is revalidation of the +ship-loop delta, not permission to skip the original full `/quality` run. + Re-run only the narrow checks that matter: - For CI fixes, rerun the failing test file(s) or exact failing check target. @@ -465,18 +585,24 @@ Re-run only the narrow checks that matter: Commit with a message that lists what was addressed: ```bash -git commit -m "ship: iteration $N — fix $CI_JOBS, address #$COMMENT_IDS" -git push +QUALITY_COMMIT_MESSAGE="ship: iteration $N — fix $CI_JOBS, address #$COMMENT_IDS" ``` Post bot pings (Phase 4), update state (Phase 5), and schedule the next wake. Do not restart Phase 1 immediately after a push; give CI and review bots time to run while the agent is asleep. --- -## Phase 3c — Auto-merge +## Phase 3c — Merge Runs when Phase 2 routes here (everything terminal, no fix work, not behind, not already merged). The point of this playbook is "PR-to-merge", not "PR-to-green" — once green, the lane lands. +Before resolving merge style, run the canonical **Validate the current quality +binding** procedure. A missing or mismatched binding means a later head or base +invalidated the result: rebase when needed, then return to quality revalidation +before merging. If the gate is non-empty or unavailable, exit +`blocked` with `quality-gate-nonempty` or `quality-result-missing`; never merge +and disclose deferred findings afterwards. + ### 3c.1 Resolve repo merge style ```bash @@ -488,7 +614,7 @@ Prefer the dominant style of the recent `main` history (look for `(#NNN)` suffix ### 3c.2 Attempt the merge ```bash -gh pr merge "$PR_NUMBER" --squash +gh pr merge "$PR_NUMBER" --squash --match-head-commit "$QUALITY_VALIDATED_SHA" ``` (Substitute `--merge` or `--rebase` per 3c.1.) @@ -498,16 +624,18 @@ gh pr merge "$PR_NUMBER" --squash If `gh pr merge` fails with `the base branch policy prohibits the merge` (typical when the repo requires a CODEOWNER review that the loop can't produce on its own), retry with admin override **only if the running user has admin rights on the repo**: ```bash -gh pr merge "$PR_NUMBER" --squash --admin +gh pr merge "$PR_NUMBER" --squash --admin --match-head-commit "$QUALITY_VALIDATED_SHA" ``` -If the user is not a repo admin, do NOT use `--admin`. Fall back to: - -```bash -gh pr merge "$PR_NUMBER" --squash --auto -``` +If the user is not a repo admin, do NOT use `--admin`. Exit `blocked` with +`exitReason: "merge-policy-blocked-no-authorized-path"` and post a PR comment +explaining what reviewer or admin action is needed. Do not arm persistent +auto-merge: a later push can replace the validated head while auto-merge remains +enabled, which violates the exact head/base/tree quality contract. -`--auto` queues GitHub's native auto-merge; the PR will land on its own once the missing requirement is satisfied. If `--auto` is also rejected (the repo doesn't have auto-merge enabled), exit `blocked` with `exitReason: "merge-policy-blocked-no-auto"` and post a PR comment explaining what reviewer is needed. +If the admin path is rejected too, exit with the same blocked reason. Do not +create and push a local fallback commit: that commit would bypass the reviewed +PR merge result and its CI evidence. ### 3c.4 Branch deletion @@ -521,11 +649,9 @@ Or simply skip deletion and rely on the repo's "Automatically delete head branch ### 3c.5 Confirm + finalize -```bash -gh pr view "$PR_NUMBER" --json state,mergedAt,mergeCommit -``` - -If `state == MERGED`, exit `done-clean`, clear `.ade/shipLane/.json`, and print the summary. Do NOT schedule another wake-up. +Run the canonical **Confirm the validated merge result** procedure. Only when it +succeeds may the loop clear `.ade/shipLane/.json` and print the summary. +Do NOT schedule another wake-up. --- @@ -566,11 +692,14 @@ Same input shape as Phase 3b.3, but with explicit constraints: ### 3d.4 Lead commit + push +Force-finalize may bypass review comments, but it may not bypass commit-bound +quality. Run the canonical Commit-bound quality revalidation procedure with the +commit message below and a normal push: + ```bash git status git diff --stat -git commit -m "ship: iteration 6 (force-finalize, review skipped) — fix $CI_JOBS" -git push +QUALITY_COMMIT_MESSAGE="ship: iteration 6 (force-finalize, review skipped) — fix $CI_JOBS" ``` Post the Phase 4 bot ping (a force-finalize push is a re-push → `@codex review`). Update state: @@ -580,6 +709,9 @@ Post the Phase 4 bot ping (a force-finalize push is a re-push → `@codex review "iteration": 6, "forceFinalize": true, "lastPushSha": "", + "qualityValidatedSha": "", + "qualityValidatedTree": "", + "qualityValidatedBaseSha": "", "addressedCommentIds": [, ], "lastPolledAt": "", "status": "running" @@ -592,9 +724,9 @@ Schedule the next wake at the normal post-push cadence (270s if CI hasn't starte When the next wake polls: -- `merged == true` → `done-clean`, exit. +- `merged == true` → run **Confirm the validated merge result**; exit `done-clean` only when it succeeds. - `behindMain == true` → run Phase 3a (rebase) once, push, schedule next wake. Do NOT count it as a new iteration; force-finalize already ran. -- CI terminal AND green → route **immediately** through Phase 3c (auto-merge). Do NOT wait on review bots; review is intentionally bypassed in this phase. +- CI terminal AND green → route **immediately** through Phase 3c. Do NOT wait on review bots; review is intentionally bypassed in this phase. - CI terminal AND any required check still failing → exit `blocked`, `exitReason: "force-finalize-ci-failed"`, post a PR comment listing the failing job names + links. Do not start a seventh iteration. - CI still running → sleep on the normal cadence; do not act on a partial signal. @@ -637,6 +769,9 @@ These are separate comments (not a single body) so each bot handler parses its o { "iteration": , "lastPushSha": "", + "qualityValidatedSha": "", + "qualityValidatedTree": "", + "qualityValidatedBaseSha": "", "addressedCommentIds": [, ], "lastPolledAt": "", "status": "running" @@ -645,9 +780,9 @@ These are separate comments (not a single body) so each bot handler parses its o ### 5.2 Decide exit vs next wake -- `merged == true` (observed during this iteration) → set `status: done-clean`, exit. +- `merged == true` → run **Confirm the validated merge result**; set `done-clean` only when it succeeds. - `iteration >= 5` AND `forceFinalize` unset/false AND not merged → run Phase 3d (force-finalize) on the next wake's fix turn. Do not exit; the cap is not a stop sign, it's a "land it now" trigger that switches the loop into review-ignoring CI-only mode. -- `forceFinalize == true` AND CI green AND not merged → route immediately through Phase 3c (auto-merge). Only if Phase 3c can't merge (policy blocked + no admin + auto-merge disabled) do you set `status: done-max` and leave a handoff comment. +- `forceFinalize == true` AND CI green AND not merged → route immediately through Phase 3c. Only if Phase 3c has no authorized direct/admin path do you set `status: done-max` and leave a handoff comment. - `forceFinalize == true` AND CI still red after the iteration-6 push → set `status: blocked`, `exitReason: "force-finalize-ci-failed"`, exit. Do not run a seventh iteration. - Otherwise → schedule next wake. @@ -678,8 +813,8 @@ The cadence is a hint, not a live polling budget. Prefer longer sleeps over freq | status | meaning | next action | | --- | --- | --- | | `done-clean` | PR merged on `main` (Phase 3c succeeded, possibly after Phase 3d force-finalize) | clear state file; print summary | -| `done-max` | 5 normal iterations + 1 force-finalize iteration exhausted AND Phase 3c could not merge (policy block + no admin + no auto-merge) | leave state file; post PR handoff comment to human | -| `blocked` | Unrecoverable conflict, gate failure, API error, or `force-finalize-ci-failed` (iteration 6 could not turn CI green) | leave state file; post PR comment with reason | +| `done-max` | 5 normal iterations + 1 force-finalize iteration exhausted AND Phase 3c has no authorized direct/admin merge path | leave state file; post PR handoff comment to human | +| `blocked` | Unrecoverable conflict, missing/non-empty quality gate, API error, or `force-finalize-ci-failed` (iteration 6 could not turn CI green) | leave state file; post PR comment with reason | ## Summary output (always print on exit)