newpr 1.0.15 → 1.0.17
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/pr-title.ts +67 -16
- package/src/stack/publish.ts +82 -16
- package/src/web/client/hooks/useStack.ts +72 -1
- package/src/web/client/panels/StackPanel.tsx +121 -24
- package/src/web/server/routes.ts +37 -3
- package/src/web/server/stack-manager.ts +34 -7
package/package.json
CHANGED
package/src/stack/pr-title.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { LlmClient } from "../llm/client.ts";
|
|
|
2
2
|
import type { StackGroup } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
const MAX_TITLE_LENGTH = 72;
|
|
5
|
+
const KOREAN_NOUN_END_RE = /(추가|개선|수정|정리|분리|통합|구현|적용|도입|구성|지원|처리|보강|최적화|리팩터링|안정화|마이그레이션|업데이트|생성|검증|연동|변경|제거|작성|설정|관리|보호|강화|정의|확장|대응|복구|표시|유지|등록|삭제|작업)$/;
|
|
6
|
+
const KOREAN_VERB_END_RE = /(합니다|하였다|했다|한다|되었다|됐다|되다|하다)$/;
|
|
5
7
|
const TYPE_PREFIX: Record<string, string> = {
|
|
6
8
|
feature: "feat",
|
|
7
9
|
feat: "feat",
|
|
@@ -21,15 +23,63 @@ function normalizeTypePrefix(type: string): string {
|
|
|
21
23
|
return TYPE_PREFIX[type] ?? "chore";
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
+
function isKoreanLanguage(languageHint: string | null): boolean {
|
|
27
|
+
if (!languageHint) return false;
|
|
28
|
+
return /korean|^ko$/i.test(languageHint);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureNounFormDescription(description: string, languageHint: string | null): string {
|
|
32
|
+
let desc = description.trim().replace(/\.+$/, "");
|
|
33
|
+
if (!desc) return isKoreanLanguage(languageHint) ? "변경 작업" : "update";
|
|
34
|
+
|
|
35
|
+
const hasKoreanContext = /[가-힣]/.test(desc) || isKoreanLanguage(languageHint);
|
|
36
|
+
if (!hasKoreanContext) return desc;
|
|
37
|
+
if (KOREAN_NOUN_END_RE.test(desc)) return desc;
|
|
38
|
+
|
|
39
|
+
const verbTrimmed = desc.replace(KOREAN_VERB_END_RE, "").trim();
|
|
40
|
+
if (verbTrimmed) {
|
|
41
|
+
if (KOREAN_NOUN_END_RE.test(verbTrimmed)) return verbTrimmed;
|
|
42
|
+
return `${verbTrimmed} 작업`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return "변경 작업";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function splitTitle(raw: string): { prefix: string; description: string } {
|
|
49
|
+
const idx = raw.indexOf(":");
|
|
50
|
+
if (idx < 0) return { prefix: "", description: raw.trim() };
|
|
51
|
+
return {
|
|
52
|
+
prefix: raw.slice(0, idx).trim(),
|
|
53
|
+
description: raw.slice(idx + 1).trim(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sanitizeTitle(raw: string, languageHint: string | null): string {
|
|
58
|
+
const parsed = splitTitle(raw);
|
|
59
|
+
const prefix = parsed.prefix;
|
|
60
|
+
let description = ensureNounFormDescription(parsed.description, languageHint);
|
|
61
|
+
let title = prefix ? `${prefix}: ${description}` : description;
|
|
62
|
+
title = title.trim().replace(/\.+$/, "");
|
|
63
|
+
|
|
26
64
|
if (title.length > MAX_TITLE_LENGTH) {
|
|
27
|
-
|
|
65
|
+
if (prefix) {
|
|
66
|
+
const prefixText = `${prefix}: `;
|
|
67
|
+
const maxDesc = MAX_TITLE_LENGTH - prefixText.length;
|
|
68
|
+
description = description.slice(0, maxDesc).replace(/\s\S*$/, "").trimEnd();
|
|
69
|
+
description = ensureNounFormDescription(description, languageHint);
|
|
70
|
+
if (description.length > maxDesc) {
|
|
71
|
+
description = description.slice(0, maxDesc).trimEnd();
|
|
72
|
+
}
|
|
73
|
+
title = `${prefixText}${description || ensureNounFormDescription("", languageHint)}`;
|
|
74
|
+
} else {
|
|
75
|
+
title = title.slice(0, MAX_TITLE_LENGTH).replace(/\s\S*$/, "").trimEnd();
|
|
76
|
+
}
|
|
28
77
|
}
|
|
78
|
+
|
|
29
79
|
return title;
|
|
30
80
|
}
|
|
31
81
|
|
|
32
|
-
function fallbackTitle(g: StackGroup): string {
|
|
82
|
+
function fallbackTitle(g: StackGroup, languageHint: string | null): string {
|
|
33
83
|
const desc = g.description || g.name;
|
|
34
84
|
const cleaned = desc.replace(/[^\p{L}\p{N}\s\-/.,()]/gu, " ").replace(/\s+/g, " ").trim();
|
|
35
85
|
const prefix = `${normalizeTypePrefix(g.type)}: `;
|
|
@@ -37,7 +87,7 @@ function fallbackTitle(g: StackGroup): string {
|
|
|
37
87
|
const truncated = cleaned.length > maxDesc
|
|
38
88
|
? cleaned.slice(0, maxDesc).replace(/\s\S*$/, "").trimEnd()
|
|
39
89
|
: cleaned;
|
|
40
|
-
return truncated ? `${prefix}${truncated}` : `${prefix}${g.name}
|
|
90
|
+
return sanitizeTitle(truncated ? `${prefix}${truncated}` : `${prefix}${g.name}`, languageHint);
|
|
41
91
|
}
|
|
42
92
|
|
|
43
93
|
export async function generatePrTitles(
|
|
@@ -70,7 +120,7 @@ export async function generatePrTitles(
|
|
|
70
120
|
Rules:
|
|
71
121
|
- Format: "type: description"
|
|
72
122
|
- type must be one of: feat | fix | refactor | chore | docs | test | perf | style | ci
|
|
73
|
-
- description:
|
|
123
|
+
- description: 4-12 words, noun phrase only, must end with a noun, no trailing period
|
|
74
124
|
- Target length: 40-72 characters total
|
|
75
125
|
- Be specific about WHAT changed, not vague
|
|
76
126
|
- Each title must be unique across the set
|
|
@@ -78,19 +128,20 @@ Rules:
|
|
|
78
128
|
${langRule}
|
|
79
129
|
|
|
80
130
|
Good examples:
|
|
81
|
-
- "feat:
|
|
82
|
-
- "fix:
|
|
83
|
-
- "refactor:
|
|
84
|
-
- "chore:
|
|
85
|
-
- "feat:
|
|
86
|
-
- "test:
|
|
87
|
-
- "refactor:
|
|
131
|
+
- "feat: JWT token refresh middleware integration"
|
|
132
|
+
- "fix: session expiry null user crash prevention"
|
|
133
|
+
- "refactor: shared validation helper extraction"
|
|
134
|
+
- "chore: eslint flat config migration"
|
|
135
|
+
- "feat: canvas node drag-and-drop reorder support"
|
|
136
|
+
- "test: payment webhook integration test coverage"
|
|
137
|
+
- "refactor: monolithic API router module split"
|
|
88
138
|
|
|
89
139
|
Bad examples:
|
|
90
140
|
- "feat: auth" (too vague)
|
|
91
141
|
- "fix: bug" (meaningless)
|
|
92
142
|
- "refactor: code" (says nothing)
|
|
93
143
|
- "feat: jwt" (just a keyword, not a title)
|
|
144
|
+
- "feat: add JWT refresh middleware" (imperative verb)
|
|
94
145
|
- "" (empty)
|
|
95
146
|
|
|
96
147
|
Return ONLY a JSON array: [{"group_id": "...", "title": "..."}]`;
|
|
@@ -118,18 +169,18 @@ Generate a descriptive PR title (40-72 chars) for each group. Return JSON array:
|
|
|
118
169
|
}
|
|
119
170
|
for (const item of parsed) {
|
|
120
171
|
if (item.group_id && item.title?.trim()) {
|
|
121
|
-
titles.set(item.group_id, sanitizeTitle(item.title));
|
|
172
|
+
titles.set(item.group_id, sanitizeTitle(item.title, lang));
|
|
122
173
|
}
|
|
123
174
|
}
|
|
124
175
|
} catch {
|
|
125
176
|
for (const g of groups) {
|
|
126
|
-
titles.set(g.id, fallbackTitle(g));
|
|
177
|
+
titles.set(g.id, fallbackTitle(g, lang));
|
|
127
178
|
}
|
|
128
179
|
}
|
|
129
180
|
|
|
130
181
|
for (const g of groups) {
|
|
131
182
|
if (!titles.has(g.id) || !titles.get(g.id)) {
|
|
132
|
-
titles.set(g.id, fallbackTitle(g));
|
|
183
|
+
titles.set(g.id, fallbackTitle(g, lang));
|
|
133
184
|
}
|
|
134
185
|
}
|
|
135
186
|
|
package/src/stack/publish.ts
CHANGED
|
@@ -10,6 +10,21 @@ export interface PublishInput {
|
|
|
10
10
|
repo: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export interface StackPublishPreviewItem {
|
|
14
|
+
group_id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
base_branch: string;
|
|
17
|
+
head_branch: string;
|
|
18
|
+
order: number;
|
|
19
|
+
total: number;
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StackPublishPreviewResult {
|
|
24
|
+
template_path: string | null;
|
|
25
|
+
items: StackPublishPreviewItem[];
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
const PR_TEMPLATE_PATHS = [
|
|
14
29
|
".github/PULL_REQUEST_TEMPLATE.md",
|
|
15
30
|
".github/pull_request_template.md",
|
|
@@ -28,6 +43,11 @@ const PR_TEMPLATE_PATHS = [
|
|
|
28
43
|
const TEMPLATE_DIR_RE = /^\.github\/(?:PULL_REQUEST_TEMPLATE|pull_request_template)\/.+\.md$/i;
|
|
29
44
|
const STACK_NAV_COMMENT_MARKER = "<!-- newpr:stack-navigation -->";
|
|
30
45
|
|
|
46
|
+
interface PrTemplateData {
|
|
47
|
+
path: string;
|
|
48
|
+
content: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
async function listCommitFiles(repoPath: string, headSha: string): Promise<string[]> {
|
|
32
52
|
const result = await Bun.$`git -C ${repoPath} ls-tree -r --name-only ${headSha}`.quiet().nothrow();
|
|
33
53
|
if (result.exitCode !== 0) return [];
|
|
@@ -51,14 +71,14 @@ function collectTemplateCandidates(files: string[]): string[] {
|
|
|
51
71
|
return deduped;
|
|
52
72
|
}
|
|
53
73
|
|
|
54
|
-
async function readPrTemplate(repoPath: string, headSha: string): Promise<
|
|
74
|
+
async function readPrTemplate(repoPath: string, headSha: string): Promise<PrTemplateData | null> {
|
|
55
75
|
const commitFiles = await listCommitFiles(repoPath, headSha);
|
|
56
76
|
const candidates = collectTemplateCandidates(commitFiles);
|
|
57
77
|
for (const path of candidates) {
|
|
58
78
|
const result = await Bun.$`git -C ${repoPath} show ${headSha}:${path}`.quiet().nothrow();
|
|
59
79
|
if (result.exitCode === 0) {
|
|
60
80
|
const content = result.stdout.toString().trim();
|
|
61
|
-
if (content) return content;
|
|
81
|
+
if (content) return { path, content };
|
|
62
82
|
}
|
|
63
83
|
}
|
|
64
84
|
return null;
|
|
@@ -79,7 +99,8 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
|
|
|
79
99
|
const ghRepo = `${owner}/${repo}`;
|
|
80
100
|
|
|
81
101
|
const headSha = exec_result.group_commits.at(-1)?.commit_sha ?? "HEAD";
|
|
82
|
-
const
|
|
102
|
+
const prTemplateData = await readPrTemplate(repo_path, headSha);
|
|
103
|
+
const prTemplate = prTemplateData?.content ?? null;
|
|
83
104
|
|
|
84
105
|
const branches: BranchInfo[] = [];
|
|
85
106
|
const prs: PrInfo[] = [];
|
|
@@ -109,9 +130,7 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
|
|
|
109
130
|
if (!prBase) continue;
|
|
110
131
|
|
|
111
132
|
const order = i + 1;
|
|
112
|
-
const title = gc
|
|
113
|
-
? `[${order}/${total}] ${gc.pr_title}`
|
|
114
|
-
: `[Stack ${order}/${total}] ${gc.group_id}`;
|
|
133
|
+
const title = buildStackPrTitle(gc, pr_meta, order, total);
|
|
115
134
|
|
|
116
135
|
const placeholder = buildPlaceholderBody(gc.group_id, order, total, pr_meta, prTemplate);
|
|
117
136
|
|
|
@@ -144,6 +163,34 @@ export async function publishStack(input: PublishInput): Promise<StackPublishRes
|
|
|
144
163
|
return { branches, prs };
|
|
145
164
|
}
|
|
146
165
|
|
|
166
|
+
export async function buildStackPublishPreview(input: PublishInput): Promise<StackPublishPreviewResult> {
|
|
167
|
+
const { repo_path, exec_result, pr_meta, base_branch } = input;
|
|
168
|
+
const headSha = exec_result.group_commits.at(-1)?.commit_sha ?? "HEAD";
|
|
169
|
+
const prTemplateData = await readPrTemplate(repo_path, headSha);
|
|
170
|
+
const prTemplate = prTemplateData?.content ?? null;
|
|
171
|
+
const total = exec_result.group_commits.length;
|
|
172
|
+
|
|
173
|
+
const items = exec_result.group_commits.map((gc, i) => {
|
|
174
|
+
const order = i + 1;
|
|
175
|
+
const title = buildStackPrTitle(gc, pr_meta, order, total);
|
|
176
|
+
const prBase = i === 0 ? base_branch : exec_result.group_commits[i - 1]?.branch_name ?? base_branch;
|
|
177
|
+
return {
|
|
178
|
+
group_id: gc.group_id,
|
|
179
|
+
title,
|
|
180
|
+
base_branch: prBase,
|
|
181
|
+
head_branch: gc.branch_name,
|
|
182
|
+
order,
|
|
183
|
+
total,
|
|
184
|
+
body: buildDescriptionBody(gc.group_id, order, total, pr_meta, prTemplate),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
template_path: prTemplateData?.path ?? null,
|
|
190
|
+
items,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
147
194
|
async function updatePrBodies(ghRepo: string, prs: PrInfo[], prMeta: PrMeta, prTemplate: string | null): Promise<void> {
|
|
148
195
|
if (prs.length === 0) return;
|
|
149
196
|
|
|
@@ -216,16 +263,25 @@ function buildPlaceholderBody(
|
|
|
216
263
|
return lines.join("\n");
|
|
217
264
|
}
|
|
218
265
|
|
|
219
|
-
function
|
|
220
|
-
|
|
221
|
-
index: number,
|
|
222
|
-
allPrs: PrInfo[],
|
|
266
|
+
function buildStackPrTitle(
|
|
267
|
+
groupCommit: StackExecResult["group_commits"][number],
|
|
223
268
|
prMeta: PrMeta,
|
|
224
|
-
|
|
269
|
+
order: number,
|
|
270
|
+
total: number,
|
|
225
271
|
): string {
|
|
226
|
-
const
|
|
227
|
-
|
|
272
|
+
const stackPrefix = `[PR#${prMeta.pr_number} ${order}/${total}]`;
|
|
273
|
+
return groupCommit.pr_title
|
|
274
|
+
? `${stackPrefix} ${groupCommit.pr_title}`
|
|
275
|
+
: `${stackPrefix} ${groupCommit.group_id}`;
|
|
276
|
+
}
|
|
228
277
|
|
|
278
|
+
function buildDescriptionBody(
|
|
279
|
+
groupId: string,
|
|
280
|
+
order: number,
|
|
281
|
+
total: number,
|
|
282
|
+
prMeta: PrMeta,
|
|
283
|
+
prTemplate: string | null,
|
|
284
|
+
): string {
|
|
229
285
|
const lines = [
|
|
230
286
|
`> **Stack ${order}/${total}** — This PR is part of a stacked PR chain created by [newpr](https://github.com/jiwonMe/newpr).`,
|
|
231
287
|
`> Source: #${prMeta.pr_number} ${prMeta.pr_title}`,
|
|
@@ -233,18 +289,28 @@ function buildFullBody(
|
|
|
233
289
|
``,
|
|
234
290
|
`---`,
|
|
235
291
|
``,
|
|
236
|
-
`## ${
|
|
292
|
+
`## ${groupId}`,
|
|
237
293
|
``,
|
|
238
294
|
`*From PR [#${prMeta.pr_number}](${prMeta.pr_url}): ${prMeta.pr_title}*`,
|
|
239
295
|
];
|
|
240
296
|
|
|
241
297
|
if (prTemplate) {
|
|
242
|
-
lines.push(
|
|
298
|
+
lines.push("", "---", "", prTemplate);
|
|
243
299
|
}
|
|
244
300
|
|
|
245
301
|
return lines.join("\n");
|
|
246
302
|
}
|
|
247
303
|
|
|
304
|
+
function buildFullBody(
|
|
305
|
+
current: PrInfo,
|
|
306
|
+
index: number,
|
|
307
|
+
allPrs: PrInfo[],
|
|
308
|
+
prMeta: PrMeta,
|
|
309
|
+
prTemplate: string | null,
|
|
310
|
+
): string {
|
|
311
|
+
return buildDescriptionBody(current.group_id, index + 1, allPrs.length, prMeta, prTemplate);
|
|
312
|
+
}
|
|
313
|
+
|
|
248
314
|
function buildStackNavigationComment(index: number, allPrs: PrInfo[]): string {
|
|
249
315
|
const total = allPrs.length;
|
|
250
316
|
const order = index + 1;
|
|
@@ -254,7 +320,7 @@ function buildStackNavigationComment(index: number, allPrs: PrInfo[]): string {
|
|
|
254
320
|
const isCurrent = i === index;
|
|
255
321
|
const marker = isCurrent ? "👉" : statusEmoji(i, index);
|
|
256
322
|
const link = `[#${pr.number}](${pr.url})`;
|
|
257
|
-
const titleText = pr.title.replace(/^\[\d+\/\d
|
|
323
|
+
const titleText = pr.title.replace(/^\[(?:PR#\d+\s+\d+\/\d+|Stack\s+\d+\/\d+|\d+\/\d+)\]\s*/i, "");
|
|
258
324
|
return `| ${marker} | ${num}/${total} | ${link} | ${titleText} |`;
|
|
259
325
|
}).join("\n");
|
|
260
326
|
|
|
@@ -79,6 +79,20 @@ interface PublishResultData {
|
|
|
79
79
|
base_branch: string;
|
|
80
80
|
head_branch: string;
|
|
81
81
|
}>;
|
|
82
|
+
publishedAt?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface PublishPreviewData {
|
|
86
|
+
template_path: string | null;
|
|
87
|
+
items: Array<{
|
|
88
|
+
group_id: string;
|
|
89
|
+
title: string;
|
|
90
|
+
base_branch: string;
|
|
91
|
+
head_branch: string;
|
|
92
|
+
order: number;
|
|
93
|
+
total: number;
|
|
94
|
+
body: string;
|
|
95
|
+
}>;
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
interface ServerStackState {
|
|
@@ -92,6 +106,7 @@ interface ServerStackState {
|
|
|
92
106
|
plan: PlanData | null;
|
|
93
107
|
execResult: ExecResultData | null;
|
|
94
108
|
verifyResult: VerifyResultData | null;
|
|
109
|
+
publishResult: PublishResultData | null;
|
|
95
110
|
startedAt: number;
|
|
96
111
|
finishedAt: number | null;
|
|
97
112
|
}
|
|
@@ -107,6 +122,9 @@ export interface StackState {
|
|
|
107
122
|
execResult: ExecResultData | null;
|
|
108
123
|
verifyResult: VerifyResultData | null;
|
|
109
124
|
publishResult: PublishResultData | null;
|
|
125
|
+
publishPreview: PublishPreviewData | null;
|
|
126
|
+
publishPreviewLoading: boolean;
|
|
127
|
+
publishPreviewError: string | null;
|
|
110
128
|
progressMessage: string | null;
|
|
111
129
|
}
|
|
112
130
|
|
|
@@ -130,6 +148,7 @@ function applyServerState(server: ServerStackState): Partial<StackState> {
|
|
|
130
148
|
plan: server.plan,
|
|
131
149
|
execResult: server.execResult,
|
|
132
150
|
verifyResult: server.verifyResult,
|
|
151
|
+
publishResult: server.publishResult,
|
|
133
152
|
};
|
|
134
153
|
}
|
|
135
154
|
|
|
@@ -149,6 +168,9 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
149
168
|
execResult: null,
|
|
150
169
|
verifyResult: null,
|
|
151
170
|
publishResult: null,
|
|
171
|
+
publishPreview: null,
|
|
172
|
+
publishPreviewLoading: false,
|
|
173
|
+
publishPreviewError: null,
|
|
152
174
|
progressMessage: null,
|
|
153
175
|
});
|
|
154
176
|
|
|
@@ -259,11 +281,15 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
259
281
|
if (!res.ok) throw new Error(data.error ?? "Publishing failed");
|
|
260
282
|
|
|
261
283
|
const publishResult = data.publish_result as PublishResultData;
|
|
284
|
+
const serverState = (data as { state?: ServerStackState }).state;
|
|
285
|
+
const nextPublishResult = serverState?.publishResult ?? publishResult;
|
|
262
286
|
|
|
263
287
|
setState((s) => ({
|
|
264
288
|
...s,
|
|
289
|
+
...(serverState ? applyServerState(serverState) : {}),
|
|
265
290
|
phase: "done",
|
|
266
|
-
publishResult,
|
|
291
|
+
publishResult: nextPublishResult,
|
|
292
|
+
progressMessage: null,
|
|
267
293
|
}));
|
|
268
294
|
|
|
269
295
|
if (options?.onTrackAnalysis && publishResult?.prs?.length > 0) {
|
|
@@ -290,6 +316,47 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
290
316
|
}
|
|
291
317
|
}, [sessionId, options]);
|
|
292
318
|
|
|
319
|
+
const loadPublishPreview = useCallback(async (force = false) => {
|
|
320
|
+
if (!sessionId) return;
|
|
321
|
+
|
|
322
|
+
setState((s) => {
|
|
323
|
+
if (!force && s.publishPreviewLoading) return s;
|
|
324
|
+
return { ...s, publishPreviewLoading: true, publishPreviewError: null };
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const res = await fetch("/api/stack/publish/preview", {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers: { "Content-Type": "application/json" },
|
|
331
|
+
body: JSON.stringify({ sessionId }),
|
|
332
|
+
});
|
|
333
|
+
const data = await res.json() as { preview?: PublishPreviewData; error?: string };
|
|
334
|
+
if (!res.ok || !data.preview) throw new Error(data.error ?? "Failed to load publish preview");
|
|
335
|
+
|
|
336
|
+
setState((s) => ({
|
|
337
|
+
...s,
|
|
338
|
+
publishPreview: data.preview!,
|
|
339
|
+
publishPreviewLoading: false,
|
|
340
|
+
publishPreviewError: null,
|
|
341
|
+
}));
|
|
342
|
+
} catch (err) {
|
|
343
|
+
setState((s) => ({
|
|
344
|
+
...s,
|
|
345
|
+
publishPreviewLoading: false,
|
|
346
|
+
publishPreviewError: err instanceof Error ? err.message : String(err),
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
}, [sessionId]);
|
|
350
|
+
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!sessionId) return;
|
|
353
|
+
if (state.phase !== "done") return;
|
|
354
|
+
if (!state.execResult) return;
|
|
355
|
+
if (state.publishResult) return;
|
|
356
|
+
if (state.publishPreview || state.publishPreviewLoading || state.publishPreviewError) return;
|
|
357
|
+
loadPublishPreview();
|
|
358
|
+
}, [sessionId, state.phase, state.execResult, state.publishResult, state.publishPreview, state.publishPreviewLoading, state.publishPreviewError, loadPublishPreview]);
|
|
359
|
+
|
|
293
360
|
const reset = useCallback(() => {
|
|
294
361
|
eventSourceRef.current?.close();
|
|
295
362
|
eventSourceRef.current = null;
|
|
@@ -304,6 +371,9 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
304
371
|
execResult: null,
|
|
305
372
|
verifyResult: null,
|
|
306
373
|
publishResult: null,
|
|
374
|
+
publishPreview: null,
|
|
375
|
+
publishPreviewLoading: false,
|
|
376
|
+
publishPreviewError: null,
|
|
307
377
|
progressMessage: null,
|
|
308
378
|
}));
|
|
309
379
|
}, []);
|
|
@@ -319,6 +389,7 @@ export function useStack(sessionId: string | null | undefined, options?: UseStac
|
|
|
319
389
|
setMaxGroups,
|
|
320
390
|
runFullPipeline,
|
|
321
391
|
startPublish,
|
|
392
|
+
loadPublishPreview,
|
|
322
393
|
reset,
|
|
323
394
|
};
|
|
324
395
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Loader2, Play, Upload, RotateCcw, CheckCircle2, AlertTriangle, Circle, GitPullRequestArrow, ArrowRight, Layers } from "lucide-react";
|
|
1
|
+
import { Loader2, Play, Upload, RotateCcw, CheckCircle2, AlertTriangle, Circle, GitPullRequestArrow, ArrowRight, Layers, FileText, RefreshCw } from "lucide-react";
|
|
2
2
|
import { useStack } from "../hooks/useStack.ts";
|
|
3
3
|
import { FeasibilityAlert } from "../components/FeasibilityAlert.tsx";
|
|
4
4
|
import { StackGroupCard } from "../components/StackGroupCard.tsx";
|
|
@@ -75,6 +75,15 @@ interface StackPanelProps {
|
|
|
75
75
|
|
|
76
76
|
export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
|
|
77
77
|
const stack = useStack(sessionId, { onTrackAnalysis });
|
|
78
|
+
const publishedCount = stack.publishResult?.prs.length ?? 0;
|
|
79
|
+
const pushedCount = stack.publishResult?.branches.filter((b) => b.pushed).length ?? 0;
|
|
80
|
+
const publishFailures = stack.publishResult
|
|
81
|
+
? stack.publishResult.branches.filter((branch) => {
|
|
82
|
+
if (!branch.pushed) return true;
|
|
83
|
+
return !stack.publishResult?.prs.some((pr) => pr.head_branch === branch.name);
|
|
84
|
+
})
|
|
85
|
+
: [];
|
|
86
|
+
const previewItems = stack.publishPreview?.items ?? [];
|
|
78
87
|
|
|
79
88
|
if (stack.phase === "idle") {
|
|
80
89
|
return (
|
|
@@ -252,6 +261,67 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
|
|
|
252
261
|
</div>
|
|
253
262
|
)}
|
|
254
263
|
|
|
264
|
+
{stack.phase === "done" && stack.execResult && !stack.publishResult && (
|
|
265
|
+
<div className="space-y-2 rounded-lg border border-border/70 bg-foreground/[0.015] p-2.5">
|
|
266
|
+
<div className="flex items-center justify-between px-1">
|
|
267
|
+
<div className="flex items-center gap-2">
|
|
268
|
+
<FileText className="h-3.5 w-3.5 text-muted-foreground/60" />
|
|
269
|
+
<span className="text-[11px] font-medium text-foreground/80">Description Preview</span>
|
|
270
|
+
</div>
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={() => stack.loadPublishPreview(true)}
|
|
274
|
+
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/45 hover:text-foreground/70 transition-colors"
|
|
275
|
+
>
|
|
276
|
+
<RefreshCw className="h-3 w-3" />
|
|
277
|
+
Refresh
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<p className="text-[10px] text-muted-foreground/35 px-1">
|
|
282
|
+
Template: {stack.publishPreview?.template_path ?? "(none found, using stack metadata body only)"}
|
|
283
|
+
</p>
|
|
284
|
+
|
|
285
|
+
{stack.publishPreviewLoading && (
|
|
286
|
+
<div className="flex items-center gap-2 px-2.5 py-2 text-[10px] text-muted-foreground/45">
|
|
287
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
288
|
+
Preparing preview bodies...
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{stack.publishPreviewError && (
|
|
293
|
+
<div className="rounded-md bg-red-500/[0.06] px-2.5 py-2 text-[10px] text-red-600/80 dark:text-red-400/80">
|
|
294
|
+
{stack.publishPreviewError}
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
|
|
298
|
+
{!stack.publishPreviewLoading && !stack.publishPreviewError && previewItems.length > 0 && (
|
|
299
|
+
<div className="space-y-1.5">
|
|
300
|
+
{previewItems.map((item) => (
|
|
301
|
+
<details key={`${item.group_id}-${item.order}`} className="rounded-md border border-border/60 bg-background/40">
|
|
302
|
+
<summary className="cursor-pointer list-none px-2.5 py-2 hover:bg-accent/25 transition-colors">
|
|
303
|
+
<div className="flex items-center justify-between gap-2">
|
|
304
|
+
<span className="text-[11px] font-medium truncate">{item.title}</span>
|
|
305
|
+
<span className="text-[10px] text-muted-foreground/35 tabular-nums shrink-0">{item.order}/{item.total}</span>
|
|
306
|
+
</div>
|
|
307
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
308
|
+
<span className="text-[10px] font-mono text-muted-foreground/30">{item.base_branch}</span>
|
|
309
|
+
<ArrowRight className="h-2.5 w-2.5 text-muted-foreground/20" />
|
|
310
|
+
<span className="text-[10px] font-mono text-muted-foreground/30">{item.head_branch}</span>
|
|
311
|
+
</div>
|
|
312
|
+
</summary>
|
|
313
|
+
<div className="px-2.5 pb-2.5">
|
|
314
|
+
<pre className="whitespace-pre-wrap break-words text-[10px] leading-relaxed text-foreground/75 bg-muted/40 rounded-md p-2.5 overflow-x-auto">
|
|
315
|
+
{item.body}
|
|
316
|
+
</pre>
|
|
317
|
+
</div>
|
|
318
|
+
</details>
|
|
319
|
+
))}
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
|
|
255
325
|
{stack.phase === "done" && stack.execResult && !stack.publishResult && (
|
|
256
326
|
<button
|
|
257
327
|
type="button"
|
|
@@ -263,30 +333,57 @@ export function StackPanel({ sessionId, onTrackAnalysis }: StackPanelProps) {
|
|
|
263
333
|
</button>
|
|
264
334
|
)}
|
|
265
335
|
|
|
266
|
-
{stack.publishResult &&
|
|
267
|
-
<div className="space-y-
|
|
268
|
-
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
336
|
+
{stack.publishResult && (
|
|
337
|
+
<div className="space-y-2 rounded-lg border border-border/70 bg-foreground/[0.015] p-2.5">
|
|
338
|
+
<div className="flex items-center justify-between px-1">
|
|
339
|
+
<div className="flex items-center gap-2">
|
|
340
|
+
<GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/70 dark:text-green-400/70" />
|
|
341
|
+
<span className="text-[11px] font-medium text-foreground/80">Draft Publish Results</span>
|
|
342
|
+
</div>
|
|
343
|
+
<span className="text-[10px] text-muted-foreground/35 tabular-nums">{publishedCount}/{pushedCount} created</span>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{stack.publishResult.prs.length > 0 ? (
|
|
347
|
+
<div className="space-y-1.5">
|
|
348
|
+
{stack.publishResult.prs.map((pr) => (
|
|
349
|
+
<a
|
|
350
|
+
key={pr.number}
|
|
351
|
+
href={pr.url}
|
|
352
|
+
target="_blank"
|
|
353
|
+
rel="noopener noreferrer"
|
|
354
|
+
className="group flex items-center gap-3 rounded-lg px-2.5 py-2 hover:bg-accent/30 transition-colors"
|
|
355
|
+
>
|
|
356
|
+
<GitPullRequestArrow className="h-3.5 w-3.5 text-green-600/60 dark:text-green-400/60 shrink-0" />
|
|
357
|
+
<div className="flex-1 min-w-0">
|
|
358
|
+
<div className="flex items-center gap-2">
|
|
359
|
+
<span className="text-[11px] font-medium truncate">{pr.title}</span>
|
|
360
|
+
<span className="text-[10px] text-muted-foreground/25 tabular-nums shrink-0">#{pr.number}</span>
|
|
361
|
+
</div>
|
|
362
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
363
|
+
<span className="text-[10px] font-mono text-muted-foreground/25">{pr.base_branch}</span>
|
|
364
|
+
<ArrowRight className="h-2.5 w-2.5 text-muted-foreground/20" />
|
|
365
|
+
<span className="text-[10px] font-mono text-muted-foreground/25">{pr.head_branch}</span>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</a>
|
|
369
|
+
))}
|
|
370
|
+
</div>
|
|
371
|
+
) : (
|
|
372
|
+
<p className="text-[10px] text-muted-foreground/35 px-2.5 py-2">No draft PR URLs were returned.</p>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{publishFailures.length > 0 && (
|
|
376
|
+
<div className="rounded-md bg-yellow-500/[0.06] px-2.5 py-2 space-y-1">
|
|
377
|
+
<p className="text-[10px] text-yellow-700/80 dark:text-yellow-300/80">
|
|
378
|
+
Some branches were pushed but PR creation did not complete.
|
|
379
|
+
</p>
|
|
380
|
+
{publishFailures.map((branch) => (
|
|
381
|
+
<div key={branch.name} className="text-[10px] font-mono text-yellow-700/70 dark:text-yellow-300/70 truncate">
|
|
382
|
+
{branch.name}
|
|
286
383
|
</div>
|
|
287
|
-
|
|
288
|
-
</
|
|
289
|
-
)
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
290
387
|
</div>
|
|
291
388
|
)}
|
|
292
389
|
</div>
|
package/src/web/server/routes.ts
CHANGED
|
@@ -15,8 +15,8 @@ import { getPlugin, getAllPlugins } from "../../plugins/registry.ts";
|
|
|
15
15
|
import { chatWithTools, createLlmClient, type ChatTool, type ChatStreamEvent } from "../../llm/client.ts";
|
|
16
16
|
import { detectAgents, runAgent } from "../../workspace/agent.ts";
|
|
17
17
|
import { randomBytes } from "node:crypto";
|
|
18
|
-
import { publishStack } from "../../stack/publish.ts";
|
|
19
|
-
import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks } from "./stack-manager.ts";
|
|
18
|
+
import { publishStack, buildStackPublishPreview } from "../../stack/publish.ts";
|
|
19
|
+
import { startStack, getStackState, cancelStack, subscribeStack, restoreCompletedStacks, setStackPublishResult } from "./stack-manager.ts";
|
|
20
20
|
import { getTelemetryConsent, setTelemetryConsent, telemetry } from "../../telemetry/index.ts";
|
|
21
21
|
|
|
22
22
|
function json(data: unknown, status = 200): Response {
|
|
@@ -1921,8 +1921,42 @@ Before posting an inline comment, ALWAYS call \`get_file_diff\` first to find th
|
|
|
1921
1921
|
owner: state.context.owner,
|
|
1922
1922
|
repo: state.context.repo,
|
|
1923
1923
|
});
|
|
1924
|
+
await setStackPublishResult(body.sessionId, result);
|
|
1924
1925
|
|
|
1925
|
-
return json({ publish_result: result });
|
|
1926
|
+
return json({ publish_result: result, state: getStackState(body.sessionId) });
|
|
1927
|
+
} catch (err) {
|
|
1928
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1929
|
+
return json({ error: msg }, 500);
|
|
1930
|
+
}
|
|
1931
|
+
},
|
|
1932
|
+
|
|
1933
|
+
"POST /api/stack/publish/preview": async (req: Request) => {
|
|
1934
|
+
try {
|
|
1935
|
+
const body = await req.json() as { sessionId: string };
|
|
1936
|
+
if (!body.sessionId) return json({ error: "Missing sessionId" }, 400);
|
|
1937
|
+
|
|
1938
|
+
let state = getStackState(body.sessionId);
|
|
1939
|
+
if (!state) {
|
|
1940
|
+
await restoreCompletedStacks([body.sessionId]);
|
|
1941
|
+
state = getStackState(body.sessionId);
|
|
1942
|
+
}
|
|
1943
|
+
if (!state) return json({ error: "No stack state found" }, 404);
|
|
1944
|
+
if (!state.execResult) return json({ error: "Stack not executed yet" }, 400);
|
|
1945
|
+
if (!state.context) return json({ error: "Missing context" }, 400);
|
|
1946
|
+
|
|
1947
|
+
const stored = await loadSession(body.sessionId);
|
|
1948
|
+
if (!stored) return json({ error: "Session not found" }, 404);
|
|
1949
|
+
|
|
1950
|
+
const preview = await buildStackPublishPreview({
|
|
1951
|
+
repo_path: state.context.repo_path,
|
|
1952
|
+
exec_result: state.execResult,
|
|
1953
|
+
pr_meta: stored.meta,
|
|
1954
|
+
base_branch: state.context.base_branch,
|
|
1955
|
+
owner: state.context.owner,
|
|
1956
|
+
repo: state.context.repo,
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
return json({ preview });
|
|
1926
1960
|
} catch (err) {
|
|
1927
1961
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1928
1962
|
return json({ error: msg }, 500);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { NewprConfig } from "../../types/config.ts";
|
|
2
2
|
import type { FileGroup } from "../../types/output.ts";
|
|
3
|
-
import type { StackWarning, FeasibilityResult, StackExecResult } from "../../stack/types.ts";
|
|
3
|
+
import type { StackWarning, FeasibilityResult, StackExecResult, StackPublishResult } from "../../stack/types.ts";
|
|
4
4
|
import type { StackPlan } from "../../stack/types.ts";
|
|
5
5
|
import { loadSession } from "../../history/store.ts";
|
|
6
6
|
import { saveStackSidecar, loadStackSidecar } from "../../history/store.ts";
|
|
@@ -68,6 +68,12 @@ export interface StackVerifyData {
|
|
|
68
68
|
structured_warnings: StackWarning[];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export interface StackPublishData {
|
|
72
|
+
branches: StackPublishResult["branches"];
|
|
73
|
+
prs: StackPublishResult["prs"];
|
|
74
|
+
publishedAt: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
export interface StackStateSnapshot {
|
|
72
78
|
status: StackStatus;
|
|
73
79
|
phase: StackPhase | null;
|
|
@@ -79,6 +85,7 @@ export interface StackStateSnapshot {
|
|
|
79
85
|
plan: StackPlanData | null;
|
|
80
86
|
execResult: StackExecResult | null;
|
|
81
87
|
verifyResult: StackVerifyData | null;
|
|
88
|
+
publishResult: StackPublishData | null;
|
|
82
89
|
startedAt: number;
|
|
83
90
|
finishedAt: number | null;
|
|
84
91
|
}
|
|
@@ -95,6 +102,7 @@ interface StackSession {
|
|
|
95
102
|
plan: StackPlanData | null;
|
|
96
103
|
execResult: StackExecResult | null;
|
|
97
104
|
verifyResult: StackVerifyData | null;
|
|
105
|
+
publishResult: StackPublishData | null;
|
|
98
106
|
events: StackEvent[];
|
|
99
107
|
subscribers: Set<(event: StackEvent | { type: "done" | "error"; data?: string }) => void>;
|
|
100
108
|
startedAt: number;
|
|
@@ -131,6 +139,7 @@ function toSnapshot(session: StackSession): StackStateSnapshot {
|
|
|
131
139
|
plan: session.plan,
|
|
132
140
|
execResult: session.execResult,
|
|
133
141
|
verifyResult: session.verifyResult,
|
|
142
|
+
publishResult: session.publishResult,
|
|
134
143
|
startedAt: session.startedAt,
|
|
135
144
|
finishedAt: session.finishedAt,
|
|
136
145
|
};
|
|
@@ -173,6 +182,7 @@ export function startStack(
|
|
|
173
182
|
plan: null,
|
|
174
183
|
execResult: null,
|
|
175
184
|
verifyResult: null,
|
|
185
|
+
publishResult: null,
|
|
176
186
|
events: [],
|
|
177
187
|
subscribers: new Set(),
|
|
178
188
|
startedAt: Date.now(),
|
|
@@ -235,12 +245,13 @@ export async function restoreCompletedStacks(sessionIds: string[]): Promise<void
|
|
|
235
245
|
context: snapshot.context,
|
|
236
246
|
partition: snapshot.partition,
|
|
237
247
|
feasibility: snapshot.feasibility,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
248
|
+
plan: snapshot.plan,
|
|
249
|
+
execResult: snapshot.execResult,
|
|
250
|
+
verifyResult: snapshot.verifyResult,
|
|
251
|
+
publishResult: snapshot.publishResult ?? null,
|
|
252
|
+
events: [],
|
|
253
|
+
subscribers: new Set(),
|
|
254
|
+
startedAt: snapshot.startedAt,
|
|
244
255
|
finishedAt: snapshot.finishedAt ?? Date.now(),
|
|
245
256
|
abortController: new AbortController(),
|
|
246
257
|
};
|
|
@@ -248,6 +259,22 @@ export async function restoreCompletedStacks(sessionIds: string[]): Promise<void
|
|
|
248
259
|
}
|
|
249
260
|
}
|
|
250
261
|
|
|
262
|
+
export async function setStackPublishResult(
|
|
263
|
+
analysisSessionId: string,
|
|
264
|
+
result: StackPublishResult,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const session = sessions.get(analysisSessionId);
|
|
267
|
+
if (!session) return;
|
|
268
|
+
|
|
269
|
+
session.publishResult = {
|
|
270
|
+
branches: result.branches,
|
|
271
|
+
prs: result.prs,
|
|
272
|
+
publishedAt: Date.now(),
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
await saveStackSidecar(session.analysisSessionId, toSnapshot(session)).catch(() => {});
|
|
276
|
+
}
|
|
277
|
+
|
|
251
278
|
// ---------------------------------------------------------------------------
|
|
252
279
|
// Pipeline
|
|
253
280
|
// ---------------------------------------------------------------------------
|