@elalfner/relevo 0.6.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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/scripts/dev-server-guard.mjs +32 -0
  4. package/scripts/extra-server.mjs +78 -0
  5. package/scripts/init.mjs +3 -1
  6. package/scripts/lib/can-edit-issue.mjs +4 -1
  7. package/scripts/lib/config.mjs +19 -1
  8. package/scripts/lib/dev-server-guard.mjs +49 -0
  9. package/scripts/lib/doctor.mjs +136 -5
  10. package/scripts/lib/extra-server.mjs +84 -0
  11. package/scripts/lib/github.mjs +15 -5
  12. package/scripts/lib/init.mjs +63 -3
  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-adapters.mjs +15 -2
  19. package/scripts/lib/review-brief.mjs +12 -4
  20. package/scripts/lib/review-events.mjs +6 -3
  21. package/scripts/lib/review-state.mjs +30 -4
  22. package/scripts/lib/ship-github.mjs +87 -0
  23. package/scripts/lib/ship-unattended.mjs +271 -0
  24. package/scripts/lib/ship.mjs +15 -3
  25. package/scripts/lib/toolchain-check.mjs +6 -0
  26. package/scripts/lib/web-preflight.mjs +133 -0
  27. package/scripts/lib/web-review.mjs +82 -1
  28. package/scripts/review-adapter.mjs +5 -3
  29. package/scripts/review-brief.mjs +3 -2
  30. package/scripts/review-event.mjs +10 -1
  31. package/scripts/review-state.mjs +12 -5
  32. package/scripts/ship-check.mjs +6 -4
  33. package/scripts/ship-unattended.mjs +45 -0
  34. package/scripts/toolchain-check.mjs +3 -3
  35. package/scripts/web-hook.mjs +28 -0
  36. package/scripts/web-preflight.mjs +48 -0
  37. package/scripts/web-review.mjs +80 -29
  38. package/skills/doctor/SKILL.md +3 -2
  39. package/skills/go/SKILL.md +4 -2
  40. package/skills/init/SKILL.md +7 -2
  41. package/skills/review/SKILL.md +49 -10
  42. package/skills/ship/SKILL.md +3 -1
  43. package/stacks/vue.md +2 -2
  44. package/templates/agents/antigravity-step.yml +8 -5
  45. package/templates/relevo.yml +4 -0
  46. package/templates/workflows/relevo-digest.yml +10 -1
  47. package/templates/workflows/relevo-go.yml +25 -5
  48. package/templates/workflows/relevo-queue.yml +10 -1
  49. package/templates/workflows/relevo-review.yml +10 -1
  50. package/templates/workflows/relevo-ship.yml +135 -0
  51. package/templates/workflows/relevo-sla.yml +10 -1
@@ -1,6 +1,8 @@
1
1
  // State of one on-device review, and the rule that decides whether a pull request may be opened.
2
2
  // Implements "Pull request only after verification" of openspec/specs/review/spec.md.
3
3
 
4
+ import { createHash } from 'node:crypto';
5
+
4
6
  export const STATUSES = ['pending', 'ok', 'fail'];
5
7
 
6
8
  const same = (a, b) => a.class === b.class && a.excerpt === b.excerpt;
@@ -78,6 +80,8 @@ export function syncState(state, items) {
78
80
  state.audit = null;
79
81
  state.waivers = [];
80
82
  state.agreed = false;
83
+ // The code changed, so the review script must be read by QA again.
84
+ state.qaScript = null;
81
85
  return state;
82
86
  }
83
87
 
@@ -93,12 +97,34 @@ export function setItem(state, id, status, note = null) {
93
97
  return state;
94
98
  }
95
99
 
96
- const driving = (state) => {
100
+ /**
101
+ * Throws unless the agent may drive the review: autopilot is not off and the person confirmed the data.
102
+ * With `open`, the review must also not be agreed yet (the browser runner asks for it).
103
+ */
104
+ export function assertDriving(state, { open = false } = {}) {
97
105
  const mode = state.autopilot ?? 'off';
98
106
  if (mode === 'off') throw new Error('the agent does not drive the review: review.autopilot is "off"');
99
107
  if (!state.envConfirmed) throw new Error('the person has not confirmed in writing that the app points to non-production data: run "confirm-env" after they do');
108
+ if (open && state.agreed) throw new Error('the review is closed: the person already agreed to open the pull request');
100
109
  return mode;
101
- };
110
+ }
111
+
112
+ /** The sha256 of a review script, as `qa-pass` records it and the runner compares it. */
113
+ export const scriptHash = (text) => createHash('sha256').update(String(text)).digest('hex');
114
+
115
+ /** QA passed this exact script; any later change to it needs a new pass. */
116
+ export function qaPass(state, path, text) {
117
+ state.qaScript = { path, sha256: scriptHash(text) };
118
+ return state;
119
+ }
120
+
121
+ /** Record a screenshot that was taken. Captures belong to the open review, so one after the agreement is refused. */
122
+ export function recordCapture(state, path) {
123
+ if (state.agreed) throw new Error('the review is closed: captures are taken while the person tests, before the agreement');
124
+ state.captures ??= [];
125
+ if (!state.captures.includes(path)) state.captures.push(path);
126
+ return state;
127
+ }
102
128
 
103
129
  /** The person confirmed that the app under review does not point to production data. */
104
130
  export function confirmEnv(state) {
@@ -108,7 +134,7 @@ export function confirmEnv(state) {
108
134
 
109
135
  /** The agent's own result for a line, apart from the person's confirmation. Modes precheck and verify only. */
110
136
  export function precheckItem(state, id, result, note = null) {
111
- driving(state);
137
+ assertDriving(state);
112
138
  if (!['ok', 'fail'].includes(result)) throw new Error('a pre-check result is ok or fail');
113
139
  const item = state.items.find((i) => i.id === id);
114
140
  if (!item) throw new Error(`there is no checklist item ${id}`);
@@ -119,7 +145,7 @@ export function precheckItem(state, id, result, note = null) {
119
145
 
120
146
  /** The agent marks a line as verified automatically, saying how. Mode verify only; the line becomes ok but stays labelled. */
121
147
  export function autoItem(state, id, how) {
122
- const mode = driving(state);
148
+ const mode = assertDriving(state);
123
149
  if (mode !== 'verify') throw new Error(`a line can be verified automatically only when review.autopilot is "verify" (it is "${mode}")`);
124
150
  const why = String(how ?? '').trim();
125
151
  if (!why) throw new Error('say how the line was verified: auto <number> <the assertion, finder or log that proved it>');
@@ -0,0 +1,87 @@
1
+ // What ship reads from GitHub besides the pull request: the commits since the approved head, the checks of that
2
+ // head, and whether the base branch requires checks. The client is injected (`gh(args) -> stdout`) for the tests.
3
+ // Implements "Checks of the approved head count for the archive commit" and "Repositories that require checks ship
4
+ // with an App" of openspec/specs/ship/spec.md.
5
+
6
+ const SPECS_DIR = 'openspec/';
7
+ // The compare API lists at most 300 files; a longer list cannot be proven to stay inside the specifications.
8
+ const COMPARE_FILE_LIMIT = 300;
9
+
10
+ const ROLLUP_QUERY = `query($owner: String!, $name: String!, $oid: GitObjectID!) {
11
+ repository(owner: $owner, name: $name) {
12
+ object(oid: $oid) {
13
+ ... on Commit {
14
+ statusCheckRollup {
15
+ contexts(first: 100) {
16
+ nodes {
17
+ __typename
18
+ ... on CheckRun { name status conclusion }
19
+ ... on StatusContext { context state }
20
+ }
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
26
+ }`;
27
+
28
+ // The fields shipDecision reads.
29
+ export const PR_FIELDS = 'number,state,isDraft,mergeable,title,author,reviews,statusCheckRollup,headRefName,headRefOid,baseRefName';
30
+
31
+ /** The open pull request of `branch` (or its latest one), as `gh pr view --json` gives it, or null when none exists. */
32
+ export function loadPr(gh, repo, branch) {
33
+ const found = JSON.parse(gh(['pr', 'list', '--repo', repo, '--head', branch, '--state', 'all', '--json', 'number,state', '--limit', '5']));
34
+ const open = found.find((p) => p.state === 'OPEN') ?? found[0];
35
+ return open ? JSON.parse(gh(['pr', 'view', String(open.number), '--repo', repo, '--json', PR_FIELDS])) : null;
36
+ }
37
+
38
+ const notFound = (err) => /404|Not Found/i.test(String(err?.stderr ?? err?.message ?? err));
39
+
40
+ /** True when every file that changed from `baseline` to `head` is under `openspec/`, and something changed. */
41
+ export function changedOnlySpecs(gh, repo, baseline, head) {
42
+ if (!baseline || !head || baseline === head) return false;
43
+ const data = JSON.parse(gh(['api', `repos/${repo}/compare/${baseline}...${head}`]));
44
+ const files = data.files ?? [];
45
+ if (files.length === 0 || files.length >= COMPARE_FILE_LIMIT) return false;
46
+ return files.every((f) => f.filename.startsWith(SPECS_DIR) && (!f.previous_filename || f.previous_filename.startsWith(SPECS_DIR)));
47
+ }
48
+
49
+ /** The status check rollup of one commit, in the shape `gh pr view --json statusCheckRollup` gives. */
50
+ export function commitRollup(gh, repo, oid) {
51
+ const [owner, name] = repo.split('/');
52
+ const data = JSON.parse(gh(['api', 'graphql', '-f', `query=${ROLLUP_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-f', `oid=${oid}`]));
53
+ return data.data?.repository?.object?.statusCheckRollup?.contexts?.nodes ?? [];
54
+ }
55
+
56
+ /** The input of shipDecision's `baselineChecks`, or undefined when there is no baseline to compare. */
57
+ export function baselineChecks(gh, repo, baseline, head) {
58
+ if (!baseline || !head || baseline === head) return undefined;
59
+ const archiveOnly = changedOnlySpecs(gh, repo, baseline, head);
60
+ return { archiveOnly, rollup: archiveOnly ? commitRollup(gh, repo, baseline) : [] };
61
+ }
62
+
63
+ /**
64
+ * Whether merging into `branch` requires status checks, by a ruleset or by classic branch protection.
65
+ * Returns { required: boolean, contexts: [names] }. A branch without protection requires none.
66
+ */
67
+ export function requiredChecks(gh, repo, branch) {
68
+ const contexts = new Set();
69
+ let required = false;
70
+ const rules = JSON.parse(gh(['api', `repos/${repo}/rules/branches/${encodeURIComponent(branch)}`]));
71
+ (Array.isArray(rules) ? rules : []).filter((r) => r.type === 'required_status_checks').forEach((r) => {
72
+ required = true;
73
+ (r.parameters?.required_status_checks ?? []).forEach((c) => contexts.add(c.context));
74
+ });
75
+ try {
76
+ const protection = JSON.parse(gh(['api', `repos/${repo}/branches/${encodeURIComponent(branch)}/protection`]));
77
+ const classic = protection.required_status_checks;
78
+ if (classic) {
79
+ required = true;
80
+ (classic.contexts ?? []).forEach((c) => contexts.add(c));
81
+ (classic.checks ?? []).forEach((c) => contexts.add(c.context));
82
+ }
83
+ } catch (err) {
84
+ if (!notFound(err)) throw err;
85
+ }
86
+ return { required, contexts: [...contexts] };
87
+ }
@@ -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();
@@ -0,0 +1,133 @@
1
+ // What a browser review checks before it drives: the project's dependencies, Playwright and its browser, and the
2
+ // address under review with the module scripts its page loads. Everything that touches the machine is injected.
3
+ // Implements "A browser review checks its tools and the app before driving" of openspec/specs/review/spec.md.
4
+
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
+ ];
13
+
14
+ const START_SCRIPTS = ['dev', 'serve', 'start'];
15
+
16
+ /** The command that starts the app, from the scripts of package.json; null when there is none. */
17
+ export function startCommand(pkgText) {
18
+ let scripts = {};
19
+ try {
20
+ scripts = JSON.parse(pkgText ?? '{}').scripts ?? {};
21
+ } catch { /* an unreadable package.json names no command */ }
22
+ const name = START_SCRIPTS.find((s) => typeof scripts[s] === 'string');
23
+ return name ? `npm run ${name}` : null;
24
+ }
25
+
26
+ /** The variable names of an example environment file (`NAME=value` lines); values are ignored. */
27
+ export function envNames(text) {
28
+ return [...String(text ?? '').matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)].map((m) => m[1]);
29
+ }
30
+
31
+ /** The same-origin module scripts that an HTML page loads, as absolute URLs. */
32
+ export function moduleScripts(html, pageUrl) {
33
+ const origin = new URL(pageUrl).origin;
34
+ return [...String(html ?? '').matchAll(/<script\b[^>]*>/gi)]
35
+ .map((m) => m[0])
36
+ .filter((tag) => /\btype\s*=\s*["']?module\b/i.test(tag))
37
+ .map((tag) => tag.match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1])
38
+ .filter(Boolean)
39
+ .map((src) => new URL(src, pageUrl))
40
+ .filter((u) => u.origin === origin)
41
+ .map((u) => u.href);
42
+ }
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
+
76
+ /** Does the page answer below 400 after redirects, and so do its module scripts? Returns { ok, detail }. */
77
+ export async function checkUrl(url, { fetch, timeout = 5000 }) {
78
+ const get = async (address) => {
79
+ try {
80
+ const response = await fetch(address, { redirect: 'follow', signal: AbortSignal.timeout(timeout) });
81
+ return { status: response.status, text: response.status < 400 ? await response.text() : '' };
82
+ } catch (error) {
83
+ return { status: null, error: String(error?.cause?.code ?? error?.name ?? error?.message ?? error) };
84
+ }
85
+ };
86
+ const page = await get(url);
87
+ if (page.status === null) return { ok: false, detail: `${url} does not answer (${page.error})` };
88
+ if (page.status >= 400) return { ok: false, detail: `${url} answered ${page.status}` };
89
+ for (const script of moduleScripts(page.text, url)) {
90
+ const r = await get(script);
91
+ if (r.status === null) return { ok: false, detail: `${script} does not answer (${r.error})` };
92
+ if (r.status >= 400) return { ok: false, detail: `${script} answered ${r.status}` };
93
+ }
94
+ return { ok: true, detail: null };
95
+ }
96
+
97
+ /**
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.
100
+ * deps: exists(path), readFile(path) (null when missing), loadPlaywright(), fetch, sleep(ms), now().
101
+ * Only `.env.example` is read for variable names; the real environment files never are.
102
+ * With `wait`, the address is retried every 500 ms until it answers or the time is over.
103
+ */
104
+ export async function webPreflight({ cwd, url, timeout = 5000, wait = 0 }, deps) {
105
+ const { exists, readFile, loadPlaywright, fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now } = deps;
106
+ const at = (name) => `${cwd}/${name}`;
107
+ const start = startCommand(readFile(at('package.json')));
108
+ const env = envNames(readFile(at('.env.example')));
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] });
112
+
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
+ }
118
+ const playwright = loadPlaywright();
119
+ if (!playwright) return result('playwright', 'Playwright does not resolve from the project');
120
+ let executable = null;
121
+ try {
122
+ executable = playwright.chromium.executablePath();
123
+ } catch { /* an unknown browser build has no path */ }
124
+ if (!executable || !exists(executable)) return result('browser', 'the Chromium of Playwright is not installed');
125
+
126
+ const deadline = now() + wait;
127
+ for (;;) {
128
+ const answer = await checkUrl(url, { fetch, timeout });
129
+ if (answer.ok) return result(null, null);
130
+ if (now() >= deadline) return result('url', answer.detail);
131
+ await sleep(500);
132
+ }
133
+ }