@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4
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 +7 -1
- package/src/analysis/diff-analyzer.mjs +31 -5
- package/src/claude/hook-decisions.mjs +14 -0
- package/src/cli/primitives.mjs +10 -2
- package/src/cli/retry-wrapper.mjs +14 -6
- package/src/config/config.mjs +1125 -240
- package/src/config/extension-defaults.yaml +217 -426
- package/src/github/copilot-helpers.mjs +139 -18
- package/src/github/issue-ops.mjs +556 -0
- package/src/github/ownership-helpers.mjs +79 -0
- package/src/github/review-threads.mjs +44 -3
- package/src/loop/bash-command-classify.mjs +35 -5
- package/src/loop/conductor-routing.mjs +1 -1
- package/src/loop/copilot-ci-status.mjs +76 -0
- package/src/loop/copilot-loop-iterations.mjs +1 -2
- package/src/loop/copilot-loop-state.mjs +9 -5
- package/src/loop/default-branch-guard.mjs +380 -0
- package/src/loop/gate-carry-forward.mjs +29 -2
- package/src/loop/gate-fanin.mjs +481 -31
- package/src/loop/handoff-envelope.mjs +43 -23
- package/src/loop/main-checkout-ff.mjs +58 -0
- package/src/loop/pr-gate-coordination.mjs +204 -47
- package/src/loop/pr-title-markers.mjs +76 -15
- package/src/loop/queue-board-sync.mjs +26 -9
- package/src/loop/reviewer-loop-state.mjs +2 -2
- package/src/loop/ui-e2e-scoping.mjs +2 -0
- package/src/loop/ui-review-drive.mjs +23 -0
- package/src/loop/ui-review-provision.mjs +36 -0
- package/src/projects/resolve-project.mjs +14 -7
- package/src/tracker/adapter.mjs +127 -0
- package/src/tracker/github-adapter.mjs +150 -0
- package/src/tracker/index.mjs +50 -0
- package/src/tracker/noop-adapter.mjs +35 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { runChild as defaultRunChild } from "../cli/primitives.mjs";
|
|
4
|
+
import { parseJsonText } from "./review-threads.mjs";
|
|
5
|
+
import { parseRepoSlug } from "./repo-slug.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Core `gh issue` operations, extracted from the thin CLI wrappers under
|
|
9
|
+
* `scripts/github/*.mjs` (view/create/edit/comment/list-issue,
|
|
10
|
+
* detect-linked-issue-pr) so both the CLI scripts and the GitHub tracker
|
|
11
|
+
* adapter (`../tracker/github-adapter.mjs`) call one implementation instead
|
|
12
|
+
* of duplicating gh-command construction. The CLI scripts keep their own
|
|
13
|
+
* arg parsing/USAGE/runCli; this module owns the actual `gh` calls and output
|
|
14
|
+
* shaping. Mirrors the existing `../projects/move-queue-item.mjs` /
|
|
15
|
+
* `../projects/list-queue-items.mjs` split (core logic + thin CLI wrapper).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const ISSUE_URL_NUMBER_PATTERN = /\/issues\/(\d+)(?:\D|$)/u;
|
|
19
|
+
|
|
20
|
+
// ── view-issue ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export const VIEW_ISSUE_DEFAULT_FIELDS = "number,title,body,state,author,labels,url,createdAt,updatedAt";
|
|
23
|
+
|
|
24
|
+
export async function viewIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
25
|
+
const fields = options.fields ?? VIEW_ISSUE_DEFAULT_FIELDS;
|
|
26
|
+
const result = await run(
|
|
27
|
+
ghCommand,
|
|
28
|
+
["issue", "view", String(options.issue), "--repo", options.repo, "--json", fields],
|
|
29
|
+
env,
|
|
30
|
+
);
|
|
31
|
+
if (result.code !== 0) {
|
|
32
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
33
|
+
throw new Error(`gh issue view failed: ${detail}`);
|
|
34
|
+
}
|
|
35
|
+
const issue = parseJsonText(result.stdout, { label: "gh issue view" });
|
|
36
|
+
if (issue === null || typeof issue !== "object" || Array.isArray(issue)) {
|
|
37
|
+
throw new Error("gh issue view did not return a JSON object");
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, issue };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── create-issue ────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
// Build the `gh issue create` args. A --body-file path is forwarded straight
|
|
45
|
+
// to gh so large bodies avoid command-length limits.
|
|
46
|
+
export function buildCreateArgs(options) {
|
|
47
|
+
const args = ["issue", "create", "--repo", options.repo, "--title", options.title];
|
|
48
|
+
if (options.body !== undefined) {
|
|
49
|
+
args.push("--body", options.body);
|
|
50
|
+
} else {
|
|
51
|
+
args.push("--body-file", options.bodyFile);
|
|
52
|
+
}
|
|
53
|
+
if (options.milestone !== undefined) {
|
|
54
|
+
args.push("--milestone", options.milestone);
|
|
55
|
+
}
|
|
56
|
+
for (const l of options.labels ?? []) {
|
|
57
|
+
args.push("--label", l);
|
|
58
|
+
}
|
|
59
|
+
for (const u of options.assignees ?? []) {
|
|
60
|
+
args.push("--assignee", u);
|
|
61
|
+
}
|
|
62
|
+
return args;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reject a --body-file path that does not RESOLVE (following symlinks) to a
|
|
66
|
+
// regular file. The CLI layer's literal-string rejections (`-`, `/dev/stdin`,
|
|
67
|
+
// `/dev/fd/N`, ...) only catch known stdin-device spellings; a symlink to one
|
|
68
|
+
// of those devices (or to /dev/null, a FIFO, etc.) dodges that regex yet still
|
|
69
|
+
// reads as empty/non-file when `gh` re-reads the same path with stdin ignored.
|
|
70
|
+
// `statSync` follows symlinks, so this closes that gap regardless of path shape.
|
|
71
|
+
function assertRegularFilePath(path) {
|
|
72
|
+
if (!statSync(path).isFile()) {
|
|
73
|
+
throw new Error(`--body-file must be a regular file: ${path}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Read (for validation only — the actual gh call still forwards the path, see
|
|
78
|
+
// buildCreateArgs) and reject a --body-file that isn't a regular file or whose
|
|
79
|
+
// content is empty/whitespace-only. This is the real guard behind the CLI's
|
|
80
|
+
// stdin-device rejection: `gh` is spawned with stdin ignored, so it re-reads
|
|
81
|
+
// the same path fresh — this validates what gh will actually see, not just the
|
|
82
|
+
// path's literal spelling.
|
|
83
|
+
export async function resolveCreateBody(options) {
|
|
84
|
+
if (options.bodyFile === undefined) return options.body;
|
|
85
|
+
assertRegularFilePath(options.bodyFile);
|
|
86
|
+
return await readFile(options.bodyFile, "utf8");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function createIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
90
|
+
const body = await resolveCreateBody(options);
|
|
91
|
+
if (typeof body !== "string" || body.trim().length === 0) {
|
|
92
|
+
const source = options.bodyFile !== undefined ? `--body-file ${options.bodyFile}` : "--body";
|
|
93
|
+
throw new Error(`issue body resolved empty from ${source} — refusing to create a bodyless issue`);
|
|
94
|
+
}
|
|
95
|
+
const args = buildCreateArgs(options);
|
|
96
|
+
const result = await run(ghCommand, args, env);
|
|
97
|
+
if (result.code !== 0) {
|
|
98
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
99
|
+
throw new Error(`gh issue create failed: ${detail}`);
|
|
100
|
+
}
|
|
101
|
+
// gh prints the created issue URL to stdout.
|
|
102
|
+
const url = (result.stdout ?? "").trim();
|
|
103
|
+
const match = ISSUE_URL_NUMBER_PATTERN.exec(url);
|
|
104
|
+
if (!match) {
|
|
105
|
+
throw new Error(`gh issue create returned no parseable issue URL: ${url || "<empty>"}`);
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, issueNumber: Number(match[1]), url };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── edit-issue ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export async function resolveEditBody(options) {
|
|
113
|
+
if (options.bodyFile === undefined) return options.body;
|
|
114
|
+
// Stdin (fd 0): fs/promises readFile does NOT accept an integer fd, so read
|
|
115
|
+
// it synchronously via the callback-style API (which does). A real path
|
|
116
|
+
// stays on the async promise read.
|
|
117
|
+
const body =
|
|
118
|
+
options.bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(options.bodyFile, "utf8");
|
|
119
|
+
if (body.trim().length === 0) {
|
|
120
|
+
throw new Error(`--body-file ${options.bodyFile} is empty`);
|
|
121
|
+
}
|
|
122
|
+
return body;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Build the `gh issue edit` args and the parallel `edited` list (which fields
|
|
126
|
+
// were touched) so callers get a stable summary without re-reading the issue.
|
|
127
|
+
export async function buildEditArgs(options) {
|
|
128
|
+
const args = ["issue", "edit", String(options.issue), "--repo", options.repo];
|
|
129
|
+
const edited = [];
|
|
130
|
+
if (options.title !== undefined) {
|
|
131
|
+
args.push("--title", options.title);
|
|
132
|
+
edited.push("title");
|
|
133
|
+
}
|
|
134
|
+
const body = await resolveEditBody(options);
|
|
135
|
+
if (body !== undefined) {
|
|
136
|
+
if (options.bodyFile !== undefined && options.bodyFile !== "-") {
|
|
137
|
+
args.push("--body-file", options.bodyFile);
|
|
138
|
+
} else {
|
|
139
|
+
args.push("--body", body);
|
|
140
|
+
}
|
|
141
|
+
edited.push("body");
|
|
142
|
+
}
|
|
143
|
+
for (const u of options.addAssignees ?? []) {
|
|
144
|
+
args.push("--add-assignee", u);
|
|
145
|
+
}
|
|
146
|
+
if ((options.addAssignees ?? []).length > 0) edited.push("add-assignee");
|
|
147
|
+
for (const u of options.removeAssignees ?? []) {
|
|
148
|
+
args.push("--remove-assignee", u);
|
|
149
|
+
}
|
|
150
|
+
if ((options.removeAssignees ?? []).length > 0) edited.push("remove-assignee");
|
|
151
|
+
if (options.milestone !== undefined) {
|
|
152
|
+
args.push("--milestone", options.milestone);
|
|
153
|
+
edited.push("milestone");
|
|
154
|
+
}
|
|
155
|
+
return { args, edited };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// gh's own --reason values are space-separated ("not planned"), but the
|
|
159
|
+
// CLI-facing flag value stays the underscore form (`not_planned`) since it's
|
|
160
|
+
// stable and shell-friendly without quoting; map it here at the gh-args
|
|
161
|
+
// boundary rather than changing the public flag value.
|
|
162
|
+
const REASON_ARG_BY_CLI_VALUE = { not_planned: "not planned" };
|
|
163
|
+
|
|
164
|
+
// Build the `gh issue close`/`gh issue reopen` args for a --state change. Kept
|
|
165
|
+
// as a separate `gh` call from `gh issue edit` — that command has no --state
|
|
166
|
+
// flag, so a state change is its own invocation, run after the edit call.
|
|
167
|
+
export function buildStateChangeArgs(options) {
|
|
168
|
+
if (options.state === "closed") {
|
|
169
|
+
const args = ["issue", "close", String(options.issue), "--repo", options.repo];
|
|
170
|
+
if (options.reason !== undefined) {
|
|
171
|
+
args.push("--reason", REASON_ARG_BY_CLI_VALUE[options.reason] ?? options.reason);
|
|
172
|
+
}
|
|
173
|
+
return args;
|
|
174
|
+
}
|
|
175
|
+
if (options.state !== "open") {
|
|
176
|
+
// Fail closed: this is an exported seam, so an unexpected state must never
|
|
177
|
+
// silently degrade into a reopen.
|
|
178
|
+
throw new Error(`invalid state ${JSON.stringify(options.state)} — expected "open" or "closed"`);
|
|
179
|
+
}
|
|
180
|
+
return ["issue", "reopen", String(options.issue), "--repo", options.repo];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function editIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
184
|
+
const { args, edited } = await buildEditArgs(options);
|
|
185
|
+
// Skip the edit call entirely when --state is the only change requested —
|
|
186
|
+
// `gh issue edit` with no field flags errors ("no changed fields").
|
|
187
|
+
if (edited.length > 0) {
|
|
188
|
+
const result = await run(ghCommand, args, env);
|
|
189
|
+
if (result.code !== 0) {
|
|
190
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
191
|
+
throw new Error(`gh issue edit failed: ${detail}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (options.state !== undefined) {
|
|
195
|
+
const stateArgs = buildStateChangeArgs(options);
|
|
196
|
+
const result = await run(ghCommand, stateArgs, env);
|
|
197
|
+
if (result.code !== 0) {
|
|
198
|
+
const verb = options.state === "closed" ? "close" : "reopen";
|
|
199
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
200
|
+
// Surface the edits that DID land before the state change failed, so a
|
|
201
|
+
// caller (or a human reading the error) knows the field edits are not
|
|
202
|
+
// rolled back — only the state change itself failed.
|
|
203
|
+
const landed = edited.length > 0 ? ` after edits were applied: ${edited.join(", ")}` : "";
|
|
204
|
+
throw new Error(`state change failed${landed} — gh issue ${verb} failed: ${detail}`);
|
|
205
|
+
}
|
|
206
|
+
edited.push("state");
|
|
207
|
+
}
|
|
208
|
+
return { ok: true, repo: options.repo, issue: options.issue, edited };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── comment-issue ───────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
export async function resolveCommentBody(options) {
|
|
214
|
+
if (options.bodyFile === undefined) {
|
|
215
|
+
if (options.body.trim().length === 0) {
|
|
216
|
+
throw new Error("--body must not be empty");
|
|
217
|
+
}
|
|
218
|
+
return options.body;
|
|
219
|
+
}
|
|
220
|
+
const source = options.bodyFile === "-" ? 0 : options.bodyFile;
|
|
221
|
+
const body = await readFile(source, "utf8");
|
|
222
|
+
if (body.trim().length === 0) {
|
|
223
|
+
throw new Error(`--body-file ${options.bodyFile} is empty`);
|
|
224
|
+
}
|
|
225
|
+
return body;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function commentIssue(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
229
|
+
const body = await resolveCommentBody(options);
|
|
230
|
+
const result = await run(
|
|
231
|
+
ghCommand,
|
|
232
|
+
["issue", "comment", String(options.issue), "--repo", options.repo, "--body", body],
|
|
233
|
+
env,
|
|
234
|
+
);
|
|
235
|
+
if (result.code !== 0) {
|
|
236
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
237
|
+
throw new Error(`gh issue comment failed: ${detail}`);
|
|
238
|
+
}
|
|
239
|
+
const commentUrl = result.stdout
|
|
240
|
+
.split(/\r?\n/u)
|
|
241
|
+
.map((line) => line.trim())
|
|
242
|
+
.filter((line) => line.length > 0)
|
|
243
|
+
.pop() ?? null;
|
|
244
|
+
if (commentUrl === null || !/^https?:\/\//u.test(commentUrl)) {
|
|
245
|
+
throw new Error(`gh issue comment did not return a comment URL (got: ${result.stdout.trim() || "<empty>"})`);
|
|
246
|
+
}
|
|
247
|
+
return { ok: true, repo: options.repo, issue: options.issue, commentUrl };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── list-issues ─────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
// Returns a well-typed issue, or null if the gh entry is missing/invalid in
|
|
253
|
+
// any required field.
|
|
254
|
+
export function normalizeIssue(raw) {
|
|
255
|
+
if (!Number.isInteger(raw?.number) || typeof raw?.title !== "string" || typeof raw?.state !== "string") {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
number: raw.number,
|
|
260
|
+
title: raw.title,
|
|
261
|
+
// gh reports issue state UPPERCASE (OPEN/CLOSED); normalize to lowercase.
|
|
262
|
+
state: raw.state.toLowerCase(),
|
|
263
|
+
labels: Array.isArray(raw?.labels)
|
|
264
|
+
? raw.labels.map((l) => (typeof l?.name === "string" ? l.name : null)).filter((n) => n !== null)
|
|
265
|
+
: [],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function listIssues(options, { env = process.env, ghCommand = "gh", run = defaultRunChild } = {}) {
|
|
270
|
+
const args = [
|
|
271
|
+
"issue",
|
|
272
|
+
"list",
|
|
273
|
+
"--repo",
|
|
274
|
+
options.repo,
|
|
275
|
+
"--state",
|
|
276
|
+
options.state ?? "open",
|
|
277
|
+
"--limit",
|
|
278
|
+
String(options.limit ?? 30),
|
|
279
|
+
"--json",
|
|
280
|
+
"number,title,state,labels",
|
|
281
|
+
];
|
|
282
|
+
for (const label of options.labels ?? []) {
|
|
283
|
+
args.push("--label", label);
|
|
284
|
+
}
|
|
285
|
+
const result = await run(ghCommand, args, env);
|
|
286
|
+
if (result.code !== 0) {
|
|
287
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
288
|
+
throw new Error(`gh issue list failed: ${detail}`);
|
|
289
|
+
}
|
|
290
|
+
const payload = parseJsonText(result.stdout, { label: "gh issue list" });
|
|
291
|
+
if (!Array.isArray(payload)) {
|
|
292
|
+
throw new Error("gh issue list did not return a JSON array");
|
|
293
|
+
}
|
|
294
|
+
return { ok: true, issues: payload.map(normalizeIssue).filter((issue) => issue !== null) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── detect-linked-issue-pr ──────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
export const LINKED_ISSUE_PR_QUERY = [
|
|
300
|
+
"query($owner:String!, $name:String!, $issue:Int!, $after:String) {",
|
|
301
|
+
" repository(owner:$owner, name:$name) {",
|
|
302
|
+
" issue(number:$issue) {",
|
|
303
|
+
" timelineItems(first:100, after:$after, itemTypes:[CONNECTED_EVENT, CROSS_REFERENCED_EVENT]) {",
|
|
304
|
+
" pageInfo {",
|
|
305
|
+
" hasNextPage",
|
|
306
|
+
" endCursor",
|
|
307
|
+
" }",
|
|
308
|
+
" nodes {",
|
|
309
|
+
" __typename",
|
|
310
|
+
" ... on ConnectedEvent {",
|
|
311
|
+
" createdAt",
|
|
312
|
+
" subject {",
|
|
313
|
+
" __typename",
|
|
314
|
+
" ... on PullRequest {",
|
|
315
|
+
" number",
|
|
316
|
+
" state",
|
|
317
|
+
" url",
|
|
318
|
+
" repository { nameWithOwner }",
|
|
319
|
+
" }",
|
|
320
|
+
" }",
|
|
321
|
+
" }",
|
|
322
|
+
" ... on CrossReferencedEvent {",
|
|
323
|
+
" createdAt",
|
|
324
|
+
" willCloseTarget",
|
|
325
|
+
" source {",
|
|
326
|
+
" __typename",
|
|
327
|
+
" ... on PullRequest {",
|
|
328
|
+
" number",
|
|
329
|
+
" state",
|
|
330
|
+
" url",
|
|
331
|
+
" repository { nameWithOwner }",
|
|
332
|
+
" }",
|
|
333
|
+
" }",
|
|
334
|
+
" }",
|
|
335
|
+
" }",
|
|
336
|
+
" }",
|
|
337
|
+
" }",
|
|
338
|
+
" }",
|
|
339
|
+
"}",
|
|
340
|
+
].join("\n");
|
|
341
|
+
|
|
342
|
+
function buildLinkedPrQueryArgs({ owner, name, issue, after }) {
|
|
343
|
+
const args = [
|
|
344
|
+
"api",
|
|
345
|
+
"graphql",
|
|
346
|
+
"--field",
|
|
347
|
+
`owner=${owner}`,
|
|
348
|
+
"--field",
|
|
349
|
+
`name=${name}`,
|
|
350
|
+
"-F",
|
|
351
|
+
`issue=${issue}`,
|
|
352
|
+
"--field",
|
|
353
|
+
`query=${LINKED_ISSUE_PR_QUERY}`,
|
|
354
|
+
];
|
|
355
|
+
if (typeof after === "string" && after.length > 0) {
|
|
356
|
+
args.push("--field", `after=${after}`);
|
|
357
|
+
}
|
|
358
|
+
return args;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function readLinkedPrTimelineConnection(payload) {
|
|
362
|
+
const connection = payload?.data?.repository?.issue?.timelineItems;
|
|
363
|
+
if (!connection || typeof connection !== "object") {
|
|
364
|
+
throw new Error("Invalid linked-PR GraphQL payload: missing data.repository.issue.timelineItems");
|
|
365
|
+
}
|
|
366
|
+
const nodes = Array.isArray(connection.nodes) ? connection.nodes : [];
|
|
367
|
+
const pageInfo = connection.pageInfo ?? {};
|
|
368
|
+
return {
|
|
369
|
+
nodes,
|
|
370
|
+
hasNextPage: Boolean(pageInfo.hasNextPage),
|
|
371
|
+
endCursor: typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function normalizeLinkedPrNode(node) {
|
|
376
|
+
if (!node || typeof node !== "object") {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
if (node.__typename === "ConnectedEvent") {
|
|
380
|
+
return {
|
|
381
|
+
eventType: "CONNECTED_EVENT",
|
|
382
|
+
eventCreatedAt: node.createdAt,
|
|
383
|
+
pr: node.subject,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (node.__typename === "CrossReferencedEvent") {
|
|
387
|
+
// Only a cross-reference that will CLOSE this issue owns its board status.
|
|
388
|
+
// A bare body-mention (willCloseTarget:false, e.g. "part of #X") must not
|
|
389
|
+
// create board-ownership linkage (#1130).
|
|
390
|
+
if (node.willCloseTarget !== true) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
eventType: "CROSS_REFERENCED_EVENT",
|
|
395
|
+
eventCreatedAt: node.createdAt,
|
|
396
|
+
pr: node.source,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function compareStableStrings(left, right) {
|
|
403
|
+
if (left === right) {
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
return left < right ? -1 : 1;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function normalizeRepoSlugForComparison(repo) {
|
|
410
|
+
return typeof repo === "string" ? repo.trim().toLowerCase() : "";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function normalizeOpenSameRepoCandidate(candidate, repo) {
|
|
414
|
+
const pr = candidate?.pr;
|
|
415
|
+
const number = pr?.number;
|
|
416
|
+
const state = pr?.state;
|
|
417
|
+
const url = pr?.url;
|
|
418
|
+
const nameWithOwner = pr?.repository?.nameWithOwner;
|
|
419
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
if (
|
|
423
|
+
state !== "OPEN"
|
|
424
|
+
|| normalizeRepoSlugForComparison(nameWithOwner) !== normalizeRepoSlugForComparison(repo)
|
|
425
|
+
) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
const createdAtMs = Date.parse(candidate.eventCreatedAt);
|
|
429
|
+
if (!Number.isFinite(createdAtMs)) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
prNumber: number,
|
|
434
|
+
prUrl: typeof url === "string" ? url : null,
|
|
435
|
+
eventType: candidate.eventType,
|
|
436
|
+
eventCreatedAt: typeof candidate.eventCreatedAt === "string" ? candidate.eventCreatedAt : null,
|
|
437
|
+
createdAtMs,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function normalizeClosedUnmergedSameRepoCandidate(candidate, repo) {
|
|
442
|
+
const pr = candidate?.pr;
|
|
443
|
+
const number = pr?.number;
|
|
444
|
+
const state = pr?.state;
|
|
445
|
+
const url = pr?.url;
|
|
446
|
+
const nameWithOwner = pr?.repository?.nameWithOwner;
|
|
447
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
if (
|
|
451
|
+
state !== "CLOSED"
|
|
452
|
+
|| normalizeRepoSlugForComparison(nameWithOwner) !== normalizeRepoSlugForComparison(repo)
|
|
453
|
+
) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const createdAtMs = Date.parse(candidate.eventCreatedAt);
|
|
457
|
+
if (!Number.isFinite(createdAtMs)) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
prNumber: number,
|
|
462
|
+
prUrl: typeof url === "string" ? url : null,
|
|
463
|
+
eventType: candidate.eventType,
|
|
464
|
+
eventCreatedAt: typeof candidate.eventCreatedAt === "string" ? candidate.eventCreatedAt : null,
|
|
465
|
+
createdAtMs,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function selectLinkedIssuePr(candidates) {
|
|
470
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const sorted = [...candidates].sort((left, right) => {
|
|
474
|
+
const leftPriority = left.eventType === "CONNECTED_EVENT" ? 0 : 1;
|
|
475
|
+
const rightPriority = right.eventType === "CONNECTED_EVENT" ? 0 : 1;
|
|
476
|
+
if (leftPriority !== rightPriority) {
|
|
477
|
+
return leftPriority - rightPriority;
|
|
478
|
+
}
|
|
479
|
+
if (left.createdAtMs !== right.createdAtMs) {
|
|
480
|
+
return right.createdAtMs - left.createdAtMs;
|
|
481
|
+
}
|
|
482
|
+
if (left.prNumber !== right.prNumber) {
|
|
483
|
+
return right.prNumber - left.prNumber;
|
|
484
|
+
}
|
|
485
|
+
return compareStableStrings(String(left.prUrl ?? ""), String(right.prUrl ?? ""));
|
|
486
|
+
});
|
|
487
|
+
return sorted[0] ?? null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function detectLinkedIssuePr({ repo, issue }, { env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {}) {
|
|
491
|
+
const { owner, name } = parseRepoSlug(repo);
|
|
492
|
+
const candidates = [];
|
|
493
|
+
const closedUnmergedCandidates = [];
|
|
494
|
+
let after = null;
|
|
495
|
+
while (true) {
|
|
496
|
+
const result = await runChild(
|
|
497
|
+
ghCommand,
|
|
498
|
+
buildLinkedPrQueryArgs({ owner, name, issue, after }),
|
|
499
|
+
env,
|
|
500
|
+
);
|
|
501
|
+
if (result.code !== 0) {
|
|
502
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
503
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
504
|
+
}
|
|
505
|
+
const payload = parseJsonText(result.stdout);
|
|
506
|
+
const { nodes, hasNextPage, endCursor } = readLinkedPrTimelineConnection(payload);
|
|
507
|
+
for (const node of nodes) {
|
|
508
|
+
const normalizedNode = normalizeLinkedPrNode(node);
|
|
509
|
+
if (!normalizedNode) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const normalizedCandidate = normalizeOpenSameRepoCandidate(normalizedNode, repo);
|
|
513
|
+
if (normalizedCandidate) {
|
|
514
|
+
candidates.push(normalizedCandidate);
|
|
515
|
+
}
|
|
516
|
+
const closedUnmergedCandidate = normalizeClosedUnmergedSameRepoCandidate(normalizedNode, repo);
|
|
517
|
+
if (closedUnmergedCandidate) {
|
|
518
|
+
closedUnmergedCandidates.push(closedUnmergedCandidate);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (!hasNextPage) {
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
if (!endCursor) {
|
|
525
|
+
throw new Error("Invalid linked-PR GraphQL payload: pageInfo.hasNextPage is true but endCursor is missing");
|
|
526
|
+
}
|
|
527
|
+
after = endCursor;
|
|
528
|
+
}
|
|
529
|
+
const selected = selectLinkedIssuePr(candidates);
|
|
530
|
+
const selectedClosedUnmerged = selectLinkedIssuePr(closedUnmergedCandidates);
|
|
531
|
+
if (!selected) {
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
repo,
|
|
535
|
+
issue,
|
|
536
|
+
hasOpenLinkedPr: false,
|
|
537
|
+
prNumber: null,
|
|
538
|
+
prUrl: null,
|
|
539
|
+
hasPriorClosedUnmergedPr: selectedClosedUnmerged !== null,
|
|
540
|
+
priorClosedUnmergedPrNumber: selectedClosedUnmerged?.prNumber ?? null,
|
|
541
|
+
priorClosedUnmergedPrUrl: selectedClosedUnmerged?.prUrl ?? null,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
ok: true,
|
|
546
|
+
repo,
|
|
547
|
+
issue,
|
|
548
|
+
hasOpenLinkedPr: true,
|
|
549
|
+
prNumber: selected.prNumber,
|
|
550
|
+
prUrl: selected.prUrl,
|
|
551
|
+
selection: {
|
|
552
|
+
eventType: selected.eventType,
|
|
553
|
+
eventCreatedAt: selected.eventCreatedAt,
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared deterministic single-contributor ownership classification for
|
|
3
|
+
* issue/PR assignees.
|
|
4
|
+
*
|
|
5
|
+
* Owner: packages/core — reusable pure logic consumed by the startup
|
|
6
|
+
* resolver (`resolve-dev-loop-startup.mjs`) and the Next Up pickup source
|
|
7
|
+
* (`resolve-active-board-item.mjs`). `classifyOwnership` never shells out —
|
|
8
|
+
* callers fetch assignees and the viewer's login via `gh`, then classify here.
|
|
9
|
+
*/
|
|
10
|
+
import { isCopilotLogin } from "./copilot-helpers.mjs";
|
|
11
|
+
|
|
12
|
+
export const OWNERSHIP_STATE = Object.freeze({
|
|
13
|
+
ASSIGNED_TO_ME: "assigned_to_me",
|
|
14
|
+
ASSIGNED_TO_OTHER: "assigned_to_other",
|
|
15
|
+
ASSIGNED_TO_COPILOT: "assigned_to_copilot",
|
|
16
|
+
UNASSIGNED: "unassigned",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Classify assignee ownership of an issue/PR relative to the viewer.
|
|
21
|
+
*
|
|
22
|
+
* Copilot assignment is checked FIRST and short-circuits before any human
|
|
23
|
+
* comparison — the viewer login is never required to detect it, so a
|
|
24
|
+
* copilot-assigned artifact is unaffected by viewer-login resolution
|
|
25
|
+
* failures (matches the existing, unchanged Copilot-first flow).
|
|
26
|
+
*
|
|
27
|
+
* `assigned_to_me` requires the viewer to be the SOLE human assignee.
|
|
28
|
+
* `gh issue/pr edit --add-assignee` is not compare-and-swap, so two loopers
|
|
29
|
+
* racing to claim the same unassigned item can both end up co-assigned;
|
|
30
|
+
* membership-based classification would wave both through. A viewer
|
|
31
|
+
* co-assigned alongside another human is `assigned_to_other` (contested) —
|
|
32
|
+
* `foreignLogins` names the OTHER humans only (never the viewer), so error
|
|
33
|
+
* messages stay accurate. Login comparison is case-insensitive (GitHub
|
|
34
|
+
* logins are case-insensitive).
|
|
35
|
+
*
|
|
36
|
+
* @param {Array<{login?: string}>} assignees
|
|
37
|
+
* @param {string|null} [viewerLogin] - required only when a non-copilot
|
|
38
|
+
* assignee is present; pass null/undefined when the caller skipped
|
|
39
|
+
* resolving it (empty assignees, or a copilot assignee already found).
|
|
40
|
+
* @returns {{ state: string, foreignLogins: string[] }}
|
|
41
|
+
*/
|
|
42
|
+
export function classifyOwnership(assignees, viewerLogin = null) {
|
|
43
|
+
const logins = (Array.isArray(assignees) ? assignees : [])
|
|
44
|
+
.map((a) => a?.login)
|
|
45
|
+
.filter((login) => typeof login === "string" && login.length > 0);
|
|
46
|
+
if (logins.some(isCopilotLogin)) {
|
|
47
|
+
return { state: OWNERSHIP_STATE.ASSIGNED_TO_COPILOT, foreignLogins: [] };
|
|
48
|
+
}
|
|
49
|
+
if (logins.length === 0) {
|
|
50
|
+
return { state: OWNERSHIP_STATE.UNASSIGNED, foreignLogins: [] };
|
|
51
|
+
}
|
|
52
|
+
const viewerLoginLower = typeof viewerLogin === "string" && viewerLogin.length > 0
|
|
53
|
+
? viewerLogin.toLowerCase()
|
|
54
|
+
: null;
|
|
55
|
+
const otherLogins = viewerLoginLower === null
|
|
56
|
+
? logins
|
|
57
|
+
: logins.filter((login) => login.toLowerCase() !== viewerLoginLower);
|
|
58
|
+
if (viewerLoginLower !== null && otherLogins.length === 0) {
|
|
59
|
+
return { state: OWNERSHIP_STATE.ASSIGNED_TO_ME, foreignLogins: [] };
|
|
60
|
+
}
|
|
61
|
+
return { state: OWNERSHIP_STATE.ASSIGNED_TO_OTHER, foreignLogins: otherLogins };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether classifying these assignees requires a resolved viewer login (i.e.
|
|
66
|
+
* there is at least one non-copilot assignee to compare against). Lets
|
|
67
|
+
* callers skip the extra `gh api user` call for the common empty/copilot
|
|
68
|
+
* cases, which also keeps those cases immune to viewer-login resolution
|
|
69
|
+
* failures.
|
|
70
|
+
*
|
|
71
|
+
* @param {Array<{login?: string}>} assignees
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function ownershipNeedsViewerLogin(assignees) {
|
|
75
|
+
const logins = (Array.isArray(assignees) ? assignees : [])
|
|
76
|
+
.map((a) => a?.login)
|
|
77
|
+
.filter((login) => typeof login === "string" && login.length > 0);
|
|
78
|
+
return logins.length > 0 && !logins.some(isCopilotLogin);
|
|
79
|
+
}
|
|
@@ -166,6 +166,34 @@ export function parseReviewThreads(payload) {
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The fix-loop's re-entry working set: only unresolved threads, each with its
|
|
171
|
+
* comment bodies joined in thread order. Location fields come from the thread
|
|
172
|
+
* node when the payload carries them (`path`/`line`/`isOutdated`); snapshots
|
|
173
|
+
* without them yield `path: null`, `line: null`, `isOutdated: false`.
|
|
174
|
+
*
|
|
175
|
+
* @returns {{ summary: object, threads: Array<{ threadId: string, path: string|null, line: number|null, isOutdated: boolean, bodies: string[] }> }}
|
|
176
|
+
*/
|
|
177
|
+
export function parseUnresolvedThreadBodies(payload) {
|
|
178
|
+
const rawThreads = extractRawThreads(payload);
|
|
179
|
+
const { summary } = parseReviewThreads(payload);
|
|
180
|
+
const threads = rawThreads
|
|
181
|
+
.map((thread, threadIndex) => ({ thread, threadIndex }))
|
|
182
|
+
.filter(({ thread }) => !thread?.isResolved)
|
|
183
|
+
.map(({ thread, threadIndex }) => ({
|
|
184
|
+
threadId: normalizeId(thread?.id ?? thread?.databaseId, `thread-${threadIndex + 1}`),
|
|
185
|
+
path: typeof thread?.path === "string" && thread.path.length > 0 ? thread.path : null,
|
|
186
|
+
line: Number.isInteger(thread?.line) ? thread.line : null,
|
|
187
|
+
isOutdated: Boolean(thread?.isOutdated),
|
|
188
|
+
bodies: extractRawComments(thread).map((comment) =>
|
|
189
|
+
normalizeBody(comment?.body ?? comment?.bodyText ?? comment?.bodyHTML ?? ""),
|
|
190
|
+
),
|
|
191
|
+
}))
|
|
192
|
+
.sort((left, right) => compareIds(left.threadId, right.threadId));
|
|
193
|
+
|
|
194
|
+
return { summary, threads };
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
// ── Signal classification heuristics ──────────────────────────────────────
|
|
170
198
|
|
|
171
199
|
const HIGH_SIGNAL_PATTERNS = [
|
|
@@ -303,10 +331,23 @@ export function parseJsonText(text) {
|
|
|
303
331
|
}
|
|
304
332
|
}
|
|
305
333
|
|
|
306
|
-
|
|
334
|
+
// Renders the one shared { ok: false, error, hint? } envelope every
|
|
335
|
+
// JSON-emitting gate CLI's main() catch block prints to stderr. `usage` is
|
|
336
|
+
// accepted only as a PRESENCE check for a fallback usage string (a caller
|
|
337
|
+
// passing its own `USAGE` constant when the error itself might not already
|
|
338
|
+
// carry one) — the fallback's actual TEXT, like `error.usage`'s, is never
|
|
339
|
+
// embedded here. Argument errors (and any error a caller marks with a usage
|
|
340
|
+
// string) used to inline that string's full multi-KB text into this JSON
|
|
341
|
+
// payload, which every calling agent then paid to read back out of its own
|
|
342
|
+
// tool result on every mistyped flag. A one-line `hint` pointing at --help
|
|
343
|
+
// carries the same "usage exists, go look" signal at a fraction of the size;
|
|
344
|
+
// --help itself is unaffected (it prints the full USAGE text directly, never
|
|
345
|
+
// through this function).
|
|
346
|
+
export function formatCliError(error, { usage } = {}) {
|
|
307
347
|
const payload = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
308
|
-
|
|
309
|
-
|
|
348
|
+
const hasUsage = (error instanceof Error && typeof error.usage === "string") || typeof usage === "string";
|
|
349
|
+
if (hasUsage) {
|
|
350
|
+
payload.hint = "run with --help for usage";
|
|
310
351
|
}
|
|
311
352
|
return JSON.stringify(payload);
|
|
312
353
|
}
|