dev-loops 0.7.1 → 0.8.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
|
+
import { parseIssueNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
6
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage: edit-issue.mjs --repo <owner/name> --issue <number> [--title <t>] [--body <b> | --body-file <path>] [--add-assignee <u>] [--remove-assignee <u>] [--milestone <m>]
|
|
11
|
+
Edit issue title/body/assignees/milestone. Thin wrapper over \`gh issue edit\` — use this
|
|
12
|
+
instead of an agent-level raw \`gh issue edit\` so the loop's internal-tooling record
|
|
13
|
+
stays clean (siblings: edit-pr.mjs, comment-issue.mjs).
|
|
14
|
+
Required:
|
|
15
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
16
|
+
--issue <number> Issue number
|
|
17
|
+
At least one edit:
|
|
18
|
+
--title <t> New issue title
|
|
19
|
+
--body <b> New issue body as a single argument
|
|
20
|
+
--body-file <path> Read the new body from a file (- reads stdin)
|
|
21
|
+
--add-assignee <u> Assignee to add (repeatable)
|
|
22
|
+
--remove-assignee <u> Assignee to remove (repeatable)
|
|
23
|
+
--milestone <m> Milestone to set (empty string clears it)
|
|
24
|
+
(--title/--body/--body-file reject empty or
|
|
25
|
+
whitespace-only values; use --milestone "" only
|
|
26
|
+
to clear the milestone)
|
|
27
|
+
Output (stdout, JSON):
|
|
28
|
+
{ "ok": true, "repo": "owner/repo", "issue": 17, "edited": ["title", "body", ...] }
|
|
29
|
+
Error output (stderr, JSON):
|
|
30
|
+
{ "ok": false, "error": "...", "usage"?: "..." }
|
|
31
|
+
${JQ_OUTPUT_USAGE}
|
|
32
|
+
Exit codes:
|
|
33
|
+
0 Success
|
|
34
|
+
1 Argument error or gh failure
|
|
35
|
+
2 Invalid --jq filter`.trim();
|
|
36
|
+
const parseError = buildParseError(USAGE);
|
|
37
|
+
|
|
38
|
+
export function parseEditIssueCliArgs(argv) {
|
|
39
|
+
const { tokens } = parseArgs({
|
|
40
|
+
args: [...argv],
|
|
41
|
+
options: {
|
|
42
|
+
help: { type: "boolean", short: "h" },
|
|
43
|
+
repo: { type: "string" },
|
|
44
|
+
issue: { type: "string" },
|
|
45
|
+
title: { type: "string" },
|
|
46
|
+
body: { type: "string" },
|
|
47
|
+
"body-file": { type: "string" },
|
|
48
|
+
"add-assignee": { type: "string", multiple: true },
|
|
49
|
+
"remove-assignee": { type: "string", multiple: true },
|
|
50
|
+
milestone: { type: "string" },
|
|
51
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
52
|
+
},
|
|
53
|
+
allowPositionals: true,
|
|
54
|
+
strict: false,
|
|
55
|
+
tokens: true,
|
|
56
|
+
});
|
|
57
|
+
const options = {
|
|
58
|
+
help: false,
|
|
59
|
+
repo: undefined,
|
|
60
|
+
issue: undefined,
|
|
61
|
+
title: undefined,
|
|
62
|
+
body: undefined,
|
|
63
|
+
bodyFile: undefined,
|
|
64
|
+
addAssignees: [],
|
|
65
|
+
removeAssignees: [],
|
|
66
|
+
milestone: undefined,
|
|
67
|
+
jq: undefined,
|
|
68
|
+
silent: false,
|
|
69
|
+
};
|
|
70
|
+
for (const token of tokens) {
|
|
71
|
+
if (token.kind === "positional") {
|
|
72
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
73
|
+
}
|
|
74
|
+
if (token.kind !== "option") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token.name === "help") {
|
|
78
|
+
options.help = true;
|
|
79
|
+
return options;
|
|
80
|
+
}
|
|
81
|
+
if (token.name === "repo") {
|
|
82
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.name === "issue") {
|
|
86
|
+
options.issue = parseIssueNumber(requireTokenValue(token, parseError), parseError);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (token.name === "title") {
|
|
90
|
+
const title = requireTokenValue(token, parseError);
|
|
91
|
+
if (title.trim().length === 0) {
|
|
92
|
+
throw parseError("--title must not be empty or whitespace-only");
|
|
93
|
+
}
|
|
94
|
+
options.title = title;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (token.name === "body") {
|
|
98
|
+
const body = requireTokenValue(token, parseError);
|
|
99
|
+
if (body.trim().length === 0) {
|
|
100
|
+
throw parseError("--body must not be empty or whitespace-only");
|
|
101
|
+
}
|
|
102
|
+
options.body = body;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (token.name === "body-file") {
|
|
106
|
+
const rawPath = requireTokenValue(token, parseError).trim();
|
|
107
|
+
if (rawPath.length === 0) {
|
|
108
|
+
throw parseError("--body-file must be a non-empty path");
|
|
109
|
+
}
|
|
110
|
+
options.bodyFile = rawPath;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (token.name === "add-assignee") {
|
|
114
|
+
const u = requireTokenValue(token, parseError).trim();
|
|
115
|
+
if (u.length === 0) throw parseError("--add-assignee must be a non-empty login");
|
|
116
|
+
options.addAssignees.push(u);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (token.name === "remove-assignee") {
|
|
120
|
+
const u = requireTokenValue(token, parseError).trim();
|
|
121
|
+
if (u.length === 0) throw parseError("--remove-assignee must be a non-empty login");
|
|
122
|
+
options.removeAssignees.push(u);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (token.name === "milestone") {
|
|
126
|
+
// Read the raw token value: an empty string is a valid milestone value
|
|
127
|
+
// (`gh issue edit --milestone ""` clears it), so this deliberately does NOT
|
|
128
|
+
// go through requireTokenValue (which rejects empty). Guard only a truly
|
|
129
|
+
// missing value (`--milestone` with no following token). A whitespace-only
|
|
130
|
+
// value is neither a clear nor a real milestone name — fail closed rather
|
|
131
|
+
// than forwarding it to gh for a less actionable error.
|
|
132
|
+
if (typeof token.value !== "string") {
|
|
133
|
+
throw parseError("--milestone requires a value (use an empty string to clear)");
|
|
134
|
+
}
|
|
135
|
+
if (token.value.length > 0 && token.value.trim().length === 0) {
|
|
136
|
+
throw parseError('--milestone must be a milestone name or "" to clear (whitespace-only is not allowed)');
|
|
137
|
+
}
|
|
138
|
+
options.milestone = token.value;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
142
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
143
|
+
}
|
|
144
|
+
if (options.repo === undefined || options.issue === undefined) {
|
|
145
|
+
throw parseError("Editing an issue requires both --repo <owner/name> and --issue <number>");
|
|
146
|
+
}
|
|
147
|
+
if (options.body !== undefined && options.bodyFile !== undefined) {
|
|
148
|
+
throw parseError("--body and --body-file are mutually exclusive; pass only one");
|
|
149
|
+
}
|
|
150
|
+
const hasEdit =
|
|
151
|
+
options.title !== undefined ||
|
|
152
|
+
options.body !== undefined ||
|
|
153
|
+
options.bodyFile !== undefined ||
|
|
154
|
+
options.addAssignees.length > 0 ||
|
|
155
|
+
options.removeAssignees.length > 0 ||
|
|
156
|
+
options.milestone !== undefined;
|
|
157
|
+
if (!hasEdit) {
|
|
158
|
+
throw parseError("Editing an issue requires at least one of --title/--body/--body-file/--add-assignee/--remove-assignee/--milestone");
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
parseRepoSlug(options.repo);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
164
|
+
}
|
|
165
|
+
return options;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function resolveBody(options) {
|
|
169
|
+
if (options.bodyFile === undefined) return options.body;
|
|
170
|
+
// Stdin (fd 0): the fs/promises readFile does NOT accept an integer fd, so read
|
|
171
|
+
// it synchronously via the callback-style API (which does). A real path stays on
|
|
172
|
+
// the async promise read.
|
|
173
|
+
const body =
|
|
174
|
+
options.bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(options.bodyFile, "utf8");
|
|
175
|
+
// Fail closed on an empty / whitespace-only file so a blank --body-file cannot
|
|
176
|
+
// silently clear the issue body (USAGE promises --body/--title reject empties).
|
|
177
|
+
if (body.trim().length === 0) {
|
|
178
|
+
throw new Error(`--body-file ${options.bodyFile} is empty`);
|
|
179
|
+
}
|
|
180
|
+
return body;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Build the `gh issue edit` args and the parallel `edited` list (which fields were
|
|
184
|
+
// touched) so callers get a stable summary without re-reading the issue.
|
|
185
|
+
async function buildEditArgs(options) {
|
|
186
|
+
const args = ["issue", "edit", String(options.issue), "--repo", options.repo];
|
|
187
|
+
const edited = [];
|
|
188
|
+
if (options.title !== undefined) {
|
|
189
|
+
args.push("--title", options.title);
|
|
190
|
+
edited.push("title");
|
|
191
|
+
}
|
|
192
|
+
// resolveBody still runs for validation (reads the file, throws on empty /
|
|
193
|
+
// whitespace-only). A REAL --body-file path is handed straight to gh so large
|
|
194
|
+
// bodies avoid command-length limits. But `--body-file -` (stdin) was already
|
|
195
|
+
// consumed by resolveBody reading fd 0; re-emitting `--body-file -` makes gh
|
|
196
|
+
// re-read an exhausted stdin and clear the body, so pass the resolved string
|
|
197
|
+
// inline via --body instead.
|
|
198
|
+
const body = await resolveBody(options);
|
|
199
|
+
if (body !== undefined) {
|
|
200
|
+
if (options.bodyFile !== undefined && options.bodyFile !== "-") {
|
|
201
|
+
args.push("--body-file", options.bodyFile);
|
|
202
|
+
} else {
|
|
203
|
+
args.push("--body", body);
|
|
204
|
+
}
|
|
205
|
+
edited.push("body");
|
|
206
|
+
}
|
|
207
|
+
for (const u of options.addAssignees) {
|
|
208
|
+
args.push("--add-assignee", u);
|
|
209
|
+
}
|
|
210
|
+
if (options.addAssignees.length > 0) edited.push("add-assignee");
|
|
211
|
+
for (const u of options.removeAssignees) {
|
|
212
|
+
args.push("--remove-assignee", u);
|
|
213
|
+
}
|
|
214
|
+
if (options.removeAssignees.length > 0) edited.push("remove-assignee");
|
|
215
|
+
if (options.milestone !== undefined) {
|
|
216
|
+
args.push("--milestone", options.milestone);
|
|
217
|
+
edited.push("milestone");
|
|
218
|
+
}
|
|
219
|
+
return { args, edited };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function editIssue(options, { env = process.env, ghCommand = "gh", run = runChild } = {}) {
|
|
223
|
+
const { args, edited } = await buildEditArgs(options);
|
|
224
|
+
const result = await run(ghCommand, args, env);
|
|
225
|
+
if (result.code !== 0) {
|
|
226
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
227
|
+
throw new Error(`gh issue edit failed: ${detail}`);
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, repo: options.repo, issue: options.issue, edited };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function runCli(
|
|
233
|
+
argv = process.argv.slice(2),
|
|
234
|
+
{ stdout = process.stdout, stderr = process.stderr, env = process.env, ghCommand = "gh", run = runChild } = {},
|
|
235
|
+
) {
|
|
236
|
+
let options;
|
|
237
|
+
try {
|
|
238
|
+
options = parseEditIssueCliArgs(argv);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
if (options.help) {
|
|
244
|
+
stdout.write(`${USAGE}\n`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
let result;
|
|
248
|
+
try {
|
|
249
|
+
result = await editIssue(options, { env, ghCommand, run });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
258
|
+
runCli().then((code) => { process.exitCode = code; });
|
|
259
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
+
import { parsePrNumber, requireTokenValue, runChild as defaultRunChild } from "../_cli-primitives.mjs";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
import { authorMatchesFilter } from "./_review-thread-mutations.mjs";
|
|
7
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
8
|
+
|
|
9
|
+
// Dedicated query: unlike capture-review-threads.mjs's REVIEW_THREADS_QUERY
|
|
10
|
+
// (all comments, no path/line/isOutdated, no pagination past 100), this tool
|
|
11
|
+
// needs only the FIRST comment per thread (the reply-resolve target) plus the
|
|
12
|
+
// thread-level path/line/isOutdated fields, and must paginate past 100 threads.
|
|
13
|
+
export const LIST_REVIEW_THREADS_QUERY = [
|
|
14
|
+
"query($owner: String!, $name: String!, $pr: Int!, $after: String) {",
|
|
15
|
+
" repository(owner: $owner, name: $name) {",
|
|
16
|
+
" pullRequest(number: $pr) {",
|
|
17
|
+
" reviewThreads(first: 100, after: $after) {",
|
|
18
|
+
" pageInfo {",
|
|
19
|
+
" hasNextPage",
|
|
20
|
+
" endCursor",
|
|
21
|
+
" }",
|
|
22
|
+
" nodes {",
|
|
23
|
+
" id",
|
|
24
|
+
" isResolved",
|
|
25
|
+
" isOutdated",
|
|
26
|
+
" path",
|
|
27
|
+
" line",
|
|
28
|
+
" comments(first: 1) {",
|
|
29
|
+
" nodes {",
|
|
30
|
+
" databaseId",
|
|
31
|
+
" body",
|
|
32
|
+
" author {",
|
|
33
|
+
" login",
|
|
34
|
+
" }",
|
|
35
|
+
" }",
|
|
36
|
+
" }",
|
|
37
|
+
" }",
|
|
38
|
+
" }",
|
|
39
|
+
" }",
|
|
40
|
+
" }",
|
|
41
|
+
"}",
|
|
42
|
+
].join("\n");
|
|
43
|
+
|
|
44
|
+
// Bounded excerpt so a listing over many threads stays scannable; full body
|
|
45
|
+
// text is available from capture-review-threads.mjs when actually needed.
|
|
46
|
+
const BODY_EXCERPT_MAX_CHARS = 200;
|
|
47
|
+
|
|
48
|
+
const USAGE = `Usage: list-review-threads.mjs --repo <owner/name> --pr <number> [--unresolved-only] [--author <login>]
|
|
49
|
+
List review threads on a pull request with the thread id + first-comment
|
|
50
|
+
databaseId reply-resolve-review-thread.mjs needs, so a fixer pass can go
|
|
51
|
+
list -> reply-resolve without hand-written GraphQL.
|
|
52
|
+
Required:
|
|
53
|
+
--repo <owner/name> Repository slug (e.g. owner/repo)
|
|
54
|
+
--pr <number> Pull request number
|
|
55
|
+
Optional:
|
|
56
|
+
--unresolved-only Only include threads where isResolved is false
|
|
57
|
+
--author <login> Only include threads whose first comment author matches
|
|
58
|
+
(case-insensitive; "copilot" matches any Copilot review
|
|
59
|
+
login variant; "all" matches every author)
|
|
60
|
+
Output (stdout, JSON):
|
|
61
|
+
{ "ok": true, "repo": "owner/name", "pr": N, "threads": [
|
|
62
|
+
{ "threadId": "...", "commentId": N|null, "author": "..."|null,
|
|
63
|
+
"body": "...", "isResolved": bool, "isOutdated": bool,
|
|
64
|
+
"path": "..."|null, "line": N|null }, ...
|
|
65
|
+
] }
|
|
66
|
+
Error output (stderr, JSON):
|
|
67
|
+
Argument/usage errors:
|
|
68
|
+
{ "ok": false, "error": "...", "usage": "..." }
|
|
69
|
+
gh/runtime failures:
|
|
70
|
+
{ "ok": false, "error": "..." }
|
|
71
|
+
${JQ_OUTPUT_USAGE}
|
|
72
|
+
Exit codes:
|
|
73
|
+
0 Success
|
|
74
|
+
1 Argument error or gh failure
|
|
75
|
+
2 Invalid --jq filter`.trim();
|
|
76
|
+
|
|
77
|
+
const parseError = buildParseError(USAGE);
|
|
78
|
+
|
|
79
|
+
export function parseListReviewThreadsCliArgs(argv) {
|
|
80
|
+
const { tokens } = parseArgs({
|
|
81
|
+
args: [...argv],
|
|
82
|
+
options: {
|
|
83
|
+
help: { type: "boolean", short: "h" },
|
|
84
|
+
repo: { type: "string" },
|
|
85
|
+
pr: { type: "string" },
|
|
86
|
+
"unresolved-only": { type: "boolean" },
|
|
87
|
+
author: { type: "string" },
|
|
88
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
89
|
+
},
|
|
90
|
+
allowPositionals: true,
|
|
91
|
+
strict: false,
|
|
92
|
+
tokens: true,
|
|
93
|
+
});
|
|
94
|
+
const options = {
|
|
95
|
+
help: false,
|
|
96
|
+
repo: undefined,
|
|
97
|
+
pr: undefined,
|
|
98
|
+
unresolvedOnly: false,
|
|
99
|
+
author: undefined,
|
|
100
|
+
};
|
|
101
|
+
for (const token of tokens) {
|
|
102
|
+
if (token.kind === "positional") {
|
|
103
|
+
throw parseError(`Unknown argument: ${token.value}`);
|
|
104
|
+
}
|
|
105
|
+
if (token.kind !== "option") {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (token.name === "help") {
|
|
109
|
+
options.help = true;
|
|
110
|
+
return options;
|
|
111
|
+
}
|
|
112
|
+
if (token.name === "repo") {
|
|
113
|
+
options.repo = requireTokenValue(token, parseError).trim();
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (token.name === "pr") {
|
|
117
|
+
options.pr = parsePrNumber(requireTokenValue(token, parseError), parseError);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (token.name === "unresolved-only") {
|
|
121
|
+
options.unresolvedOnly = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (token.name === "author") {
|
|
125
|
+
options.author = requireTokenValue(token, parseError).trim();
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
129
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
130
|
+
}
|
|
131
|
+
if (options.repo === undefined || options.pr === undefined) {
|
|
132
|
+
throw parseError("Listing review threads requires both --repo <owner/name> and --pr <number>");
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
parseRepoSlug(options.repo);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
138
|
+
}
|
|
139
|
+
return options;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildQueryArgs({ owner, name, pr, after }) {
|
|
143
|
+
const args = [
|
|
144
|
+
"api",
|
|
145
|
+
"graphql",
|
|
146
|
+
"--field",
|
|
147
|
+
`owner=${owner}`,
|
|
148
|
+
"--field",
|
|
149
|
+
`name=${name}`,
|
|
150
|
+
"--field",
|
|
151
|
+
`pr=${pr}`,
|
|
152
|
+
"--field",
|
|
153
|
+
`query=${LIST_REVIEW_THREADS_QUERY}`,
|
|
154
|
+
];
|
|
155
|
+
if (typeof after === "string" && after.length > 0) {
|
|
156
|
+
args.push("--field", `after=${after}`);
|
|
157
|
+
}
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readThreadsConnection(payload) {
|
|
162
|
+
const connection = payload?.data?.repository?.pullRequest?.reviewThreads;
|
|
163
|
+
if (!connection || typeof connection !== "object") {
|
|
164
|
+
throw new Error("Invalid review-threads GraphQL payload: missing data.repository.pullRequest.reviewThreads");
|
|
165
|
+
}
|
|
166
|
+
const nodes = Array.isArray(connection.nodes) ? connection.nodes : [];
|
|
167
|
+
const pageInfo = connection.pageInfo ?? {};
|
|
168
|
+
return {
|
|
169
|
+
nodes,
|
|
170
|
+
hasNextPage: Boolean(pageInfo.hasNextPage),
|
|
171
|
+
endCursor: typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function excerptBody(rawBody) {
|
|
176
|
+
const text = typeof rawBody === "string" ? rawBody.trim() : "";
|
|
177
|
+
if (text.length <= BODY_EXCERPT_MAX_CHARS) {
|
|
178
|
+
return text;
|
|
179
|
+
}
|
|
180
|
+
return `${text.slice(0, BODY_EXCERPT_MAX_CHARS)}…`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeThreadNode(node) {
|
|
184
|
+
const firstComment = node?.comments?.nodes?.[0] ?? null;
|
|
185
|
+
const commentId = Number.isFinite(firstComment?.databaseId) ? firstComment.databaseId : null;
|
|
186
|
+
const authorLogin = firstComment?.author?.login;
|
|
187
|
+
const author = typeof authorLogin === "string" && authorLogin.length > 0 ? authorLogin : null;
|
|
188
|
+
return {
|
|
189
|
+
threadId: typeof node?.id === "string" ? node.id : "",
|
|
190
|
+
commentId,
|
|
191
|
+
author,
|
|
192
|
+
body: excerptBody(firstComment?.body),
|
|
193
|
+
isResolved: Boolean(node?.isResolved),
|
|
194
|
+
isOutdated: Boolean(node?.isOutdated),
|
|
195
|
+
path: typeof node?.path === "string" ? node.path : null,
|
|
196
|
+
line: typeof node?.line === "number" ? node.line : null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function fetchAllReviewThreads(
|
|
201
|
+
{ repo, pr },
|
|
202
|
+
{ env = process.env, ghCommand = "gh", runChild = defaultRunChild } = {},
|
|
203
|
+
) {
|
|
204
|
+
const { owner, name } = parseRepoSlug(repo);
|
|
205
|
+
const threads = [];
|
|
206
|
+
let after = null;
|
|
207
|
+
while (true) {
|
|
208
|
+
const result = await runChild(ghCommand, buildQueryArgs({ owner, name, pr, after }), env);
|
|
209
|
+
if (result.code !== 0) {
|
|
210
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
211
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
212
|
+
}
|
|
213
|
+
const payload = parseJsonText(result.stdout);
|
|
214
|
+
const { nodes, hasNextPage, endCursor } = readThreadsConnection(payload);
|
|
215
|
+
for (const node of nodes) {
|
|
216
|
+
threads.push(normalizeThreadNode(node));
|
|
217
|
+
}
|
|
218
|
+
if (!hasNextPage) {
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
if (!endCursor) {
|
|
222
|
+
throw new Error("Invalid review-threads GraphQL payload: pageInfo.hasNextPage is true but endCursor is missing");
|
|
223
|
+
}
|
|
224
|
+
after = endCursor;
|
|
225
|
+
}
|
|
226
|
+
return threads;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function filterThreads(threads, { unresolvedOnly = false, author = undefined } = {}) {
|
|
230
|
+
// "all" is a match-everything sentinel: bypass the author filter entirely so
|
|
231
|
+
// threads with a missing/ghost first-comment author (author null) still list.
|
|
232
|
+
// authorMatchesFilter deliberately rejects empty logins for its reply-resolve
|
|
233
|
+
// callers, so routing "all" through it would drop those threads.
|
|
234
|
+
const authorFilter = typeof author === "string" && author.trim().toLowerCase() === "all"
|
|
235
|
+
? undefined
|
|
236
|
+
: author;
|
|
237
|
+
return threads.filter((thread) => {
|
|
238
|
+
if (unresolvedOnly && thread.isResolved) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (authorFilter !== undefined && !authorMatchesFilter(thread.author, authorFilter)) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function runCli(
|
|
249
|
+
argv = process.argv.slice(2),
|
|
250
|
+
{
|
|
251
|
+
stdout = process.stdout,
|
|
252
|
+
stderr = process.stderr,
|
|
253
|
+
env = process.env,
|
|
254
|
+
ghCommand = "gh",
|
|
255
|
+
} = {},
|
|
256
|
+
) {
|
|
257
|
+
const options = parseListReviewThreadsCliArgs(argv);
|
|
258
|
+
if (options.help) {
|
|
259
|
+
stdout.write(`${USAGE}\n`);
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
const allThreads = await fetchAllReviewThreads({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
263
|
+
const threads = filterThreads(allThreads, { unresolvedOnly: options.unresolvedOnly, author: options.author });
|
|
264
|
+
const result = { ok: true, repo: options.repo, pr: options.pr, threads };
|
|
265
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
269
|
+
runCli().then((code) => {
|
|
270
|
+
if (typeof code === "number") {
|
|
271
|
+
process.exitCode = code;
|
|
272
|
+
}
|
|
273
|
+
}).catch((error) => {
|
|
274
|
+
process.stderr.write(`${formatCliError(error)}\n`);
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
3
|
-
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
3
|
+
import { formatCliError, isDirectCliRun, parseJsonText, sanitizeCopilotSummonTokens } from "../_core-helpers.mjs";
|
|
4
4
|
import { loadDevLoopConfig, resolveGatePostFindingsComments } from "@dev-loops/core/config";
|
|
5
5
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
6
|
import { JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
@@ -215,7 +215,7 @@ export function renderFindingsCommentBody({ gate, headSha, findings }) {
|
|
|
215
215
|
];
|
|
216
216
|
if (findings.length === 0) {
|
|
217
217
|
lines.push("No findings. All review angles passed for this head.");
|
|
218
|
-
return lines.join("\n");
|
|
218
|
+
return sanitizeCopilotSummonTokens(lines.join("\n"));
|
|
219
219
|
}
|
|
220
220
|
const grouped = new Map();
|
|
221
221
|
for (const sev of SEVERITY_ORDER) {
|
|
@@ -261,7 +261,10 @@ export function renderFindingsCommentBody({ gate, headSha, findings }) {
|
|
|
261
261
|
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
262
262
|
lines.pop();
|
|
263
263
|
}
|
|
264
|
-
|
|
264
|
+
// Neutralize any bare @copilot/`/copilot`* tokens a finding summary quotes
|
|
265
|
+
// (e.g. an excerpt of the anti-summon rule itself) so this comment can never
|
|
266
|
+
// arm request-copilot-review.mjs's anti-summon guard.
|
|
267
|
+
return sanitizeCopilotSummonTokens(lines.join("\n"));
|
|
265
268
|
}
|
|
266
269
|
|
|
267
270
|
async function runGhJson(args, { env, ghCommand }) {
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
DEFAULT_POLL_INTERVAL_MS,
|
|
15
15
|
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
16
16
|
} from "@dev-loops/core/loop/policy-constants";
|
|
17
|
+
import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
|
|
18
|
+
import { resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
|
|
17
19
|
|
|
18
20
|
/** Maximum interval between heartbeat outputs during watch delays.
|
|
19
21
|
* Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
|
|
@@ -341,8 +343,10 @@ export async function watchCiStatus(
|
|
|
341
343
|
ghCommand = "gh",
|
|
342
344
|
delayImpl = delay,
|
|
343
345
|
now = Date.now,
|
|
346
|
+
ensureOwnershipImpl = ensureAsyncRunnerOwnership,
|
|
344
347
|
} = {},
|
|
345
348
|
) {
|
|
349
|
+
const leaseCwd = resolveRepoRoot(process.cwd());
|
|
346
350
|
const { headSha: baselineSha, prVisibleCheckNames } = await fetchPrHeadSha(
|
|
347
351
|
{ repo: options.repo, pr: options.pr },
|
|
348
352
|
{ env, ghCommand },
|
|
@@ -382,6 +386,20 @@ export async function watchCiStatus(
|
|
|
382
386
|
maxPolls: attemptBudget,
|
|
383
387
|
}) + "\n",
|
|
384
388
|
);
|
|
389
|
+
// The blocking CI wait can span the full watch budget, which equals the
|
|
390
|
+
// runner-coordination stale window; refresh the lease alongside each
|
|
391
|
+
// heartbeat so the claim stays fresh for every caller of this engine.
|
|
392
|
+
// No-ops without DEVLOOPS_RUN_ID; best-effort, never affects the watch.
|
|
393
|
+
try {
|
|
394
|
+
await ensureOwnershipImpl({
|
|
395
|
+
repo: options.repo,
|
|
396
|
+
pr: options.pr,
|
|
397
|
+
env,
|
|
398
|
+
cwd: leaseCwd,
|
|
399
|
+
claimIfMissing: true,
|
|
400
|
+
requireExisting: false,
|
|
401
|
+
});
|
|
402
|
+
} catch { /* best-effort: never affect the watch */ }
|
|
385
403
|
}
|
|
386
404
|
}
|
|
387
405
|
}
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
DEFAULT_POLL_INTERVAL_MS,
|
|
10
10
|
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
11
11
|
} from "@dev-loops/core/loop/policy-constants";
|
|
12
|
+
import { ensureAsyncRunnerOwnership } from "../loop/_pr-runner-coordination.mjs";
|
|
13
|
+
import { resolveRepoRoot } from "../loop/_repo-root-resolver.mjs";
|
|
12
14
|
|
|
13
15
|
/** Maximum interval between heartbeat outputs during watch delays.
|
|
14
16
|
* Must be shorter than pi-subagents default needsAttentionAfterMs (60s). */
|
|
@@ -275,14 +277,18 @@ export async function watchCopilotReview(
|
|
|
275
277
|
{
|
|
276
278
|
env = process.env,
|
|
277
279
|
ghCommand = "gh",
|
|
280
|
+
delayImpl = delay,
|
|
281
|
+
now = Date.now,
|
|
282
|
+
ensureOwnershipImpl = ensureAsyncRunnerOwnership,
|
|
278
283
|
} = {},
|
|
279
284
|
) {
|
|
285
|
+
const leaseCwd = resolveRepoRoot(process.cwd());
|
|
280
286
|
const baseline = parseCopilotActivity(await fetchGithubCopilotActivityPayload(
|
|
281
287
|
{ repo: options.repo, pr: options.pr },
|
|
282
288
|
{ env, ghCommand },
|
|
283
289
|
));
|
|
284
290
|
const attemptBudget = buildAttemptBudget(options.timeoutMs, options.pollIntervalMs);
|
|
285
|
-
const watchStartedAtMs =
|
|
291
|
+
const watchStartedAtMs = now();
|
|
286
292
|
for (let attempt = 1; attempt <= attemptBudget; attempt += 1) {
|
|
287
293
|
if (!(options.timeoutMs === 0 && attempt === 1)) {
|
|
288
294
|
const pollDelayMs = buildPollDelayMs(
|
|
@@ -290,15 +296,16 @@ export async function watchCopilotReview(
|
|
|
290
296
|
options.timeoutMs,
|
|
291
297
|
options.pollIntervalMs,
|
|
292
298
|
attempt,
|
|
299
|
+
now(),
|
|
293
300
|
);
|
|
294
301
|
if (pollDelayMs > 0) {
|
|
295
302
|
let remainingMs = pollDelayMs;
|
|
296
303
|
while (remainingMs > 0) {
|
|
297
304
|
const chunkMs = Math.min(WATCH_HEARTBEAT_MS, remainingMs);
|
|
298
|
-
await
|
|
305
|
+
await delayImpl(chunkMs);
|
|
299
306
|
remainingMs -= chunkMs;
|
|
300
307
|
if (remainingMs > 0) {
|
|
301
|
-
const nowMs =
|
|
308
|
+
const nowMs = now();
|
|
302
309
|
process.stderr.write(
|
|
303
310
|
JSON.stringify({
|
|
304
311
|
ok: true,
|
|
@@ -309,6 +316,20 @@ export async function watchCopilotReview(
|
|
|
309
316
|
maxPolls: attemptBudget,
|
|
310
317
|
}) + "\n",
|
|
311
318
|
);
|
|
319
|
+
// The blocking review wait can span the full watch budget, which
|
|
320
|
+
// equals the runner-coordination stale window; refresh the lease
|
|
321
|
+
// alongside each heartbeat so the claim stays fresh for every caller
|
|
322
|
+
// of this engine. No-ops without DEVLOOPS_RUN_ID; best-effort.
|
|
323
|
+
try {
|
|
324
|
+
await ensureOwnershipImpl({
|
|
325
|
+
repo: options.repo,
|
|
326
|
+
pr: options.pr,
|
|
327
|
+
env,
|
|
328
|
+
cwd: leaseCwd,
|
|
329
|
+
claimIfMissing: true,
|
|
330
|
+
requireExisting: false,
|
|
331
|
+
});
|
|
332
|
+
} catch { /* best-effort: never affect the watch */ }
|
|
312
333
|
}
|
|
313
334
|
}
|
|
314
335
|
}
|