dev-loops 0.7.1 → 0.7.2
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 +2 -2
- package/.claude/agents/review.md +2 -2
- package/.claude/commands/loop-enqueue.md +1 -1
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/skills/copilot-pr-followup/SKILL.md +37 -23
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/acceptance-criteria-verification.md +3 -2
- package/.claude/skills/docs/anti-patterns.md +3 -2
- package/.claude/skills/docs/artifact-authority-contract.md +12 -11
- package/.claude/skills/docs/confirmation-rules.md +1 -0
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +18 -14
- 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 +25 -19
- package/.claude/skills/docs/merge-preconditions.md +13 -11
- package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
- package/.claude/skills/docs/public-dev-loop-contract.md +59 -43
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +18 -5
- 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 +48 -142
- package/CHANGELOG.md +6 -0
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +2 -2
- package/agents/review.agent.md +2 -2
- package/package.json +5 -4
- 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 +1092 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +102 -17
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/request-copilot-review.mjs +92 -26
- package/scripts/github/upsert-checkpoint-verdict.mjs +74 -5
- package/scripts/github/verify-briefing-prefixes.mjs +191 -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 +213 -7
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/copilot-pr-handoff.mjs +29 -2
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +23 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -3
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/sanctioned-commands.mjs +2 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +50 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- 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 +37 -23
- package/skills/dev-loop/SKILL.md +1 -1
- package/skills/docs/acceptance-criteria-verification.md +3 -2
- package/skills/docs/anti-patterns.md +3 -2
- package/skills/docs/artifact-authority-contract.md +12 -11
- package/skills/docs/confirmation-rules.md +1 -0
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +18 -14
- 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 +25 -19
- package/skills/docs/merge-preconditions.md +13 -11
- package/skills/docs/pr-lifecycle-contract.md +44 -35
- package/skills/docs/public-dev-loop-contract.md +59 -43
- package/skills/docs/required-rules.json +149 -0
- package/skills/docs/retrospective-checkpoint-contract.md +18 -5
- 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 +48 -142
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -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 }) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import {
|
|
4
4
|
buildParseError,
|
|
5
|
+
containsBareCopilotSummon,
|
|
5
6
|
formatCliError,
|
|
6
7
|
isCopilotLogin,
|
|
7
8
|
isDirectCliRun,
|
|
@@ -14,7 +15,7 @@ import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.m
|
|
|
14
15
|
import { fetchGithubReviewThreadsPayload } from "./capture-review-threads.mjs";
|
|
15
16
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
16
17
|
import { buildSnapshotFromPrFacts, interpretLoopState } from "@dev-loops/core/loop/copilot-loop-state";
|
|
17
|
-
import { loadDevLoopConfig, resolveRefinement } from "@dev-loops/core/config";
|
|
18
|
+
import { loadDevLoopConfig, resolveEffectiveCopilotRoundCap, resolveRefinement } from "@dev-loops/core/config";
|
|
18
19
|
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult, matchJqOutputToken } from "../lib/jq-output.mjs";
|
|
19
20
|
const BLOCKED_BY_COPILOT_COMMENT_STATUS = "blocked_by_copilot_comment";
|
|
20
21
|
const SUPPRESSED_SAME_HEAD_CLEAN_STATUS = "suppressed_same_head_clean";
|
|
@@ -30,13 +31,19 @@ Optional:
|
|
|
30
31
|
--force-rerequest-review Bypass the round cap when new commits exist since
|
|
31
32
|
the last Copilot review. Refused when the PR head
|
|
32
33
|
has not changed since the last review.
|
|
34
|
+
--lightweight This PR is light-dispatched (#1210): enforce the
|
|
35
|
+
composed round cap min(localImplementation.lightMode.
|
|
36
|
+
maxCopilotRounds ?? 1, refinement.maxCopilotRounds)
|
|
37
|
+
instead of refinement.maxCopilotRounds alone.
|
|
33
38
|
Debug:
|
|
34
39
|
DEVLOOPS_DEBUG=1 Emit stderr traces when best-effort same-head clean
|
|
35
40
|
convergence detection falls back to unsuppressed behavior
|
|
36
41
|
Output (stdout, JSON):
|
|
37
42
|
{ "ok": true, "status": "requested"|"already-requested"|"unavailable"|"suppressed_same_head_clean"|"blocked_by_copilot_comment"|"round_cap_reached"|"no_changes_since_last_review"|"suppressed_draft",
|
|
38
43
|
"repo": "...", "pr": N, "reviewer": "Copilot", "detail"?: "...",
|
|
39
|
-
"sameHeadCleanConverged"?: true, "violationCommentIds"?: [N], "completedRounds"?: N, "maxRounds"?: N
|
|
44
|
+
"sameHeadCleanConverged"?: true, "violationCommentIds"?: [N], "completedRounds"?: N, "maxRounds"?: N,
|
|
45
|
+
"configWarning"?: "..." (present only when --lightweight and dev-loop config failed to load/validate;
|
|
46
|
+
the lightweight default cap of 1 was applied instead of the full-PR default) }
|
|
40
47
|
Request statuses:
|
|
41
48
|
requested Copilot review was successfully requested
|
|
42
49
|
already-requested Copilot review was already observably in progress; no new request needed
|
|
@@ -52,9 +59,20 @@ Error output (stderr, JSON):
|
|
|
52
59
|
gh/runtime failures:
|
|
53
60
|
{ "ok": false, "error": "..." }
|
|
54
61
|
${JQ_OUTPUT_USAGE}
|
|
62
|
+
Status contract: "ok": true means the helper ran without error, NOT that a
|
|
63
|
+
review was placed. Callers MUST branch on "status", never on "ok" truthiness
|
|
64
|
+
alone — every non-"requested" status (including blocked_by_copilot_comment)
|
|
65
|
+
is a caller-must-branch outcome, not a silent success.
|
|
66
|
+
--silent exit code: 0 only when status is "requested" (a new request was just
|
|
67
|
+
placed this run); non-zero for every other status, including
|
|
68
|
+
already-requested/suppressed_same_head_clean/unavailable/blocked_by_copilot_comment/
|
|
69
|
+
round_cap_reached/no_changes_since_last_review/suppressed_draft. Without
|
|
70
|
+
--silent the JSON body always prints regardless of status. --jq combined with
|
|
71
|
+
--silent keeps the shared jq-stream truthiness semantics (exit reflects the
|
|
72
|
+
filtered value) and is exempt from the status-based rule above.
|
|
55
73
|
Exit codes:
|
|
56
|
-
0 Success (including unavailable)
|
|
57
|
-
1 Argument error or
|
|
74
|
+
0 Success (including unavailable); with --silent, only when status is "requested"
|
|
75
|
+
1 Argument error, gh failure, or (--silent) any non-"requested" status
|
|
58
76
|
2 Invalid --jq filter`.trim();
|
|
59
77
|
const parseError = buildParseError(USAGE);
|
|
60
78
|
export function parseRequestCliArgs(argv) {
|
|
@@ -63,6 +81,7 @@ export function parseRequestCliArgs(argv) {
|
|
|
63
81
|
options: {
|
|
64
82
|
help: { type: "boolean", short: "h" },
|
|
65
83
|
"force-rerequest-review": { type: "boolean" },
|
|
84
|
+
lightweight: { type: "boolean" },
|
|
66
85
|
repo: { type: "string" },
|
|
67
86
|
pr: { type: "string" },
|
|
68
87
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
@@ -76,6 +95,7 @@ export function parseRequestCliArgs(argv) {
|
|
|
76
95
|
repo: undefined,
|
|
77
96
|
pr: undefined,
|
|
78
97
|
forceRerequestReview: false,
|
|
98
|
+
lightweight: false,
|
|
79
99
|
};
|
|
80
100
|
for (const token of tokens) {
|
|
81
101
|
if (token.kind === "positional") {
|
|
@@ -92,6 +112,10 @@ export function parseRequestCliArgs(argv) {
|
|
|
92
112
|
options.forceRerequestReview = true;
|
|
93
113
|
continue;
|
|
94
114
|
}
|
|
115
|
+
if (token.name === "lightweight") {
|
|
116
|
+
options.lightweight = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
95
119
|
if (token.name === "repo") {
|
|
96
120
|
options.repo = requireTokenValue(token, parseError).trim();
|
|
97
121
|
continue;
|
|
@@ -442,7 +466,9 @@ export async function checkForCopilotComments({ repo, pr }, { env = process.env,
|
|
|
442
466
|
if (isCopilotLogin(author)) {
|
|
443
467
|
continue;
|
|
444
468
|
}
|
|
445
|
-
|
|
469
|
+
// Exempt bare-text occurrences inside inline code spans/fenced blocks: a
|
|
470
|
+
// gate-evidence comment legitimately quotes the anti-summon rule itself.
|
|
471
|
+
if (containsBareCopilotSummon(body)) {
|
|
446
472
|
violationCommentIds.push(comment.id);
|
|
447
473
|
}
|
|
448
474
|
}
|
|
@@ -479,16 +505,47 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
479
505
|
}
|
|
480
506
|
let refinementConfig = { maxCopilotRounds: 5 };
|
|
481
507
|
let maxRounds = 5; // Built-in default; overridden by config when loadable
|
|
508
|
+
// Lightweight fallback when config is unreadable/invalid: fail toward the
|
|
509
|
+
// SAFE (smaller) lightweight cap instead of silently inheriting the
|
|
510
|
+
// full-PR default of 5 above, which would let a light-dispatched PR run
|
|
511
|
+
// far more review rounds than intended whenever the config can't be read.
|
|
512
|
+
const LIGHTWEIGHT_DEFAULT_CAP = 1;
|
|
513
|
+
let configWarning = null;
|
|
482
514
|
try {
|
|
483
515
|
const { config, errors } = await loadDevLoopConfig();
|
|
484
516
|
if (!errors || errors.length === 0) {
|
|
485
517
|
refinementConfig = resolveRefinement(config);
|
|
486
|
-
|
|
487
|
-
|
|
518
|
+
// Light-dispatched PRs (#1210) enforce the COMPOSED cap —
|
|
519
|
+
// min(lightMode.maxCopilotRounds ?? 1, refinement.maxCopilotRounds) — so
|
|
520
|
+
// this enforcement backstop cannot permit rounds beyond the lightweight cap.
|
|
521
|
+
const effectiveCap = options.lightweight
|
|
522
|
+
? resolveEffectiveCopilotRoundCap(config, { lightweight: true })
|
|
523
|
+
: refinementConfig.maxCopilotRounds;
|
|
524
|
+
// >= 0 (not > 0): maxCopilotRounds: 0 is documented as "disable Copilot
|
|
525
|
+
// rounds"; it must be honored as an immediate refusal, not silently
|
|
526
|
+
// ignored in favor of the built-in default of 5.
|
|
527
|
+
if (Number.isFinite(effectiveCap) && effectiveCap >= 0) {
|
|
528
|
+
maxRounds = effectiveCap;
|
|
488
529
|
}
|
|
530
|
+
if (options.lightweight) {
|
|
531
|
+
refinementConfig = { ...refinementConfig, maxCopilotRounds: effectiveCap };
|
|
532
|
+
}
|
|
533
|
+
} else if (options.lightweight) {
|
|
534
|
+
maxRounds = LIGHTWEIGHT_DEFAULT_CAP;
|
|
535
|
+
refinementConfig = { ...refinementConfig, maxCopilotRounds: LIGHTWEIGHT_DEFAULT_CAP };
|
|
536
|
+
configWarning = `dev-loop config could not be validated; using the lightweight default cap of ${LIGHTWEIGHT_DEFAULT_CAP} instead of the full-PR default. errors=${JSON.stringify(errors)}`;
|
|
537
|
+
}
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (options.lightweight) {
|
|
540
|
+
maxRounds = LIGHTWEIGHT_DEFAULT_CAP;
|
|
541
|
+
refinementConfig = { ...refinementConfig, maxCopilotRounds: LIGHTWEIGHT_DEFAULT_CAP };
|
|
542
|
+
configWarning = `dev-loop config could not be loaded; using the lightweight default cap of ${LIGHTWEIGHT_DEFAULT_CAP} instead of the full-PR default. error=${err instanceof Error ? err.message : String(err)}`;
|
|
489
543
|
}
|
|
490
|
-
} catch {
|
|
491
544
|
}
|
|
545
|
+
// Every remaining return in this function is config-dependent (round-cap
|
|
546
|
+
// decisions, the request itself); surface a config-load fallback on all of
|
|
547
|
+
// them rather than just the path a given test happens to exercise.
|
|
548
|
+
const withConfigWarning = (result) => (configWarning ? { ...result, configWarning } : result);
|
|
492
549
|
// Reconcile the completed-round count with detect-pr-gate-coordination-state (#896):
|
|
493
550
|
// when the raw count has reached the cap, re-derive it with the draft-gate round
|
|
494
551
|
// reset applied. A clean draft_gate re-pass on an earlier head resets the count, so
|
|
@@ -511,7 +568,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
511
568
|
refinementConfig,
|
|
512
569
|
);
|
|
513
570
|
if (!roundCapAutoRerequest.eligible) {
|
|
514
|
-
return {
|
|
571
|
+
return withConfigWarning({
|
|
515
572
|
ok: true,
|
|
516
573
|
status: ROUND_CAP_REACHED_STATUS,
|
|
517
574
|
repo: options.repo,
|
|
@@ -520,7 +577,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
520
577
|
completedRounds,
|
|
521
578
|
maxRounds,
|
|
522
579
|
detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. No further re-requests will be made.`,
|
|
523
|
-
};
|
|
580
|
+
});
|
|
524
581
|
}
|
|
525
582
|
}
|
|
526
583
|
// --force-rerequest-review: only bypass when there are new commits since the last review
|
|
@@ -531,7 +588,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
531
588
|
const canCompare = currentHeadSha !== null && lastReviewSha !== null;
|
|
532
589
|
const hasNewCommits = canCompare && currentHeadSha !== lastReviewSha;
|
|
533
590
|
if (!canCompare) {
|
|
534
|
-
return {
|
|
591
|
+
return withConfigWarning({
|
|
535
592
|
ok: true,
|
|
536
593
|
status: ROUND_CAP_REACHED_STATUS,
|
|
537
594
|
repo: options.repo,
|
|
@@ -540,10 +597,10 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
540
597
|
detail: `Round cap of ${maxRounds} reached with ${completedRounds} completed rounds. --force-rerequest-review was supplied but commit SHA data is unavailable, so change-since-last-review could not be evaluated.`,
|
|
541
598
|
completedRounds,
|
|
542
599
|
maxRounds,
|
|
543
|
-
};
|
|
600
|
+
});
|
|
544
601
|
}
|
|
545
602
|
if (!hasNewCommits) {
|
|
546
|
-
return {
|
|
603
|
+
return withConfigWarning({
|
|
547
604
|
ok: true,
|
|
548
605
|
status: NO_CHANGES_SINCE_LAST_REVIEW_STATUS,
|
|
549
606
|
repo: options.repo,
|
|
@@ -552,7 +609,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
552
609
|
detail: "No changes since last Copilot review. --force-rerequest-review requires new commits on the PR head.",
|
|
553
610
|
completedRounds,
|
|
554
611
|
maxRounds,
|
|
555
|
-
};
|
|
612
|
+
});
|
|
556
613
|
}
|
|
557
614
|
// Has new commits — bypass the round cap and proceed with the request
|
|
558
615
|
}
|
|
@@ -562,7 +619,7 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
562
619
|
before,
|
|
563
620
|
);
|
|
564
621
|
if (sameHeadCleanConverged) {
|
|
565
|
-
return {
|
|
622
|
+
return withConfigWarning({
|
|
566
623
|
ok: true,
|
|
567
624
|
status: SUPPRESSED_SAME_HEAD_CLEAN_STATUS,
|
|
568
625
|
repo: options.repo,
|
|
@@ -570,35 +627,35 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
570
627
|
reviewer: "Copilot",
|
|
571
628
|
sameHeadCleanConverged: true,
|
|
572
629
|
detail: "Current head already has a clean submitted Copilot review; same-head clean-convergence suppression is always enforced.",
|
|
573
|
-
};
|
|
630
|
+
});
|
|
574
631
|
}
|
|
575
632
|
if (before.requested || before.hasPendingReviewOnCurrentHead) {
|
|
576
|
-
return {
|
|
633
|
+
return withConfigWarning({
|
|
577
634
|
ok: true,
|
|
578
635
|
status: "already-requested",
|
|
579
636
|
repo: options.repo,
|
|
580
637
|
pr: options.pr,
|
|
581
638
|
reviewer: "Copilot",
|
|
582
|
-
};
|
|
639
|
+
});
|
|
583
640
|
}
|
|
584
641
|
const requestResult = await requestCopilotReview(options, { env, ghCommand });
|
|
585
642
|
if (requestResult.status === "unavailable") {
|
|
586
643
|
const after = await fetchCopilotReviewState(options, { env, ghCommand });
|
|
587
644
|
if (after.requested || after.hasPendingReviewOnCurrentHead || after.hasSubmittedReviewOnCurrentHead) {
|
|
588
|
-
return {
|
|
645
|
+
return withConfigWarning({
|
|
589
646
|
ok: true,
|
|
590
647
|
status: "already-requested",
|
|
591
648
|
repo: options.repo,
|
|
592
649
|
pr: options.pr,
|
|
593
650
|
reviewer: "Copilot",
|
|
594
|
-
};
|
|
651
|
+
});
|
|
595
652
|
}
|
|
596
|
-
return {
|
|
653
|
+
return withConfigWarning({
|
|
597
654
|
...requestResult,
|
|
598
|
-
};
|
|
655
|
+
});
|
|
599
656
|
}
|
|
600
657
|
if (requestResult.status === "already-requested") {
|
|
601
|
-
return requestResult;
|
|
658
|
+
return withConfigWarning(requestResult);
|
|
602
659
|
}
|
|
603
660
|
const after = await fetchCopilotReviewState(options, { env, ghCommand });
|
|
604
661
|
const reviewCountIncreased = after.copilotReviewIds.length > before.copilotReviewIds.length;
|
|
@@ -606,9 +663,9 @@ export async function performCopilotReviewRequest(options, { env = process.env,
|
|
|
606
663
|
if (!reviewNowObservablyInProgress) {
|
|
607
664
|
throw new Error("Copilot review request did not appear in requested reviewers or fresh/in-progress Copilot reviews after gh pr edit");
|
|
608
665
|
}
|
|
609
|
-
return {
|
|
666
|
+
return withConfigWarning({
|
|
610
667
|
...requestResult,
|
|
611
|
-
};
|
|
668
|
+
});
|
|
612
669
|
}
|
|
613
670
|
export async function runCli(
|
|
614
671
|
argv = process.argv.slice(2),
|
|
@@ -625,7 +682,16 @@ export async function runCli(
|
|
|
625
682
|
return;
|
|
626
683
|
}
|
|
627
684
|
const result = await performCopilotReviewRequest(options, { env, ghCommand });
|
|
628
|
-
|
|
685
|
+
// Honest status under --silent: `ok: true` reports "the helper ran without
|
|
686
|
+
// error", not "a review was placed" — a caller checking only exit-code
|
|
687
|
+
// truthiness must NOT read a non-`requested` status (blocked_by_copilot_comment,
|
|
688
|
+
// round_cap_reached, etc.) as a placed request. --silent therefore answers
|
|
689
|
+
// "was a request just placed" specifically: exit 0 only for `requested`,
|
|
690
|
+
// non-zero for every other status. Non-silent output is unaffected — the full
|
|
691
|
+
// JSON body (with `ok: true`) still prints for every documented status; the
|
|
692
|
+
// caller MUST branch on `.status`, not `.ok`.
|
|
693
|
+
const silentOk = options.silent ? result.status === "requested" : undefined;
|
|
694
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent, stdout, stderr, ok: silentOk });
|
|
629
695
|
}
|
|
630
696
|
if (isDirectCliRun(import.meta.url)) {
|
|
631
697
|
runCli().catch((error) => {
|