@elalfner/relevo 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.es.md +1 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/scripts/doctor.mjs +5 -4
- package/scripts/init.mjs +25 -4
- package/scripts/lib/can-edit-issue.mjs +4 -1
- package/scripts/lib/cli.mjs +6 -4
- package/scripts/lib/config.mjs +19 -1
- package/scripts/lib/doctor.mjs +264 -25
- package/scripts/lib/github.mjs +15 -5
- package/scripts/lib/init.mjs +89 -6
- package/scripts/lib/labels.mjs +4 -0
- package/scripts/lib/messages.mjs +109 -0
- package/scripts/lib/next-step.mjs +12 -0
- package/scripts/lib/plan.mjs +29 -6
- package/scripts/lib/preflight.mjs +1 -1
- package/scripts/lib/review-events.mjs +6 -3
- package/scripts/lib/ship-github.mjs +87 -0
- package/scripts/lib/ship-unattended.mjs +271 -0
- package/scripts/lib/ship.mjs +15 -3
- package/scripts/lib/toolchain-check.mjs +6 -0
- package/scripts/lib/web-preflight.mjs +50 -4
- package/scripts/lib/workflow-upgrade.mjs +198 -0
- package/scripts/review-event.mjs +10 -1
- package/scripts/ship-check.mjs +6 -4
- package/scripts/ship-unattended.mjs +45 -0
- package/scripts/toolchain-check.mjs +3 -3
- package/scripts/web-hook.mjs +28 -0
- package/scripts/web-preflight.mjs +6 -5
- package/scripts/web-review.mjs +1 -1
- package/skills/doctor/SKILL.md +6 -5
- package/skills/go/SKILL.md +4 -2
- package/skills/init/SKILL.md +10 -4
- package/skills/review/SKILL.md +4 -3
- package/skills/ship/SKILL.md +3 -1
- package/stacks/vue.md +2 -2
- package/templates/agents/antigravity-step.yml +8 -5
- package/templates/relevo.yml +4 -0
- package/templates/workflows/relevo-digest.yml +11 -1
- package/templates/workflows/relevo-go.yml +33 -14
- package/templates/workflows/relevo-queue.yml +11 -1
- package/templates/workflows/relevo-review.yml +11 -1
- package/templates/workflows/relevo-ship.yml +136 -0
- package/templates/workflows/relevo-sla.yml +11 -1
package/scripts/lib/init.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { loadConfig } from './config.mjs';
|
|
|
9
9
|
import { LEGACY_PLATFORMS } from './platforms.mjs';
|
|
10
10
|
import { labelDefinitions } from './labels.mjs';
|
|
11
11
|
import { agentWorkflow } from './agent-workflow.mjs';
|
|
12
|
-
import { OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WORKFLOW_FILES } from './doctor.mjs';
|
|
12
|
+
import { isVueWeb, OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WEB_HOOK_COMMAND, WORKFLOW_FILES } from './doctor.mjs';
|
|
13
13
|
|
|
14
14
|
export const REVIEW_MODES = ['none', 'peer', 'roles'];
|
|
15
15
|
|
|
@@ -28,12 +28,42 @@ const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE
|
|
|
28
28
|
export const PLUGIN_PROJECT_COMMAND = 'claude plugin marketplace add elalfner/relevo --scope project --sparse .claude-plugin && claude plugin install relevo@relevo --scope project';
|
|
29
29
|
const PLUGIN_PROJECT_DETAIL = 'It writes the marketplace and the enabled plugin into .claude/settings.json of this repository. It works even if the plugin is already installed for your machine, and nothing is committed for you.';
|
|
30
30
|
|
|
31
|
+
const WEB_HOOK_DETAIL = 'It adds a SessionStart hook to .claude/settings.json of this repository, keeping what the file already has. In a session of Claude Code on the web it installs OpenSpec when it is missing (npm install --global @fission-ai/openspec); on a terminal it does nothing. The file is left for you to commit.';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `.claude/settings.json` with the hook that installs OpenSpec added to `SessionStart`, every other key and hook kept.
|
|
35
|
+
* Idempotent: text that already installs OpenSpec is returned as it is. Throws on text that is not a JSON object, so a
|
|
36
|
+
* broken file is never rewritten. `text` is null when the file does not exist.
|
|
37
|
+
*/
|
|
38
|
+
export function mergeWebHook(text) {
|
|
39
|
+
const settings = text === null ? {} : JSON.parse(text);
|
|
40
|
+
if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('.claude/settings.json is not a JSON object');
|
|
41
|
+
const current = settings.hooks?.SessionStart;
|
|
42
|
+
if (current !== undefined && !Array.isArray(current)) throw new Error('hooks.SessionStart in .claude/settings.json is not a list');
|
|
43
|
+
if ((current ?? []).some((e) => (e?.hooks ?? []).some((h) => typeof h?.command === 'string' && h.command.includes('@fission-ai/openspec')))) return text;
|
|
44
|
+
const hooks = { ...(settings.hooks ?? {}), SessionStart: [...(current ?? []), { hooks: [{ type: 'command', command: WEB_HOOK_COMMAND }] }] };
|
|
45
|
+
return `${JSON.stringify({ ...settings, hooks }, null, 2)}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
export function credentialLine(workflow, credential) {
|
|
32
49
|
if (credential !== 'subscription') return workflow;
|
|
33
50
|
if (!workflow.includes(API_KEY_LINE) || !workflow.includes(TOKEN_LINE)) throw new Error('the go workflow template has no credential lines to switch');
|
|
34
51
|
return workflow.replace(API_KEY_LINE, API_KEY_LINE.replace('anthropic_api_key', '# anthropic_api_key')).replace(TOKEN_LINE, TOKEN_LINE.replace('# claude_code_oauth_token', 'claude_code_oauth_token'));
|
|
35
52
|
}
|
|
36
53
|
|
|
54
|
+
export const DEFAULT_RUNNER = ['ubuntu-latest'];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The workflow with the runner of `runner.labels` in every `runs-on`. The templates run on `ubuntu-latest`, which is
|
|
58
|
+
* what an empty or default list leaves alone; a list of one label is written as that label.
|
|
59
|
+
*/
|
|
60
|
+
export function runnerLine(workflow, labels) {
|
|
61
|
+
const list = labels ?? DEFAULT_RUNNER;
|
|
62
|
+
if (list.length === 1 && list[0] === DEFAULT_RUNNER[0]) return workflow;
|
|
63
|
+
if (!workflow.includes('runs-on: ubuntu-latest')) throw new Error('the workflow template has no runs-on line to switch');
|
|
64
|
+
return workflow.replaceAll('runs-on: ubuntu-latest', `runs-on: ${list.length === 1 ? list[0] : `[${list.join(', ')}]`}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
37
67
|
/** The text of a `platforms:` block for { name: { stack, path, targets } }. */
|
|
38
68
|
export function platformsBlock(platforms) {
|
|
39
69
|
const lines = ['platforms:'];
|
|
@@ -96,6 +126,15 @@ export function renderConfig(template, answers = {}) {
|
|
|
96
126
|
if (answers.runMaxTokens !== undefined) out = set(out, 'run_max_tokens', answers.runMaxTokens);
|
|
97
127
|
} else if (answers.runMaxCost !== undefined) out = set(out, 'run_max_cost', answers.runMaxCost);
|
|
98
128
|
if (answers.language) out = set(out, 'language', answers.language);
|
|
129
|
+
// The commented example of the template becomes the setting; the default runner leaves the template as it is.
|
|
130
|
+
if (answers.runnerLabels && !(answers.runnerLabels.length === 1 && answers.runnerLabels[0] === DEFAULT_RUNNER[0])) {
|
|
131
|
+
const re = /^# runner:[^\n]*\n# labels:[^\n]*\n/m;
|
|
132
|
+
if (!re.test(out)) throw new Error('the template has no "runner" example to switch on');
|
|
133
|
+
out = out.replace(re, `runner: # where the Relevo workflows run (docs/execution.md)\n labels: [${answers.runnerLabels.join(', ')}]\n`);
|
|
134
|
+
}
|
|
135
|
+
// A browser review is always driven by the agent, so a Vue web platform cannot keep the autopilot off.
|
|
136
|
+
const autopilot = answers.autopilot ?? (Object.values(platforms ?? {}).some(isVueWeb) ? 'precheck' : null);
|
|
137
|
+
if (autopilot) out = set(out, 'autopilot', autopilot);
|
|
99
138
|
const check = loadConfig(out);
|
|
100
139
|
if (!check.ok) throw new Error(`the answers produce an invalid relevo.yml: ${check.errors.map((e) => e.message).join('; ')}`);
|
|
101
140
|
return out;
|
|
@@ -167,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
|
|
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
|
}
|
package/scripts/lib/labels.mjs
CHANGED
|
@@ -17,6 +17,10 @@ export const LABELS = [
|
|
|
17
17
|
{ key: 'qa-failed', name: 'relevo:qa-failed', color: 'e11d21', kind: 'exit', description: 'QA still rejected after the retries; branch is pushed' },
|
|
18
18
|
{ key: 'changes-requested', name: 'relevo:changes-requested', color: 'f9d0c4', kind: 'exit', description: 'The reviewer asked for changes' },
|
|
19
19
|
{ key: 'needs-reviewer', name: 'relevo:needs-reviewer', color: 'fef2c0', kind: 'overlay', description: 'No reviewer available or the review is overdue' },
|
|
20
|
+
// A request, not a state: the ship workflow merges the pull request and always removes it.
|
|
21
|
+
{ key: 'ship', name: 'relevo:ship', color: '2ea44f', kind: 'overlay', description: 'Ship it: merge the approved pull request on GitHub' },
|
|
22
|
+
// A request, not a state: start it before the others and without waiting for the assignee's review capacity.
|
|
23
|
+
{ key: 'urgent', name: 'relevo:urgent', color: 'ff7b00', kind: 'overlay', description: 'Start it first, even if the assignee has built issues waiting for review' },
|
|
20
24
|
];
|
|
21
25
|
|
|
22
26
|
export const LABEL_KEYS = LABELS.map((l) => l.key);
|
package/scripts/lib/messages.mjs
CHANGED
|
@@ -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 = {
|
package/scripts/lib/plan.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
20
|
-
|
|
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
|
+
}
|