@elalfner/relevo 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/init.mjs +3 -1
- package/scripts/lib/can-edit-issue.mjs +4 -1
- package/scripts/lib/config.mjs +19 -1
- package/scripts/lib/doctor.mjs +136 -5
- package/scripts/lib/github.mjs +15 -5
- package/scripts/lib/init.mjs +63 -3
- package/scripts/lib/labels.mjs +4 -0
- package/scripts/lib/messages.mjs +109 -0
- package/scripts/lib/next-step.mjs +12 -0
- package/scripts/lib/plan.mjs +29 -6
- package/scripts/lib/preflight.mjs +1 -1
- package/scripts/lib/review-events.mjs +6 -3
- package/scripts/lib/ship-github.mjs +87 -0
- package/scripts/lib/ship-unattended.mjs +271 -0
- package/scripts/lib/ship.mjs +15 -3
- package/scripts/lib/toolchain-check.mjs +6 -0
- package/scripts/lib/web-preflight.mjs +50 -4
- package/scripts/review-event.mjs +10 -1
- package/scripts/ship-check.mjs +6 -4
- package/scripts/ship-unattended.mjs +45 -0
- package/scripts/toolchain-check.mjs +3 -3
- package/scripts/web-hook.mjs +28 -0
- package/scripts/web-preflight.mjs +6 -5
- package/scripts/web-review.mjs +1 -1
- package/skills/doctor/SKILL.md +3 -2
- package/skills/go/SKILL.md +4 -2
- package/skills/init/SKILL.md +7 -2
- package/skills/review/SKILL.md +4 -3
- package/skills/ship/SKILL.md +3 -1
- package/stacks/vue.md +2 -2
- package/templates/agents/antigravity-step.yml +8 -5
- package/templates/relevo.yml +4 -0
- package/templates/workflows/relevo-digest.yml +10 -1
- package/templates/workflows/relevo-go.yml +25 -5
- package/templates/workflows/relevo-queue.yml +10 -1
- package/templates/workflows/relevo-review.yml +10 -1
- package/templates/workflows/relevo-ship.yml +135 -0
- package/templates/workflows/relevo-sla.yml +10 -1
package/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
|
+
}
|