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 +1 -1
- package/src/stack/delta.ts +38 -3
- package/src/stack/execute.ts +20 -6
- package/src/stack/feasibility.ts +6 -1
- package/src/stack/plan.ts +63 -5
- package/src/stack/publish.ts +111 -21
- package/src/stack/types.ts +2 -0
- package/src/web/client/App.tsx +2 -4
- package/src/web/client/components/AnalyticsConsent.tsx +1 -98
- package/src/web/client/components/AppShell.tsx +5 -5
- package/src/web/client/components/StackDagView.tsx +317 -0
- package/src/web/client/components/StackGroupCard.tsx +15 -1
- package/src/web/client/lib/analytics.ts +6 -4
- package/src/web/client/panels/StackPanel.tsx +6 -15
- package/src/web/server/routes.ts +20 -2
- package/src/web/server/stack-manager.ts +3 -0
- package/src/web/styles/built.css +1 -1
package/package.json
CHANGED
package/src/stack/delta.ts
CHANGED
|
@@ -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 =
|
|
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();
|
package/src/stack/execute.ts
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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];
|
package/src/stack/feasibility.ts
CHANGED
|
@@ -190,10 +190,11 @@ function deduplicateEdges(edges: ConstraintEdge[]): ConstraintEdge[] {
|
|
|
190
190
|
|
|
191
191
|
function topologicalSort(
|
|
192
192
|
groups: string[],
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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:
|
|
193
|
+
deps: dagParents.get(gid) ?? [],
|
|
136
194
|
order: idx,
|
|
137
195
|
};
|
|
138
196
|
});
|
package/src/stack/publish.ts
CHANGED
|
@@ -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 ?? (
|
|
539
|
+
const prBase = previewItem?.base_branch ?? resolvePrBase(gc, i);
|
|
484
540
|
if (!prBase) continue;
|
|
485
541
|
|
|
486
542
|
const order = i + 1;
|
|
487
|
-
const
|
|
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(
|
|
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
|
|
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 ${
|
|
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(
|
|
798
|
+
function buildStackNavigationComment(
|
|
799
|
+
index: number,
|
|
800
|
+
allPrs: PrInfo[],
|
|
801
|
+
dagLevelMap: Map<string, number>,
|
|
802
|
+
): string {
|
|
729
803
|
const total = allPrs.length;
|
|
730
|
-
const
|
|
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
|
-
|
|
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
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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 (${
|
|
840
|
+
`### 📚 Stack Navigation (L${currentLevel}, PR ${index + 1}/${total})`,
|
|
751
841
|
``,
|
|
752
|
-
`| |
|
|
842
|
+
`| | Level | PR | Title |`,
|
|
753
843
|
`|---|---|---|---|`,
|
|
754
844
|
stackTable,
|
|
755
845
|
``,
|
|
756
|
-
|
|
846
|
+
navLines.join(" | "),
|
|
757
847
|
``,
|
|
758
848
|
`_Posted by newpr during stack publish._`,
|
|
759
849
|
].join("\n");
|
package/src/stack/types.ts
CHANGED
|
@@ -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 {
|
package/src/web/client/App.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|