@elalfner/relevo 0.7.0 → 0.8.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/package.json +1 -1
- package/scripts/init.mjs +3 -1
- package/scripts/lib/can-edit-issue.mjs +4 -1
- package/scripts/lib/config.mjs +19 -1
- package/scripts/lib/doctor.mjs +136 -5
- package/scripts/lib/github.mjs +15 -5
- package/scripts/lib/init.mjs +63 -3
- 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/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 +3 -2
- package/skills/go/SKILL.md +4 -2
- package/skills/init/SKILL.md +7 -2
- 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 +10 -1
- package/templates/workflows/relevo-go.yml +25 -5
- package/templates/workflows/relevo-queue.yml +10 -1
- package/templates/workflows/relevo-review.yml +10 -1
- package/templates/workflows/relevo-ship.yml +135 -0
- package/templates/workflows/relevo-sla.yml +10 -1
package/package.json
CHANGED
package/scripts/init.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
11
11
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { dirname, resolve } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { gatherFacts, readOnlyGh, WORKFLOW_FILES } from './lib/doctor.mjs';
|
|
14
|
+
import { gatherFacts, readOnlyGh, vueWebFacts, WORKFLOW_FILES } from './lib/doctor.mjs';
|
|
15
15
|
import { applyPlan, planInit, renderPlan, skipItems } from './lib/init.mjs';
|
|
16
16
|
|
|
17
17
|
const arg = (name) => {
|
|
@@ -50,6 +50,8 @@ const facts = gatherFacts({
|
|
|
50
50
|
repo,
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
facts.vueWeb = vueWebFacts(facts.config?.platforms ?? answers.platforms ?? {}, { tool, exists: existsSync });
|
|
54
|
+
|
|
53
55
|
let admin = false;
|
|
54
56
|
try {
|
|
55
57
|
admin = JSON.parse(run('gh', ['api', `repos/${repo}`])).permissions?.admin === true;
|
|
@@ -14,8 +14,11 @@ const REASON = {
|
|
|
14
14
|
'qa-failed': 'a branch already exists and the text would no longer match what was built',
|
|
15
15
|
'changes-requested': 'a branch already exists and the text would no longer match what was built',
|
|
16
16
|
'needs-reviewer': 'it is built and waiting for a reviewer',
|
|
17
|
+
ship: 'its pull request is being merged',
|
|
18
|
+
// Not a state: it travels next to one, so the state decides whether the text may change.
|
|
19
|
+
urgent: 'the label asks for an early start and does not commit the issue to a build',
|
|
17
20
|
};
|
|
18
|
-
export const EDITABLE_LABELS = ['needs-spec'];
|
|
21
|
+
export const EDITABLE_LABELS = ['needs-spec', 'urgent'];
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* @param {{state?: string, labels?: Array<string|{name: string}>}} issue `gh issue view --json state,labels`
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -20,6 +20,8 @@ export const SETTINGS = [
|
|
|
20
20
|
{ path: 'review.merger', type: 'enum', allowed: ['author', 'approver'], default: 'approver' },
|
|
21
21
|
{ path: 'review.autopilot', type: 'enum', allowed: ['off', 'precheck', 'verify'], default: 'off' },
|
|
22
22
|
{ path: 'review.allowed_origins', type: 'origin-list', default: [] },
|
|
23
|
+
// Whether the ship workflow may merge when no check ran on any commit; /relevo:ship asks the person instead.
|
|
24
|
+
{ path: 'review.ship_without_ci', type: 'bool', default: false },
|
|
23
25
|
{ path: 'limits.built_unreviewed_per_person', type: 'int', min: 1, default: 2 },
|
|
24
26
|
{ path: 'limits.concurrent_agents', type: 'int', min: 1, default: 3 },
|
|
25
27
|
{ path: 'limits.review_sla_hours', type: 'int', min: 1, default: 8 },
|
|
@@ -29,6 +31,8 @@ export const SETTINGS = [
|
|
|
29
31
|
{ path: 'limits.run_max_cost', type: 'number', exclusiveMin: 0, required: true, default: null, requiredFor: 'claude', missing: 'unattended runs must always have a cost limit (amount in US dollars per run)', rejectedFor: 'antigravity', rejection: 'the agent has no cost limit; set limits.run_max_tokens instead, and note that the quota of the Google Cloud project that owns the key is the ceiling that really holds' },
|
|
30
32
|
{ path: 'limits.run_max_tokens', type: 'int', min: 1000, default: null, required: true, requiredFor: 'antigravity', missing: 'unattended runs must always have a limit, and for this agent it is a number of tokens per run', rejectedFor: 'claude', rejection: 'the run is limited by limits.run_max_cost' },
|
|
31
33
|
{ path: 'agent.build', type: 'enum', allowed: ['claude', 'antigravity'], default: 'claude' },
|
|
34
|
+
// The runner of every Relevo workflow: runs-on is read before any step, so init writes these labels into the files.
|
|
35
|
+
{ path: 'runner.labels', type: 'label-list', default: ['ubuntu-latest'] },
|
|
32
36
|
{ path: 'qa.retries', type: 'int', min: 0, default: 1 },
|
|
33
37
|
{ path: 'qa.rules_from', type: 'path-list', default: [] },
|
|
34
38
|
{ path: 'skills.allow_scripts', type: 'string[]', default: [] },
|
|
@@ -79,6 +83,15 @@ function checkType(setting, value, errors) {
|
|
|
79
83
|
errors.push({ path, message: `${path} must be a list of non-empty strings (got ${JSON.stringify(value)})` });
|
|
80
84
|
}
|
|
81
85
|
break;
|
|
86
|
+
case 'label-list':
|
|
87
|
+
if (!Array.isArray(value)) {
|
|
88
|
+
errors.push({ path, message: `${path} must be a list of runner labels, for example [self-hosted, Front] (got ${JSON.stringify(value)})` });
|
|
89
|
+
} else if (value.length === 0) {
|
|
90
|
+
errors.push({ path, message: `${path} needs at least one label; leave the setting out to use ubuntu-latest` });
|
|
91
|
+
} else if (value.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
92
|
+
errors.push({ path, message: `${path} must be a list of non-empty strings (got ${JSON.stringify(value)})` });
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
82
95
|
case 'path-list':
|
|
83
96
|
if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
84
97
|
errors.push({ path, message: `${path} must be a file name or a list of file names (got ${JSON.stringify(value)})` });
|
|
@@ -161,7 +174,12 @@ export function validateConfig(raw, { knownTargets = null, knownChecks = null }
|
|
|
161
174
|
}
|
|
162
175
|
const known = new Set(SETTINGS.filter((s) => s.path.startsWith(`${group}.`)).map((s) => s.path.split('.')[1]));
|
|
163
176
|
Object.keys(value).forEach((k) => {
|
|
164
|
-
if (
|
|
177
|
+
if (known.has(k)) return;
|
|
178
|
+
// Relevo cannot verify what a runner is, so it offers no such key: a runner is assumed to persist.
|
|
179
|
+
const message = `${group}.${k}` === 'runner.ephemeral'
|
|
180
|
+
? 'unknown setting "runner.ephemeral": Relevo does not ask whether a runner is ephemeral, it assumes every runner persists between jobs'
|
|
181
|
+
: `unknown setting "${group}.${k}"`;
|
|
182
|
+
errors.push({ path: `${group}.${k}`, message });
|
|
165
183
|
});
|
|
166
184
|
});
|
|
167
185
|
|
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'] };
|
|
@@ -70,10 +73,10 @@ export function refMatches(pattern, branch, defaultBranch) {
|
|
|
70
73
|
|
|
71
74
|
/**
|
|
72
75
|
* What the repository enforces on a branch, from its rulesets and its classic protection.
|
|
73
|
-
* Returns { protected, pullRequestRequired, approvals, dismissStale, blocksDeletion, blocksForcePush }.
|
|
76
|
+
* Returns { protected, pullRequestRequired, approvals, dismissStale, lastPushApproval, requiredChecks, blocksDeletion, blocksForcePush }.
|
|
74
77
|
*/
|
|
75
78
|
export function summarizeProtection({ rulesets = [], classic = null, branch, defaultBranch }) {
|
|
76
|
-
const out = { protected: false, pullRequestRequired: false, approvals: 0, dismissStale: false, blocksDeletion: false, blocksForcePush: false };
|
|
79
|
+
const out = { protected: false, pullRequestRequired: false, approvals: 0, dismissStale: false, lastPushApproval: false, requiredChecks: false, blocksDeletion: false, blocksForcePush: false };
|
|
77
80
|
rulesets.filter((r) => r.target === 'branch' && r.enforcement === 'active').forEach((r) => {
|
|
78
81
|
const include = r.conditions?.ref_name?.include ?? [];
|
|
79
82
|
const exclude = r.conditions?.ref_name?.exclude ?? [];
|
|
@@ -84,7 +87,9 @@ export function summarizeProtection({ rulesets = [], classic = null, branch, def
|
|
|
84
87
|
out.pullRequestRequired = true;
|
|
85
88
|
out.approvals = Math.max(out.approvals, rule.parameters?.required_approving_review_count ?? 0);
|
|
86
89
|
if (rule.parameters?.dismiss_stale_reviews_on_push) out.dismissStale = true;
|
|
90
|
+
if (rule.parameters?.require_last_push_approval) out.lastPushApproval = true;
|
|
87
91
|
}
|
|
92
|
+
if (rule.type === 'required_status_checks') out.requiredChecks = true;
|
|
88
93
|
if (rule.type === 'deletion') out.blocksDeletion = true;
|
|
89
94
|
if (rule.type === 'non_fast_forward') out.blocksForcePush = true;
|
|
90
95
|
});
|
|
@@ -96,13 +101,45 @@ export function summarizeProtection({ rulesets = [], classic = null, branch, def
|
|
|
96
101
|
out.pullRequestRequired = true;
|
|
97
102
|
out.approvals = Math.max(out.approvals, pr.required_approving_review_count ?? 0);
|
|
98
103
|
if (pr.dismiss_stale_reviews) out.dismissStale = true;
|
|
104
|
+
if (pr.require_last_push_approval) out.lastPushApproval = true;
|
|
99
105
|
}
|
|
106
|
+
if (classic.required_status_checks) out.requiredChecks = true;
|
|
100
107
|
if (classic.allow_deletions?.enabled === false) out.blocksDeletion = true;
|
|
101
108
|
if (classic.allow_force_pushes?.enabled === false) out.blocksForcePush = true;
|
|
102
109
|
}
|
|
103
110
|
return out;
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
// ---------- Playwright of a platform
|
|
114
|
+
|
|
115
|
+
/** Is the platform a Vue platform reviewed in a browser? `web` is the only target of Vue, and its default. */
|
|
116
|
+
export const isVueWeb = (platform) => platform?.stack === 'vue' && (!platform.targets?.length || platform.targets.includes('web'));
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does Playwright resolve from the folder of a platform, and is its Chromium installed? Resolving from the folder, not
|
|
120
|
+
* from where the command runs, finds the node_modules of a platform in a subfolder. `tool(name, args)` throws when the
|
|
121
|
+
* command fails; `exists(path)`.
|
|
122
|
+
*/
|
|
123
|
+
export function playwrightFacts(dir, { tool, exists }) {
|
|
124
|
+
const where = "require.resolve('playwright', { paths: [require('node:path').resolve(process.argv[1])] })";
|
|
125
|
+
try {
|
|
126
|
+
tool('node', ['-e', where, dir || '.']);
|
|
127
|
+
} catch {
|
|
128
|
+
return { playwright: false, chromium: false };
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const path = String(tool('node', ['-e', `process.stdout.write(require(${where}).chromium.executablePath())`, dir || '.'])).trim();
|
|
132
|
+
return { playwright: true, chromium: path !== '' && Boolean(exists(path)) };
|
|
133
|
+
} catch {
|
|
134
|
+
return { playwright: true, chromium: false };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** playwrightFacts of each Vue platform with a web target, by platform name. */
|
|
139
|
+
export function vueWebFacts(platforms, deps) {
|
|
140
|
+
return Object.fromEntries(Object.entries(platforms ?? {}).filter(([, p]) => isVueWeb(p)).map(([name, p]) => [name, playwrightFacts(p.path, deps)]));
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
// ---------- gather
|
|
107
144
|
|
|
108
145
|
/**
|
|
@@ -160,6 +197,8 @@ export function gatherFacts(p) {
|
|
|
160
197
|
f.openspecRootTracked = tryRun(() => String(p.tool('git', ['ls-files', 'openspec'])).trim().length > 0, false);
|
|
161
198
|
f.evidenceIgnored = p.exists('.gitignore') && /^\/?evidence\/?\s*$/m.test(p.read('.gitignore'));
|
|
162
199
|
f.pluginDeclared = pluginDeclaration(p);
|
|
200
|
+
f.webHook = webHookDeclaration(p);
|
|
201
|
+
f.remoteSession = process.env.CLAUDE_CODE_REMOTE === 'true';
|
|
163
202
|
|
|
164
203
|
// Workflows: what runs on GitHub is what is on the default branch, not what is in the working copy, so once the
|
|
165
204
|
// repository is known they are read from there. `present` says whether the file is where it has to be; `local`
|
|
@@ -210,6 +249,7 @@ export function gatherFacts(p) {
|
|
|
210
249
|
f.canApprove = tryRun(() => api(`repos/${f.repo}/actions/permissions/workflow`).can_approve_pull_request_reviews, null);
|
|
211
250
|
f.secrets = tryRun(() => api(`repos/${f.repo}/actions/secrets`).secrets.map((s) => s.name));
|
|
212
251
|
f.cloudVariable = tryRun(() => api(`repos/${f.repo}/actions/variables/${CLOUD_VARIABLE}`).value, null);
|
|
252
|
+
f.shipVariable = tryRun(() => api(`repos/${f.repo}/actions/variables/${SHIP_VARIABLE}`).value, null);
|
|
213
253
|
// The repositories this one may depend on: can they be read, and are they private (then a token is needed)?
|
|
214
254
|
f.links = (f.config?.links ?? []).map((link) => {
|
|
215
255
|
const info = tryRun(() => api(`repos/${link}`), null);
|
|
@@ -286,6 +326,28 @@ export function pluginDeclaration(p) {
|
|
|
286
326
|
}
|
|
287
327
|
}
|
|
288
328
|
|
|
329
|
+
// The one-line `SessionStart` hook that installs OpenSpec in a session of Claude Code on the web. It does nothing
|
|
330
|
+
// outside a remote session, and nothing when `openspec` already runs; `|| true` keeps a failed install from failing the hook.
|
|
331
|
+
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';
|
|
332
|
+
|
|
333
|
+
/** Does a `SessionStart` entry install OpenSpec? The package name is what is matched, so a hook written by hand counts. */
|
|
334
|
+
const installsOpenspec = (entry) => (entry?.hooks ?? []).some((h) => typeof h?.command === 'string' && h.command.includes('@fission-ai/openspec'));
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Whether the committed settings file has a `SessionStart` hook that installs OpenSpec: { declared } or, when the file
|
|
338
|
+
* is not valid JSON, { unreadable: true }. A missing file is { declared: false }.
|
|
339
|
+
*/
|
|
340
|
+
export function webHookDeclaration(p) {
|
|
341
|
+
const path = '.claude/settings.json';
|
|
342
|
+
if (!p.exists(path)) return { declared: false };
|
|
343
|
+
try {
|
|
344
|
+
const settings = JSON.parse(p.read(path));
|
|
345
|
+
return { declared: Array.isArray(settings?.hooks?.SessionStart) && settings.hooks.SessionStart.some(installsOpenspec) };
|
|
346
|
+
} catch {
|
|
347
|
+
return { unreadable: true };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
289
351
|
/** Reads a file: the plugin's own files from disk, the repository's through the probes. Null when absent. */
|
|
290
352
|
function readerFor(p, tryRun) {
|
|
291
353
|
const pluginRoot = p.pluginRoot ?? PLUGIN_ROOT;
|
|
@@ -363,7 +425,7 @@ function gatherPlatforms(f, p, tryRun) {
|
|
|
363
425
|
const pubspec = p.exists(join(platform.path, 'pubspec.yaml')) ? tryRun(() => p.read(join(platform.path, 'pubspec.yaml')), '') : '';
|
|
364
426
|
entry.driverDependency = /^\s*flutter_driver\s*:/m.test(pubspec ?? '');
|
|
365
427
|
} else if (platform.stack === 'vue') {
|
|
366
|
-
entry.playwright =
|
|
428
|
+
entry.playwright = playwrightFacts(platform.path, { tool: p.tool, exists: p.exists }).playwright;
|
|
367
429
|
}
|
|
368
430
|
return entry;
|
|
369
431
|
}),
|
|
@@ -532,7 +594,7 @@ function platformChecks(f) {
|
|
|
532
594
|
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
595
|
else r.push(pass(`autopilot.${e.name}.dependency`, `platform ${e.name} depends on flutter_driver`));
|
|
534
596
|
} 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
|
|
597
|
+
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
598
|
else r.push(pass(`autopilot.${e.name}.playwright`, `Playwright is available for platform ${e.name}`));
|
|
537
599
|
} else {
|
|
538
600
|
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'));
|
|
@@ -576,6 +638,7 @@ export function runChecks(f) {
|
|
|
576
638
|
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
639
|
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
640
|
r.push(...pluginDeclaredChecks(f));
|
|
641
|
+
r.push(...webHookChecks(f));
|
|
579
642
|
|
|
580
643
|
// GitHub side
|
|
581
644
|
if (!f.repo || !f.ghAuth) {
|
|
@@ -602,6 +665,7 @@ export function runChecks(f) {
|
|
|
602
665
|
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
666
|
else r.push(pass(`repo.protection.${key}`, `${name} requires a pull request${p.approvals ? ` with ${p.approvals} approval(s)` : ''}`));
|
|
604
667
|
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}`));
|
|
668
|
+
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
669
|
});
|
|
606
670
|
}
|
|
607
671
|
if (!f.protectionUnavailable && !f.protectionError) {
|
|
@@ -637,11 +701,13 @@ export function runChecks(f) {
|
|
|
637
701
|
|
|
638
702
|
r.push(...pluginInstallChecks(f));
|
|
639
703
|
r.push(...toolchainChecks(f));
|
|
704
|
+
r.push(...runnerChecks(f));
|
|
640
705
|
if (f.pluginMarketplace && f.pluginRepoPrivate === true && !f.pluginMarketplaceHasToken) {
|
|
641
706
|
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
707
|
}
|
|
643
708
|
r.push(...linkChecks(f));
|
|
644
709
|
r.push(...executionChecks(f));
|
|
710
|
+
r.push(...shipChecks(f));
|
|
645
711
|
if (f.pluginRepoPrivate === true && Array.isArray(f.secrets)) {
|
|
646
712
|
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
713
|
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'));
|
|
@@ -649,6 +715,27 @@ export function runChecks(f) {
|
|
|
649
715
|
return r;
|
|
650
716
|
}
|
|
651
717
|
|
|
718
|
+
/**
|
|
719
|
+
* What the ship workflow needs, when it is on the default branch: the App secrets when the base branch requires checks
|
|
720
|
+
* (a push with the workflow token starts none, so the archive commit would never get them), and whether it is paused.
|
|
721
|
+
* Its label is checked with the other labels, and it needs no model credential.
|
|
722
|
+
*/
|
|
723
|
+
function shipChecks(f) {
|
|
724
|
+
const r = [];
|
|
725
|
+
if (!f.workflows?.['relevo-ship.yml']?.present) return r;
|
|
726
|
+
const base = f.config?.branches?.base ?? 'base';
|
|
727
|
+
if (f.protectionError || f.protectionUnavailable) return r;
|
|
728
|
+
if (f.protection?.base?.requiredChecks) {
|
|
729
|
+
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`));
|
|
730
|
+
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`));
|
|
731
|
+
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`));
|
|
732
|
+
} else {
|
|
733
|
+
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`));
|
|
734
|
+
}
|
|
735
|
+
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`));
|
|
736
|
+
return r;
|
|
737
|
+
}
|
|
738
|
+
|
|
652
739
|
// 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
740
|
// job installs for Relevo itself, and a stack that is not listed here is not judged.
|
|
654
741
|
const TOOLCHAIN_ACTIONS = { flutter: { action: 'subosito/flutter-action', step: 'the Flutter step' }, laravel: { action: 'shivammathur/setup-php', step: 'the Laravel step' } };
|
|
@@ -670,6 +757,38 @@ function toolchainChecks(f) {
|
|
|
670
757
|
});
|
|
671
758
|
}
|
|
672
759
|
|
|
760
|
+
const HOSTED_RUNNER = ['ubuntu-latest'];
|
|
761
|
+
|
|
762
|
+
/** The labels written in a `runs-on` line: `[self-hosted, Front]` or `ubuntu-latest`; null for an expression we cannot judge. */
|
|
763
|
+
function parseRunsOn(value) {
|
|
764
|
+
if (value.includes('${{')) return null;
|
|
765
|
+
const inner = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
|
|
766
|
+
return inner.split(',').map((l) => l.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Does every Relevo workflow run on the runner that relevo.yml names (`runner.labels`, else ubuntu-latest)? A workflow
|
|
771
|
+
* edited by hand to another runner is reported with the file, because the next copy of the template would undo it. A
|
|
772
|
+
* runner that is not GitHub's is only assumed to persist: Relevo cannot tell, and says so instead of claiming.
|
|
773
|
+
*/
|
|
774
|
+
function runnerChecks(f) {
|
|
775
|
+
const wanted = f.config?.runner?.labels ?? HOSTED_RUNNER;
|
|
776
|
+
const same = (a, b) => a.length === b.length && [...a].sort().every((l, i) => l === [...b].sort()[i]);
|
|
777
|
+
const r = [];
|
|
778
|
+
let judged = 0;
|
|
779
|
+
Object.entries(f.workflows ?? {}).forEach(([file, w]) => {
|
|
780
|
+
if (!w.present || typeof w.text !== 'string') return;
|
|
781
|
+
judged += 1;
|
|
782
|
+
const found = [...w.text.matchAll(/^[ \t]+runs-on:[ \t]*(.+?)[ \t]*(?:#.*)?$/gm)].map((m) => parseRunsOn(m[1])).filter((l) => l !== null);
|
|
783
|
+
const other = found.find((labels) => !same(labels, wanted));
|
|
784
|
+
if (other) r.push(fail(`automation.runner.${file}`, `${file} runs on [${other.join(', ')}] and relevo.yml says [${wanted.join(', ')}]`, same(wanted, HOSTED_RUNNER) ? `set runner.labels: [${other.join(', ')}] in relevo.yml if that is the runner you want, or restore runs-on: ubuntu-latest in ${file}` : `change runs-on in ${file} to [${wanted.join(', ')}], or change runner.labels in relevo.yml`));
|
|
785
|
+
});
|
|
786
|
+
if (r.length > 0) return r;
|
|
787
|
+
if (judged > 0) r.push(pass('automation.runner', `the Relevo workflows run on [${wanted.join(', ')}]`));
|
|
788
|
+
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)'));
|
|
789
|
+
return r;
|
|
790
|
+
}
|
|
791
|
+
|
|
673
792
|
/** Does the committed settings file declare the plugin? It is a local fact, so it needs no GitHub sign-in. */
|
|
674
793
|
function pluginDeclaredChecks(f) {
|
|
675
794
|
const r = [];
|
|
@@ -679,6 +798,18 @@ function pluginDeclaredChecks(f) {
|
|
|
679
798
|
return r;
|
|
680
799
|
}
|
|
681
800
|
|
|
801
|
+
/**
|
|
802
|
+
* Does the repository install OpenSpec in the sessions of Claude Code on the web? The hook has no use on a terminal,
|
|
803
|
+
* where OpenSpec stays installed, so it is judged only when doctor itself runs in a remote session. It warns: the
|
|
804
|
+
* project can run without it, but every new session starts without OpenSpec.
|
|
805
|
+
*/
|
|
806
|
+
function webHookChecks(f) {
|
|
807
|
+
if (!f.remoteSession || !f.webHook) return [];
|
|
808
|
+
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')];
|
|
809
|
+
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)')];
|
|
810
|
+
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}`)];
|
|
811
|
+
}
|
|
812
|
+
|
|
682
813
|
/**
|
|
683
814
|
* The installed plugin and what the workflows pin. A package that lost a folder cannot run; a workflow that clones
|
|
684
815
|
* the plugin repository for its scripts (the layout before the package) or has no pin is told to be replaced; a pin
|
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
|
|
package/scripts/lib/init.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { loadConfig } from './config.mjs';
|
|
|
9
9
|
import { LEGACY_PLATFORMS } from './platforms.mjs';
|
|
10
10
|
import { labelDefinitions } from './labels.mjs';
|
|
11
11
|
import { agentWorkflow } from './agent-workflow.mjs';
|
|
12
|
-
import { OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WORKFLOW_FILES } from './doctor.mjs';
|
|
12
|
+
import { isVueWeb, OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WEB_HOOK_COMMAND, WORKFLOW_FILES } from './doctor.mjs';
|
|
13
13
|
|
|
14
14
|
export const REVIEW_MODES = ['none', 'peer', 'roles'];
|
|
15
15
|
|
|
@@ -28,12 +28,42 @@ const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE
|
|
|
28
28
|
export const PLUGIN_PROJECT_COMMAND = 'claude plugin marketplace add elalfner/relevo --scope project --sparse .claude-plugin && claude plugin install relevo@relevo --scope project';
|
|
29
29
|
const PLUGIN_PROJECT_DETAIL = 'It writes the marketplace and the enabled plugin into .claude/settings.json of this repository. It works even if the plugin is already installed for your machine, and nothing is committed for you.';
|
|
30
30
|
|
|
31
|
+
const WEB_HOOK_DETAIL = 'It adds a SessionStart hook to .claude/settings.json of this repository, keeping what the file already has. In a session of Claude Code on the web it installs OpenSpec when it is missing (npm install --global @fission-ai/openspec); on a terminal it does nothing. The file is left for you to commit.';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `.claude/settings.json` with the hook that installs OpenSpec added to `SessionStart`, every other key and hook kept.
|
|
35
|
+
* Idempotent: text that already installs OpenSpec is returned as it is. Throws on text that is not a JSON object, so a
|
|
36
|
+
* broken file is never rewritten. `text` is null when the file does not exist.
|
|
37
|
+
*/
|
|
38
|
+
export function mergeWebHook(text) {
|
|
39
|
+
const settings = text === null ? {} : JSON.parse(text);
|
|
40
|
+
if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('.claude/settings.json is not a JSON object');
|
|
41
|
+
const current = settings.hooks?.SessionStart;
|
|
42
|
+
if (current !== undefined && !Array.isArray(current)) throw new Error('hooks.SessionStart in .claude/settings.json is not a list');
|
|
43
|
+
if ((current ?? []).some((e) => (e?.hooks ?? []).some((h) => typeof h?.command === 'string' && h.command.includes('@fission-ai/openspec')))) return text;
|
|
44
|
+
const hooks = { ...(settings.hooks ?? {}), SessionStart: [...(current ?? []), { hooks: [{ type: 'command', command: WEB_HOOK_COMMAND }] }] };
|
|
45
|
+
return `${JSON.stringify({ ...settings, hooks }, null, 2)}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
export function credentialLine(workflow, credential) {
|
|
32
49
|
if (credential !== 'subscription') return workflow;
|
|
33
50
|
if (!workflow.includes(API_KEY_LINE) || !workflow.includes(TOKEN_LINE)) throw new Error('the go workflow template has no credential lines to switch');
|
|
34
51
|
return workflow.replace(API_KEY_LINE, API_KEY_LINE.replace('anthropic_api_key', '# anthropic_api_key')).replace(TOKEN_LINE, TOKEN_LINE.replace('# claude_code_oauth_token', 'claude_code_oauth_token'));
|
|
35
52
|
}
|
|
36
53
|
|
|
54
|
+
export const DEFAULT_RUNNER = ['ubuntu-latest'];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The workflow with the runner of `runner.labels` in every `runs-on`. The templates run on `ubuntu-latest`, which is
|
|
58
|
+
* what an empty or default list leaves alone; a list of one label is written as that label.
|
|
59
|
+
*/
|
|
60
|
+
export function runnerLine(workflow, labels) {
|
|
61
|
+
const list = labels ?? DEFAULT_RUNNER;
|
|
62
|
+
if (list.length === 1 && list[0] === DEFAULT_RUNNER[0]) return workflow;
|
|
63
|
+
if (!workflow.includes('runs-on: ubuntu-latest')) throw new Error('the workflow template has no runs-on line to switch');
|
|
64
|
+
return workflow.replaceAll('runs-on: ubuntu-latest', `runs-on: ${list.length === 1 ? list[0] : `[${list.join(', ')}]`}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
37
67
|
/** The text of a `platforms:` block for { name: { stack, path, targets } }. */
|
|
38
68
|
export function platformsBlock(platforms) {
|
|
39
69
|
const lines = ['platforms:'];
|
|
@@ -96,6 +126,15 @@ export function renderConfig(template, answers = {}) {
|
|
|
96
126
|
if (answers.runMaxTokens !== undefined) out = set(out, 'run_max_tokens', answers.runMaxTokens);
|
|
97
127
|
} else if (answers.runMaxCost !== undefined) out = set(out, 'run_max_cost', answers.runMaxCost);
|
|
98
128
|
if (answers.language) out = set(out, 'language', answers.language);
|
|
129
|
+
// The commented example of the template becomes the setting; the default runner leaves the template as it is.
|
|
130
|
+
if (answers.runnerLabels && !(answers.runnerLabels.length === 1 && answers.runnerLabels[0] === DEFAULT_RUNNER[0])) {
|
|
131
|
+
const re = /^# runner:[^\n]*\n# labels:[^\n]*\n/m;
|
|
132
|
+
if (!re.test(out)) throw new Error('the template has no "runner" example to switch on');
|
|
133
|
+
out = out.replace(re, `runner: # where the Relevo workflows run (docs/execution.md)\n labels: [${answers.runnerLabels.join(', ')}]\n`);
|
|
134
|
+
}
|
|
135
|
+
// A browser review is always driven by the agent, so a Vue web platform cannot keep the autopilot off.
|
|
136
|
+
const autopilot = answers.autopilot ?? (Object.values(platforms ?? {}).some(isVueWeb) ? 'precheck' : null);
|
|
137
|
+
if (autopilot) out = set(out, 'autopilot', autopilot);
|
|
99
138
|
const check = loadConfig(out);
|
|
100
139
|
if (!check.ok) throw new Error(`the answers produce an invalid relevo.yml: ${check.errors.map((e) => e.message).join('; ')}`);
|
|
101
140
|
return out;
|
|
@@ -167,7 +206,7 @@ const item = (id, status, description, extra = {}) => ({ id, status, description
|
|
|
167
206
|
/**
|
|
168
207
|
* Plan every change. `ctx`:
|
|
169
208
|
* facts from gatherFacts (doctor)
|
|
170
|
-
* answers the person's answers (members, specWriter, reviewMode, language, platforms, links, runs, credential, stackProfiles, base, release, merger)
|
|
209
|
+
* answers the person's answers (members, specWriter, reviewMode, language, platforms, links, runs, credential, runnerLabels, stackProfiles, base, release, merger)
|
|
171
210
|
* templates { config: text, workflows: { file: text } }
|
|
172
211
|
* current { workflows: { file: text|null }, gitignore: text|null, keepFiles: { path: boolean } }
|
|
173
212
|
* admin true when the person has administrator permission on the repository
|
|
@@ -199,11 +238,30 @@ export function planInit({ facts, answers, templates, current, admin }) {
|
|
|
199
238
|
});
|
|
200
239
|
}
|
|
201
240
|
|
|
241
|
+
// What a browser review of each Vue platform needs: Playwright in its development dependencies, and its Chromium.
|
|
242
|
+
Object.entries(configuredPlatforms).filter(([, p]) => isVueWeb(p)).forEach(([name, p]) => {
|
|
243
|
+
const found = facts.vueWeb?.[name];
|
|
244
|
+
if (!found) return;
|
|
245
|
+
const cd = p.path && p.path !== '.' ? `cd ${p.path} && ` : '';
|
|
246
|
+
if (found.playwright) items.push(item(`install.playwright.${name}`, 'ok', `Playwright resolves from platform ${name}`));
|
|
247
|
+
else items.push(item(`install.playwright.${name}`, 'todo', `Add Playwright to the development dependencies of platform ${name}, which its browser review needs (package.json and the lock file are left for you to commit)`, { install: true, command: `${cd}npm install -D playwright`, detail: 'It changes package.json and the lock file of the platform: commit them on the base branch, so that every review finds Playwright. If you decline, a browser review of this platform stops until it is installed.' }));
|
|
248
|
+
if (found.playwright && found.chromium) items.push(item(`install.chromium.${name}`, 'ok', `The Chromium of Playwright is installed for platform ${name}`));
|
|
249
|
+
else items.push(item(`install.chromium.${name}`, 'todo', `Install the Chromium of Playwright for platform ${name}`, { install: true, command: `${cd}npx playwright install chromium`, detail: 'It downloads the browser to the cache of your user, outside the repository. If you decline, a browser review of this platform stops until it is installed.' }));
|
|
250
|
+
});
|
|
251
|
+
if (facts.config && (facts.config.review?.autopilot ?? 'off') === 'off' && Object.values(configuredPlatforms).some(isVueWeb)) {
|
|
252
|
+
items.push(item('review.autopilot', 'manual', 'A browser review of a Vue platform is driven by the agent: set review.autopilot to precheck (or verify) in relevo.yml'));
|
|
253
|
+
}
|
|
254
|
+
|
|
202
255
|
// the plugin, declared in the project so that a teammate who clones the repository is offered it
|
|
203
256
|
if (facts.pluginDeclared?.declared) items.push(item('install.plugin-project', 'ok', 'The repository declares the Relevo plugin (.claude/settings.json)'));
|
|
204
257
|
else if (facts.pluginDeclared?.unreadable) items.push(item('install.plugin-project', 'manual', '.claude/settings.json is not valid JSON: fix it, then run init again to declare the Relevo plugin in the project'));
|
|
205
258
|
else if (facts.pluginDeclared) items.push(item('install.plugin-project', 'todo', 'Declare the Relevo plugin in the project, so that a teammate who clones the repository is offered it (the new .claude/settings.json is left for you to commit)', { install: true, command: PLUGIN_PROJECT_COMMAND, detail: PLUGIN_PROJECT_DETAIL }));
|
|
206
259
|
|
|
260
|
+
// OpenSpec in the sessions of Claude Code on the web, where every new container starts without it
|
|
261
|
+
if (facts.webHook?.declared) items.push(item('install.web-hook', 'ok', 'The repository installs OpenSpec at the start of a web session (.claude/settings.json)'));
|
|
262
|
+
else if (facts.webHook?.unreadable) items.push(item('install.web-hook', 'manual', '.claude/settings.json is not valid JSON: fix it, then run init again to add the hook that installs OpenSpec in web sessions'));
|
|
263
|
+
else if (facts.webHook) items.push(item('install.web-hook', 'todo', 'Add a hook that installs OpenSpec at the start of a web session of Claude Code, where it is missing (the changed .claude/settings.json is left for you to commit)', { install: true, command: `node \${CLAUDE_PLUGIN_ROOT}/scripts/web-hook.mjs`, hook: WEB_HOOK_COMMAND, detail: WEB_HOOK_DETAIL }));
|
|
264
|
+
|
|
207
265
|
// relevo.yml
|
|
208
266
|
if (facts.configText !== null) items.push(item('config', facts.config ? 'ok' : 'differs', facts.config ? 'relevo.yml exists and is valid' : `relevo.yml exists but is invalid: ${facts.configErrors.join('; ')}`));
|
|
209
267
|
else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
|
|
@@ -248,11 +306,13 @@ export function planInit({ facts, answers, templates, current, admin }) {
|
|
|
248
306
|
if (!CREDENTIALS.includes(credential)) throw new Error(`credential must be one of ${CREDENTIALS.join(', ')} (got "${credential}")`);
|
|
249
307
|
const build = answers.agentBuild ?? 'claude';
|
|
250
308
|
if (!AGENT_BUILDS.includes(build)) throw new Error(`agentBuild must be one of ${AGENT_BUILDS.join(', ')} (got "${build}")`);
|
|
309
|
+
// The runner of the workflows: the setting of the repository, else the person's answer, else GitHub's.
|
|
310
|
+
const runnerLabels = facts.config?.runner?.labels ?? answers.runnerLabels ?? DEFAULT_RUNNER;
|
|
251
311
|
const cloudFiles = ['relevo-go.yml', 'relevo-queue.yml'];
|
|
252
312
|
WORKFLOW_FILES.forEach((file) => {
|
|
253
313
|
if (runs === 'local' && cloudFiles.includes(file)) return;
|
|
254
314
|
const shared = file === 'relevo-go.yml' ? credentialLine(templates.workflows[file], credential) : templates.workflows[file];
|
|
255
|
-
const wanted = agentWorkflow(shared, build, templates.agentBlocks);
|
|
315
|
+
const wanted = runnerLine(agentWorkflow(shared, build, templates.agentBlocks), runnerLabels);
|
|
256
316
|
const have = current.workflows[file] ?? null;
|
|
257
317
|
if (have === null) items.push(item(`workflow.${file}`, 'todo', `Add .github/workflows/${file}`, { path: `.github/workflows/${file}`, content: wanted }));
|
|
258
318
|
else if (have === wanted) items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date`));
|
package/scripts/lib/labels.mjs
CHANGED
|
@@ -17,6 +17,10 @@ export const LABELS = [
|
|
|
17
17
|
{ key: 'qa-failed', name: 'relevo:qa-failed', color: 'e11d21', kind: 'exit', description: 'QA still rejected after the retries; branch is pushed' },
|
|
18
18
|
{ key: 'changes-requested', name: 'relevo:changes-requested', color: 'f9d0c4', kind: 'exit', description: 'The reviewer asked for changes' },
|
|
19
19
|
{ key: 'needs-reviewer', name: 'relevo:needs-reviewer', color: 'fef2c0', kind: 'overlay', description: 'No reviewer available or the review is overdue' },
|
|
20
|
+
// A request, not a state: the ship workflow merges the pull request and always removes it.
|
|
21
|
+
{ key: 'ship', name: 'relevo:ship', color: '2ea44f', kind: 'overlay', description: 'Ship it: merge the approved pull request on GitHub' },
|
|
22
|
+
// A request, not a state: start it before the others and without waiting for the assignee's review capacity.
|
|
23
|
+
{ key: 'urgent', name: 'relevo:urgent', color: 'ff7b00', kind: 'overlay', description: 'Start it first, even if the assignee has built issues waiting for review' },
|
|
20
24
|
];
|
|
21
25
|
|
|
22
26
|
export const LABEL_KEYS = LABELS.map((l) => l.key);
|