paseo-req-kit 1.14.0-beta.2 → 1.14.0-beta.4

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/req.mjs +16 -0
  3. package/contracts/cli-json.md +1 -1
  4. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +15 -0
  5. package/engine/prompts/06-code-review.md +12 -1
  6. package/engine/prompts/incremental.md +2 -0
  7. package/engine/src/api.ts +18 -2
  8. package/engine/src/checklist-cmd.ts +37 -0
  9. package/engine/src/checklist.ts +254 -0
  10. package/engine/src/cli-args.ts +4 -1
  11. package/engine/src/code-review.ts +419 -0
  12. package/engine/src/codeowners.ts +87 -0
  13. package/engine/src/main.ts +13 -3
  14. package/engine/src/preflight.ts +10 -1
  15. package/engine/src/reply.ts +7 -4
  16. package/engine/src/review-assets/check.css +57 -0
  17. package/engine/src/review-assets/check.html +35 -0
  18. package/engine/src/review-assets/check.js +258 -0
  19. package/engine/src/review-assets/code.css +93 -0
  20. package/engine/src/review-assets/code.html +43 -0
  21. package/engine/src/review-assets/code.js +402 -0
  22. package/engine/src/review.ts +177 -2
  23. package/engine/src/try.ts +134 -0
  24. package/package.json +1 -1
  25. package/paseo-plugin/client/data.ts +3 -3
  26. package/paseo-plugin/client/detail.tsx +10 -2
  27. package/paseo-plugin/client/nav.ts +10 -0
  28. package/paseo-plugin/client/review.tsx +30 -6
  29. package/paseo-plugin/package.json +1 -1
  30. package/paseo-plugin/server/handlers.ts +2 -2
  31. package/paseo-plugin/shared/contracts.ts +4 -1
  32. package/site-dist/404.html +2 -2
  33. package/site-dist/assets/{app.gI1JF182.js → app.Cp7WwPWE.js} +1 -1
  34. package/site-dist/assets/changelog.md.Zi-_3Rjh.js +1 -0
  35. package/site-dist/assets/chunks/@localSearchIndexroot.COxp5Rrc.js +1 -0
  36. package/site-dist/assets/chunks/{VPLocalSearchBox.DNmhkkfS.js → VPLocalSearchBox.CMTXQK-Q.js} +1 -1
  37. package/site-dist/assets/chunks/{theme.CZzkCOAC.js → theme.DvVUfKr3.js} +2 -2
  38. package/site-dist/assets/{guide_getting-started.md.zmtXv15G.js → guide_getting-started.md.D8XlIQOg.js} +1 -1
  39. package/site-dist/assets/{guide_user.md.2v7b_fzd.js → guide_user.md.B4N9ywmr.js} +1 -1
  40. package/site-dist/assets/{guide_user.md.2v7b_fzd.lean.js → guide_user.md.B4N9ywmr.lean.js} +1 -1
  41. package/site-dist/assets/{reference_contracts_cli-json.md.BR6gwq-e.js → reference_contracts_cli-json.md.CCvKLasM.js} +1 -1
  42. package/site-dist/assets/{reference_contracts_cli-json.md.BR6gwq-e.lean.js → reference_contracts_cli-json.md.CCvKLasM.lean.js} +1 -1
  43. package/site-dist/changelog.html +5 -5
  44. package/site-dist/guide/getting-started.html +5 -5
  45. package/site-dist/guide/how-it-works.html +4 -4
  46. package/site-dist/guide/index.html +4 -4
  47. package/site-dist/guide/install.html +4 -4
  48. package/site-dist/guide/maintainer.html +4 -4
  49. package/site-dist/guide/phases.html +4 -4
  50. package/site-dist/guide/project.html +4 -4
  51. package/site-dist/guide/user.html +5 -5
  52. package/site-dist/hashmap.json +1 -1
  53. package/site-dist/index.html +4 -4
  54. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  55. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  56. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  57. package/site-dist/reference/adr/0004-result-files.html +4 -4
  58. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  59. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  60. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  61. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  62. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  63. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  64. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  65. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  66. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  67. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  68. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  69. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  70. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  71. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  72. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  73. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  74. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  75. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  76. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  77. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  78. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  79. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  80. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  81. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  82. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  83. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  84. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  85. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  86. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  87. package/site-dist/reference/adr/index.html +4 -4
  88. package/site-dist/reference/commands.html +4 -4
  89. package/site-dist/reference/config.html +4 -4
  90. package/site-dist/reference/contracts/cli-json.html +5 -5
  91. package/site-dist/reference/contracts/feature-files.html +4 -4
  92. package/site-dist/reference/contracts/index.html +4 -4
  93. package/site-dist/reference/contracts/notifier-event.html +4 -4
  94. package/site-dist/reference/contracts/result-file.html +4 -4
  95. package/site-dist/reference/contracts/spec.html +4 -4
  96. package/site-dist/reference/contracts/state.html +4 -4
  97. package/site-dist/reference/contracts/usage-record.html +4 -4
  98. package/site-dist/reference/contracts/verify.html +4 -4
  99. package/site-dist/reference/scope.html +4 -4
  100. package/skills/req/SKILL.md +3 -0
  101. package/skills/req/playbooks/accept.md +2 -2
  102. package/site-dist/assets/changelog.md.2xf690VW.js +0 -1
  103. package/site-dist/assets/chunks/@localSearchIndexroot.CiFeI6qC.js +0 -1
  104. /package/site-dist/assets/{changelog.md.2xf690VW.lean.js → changelog.md.Zi-_3Rjh.lean.js} +0 -0
  105. /package/site-dist/assets/{guide_getting-started.md.zmtXv15G.lean.js → guide_getting-started.md.D8XlIQOg.lean.js} +0 -0
@@ -0,0 +1,402 @@
1
+ // The code review page (1.14). Plain script, no framework: the server serves it next to its JSON
2
+ // API (api/info, api/draft, api/submit). Everything shown is set with textContent.
3
+ (function () {
4
+ "use strict";
5
+ var state = { info: null, comments: [], decisions: {}, reviewer: "", reviewers: "", note: "", saveTimer: null, busy: false, open: null };
6
+ var $ = function (id) {
7
+ return document.getElementById(id);
8
+ };
9
+
10
+ function el(tag, attrs, children) {
11
+ var node = document.createElement(tag);
12
+ Object.keys(attrs || {}).forEach(function (k) {
13
+ if (attrs[k] === null || attrs[k] === undefined) return;
14
+ if (k === "text") node.textContent = attrs[k];
15
+ else if (k === "className") node.className = attrs[k];
16
+ else if (k.indexOf("on") === 0) node.addEventListener(k.slice(2), attrs[k]);
17
+ else node.setAttribute(k, attrs[k]);
18
+ });
19
+ (children || []).forEach(function (c) {
20
+ if (c) node.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
21
+ });
22
+ return node;
23
+ }
24
+
25
+ function toast(text) {
26
+ var t = $("toast");
27
+ t.textContent = text;
28
+ t.hidden = false;
29
+ clearTimeout(toast.timer);
30
+ toast.timer = setTimeout(function () {
31
+ t.hidden = true;
32
+ }, 8000);
33
+ }
34
+
35
+ function api(pathname, method, body) {
36
+ return fetch(pathname, { method: method || "GET", headers: body ? { "content-type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined }).then(function (r) {
37
+ return r.json().then(function (data) {
38
+ if (!r.ok && data && data.message) throw new Error(data.message);
39
+ return data;
40
+ });
41
+ });
42
+ }
43
+
44
+ function reviewersList() {
45
+ return state.reviewers
46
+ .split(/[,,、\s]+/)
47
+ .map(function (s) {
48
+ return s.trim();
49
+ })
50
+ .filter(Boolean);
51
+ }
52
+
53
+ function save() {
54
+ if (state.info.readOnly) return;
55
+ clearTimeout(state.saveTimer);
56
+ state.saveTimer = setTimeout(function () {
57
+ api("api/draft", "PUT", { comments: state.comments, decisions: state.decisions, reviewer: state.reviewer, reviewers: state.reviewers, note: state.note }).catch(function () {});
58
+ }, 600);
59
+ count();
60
+ }
61
+
62
+ function fixes() {
63
+ return Object.keys(state.decisions)
64
+ .filter(function (id) {
65
+ return state.decisions[id].mark === "fix";
66
+ })
67
+ .map(function (id) {
68
+ return { id: id, note: state.decisions[id].note || "" };
69
+ });
70
+ }
71
+
72
+ function count() {
73
+ var comments = state.comments.filter(function (c) {
74
+ return c.text.trim();
75
+ }).length;
76
+ var fix = fixes().length;
77
+ var total = comments + fix + (state.note.trim() ? 1 : 0);
78
+ $("count").textContent = "代码行上的意见 " + comments + " 条,AI 意见标为要改 " + fix + " 条";
79
+ var blocked = state.info.readOnly || state.info.running || state.busy;
80
+ $("changes").disabled = blocked || total === 0;
81
+ $("changes").textContent = total ? "退回修改(" + total + " 条意见)" : "退回修改";
82
+ $("approve").disabled = blocked;
83
+ }
84
+
85
+ var SEVERITY_CLASS = { "阻塞": "danger", "重要": "warn", "建议": "", "待核实": "" };
86
+ var STATUS_TEXT = { fixed: "已修复", rejected: "修复时判为不成立", wontfix: "决定不修复", open: "未处理" };
87
+
88
+ function findingNode(f, compact) {
89
+ var d = state.decisions[f.id] || {};
90
+ var note = el("input", { className: "note-input", placeholder: "补充一句(可不填)", value: d.note || "", hidden: d.mark === "fix" ? null : "" });
91
+ note.addEventListener("input", function (e) {
92
+ state.decisions[f.id] = { mark: "fix", note: e.target.value };
93
+ save();
94
+ });
95
+ function mark(m) {
96
+ return function () {
97
+ var cur = state.decisions[f.id] && state.decisions[f.id].mark;
98
+ if (cur === m) delete state.decisions[f.id];
99
+ else state.decisions[f.id] = { mark: m, note: (state.decisions[f.id] && state.decisions[f.id].note) || "" };
100
+ render();
101
+ save();
102
+ };
103
+ }
104
+ var where = f.file ? f.file + (f.line ? ":" + f.line : "") : "";
105
+ return el("div", { className: "finding " + (SEVERITY_CLASS[f.severity] || "") + (d.mark ? " decided-" + d.mark : "") }, [
106
+ el("div", { className: "finding-head" }, [
107
+ el("span", { className: "badge " + (SEVERITY_CLASS[f.severity] || ""), text: "AI · " + f.severity }),
108
+ el("b", { text: f.id }),
109
+ f.status !== "open" ? el("span", { className: "badge " + (f.status === "fixed" ? "ok" : ""), text: STATUS_TEXT[f.status] }) : null,
110
+ !compact && where ? el("a", { href: "#", className: "where", text: where, onclick: function (e) {
111
+ e.preventDefault();
112
+ jump(f.file, f.line);
113
+ } }) : null,
114
+ ]),
115
+ el("div", { className: "finding-text", text: f.title }),
116
+ f.suggestion ? el("div", { className: "muted small", text: "建议:" + f.suggestion }) : null,
117
+ state.info.readOnly
118
+ ? null
119
+ : el("div", { className: "marks" }, [
120
+ el("button", { className: "btn small" + (d.mark === "fix" ? " on-fix" : ""), text: "要改", onclick: mark("fix") }),
121
+ el("button", { className: "btn small" + (d.mark === "ok" ? " on-ok" : ""), text: "不用改", onclick: mark("ok") }),
122
+ ]),
123
+ note,
124
+ ]);
125
+ }
126
+
127
+ function jump(file, line) {
128
+ var target = document.querySelector('[data-file="' + CSS.escape(file) + '"]' + (line ? '[data-n="' + line + '"]' : ""));
129
+ if (!target) target = document.querySelector('[data-file-head="' + CSS.escape(file) + '"]');
130
+ if (target) {
131
+ var box = target.closest(".file");
132
+ if (box && box.classList.contains("folded")) box.classList.remove("folded");
133
+ target.scrollIntoView({ block: "center", behavior: "smooth" });
134
+ target.classList.add("flash");
135
+ setTimeout(function () {
136
+ target.classList.remove("flash");
137
+ }, 1600);
138
+ }
139
+ }
140
+
141
+ function commentBox(file, line) {
142
+ var existing = state.comments.find(function (c) {
143
+ return c.file === file && c.line === line;
144
+ });
145
+ if (!existing) {
146
+ existing = { file: file, line: line, text: "" };
147
+ state.comments.push(existing);
148
+ }
149
+ var ta = el("textarea", { rows: "2", placeholder: "这一行哪里不对、应该怎么改" });
150
+ ta.value = existing.text;
151
+ ta.addEventListener("input", function (e) {
152
+ existing.text = e.target.value;
153
+ save();
154
+ });
155
+ var remove = el("button", { className: "btn small", text: "删除", onclick: function () {
156
+ state.comments = state.comments.filter(function (c) {
157
+ return c !== existing;
158
+ });
159
+ state.open = null;
160
+ render();
161
+ save();
162
+ } });
163
+ return el("tr", { className: "comment-row" }, [el("td", { colspan: "3" }, [el("div", { className: "comment" }, [el("div", { className: "muted small", text: "你的意见(" + file + ":" + line + ")" }), ta, state.info.readOnly ? null : remove])])]);
164
+ }
165
+
166
+ function renderFile(f, findingsByFile) {
167
+ var mine = findingsByFile[f.path] || [];
168
+ var owners = (state.info.reviewers.byFile || {})[f.path] || [];
169
+ var head = el("div", { className: "file-head", "data-file-head": f.path, onclick: function () {
170
+ box.classList.toggle("folded");
171
+ } }, [
172
+ el("span", { className: "path", text: f.path + (f.oldPath ? "(原 " + f.oldPath + ")" : "") }),
173
+ el("span", { className: "badge", text: { added: "新增", deleted: "删除", modified: "修改", renamed: "改名" }[f.status] }),
174
+ el("span", { className: "plus", text: "+" + f.additions }),
175
+ el("span", { className: "minus", text: "−" + f.deletions }),
176
+ f.doc ? el("span", { className: "badge", text: "需求文档" }) : null,
177
+ mine.length ? el("span", { className: "badge warn", text: "AI 意见 " + mine.length }) : null,
178
+ owners.length ? el("span", { className: "muted small", text: "负责人 " + owners.join(" ") }) : null,
179
+ ]);
180
+ var body = el("div", { className: "file-body" });
181
+ var unplaced = mine.filter(function (x) {
182
+ return !x.line || !f.hunks.some(function (h) {
183
+ return h.lines.some(function (l) {
184
+ return l.n === x.line;
185
+ });
186
+ });
187
+ });
188
+ unplaced.forEach(function (x) {
189
+ body.appendChild(findingNode(x, true));
190
+ });
191
+ if (f.binary) body.appendChild(el("div", { className: "muted pad", text: "二进制文件,不显示内容。" }));
192
+ f.hunks.forEach(function (h) {
193
+ var table = el("table", { className: "diff" });
194
+ table.appendChild(el("tr", { className: "hunk" }, [el("td", { colspan: "3", text: h.header })]));
195
+ h.lines.forEach(function (l) {
196
+ var line = l.n || l.o;
197
+ var row = el("tr", { className: "line " + (l.t === "+" ? "add" : l.t === "-" ? "del" : ""), "data-file": f.path, "data-n": l.n || "", title: state.info.readOnly ? "" : "点这一行写意见" }, [
198
+ el("td", { className: "num", text: l.o === null ? "" : String(l.o) }),
199
+ el("td", { className: "num", text: l.n === null ? "" : String(l.n) }),
200
+ el("td", { className: "code", text: (l.t === " " ? " " : l.t) + " " + l.s }),
201
+ ]);
202
+ if (!state.info.readOnly) {
203
+ row.addEventListener("click", function () {
204
+ state.open = f.path + ":" + line;
205
+ render();
206
+ });
207
+ }
208
+ table.appendChild(row);
209
+ mine
210
+ .filter(function (x) {
211
+ return x.line && l.n === x.line;
212
+ })
213
+ .forEach(function (x) {
214
+ table.appendChild(el("tr", { className: "finding-row" }, [el("td", { colspan: "3" }, [findingNode(x, true)])]));
215
+ });
216
+ var hasComment = state.comments.some(function (c) {
217
+ return c.file === f.path && c.line === line;
218
+ });
219
+ if (hasComment || state.open === f.path + ":" + line) table.appendChild(commentBox(f.path, line));
220
+ });
221
+ body.appendChild(table);
222
+ });
223
+ if (f.truncated) body.appendChild(el("div", { className: "muted pad", text: "改动太多,只显示了一部分。完整改动请在开发工具里看。" }));
224
+ var box = el("section", { className: "file" + (f.doc && !mine.length ? " folded" : ""), id: "file-" + f.path }, [head, body]);
225
+ return box;
226
+ }
227
+
228
+ function render() {
229
+ var info = state.info;
230
+ var byFile = {};
231
+ info.findings.forEach(function (x) {
232
+ if (x.file && info.files.some(function (f) {
233
+ return f.path === x.file;
234
+ })) (byFile[x.file] = byFile[x.file] || []).push(x);
235
+ });
236
+ var folded = {};
237
+ document.querySelectorAll(".file").forEach(function (n) {
238
+ folded[n.id] = n.classList.contains("folded");
239
+ });
240
+ var diffs = $("diffs");
241
+ var y = window.scrollY;
242
+ diffs.textContent = "";
243
+ if (!info.files.length) diffs.appendChild(el("div", { className: "card muted", text: info.base.commit ? "没有找到改动。" : "找不到这个需求是从哪个提交开始的,没法列出改动。" }));
244
+ info.files.forEach(function (f) {
245
+ var node = renderFile(f, byFile);
246
+ if (node.id in folded) node.classList.toggle("folded", folded[node.id]);
247
+ diffs.appendChild(node);
248
+ });
249
+ // The AI findings not on a changed file: listed at the top.
250
+ var ai = $("ai");
251
+ ai.textContent = "";
252
+ var elsewhere = info.findings.filter(function (x) {
253
+ return !byFile[x.file] || byFile[x.file].indexOf(x) < 0;
254
+ });
255
+ if (info.findings.length) {
256
+ ai.hidden = false;
257
+ var open = info.findings.filter(function (x) {
258
+ return x.status === "open";
259
+ }).length;
260
+ ai.appendChild(el("h2", { text: "AI 评审意见(" + info.findings.length + " 条,未处理 " + open + " 条)" }));
261
+ ai.appendChild(el("p", { className: "muted small", text: "在代码旁边也能看到。阻塞、重要的未处理意见建议标为要改;拿不准的请开发同事看一下。" }));
262
+ info.findings.forEach(function (x) {
263
+ if (elsewhere.indexOf(x) >= 0 || x.severity === "阻塞" || x.severity === "重要") ai.appendChild(findingNode(x, false));
264
+ });
265
+ } else if (info.rounds.length) {
266
+ ai.hidden = false;
267
+ ai.appendChild(el("h2", { text: "AI 评审" }));
268
+ ai.appendChild(el("p", { className: "muted", text: "AI 评审没有留下结构化的意见,详细内容见 code-review.md。" }));
269
+ }
270
+ var nav = $("files");
271
+ nav.textContent = "";
272
+ nav.appendChild(el("div", { className: "nav-title", text: "改动的文件(" + info.files.length + ")" }));
273
+ info.files.forEach(function (f) {
274
+ nav.appendChild(el("a", { href: "#", className: f.doc ? "doc" : "", onclick: function (e) {
275
+ e.preventDefault();
276
+ jump(f.path, null);
277
+ } }, [el("span", { text: f.path }), (byFile[f.path] || []).length ? el("span", { className: "badge warn", text: String(byFile[f.path].length) }) : null]));
278
+ });
279
+ window.scrollTo(0, y);
280
+ count();
281
+ }
282
+
283
+ function submit(verdict) {
284
+ if (state.busy) return;
285
+ var text = verdict === "approve" ? "确认评审通过?会记入 code-review.md 并提交。" : "确认退回修改?流水线会按这些意见修改(需求说明不变),改完再评审、再验证。";
286
+ if (!window.confirm(text)) return;
287
+ state.busy = true;
288
+ count();
289
+ api("api/submit", "POST", {
290
+ verdict: verdict,
291
+ reviewer: state.reviewer,
292
+ reviewers: reviewersList(),
293
+ comments: state.comments.filter(function (c) {
294
+ return c.text.trim();
295
+ }),
296
+ fix: fixes(),
297
+ note: state.note,
298
+ })
299
+ .then(function (r) {
300
+ toast(r.message);
301
+ return load();
302
+ })
303
+ .catch(function (e) {
304
+ toast(e.message);
305
+ })
306
+ .then(function () {
307
+ state.busy = false;
308
+ count();
309
+ });
310
+ }
311
+
312
+ function load() {
313
+ return api("api/info").then(function (info) {
314
+ state.info = info;
315
+ $("project").textContent = info.featureDir;
316
+ $("title").textContent = "代码审查:" + info.title;
317
+ var adds = 0,
318
+ dels = 0;
319
+ info.files.forEach(function (f) {
320
+ if (!f.doc) {
321
+ adds += f.additions;
322
+ dels += f.deletions;
323
+ }
324
+ });
325
+ var last = info.rounds.length ? info.rounds[info.rounds.length - 1] : null;
326
+ $("sub").textContent =
327
+ (info.branch ? "分支 " + info.branch : "") +
328
+ (info.base.ref ? ",相对 " + info.base.ref : "") +
329
+ ":改了 " + info.files.filter(function (f) {
330
+ return !f.doc;
331
+ }).length + " 个代码文件(+" + adds + " −" + dels + ")" +
332
+ (last ? ";AI 评审 " + info.rounds.length + " 轮,最后一轮" + (last.verdict === "pass" ? "通过" : "需要修改") : "");
333
+ var banner = $("status");
334
+ banner.hidden = true;
335
+ banner.className = "banner";
336
+ if (info.running) {
337
+ banner.hidden = false;
338
+ banner.textContent = "流水线正在运行,改动还会变化。可以先看,结束后再提交评审。";
339
+ } else if (info.human) {
340
+ banner.hidden = false;
341
+ var h = info.human;
342
+ if (h.status === "approved") {
343
+ banner.className = "banner " + (h.stale ? "warn" : "ok");
344
+ banner.textContent = (h.stale ? "评审通过后代码又有改动,需要重新评审。" : "已评审通过") + "(" + h.reviewer + "," + h.at.slice(0, 10) + (h.reviewers.length ? ",审核人 " + h.reviewers.join(" ") : "") + ")";
345
+ } else {
346
+ banner.className = "banner warn";
347
+ banner.textContent = "上次退回了 " + h.items.length + " 条意见(" + h.reviewer + "," + h.at.slice(0, 10) + ")" + (h.consumedAttempt ? ",第 " + h.consumedAttempt + " 次尝试在处理" : ",还没开始修改");
348
+ }
349
+ }
350
+ var sum = $("summary");
351
+ sum.textContent = "";
352
+ if (info.summary.length) {
353
+ sum.hidden = false;
354
+ sum.appendChild(el("h2", { text: "这次改了什么(来自提测说明)" }));
355
+ info.summary.forEach(function (l) {
356
+ sum.appendChild(el("div", { text: l.replace(/^\s*[-*]\s+/, "• ") }));
357
+ });
358
+ }
359
+ var d = info.draft || {};
360
+ state.comments = Array.isArray(d.comments) ? d.comments : [];
361
+ state.decisions = d.decisions && typeof d.decisions === "object" ? d.decisions : {};
362
+ state.reviewer = d.reviewer || "";
363
+ state.reviewers = typeof d.reviewers === "string" ? d.reviewers : info.reviewers.suggested.join(", ");
364
+ state.note = d.note || "";
365
+ $("reviewer").value = state.reviewer;
366
+ $("reviewers").value = state.reviewers;
367
+ $("reviewers").title = info.reviewers.source ? "默认取自 " + info.reviewers.source + ",可以改" : "项目里没有 CODEOWNERS,请填写";
368
+ $("note").value = state.note;
369
+ if (info.readOnly) ["reviewer", "reviewers", "note"].forEach(function (id) {
370
+ $(id).disabled = true;
371
+ });
372
+ render();
373
+ });
374
+ }
375
+
376
+ function start() {
377
+ load().catch(function (e) {
378
+ $("title").textContent = "打不开代码审查页";
379
+ $("sub").textContent = e.message;
380
+ });
381
+ $("reviewer").addEventListener("input", function (e) {
382
+ state.reviewer = e.target.value;
383
+ save();
384
+ });
385
+ $("reviewers").addEventListener("input", function (e) {
386
+ state.reviewers = e.target.value;
387
+ save();
388
+ });
389
+ $("note").addEventListener("input", function (e) {
390
+ state.note = e.target.value;
391
+ save();
392
+ });
393
+ $("changes").addEventListener("click", function () {
394
+ submit("changes");
395
+ });
396
+ $("approve").addEventListener("click", function () {
397
+ submit("approve");
398
+ });
399
+ }
400
+
401
+ start();
402
+ })();
@@ -15,6 +15,8 @@ import os from "node:os";
15
15
  import http from "node:http";
16
16
  import path from "node:path";
17
17
  import { ENGINE_DIR, KIT_ROOT, kitHome, kitVersion } from "./locations.js";
18
+ import { buildCodeReview, recordHumanReview, startReviewFix, type Submission } from "./code-review.js";
19
+ import { type Answer, acceptanceFailMessage, acceptanceText, buildChecklist, recordAcceptance, verdictOf } from "./checklist.js";
18
20
  import { readIndex } from "./records.js";
19
21
  import { readState, statePath } from "./state.js";
20
22
 
@@ -203,6 +205,10 @@ export interface ReviewServerOptions {
203
205
  env?: NodeJS.ProcessEnv;
204
206
  /** Runs `req approve|reply …` in the requirement's checkout; tests replace it. */
205
207
  run?: (command: "approve" | "reply", repo: string, featureDir: string, extra: string[]) => { ok: boolean; message: string };
208
+ /** 1.14: `req review-fix <需求>` after the code review page asked for changes; tests replace it. */
209
+ codeFix?: (repo: string, featureDir: string) => { ok: boolean; message: string };
210
+ /** 1.14: `req try <需求> --json [--stop]` for the checklist page's "试用" button; tests replace it. */
211
+ tryRun?: (repo: string, featureDir: string, stop: boolean) => { ok: boolean; message: string; services?: unknown[]; howTo?: string[] };
206
212
  onRequest?: () => void;
207
213
  }
208
214
 
@@ -304,9 +310,29 @@ function defaultRun(command: "approve" | "reply", repo: string, featureDir: stri
304
310
  return { ok: r.status === 0, message: r.error ? r.error.message : `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
305
311
  }
306
312
 
313
+ function defaultTry(repo: string, featureDir: string, stop: boolean) {
314
+ const env = { ...process.env };
315
+ delete env.PASEO_AGENT_ID;
316
+ delete env.PASEO_AGENT_CWD;
317
+ const r = spawnSync(process.execPath, [path.join(KIT_ROOT, "bin", "req.mjs"), "try", featureDir, "--json", ...(stop ? ["--stop"] : [])], {
318
+ cwd: repo,
319
+ env,
320
+ encoding: "utf8",
321
+ timeout: 300_000,
322
+ windowsHide: true,
323
+ });
324
+ try {
325
+ return JSON.parse((r.stdout ?? "").trim().split("\n").at(-1) ?? "") as { ok: boolean; message: string; services?: unknown[]; howTo?: string[] };
326
+ } catch {
327
+ return { ok: false, message: r.error ? r.error.message : `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() || "没有启动" };
328
+ }
329
+ }
330
+
307
331
  export function createReviewServer(opts: ReviewServerOptions): http.Server {
308
332
  const env = opts.env ?? process.env;
309
333
  const run = opts.run ?? defaultRun;
334
+ const tryRun = opts.tryRun ?? defaultTry;
335
+ const codeFixRun = opts.codeFix ?? startReviewFix;
310
336
  return http.createServer((req, res) => {
311
337
  opts.onRequest?.();
312
338
  handle(req, res).catch((error: Error) => json(res, 500, { ok: false, message: error.message }));
@@ -332,6 +358,16 @@ export function createReviewServer(opts: ReviewServerOptions): http.Server {
332
358
  res.writeHead(301, { location: `${url.pathname}/` });
333
359
  return res.end();
334
360
  }
361
+ if (rest === "check") {
362
+ res.writeHead(301, { location: `${url.pathname}/` });
363
+ return res.end();
364
+ }
365
+ if (rest.startsWith("check/")) return handleChecklist(req, res, rest.slice("check/".length), entry, featureAbs, full);
366
+ if (rest === "code") {
367
+ res.writeHead(301, { location: `${url.pathname}/` });
368
+ return res.end();
369
+ }
370
+ if (rest.startsWith("code/")) return handleCodeReview(req, res, rest.slice("code/".length), entry, featureAbs, full);
335
371
  if (rest === "") return sendFile(res, path.join(ASSETS, "app.html"));
336
372
  if (rest === "app.js" || rest === "app.css") return sendFile(res, path.join(ASSETS, rest));
337
373
  if (rest.startsWith("proto/cur/")) return sendFile(res, inside(prototypeDir, rest.slice("proto/cur/".length)), PROTOTYPE_CSP);
@@ -400,6 +436,129 @@ export function createReviewServer(opts: ReviewServerOptions): http.Server {
400
436
  }
401
437
  return send(res, 404, "没有这个页面");
402
438
  }
439
+
440
+ // 1.14: the code review page, /r/<token>/f/<key>/code/…
441
+ async function handleCodeReview(req: http.IncomingMessage, res: http.ServerResponse, rest: string, entry: { repo: string; featureDir: string }, featureAbs: string, full: boolean) {
442
+ const draftFile = path.join(featureAbs, ".pipeline", "code-review", "draft.json");
443
+ if (rest === "") return sendFile(res, path.join(ASSETS, "code.html"));
444
+ if (rest === "code.js" || rest === "code.css") return sendFile(res, path.join(ASSETS, rest));
445
+ if (rest === "api/info" && req.method === "GET") {
446
+ let draft: unknown = null;
447
+ try {
448
+ draft = JSON.parse(fs.readFileSync(draftFile, "utf8"));
449
+ } catch {
450
+ draft = null;
451
+ }
452
+ return json(res, 200, { ...buildCodeReview(entry.repo, entry.featureDir), readOnly: !full, draft });
453
+ }
454
+ if (rest === "api/draft" && req.method === "PUT") {
455
+ if (!full) return json(res, 403, { ok: false, message: "这是只读链接" });
456
+ const body = await readBody(req);
457
+ if (!body || typeof body !== "object") return json(res, 400, { ok: false, message: "格式不对" });
458
+ fs.mkdirSync(path.dirname(draftFile), { recursive: true });
459
+ fs.writeFileSync(draftFile, JSON.stringify({ ...(body as object), savedAt: new Date().toISOString() }, null, 2));
460
+ return json(res, 200, { ok: true });
461
+ }
462
+ if (rest === "api/submit" && req.method === "POST") {
463
+ if (!full) return json(res, 403, { ok: false, message: "这是只读链接,不能提交" });
464
+ const body = (await readBody(req)) as Partial<Submission> | null;
465
+ const verdict = body?.verdict === "approve" ? "approve" : body?.verdict === "changes" ? "changes" : null;
466
+ if (!verdict) return json(res, 400, { ok: false, message: "请选择评审通过或退回修改" });
467
+ const data = buildCodeReview(entry.repo, entry.featureDir);
468
+ if (data.running) return json(res, 409, { ok: false, message: "流水线正在运行,等它结束后再提交评审。" });
469
+ const sub: Submission = {
470
+ verdict,
471
+ reviewer: typeof body?.reviewer === "string" ? body.reviewer : "",
472
+ reviewers: Array.isArray(body?.reviewers) ? body.reviewers.filter((r): r is string => typeof r === "string") : [],
473
+ comments: (Array.isArray(body?.comments) ? body.comments : []).filter((c) => c && typeof c.text === "string").map((c) => ({ file: typeof c.file === "string" ? c.file : null, line: typeof c.line === "number" ? c.line : null, text: c.text })),
474
+ fix: (Array.isArray(body?.fix) ? body.fix : []).filter((f) => f && typeof f.id === "string").map((f) => ({ id: f.id, note: typeof f.note === "string" ? f.note : undefined })),
475
+ note: typeof body?.note === "string" ? body.note : "",
476
+ };
477
+ if (verdict === "changes" && sub.comments.length === 0 && sub.fix.length === 0 && !sub.note.trim()) {
478
+ return json(res, 400, { ok: false, message: "退回修改需要至少一条意见:在代码行上写意见,或把 AI 的意见标为\"要改\"。" });
479
+ }
480
+ const { review, committed } = recordHumanReview(entry.repo, entry.featureDir, sub);
481
+ if (verdict === "approve") {
482
+ return json(res, 200, { ok: true, verdict, committed, message: `已记录:评审通过${committed ? ",已写入 code-review.md 并提交" : ""}。${review.reviewers.length ? `审核人:${review.reviewers.join("、")}。` : ""}合并请求接入后会用这些审核人。` });
483
+ }
484
+ const started = codeFixRun(entry.repo, entry.featureDir);
485
+ return json(res, 200, {
486
+ ok: true,
487
+ verdict,
488
+ committed,
489
+ message: started.ok
490
+ ? `已记录 ${review.items.length} 条修改意见,流水线开始按意见修改(需求说明不变,从方案设计开始只处理这些意见),进展会发回需求的对话。`
491
+ : `已记录 ${review.items.length} 条修改意见,但没能开始修改:${started.message.split("\n")[0]}。可以在需求的对话里说"按代码评审意见修改",或执行 req review-fix ${path.basename(entry.featureDir)}。`,
492
+ });
493
+ }
494
+ return send(res, 404, "没有这个页面");
495
+ }
496
+
497
+ // 1.14: the acceptance checklist, /r/<token>/f/<key>/check/…
498
+ async function handleChecklist(req: http.IncomingMessage, res: http.ServerResponse, rest: string, entry: { repo: string; featureDir: string; key: string; project: string }, featureAbs: string, full: boolean) {
499
+ const checkDir = path.join(featureAbs, ".pipeline", "check");
500
+ if (rest === "") return sendFile(res, path.join(ASSETS, "check.html"));
501
+ if (rest === "check.js" || rest === "check.css") return sendFile(res, path.join(ASSETS, rest));
502
+ if (rest === "api/info" && req.method === "GET") {
503
+ const state = readState(statePath(entry.repo, entry.featureDir));
504
+ let draft: unknown = { answers: [], reviewer: "" };
505
+ try {
506
+ draft = JSON.parse(fs.readFileSync(path.join(checkDir, "draft.json"), "utf8"));
507
+ } catch {
508
+ /* none yet */
509
+ }
510
+ const acceptance = fs.existsSync(path.join(featureAbs, "acceptance.md")) ? fs.readFileSync(path.join(featureAbs, "acceptance.md"), "utf8") : "";
511
+ return json(res, 200, {
512
+ ...buildChecklist(featureAbs),
513
+ project: entry.project,
514
+ featureDir: entry.featureDir,
515
+ readOnly: !full,
516
+ running: state?.status === "running",
517
+ accepted: /^[-*]\s*验收结论[::]\s*(.+)$/m.exec(acceptance)?.[1]?.trim() ?? null,
518
+ draft,
519
+ });
520
+ }
521
+ if (rest === "api/draft" && req.method === "PUT") {
522
+ if (!full) return json(res, 403, { ok: false, message: "这是只读链接" });
523
+ const body = (await readBody(req)) as { answers?: unknown; reviewer?: unknown } | null;
524
+ if (!body || !Array.isArray(body.answers)) return json(res, 400, { ok: false, message: "格式不对" });
525
+ fs.mkdirSync(checkDir, { recursive: true });
526
+ fs.writeFileSync(path.join(checkDir, "draft.json"), JSON.stringify({ answers: body.answers, reviewer: typeof body.reviewer === "string" ? body.reviewer : "", savedAt: new Date().toISOString() }, null, 2));
527
+ return json(res, 200, { ok: true });
528
+ }
529
+ if (rest === "api/try" && req.method === "POST") {
530
+ if (!full) return json(res, 403, { ok: false, message: "这是只读链接" });
531
+ const body = (await readBody(req)) as { stop?: boolean } | null;
532
+ return json(res, 200, tryRun(entry.repo, entry.featureDir, body?.stop === true));
533
+ }
534
+ if (rest === "api/submit" && req.method === "POST") {
535
+ if (!full) return json(res, 403, { ok: false, message: "这是只读链接,不能提交" });
536
+ const body = (await readBody(req)) as { answers?: Answer[]; reviewer?: string } | null;
537
+ const answers = (Array.isArray(body?.answers) ? body.answers : []).filter((a) => a && typeof a.id === "string" && ["pass", "fail", "skip"].includes(a.mark));
538
+ const list = buildChecklist(featureAbs);
539
+ const v = verdictOf(list, answers);
540
+ if (v.verdict === "通过" && v.missing.length > 0) {
541
+ return json(res, 400, { ok: false, message: `还有 ${v.missing.length} 条必须验的没有核对(${v.missing.map((i) => i.id).join("、")})。核对完,或者有不对的标"不对"后再提交。` });
542
+ }
543
+ const file = path.join(featureAbs, "acceptance.md");
544
+ const previous = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined;
545
+ const reviewer = body?.reviewer?.trim() || "需求方(验收页)";
546
+ const text = acceptanceText(list, answers, { verdict: v.verdict, reviewer: reviewer.slice(0, 60), date: new Date().toISOString().slice(0, 10), previous });
547
+ const { committed } = recordAcceptance(entry.repo, entry.featureDir, text);
548
+ fs.rmSync(path.join(checkDir, "draft.json"), { force: true });
549
+ if (v.verdict === "通过") {
550
+ return json(res, 200, { ok: true, verdict: v.verdict, committed, message: `已记录:验收通过${committed ? ",验收记录已提交" : ""}。下一步请开发同事审阅代码并合并。` });
551
+ }
552
+ const told = run("reply", entry.repo, entry.featureDir, ["--acceptance", acceptanceFailMessage(entry.featureDir, list, answers)]);
553
+ return json(res, 200, {
554
+ ok: true,
555
+ verdict: v.verdict,
556
+ committed,
557
+ message: told.ok ? "已记录:验收不通过。不对的地方已经转给这个需求的对话,它会和你确定怎么处理。" : `已记录:验收不通过,但没能转给需求的对话(${told.message.split("\n")[0]})。请在需求的对话里说"验收不通过"。`,
558
+ });
559
+ }
560
+ return send(res, 404, "没有这个页面");
561
+ }
403
562
  }
404
563
 
405
564
  // ---------------------------------------------------------------------------
@@ -530,7 +689,7 @@ async function serve(env: NodeJS.ProcessEnv): Promise<void> {
530
689
  // ---------------------------------------------------------------------------
531
690
  // req review <需求> [--no-open] [--read-only]
532
691
 
533
- function openBrowser(url: string) {
692
+ export function openBrowser(url: string) {
534
693
  const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
535
694
  try {
536
695
  const child = spawn(cmd, args as string[], { stdio: "ignore", detached: true, windowsHide: true });
@@ -548,7 +707,23 @@ async function main(argv: string[]): Promise<number> {
548
707
  }
549
708
  const open = !argv.includes("--no-open");
550
709
  const readOnly = argv.includes("--read-only");
551
- const feature = argv.find((a) => !a.startsWith("--"));
710
+ // 1.14: --page code opens the code review page of the same server.
711
+ const pageAt = argv.indexOf("--page");
712
+ const page = pageAt >= 0 ? argv[pageAt + 1] : null;
713
+ const feature = argv.find((a, i) => !a.startsWith("--") && !(pageAt >= 0 && i === pageAt + 1));
714
+ if (page === "code" && feature && !argv.includes("--help")) {
715
+ const { locate } = await import("./api.js");
716
+ const { repo, featureDir } = locate(process.cwd(), feature);
717
+ const key = keyOf(repo, featureDir);
718
+ if (!key) {
719
+ console.error(`${featureDir} 不在本机的需求列表中,先运行一次 req index --rebuild`);
720
+ return 1;
721
+ }
722
+ const url = `${reviewUrl(await ensureReviewServer(), key, readOnly)}code/`;
723
+ console.log(`代码审查页:${url}`);
724
+ if (open) openBrowser(url);
725
+ return 0;
726
+ }
552
727
  if (!feature || argv.includes("--help") || argv.includes("-h")) {
553
728
  console.log("用法:req review <需求> [--no-open] [--read-only]\n\n在浏览器中打开这个需求的原型审查页:点选要改的地方、直接改文字,然后提交修改意见或确认方案。");
554
729
  return feature ? 0 : 2;