@k0t0vich/meta-agents-template 0.1.9 → 0.1.11
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 +19 -0
- package/README.md +10 -7
- package/package.json +2 -1
- package/src/init.mjs +8 -2
- package/template/.meta-agents/config/system.yaml +3 -0
- package/template/.meta-agents/scripts/sync-status.mjs +14 -8
- package/template/.meta-agents/scripts/task-branch-router.mjs +41 -8
- package/template/.meta-agents/scripts/tracker/github.mjs +690 -4
- package/template/.meta-agents/scripts/tracker-gateway.mjs +54 -13
- package/template/.meta-agents/scripts/verify-implementation-gate.mjs +373 -0
- package/template/README.md +2 -0
- package/template/agents.md +24 -21
- package/template/package.json +1 -0
- package/template/tracker-command-template.md +35 -16
|
@@ -1,4 +1,690 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
const STATUS_LABELS = ["TODO", "IN_PROGRESS", "REVIEW", "READY", "BLOCKED", "DONE", "PUBLISH"];
|
|
5
|
+
const REQUIRED_PRD_HEADINGS = ["### Описание", "### Проверяемость", "### Что сделано"];
|
|
6
|
+
const STATUS_COLORS = {
|
|
7
|
+
IN_PROGRESS: "FBCA04",
|
|
8
|
+
REVIEW: "5319E7",
|
|
9
|
+
READY: "0E8A16",
|
|
10
|
+
BLOCKED: "B60205",
|
|
11
|
+
DONE: "1D76DB",
|
|
12
|
+
PUBLISH: "0052CC",
|
|
13
|
+
};
|
|
14
|
+
STATUS_COLORS.TODO = "D4C5F9";
|
|
15
|
+
const FALLBACK_COLORS = {
|
|
16
|
+
type: "C2E0C6",
|
|
17
|
+
owner: "0E8A16",
|
|
18
|
+
tracker: "0052CC",
|
|
19
|
+
sprint: "F9D0C4",
|
|
20
|
+
default: "BFDADC",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function run(command, args, allowFailure = false) {
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync(command, args, {
|
|
26
|
+
cwd: process.cwd(),
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
29
|
+
}).trim();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (allowFailure) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
const stderr = String(error?.stderr || "").trim();
|
|
35
|
+
const stdout = String(error?.stdout || "").trim();
|
|
36
|
+
throw new Error(`${command} ${args.join(" ")} failed: ${stderr || stdout || error.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readJson(command, args, allowFailure = false) {
|
|
41
|
+
const raw = run(command, args, allowFailure);
|
|
42
|
+
if (!raw) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(`failed to parse JSON from '${command} ${args.join(" ")}': ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureGhReady() {
|
|
53
|
+
run("gh", ["--version"], false);
|
|
54
|
+
const auth = run("gh", ["auth", "status"], true);
|
|
55
|
+
if (!auth) {
|
|
56
|
+
throw new Error("gh is not authenticated. Run 'gh auth login' first.");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toRepoArgs(repo) {
|
|
61
|
+
if (!repo) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return ["--repo", String(repo)];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toNonEmptyString(value) {
|
|
68
|
+
if (value === null || value === undefined) {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
return String(value).trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickFirstNonEmpty(...values) {
|
|
75
|
+
for (const value of values) {
|
|
76
|
+
const normalized = toNonEmptyString(value);
|
|
77
|
+
if (normalized) {
|
|
78
|
+
return normalized;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseIssueNumber(value) {
|
|
85
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
|
|
86
|
+
return String(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const source = toNonEmptyString(value);
|
|
90
|
+
if (!source) {
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const direct = source.match(/^#?(\d+)$/);
|
|
95
|
+
if (direct) {
|
|
96
|
+
return direct[1];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const issueToken = source.match(/(?:^|[\/\s])issue-(\d+)(?:-|$|\s)/i);
|
|
100
|
+
if (issueToken) {
|
|
101
|
+
return issueToken[1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const githubUrl = source.match(/\/issues\/(\d+)(?:$|[/?#])/i);
|
|
105
|
+
if (githubUrl) {
|
|
106
|
+
return githubUrl[1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ref = source.match(/#(\d+)/);
|
|
110
|
+
if (ref) {
|
|
111
|
+
return ref[1];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeStatus(value, fallback = "TODO") {
|
|
118
|
+
const normalized = toNonEmptyString(value).toUpperCase() || fallback;
|
|
119
|
+
if (!STATUS_LABELS.includes(normalized)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`unsupported status '${normalized}'. Allowed: ${STATUS_LABELS.join(", ")}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return normalized;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeSprintLabel(value) {
|
|
128
|
+
const sprint = toNonEmptyString(value);
|
|
129
|
+
if (!sprint) {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
if (/^sprint:/i.test(sprint)) {
|
|
133
|
+
return sprint.replace(/^sprint:/i, "sprint:");
|
|
134
|
+
}
|
|
135
|
+
return `sprint:${sprint}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeOwnerLabel(value) {
|
|
139
|
+
const owner = toNonEmptyString(value);
|
|
140
|
+
if (!owner) {
|
|
141
|
+
return "owner:Engineering Agent";
|
|
142
|
+
}
|
|
143
|
+
if (/^owner:/i.test(owner)) {
|
|
144
|
+
return owner.replace(/^owner:/i, "owner:");
|
|
145
|
+
}
|
|
146
|
+
return `owner:${owner}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeTypeLabel(value) {
|
|
150
|
+
const kind = toNonEmptyString(value).toLowerCase() || "task";
|
|
151
|
+
if (kind.startsWith("type:")) {
|
|
152
|
+
return kind;
|
|
153
|
+
}
|
|
154
|
+
return `type:${kind}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseLabelsInput(value) {
|
|
158
|
+
if (!value) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (Array.isArray(value)) {
|
|
163
|
+
return value.map((item) => toNonEmptyString(item)).filter(Boolean);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return String(value)
|
|
167
|
+
.split(",")
|
|
168
|
+
.map((item) => item.trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractSection(content, heading) {
|
|
173
|
+
const source = String(content || "");
|
|
174
|
+
if (!source || !heading) {
|
|
175
|
+
return "";
|
|
176
|
+
}
|
|
177
|
+
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
178
|
+
const regex = new RegExp(`${escapedHeading}\\s*\\n([\\s\\S]*?)(?:\\n###\\s+|$)`, "i");
|
|
179
|
+
const match = source.match(regex);
|
|
180
|
+
return (match?.[1] || "").trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parsePrefixedValue(raw, key) {
|
|
184
|
+
const source = toNonEmptyString(raw);
|
|
185
|
+
if (!source) {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
const regex = new RegExp(`${key}\\s*:\\s*([^\\n;]+)`, "i");
|
|
189
|
+
const match = source.match(regex);
|
|
190
|
+
return (match?.[1] || "").trim();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildVerifiability(payload = {}) {
|
|
194
|
+
const structured = payload.verifiability;
|
|
195
|
+
let strict = "";
|
|
196
|
+
let statistical = "";
|
|
197
|
+
let human = "";
|
|
198
|
+
|
|
199
|
+
if (structured && typeof structured === "object" && !Array.isArray(structured)) {
|
|
200
|
+
strict = pickFirstNonEmpty(structured.strict, payload.strict, payload.verificationStrict);
|
|
201
|
+
statistical = pickFirstNonEmpty(
|
|
202
|
+
structured.statistical,
|
|
203
|
+
payload.statistical,
|
|
204
|
+
payload.verificationStatistical,
|
|
205
|
+
);
|
|
206
|
+
human = pickFirstNonEmpty(structured.human, payload.human, payload.verificationHuman);
|
|
207
|
+
} else {
|
|
208
|
+
const flat = pickFirstNonEmpty(payload.verifiability, payload.verification);
|
|
209
|
+
strict = pickFirstNonEmpty(
|
|
210
|
+
payload.strict,
|
|
211
|
+
payload.verificationStrict,
|
|
212
|
+
parsePrefixedValue(flat, "strict"),
|
|
213
|
+
);
|
|
214
|
+
statistical = pickFirstNonEmpty(
|
|
215
|
+
payload.statistical,
|
|
216
|
+
payload.verificationStatistical,
|
|
217
|
+
parsePrefixedValue(flat, "statistical"),
|
|
218
|
+
);
|
|
219
|
+
human = pickFirstNonEmpty(
|
|
220
|
+
payload.human,
|
|
221
|
+
payload.verificationHuman,
|
|
222
|
+
parsePrefixedValue(flat, "human"),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
strict: strict || "schema/types/invariants defined",
|
|
228
|
+
statistical: statistical || "N/A (provide measurable threshold if metric is applicable)",
|
|
229
|
+
human: human || "manual review and acceptance by task owner",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function validateVerifiabilitySection(body) {
|
|
234
|
+
const section = extractSection(body, "### Проверяемость");
|
|
235
|
+
if (!section) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
reasons: ["missing section '### Проверяемость'"],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const hasStrict = /(^|\n)\s*-\s*strict\s*:/i.test(section);
|
|
243
|
+
const hasStatistical = /(^|\n)\s*-\s*statistical\s*:/i.test(section);
|
|
244
|
+
const hasHuman = /(^|\n)\s*-\s*human\s*:/i.test(section);
|
|
245
|
+
const reasons = [];
|
|
246
|
+
|
|
247
|
+
if (!hasStrict) {
|
|
248
|
+
reasons.push("missing 'strict:' row in verifiability");
|
|
249
|
+
}
|
|
250
|
+
if (!hasStatistical) {
|
|
251
|
+
reasons.push("missing 'statistical:' row in verifiability");
|
|
252
|
+
}
|
|
253
|
+
if (!hasHuman) {
|
|
254
|
+
reasons.push("missing 'human:' row in verifiability");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const statisticalMatch = section.match(/(^|\n)\s*-\s*statistical\s*:\s*(.+)$/im);
|
|
258
|
+
if (statisticalMatch) {
|
|
259
|
+
const statisticalValue = toNonEmptyString(statisticalMatch[2]);
|
|
260
|
+
const hasThresholdOrNA = /(<=|>=|<|>|=|\b\d+(\.\d+)?\b|N\/A|^NA\b|not applicable)/i.test(
|
|
261
|
+
statisticalValue,
|
|
262
|
+
);
|
|
263
|
+
if (!hasThresholdOrNA) {
|
|
264
|
+
reasons.push("statistical row must include measurable threshold or explicit N/A");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
ok: reasons.length === 0,
|
|
270
|
+
reasons,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function validateIssueBodyForInProgress(body) {
|
|
275
|
+
const reasons = [];
|
|
276
|
+
for (const heading of REQUIRED_PRD_HEADINGS) {
|
|
277
|
+
if (!String(body || "").includes(heading)) {
|
|
278
|
+
reasons.push(`missing required PRD heading '${heading}'`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const verifiabilityCheck = validateVerifiabilitySection(body);
|
|
283
|
+
if (!verifiabilityCheck.ok) {
|
|
284
|
+
for (const reason of verifiabilityCheck.reasons) {
|
|
285
|
+
reasons.push(reason);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
ok: reasons.length === 0,
|
|
291
|
+
reasons,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function labelColor(name) {
|
|
296
|
+
if (STATUS_COLORS[name]) {
|
|
297
|
+
return STATUS_COLORS[name];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const lower = toNonEmptyString(name).toLowerCase();
|
|
301
|
+
if (lower.startsWith("type:")) {
|
|
302
|
+
return FALLBACK_COLORS.type;
|
|
303
|
+
}
|
|
304
|
+
if (lower.startsWith("owner:")) {
|
|
305
|
+
return FALLBACK_COLORS.owner;
|
|
306
|
+
}
|
|
307
|
+
if (lower.startsWith("tracker:")) {
|
|
308
|
+
return FALLBACK_COLORS.tracker;
|
|
309
|
+
}
|
|
310
|
+
if (lower.startsWith("sprint:")) {
|
|
311
|
+
return FALLBACK_COLORS.sprint;
|
|
312
|
+
}
|
|
313
|
+
return FALLBACK_COLORS.default;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function mergeUnique(values) {
|
|
317
|
+
return Array.from(new Set(values.map((item) => toNonEmptyString(item)).filter(Boolean)));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function buildCreateTaskLabels(payload) {
|
|
321
|
+
const status = normalizeStatus(payload.status || "TODO");
|
|
322
|
+
const labels = [
|
|
323
|
+
...parseLabelsInput(payload.labels),
|
|
324
|
+
status,
|
|
325
|
+
normalizeTypeLabel(payload.type),
|
|
326
|
+
normalizeOwnerLabel(payload.owner || payload.ownerRole),
|
|
327
|
+
"tracker:github",
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
const sprintLabel = normalizeSprintLabel(payload.sprint);
|
|
331
|
+
if (sprintLabel) {
|
|
332
|
+
labels.push(sprintLabel);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return mergeUnique(labels);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderBulletedValue(value, fallbackText) {
|
|
339
|
+
const text = toNonEmptyString(value);
|
|
340
|
+
if (!text) {
|
|
341
|
+
return `- ${fallbackText}`;
|
|
342
|
+
}
|
|
343
|
+
if (text.includes("\n")) {
|
|
344
|
+
const lines = text
|
|
345
|
+
.split("\n")
|
|
346
|
+
.map((line) => line.trim())
|
|
347
|
+
.filter(Boolean);
|
|
348
|
+
if (lines.length === 0) {
|
|
349
|
+
return `- ${fallbackText}`;
|
|
350
|
+
}
|
|
351
|
+
return lines.map((line) => `- ${line}`).join("\n");
|
|
352
|
+
}
|
|
353
|
+
return `- ${text}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function renderCreateTaskBody(payload, labels) {
|
|
357
|
+
const description = renderBulletedValue(
|
|
358
|
+
payload.description,
|
|
359
|
+
"заполнить описание задачи",
|
|
360
|
+
);
|
|
361
|
+
const verifiability = buildVerifiability(payload);
|
|
362
|
+
const doneBlock = renderBulletedValue(
|
|
363
|
+
payload.done,
|
|
364
|
+
"[ ] Не начато",
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const metadata = [];
|
|
368
|
+
metadata.push(`- tracker: github`);
|
|
369
|
+
metadata.push(`- labels: ${labels.join(", ")}`);
|
|
370
|
+
const sprintLabel = normalizeSprintLabel(payload.sprint);
|
|
371
|
+
if (sprintLabel) {
|
|
372
|
+
metadata.push(`- sprint: ${sprintLabel.replace(/^sprint:/i, "")}`);
|
|
373
|
+
}
|
|
374
|
+
const epicRef = parseIssueNumber(payload.epic || payload.linkToEpic || payload.parent);
|
|
375
|
+
if (epicRef) {
|
|
376
|
+
metadata.push(`- epic: #${epicRef}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return [
|
|
380
|
+
"## PRD Step",
|
|
381
|
+
"",
|
|
382
|
+
"### Описание",
|
|
383
|
+
description,
|
|
384
|
+
"",
|
|
385
|
+
"### Проверяемость",
|
|
386
|
+
`- strict: ${verifiability.strict}`,
|
|
387
|
+
`- statistical: ${verifiability.statistical}`,
|
|
388
|
+
`- human: ${verifiability.human}`,
|
|
389
|
+
"",
|
|
390
|
+
"### Что сделано",
|
|
391
|
+
doneBlock,
|
|
392
|
+
"",
|
|
393
|
+
"## Metadata",
|
|
394
|
+
...metadata,
|
|
395
|
+
].join("\n");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function ensureLabelExists(name, repo) {
|
|
399
|
+
const repoArgs = toRepoArgs(repo);
|
|
400
|
+
const exists = readJson(
|
|
401
|
+
"gh",
|
|
402
|
+
["label", "list", ...repoArgs, "--search", name, "--limit", "100", "--json", "name"],
|
|
403
|
+
true,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
if (Array.isArray(exists) && exists.some((item) => item?.name === name)) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const color = labelColor(name);
|
|
411
|
+
const description = "Auto-created by meta:ops github adapter";
|
|
412
|
+
const created = run(
|
|
413
|
+
"gh",
|
|
414
|
+
["label", "create", name, "--color", color, "--description", description, ...repoArgs],
|
|
415
|
+
true,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
if (!created) {
|
|
419
|
+
const existsAfter = readJson(
|
|
420
|
+
"gh",
|
|
421
|
+
["label", "list", ...repoArgs, "--search", name, "--limit", "100", "--json", "name"],
|
|
422
|
+
true,
|
|
423
|
+
);
|
|
424
|
+
if (!(Array.isArray(existsAfter) && existsAfter.some((item) => item?.name === name))) {
|
|
425
|
+
throw new Error(`failed to create label '${name}'`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function ensureLabelsExist(labels, repo) {
|
|
431
|
+
for (const label of labels) {
|
|
432
|
+
ensureLabelExists(label, repo);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function resolveTaskIssue(payload) {
|
|
437
|
+
const issue = parseIssueNumber(payload.task || payload.issue || payload.taskId || payload.id);
|
|
438
|
+
if (!issue) {
|
|
439
|
+
throw new Error("missing issue reference in payload (task/issue/taskId/id)");
|
|
440
|
+
}
|
|
441
|
+
return issue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function getIssueDetails(issue, repo) {
|
|
445
|
+
const repoArgs = toRepoArgs(repo);
|
|
446
|
+
return readJson(
|
|
447
|
+
"gh",
|
|
448
|
+
["issue", "view", String(issue), "--json", "number,title,body,labels,url", ...repoArgs],
|
|
449
|
+
false,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getIssueLabels(issue, repo) {
|
|
454
|
+
const repoArgs = toRepoArgs(repo);
|
|
455
|
+
const issueData = readJson(
|
|
456
|
+
"gh",
|
|
457
|
+
["issue", "view", String(issue), "--json", "labels", ...repoArgs],
|
|
458
|
+
false,
|
|
459
|
+
);
|
|
460
|
+
const labels = issueData?.labels || [];
|
|
461
|
+
return labels.map((item) => toNonEmptyString(item?.name)).filter(Boolean);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function removeAndAddIssueLabels(issue, repo, labelsToRemove, labelsToAdd) {
|
|
465
|
+
const repoArgs = toRepoArgs(repo);
|
|
466
|
+
const args = ["issue", "edit", String(issue), ...repoArgs];
|
|
467
|
+
|
|
468
|
+
for (const label of labelsToRemove) {
|
|
469
|
+
args.push("--remove-label", label);
|
|
470
|
+
}
|
|
471
|
+
for (const label of labelsToAdd) {
|
|
472
|
+
args.push("--add-label", label);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
run("gh", args, false);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function commentIssue(issue, repo, body) {
|
|
479
|
+
const content = toNonEmptyString(body);
|
|
480
|
+
if (!content) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const repoArgs = toRepoArgs(repo);
|
|
484
|
+
run("gh", ["issue", "comment", String(issue), "--body", content, ...repoArgs], false);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function gitCurrentBranch() {
|
|
488
|
+
return run("git", ["rev-parse", "--abbrev-ref", "HEAD"], true);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function gitAdd(files) {
|
|
492
|
+
if (Array.isArray(files) && files.length > 0) {
|
|
493
|
+
run("git", ["add", ...files.map((item) => String(item))], false);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
run("git", ["add", "-A"], false);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function ensureStagedChanges() {
|
|
500
|
+
const staged = run("git", ["diff", "--cached", "--name-only"], true);
|
|
501
|
+
if (!staged) {
|
|
502
|
+
throw new Error("no staged changes to commit");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function buildCommitMessage(payload) {
|
|
507
|
+
const explicit = pickFirstNonEmpty(payload.message, payload.commitMessage);
|
|
508
|
+
if (explicit) {
|
|
509
|
+
return explicit;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const issue = parseIssueNumber(payload.task || payload.issue || payload.taskId || payload.id);
|
|
513
|
+
if (!issue) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
"missing commit message. Provide payload.message or payload.issue/task to build '#<issue> <summary>'",
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const summary = pickFirstNonEmpty(payload.shortName, payload.title, payload.name, "update");
|
|
520
|
+
return `#${issue} ${summary}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export async function createTask(payload = {}) {
|
|
524
|
+
const title = pickFirstNonEmpty(payload.shortName, payload.title, payload.name);
|
|
525
|
+
if (!title) {
|
|
526
|
+
throw new Error("missing task title (shortName/title/name)");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const repo = pickFirstNonEmpty(payload.repo);
|
|
530
|
+
const labels = buildCreateTaskLabels(payload);
|
|
531
|
+
const body = renderCreateTaskBody(payload, labels);
|
|
532
|
+
|
|
533
|
+
if (payload.dryRun) {
|
|
534
|
+
console.log(
|
|
535
|
+
JSON.stringify(
|
|
536
|
+
{
|
|
537
|
+
command: "CREATE_TASK",
|
|
538
|
+
mode: "dry-run",
|
|
539
|
+
repo: repo || "current",
|
|
540
|
+
title,
|
|
541
|
+
labels,
|
|
542
|
+
},
|
|
543
|
+
null,
|
|
544
|
+
2,
|
|
545
|
+
),
|
|
546
|
+
);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
ensureGhReady();
|
|
551
|
+
ensureLabelsExist(labels, repo);
|
|
552
|
+
|
|
553
|
+
const repoArgs = toRepoArgs(repo);
|
|
554
|
+
const args = ["issue", "create", "--title", title, "--body", body, ...repoArgs];
|
|
555
|
+
for (const label of labels) {
|
|
556
|
+
args.push("--label", label);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const issueUrl = run("gh", args, false);
|
|
560
|
+
const issue = parseIssueNumber(issueUrl);
|
|
561
|
+
const epic = parseIssueNumber(payload.epic || payload.linkToEpic || payload.parent);
|
|
562
|
+
if (issue && epic) {
|
|
563
|
+
commentIssue(issue, repo, `Linked to epic #${epic}.`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
console.log(`github CREATE_TASK: ${issueUrl}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export async function setStatus(payload = {}) {
|
|
570
|
+
const issue = resolveTaskIssue(payload);
|
|
571
|
+
const repo = pickFirstNonEmpty(payload.repo);
|
|
572
|
+
const targetStatus = normalizeStatus(payload.status);
|
|
573
|
+
|
|
574
|
+
if (payload.dryRun) {
|
|
575
|
+
console.log(
|
|
576
|
+
JSON.stringify(
|
|
577
|
+
{
|
|
578
|
+
command: "SET_STATUS",
|
|
579
|
+
mode: "dry-run",
|
|
580
|
+
repo: repo || "current",
|
|
581
|
+
issue,
|
|
582
|
+
status: targetStatus,
|
|
583
|
+
},
|
|
584
|
+
null,
|
|
585
|
+
2,
|
|
586
|
+
),
|
|
587
|
+
);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
ensureGhReady();
|
|
592
|
+
if (targetStatus === "IN_PROGRESS") {
|
|
593
|
+
const issueData = getIssueDetails(issue, repo);
|
|
594
|
+
const validation = validateIssueBodyForInProgress(issueData?.body || "");
|
|
595
|
+
if (!validation.ok) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`BLOCKED: cannot set #${issue} -> IN_PROGRESS. ${validation.reasons.join("; ")}`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
ensureLabelsExist([targetStatus], repo);
|
|
602
|
+
|
|
603
|
+
const currentLabels = getIssueLabels(issue, repo);
|
|
604
|
+
const removeStatuses = currentLabels.filter(
|
|
605
|
+
(label) => STATUS_LABELS.includes(label) && label !== targetStatus,
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
removeAndAddIssueLabels(issue, repo, removeStatuses, [targetStatus]);
|
|
609
|
+
const reason = pickFirstNonEmpty(payload.reason);
|
|
610
|
+
if (reason) {
|
|
611
|
+
commentIssue(issue, repo, `Status -> ${targetStatus}. Reason: ${reason}`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
console.log(`github SET_STATUS: #${issue} -> ${targetStatus}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function commitByName(payload = {}) {
|
|
618
|
+
const branch = gitCurrentBranch();
|
|
619
|
+
const expectedBranch = pickFirstNonEmpty(payload.branch);
|
|
620
|
+
if (expectedBranch && branch && branch !== expectedBranch) {
|
|
621
|
+
throw new Error(`branch mismatch: current '${branch}', expected '${expectedBranch}'`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const message = buildCommitMessage(payload);
|
|
625
|
+
const files = Array.isArray(payload.files) ? payload.files : [];
|
|
626
|
+
|
|
627
|
+
if (payload.dryRun) {
|
|
628
|
+
console.log(
|
|
629
|
+
JSON.stringify(
|
|
630
|
+
{
|
|
631
|
+
command: "COMMIT_BY_NAME",
|
|
632
|
+
mode: "dry-run",
|
|
633
|
+
branch: branch || "unknown",
|
|
634
|
+
message,
|
|
635
|
+
files: files.length > 0 ? files : ["-A"],
|
|
636
|
+
push: Boolean(payload.push),
|
|
637
|
+
},
|
|
638
|
+
null,
|
|
639
|
+
2,
|
|
640
|
+
),
|
|
641
|
+
);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
gitAdd(files);
|
|
646
|
+
ensureStagedChanges();
|
|
647
|
+
run("git", ["commit", "-m", message], false);
|
|
648
|
+
if (payload.push) {
|
|
649
|
+
run("git", ["push"], false);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
console.log(`github COMMIT_BY_NAME: ${message}`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export async function assignSprint(payload = {}) {
|
|
656
|
+
const issue = resolveTaskIssue(payload);
|
|
657
|
+
const repo = pickFirstNonEmpty(payload.repo);
|
|
658
|
+
const sprintLabel = normalizeSprintLabel(payload.sprint);
|
|
659
|
+
if (!sprintLabel) {
|
|
660
|
+
throw new Error("missing sprint in payload");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (payload.dryRun) {
|
|
664
|
+
console.log(
|
|
665
|
+
JSON.stringify(
|
|
666
|
+
{
|
|
667
|
+
command: "ASSIGN_SPRINT",
|
|
668
|
+
mode: "dry-run",
|
|
669
|
+
repo: repo || "current",
|
|
670
|
+
issue,
|
|
671
|
+
sprint: sprintLabel,
|
|
672
|
+
},
|
|
673
|
+
null,
|
|
674
|
+
2,
|
|
675
|
+
),
|
|
676
|
+
);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
ensureGhReady();
|
|
681
|
+
ensureLabelsExist([sprintLabel], repo);
|
|
682
|
+
|
|
683
|
+
const currentLabels = getIssueLabels(issue, repo);
|
|
684
|
+
const removeSprints = currentLabels.filter(
|
|
685
|
+
(label) => label.toLowerCase().startsWith("sprint:") && label !== sprintLabel,
|
|
686
|
+
);
|
|
687
|
+
removeAndAddIssueLabels(issue, repo, removeSprints, [sprintLabel]);
|
|
688
|
+
|
|
689
|
+
console.log(`github ASSIGN_SPRINT: #${issue} -> ${sprintLabel}`);
|
|
690
|
+
}
|