@elalfner/relevo 0.7.0 → 0.9.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-plugin/plugin.json +1 -1
- package/README.es.md +1 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/scripts/doctor.mjs +5 -4
- package/scripts/init.mjs +25 -4
- package/scripts/lib/can-edit-issue.mjs +4 -1
- package/scripts/lib/cli.mjs +6 -4
- package/scripts/lib/config.mjs +19 -1
- package/scripts/lib/doctor.mjs +264 -25
- package/scripts/lib/github.mjs +15 -5
- package/scripts/lib/init.mjs +89 -6
- package/scripts/lib/labels.mjs +4 -0
- package/scripts/lib/messages.mjs +109 -0
- package/scripts/lib/next-step.mjs +12 -0
- package/scripts/lib/plan.mjs +29 -6
- package/scripts/lib/preflight.mjs +1 -1
- package/scripts/lib/review-events.mjs +6 -3
- package/scripts/lib/ship-github.mjs +87 -0
- package/scripts/lib/ship-unattended.mjs +271 -0
- package/scripts/lib/ship.mjs +15 -3
- package/scripts/lib/toolchain-check.mjs +6 -0
- package/scripts/lib/web-preflight.mjs +50 -4
- package/scripts/lib/workflow-upgrade.mjs +198 -0
- package/scripts/review-event.mjs +10 -1
- package/scripts/ship-check.mjs +6 -4
- package/scripts/ship-unattended.mjs +45 -0
- package/scripts/toolchain-check.mjs +3 -3
- package/scripts/web-hook.mjs +28 -0
- package/scripts/web-preflight.mjs +6 -5
- package/scripts/web-review.mjs +1 -1
- package/skills/doctor/SKILL.md +6 -5
- package/skills/go/SKILL.md +4 -2
- package/skills/init/SKILL.md +10 -4
- package/skills/review/SKILL.md +4 -3
- package/skills/ship/SKILL.md +3 -1
- package/stacks/vue.md +2 -2
- package/templates/agents/antigravity-step.yml +8 -5
- package/templates/relevo.yml +4 -0
- package/templates/workflows/relevo-digest.yml +11 -1
- package/templates/workflows/relevo-go.yml +33 -14
- package/templates/workflows/relevo-queue.yml +11 -1
- package/templates/workflows/relevo-review.yml +11 -1
- package/templates/workflows/relevo-ship.yml +136 -0
- package/templates/workflows/relevo-sla.yml +11 -1
package/scripts/lib/doctor.mjs
CHANGED
|
@@ -25,7 +25,10 @@ export const SKILL_INSTALL = {
|
|
|
25
25
|
'supabase-postgres-best-practices': 'run `npx skills add supabase/agent-skills --skill supabase-postgres-best-practices`',
|
|
26
26
|
};
|
|
27
27
|
export const OPENSPEC_INSTALL = 'npm install --global @fission-ai/openspec (needs Node.js 20.19 or newer)';
|
|
28
|
-
export const WORKFLOW_FILES = ['relevo-go.yml', 'relevo-queue.yml', 'relevo-review.yml', 'relevo-sla.yml', 'relevo-digest.yml'];
|
|
28
|
+
export const WORKFLOW_FILES = ['relevo-go.yml', 'relevo-queue.yml', 'relevo-review.yml', 'relevo-sla.yml', 'relevo-digest.yml', 'relevo-ship.yml'];
|
|
29
|
+
// The repository variable that pauses the ship workflow, and the secrets of the GitHub App it pushes with.
|
|
30
|
+
export const SHIP_VARIABLE = 'RELEVO_CLOUD_SHIP';
|
|
31
|
+
export const SHIP_APP_SECRETS = ['RELEVO_SHIP_APP_ID', 'RELEVO_SHIP_APP_KEY'];
|
|
29
32
|
const REQUIRED_WORKFLOWS = new Set(['relevo-go.yml']);
|
|
30
33
|
// How to ask a tool that it is there (most answer --version), and how to install the ones Relevo knows.
|
|
31
34
|
const TOOL_PROBE = { adb: ['version'] };
|
|
@@ -45,6 +48,8 @@ const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
|
45
48
|
const pass = (id, message) => ({ id, status: 'pass', message });
|
|
46
49
|
const fail = (id, message, fix) => ({ id, status: 'fail', message, fix });
|
|
47
50
|
const warn = (id, message, fix) => ({ id, status: 'warn', message, fix });
|
|
51
|
+
// Information that asks for nothing: neither a failure nor a warning, and it never changes the exit status.
|
|
52
|
+
const note = (id, message) => ({ id, status: 'note', message });
|
|
48
53
|
|
|
49
54
|
/** True when `version` (like "22.1.0") is at least `min` (like "20.19"). */
|
|
50
55
|
export function versionAtLeast(version, min) {
|
|
@@ -70,10 +75,10 @@ export function refMatches(pattern, branch, defaultBranch) {
|
|
|
70
75
|
|
|
71
76
|
/**
|
|
72
77
|
* What the repository enforces on a branch, from its rulesets and its classic protection.
|
|
73
|
-
* Returns { protected, pullRequestRequired, approvals, dismissStale, blocksDeletion, blocksForcePush }.
|
|
78
|
+
* Returns { protected, pullRequestRequired, approvals, dismissStale, lastPushApproval, requiredChecks, blocksDeletion, blocksForcePush }.
|
|
74
79
|
*/
|
|
75
80
|
export function summarizeProtection({ rulesets = [], classic = null, branch, defaultBranch }) {
|
|
76
|
-
const out = { protected: false, pullRequestRequired: false, approvals: 0, dismissStale: false, blocksDeletion: false, blocksForcePush: false };
|
|
81
|
+
const out = { protected: false, pullRequestRequired: false, approvals: 0, dismissStale: false, lastPushApproval: false, requiredChecks: false, blocksDeletion: false, blocksForcePush: false };
|
|
77
82
|
rulesets.filter((r) => r.target === 'branch' && r.enforcement === 'active').forEach((r) => {
|
|
78
83
|
const include = r.conditions?.ref_name?.include ?? [];
|
|
79
84
|
const exclude = r.conditions?.ref_name?.exclude ?? [];
|
|
@@ -84,7 +89,9 @@ export function summarizeProtection({ rulesets = [], classic = null, branch, def
|
|
|
84
89
|
out.pullRequestRequired = true;
|
|
85
90
|
out.approvals = Math.max(out.approvals, rule.parameters?.required_approving_review_count ?? 0);
|
|
86
91
|
if (rule.parameters?.dismiss_stale_reviews_on_push) out.dismissStale = true;
|
|
92
|
+
if (rule.parameters?.require_last_push_approval) out.lastPushApproval = true;
|
|
87
93
|
}
|
|
94
|
+
if (rule.type === 'required_status_checks') out.requiredChecks = true;
|
|
88
95
|
if (rule.type === 'deletion') out.blocksDeletion = true;
|
|
89
96
|
if (rule.type === 'non_fast_forward') out.blocksForcePush = true;
|
|
90
97
|
});
|
|
@@ -96,13 +103,45 @@ export function summarizeProtection({ rulesets = [], classic = null, branch, def
|
|
|
96
103
|
out.pullRequestRequired = true;
|
|
97
104
|
out.approvals = Math.max(out.approvals, pr.required_approving_review_count ?? 0);
|
|
98
105
|
if (pr.dismiss_stale_reviews) out.dismissStale = true;
|
|
106
|
+
if (pr.require_last_push_approval) out.lastPushApproval = true;
|
|
99
107
|
}
|
|
108
|
+
if (classic.required_status_checks) out.requiredChecks = true;
|
|
100
109
|
if (classic.allow_deletions?.enabled === false) out.blocksDeletion = true;
|
|
101
110
|
if (classic.allow_force_pushes?.enabled === false) out.blocksForcePush = true;
|
|
102
111
|
}
|
|
103
112
|
return out;
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
// ---------- Playwright of a platform
|
|
116
|
+
|
|
117
|
+
/** Is the platform a Vue platform reviewed in a browser? `web` is the only target of Vue, and its default. */
|
|
118
|
+
export const isVueWeb = (platform) => platform?.stack === 'vue' && (!platform.targets?.length || platform.targets.includes('web'));
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Does Playwright resolve from the folder of a platform, and is its Chromium installed? Resolving from the folder, not
|
|
122
|
+
* from where the command runs, finds the node_modules of a platform in a subfolder. `tool(name, args)` throws when the
|
|
123
|
+
* command fails; `exists(path)`.
|
|
124
|
+
*/
|
|
125
|
+
export function playwrightFacts(dir, { tool, exists }) {
|
|
126
|
+
const where = "require.resolve('playwright', { paths: [require('node:path').resolve(process.argv[1])] })";
|
|
127
|
+
try {
|
|
128
|
+
tool('node', ['-e', where, dir || '.']);
|
|
129
|
+
} catch {
|
|
130
|
+
return { playwright: false, chromium: false };
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const path = String(tool('node', ['-e', `process.stdout.write(require(${where}).chromium.executablePath())`, dir || '.'])).trim();
|
|
134
|
+
return { playwright: true, chromium: path !== '' && Boolean(exists(path)) };
|
|
135
|
+
} catch {
|
|
136
|
+
return { playwright: true, chromium: false };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** playwrightFacts of each Vue platform with a web target, by platform name. */
|
|
141
|
+
export function vueWebFacts(platforms, deps) {
|
|
142
|
+
return Object.fromEntries(Object.entries(platforms ?? {}).filter(([, p]) => isVueWeb(p)).map(([name, p]) => [name, playwrightFacts(p.path, deps)]));
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
// ---------- gather
|
|
107
146
|
|
|
108
147
|
/**
|
|
@@ -146,6 +185,12 @@ export function gatherFacts(p) {
|
|
|
146
185
|
const installedRoot = p.pluginRoot ?? PLUGIN_ROOT;
|
|
147
186
|
f.pluginFolders = ['scripts', 'skills', 'agents', 'stacks', 'templates'].filter((d) => tryRun(() => readdirSync(resolve(installedRoot, d)).length > 0, false) !== true);
|
|
148
187
|
f.pluginVersion = tryRun(() => JSON.parse(readFileSync(resolve(installedRoot, '.claude-plugin', 'plugin.json'), 'utf8')).version ?? null, null);
|
|
188
|
+
// The installations Claude Code knows of: the user scope, and the project or local scope of this repository. null when
|
|
189
|
+
// `claude` cannot list them. Only the command is read, never Claude Code's own files.
|
|
190
|
+
const repoTop = tryRun(() => String(p.tool('git', ['rev-parse', '--show-toplevel'])).trim() || null, null);
|
|
191
|
+
f.installedPlugins = tryRun(() => JSON.parse(String(p.tool('claude', ['plugin', 'list', '--json'])))
|
|
192
|
+
.filter((e) => String(e.id).startsWith('relevo@') && (e.scope === 'user' || e.projectPath === repoTop))
|
|
193
|
+
.map((e) => ({ scope: e.scope, version: e.version ?? null })), null);
|
|
149
194
|
|
|
150
195
|
f.configText = p.exists('relevo.yml') ? p.read('relevo.yml') : null;
|
|
151
196
|
const loaded = f.configText === null ? null : loadConfig(f.configText, profileLookups({ pluginRoot: p.pluginRoot ?? PLUGIN_ROOT, read: readerFor(p, tryRun) }));
|
|
@@ -160,6 +205,8 @@ export function gatherFacts(p) {
|
|
|
160
205
|
f.openspecRootTracked = tryRun(() => String(p.tool('git', ['ls-files', 'openspec'])).trim().length > 0, false);
|
|
161
206
|
f.evidenceIgnored = p.exists('.gitignore') && /^\/?evidence\/?\s*$/m.test(p.read('.gitignore'));
|
|
162
207
|
f.pluginDeclared = pluginDeclaration(p);
|
|
208
|
+
f.webHook = webHookDeclaration(p);
|
|
209
|
+
f.remoteSession = process.env.CLAUDE_CODE_REMOTE === 'true';
|
|
163
210
|
|
|
164
211
|
// Workflows: what runs on GitHub is what is on the default branch, not what is in the working copy, so once the
|
|
165
212
|
// repository is known they are read from there. `present` says whether the file is where it has to be; `local`
|
|
@@ -210,6 +257,7 @@ export function gatherFacts(p) {
|
|
|
210
257
|
f.canApprove = tryRun(() => api(`repos/${f.repo}/actions/permissions/workflow`).can_approve_pull_request_reviews, null);
|
|
211
258
|
f.secrets = tryRun(() => api(`repos/${f.repo}/actions/secrets`).secrets.map((s) => s.name));
|
|
212
259
|
f.cloudVariable = tryRun(() => api(`repos/${f.repo}/actions/variables/${CLOUD_VARIABLE}`).value, null);
|
|
260
|
+
f.shipVariable = tryRun(() => api(`repos/${f.repo}/actions/variables/${SHIP_VARIABLE}`).value, null);
|
|
213
261
|
// The repositories this one may depend on: can they be read, and are they private (then a token is needed)?
|
|
214
262
|
f.links = (f.config?.links ?? []).map((link) => {
|
|
215
263
|
const info = tryRun(() => api(`repos/${link}`), null);
|
|
@@ -266,6 +314,11 @@ export function gatherFacts(p) {
|
|
|
266
314
|
WORKFLOW_FILES.forEach((file) => {
|
|
267
315
|
if (typeof texts[file] === 'string') f.pluginPins[file] = (/^\s*RELEVO_VERSION:\s*['"]?([\w.+-]+)['"]?\s*$/m.exec(texts[file]) ?? [])[1] ?? null;
|
|
268
316
|
});
|
|
317
|
+
// The version of the text each workflow was copied from (null when it has no stamp).
|
|
318
|
+
f.pluginStamps = {};
|
|
319
|
+
WORKFLOW_FILES.forEach((file) => {
|
|
320
|
+
if (typeof texts[file] === 'string') f.pluginStamps[file] = (/^# relevo-template: (\d+\.\d+\.\d+)\s*$/m.exec(texts[file]) ?? [])[1] ?? null;
|
|
321
|
+
});
|
|
269
322
|
f.hasCi = names.some((n) => /\.ya?ml$/.test(n) && !n.startsWith('relevo-'));
|
|
270
323
|
}
|
|
271
324
|
}
|
|
@@ -286,6 +339,28 @@ export function pluginDeclaration(p) {
|
|
|
286
339
|
}
|
|
287
340
|
}
|
|
288
341
|
|
|
342
|
+
// The one-line `SessionStart` hook that installs OpenSpec in a session of Claude Code on the web. It does nothing
|
|
343
|
+
// outside a remote session, and nothing when `openspec` already runs; `|| true` keeps a failed install from failing the hook.
|
|
344
|
+
export const WEB_HOOK_COMMAND = '[ "$CLAUDE_CODE_REMOTE" = "true" ] && ! command -v openspec >/dev/null 2>&1 && OPENSPEC_TELEMETRY=0 npm install --global @fission-ai/openspec || true';
|
|
345
|
+
|
|
346
|
+
/** Does a `SessionStart` entry install OpenSpec? The package name is what is matched, so a hook written by hand counts. */
|
|
347
|
+
const installsOpenspec = (entry) => (entry?.hooks ?? []).some((h) => typeof h?.command === 'string' && h.command.includes('@fission-ai/openspec'));
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Whether the committed settings file has a `SessionStart` hook that installs OpenSpec: { declared } or, when the file
|
|
351
|
+
* is not valid JSON, { unreadable: true }. A missing file is { declared: false }.
|
|
352
|
+
*/
|
|
353
|
+
export function webHookDeclaration(p) {
|
|
354
|
+
const path = '.claude/settings.json';
|
|
355
|
+
if (!p.exists(path)) return { declared: false };
|
|
356
|
+
try {
|
|
357
|
+
const settings = JSON.parse(p.read(path));
|
|
358
|
+
return { declared: Array.isArray(settings?.hooks?.SessionStart) && settings.hooks.SessionStart.some(installsOpenspec) };
|
|
359
|
+
} catch {
|
|
360
|
+
return { unreadable: true };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
289
364
|
/** Reads a file: the plugin's own files from disk, the repository's through the probes. Null when absent. */
|
|
290
365
|
function readerFor(p, tryRun) {
|
|
291
366
|
const pluginRoot = p.pluginRoot ?? PLUGIN_ROOT;
|
|
@@ -363,7 +438,7 @@ function gatherPlatforms(f, p, tryRun) {
|
|
|
363
438
|
const pubspec = p.exists(join(platform.path, 'pubspec.yaml')) ? tryRun(() => p.read(join(platform.path, 'pubspec.yaml')), '') : '';
|
|
364
439
|
entry.driverDependency = /^\s*flutter_driver\s*:/m.test(pubspec ?? '');
|
|
365
440
|
} else if (platform.stack === 'vue') {
|
|
366
|
-
entry.playwright =
|
|
441
|
+
entry.playwright = playwrightFacts(platform.path, { tool: p.tool, exists: p.exists }).playwright;
|
|
367
442
|
}
|
|
368
443
|
return entry;
|
|
369
444
|
}),
|
|
@@ -416,11 +491,11 @@ function executionChecks(f) {
|
|
|
416
491
|
|
|
417
492
|
if (mode === 'both') r.push(warn('execution.credential', `both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN exist${used ? `; the workflow uses the ${used === 'api_key' ? 'API key' : 'subscription token'}` : ''}`, 'keep only the secret the workflow uses, so that it is clear which account pays'));
|
|
418
493
|
else if (mode === 'api_key') r.push(pass('execution.credential', 'the cloud builds with an API key: run_max_cost is a real ceiling on spending'));
|
|
419
|
-
else if (mode === 'subscription') r.push(
|
|
494
|
+
else if (mode === 'subscription') r.push(note('execution.credential', 'the cloud builds with a subscription token: run_max_cost is only a notional ceiling, and the quota is shared with the use of the account that owns the token'));
|
|
420
495
|
else r.push(pass('execution.credential', installed ? 'no model credential: the cloud cannot build (see the warning about the secret)' : 'no model credential and no go workflow: go builds on machines only'));
|
|
421
496
|
|
|
422
497
|
if (mode === 'subscription' || (mode === 'both' && used === 'subscription')) {
|
|
423
|
-
r.push(
|
|
498
|
+
r.push(note('execution.policy', 'reminder: read the current Anthropic terms on automated use of a subscription token (and the README of claude-code-action) before relying on it for a team'));
|
|
424
499
|
if ((f.config?.limits?.concurrent_agents ?? 1) > 1) {
|
|
425
500
|
r.push(warn('execution.concurrency', `limits.concurrent_agents is ${f.config.limits.concurrent_agents} with a subscription, whose quota is shared by every run and by the interactive use of the account`, 'set limits.concurrent_agents to 1'));
|
|
426
501
|
}
|
|
@@ -502,7 +577,7 @@ function platformChecks(f) {
|
|
|
502
577
|
e.targetTools.forEach(({ target, tool, ok }) => {
|
|
503
578
|
const id = `tools.target.${e.name}.${target}`;
|
|
504
579
|
if (tool === '') r.push(pass(id, `target ${target} of ${e.name} needs no extra tool`));
|
|
505
|
-
else if (tool === 'xcrun' && !f.macos) r.push(
|
|
580
|
+
else if (tool === 'xcrun' && !f.macos) r.push(note(id, `target ${target} of ${e.name}: iOS can only be built and reviewed on macOS`));
|
|
506
581
|
else if (ok) r.push(pass(id, `target ${target} of ${e.name}: ${tool} is installed`));
|
|
507
582
|
else r.push(warn(id, `target ${target} of ${e.name}: ${tool} is not installed; \`review\` needs it for this target`, TOOL_INSTALL[tool] ?? `install ${tool}`));
|
|
508
583
|
});
|
|
@@ -532,7 +607,7 @@ function platformChecks(f) {
|
|
|
532
607
|
if (!e.driverDependency) r.push(warn(`autopilot.${e.name}.dependency`, `platform ${e.name} does not depend on flutter_driver (dev_dependencies), which the driven review needs`, 'add flutter_driver: { sdk: flutter } under dev_dependencies in pubspec.yaml'));
|
|
533
608
|
else r.push(pass(`autopilot.${e.name}.dependency`, `platform ${e.name} depends on flutter_driver`));
|
|
534
609
|
} else if (e.stack === 'vue') {
|
|
535
|
-
if (!e.playwright) r.push(warn(`autopilot.${e.name}.playwright`, `review.autopilot is ${f.autopilot.mode} but Playwright cannot be resolved from this project (platform ${e.name})`, 'npm install -D playwright && npx playwright install chromium
|
|
610
|
+
if (!e.playwright) r.push(warn(`autopilot.${e.name}.playwright`, `review.autopilot is ${f.autopilot.mode} but Playwright cannot be resolved from this project (platform ${e.name})`, `run /relevo:init, which offers it, or \`${e.path && e.path !== '.' ? `cd ${e.path} && ` : ''}npm install -D playwright && npx playwright install chromium\` and commit package.json and the lock file`));
|
|
536
611
|
else r.push(pass(`autopilot.${e.name}.playwright`, `Playwright is available for platform ${e.name}`));
|
|
537
612
|
} else {
|
|
538
613
|
r.push(warn(`autopilot.${e.name}.adapter`, `review.autopilot is ${f.autopilot.mode}, but a driven review of the stack ${e.stack} (platform ${e.name}) is not available`, 'set review.autopilot to off, or review that platform by hand'));
|
|
@@ -560,7 +635,7 @@ export function runChecks(f) {
|
|
|
560
635
|
// skills that think invokes
|
|
561
636
|
REQUIRED_SKILLS.forEach((name) => {
|
|
562
637
|
const manual = (f.manualOnlySkills ?? []).includes(name);
|
|
563
|
-
r.push(f.skills[name] ?
|
|
638
|
+
r.push(f.skills[name] ? (manual ? note(`skills.${name}`, `skill ${name} is installed; it is reserved for you, so run /${name} by hand when \`think\` asks`) : pass(`skills.${name}`, `skill ${name} is installed`)) : fail(`skills.${name}`, `skill ${name} is not installed; \`think\` needs it`, SKILL_INSTALL[name]));
|
|
564
639
|
});
|
|
565
640
|
|
|
566
641
|
// configuration
|
|
@@ -576,6 +651,7 @@ export function runChecks(f) {
|
|
|
576
651
|
else r.push(fail('openspec.root', 'the OpenSpec root is missing or not tracked by git, so a clean checkout (and the runner) has no `openspec/`', 'run /relevo:init, then commit openspec/config.yaml and the .gitkeep files'));
|
|
577
652
|
r.push(f.evidenceIgnored ? pass('evidence.ignored', 'evidence/ is ignored by git') : fail('evidence.ignored', 'evidence/ is not in .gitignore; `review` would put screenshots in git', 'add the line `evidence/` to .gitignore (or run /relevo:init)'));
|
|
578
653
|
r.push(...pluginDeclaredChecks(f));
|
|
654
|
+
r.push(...webHookChecks(f));
|
|
579
655
|
|
|
580
656
|
// GitHub side
|
|
581
657
|
if (!f.repo || !f.ghAuth) {
|
|
@@ -602,6 +678,7 @@ export function runChecks(f) {
|
|
|
602
678
|
else if (f.config?.review?.mode !== 'none' && p.approvals < 1) r.push(fail(`repo.protection.${key}`, `${name} requires a pull request but no approval`, `require at least 1 approving review on ${name}`));
|
|
603
679
|
else r.push(pass(`repo.protection.${key}`, `${name} requires a pull request${p.approvals ? ` with ${p.approvals} approval(s)` : ''}`));
|
|
604
680
|
if (p?.dismissStale) r.push(fail(`repo.dismiss-stale.${key}`, `${name} dismisses approvals when new commits are pushed; the spec archive commit pushed by \`ship\` would dismiss the approval`, `turn off "dismiss stale pull request approvals when new commits are pushed" on ${name}`));
|
|
681
|
+
if (p?.lastPushApproval) r.push(fail(`repo.last-push.${key}`, `${name} requires the most recent push to be approved by someone else; the spec archive commit pushed by \`ship\` would need a new approval`, `turn off "require approval of the most recent reviewable push" on ${name}`));
|
|
605
682
|
});
|
|
606
683
|
}
|
|
607
684
|
if (!f.protectionUnavailable && !f.protectionError) {
|
|
@@ -624,31 +701,70 @@ export function runChecks(f) {
|
|
|
624
701
|
} else if (f.secrets.some((n) => ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].includes(n))) r.push(pass('automation.secret', 'a model credential secret exists'));
|
|
625
702
|
else r.push(warn('automation.secret', 'no ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN secret, so the cloud cannot build: a ready label only leaves a comment that says to run /relevo:go on a machine', 'for the cloud, add one to the repository secrets (init never touches it; for a subscription token remove all whitespace before storing it); for local builds nothing is needed'));
|
|
626
703
|
|
|
704
|
+
// One result per cause: the workflows in the same state, with the same status and the same fix, are one result that names them.
|
|
705
|
+
const groups = new Map();
|
|
706
|
+
const put = (kind, status, file, what, fix) => {
|
|
707
|
+
const key = `${kind}|${status}`;
|
|
708
|
+
const g = groups.get(key) ?? { kind, status, files: [], what, fix };
|
|
709
|
+
g.files.push(file);
|
|
710
|
+
groups.set(key, g);
|
|
711
|
+
};
|
|
627
712
|
Object.entries(f.workflows).forEach(([file, w]) => {
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
if (!w.present
|
|
631
|
-
else if (
|
|
632
|
-
else if (w.enabled ===
|
|
633
|
-
else
|
|
634
|
-
|
|
713
|
+
const level = REQUIRED_WORKFLOWS.has(file) ? 'fail' : 'warn';
|
|
714
|
+
if (!w.present && w.local && w.remote === false) put('local-only', level, file, 'only in your working copy, not on the default branch, and a workflow only runs from there', 'commit it and push it to the default branch (through a pull request if the branch is protected)');
|
|
715
|
+
else if (!w.present) put('missing', level, file, 'not installed', 'run /relevo:init to copy it into .github/workflows/');
|
|
716
|
+
else if (w.enabled === false) put('disabled', level, file, 'disabled', 'enable it in the Actions tab');
|
|
717
|
+
else if (w.enabled === null) put('unlisted', 'warn', file, 'on the default branch but GitHub does not list it yet', 'check the Actions tab, or run doctor again in a minute');
|
|
718
|
+
else put('installed', 'pass', file, 'installed and enabled');
|
|
719
|
+
});
|
|
720
|
+
groups.forEach(({ kind, status, files, what, fix }) => {
|
|
721
|
+
// The same state can be a failure for a required workflow and a warning for an optional one: two results, two ids.
|
|
722
|
+
const id = files.length === 1 ? `automation.workflow.${files[0]}` : `automation.workflow.${kind}${status === 'warn' && kind !== 'unlisted' ? '-optional' : ''}`;
|
|
723
|
+
const message = files.length === 1 ? `${files[0]} is ${what}` : `${files.join(', ')} are ${what}`;
|
|
724
|
+
r.push({ id, status, message, ...(fix ? { fix } : {}) });
|
|
635
725
|
});
|
|
636
726
|
r.push(f.hasCi ? pass('automation.ci', 'an objective CI check exists') : fail('automation.ci', 'no CI workflow other than Relevo\'s was found; QA needs an objective check', 'add a workflow that runs the project\'s analysis and tests on pull requests'));
|
|
637
727
|
|
|
638
728
|
r.push(...pluginInstallChecks(f));
|
|
639
729
|
r.push(...toolchainChecks(f));
|
|
730
|
+
r.push(...runnerChecks(f));
|
|
640
731
|
if (f.pluginMarketplace && f.pluginRepoPrivate === true && !f.pluginMarketplaceHasToken) {
|
|
641
732
|
r.push(fail('automation.plugin-clone', `the plugin repository (${f.pluginMarketplace}) is private, so the runner cannot clone it without a credential`, 'store a read-only token as RELEVO_PLUGIN_TOKEN and use the private-repository line in relevo-go.yml'));
|
|
642
733
|
}
|
|
643
734
|
r.push(...linkChecks(f));
|
|
644
735
|
r.push(...executionChecks(f));
|
|
645
|
-
|
|
736
|
+
r.push(...shipChecks(f));
|
|
737
|
+
// The token is for reading the plugin repository, which only a workflow that still loads the plugin through a marketplace
|
|
738
|
+
// (or clones it, the older layout) does; one that loads it from the pinned package needs none.
|
|
739
|
+
const readsPluginRepository = Boolean(f.pluginMarketplace) || (f.pluginCheckouts ?? []).length > 0;
|
|
740
|
+
if (readsPluginRepository && f.pluginRepoPrivate === true && Array.isArray(f.secrets)) {
|
|
646
741
|
if (f.secrets.includes('RELEVO_PLUGIN_TOKEN')) r.push(pass('automation.plugin-secret', 'the RELEVO_PLUGIN_TOKEN secret exists for the private plugin repository'));
|
|
647
742
|
else r.push(fail('automation.plugin-secret', 'the plugin repository is private and the repository has no RELEVO_PLUGIN_TOKEN secret, so the workflows fall back to the default token and fail at checkout', 'store a read-only fine-grained token that can read the plugin repository as the repository secret RELEVO_PLUGIN_TOKEN; a secret defined at organization level is not visible to this check'));
|
|
648
743
|
}
|
|
649
744
|
return r;
|
|
650
745
|
}
|
|
651
746
|
|
|
747
|
+
/**
|
|
748
|
+
* What the ship workflow needs, when it is on the default branch: the App secrets when the base branch requires checks
|
|
749
|
+
* (a push with the workflow token starts none, so the archive commit would never get them), and whether it is paused.
|
|
750
|
+
* Its label is checked with the other labels, and it needs no model credential.
|
|
751
|
+
*/
|
|
752
|
+
function shipChecks(f) {
|
|
753
|
+
const r = [];
|
|
754
|
+
if (!f.workflows?.['relevo-ship.yml']?.present) return r;
|
|
755
|
+
const base = f.config?.branches?.base ?? 'base';
|
|
756
|
+
if (f.protectionError || f.protectionUnavailable) return r;
|
|
757
|
+
if (f.protection?.base?.requiredChecks) {
|
|
758
|
+
if (f.secrets === null) r.push(warn('ship.app', `${base} requires status checks and the secrets could not be listed, so the ship App was not checked`, `check that ${SHIP_APP_SECRETS.join(' and ')} exist`));
|
|
759
|
+
else if (SHIP_APP_SECRETS.every((s) => f.secrets.includes(s))) r.push(pass('ship.app', `${base} requires status checks and the ship App secrets exist, so the relevo:ship label pushes with the App and the checks run on the archive commit`));
|
|
760
|
+
else r.push(fail('ship.app', `${base} requires status checks, so the relevo:ship label cannot ship here: the archive commit pushed with the workflow token would never get its checks`, `create a GitHub App for this repository and store its id and private key as ${SHIP_APP_SECRETS.join(' and ')} (docs/execution.md, "Shipping with a label"), or ship with /relevo:ship`));
|
|
761
|
+
} else {
|
|
762
|
+
r.push(pass('ship.app', `${base} requires no status checks, so the checks of the approved head carry over to the archive commit and the ship workflow needs no App`));
|
|
763
|
+
}
|
|
764
|
+
if (String(f.shipVariable ?? '').trim().toLowerCase() === 'off') r.push(warn('ship.paused', `the relevo:ship label is paused (${SHIP_VARIABLE} is off)`, `delete the repository variable ${SHIP_VARIABLE}, or set it to on, to ship with the label again`));
|
|
765
|
+
return r;
|
|
766
|
+
}
|
|
767
|
+
|
|
652
768
|
// The action that sets up the toolchain of a stack in the build job of the go workflow. Vue needs only Node, which the
|
|
653
769
|
// job installs for Relevo itself, and a stack that is not listed here is not judged.
|
|
654
770
|
const TOOLCHAIN_ACTIONS = { flutter: { action: 'subosito/flutter-action', step: 'the Flutter step' }, laravel: { action: 'shivammathur/setup-php', step: 'the Laravel step' } };
|
|
@@ -670,6 +786,47 @@ function toolchainChecks(f) {
|
|
|
670
786
|
});
|
|
671
787
|
}
|
|
672
788
|
|
|
789
|
+
const HOSTED_RUNNER = ['ubuntu-latest'];
|
|
790
|
+
|
|
791
|
+
/** The labels written in a `runs-on` line: `[self-hosted, linux-build]` or `ubuntu-latest`; null for an expression we cannot judge. */
|
|
792
|
+
function parseRunsOn(value) {
|
|
793
|
+
if (value.includes('${{')) return null;
|
|
794
|
+
const inner = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
|
|
795
|
+
return inner.split(',').map((l) => l.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Does every Relevo workflow run on the runner that relevo.yml names (`runner.labels`, else ubuntu-latest)? A workflow
|
|
800
|
+
* edited by hand to another runner is reported with the file, because the next copy of the template would undo it. A
|
|
801
|
+
* runner that is not GitHub's is only assumed to persist: Relevo cannot tell, and says so instead of claiming.
|
|
802
|
+
*/
|
|
803
|
+
function runnerChecks(f) {
|
|
804
|
+
const wanted = f.config?.runner?.labels ?? HOSTED_RUNNER;
|
|
805
|
+
const same = (a, b) => a.length === b.length && [...a].sort().every((l, i) => l === [...b].sort()[i]);
|
|
806
|
+
const r = [];
|
|
807
|
+
let judged = 0;
|
|
808
|
+
// One result per cause: the files that run on the same other runner are one failure that names all of them.
|
|
809
|
+
const differing = new Map();
|
|
810
|
+
Object.entries(f.workflows ?? {}).forEach(([file, w]) => {
|
|
811
|
+
if (!w.present || typeof w.text !== 'string') return;
|
|
812
|
+
judged += 1;
|
|
813
|
+
const found = [...w.text.matchAll(/^[ \t]+runs-on:[ \t]*(.+?)[ \t]*(?:#.*)?$/gm)].map((m) => parseRunsOn(m[1])).filter((l) => l !== null);
|
|
814
|
+
const other = found.find((labels) => !same(labels, wanted));
|
|
815
|
+
if (!other) return;
|
|
816
|
+
const key = other.join(', ');
|
|
817
|
+
differing.set(key, [...(differing.get(key) ?? []), file]);
|
|
818
|
+
});
|
|
819
|
+
[...differing.entries()].forEach(([labels, files]) => {
|
|
820
|
+
const names = files.join(', ');
|
|
821
|
+
const id = differing.size === 1 ? 'automation.runner' : `automation.runner.${labels}`;
|
|
822
|
+
r.push(fail(id, `${names} ${files.length === 1 ? 'runs' : 'run'} on [${labels}] and relevo.yml says [${wanted.join(', ')}]`, same(wanted, HOSTED_RUNNER) ? `set runner.labels: [${labels}] in relevo.yml if that is the runner you want, or restore runs-on: ubuntu-latest in ${names}` : `change runs-on in ${names} to [${wanted.join(', ')}], or change runner.labels in relevo.yml`));
|
|
823
|
+
});
|
|
824
|
+
if (r.length > 0) return r;
|
|
825
|
+
if (judged > 0) r.push(pass('automation.runner', `the Relevo workflows run on [${wanted.join(', ')}]`));
|
|
826
|
+
if (!same(wanted, HOSTED_RUNNER)) r.push(warn('automation.runner.persistence', `the runner [${wanted.join(', ')}] is not GitHub's: Relevo assumes it keeps its files between jobs, and the code the agent writes runs with the permissions of the runner's user`, 'an ephemeral runner (created for one job) or a container is the real barrier; Relevo installs its tools under the temporary folder of the job and removes what it adds, but cannot make the machine a sandbox (docs/security.md)'));
|
|
827
|
+
return r;
|
|
828
|
+
}
|
|
829
|
+
|
|
673
830
|
/** Does the committed settings file declare the plugin? It is a local fact, so it needs no GitHub sign-in. */
|
|
674
831
|
function pluginDeclaredChecks(f) {
|
|
675
832
|
const r = [];
|
|
@@ -679,6 +836,18 @@ function pluginDeclaredChecks(f) {
|
|
|
679
836
|
return r;
|
|
680
837
|
}
|
|
681
838
|
|
|
839
|
+
/**
|
|
840
|
+
* Does the repository install OpenSpec in the sessions of Claude Code on the web? The hook has no use on a terminal,
|
|
841
|
+
* where OpenSpec stays installed, so it is judged only when doctor itself runs in a remote session. It warns: the
|
|
842
|
+
* project can run without it, but every new session starts without OpenSpec.
|
|
843
|
+
*/
|
|
844
|
+
function webHookChecks(f) {
|
|
845
|
+
if (!f.remoteSession || !f.webHook) return [];
|
|
846
|
+
if (f.webHook.unreadable) return [warn('web.openspec-hook', '.claude/settings.json is not valid JSON, so it cannot be checked whether it installs OpenSpec in web sessions', 'fix the file; /relevo:init then offers the hook')];
|
|
847
|
+
if (f.webHook.declared) return [pass('web.openspec-hook', 'the repository installs OpenSpec at the start of a web session (SessionStart hook in .claude/settings.json)')];
|
|
848
|
+
return [warn('web.openspec-hook', 'this is a web session and the repository has no SessionStart hook that installs OpenSpec, so every new session starts without it and go cannot run', `run /relevo:init to add the hook, commit .claude/settings.json, or install it now: ${OPENSPEC_INSTALL}`)];
|
|
849
|
+
}
|
|
850
|
+
|
|
682
851
|
/**
|
|
683
852
|
* The installed plugin and what the workflows pin. A package that lost a folder cannot run; a workflow that clones
|
|
684
853
|
* the plugin repository for its scripts (the layout before the package) or has no pin is told to be replaced; a pin
|
|
@@ -694,7 +863,50 @@ function pluginInstallChecks(f) {
|
|
|
694
863
|
const unpinned = Object.entries(pins).filter(([file, v]) => v === null && !cloners.includes(file)).map(([file]) => file);
|
|
695
864
|
if (unpinned.length > 0) r.push(fail('automation.plugin-pin', `${unpinned.join(', ')} do not pin the version of the Relevo package`, 'copy the current templates, which set RELEVO_VERSION'));
|
|
696
865
|
const old = f.pluginVersion ? Object.entries(pins).filter(([, v]) => v !== null && v !== f.pluginVersion).map(([file, v]) => `${file} (${v})`) : [];
|
|
697
|
-
if (old.length > 0) r.push(warn('automation.plugin-version', `${old.join(', ')} pin a different version than the installed plugin (${f.pluginVersion})`, '
|
|
866
|
+
if (old.length > 0) r.push(warn('automation.plugin-version', `${old.join(', ')} pin a different version than the installed plugin (${f.pluginVersion})`, 'run /relevo:init: it updates a workflow by merging it with the new template and keeps your own changes; or change RELEVO_VERSION by hand after reading the changelog (that moves the scripts, not the text)'));
|
|
867
|
+
r.push(...versionChecks(f), ...workflowTextChecks(f));
|
|
868
|
+
return r;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const byVersion = (a, b) => a.split('.').map(Number).reduce((x, n, i) => x || n - Number(b.split('.')[i]), 0);
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Which version each part is on, from what can be read here: the plugin in use, each installation `claude` lists and the
|
|
875
|
+
* pins of the workflows. It is a note, always visible; a warning only when the user and the project scope disagree, with
|
|
876
|
+
* the command that moves the lagging one. It asks the npm registry nothing.
|
|
877
|
+
*/
|
|
878
|
+
function versionChecks(f) {
|
|
879
|
+
if (f.installedPlugins === undefined) return [];
|
|
880
|
+
if (f.installedPlugins === null) return [warn('plugin.versions', 'the installed versions of the plugin could not be read: `claude plugin list --json` failed', 'check that Claude Code is installed (claude --version) and run claude plugin list')];
|
|
881
|
+
const r = [];
|
|
882
|
+
const scopes = f.installedPlugins.filter((e) => e.version);
|
|
883
|
+
const pins = [...new Set(Object.values(f.pluginPins ?? {}).filter(Boolean))].sort(byVersion);
|
|
884
|
+
const parts = [
|
|
885
|
+
`plugin in use ${f.pluginVersion ?? 'unknown'}`,
|
|
886
|
+
scopes.length > 0 ? `installed ${scopes.map((e) => `${e.scope} ${e.version}`).join(', ')}` : 'not installed through Claude Code',
|
|
887
|
+
pins.length > 0 ? `workflows pin ${pins.join(', ')}` : null,
|
|
888
|
+
].filter(Boolean);
|
|
889
|
+
r.push(note('plugin.versions', `versions: ${parts.join('; ')}`));
|
|
890
|
+
const distinct = [...new Set(scopes.map((e) => e.version))].sort(byVersion);
|
|
891
|
+
if (distinct.length > 1) {
|
|
892
|
+
const newest = distinct.at(-1);
|
|
893
|
+
const lagging = scopes.filter((e) => e.version !== newest).map((e) => e.scope);
|
|
894
|
+
r.push(warn('plugin.scopes', `the plugin is on different versions: ${scopes.map((e) => `${e.scope} ${e.version}`).join(', ')}`, `update the lagging scope: ${lagging.map((s) => `claude plugin update relevo@relevo --scope ${s}`).join('; ')}`));
|
|
895
|
+
}
|
|
896
|
+
return r;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Is the text of each workflow as current as the scripts it runs? The stamp is the version of the text; the pin is the scripts'. */
|
|
900
|
+
function workflowTextChecks(f) {
|
|
901
|
+
const stamps = f.pluginStamps;
|
|
902
|
+
if (!stamps) return [];
|
|
903
|
+
const pins = f.pluginPins ?? {};
|
|
904
|
+
const unknown = Object.keys(stamps).filter((file) => stamps[file] === null && f.workflows?.[file]?.present);
|
|
905
|
+
const older = Object.keys(stamps).filter((file) => stamps[file] !== null && pins[file] && byVersion(stamps[file], pins[file]) < 0);
|
|
906
|
+
const r = [];
|
|
907
|
+
if (older.length > 0) r.push(warn('automation.workflow-text', `${older.map((file) => `${file} (text ${stamps[file]}, scripts ${pins[file]})`).join(', ')}: the text of the workflow is older than the scripts it runs`, 'run /relevo:init: it updates the workflow by merging it with the new template and keeps your own changes'));
|
|
908
|
+
if (unknown.length > 0) r.push(warn('automation.workflow-text-unknown', `${unknown.join(', ')} have no # relevo-template: line, so the age of their text is unknown`, 'run /relevo:init: it updates the workflow by merging it with the new template and keeps your own changes'));
|
|
909
|
+
if (older.length === 0 && unknown.length === 0 && Object.keys(stamps).length > 0) r.push(pass('automation.workflow-text', 'the text of the workflows is as current as the scripts they run'));
|
|
698
910
|
return r;
|
|
699
911
|
}
|
|
700
912
|
|
|
@@ -715,14 +927,41 @@ function adapterChecks(f) {
|
|
|
715
927
|
});
|
|
716
928
|
}
|
|
717
929
|
|
|
718
|
-
/**
|
|
719
|
-
export function
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
930
|
+
/** The exit status of `doctor`: 1 when a check failed, 0 otherwise. Warnings and notes never fail it. */
|
|
931
|
+
export function exitCode(results) {
|
|
932
|
+
return results.some((x) => x.status === 'fail') ? 1 : 0;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const TAG = { pass: 'pass', fail: 'FAIL', warn: 'warn', note: 'note' };
|
|
936
|
+
const lineOf = (x) => [`[${TAG[x.status]}] ${x.message}`, ...(x.fix && (x.status === 'fail' || x.status === 'warn') ? [` fix: ${x.fix}`] : [])];
|
|
937
|
+
|
|
938
|
+
/** The first line of the report: what was judged, and where the workflows were read from. */
|
|
939
|
+
export function reportHeader(facts) {
|
|
940
|
+
if (!facts?.repo || !facts.ghAuth) return 'Relevo doctor: only the local checks ran (no GitHub repository, or not signed in to GitHub)';
|
|
941
|
+
return `Relevo doctor: ${facts.repo}, workflows read from the ${facts.workflowSource ?? 'working copy'}`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Text report. By default what needs action comes first (failures, warnings, notes, each with its count) and the checks
|
|
946
|
+
* that passed are only counted; `all` lists every check in the order it was evaluated. The closing line counts results.
|
|
947
|
+
*/
|
|
948
|
+
export function renderReport(results, { header = null, all = false } = {}) {
|
|
949
|
+
const by = (status) => results.filter((x) => x.status === status);
|
|
950
|
+
const failed = by('fail').length;
|
|
951
|
+
const warned = by('warn').length;
|
|
952
|
+
const passed = by('pass').length;
|
|
953
|
+
const lines = header ? [header, ''] : [];
|
|
954
|
+
if (all) lines.push(...results.flatMap(lineOf));
|
|
955
|
+
else {
|
|
956
|
+
[['FAILURES', 'fail'], ['WARNINGS', 'warn'], ['NOTES', 'note']].forEach(([title, status]) => {
|
|
957
|
+
const group = by(status);
|
|
958
|
+
if (group.length === 0) return;
|
|
959
|
+
lines.push(`${title} (${group.length})`, ...group.flatMap(lineOf), '');
|
|
960
|
+
});
|
|
961
|
+
if (passed > 0) lines.push(`${passed} check(s) passed; run with --all to list them.`);
|
|
962
|
+
}
|
|
724
963
|
lines.push('', failed === 0 ? (warned === 0 ? 'All green.' : `No failures, ${warned} warning(s).`) : `${failed} check(s) failed${warned ? `, ${warned} warning(s)` : ''}.`);
|
|
725
|
-
return lines.join('\n');
|
|
964
|
+
return lines.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
726
965
|
}
|
|
727
966
|
|
|
728
967
|
/** Wrap a `gh` client so that it can only read: `doctor` must never change anything. */
|
package/scripts/lib/github.mjs
CHANGED
|
@@ -45,16 +45,20 @@ function normalizeIssue(raw) {
|
|
|
45
45
|
milestone: raw.milestone ? { number: raw.milestone.number, due_on: raw.milestone.due_on ?? null } : null,
|
|
46
46
|
ready_at: null,
|
|
47
47
|
ready_by: null,
|
|
48
|
+
urgent_by: null,
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/** When and by whom
|
|
52
|
-
export function
|
|
53
|
-
const events = timeline.filter((e) => e.event === 'labeled' && e.label?.name ===
|
|
52
|
+
/** When and by whom a label was last applied, from the issue timeline. */
|
|
53
|
+
export function labelEvent(timeline, labelName) {
|
|
54
|
+
const events = timeline.filter((e) => e.event === 'labeled' && e.label?.name === labelName);
|
|
54
55
|
const last = events[events.length - 1];
|
|
55
56
|
return last ? { at: last.created_at, by: last.actor?.login ?? null } : { at: null, by: null };
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/** When and by whom the ready label was last applied. */
|
|
60
|
+
export const readyEvent = labelEvent;
|
|
61
|
+
|
|
58
62
|
/**
|
|
59
63
|
* Collect everything `planGo` needs. Returns { issues, completed, completedExternal, unreadable, running, permissions }.
|
|
60
64
|
* `running` is supplied by the caller (workflow runs in progress). `ghLinks` reads issues of linked repositories
|
|
@@ -74,9 +78,11 @@ export function gather({ gh, ghLinks = gh, repo, config, running = [] }) {
|
|
|
74
78
|
const candidates = issues.filter((i) => [names.ready, names.queued, names.blocked].some((n) => i.labels.includes(n)));
|
|
75
79
|
candidates.forEach((issue) => {
|
|
76
80
|
const timeline = apiJson(gh, `repos/${repo}/issues/${issue.number}/timeline?per_page=100`, { paginate: true });
|
|
77
|
-
const { at, by } =
|
|
81
|
+
const { at, by } = labelEvent(timeline, names.ready);
|
|
78
82
|
issue.ready_at = at;
|
|
79
83
|
issue.ready_by = by;
|
|
84
|
+
// The same timeline answers for the urgent label: who asked for the early start.
|
|
85
|
+
if (issue.labels.includes(names.urgent)) issue.urgent_by = labelEvent(timeline, names.urgent).by;
|
|
80
86
|
});
|
|
81
87
|
|
|
82
88
|
const wanted = new Set();
|
|
@@ -109,7 +115,7 @@ export function gather({ gh, ghLinks = gh, repo, config, running = [] }) {
|
|
|
109
115
|
});
|
|
110
116
|
|
|
111
117
|
const permissions = {};
|
|
112
|
-
new Set(candidates.
|
|
118
|
+
new Set(candidates.flatMap((i) => [i.ready_by, i.urgent_by]).filter(Boolean)).forEach((login) => {
|
|
113
119
|
try {
|
|
114
120
|
permissions[login] = apiJson(gh, `repos/${repo}/collaborators/${login}/permission`).permission ?? 'none';
|
|
115
121
|
} catch {
|
|
@@ -132,6 +138,10 @@ export function commandsFor(decision, { repo, config }) {
|
|
|
132
138
|
const body = renderMessage(decision.comment.kind, decision.comment.data, config.language);
|
|
133
139
|
commands.push(['issue', 'comment', String(decision.issue), '--repo', repo, '--body', body]);
|
|
134
140
|
}
|
|
141
|
+
if (decision.notice) {
|
|
142
|
+
const body = renderMessage(decision.notice.kind, decision.notice.data, config.language);
|
|
143
|
+
commands.push(['issue', 'comment', String(decision.issue), '--repo', repo, '--body', body]);
|
|
144
|
+
}
|
|
135
145
|
return commands;
|
|
136
146
|
}
|
|
137
147
|
|