paseo-req-kit 1.9.0-beta.2 → 1.9.1
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/CHANGELOG.md +36 -0
- package/bin/req.mjs +8 -0
- package/contracts/cli-json.md +2 -1
- package/contracts/feature-files.md +10 -0
- package/contracts/state.md +1 -0
- package/docs/adr/0028-external-integrations.md +22 -0
- package/docs/adr/README.md +1 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +6 -5
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +32 -0
- package/engine/src/api.ts +2 -1
- package/engine/src/cli-args.ts +4 -1
- package/engine/src/config.ts +21 -1
- package/engine/src/control.ts +2 -0
- package/engine/src/integration-cmd.ts +161 -0
- package/engine/src/integrations.ts +400 -0
- package/engine/src/launch.ts +12 -0
- package/engine/src/pipeline.ts +31 -1
- package/engine/src/preflight.ts +2 -0
- package/engine/src/state.ts +3 -0
- package/package.json +1 -1
- package/paseo-plugin/client/detail.tsx +29 -3
- package/paseo-plugin/client/format.ts +16 -6
- package/paseo-plugin/client/markdown.tsx +219 -0
- package/paseo-plugin/package.json +1 -1
- package/paseo-plugin/shared/contracts.ts +3 -1
- package/skills/req/SKILL.md +1 -0
- package/skills/req/playbooks/new.md +3 -1
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// External system integrations (1.9, ADR 0028): the requirement as a card in the team's
|
|
2
|
+
// requirement management system, kept in step with the pipeline.
|
|
3
|
+
//
|
|
4
|
+
// An integration is a local command line tool (e.g. an ALM's CLI) described in the machine or
|
|
5
|
+
// team configuration: how to create a card, how to change its state, and which state each
|
|
6
|
+
// pipeline event maps to. The engine fills the arguments from the requirement and the card
|
|
7
|
+
// fields the requester confirmed with the spec (card.json, written by /req), runs the tool
|
|
8
|
+
// without a shell, reads the card's id and link from its output and keeps them in the state
|
|
9
|
+
// file. A call that fails is kept in order and sent again before the next one, or with
|
|
10
|
+
// `req sync`. An integration never stops the pipeline.
|
|
11
|
+
|
|
12
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import type { PipelineState } from "./state.js";
|
|
16
|
+
|
|
17
|
+
export const INTEGRATION_EVENTS = ["started", "approval", "blocked", "phase_done", "verify", "completed", "failed"] as const;
|
|
18
|
+
export type IntegrationEvent = (typeof INTEGRATION_EVENTS)[number];
|
|
19
|
+
|
|
20
|
+
export const INTEGRATION_EVENT_LABELS: Record<IntegrationEvent, string> = {
|
|
21
|
+
started: "开始开发",
|
|
22
|
+
approval: "等待确认方案",
|
|
23
|
+
blocked: "等待需求方决定",
|
|
24
|
+
phase_done: "研发完成,待测试",
|
|
25
|
+
verify: "开始测试验证",
|
|
26
|
+
completed: "完成",
|
|
27
|
+
failed: "中断",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** One command: its arguments, each may contain {placeholders}. */
|
|
31
|
+
export interface IntegrationAction {
|
|
32
|
+
args: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IntegrationConfig {
|
|
36
|
+
/** Shown to people, and the key under which the card is kept, e.g. "ALM". */
|
|
37
|
+
name: string;
|
|
38
|
+
/** The tool to run, e.g. "shenbing". Found on PATH; npm's .cmd shims on Windows are run through node. */
|
|
39
|
+
command: string;
|
|
40
|
+
/** Creates the card. `id` is a regular expression for the card id in its output (the first group, or a group named id); `url` likewise, optional. */
|
|
41
|
+
create?: IntegrationAction & { id: string; url?: string };
|
|
42
|
+
/** Changes the card's state; {status} is the mapped state. */
|
|
43
|
+
status?: IntegrationAction;
|
|
44
|
+
/** Card state for each pipeline event; "completed_with_issues" and "not_completed" refine "completed". */
|
|
45
|
+
statusMap?: Partial<Record<IntegrationEvent | "completed_with_issues" | "not_completed", string>>;
|
|
46
|
+
/** Fields the requester confirms with the spec (card.json): name → what to fill in, for /req. */
|
|
47
|
+
cardFields?: Record<string, string>;
|
|
48
|
+
/** Values for {field.<name>} placeholders; a project's integrationDefaults[name] adds to these. */
|
|
49
|
+
defaults?: Record<string, string>;
|
|
50
|
+
timeoutSeconds?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What is known about a requirement's card, in the state file's `external[name]`. */
|
|
54
|
+
export interface ExternalRecord {
|
|
55
|
+
id: string | null;
|
|
56
|
+
url: string | null;
|
|
57
|
+
/** Last state set on the card. */
|
|
58
|
+
status: string | null;
|
|
59
|
+
syncedAt: string | null;
|
|
60
|
+
/** Calls that failed, oldest first; sent again before the next call and by `req sync`. */
|
|
61
|
+
pending: Array<{ action: "create" | "status"; event: IntegrationEvent; status: string | null; at: string; attempts: number; error: string }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The card fields /req writes with the spec: card.json in the feature directory. */
|
|
65
|
+
export interface CardFile {
|
|
66
|
+
title?: string;
|
|
67
|
+
body?: string;
|
|
68
|
+
fields?: Record<string, string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateIntegrations(value: unknown): string | null {
|
|
72
|
+
if (!Array.isArray(value)) return "integrations 必须是数组";
|
|
73
|
+
const names = new Set<string>();
|
|
74
|
+
for (const [i, raw] of value.entries()) {
|
|
75
|
+
const at = `integrations[${i}]`;
|
|
76
|
+
if (!raw || typeof raw !== "object") return `${at} 必须是对象`;
|
|
77
|
+
const c = raw as Record<string, unknown>;
|
|
78
|
+
if (typeof c.name !== "string" || !c.name.trim()) return `${at}.name 必须是名称,例如 "ALM"`;
|
|
79
|
+
if (names.has(c.name)) return `${at}.name「${c.name}」重复了`;
|
|
80
|
+
names.add(c.name);
|
|
81
|
+
if (typeof c.command !== "string" || !c.command.trim()) return `${at}.command 必须是要运行的命令,例如 "shenbing"`;
|
|
82
|
+
for (const key of ["create", "status"] as const) {
|
|
83
|
+
const action = c[key] as Record<string, unknown> | undefined;
|
|
84
|
+
if (action === undefined) continue;
|
|
85
|
+
if (!action || !Array.isArray(action.args) || !action.args.every((a) => typeof a === "string")) return `${at}.${key}.args 必须是字符串数组`;
|
|
86
|
+
}
|
|
87
|
+
const create = c.create as Record<string, unknown> | undefined;
|
|
88
|
+
if (create) {
|
|
89
|
+
if (typeof create.id !== "string") return `${at}.create.id 必须是从输出中取卡片编号的正则表达式`;
|
|
90
|
+
for (const k of ["id", "url"] as const) {
|
|
91
|
+
if (create[k] === undefined) continue;
|
|
92
|
+
try {
|
|
93
|
+
new RegExp(String(create[k]));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return `${at}.create.${k} 不是有效的正则表达式:${(error as Error).message}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (c.statusMap !== undefined) {
|
|
100
|
+
if (!c.statusMap || typeof c.statusMap !== "object") return `${at}.statusMap 必须是对象`;
|
|
101
|
+
for (const [k, v] of Object.entries(c.statusMap)) {
|
|
102
|
+
if (![...INTEGRATION_EVENTS, "completed_with_issues", "not_completed"].includes(k)) return `${at}.statusMap 中的 ${k} 不是流水线事件(可用:${INTEGRATION_EVENTS.join("、")})`;
|
|
103
|
+
if (typeof v !== "string") return `${at}.statusMap.${k} 必须是字符串`;
|
|
104
|
+
}
|
|
105
|
+
if (!c.status) return `${at} 写了 statusMap,但没有 status(改状态的命令)`;
|
|
106
|
+
}
|
|
107
|
+
for (const key of ["cardFields", "defaults"] as const) {
|
|
108
|
+
const m = c[key];
|
|
109
|
+
if (m === undefined) continue;
|
|
110
|
+
if (!m || typeof m !== "object" || !Object.values(m).every((v) => typeof v === "string")) return `${at}.${key} 必须是"名称: 文字"的对象`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Running the tool
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* How to run `command` without a shell. On Windows an npm-installed tool is a .cmd shim that
|
|
121
|
+
* only cmd.exe can run, and cmd.exe would mangle arguments such as a Markdown body; the shim
|
|
122
|
+
* names the script it runs, so run that with node instead.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveCommand(command: string, env: NodeJS.ProcessEnv = process.env): { file: string; prefix: string[] } {
|
|
125
|
+
if (process.platform !== "win32" || /[\\/]/.test(command) && !/\.cmd$/i.test(command)) return { file: command, prefix: [] };
|
|
126
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
|
|
127
|
+
const candidates = /\.(cmd|exe|bat)$/i.test(command) ? [command] : [`${command}.exe`, `${command}.cmd`];
|
|
128
|
+
for (const dir of [...(/[\\/]/.test(command) ? [""] : dirs)]) {
|
|
129
|
+
for (const name of candidates) {
|
|
130
|
+
const full = dir ? path.join(dir, name) : name;
|
|
131
|
+
if (!fs.existsSync(full)) continue;
|
|
132
|
+
if (/\.exe$/i.test(full)) return { file: full, prefix: [] };
|
|
133
|
+
const text = fs.readFileSync(full, "utf8");
|
|
134
|
+
const script = /"%(?:~?dp0)%\\?([^"]+?\.(?:c|m)?js)"/i.exec(text)?.[1];
|
|
135
|
+
if (script) return { file: process.execPath, prefix: [path.join(path.dirname(full), script)] };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { file: command, prefix: [] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface RunResult {
|
|
142
|
+
ok: boolean;
|
|
143
|
+
output: string;
|
|
144
|
+
error: string | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type Runner = (command: string, args: string[], timeoutMs: number) => Promise<RunResult>;
|
|
148
|
+
|
|
149
|
+
export const runCommand: Runner = (command, args, timeoutMs) =>
|
|
150
|
+
new Promise((resolve) => {
|
|
151
|
+
const { file, prefix } = resolveCommand(command);
|
|
152
|
+
execFile(file, [...prefix, ...args], { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
|
|
153
|
+
const output = `${stdout ?? ""}${stderr ? `\n${stderr}` : ""}`.trim();
|
|
154
|
+
if (error) {
|
|
155
|
+
const why = (error as NodeJS.ErrnoException).code === "ENOENT" ? `找不到命令 ${command}` : error.killed ? `超过 ${timeoutMs / 1000} 秒没有完成` : (stderr || error.message).trim().split("\n").slice(-2).join(" ");
|
|
156
|
+
resolve({ ok: false, output, error: why });
|
|
157
|
+
} else resolve({ ok: true, output, error: null });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Arguments
|
|
163
|
+
|
|
164
|
+
export interface IntegrationContext {
|
|
165
|
+
repo: string;
|
|
166
|
+
featureDir: string;
|
|
167
|
+
state: Pick<PipelineState, "branch" | "profile" | "type" | "outcome" | "external">;
|
|
168
|
+
/** Project values for {field.*}, from the project's integrationDefaults. */
|
|
169
|
+
projectDefaults?: Record<string, string>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readFile(file: string): string | null {
|
|
173
|
+
try {
|
|
174
|
+
return fs.readFileSync(file, "utf8");
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function readCard(repo: string, featureDir: string): CardFile {
|
|
181
|
+
const raw = readFile(path.join(repo, featureDir, "card.json"));
|
|
182
|
+
if (!raw) return {};
|
|
183
|
+
try {
|
|
184
|
+
const parsed = JSON.parse(raw) as CardFile;
|
|
185
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
186
|
+
} catch {
|
|
187
|
+
return {};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The values arguments can name: {title}, {body}, {card.<field>}, {field.<name>}, {id}, {status}… */
|
|
192
|
+
export function placeholderValues(config: IntegrationConfig, ctx: IntegrationContext, extra: { status?: string | null; event?: IntegrationEvent } = {}): Record<string, string> {
|
|
193
|
+
const spec = readFile(path.join(ctx.repo, ctx.featureDir, "spec.md")) ?? "";
|
|
194
|
+
const card = readCard(ctx.repo, ctx.featureDir);
|
|
195
|
+
const record = ctx.state.external?.[config.name];
|
|
196
|
+
const values: Record<string, string> = {
|
|
197
|
+
title: card.title ?? /^#\s+(.+)$/m.exec(spec)?.[1]?.trim() ?? path.basename(ctx.featureDir),
|
|
198
|
+
body: card.body ?? spec,
|
|
199
|
+
feature: path.basename(ctx.featureDir),
|
|
200
|
+
featureDir: ctx.featureDir,
|
|
201
|
+
repo: ctx.repo,
|
|
202
|
+
branch: ctx.state.branch ?? "",
|
|
203
|
+
type: ctx.state.type ?? "",
|
|
204
|
+
profile: ctx.state.profile,
|
|
205
|
+
outcome: ctx.state.outcome ?? "",
|
|
206
|
+
id: record?.id ?? "",
|
|
207
|
+
url: record?.url ?? "",
|
|
208
|
+
status: extra.status ?? "",
|
|
209
|
+
event: extra.event ?? "",
|
|
210
|
+
};
|
|
211
|
+
for (const [k, v] of Object.entries({ ...(config.defaults ?? {}), ...(ctx.projectDefaults ?? {}) })) values[`field.${k}`] = v;
|
|
212
|
+
for (const [k, v] of Object.entries(card.fields ?? {})) values[`card.${k}`] = String(v);
|
|
213
|
+
// A card field the requester left out falls back to the configured default of the same name.
|
|
214
|
+
for (const k of Object.keys(config.cardFields ?? {})) if (values[`card.${k}`] === undefined) values[`card.${k}`] = values[`field.${k}`] ?? "";
|
|
215
|
+
return values;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The arguments with placeholders filled. An argument that is only a placeholder and comes out
|
|
220
|
+
* empty is dropped together with the option before it ("--priority {card.priority}" → nothing),
|
|
221
|
+
* so optional fields need no special syntax. Unknown placeholders are an error.
|
|
222
|
+
*/
|
|
223
|
+
export function renderArgs(args: string[], values: Record<string, string>): string[] {
|
|
224
|
+
const out: string[] = [];
|
|
225
|
+
for (const arg of args) {
|
|
226
|
+
const only = /^\{([\w.-]+)\}$/.exec(arg);
|
|
227
|
+
const rendered = arg.replace(/\{([\w.-]+)\}/g, (_, key: string) => {
|
|
228
|
+
if (!(key in values)) throw new Error(`参数 ${arg} 中的 {${key}} 没有对应的值`);
|
|
229
|
+
return values[key];
|
|
230
|
+
});
|
|
231
|
+
if (only && rendered === "") {
|
|
232
|
+
if (out.length > 0 && out.at(-1)!.startsWith("-")) out.pop();
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
out.push(rendered);
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function capture(pattern: string | undefined, output: string): string | null {
|
|
241
|
+
if (!pattern) return null;
|
|
242
|
+
const m = new RegExp(pattern).exec(output);
|
|
243
|
+
if (!m) return null;
|
|
244
|
+
return (m.groups?.id ?? m.groups?.url ?? m[1] ?? m[0]).trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The state an event sets, if any. */
|
|
248
|
+
export function statusFor(config: IntegrationConfig, event: IntegrationEvent, outcome: string | null): string | null {
|
|
249
|
+
const map = config.statusMap ?? {};
|
|
250
|
+
if (event === "completed" && (outcome === "completed_with_issues" || outcome === "not_completed") && map[outcome]) return map[outcome] ?? null;
|
|
251
|
+
return map[event] ?? null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Syncing
|
|
256
|
+
|
|
257
|
+
function emptyRecord(): ExternalRecord {
|
|
258
|
+
return { id: null, url: null, status: null, syncedAt: null, pending: [] };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Brings one integration up to date for an event: sends what failed before (in order), creates
|
|
263
|
+
* the card if there is none yet, then sets the event's state. Updates `state.external` in place;
|
|
264
|
+
* the caller saves the state. Returns lines for the log. Never throws.
|
|
265
|
+
*/
|
|
266
|
+
export async function syncIntegration(input: {
|
|
267
|
+
config: IntegrationConfig;
|
|
268
|
+
ctx: IntegrationContext & { state: PipelineState };
|
|
269
|
+
event: IntegrationEvent | null;
|
|
270
|
+
run?: Runner;
|
|
271
|
+
log: (message: string) => void;
|
|
272
|
+
}): Promise<ExternalRecord> {
|
|
273
|
+
const { config, ctx, log } = input;
|
|
274
|
+
const run = input.run ?? runCommand;
|
|
275
|
+
const timeout = (config.timeoutSeconds ?? 60) * 1000;
|
|
276
|
+
ctx.state.external ??= {};
|
|
277
|
+
const record = (ctx.state.external[config.name] ??= emptyRecord());
|
|
278
|
+
const now = () => new Date().toISOString();
|
|
279
|
+
|
|
280
|
+
const attempt = async (action: "create" | "status", event: IntegrationEvent, status: string | null): Promise<boolean> => {
|
|
281
|
+
const def = action === "create" ? config.create : config.status;
|
|
282
|
+
if (!def) return true;
|
|
283
|
+
let args: string[];
|
|
284
|
+
try {
|
|
285
|
+
args = renderArgs(def.args, placeholderValues(config, ctx, { status, event }));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
log(`${config.name}:${(error as Error).message},没有调用`);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const result = await run(config.command, args, timeout);
|
|
291
|
+
if (!result.ok) {
|
|
292
|
+
log(`${config.name}:${action === "create" ? "建卡" : `改为「${status}」`}失败:${result.error}`);
|
|
293
|
+
const prev = record.pending.find((p) => p.action === action && p.status === status);
|
|
294
|
+
if (prev) {
|
|
295
|
+
prev.attempts += 1;
|
|
296
|
+
prev.error = result.error ?? "";
|
|
297
|
+
} else {
|
|
298
|
+
if (action === "status") record.pending = record.pending.filter((p) => p.action !== "status");
|
|
299
|
+
record.pending.push({ action, event, status, at: now(), attempts: 1, error: result.error ?? "" });
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
if (action === "create") {
|
|
304
|
+
const id = capture(config.create!.id, result.output);
|
|
305
|
+
if (!id) {
|
|
306
|
+
log(`${config.name}:建卡命令成功了,但输出中没有找到卡片编号(create.id 的正则没有匹配)。输出:${result.output.slice(0, 300)}`);
|
|
307
|
+
record.pending.push({ action, event, status, at: now(), attempts: 1, error: "输出中没有卡片编号" });
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
record.id = id;
|
|
311
|
+
record.url = capture(config.create!.url, result.output);
|
|
312
|
+
log(`${config.name}:已建卡 ${id}${record.url ? `(${record.url})` : ""}`);
|
|
313
|
+
} else {
|
|
314
|
+
record.status = status;
|
|
315
|
+
log(`${config.name}:${record.id} 已改为「${status}」`);
|
|
316
|
+
}
|
|
317
|
+
record.syncedAt = now();
|
|
318
|
+
record.pending = record.pending.filter((p) => !(p.action === action && (action === "create" || p.status === status)));
|
|
319
|
+
return true;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
// A new state replaces one still waiting to be sent: only the latest matters.
|
|
324
|
+
const newStatus = input.event ? statusFor(config, input.event, ctx.state.outcome ?? null) : null;
|
|
325
|
+
if (newStatus) record.pending = record.pending.filter((p) => p.action !== "status");
|
|
326
|
+
// 1. What failed before, oldest first; stop at the first that still fails, keeping the order.
|
|
327
|
+
let blocked = false;
|
|
328
|
+
for (const p of [...record.pending]) {
|
|
329
|
+
if (p.action === "status" && !record.id) {
|
|
330
|
+
blocked = true;
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
if ((p.action === "status" && p.status === record.status) || (p.action === "create" && record.id)) {
|
|
334
|
+
record.pending = record.pending.filter((x) => x !== p);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const index = record.pending.indexOf(p);
|
|
338
|
+
record.pending.splice(index, 1);
|
|
339
|
+
if (!(await attempt(p.action, p.event, p.status))) {
|
|
340
|
+
// attempt() appended it again; put it back where it was so the order holds.
|
|
341
|
+
const again = record.pending.pop();
|
|
342
|
+
if (again) record.pending.splice(index, 0, { ...again, attempts: p.attempts + 1, at: p.at });
|
|
343
|
+
blocked = true;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (!input.event) return record;
|
|
348
|
+
// 2. A card for this requirement.
|
|
349
|
+
if (!record.id && config.create && !blocked) {
|
|
350
|
+
if (!(await attempt("create", input.event, null))) blocked = true;
|
|
351
|
+
}
|
|
352
|
+
// 3. The event's state: now, or queued behind what is still waiting.
|
|
353
|
+
const status = newStatus;
|
|
354
|
+
if (status && status !== record.status && !record.pending.some((p) => p.action === "status" && p.status === status)) {
|
|
355
|
+
// Only the latest state matters: a state still waiting is replaced, not sent after this one.
|
|
356
|
+
if (blocked || !record.id) {
|
|
357
|
+
record.pending = record.pending.filter((p) => p.action !== "status");
|
|
358
|
+
record.pending.push({ action: "status", event: input.event, status, at: now(), attempts: 0, error: "等待前面的调用完成" });
|
|
359
|
+
}
|
|
360
|
+
else await attempt("status", input.event, status);
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
log(`${config.name}:${(error as Error).message}`);
|
|
364
|
+
}
|
|
365
|
+
return record;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Plain-language summary lines for status and reports. */
|
|
369
|
+
export function describeExternal(external: PipelineState["external"]): string[] {
|
|
370
|
+
const lines: string[] = [];
|
|
371
|
+
for (const [name, r] of Object.entries(external ?? {})) {
|
|
372
|
+
const head = r.id ? `${name}:${r.id}${r.url ? `(${r.url})` : ""}${r.status ? `,状态「${r.status}」` : ""}` : `${name}:还没有建卡`;
|
|
373
|
+
lines.push(r.pending.length > 0 ? `${head};有 ${r.pending.length} 次调用没有成功,下次自动补发(或 req sync):${r.pending.at(-1)!.error}` : head);
|
|
374
|
+
}
|
|
375
|
+
return lines;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** For `--card-fields`: what /req should ask for and write to card.json, or null without integrations. */
|
|
379
|
+
export function cardFieldsText(integrations: IntegrationConfig[]): string | null {
|
|
380
|
+
const withFields = integrations.filter((i) => i.cardFields && Object.keys(i.cardFields).length > 0);
|
|
381
|
+
if (withFields.length === 0) return null;
|
|
382
|
+
const lines = [
|
|
383
|
+
"需求说明确认时,一并和需求方确认下面的卡片字段,写入功能目录的 card.json(和 spec.md 一起提交):",
|
|
384
|
+
'{ "title": "<卡片标题,默认用需求说明的标题>", "fields": { "<字段>": "<值>", … } }',
|
|
385
|
+
"",
|
|
386
|
+
];
|
|
387
|
+
for (const i of withFields) {
|
|
388
|
+
lines.push(`${i.name}:`);
|
|
389
|
+
for (const [k, v] of Object.entries(i.cardFields!)) lines.push(`- ${k}:${v}${i.defaults?.[k] ? `(默认 ${i.defaults[k]})` : ""}`);
|
|
390
|
+
}
|
|
391
|
+
lines.push("", "能从需求说明中判断的直接填上请需求方确认;判断不了的问需求方;不知道的可以不写(用默认值或不填)。");
|
|
392
|
+
return lines.join("\n");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Checks that the tool can be found, for the doctor. */
|
|
396
|
+
export function commandAvailable(command: string): boolean {
|
|
397
|
+
const { file, prefix } = resolveCommand(command);
|
|
398
|
+
const r = spawnSync(file, [...prefix, "--help"], { encoding: "utf8", timeout: 20_000, windowsHide: true });
|
|
399
|
+
return !r.error;
|
|
400
|
+
}
|
package/engine/src/launch.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { scheduledStartPrompt } from "./messages.js";
|
|
|
13
13
|
import { describeTime, parseStartTime } from "./schedule-time.js";
|
|
14
14
|
import { moveToOwnWorktree, type OwnWorktree } from "./worktree.js";
|
|
15
15
|
import { loadConfig, PHASE_MODE_LABELS, type PipelineConfig, PROFILE_LABELS } from "./config.js";
|
|
16
|
+
import { cardFieldsText } from "./integrations.js";
|
|
16
17
|
import { defaultConfigPath, ENGINE_DIR } from "./locations.js";
|
|
17
18
|
import { describeLiveActivity, describeStatus, stopPipeline } from "./control.js";
|
|
18
19
|
import { describeFeatures, featuresWithRuns, findWorktreeRun, listFeatures, resolveFeatureArg } from "./features.js";
|
|
@@ -79,6 +80,17 @@ try {
|
|
|
79
80
|
}
|
|
80
81
|
const configPath = args.configPath ?? defaultConfigPath();
|
|
81
82
|
|
|
83
|
+
if (args.cardFields) {
|
|
84
|
+
try {
|
|
85
|
+
const text = cardFieldsText(loadConfig(configPath, fs.existsSync(args.repo) ? args.repo : undefined).config.integrations);
|
|
86
|
+
console.log(text ?? "没有配置外部系统对接(integrations),不需要 card.json,跳过这一步。");
|
|
87
|
+
process.exit(0);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error((error as Error).message);
|
|
90
|
+
process.exit(2);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
82
94
|
if (args.list) {
|
|
83
95
|
console.log(describeFeatures(listFeatures(args.repo), args.repo));
|
|
84
96
|
process.exit(0);
|
package/engine/src/pipeline.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { kitVersion } from "./locations.js";
|
|
34
34
|
import { type AgentUsage, describeTotals, totals } from "./usage.js";
|
|
35
35
|
import { dispatchNotifiers, type NotifierEventType } from "./notifiers.js";
|
|
36
|
+
import { describeExternal, type IntegrationEvent, type Runner, syncIntegration } from "./integrations.js";
|
|
36
37
|
import { principlesSection, projectAdditions, type PromptLocations, readToolPrompt } from "./prompts.js";
|
|
37
38
|
import type { AgentRuntime, ManagedService, ServiceTarget, StepAgent } from "./runtime.js";
|
|
38
39
|
import { type CheckOutcome, type GateRun, type JointRepo, type PhaseName, type PipelineState, type RunPhases, type StepRecord, writeState } from "./state.js";
|
|
@@ -56,6 +57,8 @@ export interface PipelineContext {
|
|
|
56
57
|
approveCommand?: string;
|
|
57
58
|
log: (message: string) => void;
|
|
58
59
|
sleep: (ms: number) => Promise<void>;
|
|
60
|
+
/** Runs integration commands; tests replace it. Default: the real command line. */
|
|
61
|
+
integrationRunner?: Runner;
|
|
59
62
|
/** Paseo-managed services of this run (filled in by the pipeline). */
|
|
60
63
|
services?: { names: string[] | null | undefined; started: Set<string> };
|
|
61
64
|
/** Paseo tools the step agents get; looked up once (undefined: not yet). */
|
|
@@ -384,6 +387,21 @@ async function notifyExternal(ctx: PipelineContext, type: NotifierEventType, tex
|
|
|
384
387
|
);
|
|
385
388
|
}
|
|
386
389
|
|
|
390
|
+
/** Keeps the requirement's card in each configured external system in step (1.9). Never throws. */
|
|
391
|
+
async function integrate(ctx: PipelineContext, event: IntegrationEvent): Promise<void> {
|
|
392
|
+
if (ctx.config.integrations.length === 0) return;
|
|
393
|
+
for (const config of ctx.config.integrations) {
|
|
394
|
+
await syncIntegration({
|
|
395
|
+
config,
|
|
396
|
+
ctx: { repo: ctx.repo, featureDir: ctx.featureDir, state: ctx.state, projectDefaults: ctx.config.integrationDefaults[config.name] },
|
|
397
|
+
event,
|
|
398
|
+
run: ctx.integrationRunner,
|
|
399
|
+
log: ctx.log,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
save(ctx);
|
|
403
|
+
}
|
|
404
|
+
|
|
387
405
|
/** Sends a progress note once per run attempt, when enabled. */
|
|
388
406
|
async function milestone(ctx: PipelineContext, id: string, text: string): Promise<void> {
|
|
389
407
|
if (!ctx.config.notifyMilestones || ctx.state.milestones.includes(id)) return;
|
|
@@ -1648,8 +1666,13 @@ async function setUpJoint(ctx: PipelineContext): Promise<void> {
|
|
|
1648
1666
|
}
|
|
1649
1667
|
|
|
1650
1668
|
/** What the report is told about this attempt: usage, the quality gate, the scope check. */
|
|
1669
|
+
/** The requirement's cards in external systems, for the report's appendix. */
|
|
1670
|
+
function externalLines(ctx: PipelineContext): string[] {
|
|
1671
|
+
return describeExternal(ctx.state.external).map((l) => `外部系统 ${l}`);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1651
1674
|
function reportLines(ctx: PipelineContext): string[] {
|
|
1652
|
-
const lines = [...usageLines(ctx), ...gateLines(ctx), ...scopeLines(ctx), ...acceptanceLines(ctx), ...jointLines(ctx)];
|
|
1675
|
+
const lines = [...usageLines(ctx), ...gateLines(ctx), ...scopeLines(ctx), ...acceptanceLines(ctx), ...jointLines(ctx), ...externalLines(ctx)];
|
|
1653
1676
|
if (ctx.config.checks.length === 0 && ctx.state.type !== "assessment") {
|
|
1654
1677
|
lines.push("质量门:项目没有配置检查(checks),流水线没有自己运行检查,以各步骤记录的检查结果为准");
|
|
1655
1678
|
}
|
|
@@ -1693,6 +1716,7 @@ async function flow(ctx: PipelineContext): Promise<Record<string, unknown>> {
|
|
|
1693
1716
|
}
|
|
1694
1717
|
ctx.state.phase = "verify";
|
|
1695
1718
|
save(ctx);
|
|
1719
|
+
await integrate(ctx, "verify");
|
|
1696
1720
|
}
|
|
1697
1721
|
if (!(await verifyPhase(ctx, design))) break;
|
|
1698
1722
|
await handBack(ctx);
|
|
@@ -1705,6 +1729,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1705
1729
|
ctx.state.status = "running";
|
|
1706
1730
|
ctx.state.stopReason = null;
|
|
1707
1731
|
save(ctx);
|
|
1732
|
+
await integrate(ctx, phaseOf(ctx) === "verify" ? "verify" : "started");
|
|
1708
1733
|
try {
|
|
1709
1734
|
await checkQuota(ctx);
|
|
1710
1735
|
const report = await flow(ctx);
|
|
@@ -1730,6 +1755,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1730
1755
|
const text = developOnly
|
|
1731
1756
|
? `研发已完成(只做研发):${outcomeText[report.outcome as string] ?? report.outcome},请按提测说明人工测试。`
|
|
1732
1757
|
: `开发流水线已结束:${outcomeText[report.outcome as string] ?? report.outcome}。`;
|
|
1758
|
+
await integrate(ctx, "completed");
|
|
1733
1759
|
await notifyExternal(ctx, "completed", text, {
|
|
1734
1760
|
outcome: report.outcome,
|
|
1735
1761
|
summary: report.summary,
|
|
@@ -1757,6 +1783,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1757
1783
|
handbacks: ctx.state.handbacks ?? 0,
|
|
1758
1784
|
}),
|
|
1759
1785
|
);
|
|
1786
|
+
await integrate(ctx, "phase_done");
|
|
1760
1787
|
await notifyExternal(ctx, "phase_done", `研发已完成,可以提测:提测说明在 ${ctx.featureDir}/handoff.md。${verifyAt ? `已预约在 ${verifyAt} 开始测试验证。` : ""}`, {
|
|
1761
1788
|
handoffPath: `${ctx.featureDir}/handoff.md`,
|
|
1762
1789
|
mode: runPhases(ctx.state).mode,
|
|
@@ -1785,6 +1812,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1785
1812
|
autoContinueAt,
|
|
1786
1813
|
}),
|
|
1787
1814
|
);
|
|
1815
|
+
await integrate(ctx, "approval");
|
|
1788
1816
|
await notifyExternal(ctx, "approval", `方案已通过审核,请需求方确认"做完后会看到什么"再开始写代码:${ctx.featureDir}/preview.md`, {
|
|
1789
1817
|
previewPath: `${ctx.featureDir}/preview.md`,
|
|
1790
1818
|
approveCommand: approveCommandOf(ctx),
|
|
@@ -1805,6 +1833,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1805
1833
|
const external = reproduce
|
|
1806
1834
|
? `没能复现这个缺陷,需要需求方补充 ${error.questions.length} 项信息,流水线已暂停,请到发起的对话中回答。`
|
|
1807
1835
|
: `方案审核发现 ${error.questions.length} 个需要需求方决定的问题,流水线已暂停,请到发起的对话中回答。`;
|
|
1836
|
+
await integrate(ctx, "blocked");
|
|
1808
1837
|
await notifyExternal(ctx, "blocked", external, {
|
|
1809
1838
|
questions: error.questions,
|
|
1810
1839
|
resumeCommand: ctx.resumeCommand,
|
|
@@ -1827,6 +1856,7 @@ export async function runPipeline(ctx: PipelineContext): Promise<PipelineOutcome
|
|
|
1827
1856
|
autoResumeAt,
|
|
1828
1857
|
}),
|
|
1829
1858
|
);
|
|
1859
|
+
await integrate(ctx, "failed");
|
|
1830
1860
|
await notifyExternal(ctx, "failed", `开发流水线在「${stepTitle}」步骤中断:${(error as Error).message}`, {
|
|
1831
1861
|
stepTitle,
|
|
1832
1862
|
reason: (error as Error).message,
|
package/engine/src/preflight.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface LaunchArgs {
|
|
|
29
29
|
stop: boolean;
|
|
30
30
|
/** --list: print every requirement in this repository's worktrees. */
|
|
31
31
|
list: boolean;
|
|
32
|
+
/** --card-fields: what /req confirms with the spec for the external systems (card.json). */
|
|
33
|
+
cardFields?: boolean;
|
|
32
34
|
/** --spec-review: independent review of the draft spec, before confirmation. */
|
|
33
35
|
specReview: boolean;
|
|
34
36
|
/** --worktree: create a Paseo worktree workspace for this requirement and develop there. */
|
package/engine/src/state.ts
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import type { DefectHandling, PhaseMode, ProfileName, RequirementType, VerifyEnvironment } from "./config.js";
|
|
6
|
+
import type { ExternalRecord } from "./integrations.js";
|
|
6
7
|
import { registerRequirement } from "./records.js";
|
|
7
8
|
import type { AgentUsage } from "./usage.js";
|
|
8
9
|
import type { StepId } from "./steps.js";
|
|
@@ -106,6 +107,8 @@ export interface PipelineState {
|
|
|
106
107
|
approval?: { attempt: number; status: "waiting" | "approved"; at: string; auto?: boolean } | null;
|
|
107
108
|
/** Quality gate: the checks' results before the change, and every run after a step (1.8). */
|
|
108
109
|
gate?: { baseline: GateBaseline | null; runs: GateRun[] } | null;
|
|
110
|
+
/** External systems (1.9): the requirement's card in each, by integration name. */
|
|
111
|
+
external?: Record<string, ExternalRecord> | null;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export interface JointRepo {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-req-kit",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "req-kit: a requirement-development workflow for Paseo. /req aligns a requirement in chat; the engine runs design, implementation, review and verification as Paseo agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
// management actions (each one is `req <command>` on the daemon machine, after confirming).
|
|
3
3
|
import type { PluginHostProps, PluginScreenProps, PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
4
4
|
import { Modal, ScrollView, useToast } from "@getpaseo/plugin/client/react-native";
|
|
5
|
+
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
|
5
6
|
import { useState } from "react";
|
|
6
7
|
import { Pressable, Text, View } from "react-native";
|
|
7
8
|
import type { Action, RequirementDetail, Step } from "../shared/contracts";
|
|
8
9
|
import { useAction, useDocument, usePreferences, useRequirement, useRequirements } from "./data";
|
|
9
10
|
import { ACTION_TEXT, actionsFor, ago, ATTENTION_LABELS, facts, FILE_LABELS, minutes, tokens } from "./format";
|
|
11
|
+
import { Markdown, tsvToMarkdown } from "./markdown";
|
|
10
12
|
import { Badge, Button, Chip, Status, useStyles } from "./ui";
|
|
11
13
|
|
|
12
14
|
type Theme = PluginHostProps["theme"];
|
|
@@ -64,9 +66,7 @@ function DocumentModal({
|
|
|
64
66
|
<Status theme={theme} loading={doc.isPending} error={doc.error} />
|
|
65
67
|
{doc.data ? (
|
|
66
68
|
<>
|
|
67
|
-
<
|
|
68
|
-
{doc.data.content}
|
|
69
|
-
</Text>
|
|
69
|
+
<Markdown theme={theme} source={doc.data.file.endsWith(".tsv") ? tsvToMarkdown(doc.data.content) : doc.data.content} />
|
|
70
70
|
{doc.data.truncated ? <Text style={s.muted}>文件较大,只显示了开头的 512 KB。</Text> : null}
|
|
71
71
|
</>
|
|
72
72
|
) : null}
|
|
@@ -221,6 +221,32 @@ export function RequirementView({
|
|
|
221
221
|
))}
|
|
222
222
|
</View>
|
|
223
223
|
|
|
224
|
+
{(d.external ?? []).length > 0 ? (
|
|
225
|
+
<View style={s.card}>
|
|
226
|
+
<Text style={s.heading}>外部系统</Text>
|
|
227
|
+
{(d.external ?? []).map((e) => (
|
|
228
|
+
<View key={e.name} style={{ gap: 2 }}>
|
|
229
|
+
<View style={s.row}>
|
|
230
|
+
<Text style={s.text}>
|
|
231
|
+
{e.name}:{e.id ?? "还没有建卡"}
|
|
232
|
+
</Text>
|
|
233
|
+
{e.status ? (
|
|
234
|
+
<Badge theme={theme} tone="muted">
|
|
235
|
+
{e.status}
|
|
236
|
+
</Badge>
|
|
237
|
+
) : null}
|
|
238
|
+
</View>
|
|
239
|
+
{e.url ? <ExternalLink href={e.url}>打开卡片</ExternalLink> : null}
|
|
240
|
+
{e.pending > 0 ? (
|
|
241
|
+
<Text style={s.danger}>
|
|
242
|
+
有 {e.pending} 次调用没有成功:{e.lastError}
|
|
243
|
+
</Text>
|
|
244
|
+
) : null}
|
|
245
|
+
</View>
|
|
246
|
+
))}
|
|
247
|
+
</View>
|
|
248
|
+
) : null}
|
|
249
|
+
|
|
224
250
|
{d.usage ? (
|
|
225
251
|
<View style={s.card}>
|
|
226
252
|
<Text style={s.heading}>用时与用量</Text>
|
|
@@ -85,12 +85,21 @@ export function facts(r: Requirement): string {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/** What a person can do from here, by the requirement's state. */
|
|
88
|
-
export function actionsFor(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
export function actionsFor(
|
|
89
|
+
r: Pick<Requirement, "attention" | "running" | "status"> & { external?: Array<{ pending: number; id: string | null }> },
|
|
90
|
+
): Array<"approve" | "stop" | "resume" | "verify" | "sync"> {
|
|
91
|
+
const base: Array<"approve" | "stop" | "resume" | "verify" | "sync"> = r.running
|
|
92
|
+
? ["stop"]
|
|
93
|
+
: r.attention === "approval"
|
|
94
|
+
? ["approve"]
|
|
95
|
+
: r.attention === "interrupted" || r.attention === "business"
|
|
96
|
+
? ["resume"]
|
|
97
|
+
: r.status === "phase_done"
|
|
98
|
+
? ["verify"]
|
|
99
|
+
: [];
|
|
100
|
+
// A running pipeline sends what failed by itself at its next step change.
|
|
101
|
+
if (!r.running && r.status && (r.external ?? []).some((e) => e.pending > 0 || !e.id)) base.push("sync");
|
|
102
|
+
return base;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
export const ACTION_TEXT = {
|
|
@@ -98,4 +107,5 @@ export const ACTION_TEXT = {
|
|
|
98
107
|
stop: { label: "停止", confirm: "停止这个需求的流水线?正在运行的步骤会被中断,之后可以继续。" },
|
|
99
108
|
resume: { label: "继续", confirm: "从中断处继续?需求说明改过的话,只处理变化的部分。" },
|
|
100
109
|
verify: { label: "开始测试验证", confirm: "开始测试验证?选择在哪里验证。" },
|
|
110
|
+
sync: { label: "同步到外部系统", confirm: "把这个需求同步到对接的外部系统:补发没有成功的调用、补建卡片、更新状态。" },
|
|
101
111
|
} as const;
|