dsh-issue2pr 0.1.0
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/LICENSE +21 -0
- package/README.md +291 -0
- package/client.js +3227 -0
- package/cordis.patch.yml +4 -0
- package/docs/assets/banner.svg +59 -0
- package/docs/assets/pipeline.svg +175 -0
- package/index.js +658 -0
- package/lib/assistant.js +137 -0
- package/lib/connections.js +114 -0
- package/lib/llm.js +159 -0
- package/lib/pipeline.js +151 -0
- package/lib/stageConfig.js +313 -0
- package/lib/stages/helpers.js +127 -0
- package/lib/stages/index.js +16 -0
- package/lib/stages/p1-issue-analyzer.js +18 -0
- package/lib/stages/p10-failure.js +21 -0
- package/lib/stages/p11-pr-builder.js +24 -0
- package/lib/stages/p2-search.js +18 -0
- package/lib/stages/p3-code-understanding.js +24 -0
- package/lib/stages/p4-hypothesis.js +17 -0
- package/lib/stages/p5-planner.js +17 -0
- package/lib/stages/p6-coder.js +226 -0
- package/lib/stages/p7-patch.js +73 -0
- package/lib/stages/p8-test-runner.js +44 -0
- package/lib/stages/p9-reviewer.js +41 -0
- package/lib/store.js +153 -0
- package/package.json +30 -0
package/index.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-issue2pr — node 半:REST API + 驱动循环(随 dsh web 同生共死)。
|
|
3
|
+
* 路由与 spec §7 对齐:projects / runs(嵌套)/ review / rollback / tree / artifact。
|
|
4
|
+
* 数据读写一律走 lib/store.js 与 lib/pipeline.js,不重复造轮子。
|
|
5
|
+
*/
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { existsSync, readdirSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import {
|
|
10
|
+
defaultDataRoot, saveProject, loadProject, listProjects,
|
|
11
|
+
createRun, runDirOf, readArtifact, listRunTree, rmTree, loadUiState, saveUiState,
|
|
12
|
+
} from "./lib/store.js";
|
|
13
|
+
import { initRun, saveRun, loadRun, advance, applyReview, STAGES } from "./lib/pipeline.js";
|
|
14
|
+
import { makeLlm, routeInfo } from "./lib/llm.js";
|
|
15
|
+
import { buildExecutors } from "./lib/stages/index.js";
|
|
16
|
+
import { rollbackLedger } from "./lib/stages/p7-patch.js";
|
|
17
|
+
import { killExternal, resolveClaudeBin } from "./lib/stages/p6-coder.js";
|
|
18
|
+
import { readTriggerText, logEvent } from "./lib/stages/helpers.js";
|
|
19
|
+
import {
|
|
20
|
+
loadConnections, upsertConnection, deleteConnection, normalizeConnection,
|
|
21
|
+
matchConnection, injectGitCredentials, redactUrl, maskToken, hostOf, CONNECTION_KINDS,
|
|
22
|
+
} from "./lib/connections.js";
|
|
23
|
+
import { STAGE_DEFS, stageCfgOf, routeOverridesOf, DEFAULT_LLM_TIMEOUT_MS, DEFAULT_TEST_TIMEOUT_MS } from "./lib/stageConfig.js";
|
|
24
|
+
import { ASSISTANT_SYSTEM_HEAD, buildAssistantContext } from "./lib/assistant.js";
|
|
25
|
+
|
|
26
|
+
export const name = "dsh-issue2pr";
|
|
27
|
+
export const inject = ["webServer", "llm"];
|
|
28
|
+
|
|
29
|
+
// —— 测试注入(仅本插件测试用):覆盖内部默认值 dataRoot / executors ——
|
|
30
|
+
let __testHooks = null;
|
|
31
|
+
export function __setTestHooks(hooks) { __testHooks = hooks || null; }
|
|
32
|
+
|
|
33
|
+
export function sendJson(res, code, obj) {
|
|
34
|
+
const data = Buffer.from(JSON.stringify(obj), "utf8");
|
|
35
|
+
// no-store:run 状态高频轮询,任何浏览器/代理缓存都会让停止/删除「看起来没生效」
|
|
36
|
+
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Content-Length": data.length, "Cache-Control": "no-store" });
|
|
37
|
+
res.end(data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readBody(req) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const chunks = [];
|
|
43
|
+
req.on("data", (c) => chunks.push(c));
|
|
44
|
+
req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); } catch (e) { reject(e); } });
|
|
45
|
+
req.on("error", reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// —— 执行器解析:测试注入缺省 = 立即 approved 的空执行器;生产走 buildExecutors() ——
|
|
50
|
+
function executorsOf() {
|
|
51
|
+
if (__testHooks && __testHooks.executors) {
|
|
52
|
+
const map = {};
|
|
53
|
+
for (const s of STAGES) map[s.id] = __testHooks.executors[s.id] || (async () => ({}));
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
return buildExecutors();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// —— rcx 组装(简报指定形态);project 每次刷新,保证读取最新配置 ——
|
|
60
|
+
// llm 的事件钩子绑定到 rcx 本身(读 run.current 得到当前阶段),LLM 调用自动进 trace/events.jsonl
|
|
61
|
+
// stageCfgOf:按阶段取合并后的配置(阶段执行器读提示词/委托;llm 读路由覆盖),
|
|
62
|
+
// 每次调用现读 project 与 run.current,配置修改在下一阶段即时生效
|
|
63
|
+
function buildRcx(ctx, root, runDir, run) {
|
|
64
|
+
const rcx = {
|
|
65
|
+
runDir, run,
|
|
66
|
+
project: loadProject(root, run.project),
|
|
67
|
+
repoDir: join(root, "projects", run.project, "repo"),
|
|
68
|
+
trigger: run.trigger,
|
|
69
|
+
llm: null,
|
|
70
|
+
p6Mode: run.p6Mode,
|
|
71
|
+
executors: executorsOf(),
|
|
72
|
+
log() {},
|
|
73
|
+
};
|
|
74
|
+
// 连接配置用 getter 现读盘:Run 进行中新增/修改连接,下一阶段即生效(与 project 同策略)
|
|
75
|
+
Object.defineProperty(rcx, "connections", { get: () => loadConnections(root) });
|
|
76
|
+
rcx.stageCfgOf = (stageId) => stageCfgOf(rcx.project, stageId || (rcx.run && rcx.run.current));
|
|
77
|
+
rcx.llm = makeLlm(ctx, (ev) => logEvent(rcx, ev), () => routeOverridesOf(rcx));
|
|
78
|
+
return rcx;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// —— 单 run 一把锁防并发;rcx 跨 drive/review 共享(打回意见 reviewComment 跨循环传递) ——
|
|
82
|
+
const runSessions = new Map();
|
|
83
|
+
|
|
84
|
+
function sessionFor(runDir) {
|
|
85
|
+
if (!runSessions.has(runDir)) runSessions.set(runDir, { lock: Promise.resolve(), rcx: null });
|
|
86
|
+
return runSessions.get(runDir);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// —— 仓库克隆:repo 目录不存在时 git clone --depth 1 主仓库(幂等) ——
|
|
90
|
+
// https 地址若匹配到「Git 托管连接」则注入凭据(私有仓库可克隆);ssh/scp 形态走本机密钥不注入。
|
|
91
|
+
// 注入后的 URL 绝不落事件/错误信息(redactUrl 脱敏)。
|
|
92
|
+
function ensureRepo(root, project, rcx) {
|
|
93
|
+
const repoDir = join(root, "projects", project.slug, "repo");
|
|
94
|
+
if (existsSync(join(repoDir, ".git"))) return Promise.resolve(repoDir);
|
|
95
|
+
if (!project.repos || !project.repos.length) return Promise.reject(new Error("项目未配置仓库"));
|
|
96
|
+
mkdirSync(repoDir, { recursive: true });
|
|
97
|
+
const uri0 = typeof project.repos[0] === "string" ? project.repos[0] : project.repos[0].uri;
|
|
98
|
+
const conn = matchConnection(uri0, loadConnections(root));
|
|
99
|
+
const uri = injectGitCredentials(uri0, conn);
|
|
100
|
+
const t0 = Date.now();
|
|
101
|
+
logEvent(rcx, {
|
|
102
|
+
kind: "git", name: "git clone --depth 1 " + redactUrl(uri0),
|
|
103
|
+
detail: "克隆主仓库到本地 repo/" + (conn ? `(已注入 ${CONNECTION_KINDS[conn.kind].label} 连接凭据)` : ""),
|
|
104
|
+
});
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
execFile("git", ["clone", "--depth", "1", uri, repoDir], { stdio: "pipe", timeout: 120000, windowsHide: true }, (err, stdout, stderr) => {
|
|
107
|
+
if (err) {
|
|
108
|
+
const stderrS = redactUrl(String(stderr || err.message));
|
|
109
|
+
logEvent(rcx, { kind: "git", name: "git clone 失败", detail: stderrS, ms: Date.now() - t0, ok: false });
|
|
110
|
+
const host = hostOf(uri0);
|
|
111
|
+
const hint = !conn && host ? `(若为私有仓库,请在项目页「Git 托管连接」配置 ${host} 的凭据)` : "";
|
|
112
|
+
reject(new Error("git clone 失败" + hint + ": " + stderrS));
|
|
113
|
+
} else {
|
|
114
|
+
logEvent(rcx, { kind: "git", name: "git clone 完成", detail: redactUrl(uri0), ms: Date.now() - t0 });
|
|
115
|
+
resolve(repoDir);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// —— 快速失败:把运行中的 run 置为 failed(阶段状态同步落盘),供克隆失败等开工前错误使用 ——
|
|
122
|
+
export function failRun(runDir, stageId, message) {
|
|
123
|
+
const run = loadRun(runDir);
|
|
124
|
+
if (!run || run.status !== "running") return run;
|
|
125
|
+
run.status = "failed";
|
|
126
|
+
const st = run.stages[stageId || run.current || "P1"];
|
|
127
|
+
if (st) { st.status = "failed"; st.error = message; }
|
|
128
|
+
saveRun(runDir, run);
|
|
129
|
+
return run;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 推进循环:仅 run.status==="running" 时调用 advance(awaiting_review 停手等 applyReview);
|
|
133
|
+
// 阶段失败自动调 P10 executor 写分类产物后停(v1:不自动 replan)。
|
|
134
|
+
function drive(ctx, root, runDir) {
|
|
135
|
+
const s = sessionFor(runDir);
|
|
136
|
+
const task = s.lock.then(async () => {
|
|
137
|
+
// 确保仓库已克隆(幂等:已存在则跳过)。克隆失败 = 流水线无法开工:
|
|
138
|
+
// 快速失败写入 run.json 并走 P10 分类,而不是让 P2 拿空仓库产出垃圾候选。
|
|
139
|
+
// 测试钩子注入执行器时跳过真实 clone(用例使用假仓库地址)。
|
|
140
|
+
const initRun = loadRun(runDir);
|
|
141
|
+
if (!__testHooks && initRun && initRun.status === "running") {
|
|
142
|
+
const project = loadProject(root, initRun.project);
|
|
143
|
+
if (project) {
|
|
144
|
+
const rcx0 = s.rcx || (s.rcx = buildRcx(ctx, root, runDir, initRun));
|
|
145
|
+
try { await ensureRepo(root, project, rcx0); }
|
|
146
|
+
catch (e) {
|
|
147
|
+
const msg = "仓库克隆失败: " + String((e && e.message) || e);
|
|
148
|
+
const run = failRun(runDir, initRun.current, msg);
|
|
149
|
+
if (run) {
|
|
150
|
+
const rcx = s.rcx;
|
|
151
|
+
rcx.run = run;
|
|
152
|
+
try {
|
|
153
|
+
await rcx.executors.P10({ ...rcx, failure: { stage: run.current, error: msg } });
|
|
154
|
+
} catch { /* P10 自身失败不阻断主流程 */ }
|
|
155
|
+
}
|
|
156
|
+
ctx.logger?.warn?.("issue2pr: " + msg);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (;;) {
|
|
162
|
+
const run = loadRun(runDir);
|
|
163
|
+
if (!run) {
|
|
164
|
+
// run.json 不在了 = 运行中被删除:清掉执行器可能重建的孤儿产物目录
|
|
165
|
+
try { rmTree(runDir); } catch { /* 尽力清理 */ }
|
|
166
|
+
runSessions.delete(runDir);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (run.status !== "running") return;
|
|
170
|
+
const rcx = s.rcx || (s.rcx = buildRcx(ctx, root, runDir, run));
|
|
171
|
+
rcx.run = run; // 刷新为最新落盘状态(reviewComment 保留在 rcx 上)
|
|
172
|
+
rcx.project = loadProject(root, run.project) || rcx.project; // 配置页改动下一阶段生效
|
|
173
|
+
await advance(rcx);
|
|
174
|
+
if (run.status === "failed") {
|
|
175
|
+
try {
|
|
176
|
+
await rcx.executors.P10({ ...rcx, failure: { stage: run.current, error: run.stages[run.current]?.error } });
|
|
177
|
+
} catch { /* P10 自身失败不阻断主流程 */ }
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
s.lock = task.then(() => {}, () => {}); // 失败不污染锁链
|
|
183
|
+
return s.lock;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 启动恢复:dsh 进程重启后,盘上遗留 status==="running" 的 Run 已无驱动循环,
|
|
187
|
+
// 置为 stopped(阶段状态同步),避免 UI 永远显示「运行中」。可用「重跑」续跑。
|
|
188
|
+
export function recoverInterruptedRuns(root) {
|
|
189
|
+
const projectsDir = join(root, "projects");
|
|
190
|
+
if (!existsSync(projectsDir)) return;
|
|
191
|
+
for (const slug of readdirSync(projectsDir)) {
|
|
192
|
+
const runsDir = join(projectsDir, slug, "runs");
|
|
193
|
+
if (!existsSync(runsDir)) continue;
|
|
194
|
+
for (const id of readdirSync(runsDir)) {
|
|
195
|
+
const runDir = join(runsDir, id);
|
|
196
|
+
const run = loadRun(runDir);
|
|
197
|
+
if (!run || run.status !== "running") continue;
|
|
198
|
+
const st = run.stages[run.current];
|
|
199
|
+
if (st && st.status === "running") st.status = "stopped";
|
|
200
|
+
run.status = "stopped";
|
|
201
|
+
run.interruptedAt = new Date().toISOString();
|
|
202
|
+
saveRun(runDir, run);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function handleApi(ctx, root, req, res) {
|
|
208
|
+
const url = new URL(req.url, "http://localhost");
|
|
209
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
210
|
+
const m = req.method;
|
|
211
|
+
try {
|
|
212
|
+
if (m === "GET" && parts.join("/") === "issue2pr/api/ping") {
|
|
213
|
+
return sendJson(res, 200, { ok: true, plugin: "dsh-issue2pr" });
|
|
214
|
+
}
|
|
215
|
+
// —— 阶段默认值与能力表(配置页数据源:默认提示词 / 可配能力 / 默认超时) ——
|
|
216
|
+
if (m === "GET" && parts.join("/") === "issue2pr/api/stage-defaults") {
|
|
217
|
+
return sendJson(res, 200, {
|
|
218
|
+
ok: true,
|
|
219
|
+
defaults: {
|
|
220
|
+
llmTimeoutMs: DEFAULT_LLM_TIMEOUT_MS, testTimeoutMs: DEFAULT_TEST_TIMEOUT_MS,
|
|
221
|
+
maxTokens: 8192,
|
|
222
|
+
stages: Object.fromEntries(Object.entries(STAGE_DEFS).map(([id, def]) => [id, {
|
|
223
|
+
name: def.name, desc: def.desc, caps: def.caps, prompts: def.prompts,
|
|
224
|
+
params: Object.keys(def.params || {}).length ? def.params : undefined,
|
|
225
|
+
delegateSpec: def.caps.delegate ? def.delegateSpec : undefined,
|
|
226
|
+
}])),
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (parts[0] !== "issue2pr" || parts[1] !== "api") {
|
|
231
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
232
|
+
}
|
|
233
|
+
// —— /issue2pr/api/ui-state:UI 偏好兜底存储(宿主重启丢 localStorage 时恢复选中记忆) ——
|
|
234
|
+
if (parts[2] === "ui-state" && !parts[3]) {
|
|
235
|
+
if (m === "GET") return sendJson(res, 200, { ok: true, state: loadUiState(root) });
|
|
236
|
+
if (m === "POST") {
|
|
237
|
+
const body = await readBody(req);
|
|
238
|
+
const prev = loadUiState(root);
|
|
239
|
+
// lastProject:只认本字段;slug 走既有白名单形态,null/空 = 清除
|
|
240
|
+
const lp = body && Object.prototype.hasOwnProperty.call(body, "lastProject") ? body.lastProject : prev.lastProject;
|
|
241
|
+
// 智能助手面板尺寸(跨软件重启兜底):整数且在合法范围才更新,非法值忽略保留旧值
|
|
242
|
+
const intIn = (v, lo, hi) => (Number.isInteger(v) && v >= lo && v <= hi) ? v : null;
|
|
243
|
+
const state = {
|
|
244
|
+
...prev,
|
|
245
|
+
lastProject: (typeof lp === "string" && /^[a-z0-9-]+$/.test(lp)) ? lp : null,
|
|
246
|
+
aiW: (body ? intIn(body.aiW, 240, 760) : null) ?? prev.aiW,
|
|
247
|
+
aiH: (body ? intIn(body.aiH, 200, 1800) : null) ?? prev.aiH,
|
|
248
|
+
aiR: (body ? intIn(body.aiR, 0, 4000) : null) ?? prev.aiR,
|
|
249
|
+
aiT: (body ? intIn(body.aiT, 44, 2000) : null) ?? prev.aiT,
|
|
250
|
+
aiTop: (body ? intIn(body.aiTop, 44, 600) : null) ?? prev.aiTop,
|
|
251
|
+
};
|
|
252
|
+
saveUiState(root, state);
|
|
253
|
+
return sendJson(res, 200, { ok: true, state });
|
|
254
|
+
}
|
|
255
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
256
|
+
}
|
|
257
|
+
// —— /issue2pr/api/connections:Git 托管连接(GitHub/GitLab/CodeArts 凭据,全局共享,所有项目复用) ——
|
|
258
|
+
// token 明文存于 <dataRoot>/connections.json(与本机 GITHUB_TOKEN 环境变量同级安全);返回给 UI 一律脱敏。
|
|
259
|
+
if (parts[2] === "connections") {
|
|
260
|
+
const masked = (c) => ({ ...c, token: maskToken(c.token) });
|
|
261
|
+
if (!parts[3] && m === "GET") {
|
|
262
|
+
return sendJson(res, 200, { ok: true, connections: loadConnections(root).map(masked) });
|
|
263
|
+
}
|
|
264
|
+
if (!parts[3] && m === "POST") {
|
|
265
|
+
const body = await readBody(req);
|
|
266
|
+
try { return sendJson(res, 200, { ok: true, connection: masked(upsertConnection(root, body)) }); }
|
|
267
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
268
|
+
}
|
|
269
|
+
if (parts[3] && !parts[4] && m === "DELETE") {
|
|
270
|
+
const removed = deleteConnection(root, decodeURIComponent(parts[3]));
|
|
271
|
+
if (!removed) return sendJson(res, 404, { ok: false, message: "连接不存在" });
|
|
272
|
+
return sendJson(res, 200, { ok: true });
|
|
273
|
+
}
|
|
274
|
+
// —— 连接探活:github/gitlab 调 /user 回显账号名;支持未保存前直接测输入值(添加表单用) ——
|
|
275
|
+
if (parts[3] === "test" && m === "POST") {
|
|
276
|
+
const body = await readBody(req);
|
|
277
|
+
let conn = null;
|
|
278
|
+
if (body && body.token && body.kind) {
|
|
279
|
+
try { conn = normalizeConnection(body, loadConnections(root)); }
|
|
280
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
281
|
+
} else {
|
|
282
|
+
conn = loadConnections(root).find((c) => c.id === (body && body.id)) || null;
|
|
283
|
+
if (!conn) return sendJson(res, 404, { ok: false, message: "连接不存在" });
|
|
284
|
+
}
|
|
285
|
+
if (conn.kind === "codearts") {
|
|
286
|
+
return sendJson(res, 200, { ok: false, message: "CodeArts 无账号探活 API,请在下方仓库地址行点「测试」用真实仓库验证连通" });
|
|
287
|
+
}
|
|
288
|
+
const api = conn.kind === "github" ? `https://api.${conn.host}/user` : `https://${conn.host}/api/v4/user`;
|
|
289
|
+
try {
|
|
290
|
+
const resp = await fetch(api, {
|
|
291
|
+
headers: { "User-Agent": "dsh-issue2pr", Authorization: "Bearer " + conn.token },
|
|
292
|
+
signal: AbortSignal.timeout(10000),
|
|
293
|
+
});
|
|
294
|
+
if (!resp.ok) return sendJson(res, 200, { ok: false, message: "凭据无效 (HTTP " + resp.status + ")" });
|
|
295
|
+
const data = await resp.json();
|
|
296
|
+
return sendJson(res, 200, { ok: true, account: data.login || data.username || "" });
|
|
297
|
+
} catch (e) { return sendJson(res, 200, { ok: false, message: "请求失败: " + String((e && e.message) || e) }); }
|
|
298
|
+
}
|
|
299
|
+
// —— 真实仓库连通性:匹配连接注入凭据后 git ls-remote(三类托管通用;CodeArts 的唯一探活手段) ——
|
|
300
|
+
if (parts[3] === "test-repo" && m === "POST") {
|
|
301
|
+
const body = await readBody(req);
|
|
302
|
+
const uri = typeof body?.uri === "string" ? body.uri.trim() : "";
|
|
303
|
+
if (!uri) return sendJson(res, 400, { ok: false, message: "uri 必填" });
|
|
304
|
+
const conn = matchConnection(uri, loadConnections(root));
|
|
305
|
+
const injected = injectGitCredentials(uri, conn);
|
|
306
|
+
const runGit = __testHooks?.runGit || ((args, opts, cb) => execFile("git", args, opts, cb));
|
|
307
|
+
const t0 = Date.now();
|
|
308
|
+
runGit(["ls-remote", "--heads", injected], { timeout: 15000, windowsHide: true }, (err, stdout, stderr) => {
|
|
309
|
+
if (err) {
|
|
310
|
+
return sendJson(res, 200, {
|
|
311
|
+
ok: false, matched: conn ? conn.id : null,
|
|
312
|
+
message: "不可达: " + redactUrl(String(stderr || err.message)).slice(0, 300),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const heads = String(stdout).trim().split("\n").filter(Boolean).length;
|
|
316
|
+
sendJson(res, 200, {
|
|
317
|
+
ok: true, matched: conn ? conn.id : null, ms: Date.now() - t0,
|
|
318
|
+
message: "可达(" + heads + " 个分支" + (conn ? "" : ",匿名访问,未匹配连接") + ")",
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
324
|
+
}
|
|
325
|
+
// —— /issue2pr/api/preflight:环境健康探测(git 二进制 / claude CLI / LLM 默认路由与来源) ——
|
|
326
|
+
// 纯只读探测:git --version、claudeBin 解析 + 存在性/PATH 校验、resolveRoute 现算;不发真实 LLM 请求。
|
|
327
|
+
if (parts[2] === "preflight" && !parts[3] && m === "GET") {
|
|
328
|
+
const runGit = __testHooks?.runGit || ((args, opts, cb) => execFile("git", args, opts, cb));
|
|
329
|
+
const runWhich = __testHooks?.runWhich
|
|
330
|
+
|| ((args, opts, cb) => execFile(process.platform === "win32" ? "where" : "which", args, opts, cb));
|
|
331
|
+
const slugQ = url.searchParams.get("slug");
|
|
332
|
+
const project = (slugQ && /^[a-z0-9-]+$/.test(slugQ)) ? loadProject(root, slugQ) : null;
|
|
333
|
+
const claudeBin = resolveClaudeBin(project?.stageConfig?.P6?.params?.claudeBin || "");
|
|
334
|
+
const [git, claude] = await Promise.all([
|
|
335
|
+
new Promise((r) => runGit(["--version"], { timeout: 5000 }, (err, stdout) =>
|
|
336
|
+
r(err ? { ok: false, message: String(err.message || err) } : { ok: true, version: String(stdout).trim() }))),
|
|
337
|
+
new Promise((r) => {
|
|
338
|
+
if (existsSync(claudeBin)) return r({ ok: true, path: claudeBin });
|
|
339
|
+
if (/^claude(\.cmd|\.exe)?$/i.test(claudeBin)) {
|
|
340
|
+
// 兜底裸名"claude":靠 where/which 验证是否在 PATH(显式配置的完整路径则不必,存在性即结论)
|
|
341
|
+
return runWhich([claudeBin], { timeout: 5000 }, (err, stdout) => {
|
|
342
|
+
const hit = !err && String(stdout).trim();
|
|
343
|
+
r(hit ? { ok: true, path: String(stdout).trim().split(/\r?\n/)[0] } : { ok: false, path: claudeBin });
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
r({ ok: false, path: claudeBin });
|
|
347
|
+
}),
|
|
348
|
+
]);
|
|
349
|
+
const info = routeInfo(ctx, {});
|
|
350
|
+
const overrides = {};
|
|
351
|
+
if (project?.stageConfig) {
|
|
352
|
+
for (const id of Object.keys(STAGE_DEFS)) {
|
|
353
|
+
const cfg = project.stageConfig[id];
|
|
354
|
+
if (cfg && cfg.provider && cfg.model) overrides[id] = { provider: cfg.provider, model: cfg.model };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return sendJson(res, 200, { ok: true, preflight: { git, claude, llm: { ...info.route, source: info.source, overrides } } });
|
|
358
|
+
}
|
|
359
|
+
// —— /issue2pr/api/check-local:本地触发源存在性(项目页触发源行内即时提示) ——
|
|
360
|
+
if (parts[2] === "check-local" && !parts[3] && m === "POST") {
|
|
361
|
+
const body = await readBody(req);
|
|
362
|
+
const p = typeof body?.path === "string" ? body.path.trim() : "";
|
|
363
|
+
if (!p) return sendJson(res, 400, { ok: false, message: "path 必填" });
|
|
364
|
+
return sendJson(res, 200, { ok: true, exists: existsSync(p) });
|
|
365
|
+
}
|
|
366
|
+
// —— /issue2pr/api/assistant/ask:悬浮智能助手(LLM 走宿主 ctx.llm,流式 JSONL 响应) ——
|
|
367
|
+
// body {question, history:[{role,text}], focus:{nav,slug,runId}};每行 {"delta"} … 末行 {"done":true} 或 {"error"}
|
|
368
|
+
// 上下文每次现读 buildAssistantContext(数据最新);客户端断开(close)即 abort 生成
|
|
369
|
+
if (parts[2] === "assistant" && parts[3] === "ask" && m === "POST") {
|
|
370
|
+
const body = await readBody(req);
|
|
371
|
+
const question = typeof body?.question === "string" ? body.question.trim() : "";
|
|
372
|
+
if (!question || question.length > 4000) return sendJson(res, 400, { ok: false, message: "question 必填且不超过 4000 字" });
|
|
373
|
+
const history = (Array.isArray(body?.history) ? body.history : [])
|
|
374
|
+
.filter((x) => x && (x.role === "user" || x.role === "assistant") && typeof x.text === "string" && x.text.trim())
|
|
375
|
+
.slice(-12).map((x) => ({ role: x.role, text: x.text.slice(0, 4000) }));
|
|
376
|
+
const focus = body?.focus && typeof body.focus === "object" ? body.focus : {};
|
|
377
|
+
const fSlug = typeof focus.slug === "string" && /^[a-z0-9-]+$/.test(focus.slug) ? focus.slug : null;
|
|
378
|
+
const fRunId = typeof focus.runId === "string" && /^\d{8}-\d{6}-[a-z0-9-]+$/.test(focus.runId) ? focus.runId : null;
|
|
379
|
+
|
|
380
|
+
const system = ASSISTANT_SYSTEM_HEAD + "\n\n" + buildAssistantContext(root, { nav: focus.nav, slug: fSlug, runId: fRunId });
|
|
381
|
+
const messages = [...history, { role: "user", text: question }];
|
|
382
|
+
|
|
383
|
+
let finished = false;
|
|
384
|
+
const ac = new AbortController();
|
|
385
|
+
req.on("close", () => { if (!finished) ac.abort(); });
|
|
386
|
+
// 响应已开始流式写入后不能再走 sendJson(外层 catch 的 500 会二次 writeHead),
|
|
387
|
+
// 因此端点内部消化一切错误:已写头则补 {"error"} 行,未写头前出错仍可 sendJson
|
|
388
|
+
let headerSent = false;
|
|
389
|
+
try {
|
|
390
|
+
const llm = makeLlm(ctx, null, () => ({}));
|
|
391
|
+
await llm.streamText({
|
|
392
|
+
system, messages, maxTokens: 8192, signal: ac.signal,
|
|
393
|
+
onDelta: (d) => {
|
|
394
|
+
if (!headerSent) {
|
|
395
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store", "Transfer-Encoding": "chunked" });
|
|
396
|
+
headerSent = true;
|
|
397
|
+
}
|
|
398
|
+
res.write(JSON.stringify({ delta: d }) + "\n");
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
if (!headerSent) {
|
|
402
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store", "Transfer-Encoding": "chunked" });
|
|
403
|
+
headerSent = true;
|
|
404
|
+
}
|
|
405
|
+
res.write(JSON.stringify({ done: true }) + "\n");
|
|
406
|
+
} catch (e) {
|
|
407
|
+
if (!headerSent) return sendJson(res, 500, { ok: false, message: (e && e.message) || String(e) });
|
|
408
|
+
try { res.write(JSON.stringify({ error: String((e && e.message) || e) }) + "\n"); } catch { /* 连接已断 */ }
|
|
409
|
+
} finally {
|
|
410
|
+
finished = true;
|
|
411
|
+
res.end();
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (parts[2] !== "projects") {
|
|
416
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
417
|
+
}
|
|
418
|
+
const slug = parts[3];
|
|
419
|
+
const p = parts[4];
|
|
420
|
+
|
|
421
|
+
// —— /issue2pr/api/projects(集合,无 slug) ——
|
|
422
|
+
if (!slug) {
|
|
423
|
+
if (m === "GET") return sendJson(res, 200, { ok: true, projects: listProjects(root) });
|
|
424
|
+
if (m === "POST") {
|
|
425
|
+
const body = await readBody(req);
|
|
426
|
+
try { saveProject(root, body); }
|
|
427
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
428
|
+
return sendJson(res, 200, { ok: true, project: body });
|
|
429
|
+
}
|
|
430
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
431
|
+
}
|
|
432
|
+
if (!/^[a-z0-9-]+$/.test(slug)) return sendJson(res, 400, { ok: false, message: "非法 slug" });
|
|
433
|
+
|
|
434
|
+
// —— 删除项目(整目录:project.json + runs + repo 克隆);?confirm=slug 防误删 ——
|
|
435
|
+
if (!parts[4] && m === "DELETE") {
|
|
436
|
+
const dir = join(root, "projects", slug);
|
|
437
|
+
if (!existsSync(dir)) return sendJson(res, 404, { ok: false, message: "项目不存在" });
|
|
438
|
+
if (url.searchParams.get("confirm") !== slug) return sendJson(res, 400, { ok: false, message: "缺少 confirm=slug 确认参数" });
|
|
439
|
+
// 运行中/待复核的 Run 先落 stopped(驱动循环下一圈读盘即退出;目录随后整体删除)
|
|
440
|
+
const runsDir = join(dir, "runs");
|
|
441
|
+
let stopped = 0;
|
|
442
|
+
if (existsSync(runsDir)) {
|
|
443
|
+
for (const id of readdirSync(runsDir)) {
|
|
444
|
+
const rd = join(runsDir, id);
|
|
445
|
+
const r = loadRun(rd);
|
|
446
|
+
if (r && (r.status === "running" || r.status === "awaiting_review")) {
|
|
447
|
+
r.status = "stopped";
|
|
448
|
+
if (r.stages[r.current] && r.stages[r.current].status === "running") r.stages[r.current].status = "stopped";
|
|
449
|
+
saveRun(rd, r);
|
|
450
|
+
runSessions.delete(rd);
|
|
451
|
+
killExternal(rd);
|
|
452
|
+
stopped += 1;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
try { rmTree(dir); }
|
|
457
|
+
catch (e) { return sendJson(res, 500, { ok: false, message: "删除失败: " + String((e && e.message) || e) }); }
|
|
458
|
+
return sendJson(res, 200, { ok: true, message: stopped > 0 ? `项目已删除(含 ${stopped} 个进行中的 Run,已一并停止移除)` : "项目已删除" });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const project = loadProject(root, slug);
|
|
462
|
+
const runsDir = join(root, "projects", slug, "runs");
|
|
463
|
+
|
|
464
|
+
// —— /issue2pr/api/projects/:slug/runs(集合) ——
|
|
465
|
+
if (p === "runs" && !parts[5]) {
|
|
466
|
+
if (m === "GET") {
|
|
467
|
+
// 扫 runs/*/run.json,出摘要
|
|
468
|
+
const runs = existsSync(runsDir)
|
|
469
|
+
? readdirSync(runsDir).map((id) => {
|
|
470
|
+
const r = loadRun(join(runsDir, id));
|
|
471
|
+
return r ? { id: r.id, status: r.status, current: r.current, trigger: r.trigger, createdAt: r.createdAt } : null;
|
|
472
|
+
}).filter(Boolean).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
473
|
+
: [];
|
|
474
|
+
return sendJson(res, 200, { ok: true, runs });
|
|
475
|
+
}
|
|
476
|
+
if (m === "POST") {
|
|
477
|
+
if (!project) return sendJson(res, 404, { ok: false, message: "项目不存在" });
|
|
478
|
+
const body = await readBody(req);
|
|
479
|
+
const { kind, uri } = body || {};
|
|
480
|
+
if ((kind !== "issue" && kind !== "requirement") || typeof uri !== "string" || !uri.trim()) {
|
|
481
|
+
return sendJson(res, 400, { ok: false, message: "kind 仅允许 issue|requirement 且 uri 必填" });
|
|
482
|
+
}
|
|
483
|
+
const trigger = { kind, uri };
|
|
484
|
+
let text;
|
|
485
|
+
try { text = await readTriggerText({ trigger, connections: loadConnections(root) }); } // 读触发文本(连接凭据优先于环境变量)
|
|
486
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
487
|
+
let created;
|
|
488
|
+
try { created = createRun(root, slug, trigger); } // 同秒同触发源重复发起 → Run 已存在 → 409
|
|
489
|
+
catch (e) { return sendJson(res, 409, { ok: false, message: (e && e.message) || String(e) }); }
|
|
490
|
+
const { runId, runDir } = created;
|
|
491
|
+
const run = initRun({ runId, slug, trigger: { ...trigger, text }, reviewMode: project.reviewMode, p6Mode: project.p6Mode });
|
|
492
|
+
run.status = "running"; // 发起即进入运行态(drive 只在 running 时推进)
|
|
493
|
+
saveRun(runDir, run);
|
|
494
|
+
sendJson(res, 200, { ok: true, runId });
|
|
495
|
+
setImmediate(() => drive(ctx, root, runDir)); // 同步返回后异步推进
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// —— /issue2pr/api/projects/:slug/runs/:runId[/action] ——
|
|
502
|
+
if (p === "runs" && parts[5]) {
|
|
503
|
+
const runId = parts[5];
|
|
504
|
+
let runDir;
|
|
505
|
+
try { runDir = runDirOf(root, slug, runId); }
|
|
506
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
507
|
+
const action = parts[6];
|
|
508
|
+
|
|
509
|
+
if (!action && m === "GET") {
|
|
510
|
+
const run = loadRun(runDir);
|
|
511
|
+
if (!run) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
512
|
+
if (run.p6Mode === "session" || run.p6Mode === "claude") run.externalProgress = externalProgress(runDir);
|
|
513
|
+
return sendJson(res, 200, run); // 直接吐 run.json(session/claude 模式附带外部执行进度)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// —— 停止:不再推进(当前正在执行的阶段跑完即停;异步执行器不阻塞本请求) ——
|
|
517
|
+
if (action === "stop" && m === "POST") {
|
|
518
|
+
const run = loadRun(runDir);
|
|
519
|
+
if (!run) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
520
|
+
if (run.status !== "running" && run.status !== "awaiting_review") {
|
|
521
|
+
return sendJson(res, 400, { ok: false, message: "仅运行中/待复核的 Run 可停止(当前: " + run.status + ")" });
|
|
522
|
+
}
|
|
523
|
+
run.status = "stopped";
|
|
524
|
+
if (run.stages[run.current] && run.stages[run.current].status === "running") {
|
|
525
|
+
run.stages[run.current].status = "stopped";
|
|
526
|
+
}
|
|
527
|
+
saveRun(runDir, run);
|
|
528
|
+
killExternal(runDir); // claude 委托在跑时一并终止进程树,避免孤儿进程继续写仓库
|
|
529
|
+
return sendJson(res, 200, { ok: true, message: "已停止(当前阶段执行完即停)" });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// —— 回退重跑:指定阶段及其后全部置为 pending,从该阶段重新推进 ——
|
|
533
|
+
if (action === "rerun" && m === "POST") {
|
|
534
|
+
const run = loadRun(runDir);
|
|
535
|
+
if (!run) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
536
|
+
if (run.status === "running") return sendJson(res, 400, { ok: false, message: "运行中的 Run 请先停止再回退" });
|
|
537
|
+
const body = await readBody(req);
|
|
538
|
+
const stage = String(body?.stage || "");
|
|
539
|
+
const ids = STAGES.map((s) => s.id);
|
|
540
|
+
if (!ids.includes(stage)) return sendJson(res, 400, { ok: false, message: "非法阶段: " + stage });
|
|
541
|
+
for (const s of STAGES) {
|
|
542
|
+
const idx = ids.indexOf(s.id);
|
|
543
|
+
if (idx >= ids.indexOf(stage)) {
|
|
544
|
+
run.stages[s.id] = { status: "pending", attempts: run.stages[s.id]?.attempts || 0 };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
run.current = stage;
|
|
548
|
+
run.status = "running";
|
|
549
|
+
saveRun(runDir, run);
|
|
550
|
+
sendJson(res, 200, { ok: true, message: "已从 " + stage + " 重跑" });
|
|
551
|
+
setImmediate(() => drive(ctx, root, runDir));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// —— 删除:整目录移除(先落一个 stopped 状态让推进循环退出;孤儿产物由驱动循环兜底清理) ——
|
|
556
|
+
if (!action && m === "DELETE") {
|
|
557
|
+
if (!existsSync(runDir)) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
558
|
+
const run = loadRun(runDir);
|
|
559
|
+
if (run && (run.status === "running" || run.status === "awaiting_review")) {
|
|
560
|
+
run.status = "stopped";
|
|
561
|
+
saveRun(runDir, run); // 推进循环下一圈 loadRun 读到 stopped 即退出
|
|
562
|
+
}
|
|
563
|
+
runSessions.delete(runDir); // 丢弃共享 rcx(reviewComment 等),防复活
|
|
564
|
+
killExternal(runDir);
|
|
565
|
+
try { rmTree(runDir); }
|
|
566
|
+
catch (e) { return sendJson(res, 500, { ok: false, message: "删除失败: " + String((e && e.message) || e) }); }
|
|
567
|
+
return sendJson(res, 200, { ok: true, message: "已删除" });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (action === "review" && m === "POST") {
|
|
571
|
+
const run = loadRun(runDir);
|
|
572
|
+
if (!run) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
573
|
+
const s = sessionFor(runDir);
|
|
574
|
+
const rcx = s.rcx || (s.rcx = buildRcx(ctx, root, runDir, run));
|
|
575
|
+
rcx.run = run;
|
|
576
|
+
const body = await readBody(req);
|
|
577
|
+
const [ok, msg] = applyReview(rcx, { decision: body?.decision, comment: body?.comment });
|
|
578
|
+
if (!ok) return sendJson(res, 400, { ok: false, message: msg });
|
|
579
|
+
sendJson(res, 200, { ok: true, message: msg });
|
|
580
|
+
if (run.status === "running") setImmediate(() => drive(ctx, root, runDir)); // approve/reject 后继续推进
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (action === "rollback" && m === "POST") {
|
|
585
|
+
const run = loadRun(runDir);
|
|
586
|
+
// 运行中/待复核时禁止回滚:避免推进循环或后续阶段在半撤销的仓库上继续工作
|
|
587
|
+
if (run && (run.status === "running" || run.status === "awaiting_review")) {
|
|
588
|
+
return sendJson(res, 400, { ok: false, message: "Run 运行中/待复核,请先停止再回滚" });
|
|
589
|
+
}
|
|
590
|
+
const body = await readBody(req);
|
|
591
|
+
const lineNo = Number(body?.lineNo);
|
|
592
|
+
if (!Number.isInteger(lineNo) || lineNo < 0) return sendJson(res, 400, { ok: false, message: "lineNo 必须是合法行号" });
|
|
593
|
+
try {
|
|
594
|
+
await rollbackLedger(runDir, join(root, "projects", slug, "repo"), lineNo);
|
|
595
|
+
} catch (e) { return sendJson(res, 400, { ok: false, message: "回滚失败: " + String((e && e.message) || e) }); }
|
|
596
|
+
return sendJson(res, 200, { ok: true });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// —— 在系统文件管理器中打开 run 产物目录 ——
|
|
600
|
+
if (action === "open" && m === "POST") {
|
|
601
|
+
if (!existsSync(runDir)) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
602
|
+
// opener 可注入(测试传空函数,避免 npm test 真弹资源管理器)
|
|
603
|
+
const openerFn = __testHooks?.opener || execFile;
|
|
604
|
+
const opener = process.platform === "win32" ? "explorer" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
605
|
+
openerFn(opener, [runDir], { timeout: 10000 }, (err) => {
|
|
606
|
+
// explorer 常返回非 0 成功码,不据此报错
|
|
607
|
+
if (err && process.platform !== "win32") ctx.logger?.warn?.("issue2pr: 打开目录失败: " + err.message);
|
|
608
|
+
});
|
|
609
|
+
return sendJson(res, 200, { ok: true, message: "已请求打开产物目录" });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (action === "tree" && m === "GET") {
|
|
613
|
+
if (!existsSync(runDir)) return sendJson(res, 404, { ok: false, message: "run 不存在" });
|
|
614
|
+
return sendJson(res, 200, { ok: true, files: listRunTree(runDir) });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (action === "artifact" && m === "GET") {
|
|
618
|
+
const rel = url.searchParams.get("path") || "";
|
|
619
|
+
let text;
|
|
620
|
+
try { text = readArtifact(runDir, rel); } // safeJoin 防 ..
|
|
621
|
+
catch (e) { return sendJson(res, 400, { ok: false, message: (e && e.message) || String(e) }); }
|
|
622
|
+
if (text == null) return sendJson(res, 404, { ok: false, message: "产物不存在: " + rel });
|
|
623
|
+
if (Buffer.byteLength(text, "utf8") > 200 * 1024) return sendJson(res, 400, { ok: false, message: "文件超过 200KB 上限" });
|
|
624
|
+
return sendJson(res, 200, { ok: true, text });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return sendJson(res, 404, { ok: false, message: "not found" });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
sendJson(res, 404, { ok: false, message: "not found" });
|
|
631
|
+
} catch (e) {
|
|
632
|
+
sendJson(res, 500, { ok: false, message: "出错: " + String((e && e.message) || e) });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// session 模式外部执行进度(每次现算不落盘;UI 3s 轮询本接口自动刷新)
|
|
637
|
+
// tasks 取自 P5 任务图节点数,patches 为 06-implementation/patches/*.diff 计数,report 即 coder-report.json
|
|
638
|
+
function externalProgress(runDir) {
|
|
639
|
+
const dir = join(runDir, "06-implementation", "patches");
|
|
640
|
+
let patches = 0;
|
|
641
|
+
if (existsSync(dir)) patches = readdirSync(dir).filter((f) => f.endsWith(".diff")).length;
|
|
642
|
+
let tasks = null;
|
|
643
|
+
try { tasks = JSON.parse(readFileSync(join(runDir, "05-task-graph.json"), "utf8")).nodes.length; } catch { /* 任务图缺失时只报 patch 数 */ }
|
|
644
|
+
return { patches, tasks, report: existsSync(join(runDir, "06-implementation", "coder-report.json")) };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function apply(ctx, config = {}) {
|
|
648
|
+
const root = __testHooks?.dataRoot || config.dataRoot || defaultDataRoot();
|
|
649
|
+
// 启动恢复:把上次进程退出时遗留的 running Run 置为 stopped(仅测试钩子注入时跳过,交由用例自行构造)
|
|
650
|
+
if (!__testHooks?.dataRoot) {
|
|
651
|
+
try { recoverInterruptedRuns(root); }
|
|
652
|
+
catch (e) { ctx.logger?.warn?.("issue2pr: 启动恢复失败: " + String((e && e.message) || e)); }
|
|
653
|
+
}
|
|
654
|
+
ctx.effect(() => ctx.webServer.register({
|
|
655
|
+
kind: "prefix", path: "/issue2pr", handler: (req, res) => handleApi(ctx, root, req, res),
|
|
656
|
+
}), "issue2pr: api routes");
|
|
657
|
+
ctx.logger?.info?.(`issue2pr: API ready at /issue2pr/api/projects/* (dataRoot=${root})`);
|
|
658
|
+
}
|