dev-loops 0.2.7 → 0.4.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 +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +25 -5
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
|
|
3
|
+
import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
|
+
import { loadDevLoopConfig, resolveGatePostFindingsComments } from "@dev-loops/core/config";
|
|
5
|
+
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage: post-gate-findings.mjs --repo <owner/name> --pr <number> --gate <draft_gate|pre_approval_gate> --head-sha <sha> --findings <json>
|
|
8
|
+
Post (or idempotently update) a visible, marker-tagged PR issue comment that lists the
|
|
9
|
+
consolidated gate fan-out findings, grouped by severity. The comment is idempotent
|
|
10
|
+
per gate: there is exactly one comment per gate, updated in place on each run
|
|
11
|
+
(the reviewed head is shown in the body) instead of duplicating it.
|
|
12
|
+
|
|
13
|
+
The disposition ledger (write-gate-findings-log.mjs) is the durable source of truth and is
|
|
14
|
+
written regardless of this comment. This helper only posts the auditable PR summary, and
|
|
15
|
+
no-ops when gates.postFindingsComments is set to false in config.
|
|
16
|
+
|
|
17
|
+
Required:
|
|
18
|
+
--repo <owner/name>
|
|
19
|
+
--pr <number>
|
|
20
|
+
--gate <draft_gate|pre_approval_gate>
|
|
21
|
+
--head-sha <sha> Current head SHA or hexadecimal prefix
|
|
22
|
+
--findings <json> JSON array of findings in the findings-log shape
|
|
23
|
+
([{severity, angle, summary, disposition?, files?}])
|
|
24
|
+
Output (stdout, JSON):
|
|
25
|
+
{ "ok": true, "action": "created"|"updated"|"noop"|"skipped", ... }
|
|
26
|
+
Exit codes:
|
|
27
|
+
0 Success
|
|
28
|
+
1 Argument error or gh failure`.trim();
|
|
29
|
+
|
|
30
|
+
const VALID_SEVERITIES = new Set(["must-fix", "worth-fixing-now", "defer"]);
|
|
31
|
+
// Severity ordering for grouped rendering (most-blocking first).
|
|
32
|
+
const SEVERITY_ORDER = ["must-fix", "worth-fixing-now", "defer"];
|
|
33
|
+
const SEVERITY_LABELS = {
|
|
34
|
+
"must-fix": "Must fix",
|
|
35
|
+
"worth-fixing-now": "Worth fixing now",
|
|
36
|
+
"defer": "Defer",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function parseError(message) {
|
|
40
|
+
return Object.assign(new Error(message), { usage: USAGE });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeGate(value) {
|
|
44
|
+
const gates = new Set(["draft_gate", "pre_approval_gate"]);
|
|
45
|
+
const normalized = String(value).trim().toLowerCase();
|
|
46
|
+
return gates.has(normalized) ? normalized : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeHeadSha(value) {
|
|
50
|
+
const normalized = String(value).trim().toLowerCase();
|
|
51
|
+
return /^[0-9a-f]{7,64}$/i.test(normalized) ? normalized : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Validate via the centralized repo-slug validator shared by sibling GitHub
|
|
55
|
+
// scripts (parseRepoSlug). It enforces owner/name structure and rejects unsafe
|
|
56
|
+
// segments (".", "..", slashes, whitespace); we re-throw as a parseError so the
|
|
57
|
+
// CLI usage banner is preserved.
|
|
58
|
+
function validateRepo(repo) {
|
|
59
|
+
try {
|
|
60
|
+
parseRepoSlug(repo);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw parseError(error instanceof Error ? error.message : String(error));
|
|
63
|
+
}
|
|
64
|
+
return repo;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseFindings(raw) {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(raw);
|
|
71
|
+
} catch {
|
|
72
|
+
throw parseError("--findings must be valid JSON");
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(parsed)) {
|
|
75
|
+
throw parseError("--findings must be a JSON array");
|
|
76
|
+
}
|
|
77
|
+
return parsed.map((f, i) => {
|
|
78
|
+
if (!f || typeof f !== "object") {
|
|
79
|
+
throw parseError(`--findings[${i}] must be an object`);
|
|
80
|
+
}
|
|
81
|
+
if (!f.severity || !VALID_SEVERITIES.has(f.severity)) {
|
|
82
|
+
throw parseError(`--findings[${i}].severity must be one of: must-fix, worth-fixing-now, defer`);
|
|
83
|
+
}
|
|
84
|
+
if (!f.angle || typeof f.angle !== "string" || f.angle.trim().length === 0) {
|
|
85
|
+
throw parseError(`--findings[${i}].angle is required`);
|
|
86
|
+
}
|
|
87
|
+
if (!f.summary || typeof f.summary !== "string" || f.summary.trim().length === 0) {
|
|
88
|
+
throw parseError(`--findings[${i}].summary is required`);
|
|
89
|
+
}
|
|
90
|
+
const entry = {
|
|
91
|
+
severity: f.severity,
|
|
92
|
+
angle: f.angle.trim(),
|
|
93
|
+
summary: f.summary.trim(),
|
|
94
|
+
};
|
|
95
|
+
if ("disposition" in f && typeof f.disposition === "string" && f.disposition.trim().length > 0) {
|
|
96
|
+
entry.disposition = f.disposition.trim();
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(f.files)) {
|
|
99
|
+
entry.files = f.files.filter(x => typeof x === "string" && x.trim().length > 0).map(x => x.trim());
|
|
100
|
+
}
|
|
101
|
+
return entry;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parsePostGateFindingsCliArgs(argv) {
|
|
106
|
+
const args = [...argv];
|
|
107
|
+
const options = {
|
|
108
|
+
help: false,
|
|
109
|
+
repo: undefined,
|
|
110
|
+
pr: undefined,
|
|
111
|
+
gate: undefined,
|
|
112
|
+
headSha: undefined,
|
|
113
|
+
findings: undefined,
|
|
114
|
+
};
|
|
115
|
+
while (args.length > 0) {
|
|
116
|
+
const token = args.shift();
|
|
117
|
+
if (token === "--help" || token === "-h") {
|
|
118
|
+
options.help = true;
|
|
119
|
+
return options;
|
|
120
|
+
}
|
|
121
|
+
if (token === "--repo") {
|
|
122
|
+
options.repo = validateRepo(requireOptionValue(args, "--repo", parseError).trim());
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (token === "--pr") {
|
|
126
|
+
options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (token === "--gate") {
|
|
130
|
+
const gate = normalizeGate(requireOptionValue(args, "--gate", parseError));
|
|
131
|
+
if (!gate) throw parseError("--gate must be draft_gate or pre_approval_gate");
|
|
132
|
+
options.gate = gate;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (token === "--head-sha") {
|
|
136
|
+
const sha = normalizeHeadSha(requireOptionValue(args, "--head-sha", parseError));
|
|
137
|
+
if (!sha) throw parseError("--head-sha must be a 7-64 character hex SHA");
|
|
138
|
+
options.headSha = sha;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (token === "--findings") {
|
|
142
|
+
options.findings = requireOptionValue(args, "--findings", parseError);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
throw parseError(`Unknown argument: ${token}`);
|
|
146
|
+
}
|
|
147
|
+
const missing = ["repo", "pr", "gate", "headSha", "findings"]
|
|
148
|
+
.filter(k => options[k] === undefined);
|
|
149
|
+
if (missing.length > 0) {
|
|
150
|
+
throw parseError(`Missing required arguments: ${missing.join(", ")}`);
|
|
151
|
+
}
|
|
152
|
+
return options;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Hidden marker keyed by GATE ONLY. There is exactly one findings comment per
|
|
156
|
+
// gate, updated in place each run. The marker deliberately does NOT include the
|
|
157
|
+
// head SHA: --head-sha accepts any 7-64 hex prefix, so keying on its literal
|
|
158
|
+
// value would let a different prefix length (or the full SHA) for the same head
|
|
159
|
+
// miss the marker and post a duplicate. The reviewed head is still shown in the
|
|
160
|
+
// comment body for context. The HTML comment is not rendered by GitHub but is
|
|
161
|
+
// matched on the comment body.
|
|
162
|
+
export function buildFindingsMarker({ gate }) {
|
|
163
|
+
return `<!-- dev-loops:gate-findings gate=${gate} -->`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Collapse any run of whitespace (newlines, tabs, repeated spaces) to a single
|
|
167
|
+
// space and trim. LLM-generated free text often carries embedded newlines, which
|
|
168
|
+
// would otherwise break a single Markdown list item across lines.
|
|
169
|
+
//
|
|
170
|
+
// Additionally neutralize any embedded HTML-comment delimiters (`<!--` / `-->`).
|
|
171
|
+
// The findings comment is keyed by a hidden marker that IS an HTML comment
|
|
172
|
+
// (buildFindingsMarker), and free text comes from scoped-review agents. Without
|
|
173
|
+
// this, a finding field could inject a second `<!-- dev-loops:gate-findings ... -->`
|
|
174
|
+
// marker (breaking idempotent comment matching) or otherwise smuggle an HTML
|
|
175
|
+
// comment into the rendered body. We escape the opening/closing angle brackets so
|
|
176
|
+
// the delimiter renders as visible literal text and cannot form a real comment.
|
|
177
|
+
function sanitizeInline(value) {
|
|
178
|
+
return String(value)
|
|
179
|
+
.replace(/\s+/g, " ")
|
|
180
|
+
.replace(/<!--/g, "<!--")
|
|
181
|
+
.replace(/-->/g, "-->")
|
|
182
|
+
.trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sanitize free text that is rendered INSIDE an inline backtick code span
|
|
186
|
+
// (`angle`, file refs). On top of sanitizeInline, strip any literal backtick:
|
|
187
|
+
// a backtick inside the span would prematurely close it, breaking out into raw
|
|
188
|
+
// Markdown (injection) for the remainder of the list item. Backticks are never
|
|
189
|
+
// meaningful in an angle label or a file path, so dropping them is safe.
|
|
190
|
+
function sanitizeCodeSpan(value) {
|
|
191
|
+
return sanitizeInline(String(value).replace(/`/g, ""));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function renderFindingsCommentBody({ gate, headSha, findings }) {
|
|
195
|
+
const marker = buildFindingsMarker({ gate });
|
|
196
|
+
const lines = [
|
|
197
|
+
marker,
|
|
198
|
+
`### Gate fan-out findings: ${gate}`,
|
|
199
|
+
"",
|
|
200
|
+
// Plain text head SHA (no backticks) so GitHub autolinks the commit.
|
|
201
|
+
`Reviewed head: ${headSha}`,
|
|
202
|
+
"",
|
|
203
|
+
];
|
|
204
|
+
if (findings.length === 0) {
|
|
205
|
+
lines.push("No findings. All review angles passed for this head.");
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
const grouped = new Map();
|
|
209
|
+
for (const sev of SEVERITY_ORDER) {
|
|
210
|
+
grouped.set(sev, []);
|
|
211
|
+
}
|
|
212
|
+
for (const finding of findings) {
|
|
213
|
+
grouped.get(finding.severity).push(finding);
|
|
214
|
+
}
|
|
215
|
+
for (const sev of SEVERITY_ORDER) {
|
|
216
|
+
const group = grouped.get(sev);
|
|
217
|
+
if (group.length === 0) continue;
|
|
218
|
+
lines.push(`#### ${SEVERITY_LABELS[sev]} (${group.length})`);
|
|
219
|
+
for (const finding of group) {
|
|
220
|
+
// Sanitize free-text fields so embedded newlines/whitespace don't break
|
|
221
|
+
// the single-line Markdown list item.
|
|
222
|
+
const summary = sanitizeInline(finding.summary);
|
|
223
|
+
const dispositionSuffix = finding.disposition ? ` — _${sanitizeInline(finding.disposition)}_` : "";
|
|
224
|
+
// angle is a code/label literal → backticks; summary is prose. angle is
|
|
225
|
+
// free text from a scoped-review agent and is rendered inside an inline
|
|
226
|
+
// code span, so it must be sanitized too: an embedded backtick or newline
|
|
227
|
+
// would otherwise break the code span (markdown injection) or split the
|
|
228
|
+
// list item. Use sanitizeCodeSpan (backtick-stripping) since it lives
|
|
229
|
+
// inside backticks, consistent with the file refs below.
|
|
230
|
+
const angle = sanitizeCodeSpan(finding.angle);
|
|
231
|
+
lines.push(`- \`${angle}\`: ${summary}${dispositionSuffix}`);
|
|
232
|
+
if (Array.isArray(finding.files) && finding.files.length > 0) {
|
|
233
|
+
// File refs go inside backticks; sanitize each so embedded whitespace,
|
|
234
|
+
// newlines, or backticks can't break the single Markdown list item /
|
|
235
|
+
// code span, and drop any that sanitize to empty.
|
|
236
|
+
const refs = finding.files
|
|
237
|
+
.map(f => sanitizeCodeSpan(f))
|
|
238
|
+
.filter(f => f.length > 0)
|
|
239
|
+
.map(f => `\`${f}\``)
|
|
240
|
+
.join(", ");
|
|
241
|
+
if (refs.length > 0) {
|
|
242
|
+
lines.push(` - files: ${refs}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
lines.push("");
|
|
247
|
+
}
|
|
248
|
+
// Drop trailing blank line.
|
|
249
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
250
|
+
lines.pop();
|
|
251
|
+
}
|
|
252
|
+
return lines.join("\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function runGhJson(args, { env, ghCommand }) {
|
|
256
|
+
const result = await runChild(ghCommand, args, env);
|
|
257
|
+
if (result.code !== 0) {
|
|
258
|
+
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
259
|
+
throw new Error(`gh command failed: ${detail}`);
|
|
260
|
+
}
|
|
261
|
+
return parseJsonText(result.stdout, { label: `gh ${args.slice(0, 3).join(" ")}` });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function listIssueComments({ repo, pr }, { env, ghCommand }) {
|
|
265
|
+
const payload = await runGhJson(
|
|
266
|
+
["api", "--paginate", "--slurp", `repos/${repo}/issues/${pr}/comments?per_page=100`],
|
|
267
|
+
{ env, ghCommand },
|
|
268
|
+
);
|
|
269
|
+
// --slurp returns an array of pages; flatten to a single comment list.
|
|
270
|
+
if (Array.isArray(payload) && payload.every(p => Array.isArray(p))) {
|
|
271
|
+
return payload.flat();
|
|
272
|
+
}
|
|
273
|
+
return Array.isArray(payload) ? payload : [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function findMarkedComment(comments, marker) {
|
|
277
|
+
for (const comment of comments) {
|
|
278
|
+
if (comment && typeof comment.body === "string" && comment.body.includes(marker)) {
|
|
279
|
+
return comment;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseCommentMutationResponse(payload) {
|
|
286
|
+
const commentId = Number.isInteger(payload?.id) ? payload.id : null;
|
|
287
|
+
const commentUrl = typeof payload?.html_url === "string" && payload.html_url.trim().length > 0
|
|
288
|
+
? payload.html_url.trim()
|
|
289
|
+
: null;
|
|
290
|
+
if (commentId === null || commentUrl === null) {
|
|
291
|
+
throw new Error("Gate findings comment mutation did not return a comment id and html_url");
|
|
292
|
+
}
|
|
293
|
+
return { commentId, commentUrl };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function createComment({ repo, pr, body }, { env, ghCommand }) {
|
|
297
|
+
const payload = await runGhJson(
|
|
298
|
+
["api", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`],
|
|
299
|
+
{ env, ghCommand },
|
|
300
|
+
);
|
|
301
|
+
return parseCommentMutationResponse(payload);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function updateComment({ repo, commentId, body }, { env, ghCommand }) {
|
|
305
|
+
const payload = await runGhJson(
|
|
306
|
+
["api", "-X", "PATCH", `repos/${repo}/issues/comments/${commentId}`, "-f", `body=${body}`],
|
|
307
|
+
{ env, ghCommand },
|
|
308
|
+
);
|
|
309
|
+
return parseCommentMutationResponse(payload);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function postGateFindings(options, { env = process.env, ghCommand = "gh", repoRoot = process.cwd() } = {}) {
|
|
313
|
+
const findings = parseFindings(options.findings);
|
|
314
|
+
// loadDevLoopConfig never throws: it returns { config, warnings, errors }.
|
|
315
|
+
// A non-empty errors array means the config could not be loaded/validated, so
|
|
316
|
+
// log it (stderr) and fall back to default behavior (config-unavailable →
|
|
317
|
+
// null → resolveGatePostFindingsComments defaults on → proceed to post),
|
|
318
|
+
// rather than trusting a malformed/partial config object. Mirrors how
|
|
319
|
+
// detect-checkpoint-evidence treats config-unavailable.
|
|
320
|
+
const { config: loadedConfig, errors: configErrors } = await loadDevLoopConfig({ repoRoot });
|
|
321
|
+
let config = loadedConfig;
|
|
322
|
+
if (Array.isArray(configErrors) && configErrors.length > 0) {
|
|
323
|
+
process.stderr.write(
|
|
324
|
+
`post-gate-findings: dev-loop config could not be loaded/validated; using default behavior. errors=${JSON.stringify(configErrors)}\n`,
|
|
325
|
+
);
|
|
326
|
+
config = null;
|
|
327
|
+
}
|
|
328
|
+
if (!resolveGatePostFindingsComments(config)) {
|
|
329
|
+
return {
|
|
330
|
+
ok: true,
|
|
331
|
+
action: "skipped",
|
|
332
|
+
reason: "gates.postFindingsComments is false",
|
|
333
|
+
repo: options.repo,
|
|
334
|
+
pr: options.pr,
|
|
335
|
+
gate: options.gate,
|
|
336
|
+
headSha: options.headSha,
|
|
337
|
+
findingsCount: findings.length,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const marker = buildFindingsMarker({ gate: options.gate });
|
|
341
|
+
const desiredBody = renderFindingsCommentBody({ gate: options.gate, headSha: options.headSha, findings });
|
|
342
|
+
const comments = await listIssueComments({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
343
|
+
const existing = findMarkedComment(comments, marker);
|
|
344
|
+
const base = {
|
|
345
|
+
ok: true,
|
|
346
|
+
repo: options.repo,
|
|
347
|
+
pr: options.pr,
|
|
348
|
+
gate: options.gate,
|
|
349
|
+
headSha: options.headSha,
|
|
350
|
+
findingsCount: findings.length,
|
|
351
|
+
};
|
|
352
|
+
if (existing) {
|
|
353
|
+
if (typeof existing.body === "string" && existing.body === desiredBody) {
|
|
354
|
+
return {
|
|
355
|
+
...base,
|
|
356
|
+
action: "noop",
|
|
357
|
+
commentId: Number.isInteger(existing.id) ? existing.id : null,
|
|
358
|
+
commentUrl: typeof existing.html_url === "string" ? existing.html_url : null,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const updated = await updateComment({ repo: options.repo, commentId: existing.id, body: desiredBody }, { env, ghCommand });
|
|
362
|
+
return { ...base, action: "updated", ...updated };
|
|
363
|
+
}
|
|
364
|
+
const created = await createComment({ repo: options.repo, pr: options.pr, body: desiredBody }, { env, ghCommand });
|
|
365
|
+
return { ...base, action: "created", ...created };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function main() {
|
|
369
|
+
let options;
|
|
370
|
+
try {
|
|
371
|
+
options = parsePostGateFindingsCliArgs(process.argv.slice(2));
|
|
372
|
+
} catch (error) {
|
|
373
|
+
process.stderr.write(`${formatCliError(error, { usage: USAGE })}\n`);
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (options.help) {
|
|
378
|
+
process.stdout.write(`${USAGE}\n`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const result = await postGateFindings(options);
|
|
383
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
386
|
+
process.exitCode = 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
391
|
+
await main();
|
|
392
|
+
}
|