@elalfner/relevo 0.0.0-stage → 0.3.1
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/failure-comment.mjs +30 -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 +636 -0
- package/scripts/lib/execution-mode.mjs +24 -0
- package/scripts/lib/failure-comment.mjs +19 -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 +328 -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 +341 -0
- package/scripts/lib/next-step.mjs +153 -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/wip-commit.mjs +24 -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 +129 -0
- package/skills/go/SKILL.md +250 -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 +268 -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,24 @@
|
|
|
1
|
+
// Where `go` builds. Implements the "execution-modes" specification: nothing is stored in relevo.yml, the mode is
|
|
2
|
+
// derived from what is installed, the secrets that exist and a repository variable.
|
|
3
|
+
|
|
4
|
+
export const API_KEY_SECRET = 'ANTHROPIC_API_KEY';
|
|
5
|
+
export const SUBSCRIPTION_SECRET = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
6
|
+
export const CLOUD_VARIABLE = 'RELEVO_CLOUD_GO';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The credential mode from the names of the repository secrets:
|
|
10
|
+
* { mode: 'api_key' | 'subscription' | 'both' | 'none', usable: boolean }.
|
|
11
|
+
*/
|
|
12
|
+
export function credentialMode(secretNames = []) {
|
|
13
|
+
const key = secretNames.includes(API_KEY_SECRET);
|
|
14
|
+
const token = secretNames.includes(SUBSCRIPTION_SECRET);
|
|
15
|
+
if (key && token) return { mode: 'both', usable: true };
|
|
16
|
+
if (key) return { mode: 'api_key', usable: true };
|
|
17
|
+
if (token) return { mode: 'subscription', usable: true };
|
|
18
|
+
return { mode: 'none', usable: false };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Does the cloud build? The go workflow is on the default branch and the variable is not `off`. */
|
|
22
|
+
export function cloudBuilds({ workflowPresent, variable = null }) {
|
|
23
|
+
return Boolean(workflowPresent) && String(variable ?? '').trim().toLowerCase() !== 'off';
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Finds the last failure comment of an issue and the finding in it. `fix` reads it as the defect to correct when the
|
|
2
|
+
// branch holds work that QA rejected. The comment is text written by the workflow, never an instruction.
|
|
3
|
+
import { isWipCommit } from './wip-commit.mjs';
|
|
4
|
+
|
|
5
|
+
const HEADING = /^@?\S* ?#\d+ ".*" (?:failed QA|no pasó QA) ❌/;
|
|
6
|
+
const FINDING = /^(?:Finding|Hallazgo): (.+)$/m;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* comments [{ body }] oldest first, as `gh issue view --json comments` gives them
|
|
10
|
+
* subject the subject of the last commit of the branch
|
|
11
|
+
* Returns { kept, found, finding, body }: `kept` is true when that commit is the work-in-progress commit of the issue.
|
|
12
|
+
*/
|
|
13
|
+
export function failureContext({ comments = [], subject = '', issue = null }) {
|
|
14
|
+
const kept = isWipCommit(subject, issue);
|
|
15
|
+
const last = [...comments].reverse().find((c) => HEADING.test(String(c?.body ?? '').split('\n')[0]));
|
|
16
|
+
if (!last) return { kept, found: false, finding: null, body: null };
|
|
17
|
+
const finding = (FINDING.exec(last.body) ?? [])[1] ?? null;
|
|
18
|
+
return { kept, found: true, finding, body: last.body };
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Enforces "specification first": before any code is written for a fix, the change's specification and task
|
|
2
|
+
// list must already describe the correction, and nothing outside the change folder may have been edited.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* changedFiles every modified or new path in the working tree
|
|
6
|
+
* changeDir e.g. "openspec/changes/31-summary"
|
|
7
|
+
* specLinesAdded added lines in the change's specs/ (diff against HEAD)
|
|
8
|
+
* taskLinesAdded added lines in the change's tasks.md (diff against HEAD)
|
|
9
|
+
* Returns { ok, reasons }.
|
|
10
|
+
*/
|
|
11
|
+
export function checkSpecFirst({ changedFiles, changeDir, specLinesAdded, taskLinesAdded }) {
|
|
12
|
+
const reasons = [];
|
|
13
|
+
const prefix = `${changeDir.replace(/\/+$/, '')}/`;
|
|
14
|
+
const outside = changedFiles.filter((f) => !f.startsWith(prefix));
|
|
15
|
+
if (outside.length > 0) {
|
|
16
|
+
reasons.push(`files outside the change folder were edited before the specification was ready: ${outside.slice(0, 5).join(', ')}${outside.length > 5 ? ', ...' : ''}`);
|
|
17
|
+
}
|
|
18
|
+
const scenarioLines = specLinesAdded.filter((l) => /^\+\s*(####\s+Scenario:|[-*]\s+\*\*(WHEN|THEN)\*\*)/.test(l));
|
|
19
|
+
if (scenarioLines.length === 0) reasons.push('no scenario describing the corrected behaviour was added to the change specification');
|
|
20
|
+
const newTasks = taskLinesAdded.filter((l) => /^\+\s*- \[ \] \d+\.\d+/.test(l));
|
|
21
|
+
if (newTasks.length === 0) reasons.push('no unchecked correction task was added to tasks.md');
|
|
22
|
+
return { ok: reasons.length === 0, reasons };
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The stash that keeps the work of a fix that QA rejected.
|
|
2
|
+
// Implements "Unfinished work is saved, not left in the tree" of openspec/specs/fix/spec.md.
|
|
3
|
+
|
|
4
|
+
const LINE_RE = /^(stash@\{\d+\}):\s+(?:On [^:]*|WIP on [^:]*):\s+(relevo-fix #(\d+) iteration (\d+))\s*$/;
|
|
5
|
+
|
|
6
|
+
export function stashMessage(issue, iteration) {
|
|
7
|
+
return `relevo-fix #${Number(issue)} iteration ${Number(iteration)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Newest stash of the issue from `git stash list` output: { ref, message, iteration }, or null. */
|
|
11
|
+
export function findStash(listOutput, issue) {
|
|
12
|
+
for (const line of String(listOutput).split('\n')) {
|
|
13
|
+
const m = line.match(LINE_RE);
|
|
14
|
+
if (m && Number(m[3]) === Number(issue)) return { ref: m[1], message: m[2], iteration: Number(m[4]) };
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Which state an issue returns to after a successful fix.
|
|
2
|
+
// Implements "State is preserved" of openspec/specs/fix/spec.md.
|
|
3
|
+
|
|
4
|
+
import { LABELS, resolveLabelNames, transition } from './labels.mjs';
|
|
5
|
+
|
|
6
|
+
// state before the fix -> state after, and who is told
|
|
7
|
+
const OUTCOMES = {
|
|
8
|
+
'in-review': { to: 'in-review', notify: null },
|
|
9
|
+
built: { to: 'built', notify: null },
|
|
10
|
+
'qa-failed': { to: 'built', notify: 'assignee' },
|
|
11
|
+
'changes-requested': { to: 'pr-open', notify: 'reviewer' },
|
|
12
|
+
'pr-open': { to: 'pr-open', notify: null },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const STATE_KEYS = LABELS.filter((l) => l.kind !== 'overlay').map((l) => l.key);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Work out what a fix does to an issue's labels.
|
|
19
|
+
* Returns { allowed, from, to, notify, labels } or { allowed: false, from, reason }.
|
|
20
|
+
*/
|
|
21
|
+
export function fixOutcome(currentNames, overrides = {}) {
|
|
22
|
+
const names = resolveLabelNames(overrides);
|
|
23
|
+
const from = STATE_KEYS.find((k) => currentNames.includes(names[k])) ?? null;
|
|
24
|
+
if (!from) return { allowed: false, from, reason: 'the issue has no Relevo state label, so there is nothing to fix yet' };
|
|
25
|
+
const outcome = OUTCOMES[from];
|
|
26
|
+
if (!outcome) {
|
|
27
|
+
return { allowed: false, from, reason: `a fix needs a branch with work to correct; the issue is "${from}" (go or the spec writer comes first)` };
|
|
28
|
+
}
|
|
29
|
+
const labels = outcome.to === from ? { add: [], remove: [] } : transition(currentNames, outcome.to, overrides);
|
|
30
|
+
return { allowed: true, from, to: outcome.to, notify: outcome.notify, labels };
|
|
31
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// GitHub adapter for `go`: reads the state the planner needs and turns decisions into `gh` commands.
|
|
2
|
+
// The GitHub client is injected (`gh(args) -> stdout`) so everything here is testable without a network.
|
|
3
|
+
// Not yet exercised against the live API: verify in the first pilot.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { resolveLabelNames } from './labels.mjs';
|
|
7
|
+
import { parseIssueBody } from './issue-parse.mjs';
|
|
8
|
+
import { renderMessage } from './messages.mjs';
|
|
9
|
+
|
|
10
|
+
/** The real client: runs the GitHub CLI and returns its output. */
|
|
11
|
+
export function realGh(args) {
|
|
12
|
+
return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The client for linked repositories. It is the ordinary client, except that the token of the process (`GH_TOKEN`)
|
|
17
|
+
* is replaced by `GH_LINKS_TOKEN` when that is set: a read-only token for private linked repositories. Without it
|
|
18
|
+
* public repositories are still read. `run` and `env` are injectable for the tests.
|
|
19
|
+
*/
|
|
20
|
+
export function realGhLinks({ env = process.env, run = execFileSync } = {}) {
|
|
21
|
+
const token = env.GH_LINKS_TOKEN;
|
|
22
|
+
return (args) => run('gh', args, {
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
25
|
+
...(token ? { env: { ...env, GH_TOKEN: token } } : {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function apiJson(gh, path, { paginate = false } = {}) {
|
|
30
|
+
const args = ['api'];
|
|
31
|
+
if (paginate) args.push('--paginate', '--slurp');
|
|
32
|
+
args.push(path);
|
|
33
|
+
const data = JSON.parse(gh(args));
|
|
34
|
+
return paginate ? data.flat() : data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeIssue(raw) {
|
|
38
|
+
return {
|
|
39
|
+
number: raw.number,
|
|
40
|
+
title: raw.title,
|
|
41
|
+
body: raw.body ?? '',
|
|
42
|
+
state: raw.state,
|
|
43
|
+
labels: (raw.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)),
|
|
44
|
+
assignees: (raw.assignees ?? []).map((a) => a.login),
|
|
45
|
+
milestone: raw.milestone ? { number: raw.milestone.number, due_on: raw.milestone.due_on ?? null } : null,
|
|
46
|
+
ready_at: null,
|
|
47
|
+
ready_by: null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** When and by whom the ready label was last applied, from the issue timeline. */
|
|
52
|
+
export function readyEvent(timeline, readyName) {
|
|
53
|
+
const events = timeline.filter((e) => e.event === 'labeled' && e.label?.name === readyName);
|
|
54
|
+
const last = events[events.length - 1];
|
|
55
|
+
return last ? { at: last.created_at, by: last.actor?.login ?? null } : { at: null, by: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Collect everything `planGo` needs. Returns { issues, completed, completedExternal, unreadable, running, permissions }.
|
|
60
|
+
* `running` is supplied by the caller (workflow runs in progress). `ghLinks` reads issues of linked repositories
|
|
61
|
+
* (`config.links` only; a repository outside the list is never asked).
|
|
62
|
+
*/
|
|
63
|
+
export function gather({ gh, ghLinks = gh, repo, config, running = [] }) {
|
|
64
|
+
const names = resolveLabelNames(config.labels);
|
|
65
|
+
const stateKeys = ['ready', 'queued', 'blocked', 'building', 'built'];
|
|
66
|
+
|
|
67
|
+
const byNumber = new Map();
|
|
68
|
+
stateKeys.forEach((key) => {
|
|
69
|
+
const found = apiJson(gh, `repos/${repo}/issues?state=open&per_page=100&labels=${encodeURIComponent(names[key])}`, { paginate: true });
|
|
70
|
+
found.filter((i) => !i.pull_request).forEach((i) => byNumber.set(i.number, normalizeIssue(i)));
|
|
71
|
+
});
|
|
72
|
+
const issues = [...byNumber.values()];
|
|
73
|
+
|
|
74
|
+
const candidates = issues.filter((i) => [names.ready, names.queued, names.blocked].some((n) => i.labels.includes(n)));
|
|
75
|
+
candidates.forEach((issue) => {
|
|
76
|
+
const timeline = apiJson(gh, `repos/${repo}/issues/${issue.number}/timeline?per_page=100`, { paginate: true });
|
|
77
|
+
const { at, by } = readyEvent(timeline, names.ready);
|
|
78
|
+
issue.ready_at = at;
|
|
79
|
+
issue.ready_by = by;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const wanted = new Set();
|
|
83
|
+
const wantedExternal = new Set();
|
|
84
|
+
const linked = new Set((config.links ?? []).map((l) => l.toLowerCase()));
|
|
85
|
+
candidates.forEach((i) => {
|
|
86
|
+
const parsed = parseIssueBody(i.body);
|
|
87
|
+
parsed.dependsOn.forEach((n) => wanted.add(n));
|
|
88
|
+
parsed.dependsOnExternal.forEach(({ repo: other, number }) => {
|
|
89
|
+
// The own repository written in full is a local reference; a repository outside `links` is never read.
|
|
90
|
+
if (other.toLowerCase() === String(repo).toLowerCase()) wanted.add(number);
|
|
91
|
+
else if (linked.has(other.toLowerCase())) wantedExternal.add(`${other}#${number}`);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
const completed = [];
|
|
95
|
+
wanted.forEach((n) => {
|
|
96
|
+
const dep = apiJson(gh, `repos/${repo}/issues/${n}`);
|
|
97
|
+
if (dep.state === 'closed' && dep.state_reason === 'completed') completed.push(n);
|
|
98
|
+
});
|
|
99
|
+
const completedExternal = [];
|
|
100
|
+
const unreadable = [];
|
|
101
|
+
wantedExternal.forEach((ref) => {
|
|
102
|
+
const [other, number] = ref.split('#');
|
|
103
|
+
try {
|
|
104
|
+
const dep = apiJson(ghLinks, `repos/${other}/issues/${number}`);
|
|
105
|
+
if (dep.state === 'closed' && dep.state_reason === 'completed') completedExternal.push(ref);
|
|
106
|
+
} catch {
|
|
107
|
+
unreadable.push(ref);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const permissions = {};
|
|
112
|
+
new Set(candidates.map((i) => i.ready_by).filter(Boolean)).forEach((login) => {
|
|
113
|
+
try {
|
|
114
|
+
permissions[login] = apiJson(gh, `repos/${repo}/collaborators/${login}/permission`).permission ?? 'none';
|
|
115
|
+
} catch {
|
|
116
|
+
permissions[login] = 'none';
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return { issues, completed, completedExternal, unreadable, running, permissions };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The `gh` commands that carry out one decision (labels, assignees and comment). Pure. */
|
|
124
|
+
export function commandsFor(decision, { repo, config }) {
|
|
125
|
+
const commands = [];
|
|
126
|
+
const edit = ['issue', 'edit', String(decision.issue), '--repo', repo];
|
|
127
|
+
decision.labels.add.forEach((l) => edit.push('--add-label', l));
|
|
128
|
+
decision.labels.remove.forEach((l) => edit.push('--remove-label', l));
|
|
129
|
+
(decision.assign ?? []).forEach((a) => edit.push('--add-assignee', a));
|
|
130
|
+
if (edit.length > 5) commands.push(edit);
|
|
131
|
+
if (decision.comment) {
|
|
132
|
+
const body = renderMessage(decision.comment.kind, decision.comment.data, config.language);
|
|
133
|
+
commands.push(['issue', 'comment', String(decision.issue), '--repo', repo, '--body', body]);
|
|
134
|
+
}
|
|
135
|
+
return commands;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Apply decisions. `start` decisions are skipped unless `includeStart` is set: the build itself is done by
|
|
140
|
+
* the skill, and only the issue it is about to build should be marked as building. Returns the commands run.
|
|
141
|
+
*/
|
|
142
|
+
export function applyDecisions(decisions, { gh, repo, config, includeStart = false }) {
|
|
143
|
+
const done = [];
|
|
144
|
+
decisions.filter((d) => includeStart || d.action !== 'start').forEach((d) => {
|
|
145
|
+
commandsFor(d, { repo, config }).forEach((cmd) => {
|
|
146
|
+
gh(cmd);
|
|
147
|
+
done.push(cmd);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
return done;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Open pull requests currently waiting for a review from each of `logins`. Returns { login: count }. */
|
|
154
|
+
export function openReviewCounts({ gh, repo, logins }) {
|
|
155
|
+
const counts = {};
|
|
156
|
+
logins.forEach((login) => {
|
|
157
|
+
const q = encodeURIComponent(`repo:${repo} is:pr is:open review-requested:${login}`);
|
|
158
|
+
counts[login] = apiJson(gh, `search/issues?q=${q}&per_page=1`).total_count ?? 0;
|
|
159
|
+
});
|
|
160
|
+
return counts;
|
|
161
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Run `go` on a person's machine. Implements "A local run uses the same limits and tools as the cloud" and
|
|
2
|
+
// "A local run has its own working tree" of the execution-modes specification.
|
|
3
|
+
//
|
|
4
|
+
// Every process goes through an injected runner so the logic is tested without `claude`, `gh` or a network:
|
|
5
|
+
// capture(cmd, args, { cwd }) -> stdout (throws when the command fails)
|
|
6
|
+
// stream(cmd, args, { cwd, env }) -> exit status (the output goes to the terminal); throws with code ENOENT when missing
|
|
7
|
+
|
|
8
|
+
import { basename, dirname, join } from 'node:path';
|
|
9
|
+
import { cloudBuilds } from './execution-mode.mjs';
|
|
10
|
+
import { runFlags } from './run-flags.mjs';
|
|
11
|
+
|
|
12
|
+
export const WORKTREES_FOLDER = '.relevo-worktrees';
|
|
13
|
+
|
|
14
|
+
/** What a local run will do, without doing it: the worktree and the `claude` command. Pure. */
|
|
15
|
+
export function planLocalRun({ issue, repoRoot, config }) {
|
|
16
|
+
const base = config.branches.base;
|
|
17
|
+
const worktree = join(dirname(repoRoot), WORKTREES_FOLDER, `${basename(repoRoot)}-${issue}`);
|
|
18
|
+
return {
|
|
19
|
+
issue,
|
|
20
|
+
base,
|
|
21
|
+
worktree,
|
|
22
|
+
git: [
|
|
23
|
+
['fetch', 'origin', base],
|
|
24
|
+
['worktree', 'add', '--detach', worktree, `origin/${base}`],
|
|
25
|
+
],
|
|
26
|
+
claude: ['claude', ['-p', `/relevo:go ${issue}`, ...runFlags(config)]],
|
|
27
|
+
// Without this the agent may start a subagent in the background and end the run before it finishes.
|
|
28
|
+
env: { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run it. Returns { status, worktree, removed, messages } and never throws for an expected problem; `messages` is
|
|
34
|
+
* what to print. `dryRun` returns the plan without running anything.
|
|
35
|
+
*/
|
|
36
|
+
export function runLocal({ issue, config, runner, workflowPresent = false, dryRun = false, exists = () => false }) {
|
|
37
|
+
const messages = [];
|
|
38
|
+
const say = (m) => messages.push(m);
|
|
39
|
+
if (!/^\d+$/.test(String(issue))) return { status: 2, worktree: null, removed: false, messages: ['give the issue number: go-local.mjs <issue>'] };
|
|
40
|
+
|
|
41
|
+
let repoRoot;
|
|
42
|
+
try {
|
|
43
|
+
repoRoot = runner.capture('git', ['rev-parse', '--show-toplevel'], {}).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
return { status: 2, worktree: null, removed: false, messages: ['not inside a git repository'] };
|
|
46
|
+
}
|
|
47
|
+
const plan = planLocalRun({ issue: String(issue), repoRoot, config });
|
|
48
|
+
|
|
49
|
+
if (dryRun) {
|
|
50
|
+
say(`worktree: ${plan.worktree} (from origin/${plan.base})`);
|
|
51
|
+
say(`command: ${plan.claude[0]} ${plan.claude[1].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')}`);
|
|
52
|
+
return { status: 0, worktree: plan.worktree, removed: false, messages };
|
|
53
|
+
}
|
|
54
|
+
if (exists(plan.worktree)) {
|
|
55
|
+
return { status: 2, worktree: plan.worktree, removed: false, messages: [`${plan.worktree} already exists: finish or remove that run first (git worktree remove --force "${plan.worktree}")`] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
runner.capture('gh', ['auth', 'status'], {});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return { status: 2, worktree: null, removed: false, messages: [err.code === 'ENOENT' ? 'the GitHub CLI (gh) is not installed' : 'the GitHub CLI is not signed in: run `gh auth login`'] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The cloud may start the same issue: the variable decides.
|
|
65
|
+
let variable = null;
|
|
66
|
+
try {
|
|
67
|
+
variable = runner.capture('gh', ['variable', 'get', 'RELEVO_CLOUD_GO'], { cwd: repoRoot }).trim();
|
|
68
|
+
} catch {
|
|
69
|
+
variable = null;
|
|
70
|
+
}
|
|
71
|
+
if (cloudBuilds({ workflowPresent, variable })) {
|
|
72
|
+
say('warning: the cloud can also build this issue (the go workflow is installed and RELEVO_CLOUD_GO is not `off`); if the issue is `ready` it may already be running. Set RELEVO_CLOUD_GO to `off` in the repository variables to build only on machines.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
plan.git.forEach((args) => runner.capture('git', args, { cwd: repoRoot }));
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return { status: 1, worktree: null, removed: false, messages: [...messages, `could not prepare the worktree: ${String(err.stderr ?? err.message).trim().split('\n')[0]}`] };
|
|
79
|
+
}
|
|
80
|
+
say(`building #${issue} in ${plan.worktree}`);
|
|
81
|
+
|
|
82
|
+
let status;
|
|
83
|
+
try {
|
|
84
|
+
status = runner.stream(plan.claude[0], plan.claude[1], { cwd: plan.worktree, env: plan.env });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
status = err.code === 'ENOENT' ? 127 : 1;
|
|
87
|
+
say(err.code === 'ENOENT' ? 'the claude command was not found: install Claude Code and the Relevo plugin' : `the run could not start: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (status === 0) {
|
|
91
|
+
try {
|
|
92
|
+
runner.capture('git', ['worktree', 'remove', '--force', plan.worktree], { cwd: repoRoot });
|
|
93
|
+
say('done; the worktree was removed. Look at the issue for the result.');
|
|
94
|
+
return { status: 0, worktree: plan.worktree, removed: true, messages };
|
|
95
|
+
} catch {
|
|
96
|
+
say(`done, but the worktree could not be removed: git worktree remove --force "${plan.worktree}"`);
|
|
97
|
+
return { status: 0, worktree: plan.worktree, removed: false, messages };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
say(`the run ended with status ${status}; the worktree is kept for inspection: ${plan.worktree}`);
|
|
101
|
+
return { status, worktree: plan.worktree, removed: false, messages };
|
|
102
|
+
}
|