jules-orchestrator-kit 0.38.2 → 0.41.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/.agent/prompts/Bolt.md +10 -7
- package/.agent/prompts/Janitor.md +6 -6
- package/.agent/prompts/Task_Template.md +6 -5
- package/.agent/rules/jules-protocol.md +1 -1
- package/JULES_RULES_TEMPLATE.md +12 -8
- package/README.md +45 -8
- package/bin/agentctl.mjs +297 -9
- package/index.mjs +15 -1
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +186 -0
- package/scripts/jules-merge-swarm.mjs +13 -3
- package/src/assertions.mjs +579 -0
- package/src/budget.mjs +48 -6
- package/src/config.mjs +69 -6
- package/src/dag-engine.mjs +22 -1
- package/src/engine.mjs +133 -23
- package/src/evidence.mjs +111 -2
- package/src/execution-envelope.mjs +23 -7
- package/src/flaky-ledger.mjs +4 -1
- package/src/ops/command-registry.mjs +32 -0
- package/src/ops/doctor-planner.mjs +9 -3
- package/src/ops/pr-harvest.mjs +349 -0
- package/src/provider.mjs +491 -25
- package/src/review-repair.mjs +85 -7
- package/src/risk.mjs +134 -26
- package/src/role-resolver.mjs +54 -2
- package/src/router.mjs +64 -6
- package/src/state.mjs +15 -3
- package/src/task-optimizer.mjs +9 -1
- package/src/telemetry.mjs +65 -0
- package/src/web-templates.mjs +372 -7
- package/src/wizard-init.mjs +13 -3
- package/src/wizard-task.mjs +76 -6
|
@@ -25,6 +25,38 @@
|
|
|
25
25
|
|
|
26
26
|
/** @type {CommandDescriptor[]} */
|
|
27
27
|
export const COMMAND_REGISTRY = [
|
|
28
|
+
{
|
|
29
|
+
id: "assert",
|
|
30
|
+
path: ["assert"],
|
|
31
|
+
title: "assert",
|
|
32
|
+
description: "Run declarative zero-dependency verification assertion primitives",
|
|
33
|
+
category: "Inspect",
|
|
34
|
+
mutates: false,
|
|
35
|
+
risk: "low",
|
|
36
|
+
interactive: "never",
|
|
37
|
+
requiresRepository: false,
|
|
38
|
+
shortcuts: ["ast"],
|
|
39
|
+
examples: [
|
|
40
|
+
"agentctl assert --dir dist --max-mb 10 --gzip",
|
|
41
|
+
"agentctl assert --file dist/server.js --max-kb 500",
|
|
42
|
+
'agentctl assert --patterns "console.log" --targets "src/**/*.js"',
|
|
43
|
+
"agentctl assert --config assert.json --json",
|
|
44
|
+
],
|
|
45
|
+
flags: [
|
|
46
|
+
{ name: "dir", type: "string", description: "Target directory path for dir-size assertion" },
|
|
47
|
+
{ name: "file", type: "string", description: "Target file path for file-size assertion" },
|
|
48
|
+
{ name: "targets", type: "string", description: "Target glob/path for pattern matching" },
|
|
49
|
+
{ name: "patterns", type: "string", description: "Comma-separated patterns or regex to ban" },
|
|
50
|
+
{ name: "patterns-file", type: "string", description: "Path to JSON file containing banned patterns" },
|
|
51
|
+
{ name: "max-bytes", type: "string", description: "Maximum byte limit" },
|
|
52
|
+
{ name: "max-kb", type: "string", description: "Maximum KiB limit" },
|
|
53
|
+
{ name: "max-mb", type: "string", description: "Maximum MiB limit" },
|
|
54
|
+
{ name: "gzip", type: "boolean", description: "Measure gzip compressed byte size" },
|
|
55
|
+
{ name: "config", type: "string", description: "Path to assertion JSON/YAML config" },
|
|
56
|
+
{ name: "json", type: "boolean", description: "Output structured JSON assertion result" },
|
|
57
|
+
{ name: "json-report", type: "string", description: "Write structured JSON report to target path" },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
28
60
|
{
|
|
29
61
|
id: "doctor",
|
|
30
62
|
path: ["doctor"],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { detectStack } from "../config.mjs";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @typedef {import("../ux/capabilities.mjs").ActionPlan} ActionPlan
|
|
@@ -72,12 +73,17 @@ export async function planDiagnosticFixes(context = {}) {
|
|
|
72
73
|
|
|
73
74
|
if (fix.id === "config.create-default") {
|
|
74
75
|
const configPath = ".agent/config.yml";
|
|
76
|
+
// Detected, not assumed. Writing `npm test` into a Rust or Python
|
|
77
|
+
// repository's config produced a manifest whose first verification run
|
|
78
|
+
// could only fail, and the stack detector already has the answer.
|
|
79
|
+
const detected = detectStack(root);
|
|
75
80
|
const newContent = `# jules-orchestrator-kit configuration manifest
|
|
76
81
|
version: 1
|
|
77
|
-
tier
|
|
82
|
+
# Free-tier limits until you state your plan (free | pro | ultra).
|
|
83
|
+
tier: free
|
|
78
84
|
verify:
|
|
79
|
-
test:
|
|
80
|
-
build:
|
|
85
|
+
test: ${JSON.stringify(detected.testCmd || "")}
|
|
86
|
+
build: ${JSON.stringify(detected.buildCmd || "")}
|
|
81
87
|
`;
|
|
82
88
|
const diff = createDiffPreview(configPath, "", newContent);
|
|
83
89
|
const planId = `PLAN-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { classifyRiskTier, RISK_TIERS } from "../risk.mjs";
|
|
3
|
+
import { checkSafetyGate } from "../../scripts/jules-merge-swarm.mjs";
|
|
4
|
+
import { normalizePath, loadConfig } from "../config.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Checks if GitHub CLI statusCheckRollup indicates all CI checks are green/successful.
|
|
8
|
+
*
|
|
9
|
+
* An empty rollup is NOT passing. It means one of two things, and neither is
|
|
10
|
+
* "verified": the repository has no CI at all — the common case for the repos
|
|
11
|
+
* this kit is installed into — or the workflows are queued and have not
|
|
12
|
+
* registered yet, so a harvest run seconds after the PR opens would merge ahead
|
|
13
|
+
* of the checks it claims to be waiting for. Treating "no evidence" as "good
|
|
14
|
+
* evidence" is the wrong default anywhere, and this value feeds the only
|
|
15
|
+
* irreversible action the kit takes.
|
|
16
|
+
*
|
|
17
|
+
* Operators who genuinely have no CI and want the triage anyway pass
|
|
18
|
+
* `--allow-no-checks`, which is a stated choice rather than a silent one.
|
|
19
|
+
*
|
|
20
|
+
* @param {Array<object>} checks
|
|
21
|
+
* @returns {{ passing: boolean, pending: boolean, failing: boolean, noChecks: boolean, summary: string }}
|
|
22
|
+
*/
|
|
23
|
+
export function evaluateStatusCheckRollup(checks = []) {
|
|
24
|
+
if (!Array.isArray(checks) || checks.length === 0) {
|
|
25
|
+
return { passing: false, pending: false, failing: false, noChecks: true, summary: "NO_CHECKS" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let failingCount = 0;
|
|
29
|
+
let pendingCount = 0;
|
|
30
|
+
let successCount = 0;
|
|
31
|
+
|
|
32
|
+
for (const check of checks) {
|
|
33
|
+
const status = String(check.status || check.state || "").toUpperCase();
|
|
34
|
+
const conclusion = String(check.conclusion || "").toUpperCase();
|
|
35
|
+
|
|
36
|
+
if (conclusion === "SUCCESS" || conclusion === "NEUTRAL" || conclusion === "SKIPPED") {
|
|
37
|
+
successCount++;
|
|
38
|
+
} else if (
|
|
39
|
+
conclusion === "FAILURE" ||
|
|
40
|
+
conclusion === "TIMED_OUT" ||
|
|
41
|
+
conclusion === "ACTION_REQUIRED" ||
|
|
42
|
+
status === "FAILURE" ||
|
|
43
|
+
status === "ERROR"
|
|
44
|
+
) {
|
|
45
|
+
failingCount++;
|
|
46
|
+
} else if (status === "IN_PROGRESS" || status === "QUEUED" || status === "PENDING" || !conclusion) {
|
|
47
|
+
pendingCount++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const failing = failingCount > 0;
|
|
52
|
+
const pending = pendingCount > 0;
|
|
53
|
+
const passing = !failing && !pending;
|
|
54
|
+
|
|
55
|
+
const summary = failing ? `FAILED (${failingCount})` : pending ? `PENDING (${pendingCount})` : `PASSING (${successCount})`;
|
|
56
|
+
return { passing, pending, failing, noChecks: false, summary };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reads the changed-file list off a `gh pr list --json files` entry.
|
|
61
|
+
*
|
|
62
|
+
* The distinction between "this PR changes nothing" and "gh did not tell us
|
|
63
|
+
* what it changes" is load-bearing: `classifyRiskTier([])` answers R0, which is
|
|
64
|
+
* auto-merge eligible, so a missing or truncated `files` array used to
|
|
65
|
+
* downgrade an unknown PR to the lowest risk tier. GitHub also caps the
|
|
66
|
+
* per-PR file list, so a large PR can arrive complete-looking but partial.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} pr
|
|
69
|
+
* @param {number} [fileCap=100] - Page size at which the list is assumed truncated.
|
|
70
|
+
* @returns {{ known: boolean, truncated: boolean, files: string[], diffLines: number }}
|
|
71
|
+
*/
|
|
72
|
+
export function readPrFiles(pr = {}, fileCap = 100) {
|
|
73
|
+
if (!Array.isArray(pr.files)) {
|
|
74
|
+
return { known: false, truncated: false, files: [], diffLines: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const files = pr.files.map((f) => normalizePath(typeof f === "string" ? f : f.path || ""))
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
|
|
80
|
+
// Only summed when the entries carry line counts; a PR whose files lack them
|
|
81
|
+
// reports 0, and the caller must not read that as "a small diff".
|
|
82
|
+
let diffLines = 0;
|
|
83
|
+
let hasLineCounts = false;
|
|
84
|
+
for (const f of pr.files) {
|
|
85
|
+
if (f && typeof f === "object" && (Number.isFinite(f.additions) || Number.isFinite(f.deletions))) {
|
|
86
|
+
hasLineCounts = true;
|
|
87
|
+
diffLines += (Number(f.additions) || 0) + (Number(f.deletions) || 0);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
known: files.length > 0,
|
|
93
|
+
truncated: pr.files.length >= fileCap,
|
|
94
|
+
files,
|
|
95
|
+
diffLines: hasLineCounts ? diffLines : 0,
|
|
96
|
+
hasLineCounts,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parses risk tier option string or array and maps shorthand names (R0, R1, R2, R3).
|
|
102
|
+
* @param {string|string[]} tierOption
|
|
103
|
+
* @returns {Set<string>}
|
|
104
|
+
*/
|
|
105
|
+
export function parseTierFilter(tierOption) {
|
|
106
|
+
const mapShorthand = (t) => {
|
|
107
|
+
const upper = String(t).toUpperCase().trim();
|
|
108
|
+
if (upper === "R0" || upper === "R0_COSMETIC") return RISK_TIERS.R0;
|
|
109
|
+
if (upper === "R1" || upper === "R1_ROUTINE") return RISK_TIERS.R1;
|
|
110
|
+
if (upper === "R2" || upper === "R2_CONSEQUENTIAL") return RISK_TIERS.R2;
|
|
111
|
+
if (upper === "R3" || upper === "R3_RESTRICTED") return RISK_TIERS.R3;
|
|
112
|
+
return upper;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (!tierOption) return new Set([RISK_TIERS.R0, RISK_TIERS.R1]);
|
|
116
|
+
if (Array.isArray(tierOption)) return new Set(tierOption.map(mapShorthand));
|
|
117
|
+
return new Set(
|
|
118
|
+
String(tierOption)
|
|
119
|
+
.split(",")
|
|
120
|
+
.map(mapShorthand)
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Automated PR Harvester: Discovers open PRs, evaluates CI status, risk tiers, and safety locks,
|
|
127
|
+
* and optionally merges/squashes green, low-risk changes autonomously.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} root - Repository root path
|
|
130
|
+
* @param {object} options
|
|
131
|
+
* @returns {Promise<{ prs: Array<object>, harvested: Array<object>, summary: object }>}
|
|
132
|
+
*/
|
|
133
|
+
export async function harvestPullRequests(root = process.cwd(), options = {}) {
|
|
134
|
+
const limit = options.limit || 50;
|
|
135
|
+
const state = options.state || "open";
|
|
136
|
+
const allowedTiers = parseTierFilter(options.tier);
|
|
137
|
+
const isAuto = Boolean(options.auto || options.merge);
|
|
138
|
+
const isDryRun = Boolean(options.dryRun);
|
|
139
|
+
const allowNoChecks = Boolean(options.allowNoChecks);
|
|
140
|
+
// Loaded once: the repository's own risk paths decide whether a PR is
|
|
141
|
+
// auto-merge eligible, and re-reading the config per PR would be pure I/O.
|
|
142
|
+
let riskConfig = options.config;
|
|
143
|
+
if (!riskConfig) {
|
|
144
|
+
try {
|
|
145
|
+
riskConfig = loadConfig(root);
|
|
146
|
+
} catch (_) {
|
|
147
|
+
riskConfig = {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let rawPrs = [];
|
|
152
|
+
if (typeof options.execGh === "function") {
|
|
153
|
+
rawPrs = await options.execGh("pr", ["list", "--state", state, "--limit", String(limit), "--json", "number,title,headRefName,statusCheckRollup,mergeable,labels,files"]);
|
|
154
|
+
} else {
|
|
155
|
+
const res = spawnSync(
|
|
156
|
+
"gh",
|
|
157
|
+
[
|
|
158
|
+
"pr",
|
|
159
|
+
"list",
|
|
160
|
+
"--state",
|
|
161
|
+
state,
|
|
162
|
+
"--limit",
|
|
163
|
+
String(limit),
|
|
164
|
+
"--json",
|
|
165
|
+
"number,title,headRefName,statusCheckRollup,mergeable,labels,files",
|
|
166
|
+
],
|
|
167
|
+
{
|
|
168
|
+
cwd: root,
|
|
169
|
+
encoding: "utf-8",
|
|
170
|
+
timeout: 30000,
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (res.error) {
|
|
175
|
+
if (res.error.code === "ENOENT") {
|
|
176
|
+
throw new Error("GitHub CLI (gh) not found in PATH. Install gh or use JULES_DRY_RUN=1.");
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Failed to list pull requests: ${res.error.message}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (res.status !== 0) {
|
|
182
|
+
throw new Error(`gh pr list failed (exit ${res.status}): ${(res.stderr || "").trim()}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
rawPrs = JSON.parse(res.stdout || "[]");
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw new Error(`Invalid JSON output from gh pr list: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const evaluatedPrs = [];
|
|
193
|
+
const harvested = [];
|
|
194
|
+
|
|
195
|
+
for (const pr of rawPrs) {
|
|
196
|
+
const fileInfo = readPrFiles(pr);
|
|
197
|
+
const files = fileInfo.files;
|
|
198
|
+
// diffLines was never passed before, which left the 400-line R2 rule in
|
|
199
|
+
// risk.mjs unreachable from the harvest path: a 5,000-line change to one
|
|
200
|
+
// unremarkable file classified R1 and auto-merged.
|
|
201
|
+
const risk = classifyRiskTier(files, { diffLines: fileInfo.diffLines, config: riskConfig });
|
|
202
|
+
const checks = evaluateStatusCheckRollup(pr.statusCheckRollup || []);
|
|
203
|
+
const gate = checkSafetyGate(pr.headRefName || "", root);
|
|
204
|
+
const tierMatches = allowedTiers.has(risk.tier);
|
|
205
|
+
// "UNKNOWN" means GitHub is still computing mergeability. Only "MERGEABLE"
|
|
206
|
+
// is an answer; anything else is either a conflict or an absence of one.
|
|
207
|
+
const isMergeable = String(pr.mergeable || "").toUpperCase() === "MERGEABLE";
|
|
208
|
+
const checksUsable = checks.passing || (checks.noChecks && allowNoChecks);
|
|
209
|
+
const filesUsable = fileInfo.known && !fileInfo.truncated;
|
|
210
|
+
|
|
211
|
+
const eligible = checksUsable && filesUsable && gate.safe && tierMatches && isMergeable;
|
|
212
|
+
let actionStatus = "SKIPPED";
|
|
213
|
+
let actionReason = "";
|
|
214
|
+
|
|
215
|
+
if (!filesUsable) {
|
|
216
|
+
actionReason = fileInfo.truncated
|
|
217
|
+
? `Changed-file list truncated at ${files.length} entries — risk tier cannot be trusted`
|
|
218
|
+
: "Changed-file list unavailable from gh — risk tier cannot be determined";
|
|
219
|
+
} else if (!tierMatches) {
|
|
220
|
+
actionReason = `Risk Tier ${risk.tier} excluded by filter (${Array.from(allowedTiers).join(",")})`;
|
|
221
|
+
} else if (!checksUsable) {
|
|
222
|
+
actionReason = checks.noChecks
|
|
223
|
+
? "No CI checks reported — pass --allow-no-checks to harvest unverified PRs"
|
|
224
|
+
: `CI Checks not clean: ${checks.summary}`;
|
|
225
|
+
} else if (!gate.safe) {
|
|
226
|
+
actionReason = `Safety gate locked: ${gate.reason}`;
|
|
227
|
+
} else if (!isMergeable) {
|
|
228
|
+
actionReason = pr.mergeable ? `Not mergeable (${pr.mergeable})` : "Mergeability unknown";
|
|
229
|
+
} else {
|
|
230
|
+
actionStatus = "ELIGIBLE";
|
|
231
|
+
actionReason = checks.noChecks ? "Ready for harvest (UNVERIFIED — no CI checks)" : "Ready for harvest";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let merged = false;
|
|
235
|
+
let mergeError = null;
|
|
236
|
+
|
|
237
|
+
if (eligible && isAuto && !isDryRun) {
|
|
238
|
+
if (typeof options.mergeGh === "function") {
|
|
239
|
+
const mergeRes = await options.mergeGh(pr.number);
|
|
240
|
+
merged = mergeRes.ok;
|
|
241
|
+
if (!merged) mergeError = mergeRes.error;
|
|
242
|
+
} else {
|
|
243
|
+
const mergeCmd = spawnSync("gh", ["pr", "merge", String(pr.number), "--squash", "--auto"], {
|
|
244
|
+
cwd: root,
|
|
245
|
+
encoding: "utf-8",
|
|
246
|
+
timeout: 30000,
|
|
247
|
+
});
|
|
248
|
+
if (mergeCmd.status === 0) {
|
|
249
|
+
merged = true;
|
|
250
|
+
actionStatus = "MERGED";
|
|
251
|
+
actionReason = "Auto-squashed and merged successfully";
|
|
252
|
+
} else {
|
|
253
|
+
mergeError = (mergeCmd.stderr || mergeCmd.stdout || "Merge failed").trim();
|
|
254
|
+
actionStatus = "FAILED";
|
|
255
|
+
actionReason = `Merge error: ${mergeError}`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (merged) {
|
|
260
|
+
harvested.push({
|
|
261
|
+
number: pr.number,
|
|
262
|
+
title: pr.title,
|
|
263
|
+
headRefName: pr.headRefName,
|
|
264
|
+
tier: risk.tier,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
evaluatedPrs.push({
|
|
270
|
+
number: pr.number,
|
|
271
|
+
title: pr.title,
|
|
272
|
+
branch: pr.headRefName,
|
|
273
|
+
filesCount: files.length,
|
|
274
|
+
riskTier: risk.tier,
|
|
275
|
+
riskReason: risk.reason,
|
|
276
|
+
diffLines: fileInfo.diffLines,
|
|
277
|
+
ciStatus: checks.summary,
|
|
278
|
+
ciPassing: checks.passing,
|
|
279
|
+
// True when this PR was allowed through without any CI evidence. Kept on
|
|
280
|
+
// the record so a report can distinguish "verified green" from "nobody
|
|
281
|
+
// checked", which the ciStatus string alone no longer conveys.
|
|
282
|
+
unverified: checks.noChecks && allowNoChecks,
|
|
283
|
+
safetySafe: gate.safe,
|
|
284
|
+
eligible,
|
|
285
|
+
status: actionStatus,
|
|
286
|
+
reason: actionReason,
|
|
287
|
+
merged,
|
|
288
|
+
mergeError,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const summary = {
|
|
293
|
+
total: evaluatedPrs.length,
|
|
294
|
+
eligible: evaluatedPrs.filter((p) => p.eligible).length,
|
|
295
|
+
merged: harvested.length,
|
|
296
|
+
skipped: evaluatedPrs.filter((p) => !p.eligible).length,
|
|
297
|
+
unverified: evaluatedPrs.filter((p) => p.unverified && p.eligible).length,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
prs: evaluatedPrs,
|
|
302
|
+
harvested,
|
|
303
|
+
summary,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Formats PR harvest results into a human-readable CLI terminal table.
|
|
309
|
+
* @param {object} harvestResult
|
|
310
|
+
* @returns {string}
|
|
311
|
+
*/
|
|
312
|
+
export function formatHarvestTable(harvestResult) {
|
|
313
|
+
const { prs, summary } = harvestResult;
|
|
314
|
+
if (!prs || prs.length === 0) {
|
|
315
|
+
return "No open pull requests found for harvest.";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const lines = [];
|
|
319
|
+
lines.push("🌾 Pull Request Harvest & Triage Report");
|
|
320
|
+
lines.push("═".repeat(80));
|
|
321
|
+
lines.push(
|
|
322
|
+
`${"PR #".padEnd(8)} | ${"Tier".padEnd(6)} | ${"CI Checks".padEnd(16)} | ${"Status".padEnd(10)} | ${"Title"}`
|
|
323
|
+
);
|
|
324
|
+
lines.push("-".repeat(80));
|
|
325
|
+
|
|
326
|
+
for (const pr of prs) {
|
|
327
|
+
const prNum = `#${pr.number}`.padEnd(8);
|
|
328
|
+
const tier = pr.riskTier.padEnd(6);
|
|
329
|
+
const ci = pr.ciStatus.slice(0, 16).padEnd(16);
|
|
330
|
+
const status = pr.status.padEnd(10);
|
|
331
|
+
const title = pr.title.length > 32 ? `${pr.title.slice(0, 29)}...` : pr.title;
|
|
332
|
+
lines.push(`${prNum} | ${tier} | ${ci} | ${status} | ${title}`);
|
|
333
|
+
if (pr.reason && pr.status !== "MERGED" && pr.status !== "ELIGIBLE") {
|
|
334
|
+
lines.push(` └─ Reason: ${pr.reason}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
lines.push("═".repeat(80));
|
|
339
|
+
lines.push(
|
|
340
|
+
`Summary: ${summary.total} total PRs · ${summary.eligible} eligible · ${summary.merged} merged · ${summary.skipped} skipped`
|
|
341
|
+
);
|
|
342
|
+
if (summary.unverified > 0) {
|
|
343
|
+
lines.push(
|
|
344
|
+
`⚠️ ${summary.unverified} of the eligible PRs carry no CI checks and were admitted by --allow-no-checks.`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return lines.join("\n");
|
|
349
|
+
}
|