@elalfner/relevo 0.0.0-stage → 0.3.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 +19 -0
- package/LICENSE +21 -0
- package/NOTICE +51 -0
- package/README.es.md +90 -0
- package/README.md +89 -2
- package/agents/dev.md +101 -0
- package/agents/qa.md +112 -0
- package/package.json +42 -4
- package/scripts/add-corrections.mjs +21 -0
- package/scripts/app-id.mjs +19 -0
- package/scripts/archive-state.mjs +31 -0
- package/scripts/branch-state.mjs +31 -0
- package/scripts/branch-work.mjs +25 -0
- package/scripts/can-edit-issue.mjs +45 -0
- package/scripts/capture.mjs +22 -0
- package/scripts/check-archive.mjs +19 -0
- package/scripts/check-issue.mjs +41 -0
- package/scripts/checklist.mjs +29 -0
- package/scripts/cli.mjs +28 -0
- package/scripts/commit-change.mjs +50 -0
- package/scripts/conflict-scope.mjs +19 -0
- package/scripts/design-fetch.mjs +103 -0
- package/scripts/device.mjs +12 -0
- package/scripts/digest.mjs +50 -0
- package/scripts/doctor.mjs +47 -0
- package/scripts/fix-guard.mjs +38 -0
- package/scripts/fix-stash.mjs +57 -0
- package/scripts/fix-state.mjs +28 -0
- package/scripts/fix-tasks.mjs +22 -0
- package/scripts/go-local.mjs +49 -0
- package/scripts/go-plan.mjs +82 -0
- package/scripts/init.mjs +109 -0
- package/scripts/intent-to-add.mjs +20 -0
- package/scripts/issue-coverage.mjs +30 -0
- package/scripts/issue-hash.mjs +44 -0
- package/scripts/issue-sections.mjs +42 -0
- package/scripts/label-change.mjs +35 -0
- package/scripts/lib/appid.mjs +12 -0
- package/scripts/lib/archive-check.mjs +29 -0
- package/scripts/lib/archive-state.mjs +15 -0
- package/scripts/lib/branch-work.mjs +13 -0
- package/scripts/lib/branch.mjs +43 -0
- package/scripts/lib/can-edit-issue.mjs +34 -0
- package/scripts/lib/checklist.mjs +68 -0
- package/scripts/lib/cli.mjs +82 -0
- package/scripts/lib/commit-change.mjs +57 -0
- package/scripts/lib/config.mjs +296 -0
- package/scripts/lib/design-fetch.mjs +156 -0
- package/scripts/lib/device.mjs +67 -0
- package/scripts/lib/doctor.mjs +614 -0
- package/scripts/lib/execution-mode.mjs +24 -0
- package/scripts/lib/fix-guard.mjs +23 -0
- package/scripts/lib/fix-stash.mjs +17 -0
- package/scripts/lib/fix.mjs +31 -0
- package/scripts/lib/github.mjs +161 -0
- package/scripts/lib/go-local.mjs +102 -0
- package/scripts/lib/init.mjs +324 -0
- package/scripts/lib/intent-to-add.mjs +32 -0
- package/scripts/lib/issue-check.mjs +63 -0
- package/scripts/lib/issue-coverage.mjs +47 -0
- package/scripts/lib/issue-hash.mjs +25 -0
- package/scripts/lib/issue-parse.mjs +194 -0
- package/scripts/lib/issue-sections.mjs +43 -0
- package/scripts/lib/labels.mjs +65 -0
- package/scripts/lib/language.mjs +9 -0
- package/scripts/lib/logaudit.mjs +123 -0
- package/scripts/lib/messages.mjs +328 -0
- package/scripts/lib/next-step.mjs +144 -0
- package/scripts/lib/openspec-commands.mjs +44 -0
- package/scripts/lib/openspec-status.mjs +16 -0
- package/scripts/lib/plan.mjs +256 -0
- package/scripts/lib/platform-of.mjs +32 -0
- package/scripts/lib/platform-scope.mjs +32 -0
- package/scripts/lib/platforms.mjs +90 -0
- package/scripts/lib/preflight.mjs +71 -0
- package/scripts/lib/protected-paths.mjs +102 -0
- package/scripts/lib/requeue.mjs +23 -0
- package/scripts/lib/review-adapters.mjs +37 -0
- package/scripts/lib/review-brief.mjs +43 -0
- package/scripts/lib/review-events.mjs +56 -0
- package/scripts/lib/review-state.mjs +157 -0
- package/scripts/lib/reviewers.mjs +39 -0
- package/scripts/lib/role-skills.mjs +112 -0
- package/scripts/lib/run-flags.mjs +16 -0
- package/scripts/lib/run-usage.mjs +65 -0
- package/scripts/lib/scan.mjs +73 -0
- package/scripts/lib/ship.mjs +161 -0
- package/scripts/lib/sla.mjs +90 -0
- package/scripts/lib/stack-profile.mjs +169 -0
- package/scripts/lib/staging.mjs +21 -0
- package/scripts/lib/status-data.mjs +109 -0
- package/scripts/lib/status.mjs +199 -0
- package/scripts/lib/tasks-file.mjs +81 -0
- package/scripts/lib/web-review.mjs +98 -0
- package/scripts/lib/yaml-lite.mjs +157 -0
- package/scripts/needs-reviewer.mjs +41 -0
- package/scripts/next-step.mjs +39 -0
- package/scripts/pick-reviewer.mjs +42 -0
- package/scripts/platform-of.mjs +49 -0
- package/scripts/preflight.mjs +32 -0
- package/scripts/render-message.mjs +35 -0
- package/scripts/requeue.mjs +53 -0
- package/scripts/review-adapter.mjs +32 -0
- package/scripts/review-brief.mjs +29 -0
- package/scripts/review-event.mjs +70 -0
- package/scripts/review-sla.mjs +41 -0
- package/scripts/review-state.mjs +171 -0
- package/scripts/review-who.mjs +21 -0
- package/scripts/role-skills.mjs +53 -0
- package/scripts/run-usage.mjs +22 -0
- package/scripts/scan-diff.mjs +16 -0
- package/scripts/ship-check.mjs +54 -0
- package/scripts/spec-ready.mjs +40 -0
- package/scripts/stack-profile.mjs +67 -0
- package/scripts/stage.mjs +14 -0
- package/scripts/status.mjs +47 -0
- package/scripts/usage-limit.mjs +17 -0
- package/scripts/validate-config.mjs +44 -0
- package/scripts/web-review.mjs +134 -0
- package/skills/doctor/SKILL.md +28 -0
- package/skills/fix/SKILL.md +128 -0
- package/skills/go/SKILL.md +242 -0
- package/skills/init/SKILL.md +72 -0
- package/skills/issue/SKILL.md +128 -0
- package/skills/issue/references/update.md +15 -0
- package/skills/review/SKILL.md +157 -0
- package/skills/ship/SKILL.md +128 -0
- package/skills/status/SKILL.md +27 -0
- package/skills/think/SKILL.md +76 -0
- package/stacks/flutter.md +63 -0
- package/stacks/laravel.md +65 -0
- package/stacks/vue.md +70 -0
- package/templates/issue.md +99 -0
- package/templates/relevo.yml +59 -0
- package/templates/workflows/relevo-digest.yml +54 -0
- package/templates/workflows/relevo-go.yml +263 -0
- package/templates/workflows/relevo-queue.yml +72 -0
- package/templates/workflows/relevo-review.yml +60 -0
- package/templates/workflows/relevo-sla.yml +53 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// What is waiting on whom. Implements openspec/specs/status/spec.md. Pure: data in, report out.
|
|
2
|
+
//
|
|
3
|
+
// issues [{ number, title, labels: [names], assignees: [logins], milestone: number | null }] open Relevo issues
|
|
4
|
+
// prs [{ number, issue, url, author, reviewer, requested_at, approved, behind_by }] open feature pull requests
|
|
5
|
+
// milestones [{ number, title, due_on, closed_issues }]
|
|
6
|
+
// me login of the person asking (null for the team-level view)
|
|
7
|
+
|
|
8
|
+
import { resolveLabelNames } from './labels.mjs';
|
|
9
|
+
|
|
10
|
+
/** Reviews one person can complete per working day; used only for the capacity warning. */
|
|
11
|
+
export const REVIEWS_PER_PERSON_PER_DAY = 1;
|
|
12
|
+
/** A feature branch this many commits behind the base branch is reported. */
|
|
13
|
+
export const BEHIND_ALERT = 10;
|
|
14
|
+
|
|
15
|
+
const HOUR = 3600 * 1000;
|
|
16
|
+
|
|
17
|
+
function workingDays(from, to) {
|
|
18
|
+
let days = 0;
|
|
19
|
+
for (let t = new Date(from); t < to; t = new Date(t.getTime() + 24 * HOUR)) {
|
|
20
|
+
const d = t.getUTCDay();
|
|
21
|
+
if (d !== 0 && d !== 6) days += 1;
|
|
22
|
+
}
|
|
23
|
+
return days;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stateOf(issue, names) {
|
|
27
|
+
const order = ['needs-spec', 'qa-failed', 'changes-requested', 'blocked', 'in-review', 'pr-open', 'built', 'building', 'queued', 'ready'];
|
|
28
|
+
return order.find((k) => issue.labels.includes(names[k])) ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildStatus({ config, issues, prs = [], milestones = [], externalBlocks = {}, cloudBuilds = true, me = null, now = new Date() }) {
|
|
32
|
+
const names = resolveLabelNames(config.labels);
|
|
33
|
+
const entries = issues.map((i) => ({
|
|
34
|
+
number: i.number, title: i.title, state: stateOf(i, names), assignee: i.assignees[0] ?? null, milestone: i.milestone ?? null,
|
|
35
|
+
needsReviewer: i.labels.includes(names['needs-reviewer']),
|
|
36
|
+
updatedAt: i.updated_at ?? null,
|
|
37
|
+
...(externalBlocks[i.number] ? { blockedBy: externalBlocks[i.number] } : {}),
|
|
38
|
+
})).filter((e) => e.state);
|
|
39
|
+
|
|
40
|
+
const mine = me ? entries.filter((e) => e.assignee === me) : [];
|
|
41
|
+
const nextCommand = { built: 'review', 'in-review': 'review', 'qa-failed': 'fix', 'changes-requested': 'fix' };
|
|
42
|
+
const onMe = [];
|
|
43
|
+
['built', 'in-review', 'qa-failed', 'changes-requested'].forEach((state) => {
|
|
44
|
+
mine.filter((e) => e.state === state).forEach((e) => onMe.push({ ...e, command: `/relevo:${nextCommand[state]} ${e.number}` }));
|
|
45
|
+
});
|
|
46
|
+
const toReview = me
|
|
47
|
+
? prs.filter((p) => p.reviewer === me && !p.approved).map((p) => ({ number: p.issue, pr: p.url, since: p.requested_at }))
|
|
48
|
+
: [];
|
|
49
|
+
const toShip = me
|
|
50
|
+
? prs.filter((p) => p.approved && (config.review.merger === 'author' ? p.author === me : p.reviewer === me))
|
|
51
|
+
.map((p) => ({ number: p.issue, pr: p.url, command: `/relevo:ship ${p.issue}` }))
|
|
52
|
+
: [];
|
|
53
|
+
const waitingOnOthers = mine.filter((e) => !['built', 'in-review', 'qa-failed', 'changes-requested'].includes(e.state));
|
|
54
|
+
|
|
55
|
+
const specWriter = config.team.spec_writer;
|
|
56
|
+
const isSpecWriter = Boolean(me) && me === specWriter;
|
|
57
|
+
const specWriterView = isSpecWriter
|
|
58
|
+
? {
|
|
59
|
+
needsSpec: entries.filter((e) => e.state === 'needs-spec'),
|
|
60
|
+
blocked: entries.filter((e) => e.state === 'blocked'),
|
|
61
|
+
needsReviewer: entries.filter((e) => e.needsReviewer),
|
|
62
|
+
}
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
// Milestones: merged, in review, queued, and a capacity warning against the due date.
|
|
66
|
+
const flight = new Set(['building', 'built', 'in-review', 'pr-open', 'changes-requested', 'qa-failed']);
|
|
67
|
+
const summaries = milestones.map((m) => {
|
|
68
|
+
const inMs = entries.filter((e) => e.milestone === m.number);
|
|
69
|
+
const queued = inMs.filter((e) => ['ready', 'queued', 'blocked', 'needs-spec'].includes(e.state)).length;
|
|
70
|
+
const inFlight = inMs.filter((e) => flight.has(e.state)).length;
|
|
71
|
+
const out = { number: m.number, title: m.title, merged: m.closed_issues ?? 0, inReview: inMs.filter((e) => ['in-review', 'pr-open', 'changes-requested'].includes(e.state)).length, queued, inFlight, dueOn: m.due_on ?? null, capacity: null, warning: false };
|
|
72
|
+
if (m.due_on) {
|
|
73
|
+
const people = config.team.members.filter((x) => x !== specWriter).length;
|
|
74
|
+
const days = workingDays(now, new Date(m.due_on));
|
|
75
|
+
out.capacity = { people, workingDays: days, reviews: people * days * REVIEWS_PER_PERSON_PER_DAY };
|
|
76
|
+
out.warning = queued + inFlight > out.capacity.reviews;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// When the cloud is not building, the ready issues assigned to the person are theirs to build.
|
|
82
|
+
const readyForMe = !cloudBuilds && me
|
|
83
|
+
? mine.filter((e) => e.state === 'ready').map((e) => ({ number: e.number, title: e.title, command: `/relevo:go ${e.number}` }))
|
|
84
|
+
: [];
|
|
85
|
+
|
|
86
|
+
const alerts = [];
|
|
87
|
+
// A build that stopped reporting: nothing happened on the issue for longer than limits.building_alert_hours.
|
|
88
|
+
entries.filter((e) => e.state === 'building' && e.updatedAt).forEach((e) => {
|
|
89
|
+
const hours = Math.floor((new Date(now).getTime() - Date.parse(e.updatedAt)) / HOUR);
|
|
90
|
+
if (hours >= config.limits.building_alert_hours) alerts.push({ type: 'stuck-build', issue: e.number, hours, command: `/relevo:go ${e.number}` });
|
|
91
|
+
});
|
|
92
|
+
prs.forEach((p) => {
|
|
93
|
+
if (!p.approved && p.reviewer && p.requested_at) {
|
|
94
|
+
const hours = Math.floor((new Date(now).getTime() - Date.parse(p.requested_at)) / HOUR);
|
|
95
|
+
if (hours >= config.limits.review_sla_hours) alerts.push({ type: 'stale-review', issue: p.issue, pr: p.url, reviewer: p.reviewer, hours });
|
|
96
|
+
}
|
|
97
|
+
if ((p.behind_by ?? 0) >= BEHIND_ALERT) alerts.push({ type: 'stale-branch', issue: p.issue, pr: p.url, behind: p.behind_by });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Who is waiting on what, for the whole team (used by the digest).
|
|
101
|
+
const waiting = {};
|
|
102
|
+
const add = (login, line) => {
|
|
103
|
+
if (!login) return;
|
|
104
|
+
(waiting[login] ??= []).push(line);
|
|
105
|
+
};
|
|
106
|
+
entries.forEach((e) => {
|
|
107
|
+
if (['built', 'in-review', 'qa-failed', 'changes-requested'].includes(e.state)) add(e.assignee, { number: e.number, state: e.state, command: `/relevo:${nextCommand[e.state]} ${e.number}` });
|
|
108
|
+
if (e.state === 'needs-spec') add(specWriter, { number: e.number, state: e.state });
|
|
109
|
+
if (e.needsReviewer) add(specWriter, { number: e.number, state: 'needs-reviewer' });
|
|
110
|
+
});
|
|
111
|
+
prs.forEach((p) => {
|
|
112
|
+
if (!p.approved && p.reviewer) add(p.reviewer, { number: p.issue, state: 'review-requested', pr: p.url });
|
|
113
|
+
if (p.approved) add(config.review.merger === 'author' ? p.author : p.reviewer, { number: p.issue, state: 'approved', command: `/relevo:ship ${p.issue}` });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
me, view: isSpecWriter ? 'spec-writer' : 'person',
|
|
118
|
+
onMe, toReview, toShip, readyForMe, waitingOnOthers, specWriter: specWriterView,
|
|
119
|
+
milestones: summaries, alerts, waiting,
|
|
120
|
+
readOnly: true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const T = {
|
|
125
|
+
en: {
|
|
126
|
+
nothing: 'Nothing is waiting on you.', onMe: 'Waiting on you', review: 'Pull requests to review', ship: 'Approved, ready to ship',
|
|
127
|
+
others: 'Waiting on others', spec: 'Back with the spec writer', blocked: 'Blocked', needsReviewer: 'Need a reviewer', milestone: 'Milestone',
|
|
128
|
+
noDue: 'has no due date, so no capacity warning is computed', merged: 'merged', inReview: 'in review', queued: 'queued', warn: 'capacity warning: more work than the team can review before the due date',
|
|
129
|
+
alerts: 'Alerts', staleReview: 'review waiting', staleBranch: 'branch behind the base by',
|
|
130
|
+
waitingFor: 'waiting for', open: 'open', closed: 'closed, not completed', unreadable: 'could not be read',
|
|
131
|
+
readyForMe: 'Ready, for you to build (the cloud is not building)', stuck: 'build with no activity for', restart: 'remove relevo:building and apply relevo:ready again, or run',
|
|
132
|
+
},
|
|
133
|
+
es: {
|
|
134
|
+
nothing: 'Nada espera por ti.', onMe: 'Esperando por ti', review: 'Pull requests por revisar', ship: 'Aprobadas, listas para entregar',
|
|
135
|
+
others: 'Esperando a otros', spec: 'De vuelta con quien escribe las specs', blocked: 'Bloqueadas', needsReviewer: 'Sin revisor', milestone: 'Milestone',
|
|
136
|
+
noDue: 'no tiene fecha límite, así que no se calcula la advertencia de capacidad', merged: 'fusionadas', inReview: 'en revisión', queued: 'en cola', warn: 'advertencia de capacidad: hay más trabajo del que el equipo puede revisar antes de la fecha límite',
|
|
137
|
+
alerts: 'Alertas', staleReview: 'revisión esperando', staleBranch: 'rama atrasada respecto a la base por',
|
|
138
|
+
waitingFor: 'esperando', open: 'abierto', closed: 'cerrado, sin completar', unreadable: 'no se pudo leer',
|
|
139
|
+
readyForMe: 'Listas, para que las construyas (la nube no está construyendo)', stuck: 'construcción sin actividad desde hace', restart: 'quita relevo:building y vuelve a poner relevo:ready, o ejecuta',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** The report as text, in the language of relevo.yml. */
|
|
144
|
+
export function renderStatus(report, language = 'en') {
|
|
145
|
+
const t = T[String(language).startsWith('es') ? 'es' : 'en'];
|
|
146
|
+
const out = [];
|
|
147
|
+
const section = (title, lines) => {
|
|
148
|
+
if (lines.length) out.push(`${title}:`, ...lines.map((l) => ` ${l}`), '');
|
|
149
|
+
};
|
|
150
|
+
const waits = (e) => (e.blockedBy ?? []).map((b) => `${t.waitingFor} ${b.ref} (${b.state === 'unreadable' ? t.unreadable : b.lifecycle ?? t[b.state]})`).join(', ');
|
|
151
|
+
const withWaits = (e, base) => (e.blockedBy ? `${base} — ${waits(e)}` : base);
|
|
152
|
+
const sw = report.specWriter;
|
|
153
|
+
if (sw) {
|
|
154
|
+
section(t.spec, sw.needsSpec.map((e) => `#${e.number} ${e.title}`));
|
|
155
|
+
section(t.blocked, sw.blocked.map((e) => withWaits(e, `#${e.number} ${e.title}`)));
|
|
156
|
+
section(t.needsReviewer, sw.needsReviewer.map((e) => `#${e.number} ${e.title}`));
|
|
157
|
+
}
|
|
158
|
+
section(t.onMe, report.onMe.map((e) => `#${e.number} ${e.title} → ${e.command}`));
|
|
159
|
+
section(t.readyForMe, report.readyForMe.map((e) => `#${e.number} ${e.title} → ${e.command}`));
|
|
160
|
+
section(t.review, report.toReview.map((r) => `#${r.number} ${r.pr}`));
|
|
161
|
+
section(t.ship, report.toShip.map((r) => `#${r.number} → ${r.command}`));
|
|
162
|
+
section(t.others, report.waitingOnOthers.map((e) => withWaits(e, `#${e.number} ${e.title} (${e.state})`)));
|
|
163
|
+
if (!sw && !report.onMe.length && !report.toReview.length && !report.toShip.length) out.unshift(t.nothing, '');
|
|
164
|
+
report.milestones.forEach((m) => {
|
|
165
|
+
out.push(`${t.milestone} "${m.title}": ${m.merged} ${t.merged}, ${m.inReview} ${t.inReview}, ${m.queued} ${t.queued}`);
|
|
166
|
+
if (!m.dueOn) out.push(` ${t.milestone} ${t.noDue}`);
|
|
167
|
+
else if (m.warning) out.push(` ⚠ ${t.warn} (${m.queued + m.inFlight} > ${m.capacity.reviews})`);
|
|
168
|
+
});
|
|
169
|
+
if (report.milestones.length) out.push('');
|
|
170
|
+
section(t.alerts, report.alerts.map((a) => {
|
|
171
|
+
if (a.type === 'stuck-build') return `#${a.issue} ${t.stuck} ${a.hours}h: ${t.restart} ${a.command}`;
|
|
172
|
+
return a.type === 'stale-review'
|
|
173
|
+
? `#${a.issue} ${t.staleReview} ${a.hours}h (@${a.reviewer}) ${a.pr}`
|
|
174
|
+
: `#${a.issue} ${t.staleBranch} ${a.behind} ${a.pr}`;
|
|
175
|
+
}));
|
|
176
|
+
while (out.length && out[out.length - 1] === '') out.pop();
|
|
177
|
+
return out.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The daily digest: only who is waiting on what, and the alerts. Null when there is nothing to say. */
|
|
181
|
+
export function renderDigest(report, language = 'en') {
|
|
182
|
+
const es = String(language).startsWith('es');
|
|
183
|
+
const people = Object.keys(report.waiting).sort();
|
|
184
|
+
if (!people.length && !report.alerts.length) return null;
|
|
185
|
+
const lines = [es ? 'Resumen diario de Relevo' : 'Relevo daily digest', ''];
|
|
186
|
+
people.forEach((login) => {
|
|
187
|
+
lines.push(`@${login}: ${report.waiting[login].map((w) => `#${w.number} (${w.state})`).join(', ')}`);
|
|
188
|
+
});
|
|
189
|
+
if (report.alerts.length) {
|
|
190
|
+
lines.push('', `${es ? 'Alertas' : 'Alerts'}:`);
|
|
191
|
+
report.alerts.forEach((a) => {
|
|
192
|
+
if (a.type === 'stuck-build') lines.push(`- #${a.issue} ${es ? 'construcción sin actividad desde hace' : 'build with no activity for'} ${a.hours}h`);
|
|
193
|
+
else lines.push(a.type === 'stale-review'
|
|
194
|
+
? `- #${a.issue} ${es ? 'revisión esperando' : 'review waiting'} ${a.hours}h (@${a.reviewer})`
|
|
195
|
+
: `- #${a.issue} ${es ? 'rama atrasada por' : 'branch behind the base by'} ${a.behind}`);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return lines.join('\n');
|
|
199
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Edits tasks.md of a change: appends a section of corrections for a review iteration.
|
|
2
|
+
// Implements "Specification first" of openspec/specs/fix/spec.md.
|
|
3
|
+
|
|
4
|
+
const SECTION_RE = /^##\s+(\d+)\.\s+(.*)$/;
|
|
5
|
+
const ITERATION_RE = /Review corrections \[Iteration (\d+)\]/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Append "## N. Review corrections [Iteration K]" with one unchecked task per title.
|
|
9
|
+
* Returns { text, section, iteration, ids }.
|
|
10
|
+
*/
|
|
11
|
+
export function addCorrections(tasksText, titles) {
|
|
12
|
+
const clean = titles.map((t) => String(t).trim()).filter(Boolean);
|
|
13
|
+
if (clean.length === 0) throw new Error('give at least one correction task');
|
|
14
|
+
const lines = String(tasksText).split('\n');
|
|
15
|
+
let last = 0;
|
|
16
|
+
let iteration = 0;
|
|
17
|
+
lines.forEach((line) => {
|
|
18
|
+
const s = line.match(SECTION_RE);
|
|
19
|
+
if (!s) return;
|
|
20
|
+
last = Math.max(last, Number(s[1]));
|
|
21
|
+
const it = line.match(ITERATION_RE);
|
|
22
|
+
if (it) iteration = Math.max(iteration, Number(it[1]));
|
|
23
|
+
});
|
|
24
|
+
const section = last + 1;
|
|
25
|
+
const next = iteration + 1;
|
|
26
|
+
const ids = clean.map((_, i) => `${section}.${i + 1}`);
|
|
27
|
+
const block = [`## ${section}. Review corrections [Iteration ${next}]`, '', ...clean.map((t, i) => `- [ ] ${ids[i]} ${t}`), ''].join('\n');
|
|
28
|
+
const base = String(tasksText).replace(/\s*$/, '\n');
|
|
29
|
+
return { text: `${base}\n${block}`, section, iteration: next, ids };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Ids of the tasks that are still unchecked. */
|
|
33
|
+
export function uncheckedTasks(tasksText) {
|
|
34
|
+
return [...String(tasksText).matchAll(/^\s*- \[ \] (\d+\.\d+)\b/gm)].map((m) => m[1]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Tasks of the last "Review corrections" section: { found, ids, tasks } with each task's id and text. */
|
|
38
|
+
export function correctionSection(tasksText) {
|
|
39
|
+
const lines = String(tasksText).split('\n');
|
|
40
|
+
let start = -1;
|
|
41
|
+
lines.forEach((line, i) => {
|
|
42
|
+
if (ITERATION_RE.test(line) && SECTION_RE.test(line)) start = i;
|
|
43
|
+
});
|
|
44
|
+
if (start === -1) return { found: false, ids: [], tasks: [] };
|
|
45
|
+
const tasks = [];
|
|
46
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
47
|
+
if (SECTION_RE.test(lines[i])) break;
|
|
48
|
+
const m = lines[i].match(/^\s*- \[[ x]\] (\d+\.\d+)\s+(.*)$/);
|
|
49
|
+
if (m) tasks.push({ id: m[1], text: m[2].trim() });
|
|
50
|
+
}
|
|
51
|
+
return { found: true, ids: tasks.map((t) => t.id), tasks };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEVICE_ONLY_RE = /\(device only\)\s*$/i;
|
|
55
|
+
const TEST_RE = /^test:/i;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Checks the task list around a fix.
|
|
59
|
+
* before the developer has not run: the only unchecked tasks are the corrections, and there is a regression test
|
|
60
|
+
* task unless every correction is device-only
|
|
61
|
+
* after QA passed: nothing is unchecked
|
|
62
|
+
* Returns { ok, reasons }.
|
|
63
|
+
*/
|
|
64
|
+
export function checkFixTasks(tasksText, stage) {
|
|
65
|
+
const open = uncheckedTasks(tasksText);
|
|
66
|
+
if (stage === 'after') {
|
|
67
|
+
return open.length === 0
|
|
68
|
+
? { ok: true, reasons: [] }
|
|
69
|
+
: { ok: false, reasons: [`tasks are still unchecked: ${open.join(', ')}`] };
|
|
70
|
+
}
|
|
71
|
+
const section = correctionSection(tasksText);
|
|
72
|
+
if (!section.found) return { ok: false, reasons: ['tasks.md has no "Review corrections" section: add the corrections first'] };
|
|
73
|
+
const reasons = [];
|
|
74
|
+
const old = open.filter((id) => !section.ids.includes(id));
|
|
75
|
+
if (old.length > 0) reasons.push(`tasks that are not corrections are still unchecked: ${old.join(', ')}`);
|
|
76
|
+
const allDevice = section.tasks.length > 0 && section.tasks.every((t) => DEVICE_ONLY_RE.test(t.text));
|
|
77
|
+
if (!allDevice && !section.tasks.some((t) => TEST_RE.test(t.text))) {
|
|
78
|
+
reasons.push('a regression test is missing: add a correction task that starts with "Test:", or mark the corrections "(device only)"');
|
|
79
|
+
}
|
|
80
|
+
return { ok: reasons.length === 0, reasons };
|
|
81
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The rules of a driven web review that must not vary: which origins a page may reach, what a review script may
|
|
2
|
+
// contain, how a secret is kept out of the output, and the log the audit reads.
|
|
3
|
+
// Implements "The agent operates the app only within fixed limits", "A web review script is reviewed before it runs",
|
|
4
|
+
// "Credentials of a driven review are names, not values" and "The audit understands a browser" of openspec/specs/review/spec.md.
|
|
5
|
+
|
|
6
|
+
export const WEB_PACKAGE = 'web';
|
|
7
|
+
|
|
8
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
9
|
+
// URLs that do not reach the network.
|
|
10
|
+
const OFFLINE_PROTOCOLS = new Set(['data:', 'blob:', 'about:']);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* May a request to `url` leave the browser? Only localhost, the origin under review and the listed origins.
|
|
14
|
+
* `allowed` is the list of origins (review.allowed_origins); `baseUrl` is the page under review.
|
|
15
|
+
*/
|
|
16
|
+
export function isAllowedOrigin(url, { baseUrl, allowed = [] } = {}) {
|
|
17
|
+
let u;
|
|
18
|
+
try {
|
|
19
|
+
u = new URL(url);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (OFFLINE_PROTOCOLS.has(u.protocol)) return true;
|
|
24
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
25
|
+
if (LOCAL_HOSTS.has(u.hostname)) return true;
|
|
26
|
+
const origins = new Set(allowed);
|
|
27
|
+
if (baseUrl) {
|
|
28
|
+
try {
|
|
29
|
+
origins.add(new URL(baseUrl).origin);
|
|
30
|
+
} catch { /* an unusable base adds nothing */ }
|
|
31
|
+
}
|
|
32
|
+
return origins.has(u.origin);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ALLOWED_MODULES = new Set(['node:assert', 'node:assert/strict', 'assert', 'assert/strict', 'playwright', '@playwright/test']);
|
|
36
|
+
const FORBIDDEN = [
|
|
37
|
+
[/\bchild_process\b/, 'it reaches child_process'],
|
|
38
|
+
[/\beval\s*\(/, 'it calls eval'],
|
|
39
|
+
[/\bnew\s+Function\b|\bFunction\s*\(/, 'it builds code with Function'],
|
|
40
|
+
[/\bprocess\s*\.\s*(env|exit|kill|binding|argv)\b/, 'it reaches the process (credentials come from credentials(name))'],
|
|
41
|
+
[/\bglobalThis\s*\.\s*process\b/, 'it reaches the process'],
|
|
42
|
+
[/\bfetch\s*\(/, 'it makes a request outside the browser with fetch'],
|
|
43
|
+
[/\brequire\.resolve\b|\bimport\.meta\b/, 'it inspects the module system'],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A tripwire for a review script written by the agent: it lists what the script must not do. It is not a sandbox;
|
|
48
|
+
* the real protections are the review by QA, the origin guard and the clean profile.
|
|
49
|
+
*/
|
|
50
|
+
export function scanReviewScript(text) {
|
|
51
|
+
const source = String(text ?? '');
|
|
52
|
+
const problems = [];
|
|
53
|
+
const modules = [
|
|
54
|
+
...[...source.matchAll(/\bimport\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g)].map((m) => m[1]),
|
|
55
|
+
...[...source.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1]),
|
|
56
|
+
...[...source.matchAll(/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1]),
|
|
57
|
+
];
|
|
58
|
+
[...new Set(modules)].filter((m) => !ALLOWED_MODULES.has(m)).forEach((m) => problems.push(`it imports "${m}"; only the assertion and browser libraries are allowed`));
|
|
59
|
+
if (/\bimport\s*\(\s*[^'"\s)]/.test(source) || /\brequire\s*\(\s*[^'"\s)]/.test(source)) problems.push('it loads a module by a computed name');
|
|
60
|
+
FORBIDDEN.forEach(([re, why]) => {
|
|
61
|
+
if (re.test(source)) problems.push(why);
|
|
62
|
+
});
|
|
63
|
+
return problems;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Replace the value of every secret by [redacted:NAME]. `secrets` maps a variable name to its value. */
|
|
67
|
+
export function redact(text, secrets = {}) {
|
|
68
|
+
let out = String(text ?? '');
|
|
69
|
+
Object.entries(secrets)
|
|
70
|
+
.filter(([, value]) => typeof value === 'string' && value.length >= 3)
|
|
71
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
72
|
+
.forEach(([name, value]) => {
|
|
73
|
+
out = out.split(value).join(`[redacted:${name}]`);
|
|
74
|
+
});
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
|
79
|
+
const stamp = (d) => `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The browser's errors as a log in the format the audit reads. `events` are { type, text } with type
|
|
83
|
+
* `console` (a console error), `pageerror` (an uncaught exception), `requestfailed`, `response` (a 4xx or 5xx) or
|
|
84
|
+
* `blocked` (a request the origin guard stopped). The first line names the "process", so the audit can filter by
|
|
85
|
+
* the package `web`. Returns '' only when there were no events at all, which the caller still writes with the header.
|
|
86
|
+
*/
|
|
87
|
+
export function formatBrowserLog(events, { origin = 'page', now = new Date() } = {}) {
|
|
88
|
+
const line = (level, tag, text) => `${stamp(now)} 1 1 ${level} ${tag}: ${String(text).replace(/\s*\n\s*/g, ' ').trim()}`;
|
|
89
|
+
const out = [line('I', 'ActivityManager', `Start proc 1:${WEB_PACKAGE}/${origin} for browser`)];
|
|
90
|
+
events.forEach((e) => {
|
|
91
|
+
if (e.type === 'pageerror') out.push(line('E', WEB_PACKAGE, `Uncaught ${e.text}`));
|
|
92
|
+
else if (e.type === 'console') out.push(line('E', WEB_PACKAGE, `console.error ${e.text}`));
|
|
93
|
+
else if (e.type === 'requestfailed') out.push(line('E', WEB_PACKAGE, `requestfailed ${e.text}`));
|
|
94
|
+
else if (e.type === 'response') out.push(line('E', WEB_PACKAGE, `response ${e.text}`));
|
|
95
|
+
else if (e.type === 'blocked') out.push(line('E', WEB_PACKAGE, `blocked request ${e.text}`));
|
|
96
|
+
});
|
|
97
|
+
return `${out.join('\n')}\n`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Minimal parser for the YAML subset used by relevo.yml. No dependencies.
|
|
2
|
+
//
|
|
3
|
+
// Supported: nested maps (2+ space indentation), block lists of scalars,
|
|
4
|
+
// inline lists of scalars ([a, b]), empty inline map ({}), comments,
|
|
5
|
+
// quoted strings, booleans, null, integers and decimals.
|
|
6
|
+
// Not supported (reported as errors): tabs, anchors, multi-line strings,
|
|
7
|
+
// lists of maps, non-empty inline maps, duplicate keys.
|
|
8
|
+
// Quote any string that contains "#" or quote characters.
|
|
9
|
+
|
|
10
|
+
export class YamlLiteError extends Error {
|
|
11
|
+
constructor(message, line) {
|
|
12
|
+
super(`line ${line}: ${message}`);
|
|
13
|
+
this.name = 'YamlLiteError';
|
|
14
|
+
this.line = line;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripComment(raw) {
|
|
19
|
+
let inSingle = false;
|
|
20
|
+
let inDouble = false;
|
|
21
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
22
|
+
const c = raw[i];
|
|
23
|
+
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
24
|
+
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
25
|
+
else if (c === '#' && !inSingle && !inDouble && (i === 0 || /\s/.test(raw[i - 1]))) {
|
|
26
|
+
return raw.slice(0, i);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return raw;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function splitInline(inner, line) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
let current = '';
|
|
35
|
+
let inSingle = false;
|
|
36
|
+
let inDouble = false;
|
|
37
|
+
for (const c of inner) {
|
|
38
|
+
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
39
|
+
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
40
|
+
if (c === ',' && !inSingle && !inDouble) {
|
|
41
|
+
parts.push(current);
|
|
42
|
+
current = '';
|
|
43
|
+
} else {
|
|
44
|
+
current += c;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (inSingle || inDouble) throw new YamlLiteError('unterminated quote in inline list', line);
|
|
48
|
+
parts.push(current);
|
|
49
|
+
return parts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseScalar(text, line = 0) {
|
|
53
|
+
const t = text.trim();
|
|
54
|
+
if (t === '') return null;
|
|
55
|
+
if (t.startsWith('"')) {
|
|
56
|
+
if (t.length < 2 || !t.endsWith('"')) throw new YamlLiteError('unterminated double-quoted string', line);
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(t);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new YamlLiteError('invalid escape in double-quoted string', line);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (t.startsWith("'")) {
|
|
64
|
+
if (t.length < 2 || !t.endsWith("'")) throw new YamlLiteError('unterminated single-quoted string', line);
|
|
65
|
+
return t.slice(1, -1).replace(/''/g, "'");
|
|
66
|
+
}
|
|
67
|
+
if (t.startsWith('[')) {
|
|
68
|
+
if (!t.endsWith(']')) throw new YamlLiteError('unterminated inline list', line);
|
|
69
|
+
const inner = t.slice(1, -1).trim();
|
|
70
|
+
if (inner === '') return [];
|
|
71
|
+
return splitInline(inner, line).map((part) => parseScalar(part, line));
|
|
72
|
+
}
|
|
73
|
+
if (t === '{}') return {};
|
|
74
|
+
if (t.startsWith('{')) throw new YamlLiteError('inline maps are not supported', line);
|
|
75
|
+
if (t.startsWith('&') || t.startsWith('*') || t === '|' || t === '>') {
|
|
76
|
+
throw new YamlLiteError('anchors and multi-line strings are not supported', line);
|
|
77
|
+
}
|
|
78
|
+
if (t === 'true') return true;
|
|
79
|
+
if (t === 'false') return false;
|
|
80
|
+
if (t === 'null' || t === '~') return null;
|
|
81
|
+
if (/^-?\d+$/.test(t)) return Number.parseInt(t, 10);
|
|
82
|
+
if (/^-?\d+\.\d+$/.test(t)) return Number.parseFloat(t);
|
|
83
|
+
return t;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const KEY_RE = /^([A-Za-z0-9_.-]+):(?:\s+(.*))?$/;
|
|
87
|
+
|
|
88
|
+
export function parse(text) {
|
|
89
|
+
const rawLines = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
90
|
+
const items = [];
|
|
91
|
+
rawLines.forEach((raw, idx) => {
|
|
92
|
+
const line = idx + 1;
|
|
93
|
+
const withoutComment = stripComment(raw);
|
|
94
|
+
if (withoutComment.trim() === '') return;
|
|
95
|
+
if (/^\s*\t/.test(withoutComment)) throw new YamlLiteError('tabs are not allowed for indentation', line);
|
|
96
|
+
const m = withoutComment.match(/^( *)(\S.*?)\s*$/);
|
|
97
|
+
items.push({ indent: m[1].length, content: m[2], line });
|
|
98
|
+
});
|
|
99
|
+
if (items.length === 0) return {};
|
|
100
|
+
if (items[0].indent !== 0) throw new YamlLiteError('top level must not be indented', items[0].line);
|
|
101
|
+
|
|
102
|
+
let pos = 0;
|
|
103
|
+
|
|
104
|
+
function isListItem(item) {
|
|
105
|
+
return item.content === '-' || item.content.startsWith('- ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseList(indent) {
|
|
109
|
+
const list = [];
|
|
110
|
+
while (pos < items.length && items[pos].indent === indent && isListItem(items[pos])) {
|
|
111
|
+
const item = items[pos];
|
|
112
|
+
const rest = item.content.slice(1).trim();
|
|
113
|
+
const quoted = rest.startsWith('"') || rest.startsWith("'");
|
|
114
|
+
if (!quoted && /^[A-Za-z0-9_.-]+:(\s|$)/.test(rest)) {
|
|
115
|
+
throw new YamlLiteError('lists of maps are not supported', item.line);
|
|
116
|
+
}
|
|
117
|
+
list.push(parseScalar(rest, item.line));
|
|
118
|
+
pos += 1;
|
|
119
|
+
}
|
|
120
|
+
return list;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseMap(indent) {
|
|
124
|
+
const map = {};
|
|
125
|
+
while (pos < items.length && items[pos].indent === indent) {
|
|
126
|
+
const item = items[pos];
|
|
127
|
+
if (isListItem(item)) throw new YamlLiteError('unexpected list item in a map', item.line);
|
|
128
|
+
const m = item.content.match(KEY_RE);
|
|
129
|
+
if (!m) throw new YamlLiteError(`expected "key: value", got "${item.content}"`, item.line);
|
|
130
|
+
const key = m[1];
|
|
131
|
+
if (Object.prototype.hasOwnProperty.call(map, key)) {
|
|
132
|
+
throw new YamlLiteError(`duplicate key "${key}"`, item.line);
|
|
133
|
+
}
|
|
134
|
+
pos += 1;
|
|
135
|
+
if (m[2] !== undefined && m[2].trim() !== '') {
|
|
136
|
+
map[key] = parseScalar(m[2], item.line);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const next = items[pos];
|
|
140
|
+
if (next && next.indent > indent) {
|
|
141
|
+
map[key] = isListItem(next) ? parseList(next.indent) : parseMap(next.indent);
|
|
142
|
+
} else if (next && next.indent === indent && isListItem(next)) {
|
|
143
|
+
map[key] = parseList(indent);
|
|
144
|
+
} else {
|
|
145
|
+
map[key] = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (pos < items.length && items[pos].indent > indent) {
|
|
149
|
+
throw new YamlLiteError('unexpected indentation', items[pos].line);
|
|
150
|
+
}
|
|
151
|
+
return map;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result = parseMap(0);
|
|
155
|
+
if (pos < items.length) throw new YamlLiteError('unexpected content', items[pos].line);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Used when no reviewer could be chosen: labels the issue `needs-reviewer` (next to its state) and mentions the spec writer.
|
|
3
|
+
// node scripts/needs-reviewer.mjs --repo owner/name --issue 12 --title "Add total" --pr-url <url> [--config relevo.yml] [--dry-run]
|
|
4
|
+
// Prints the decision as JSON. exit 0: done exit 2: invalid configuration or bad arguments
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { loadConfig } from './lib/config.mjs';
|
|
9
|
+
import { realGh } from './lib/github.mjs';
|
|
10
|
+
import { renderMessage } from './lib/messages.mjs';
|
|
11
|
+
import { noReviewerDecision } from './lib/reviewers.mjs';
|
|
12
|
+
|
|
13
|
+
const arg = (name) => {
|
|
14
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
15
|
+
return i === -1 ? undefined : process.argv[i + 1];
|
|
16
|
+
};
|
|
17
|
+
const fail = (m) => {
|
|
18
|
+
console.error(m);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const repo = arg('repo') ?? process.env.GITHUB_REPOSITORY;
|
|
23
|
+
const issue = Number(arg('issue'));
|
|
24
|
+
const title = arg('title');
|
|
25
|
+
const prUrl = arg('pr-url');
|
|
26
|
+
if (!repo || !Number.isInteger(issue) || !title || !prUrl) fail('usage: needs-reviewer.mjs --repo owner/name --issue <n> --title <text> --pr-url <url> [--config relevo.yml] [--dry-run]');
|
|
27
|
+
|
|
28
|
+
const path = resolve(arg('config') ?? 'relevo.yml');
|
|
29
|
+
if (!existsSync(path)) fail(`relevo.yml not found at ${path}. Run /relevo:init to create it.`);
|
|
30
|
+
const loaded = loadConfig(readFileSync(path, 'utf8'));
|
|
31
|
+
if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
|
|
32
|
+
const { config } = loaded;
|
|
33
|
+
|
|
34
|
+
const dry = process.argv.includes('--dry-run');
|
|
35
|
+
const labels = dry ? [] : JSON.parse(realGh(['api', `repos/${repo}/issues/${issue}`])).labels.map((l) => (typeof l === 'string' ? l : l.name));
|
|
36
|
+
const decision = noReviewerDecision({ config, issue: { number: issue, title, pr: prUrl, labels } });
|
|
37
|
+
if (!dry) {
|
|
38
|
+
if (decision.labels.add.length) realGh(['issue', 'edit', String(issue), '--repo', repo, ...decision.labels.add.flatMap((l) => ['--add-label', l])]);
|
|
39
|
+
if (decision.comment) realGh(['issue', 'comment', String(issue), '--repo', repo, '--body', renderMessage(decision.comment.kind, decision.comment.data, config.language)]);
|
|
40
|
+
}
|
|
41
|
+
console.log(JSON.stringify(decision));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Prints the next step of an issue, in the language of the repository. Usage:
|
|
3
|
+
// node scripts/next-step.mjs --state built --issue 42 [--reason <reason>] [--language es] [--config relevo.yml] [--json]
|
|
4
|
+
// States: the lifecycle labels without the prefix (built, in-review, pr-open, needs-spec, qa-failed, ...).
|
|
5
|
+
// --reason: `missing` or `ambiguous` for needs-spec; `fix` or `delete-branch` for qa-failed.
|
|
6
|
+
// The language comes from --language, else from relevo.yml (if found), else English.
|
|
7
|
+
// exit 0: printed exit 2: unknown state or reason, a missing issue number, or bad arguments
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { loadConfig } from './lib/config.mjs';
|
|
12
|
+
import { NEXT_STEP_STATES, nextStep } from './lib/next-step.mjs';
|
|
13
|
+
|
|
14
|
+
const arg = (name) => {
|
|
15
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
16
|
+
return i === -1 ? undefined : process.argv[i + 1];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
if (!arg('state')) {
|
|
20
|
+
console.error(`usage: next-step.mjs --state <${NEXT_STEP_STATES.join('|')}> --issue <number> [--reason <reason>] [--language xx] [--json]`);
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let language = arg('language');
|
|
25
|
+
if (!language) {
|
|
26
|
+
const path = resolve(arg('config') ?? 'relevo.yml');
|
|
27
|
+
if (existsSync(path)) {
|
|
28
|
+
const loaded = loadConfig(readFileSync(path, 'utf8'));
|
|
29
|
+
if (loaded.ok) language = loaded.config.language;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const step = nextStep({ state: arg('state'), reason: arg('reason') ?? null, issue: arg('issue') ?? null, language: language ?? 'en' });
|
|
35
|
+
console.log(process.argv.includes('--json') ? JSON.stringify(step, null, 2) : step.line);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(err.message);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Picks the reviewer of a pull request. Usage:
|
|
3
|
+
// node scripts/pick-reviewer.mjs --repo owner/name --author login --pr 40 [--config relevo.yml]
|
|
4
|
+
// Prints {"reviewer": "login"} or {"reviewer": null} when nobody is eligible.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { loadConfig } from './lib/config.mjs';
|
|
9
|
+
import { openReviewCounts, realGh } from './lib/github.mjs';
|
|
10
|
+
import { pickReviewer } from './lib/reviewers.mjs';
|
|
11
|
+
|
|
12
|
+
const arg = (name) => {
|
|
13
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
14
|
+
return i === -1 ? undefined : process.argv[i + 1];
|
|
15
|
+
};
|
|
16
|
+
const fail = (m) => {
|
|
17
|
+
console.error(m);
|
|
18
|
+
process.exit(2);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const repo = arg('repo') ?? process.env.GITHUB_REPOSITORY;
|
|
22
|
+
const author = arg('author');
|
|
23
|
+
const pr = Number(arg('pr'));
|
|
24
|
+
if (!repo || !author || !Number.isInteger(pr)) fail('usage: pick-reviewer.mjs --repo owner/name --author login --pr <number> [--config relevo.yml]');
|
|
25
|
+
|
|
26
|
+
const path = resolve(arg('config') ?? 'relevo.yml');
|
|
27
|
+
if (!existsSync(path)) fail(`relevo.yml not found at ${path}. Run /relevo:init to create it.`);
|
|
28
|
+
const loaded = loadConfig(readFileSync(path, 'utf8'));
|
|
29
|
+
if (!loaded.ok) fail(loaded.errors.map((e) => `error: ${e.message}`).join('\n'));
|
|
30
|
+
const { config } = loaded;
|
|
31
|
+
|
|
32
|
+
if (config.review.mode === 'none') {
|
|
33
|
+
console.log(JSON.stringify({ reviewer: null, reason: 'review.mode is none' }));
|
|
34
|
+
} else {
|
|
35
|
+
const candidates = config.team.members.filter((m) => m !== author && m !== config.team.spec_writer);
|
|
36
|
+
const openCounts = openReviewCounts({ gh: realGh, repo, logins: candidates });
|
|
37
|
+
const reviewer = pickReviewer({
|
|
38
|
+
members: config.team.members, specWriter: config.team.spec_writer, author,
|
|
39
|
+
approvers: config.team.approvers, mode: config.review.mode, openCounts, prNumber: pr,
|
|
40
|
+
});
|
|
41
|
+
console.log(JSON.stringify({ reviewer }));
|
|
42
|
+
}
|