@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.
Files changed (139) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +51 -0
  4. package/README.es.md +90 -0
  5. package/README.md +89 -2
  6. package/agents/dev.md +101 -0
  7. package/agents/qa.md +112 -0
  8. package/package.json +42 -4
  9. package/scripts/add-corrections.mjs +21 -0
  10. package/scripts/app-id.mjs +19 -0
  11. package/scripts/archive-state.mjs +31 -0
  12. package/scripts/branch-state.mjs +31 -0
  13. package/scripts/branch-work.mjs +25 -0
  14. package/scripts/can-edit-issue.mjs +45 -0
  15. package/scripts/capture.mjs +22 -0
  16. package/scripts/check-archive.mjs +19 -0
  17. package/scripts/check-issue.mjs +41 -0
  18. package/scripts/checklist.mjs +29 -0
  19. package/scripts/cli.mjs +28 -0
  20. package/scripts/commit-change.mjs +50 -0
  21. package/scripts/conflict-scope.mjs +19 -0
  22. package/scripts/design-fetch.mjs +103 -0
  23. package/scripts/device.mjs +12 -0
  24. package/scripts/digest.mjs +50 -0
  25. package/scripts/doctor.mjs +47 -0
  26. package/scripts/fix-guard.mjs +38 -0
  27. package/scripts/fix-stash.mjs +57 -0
  28. package/scripts/fix-state.mjs +28 -0
  29. package/scripts/fix-tasks.mjs +22 -0
  30. package/scripts/go-local.mjs +49 -0
  31. package/scripts/go-plan.mjs +82 -0
  32. package/scripts/init.mjs +109 -0
  33. package/scripts/intent-to-add.mjs +20 -0
  34. package/scripts/issue-coverage.mjs +30 -0
  35. package/scripts/issue-hash.mjs +44 -0
  36. package/scripts/issue-sections.mjs +42 -0
  37. package/scripts/label-change.mjs +35 -0
  38. package/scripts/lib/appid.mjs +12 -0
  39. package/scripts/lib/archive-check.mjs +29 -0
  40. package/scripts/lib/archive-state.mjs +15 -0
  41. package/scripts/lib/branch-work.mjs +13 -0
  42. package/scripts/lib/branch.mjs +43 -0
  43. package/scripts/lib/can-edit-issue.mjs +34 -0
  44. package/scripts/lib/checklist.mjs +68 -0
  45. package/scripts/lib/cli.mjs +82 -0
  46. package/scripts/lib/commit-change.mjs +57 -0
  47. package/scripts/lib/config.mjs +296 -0
  48. package/scripts/lib/design-fetch.mjs +156 -0
  49. package/scripts/lib/device.mjs +67 -0
  50. package/scripts/lib/doctor.mjs +614 -0
  51. package/scripts/lib/execution-mode.mjs +24 -0
  52. package/scripts/lib/fix-guard.mjs +23 -0
  53. package/scripts/lib/fix-stash.mjs +17 -0
  54. package/scripts/lib/fix.mjs +31 -0
  55. package/scripts/lib/github.mjs +161 -0
  56. package/scripts/lib/go-local.mjs +102 -0
  57. package/scripts/lib/init.mjs +324 -0
  58. package/scripts/lib/intent-to-add.mjs +32 -0
  59. package/scripts/lib/issue-check.mjs +63 -0
  60. package/scripts/lib/issue-coverage.mjs +47 -0
  61. package/scripts/lib/issue-hash.mjs +25 -0
  62. package/scripts/lib/issue-parse.mjs +194 -0
  63. package/scripts/lib/issue-sections.mjs +43 -0
  64. package/scripts/lib/labels.mjs +65 -0
  65. package/scripts/lib/language.mjs +9 -0
  66. package/scripts/lib/logaudit.mjs +123 -0
  67. package/scripts/lib/messages.mjs +328 -0
  68. package/scripts/lib/next-step.mjs +144 -0
  69. package/scripts/lib/openspec-commands.mjs +44 -0
  70. package/scripts/lib/openspec-status.mjs +16 -0
  71. package/scripts/lib/plan.mjs +256 -0
  72. package/scripts/lib/platform-of.mjs +32 -0
  73. package/scripts/lib/platform-scope.mjs +32 -0
  74. package/scripts/lib/platforms.mjs +90 -0
  75. package/scripts/lib/preflight.mjs +71 -0
  76. package/scripts/lib/protected-paths.mjs +102 -0
  77. package/scripts/lib/requeue.mjs +23 -0
  78. package/scripts/lib/review-adapters.mjs +37 -0
  79. package/scripts/lib/review-brief.mjs +43 -0
  80. package/scripts/lib/review-events.mjs +56 -0
  81. package/scripts/lib/review-state.mjs +157 -0
  82. package/scripts/lib/reviewers.mjs +39 -0
  83. package/scripts/lib/role-skills.mjs +112 -0
  84. package/scripts/lib/run-flags.mjs +16 -0
  85. package/scripts/lib/run-usage.mjs +65 -0
  86. package/scripts/lib/scan.mjs +73 -0
  87. package/scripts/lib/ship.mjs +161 -0
  88. package/scripts/lib/sla.mjs +90 -0
  89. package/scripts/lib/stack-profile.mjs +169 -0
  90. package/scripts/lib/staging.mjs +21 -0
  91. package/scripts/lib/status-data.mjs +109 -0
  92. package/scripts/lib/status.mjs +199 -0
  93. package/scripts/lib/tasks-file.mjs +81 -0
  94. package/scripts/lib/web-review.mjs +98 -0
  95. package/scripts/lib/yaml-lite.mjs +157 -0
  96. package/scripts/needs-reviewer.mjs +41 -0
  97. package/scripts/next-step.mjs +39 -0
  98. package/scripts/pick-reviewer.mjs +42 -0
  99. package/scripts/platform-of.mjs +49 -0
  100. package/scripts/preflight.mjs +32 -0
  101. package/scripts/render-message.mjs +35 -0
  102. package/scripts/requeue.mjs +53 -0
  103. package/scripts/review-adapter.mjs +32 -0
  104. package/scripts/review-brief.mjs +29 -0
  105. package/scripts/review-event.mjs +70 -0
  106. package/scripts/review-sla.mjs +41 -0
  107. package/scripts/review-state.mjs +171 -0
  108. package/scripts/review-who.mjs +21 -0
  109. package/scripts/role-skills.mjs +53 -0
  110. package/scripts/run-usage.mjs +22 -0
  111. package/scripts/scan-diff.mjs +16 -0
  112. package/scripts/ship-check.mjs +54 -0
  113. package/scripts/spec-ready.mjs +40 -0
  114. package/scripts/stack-profile.mjs +67 -0
  115. package/scripts/stage.mjs +14 -0
  116. package/scripts/status.mjs +47 -0
  117. package/scripts/usage-limit.mjs +17 -0
  118. package/scripts/validate-config.mjs +44 -0
  119. package/scripts/web-review.mjs +134 -0
  120. package/skills/doctor/SKILL.md +28 -0
  121. package/skills/fix/SKILL.md +128 -0
  122. package/skills/go/SKILL.md +242 -0
  123. package/skills/init/SKILL.md +72 -0
  124. package/skills/issue/SKILL.md +128 -0
  125. package/skills/issue/references/update.md +15 -0
  126. package/skills/review/SKILL.md +157 -0
  127. package/skills/ship/SKILL.md +128 -0
  128. package/skills/status/SKILL.md +27 -0
  129. package/skills/think/SKILL.md +76 -0
  130. package/stacks/flutter.md +63 -0
  131. package/stacks/laravel.md +65 -0
  132. package/stacks/vue.md +70 -0
  133. package/templates/issue.md +99 -0
  134. package/templates/relevo.yml +59 -0
  135. package/templates/workflows/relevo-digest.yml +54 -0
  136. package/templates/workflows/relevo-go.yml +263 -0
  137. package/templates/workflows/relevo-queue.yml +72 -0
  138. package/templates/workflows/relevo-review.yml +60 -0
  139. package/templates/workflows/relevo-sla.yml +53 -0
@@ -0,0 +1,161 @@
1
+ // Decides whether a pull request may be merged and what the person must still confirm.
2
+ // Implements openspec/specs/ship/spec.md.
3
+ //
4
+ // `pr` is the JSON of `gh pr view --json number,state,isDraft,mergeable,title,author,reviews,statusCheckRollup,headRefName,headRefOid,baseRefName`.
5
+
6
+ import { resolveLabelNames } from './labels.mjs';
7
+
8
+ const OK_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
9
+
10
+ /** Latest decisive review of each person: APPROVED, CHANGES_REQUESTED or DISMISSED. Plain comments never count. */
11
+ export function latestReviewStates(reviews = []) {
12
+ const ordered = [...reviews].sort((a, b) => String(a.submittedAt ?? '').localeCompare(String(b.submittedAt ?? '')));
13
+ const states = new Map();
14
+ ordered.forEach((r) => {
15
+ const login = r.author?.login;
16
+ if (!login) return;
17
+ if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(r.state)) states.set(login, r.state);
18
+ });
19
+ return states;
20
+ }
21
+
22
+ /** Like latestReviewStates, with the commit each decisive review was given on: login -> { state, oid }. */
23
+ export function latestReviews(reviews = []) {
24
+ const ordered = [...reviews].sort((a, b) => String(a.submittedAt ?? '').localeCompare(String(b.submittedAt ?? '')));
25
+ const latest = new Map();
26
+ ordered.forEach((r) => {
27
+ const login = r.author?.login;
28
+ if (!login) return;
29
+ if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(r.state)) latest.set(login, { state: r.state, oid: r.commit?.oid ?? null });
30
+ });
31
+ return latest;
32
+ }
33
+
34
+ // state of the issue -> the command that is missing
35
+ const MISSING_COMMAND = {
36
+ built: '/relevo:review',
37
+ 'in-review': '/relevo:review',
38
+ 'qa-failed': '/relevo:fix',
39
+ 'changes-requested': '/relevo:fix',
40
+ };
41
+ const STATE_KEYS = ['ready', 'queued', 'building', 'built', 'in-review', 'pr-open', 'qa-failed', 'changes-requested', 'needs-spec', 'blocked'];
42
+
43
+ /** Summarise the checks of a pull request: none, passing, failing or pending. */
44
+ export function checkRollup(rollup = []) {
45
+ if (!rollup || rollup.length === 0) return { state: 'none', failing: [], pending: [] };
46
+ const failing = [];
47
+ const pending = [];
48
+ rollup.forEach((c) => {
49
+ if (c.__typename === 'StatusContext') {
50
+ if (['FAILURE', 'ERROR'].includes(c.state)) failing.push(c.context);
51
+ else if (c.state !== 'SUCCESS') pending.push(c.context);
52
+ return;
53
+ }
54
+ const name = c.name ?? 'a check';
55
+ if (c.status !== 'COMPLETED') pending.push(name);
56
+ else if (!OK_CONCLUSIONS.has(c.conclusion)) failing.push(name);
57
+ });
58
+ if (failing.length > 0) return { state: 'failing', failing, pending };
59
+ if (pending.length > 0) return { state: 'pending', failing, pending };
60
+ return { state: 'passing', failing, pending };
61
+ }
62
+
63
+ /**
64
+ * config effective configuration
65
+ * pr the pull request, or null when none exists for the branch
66
+ * invoker GitHub login of the person running ship
67
+ * baseline optional: the head commit recorded when ship started; an approval given on it still counts
68
+ * labels optional: the names of the issue's labels; when given, the issue must be pr-open
69
+ * issue optional: the issue number, used in the messages about the state
70
+ * Returns { allowed, reasons, confirm, summary }.
71
+ * reasons why it must stop (empty when allowed)
72
+ * confirm what the person must still confirm in writing, always including "merge"
73
+ */
74
+ export function shipDecision({ config, pr, invoker, baseline, labels, issue }) {
75
+ const reasons = [];
76
+ const warnings = [];
77
+ const confirm = ['merge'];
78
+
79
+ if (!pr) {
80
+ return { allowed: false, reasons: ['there is no pull request for this branch: it is opened by /relevo:review'], confirm: [], summary: null };
81
+ }
82
+ if (pr.state === 'MERGED') return { allowed: false, reasons: ['the pull request is already merged'], confirm: [], summary: null };
83
+ if (pr.state === 'CLOSED') return { allowed: false, reasons: ['the pull request was closed without merging'], confirm: [], summary: null };
84
+ if (pr.isDraft) reasons.push('the pull request is still a draft');
85
+
86
+ if (labels) {
87
+ const names = resolveLabelNames(config.labels);
88
+ const state = STATE_KEYS.find((k) => labels.includes(names[k])) ?? null;
89
+ if (state !== 'pr-open') {
90
+ const command = MISSING_COMMAND[state] ?? '/relevo:review';
91
+ reasons.push(`the issue is ${state ? `"${state}"` : 'not in a Relevo state'}, not "pr-open": run ${command}${issue ? ` ${issue}` : ''} first`);
92
+ }
93
+ }
94
+
95
+ if (pr.mergeable === 'CONFLICTING') reasons.push('the pull request has conflicts: the author must synchronise the branch with the base branch');
96
+ else if (pr.mergeable !== 'MERGEABLE') reasons.push('GitHub has not finished checking whether the pull request can be merged: try again in a minute');
97
+
98
+ const checks = checkRollup(pr.statusCheckRollup);
99
+ if (checks.state === 'failing') reasons.push(`checks are failing: ${checks.failing.join(', ')}`);
100
+ else if (checks.state === 'pending') reasons.push(`checks are still running: ${checks.pending.join(', ')}`);
101
+ else if (checks.state === 'none') confirm.push('no-ci');
102
+
103
+ const author = pr.author?.login;
104
+ const mode = config.review.mode;
105
+ let approvedBy = [];
106
+
107
+ if (mode === 'peer' || mode === 'roles') {
108
+ const latest = latestReviews(pr.reviews);
109
+ const eligible = (login) => login !== author && (mode !== 'roles' || config.team.approvers.includes(login));
110
+ const approvals = [...latest].filter(([login, r]) => r.state === 'APPROVED' && eligible(login));
111
+ const stale = [];
112
+ approvedBy = approvals
113
+ .filter(([login, r]) => {
114
+ if (!pr.headRefOid) return true;
115
+ if (!r.oid) {
116
+ if (!warnings.includes('review-commit-unknown')) warnings.push('review-commit-unknown');
117
+ return true;
118
+ }
119
+ if (r.oid === pr.headRefOid || (baseline && r.oid === baseline)) return true;
120
+ stale.push(`${login} (${r.oid.slice(0, 7)})`);
121
+ return false;
122
+ })
123
+ .map(([login]) => login);
124
+ const changesBy = [...latest].filter(([, r]) => r.state === 'CHANGES_REQUESTED').map(([login]) => login);
125
+ if (stale.length > 0) reasons.push(`the approval of ${stale.join(', ')} was given on an older commit than the current ${pr.headRefOid.slice(0, 7)}: a new approval is needed on the current code`);
126
+
127
+ if (changesBy.length > 0) reasons.push(`changes were requested by ${changesBy.join(', ')} and not yet resolved`);
128
+ if (approvedBy.length === 0) {
129
+ reasons.push(mode === 'roles' ? 'waiting for an approval from one of the approvers (someone other than the author)' : 'waiting for an approval from someone other than the author');
130
+ }
131
+
132
+ if (config.review.merger === 'approver' && invoker === author) {
133
+ const peers = approvedBy.length > 0 ? approvedBy : config.team.members.filter((m) => m !== author && m !== config.team.spec_writer);
134
+ reasons.push(`the author cannot run ship here (review.merger is "approver"): ${peers.join(' or ')} should run it`);
135
+ } else if (config.review.merger === 'approver') {
136
+ const { spec_writer: writer, members, approvers } = config.team;
137
+ let eligibleToRun = true;
138
+ if (invoker === writer) {
139
+ reasons.push('the spec writer never reviews or merges: someone else should run ship');
140
+ eligibleToRun = false;
141
+ } else if (members.length > 0 && !members.includes(invoker)) {
142
+ reasons.push(`${invoker} is not listed in team.members: only members run ship`);
143
+ eligibleToRun = false;
144
+ } else if (mode === 'roles' && !approvers.includes(invoker)) {
145
+ reasons.push(`${invoker} is not one of the approvers (team.approvers): one of them should run ship`);
146
+ eligibleToRun = false;
147
+ }
148
+ if (eligibleToRun && approvedBy.length > 0 && !approvedBy.includes(invoker)) confirm.push('not-approver');
149
+ }
150
+ if (config.review.merger === 'author' && invoker !== author) {
151
+ reasons.push(`the author (${author}) runs ship here (review.merger is "author")`);
152
+ }
153
+ }
154
+
155
+ return {
156
+ allowed: reasons.length === 0,
157
+ reasons,
158
+ confirm,
159
+ summary: { pr: pr.number, title: pr.title, author, branch: pr.headRefName, base: pr.baseRefName, mode, approvedBy, checks: checks.state, ...(warnings.length > 0 ? { warnings } : {}) },
160
+ };
161
+ }
@@ -0,0 +1,90 @@
1
+ // Review reminder and escalation. Implements "Review reminders and escalation" of
2
+ // openspec/specs/issue-lifecycle/spec.md.
3
+ // The GitHub client is injected (`gh(args) -> stdout`), so everything here is testable without a network.
4
+
5
+ import { issueFromBranch } from './branch.mjs';
6
+ import { resolveLabelNames } from './labels.mjs';
7
+ import { renderMessage } from './messages.mjs';
8
+
9
+ /** Hidden markers that make the reminder and the escalation happen once. */
10
+ export const MARKERS = {
11
+ review_reminder: '<!-- relevo:review_reminder -->',
12
+ review_escalation: '<!-- relevo:review_escalation -->',
13
+ };
14
+
15
+ const HOUR = 3600 * 1000;
16
+
17
+ /**
18
+ * prs: [{ number, issue, url, reviewer, requested_at, reviewed, reminded, escalated, labels: [names] }]
19
+ * Returns decisions: { pr, issue, action: 'remind' | 'escalate', hours, labels: { add }, comment: { kind, data } | null }
20
+ */
21
+ export function planSla({ config, prs, now = new Date() }) {
22
+ const sla = config.limits.review_sla_hours;
23
+ const names = resolveLabelNames(config.labels);
24
+ const decisions = [];
25
+ prs.forEach((pr) => {
26
+ if (pr.reviewed || !pr.reviewer || !pr.requested_at) return;
27
+ const waited = (new Date(now).getTime() - Date.parse(pr.requested_at)) / HOUR;
28
+ const hours = Math.floor(waited);
29
+ if (waited >= 2 * sla && !pr.escalated) {
30
+ const specWriter = config.team.spec_writer;
31
+ decisions.push({
32
+ pr: pr.number, issue: pr.issue, action: 'escalate', hours,
33
+ labels: { add: pr.labels.includes(names['needs-reviewer']) ? [] : [names['needs-reviewer']] },
34
+ comment: specWriter
35
+ ? { kind: 'review_escalation', data: { spec_writer: specWriter, issue: pr.issue, pr: pr.url, reviewer: pr.reviewer, hours } }
36
+ : null,
37
+ });
38
+ } else if (waited >= sla && waited < 2 * sla && !pr.reminded && !pr.escalated) {
39
+ decisions.push({
40
+ pr: pr.number, issue: pr.issue, action: 'remind', hours, labels: { add: [] },
41
+ comment: { kind: 'review_reminder', data: { reviewer: pr.reviewer, issue: pr.issue, pr: pr.url, hours } },
42
+ });
43
+ }
44
+ });
45
+ return decisions;
46
+ }
47
+
48
+ const json = (gh, args) => JSON.parse(gh(args));
49
+ const paged = (gh, path) => JSON.parse(gh(['api', '--paginate', '--slurp', path])).flat();
50
+
51
+ /** Open pull requests of Relevo issues that wait for a review, with what the SLA needs to know. */
52
+ export function gatherPrs({ gh, repo, config }) {
53
+ const names = resolveLabelNames(config.labels);
54
+ const prs = [];
55
+ paged(gh, `repos/${repo}/pulls?state=open&per_page=100`).forEach((pr) => {
56
+ const issue = issueFromBranch(pr.head.ref);
57
+ if (!issue) return;
58
+ const labels = json(gh, ['api', `repos/${repo}/issues/${issue}`]).labels.map((l) => (typeof l === 'string' ? l : l.name));
59
+ if (!labels.includes(names['pr-open'])) return;
60
+ const reviewer = (pr.requested_reviewers ?? [])[0]?.login ?? null;
61
+ const timeline = paged(gh, `repos/${repo}/issues/${pr.number}/timeline?per_page=100`);
62
+ const requests = timeline.filter((e) => e.event === 'review_requested' && (!reviewer || e.requested_reviewer?.login === reviewer));
63
+ const requested_at = requests.length ? requests[requests.length - 1].created_at : null;
64
+ const reviews = paged(gh, `repos/${repo}/pulls/${pr.number}/reviews?per_page=100`)
65
+ .filter((r) => r.submitted_at && r.user?.login !== pr.user?.login);
66
+ const reviewed = requested_at ? reviews.some((r) => Date.parse(r.submitted_at) >= Date.parse(requested_at)) : reviews.length > 0;
67
+ const comments = paged(gh, `repos/${repo}/issues/${pr.number}/comments?per_page=100`).map((c) => c.body ?? '');
68
+ prs.push({
69
+ number: pr.number, issue, url: pr.html_url, reviewer, requested_at, reviewed, labels,
70
+ reminded: comments.some((b) => b.includes(MARKERS.review_reminder)),
71
+ escalated: comments.some((b) => b.includes(MARKERS.review_escalation)),
72
+ });
73
+ });
74
+ return prs;
75
+ }
76
+
77
+ /** The `gh` commands that carry out one decision. Pure. */
78
+ export function slaCommands(decision, { repo, config }) {
79
+ const commands = [];
80
+ if (decision.labels.add.length) {
81
+ const edit = ['issue', 'edit', String(decision.issue), '--repo', repo];
82
+ decision.labels.add.forEach((l) => edit.push('--add-label', l));
83
+ commands.push(edit);
84
+ }
85
+ if (decision.comment) {
86
+ const body = `${renderMessage(decision.comment.kind, decision.comment.data, config.language)}\n\n${MARKERS[decision.comment.kind]}`;
87
+ commands.push(['issue', 'comment', String(decision.pr), '--repo', repo, '--body', body]);
88
+ }
89
+ return commands;
90
+ }
@@ -0,0 +1,169 @@
1
+ // Stack profiles. Implements the "platforms" specification: a profile is a Markdown file whose header scripts read
2
+ // and whose body the agents read; the repository can add to the plugin's profile but never weaken it.
3
+ //
4
+ // The filesystem is injected (`read(path) -> text | null`) so the logic is tested without a repository.
5
+
6
+ import { parse, YamlLiteError } from './yaml-lite.mjs';
7
+
8
+ export const ROLES = ['dev', 'qa', 'issue', 'review'];
9
+ const HEADER_KEYS = new Set(['stack', 'commands', 'generated', 'tools', 'requires_skills', 'skills', 'targets']);
10
+ const STACK_RE = /^[a-z][a-z0-9-]*$/;
11
+
12
+ const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
13
+ const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string' && x.trim() !== '');
14
+ const unique = (list) => [...new Set(list)];
15
+
16
+ const emptyProfile = (stack) => ({
17
+ stack,
18
+ commands: {},
19
+ generated: [],
20
+ tools: [],
21
+ requires_skills: [],
22
+ skills: Object.fromEntries(ROLES.map((r) => [r, []])),
23
+ targets: {},
24
+ body: '',
25
+ questions: '',
26
+ });
27
+
28
+ export const QUESTIONS_HEADING = 'Questions for the issue writer';
29
+
30
+ /** Split the section "## Questions for the issue writer" (up to the next heading of level 2) out of a body. */
31
+ export function splitQuestions(text) {
32
+ const lines = String(text).split('\n');
33
+ const start = lines.findIndex((l) => /^##\s+/.test(l) && l.replace(/^##\s+/, '').trim().toLowerCase() === QUESTIONS_HEADING.toLowerCase());
34
+ if (start === -1) return { body: text, questions: '' };
35
+ let end = lines.findIndex((l, i) => i > start && /^##\s+/.test(l));
36
+ if (end === -1) end = lines.length;
37
+ return {
38
+ body: [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\n{3,}/g, '\n\n').trim(),
39
+ questions: lines.slice(start + 1, end).join('\n').trim(),
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Read the text of a profile file. Returns { profile, errors }.
45
+ * The header is the block between the first two `---` lines; the body is the rest.
46
+ */
47
+ export function parseProfile(text) {
48
+ const errors = [];
49
+ const match = String(text ?? '').replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---[ \t]*(?:\n([\s\S]*))?$/);
50
+ if (!match) return { profile: null, errors: ['the profile must start with a header between two "---" lines'] };
51
+ let raw;
52
+ try {
53
+ raw = parse(match[1]);
54
+ } catch (err) {
55
+ if (err instanceof YamlLiteError) return { profile: null, errors: [`the profile header is not valid: ${err.message}`] };
56
+ throw err;
57
+ }
58
+ if (!isMap(raw)) return { profile: null, errors: ['the profile header must be a map'] };
59
+
60
+ Object.keys(raw).filter((k) => !HEADER_KEYS.has(k)).forEach((k) => errors.push(`unknown header key "${k}"; allowed: ${[...HEADER_KEYS].join(', ')}`));
61
+ if (typeof raw.stack !== 'string' || !STACK_RE.test(raw.stack)) errors.push('the header needs "stack" with the name of the stack');
62
+
63
+ const profile = emptyProfile(raw.stack);
64
+ if (raw.commands !== undefined) {
65
+ if (!isMap(raw.commands) || Object.values(raw.commands).some((v) => typeof v !== 'string' || v.trim() === '')) errors.push('"commands" must map a name to a command');
66
+ else profile.commands = { ...raw.commands };
67
+ }
68
+ ['generated', 'tools', 'requires_skills'].forEach((key) => {
69
+ if (raw[key] === undefined) return;
70
+ if (!isStringList(raw[key])) errors.push(`"${key}" must be a list of names`);
71
+ else profile[key] = unique(raw[key]);
72
+ });
73
+ if (raw.skills !== undefined) {
74
+ if (!isMap(raw.skills)) errors.push('"skills" must map a role to a list of skill names');
75
+ else {
76
+ Object.entries(raw.skills).forEach(([role, list]) => {
77
+ if (!ROLES.includes(role)) errors.push(`"skills" has unknown role "${role}"; allowed: ${ROLES.join(', ')}`);
78
+ else if (!isStringList(list)) errors.push(`"skills.${role}" must be a list of skill names`);
79
+ else profile.skills[role] = unique(list);
80
+ });
81
+ }
82
+ }
83
+ if (raw.targets !== undefined) {
84
+ if (!isMap(raw.targets) || Object.values(raw.targets).some((v) => typeof v !== 'string')) errors.push('"targets" must map a target to the tool it needs (empty when none)');
85
+ else profile.targets = { ...raw.targets };
86
+ }
87
+ // The questions for the issue writer are kept apart from the body: dev and qa never receive them.
88
+ const { body, questions } = splitQuestions((match[2] ?? '').trim());
89
+ profile.body = body;
90
+ profile.questions = questions;
91
+ return { profile: errors.length ? null : profile, errors };
92
+ }
93
+
94
+ /**
95
+ * Add the repository's profile to the plugin's. Lists are joined, bodies follow each other, and a value the
96
+ * plugin already sets is kept (the difference is reported in `ignored`). With no plugin profile the repository's
97
+ * is the whole profile.
98
+ */
99
+ export function mergeProfiles(plugin, repository) {
100
+ if (!plugin && !repository) return null;
101
+ if (!plugin) return { ...repository, layers: ['repository'], ignored: [] };
102
+ if (!repository) return { ...plugin, layers: ['plugin'], ignored: [] };
103
+
104
+ const out = structuredClone(plugin);
105
+ const ignored = [];
106
+ ['generated', 'tools', 'requires_skills'].forEach((key) => { out[key] = unique([...plugin[key], ...repository[key]]); });
107
+ ROLES.forEach((role) => { out.skills[role] = unique([...plugin.skills[role], ...repository.skills[role]]); });
108
+ ['commands', 'targets'].forEach((key) => {
109
+ Object.entries(repository[key]).forEach(([name, value]) => {
110
+ if (!(name in plugin[key])) out[key][name] = value;
111
+ else if (plugin[key][name] !== value) ignored.push({ key: `${key}.${name}`, value, kept: plugin[key][name] });
112
+ });
113
+ });
114
+ out.body = [plugin.body, repository.body].filter(Boolean).join('\n\n');
115
+ out.questions = [plugin.questions, repository.questions].filter(Boolean).join('\n\n');
116
+ return { ...out, layers: ['plugin', 'repository'], ignored };
117
+ }
118
+
119
+ /** Apply `platforms.<name>.checks` of relevo.yml: the only way to replace a command. */
120
+ export function applyChecks(profile, checks = {}) {
121
+ const out = structuredClone(profile);
122
+ out.commands = { ...profile.commands, ...checks };
123
+ out.overridden = Object.keys(checks);
124
+ return out;
125
+ }
126
+
127
+ export const pluginProfilePath = (pluginRoot, stack) => `${pluginRoot}/stacks/${stack}.md`;
128
+ export const repositoryProfilePath = (stack) => `.relevo/stacks/${stack}.md`;
129
+
130
+ /** Where a profile for `stack` can be defined, for the messages. */
131
+ export const profileLocations = (stack) => `stacks/${stack}.md in the plugin or ${repositoryProfilePath(stack)} in the repository`;
132
+
133
+ function readProfile(read, path) {
134
+ const text = read(path);
135
+ if (text === null || text === undefined) return { profile: null, errors: [] };
136
+ const r = parseProfile(text);
137
+ return { profile: r.profile, errors: r.errors.map((e) => `${path}: ${e}`) };
138
+ }
139
+
140
+ /**
141
+ * Resolve the profile of one platform entry ({ stack, path, targets, checks }).
142
+ * Returns { ok: true, profile } or { ok: false, errors }.
143
+ */
144
+ export function resolveProfile({ platform, pluginRoot, read }) {
145
+ const plugin = readProfile(read, pluginProfilePath(pluginRoot, platform.stack));
146
+ const repository = readProfile(read, repositoryProfilePath(platform.stack));
147
+ const errors = [...plugin.errors, ...repository.errors];
148
+ if (errors.length > 0) return { ok: false, errors };
149
+ const merged = mergeProfiles(plugin.profile, repository.profile);
150
+ if (!merged) return { ok: false, errors: [`no profile for stack "${platform.stack}": define one in ${profileLocations(platform.stack)}`] };
151
+ const profile = applyChecks(merged, platform.checks ?? {});
152
+ profile.stack = platform.stack;
153
+ return { ok: true, profile };
154
+ }
155
+
156
+ /**
157
+ * Lookups for the validation of relevo.yml: the targets and the commands a stack defines, or null when no profile
158
+ * can be read (then nothing is checked against it; `doctor` reports the missing profile).
159
+ */
160
+ export function profileLookups({ pluginRoot, read }) {
161
+ const of = (stack) => {
162
+ const r = resolveProfile({ platform: { stack }, pluginRoot, read });
163
+ return r.ok ? r.profile : null;
164
+ };
165
+ return {
166
+ knownTargets: (stack) => { const p = of(stack); return p ? Object.keys(p.targets) : null; },
167
+ knownChecks: (stack) => { const p = of(stack); return p ? Object.keys(p.commands) : null; },
168
+ };
169
+ }
@@ -0,0 +1,21 @@
1
+ // Safe staging: commits must name the files they include. Never "add everything".
2
+
3
+ const FORBIDDEN_ARGS = new Set(['.', '*', ':/', '-A', '--all', '-a', '-u', '--update', '--patch', '-p', '-i', '--interactive']);
4
+
5
+ /**
6
+ * Validate a list of paths to stage and return the arguments for `git add`.
7
+ * Throws if the list is empty or tries to stage everything.
8
+ */
9
+ export function explicitStageArgs(paths) {
10
+ if (!Array.isArray(paths) || paths.length === 0) {
11
+ throw new Error('staging needs an explicit list of files; refusing to stage nothing or everything');
12
+ }
13
+ paths.forEach((p) => {
14
+ if (typeof p !== 'string' || p.trim() === '') throw new Error('staging paths must be non-empty strings');
15
+ if (FORBIDDEN_ARGS.has(p) || p.startsWith('-')) {
16
+ throw new Error(`refusing to stage "${p}": name the files explicitly instead of staging everything`);
17
+ }
18
+ if (p.split('/').includes('..')) throw new Error(`refusing to stage "${p}": path leaves the repository`);
19
+ });
20
+ return ['add', '--', ...paths];
21
+ }
@@ -0,0 +1,109 @@
1
+ // Reads what `status` and the digest need from GitHub. Read-only: every call is a GET.
2
+ // The client is injected (`gh(args) -> stdout`).
3
+
4
+ import { issueFromBranch } from './branch.mjs';
5
+ import { LABELS, resolveLabelNames } from './labels.mjs';
6
+ import { parseIssueBody } from './issue-parse.mjs';
7
+ import { cloudBuilds, CLOUD_VARIABLE } from './execution-mode.mjs';
8
+
9
+ const paged = (gh, path) => JSON.parse(gh(['api', '--paginate', '--slurp', path])).flat();
10
+
11
+ export function gatherStatus({ gh, ghLinks = gh, repo, config }) {
12
+ const names = resolveLabelNames(config.labels);
13
+ const byNumber = new Map();
14
+ LABELS.forEach((l) => {
15
+ paged(gh, `repos/${repo}/issues?state=open&per_page=100&labels=${encodeURIComponent(names[l.key])}`)
16
+ .filter((i) => !i.pull_request)
17
+ .forEach((i) => byNumber.set(i.number, {
18
+ number: i.number, title: i.title, updated_at: i.updated_at ?? null, labels: i.labels.map((x) => (typeof x === 'string' ? x : x.name)),
19
+ assignees: (i.assignees ?? []).map((a) => a.login), milestone: i.milestone?.number ?? null,
20
+ // Only blocked issues need their text: it names what they wait for in other repositories.
21
+ ...(i.labels.some((x) => (typeof x === 'string' ? x : x.name) === names.blocked) ? { body: i.body ?? '' } : {}),
22
+ }));
23
+ });
24
+ const issues = [...byNumber.values()];
25
+
26
+ const wanted = new Set(issues.map((i) => i.milestone).filter(Boolean));
27
+ const milestones = [...wanted].map((n) => {
28
+ const m = JSON.parse(gh(['api', `repos/${repo}/milestones/${n}`]));
29
+ return { number: m.number, title: m.title, due_on: m.due_on ?? null, closed_issues: m.closed_issues ?? 0 };
30
+ });
31
+
32
+ const prs = [];
33
+ paged(gh, `repos/${repo}/pulls?state=open&per_page=100`).forEach((pr) => {
34
+ const issue = issueFromBranch(pr.head.ref);
35
+ if (!issue) return;
36
+ const reviewer = (pr.requested_reviewers ?? [])[0]?.login ?? null;
37
+ const reviews = paged(gh, `repos/${repo}/pulls/${pr.number}/reviews?per_page=100`);
38
+ const latest = new Map();
39
+ reviews.filter((r) => r.user && r.user.login !== pr.user.login && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(r.state))
40
+ .forEach((r) => latest.set(r.user.login, r));
41
+ const approvedBy = [...latest.values()].filter((r) => r.state === 'APPROVED').map((r) => r.user.login);
42
+ const timeline = paged(gh, `repos/${repo}/issues/${pr.number}/timeline?per_page=100`)
43
+ .filter((e) => e.event === 'review_requested');
44
+ let behind = 0;
45
+ try {
46
+ behind = JSON.parse(gh(['api', `repos/${repo}/compare/${pr.head.sha}...${pr.base.ref}`])).ahead_by ?? 0;
47
+ } catch {
48
+ behind = 0;
49
+ }
50
+ prs.push({
51
+ number: pr.number, issue, url: pr.html_url, author: pr.user.login,
52
+ reviewer: reviewer ?? approvedBy[0] ?? null,
53
+ requested_at: timeline.length ? timeline[timeline.length - 1].created_at : null,
54
+ approved: approvedBy.length > 0, behind_by: behind,
55
+ });
56
+ });
57
+ return { issues: issues.map(({ body, ...rest }) => rest), prs, milestones, externalBlocks: readExternalBlocks({ ghLinks, issues, repo, config, names }), cloudBuilds: readCloudBuilds({ gh, repo }) };
58
+ }
59
+
60
+ /**
61
+ * What each blocked issue waits for in other (linked) repositories: { [issue number]: [{ ref, state, lifecycle }] }.
62
+ * `state` is `open`, `closed` (not completed) or `unreadable`; a dependency that is completed is not listed. The
63
+ * lifecycle is the Relevo label of the other issue, without its prefix, as that repository names it.
64
+ */
65
+ function readExternalBlocks({ ghLinks, issues, repo, config, names }) {
66
+ const linked = new Set((config.links ?? []).map((l) => l.toLowerCase()));
67
+ const out = {};
68
+ issues.filter((i) => i.labels.includes(names.blocked)).forEach((i) => {
69
+ const waits = [];
70
+ parseIssueBody(i.body ?? '').dependsOnExternal
71
+ .filter((d) => d.repo.toLowerCase() !== String(repo).toLowerCase() && linked.has(d.repo.toLowerCase()))
72
+ .forEach((d) => {
73
+ const ref = `${d.repo}#${d.number}`;
74
+ try {
75
+ const dep = JSON.parse(ghLinks(['api', `repos/${d.repo}/issues/${d.number}`]));
76
+ if (dep.state === 'closed' && dep.state_reason === 'completed') return;
77
+ const label = (dep.labels ?? []).map((x) => (typeof x === 'string' ? x : x.name)).find((n) => String(n).startsWith('relevo:'));
78
+ waits.push({ ref, state: dep.state === 'closed' ? 'closed' : 'open', lifecycle: label ? label.slice('relevo:'.length) : null });
79
+ } catch {
80
+ waits.push({ ref, state: 'unreadable', lifecycle: null });
81
+ }
82
+ });
83
+ if (waits.length) out[i.number] = waits;
84
+ });
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * Does the cloud build? The go workflow is on the default branch and the repository variable that pauses the cloud
90
+ * is not `off`. When the workflow cannot be read the answer is `true` (the cautious one: no extra rows, no claim
91
+ * that a person must build by hand).
92
+ */
93
+ function readCloudBuilds({ gh, repo }) {
94
+ let workflowPresent;
95
+ try {
96
+ gh(['api', `repos/${repo}/contents/.github/workflows/relevo-go.yml`]);
97
+ workflowPresent = true;
98
+ } catch (err) {
99
+ if (!/404|Not Found/i.test(String(err.stderr ?? err.message ?? err))) return true;
100
+ workflowPresent = false;
101
+ }
102
+ let variable = null;
103
+ try {
104
+ variable = JSON.parse(gh(['api', `repos/${repo}/actions/variables/${CLOUD_VARIABLE}`])).value ?? null;
105
+ } catch {
106
+ variable = null;
107
+ }
108
+ return cloudBuilds({ workflowPresent, variable });
109
+ }