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.
- package/package.json +1 -1
- package/src/stack/delta.test.ts +79 -1
- package/src/stack/delta.ts +164 -11
- package/src/stack/execute.test.ts +129 -1
- package/src/stack/execute.ts +87 -26
- package/src/stack/feasibility.ts +6 -1
- package/src/stack/integration.test.ts +1 -0
- package/src/stack/merge-groups.test.ts +23 -0
- package/src/stack/merge-groups.ts +17 -1
- package/src/stack/plan.test.ts +30 -0
- package/src/stack/plan.ts +69 -5
- package/src/stack/publish.test.ts +83 -0
- package/src/stack/publish.ts +132 -26
- package/src/stack/types.ts +12 -2
- package/src/stack/verify.ts +76 -19
- 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 +387 -0
- package/src/web/client/components/StackGroupCard.tsx +15 -1
- package/src/web/client/hooks/useStack.ts +43 -0
- package/src/web/client/lib/analytics.ts +6 -4
- package/src/web/client/panels/StackPanel.tsx +7 -15
- package/src/web/server/routes.ts +25 -3
- package/src/web/server/stack-manager.ts +80 -3
- package/src/web/styles/built.css +1 -1
|
@@ -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]
|
|
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
|
|
package/src/stack/plan.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
51
|
-
|
|
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:
|
|
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
|
+
});
|
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>,
|
|
@@ -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 ?? (
|
|
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
|
|
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
|
|
549
|
-
const
|
|
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(
|
|
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
|
|
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 ${
|
|
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(
|
|
814
|
+
function buildStackNavigationComment(
|
|
815
|
+
index: number,
|
|
816
|
+
allPrs: PrInfo[],
|
|
817
|
+
dagLevelMap: Map<string, number>,
|
|
818
|
+
): string {
|
|
729
819
|
const total = allPrs.length;
|
|
730
|
-
const
|
|
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
|
-
|
|
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
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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 (${
|
|
856
|
+
`### 📚 Stack Navigation (L${currentLevel}, PR ${index + 1}/${total})`,
|
|
751
857
|
``,
|
|
752
|
-
`| |
|
|
858
|
+
`| | Level | PR | Title |`,
|
|
753
859
|
`|---|---|---|---|`,
|
|
754
860
|
stackTable,
|
|
755
861
|
``,
|
|
756
|
-
|
|
862
|
+
navLines.join(" | "),
|
|
757
863
|
``,
|
|
758
864
|
`_Posted by newpr during stack publish._`,
|
|
759
865
|
].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[] }>;
|
|
@@ -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[];
|
|
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>;
|
|
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 {
|