@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
@@ -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,14 +206,17 @@ 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
213
+ * upgrades { file: state } or a function file -> state (asked only for a workflow that exists and differs), from
214
+ * `planUpgrades`: a clean merge becomes an
215
+ * update to confirm, a conflict or a missing base stays `differs` and says why
174
216
  * Items have status: ok (nothing to do), todo (init will do it), differs (exists but differs: reported, never
175
217
  * overwritten), manual (the person must do it).
176
218
  */
177
- export function planInit({ facts, answers, templates, current, admin }) {
219
+ export function planInit({ facts, answers, templates, current, admin, upgrades = {} }) {
178
220
  const items = [];
179
221
  const base = facts.config?.branches?.base ?? answers.base ?? 'dev';
180
222
  const release = facts.config?.branches?.release ?? answers.release ?? 'main';
@@ -199,11 +241,30 @@ export function planInit({ facts, answers, templates, current, admin }) {
199
241
  });
200
242
  }
201
243
 
244
+ // What a browser review of each Vue platform needs: Playwright in its development dependencies, and its Chromium.
245
+ Object.entries(configuredPlatforms).filter(([, p]) => isVueWeb(p)).forEach(([name, p]) => {
246
+ const found = facts.vueWeb?.[name];
247
+ if (!found) return;
248
+ const cd = p.path && p.path !== '.' ? `cd ${p.path} && ` : '';
249
+ if (found.playwright) items.push(item(`install.playwright.${name}`, 'ok', `Playwright resolves from platform ${name}`));
250
+ 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.' }));
251
+ if (found.playwright && found.chromium) items.push(item(`install.chromium.${name}`, 'ok', `The Chromium of Playwright is installed for platform ${name}`));
252
+ 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.' }));
253
+ });
254
+ if (facts.config && (facts.config.review?.autopilot ?? 'off') === 'off' && Object.values(configuredPlatforms).some(isVueWeb)) {
255
+ 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'));
256
+ }
257
+
202
258
  // the plugin, declared in the project so that a teammate who clones the repository is offered it
203
259
  if (facts.pluginDeclared?.declared) items.push(item('install.plugin-project', 'ok', 'The repository declares the Relevo plugin (.claude/settings.json)'));
204
260
  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
261
  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
262
 
263
+ // OpenSpec in the sessions of Claude Code on the web, where every new container starts without it
264
+ 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)'));
265
+ 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'));
266
+ 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 }));
267
+
207
268
  // relevo.yml
208
269
  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
270
  else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
@@ -248,15 +309,30 @@ export function planInit({ facts, answers, templates, current, admin }) {
248
309
  if (!CREDENTIALS.includes(credential)) throw new Error(`credential must be one of ${CREDENTIALS.join(', ')} (got "${credential}")`);
249
310
  const build = answers.agentBuild ?? 'claude';
250
311
  if (!AGENT_BUILDS.includes(build)) throw new Error(`agentBuild must be one of ${AGENT_BUILDS.join(', ')} (got "${build}")`);
312
+ // The runner of the workflows: the setting of the repository, else the person's answer, else GitHub's.
313
+ const runnerLabels = facts.config?.runner?.labels ?? answers.runnerLabels ?? DEFAULT_RUNNER;
251
314
  const cloudFiles = ['relevo-go.yml', 'relevo-queue.yml'];
252
315
  WORKFLOW_FILES.forEach((file) => {
253
316
  if (runs === 'local' && cloudFiles.includes(file)) return;
254
317
  const shared = file === 'relevo-go.yml' ? credentialLine(templates.workflows[file], credential) : templates.workflows[file];
255
- const wanted = agentWorkflow(shared, build, templates.agentBlocks);
318
+ const wanted = runnerLine(agentWorkflow(shared, build, templates.agentBlocks), runnerLabels);
256
319
  const have = current.workflows[file] ?? null;
320
+ // The state of the update is asked for only when the file exists and differs: it may need the network.
321
+ const upgrade = () => (typeof upgrades === 'function' ? upgrades(file) : upgrades[file]);
322
+ const up = have !== null && have !== wanted ? upgrade() : undefined;
257
323
  if (have === null) items.push(item(`workflow.${file}`, 'todo', `Add .github/workflows/${file}`, { path: `.github/workflows/${file}`, content: wanted }));
258
324
  else if (have === wanted) items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date`));
259
- else {
325
+ else if (up?.status === 'update') {
326
+ items.push(item(`workflow.${file}`, 'todo', `Update .github/workflows/${file} from ${up.from} to ${up.to}, keeping your own changes`, { path: `.github/workflows/${file}`, content: up.text, diff: up.diff }));
327
+ } else if (up?.status === 'current') items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date (with your own changes)`));
328
+ else if (up?.status === 'conflict') {
329
+ const where = up.conflicts.length > 0 ? `${up.conflicts.length} place(s) changed by you and by the new template` : up.message;
330
+ items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} differs from the template and cannot be updated without a conflict (${where}); it is left as it is`, { conflicts: up.conflicts }));
331
+ } else if (up?.status === 'nobase') {
332
+ items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} differs from the template and no published version is close to it; it is left as it is (name the version it came from with --from <version>)`));
333
+ } else if (up?.status === 'error') {
334
+ items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} differs from the template and could not be checked for an update (${up.message}); it is left as it is`));
335
+ } else {
260
336
  // The cloud build only passes QA when the build job installs the toolchain of the stack, which the template does.
261
337
  const toolchain = file === 'relevo-go.yml' ? '; check that its build job installs the toolchain of your stacks, as the template does for Flutter and Laravel' : '';
262
338
  items.push(item(`workflow.${file}`, 'differs', `.github/workflows/${file} exists and differs from the template; it is left as it is (compare it by hand${toolchain})`));
@@ -298,7 +374,12 @@ export function planInit({ facts, answers, templates, current, admin }) {
298
374
  export function renderPlan(items) {
299
375
  const tag = { ok: 'ok ', todo: 'will do', differs: 'differs', manual: 'by hand' };
300
376
  const todo = items.filter((i) => i.status === 'todo').length;
301
- const lines = items.map((i) => `[${tag[i.status]}] ${i.description}${i.install ? ` (${i.command})` : ''}`);
377
+ const indent = (text) => text.split('\n').map((l) => ` ${l}`);
378
+ const lines = items.flatMap((i) => [
379
+ `[${tag[i.status]}] ${i.description}${i.install ? ` (${i.command})` : ''}`,
380
+ ...(i.diff ? indent(i.diff.trimEnd()) : []),
381
+ ...(i.conflicts ?? []).flatMap((c) => [indent('yours:'), indent(c.yours), indent('the new template:'), indent(c.theirs)].flat()),
382
+ ]);
302
383
  lines.push('', todo === 0 ? 'Nothing to do.' : `${todo} change(s) to make.`);
303
384
  return lines.join('\n');
304
385
  }
@@ -351,6 +432,8 @@ export function applyPlan(items, { repo, io }) {
351
432
  it.paths.forEach((p) => io.write(p, ''));
352
433
  break;
353
434
  default:
435
+ // A workflow is written only under .github/workflows/, whatever the item says.
436
+ if (it.id.startsWith('workflow.') && !/^\.github\/workflows\/[\w.-]+\.yml$/.test(it.path)) throw new Error(`refusing to write ${it.path}: a workflow goes under .github/workflows/`);
354
437
  if (it.id.startsWith('workflow.') || it.id.startsWith('stack-profile.')) io.write(it.path, it.content);
355
438
  else throw new Error(`no way to apply "${it.id}"`);
356
439
  }
@@ -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);
@@ -29,10 +29,18 @@ const REQUIRED = {
29
29
  no_assignee: ['actor', 'issue'],
30
30
  ready_denied: ['issue'],
31
31
  ready_denied_maintainer: ['issue'],
32
+ urgent_denied: ['issue'],
33
+ urgent_denied_maintainer: ['issue'],
32
34
  blocked_notice: ['issue', 'title', 'blockers'],
33
35
  queued_capacity: ['issue', 'title', 'assignee', 'built', 'limit'],
34
36
  queued_agents: ['issue', 'title'],
35
37
  fix_done: ['issue', 'title', 'summary', 'qa', 'commit', 'iteration'],
38
+ ship_stopped: ['actor', 'issue', 'reasons'],
39
+ ship_not_approver: ['actor', 'issue'],
40
+ ship_no_ci: ['actor', 'issue'],
41
+ ship_needs_app: ['actor', 'issue', 'base'],
42
+ ship_done: ['issue', 'pr', 'base'],
43
+ ship_failed: ['issue', 'run'],
36
44
  };
37
45
 
38
46
  // The lifecycle state whose next step each message shows (see next-step.mjs, the only place that knows the commands).
@@ -52,6 +60,7 @@ const CATALOG = {
52
60
  '',
53
61
  'What happened: {{tasks_done}} of {{tasks_total}} tasks done, QA passed.',
54
62
  'Branch: {{branch}}',
63
+ 'Started ahead of the queue (urgent): you now have {{built_waiting}} built issues waiting for your review.',
55
64
  'Built from the issue text: {{issue_hash_short}}',
56
65
  'What to test: {{scenarios}} scenarios (checklist below)',
57
66
  'Riskiest area, check it first: {{risk}}',
@@ -93,6 +102,7 @@ const CATALOG = {
93
102
  '',
94
103
  'Pull request: {{pr}}',
95
104
  'Your next step: {{command}}',
105
+ 'Or apply the label {{ship_label}} to this issue: it ships on GitHub, with no local copy.',
96
106
  ],
97
107
  needs_reviewer: [
98
108
  '@{{spec_writer}} #{{issue}} "{{title}}" has no available reviewer',
@@ -134,10 +144,19 @@ const CATALOG = {
134
144
  '#{{issue}} was not started: this repository is public, so only maintainers can mark an issue ready, and the label was removed',
135
145
  'Applied by: @{{actor}}',
136
146
  ],
147
+ urgent_denied: [
148
+ '#{{issue}}: the urgent label was removed because only people with write access can ask for an early start. The issue keeps its place in the queue.',
149
+ 'Applied by: @{{actor}}',
150
+ ],
151
+ urgent_denied_maintainer: [
152
+ '#{{issue}}: the urgent label was removed because this repository is public, so only maintainers can ask for an early start. The issue keeps its place in the queue.',
153
+ 'Applied by: @{{actor}}',
154
+ ],
137
155
  blocked_notice: [
138
156
  '#{{issue}} "{{title}}" is blocked',
139
157
  '',
140
158
  'Waiting for {{blockers}} to be merged. It starts automatically when they are.',
159
+ '{{urgent_note}}',
141
160
  ],
142
161
  queued_capacity: [
143
162
  '#{{issue}} "{{title}}" is queued',
@@ -157,6 +176,41 @@ const CATALOG = {
157
176
  'Commit: {{commit}}',
158
177
  '@{{recipient}} {{ask}}',
159
178
  ],
179
+ ship_stopped: [
180
+ '@{{actor}} #{{issue}} was not shipped',
181
+ '',
182
+ 'Why: {{reasons}}',
183
+ 'Nothing was merged and the issue keeps its state; the relevo:ship label was removed. Apply it again once this is solved, or run `/relevo:ship {{issue}}`.',
184
+ ],
185
+ ship_not_approver: [
186
+ '@{{actor}} #{{issue}} was not shipped: you did not approve this pull request',
187
+ '',
188
+ 'An eligible person who did not approve has to say so in writing, and a label cannot. Run `/relevo:ship {{issue}}`, which asks you to confirm it, or ask {{approvers}} to apply the label. The label was removed.',
189
+ ],
190
+ ship_no_ci: [
191
+ '@{{actor}} #{{issue}} was not shipped: no check ran on this pull request',
192
+ '',
193
+ 'Nothing but the review vouches for this code, and the label cannot confirm that. Run `/relevo:ship {{issue}}`, which asks you, or set `review.ship_without_ci: true` in relevo.yml to let the label merge without checks. The label was removed.',
194
+ ],
195
+ ship_needs_app: [
196
+ '@{{actor}} #{{issue}} was not shipped: {{base}} requires status checks',
197
+ '',
198
+ 'The archive commit needs its own checks, and a push with the workflow token starts none. Create a GitHub App for this repository and store its id and private key as the secrets RELEVO_SHIP_APP_ID and RELEVO_SHIP_APP_KEY (see docs/execution.md), or run `/relevo:ship {{issue}}`. Nothing was archived; the label was removed.',
199
+ ],
200
+ ship_done: [
201
+ '#{{issue}} shipped ✅',
202
+ '',
203
+ 'Pull request {{pr}} was squash-merged into {{base}} as {{commit}}.',
204
+ 'Applied by: @{{actor}}',
205
+ '{{checks_note}}',
206
+ 'The issue is closed and its labels removed. Promoting {{base}} to the release branch is a separate pull request.',
207
+ ],
208
+ ship_failed: [
209
+ '#{{issue}} was not shipped: the run ended without an outcome',
210
+ '',
211
+ '{{log_line}}',
212
+ 'When the cause is fixed, apply relevo:ship again: a specification already archived is not archived twice.',
213
+ ],
160
214
  },
161
215
  es: {
162
216
  built: [
@@ -164,6 +218,7 @@ const CATALOG = {
164
218
  '',
165
219
  'Qué pasó: {{tasks_done}} de {{tasks_total}} tareas hechas, QA aprobado.',
166
220
  'Rama: {{branch}}',
221
+ 'Empezó antes que la cola (urgente): ahora tienes {{built_waiting}} issues construidos esperando tu revisión.',
167
222
  'Construida con el texto del issue: {{issue_hash_short}}',
168
223
  'Qué probar: {{scenarios}} escenarios (checklist abajo)',
169
224
  'Zona más riesgosa, revísala primero: {{risk}}',
@@ -205,6 +260,7 @@ const CATALOG = {
205
260
  '',
206
261
  'Pull request: {{pr}}',
207
262
  'Tu siguiente paso: {{command}}',
263
+ 'O pon la etiqueta {{ship_label}} a este issue: se fusiona en GitHub, sin copia local.',
208
264
  ],
209
265
  needs_reviewer: [
210
266
  '@{{spec_writer}} #{{issue}} "{{title}}" no tiene revisor disponible',
@@ -246,10 +302,19 @@ const CATALOG = {
246
302
  '#{{issue}} no se inició: este repositorio es público, así que solo quienes mantienen el proyecto pueden marcar un issue como ready, y se quitó la etiqueta',
247
303
  'Aplicada por: @{{actor}}',
248
304
  ],
305
+ urgent_denied: [
306
+ '#{{issue}}: se quitó la etiqueta de urgente porque solo quienes tienen acceso de escritura pueden pedir un inicio anticipado. El issue conserva su lugar en la cola.',
307
+ 'Aplicada por: @{{actor}}',
308
+ ],
309
+ urgent_denied_maintainer: [
310
+ '#{{issue}}: se quitó la etiqueta de urgente porque este repositorio es público, así que solo quienes mantienen el proyecto pueden pedir un inicio anticipado. El issue conserva su lugar en la cola.',
311
+ 'Aplicada por: @{{actor}}',
312
+ ],
249
313
  blocked_notice: [
250
314
  '#{{issue}} "{{title}}" está bloqueado',
251
315
  '',
252
316
  'Espera a que se fusionen {{blockers}}. Empieza solo cuando lo estén.',
317
+ '{{urgent_note}}',
253
318
  ],
254
319
  queued_capacity: [
255
320
  '#{{issue}} "{{title}}" está en cola',
@@ -269,6 +334,41 @@ const CATALOG = {
269
334
  'Commit: {{commit}}',
270
335
  '@{{recipient}} {{ask}}',
271
336
  ],
337
+ ship_stopped: [
338
+ '@{{actor}} #{{issue}} no se fusionó',
339
+ '',
340
+ 'Por qué: {{reasons}}',
341
+ 'No se fusionó nada y el issue conserva su estado; se quitó la etiqueta relevo:ship. Vuelve a ponerla cuando esté resuelto, o ejecuta `/relevo:ship {{issue}}`.',
342
+ ],
343
+ ship_not_approver: [
344
+ '@{{actor}} #{{issue}} no se fusionó: tú no aprobaste este pull request',
345
+ '',
346
+ 'Quien es elegible pero no aprobó tiene que decirlo por escrito, y una etiqueta no puede. Ejecuta `/relevo:ship {{issue}}`, que te pide confirmarlo, o pide a {{approvers}} que ponga la etiqueta. Se quitó la etiqueta.',
347
+ ],
348
+ ship_no_ci: [
349
+ '@{{actor}} #{{issue}} no se fusionó: no corrió ningún check en este pull request',
350
+ '',
351
+ 'Solo la revisión respalda este código, y la etiqueta no puede confirmarlo. Ejecuta `/relevo:ship {{issue}}`, que te lo pregunta, o pon `review.ship_without_ci: true` en relevo.yml para que la etiqueta fusione sin checks. Se quitó la etiqueta.',
352
+ ],
353
+ ship_needs_app: [
354
+ '@{{actor}} #{{issue}} no se fusionó: {{base}} exige status checks',
355
+ '',
356
+ 'El commit del archivo necesita sus propios checks, y un push con el token del workflow no lanza ninguno. Crea una GitHub App para este repositorio y guarda su id y su clave privada como los secrets RELEVO_SHIP_APP_ID y RELEVO_SHIP_APP_KEY (ver docs/execution.es.md), o ejecuta `/relevo:ship {{issue}}`. No se archivó nada; se quitó la etiqueta.',
357
+ ],
358
+ ship_done: [
359
+ '#{{issue}} fusionada ✅',
360
+ '',
361
+ 'El pull request {{pr}} se fusionó con squash en {{base}} como {{commit}}.',
362
+ 'La pidió: @{{actor}}',
363
+ '{{checks_note}}',
364
+ 'El issue está cerrado y sin etiquetas. Promover {{base}} a la rama de release es un pull request aparte.',
365
+ ],
366
+ ship_failed: [
367
+ '#{{issue}} no se fusionó: la ejecución terminó sin resultado',
368
+ '',
369
+ '{{log_line}}',
370
+ 'Cuando la causa esté resuelta, vuelve a poner relevo:ship: una especificación ya archivada no se archiva dos veces.',
371
+ ],
272
372
  },
273
373
  };
274
374
 
@@ -297,7 +397,16 @@ export function renderMessage(kind, data = {}, language = 'en') {
297
397
  });
298
398
  const language_ = pickLanguage(language);
299
399
  const es = language_ === 'es';
400
+ // The person who applied the label is mentioned when the run knows who it was.
401
+ if (kind === 'ship_failed') {
402
+ const who = present(values.actor) ? `@${values.actor} ` : '';
403
+ values.log_line = es ? `${who}${who ? 'lee' : 'Lee'} el log para ver por qué: ${values.run}` : `${who}${who ? 'read' : 'Read'} the log to see why: ${values.run}`;
404
+ }
300
405
  if (kind === 'built' && present(values.issue_hash)) values.issue_hash_short = shortHash(values.issue_hash);
406
+ // Urgent does not skip dependencies: an urgent issue that is blocked says so.
407
+ if (kind === 'blocked_notice' && values.urgent === true) {
408
+ values.urgent_note = es ? 'La etiqueta de urgente no salta las dependencias: empieza primero en la cola cuando se fusionen.' : 'The urgent label does not skip dependencies: it goes first in the queue once they are merged.';
409
+ }
301
410
 
302
411
  // The next step comes from one table. needs-spec depends on why the issue came back, qa-failed on what the branch holds.
303
412
  if (STATE_OF[kind]) {
@@ -104,6 +104,18 @@ const TABLE = {
104
104
  who: 'spec_writer',
105
105
  hint: { en: 'assign a reviewer or ask someone to take it', es: 'asigna a alguien que revise o pide que alguien lo tome' },
106
106
  },
107
+ // The ship workflow is merging it; when it stops, its comment says why and the issue is back on pr-open.
108
+ ship: {
109
+ command: null,
110
+ who: 'nobody',
111
+ hint: { en: 'the ship workflow is merging it', es: 'el workflow de ship lo está fusionando' },
112
+ },
113
+ // A request next to the state, not a state: the state it travels with decides the next step.
114
+ urgent: {
115
+ command: null,
116
+ who: 'nobody',
117
+ hint: { en: 'it starts before the others; the state it is in decides the next step', es: 'empieza antes que las demás; el estado en que está decide el siguiente paso' },
118
+ },
107
119
  };
108
120
 
109
121
  const WHO = {
@@ -6,7 +6,8 @@
6
6
  // issues [{ number, title, body, state, labels: [names], assignees: [logins],
7
7
  // milestone: { number, due_on } | null,
8
8
  // ready_at: ISO | null, // when the ready label was last applied
9
- // ready_by: login | null // who applied it }]
9
+ // ready_by: login | null, // who applied it
10
+ // urgent_by: login | null // who applied the urgent label, when the issue carries it }]
10
11
  // completed issue numbers closed as completed (their pull request merged)
11
12
  // running issue numbers with an agent run in progress
12
13
  // permissions { login: 'admin' | 'maintain' | 'write' | 'triage' | 'read' | 'none' }
@@ -61,6 +62,12 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
61
62
  const linked = (config.links ?? []);
62
63
  const own = repo ? repo.toLowerCase() : null;
63
64
 
65
+ // The urgent label is a request that only the people who may start work can make. A label from anyone else is not
66
+ // valid: it is removed (with a notice) and the issue is evaluated as a normal one.
67
+ const mayTrigger = config.trust === 'public' ? MAY_TRIGGER_PUBLIC : MAY_TRIGGER;
68
+ const hasUrgentLabel = (issue) => has(issue, 'urgent');
69
+ const isUrgent = (issue) => hasUrgentLabel(issue) && Boolean(issue.urgent_by) && mayTrigger.has(permissions[issue.urgent_by]);
70
+
64
71
  const runningSet = new Set(running);
65
72
  open.filter((i) => has(i, 'building')).forEach((i) => runningSet.add(i.number));
66
73
  let freeSlots = Math.max(0, config.limits.concurrent_agents - runningSet.size);
@@ -71,14 +78,16 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
71
78
  if (owner) builtBy.set(owner, (builtBy.get(owner) ?? 0) + 1);
72
79
  });
73
80
 
81
+ // Valid urgent issues are considered first, so they take the free agent slots before the others; inside each group
82
+ // the order is the time `ready` was applied.
74
83
  const candidates = open
75
84
  .filter((i) => has(i, 'ready') || has(i, 'queued') || has(i, 'blocked'))
76
- .sort(byReadyAt);
85
+ .sort((a, b) => Number(isUrgent(b)) - Number(isUrgent(a)) || byReadyAt(a, b));
77
86
 
78
87
  const decisions = [];
79
88
 
80
89
  function decision(issue, action, reason, extra = {}) {
81
- const labels = extra.labels ?? { add: [], remove: [] };
90
+ let labels = extra.labels ?? { add: [], remove: [] };
82
91
  const out = {
83
92
  issue: issue.number,
84
93
  action,
@@ -87,6 +96,16 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
87
96
  comment: extra.comment ?? null,
88
97
  assign: extra.assign ?? null,
89
98
  };
99
+ // An urgent label that is not valid is removed in the decision of the issue, with its own notice. An issue whose
100
+ // run is in progress is left alone.
101
+ if (action !== 'skip' && hasUrgentLabel(issue) && !isUrgent(issue)) {
102
+ labels = { add: labels.add, remove: [...labels.remove, names.urgent] };
103
+ out.labels = labels;
104
+ out.notice = {
105
+ kind: config.trust === 'public' ? 'urgent_denied_maintainer' : 'urgent_denied',
106
+ data: { issue: issue.number, ...(issue.urgent_by ? { actor: issue.urgent_by } : {}) },
107
+ };
108
+ }
90
109
  if (action === 'start') Object.assign(out, extra.start);
91
110
  decisions.push(out);
92
111
  }
@@ -189,15 +208,17 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
189
208
  decision(issue, 'block', `waiting for ${blockers.join(', ')}`, {
190
209
  labels: moveTo(issue, 'blocked'),
191
210
  comment: entering
192
- ? { kind: 'blocked_notice', data: { issue: issue.number, title: issue.title, blockers: blockers.join(', ') } }
211
+ ? { kind: 'blocked_notice', data: { issue: issue.number, title: issue.title, blockers: blockers.join(', '), ...(isUrgent(issue) ? { urgent: true } : {}) } }
193
212
  : null,
194
213
  });
195
214
  return;
196
215
  }
197
216
 
198
- // Gate 3: capacity of the assignee.
217
+ // Gate 3: capacity of the assignee. A valid urgent label skips this gate and no other.
199
218
  const built = assignee ? builtBy.get(assignee) ?? 0 : 0;
200
- if (assignee && built >= config.limits.built_unreviewed_per_person) {
219
+ const overCapacity = Boolean(assignee) && built >= config.limits.built_unreviewed_per_person;
220
+ const urgent = isUrgent(issue);
221
+ if (overCapacity && !urgent) {
201
222
  decision(issue, 'queue', `${assignee} already has ${built} built issues waiting for review`, {
202
223
  labels: moveTo(issue, 'queued'),
203
224
  comment: has(issue, 'queued')
@@ -244,6 +265,8 @@ export function planGo({ config, issues, completed = [], completedExternal = [],
244
265
  stack: platform.entry.stack,
245
266
  path: platform.entry.path,
246
267
  targets: platform.entry.targets,
268
+ // Only when the urgent label let the issue start over the assignee's capacity: the hand-off message says so.
269
+ ...(overCapacity && urgent ? { urgent: true, built_waiting: built + 1 } : {}),
247
270
  },
248
271
  });
249
272
  });
@@ -27,7 +27,7 @@ function checkConfig({ readConfig }) {
27
27
 
28
28
  function checkOpenspec({ run }) {
29
29
  const r = run('openspec', ['--version']);
30
- return r.status === 0 ? pass('openspec') : fail('openspec', 'the OpenSpec command line does not run', 'run /relevo:doctor');
30
+ return r.status === 0 ? pass('openspec') : fail('openspec', 'the OpenSpec command line does not run. Stop here: do not go on from memory and do not write anything under openspec/changes/ by hand', 'install it with `npm install --global @fission-ai/openspec` (Node.js 20.19 or newer), or run /relevo:doctor');
31
31
  }
32
32
 
33
33
  function checkOpenspecRoot({ run }) {
@@ -16,8 +16,11 @@ function summarise(body, es) {
16
16
  return text.length > SUMMARY_LENGTH ? `${text.slice(0, SUMMARY_LENGTH)}…` : text;
17
17
  }
18
18
 
19
- /** Returns { action, labels: { add, remove }, comment } or null when the event changes nothing. */
20
- export function planReviewEvent({ config, event, pr, issue }) {
19
+ /**
20
+ * Returns { action, labels: { add, remove }, comment } or null when the event changes nothing.
21
+ * shipWorkflow: the ship workflow is installed on the default branch, so the approval also names its label.
22
+ */
23
+ export function planReviewEvent({ config, event, pr, issue, shipWorkflow = false }) {
21
24
  if (config.review.mode === 'none') return null;
22
25
  const names = resolveLabelNames(config.labels);
23
26
  const has = (key) => issue.labels.includes(names[key]);
@@ -49,7 +52,7 @@ export function planReviewEvent({ config, event, pr, issue }) {
49
52
  return {
50
53
  action: 'approved',
51
54
  labels: { add: [], remove: [] },
52
- comment: { kind: 'approved', data: { recipient, issue: issue.number, reviewer: event.reviewer, pr: pr.url } },
55
+ comment: { kind: 'approved', data: { recipient, issue: issue.number, reviewer: event.reviewer, pr: pr.url, ...(shipWorkflow ? { ship_label: names.ship } : {}) } },
53
56
  };
54
57
  }
55
58
  return null;
@@ -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
+ }