mandrel 1.73.0 → 1.75.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.
@@ -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
- let project;
145
- if (this.projectOwner) {
146
- // When the project is owned by a different account than the
147
- // authenticated viewer, `viewer.projectV2` returns null. Use
148
- // `user(login: $owner).projectV2` instead. (Story #3560)
149
- const data = await this.provider.graphql(
150
- `
151
- query($owner: String!, $number: Int!) {
152
- user(login: $owner) {
153
- projectV2(number: $number) {
154
- id
155
- field(name: "Status") {
156
- ... on ProjectV2SingleSelectField {
157
- id
158
- options { id name }
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
- { owner: this.projectOwner, number: this.projectNumber },
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 ?? []) assertNoAgentLabels(op);
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`, `ensureProjectViews`, `ensureProjectFields`. The
6
- * low-level GraphQL mutations live in `./projects-v2-graphql.js`; this
7
- * class threads the parent provider's `_ctx` (which carries `projectNumber`,
8
- * `projectOwner`, `state`, and the shared cache) into each call so the
9
- * legacy shim contract is preserved.
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: ticketData.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) {