Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17569,14 +17569,31 @@ function formatLaneDetail(value: unknown): string {
]);
}

/**
* Lane cell for a PR row.
*
* PR rows are soft-detached rather than deleted when their lane is removed or moves to
* another branch, and their `lane_id` intentionally keeps pointing at the gone lane. So
* printing `laneId` alone would present dead history as a live mapping. Detached rows
* render as `was <lane>` instead.
*/
function prLaneCell(pr: JsonObject): unknown {
const detached = isRecord(pr.detached) ? pr.detached : null;
if (detached) {
const name = asString(detached.laneName) ?? asString(pr.laneName);
return `was ${name ?? "deleted lane"}`;
}
return pr.laneId ?? pr.laneName;
}

function formatPrList(value: unknown): string {
const prs = firstArray(value, ["prs", "pullRequests", "items", "results"]);
return renderTable(
["PR", "state", "lane", "branch", "title"],
prs.map((pr) => [
pr.githubPrNumber ?? pr.number ?? pr.prNumber ?? pr.id,
pr.state ?? pr.status,
pr.laneId ?? pr.laneName,
prLaneCell(pr),
pr.headBranch ?? pr.headRefName ?? pr.branchRef ?? pr.branch,
pr.title,
]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ describe("rightPaneFormatters", () => {
expect(body).not.toContain("\"title\"");
});

it("renders a detached PR's lane as history, not as a live mapping", () => {
const body = formatPrSummary({
id: "pr-9",
number: 9,
title: "Merged work",
state: "merged",
laneId: "lane-deleted-uuid",
detached: { at: "2026-07-30T00:00:00Z", laneName: "prs-tab", laneColor: null },
});

expect(body).toContain("was prs-tab");
expect(body).not.toContain("lane-deleted-uuid");
});

it("formats PR create links from the new action envelope", () => {
const body = formatPrSummary({
pr: {
Expand Down
8 changes: 7 additions & 1 deletion apps/ade-cli/src/tuiClient/rightPaneFormatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,13 @@ export function formatPrSummary(value: unknown): string {
const draft = pickBoolean(pr, ["isDraft", "draft"]) === true ? " · draft" : "";
const title = pickString(pr, ["title", "name"]) ?? "Untitled PR";
const id = pickString(pr, ["id", "prId"]);
const lane = pickString(pr, ["laneName", "laneId"]);
// A PR whose lane was deleted (or moved to another branch) keeps its row and its now
// dangling `laneId`, so the raw id would read as a live mapping. Show it as history.
const detached = isRecord(pr.detached) ? pr.detached : null;
const laneValue = pickString(pr, ["laneName", "laneId"]);
const lane = detached
? `was ${pickString(detached, ["laneName"]) ?? laneValue ?? "deleted lane"}`
: laneValue;
const head = pickString(pr, ["headBranch", "headRefName", "branchRef", "branch"]);
const base = pickString(pr, ["baseBranch", "baseRefName", "baseRef", "targetBranch"]);
const githubUrl = pickString(root, ["githubUrl", "githubPrUrl"])
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/services/conflicts/conflictService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4380,6 +4380,9 @@ export function createConflictService({
from pull_requests
where project_id = ?
and state in ('open', 'draft')
-- A detached row belongs to a lane that was deleted or has moved to another
-- branch; it must not raise rebase needs against the live lane.
and detached_at is null
order by updated_at desc, created_at desc
`,
[projectId],
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/services/lanes/autoRebaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@ export function createAutoRebaseService(args: {
from pull_requests
where lane_id = ?
and state in ('open', 'draft')
-- Detached rows describe a branch this lane no longer tracks.
and detached_at is null
order by updated_at desc, created_at desc
limit 1
`,
Expand Down
52 changes: 44 additions & 8 deletions apps/desktop/src/main/services/lanes/laneService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4098,10 +4098,37 @@ describe("laneService delete teardown + cancellation + streaming", () => {
expect(count("conflict_proposals", "lane_id = ? or peer_lane_id = ?", ["lane-child", "lane-child"])).toBe(0);
expect(count("files_workspaces", "lane_id = ?", ["lane-child"])).toBe(0);
expect(count("file_directory_snapshots", "workspace_id = ?", ["workspace-child"])).toBe(0);
expect(count("pull_requests", "id = ?", ["pr-child"])).toBe(0);
// The PR row is DETACHED, not deleted: a merged PR outlives its lane, and deleting
// the row is what made merged PRs render as `unmapped` in the PRs tab.
expect(count("pull_requests", "id = ?", ["pr-child"])).toBe(1);
const detachedPr = db.get<{
lane_id: string;
detached_at: string | null;
detached_lane_name: string | null;
detached_provenance: string | null;
}>(
"select lane_id, detached_at, detached_lane_name, detached_provenance from pull_requests where id = ?",
["pr-child"],
);
expect(detachedPr?.detached_at).toBeTruthy();
// lane_id deliberately still points at the deleted lane — it is NOT NULL, CRR
// strips the FK, and it stays useful as a provenance key.
expect(detachedPr?.lane_id).toBe("lane-child");
expect(detachedPr?.detached_lane_name).toBeTruthy();
expect(JSON.parse(detachedPr?.detached_provenance ?? "{}")).toMatchObject({
chats: expect.any(Number),
artifacts: expect.any(Number),
checkpoints: expect.any(Number),
});
expect(count("pr_group_members", "lane_id = ?", ["lane-child"])).toBe(0);
expect(count("pull_request_ai_summaries", "pr_id = ?", ["pr-child"])).toBe(0);
expect(count("pull_request_snapshots", "pr_id = ?", ["pr-child"])).toBe(0);
// The snapshot row survives with the bulky kinds purged, so storage does not grow.
expect(count("pull_request_snapshots", "pr_id = ?", ["pr-child"])).toBe(1);
expect(
db.get<{ files_json: string | null; checks_json: string | null }>(
"select files_json, checks_json, comments_json, reviews_json from pull_request_snapshots where pr_id = ?",
["pr-child"],
),
).toEqual({ files_json: null, checks_json: null, comments_json: null, reviews_json: null });
expect(count("pr_auto_link_ignores", "lane_id = ?", ["lane-child"])).toBe(0);
expect(count("review_runs", "id = ?", ["review-run-child"])).toBe(0);
expect(count("review_reviewer_runs", "id = ?", ["reviewer-run-child"])).toBe(0);
Expand Down Expand Up @@ -4746,13 +4773,22 @@ describe("laneService - branchSwitch", () => {
);
expect(keep?.lane_id).toBe("lane-a");

const stale = db.get<{ lane_id: string | null }>(
"select lane_id from pull_requests where id = ?",
// Stale rows are detached, not deleted — the PR happened on this lane even
// though the lane now tracks a different branch.
const stale = db.get<{ lane_id: string | null; detached_at: string | null; detached_lane_name: string | null }>(
"select lane_id, detached_at, detached_lane_name from pull_requests where id = ?",
["pr-stale"],
);
expect(stale).toBeNull();
expect(db.get<{ count: number }>("select count(1) as count from pull_request_ai_summaries where pr_id = ?", ["pr-stale"])?.count).toBe(0);
expect(db.get<{ count: number }>("select count(1) as count from pull_request_snapshots where pr_id = ?", ["pr-stale"])?.count).toBe(0);
expect(stale?.detached_at).toBeTruthy();
expect(stale?.lane_id).toBe("lane-a");
// A detached row must not be treated as live: the kept row is the only one
// still claiming the lane's current branch.
expect(
db.get<{ count: number }>(
"select count(1) as count from pull_requests where lane_id = ? and detached_at is null",
["lane-a"],
)?.count,
).toBe(1);
} finally {
db.close();
fs.rmSync(repoRoot, { recursive: true, force: true });
Expand Down
55 changes: 42 additions & 13 deletions apps/desktop/src/main/services/lanes/laneService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type { AdeDb } from "../state/kvDb";
import { getHeadSha, runGit, runGitOrThrow } from "../git/git";
import { deletePullRequestRowsByIds, deletePullRequestRowsForLane } from "../prs/pullRequestRowCleanup";
import { detachPullRequestRowsByIds, detachPullRequestRowsForLane } from "../prs/pullRequestRowCleanup";
import { isWithinDir, normalizeBranchName, resolvePathWithinRoot } from "../shared/utils";
import { fetchRemoteTrackingBranch } from "../shared/remoteTrackingBranch";
import { detectConflictKind } from "../git/gitConflictState";
Expand Down Expand Up @@ -2397,6 +2397,7 @@ export function createLaneService({
where pr.project_id = l.project_id
and pr.lane_id = l.id
and pr.state in ('open', 'draft')
and pr.detached_at is null
)
`,
[projectId, normalizedDefaultBaseRef],
Expand All @@ -2419,6 +2420,7 @@ export function createLaneService({
where pr.project_id = lanes.project_id
and pr.lane_id = lanes.id
and pr.state in ('open', 'draft')
and pr.detached_at is null
)
`,
[normalizedDefaultBaseRef, projectId, normalizedDefaultBaseRef],
Expand Down Expand Up @@ -3144,7 +3146,20 @@ export function createLaneService({
db.run("update integration_proposals set integration_lane_id = null where integration_lane_id = ? and project_id = ?", [laneId, projectId]);
db.run("update integration_proposals set preferred_integration_lane_id = null where preferred_integration_lane_id = ? and project_id = ?", [laneId, projectId]);

deletePullRequestRowsForLane(db, projectId, laneId);
// Soft-detach rather than delete: the PR outlives the lane, and deleting the row is
// what made every merged PR show up as `unmapped` once its lane was cleaned up.
// Runs before the session/artifact/checkpoint deletes below, because it counts them.
const detachedLane = db.get<{ name: string | null; color: string | null }>(
"select name, color from lanes where id = ? and project_id = ?",
[laneId, projectId],
);
detachPullRequestRowsForLane(db, {
projectId,
laneId,
laneName: detachedLane?.name ?? null,
laneColor: detachedLane?.color ?? null,
detachedAt: new Date().toISOString(),
});
db.run("delete from pr_auto_link_ignores where lane_id = ? and project_id = ?", [laneId, projectId]);

db.run("delete from review_run_publications where run_id in (select id from review_runs where lane_id = ? and project_id = ?)", [laneId, projectId]);
Expand Down Expand Up @@ -4476,12 +4491,11 @@ export function createLaneService({
`,
[targetBranchRef, baseRef, parentLaneId, row.id, projectId],
);
// Drop any PR rows still associated with this lane whose head_branch
// no longer matches the lane's current branch — those references are
// stale after a branch switch and must not bleed into PR lookups.
// pull_requests.lane_id is NOT NULL, so we DELETE (mirrors the explicit
// child-row cleanup used by the lane-delete path; CRR conversion can
// strip FK cascades).
// PR rows whose head_branch no longer matches the lane's current branch are
// stale references and must not bleed into PR lookups. Detach rather than
// delete: the PR still happened on this lane, and erasing it is what made
// merged PRs render as `unmapped`. detached_at takes them out of live lookups
// while keeping the history.
const stalePrRows = db.all<{ id: string }>(
`
select id from pull_requests
Expand All @@ -4492,8 +4506,14 @@ export function createLaneService({
[row.id, projectId, targetBranchRef],
);
if (stalePrRows.length > 0) {
const stalePrIds = stalePrRows.map((r) => r.id);
deletePullRequestRowsByIds(db, projectId, stalePrIds);
detachPullRequestRowsByIds(db, {
projectId,
laneId: row.id,
laneName: row.name ?? null,
laneColor: row.color ?? null,
detachedAt: new Date().toISOString(),
prIds: stalePrRows.map((r) => r.id),
});
}
db.run("commit");
} catch (err) {
Expand Down Expand Up @@ -4667,7 +4687,9 @@ export function createLaneService({
);
// Same rationale as switchBranch: PR rows whose head branch no longer
// matches the lane are stale references and must not bleed into PR
// lookups now that the lane tracks a different branch.
// lookups now that the lane tracks a different branch. Detached, not deleted,
// so the merged view keeps the record. `row` is the pre-update snapshot, so
// `row.name` is the name these PRs were actually built under.
const stalePrRows = db.all<{ id: string }>(
`
select id from pull_requests
Expand All @@ -4678,7 +4700,14 @@ export function createLaneService({
[row.id, projectId, targetBranchRef],
);
if (stalePrRows.length > 0) {
deletePullRequestRowsByIds(db, projectId, stalePrRows.map((entry) => entry.id));
detachPullRequestRowsByIds(db, {
projectId,
laneId: row.id,
laneName: row.name ?? null,
laneColor: row.color ?? null,
detachedAt: new Date().toISOString(),
prIds: stalePrRows.map((entry) => entry.id),
});
}
db.run("commit");
} catch (err) {
Expand Down Expand Up @@ -5517,7 +5546,7 @@ export function createLaneService({
if (remoteTemp.exitCode !== 2) return skipBranch("remote_state_unavailable");
}
const pr = db.get<{ id: string }>(
"select id from pull_requests where project_id = ? and lane_id = ? limit 1",
"select id from pull_requests where project_id = ? and lane_id = ? and detached_at is null limit 1",
[projectId, args.laneId],
);
if (pr) return skipBranch("pull_request_exists");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export function createRebaseSuggestionService(args: {
select lane_id
from pull_requests
where project_id = ?
-- Detached rows keep a dangling lane_id, so they would otherwise mark a
-- lane as PR-backed long after its PR stopped belonging to it.
and detached_at is null
`,
[projectId]
);
Expand Down
Loading