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.
- package/.agents/README.md +9 -5
- package/.agents/instructions.md +9 -0
- 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/.agents/scripts/sync-claude-commands.js +73 -22
- package/docs/CHANGELOG.md +21 -0
- package/package.json +1 -1
|
@@ -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) {
|