mandrel 2.65.0 → 2.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/acceptance-critic.md +7 -7
- package/.agents/agents/auditor.md +17 -18
- package/.agents/agents/plan-critic.md +5 -5
- package/.agents/agents/story-worker.md +5 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/execution-reference.md +27 -5
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +12 -13
- package/.agents/rules/ci-remediation.md +3 -3
- package/.agents/rules/gherkin-standards.md +3 -2
- package/.agents/rules/git-conventions-reference.md +17 -8
- package/.agents/rules/git-conventions.md +10 -8
- package/.agents/rules/testing-standards.md +8 -7
- package/.agents/runtime-deps.json +1 -1
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/bootstrap.js +94 -89
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/cli/standard-args.js +60 -76
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/config/gates/shared.js +3 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +11 -2
- package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
- package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
- package/.agents/scripts/lib/observability/signal-validator.js +17 -5
- package/.agents/scripts/lib/observability/source-classifier.js +3 -1
- package/.agents/scripts/lib/orchestration/code-review.js +22 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
- package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
- package/.agents/scripts/lib/signals/detectors/common.js +63 -51
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/transpile.js +28 -3
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/single-story-close.js +10 -2
- package/.agents/scripts/single-story-confirm-merge.js +267 -238
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
- package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
- package/.agents/workflows/audit-architecture.md +5 -4
- package/.agents/workflows/audit-documentation.md +5 -5
- package/.agents/workflows/audit-performance.md +10 -10
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +11 -10
- package/.agents/workflows/helpers/audit-lens-core.md +30 -57
- package/.agents/workflows/helpers/deliver-digest.md +2 -2
- package/.agents/workflows/helpers/deliver-reference.md +3 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +6 -1
- package/.agents/workflows/helpers/parallel-tooling.md +16 -18
- package/.agents/workflows/mandrel-deliver.md +1 -1
- package/.agents/workflows/mandrel-plan.md +6 -5
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/guarded-sync.js +87 -0
- package/lib/cli/sync-agents.js +9 -92
- package/lib/cli/sync-commands.js +9 -101
- package/package.json +2 -2
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graduator's gh/git primitives. Each takes one gh context value —
|
|
3
|
+
* `{ ghPath, spawnImpl, cwd, timeoutMs }` — spread flat into its options.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
import { LABEL_COLORS } from '../label-constants.js';
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_RUN_CHILD_TIMEOUT_MS = 30000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The feedback loop's single spawn helper. Never throws: spawn errors land in
|
|
14
|
+
* `spawnError`; an overrun is SIGKILL'd and resolves `timedOut: true`.
|
|
15
|
+
* `timeoutMs` of `0`/`Infinity` disables the watchdog.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {string} opts.cmd
|
|
19
|
+
* @param {string[]} opts.args
|
|
20
|
+
* @param {Function} [opts.spawnImpl]
|
|
21
|
+
* @param {string} [opts.cwd]
|
|
22
|
+
* @param {number} [opts.timeoutMs]
|
|
23
|
+
* @returns {Promise<{ code: number|null, stdout: string, stderr: string, spawnError: Error|null, timedOut: boolean }>}
|
|
24
|
+
*/
|
|
25
|
+
export function runChild({
|
|
26
|
+
cmd,
|
|
27
|
+
args,
|
|
28
|
+
spawnImpl = defaultSpawn,
|
|
29
|
+
cwd,
|
|
30
|
+
timeoutMs = DEFAULT_RUN_CHILD_TIMEOUT_MS,
|
|
31
|
+
}) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let child;
|
|
34
|
+
try {
|
|
35
|
+
child = spawnImpl(cmd, args, {
|
|
36
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
37
|
+
cwd,
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
resolve({
|
|
41
|
+
code: null,
|
|
42
|
+
stdout: '',
|
|
43
|
+
stderr: '',
|
|
44
|
+
spawnError: err,
|
|
45
|
+
timedOut: false,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let stdout = '';
|
|
50
|
+
let stderr = '';
|
|
51
|
+
let spawnError = null;
|
|
52
|
+
let settled = false;
|
|
53
|
+
let timer = null;
|
|
54
|
+
const finish = (result) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
if (timer) clearTimeout(timer);
|
|
58
|
+
resolve(result);
|
|
59
|
+
};
|
|
60
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
61
|
+
timer = setTimeout(() => {
|
|
62
|
+
try {
|
|
63
|
+
child.kill?.('SIGKILL');
|
|
64
|
+
} catch {
|
|
65
|
+
// already dead / stub child
|
|
66
|
+
}
|
|
67
|
+
finish({
|
|
68
|
+
code: null,
|
|
69
|
+
stdout,
|
|
70
|
+
stderr,
|
|
71
|
+
spawnError: Object.assign(
|
|
72
|
+
new Error(
|
|
73
|
+
`child process '${cmd}' exceeded ${timeoutMs}ms and was killed`,
|
|
74
|
+
),
|
|
75
|
+
{ code: 'ETIMEDOUT' },
|
|
76
|
+
),
|
|
77
|
+
timedOut: true,
|
|
78
|
+
});
|
|
79
|
+
}, timeoutMs);
|
|
80
|
+
// Deliberately NOT unref'd: a child whose handles close early (or a
|
|
81
|
+
// stub) leaves the loop idle, and an unref'd watchdog would never fire.
|
|
82
|
+
// `finish()` always clears it.
|
|
83
|
+
}
|
|
84
|
+
child.stdout?.on('data', (chunk) => {
|
|
85
|
+
stdout += chunk.toString();
|
|
86
|
+
});
|
|
87
|
+
child.stderr?.on('data', (chunk) => {
|
|
88
|
+
stderr += chunk.toString();
|
|
89
|
+
});
|
|
90
|
+
child.on('error', (err) => {
|
|
91
|
+
spawnError = err;
|
|
92
|
+
});
|
|
93
|
+
child.on('close', (code) => {
|
|
94
|
+
finish({ code, stdout, stderr, spawnError, timedOut: false });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function runWith(gh, cmd, args) {
|
|
100
|
+
return runChild({
|
|
101
|
+
cmd,
|
|
102
|
+
args,
|
|
103
|
+
spawnImpl: gh.spawnImpl,
|
|
104
|
+
cwd: gh.cwd,
|
|
105
|
+
timeoutMs: gh.timeoutMs,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function runGh(gh, args) {
|
|
110
|
+
return runWith(gh, gh.ghPath, args);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function childFailed(res) {
|
|
114
|
+
return (
|
|
115
|
+
Boolean(res.spawnError) || (typeof res.code === 'number' && res.code !== 0)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `git cat-file -e <ref>:<path>`; a spawn failure/timeout is `probeError`,
|
|
121
|
+
* distinct from a confirmed-missing file.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} opts
|
|
124
|
+
* @param {string} opts.ref
|
|
125
|
+
* @param {string} opts.path
|
|
126
|
+
* @param {Function} [opts.spawnImpl]
|
|
127
|
+
* @param {string} [opts.cwd]
|
|
128
|
+
* @param {number} [opts.timeoutMs]
|
|
129
|
+
* @returns {Promise<{ exists: boolean, probeError: boolean }>}
|
|
130
|
+
*/
|
|
131
|
+
export async function probePathStatus({ ref, path, ...gh }) {
|
|
132
|
+
const res = await runWith(gh, 'git', ['cat-file', '-e', `${ref}:${path}`]);
|
|
133
|
+
if (res.spawnError || res.timedOut) {
|
|
134
|
+
return { exists: false, probeError: true };
|
|
135
|
+
}
|
|
136
|
+
return { exists: res.code === 0, probeError: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* GitHub search indexes the text inside an HTML-comment marker, but a query
|
|
141
|
+
* carrying the `<!--`/`-->` delimiters never matches it; strip them.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} marker
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
function normalizeMarkerQuery(marker) {
|
|
147
|
+
if (typeof marker !== 'string') return '';
|
|
148
|
+
return marker.replaceAll('<!--', '').replaceAll('-->', '').trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `state` defaults to `''`, never `'open'`: an unknown state must not
|
|
153
|
+
* authorize editing an issue.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} row
|
|
156
|
+
* @returns {{ number: number|null, state: string, url: string }}
|
|
157
|
+
*/
|
|
158
|
+
function toFollowUpRef(row) {
|
|
159
|
+
const number = Number(row?.number);
|
|
160
|
+
return {
|
|
161
|
+
number: Number.isInteger(number) && number > 0 ? number : null,
|
|
162
|
+
state: String(row?.state ?? '').toLowerCase(),
|
|
163
|
+
url: typeof row?.url === 'string' ? row.url : '',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `null` on no match OR an undecidable probe — degrade toward filing: a
|
|
169
|
+
* duplicate beats a swallowed finding.
|
|
170
|
+
*
|
|
171
|
+
* @returns {Promise<{ number: number|null, state: string, url: string }|null>}
|
|
172
|
+
*/
|
|
173
|
+
export async function searchFollowUpByMarker({ marker, owner, repo, ...gh }) {
|
|
174
|
+
const res = await runGh(gh, [
|
|
175
|
+
'search',
|
|
176
|
+
'issues',
|
|
177
|
+
normalizeMarkerQuery(marker),
|
|
178
|
+
'--repo',
|
|
179
|
+
`${owner}/${repo}`,
|
|
180
|
+
'--json',
|
|
181
|
+
'number,state,url',
|
|
182
|
+
'--limit',
|
|
183
|
+
'1',
|
|
184
|
+
]);
|
|
185
|
+
if (childFailed(res)) return null;
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(res.stdout || '[]');
|
|
188
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
189
|
+
return toFollowUpRef(parsed[0]);
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Strongly-consistent last gate before creating: the search index can lag
|
|
197
|
+
* long enough to miss a duplicate filed seconds earlier, while a
|
|
198
|
+
* label-scoped `gh issue list --state all` cannot. Any of `markers` matching
|
|
199
|
+
* counts, so a marker-format change does not re-file the backlog. `null` on
|
|
200
|
+
* no match or error (degrade toward filing).
|
|
201
|
+
*
|
|
202
|
+
* @param {object} opts
|
|
203
|
+
* @param {string[]} opts.markers
|
|
204
|
+
* @param {string} opts.owner
|
|
205
|
+
* @param {string} opts.repo
|
|
206
|
+
* @param {string[]} [opts.labels]
|
|
207
|
+
* @returns {Promise<{ number: number|null, state: string, url: string }|null>}
|
|
208
|
+
*/
|
|
209
|
+
export async function findExistingFollowUp({
|
|
210
|
+
markers,
|
|
211
|
+
owner,
|
|
212
|
+
repo,
|
|
213
|
+
labels,
|
|
214
|
+
...gh
|
|
215
|
+
}) {
|
|
216
|
+
const tokens = (Array.isArray(markers) ? markers : []).filter(
|
|
217
|
+
(m) => typeof m === 'string' && m.length > 0,
|
|
218
|
+
);
|
|
219
|
+
if (tokens.length === 0) return null;
|
|
220
|
+
const args = [
|
|
221
|
+
'issue',
|
|
222
|
+
'list',
|
|
223
|
+
'--repo',
|
|
224
|
+
`${owner}/${repo}`,
|
|
225
|
+
'--state',
|
|
226
|
+
'all',
|
|
227
|
+
'--json',
|
|
228
|
+
'number,body,state,url',
|
|
229
|
+
];
|
|
230
|
+
for (const label of Array.isArray(labels) ? labels : []) {
|
|
231
|
+
args.push('--label', label);
|
|
232
|
+
}
|
|
233
|
+
const res = await runGh(gh, args);
|
|
234
|
+
if (childFailed(res)) return null;
|
|
235
|
+
let parsed;
|
|
236
|
+
try {
|
|
237
|
+
parsed = JSON.parse(res.stdout || '[]');
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (!Array.isArray(parsed)) return null;
|
|
242
|
+
const matches = parsed.filter(
|
|
243
|
+
(issue) =>
|
|
244
|
+
typeof issue?.body === 'string' &&
|
|
245
|
+
tokens.some((token) => issue.body.includes(token)),
|
|
246
|
+
);
|
|
247
|
+
if (matches.length === 0) return null;
|
|
248
|
+
// Prefer an open match: a closed one is a decided follow-up.
|
|
249
|
+
const open = matches.find(
|
|
250
|
+
(issue) => String(issue?.state ?? '').toLowerCase() === 'open',
|
|
251
|
+
);
|
|
252
|
+
return toFollowUpRef(open ?? matches[0]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function issueWriteError(verb, res) {
|
|
256
|
+
return res.spawnError
|
|
257
|
+
? `gh issue ${verb} spawn failed: ${res.spawnError.message}`
|
|
258
|
+
: `gh issue ${verb} exited ${res.code}: ${(res.stderr || '').trim()}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Recurrence path: refresh the body only, leaving human-curated labels,
|
|
263
|
+
* title, assignees and state untouched.
|
|
264
|
+
*
|
|
265
|
+
* @returns {Promise<{ url: string|null, error: string|null }>}
|
|
266
|
+
*/
|
|
267
|
+
export async function updateFollowUpIssue({
|
|
268
|
+
owner,
|
|
269
|
+
repo,
|
|
270
|
+
number,
|
|
271
|
+
body,
|
|
272
|
+
...gh
|
|
273
|
+
}) {
|
|
274
|
+
const res = await runGh(gh, [
|
|
275
|
+
'issue',
|
|
276
|
+
'edit',
|
|
277
|
+
String(number),
|
|
278
|
+
'--repo',
|
|
279
|
+
`${owner}/${repo}`,
|
|
280
|
+
'--body',
|
|
281
|
+
body,
|
|
282
|
+
]);
|
|
283
|
+
if (childFailed(res))
|
|
284
|
+
return { url: null, error: issueWriteError('edit', res) };
|
|
285
|
+
return { url: (res.stdout || '').trim(), error: null };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const FRICTION_LABEL_PREFIX = 'friction::';
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Only `meta::*` and `friction::<category>` labels reach this path.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} name
|
|
294
|
+
* @returns {{ color: string, description: string }}
|
|
295
|
+
*/
|
|
296
|
+
function describeMintedLabel(name) {
|
|
297
|
+
if (name.startsWith(FRICTION_LABEL_PREFIX)) {
|
|
298
|
+
return {
|
|
299
|
+
color: LABEL_COLORS.FRICTION,
|
|
300
|
+
description: `Recurring friction category "${name.slice(FRICTION_LABEL_PREFIX.length)}" (minted by the feedback loop)`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
color: LABEL_COLORS.META,
|
|
305
|
+
description: 'Feedback-loop routing axis (minted by the feedback loop)',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Memoized per repo. `known: null` means unverifiable, not "no labels".
|
|
311
|
+
*
|
|
312
|
+
* @returns {Promise<{ known: Set<string>|null, error: string|null }>}
|
|
313
|
+
*/
|
|
314
|
+
async function readLiveLabelNames({ owner, repo, labelCache, gh }) {
|
|
315
|
+
const key = `${owner}/${repo}`;
|
|
316
|
+
const cached = labelCache?.get(key);
|
|
317
|
+
if (cached) return { known: cached, error: null };
|
|
318
|
+
const res = await runGh(gh, [
|
|
319
|
+
'label',
|
|
320
|
+
'list',
|
|
321
|
+
'--repo',
|
|
322
|
+
key,
|
|
323
|
+
'--limit',
|
|
324
|
+
'500',
|
|
325
|
+
'--json',
|
|
326
|
+
'name',
|
|
327
|
+
]);
|
|
328
|
+
if (childFailed(res)) {
|
|
329
|
+
return {
|
|
330
|
+
known: null,
|
|
331
|
+
error: `gh label list ${key} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
let parsed;
|
|
335
|
+
try {
|
|
336
|
+
parsed = JSON.parse(res.stdout || '[]');
|
|
337
|
+
} catch {
|
|
338
|
+
return {
|
|
339
|
+
known: null,
|
|
340
|
+
error: `gh label list ${key} returned unparseable JSON`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (!Array.isArray(parsed)) {
|
|
344
|
+
return { known: null, error: `gh label list ${key} returned a non-array` };
|
|
345
|
+
}
|
|
346
|
+
const known = new Set();
|
|
347
|
+
for (const row of parsed) {
|
|
348
|
+
if (row && typeof row.name === 'string') known.add(row.name);
|
|
349
|
+
}
|
|
350
|
+
labelCache?.set(key, known);
|
|
351
|
+
return { known, error: null };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Mint absent labels: `gh issue create` fails the whole call on any unknown
|
|
356
|
+
* `--label`, and `friction::<category>` names come from live telemetry so no
|
|
357
|
+
* bootstrap can pre-create them. An unreadable live set reports nothing
|
|
358
|
+
* `missing`, so the caller still attempts the filing.
|
|
359
|
+
*
|
|
360
|
+
* @param {object} opts
|
|
361
|
+
* @param {string} opts.owner
|
|
362
|
+
* @param {string} opts.repo
|
|
363
|
+
* @param {string[]} opts.labels
|
|
364
|
+
* @param {Map<string, Set<string>>} [opts.labelCache]
|
|
365
|
+
* @returns {Promise<{ created: string[], missing: string[], errors: string[] }>}
|
|
366
|
+
*/
|
|
367
|
+
export async function ensureIssueLabels({
|
|
368
|
+
owner,
|
|
369
|
+
repo,
|
|
370
|
+
labels,
|
|
371
|
+
labelCache,
|
|
372
|
+
...ghOpts
|
|
373
|
+
}) {
|
|
374
|
+
const gh = { ...ghOpts, ghPath: ghOpts.ghPath ?? 'gh' };
|
|
375
|
+
const wanted = (Array.isArray(labels) ? labels : []).filter(
|
|
376
|
+
(name) => typeof name === 'string' && name.trim().length > 0,
|
|
377
|
+
);
|
|
378
|
+
if (wanted.length === 0) return { created: [], missing: [], errors: [] };
|
|
379
|
+
|
|
380
|
+
const { known, error } = await readLiveLabelNames({
|
|
381
|
+
owner,
|
|
382
|
+
repo,
|
|
383
|
+
labelCache,
|
|
384
|
+
gh,
|
|
385
|
+
});
|
|
386
|
+
if (!known) return { created: [], missing: [], errors: error ? [error] : [] };
|
|
387
|
+
|
|
388
|
+
const created = [];
|
|
389
|
+
const missing = [];
|
|
390
|
+
const errors = [];
|
|
391
|
+
for (const name of wanted) {
|
|
392
|
+
if (known.has(name)) continue;
|
|
393
|
+
const { color, description } = describeMintedLabel(name);
|
|
394
|
+
const res = await runGh(gh, [
|
|
395
|
+
'label',
|
|
396
|
+
'create',
|
|
397
|
+
name,
|
|
398
|
+
'--repo',
|
|
399
|
+
`${owner}/${repo}`,
|
|
400
|
+
'--color',
|
|
401
|
+
color.replace(/^#/, ''),
|
|
402
|
+
'--description',
|
|
403
|
+
description,
|
|
404
|
+
]);
|
|
405
|
+
// A concurrent mint between list and create is success.
|
|
406
|
+
const raced = /label\b[\s\S]*?already exists/i.test(
|
|
407
|
+
`${res.stderr ?? ''}${res.spawnError?.message ?? ''}`,
|
|
408
|
+
);
|
|
409
|
+
if (!childFailed(res) || raced) {
|
|
410
|
+
known.add(name);
|
|
411
|
+
if (!raced) created.push(name);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
missing.push(name);
|
|
415
|
+
errors.push(
|
|
416
|
+
`gh label create "${name}" in ${owner}/${repo} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return { created, missing, errors };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Resolves `{ url, error }`; exactly one is non-null. */
|
|
423
|
+
export async function createFollowUpIssue({
|
|
424
|
+
owner,
|
|
425
|
+
repo,
|
|
426
|
+
title,
|
|
427
|
+
body,
|
|
428
|
+
labels,
|
|
429
|
+
...gh
|
|
430
|
+
}) {
|
|
431
|
+
const args = [
|
|
432
|
+
'issue',
|
|
433
|
+
'create',
|
|
434
|
+
'--repo',
|
|
435
|
+
`${owner}/${repo}`,
|
|
436
|
+
'--title',
|
|
437
|
+
title,
|
|
438
|
+
'--body',
|
|
439
|
+
body,
|
|
440
|
+
];
|
|
441
|
+
for (const label of labels) {
|
|
442
|
+
args.push('--label', label);
|
|
443
|
+
}
|
|
444
|
+
const res = await runGh(gh, args);
|
|
445
|
+
if (childFailed(res)) {
|
|
446
|
+
return { url: null, error: issueWriteError('create', res) };
|
|
447
|
+
}
|
|
448
|
+
return { url: (res.stdout || '').trim(), error: null };
|
|
449
|
+
}
|