newpr 1.0.21 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -216,4 +216,27 @@ describe("mergeEmptyGroups", () => {
216
216
  expect(result.groups[0]!.order).toBe(0);
217
217
  expect(result.groups[1]!.order).toBe(1);
218
218
  });
219
+
220
+ test("removes self deps after empty-group merge remapping", () => {
221
+ const groups: StackGroup[] = [
222
+ makeStackGroup({
223
+ id: "A",
224
+ name: "A",
225
+ files: ["a.ts"],
226
+ order: 0,
227
+ stats: nonZeroStats,
228
+ deps: ["B"],
229
+ explicit_deps: ["B"],
230
+ }),
231
+ makeStackGroup({ id: "B", name: "B", files: ["b.ts"], order: 1, stats: zeroStats }),
232
+ ];
233
+ const ownership = new Map([["a.ts", "A"], ["b.ts", "B"]]);
234
+ const trees = new Map([["A", "tree-a"], ["B", "tree-b"]]);
235
+
236
+ const result = mergeEmptyGroups(groups, ownership, trees);
237
+ expect(result.groups.length).toBe(1);
238
+ expect(result.groups[0]!.id).toBe("A");
239
+ expect(result.groups[0]!.deps).toEqual([]);
240
+ expect(result.groups[0]!.explicit_deps).toEqual([]);
241
+ });
219
242
  });
@@ -149,7 +149,23 @@ export function mergeEmptyGroups(
149
149
  working.splice(i, 1);
150
150
 
151
151
  for (let j = 0; j < working.length; j++) {
152
- working[j]!.order = j;
152
+ const current = working[j];
153
+ if (!current) continue;
154
+
155
+ current.order = j;
156
+ const nextDeps = (current.deps ?? [])
157
+ .map((dep) => dep === g.id ? neighbor.id : dep)
158
+ .filter((dep) => dep !== current.id)
159
+ .filter((dep) => working.some((w) => w.id === dep));
160
+ current.deps = Array.from(new Set(nextDeps));
161
+
162
+ if (current.explicit_deps) {
163
+ const nextExplicitDeps = current.explicit_deps
164
+ .map((dep) => dep === g.id ? neighbor.id : dep)
165
+ .filter((dep) => dep !== current.id)
166
+ .filter((dep) => working.some((w) => w.id === dep));
167
+ current.explicit_deps = Array.from(new Set(nextExplicitDeps));
168
+ }
153
169
  }
154
170
  }
155
171
 
@@ -80,6 +80,35 @@ describe("createStackPlan", () => {
80
80
  expect(plan.groups[1]?.files).toContain("src/ui.tsx");
81
81
  });
82
82
 
83
+ test("ignores self dependency edges when building group deps", async () => {
84
+ const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
85
+
86
+ const ownership = new Map([
87
+ ["src/auth.ts", "Auth"],
88
+ ["src/ui.tsx", "UI"],
89
+ ]);
90
+
91
+ const groups: FileGroup[] = [
92
+ { name: "Auth", type: "feature", description: "Auth", files: ["src/auth.ts"] },
93
+ { name: "UI", type: "feature", description: "UI", files: ["src/ui.tsx"] },
94
+ ];
95
+
96
+ const plan = await createStackPlan({
97
+ repo_path: testRepoPath,
98
+ base_sha: baseSha,
99
+ head_sha: headSha,
100
+ deltas,
101
+ ownership,
102
+ group_order: ["Auth", "UI"],
103
+ groups,
104
+ dependency_edges: [{ from: "UI", to: "UI" }],
105
+ });
106
+
107
+ const uiGroup = plan.groups.find((g) => g.id === "UI");
108
+ expect(uiGroup).toBeDefined();
109
+ expect(uiGroup?.deps).toEqual([]);
110
+ });
111
+
83
112
  test("final tree matches HEAD tree (suffix propagation)", async () => {
84
113
  const deltas = await extractDeltas(testRepoPath, baseSha, headSha);
85
114
 
@@ -101,6 +130,7 @@ describe("createStackPlan", () => {
101
130
  ownership,
102
131
  group_order: ["Auth", "UI"],
103
132
  groups,
133
+ dependency_edges: [{ from: "Auth", to: "UI" }],
104
134
  });
105
135
 
106
136
  const headTreeResult = await Bun.$`git -C ${testRepoPath} rev-parse ${headSha}^{tree}`.quiet();
package/src/stack/plan.ts CHANGED
@@ -13,15 +13,21 @@ export interface PlanInput {
13
13
  ownership: Map<string, string>;
14
14
  group_order: string[];
15
15
  groups: FileGroup[];
16
+ dependency_edges?: Array<{ from: string; to: string }>;
16
17
  }
17
18
 
18
19
  export async function createStackPlan(input: PlanInput): Promise<StackPlan> {
19
- const { repo_path, base_sha, head_sha, deltas, ownership, group_order, groups } = input;
20
+ const { repo_path, base_sha, head_sha, deltas, ownership, group_order, groups, dependency_edges } = input;
20
21
 
21
22
  const groupRank = new Map<string, number>();
22
23
  group_order.forEach((gid, idx) => groupRank.set(gid, idx));
23
24
 
24
- const stackGroups = buildStackGroups(groups, group_order, ownership);
25
+ const edges = dependency_edges ?? [];
26
+ const dagParents = buildDagParents(group_order, edges);
27
+ const explicitDagParents = buildExplicitDagParents(group_order, edges);
28
+ const ancestorSets = buildAncestorSets(group_order, dagParents);
29
+
30
+ const stackGroups = buildStackGroups(groups, group_order, ownership, dagParents, explicitDagParents);
25
31
 
26
32
  const tmpIndexFiles: string[] = [];
27
33
  const expectedTrees = new Map<string, string>();
@@ -47,8 +53,12 @@ export async function createStackPlan(input: PlanInput): Promise<StackPlan> {
47
53
  const fileRank = groupRank.get(fileGroupId);
48
54
  if (fileRank === undefined) continue;
49
55
 
50
- // Suffix propagation: update index[fileRank] through index[N-1]
51
- for (let idxNum = fileRank; idxNum < group_order.length; idxNum++) {
56
+ for (let idxNum = 0; idxNum < group_order.length; idxNum++) {
57
+ const targetGroupId = group_order[idxNum]!;
58
+ const isOwner = targetGroupId === fileGroupId;
59
+ const isAncestorOfOwner = ancestorSets.get(targetGroupId)?.has(fileGroupId) ?? false;
60
+ if (!isOwner && !isAncestorOfOwner) continue;
61
+
52
62
  let batch = batchPerIndex.get(idxNum);
53
63
  if (!batch) {
54
64
  batch = [];
@@ -100,18 +110,71 @@ export async function createStackPlan(input: PlanInput): Promise<StackPlan> {
100
110
  }
101
111
  }
102
112
 
113
+ const ancestorSetsRecord = new Map<string, string[]>();
114
+ for (const [gid, set] of ancestorSets) {
115
+ ancestorSetsRecord.set(gid, Array.from(set));
116
+ }
117
+
103
118
  return {
104
119
  base_sha,
105
120
  head_sha,
106
121
  groups: stackGroups,
107
122
  expected_trees: expectedTrees,
123
+ ancestor_sets: ancestorSetsRecord,
108
124
  };
109
125
  }
110
126
 
127
+ export function buildDagParents(
128
+ groupOrder: string[],
129
+ dependencyEdges: Array<{ from: string; to: string }>,
130
+ ): Map<string, string[]> {
131
+ return buildExplicitDagParents(groupOrder, dependencyEdges);
132
+ }
133
+
134
+ export function buildExplicitDagParents(
135
+ groupOrder: string[],
136
+ dependencyEdges: Array<{ from: string; to: string }>,
137
+ ): Map<string, string[]> {
138
+ const parents = new Map<string, string[]>();
139
+ for (const gid of groupOrder) parents.set(gid, []);
140
+
141
+ for (const edge of dependencyEdges) {
142
+ if (!parents.has(edge.to)) continue;
143
+ if (edge.from === edge.to) continue;
144
+ const arr = parents.get(edge.to)!;
145
+ if (!arr.includes(edge.from)) arr.push(edge.from);
146
+ }
147
+
148
+ return parents;
149
+ }
150
+
151
+ export function buildAncestorSets(
152
+ groupOrder: string[],
153
+ dagParents: Map<string, string[]>,
154
+ ): Map<string, Set<string>> {
155
+ const ancestors = new Map<string, Set<string>>();
156
+
157
+ for (const gid of groupOrder) {
158
+ const set = new Set<string>();
159
+ const queue = [...(dagParents.get(gid) ?? [])];
160
+ while (queue.length > 0) {
161
+ const node = queue.shift()!;
162
+ if (set.has(node)) continue;
163
+ set.add(node);
164
+ for (const p of dagParents.get(node) ?? []) queue.push(p);
165
+ }
166
+ ancestors.set(gid, set);
167
+ }
168
+
169
+ return ancestors;
170
+ }
171
+
111
172
  function buildStackGroups(
112
173
  groups: FileGroup[],
113
174
  groupOrder: string[],
114
175
  ownership: Map<string, string>,
176
+ dagParents: Map<string, string[]>,
177
+ explicitDagParents: Map<string, string[]>,
115
178
  ): StackGroup[] {
116
179
  const groupNameMap = new Map<string, FileGroup>();
117
180
  for (const g of groups) {
@@ -132,7 +195,8 @@ function buildStackGroups(
132
195
  type: (original?.type ?? "chore") as GroupType,
133
196
  description: original?.description ?? "",
134
197
  files: files.sort(),
135
- deps: original?.dependencies ?? [],
198
+ deps: dagParents.get(gid) ?? [],
199
+ explicit_deps: explicitDagParents.get(gid) ?? [],
136
200
  order: idx,
137
201
  };
138
202
  });
@@ -0,0 +1,83 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { PrMeta } from "../types/output.ts";
6
+ import { buildStackPublishPreview } from "./publish.ts";
7
+ import type { StackExecResult } from "./types.ts";
8
+
9
+ let repoPath = "";
10
+ let headSha = "";
11
+
12
+ beforeAll(async () => {
13
+ repoPath = mkdtempSync(join(tmpdir(), "publish-preview-test-"));
14
+
15
+ await Bun.$`git init ${repoPath}`.quiet();
16
+ await Bun.$`git -C ${repoPath} config user.name "Test User"`.quiet();
17
+ await Bun.$`git -C ${repoPath} config user.email "test@example.com"`.quiet();
18
+
19
+ writeFileSync(join(repoPath, "README.md"), "initial\n");
20
+ await Bun.$`git -C ${repoPath} add README.md`.quiet();
21
+ await Bun.$`git -C ${repoPath} commit -m "Initial commit"`.quiet();
22
+
23
+ headSha = (await Bun.$`git -C ${repoPath} rev-parse HEAD`.quiet()).stdout.toString().trim();
24
+ });
25
+
26
+ afterAll(() => {
27
+ if (repoPath) rmSync(repoPath, { recursive: true, force: true });
28
+ });
29
+
30
+ describe("buildStackPublishPreview", () => {
31
+ test("uses DAG dependency bases instead of linear previous branch", async () => {
32
+ const execResult: StackExecResult = {
33
+ run_id: "run-1",
34
+ source_copy_branch: "newpr/stack-source/pr-42",
35
+ group_commits: [
36
+ { group_id: "A", commit_sha: headSha, tree_sha: headSha, branch_name: "stack/a", pr_title: "A title" },
37
+ { group_id: "B", commit_sha: headSha, tree_sha: headSha, branch_name: "stack/b", pr_title: "B title" },
38
+ { group_id: "C", commit_sha: headSha, tree_sha: headSha, branch_name: "stack/c", pr_title: "C title" },
39
+ ],
40
+ final_tree_sha: headSha,
41
+ verified: true,
42
+ };
43
+
44
+ const prMeta: PrMeta = {
45
+ pr_number: 42,
46
+ pr_title: "Source PR",
47
+ pr_url: "https://github.com/acme/repo/pull/42",
48
+ base_branch: "main",
49
+ head_branch: "feature/source",
50
+ author: "tester",
51
+ total_files_changed: 3,
52
+ total_additions: 10,
53
+ total_deletions: 2,
54
+ analyzed_at: new Date().toISOString(),
55
+ model_used: "test-model",
56
+ };
57
+
58
+ const preview = await buildStackPublishPreview({
59
+ repo_path: repoPath,
60
+ exec_result: execResult,
61
+ pr_meta: prMeta,
62
+ base_branch: "main",
63
+ owner: "acme",
64
+ repo: "repo",
65
+ plan_groups: [
66
+ { id: "A", name: "A", description: "A desc", files: ["a.ts"], order: 0, deps: [] },
67
+ { id: "B", name: "B", description: "B desc", files: ["b.ts"], order: 1, deps: ["A"] },
68
+ { id: "C", name: "C", description: "C desc", files: ["c.ts"], order: 2, deps: [] },
69
+ ],
70
+ });
71
+
72
+ const itemById = new Map(preview.items.map((item) => [item.group_id, item]));
73
+
74
+ expect(itemById.get("A")?.base_branch).toBe("main");
75
+ expect(itemById.get("B")?.base_branch).toBe("stack/a");
76
+ expect(itemById.get("C")?.base_branch).toBe("main");
77
+
78
+ expect(itemById.get("B")?.title).toContain("L1");
79
+ expect(itemById.get("C")?.title).toContain("L0");
80
+ expect(itemById.get("B")?.body).toContain("Stack L1");
81
+ expect(itemById.get("C")?.body).toContain("Stack L0");
82
+ });
83
+ });
@@ -24,6 +24,7 @@ export interface StackPublishGroupMeta {
24
24
  order: number;
25
25
  type?: string;
26
26
  pr_title?: string;
27
+ deps?: string[];
27
28
  }
28
29
 
29
30
  export interface StackPublishPreviewItem {
@@ -87,6 +88,45 @@ function buildGroupMetaMap(groups: StackPublishGroupMeta[] | undefined): Map<str
87
88
  return map;
88
89
  }
89
90
 
91
+ function buildDagLevelMap(
92
+ groupCommits: StackExecResult["group_commits"],
93
+ groupMetaById: Map<string, StackPublishGroupMeta>,
94
+ ): Map<string, number> {
95
+ const levels = new Map<string, number>();
96
+ const inDegree = new Map<string, number>();
97
+
98
+ for (const gc of groupCommits) {
99
+ inDegree.set(gc.group_id, 0);
100
+ }
101
+
102
+ for (const gc of groupCommits) {
103
+ const deps = groupMetaById.get(gc.group_id)?.deps ?? [];
104
+ if (deps.length > 0) {
105
+ inDegree.set(gc.group_id, deps.length);
106
+ }
107
+ }
108
+
109
+ const queue = groupCommits.filter((gc) => (inDegree.get(gc.group_id) ?? 0) === 0).map((gc) => gc.group_id);
110
+ for (const gid of queue) levels.set(gid, 0);
111
+
112
+ while (queue.length > 0) {
113
+ const gid = queue.shift()!;
114
+ const level = levels.get(gid) ?? 0;
115
+ for (const gc of groupCommits) {
116
+ const deps = groupMetaById.get(gc.group_id)?.deps ?? [];
117
+ if (deps.includes(gid)) {
118
+ const newLevel = Math.max(levels.get(gc.group_id) ?? 0, level + 1);
119
+ levels.set(gc.group_id, newLevel);
120
+ const remaining = (inDegree.get(gc.group_id) ?? 1) - 1;
121
+ inDegree.set(gc.group_id, remaining);
122
+ if (remaining === 0) queue.push(gc.group_id);
123
+ }
124
+ }
125
+ }
126
+
127
+ return levels;
128
+ }
129
+
90
130
  function buildEffectiveGroupMeta(
91
131
  execResult: StackExecResult,
92
132
  groupMetaById: Map<string, StackPublishGroupMeta>,
@@ -105,6 +145,24 @@ function buildEffectiveGroupMeta(
105
145
  });
106
146
  }
107
147
 
148
+ function resolvePrBaseBranch(
149
+ groupId: string,
150
+ baseBranch: string,
151
+ planGroups: StackPublishGroupMeta[] | undefined,
152
+ branchByGroupId: Map<string, string>,
153
+ ): string {
154
+ const planGroup = planGroups?.find((g) => g.id === groupId);
155
+ const directDeps: string[] = planGroup?.deps ?? [];
156
+ if (directDeps.length > 0) {
157
+ const depBranch = directDeps
158
+ .map((dep: string) => branchByGroupId.get(dep))
159
+ .find((b: string | undefined): b is string => Boolean(b));
160
+ if (depBranch) return depBranch;
161
+ }
162
+
163
+ return baseBranch;
164
+ }
165
+
108
166
  function isPreviewCompatible(execResult: StackExecResult, preview: StackPublishPreviewResult | null | undefined): boolean {
109
167
  if (!preview || preview.items.length === 0) return false;
110
168
  if (preview.items.length !== execResult.group_commits.length) return false;
@@ -458,6 +516,10 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
458
516
  const prs: PrInfo[] = [];
459
517
  const total = exec_result.group_commits.length;
460
518
 
519
+ const branchByGroupId = new Map(exec_result.group_commits.map((gc) => [gc.group_id, gc.branch_name]));
520
+
521
+ const dagLevelMap = buildDagLevelMap(exec_result.group_commits, groupMetaById);
522
+
461
523
  for (const gc of exec_result.group_commits) {
462
524
  const pushResult = await Bun.$`git -C ${repo_path} push origin refs/heads/${gc.branch_name}:refs/heads/${gc.branch_name} --force-with-lease`.quiet().nothrow();
463
525
 
@@ -480,11 +542,12 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
480
542
  if (!branchInfo?.pushed) continue;
481
543
 
482
544
  const previewItem = previewByGroup.get(gc.group_id);
483
- const prBase = previewItem?.base_branch ?? (i === 0 ? base_branch : exec_result.group_commits[i - 1]?.branch_name);
545
+ const prBase = previewItem?.base_branch ?? resolvePrBaseBranch(gc.group_id, base_branch, plan_groups, branchByGroupId);
484
546
  if (!prBase) continue;
485
547
 
486
548
  const order = i + 1;
487
- const title = previewItem?.title ?? buildStackPrTitle(gc, pr_meta, order, total);
549
+ const dagLevel = dagLevelMap.get(gc.group_id);
550
+ const title = previewItem?.title ?? buildStackPrTitle(gc, pr_meta, order, total, dagLevel);
488
551
 
489
552
  const placeholder = previewItem?.body ?? buildPlaceholderBody(
490
553
  gc.group_id,
@@ -496,6 +559,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
496
559
  prBase,
497
560
  gc.branch_name,
498
561
  llmPrefillByGroup.get(gc.group_id),
562
+ dagLevel,
499
563
  );
500
564
 
501
565
  const prResult = await runWithBodyFile(
@@ -507,6 +571,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
507
571
  const prUrl = prResult.stdout.toString().trim();
508
572
  const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
509
573
 
574
+ const planGroupDeps = plan_groups?.find((g) => g.id === gc.group_id)?.deps ?? [];
510
575
  prs.push({
511
576
  group_id: gc.group_id,
512
577
  number: prNumberMatch ? parseInt(prNumberMatch[1]!, 10) : 0,
@@ -514,6 +579,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
514
579
  title,
515
580
  base_branch: prBase,
516
581
  head_branch: gc.branch_name,
582
+ dep_group_ids: planGroupDeps,
517
583
  });
518
584
  } else {
519
585
  const stderr = prResult.stderr.toString().trim();
@@ -521,8 +587,8 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
521
587
  }
522
588
  }
523
589
 
524
- await updatePrBodies(ghRepo, prs, pr_meta, prTemplate, groupMetaById, llmPrefillByGroup, previewByGroup);
525
- await postStackNavigationComments(ghRepo, prs);
590
+ await updatePrBodies(ghRepo, prs, pr_meta, prTemplate, groupMetaById, llmPrefillByGroup, previewByGroup, dagLevelMap);
591
+ await postStackNavigationComments(ghRepo, prs, dagLevelMap);
526
592
 
527
593
  return { branches, prs };
528
594
  }
@@ -534,6 +600,8 @@ export async function buildStackPublishPreview(input: PublishInput): Promise<Sta
534
600
  const prTemplate = prTemplateData?.content ?? null;
535
601
  const total = exec_result.group_commits.length;
536
602
  const groupMetaById = buildGroupMetaMap(plan_groups);
603
+ const branchByGroupId = new Map(exec_result.group_commits.map((gc) => [gc.group_id, gc.branch_name]));
604
+ const dagLevelMap = buildDagLevelMap(exec_result.group_commits, groupMetaById);
537
605
  const effectiveGroups = buildEffectiveGroupMeta(exec_result, groupMetaById);
538
606
  const llmPrefillByGroup = await generateTemplatePrefillWithLlm(
539
607
  llm_client,
@@ -545,8 +613,9 @@ export async function buildStackPublishPreview(input: PublishInput): Promise<Sta
545
613
 
546
614
  const items = exec_result.group_commits.map((gc, i) => {
547
615
  const order = i + 1;
548
- const title = buildStackPrTitle(gc, pr_meta, order, total);
549
- const prBase = i === 0 ? base_branch : exec_result.group_commits[i - 1]?.branch_name ?? base_branch;
616
+ const dagLevel = dagLevelMap.get(gc.group_id);
617
+ const title = buildStackPrTitle(gc, pr_meta, order, total, dagLevel);
618
+ const prBase = resolvePrBaseBranch(gc.group_id, base_branch, plan_groups, branchByGroupId);
550
619
  const groupMeta = groupMetaById.get(gc.group_id);
551
620
  return {
552
621
  group_id: gc.group_id,
@@ -565,6 +634,7 @@ export async function buildStackPublishPreview(input: PublishInput): Promise<Sta
565
634
  prBase,
566
635
  gc.branch_name,
567
636
  llmPrefillByGroup.get(gc.group_id),
637
+ dagLevel,
568
638
  ),
569
639
  };
570
640
  });
@@ -583,6 +653,7 @@ async function updatePrBodies(
583
653
  groupMetaById: Map<string, StackPublishGroupMeta>,
584
654
  llmPrefillByGroup: Map<string, SectionBullets>,
585
655
  previewByGroup: Map<string, StackPublishPreviewItem>,
656
+ dagLevelMap: Map<string, number>,
586
657
  ): Promise<void> {
587
658
  if (prs.length === 0) return;
588
659
 
@@ -591,6 +662,7 @@ async function updatePrBodies(
591
662
  const previewItem = previewByGroup.get(pr.group_id);
592
663
  const previewBody = previewItem?.body;
593
664
  const groupMeta = groupMetaById.get(pr.group_id);
665
+ const dagLevel = dagLevelMap.get(pr.group_id);
594
666
  const body = previewBody ?? buildFullBody(
595
667
  pr,
596
668
  i,
@@ -601,6 +673,7 @@ async function updatePrBodies(
601
673
  pr.base_branch,
602
674
  pr.head_branch,
603
675
  llmPrefillByGroup.get(pr.group_id),
676
+ dagLevel,
604
677
  );
605
678
  const editResult = await runWithBodyFile(
606
679
  body,
@@ -613,7 +686,11 @@ async function updatePrBodies(
613
686
  }
614
687
  }
615
688
 
616
- async function postStackNavigationComments(ghRepo: string, prs: PrInfo[]): Promise<void> {
689
+ async function postStackNavigationComments(
690
+ ghRepo: string,
691
+ prs: PrInfo[],
692
+ dagLevelMap: Map<string, number>,
693
+ ): Promise<void> {
617
694
  if (prs.length === 0) return;
618
695
 
619
696
  for (let i = 0; i < prs.length; i++) {
@@ -621,7 +698,7 @@ async function postStackNavigationComments(ghRepo: string, prs: PrInfo[]): Promi
621
698
  const alreadyPosted = await hasStackNavigationComment(ghRepo, pr.number);
622
699
  if (alreadyPosted) continue;
623
700
 
624
- const comment = buildStackNavigationComment(i, prs);
701
+ const comment = buildStackNavigationComment(i, prs, dagLevelMap);
625
702
  const commentResult = await runWithBodyFile(
626
703
  comment,
627
704
  (filePath) => Bun.$`gh pr comment ${pr.number} --repo ${ghRepo} --body-file ${filePath}`.quiet().nothrow(),
@@ -655,8 +732,9 @@ function buildPlaceholderBody(
655
732
  baseBranch?: string,
656
733
  headBranch?: string,
657
734
  llmBullets?: SectionBullets,
735
+ dagLevel?: number,
658
736
  ): string {
659
- return buildDescriptionBody(groupId, order, total, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets);
737
+ return buildDescriptionBody(groupId, order, total, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets, dagLevel);
660
738
  }
661
739
 
662
740
  function buildStackPrTitle(
@@ -664,8 +742,10 @@ function buildStackPrTitle(
664
742
  prMeta: PrMeta,
665
743
  order: number,
666
744
  total: number,
745
+ dagLevel?: number,
667
746
  ): string {
668
- const stackPrefix = `[PR#${prMeta.pr_number} ${order}/${total}]`;
747
+ const levelLabel = dagLevel !== undefined ? `L${dagLevel}` : `${order}/${total}`;
748
+ const stackPrefix = `[PR#${prMeta.pr_number} ${levelLabel}]`;
669
749
  return groupCommit.pr_title
670
750
  ? `${stackPrefix} ${groupCommit.pr_title}`
671
751
  : `${stackPrefix} ${groupCommit.group_id}`;
@@ -681,11 +761,16 @@ function buildDescriptionBody(
681
761
  baseBranch?: string,
682
762
  headBranch?: string,
683
763
  llmBullets?: SectionBullets,
764
+ dagLevel?: number,
684
765
  ): string {
766
+ const positionLabel = dagLevel !== undefined ? `L${dagLevel}` : `${order}/${total}`;
767
+ const depNames = (groupMeta?.deps ?? []).length > 0
768
+ ? `Depends on: ${(groupMeta?.deps ?? []).join(", ")}`
769
+ : "Base of stack";
685
770
  const lines = [
686
- `> **Stack ${order}/${total}** — This PR is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
771
+ `> **Stack ${positionLabel}** — This PR is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
687
772
  `> Source: #${prMeta.pr_number} ${prMeta.pr_title}`,
688
- `> Stack navigation is posted as a discussion comment.`,
773
+ `> ${depNames} · Stack navigation is posted as a discussion comment.`,
689
774
  ``,
690
775
  `---`,
691
776
  ``,
@@ -721,39 +806,60 @@ function buildFullBody(
721
806
  baseBranch?: string,
722
807
  headBranch?: string,
723
808
  llmBullets?: SectionBullets,
809
+ dagLevel?: number,
724
810
  ): string {
725
- return buildDescriptionBody(current.group_id, index + 1, allPrs.length, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets);
811
+ return buildDescriptionBody(current.group_id, index + 1, allPrs.length, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets, dagLevel);
726
812
  }
727
813
 
728
- function buildStackNavigationComment(index: number, allPrs: PrInfo[]): string {
814
+ function buildStackNavigationComment(
815
+ index: number,
816
+ allPrs: PrInfo[],
817
+ dagLevelMap: Map<string, number>,
818
+ ): string {
729
819
  const total = allPrs.length;
730
- const order = index + 1;
820
+ const currentPr = allPrs[index]!;
821
+ const currentLevel = dagLevelMap.get(currentPr.group_id) ?? index;
822
+ const prByGroupId = new Map(allPrs.map((pr) => [pr.group_id, pr]));
823
+
824
+ const depPrs = (currentPr.dep_group_ids ?? [])
825
+ .map((depId) => prByGroupId.get(depId))
826
+ .filter((pr): pr is PrInfo => Boolean(pr));
827
+
828
+ const dependentPrs = allPrs.filter((pr) =>
829
+ (pr.dep_group_ids ?? []).includes(currentPr.group_id),
830
+ );
731
831
 
732
832
  const stackTable = allPrs.map((pr, i) => {
733
- const num = i + 1;
734
833
  const isCurrent = i === index;
735
834
  const marker = isCurrent ? "👉" : statusEmoji(i, index);
736
835
  const link = `[#${pr.number}](${pr.url})`;
737
836
  const titleText = pr.title.replace(/^\[(?:PR#\d+\s+\d+\/\d+|Stack\s+\d+\/\d+|\d+\/\d+)\]\s*/i, "");
738
- return `| ${marker} | ${num}/${total} | ${link} | ${titleText} |`;
837
+ const level = dagLevelMap.get(pr.group_id) ?? i;
838
+ const indent = level > 0 ? " ".repeat(level) : "";
839
+ return `| ${marker} | L${level} | ${link} | ${indent}${titleText} |`;
739
840
  }).join("\n");
740
841
 
741
- const prev = index > 0
742
- ? `⬅️ Previous: [#${allPrs[index - 1]!.number}](${allPrs[index - 1]!.url})`
743
- : "⬅️ Previous: base branch";
744
- const next = index < total - 1
745
- ? `➡️ Next: [#${allPrs[index + 1]!.number}](${allPrs[index + 1]!.url})`
746
- : "➡️ Next: top of stack";
842
+ const navLines: string[] = [];
843
+ if (depPrs.length > 0) {
844
+ navLines.push(`⬆️ Depends on: ${depPrs.map((p) => `[#${p.number}](${p.url})`).join(", ")}`);
845
+ } else {
846
+ navLines.push("⬆️ Depends on: base branch");
847
+ }
848
+ if (dependentPrs.length > 0) {
849
+ navLines.push(`⬇️ Required by: ${dependentPrs.map((p) => `[#${p.number}](${p.url})`).join(", ")}`);
850
+ } else {
851
+ navLines.push("⬇️ Required by: (top of stack)");
852
+ }
747
853
 
748
854
  return [
749
855
  STACK_NAV_COMMENT_MARKER,
750
- `### 📚 Stack Navigation (${order}/${total})`,
856
+ `### 📚 Stack Navigation (L${currentLevel}, PR ${index + 1}/${total})`,
751
857
  ``,
752
- `| | Order | PR | Title |`,
858
+ `| | Level | PR | Title |`,
753
859
  `|---|---|---|---|`,
754
860
  stackTable,
755
861
  ``,
756
- `${prev} | ${next}`,
862
+ navLines.join(" | "),
757
863
  ``,
758
864
  `_Posted by newpr during stack publish._`,
759
865
  ].join("\n");
@@ -89,6 +89,7 @@ export interface CycleReport {
89
89
  export interface FeasibilityResult {
90
90
  feasible: boolean;
91
91
  ordered_group_ids?: string[];
92
+ dependency_edges?: Array<{ from: string; to: string }>;
92
93
  cycle?: CycleReport;
93
94
  unassigned_paths?: Array<{ path: string; commits: string[] }>;
94
95
  ambiguous_paths?: Array<{ path: string; groups: string[]; commits: string[] }>;
@@ -106,15 +107,22 @@ export interface StackGroupStats {
106
107
  files_deleted: number;
107
108
  }
108
109
 
110
+ export interface StackFileStats {
111
+ additions: number;
112
+ deletions: number;
113
+ }
114
+
109
115
  export interface StackGroup {
110
116
  id: string;
111
117
  name: string;
112
118
  type: GroupType;
113
119
  description: string;
114
120
  files: string[];
115
- deps: string[]; // groupIds
121
+ deps: string[];
122
+ explicit_deps?: string[];
116
123
  order: number;
117
124
  stats?: StackGroupStats;
125
+ file_stats?: Record<string, StackFileStats>;
118
126
  pr_title?: string;
119
127
  }
120
128
 
@@ -122,7 +130,8 @@ export interface StackPlan {
122
130
  base_sha: string;
123
131
  head_sha: string;
124
132
  groups: StackGroup[];
125
- expected_trees: Map<string, string>; // groupId -> treeSha
133
+ expected_trees: Map<string, string>;
134
+ ancestor_sets: Map<string, string[]>;
126
135
  }
127
136
 
128
137
  // ============================================================================
@@ -161,6 +170,7 @@ export interface PrInfo {
161
170
  title: string;
162
171
  base_branch: string;
163
172
  head_branch: string;
173
+ dep_group_ids?: string[];
164
174
  }
165
175
 
166
176
  export interface StackPublishResult {