mandrel 1.73.0 → 1.74.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/README.md +9 -5
- package/.agents/scripts/agents-bootstrap-github.js +42 -43
- package/.agents/scripts/bootstrap.js +79 -11
- package/.agents/scripts/lib/bootstrap/manifest.js +5 -5
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +8 -5
- package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
- package/.agents/scripts/lib/bootstrap/summary.js +0 -6
- package/.agents/scripts/lib/bootstrap/workflow-audit.js +25 -12
- package/.agents/scripts/lib/label-taxonomy.js +0 -37
- package/.agents/scripts/lib/onboard/init-tail.js +9 -10
- package/.agents/scripts/lib/orchestration/column-sync.js +22 -41
- package/.agents/scripts/lib/orchestration/epic-spec-reconciler-discriminator.js +56 -2
- package/.agents/scripts/lib/orchestration/project-meta-resolver.js +129 -0
- package/.agents/scripts/providers/github/project-board.js +5 -9
- package/.agents/scripts/providers/github/projects-v2-graphql.js +0 -166
- package/.agents/scripts/providers/github/tickets.js +10 -1
- package/.agents/scripts/providers/github.js +0 -1
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
package/.agents/README.md
CHANGED
|
@@ -33,7 +33,7 @@ It then shows a **two-option prompt**:
|
|
|
33
33
|
|
|
34
34
|
1. **Configure now** — runs `node .agents/scripts/bootstrap.js`, forwarding any
|
|
35
35
|
passthrough flags unchanged, to wire the project and GitHub side (creates the
|
|
36
|
-
GitHub repo
|
|
36
|
+
GitHub repo; board decoration and Issue Forms are opt-in — see below).
|
|
37
37
|
2. **Just the files** — stops after materialization and prints a re-run hint
|
|
38
38
|
(`mandrel init`) so you can configure later.
|
|
39
39
|
|
|
@@ -100,9 +100,13 @@ The bootstrap pipeline, in order:
|
|
|
100
100
|
`.claude/commands/` tree so every `/<command>` loads), wires the system
|
|
101
101
|
prompt (see below), gitignores derived artefacts, and runs the
|
|
102
102
|
quality-gates installer.
|
|
103
|
-
4. **GitHub-side mutations.** Creates the label taxonomy,
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
4. **GitHub-side mutations.** Creates the label taxonomy, branch protection,
|
|
104
|
+
and merge-method settings. Skipped with `--skip-github`. Two additional
|
|
105
|
+
mutations are **opt-in** (prompted y/N, defaulting No, or passed as flags):
|
|
106
|
+
- `--with-project-board` — provision the Projects V2 Status field and
|
|
107
|
+
custom fields on an existing board.
|
|
108
|
+
- `--with-issue-forms` — generate `.github/ISSUE_TEMPLATE/story.yml` and
|
|
109
|
+
`epic.yml` from the ticket-body schema.
|
|
106
110
|
|
|
107
111
|
The bootstrap is idempotent — safe to re-run; an already-configured
|
|
108
112
|
clone produces zero file mutations.
|
|
@@ -184,7 +188,7 @@ Reverses a recorded install using the install ledger
|
|
|
184
188
|
(`.agents/.install-manifest.json`). Each ledger entry is a
|
|
185
189
|
mutation-manifest record; uninstall walks reversible entries and undoes
|
|
186
190
|
exactly what the install applied, without touching pre-existing operator
|
|
187
|
-
content. GitHub-side state (labels, branch protection,
|
|
191
|
+
content. GitHub-side state (labels, branch protection, project board fields)
|
|
188
192
|
requires manual reversal and is surfaced as a follow-up checklist.
|
|
189
193
|
|
|
190
194
|
```bash
|
|
@@ -43,7 +43,6 @@ import { Logger } from './lib/Logger.js';
|
|
|
43
43
|
import {
|
|
44
44
|
LABEL_TAXONOMY,
|
|
45
45
|
PROJECT_FIELD_DEFS,
|
|
46
|
-
PROJECT_VIEW_DEFS,
|
|
47
46
|
STATUS_FIELD_OPTIONS,
|
|
48
47
|
} from './lib/label-taxonomy.js';
|
|
49
48
|
import { createProvider } from './lib/provider-factory.js';
|
|
@@ -154,25 +153,6 @@ async function ensureStatusField(provider, log) {
|
|
|
154
153
|
}
|
|
155
154
|
}
|
|
156
155
|
|
|
157
|
-
async function ensureViews(provider, log) {
|
|
158
|
-
try {
|
|
159
|
-
const views = await provider.ensureProjectViews(PROJECT_VIEW_DEFS);
|
|
160
|
-
if (views.unavailable) {
|
|
161
|
-
log(
|
|
162
|
-
`[Bootstrap] Projects V2 Views unavailable — skipped ${views.skipped.join(', ')}.${views.error ? ` (${views.error})` : ''} ${PROJECTS_DOC_POINTER}`,
|
|
163
|
-
);
|
|
164
|
-
} else {
|
|
165
|
-
log(
|
|
166
|
-
`[Bootstrap] Views — created: ${views.created.length}, skipped: ${views.skipped.length}`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
return views;
|
|
170
|
-
} catch (err) {
|
|
171
|
-
log(`[Bootstrap] Views provisioning failed: ${err.message}`);
|
|
172
|
-
return { created: [], skipped: [], unavailable: false };
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
156
|
/**
|
|
177
157
|
* Audit the project's built-in workflows and, when explicitly opted-in
|
|
178
158
|
* via `--reap-conflicting-workflows`, delete the ones that race against
|
|
@@ -283,10 +263,15 @@ async function ensureProjectFields(provider, project, log) {
|
|
|
283
263
|
* github?: object,
|
|
284
264
|
* baseBranch?: string,
|
|
285
265
|
* githubAdminApproved?: boolean,
|
|
266
|
+
* withProjectBoard?: boolean,
|
|
286
267
|
* isTTY?: boolean,
|
|
287
268
|
* }} [opts] - `githubAdminApproved` MUST be `true` for any GitHub mutation to
|
|
288
269
|
* occur; any other value (absent / `false`) is treated as "not approved"
|
|
289
270
|
* and the run is a verified no-op.
|
|
271
|
+
* `withProjectBoard` (default `false`) — opt-in for Projects V2 board,
|
|
272
|
+
* Status field, custom fields, and workflow audit. When absent or `false`,
|
|
273
|
+
* the board decoration is skipped and only labels + branch protection +
|
|
274
|
+
* merge methods are provisioned.
|
|
290
275
|
*/
|
|
291
276
|
export async function runBootstrap(config, opts = {}) {
|
|
292
277
|
// Explicit opt-in gate (Story #3526). Default-deny: absent or non-`true`
|
|
@@ -316,36 +301,46 @@ export async function runBootstrap(config, opts = {}) {
|
|
|
316
301
|
log('[Bootstrap] API access verified.');
|
|
317
302
|
|
|
318
303
|
const labels = await ensureLabels(provider, log);
|
|
319
|
-
const project = await resolveProject(provider, providerConfig, log);
|
|
320
304
|
|
|
321
|
-
|
|
305
|
+
// Board decoration (Projects V2 board, Status field, custom fields, workflow
|
|
306
|
+
// audit) is opt-in and defaults OFF. Minimal install = labels only. Gate is
|
|
307
|
+
// `opts.withProjectBoard === true`; absent or false skips all board work.
|
|
308
|
+
// ColumnSync already soft-noops when projectNumber is unset (column-sync.js:19-23).
|
|
309
|
+
const projectBoard = opts.withProjectBoard === true;
|
|
310
|
+
let project = { projectNumber: null, created: false, skipped: true };
|
|
322
311
|
let statusField = { status: 'skipped', added: [] };
|
|
323
|
-
let views = { created: [], skipped: [], unavailable: false };
|
|
324
312
|
let fields = { created: [], skipped: [] };
|
|
313
|
+
let workflowAudit = {
|
|
314
|
+
skipped: true,
|
|
315
|
+
reason: 'board-decoration-not-opted-in',
|
|
316
|
+
};
|
|
325
317
|
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// read-only).
|
|
341
|
-
const workflowAudit = projectReady
|
|
342
|
-
? await auditAndOptionallyReapWorkflows(
|
|
318
|
+
if (projectBoard) {
|
|
319
|
+
project = await resolveProject(provider, providerConfig, log);
|
|
320
|
+
const projectReady = !project.skipped && project.projectNumber;
|
|
321
|
+
if (projectReady) {
|
|
322
|
+
statusField = await ensureStatusField(provider, log);
|
|
323
|
+
fields = await ensureProjectFields(provider, project, log);
|
|
324
|
+
// Story #2845 — audit project workflows for the ones that race against
|
|
325
|
+
// the orchestrator's ColumnSync writes (notably `Pull request merged`
|
|
326
|
+
// and `Pull request linked to issue`, which both rewrite Status as a
|
|
327
|
+
// side-effect of auto-merge). When `--reap-conflicting-workflows` is
|
|
328
|
+
// set, also delete the offenders via `deleteProjectV2Workflow` (the
|
|
329
|
+
// only programmatic action GraphQL exposes today — `enabled` is
|
|
330
|
+
// read-only).
|
|
331
|
+
workflowAudit = await auditAndOptionallyReapWorkflows(
|
|
343
332
|
provider,
|
|
344
333
|
project.projectNumber,
|
|
345
334
|
opts.reapConflictingWorkflows === true,
|
|
346
335
|
log,
|
|
347
|
-
)
|
|
348
|
-
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
log('[Bootstrap] No active project — skipping project-field setup.');
|
|
339
|
+
workflowAudit = { skipped: true, reason: 'no-project' };
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
log('[Bootstrap] Project board decoration skipped (opt-in not set).');
|
|
343
|
+
}
|
|
349
344
|
|
|
350
345
|
// Consumer-facing bootstrap promotes the framework's CI-gates-only
|
|
351
346
|
// stance: branch protection with enforce_admins + 0-approval-count and
|
|
@@ -405,7 +400,6 @@ export async function runBootstrap(config, opts = {}) {
|
|
|
405
400
|
fields,
|
|
406
401
|
project,
|
|
407
402
|
statusField,
|
|
408
|
-
views,
|
|
409
403
|
workflowAudit,
|
|
410
404
|
branchProtection,
|
|
411
405
|
mergeMethods,
|
|
@@ -490,6 +484,10 @@ async function main() {
|
|
|
490
484
|
// invocation never silently reconfigures branch protection or merge methods.
|
|
491
485
|
const githubAdminApproved =
|
|
492
486
|
assumeYes || process.argv.includes('--approve-github-admin');
|
|
487
|
+
// Story #4234 — Board decoration is opt-in (default off). Pass
|
|
488
|
+
// `--with-project-board` to also provision the Projects V2 board, Status
|
|
489
|
+
// field, and custom fields.
|
|
490
|
+
const withProjectBoard = process.argv.includes('--with-project-board');
|
|
493
491
|
|
|
494
492
|
try {
|
|
495
493
|
const result = await runBootstrap(config, {
|
|
@@ -499,6 +497,7 @@ async function main() {
|
|
|
499
497
|
assumeNo,
|
|
500
498
|
reapConflictingWorkflows,
|
|
501
499
|
githubAdminApproved,
|
|
500
|
+
withProjectBoard,
|
|
502
501
|
});
|
|
503
502
|
// A non-approved run returns the skip envelope (no full result shape);
|
|
504
503
|
// the skip line is already logged inside runBootstrap, so render the
|
|
@@ -37,8 +37,16 @@
|
|
|
37
37
|
* (labels, Projects V2, branch protection, merge
|
|
38
38
|
* methods) without accepting every other default.
|
|
39
39
|
* --skip-github Skip the GitHub-side bootstrap entirely
|
|
40
|
-
* --
|
|
40
|
+
* --with-quality Opt-in: install local quality gates (pre-commit
|
|
41
|
+
* hook + quality:preview/watch scripts). Off by
|
|
42
|
+
* default — prompted y/N.
|
|
41
43
|
* --dry-run Collect info and print the plan; change nothing
|
|
44
|
+
* --with-project-board Opt-in: provision the Projects V2 Status field
|
|
45
|
+
* and custom fields. Off by default — the project
|
|
46
|
+
* board object is still created when a project
|
|
47
|
+
* name is supplied, but decoration is skipped.
|
|
48
|
+
* --with-issue-forms Opt-in: generate .github/ISSUE_TEMPLATE/story.yml
|
|
49
|
+
* and epic.yml. Off by default.
|
|
42
50
|
* --reap-conflicting-workflows Delete Projects V2 built-in workflows that
|
|
43
51
|
* race against the orchestrator (destructive)
|
|
44
52
|
* --help Print this help
|
|
@@ -97,8 +105,14 @@ Flags:
|
|
|
97
105
|
(labels, Projects V2, branch protection, merge
|
|
98
106
|
methods) without accepting every other default.
|
|
99
107
|
--skip-github Skip the GitHub-side bootstrap entirely
|
|
100
|
-
--
|
|
108
|
+
--with-quality Opt-in: install local quality gates (pre-commit
|
|
109
|
+
hook + quality:preview/watch scripts).
|
|
110
|
+
(default: off — prompted y/N).
|
|
101
111
|
--dry-run Collect info and print the plan; change nothing
|
|
112
|
+
--with-project-board Opt-in: provision the Projects V2 Status field
|
|
113
|
+
and custom fields (default: off — prompted y/N).
|
|
114
|
+
--with-issue-forms Opt-in: generate .github/ISSUE_TEMPLATE/story.yml
|
|
115
|
+
and epic.yml (default: off — prompted y/N).
|
|
102
116
|
--reap-conflicting-workflows Delete Projects V2 built-in workflows that
|
|
103
117
|
race against the orchestrator (destructive)
|
|
104
118
|
--help Print this help
|
|
@@ -157,16 +171,24 @@ export function resolveOwnerForPicker(defaults, flags, env = process.env) {
|
|
|
157
171
|
return null;
|
|
158
172
|
}
|
|
159
173
|
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Ask a yes/no question. `defaultAnswer` controls the default when the
|
|
176
|
+
* operator presses Enter without typing (true = Y/n, false = y/N). In
|
|
177
|
+
* non-interactive mode the default is returned immediately.
|
|
178
|
+
*/
|
|
179
|
+
async function confirmYesNo(message, interactive, defaultAnswer = true) {
|
|
180
|
+
if (!interactive) return defaultAnswer;
|
|
163
181
|
const rl = readline.createInterface({
|
|
164
182
|
input: process.stdin,
|
|
165
183
|
output: process.stdout,
|
|
166
184
|
});
|
|
167
185
|
try {
|
|
168
|
-
const
|
|
169
|
-
|
|
186
|
+
const hint = defaultAnswer ? '[Y/n]' : '[y/N]';
|
|
187
|
+
const raw = (await rl.question(`${message} ${hint}: `))
|
|
188
|
+
.trim()
|
|
189
|
+
.toLowerCase();
|
|
190
|
+
if (raw === '') return defaultAnswer;
|
|
191
|
+
return raw === 'y' || raw === 'yes';
|
|
170
192
|
} finally {
|
|
171
193
|
rl.close();
|
|
172
194
|
}
|
|
@@ -688,6 +710,9 @@ async function runGithubBootstrap(answers, opts) {
|
|
|
688
710
|
// interactive operator confirmation, `--assume-yes`, or
|
|
689
711
|
// `--approve-github-admin`. Default-deny at the boundary gate when absent.
|
|
690
712
|
githubAdminApproved: opts.githubAdminApproved === true,
|
|
713
|
+
// Opt-in: provision Status field + custom fields on the project board.
|
|
714
|
+
// Default off — prompted y/N during collect/confirm or via --with-project-board.
|
|
715
|
+
withProjectBoard: opts.withProjectBoard === true,
|
|
691
716
|
// Opt-in: delete the Projects V2 built-in workflows that race against the
|
|
692
717
|
// orchestrator's ColumnSync (e.g. "Pull request merged"). Off by default.
|
|
693
718
|
reapConflictingWorkflows: Boolean(opts.reapConflictingWorkflows),
|
|
@@ -1011,7 +1036,48 @@ export async function collectAndConfirm(state) {
|
|
|
1011
1036
|
return { ok: false, exit: 1 };
|
|
1012
1037
|
}
|
|
1013
1038
|
}
|
|
1014
|
-
|
|
1039
|
+
|
|
1040
|
+
// Opt-in: board decoration (Status field, custom fields). Default off.
|
|
1041
|
+
// Dry-run halts immediately after this step — resolve without prompting.
|
|
1042
|
+
let withProjectBoard = Boolean(state.flags['with-project-board']);
|
|
1043
|
+
if (!state.flags['dry-run'] && !withProjectBoard) {
|
|
1044
|
+
withProjectBoard = await confirmYesNo(
|
|
1045
|
+
'Set up project board fields (Status, custom)?',
|
|
1046
|
+
state.interactive,
|
|
1047
|
+
false,
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Opt-in: GitHub Issue Form templates. Default off.
|
|
1052
|
+
let withIssueForms = Boolean(state.flags['with-issue-forms']);
|
|
1053
|
+
if (!state.flags['dry-run'] && !withIssueForms) {
|
|
1054
|
+
withIssueForms = await confirmYesNo(
|
|
1055
|
+
'Generate GitHub Issue Form templates?',
|
|
1056
|
+
state.interactive,
|
|
1057
|
+
false,
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Opt-in: local quality gates. Default off.
|
|
1062
|
+
let withQuality = Boolean(state.flags['with-quality']);
|
|
1063
|
+
if (!state.flags['dry-run'] && !withQuality) {
|
|
1064
|
+
withQuality = await confirmYesNo(
|
|
1065
|
+
'Install local quality gates (pre-commit hook + quality:preview/watch scripts)?',
|
|
1066
|
+
state.interactive,
|
|
1067
|
+
false,
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return {
|
|
1072
|
+
ok: true,
|
|
1073
|
+
payload: {
|
|
1074
|
+
answers,
|
|
1075
|
+
creation,
|
|
1076
|
+
withProjectBoard,
|
|
1077
|
+
withIssueForms,
|
|
1078
|
+
withQuality,
|
|
1079
|
+
},
|
|
1080
|
+
};
|
|
1015
1081
|
}
|
|
1016
1082
|
}
|
|
1017
1083
|
|
|
@@ -1157,7 +1223,8 @@ export async function executeBootstrap(state) {
|
|
|
1157
1223
|
agentRoot: state.agentRoot,
|
|
1158
1224
|
answers: state.answers,
|
|
1159
1225
|
approvedGroups,
|
|
1160
|
-
|
|
1226
|
+
withQuality: state.withQuality === true,
|
|
1227
|
+
withIssueForms: state.withIssueForms === true,
|
|
1161
1228
|
});
|
|
1162
1229
|
return { ok: true, payload: { report, approvedGroups } };
|
|
1163
1230
|
}
|
|
@@ -1210,6 +1277,7 @@ export async function executeGithubBootstrap(state) {
|
|
|
1210
1277
|
state.report.github = await runGithubBootstrap(state.answers, {
|
|
1211
1278
|
assumeYes: state.assumeYes,
|
|
1212
1279
|
githubAdminApproved: state.githubAdminApproved === true,
|
|
1280
|
+
withProjectBoard: state.withProjectBoard === true,
|
|
1213
1281
|
reapConflictingWorkflows: Boolean(
|
|
1214
1282
|
state.flags['reap-conflicting-workflows'],
|
|
1215
1283
|
),
|
|
@@ -1232,7 +1300,7 @@ export function recordLedger(state) {
|
|
|
1232
1300
|
const manifestCtx = {
|
|
1233
1301
|
answers: state.answers,
|
|
1234
1302
|
skipGithub: Boolean(state.flags['skip-github']),
|
|
1235
|
-
|
|
1303
|
+
withQuality: state.withQuality === true,
|
|
1236
1304
|
};
|
|
1237
1305
|
const entries = buildMutationManifest(manifestCtx).filter((e) =>
|
|
1238
1306
|
appliedGroups.has(e.phaseGroup),
|
|
@@ -1384,7 +1452,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
1384
1452
|
`\n[Bootstrap] GitHub bootstrap failed: ${githubError}. ` +
|
|
1385
1453
|
'Project-side setup (labels are GitHub-side; the local .agentrc.json / ' +
|
|
1386
1454
|
'quality-gate / workflow files that were applied are recorded in the ' +
|
|
1387
|
-
'install ledger) completed, but the GitHub label/board/
|
|
1455
|
+
'install ledger) completed, but the GitHub label/board/protection ' +
|
|
1388
1456
|
'setup did not. Resolve the cause above (commonly `gh auth login` or a ' +
|
|
1389
1457
|
'missing repo/project scope) and re-run `mandrel bootstrap` — the run is ' +
|
|
1390
1458
|
'idempotent and will skip what already succeeded.',
|
|
@@ -94,14 +94,14 @@ export const MANIFEST_ENTRY_FIELDS = Object.freeze([
|
|
|
94
94
|
* platform; remote targets are scoped to the resolved `owner/repo` slug.
|
|
95
95
|
*
|
|
96
96
|
* The `github-admin` group is omitted when `ctx.skipGithub` is set, and the
|
|
97
|
-
* `quality-gates` group is
|
|
98
|
-
* preview reflects the same flags the executing pipeline honours.
|
|
97
|
+
* `quality-gates` group is included only when `ctx.withQuality` is true, so
|
|
98
|
+
* the preview reflects the same flags the executing pipeline honours.
|
|
99
99
|
*
|
|
100
100
|
* @param {object} [ctx]
|
|
101
101
|
* @param {{ owner?: string, repo?: string }} [ctx.answers] — scopes the
|
|
102
102
|
* `github-admin` targets to the `owner/repo` slug.
|
|
103
103
|
* @param {boolean} [ctx.skipGithub] — omit the `github-admin` group.
|
|
104
|
-
* @param {boolean} [ctx.
|
|
104
|
+
* @param {boolean} [ctx.withQuality] — include the `quality-gates` group.
|
|
105
105
|
* @returns {MutationManifestEntry[]}
|
|
106
106
|
*/
|
|
107
107
|
export function buildMutationManifest(ctx = {}) {
|
|
@@ -189,8 +189,8 @@ export function buildMutationManifest(ctx = {}) {
|
|
|
189
189
|
|
|
190
190
|
// --- quality-gates ----------------------------------------------------
|
|
191
191
|
// Stabilized quality-gate surface (husky pre-commit, quality npm
|
|
192
|
-
// scripts, .agentrc quality defaults).
|
|
193
|
-
if (
|
|
192
|
+
// scripts, .agentrc quality defaults). Included only when opted in.
|
|
193
|
+
if (ctx.withQuality) {
|
|
194
194
|
entries.push(
|
|
195
195
|
{
|
|
196
196
|
phaseGroup: PHASE_GROUPS.QUALITY_GATES,
|
|
@@ -724,7 +724,10 @@ export const BOOTSTRAP_PHASES = Object.freeze([
|
|
|
724
724
|
{
|
|
725
725
|
name: 'issueForms',
|
|
726
726
|
phaseGroup: PHASE_GROUPS.REPO_CONFIG,
|
|
727
|
-
run: (ctx) =>
|
|
727
|
+
run: (ctx) =>
|
|
728
|
+
ctx.withIssueForms === true
|
|
729
|
+
? ensureIssueFormsPhase(ctx)
|
|
730
|
+
: { skipped: true, reason: 'issue-forms-not-opted-in' },
|
|
728
731
|
},
|
|
729
732
|
{
|
|
730
733
|
name: 'sync',
|
|
@@ -742,9 +745,9 @@ export const BOOTSTRAP_PHASES = Object.freeze([
|
|
|
742
745
|
name: 'quality',
|
|
743
746
|
phaseGroup: PHASE_GROUPS.QUALITY_GATES,
|
|
744
747
|
run: (ctx) =>
|
|
745
|
-
ctx.
|
|
746
|
-
? {
|
|
747
|
-
:
|
|
748
|
+
ctx.withQuality === true
|
|
749
|
+
? applyQualityBootstrap({ projectRoot: ctx.projectRoot })
|
|
750
|
+
: { skipped: true, reason: 'quality-not-opted-in' },
|
|
748
751
|
},
|
|
749
752
|
{
|
|
750
753
|
name: 'winPerf',
|
|
@@ -844,7 +847,7 @@ export async function runPhases(phases, ctx) {
|
|
|
844
847
|
* @param {Set<string>} [ctx.approvedGroups] — when present, only phases
|
|
845
848
|
* whose `phaseGroup` is in this set execute (the consent-first gate from
|
|
846
849
|
* Story #3524); always-run infrastructure phases ignore it.
|
|
847
|
-
* @param {boolean} [ctx.
|
|
850
|
+
* @param {boolean} [ctx.withQuality]
|
|
848
851
|
* @param {boolean} [ctx.skipGithub]
|
|
849
852
|
* @param {boolean} [ctx.skipInstall]
|
|
850
853
|
* @param {boolean} [ctx.quiet]
|
|
@@ -57,12 +57,6 @@ export function printSummary(result) {
|
|
|
57
57
|
Logger.info(`Fields skipped: ${result.fields.skipped.length}`);
|
|
58
58
|
Logger.info(`Project: ${formatProjectSummary(result.project)}`);
|
|
59
59
|
Logger.info(`Status field: ${result.statusField.status}`);
|
|
60
|
-
const unavailableSuffix = result.views.unavailable
|
|
61
|
-
? ' (mutation unavailable)'
|
|
62
|
-
: '';
|
|
63
|
-
Logger.info(
|
|
64
|
-
`Views — created: ${result.views.created.length}, skipped: ${result.views.skipped.length}${unavailableSuffix}`,
|
|
65
|
-
);
|
|
66
60
|
Logger.info(
|
|
67
61
|
`Workflow audit: ${formatWorkflowAuditSummary(result.workflowAudit)}`,
|
|
68
62
|
);
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
* {@link reapConflictingWorkflows}.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
import { resolveProjectMeta } from '../orchestration/project-meta-resolver.js';
|
|
40
|
+
|
|
39
41
|
/**
|
|
40
42
|
* Workflows that **must not** be enabled when the orchestrator owns
|
|
41
43
|
* the Status column. Each entry writes Status as a side-effect of an
|
|
@@ -207,14 +209,23 @@ export async function reapConflictingWorkflows(args) {
|
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
/**
|
|
210
|
-
* Resolve a Project v2 node id from a project number
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
212
|
+
* Resolve a Project v2 node id from a project number. Used by the
|
|
213
|
+
* bootstrap CLI to convert the resolver's `projectNumber` into the node
|
|
214
|
+
* id required by {@link auditProjectWorkflows}.
|
|
215
|
+
*
|
|
216
|
+
* Walks the shared owner-type ladder — `organization(login:$owner)` →
|
|
217
|
+
* `user(login:$owner)` → `viewer` — via {@link resolveProjectMeta}, so an
|
|
218
|
+
* **org-owned** board resolves here the same way it does for `ColumnSync`
|
|
219
|
+
* (Story #4237). The owner login is read from `provider.projectOwner`
|
|
220
|
+
* (explicit board owner) and falls back to `provider.owner` (the repo
|
|
221
|
+
* owner) so org boards resolve even when no separate `projectOwner` is
|
|
222
|
+
* configured. Returns `null` when no owner scope can see the project
|
|
223
|
+
* (e.g. missing scope) so the caller can degrade gracefully.
|
|
216
224
|
*
|
|
217
|
-
* @param {{
|
|
225
|
+
* @param {{
|
|
226
|
+
* provider: { graphql: Function, owner?: string|null, projectOwner?: string|null },
|
|
227
|
+
* projectNumber: number,
|
|
228
|
+
* }} args
|
|
218
229
|
* @returns {Promise<string|null>}
|
|
219
230
|
*/
|
|
220
231
|
export async function resolveProjectIdByNumber(args) {
|
|
@@ -230,11 +241,13 @@ export async function resolveProjectIdByNumber(args) {
|
|
|
230
241
|
);
|
|
231
242
|
}
|
|
232
243
|
try {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
244
|
+
const project = await resolveProjectMeta({
|
|
245
|
+
provider,
|
|
246
|
+
owner: provider.projectOwner ?? provider.owner ?? null,
|
|
247
|
+
projectNumber,
|
|
248
|
+
projectFields: 'id',
|
|
249
|
+
});
|
|
250
|
+
return project?.id ?? null;
|
|
238
251
|
} catch {
|
|
239
252
|
return null;
|
|
240
253
|
}
|
|
@@ -156,40 +156,3 @@ export const PROJECT_FIELD_DEFS = [
|
|
|
156
156
|
* @type {string[]}
|
|
157
157
|
*/
|
|
158
158
|
export const STATUS_FIELD_OPTIONS = ['Todo', 'In Progress', 'Done'];
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Default Projects V2 saved Views. Filter strings follow GitHub's Projects
|
|
162
|
-
* search syntax (`label:`, `status:`, `assignee:`). Each is grouped by the
|
|
163
|
-
* Status field to match the board's columnar layout.
|
|
164
|
-
*
|
|
165
|
-
* GitHub's GraphQL surface does not expose a public `createProjectV2View`
|
|
166
|
-
* mutation, so bootstrap creates these via the REST Projects V2 views
|
|
167
|
-
* endpoint best-effort; when the endpoint is unavailable the views must be
|
|
168
|
-
* configured manually in the GitHub Projects UI.
|
|
169
|
-
*
|
|
170
|
-
* @type {Array<{ name: string, filter: string, groupBy: string,
|
|
171
|
-
* layout?: 'table'|'board'|'roadmap' }>}
|
|
172
|
-
*/
|
|
173
|
-
export const PROJECT_VIEW_DEFS = [
|
|
174
|
-
{
|
|
175
|
-
name: 'Mandrel Board',
|
|
176
|
-
filter: '',
|
|
177
|
-
groupBy: 'Status',
|
|
178
|
-
layout: 'board',
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
name: 'Epic Roadmap',
|
|
182
|
-
filter: 'label:type::epic',
|
|
183
|
-
groupBy: 'Status',
|
|
184
|
-
},
|
|
185
|
-
{
|
|
186
|
-
name: 'Active Stories',
|
|
187
|
-
filter: 'label:type::story -status:Done',
|
|
188
|
-
groupBy: 'Status',
|
|
189
|
-
},
|
|
190
|
-
{
|
|
191
|
-
name: 'My Queue',
|
|
192
|
-
filter: 'assignee:@me',
|
|
193
|
-
groupBy: 'Status',
|
|
194
|
-
},
|
|
195
|
-
];
|
|
@@ -62,23 +62,22 @@ function formatMissingList(missing) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Prompt text shown only on a TTY when asking to scaffold. */
|
|
65
|
-
const SCAFFOLD_PROMPT = '\nCreate placeholders? [
|
|
65
|
+
const SCAFFOLD_PROMPT = '\nCreate placeholders? [y/N]: ';
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Async y/N read from stdin via `node:readline` (mirrors the prompt mechanism
|
|
69
69
|
* in `bootstrap.js`). Returns on Enter and never blocks waiting for EOF the way
|
|
70
70
|
* `fs.readFileSync(0)` did — that EOF-blocking read hung `mandrel init` on an
|
|
71
|
-
* interactive TTY.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* passed here is empty.
|
|
71
|
+
* interactive TTY. No is the default (`[y/N]`): only an explicit `y`/`yes`
|
|
72
|
+
* resolves to `true` (create the placeholders). A bare Enter or any other input
|
|
73
|
+
* declines, matching the same default-off policy as `--with-issue-forms`. A
|
|
74
|
+
* read error resolves to `false` so a genuine I/O failure never writes
|
|
75
|
+
* unattended. The prompt text is written by the caller via `stdout`, so the
|
|
76
|
+
* question string passed here is empty.
|
|
78
77
|
*
|
|
79
78
|
* `terminal: false` is **load-bearing**: with terminal mode on (the default
|
|
80
79
|
* when stdout is a TTY) readline emits cursor-control escapes
|
|
81
|
-
* (`\x1b[1G\x1b[0J`) that erase the `Create placeholders? [
|
|
80
|
+
* (`\x1b[1G\x1b[0J`) that erase the `Create placeholders? [y/N]:` prompt already
|
|
82
81
|
* written via the caller's `stdout`, leaving the operator staring at a blank,
|
|
83
82
|
* dead-looking line. Disabling terminal mode preserves the pre-written prompt
|
|
84
83
|
* and reads the line via the TTY's cooked-mode echo. `createInterface` is
|
|
@@ -97,7 +96,7 @@ export async function readConfirm({
|
|
|
97
96
|
});
|
|
98
97
|
try {
|
|
99
98
|
const answer = (await rl.question('')).trim().toLowerCase();
|
|
100
|
-
return answer
|
|
99
|
+
return answer === 'y' || answer === 'yes';
|
|
101
100
|
} catch {
|
|
102
101
|
return false;
|
|
103
102
|
} finally {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { AGENT_LABELS } from '../label-constants.js';
|
|
38
|
+
import { resolveProjectMeta } from './project-meta-resolver.js';
|
|
38
39
|
|
|
39
40
|
export const LABEL_TO_COLUMN = Object.freeze({
|
|
40
41
|
[AGENT_LABELS.REVIEW_SPEC]: 'Todo',
|
|
@@ -141,49 +142,29 @@ export class ColumnSync {
|
|
|
141
142
|
async #loadMeta() {
|
|
142
143
|
if (this._meta !== null) return this._meta || null;
|
|
143
144
|
try {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
145
|
+
// Resolve the board by walking the owner-type ladder
|
|
146
|
+
// (organization → user → viewer) via the shared resolver so the
|
|
147
|
+
// org-owned path can't drift from `workflow-audit.js`. The Status
|
|
148
|
+
// single-select field is projected alongside the board id in one
|
|
149
|
+
// round-trip. (Story #4237; org-owner support extends the
|
|
150
|
+
// user/viewer ladder added in #3560.)
|
|
151
|
+
const project = await resolveProjectMeta({
|
|
152
|
+
provider: this.provider,
|
|
153
|
+
// Prefer the explicit `github.projectOwner`; fall back to the repo
|
|
154
|
+
// owner so an org-owned board still gets a login to scope
|
|
155
|
+
// `organization(login:)` / `user(login:)` by even when no separate
|
|
156
|
+
// projectOwner is configured. `viewer` is always the final rung.
|
|
157
|
+
owner: this.projectOwner ?? this.provider.owner ?? null,
|
|
158
|
+
projectNumber: this.projectNumber,
|
|
159
|
+
projectFields: `
|
|
160
|
+
id
|
|
161
|
+
field(name: "Status") {
|
|
162
|
+
... on ProjectV2SingleSelectField {
|
|
163
|
+
id
|
|
164
|
+
options { id name }
|
|
162
165
|
}
|
|
163
166
|
}`,
|
|
164
|
-
|
|
165
|
-
);
|
|
166
|
-
project = data?.user?.projectV2;
|
|
167
|
-
} else {
|
|
168
|
-
const data = await this.provider.graphql(
|
|
169
|
-
`
|
|
170
|
-
query($number: Int!) {
|
|
171
|
-
viewer {
|
|
172
|
-
projectV2(number: $number) {
|
|
173
|
-
id
|
|
174
|
-
field(name: "Status") {
|
|
175
|
-
... on ProjectV2SingleSelectField {
|
|
176
|
-
id
|
|
177
|
-
options { id name }
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}`,
|
|
183
|
-
{ number: this.projectNumber },
|
|
184
|
-
);
|
|
185
|
-
project = data?.viewer?.projectV2;
|
|
186
|
-
}
|
|
167
|
+
});
|
|
187
168
|
const field = project?.field;
|
|
188
169
|
if (!project || !field) {
|
|
189
170
|
this._meta = false;
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* @property {string} [reason] Structured reason code when allowed=false.
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
-
import { AGENT_LABELS } from '../label-constants.js';
|
|
48
|
+
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Execution-signal labels that block Close. Stored as a frozen Set for
|
|
@@ -245,6 +245,57 @@ export class LabelAllowListViolation extends Error {
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Error class thrown synchronously by `assertStoryTypeLabel` when a Story
|
|
250
|
+
* create operation carries no `type::story` label. Named distinctly from
|
|
251
|
+
* `LabelAllowListViolation` so callers can route it separately.
|
|
252
|
+
*
|
|
253
|
+
* The class carries structured metadata (`slug`, `title`) so the error
|
|
254
|
+
* message can name the offending Story clearly.
|
|
255
|
+
*/
|
|
256
|
+
export class MissingTypeLabelError extends Error {
|
|
257
|
+
/**
|
|
258
|
+
* @param {string} message
|
|
259
|
+
* @param {{slug?: string, title?: string}} [meta]
|
|
260
|
+
*/
|
|
261
|
+
constructor(message, meta = {}) {
|
|
262
|
+
super(message);
|
|
263
|
+
this.name = 'MissingTypeLabelError';
|
|
264
|
+
if (meta.slug !== undefined) this.slug = meta.slug;
|
|
265
|
+
if (meta.title !== undefined) this.title = meta.title;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Diff-time assertion. Throws `MissingTypeLabelError` synchronously when a
|
|
271
|
+
* Story create operation is missing the mandatory `type::story` label.
|
|
272
|
+
*
|
|
273
|
+
* Symmetric with `assertNoAgentLabels`: both fire at diff time so the plan
|
|
274
|
+
* fails loudly before the apply pipeline touches GitHub.
|
|
275
|
+
*
|
|
276
|
+
* Only validates Story create ops — Epic creates carry a different mandatory
|
|
277
|
+
* label (`type::epic`) that the caller already hard-codes at issue-creation
|
|
278
|
+
* time; the assertion is not needed there.
|
|
279
|
+
*
|
|
280
|
+
* @param {{slug?: string, title?: string, entity?: string, labels?: string[]}} op
|
|
281
|
+
* @returns {void}
|
|
282
|
+
*/
|
|
283
|
+
export function assertStoryTypeLabel(op) {
|
|
284
|
+
if (!op || typeof op !== 'object') return;
|
|
285
|
+
if (op.entity !== 'story') return;
|
|
286
|
+
// A create op for a Story MUST carry type::story. An absent or empty labels
|
|
287
|
+
// array means the mandatory label is missing — fail loud so the operator
|
|
288
|
+
// sees a named Story rather than a silent unlabeled issue on GitHub.
|
|
289
|
+
if (!Array.isArray(op.labels) || !op.labels.includes(TYPE_LABELS.STORY)) {
|
|
290
|
+
throw new MissingTypeLabelError(
|
|
291
|
+
`create plan for story slug=${op.slug ?? '?'} ("${op.title ?? ''}") is ` +
|
|
292
|
+
`missing the mandatory "${TYPE_LABELS.STORY}" label. Add it to the ` +
|
|
293
|
+
`spec's labels array for this Story and re-run.`,
|
|
294
|
+
{ slug: op.slug, title: op.title },
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
248
299
|
/**
|
|
249
300
|
* Diff-time assertion. Throws `LabelAllowListViolation` synchronously
|
|
250
301
|
* when an operation targets an `agent::*` label. The assertion is the
|
|
@@ -329,7 +380,10 @@ export function assertNoAgentLabels(op) {
|
|
|
329
380
|
*/
|
|
330
381
|
export function assertPlanLabelAllowList(plan) {
|
|
331
382
|
if (!plan || typeof plan !== 'object') return;
|
|
332
|
-
for (const op of plan.creates ?? [])
|
|
383
|
+
for (const op of plan.creates ?? []) {
|
|
384
|
+
assertNoAgentLabels(op);
|
|
385
|
+
assertStoryTypeLabel(op);
|
|
386
|
+
}
|
|
333
387
|
for (const op of plan.updates ?? []) assertNoAgentLabels(op);
|
|
334
388
|
// closes/relinks do not carry label payloads — nothing to assert.
|
|
335
389
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-meta-resolver — shared GitHub Projects v2 owner-resolution
|
|
3
|
+
* primitive (Story #4237).
|
|
4
|
+
*
|
|
5
|
+
* Background:
|
|
6
|
+
* Both `ColumnSync._loadMeta` (`lib/orchestration/column-sync.js`) and
|
|
7
|
+
* `resolveProjectIdByNumber` (`lib/bootstrap/workflow-audit.js`)
|
|
8
|
+
* needed to turn a `(owner, projectNumber)` pair into a Projects v2
|
|
9
|
+
* board node id. Each historically resolved only **user-owned** /
|
|
10
|
+
* `viewer`-owned boards: `viewer.projectV2(number:)` first, then
|
|
11
|
+
* `user(login:$owner).projectV2(number:)` (Story #3560). Neither had an
|
|
12
|
+
* `organization(login:$owner)` branch, so for an **org-owned** board
|
|
13
|
+
* every lookup failed with `NOT_FOUND` and the `agent::*` → board
|
|
14
|
+
* Status mirror silently no-oped (reproduced on `Beestera/swarm-os`).
|
|
15
|
+
*
|
|
16
|
+
* Fix:
|
|
17
|
+
* A single shared resolver that walks the owner-type ladder in order —
|
|
18
|
+
* `organization(login:$owner)` → `user(login:$owner)` → `viewer` —
|
|
19
|
+
* returning the first board it can resolve. Centralising the ladder in
|
|
20
|
+
* one place means the org path can never again drift between the two
|
|
21
|
+
* call sites.
|
|
22
|
+
*
|
|
23
|
+
* The resolver issues a sub-query for the project itself (`field(name:
|
|
24
|
+
* "Status") { … }` for the column-sync caller, or a bare `id` for the
|
|
25
|
+
* workflow-audit caller). Pass the desired projection in via
|
|
26
|
+
* `projectFields`; the resolver wraps it in the right owner scope and
|
|
27
|
+
* extracts the resolved `projectV2` node.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The owner-resolution ladder, in priority order. Each entry names the
|
|
32
|
+
* GraphQL root field and whether it requires the `$owner` variable.
|
|
33
|
+
*
|
|
34
|
+
* `organization` and `user` are keyed by `login: $owner`; `viewer` is the
|
|
35
|
+
* authenticated identity and takes no owner argument. The viewer rung is
|
|
36
|
+
* the historical default and stays last so a configured owner is always
|
|
37
|
+
* preferred over the ambient identity.
|
|
38
|
+
*/
|
|
39
|
+
const OWNER_SCOPES = Object.freeze([
|
|
40
|
+
{ root: 'organization', needsOwner: true },
|
|
41
|
+
{ root: 'user', needsOwner: true },
|
|
42
|
+
{ root: 'viewer', needsOwner: false },
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the GraphQL document for a single owner scope.
|
|
47
|
+
*
|
|
48
|
+
* @param {{ root: string, needsOwner: boolean }} scope
|
|
49
|
+
* @param {string} projectFields — the inner `projectV2(number: $number) { … }`
|
|
50
|
+
* selection body (everything between the braces).
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function buildScopedQuery(scope, projectFields) {
|
|
54
|
+
if (scope.needsOwner) {
|
|
55
|
+
return `
|
|
56
|
+
query($owner: String!, $number: Int!) {
|
|
57
|
+
${scope.root}(login: $owner) {
|
|
58
|
+
projectV2(number: $number) {
|
|
59
|
+
${projectFields}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}`;
|
|
63
|
+
}
|
|
64
|
+
return `
|
|
65
|
+
query($number: Int!) {
|
|
66
|
+
${scope.root} {
|
|
67
|
+
projectV2(number: $number) {
|
|
68
|
+
${projectFields}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a Projects v2 board node by walking the owner-type ladder.
|
|
76
|
+
*
|
|
77
|
+
* Tries `organization(login:$owner)` → `user(login:$owner)` → `viewer` in
|
|
78
|
+
* order, returning the first non-null `projectV2` node. A scope that
|
|
79
|
+
* throws (e.g. GitHub returns `NOT_FOUND` for the wrong owner type) or
|
|
80
|
+
* resolves to `null` is treated as a miss and the ladder advances to the
|
|
81
|
+
* next rung. Returns `null` when every rung misses.
|
|
82
|
+
*
|
|
83
|
+
* When `owner` is falsy, only the `viewer` rung is attempted (there is no
|
|
84
|
+
* login to scope `organization`/`user` by) — this preserves the original
|
|
85
|
+
* viewer-only behaviour for callers that never configured a project owner.
|
|
86
|
+
*
|
|
87
|
+
* @param {{
|
|
88
|
+
* provider: { graphql: Function },
|
|
89
|
+
* owner?: string | null,
|
|
90
|
+
* projectNumber: number,
|
|
91
|
+
* projectFields: string,
|
|
92
|
+
* }} args
|
|
93
|
+
* @returns {Promise<object|null>} the resolved `projectV2` node, or null.
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveProjectMeta(args) {
|
|
96
|
+
const { provider, owner, projectNumber, projectFields } = args ?? {};
|
|
97
|
+
if (!provider || typeof provider.graphql !== 'function') {
|
|
98
|
+
throw new TypeError('resolveProjectMeta requires a provider with graphql');
|
|
99
|
+
}
|
|
100
|
+
if (typeof projectFields !== 'string' || projectFields.length === 0) {
|
|
101
|
+
throw new TypeError(
|
|
102
|
+
'resolveProjectMeta requires a projectFields selection',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const scope of OWNER_SCOPES) {
|
|
107
|
+
// Skip the owner-scoped rungs when no owner login is available.
|
|
108
|
+
if (scope.needsOwner && !owner) continue;
|
|
109
|
+
|
|
110
|
+
const query = buildScopedQuery(scope, projectFields);
|
|
111
|
+
const vars = scope.needsOwner
|
|
112
|
+
? { owner, number: projectNumber }
|
|
113
|
+
: { number: projectNumber };
|
|
114
|
+
|
|
115
|
+
let data;
|
|
116
|
+
try {
|
|
117
|
+
data = await provider.graphql(query, vars);
|
|
118
|
+
} catch {
|
|
119
|
+
// Wrong owner type (NOT_FOUND), missing scope, etc. — advance the
|
|
120
|
+
// ladder rather than aborting the whole resolution.
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const node = data?.[scope.root]?.projectV2;
|
|
125
|
+
if (node) return node;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* GitHub Provider — ProjectBoardGateway.
|
|
3
3
|
*
|
|
4
4
|
* Owns the Projects V2 bootstrap surface: `resolveOrCreateProject`,
|
|
5
|
-
* `ensureStatusField`, `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
5
|
+
* `ensureStatusField`, `ensureProjectFields`. The low-level GraphQL
|
|
6
|
+
* mutations live in `./projects-v2-graphql.js`; this class threads the
|
|
7
|
+
* parent provider's `_ctx` (which carries `projectNumber`, `projectOwner`,
|
|
8
|
+
* `state`, and the shared cache) into each call so the legacy shim contract
|
|
9
|
+
* is preserved.
|
|
10
10
|
*
|
|
11
11
|
* Extracted from `../github.js` in Story #2462 / Task #2479. Public
|
|
12
12
|
* surface on `GitHubProvider` is unchanged — every project-board method
|
|
@@ -36,10 +36,6 @@ export class ProjectBoardGateway {
|
|
|
36
36
|
return projects.ensureStatusField(this._ctx, optionNames);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
async ensureProjectViews(viewDefs) {
|
|
40
|
-
return projects.ensureProjectViews(this._ctx, viewDefs);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
39
|
/* node:coverage ignore next */
|
|
44
40
|
async ensureProjectFields(fieldDefs) {
|
|
45
41
|
return projects.ensureProjectFields(this._ctx, fieldDefs);
|
|
@@ -21,14 +21,8 @@ const Q_PROJ = (scope, fields) =>
|
|
|
21
21
|
const M_PROJ = `mutation($ownerId:ID!,$title:String!){createProjectV2(input:{ownerId:$ownerId,title:$title}){projectV2{id number}}}`;
|
|
22
22
|
const M_FIELD = `mutation($projectId:ID!,$name:String!,$options:[ProjectV2SingleSelectFieldOptionInput!]!){createProjectV2Field(input:{projectId:$projectId,dataType:SINGLE_SELECT,name:$name,singleSelectOptions:$options}){projectV2Field{... on ProjectV2SingleSelectField{id name}}}}`;
|
|
23
23
|
const M_UPDATE = `mutation($fieldId:ID!,$name:String!,$options:[ProjectV2SingleSelectFieldOptionInput!]!){updateProjectV2Field(input:{fieldId:$fieldId,name:$name,singleSelectOptions:$options}){projectV2Field{... on ProjectV2SingleSelectField{id name}}}}`;
|
|
24
|
-
// Projects V2 view creation uses the REST API — the GraphQL
|
|
25
|
-
// `createProjectV2View` mutation is not generally available. Endpoints:
|
|
26
|
-
// org-owned: POST /orgs/{org}/projectsV2/{number}/views ({org} login)
|
|
27
|
-
// user-owned: POST /users/{user_id}/projectsV2/{number}/views (numeric id)
|
|
28
|
-
const REST_API_VERSION = '2026-03-10';
|
|
29
24
|
const M_ITEM = `mutation($projectId:ID!,$contentId:ID!){addProjectV2ItemById(input:{projectId:$projectId,contentId:$contentId}){item{id}}}`;
|
|
30
25
|
const F_STATUS = `id fields(first:50){nodes{... on ProjectV2SingleSelectField{id name options{id name}}}}`;
|
|
31
|
-
const F_VIEWS = `id views(first:50){nodes{name}}`;
|
|
32
26
|
const F_FIELDS = `id fields(first:50){nodes{... on ProjectV2Field{name} ... on ProjectV2IterationField{name} ... on ProjectV2SingleSelectField{name}}}`;
|
|
33
27
|
const SCOPES_RE =
|
|
34
28
|
/INSUFFICIENT_SCOPES|Resource not accessible by personal access token|your token has not been granted the required scopes/i;
|
|
@@ -106,97 +100,6 @@ async function gql(ctx, query, variables, { retry = false } = {}) {
|
|
|
106
100
|
return retry ? withTransientRetry(run) : run();
|
|
107
101
|
}
|
|
108
102
|
|
|
109
|
-
/**
|
|
110
|
-
* Issue a REST request against api.github.com, reusing the same token and
|
|
111
|
-
* fetch seam as `gql`. Throws on non-2xx with the response body for context.
|
|
112
|
-
* Pass `{ retry: true }` to retry transient network blips (idempotent calls).
|
|
113
|
-
*/
|
|
114
|
-
async function rest(ctx, method, apiPath, body, { retry = false } = {}) {
|
|
115
|
-
const run = async () => {
|
|
116
|
-
const fetchImpl = ctx.fetchImpl ?? globalThis.fetch;
|
|
117
|
-
const response = await fetchImpl(`https://api.github.com${apiPath}`, {
|
|
118
|
-
method,
|
|
119
|
-
headers: {
|
|
120
|
-
Accept: 'application/vnd.github+json',
|
|
121
|
-
Authorization: `Bearer ${ctx.token ?? resolveToken()}`,
|
|
122
|
-
'Content-Type': 'application/json',
|
|
123
|
-
'User-Agent': 'node.js',
|
|
124
|
-
'X-GitHub-Api-Version': REST_API_VERSION,
|
|
125
|
-
},
|
|
126
|
-
...(body ? { body: JSON.stringify(body) } : {}),
|
|
127
|
-
});
|
|
128
|
-
if (!response.ok) {
|
|
129
|
-
const text = await response.text().catch(() => '');
|
|
130
|
-
const err = new Error(
|
|
131
|
-
`[GitHubProvider] REST ${method} ${apiPath} → ${response.status}: ${text}`,
|
|
132
|
-
);
|
|
133
|
-
err.status = response.status;
|
|
134
|
-
throw err;
|
|
135
|
-
}
|
|
136
|
-
return response.json().catch(() => ({}));
|
|
137
|
-
};
|
|
138
|
-
return retry ? withTransientRetry(run) : run();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Resolve an owner login to its account type and numeric id via
|
|
143
|
-
* `GET /users/{login}` (which serves both users and orgs). The REST views
|
|
144
|
-
* endpoint keys orgs by login but users by numeric id, so we need both.
|
|
145
|
-
*/
|
|
146
|
-
async function resolveOwnerAccount(ctx, owner) {
|
|
147
|
-
const data = await rest(
|
|
148
|
-
ctx,
|
|
149
|
-
'GET',
|
|
150
|
-
`/users/${encodeURIComponent(owner)}`,
|
|
151
|
-
undefined,
|
|
152
|
-
{ retry: true },
|
|
153
|
-
);
|
|
154
|
-
return { id: data?.id ?? null, type: data?.type ?? null };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Build the candidate REST views endpoints to try, in order. Orgs key by
|
|
159
|
-
* login. For user-owned projects the docs label the path param `{user_id}`
|
|
160
|
-
* but it's ambiguous (numeric id vs login) and the numeric form was observed
|
|
161
|
-
* to 404 — so we try the login first (mirroring the org endpoint) then fall
|
|
162
|
-
* back to the numeric id, treating a 404 as "wrong param, try the next".
|
|
163
|
-
*/
|
|
164
|
-
function viewsEndpoints(account, owner, projectNumber) {
|
|
165
|
-
if (account.type === 'Organization') {
|
|
166
|
-
return [
|
|
167
|
-
`/orgs/${encodeURIComponent(owner)}/projectsV2/${projectNumber}/views`,
|
|
168
|
-
];
|
|
169
|
-
}
|
|
170
|
-
const candidates = [];
|
|
171
|
-
if (owner) {
|
|
172
|
-
candidates.push(
|
|
173
|
-
`/users/${encodeURIComponent(owner)}/projectsV2/${projectNumber}/views`,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
if (account.id != null) {
|
|
177
|
-
candidates.push(`/users/${account.id}/projectsV2/${projectNumber}/views`);
|
|
178
|
-
}
|
|
179
|
-
return candidates;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* POST a view to the first candidate endpoint that does not 404. A 404 means
|
|
184
|
-
* the path param shape was wrong (login vs numeric id) — try the next. Any
|
|
185
|
-
* other status is a real failure and propagates.
|
|
186
|
-
*/
|
|
187
|
-
async function createView(ctx, endpoints, body) {
|
|
188
|
-
let lastError = null;
|
|
189
|
-
for (const endpoint of endpoints) {
|
|
190
|
-
try {
|
|
191
|
-
return await rest(ctx, 'POST', endpoint, body, { retry: true });
|
|
192
|
-
} catch (err) {
|
|
193
|
-
lastError = err;
|
|
194
|
-
if (err.status !== 404) throw err;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
throw lastError ?? new Error('[GitHubProvider] No views endpoint available.');
|
|
198
|
-
}
|
|
199
|
-
|
|
200
103
|
async function lookupProject(ctx, fragment, strict = false) {
|
|
201
104
|
if (!ctx.projectNumber) return null;
|
|
202
105
|
let lastError = null;
|
|
@@ -357,75 +260,6 @@ export async function ensureStatusField(ctx, optionNames) {
|
|
|
357
260
|
}
|
|
358
261
|
}
|
|
359
262
|
|
|
360
|
-
export async function ensureProjectViews(ctx, viewDefs) {
|
|
361
|
-
if (!ctx.projectNumber)
|
|
362
|
-
throw new Error(
|
|
363
|
-
'[GitHubProvider] ensureProjectViews requires projectNumber.',
|
|
364
|
-
);
|
|
365
|
-
const created = [],
|
|
366
|
-
skipped = [];
|
|
367
|
-
let project;
|
|
368
|
-
try {
|
|
369
|
-
project = await lookupProject(ctx, F_VIEWS, true);
|
|
370
|
-
} catch {
|
|
371
|
-
return {
|
|
372
|
-
created,
|
|
373
|
-
skipped: viewDefs.map((view) => view.name),
|
|
374
|
-
unavailable: true,
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
if (!project)
|
|
378
|
-
throw new Error(
|
|
379
|
-
`[GitHubProvider] Project #${ctx.projectNumber} not found for ${ctx.projectOwner}.`,
|
|
380
|
-
);
|
|
381
|
-
const existingViewNames = new Set(
|
|
382
|
-
(project.views?.nodes ?? []).map((view) => view?.name).filter(Boolean),
|
|
383
|
-
);
|
|
384
|
-
|
|
385
|
-
// Resolve the owner account once to pick the right REST endpoint shape.
|
|
386
|
-
let account;
|
|
387
|
-
try {
|
|
388
|
-
account = await resolveOwnerAccount(ctx, ctx.projectOwner);
|
|
389
|
-
} catch (err) {
|
|
390
|
-
return {
|
|
391
|
-
created,
|
|
392
|
-
skipped: viewDefs.map((view) => view.name),
|
|
393
|
-
unavailable: true,
|
|
394
|
-
error: err.message,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const endpoints = viewsEndpoints(
|
|
399
|
-
account,
|
|
400
|
-
ctx.projectOwner,
|
|
401
|
-
ctx.projectNumber,
|
|
402
|
-
);
|
|
403
|
-
let unavailable = false;
|
|
404
|
-
let error;
|
|
405
|
-
for (const def of viewDefs) {
|
|
406
|
-
if (existingViewNames.has(def.name) || unavailable) {
|
|
407
|
-
skipped.push(def.name);
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
try {
|
|
411
|
-
await createView(ctx, endpoints, {
|
|
412
|
-
name: def.name,
|
|
413
|
-
// PROJECT_VIEW_DEFS predate REST layouts; the GraphQL path always
|
|
414
|
-
// created board views, so default to 'board' (override via
|
|
415
|
-
// `def.layout` = 'table' | 'board' | 'roadmap').
|
|
416
|
-
layout: def.layout ?? 'board',
|
|
417
|
-
...(def.filter ? { filter: def.filter } : {}),
|
|
418
|
-
});
|
|
419
|
-
created.push(def.name);
|
|
420
|
-
} catch (err) {
|
|
421
|
-
unavailable = true;
|
|
422
|
-
error = err.message;
|
|
423
|
-
skipped.push(def.name);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
return { created, skipped, unavailable, ...(error ? { error } : {}) };
|
|
427
|
-
}
|
|
428
|
-
|
|
429
263
|
export async function ensureProjectFields(ctx, fieldDefs) {
|
|
430
264
|
if (!ctx.projectNumber) return { created: [], skipped: [] };
|
|
431
265
|
const project = await lookupProject(ctx, F_FIELDS);
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { parseBlockedBy, parseBlocks } from '../../lib/dependency-parser.js';
|
|
25
25
|
import { Logger } from '../../lib/Logger.js';
|
|
26
|
+
import { TYPE_LABELS } from '../../lib/label-constants.js';
|
|
26
27
|
import { addIssueToBoard } from './board-add.js';
|
|
27
28
|
import { createInlineTicketCache } from './cache.js';
|
|
28
29
|
import { withTransientRetry } from './errors.js';
|
|
@@ -318,13 +319,21 @@ export class TicketGateway {
|
|
|
318
319
|
dependencies: ticketData.dependencies ?? [],
|
|
319
320
|
});
|
|
320
321
|
|
|
322
|
+
// Mirror the Epic create path (issues.js:160 → `labels: TYPE_LABELS.EPIC`):
|
|
323
|
+
// always inject TYPE_LABELS.STORY so a spec that omits the labels array
|
|
324
|
+
// cannot produce an unlabeled, undispatchable Story. Dedupe to avoid
|
|
325
|
+
// duplicates when the caller already carries the label.
|
|
326
|
+
const callerLabels = ticketData.labels ?? [];
|
|
327
|
+
const labels = callerLabels.includes(TYPE_LABELS.STORY)
|
|
328
|
+
? callerLabels
|
|
329
|
+
: [TYPE_LABELS.STORY, ...callerLabels];
|
|
321
330
|
const result = await this._gh.api({
|
|
322
331
|
method: 'POST',
|
|
323
332
|
endpoint: `/repos/${this.owner}/${this.repo}/issues`,
|
|
324
333
|
body: {
|
|
325
334
|
title: ticketData.title,
|
|
326
335
|
body: renderedBody,
|
|
327
|
-
labels
|
|
336
|
+
labels,
|
|
328
337
|
},
|
|
329
338
|
});
|
|
330
339
|
const issue = parseApiJson(result);
|
|
@@ -130,7 +130,6 @@ const DELEGATIONS = [
|
|
|
130
130
|
['setMergeMethods', 'mergeMethods.setMergeMethods'],
|
|
131
131
|
['resolveOrCreateProject', 'projectBoard.resolveOrCreateProject'],
|
|
132
132
|
['ensureStatusField', 'projectBoard.ensureStatusField'],
|
|
133
|
-
['ensureProjectViews', 'projectBoard.ensureProjectViews'],
|
|
134
133
|
['ensureProjectFields', 'projectBoard.ensureProjectFields'],
|
|
135
134
|
];
|
|
136
135
|
for (const [name, target] of DELEGATIONS) {
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.74.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.73.0...mandrel-v1.74.0) (2026-06-19)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
* minimize install footprint: drop Project views, make the board + custom-fields + issue-forms decoration opt-in (default off) ([#4234](https://github.com/dsj1984/mandrel/issues/4234)) ([#4235](https://github.com/dsj1984/mandrel/issues/4235)) ([9c8acf6](https://github.com/dsj1984/mandrel/commit/9c8acf6f8b324d3d2c221031f636680c823d5c2a))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **bootstrap:** make quality gates and docs-stub scaffold opt-in and default-off ([#4240](https://github.com/dsj1984/mandrel/issues/4240)) ([e41da73](https://github.com/dsj1984/mandrel/commit/e41da7343e7fc1b55d1e1749f2e532b231426766))
|
|
16
|
+
* **decompose:** enforce/default the mandatory type::story label on Story create → no more unlabeled, undispatchable Stories ([#4241](https://github.com/dsj1984/mandrel/issues/4241)) ([#4242](https://github.com/dsj1984/mandrel/issues/4242)) ([14d3752](https://github.com/dsj1984/mandrel/commit/14d3752f0bf8ed1133508e5c1955e60afb8d8a24))
|
|
17
|
+
* **projects:** resolve organization-owned Projects v2 boards (refs [#4237](https://github.com/dsj1984/mandrel/issues/4237)) ([#4238](https://github.com/dsj1984/mandrel/issues/4238)) ([3c033c3](https://github.com/dsj1984/mandrel/commit/3c033c36a03ca68c94be0cfbee991ef33e7fa14d))
|
|
18
|
+
|
|
5
19
|
## [1.73.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.72.0...mandrel-v1.73.0) (2026-06-17)
|
|
6
20
|
|
|
7
21
|
|
package/package.json
CHANGED