@dev-loops/core 1.0.4-pre.0 → 1.0.4-pre.1
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/package.json +1 -1
- package/src/analysis/change-classifier.mjs +16 -30
- package/src/analysis/diff-analyzer.mjs +38 -15
- package/src/claude/asset-generation.mjs +36 -1
- package/src/config/config.mjs +143 -81
- package/src/config/extension-defaults.yaml +18 -6
- package/src/github/copilot-helpers.mjs +22 -8
- package/src/github/gh.mjs +14 -1
- package/src/github/review-threads.mjs +6 -0
- package/src/loop/copilot-loop-state.mjs +38 -2
- package/src/loop/finding-cluster.mjs +9 -9
- package/src/loop/gate-fanin.mjs +21 -0
- package/src/loop/main-checkout-ff.mjs +169 -17
- package/src/loop/merge-approval.mjs +87 -22
- package/src/loop/pr-gate-coordination.mjs +270 -60
- package/src/loop/spec-authority.mjs +21 -0
- package/src/projects/move-queue-item.mjs +5 -79
- package/src/projects/projects-access.mjs +124 -0
|
@@ -3,7 +3,7 @@ import { runPickupRefinementGate } from "../loop/issue-refinement-artifact.mjs";
|
|
|
3
3
|
import { loadStateColumnMap, LOGICAL_COLUMN } from "../loop/queue-board-sync.mjs";
|
|
4
4
|
import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
|
|
5
5
|
import { ghGraphql, resolveOwner } from "../github/gh.mjs";
|
|
6
|
-
import { validateProjectsRepo, discoverProjects, listProjectFields,
|
|
6
|
+
import { validateProjectsRepo, discoverProjects, listProjectFields, extractStatus, resolveProjectItem } from "./projects-access.mjs";
|
|
7
7
|
|
|
8
8
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
9
9
|
|
|
@@ -11,33 +11,6 @@ const validateRepo = validateProjectsRepo;
|
|
|
11
11
|
|
|
12
12
|
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
const GET_PROJECT_ITEMS_BY_CONTENT = [
|
|
15
|
-
"query($projectId:ID!, $after:String) {",
|
|
16
|
-
" node(id:$projectId) {",
|
|
17
|
-
" ... on ProjectV2 {",
|
|
18
|
-
" items(first:100, after:$after, orderBy:{field:POSITION, direction:ASC}) {",
|
|
19
|
-
" pageInfo { hasNextPage endCursor }",
|
|
20
|
-
" nodes {",
|
|
21
|
-
" id",
|
|
22
|
-
" fieldValues(first:20) {",
|
|
23
|
-
" nodes {",
|
|
24
|
-
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
25
|
-
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
26
|
-
" name",
|
|
27
|
-
" }",
|
|
28
|
-
" }",
|
|
29
|
-
" }",
|
|
30
|
-
" content {",
|
|
31
|
-
" ... on Issue { __typename number repository { nameWithOwner } }",
|
|
32
|
-
" ... on PullRequest { __typename number repository { nameWithOwner } }",
|
|
33
|
-
" }",
|
|
34
|
-
" }",
|
|
35
|
-
" }",
|
|
36
|
-
" }",
|
|
37
|
-
" }",
|
|
38
|
-
"}"
|
|
39
|
-
].join("\n");
|
|
40
|
-
|
|
41
14
|
const UPDATE_ITEM_FIELD = [
|
|
42
15
|
"mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) {",
|
|
43
16
|
" updateProjectV2ItemFieldValue(input:{projectId:$projectId, itemId:$itemId, fieldId:$fieldId, value:{singleSelectOptionId:$optionId}}) {",
|
|
@@ -53,19 +26,6 @@ const UPDATE_ITEM_FIELD = [
|
|
|
53
26
|
const listAllProjects = discoverProjects;
|
|
54
27
|
const listAllFields = listProjectFields;
|
|
55
28
|
|
|
56
|
-
// ── Paginated item listing (position order) ──────────────────────────────
|
|
57
|
-
|
|
58
|
-
function fetchAllItems(projectId, env, runChild) {
|
|
59
|
-
return paginateNodes({
|
|
60
|
-
query: GET_PROJECT_ITEMS_BY_CONTENT,
|
|
61
|
-
variables: { projectId },
|
|
62
|
-
selectConnection: (payload) => payload?.data?.node?.items,
|
|
63
|
-
env,
|
|
64
|
-
runChild,
|
|
65
|
-
entity: "items",
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
29
|
const statusOf = extractStatus;
|
|
70
30
|
|
|
71
31
|
// ── Exit code classification ────────────────────────────────────────────
|
|
@@ -84,7 +44,7 @@ function classifyExitCode(err) {
|
|
|
84
44
|
async function main(args, { env = process.env, runChild, cwd = null } = {}) {
|
|
85
45
|
const child = runChild ?? _runChild;
|
|
86
46
|
const repo = validateRepo(args.repo);
|
|
87
|
-
const [owner
|
|
47
|
+
const [owner] = repo.split("/");
|
|
88
48
|
const selector = resolveProjectSelector(args);
|
|
89
49
|
const itemRef = parseItemRef(args.item);
|
|
90
50
|
const toColumn = (args.toColumn ?? "").trim();
|
|
@@ -122,43 +82,9 @@ async function main(args, { env = process.env, runChild, cwd = null } = {}) {
|
|
|
122
82
|
);
|
|
123
83
|
}
|
|
124
84
|
|
|
125
|
-
// 4. Find the item
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
// BOTH ref kinds against it. This reuses the proven pattern from
|
|
129
|
-
// reorder-queue-item / list-queue-items: a node-id ref matches by item.id, a
|
|
130
|
-
// number ref matches by content.number. Both are scoped to the requested repo
|
|
131
|
-
// so a cross-project ref fails closed with ITEM_NOT_FOUND. (The previous code
|
|
132
|
-
// used `ProjectV2.item` — a field that does not exist — for the node-id path,
|
|
133
|
-
// and a single non-paginated `items(first:10)` page for the number path, so it
|
|
134
|
-
// could not find items beyond the first page.)
|
|
135
|
-
const allItems = await fetchAllItems(project.id, env, child);
|
|
136
|
-
|
|
137
|
-
let match;
|
|
138
|
-
if (itemRef.kind === "id") {
|
|
139
|
-
match = allItems.find(
|
|
140
|
-
(it) => it.id === itemRef.value && it.content?.repository?.nameWithOwner === repo,
|
|
141
|
-
);
|
|
142
|
-
if (!match) {
|
|
143
|
-
throw Object.assign(
|
|
144
|
-
new Error(`Item "${itemRef.value}" not found in project "${project.title}" for repo "${repo}"`),
|
|
145
|
-
{ code: "ITEM_NOT_FOUND" },
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
} else {
|
|
149
|
-
match = allItems.find(
|
|
150
|
-
(it) =>
|
|
151
|
-
it.content &&
|
|
152
|
-
it.content.repository?.nameWithOwner === repo &&
|
|
153
|
-
it.content.number === itemRef.value,
|
|
154
|
-
);
|
|
155
|
-
if (!match) {
|
|
156
|
-
throw Object.assign(
|
|
157
|
-
new Error(`Item #${itemRef.value} not found in project "${project.title}" for repo "${repo}"`),
|
|
158
|
-
{ code: "ITEM_NOT_FOUND" },
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
85
|
+
// 4. Find the item from the issue side or by node, never from the board
|
|
86
|
+
// listing: `ProjectV2.items` can lag behind GitHub and omit new items.
|
|
87
|
+
const match = await resolveProjectItem({ projectId: project.id, projectTitle: project.title, repo, itemRef, env, runChild: child });
|
|
162
88
|
|
|
163
89
|
const itemId = match.id;
|
|
164
90
|
const previousColumn = statusOf(match);
|
|
@@ -200,3 +200,127 @@ export function extractStatus(node) {
|
|
|
200
200
|
}
|
|
201
201
|
return null;
|
|
202
202
|
}
|
|
203
|
+
|
|
204
|
+
// ── Single item resolution ─────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
// Item projection shared by both lookups. It matches the board listing node
|
|
207
|
+
// shape (id, fieldValues, content) so `extractStatus` works on the result.
|
|
208
|
+
const ITEM_FIELDS = [
|
|
209
|
+
"id isArchived project { id }",
|
|
210
|
+
"fieldValues(first:20) {",
|
|
211
|
+
" nodes {",
|
|
212
|
+
" ... on ProjectV2ItemFieldSingleSelectValue {",
|
|
213
|
+
" field { ... on ProjectV2SingleSelectField { id name } }",
|
|
214
|
+
" name",
|
|
215
|
+
" }",
|
|
216
|
+
" }",
|
|
217
|
+
"}",
|
|
218
|
+
"content {",
|
|
219
|
+
" ... on Issue { __typename number repository { nameWithOwner } }",
|
|
220
|
+
" ... on PullRequest { __typename number repository { nameWithOwner } }",
|
|
221
|
+
"}",
|
|
222
|
+
].join("\n");
|
|
223
|
+
|
|
224
|
+
const GET_ITEMS_BY_CONTENT_NUMBER = [
|
|
225
|
+
"query($owner:String!, $name:String!, $number:Int!, $after:String) {",
|
|
226
|
+
" repository(owner:$owner, name:$name) {",
|
|
227
|
+
" issueOrPullRequest(number:$number) {",
|
|
228
|
+
` ... on Issue { projectItems(first:100, after:$after, includeArchived:false) { pageInfo { hasNextPage endCursor } nodes { ${ITEM_FIELDS} } } }`,
|
|
229
|
+
` ... on PullRequest { projectItems(first:100, after:$after, includeArchived:false) { pageInfo { hasNextPage endCursor } nodes { ${ITEM_FIELDS} } } }`,
|
|
230
|
+
" }",
|
|
231
|
+
" }",
|
|
232
|
+
"}",
|
|
233
|
+
].join("\n");
|
|
234
|
+
|
|
235
|
+
const GET_ITEM_BY_ID = [
|
|
236
|
+
"query($id:ID!) {",
|
|
237
|
+
` node(id:$id) { ... on ProjectV2Item { ${ITEM_FIELDS} } }`,
|
|
238
|
+
"}",
|
|
239
|
+
].join("\n");
|
|
240
|
+
|
|
241
|
+
function itemNotFound(message) {
|
|
242
|
+
return Object.assign(new Error(message), { code: "ITEM_NOT_FOUND" });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Throw GRAPHQL_ERROR for any GraphQL error in `payload` other than NOT_FOUND.
|
|
247
|
+
* Use it on a `ghGraphql(..., { allowErrors: true })` payload where NOT_FOUND
|
|
248
|
+
* means "no such entity" and every other error must keep its message.
|
|
249
|
+
*/
|
|
250
|
+
export function assertOnlyNotFoundErrors(payload) {
|
|
251
|
+
const other = (payload?.errors ?? []).filter((e) => e?.type !== "NOT_FOUND");
|
|
252
|
+
if (other.length > 0) {
|
|
253
|
+
throw Object.assign(
|
|
254
|
+
new Error(`GraphQL errors: ${other.map((e) => e.message).join("; ")}`),
|
|
255
|
+
{ code: "GRAPHQL_ERROR" },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve one project item without the whole-board `ProjectV2.items` listing,
|
|
262
|
+
* which can lag behind GitHub by hours and omit newly added items.
|
|
263
|
+
*
|
|
264
|
+
* - number ref: read the issue or PR's own `projectItems` and pick the
|
|
265
|
+
* unarchived item on `projectId`.
|
|
266
|
+
* - id ref: look the item node up directly, then verify that it belongs to
|
|
267
|
+
* `projectId` and that its content is in `repo`.
|
|
268
|
+
*
|
|
269
|
+
* Every miss or mismatch fails closed with code ITEM_NOT_FOUND. Returns a node
|
|
270
|
+
* in the listing shape: `{ id, isArchived, project, fieldValues, content }`.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} opts
|
|
273
|
+
* @param {string} opts.projectId
|
|
274
|
+
* @param {string} [opts.projectTitle] shown in ITEM_NOT_FOUND messages when present
|
|
275
|
+
* @param {string} opts.repo validated `owner/name`
|
|
276
|
+
* @param {{kind:"number"|"id", value:number|string}} opts.itemRef from parseItemRef
|
|
277
|
+
* @param {object} opts.env
|
|
278
|
+
* @param {Function} opts.runChild
|
|
279
|
+
*/
|
|
280
|
+
export async function resolveProjectItem({ projectId, projectTitle, repo, itemRef, env, runChild }) {
|
|
281
|
+
const projectLabel = projectTitle ?? projectId;
|
|
282
|
+
if (itemRef.kind === "number") {
|
|
283
|
+
const [owner, name] = repo.split("/");
|
|
284
|
+
let after = null;
|
|
285
|
+
while (true) {
|
|
286
|
+
const vars = { owner, name, number: itemRef.value };
|
|
287
|
+
if (after) vars.after = after;
|
|
288
|
+
const payload = await ghGraphql(GET_ITEMS_BY_CONTENT_NUMBER, vars, env, runChild, { allowErrors: true });
|
|
289
|
+
const connection = payload?.data?.repository?.issueOrPullRequest?.projectItems;
|
|
290
|
+
const match = (connection?.nodes ?? []).find((n) => n && n.project?.id === projectId && !n.isArchived);
|
|
291
|
+
// A partial response can carry errors for other boards the token cannot
|
|
292
|
+
// read (e.g. FORBIDDEN). A match on the configured board wins; the error
|
|
293
|
+
// is raised only when no page holds a match.
|
|
294
|
+
if (match) return match;
|
|
295
|
+
const pageInfo = connection?.pageInfo ?? {};
|
|
296
|
+
if (!pageInfo.hasNextPage) {
|
|
297
|
+
assertOnlyNotFoundErrors(payload);
|
|
298
|
+
throw itemNotFound(`Item #${itemRef.value} not found in project "${projectLabel}" for repo "${repo}"`);
|
|
299
|
+
}
|
|
300
|
+
if (!pageInfo.endCursor) {
|
|
301
|
+
throw Object.assign(
|
|
302
|
+
new Error("Invalid projectItems payload: hasNextPage is true but endCursor is missing"),
|
|
303
|
+
{ code: "GH_API_ERROR" },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
after = pageInfo.endCursor;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const payload = await ghGraphql(GET_ITEM_BY_ID, { id: itemRef.value }, env, runChild, { allowErrors: true });
|
|
311
|
+
assertOnlyNotFoundErrors(payload);
|
|
312
|
+
const node = payload?.data?.node;
|
|
313
|
+
if (!node?.id || node.isArchived) {
|
|
314
|
+
throw itemNotFound(`Item "${itemRef.value}" not found in project "${projectLabel}" for repo "${repo}"`);
|
|
315
|
+
}
|
|
316
|
+
if (node.project?.id !== projectId) {
|
|
317
|
+
throw itemNotFound(
|
|
318
|
+
`Item "${itemRef.value}" belongs to project "${node.project?.id ?? "(unknown)"}", not "${projectId}"`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const itemRepo = node.content?.repository?.nameWithOwner ?? null;
|
|
322
|
+
if (itemRepo !== repo) {
|
|
323
|
+
throw itemNotFound(`Item "${itemRef.value}" is for repo "${itemRepo ?? "(none)"}", not "${repo}"`);
|
|
324
|
+
}
|
|
325
|
+
return node;
|
|
326
|
+
}
|