@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.
Files changed (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.es.md +1 -0
  3. package/README.md +1 -0
  4. package/package.json +1 -1
  5. package/scripts/doctor.mjs +5 -4
  6. package/scripts/init.mjs +25 -4
  7. package/scripts/lib/can-edit-issue.mjs +4 -1
  8. package/scripts/lib/cli.mjs +6 -4
  9. package/scripts/lib/config.mjs +19 -1
  10. package/scripts/lib/doctor.mjs +264 -25
  11. package/scripts/lib/github.mjs +15 -5
  12. package/scripts/lib/init.mjs +89 -6
  13. package/scripts/lib/labels.mjs +4 -0
  14. package/scripts/lib/messages.mjs +109 -0
  15. package/scripts/lib/next-step.mjs +12 -0
  16. package/scripts/lib/plan.mjs +29 -6
  17. package/scripts/lib/preflight.mjs +1 -1
  18. package/scripts/lib/review-events.mjs +6 -3
  19. package/scripts/lib/ship-github.mjs +87 -0
  20. package/scripts/lib/ship-unattended.mjs +271 -0
  21. package/scripts/lib/ship.mjs +15 -3
  22. package/scripts/lib/toolchain-check.mjs +6 -0
  23. package/scripts/lib/web-preflight.mjs +50 -4
  24. package/scripts/lib/workflow-upgrade.mjs +198 -0
  25. package/scripts/review-event.mjs +10 -1
  26. package/scripts/ship-check.mjs +6 -4
  27. package/scripts/ship-unattended.mjs +45 -0
  28. package/scripts/toolchain-check.mjs +3 -3
  29. package/scripts/web-hook.mjs +28 -0
  30. package/scripts/web-preflight.mjs +6 -5
  31. package/scripts/web-review.mjs +1 -1
  32. package/skills/doctor/SKILL.md +6 -5
  33. package/skills/go/SKILL.md +4 -2
  34. package/skills/init/SKILL.md +10 -4
  35. package/skills/review/SKILL.md +4 -3
  36. package/skills/ship/SKILL.md +3 -1
  37. package/stacks/vue.md +2 -2
  38. package/templates/agents/antigravity-step.yml +8 -5
  39. package/templates/relevo.yml +4 -0
  40. package/templates/workflows/relevo-digest.yml +11 -1
  41. package/templates/workflows/relevo-go.yml +33 -14
  42. package/templates/workflows/relevo-queue.yml +11 -1
  43. package/templates/workflows/relevo-review.yml +11 -1
  44. package/templates/workflows/relevo-ship.yml +136 -0
  45. package/templates/workflows/relevo-sla.yml +11 -1
@@ -0,0 +1,271 @@
1
+ // The ship that the `relevo:ship` label starts on GitHub: the steps of /relevo:ship, run by a workflow with no model.
2
+ // Implements "A label starts an unattended ship" and the requirements after it in openspec/specs/ship/spec.md.
3
+ //
4
+ // Everything that touches the outside is injected, so the whole sequence is testable without a network:
5
+ // sh(cmd, args, { cwd, input }) -> { status, stdout, stderr } git, openspec and the plugin's scripts
6
+ // gh(args) -> stdout reads, comments and labels (workflow token)
7
+ // pushGh(args) -> stdout merge and close (App token, or the workflow token)
8
+ // sleep(ms), now() waiting for the checks of the archive commit
9
+ // Nothing from the feature branch is executed: git runs without hooks, and the scripts come from `pluginRoot`.
10
+
11
+ import { existsSync, readdirSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { loadConfig } from './config.mjs';
14
+ import { clearAll, resolveLabelNames } from './labels.mjs';
15
+ import { renderMessage } from './messages.mjs';
16
+ import { shipDecision } from './ship.mjs';
17
+ import { baselineChecks, loadPr, requiredChecks } from './ship-github.mjs';
18
+
19
+ const ARCHIVE_SUBJECT = 'chore(openspec): archive ';
20
+ const CONFLICT_REASON = /has conflicts/;
21
+
22
+ const NOTES = {
23
+ baseline: {
24
+ en: (sha) => `The archive commit changes only openspec/ and had no checks of its own, so the checks of the approved head ${sha} were used.`,
25
+ es: (sha) => `El commit del archivo solo cambia openspec/ y no tuvo checks propios, así que se usaron los checks del head aprobado ${sha}.`,
26
+ },
27
+ none: {
28
+ en: () => 'No check ran on this code: only the review vouched for it (review.ship_without_ci is true).',
29
+ es: () => 'No corrió ningún check sobre este código: solo la revisión lo respalda (review.ship_without_ci es true).',
30
+ },
31
+ };
32
+
33
+ /**
34
+ * What a label cannot do that a person in a session can. Returns null when the label may ship, or the message to post.
35
+ * - a decision that stops, stops;
36
+ * - an eligible person who did not approve must say so in writing, which a label cannot;
37
+ * - no checks at all need the person's word, unless review.ship_without_ci says the label is enough.
38
+ */
39
+ export function labelRules({ decision, config }) {
40
+ if (!decision.allowed) return { kind: 'ship_stopped', data: { reasons: decision.reasons.join('; ') } };
41
+ if (decision.confirm.includes('not-approver')) {
42
+ return { kind: 'ship_not_approver', data: { approvers: decision.summary.approvedBy.map((l) => `@${l}`).join(', ') } };
43
+ }
44
+ if (decision.confirm.includes('no-ci') && !config.review.ship_without_ci) return { kind: 'ship_no_ci', data: {} };
45
+ return null;
46
+ }
47
+
48
+ class Stop {
49
+ constructor(kind, data = {}) {
50
+ this.kind = kind;
51
+ this.data = data;
52
+ }
53
+ }
54
+ const stopWith = (kind, data) => {
55
+ throw new Stop(kind, data);
56
+ };
57
+ const stopReasons = (...reasons) => stopWith('ship_stopped', { reasons: reasons.join('; ') });
58
+ const firstLines = (text, n = 6) => String(text ?? '').trim().split('\n').slice(0, n).join(' / ');
59
+
60
+ /** The change folder of the issue on the branch, and whether it is still to be archived. */
61
+ export function findChange(workDir, issue) {
62
+ const changes = join(workDir, 'openspec', 'changes');
63
+ const prefix = `${issue}-`;
64
+ const active = existsSync(changes) ? readdirSync(changes).filter((n) => n.startsWith(prefix) && n !== 'archive') : [];
65
+ if (active.length === 1) return { name: active[0], state: 'active' };
66
+ if (active.length > 1) return { name: null, state: 'ambiguous', found: active };
67
+ const archiveDir = join(changes, 'archive');
68
+ const dated = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-(${issue}-.+)$`);
69
+ const archived = existsSync(archiveDir) ? readdirSync(archiveDir).map((n) => n.match(dated)?.[1]).filter(Boolean) : [];
70
+ if (archived.length === 1) return { name: archived[0], state: 'archived' };
71
+ return { name: null, state: archived.length > 1 ? 'ambiguous' : 'missing', found: archived };
72
+ }
73
+
74
+ export async function runUnattendedShip(ctx) {
75
+ const { repo, issue, actor, actorType, hasApp, workDir, pluginRoot, sh, gh, pushGh } = ctx;
76
+ const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
77
+ const now = ctx.now ?? (() => Date.now());
78
+ const waitLimitMs = ctx.waitLimitMs ?? 30 * 60 * 1000;
79
+ const settleMs = ctx.settleMs ?? 3 * 60 * 1000;
80
+ const pollMs = ctx.pollMs ?? 20 * 1000;
81
+ const headPollMs = ctx.headPollMs ?? 10 * 1000;
82
+ const n = String(issue);
83
+
84
+ const run = (cmd, args, opts = {}) => sh(cmd, args, { cwd: workDir, ...opts });
85
+ const git = (args, opts = {}) => {
86
+ const r = run('git', ['-c', 'core.hooksPath=/dev/null', ...args], opts);
87
+ if (r.status !== 0 && !opts.allowFail) stopReasons(`git ${args[0]} failed: ${firstLines(r.stderr || r.stdout)}`);
88
+ return r;
89
+ };
90
+ const script = (name, args, opts = {}) => run(process.execPath, [join(pluginRoot, 'scripts', name), ...args], opts);
91
+ const openspec = (args) => run('openspec', args, { env: { OPENSPEC_TELEMETRY: '0' } });
92
+
93
+ // The configuration of the default branch only names the base branch; the one that applies is the base branch's.
94
+ const readConfig = (text, where) => {
95
+ const loaded = loadConfig(text);
96
+ if (!loaded.ok) stopReasons(`relevo.yml on ${where} is not valid: ${loaded.errors.map((e) => e.message).join('; ')}`);
97
+ return loaded.config;
98
+ };
99
+
100
+ let config = loadConfig('limits:\n run_max_cost: 1\n').config;
101
+ let names = resolveLabelNames({});
102
+ const removeShipLabel = () => {
103
+ try {
104
+ gh(['issue', 'edit', n, '--repo', repo, '--remove-label', names.ship]);
105
+ } catch {
106
+ // The label may already be gone; the comment still says what happened.
107
+ }
108
+ };
109
+
110
+ if (actorType !== 'User') {
111
+ removeShipLabel();
112
+ return { outcome: 'ignored', reason: 'the label was not applied by a person' };
113
+ }
114
+
115
+ try {
116
+ const fromDefault = run('git', ['show', 'HEAD:relevo.yml']);
117
+ if (fromDefault.status !== 0) stopReasons('relevo.yml not found on the default branch: run /relevo:init');
118
+ const base = readConfig(fromDefault.stdout, 'the default branch').branches.base;
119
+ git(['fetch', '--no-tags', 'origin', base]);
120
+ const fromBase = run('git', ['show', `origin/${base}:relevo.yml`]);
121
+ if (fromBase.status !== 0) stopReasons(`relevo.yml not found on ${base}`);
122
+ config = readConfig(fromBase.stdout, base);
123
+ names = resolveLabelNames(config.labels);
124
+ const language = config.language;
125
+
126
+ const issueData = JSON.parse(gh(['issue', 'view', n, '--repo', repo, '--json', 'state,labels']));
127
+ const labels = issueData.labels.map((l) => l.name);
128
+ if (issueData.state !== 'OPEN') stopReasons('the issue is closed');
129
+
130
+ // The issue's branch, looked up on the remote: never a branch named by the event.
131
+ const heads = git(['ls-remote', '--heads', 'origin']).stdout.split('\n')
132
+ .map((l) => l.split('\t')[1]?.replace('refs/heads/', ''))
133
+ .filter((b) => b && b.startsWith(`feature/${n}-`));
134
+ if (heads.length === 0) stopReasons(`there is no branch feature/${n}-* on the remote`);
135
+ if (heads.length > 1) stopReasons(`more than one branch matches feature/${n}-*: ${heads.join(', ')}`);
136
+ const branch = heads[0];
137
+ git(['fetch', '--no-tags', 'origin', branch]);
138
+ git(['switch', '--force-create', branch, `origin/${branch}`]);
139
+ const head = () => git(['rev-parse', 'HEAD']).stdout.trim();
140
+
141
+ const change = findChange(workDir, n);
142
+ if (change.state === 'missing') stopReasons(`no change folder openspec/changes/${n}-* on ${branch}`);
143
+ if (change.state === 'ambiguous') stopReasons(`more than one change folder for #${n}: ${change.found.join(', ')}`);
144
+
145
+ // The head the approval was given on: the current one, or the one before the archive when a previous run archived.
146
+ const subject = (ref) => git(['log', '-1', '--format=%s', ref]).stdout.trim();
147
+ let baseline = head();
148
+ if (change.state === 'archived') baseline = subject('HEAD').startsWith(ARCHIVE_SUBJECT) ? git(['rev-parse', 'HEAD^']).stdout.trim() : undefined;
149
+
150
+ const decide = (pr, withBaseline) => shipDecision({
151
+ config, pr, invoker: actor, labels, issue: n,
152
+ baseline: withBaseline,
153
+ baselineChecks: withBaseline ? baselineChecks(gh, repo, withBaseline, pr?.headRefOid) : undefined,
154
+ });
155
+ // GitHub takes a moment to see a push on the pull request; a head that never matches means someone else pushed.
156
+ const check = async (withBaseline) => {
157
+ const local = head();
158
+ let pr = loadPr(gh, repo, branch);
159
+ for (let tries = 0; pr && pr.headRefOid !== local && tries < 6; tries += 1) {
160
+ await sleep(headPollMs);
161
+ pr = loadPr(gh, repo, branch);
162
+ }
163
+ if (pr && pr.headRefOid !== local) stopReasons('the branch moved while shipping: apply the label again');
164
+ return { pr, decision: decide(pr, withBaseline) };
165
+ };
166
+
167
+ let { pr, decision } = await check(baseline);
168
+ const rule = labelRules({ decision, config });
169
+ if (rule) stopWith(rule.kind, rule.data);
170
+
171
+ const required = requiredChecks(gh, repo, base);
172
+ if (required.required && !hasApp) stopWith('ship_needs_app', { base });
173
+
174
+ const diff = git(['diff', `origin/${base}...HEAD`]).stdout;
175
+ const scan = script('scan-diff.mjs', [], { input: diff });
176
+ if (scan.status !== 0) stopReasons(`the diff has findings: ${firstLines(scan.stdout || scan.stderr)}`);
177
+
178
+ const archive = () => {
179
+ const steps = [
180
+ () => openspec(['validate', change.name]),
181
+ () => script('fix-tasks.mjs', ['--file', `openspec/changes/${change.name}/tasks.md`, '--stage', 'after']),
182
+ () => openspec(['archive', change.name, '-y']),
183
+ () => script('check-archive.mjs', []),
184
+ () => script('stage.mjs', ['openspec/changes', 'openspec/specs']),
185
+ ];
186
+ steps.forEach((step) => {
187
+ const r = step();
188
+ if (r.status !== 0) stopReasons(`the archive stopped: ${firstLines(r.stdout || r.stderr)}`);
189
+ });
190
+ git(['commit', '-m', `${ARCHIVE_SUBJECT}${change.name} (#${n})`]);
191
+ git(['push', 'origin', `HEAD:refs/heads/${branch}`]);
192
+ };
193
+
194
+ let pushed = false;
195
+ if (change.state === 'active') {
196
+ archive();
197
+ pushed = true;
198
+ }
199
+
200
+ // With the App, the push started the project's checks: wait for them, for a bounded time.
201
+ if (pushed && hasApp) {
202
+ const started = now();
203
+ for (;;) {
204
+ const current = loadPr(gh, repo, branch);
205
+ const rollup = current?.statusCheckRollup ?? [];
206
+ const running = rollup.some((c) => (c.__typename === 'StatusContext' ? !['SUCCESS', 'FAILURE', 'ERROR'].includes(c.state) : c.status !== 'COMPLETED'));
207
+ if (rollup.length > 0 && !running) break;
208
+ if (rollup.length === 0 && now() - started >= settleMs) break;
209
+ if (now() - started >= waitLimitMs) {
210
+ stopReasons(`the checks of the archive commit did not finish within ${Math.round(waitLimitMs / 60000)} minutes; apply the label again: the specification is already archived, so it resumes from the merge`);
211
+ }
212
+ await sleep(pollMs);
213
+ }
214
+ }
215
+
216
+ ({ pr, decision } = await check(baseline));
217
+ if (!decision.allowed && decision.reasons.length === 1 && CONFLICT_REASON.test(decision.reasons[0]) && subject('HEAD').startsWith(ARCHIVE_SUBJECT)) {
218
+ // The base branch changed the specifications the archive wrote: undo the archive, bring in the base, archive again.
219
+ git(['revert', '--no-edit', 'HEAD']);
220
+ const merge = git(['merge', '--no-edit', `origin/${base}`], { allowFail: true });
221
+ if (merge.status !== 0) {
222
+ const scope = script('conflict-scope.mjs', []);
223
+ git(['merge', '--abort'], { allowFail: true });
224
+ if (scope.status !== 0) stopReasons(`bringing in ${base} conflicts in code, which is for the author: ${firstLines(scope.stdout)}`);
225
+ stopReasons(`bringing in ${base} conflicts inside openspec/: run /relevo:ship ${n} to resolve it in a session`);
226
+ }
227
+ archive();
228
+ // The head now holds more than the archive, so the approval must be on it.
229
+ ({ pr, decision } = await check(undefined));
230
+ }
231
+ const again = labelRules({ decision, config });
232
+ if (again) stopWith(again.kind, again.data);
233
+
234
+ const merged = head();
235
+ try {
236
+ pushGh(['pr', 'merge', String(pr.number), '--repo', repo, '--squash', '--delete-branch', '--subject', pr.title, '--body', `Issue #${n}`, '--match-head-commit', merged]);
237
+ } catch (err) {
238
+ stopReasons(`GitHub refused the merge: ${firstLines(err.stderr || err.message)}`);
239
+ }
240
+
241
+ const after = JSON.parse(gh(['issue', 'view', n, '--repo', repo, '--json', 'state,labels']));
242
+ if (after.state === 'OPEN') pushGh(['issue', 'close', n, '--repo', repo, '--reason', 'completed']);
243
+ const { remove } = clearAll(after.labels.map((l) => l.name), config.labels);
244
+ if (remove.length > 0) pushGh(['issue', 'edit', n, '--repo', repo, ...remove.flatMap((l) => ['--remove-label', l])]);
245
+
246
+ let commit = merged.slice(0, 7);
247
+ try {
248
+ commit = (gh(['pr', 'view', String(pr.number), '--repo', repo, '--json', 'mergeCommit', '--jq', '.mergeCommit.oid']).trim() || merged).slice(0, 7);
249
+ } catch {
250
+ // The short head is close enough for the report.
251
+ }
252
+ const lang = language === 'es' ? 'es' : 'en';
253
+ const note = decision.summary.checksFrom === 'baseline' ? NOTES.baseline[lang](String(baseline).slice(0, 7)) : decision.summary.checks === 'none' ? NOTES.none[lang]() : '';
254
+ gh(['issue', 'comment', n, '--repo', repo, '--body', renderMessage('ship_done', { issue: n, pr: `#${pr.number}`, base, commit, actor, checks_note: note }, language)]);
255
+
256
+ // A close made with the workflow token starts no workflow: tell the queue, so dependent issues are unblocked now.
257
+ if (!hasApp) {
258
+ try {
259
+ gh(['workflow', 'run', 'relevo-queue.yml', '--repo', repo]);
260
+ } catch {
261
+ // No queue workflow: nothing waits on it.
262
+ }
263
+ }
264
+ return { outcome: 'merged', pr: pr.number, commit };
265
+ } catch (err) {
266
+ if (!(err instanceof Stop)) throw err;
267
+ removeShipLabel();
268
+ gh(['issue', 'comment', n, '--repo', repo, '--body', renderMessage(err.kind, { actor, issue: n, ...err.data }, config.language)]);
269
+ return { outcome: 'stopped', kind: err.kind, ...err.data };
270
+ }
271
+ }
@@ -67,11 +67,13 @@ export function checkRollup(rollup = []) {
67
67
  * baseline optional: the head commit recorded when ship started; an approval given on it still counts
68
68
  * labels optional: the names of the issue's labels; when given, the issue must be pr-open
69
69
  * issue optional: the issue number, used in the messages about the state
70
+ * baselineChecks optional: { archiveOnly, rollup } of the baseline. When the head differs from it only under
71
+ * `openspec/` (archiveOnly) and the head has no checks of its own, the baseline's checks are judged.
70
72
  * Returns { allowed, reasons, confirm, summary }.
71
73
  * reasons why it must stop (empty when allowed)
72
74
  * confirm what the person must still confirm in writing, always including "merge"
73
75
  */
74
- export function shipDecision({ config, pr, invoker, baseline, labels, issue }) {
76
+ export function shipDecision({ config, pr, invoker, baseline, labels, issue, baselineChecks }) {
75
77
  const reasons = [];
76
78
  const warnings = [];
77
79
  const confirm = ['merge'];
@@ -95,7 +97,17 @@ export function shipDecision({ config, pr, invoker, baseline, labels, issue }) {
95
97
  if (pr.mergeable === 'CONFLICTING') reasons.push('the pull request has conflicts: the author must synchronise the branch with the base branch');
96
98
  else if (pr.mergeable !== 'MERGEABLE') reasons.push('GitHub has not finished checking whether the pull request can be merged: try again in a minute');
97
99
 
98
- const checks = checkRollup(pr.statusCheckRollup);
100
+ // A push with the workflow token starts no workflows, so the archive commit may have no checks: the checks of the
101
+ // approved head still vouch for the code when only the specifications changed since.
102
+ let checks = checkRollup(pr.statusCheckRollup);
103
+ let checksFrom = 'head';
104
+ if (checks.state === 'none' && baselineChecks?.archiveOnly) {
105
+ const carried = checkRollup(baselineChecks.rollup);
106
+ if (carried.state !== 'none') {
107
+ checks = carried;
108
+ checksFrom = 'baseline';
109
+ }
110
+ }
99
111
  if (checks.state === 'failing') reasons.push(`checks are failing: ${checks.failing.join(', ')}`);
100
112
  else if (checks.state === 'pending') reasons.push(`checks are still running: ${checks.pending.join(', ')}`);
101
113
  else if (checks.state === 'none') confirm.push('no-ci');
@@ -156,6 +168,6 @@ export function shipDecision({ config, pr, invoker, baseline, labels, issue }) {
156
168
  allowed: reasons.length === 0,
157
169
  reasons,
158
170
  confirm,
159
- summary: { pr: pr.number, title: pr.title, author, branch: pr.headRefName, base: pr.baseRefName, mode, approvedBy, checks: checks.state, ...(warnings.length > 0 ? { warnings } : {}) },
171
+ summary: { pr: pr.number, title: pr.title, author, branch: pr.headRefName, base: pr.baseRefName, head: pr.headRefOid ?? null, mode, approvedBy, checks: checks.state, checksFrom, ...(warnings.length > 0 ? { warnings } : {}) },
160
172
  };
161
173
  }
@@ -1,6 +1,12 @@
1
1
  // Which of the tools that a profile lists are not on the path. `probe(tool)` says whether one answers; it is injected
2
2
  // so that a test does not need the tools. An empty list checks nothing.
3
3
 
4
+ /**
5
+ * What every Relevo job needs on the runner whatever the stack: a GitHub-hosted runner has them, a self-hosted one may not.
6
+ * They are checked with the tools of the profile, so a runner without one stops before the agent, like one without Flutter.
7
+ */
8
+ export const RUNNER_TOOLS = ['git', 'gh', 'jq', 'node'];
9
+
4
10
  /** The tools that are missing, in the order of the profile and without repeats. */
5
11
  export function missingTools(tools, probe) {
6
12
  const seen = new Set();
@@ -2,7 +2,14 @@
2
2
  // address under review with the module scripts its page loads. Everything that touches the machine is injected.
3
3
  // Implements "A browser review checks its tools and the app before driving" of openspec/specs/review/spec.md.
4
4
 
5
- export const EXIT = { node_modules: 3, playwright: 4, browser: 5, url: 6 };
5
+ export const EXIT = { node_modules: 3, playwright: 4, browser: 5, url: 6, deps_stale: 7 };
6
+
7
+ // The frozen install of each lock file, first match wins. None runs package scripts where the tool can skip them.
8
+ const LOCKS = [
9
+ { file: 'package-lock.json', install: 'npm ci --ignore-scripts' },
10
+ { file: 'pnpm-lock.yaml', install: 'pnpm install --frozen-lockfile --ignore-scripts' },
11
+ { file: 'yarn.lock', install: 'yarn install --immutable' },
12
+ ];
6
13
 
7
14
  const START_SCRIPTS = ['dev', 'serve', 'start'];
8
15
 
@@ -34,6 +41,38 @@ export function moduleScripts(html, pageUrl) {
34
41
  .map((u) => u.href);
35
42
  }
36
43
 
44
+ /** The lock file of the folder and its frozen install command; null when the folder has none. */
45
+ export function lockOf(cwd, exists) {
46
+ return LOCKS.find((l) => exists(`${cwd}/${l.file}`)) ?? null;
47
+ }
48
+
49
+ const parse = (text) => {
50
+ try {
51
+ return JSON.parse(text);
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
57
+ /**
58
+ * Do the installed dependencies differ from package-lock.json? Compares each package of the lock file with the one
59
+ * npm recorded in node_modules/.package-lock.json. An optional package that is not installed (a binary for another
60
+ * platform) is not a difference. Returns true, false, or null when it cannot tell (never treated as stale).
61
+ */
62
+ export function npmStale(lockText, hiddenText) {
63
+ const lock = parse(lockText)?.packages;
64
+ if (!lock || typeof lock !== 'object') return null;
65
+ if (hiddenText === null) return true;
66
+ const hidden = parse(hiddenText)?.packages;
67
+ if (!hidden || typeof hidden !== 'object') return null;
68
+ return Object.entries(lock).some(([key, wanted]) => {
69
+ if (key === '') return false;
70
+ const installed = hidden[key];
71
+ if (!installed) return !(wanted?.optional || wanted?.devOptional);
72
+ return wanted?.version !== undefined && installed.version !== wanted.version;
73
+ });
74
+ }
75
+
37
76
  /** Does the page answer below 400 after redirects, and so do its module scripts? Returns { ok, detail }. */
38
77
  export async function checkUrl(url, { fetch, timeout = 5000 }) {
39
78
  const get = async (address) => {
@@ -56,7 +95,8 @@ export async function checkUrl(url, { fetch, timeout = 5000 }) {
56
95
  }
57
96
 
58
97
  /**
59
- * Runs the checks in order and stops at the first failure. Returns { ok, check, detail, start, env, code }.
98
+ * Runs the checks in order and stops at the first failure. Returns { ok, check, detail, start, env, install, code }:
99
+ * `install` is the frozen install command of the folder's lock file, or null without one.
60
100
  * deps: exists(path), readFile(path) (null when missing), loadPlaywright(), fetch, sleep(ms), now().
61
101
  * Only `.env.example` is read for variable names; the real environment files never are.
62
102
  * With `wait`, the address is retried every 500 ms until it answers or the time is over.
@@ -66,9 +106,15 @@ export async function webPreflight({ cwd, url, timeout = 5000, wait = 0 }, deps)
66
106
  const at = (name) => `${cwd}/${name}`;
67
107
  const start = startCommand(readFile(at('package.json')));
68
108
  const env = envNames(readFile(at('.env.example')));
69
- const result = (check, detail) => ({ ok: check === null, check, detail, start, env, code: check === null ? 0 : EXIT[check] });
109
+ const lock = lockOf(cwd, exists);
110
+ const install = lock?.install ?? null;
111
+ const result = (check, detail) => ({ ok: check === null, check, detail, start, env, install, code: check === null ? 0 : EXIT[check] });
70
112
 
71
- if (!exists(at('node_modules'))) return result('node_modules', 'the project has no node_modules: run npm install first');
113
+ if (!exists(at('node_modules'))) return result('node_modules', lock ? 'the project has no node_modules' : 'the project has no node_modules and no lock file to install from');
114
+ // Only npm records what it installed in a form comparable with its lock file; pnpm and yarn are checked for presence.
115
+ if (lock?.file === 'package-lock.json' && npmStale(readFile(at('package-lock.json')), readFile(at('node_modules/.package-lock.json'))) === true) {
116
+ return result('deps_stale', 'the installed dependencies do not match package-lock.json');
117
+ }
72
118
  const playwright = loadPlaywright();
73
119
  if (!playwright) return result('playwright', 'Playwright does not resolve from the project');
74
120
  let executable = null;
@@ -0,0 +1,198 @@
1
+ // Updates a workflow of a repository to the current template by a three-way merge. Implements the requirements "Init
2
+ // updates a workflow by a three-way merge", "A merged workflow is written only when clean and confirmed" and "The pin and
3
+ // the stamp are set, not merged" of openspec/specs/init-doctor/spec.md.
4
+ //
5
+ // base the published template closest to the file (the pin is no guide: a person can raise it by hand)
6
+ // ours the file as it is
7
+ // theirs the current template, rendered with the runner and the agent of the repository, so that a person's runner
8
+ // or agent block is the same on both sides and is not reported as a change
9
+ //
10
+ // The pin and the stamp are not merged: both are set to the base's value in the file before the merge (otherwise the
11
+ // person's own pin conflicts with every release) and to the current value after it. Nothing here writes a workflow: the
12
+ // caller decides, and a conflict gives back text that must not be written.
13
+
14
+ import { execFileSync } from 'node:child_process';
15
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { agentWorkflow } from './agent-workflow.mjs';
19
+ import { runnerLine } from './init.mjs';
20
+
21
+ const PIN_RE = /^(\s*RELEVO_VERSION:\s*)(['"]?)([\w.+-]+)\2[ \t]*$/m;
22
+ const STAMP_RE = /^# relevo-template: (\d+\.\d+\.\d+)[ \t]*$/m;
23
+ const MARKERS = ['credential', 'agent-step'];
24
+
25
+ export const pinOf = (text) => PIN_RE.exec(text)?.[3] ?? null;
26
+ export const stampOf = (text) => STAMP_RE.exec(text)?.[1] ?? null;
27
+ const setPin = (text, version) => text.replace(PIN_RE, (_, head, quote) => `${head}${quote}${version}${quote}`);
28
+ const setStamp = (text, version) => text.replace(STAMP_RE, `# relevo-template: ${version}`);
29
+
30
+ /** How many lines two workflows do not share (as multisets), with the pin and the stamp set aside. */
31
+ export function distance(a, b) {
32
+ const lines = (t) => setStamp(setPin(t, 'X'), 'X').split('\n');
33
+ const count = new Map();
34
+ lines(a).forEach((l) => count.set(l, (count.get(l) ?? 0) + 1));
35
+ lines(b).forEach((l) => count.set(l, (count.get(l) ?? 0) - 1));
36
+ return [...count.values()].reduce((sum, n) => sum + Math.abs(n), 0);
37
+ }
38
+
39
+ /**
40
+ * The candidate whose text is closest to the file; the newest among equals. `from` names the base instead. null when no
41
+ * candidate is close enough: more than half of the lines of the file different is not a version of this template.
42
+ */
43
+ export function chooseBase(file, candidates, { from = null } = {}) {
44
+ if (from !== null) {
45
+ const named = candidates.find((c) => c.version === from);
46
+ return named ? { ...named, distance: distance(file, named.rendered) } : null;
47
+ }
48
+ const ranked = candidates.map((c) => ({ ...c, distance: distance(file, c.rendered) }))
49
+ .sort((a, b) => a.distance - b.distance || compareVersions(b.version, a.version));
50
+ const limit = Math.max(20, Math.floor(file.split('\n').length / 2));
51
+ return ranked.length > 0 && ranked[0].distance <= limit ? ranked[0] : null;
52
+ }
53
+
54
+ const compareVersions = (a, b) => a.split('.').map(Number).reduce((r, n, i) => r || n - Number(b.split('.')[i]), 0);
55
+
56
+ /** `git merge-file -p`: the exit status is the number of conflicts (1 to 127); anything else is an error. */
57
+ export function gitMergeFile(ours, base, theirs) {
58
+ const dir = mkdtempSync(join(tmpdir(), 'relevo-merge-'));
59
+ try {
60
+ const paths = [['ours', ours], ['base', base], ['theirs', theirs]].map(([name, text]) => {
61
+ const path = join(dir, name);
62
+ writeFileSync(path, text);
63
+ return path;
64
+ });
65
+ try {
66
+ return { status: 0, stdout: execFileSync('git', ['merge-file', '-p', ...paths], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) };
67
+ } catch (err) {
68
+ return { status: err.status ?? 255, stdout: String(err.stdout ?? '') };
69
+ }
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ /** The blocks of conflict of a merged text: [{ yours, theirs }]. */
76
+ export function conflictsOf(text) {
77
+ const out = [];
78
+ const re = /^<<<<<<<[^\n]*\n([\s\S]*?)^=======\n([\s\S]*?)^>>>>>>>[^\n]*\n/gm;
79
+ let m;
80
+ while ((m = re.exec(text)) !== null) out.push({ yours: m[1].trimEnd(), theirs: m[2].trimEnd() });
81
+ return out;
82
+ }
83
+
84
+ /** A merge that lost what other code depends on is not safe to write, whatever git says. */
85
+ function structureProblem(merged, current) {
86
+ if ((merged.match(/^\s*RELEVO_VERSION:/gm) ?? []).length !== 1) return 'it does not have exactly one RELEVO_VERSION';
87
+ if (stampOf(current) !== null && (merged.match(/^# relevo-template:/gm) ?? []).length !== 1) return 'it does not have exactly one stamp';
88
+ for (const name of MARKERS) {
89
+ const count = (t, kind) => (t.match(new RegExp(`^\\s*# relevo:${name} ${kind}\\s*$`, 'gm')) ?? []).length;
90
+ for (const kind of ['begin', 'end']) {
91
+ if (count(merged, kind) !== count(current, kind)) return `the block "${name}" (${kind}) is not as in the template`;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * The state of one workflow against the current template.
99
+ * { status: 'current' } nothing to update
100
+ * { status: 'update', from, to, text } a clean merge; `text` is what to write
101
+ * { status: 'conflict', from, conflicts } lines both sides changed, or a merge that is not safe; write nothing
102
+ * { status: 'nobase' } no published template is close to the file
103
+ * { status: 'error', message } git failed
104
+ * `candidates` are [{ version, template, blocks }] (the raw template of a published version and its agent blocks, or
105
+ * null). `render` turns a raw template into what `init` would write for this repository.
106
+ */
107
+ export function upgradeWorkflow({ ours, current, candidates, from = null, render, merge = gitMergeFile }) {
108
+ const theirs = render(current.template, current.blocks);
109
+ const rendered = candidates.map((c) => ({ ...c, rendered: render(c.template, c.blocks) }));
110
+ const base = chooseBase(ours, rendered, { from });
111
+ if (!base) return { status: 'nobase' };
112
+
113
+ let prepared = ours;
114
+ if (pinOf(prepared) !== null && pinOf(base.rendered) !== null) prepared = setPin(prepared, pinOf(base.rendered));
115
+ if (stampOf(prepared) !== null && stampOf(base.rendered) !== null) prepared = setStamp(prepared, stampOf(base.rendered));
116
+
117
+ const result = merge(prepared, base.rendered, theirs);
118
+ if (result.status === 0) {
119
+ let text = result.stdout;
120
+ if (pinOf(theirs) !== null) text = setPin(text, pinOf(theirs));
121
+ if (stampOf(theirs) !== null && stampOf(text) !== null) text = setStamp(text, stampOf(theirs));
122
+ const problem = structureProblem(text, theirs);
123
+ if (problem) return { status: 'conflict', from: base.version, conflicts: [], message: `the merge is not safe: ${problem}` };
124
+ if (text === ours) return { status: 'current' };
125
+ return { status: 'update', from: base.version, to: stampOf(theirs) ?? pinOf(theirs), text };
126
+ }
127
+ if (result.status >= 1 && result.status <= 127) return { status: 'conflict', from: base.version, conflicts: conflictsOf(result.stdout) };
128
+ return { status: 'error', message: 'git merge-file failed' };
129
+ }
130
+
131
+ /** The published versions of the package and their templates, fetched with `npm`; `exec(cmd, args)` returns stdout. */
132
+ export function publishedTemplates({ exec, tmp = mkdtempSync(join(tmpdir(), 'relevo-published-')), pkg = '@elalfner/relevo' }) {
133
+ const unpacked = new Map();
134
+ const dirOf = (version) => {
135
+ if (!unpacked.has(version)) {
136
+ const dir = join(tmp, version);
137
+ mkdirSync(dir, { recursive: true });
138
+ try {
139
+ exec('npm', ['pack', `${pkg}@${version}`, '--silent', '--pack-destination', dir]);
140
+ const tarball = join(dir, `${pkg.replace(/^@/, '').replace('/', '-')}-${version}.tgz`);
141
+ exec('tar', ['-xzf', tarball, '-C', dir]);
142
+ unpacked.set(version, join(dir, 'package'));
143
+ } catch {
144
+ unpacked.set(version, null);
145
+ }
146
+ }
147
+ return unpacked.get(version);
148
+ };
149
+ return {
150
+ versions: () => JSON.parse(exec('npm', ['view', pkg, 'versions', '--json'])).filter((v) => /^\d+\.\d+\.\d+$/.test(v) && v !== '0.0.0'),
151
+ read: (version, path) => {
152
+ const dir = dirOf(version);
153
+ if (dir === null) return null;
154
+ try {
155
+ return readFileSync(join(dir, path), 'utf8');
156
+ } catch {
157
+ return null;
158
+ }
159
+ },
160
+ cleanup: () => rmSync(tmp, { recursive: true, force: true }),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The state of each workflow of the repository that exists and differs from what `init` would write. `ours` and
166
+ * `templates` map a file name to its text; `repo` brings `runnerLabels` and `build`; `source` is `publishedTemplates`.
167
+ */
168
+ export function planUpgrades({ files, ours, templates, agentBlocks, labels, build, from = null, source, merge }) {
169
+ const render = (template, blocks) => runnerLine(agentWorkflow(template, build, blocks), labels);
170
+ const versions = files.some((f) => ours[f] != null) ? source.versions() : [];
171
+ const out = {};
172
+ files.filter((f) => ours[f] != null).forEach((file) => {
173
+ const candidates = versions.map((version) => {
174
+ const template = source.read(version, `templates/workflows/${file}`);
175
+ return template === null ? null : { version, template, blocks: build === 'claude' ? null : source.read(version, `templates/agents/${build}-step.yml`) };
176
+ }).filter(Boolean);
177
+ out[file] = upgradeWorkflow({ ours: ours[file], current: { template: templates[file], blocks: agentBlocks }, candidates, from, render, merge });
178
+ });
179
+ return out;
180
+ }
181
+
182
+ /** A readable unified difference between the file and its update, for the plan. */
183
+ export function unifiedDiff(before, after) {
184
+ const dir = mkdtempSync(join(tmpdir(), 'relevo-diff-'));
185
+ try {
186
+ const a = join(dir, 'before');
187
+ const b = join(dir, 'after');
188
+ writeFileSync(a, before);
189
+ writeFileSync(b, after);
190
+ try {
191
+ return execFileSync('git', ['diff', '--no-index', '--no-color', '--unified=2', a, b], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
192
+ } catch (err) {
193
+ return String(err.stdout ?? '').split('\n').slice(4).join('\n');
194
+ }
195
+ } finally {
196
+ rmSync(dir, { recursive: true, force: true });
197
+ }
198
+ }
@@ -52,7 +52,16 @@ if (eventName === 'pull_request_review') {
52
52
 
53
53
  const pr = { number: prData.number, url: prData.html_url, author: prData.user.login };
54
54
  const labels = JSON.parse(realGh(['api', `repos/${repo}/issues/${number}`])).labels.map((l) => (typeof l === 'string' ? l : l.name));
55
- const decision = planReviewEvent({ config, event, pr, issue: { number, labels } });
55
+ // The approval names the ship label only where the ship workflow runs: it runs from the default branch.
56
+ const shipWorkflow = event.kind === 'review' && event.state === 'approved' && (() => {
57
+ try {
58
+ realGh(['api', `repos/${repo}/contents/.github/workflows/relevo-ship.yml`, '--silent']);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ })();
64
+ const decision = planReviewEvent({ config, event, pr, issue: { number, labels }, shipWorkflow });
56
65
  if (!decision) {
57
66
  console.log(JSON.stringify({ action: 'none' }));
58
67
  process.exit(0);