newpr 1.0.21 → 1.0.22

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -183,22 +183,57 @@ function checkForUnsupportedModes(
183
183
  }
184
184
  }
185
185
 
186
+ async function resolveParentTree(
187
+ repoPath: string,
188
+ baseSha: string,
189
+ gid: string,
190
+ expectedTrees: Map<string, string>,
191
+ dagParents: Map<string, string[]>,
192
+ ): Promise<string | null> {
193
+ const parentIds = dagParents.get(gid) ?? [];
194
+
195
+ if (parentIds.length === 0) {
196
+ return resolveTree(repoPath, baseSha);
197
+ }
198
+
199
+ if (parentIds.length === 1) {
200
+ return expectedTrees.get(parentIds[0]!) ?? resolveTree(repoPath, baseSha);
201
+ }
202
+
203
+ const parentTrees = parentIds.map((p) => expectedTrees.get(p)).filter((t): t is string => Boolean(t));
204
+ if (parentTrees.length === 0) return resolveTree(repoPath, baseSha);
205
+ if (parentTrees.length === 1) return parentTrees[0]!;
206
+
207
+ let mergedTree = parentTrees[0]!;
208
+ for (let i = 1; i < parentTrees.length; i++) {
209
+ const mergeResult = await Bun.$`git -C ${repoPath} merge-tree --write-tree --allow-unrelated-histories ${mergedTree} ${parentTrees[i]!}`.quiet().nothrow();
210
+ if (mergeResult.exitCode === 0) {
211
+ mergedTree = mergeResult.stdout.toString().trim().split("\n")[0]!.trim();
212
+ }
213
+ }
214
+
215
+ return mergedTree;
216
+ }
217
+
186
218
  export async function computeGroupStats(
187
219
  repoPath: string,
188
220
  baseSha: string,
189
221
  orderedGroupIds: string[],
190
222
  expectedTrees: Map<string, string>,
223
+ dagParents?: Map<string, string[]>,
191
224
  ): Promise<Map<string, StackGroupStats>> {
192
225
  const stats = new Map<string, StackGroupStats>();
226
+ const linearParents = new Map<string, string[]>(
227
+ orderedGroupIds.map((gid, i) => [gid, i === 0 ? [] : [orderedGroupIds[i - 1]!]]),
228
+ );
229
+ const effectiveDagParents = dagParents ?? linearParents;
193
230
 
194
231
  for (let i = 0; i < orderedGroupIds.length; i++) {
195
232
  const gid = orderedGroupIds[i]!;
196
233
  const tree = expectedTrees.get(gid);
197
234
  if (!tree) continue;
198
235
 
199
- const prevTree = i === 0
200
- ? await resolveTree(repoPath, baseSha)
201
- : expectedTrees.get(orderedGroupIds[i - 1]!);
236
+ const prevTree = await resolveParentTree(repoPath, baseSha, gid, expectedTrees, effectiveDagParents);
202
237
  if (!prevTree) continue;
203
238
 
204
239
  const numstatResult = await Bun.$`git -C ${repoPath} diff-tree --numstat -r ${prevTree} ${tree}`.quiet().nothrow();
@@ -4,6 +4,7 @@ import type {
4
4
  StackExecResult,
5
5
  GroupCommitInfo,
6
6
  } from "./types.ts";
7
+ import { buildAncestorSets } from "./plan.ts";
7
8
 
8
9
  export class StackExecutionError extends Error {
9
10
  constructor(message: string) {
@@ -78,6 +79,10 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
78
79
  const groupRank = new Map<string, number>();
79
80
  groupOrder.forEach((gid, idx) => groupRank.set(gid, idx));
80
81
 
82
+ const dagParents = new Map<string, string[]>();
83
+ for (const g of plan.groups) dagParents.set(g.id, g.deps ?? []);
84
+ const ancestorSets = buildAncestorSets(groupOrder, dagParents);
85
+
81
86
  try {
82
87
  for (let i = 0; i < groupOrder.length; i++) {
83
88
  const idxFile = `/tmp/newpr-exec-idx-${runId}-${i}`;
@@ -101,8 +106,12 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
101
106
  const fileRank = groupRank.get(fileGroupId);
102
107
  if (fileRank === undefined) continue;
103
108
 
104
- // Suffix propagation: update index[fileRank] through index[N-1]
105
- for (let idxNum = fileRank; idxNum < groupOrder.length; idxNum++) {
109
+ for (let idxNum = 0; idxNum < groupOrder.length; idxNum++) {
110
+ const targetGroupId = groupOrder[idxNum]!;
111
+ const isOwner = targetGroupId === fileGroupId;
112
+ const isAncestorOfOwner = ancestorSets.get(targetGroupId)?.has(fileGroupId) ?? false;
113
+ if (!isOwner && !isAncestorOfOwner) continue;
114
+
106
115
  let batch = batchPerIndex.get(idxNum);
107
116
  if (!batch) {
108
117
  batch = [];
@@ -137,7 +146,7 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
137
146
  }
138
147
 
139
148
  const groupCommits: GroupCommitInfo[] = [];
140
- let prevCommitSha = plan.base_sha;
149
+ const commitBySha = new Map<string, string>();
141
150
 
142
151
  for (let i = 0; i < groupOrder.length; i++) {
143
152
  const idxFile = tmpIndexFiles[i];
@@ -162,7 +171,13 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
162
171
 
163
172
  const commitMessage = group.pr_title ?? `${group.type}(${group.name}): ${group.description}`;
164
173
 
165
- const commitTree = await Bun.$`git -C ${repo_path} commit-tree ${treeSha} -p ${prevCommitSha} -m ${commitMessage}`.env({
174
+ const directParents = (group.deps ?? []).length > 0
175
+ ? group.deps.map((dep) => commitBySha.get(dep) ?? plan.base_sha)
176
+ : [groupCommits[i - 1]?.commit_sha ?? plan.base_sha];
177
+
178
+ const parentArgs = directParents.flatMap((p) => ["-p", p]);
179
+
180
+ const commitTree = await Bun.$`git -C ${repo_path} commit-tree ${treeSha} ${parentArgs} -m ${commitMessage}`.env({
166
181
  GIT_AUTHOR_NAME: pr_author.name,
167
182
  GIT_AUTHOR_EMAIL: pr_author.email,
168
183
  GIT_COMMITTER_NAME: pr_author.name,
@@ -175,6 +190,7 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
175
190
  );
176
191
  }
177
192
  const commitSha = commitTree.stdout.toString().trim();
193
+ commitBySha.set(gid, commitSha);
178
194
 
179
195
  const branchName = buildStackBranchName(pr_number, head_branch, group);
180
196
 
@@ -193,8 +209,6 @@ export async function executeStack(input: ExecuteInput): Promise<StackExecResult
193
209
  branch_name: branchName,
194
210
  pr_title: group.pr_title,
195
211
  });
196
-
197
- prevCommitSha = commitSha;
198
212
  }
199
213
 
200
214
  const lastCommit = groupCommits[groupCommits.length - 1];
@@ -190,10 +190,11 @@ function deduplicateEdges(edges: ConstraintEdge[]): ConstraintEdge[] {
190
190
 
191
191
  function topologicalSort(
192
192
  groups: string[],
193
- edges: ConstraintEdge[],
193
+ acyclicEdges: ConstraintEdge[],
194
194
  deltas: DeltaEntry[],
195
195
  ownership?: Map<string, string>,
196
196
  ): FeasibilityResult {
197
+ const edges = acyclicEdges;
197
198
  const inDegree = new Map<string, number>();
198
199
  const adjacency = new Map<string, string[]>();
199
200
  const edgeMap = new Map<string, ConstraintEdge>();
@@ -238,9 +239,13 @@ function topologicalSort(
238
239
  }
239
240
 
240
241
  if (sorted.length === groups.length) {
242
+ const dependencyEdges = acyclicEdges
243
+ .filter((e) => e.kind === "dependency" || e.kind === "path-order")
244
+ .map((e) => ({ from: e.from, to: e.to }));
241
245
  return {
242
246
  feasible: true,
243
247
  ordered_group_ids: sorted,
248
+ dependency_edges: dependencyEdges,
244
249
  };
245
250
  }
246
251
 
package/src/stack/plan.ts CHANGED
@@ -13,15 +13,19 @@ 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 dagParents = buildDagParents(group_order, dependency_edges ?? []);
26
+ const ancestorSets = buildAncestorSets(group_order, dagParents);
27
+
28
+ const stackGroups = buildStackGroups(groups, group_order, ownership, dagParents);
25
29
 
26
30
  const tmpIndexFiles: string[] = [];
27
31
  const expectedTrees = new Map<string, string>();
@@ -47,8 +51,12 @@ export async function createStackPlan(input: PlanInput): Promise<StackPlan> {
47
51
  const fileRank = groupRank.get(fileGroupId);
48
52
  if (fileRank === undefined) continue;
49
53
 
50
- // Suffix propagation: update index[fileRank] through index[N-1]
51
- for (let idxNum = fileRank; idxNum < group_order.length; idxNum++) {
54
+ for (let idxNum = 0; idxNum < group_order.length; idxNum++) {
55
+ const targetGroupId = group_order[idxNum]!;
56
+ const isOwner = targetGroupId === fileGroupId;
57
+ const isAncestorOfOwner = ancestorSets.get(targetGroupId)?.has(fileGroupId) ?? false;
58
+ if (!isOwner && !isAncestorOfOwner) continue;
59
+
52
60
  let batch = batchPerIndex.get(idxNum);
53
61
  if (!batch) {
54
62
  batch = [];
@@ -108,10 +116,60 @@ export async function createStackPlan(input: PlanInput): Promise<StackPlan> {
108
116
  };
109
117
  }
110
118
 
119
+ export function buildDagParents(
120
+ groupOrder: string[],
121
+ dependencyEdges: Array<{ from: string; to: string }>,
122
+ ): Map<string, string[]> {
123
+ const parents = new Map<string, string[]>();
124
+ for (const gid of groupOrder) parents.set(gid, []);
125
+
126
+ for (const edge of dependencyEdges) {
127
+ if (!parents.has(edge.to)) continue;
128
+ const arr = parents.get(edge.to)!;
129
+ if (!arr.includes(edge.from)) arr.push(edge.from);
130
+ }
131
+
132
+ for (const gid of groupOrder) {
133
+ if ((parents.get(gid) ?? []).length === 0) {
134
+ const rank = groupOrder.indexOf(gid);
135
+ if (rank > 0) {
136
+ const prev = groupOrder[rank - 1]!;
137
+ if (!dependencyEdges.some((e) => e.to === gid)) {
138
+ parents.set(gid, [prev]);
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ return parents;
145
+ }
146
+
147
+ export function buildAncestorSets(
148
+ groupOrder: string[],
149
+ dagParents: Map<string, string[]>,
150
+ ): Map<string, Set<string>> {
151
+ const ancestors = new Map<string, Set<string>>();
152
+
153
+ for (const gid of groupOrder) {
154
+ const set = new Set<string>();
155
+ const queue = [...(dagParents.get(gid) ?? [])];
156
+ while (queue.length > 0) {
157
+ const node = queue.shift()!;
158
+ if (set.has(node)) continue;
159
+ set.add(node);
160
+ for (const p of dagParents.get(node) ?? []) queue.push(p);
161
+ }
162
+ ancestors.set(gid, set);
163
+ }
164
+
165
+ return ancestors;
166
+ }
167
+
111
168
  function buildStackGroups(
112
169
  groups: FileGroup[],
113
170
  groupOrder: string[],
114
171
  ownership: Map<string, string>,
172
+ dagParents: Map<string, string[]>,
115
173
  ): StackGroup[] {
116
174
  const groupNameMap = new Map<string, FileGroup>();
117
175
  for (const g of groups) {
@@ -132,7 +190,7 @@ function buildStackGroups(
132
190
  type: (original?.type ?? "chore") as GroupType,
133
191
  description: original?.description ?? "",
134
192
  files: files.sort(),
135
- deps: original?.dependencies ?? [],
193
+ deps: dagParents.get(gid) ?? [],
136
194
  order: idx,
137
195
  };
138
196
  });
@@ -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>,
@@ -458,6 +498,22 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
458
498
  const prs: PrInfo[] = [];
459
499
  const total = exec_result.group_commits.length;
460
500
 
501
+ const branchByGroupId = new Map(exec_result.group_commits.map((gc) => [gc.group_id, gc.branch_name]));
502
+
503
+ const dagLevelMap = buildDagLevelMap(exec_result.group_commits, groupMetaById);
504
+
505
+ const resolvePrBase = (gc: typeof exec_result.group_commits[number], index: number): string => {
506
+ const planGroup = plan_groups?.find((g) => g.id === gc.group_id);
507
+ const directDeps: string[] = planGroup?.deps ?? [];
508
+ if (directDeps.length > 0) {
509
+ const depBranch = directDeps
510
+ .map((dep: string) => branchByGroupId.get(dep))
511
+ .find((b: string | undefined): b is string => Boolean(b));
512
+ if (depBranch) return depBranch;
513
+ }
514
+ return index === 0 ? base_branch : (exec_result.group_commits[index - 1]?.branch_name ?? base_branch);
515
+ };
516
+
461
517
  for (const gc of exec_result.group_commits) {
462
518
  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
519
 
@@ -480,11 +536,12 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
480
536
  if (!branchInfo?.pushed) continue;
481
537
 
482
538
  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);
539
+ const prBase = previewItem?.base_branch ?? resolvePrBase(gc, i);
484
540
  if (!prBase) continue;
485
541
 
486
542
  const order = i + 1;
487
- const title = previewItem?.title ?? buildStackPrTitle(gc, pr_meta, order, total);
543
+ const dagLevel = dagLevelMap.get(gc.group_id);
544
+ const title = previewItem?.title ?? buildStackPrTitle(gc, pr_meta, order, total, dagLevel);
488
545
 
489
546
  const placeholder = previewItem?.body ?? buildPlaceholderBody(
490
547
  gc.group_id,
@@ -507,6 +564,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
507
564
  const prUrl = prResult.stdout.toString().trim();
508
565
  const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
509
566
 
567
+ const planGroupDeps = plan_groups?.find((g) => g.id === gc.group_id)?.deps ?? [];
510
568
  prs.push({
511
569
  group_id: gc.group_id,
512
570
  number: prNumberMatch ? parseInt(prNumberMatch[1]!, 10) : 0,
@@ -514,6 +572,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
514
572
  title,
515
573
  base_branch: prBase,
516
574
  head_branch: gc.branch_name,
575
+ dep_group_ids: planGroupDeps,
517
576
  });
518
577
  } else {
519
578
  const stderr = prResult.stderr.toString().trim();
@@ -522,7 +581,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
522
581
  }
523
582
 
524
583
  await updatePrBodies(ghRepo, prs, pr_meta, prTemplate, groupMetaById, llmPrefillByGroup, previewByGroup);
525
- await postStackNavigationComments(ghRepo, prs);
584
+ await postStackNavigationComments(ghRepo, prs, dagLevelMap);
526
585
 
527
586
  return { branches, prs };
528
587
  }
@@ -613,7 +672,11 @@ async function updatePrBodies(
613
672
  }
614
673
  }
615
674
 
616
- async function postStackNavigationComments(ghRepo: string, prs: PrInfo[]): Promise<void> {
675
+ async function postStackNavigationComments(
676
+ ghRepo: string,
677
+ prs: PrInfo[],
678
+ dagLevelMap: Map<string, number>,
679
+ ): Promise<void> {
617
680
  if (prs.length === 0) return;
618
681
 
619
682
  for (let i = 0; i < prs.length; i++) {
@@ -621,7 +684,7 @@ async function postStackNavigationComments(ghRepo: string, prs: PrInfo[]): Promi
621
684
  const alreadyPosted = await hasStackNavigationComment(ghRepo, pr.number);
622
685
  if (alreadyPosted) continue;
623
686
 
624
- const comment = buildStackNavigationComment(i, prs);
687
+ const comment = buildStackNavigationComment(i, prs, dagLevelMap);
625
688
  const commentResult = await runWithBodyFile(
626
689
  comment,
627
690
  (filePath) => Bun.$`gh pr comment ${pr.number} --repo ${ghRepo} --body-file ${filePath}`.quiet().nothrow(),
@@ -664,8 +727,10 @@ function buildStackPrTitle(
664
727
  prMeta: PrMeta,
665
728
  order: number,
666
729
  total: number,
730
+ dagLevel?: number,
667
731
  ): string {
668
- const stackPrefix = `[PR#${prMeta.pr_number} ${order}/${total}]`;
732
+ const levelLabel = dagLevel !== undefined ? `L${dagLevel}` : `${order}/${total}`;
733
+ const stackPrefix = `[PR#${prMeta.pr_number} ${levelLabel}]`;
669
734
  return groupCommit.pr_title
670
735
  ? `${stackPrefix} ${groupCommit.pr_title}`
671
736
  : `${stackPrefix} ${groupCommit.group_id}`;
@@ -681,11 +746,16 @@ function buildDescriptionBody(
681
746
  baseBranch?: string,
682
747
  headBranch?: string,
683
748
  llmBullets?: SectionBullets,
749
+ dagLevel?: number,
684
750
  ): string {
751
+ const positionLabel = dagLevel !== undefined ? `L${dagLevel}` : `${order}/${total}`;
752
+ const depNames = (groupMeta?.deps ?? []).length > 0
753
+ ? `Depends on: ${(groupMeta?.deps ?? []).join(", ")}`
754
+ : "Base of stack";
685
755
  const lines = [
686
- `> **Stack ${order}/${total}** — This PR is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
756
+ `> **Stack ${positionLabel}** — This PR is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
687
757
  `> Source: #${prMeta.pr_number} ${prMeta.pr_title}`,
688
- `> Stack navigation is posted as a discussion comment.`,
758
+ `> ${depNames} · Stack navigation is posted as a discussion comment.`,
689
759
  ``,
690
760
  `---`,
691
761
  ``,
@@ -725,35 +795,55 @@ function buildFullBody(
725
795
  return buildDescriptionBody(current.group_id, index + 1, allPrs.length, prMeta, prTemplate, groupMeta, baseBranch, headBranch, llmBullets);
726
796
  }
727
797
 
728
- function buildStackNavigationComment(index: number, allPrs: PrInfo[]): string {
798
+ function buildStackNavigationComment(
799
+ index: number,
800
+ allPrs: PrInfo[],
801
+ dagLevelMap: Map<string, number>,
802
+ ): string {
729
803
  const total = allPrs.length;
730
- const order = index + 1;
804
+ const currentPr = allPrs[index]!;
805
+ const currentLevel = dagLevelMap.get(currentPr.group_id) ?? index;
806
+ const prByGroupId = new Map(allPrs.map((pr) => [pr.group_id, pr]));
807
+
808
+ const depPrs = (currentPr.dep_group_ids ?? [])
809
+ .map((depId) => prByGroupId.get(depId))
810
+ .filter((pr): pr is PrInfo => Boolean(pr));
811
+
812
+ const dependentPrs = allPrs.filter((pr) =>
813
+ (pr.dep_group_ids ?? []).includes(currentPr.group_id),
814
+ );
731
815
 
732
816
  const stackTable = allPrs.map((pr, i) => {
733
- const num = i + 1;
734
817
  const isCurrent = i === index;
735
818
  const marker = isCurrent ? "👉" : statusEmoji(i, index);
736
819
  const link = `[#${pr.number}](${pr.url})`;
737
820
  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} |`;
821
+ const level = dagLevelMap.get(pr.group_id) ?? i;
822
+ const indent = level > 0 ? " ".repeat(level) : "";
823
+ return `| ${marker} | L${level} | ${link} | ${indent}${titleText} |`;
739
824
  }).join("\n");
740
825
 
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";
826
+ const navLines: string[] = [];
827
+ if (depPrs.length > 0) {
828
+ navLines.push(`⬆️ Depends on: ${depPrs.map((p) => `[#${p.number}](${p.url})`).join(", ")}`);
829
+ } else {
830
+ navLines.push("⬆️ Depends on: base branch");
831
+ }
832
+ if (dependentPrs.length > 0) {
833
+ navLines.push(`⬇️ Required by: ${dependentPrs.map((p) => `[#${p.number}](${p.url})`).join(", ")}`);
834
+ } else {
835
+ navLines.push("⬇️ Required by: (top of stack)");
836
+ }
747
837
 
748
838
  return [
749
839
  STACK_NAV_COMMENT_MARKER,
750
- `### 📚 Stack Navigation (${order}/${total})`,
840
+ `### 📚 Stack Navigation (L${currentLevel}, PR ${index + 1}/${total})`,
751
841
  ``,
752
- `| | Order | PR | Title |`,
842
+ `| | Level | PR | Title |`,
753
843
  `|---|---|---|---|`,
754
844
  stackTable,
755
845
  ``,
756
- `${prev} | ${next}`,
846
+ navLines.join(" | "),
757
847
  ``,
758
848
  `_Posted by newpr during stack publish._`,
759
849
  ].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[] }>;
@@ -161,6 +162,7 @@ export interface PrInfo {
161
162
  title: string;
162
163
  base_branch: string;
163
164
  head_branch: string;
165
+ dep_group_ids?: string[];
164
166
  }
165
167
 
166
168
  export interface StackPublishResult {
@@ -14,8 +14,7 @@ import { DetailPane, resolveDetail } from "./components/DetailPane.tsx";
14
14
  import { useChatState, ChatProvider, ChatInput } from "./components/ChatSection.tsx";
15
15
  import type { AnchorItem } from "./components/TipTapEditor.tsx";
16
16
  import { requestNotificationPermission } from "./lib/notify.ts";
17
- import { analytics, initAnalytics, getConsent } from "./lib/analytics.ts";
18
- import { AnalyticsConsent } from "./components/AnalyticsConsent.tsx";
17
+ import { analytics, initAnalytics } from "./lib/analytics.ts";
19
18
 
20
19
  function getUrlParam(key: string): string | null {
21
20
  return new URLSearchParams(window.location.search).get(key);
@@ -41,7 +40,7 @@ export function App() {
41
40
  const features = useFeatures();
42
41
  const bgAnalyses = useBackgroundAnalyses();
43
42
  const initialLoadDone = useRef(false);
44
- const [showConsent, setShowConsent] = useState(() => getConsent() === "pending");
43
+
45
44
 
46
45
  useEffect(() => {
47
46
  requestNotificationPermission();
@@ -160,7 +159,6 @@ export function App() {
160
159
 
161
160
  return (
162
161
  <ChatProvider state={chatState} anchorItems={anchorItems} analyzedAt={analysis.result?.meta.analyzed_at}>
163
- {showConsent && <AnalyticsConsent onDone={() => setShowConsent(false)} />}
164
162
  <AppShell
165
163
  theme={themeCtx.theme}
166
164
  onThemeChange={themeCtx.setTheme}
@@ -1,98 +1 @@
1
- import { useState } from "react";
2
- import { BarChart3, Shield } from "lucide-react";
3
- import { getConsent, setConsent, type ConsentState } from "../lib/analytics.ts";
4
-
5
- export function AnalyticsConsent({ onDone }: { onDone: () => void }) {
6
- const [state] = useState<ConsentState>(() => getConsent());
7
-
8
- if (state !== "pending") return null;
9
-
10
- const syncServer = (consent: "granted" | "denied") => {
11
- fetch("/api/config", {
12
- method: "PUT",
13
- headers: { "Content-Type": "application/json" },
14
- body: JSON.stringify({ telemetry_consent: consent }),
15
- }).catch(() => {});
16
- };
17
-
18
- const handleAccept = () => {
19
- setConsent("granted");
20
- syncServer("granted");
21
- onDone();
22
- };
23
-
24
- const handleDecline = () => {
25
- setConsent("denied");
26
- syncServer("denied");
27
- onDone();
28
- };
29
-
30
- return (
31
- <div className="fixed inset-0 z-[100] flex items-center justify-center">
32
- <div className="fixed inset-0 bg-background/70 backdrop-blur-sm" />
33
- <div className="relative z-10 w-full max-w-md mx-4 rounded-2xl border bg-background shadow-2xl overflow-hidden">
34
- <div className="px-6 pt-6 pb-4">
35
- <div className="flex items-center gap-3 mb-4">
36
- <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10">
37
- <BarChart3 className="h-5 w-5 text-blue-500" />
38
- </div>
39
- <div>
40
- <h2 className="text-base font-semibold">Help improve newpr</h2>
41
- <p className="text-xs text-muted-foreground">Anonymous usage analytics</p>
42
- </div>
43
- </div>
44
-
45
- <p className="text-sm text-muted-foreground leading-relaxed mb-3">
46
- We'd like to collect anonymous usage data to understand how newpr is used and improve the experience.
47
- </p>
48
-
49
- <div className="rounded-lg bg-muted/40 px-3.5 py-2.5 space-y-1.5 mb-4">
50
- <div className="flex items-start gap-2">
51
- <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
52
- <div className="text-xs text-muted-foreground leading-relaxed">
53
- <p className="font-medium text-foreground/80 mb-1">What we collect:</p>
54
- <ul className="space-y-0.5 list-disc list-inside text-xs">
55
- <li>Feature usage (which tabs, buttons, and actions you use)</li>
56
- <li>Performance metrics (analysis duration, error rates)</li>
57
- <li>Basic device info (browser, screen size)</li>
58
- </ul>
59
- </div>
60
- </div>
61
- <div className="flex items-start gap-2 pt-1">
62
- <Shield className="h-3.5 w-3.5 text-emerald-500 mt-0.5 shrink-0" />
63
- <div className="text-xs text-muted-foreground leading-relaxed">
64
- <p className="font-medium text-foreground/80 mb-1">What we never collect:</p>
65
- <ul className="space-y-0.5 list-disc list-inside text-xs">
66
- <li>PR content, code, or commit messages</li>
67
- <li>Chat messages or review comments</li>
68
- <li>API keys, tokens, or personal data</li>
69
- </ul>
70
- </div>
71
- </div>
72
- </div>
73
-
74
- <p className="text-xs text-muted-foreground/50 mb-4">
75
- Powered by Google Analytics. You can change this anytime in Settings.
76
- </p>
77
- </div>
78
-
79
- <div className="flex border-t">
80
- <button
81
- type="button"
82
- onClick={handleDecline}
83
- className="flex-1 px-4 py-3 text-sm text-muted-foreground hover:bg-muted/50 transition-colors"
84
- >
85
- Decline
86
- </button>
87
- <button
88
- type="button"
89
- onClick={handleAccept}
90
- className="flex-1 px-4 py-3 text-sm font-medium bg-foreground text-background hover:opacity-90 transition-opacity"
91
- >
92
- Accept
93
- </button>
94
- </div>
95
- </div>
96
- </div>
97
- );
98
- }
1
+ export {};
@@ -389,11 +389,11 @@ export function AppShell({
389
389
  <ResizeHandle onResize={handleLeftResize} side="right" />
390
390
 
391
391
  <div className="flex-1 flex flex-col overflow-hidden relative" style={{ minWidth: 400 }}>
392
- <main ref={mainRef} className="flex-1 overflow-y-auto">
393
- <div className="mx-auto max-w-5xl px-10 py-10">
394
- {children}
395
- </div>
396
- </main>
392
+ <main ref={mainRef} className="flex-1 overflow-y-auto">
393
+ <div className="mx-auto max-w-5xl px-10 pt-10 pb-24">
394
+ {children}
395
+ </div>
396
+ </main>
397
397
  {bottomBar}
398
398
  {showScrollTop && (
399
399
  <button