knodin 0.5.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/LICENSE +21 -0
- package/README.md +590 -0
- package/dist/bin/cli.js +1704 -0
- package/dist/src/agent-integration.js +250 -0
- package/dist/src/artifact-refresh.js +81 -0
- package/dist/src/cli-args.js +267 -0
- package/dist/src/cli-model.js +324 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/competitive-constraints.js +20 -0
- package/dist/src/competitive-manifest.js +330 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +453 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +422 -0
- package/dist/src/context.js +102 -0
- package/dist/src/docs-sections.js +141 -0
- package/dist/src/doctor.js +380 -0
- package/dist/src/engine/ann-hnsw.js +271 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +43 -0
- package/dist/src/engine/index.js +13030 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/source-policy.js +69 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/failure-diagnosis.js +590 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/index-activity.js +125 -0
- package/dist/src/init-progress-worker.js +107 -0
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +985 -0
- package/dist/src/lifecycle-health.js +213 -0
- package/dist/src/lsp-readonly.js +217 -0
- package/dist/src/output-compression.js +629 -0
- package/dist/src/output-telemetry.js +359 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/relationship-adapters.js +370 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/repair-progress-worker.js +121 -0
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +173 -0
- package/dist/src/repository-management.js +1089 -0
- package/dist/src/response-budget.js +184 -0
- package/dist/src/server.js +53 -0
- package/dist/src/system-config.js +615 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1438 -0
- package/dist/src/tools/reckon-tools.js +5 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +503 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +162 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +231 -0
- package/docs/CLI.md +39 -0
- package/docs/COMMAND-OUTPUT-COMPRESSION.md +194 -0
- package/docs/DEAD-CODE-AND-IMPACT.md +27 -0
- package/docs/DOCTOR-AND-UPDATES.md +84 -0
- package/docs/INDEXING-POLICY-AND-PROVENANCE.md +37 -0
- package/docs/INSTALLATION.md +208 -0
- package/docs/MCP.md +100 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +91 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +81 -0
- package/docs/SIGNED-UPDATES.md +146 -0
- package/docs/SYSTEMS-AND-RELATIONSHIPS.md +45 -0
- package/docs/TELEMETRY.md +42 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/package.json +110 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/tree-sitter-prisma.wasm +0 -0
- package/tree-sitter-sql.wasm +0 -0
- package/tree-sitter-xml.wasm +0 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR triage — a thin local wrapper around the user's already-authenticated
|
|
3
|
+
* `gh` CLI. No stored credentials, no auth logic here: this only shells out to
|
|
4
|
+
* `gh` and joins its output onto the engine's existing `query("impact", ...)`
|
|
5
|
+
* and `map()` results.
|
|
6
|
+
*
|
|
7
|
+
* Shipped CLI-first under R7; R22 additionally exposes this same pipeline
|
|
8
|
+
* through the `knodin` MCP gateway's `prs` operation (the R7 "CLI-only, never
|
|
9
|
+
* wire into knodin-tools" ruling is superseded — the no-stored-creds property
|
|
10
|
+
* it protected is unchanged). The MCP path must never throw into the stdio
|
|
11
|
+
* transport, so it goes through `ghUnavailableReason()` (non-throwing) rather
|
|
12
|
+
* than `ensureGhAvailable()` (throwing, still used by the CLI's hard-fail).
|
|
13
|
+
*/
|
|
14
|
+
import child_process from "node:child_process";
|
|
15
|
+
/** Resolve the `gh` binary to invoke. Tests override via `RECKON_GH_BIN` to
|
|
16
|
+
* point at a stub script fixture — no network in tests. */
|
|
17
|
+
function ghBin() {
|
|
18
|
+
return process.env.RECKON_GH_BIN || "gh";
|
|
19
|
+
}
|
|
20
|
+
const STALE_DAYS = 30;
|
|
21
|
+
const PR_LIST_FIELDS = "number,title,updatedAt,reviewDecision,statusCheckRollup,files,headRefName";
|
|
22
|
+
const PR_AUDIT_FIELDS = `${PR_LIST_FIELDS},baseRefName,state,mergeCommit,url,isDraft`;
|
|
23
|
+
const GH_MISSING_MSG = "knodin prs requires the GitHub CLI ('gh') on PATH — install it, then run 'gh auth login'.";
|
|
24
|
+
const GH_UNAUTH_MSG = "knodin prs requires an authenticated 'gh' — run 'gh auth login' (gh auth status failed).";
|
|
25
|
+
/** Match a bounded branch glob without compiling user input as a regular expression. */
|
|
26
|
+
export function matchesBranchGlob(value, pattern) {
|
|
27
|
+
if (pattern.length > 256)
|
|
28
|
+
return false;
|
|
29
|
+
let valueIndex = 0;
|
|
30
|
+
let patternIndex = 0;
|
|
31
|
+
let starIndex = -1;
|
|
32
|
+
let starValueIndex = 0;
|
|
33
|
+
while (valueIndex < value.length) {
|
|
34
|
+
if (patternIndex < pattern.length &&
|
|
35
|
+
(pattern[patternIndex] === "?" || pattern[patternIndex] === value[valueIndex])) {
|
|
36
|
+
valueIndex++;
|
|
37
|
+
patternIndex++;
|
|
38
|
+
}
|
|
39
|
+
else if (patternIndex < pattern.length && pattern[patternIndex] === "*") {
|
|
40
|
+
starIndex = patternIndex++;
|
|
41
|
+
starValueIndex = valueIndex;
|
|
42
|
+
}
|
|
43
|
+
else if (starIndex >= 0) {
|
|
44
|
+
patternIndex = starIndex + 1;
|
|
45
|
+
valueIndex = ++starValueIndex;
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
while (patternIndex < pattern.length && pattern[patternIndex] === "*")
|
|
51
|
+
patternIndex++;
|
|
52
|
+
return patternIndex === pattern.length;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Non-throwing gh-availability probe: returns a one-line reason string when
|
|
56
|
+
* `gh` is missing from PATH or unauthenticated, or `null` when it's usable.
|
|
57
|
+
* The MCP path (R22) uses this so a `gh` problem surfaces as a structured
|
|
58
|
+
* `{ error }` result instead of an exception the stdio transport can't
|
|
59
|
+
* interpret. Shares its message text with {@link ensureGhAvailable}.
|
|
60
|
+
*/
|
|
61
|
+
export function ghUnavailableReason() {
|
|
62
|
+
const bin = ghBin();
|
|
63
|
+
const res = child_process.spawnSync(bin, ["auth", "status"], {
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
env: process.env,
|
|
66
|
+
});
|
|
67
|
+
if (res.error)
|
|
68
|
+
return GH_MISSING_MSG;
|
|
69
|
+
if (res.status !== 0)
|
|
70
|
+
return GH_UNAUTH_MSG;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Hard-fails fast (one-line message, no stack trace expected from callers)
|
|
75
|
+
* when `gh` isn't on PATH or isn't authenticated. Required before the single
|
|
76
|
+
* `gh pr list` call per the R7 spec. Retained for the CLI's hard-fail path;
|
|
77
|
+
* the MCP path uses {@link ghUnavailableReason} instead.
|
|
78
|
+
*/
|
|
79
|
+
export function ensureGhAvailable() {
|
|
80
|
+
const reason = ghUnavailableReason();
|
|
81
|
+
if (reason)
|
|
82
|
+
throw new Error(reason);
|
|
83
|
+
}
|
|
84
|
+
/** Single `gh pr list` call for open PRs — NO per-PR `gh` calls (per spec). */
|
|
85
|
+
export function fetchOpenPrs(repoPath, limit = 50) {
|
|
86
|
+
const bin = ghBin();
|
|
87
|
+
const res = child_process.spawnSync(bin, ["pr", "list", "--json", PR_LIST_FIELDS, "--limit", String(limit)], { cwd: repoPath, encoding: "utf-8", env: process.env });
|
|
88
|
+
if (res.error) {
|
|
89
|
+
throw new Error(GH_MISSING_MSG);
|
|
90
|
+
}
|
|
91
|
+
if (res.status !== 0) {
|
|
92
|
+
const stderr = (res.stderr ?? "").toString().trim();
|
|
93
|
+
throw new Error(`knodin prs: 'gh pr list' failed${stderr ? `: ${stderr}` : ""}`);
|
|
94
|
+
}
|
|
95
|
+
const stdout = (res.stdout ?? "").toString().trim();
|
|
96
|
+
if (!stdout)
|
|
97
|
+
return [];
|
|
98
|
+
return JSON.parse(stdout);
|
|
99
|
+
}
|
|
100
|
+
/** `gh pr view <n>` fields — same set as {@link PR_LIST_FIELDS} plus `state`,
|
|
101
|
+
* needed to filter out a closed/merged PR that `gh pr view` will happily
|
|
102
|
+
* return even though it isn't "open". */
|
|
103
|
+
const PR_VIEW_FIELDS = `${PR_LIST_FIELDS},state`;
|
|
104
|
+
/**
|
|
105
|
+
* Single-PR lookup that isn't bounded by `gh pr list`'s `--limit` page: a PR
|
|
106
|
+
* that exists and is open but falls outside a small `fetchOpenPrs` page (e.g.
|
|
107
|
+
* an older PR when there are more than `limit` open PRs) must still be found.
|
|
108
|
+
* Returns `null` — never throws — both when the PR number doesn't exist at
|
|
109
|
+
* all (`gh pr view`'s failure mode for a bad number) and when it exists but
|
|
110
|
+
* isn't currently OPEN (closed/merged), matching "not found among open PRs".
|
|
111
|
+
*/
|
|
112
|
+
export function fetchOpenPr(repoPath, prNumber) {
|
|
113
|
+
const bin = ghBin();
|
|
114
|
+
const res = child_process.spawnSync(bin, ["pr", "view", String(prNumber), "--json", PR_VIEW_FIELDS], { cwd: repoPath, encoding: "utf-8", env: process.env });
|
|
115
|
+
if (res.error || res.status !== 0)
|
|
116
|
+
return null;
|
|
117
|
+
const stdout = (res.stdout ?? "").toString().trim();
|
|
118
|
+
if (!stdout)
|
|
119
|
+
return null;
|
|
120
|
+
const pr = JSON.parse(stdout);
|
|
121
|
+
if (pr.state !== "OPEN")
|
|
122
|
+
return null;
|
|
123
|
+
return pr;
|
|
124
|
+
}
|
|
125
|
+
/** Collapses a PR's `statusCheckRollup` (CheckRun/StatusContext entries, mixed
|
|
126
|
+
* `conclusion`/`state`/`status` vocab across GitHub's two check APIs) into one
|
|
127
|
+
* of `pass | pending | fail | none`. */
|
|
128
|
+
export function computeCiStatus(rollup) {
|
|
129
|
+
if (!rollup || rollup.length === 0)
|
|
130
|
+
return "none";
|
|
131
|
+
const FAIL_CONCLUSIONS = new Set([
|
|
132
|
+
"FAILURE",
|
|
133
|
+
"CANCELLED",
|
|
134
|
+
"TIMED_OUT",
|
|
135
|
+
"ERROR",
|
|
136
|
+
"ACTION_REQUIRED",
|
|
137
|
+
"STARTUP_FAILURE",
|
|
138
|
+
]);
|
|
139
|
+
let pending = false;
|
|
140
|
+
for (const entry of rollup) {
|
|
141
|
+
const conclusion = (entry.conclusion ?? "").toUpperCase();
|
|
142
|
+
const state = (entry.state ?? "").toUpperCase();
|
|
143
|
+
const status = (entry.status ?? "").toUpperCase();
|
|
144
|
+
if (FAIL_CONCLUSIONS.has(conclusion) || state === "FAILURE" || state === "ERROR") {
|
|
145
|
+
return "fail";
|
|
146
|
+
}
|
|
147
|
+
if (state === "PENDING" || (status && status !== "COMPLETED")) {
|
|
148
|
+
pending = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return pending ? "pending" : "pass";
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Classifies a PR per the R7 spec: BLOCKED (changes requested or CI fail)
|
|
155
|
+
* beats PENDING (CI running) beats STALE (>30d untouched) beats READY (CI
|
|
156
|
+
* pass and review approved/none-required).
|
|
157
|
+
*/
|
|
158
|
+
export function classifyPr(pr, ci, now = Date.now()) {
|
|
159
|
+
if (ci === "fail" || pr.reviewDecision === "CHANGES_REQUESTED")
|
|
160
|
+
return "BLOCKED";
|
|
161
|
+
if (ci === "pending")
|
|
162
|
+
return "PENDING";
|
|
163
|
+
const updatedMs = Date.parse(pr.updatedAt);
|
|
164
|
+
const ageDays = (now - updatedMs) / (1000 * 60 * 60 * 24);
|
|
165
|
+
if (Number.isFinite(ageDays) && ageDays > STALE_DAYS)
|
|
166
|
+
return "STALE";
|
|
167
|
+
return "READY";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Shared join for a PR's changed files → `query("impact", ...)` blast radius →
|
|
171
|
+
* `map()` community membership. Returns `null` when NONE of the PR's changed
|
|
172
|
+
* files are indexed (deps/workflow-only changes), which both public wrappers
|
|
173
|
+
* render as their respective "nothing indexed" shape. Names/counts are both
|
|
174
|
+
* derived from this one traversal so the two wrappers can never disagree.
|
|
175
|
+
*/
|
|
176
|
+
async function joinPrImpact(pr, engine, repoPath) {
|
|
177
|
+
const changedPaths = (pr.files ?? []).map((f) => f.path).filter(Boolean);
|
|
178
|
+
if (changedPaths.length === 0)
|
|
179
|
+
return null;
|
|
180
|
+
const indexedPaths = [];
|
|
181
|
+
for (const filePath of changedPaths) {
|
|
182
|
+
const summary = await engine.query("file_summary", filePath, repoPath);
|
|
183
|
+
if (summary.count > 0)
|
|
184
|
+
indexedPaths.push(filePath);
|
|
185
|
+
}
|
|
186
|
+
if (indexedPaths.length === 0)
|
|
187
|
+
return null;
|
|
188
|
+
const impactResult = await engine.query("impact", indexedPaths.join(","), repoPath, undefined, undefined, undefined, undefined, undefined, { mode: "file" });
|
|
189
|
+
const impactedFiles = new Set(indexedPaths);
|
|
190
|
+
for (const row of impactResult.results) {
|
|
191
|
+
if (row.file)
|
|
192
|
+
impactedFiles.add(row.file);
|
|
193
|
+
}
|
|
194
|
+
const mapResult = await engine.map(repoPath, "standard");
|
|
195
|
+
const communities = [];
|
|
196
|
+
for (const community of mapResult.communities) {
|
|
197
|
+
const files = community.files ?? [];
|
|
198
|
+
if (files.some((f) => impactedFiles.has(f)))
|
|
199
|
+
communities.push(community.name);
|
|
200
|
+
}
|
|
201
|
+
return { symbolCount: impactResult.count, files: [...impactedFiles], communities };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Joins a PR's changed files onto `query("impact", ...)` and `map()`
|
|
205
|
+
* community membership. Files not present in the index (deps, workflows, …)
|
|
206
|
+
* are dropped before the join; if NONE of a PR's files are indexed the whole
|
|
207
|
+
* impact is reported as `"–"`, matching graphify's dep-only-PR row.
|
|
208
|
+
*/
|
|
209
|
+
export async function computePrImpact(pr, engine, repoPath) {
|
|
210
|
+
const joined = await joinPrImpact(pr, engine, repoPath);
|
|
211
|
+
if (!joined)
|
|
212
|
+
return { symbols: "–", communities: "–" };
|
|
213
|
+
return { symbols: joined.symbolCount, communities: joined.communities.length };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Detailed single-PR impact (R22's `get_pr_impact` equivalent): the impacted
|
|
217
|
+
* file list and community **names**, not just counts. Returns empty lists when
|
|
218
|
+
* none of the PR's changed files are indexed.
|
|
219
|
+
*/
|
|
220
|
+
export async function computePrImpactDetail(pr, engine, repoPath) {
|
|
221
|
+
const joined = await joinPrImpact(pr, engine, repoPath);
|
|
222
|
+
if (!joined)
|
|
223
|
+
return { files: [], communities: [] };
|
|
224
|
+
return { files: joined.files, communities: joined.communities };
|
|
225
|
+
}
|
|
226
|
+
const STATUS_RANK = {
|
|
227
|
+
READY: 0,
|
|
228
|
+
PENDING: 1,
|
|
229
|
+
STALE: 2,
|
|
230
|
+
BLOCKED: 3,
|
|
231
|
+
};
|
|
232
|
+
/** Numeric sort key for an impact — `"–"` (unindexed/dep-only) sorts as the
|
|
233
|
+
* smallest, simplest possible change. */
|
|
234
|
+
function impactSortKey(impact) {
|
|
235
|
+
return impact.symbols === "–" ? 0 : impact.symbols;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Runs the full R7 pipeline: hard-fail check, single `gh pr list` call,
|
|
239
|
+
* per-PR classification + impact join, then sorts READY-small-impact first,
|
|
240
|
+
* then PENDING, then STALE, then BLOCKED.
|
|
241
|
+
*
|
|
242
|
+
* `skipGhCheck` lets a caller that already probed gh availability itself
|
|
243
|
+
* (the MCP dispatcher, via {@link ghUnavailableReason}) skip this function's
|
|
244
|
+
* own {@link ensureGhAvailable} call — otherwise a single `knodin prs` MCP
|
|
245
|
+
* call spawns `gh auth status` twice. Defaults to `false` so the CLI's
|
|
246
|
+
* existing standalone hard-fail behavior is unchanged.
|
|
247
|
+
*/
|
|
248
|
+
export async function triagePrs(repoPath, engine, limit = 50, skipGhCheck = false) {
|
|
249
|
+
if (!skipGhCheck)
|
|
250
|
+
ensureGhAvailable();
|
|
251
|
+
const prs = fetchOpenPrs(repoPath, limit);
|
|
252
|
+
const triaged = [];
|
|
253
|
+
for (const pr of prs) {
|
|
254
|
+
const ci = computeCiStatus(pr.statusCheckRollup);
|
|
255
|
+
const status = classifyPr(pr, ci);
|
|
256
|
+
const impact = await computePrImpact(pr, engine, repoPath);
|
|
257
|
+
triaged.push({
|
|
258
|
+
number: pr.number,
|
|
259
|
+
title: pr.title,
|
|
260
|
+
status,
|
|
261
|
+
ci,
|
|
262
|
+
updatedAt: pr.updatedAt,
|
|
263
|
+
impact,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
triaged.sort((a, b) => {
|
|
267
|
+
const rankDiff = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
268
|
+
if (rankDiff !== 0)
|
|
269
|
+
return rankDiff;
|
|
270
|
+
return impactSortKey(a.impact) - impactSortKey(b.impact);
|
|
271
|
+
});
|
|
272
|
+
return triaged;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Single-PR detail path (R22): looks up `prNumber` directly via `gh pr view`
|
|
276
|
+
* (see {@link fetchOpenPr}) — NOT via `fetchOpenPrs(...).find(...)`, since
|
|
277
|
+
* that's bounded by `--limit` (default 50) and would falsely report "not
|
|
278
|
+
* found" for a real, open PR that simply falls outside that page — and
|
|
279
|
+
* returns the detailed impact (file list + community names). Assumes gh
|
|
280
|
+
* availability was already checked by the caller (the MCP dispatcher probes
|
|
281
|
+
* {@link ghUnavailableReason} first). Returns a structured `{ error }` —
|
|
282
|
+
* never throws — when the PR isn't open (missing entirely, or closed/merged).
|
|
283
|
+
*/
|
|
284
|
+
export async function triagePrDetail(repoPath, engine, prNumber,
|
|
285
|
+
// Kept for call-site compatibility (the MCP dispatcher and CLI both still
|
|
286
|
+
// pass a `limit`), but unused now that lookup goes through `fetchOpenPr`
|
|
287
|
+
// (`gh pr view`) rather than a `--limit`-bounded `gh pr list` page.
|
|
288
|
+
_limit = 50) {
|
|
289
|
+
const pr = fetchOpenPr(repoPath, prNumber);
|
|
290
|
+
if (!pr)
|
|
291
|
+
return { error: `knodin prs: PR #${prNumber} not found among open PRs` };
|
|
292
|
+
const ci = computeCiStatus(pr.statusCheckRollup);
|
|
293
|
+
const status = classifyPr(pr, ci);
|
|
294
|
+
const impact = await computePrImpactDetail(pr, engine, repoPath);
|
|
295
|
+
return { number: pr.number, title: pr.title, status, ci, updatedAt: pr.updatedAt, impact };
|
|
296
|
+
}
|
|
297
|
+
function ghText(repoPath, args) {
|
|
298
|
+
const result = child_process.spawnSync(ghBin(), args, {
|
|
299
|
+
cwd: repoPath,
|
|
300
|
+
encoding: "utf-8",
|
|
301
|
+
env: process.env,
|
|
302
|
+
});
|
|
303
|
+
if (result.error)
|
|
304
|
+
return {
|
|
305
|
+
value: null,
|
|
306
|
+
error: result.error instanceof Error ? result.error.message : String(result.error),
|
|
307
|
+
};
|
|
308
|
+
if (result.status !== 0)
|
|
309
|
+
return {
|
|
310
|
+
value: null,
|
|
311
|
+
error: (result.stderr ?? "").toString().trim() || `gh ${args.join(" ")} failed`,
|
|
312
|
+
};
|
|
313
|
+
return { value: (result.stdout ?? "").toString().trim(), error: null };
|
|
314
|
+
}
|
|
315
|
+
function revisionCommits(repoPath, base, head) {
|
|
316
|
+
if (!base && !head)
|
|
317
|
+
return { commits: null, error: null };
|
|
318
|
+
if (!base || !head)
|
|
319
|
+
return { commits: null, error: "both --base and --head are required for revision auditing" };
|
|
320
|
+
const result = child_process.spawnSync("git", ["rev-list", `${base}..${head}`], {
|
|
321
|
+
cwd: repoPath,
|
|
322
|
+
encoding: "utf-8",
|
|
323
|
+
});
|
|
324
|
+
if (result.status !== 0)
|
|
325
|
+
return {
|
|
326
|
+
commits: null,
|
|
327
|
+
error: (result.stderr ?? "").toString().trim() || `unable to resolve ${base}..${head}`,
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
commits: new Set((result.stdout ?? "").toString().split(/\r?\n/).filter(Boolean)),
|
|
331
|
+
error: null,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function rulesetPatternMatches(pattern, branch, defaultBranch) {
|
|
335
|
+
if (pattern === "~ALL")
|
|
336
|
+
return true;
|
|
337
|
+
if (pattern === "~DEFAULT_BRANCH")
|
|
338
|
+
return defaultBranch === branch;
|
|
339
|
+
const ref = `refs/heads/${branch}`;
|
|
340
|
+
return (pattern === branch ||
|
|
341
|
+
pattern === ref ||
|
|
342
|
+
matchesBranchGlob(branch, pattern) ||
|
|
343
|
+
matchesBranchGlob(ref, pattern));
|
|
344
|
+
}
|
|
345
|
+
function rulesetAppliesToBranch(ruleset, branch, defaultBranch) {
|
|
346
|
+
if (ruleset.target !== "branch" || ruleset.enforcement !== "active")
|
|
347
|
+
return false;
|
|
348
|
+
const names = ruleset.conditions?.ref_name;
|
|
349
|
+
const excluded = (names?.exclude ?? []).some((pattern) => rulesetPatternMatches(pattern, branch, defaultBranch));
|
|
350
|
+
if (excluded)
|
|
351
|
+
return false;
|
|
352
|
+
const included = names?.include ?? [];
|
|
353
|
+
return (included.length === 0 ||
|
|
354
|
+
included.some((pattern) => rulesetPatternMatches(pattern, branch, defaultBranch)));
|
|
355
|
+
}
|
|
356
|
+
function rulesetRequiredChecks(repoPath, repository, branch, defaultBranch) {
|
|
357
|
+
const listed = ghText(repoPath, ["api", `repos/${repository}/rulesets?includes_parents=true`]);
|
|
358
|
+
if (listed.error)
|
|
359
|
+
return { checks: null, error: listed.error };
|
|
360
|
+
let summaries;
|
|
361
|
+
try {
|
|
362
|
+
summaries = JSON.parse(listed.value || "[]");
|
|
363
|
+
if (!Array.isArray(summaries))
|
|
364
|
+
throw new Error("expected an array");
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
return {
|
|
368
|
+
checks: null,
|
|
369
|
+
error: `invalid ruleset list JSON (${error instanceof Error ? error.message : String(error)})`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const checks = [];
|
|
373
|
+
for (const summary of summaries) {
|
|
374
|
+
if (summary.id === undefined)
|
|
375
|
+
continue;
|
|
376
|
+
const detail = ghText(repoPath, [
|
|
377
|
+
"api",
|
|
378
|
+
`repos/${repository}/rulesets/${summary.id}?includes_parents=true`,
|
|
379
|
+
]);
|
|
380
|
+
if (detail.error)
|
|
381
|
+
return { checks: null, error: `ruleset ${summary.id}: ${detail.error}` };
|
|
382
|
+
let ruleset;
|
|
383
|
+
try {
|
|
384
|
+
ruleset = JSON.parse(detail.value || "{}");
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
return {
|
|
388
|
+
checks: null,
|
|
389
|
+
error: `ruleset ${summary.id}: invalid JSON (${error instanceof Error ? error.message : String(error)})`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (!rulesetAppliesToBranch(ruleset, branch, defaultBranch))
|
|
393
|
+
continue;
|
|
394
|
+
for (const rule of ruleset.rules ?? []) {
|
|
395
|
+
if (rule.type === "required_status_checks") {
|
|
396
|
+
for (const check of rule.parameters?.required_status_checks ?? []) {
|
|
397
|
+
checks.push({
|
|
398
|
+
...check,
|
|
399
|
+
source: "ruleset",
|
|
400
|
+
rulesetId: ruleset.id ?? summary.id,
|
|
401
|
+
rulesetName: ruleset.name ?? summary.name ?? null,
|
|
402
|
+
ruleType: rule.type,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (rule.type === "workflows") {
|
|
407
|
+
for (const workflow of rule.parameters?.workflows ?? []) {
|
|
408
|
+
checks.push({
|
|
409
|
+
...workflow,
|
|
410
|
+
context: workflow.path ?? null,
|
|
411
|
+
source: "ruleset",
|
|
412
|
+
rulesetId: ruleset.id ?? summary.id,
|
|
413
|
+
rulesetName: ruleset.name ?? summary.name ?? null,
|
|
414
|
+
ruleType: rule.type,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return { checks, error: null };
|
|
421
|
+
}
|
|
422
|
+
function auditSupplement(repoPath, repository, pr, defaultBranch) {
|
|
423
|
+
const errors = [];
|
|
424
|
+
let requiredChecks = null;
|
|
425
|
+
const required = ghText(repoPath, [
|
|
426
|
+
"api",
|
|
427
|
+
`repos/${repository}/branches/${encodeURIComponent(pr.baseRefName ?? "")}/protection/required_status_checks`,
|
|
428
|
+
]);
|
|
429
|
+
if (required.error?.includes("HTTP 404")) {
|
|
430
|
+
const fallback = rulesetRequiredChecks(repoPath, repository, pr.baseRefName ?? "", defaultBranch);
|
|
431
|
+
if (fallback.error)
|
|
432
|
+
errors.push(`PR #${pr.number} required checks: classic protection unavailable (${required.error}); rulesets: ${fallback.error}`);
|
|
433
|
+
else
|
|
434
|
+
requiredChecks = fallback.checks;
|
|
435
|
+
}
|
|
436
|
+
else if (required.error)
|
|
437
|
+
errors.push(`PR #${pr.number} required checks: ${required.error}`);
|
|
438
|
+
else {
|
|
439
|
+
try {
|
|
440
|
+
const parsed = JSON.parse(required.value || "{}");
|
|
441
|
+
requiredChecks = parsed.checks ?? parsed.contexts ?? [];
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
errors.push(`PR #${pr.number} required checks: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const [owner, name] = repository.split("/", 2);
|
|
448
|
+
const threads = ghText(repoPath, [
|
|
449
|
+
"api",
|
|
450
|
+
"graphql",
|
|
451
|
+
"-f",
|
|
452
|
+
"query=query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved isOutdated}pageInfo{hasNextPage}}}}}",
|
|
453
|
+
"-F",
|
|
454
|
+
`owner=${owner}`,
|
|
455
|
+
"-F",
|
|
456
|
+
`name=${name}`,
|
|
457
|
+
"-F",
|
|
458
|
+
`number=${pr.number}`,
|
|
459
|
+
]);
|
|
460
|
+
let unresolvedActionableThreads = null;
|
|
461
|
+
if (threads.error)
|
|
462
|
+
errors.push(`PR #${pr.number} review threads: ${threads.error}`);
|
|
463
|
+
else {
|
|
464
|
+
try {
|
|
465
|
+
const parsed = JSON.parse(threads.value || "{}");
|
|
466
|
+
const reviewThreads = parsed.data?.repository?.pullRequest?.reviewThreads;
|
|
467
|
+
if (reviewThreads?.pageInfo?.hasNextPage)
|
|
468
|
+
errors.push(`PR #${pr.number} review threads: incomplete response exceeds 100 threads`);
|
|
469
|
+
else
|
|
470
|
+
unresolvedActionableThreads = (reviewThreads?.nodes ?? []).filter((thread) => !thread.isResolved && !thread.isOutdated).length;
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
errors.push(`PR #${pr.number} review threads: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const branch = ghText(repoPath, [
|
|
477
|
+
"api",
|
|
478
|
+
`repos/${repository}/git/ref/heads/${encodeURIComponent(pr.headRefName)}`,
|
|
479
|
+
]);
|
|
480
|
+
const headBranchSurvives = branch.error
|
|
481
|
+
? branch.error.includes("HTTP 404")
|
|
482
|
+
? false
|
|
483
|
+
: null
|
|
484
|
+
: true;
|
|
485
|
+
if (branch.error && headBranchSurvives === null)
|
|
486
|
+
errors.push(`PR #${pr.number} head branch: ${branch.error}`);
|
|
487
|
+
return { requiredChecks, unresolvedActionableThreads, headBranchSurvives, errors };
|
|
488
|
+
}
|
|
489
|
+
/** Completion-oriented, schema-versioned PR evidence without mutating gh account state. */
|
|
490
|
+
export async function auditPullRequests(repoPath, engine, options = {}) {
|
|
491
|
+
const errors = [];
|
|
492
|
+
const repositoryResult = ghText(repoPath, [
|
|
493
|
+
"repo",
|
|
494
|
+
"view",
|
|
495
|
+
"--json",
|
|
496
|
+
"nameWithOwner,url,defaultBranchRef",
|
|
497
|
+
]);
|
|
498
|
+
if (repositoryResult.error)
|
|
499
|
+
errors.push(`repository: ${repositoryResult.error}`);
|
|
500
|
+
let repository = null;
|
|
501
|
+
let host = null;
|
|
502
|
+
let defaultBranch = null;
|
|
503
|
+
try {
|
|
504
|
+
const resolved = JSON.parse(repositoryResult.value || "{}");
|
|
505
|
+
repository = resolved.nameWithOwner ?? null;
|
|
506
|
+
host = resolved.url ? new URL(resolved.url).host : null;
|
|
507
|
+
defaultBranch = resolved.defaultBranchRef?.name ?? null;
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
errors.push(`repository: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
511
|
+
}
|
|
512
|
+
const loginResult = ghText(repoPath, [
|
|
513
|
+
"api",
|
|
514
|
+
...(host ? ["--hostname", host] : []),
|
|
515
|
+
"user",
|
|
516
|
+
"--jq",
|
|
517
|
+
".login",
|
|
518
|
+
]);
|
|
519
|
+
if (loginResult.error)
|
|
520
|
+
errors.push(`authentication: ${loginResult.error}`);
|
|
521
|
+
const authenticatedAs = loginResult.value;
|
|
522
|
+
if (options.expectedLogin && authenticatedAs && options.expectedLogin !== authenticatedAs) {
|
|
523
|
+
errors.push(`authenticated login mismatch: expected ${options.expectedLogin}, resolved ${authenticatedAs}`);
|
|
524
|
+
}
|
|
525
|
+
if (!repository || !authenticatedAs || errors.some((error) => error.includes("login mismatch"))) {
|
|
526
|
+
return {
|
|
527
|
+
schemaVersion: 1,
|
|
528
|
+
status: "unavailable",
|
|
529
|
+
repository,
|
|
530
|
+
host,
|
|
531
|
+
authenticatedAs,
|
|
532
|
+
resultCount: null,
|
|
533
|
+
pullRequests: [],
|
|
534
|
+
errors,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const state = options.state ?? "open";
|
|
538
|
+
if (!["open", "merged", "closed", "all"].includes(state))
|
|
539
|
+
errors.push(`pull requests: unsupported state ${state}`);
|
|
540
|
+
if (options.range && !/^\d+\.\.\d+$/.test(options.range))
|
|
541
|
+
errors.push(`pull requests: invalid range ${options.range}; expected N..N`);
|
|
542
|
+
const revisions = revisionCommits(repoPath, options.base, options.head);
|
|
543
|
+
if (revisions.error)
|
|
544
|
+
errors.push(`revision range: ${revisions.error}`);
|
|
545
|
+
if (errors.length > 0) {
|
|
546
|
+
return {
|
|
547
|
+
schemaVersion: 1,
|
|
548
|
+
status: "unavailable",
|
|
549
|
+
repository,
|
|
550
|
+
host,
|
|
551
|
+
authenticatedAs,
|
|
552
|
+
resultCount: null,
|
|
553
|
+
pullRequests: [],
|
|
554
|
+
errors,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const list = ghText(repoPath, [
|
|
558
|
+
"pr",
|
|
559
|
+
"list",
|
|
560
|
+
"--state",
|
|
561
|
+
state,
|
|
562
|
+
"--limit",
|
|
563
|
+
String(options.limit ?? 50),
|
|
564
|
+
"--json",
|
|
565
|
+
PR_AUDIT_FIELDS,
|
|
566
|
+
]);
|
|
567
|
+
if (list.error) {
|
|
568
|
+
errors.push(`pull requests: ${list.error}`);
|
|
569
|
+
return {
|
|
570
|
+
schemaVersion: 1,
|
|
571
|
+
status: "unavailable",
|
|
572
|
+
repository,
|
|
573
|
+
host,
|
|
574
|
+
authenticatedAs,
|
|
575
|
+
resultCount: null,
|
|
576
|
+
pullRequests: [],
|
|
577
|
+
errors,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
let raw;
|
|
581
|
+
try {
|
|
582
|
+
raw = list.value ? JSON.parse(list.value) : [];
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
errors.push(`pull requests: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
586
|
+
raw = [];
|
|
587
|
+
}
|
|
588
|
+
const branchGlob = options.branches ?? null;
|
|
589
|
+
const range = options.range?.match(/^(\d+)\.\.(\d+)$/);
|
|
590
|
+
const minimum = range ? Number(range[1]) : null;
|
|
591
|
+
const maximum = range ? Number(range[2]) : null;
|
|
592
|
+
const selected = raw.filter((pr) => (!branchGlob || matchesBranchGlob(pr.headRefName, branchGlob)) &&
|
|
593
|
+
(minimum === null || maximum === null || (pr.number >= minimum && pr.number <= maximum)) &&
|
|
594
|
+
(revisions.commits === null ||
|
|
595
|
+
(pr.mergeCommit?.oid !== undefined && revisions.commits.has(pr.mergeCommit.oid))));
|
|
596
|
+
const pullRequests = [];
|
|
597
|
+
for (const pr of selected) {
|
|
598
|
+
const ci = computeCiStatus(pr.statusCheckRollup);
|
|
599
|
+
const supplement = auditSupplement(repoPath, repository, pr, defaultBranch);
|
|
600
|
+
errors.push(...supplement.errors);
|
|
601
|
+
pullRequests.push({
|
|
602
|
+
number: pr.number,
|
|
603
|
+
title: pr.title,
|
|
604
|
+
state: pr.state ?? state.toUpperCase(),
|
|
605
|
+
head: pr.headRefName,
|
|
606
|
+
base: pr.baseRefName ?? null,
|
|
607
|
+
mergeCommit: pr.mergeCommit?.oid ?? null,
|
|
608
|
+
url: pr.url ?? null,
|
|
609
|
+
isDraft: pr.isDraft ?? false,
|
|
610
|
+
reviewDecision: pr.reviewDecision,
|
|
611
|
+
approval: pr.reviewDecision === "APPROVED",
|
|
612
|
+
ci,
|
|
613
|
+
triageStatus: classifyPr(pr, ci),
|
|
614
|
+
observedChecks: pr.statusCheckRollup ?? [],
|
|
615
|
+
requiredChecks: supplement.requiredChecks,
|
|
616
|
+
unresolvedActionableThreads: supplement.unresolvedActionableThreads,
|
|
617
|
+
headBranchSurvives: supplement.headBranchSurvives,
|
|
618
|
+
impact: await computePrImpact(pr, engine, repoPath),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
schemaVersion: 1,
|
|
623
|
+
status: errors.length > 0 ? "partial" : "ok",
|
|
624
|
+
repository,
|
|
625
|
+
host,
|
|
626
|
+
authenticatedAs,
|
|
627
|
+
state,
|
|
628
|
+
resultCount: pullRequests.length,
|
|
629
|
+
pullRequests,
|
|
630
|
+
errors,
|
|
631
|
+
revisionRange: options.base && options.head
|
|
632
|
+
? { base: options.base, head: options.head, commitCount: revisions.commits?.size ?? null }
|
|
633
|
+
: null,
|
|
634
|
+
limitations: [
|
|
635
|
+
"review-thread evidence is intentionally incomplete when a PR exceeds 100 threads",
|
|
636
|
+
],
|
|
637
|
+
};
|
|
638
|
+
}
|