@elalfner/relevo 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.es.md +3 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/scripts/agent-guard.mjs +94 -0
- package/scripts/cli.mjs +67 -6
- package/scripts/doctor.mjs +10 -0
- package/scripts/init.mjs +1 -0
- package/scripts/lib/adapters/antigravity.mjs +99 -0
- package/scripts/lib/adapters/index.mjs +6 -0
- package/scripts/lib/adapters/install.mjs +127 -0
- package/scripts/lib/adapters/source.mjs +65 -0
- package/scripts/lib/adapters/tools.mjs +52 -0
- package/scripts/lib/agent-guard.mjs +188 -0
- package/scripts/lib/agent-workflow.mjs +46 -0
- package/scripts/lib/cli.mjs +44 -12
- package/scripts/lib/config.mjs +18 -7
- package/scripts/lib/doctor.mjs +92 -4
- package/scripts/lib/go-local.mjs +31 -4
- package/scripts/lib/init-tools.mjs +97 -0
- package/scripts/lib/init.mjs +38 -2
- package/scripts/lib/messages.mjs +16 -1
- package/scripts/lib/next-step.mjs +10 -1
- package/scripts/lib/protected-paths.mjs +10 -4
- package/scripts/lib/run-usage.mjs +1 -1
- package/scripts/lib/select.mjs +118 -0
- package/scripts/lib/status-data.mjs +16 -5
- package/scripts/lib/status.mjs +20 -5
- package/scripts/lib/toolchain-check.mjs +12 -0
- package/scripts/toolchain-check.mjs +49 -0
- package/skills/init/SKILL.md +5 -2
- package/skills/review/SKILL.md +10 -0
- package/stacks/supabase.md +63 -0
- package/templates/agents/antigravity-step.yml +70 -0
- package/templates/deploy-supabase.yml +75 -0
- package/templates/workflows/relevo-digest.yml +1 -1
- package/templates/workflows/relevo-go.yml +35 -1
- package/templates/workflows/relevo-queue.yml +3 -1
- package/templates/workflows/relevo-review.yml +1 -1
- package/templates/workflows/relevo-sla.yml +1 -1
package/scripts/lib/go-local.mjs
CHANGED
|
@@ -5,16 +5,38 @@
|
|
|
5
5
|
// capture(cmd, args, { cwd }) -> stdout (throws when the command fails)
|
|
6
6
|
// stream(cmd, args, { cwd, env }) -> exit status (the output goes to the terminal); throws with code ENOENT when missing
|
|
7
7
|
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
8
9
|
import { basename, dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
9
11
|
import { cloudBuilds } from './execution-mode.mjs';
|
|
10
12
|
import { runFlags } from './run-flags.mjs';
|
|
11
13
|
|
|
12
14
|
export const WORKTREES_FOLDER = '.relevo-worktrees';
|
|
15
|
+
// The guard is part of the plugin, not of the project that is built, so it is found next to this file.
|
|
16
|
+
const GUARD = fileURLToPath(new URL('../agent-guard.mjs', import.meta.url));
|
|
17
|
+
// The same time limit as the cloud step of the Antigravity agent.
|
|
18
|
+
export const GUARD_TIMEOUT_MINUTES = 50;
|
|
13
19
|
|
|
14
|
-
/** What a local run will do, without doing it: the worktree and the `claude` command. Pure. */
|
|
20
|
+
/** What a local run will do, without doing it: the worktree and the `claude` or `agy` command. Pure. */
|
|
15
21
|
export function planLocalRun({ issue, repoRoot, config }) {
|
|
16
22
|
const base = config.branches.base;
|
|
17
23
|
const worktree = join(dirname(repoRoot), WORKTREES_FOLDER, `${basename(repoRoot)}-${issue}`);
|
|
24
|
+
const isAntigravity = config.agent?.build === 'antigravity';
|
|
25
|
+
const command = isAntigravity
|
|
26
|
+
? ['node', [
|
|
27
|
+
GUARD,
|
|
28
|
+
'run',
|
|
29
|
+
'--prompt',
|
|
30
|
+
`/relevo:go ${issue}`,
|
|
31
|
+
'--config',
|
|
32
|
+
'relevo.yml',
|
|
33
|
+
'--timeout-minutes',
|
|
34
|
+
String(GUARD_TIMEOUT_MINUTES),
|
|
35
|
+
// outside the worktree, so a clean end can remove it and the agent finds the tree clean
|
|
36
|
+
'--summary',
|
|
37
|
+
join(tmpdir(), `relevo-guard-${basename(repoRoot)}-${issue}.json`),
|
|
38
|
+
]]
|
|
39
|
+
: ['claude', ['-p', `/relevo:go ${issue}`, ...runFlags(config)]];
|
|
18
40
|
return {
|
|
19
41
|
issue,
|
|
20
42
|
base,
|
|
@@ -23,9 +45,9 @@ export function planLocalRun({ issue, repoRoot, config }) {
|
|
|
23
45
|
['fetch', 'origin', base],
|
|
24
46
|
['worktree', 'add', '--detach', worktree, `origin/${base}`],
|
|
25
47
|
],
|
|
26
|
-
claude:
|
|
48
|
+
claude: command,
|
|
27
49
|
// 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' },
|
|
50
|
+
env: isAntigravity ? {} : { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' },
|
|
29
51
|
};
|
|
30
52
|
}
|
|
31
53
|
|
|
@@ -45,6 +67,7 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
|
|
|
45
67
|
return { status: 2, worktree: null, removed: false, messages: ['not inside a git repository'] };
|
|
46
68
|
}
|
|
47
69
|
const plan = planLocalRun({ issue: String(issue), repoRoot, config });
|
|
70
|
+
const isAntigravity = config.agent?.build === 'antigravity';
|
|
48
71
|
|
|
49
72
|
if (dryRun) {
|
|
50
73
|
say(`worktree: ${plan.worktree} (from origin/${plan.base})`);
|
|
@@ -84,7 +107,10 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
|
|
|
84
107
|
status = runner.stream(plan.claude[0], plan.claude[1], { cwd: plan.worktree, env: plan.env });
|
|
85
108
|
} catch (err) {
|
|
86
109
|
status = err.code === 'ENOENT' ? 127 : 1;
|
|
87
|
-
|
|
110
|
+
const missing = config.agent?.build === 'antigravity'
|
|
111
|
+
? 'the agy or node command was not found: install the Antigravity CLI'
|
|
112
|
+
: 'the claude command was not found: install Claude Code and the Relevo plugin';
|
|
113
|
+
say(err.code === 'ENOENT' ? missing : `the run could not start: ${err.message}`);
|
|
88
114
|
}
|
|
89
115
|
|
|
90
116
|
if (status === 0) {
|
|
@@ -97,6 +123,7 @@ export function runLocal({ issue, config, runner, workflowPresent = false, dryRu
|
|
|
97
123
|
return { status: 0, worktree: plan.worktree, removed: false, messages };
|
|
98
124
|
}
|
|
99
125
|
}
|
|
126
|
+
if (isAntigravity && status === 2) say('the run guard refused to start (usage error): see its message above');
|
|
100
127
|
say(`the run ended with status ${status}; the worktree is kept for inspection: ${plan.worktree}`);
|
|
101
128
|
return { status, worktree: plan.worktree, removed: false, messages };
|
|
102
129
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// `relevo init`: set up Relevo for the tools a team uses, in the project or on the machine, and `relevo update` for the
|
|
2
|
+
// plugins that were rendered. Claude Code keeps being installed by `claude plugin` (see cli.mjs); another tool gets a
|
|
3
|
+
// plugin rendered from the same sources (see adapters/). Everything that talks to the outside is injected.
|
|
4
|
+
import { execute } from './cli.mjs';
|
|
5
|
+
import { findRecords, installAntigravity } from './adapters/install.mjs';
|
|
6
|
+
|
|
7
|
+
export const TOOLS = [
|
|
8
|
+
{ value: 'claude', label: 'Claude Code: the plugin, which updates itself and adds no files to the repository' },
|
|
9
|
+
{ value: 'antigravity', label: 'Antigravity: the IDE, the App and the CLI, with a plugin rendered for them' }
|
|
10
|
+
];
|
|
11
|
+
export const INIT_SCOPES = ['project', 'machine'];
|
|
12
|
+
|
|
13
|
+
/** Parses what follows `init`: { tools, scope, yes, force } or { error }. `user` is accepted for `machine`. */
|
|
14
|
+
export function parseInitArgs(rest) {
|
|
15
|
+
const out = { command: 'init', tools: null, scope: null };
|
|
16
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
17
|
+
const arg = rest[i];
|
|
18
|
+
const take = (name) => (arg === name ? rest[++i] : arg.slice(name.length + 1));
|
|
19
|
+
if (arg === '--tools' || arg.startsWith('--tools=')) out.tools = String(take('--tools') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
20
|
+
else if (arg === '--scope' || arg.startsWith('--scope=')) out.scope = take('--scope');
|
|
21
|
+
else if (arg === '--yes' || arg === '-y') out.yes = true;
|
|
22
|
+
else if (arg === '--force') out.force = true;
|
|
23
|
+
else return { error: `unknown option "${arg}"` };
|
|
24
|
+
}
|
|
25
|
+
if (out.scope === 'user') out.scope = 'machine';
|
|
26
|
+
if (out.scope !== null && !INIT_SCOPES.includes(out.scope)) return { error: `the scope of init must be project or machine` };
|
|
27
|
+
if (out.tools) {
|
|
28
|
+
const unknown = out.tools.filter((t) => !TOOLS.some((x) => x.value === t));
|
|
29
|
+
if (unknown.length) return { error: `unknown tool "${unknown[0]}"; the tools are ${TOOLS.map((t) => t.value).join(', ')}` };
|
|
30
|
+
if (out.tools.length === 0) return { error: '--tools needs at least one tool' };
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Like resolveScope of cli.mjs, with the choices project and machine. */
|
|
36
|
+
export function resolveInitScope({ given = null, interactive = false, inRepo = false }) {
|
|
37
|
+
if (given) return { scope: given };
|
|
38
|
+
const preferred = inRepo ? 'project' : 'machine';
|
|
39
|
+
if (interactive) return { ask: true, preferred };
|
|
40
|
+
const why = inRepo ? 'this folder is inside a git repository, so the project is used' : 'this folder is not inside a git repository, so the machine is used';
|
|
41
|
+
return { scope: preferred, reason: `no --scope given and no terminal: ${why}` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function initScopeChoices({ cwd, inRepo }) {
|
|
45
|
+
return [
|
|
46
|
+
{ value: 'project', label: `Project (${cwd})${inRepo ? '' : ' [not a git repository]'}: files in the repository, shared with your team` },
|
|
47
|
+
{ value: 'machine', label: 'Machine: every project of this machine' }
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The top folder of the git repository of the current folder, or null. */
|
|
52
|
+
export function repoRoot(run) {
|
|
53
|
+
const r = run('git', ['rev-parse', '--show-toplevel']);
|
|
54
|
+
return r.code === 0 && r.output.trim() ? r.output.trim() : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NEXT_STEP = 'Next: /relevo:init (the skill) sets up the labels and the rules of the repository; it is a separate step from this command.';
|
|
58
|
+
|
|
59
|
+
/** Sets up each tool, in order, and stops at the first failure. Returns { code, lines }. */
|
|
60
|
+
export function runInit({ tools, scope, force = false }, deps) {
|
|
61
|
+
const lines = [];
|
|
62
|
+
for (const tool of tools) {
|
|
63
|
+
lines.push(`== ${TOOLS.find((t) => t.value === tool).label.split(':')[0]}`);
|
|
64
|
+
const r =
|
|
65
|
+
tool === 'claude'
|
|
66
|
+
? execute({ command: 'install', scope: scope === 'machine' ? 'user' : 'project' }, deps)
|
|
67
|
+
: installAntigravity({ packageRoot: deps.packageRoot, scope, root: deps.root, home: deps.home, run: deps.run, has: deps.has, force });
|
|
68
|
+
lines.push(...r.lines);
|
|
69
|
+
if (r.code !== 0) return { code: r.code, lines };
|
|
70
|
+
}
|
|
71
|
+
lines.push(NEXT_STEP);
|
|
72
|
+
return { code: 0, lines };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Updates Claude Code as before and renders again the plugins that `init` made. With neither, it says what it always
|
|
77
|
+
* said. The code is the worst of the parts, and each part says what it did.
|
|
78
|
+
*/
|
|
79
|
+
export function runUpdate({ scope = null, force = false }, deps) {
|
|
80
|
+
const records = findRecords({ root: deps.root, home: deps.home });
|
|
81
|
+
if (records.length === 0) return execute({ command: 'update', scope }, deps);
|
|
82
|
+
const lines = [];
|
|
83
|
+
let code = 0;
|
|
84
|
+
if (deps.has('claude')) {
|
|
85
|
+
lines.push('== Claude Code');
|
|
86
|
+
const r = execute({ command: 'update', scope }, deps);
|
|
87
|
+
lines.push(...r.lines);
|
|
88
|
+
code = r.code;
|
|
89
|
+
}
|
|
90
|
+
for (const rec of records) {
|
|
91
|
+
lines.push(`== Antigravity (${rec.scope})`);
|
|
92
|
+
const r = installAntigravity({ packageRoot: deps.packageRoot, scope: rec.scope, root: deps.root, home: deps.home, run: deps.run, has: deps.has, force, update: true });
|
|
93
|
+
lines.push(...r.lines);
|
|
94
|
+
code = code || r.code;
|
|
95
|
+
}
|
|
96
|
+
return { code, lines };
|
|
97
|
+
}
|
package/scripts/lib/init.mjs
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
import { loadConfig } from './config.mjs';
|
|
9
9
|
import { LEGACY_PLATFORMS } from './platforms.mjs';
|
|
10
10
|
import { labelDefinitions } from './labels.mjs';
|
|
11
|
+
import { agentWorkflow } from './agent-workflow.mjs';
|
|
11
12
|
import { OPENSPEC_INSTALL, REQUIRED_SKILLS, SKILL_INSTALL, WORKFLOW_FILES } from './doctor.mjs';
|
|
12
13
|
|
|
13
14
|
export const REVIEW_MODES = ['none', 'peer', 'roles'];
|
|
14
15
|
|
|
15
16
|
export const RUNS = ['cloud', 'local', 'both'];
|
|
16
17
|
export const CREDENTIALS = ['api_key', 'subscription'];
|
|
18
|
+
export const AGENT_BUILDS = ['claude', 'antigravity'];
|
|
17
19
|
const API_KEY_LINE = ' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}';
|
|
18
20
|
const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}';
|
|
19
21
|
|
|
@@ -22,6 +24,10 @@ const TOKEN_LINE = ' # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE
|
|
|
22
24
|
* subscription token that line is commented out and the token line is switched on. Throws when the template no
|
|
23
25
|
* longer has the lines (a test runs this on the real template).
|
|
24
26
|
*/
|
|
27
|
+
// The two official commands that install the plugin at the project scope; the person confirms this exact line.
|
|
28
|
+
export const PLUGIN_PROJECT_COMMAND = 'claude plugin marketplace add elalfner/relevo --scope project --sparse .claude-plugin && claude plugin install relevo@relevo --scope project';
|
|
29
|
+
const PLUGIN_PROJECT_DETAIL = 'It writes the marketplace and the enabled plugin into .claude/settings.json of this repository. It works even if the plugin is already installed for your machine, and nothing is committed for you.';
|
|
30
|
+
|
|
25
31
|
export function credentialLine(workflow, credential) {
|
|
26
32
|
if (credential !== 'subscription') return workflow;
|
|
27
33
|
if (!workflow.includes(API_KEY_LINE) || !workflow.includes(TOKEN_LINE)) throw new Error('the go workflow template has no credential lines to switch');
|
|
@@ -83,7 +89,12 @@ export function renderConfig(template, answers = {}) {
|
|
|
83
89
|
if (answers.specWriter !== undefined) out = set(out, 'spec_writer', answers.specWriter ?? 'null');
|
|
84
90
|
if (answers.reviewMode) out = set(out, 'mode', answers.reviewMode);
|
|
85
91
|
if (answers.merger) out = set(out, 'merger', answers.merger);
|
|
86
|
-
if (answers.
|
|
92
|
+
if (answers.agentBuild === 'antigravity') {
|
|
93
|
+
// The agent has no cost limit: the limit of the run is a number of tokens, and the agent is named in its own group.
|
|
94
|
+
out = out.replace(/^(\s*)run_max_cost:[^\n]*$/m, '$1run_max_tokens: 2000000 # REQUIRED with agent.build antigravity: tokens per unattended run; choose your own');
|
|
95
|
+
out = `${out.trimEnd()}\n\n# Which agent builds in the cloud: claude (the default) or antigravity.\nagent:\n build: antigravity\n`;
|
|
96
|
+
if (answers.runMaxTokens !== undefined) out = set(out, 'run_max_tokens', answers.runMaxTokens);
|
|
97
|
+
} else if (answers.runMaxCost !== undefined) out = set(out, 'run_max_cost', answers.runMaxCost);
|
|
87
98
|
if (answers.language) out = set(out, 'language', answers.language);
|
|
88
99
|
const check = loadConfig(out);
|
|
89
100
|
if (!check.ok) throw new Error(`the answers produce an invalid relevo.yml: ${check.errors.map((e) => e.message).join('; ')}`);
|
|
@@ -174,6 +185,25 @@ export function planInit({ facts, answers, templates, current, admin }) {
|
|
|
174
185
|
if (!facts.openspec) items.push(item('install.openspec', 'todo', 'Install the OpenSpec command-line tool', { install: true, command: 'npm install --global @fission-ai/openspec', detail: OPENSPEC_INSTALL }));
|
|
175
186
|
REQUIRED_SKILLS.filter((n) => !facts.skills[n]).forEach((n) => items.push(item(`install.skill.${n}`, 'todo', `Install the ${n} skill`, { install: true, command: n === 'openspec-explore' ? 'openspec init' : 'npx skills add mattpocock/skills', detail: SKILL_INSTALL[n] })));
|
|
176
187
|
|
|
188
|
+
const configuredPlatforms = facts.config?.platforms ?? answers.platforms ?? (answers.platform ? { app: LEGACY_PLATFORMS[answers.platform] } : {});
|
|
189
|
+
const hasSupabase = Object.values(configuredPlatforms).some((p) => p?.stack === 'supabase');
|
|
190
|
+
if (hasSupabase) {
|
|
191
|
+
['supabase', 'supabase-postgres-best-practices'].forEach((skill) => {
|
|
192
|
+
if (facts.skills?.[skill] === false) {
|
|
193
|
+
items.push(item(`install.skill.${skill}`, 'todo', `Install the ${skill} skill`, {
|
|
194
|
+
install: true,
|
|
195
|
+
command: `npx skills add supabase/agent-skills --skill ${skill}`,
|
|
196
|
+
detail: SKILL_INSTALL[skill] ?? `run \`npx skills add supabase/agent-skills --skill ${skill}\``,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// the plugin, declared in the project so that a teammate who clones the repository is offered it
|
|
203
|
+
if (facts.pluginDeclared?.declared) items.push(item('install.plugin-project', 'ok', 'The repository declares the Relevo plugin (.claude/settings.json)'));
|
|
204
|
+
else if (facts.pluginDeclared?.unreadable) items.push(item('install.plugin-project', 'manual', '.claude/settings.json is not valid JSON: fix it, then run init again to declare the Relevo plugin in the project'));
|
|
205
|
+
else if (facts.pluginDeclared) items.push(item('install.plugin-project', 'todo', 'Declare the Relevo plugin in the project, so that a teammate who clones the repository is offered it (the new .claude/settings.json is left for you to commit)', { install: true, command: PLUGIN_PROJECT_COMMAND, detail: PLUGIN_PROJECT_DETAIL }));
|
|
206
|
+
|
|
177
207
|
// relevo.yml
|
|
178
208
|
if (facts.configText !== null) items.push(item('config', facts.config ? 'ok' : 'differs', facts.config ? 'relevo.yml exists and is valid' : `relevo.yml exists but is invalid: ${facts.configErrors.join('; ')}`));
|
|
179
209
|
else items.push(item('config', 'todo', 'Create relevo.yml from the answers', { content: renderConfig(templates.config, { base, release, ...answers }) }));
|
|
@@ -216,10 +246,13 @@ export function planInit({ facts, answers, templates, current, admin }) {
|
|
|
216
246
|
const credential = answers.credential ?? 'api_key';
|
|
217
247
|
if (!RUNS.includes(runs)) throw new Error(`runs must be one of ${RUNS.join(', ')} (got "${runs}")`);
|
|
218
248
|
if (!CREDENTIALS.includes(credential)) throw new Error(`credential must be one of ${CREDENTIALS.join(', ')} (got "${credential}")`);
|
|
249
|
+
const build = answers.agentBuild ?? 'claude';
|
|
250
|
+
if (!AGENT_BUILDS.includes(build)) throw new Error(`agentBuild must be one of ${AGENT_BUILDS.join(', ')} (got "${build}")`);
|
|
219
251
|
const cloudFiles = ['relevo-go.yml', 'relevo-queue.yml'];
|
|
220
252
|
WORKFLOW_FILES.forEach((file) => {
|
|
221
253
|
if (runs === 'local' && cloudFiles.includes(file)) return;
|
|
222
|
-
const
|
|
254
|
+
const shared = file === 'relevo-go.yml' ? credentialLine(templates.workflows[file], credential) : templates.workflows[file];
|
|
255
|
+
const wanted = agentWorkflow(shared, build, templates.agentBlocks);
|
|
223
256
|
const have = current.workflows[file] ?? null;
|
|
224
257
|
if (have === null) items.push(item(`workflow.${file}`, 'todo', `Add .github/workflows/${file}`, { path: `.github/workflows/${file}`, content: wanted }));
|
|
225
258
|
else if (have === wanted) items.push(item(`workflow.${file}`, 'ok', `.github/workflows/${file} is up to date`));
|
|
@@ -253,6 +286,9 @@ export function planInit({ facts, answers, templates, current, admin }) {
|
|
|
253
286
|
if ((facts.secrets ?? []).includes('RELEVO_LINKS_TOKEN')) items.push(item('manual.links-token', 'ok', 'The RELEVO_LINKS_TOKEN secret exists (init never reads it)'));
|
|
254
287
|
else items.push(item('manual.links-token', 'manual', `If ${links.join(', ')} ${links.length > 1 ? 'are' : 'is'} private, create a fine-grained token with read access to issues and pull requests and store it yourself as the secret RELEVO_LINKS_TOKEN (init never reads or writes it; Relevo only reads with it, and public repositories need none)`));
|
|
255
288
|
}
|
|
289
|
+
if (hasSupabase) {
|
|
290
|
+
items.push(item('manual.supabase-mcp', 'manual', 'To explore your Supabase database in Claude Code or Antigravity during /relevo:think, consider connecting the Supabase MCP server (see https://supabase.com/docs/guides/ai-tools)'));
|
|
291
|
+
}
|
|
256
292
|
items.push(item('manual.push', 'manual', 'Commit and push the new files to the default branch: a workflow only runs from there'));
|
|
257
293
|
|
|
258
294
|
return items;
|
package/scripts/lib/messages.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const MAX_VALUE_LENGTH = 400;
|
|
|
17
17
|
const REQUIRED = {
|
|
18
18
|
built: ['assignee', 'issue', 'title', 'tasks_done', 'tasks_total', 'branch', 'scenarios'],
|
|
19
19
|
qa_failed: ['assignee', 'issue', 'title', 'finding', 'branch'],
|
|
20
|
+
toolchain_missing: ['assignee', 'issue', 'title', 'tools', 'platform'],
|
|
20
21
|
needs_spec: ['spec_writer', 'issue', 'title', 'missing'],
|
|
21
22
|
changes_requested: ['author', 'issue', 'reviewer', 'summary'],
|
|
22
23
|
needs_reviewer: ['spec_writer', 'issue', 'title', 'pr'],
|
|
@@ -38,6 +39,7 @@ const REQUIRED = {
|
|
|
38
39
|
const STATE_OF = {
|
|
39
40
|
built: 'built',
|
|
40
41
|
qa_failed: 'qa-failed',
|
|
42
|
+
toolchain_missing: 'qa-failed',
|
|
41
43
|
needs_spec: 'needs-spec',
|
|
42
44
|
changes_requested: 'changes-requested',
|
|
43
45
|
approved: 'pr-open',
|
|
@@ -67,6 +69,12 @@ const CATALOG = {
|
|
|
67
69
|
'Full QA report: {{log_url}}',
|
|
68
70
|
'{{next_line}}',
|
|
69
71
|
],
|
|
72
|
+
toolchain_missing: [
|
|
73
|
+
'@{{assignee}} #{{issue}} "{{title}}" was not built: the runner has no {{tools}} ❌',
|
|
74
|
+
'',
|
|
75
|
+
'What happened: the build job checked the toolchain of platform {{platform}} before the agent started, and {{tools}} is not on the path of the runner. Nothing was built and no model turns were spent.',
|
|
76
|
+
'{{next_line}}',
|
|
77
|
+
],
|
|
70
78
|
needs_spec: [
|
|
71
79
|
'@{{spec_writer}} #{{issue}} "{{title}}" is back with you: the spec needs work',
|
|
72
80
|
'',
|
|
@@ -173,6 +181,12 @@ const CATALOG = {
|
|
|
173
181
|
'Reporte completo de QA: {{log_url}}',
|
|
174
182
|
'{{next_line}}',
|
|
175
183
|
],
|
|
184
|
+
toolchain_missing: [
|
|
185
|
+
'@{{assignee}} #{{issue}} "{{title}}" no se construyó: el runner no tiene {{tools}} ❌',
|
|
186
|
+
'',
|
|
187
|
+
'Qué pasó: el job de build comprobó el toolchain de la plataforma {{platform}} antes de que empezara el agente, y {{tools}} no está en el path del runner. No se construyó nada y no se gastó ningún turno del modelo.',
|
|
188
|
+
'{{next_line}}',
|
|
189
|
+
],
|
|
176
190
|
needs_spec: [
|
|
177
191
|
'@{{spec_writer}} #{{issue}} "{{title}}" regresa contigo: la spec necesita trabajo',
|
|
178
192
|
'',
|
|
@@ -287,9 +301,10 @@ export function renderMessage(kind, data = {}, language = 'en') {
|
|
|
287
301
|
|
|
288
302
|
// The next step comes from one table. needs-spec depends on why the issue came back, qa-failed on what the branch holds.
|
|
289
303
|
if (STATE_OF[kind]) {
|
|
290
|
-
const reason = kind === 'needs_spec' ? values.reason ?? 'missing' : kind === 'qa_failed' ? values.recovery ?? (values.branch_state === 'kept' ? 'kept' : 'fix') : null;
|
|
304
|
+
const reason = kind === 'needs_spec' ? values.reason ?? 'missing' : kind === 'qa_failed' ? values.recovery ?? (values.branch_state === 'kept' ? 'kept' : 'fix') : kind === 'toolchain_missing' ? 'toolchain' : null;
|
|
291
305
|
const step = nextStep({ state: STATE_OF[kind], reason, issue: values.issue, language: language_ });
|
|
292
306
|
if (!present(values.command) && step.command) values.command = step.command;
|
|
307
|
+
if (kind === 'toolchain_missing') values.next_line = `${nextStepPrefix(language_)}: ${step.hint}.`;
|
|
293
308
|
if (kind === 'needs_spec') values.next_line = `${nextStepPrefix(language_)}: ${step.command}: ${step.hint}.`;
|
|
294
309
|
if (kind === 'qa_failed') {
|
|
295
310
|
// For kept work the next step says what the command does and the other way forward; the others are the bare command.
|
|
@@ -76,6 +76,15 @@ const TABLE = {
|
|
|
76
76
|
es: 'verifica y corrige el trabajo que se guardó; para repensar el issue, usa /relevo:think y luego /relevo:issue {n}',
|
|
77
77
|
},
|
|
78
78
|
},
|
|
79
|
+
// Nothing was built: the runner lacks a tool of the profile, and the fix is in the workflow, not a command.
|
|
80
|
+
toolchain: {
|
|
81
|
+
command: null,
|
|
82
|
+
who: 'assignee',
|
|
83
|
+
hint: {
|
|
84
|
+
en: 'install the toolchain in the build job of relevo-go.yml (the template has the step for Flutter and Laravel, and /relevo:doctor checks it), then remove relevo:qa-failed and label the issue relevo:ready again',
|
|
85
|
+
es: 'instala el toolchain en el job de build de relevo-go.yml (la plantilla trae el paso para Flutter y Laravel, y /relevo:doctor lo comprueba), luego quita relevo:qa-failed y vuelve a poner relevo:ready al issue',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
79
88
|
'delete-branch': {
|
|
80
89
|
command: null,
|
|
81
90
|
who: 'assignee',
|
|
@@ -113,7 +122,7 @@ export const NEXT_STEP_STATES = Object.keys(TABLE);
|
|
|
113
122
|
/** Reasons for the states whose next step depends on one. */
|
|
114
123
|
export const NEXT_STEP_REASONS = {
|
|
115
124
|
'needs-spec': ['missing', 'ambiguous'],
|
|
116
|
-
'qa-failed': ['fix', 'kept', 'delete-branch'],
|
|
125
|
+
'qa-failed': ['fix', 'kept', 'toolchain', 'delete-branch'],
|
|
117
126
|
};
|
|
118
127
|
|
|
119
128
|
const fill = (text, issue) => String(text).replaceAll('{n}', String(issue ?? ''));
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Two tiers:
|
|
4
4
|
// hard never allowed, whatever the issue says: workflows, the Relevo configuration, the folders of skills and
|
|
5
|
-
// the file with the project rules (a defect is fixed in the code, not by rewriting the rule)
|
|
5
|
+
// the file with the project rules (a defect is fixed in the code, not by rewriting the rule), and what carries
|
|
6
|
+
// the instructions or the permissions of another agent (`.agents/`, `.gemini/`, `AGENTS.md`, `GEMINI.md`):
|
|
7
|
+
// a run that could edit them could rewrite its own instructions.
|
|
6
8
|
// soft dependency manifests and lock files: allowed only when the issue lists them in an `Allow-paths:` line.
|
|
7
9
|
|
|
8
|
-
export const HARD_PATTERNS = ['.github/', 'relevo.yml', '.relevo/', '.claude/skills/', '.agents/
|
|
10
|
+
export const HARD_PATTERNS = ['.github/', 'relevo.yml', '.relevo/', '.claude/skills/', '.agents/', '.gemini/', 'skills-lock.json'];
|
|
11
|
+
|
|
12
|
+
// Files that an agent reads from the folder of whatever it edits, so they are protected at any depth.
|
|
13
|
+
export const HARD_ANY_DEPTH = ['AGENTS.md', 'GEMINI.md'];
|
|
9
14
|
|
|
10
15
|
export const SOFT_FILES = [
|
|
11
16
|
'pubspec.yaml', 'pubspec.lock',
|
|
@@ -60,7 +65,8 @@ const matchesHard = (path, pattern) => {
|
|
|
60
65
|
|
|
61
66
|
/** The hard rule a path breaks, or null. */
|
|
62
67
|
export function hardRule(path, { rulesFile = null } = {}) {
|
|
63
|
-
|
|
68
|
+
const name = clean(path).split('/').pop();
|
|
69
|
+
return hardPatterns(rulesFile).find((pattern) => matchesHard(path, pattern)) ?? HARD_ANY_DEPTH.find((f) => f === name) ?? null;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** Is the file a dependency manifest or lock file (soft tier)? */
|
|
@@ -87,7 +93,7 @@ export function checkProtected(paths, { allow = [], rulesFile = null } = {}) {
|
|
|
87
93
|
|
|
88
94
|
/** Entries of an `Allow-paths:` list that name (or would cover) a hard-protected path: they can never be allowed. */
|
|
89
95
|
export function hardPathsInAllow(allow, { rulesFile = null } = {}) {
|
|
90
|
-
const probes = [...HARD_PATTERNS.map((pattern) => (pattern.endsWith('/') ? `${pattern}x` : pattern)), ...ruleFiles(rulesFile)].filter(Boolean);
|
|
96
|
+
const probes = [...HARD_PATTERNS.map((pattern) => (pattern.endsWith('/') ? `${pattern}x` : pattern)), ...HARD_ANY_DEPTH, ...ruleFiles(rulesFile)].filter(Boolean);
|
|
91
97
|
return (allow ?? []).filter((entry) => hardRule(entry, { rulesFile }) || probes.some((probe) => matchesGlob(probe, entry)));
|
|
92
98
|
}
|
|
93
99
|
|
|
@@ -57,7 +57,7 @@ export function usageLine(usage, { subscription = false } = {}) {
|
|
|
57
57
|
// Messages the Claude CLI and the API document for a usage limit, a quota or an empty credit balance. These patterns
|
|
58
58
|
// are not from a failed run seen here (see docs/pilot-findings.md); a wrong guess only changes the wording of a
|
|
59
59
|
// comment on a run that already failed.
|
|
60
|
-
const LIMIT_RE = /usage limit|limit reached|rate[ _-]?limit|credit balance is too low|exceeded your (current )?quota|insufficient[ _]quota/i;
|
|
60
|
+
const LIMIT_RE = /usage limit|limit reached|rate[ _-]?limit|credit balance is too low|exceeded your (current )?quota|quota exceeded|resource[ _]exhausted|insufficient[ _]quota/i;
|
|
61
61
|
|
|
62
62
|
/** Does the log show that a usage limit, a quota or the credit was reached? */
|
|
63
63
|
export function reachedUsageLimit(text) {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A list in the terminal that is chosen with the arrows, and nothing else: no dependency, so that the package stays
|
|
2
|
+
// small. The input and the output are injected, so a test drives it with a simulated keyboard. The terminal is put
|
|
3
|
+
// back (raw mode off, cursor shown) on every way out, including a cancel and an error.
|
|
4
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
5
|
+
|
|
6
|
+
const HIDE = '\u001b[?25l';
|
|
7
|
+
const SHOW = '\u001b[?25h';
|
|
8
|
+
const UP = (n) => `\u001b[${n}A`;
|
|
9
|
+
const CLEAR = '\u001b[0J';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* choices: [{ value, label }]; defaultIndex: the one selected at the start.
|
|
13
|
+
* Resolves the chosen value, or null when the person cancels (Esc, Ctrl-C, Ctrl-D, q).
|
|
14
|
+
* Keys: up/down (or k/j), Enter, the digits 1 to 9 as a shortcut that chooses at once.
|
|
15
|
+
*/
|
|
16
|
+
export function select({ title, choices, defaultIndex = 0, input, output, hint = 'Arrows and Enter to choose, a number to pick one, Esc to cancel' }) {
|
|
17
|
+
if (!input?.isTTY || typeof input.setRawMode !== 'function') return Promise.reject(new Error('the list needs a terminal'));
|
|
18
|
+
if (!Array.isArray(choices) || choices.length === 0) return Promise.reject(new Error('the list has no choices'));
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
let index = Math.min(Math.max(defaultIndex, 0), choices.length - 1);
|
|
21
|
+
let drawn = 0;
|
|
22
|
+
const draw = () => {
|
|
23
|
+
const lines = [title, ...choices.map((c, i) => `${i === index ? '>' : ' '} ${i + 1}. ${c.label}`), hint];
|
|
24
|
+
output.write(`${drawn ? UP(drawn) : ''}\r${CLEAR}${lines.join('\n')}\n`);
|
|
25
|
+
drawn = lines.length;
|
|
26
|
+
};
|
|
27
|
+
const finish = (value) => {
|
|
28
|
+
input.removeListener('keypress', onKey);
|
|
29
|
+
try {
|
|
30
|
+
input.setRawMode(false);
|
|
31
|
+
} finally {
|
|
32
|
+
input.pause?.();
|
|
33
|
+
output.write(SHOW);
|
|
34
|
+
}
|
|
35
|
+
resolve(value);
|
|
36
|
+
};
|
|
37
|
+
function onKey(str, key = {}) {
|
|
38
|
+
try {
|
|
39
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'd')) return finish(null);
|
|
40
|
+
if (key.name === 'escape' || str === 'q') return finish(null);
|
|
41
|
+
if (key.name === 'return' || key.name === 'enter') return finish(choices[index].value);
|
|
42
|
+
if (key.name === 'up' || str === 'k') index = (index + choices.length - 1) % choices.length;
|
|
43
|
+
else if (key.name === 'down' || str === 'j') index = (index + 1) % choices.length;
|
|
44
|
+
else if (/^[1-9]$/.test(str ?? '') && Number(str) <= choices.length) return finish(choices[Number(str) - 1].value);
|
|
45
|
+
else return undefined;
|
|
46
|
+
draw();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
finish(null);
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
emitKeypressEvents(input);
|
|
54
|
+
input.setRawMode(true);
|
|
55
|
+
input.resume?.();
|
|
56
|
+
input.on('keypress', onKey);
|
|
57
|
+
output.write(HIDE);
|
|
58
|
+
draw();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Like `select`, but several choices can be ticked: Space (or the digit of a choice) toggles, Enter confirms.
|
|
64
|
+
* choices: [{ value, label }]; initial: the values ticked at the start. Resolves the array of ticked values in the
|
|
65
|
+
* order of `choices` (possibly empty), or null when the person cancels.
|
|
66
|
+
*/
|
|
67
|
+
export function selectMany({ title, choices, initial = [], input, output, hint = 'Arrows to move, Space or a number to tick, Enter to confirm, Esc to cancel' }) {
|
|
68
|
+
if (!input?.isTTY || typeof input.setRawMode !== 'function') return Promise.reject(new Error('the list needs a terminal'));
|
|
69
|
+
if (!Array.isArray(choices) || choices.length === 0) return Promise.reject(new Error('the list has no choices'));
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const ticked = new Set(initial);
|
|
72
|
+
let index = 0;
|
|
73
|
+
let drawn = 0;
|
|
74
|
+
const draw = () => {
|
|
75
|
+
const lines = [title, ...choices.map((c, i) => `${i === index ? '>' : ' '} [${ticked.has(c.value) ? 'x' : ' '}] ${i + 1}. ${c.label}`), hint];
|
|
76
|
+
output.write(`${drawn ? UP(drawn) : ''}\r${CLEAR}${lines.join('\n')}\n`);
|
|
77
|
+
drawn = lines.length;
|
|
78
|
+
};
|
|
79
|
+
const finish = (value) => {
|
|
80
|
+
input.removeListener('keypress', onKey);
|
|
81
|
+
try {
|
|
82
|
+
input.setRawMode(false);
|
|
83
|
+
} finally {
|
|
84
|
+
input.pause?.();
|
|
85
|
+
output.write(SHOW);
|
|
86
|
+
}
|
|
87
|
+
resolve(value);
|
|
88
|
+
};
|
|
89
|
+
const toggle = (i) => {
|
|
90
|
+
const v = choices[i].value;
|
|
91
|
+
if (ticked.has(v)) ticked.delete(v);
|
|
92
|
+
else ticked.add(v);
|
|
93
|
+
};
|
|
94
|
+
function onKey(str, key = {}) {
|
|
95
|
+
try {
|
|
96
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'd')) return finish(null);
|
|
97
|
+
if (key.name === 'escape' || str === 'q') return finish(null);
|
|
98
|
+
if (key.name === 'return' || key.name === 'enter') return finish(choices.map((c) => c.value).filter((v) => ticked.has(v)));
|
|
99
|
+
if (key.name === 'up' || str === 'k') index = (index + choices.length - 1) % choices.length;
|
|
100
|
+
else if (key.name === 'down' || str === 'j') index = (index + 1) % choices.length;
|
|
101
|
+
else if (key.name === 'space' || str === ' ') toggle(index);
|
|
102
|
+
else if (/^[1-9]$/.test(str ?? '') && Number(str) <= choices.length) toggle(Number(str) - 1);
|
|
103
|
+
else return undefined;
|
|
104
|
+
draw();
|
|
105
|
+
} catch (err) {
|
|
106
|
+
finish(null);
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
emitKeypressEvents(input);
|
|
112
|
+
input.setRawMode(true);
|
|
113
|
+
input.resume?.();
|
|
114
|
+
input.on('keypress', onKey);
|
|
115
|
+
output.write(HIDE);
|
|
116
|
+
draw();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -8,7 +8,13 @@ import { cloudBuilds, CLOUD_VARIABLE } from './execution-mode.mjs';
|
|
|
8
8
|
|
|
9
9
|
const paged = (gh, path) => JSON.parse(gh(['api', '--paginate', '--slurp', path])).flat();
|
|
10
10
|
|
|
11
|
+
// The first line of an error, which is the reason a read failed.
|
|
12
|
+
const reasonOf = (err) => String(err?.stderr ?? err?.message ?? err).trim().split('\n')[0];
|
|
13
|
+
const notFound = (err) => /404|Not Found/i.test(String(err?.stderr ?? err?.message ?? err));
|
|
14
|
+
|
|
11
15
|
export function gatherStatus({ gh, ghLinks = gh, repo, config }) {
|
|
16
|
+
// The reads that fail and are not an answer; the report lists them so that a partial report is not taken for a complete one.
|
|
17
|
+
const unread = [];
|
|
12
18
|
const names = resolveLabelNames(config.labels);
|
|
13
19
|
const byNumber = new Map();
|
|
14
20
|
LABELS.forEach((l) => {
|
|
@@ -44,8 +50,9 @@ export function gatherStatus({ gh, ghLinks = gh, repo, config }) {
|
|
|
44
50
|
let behind = 0;
|
|
45
51
|
try {
|
|
46
52
|
behind = JSON.parse(gh(['api', `repos/${repo}/compare/${pr.head.sha}...${pr.base.ref}`])).ahead_by ?? 0;
|
|
47
|
-
} catch {
|
|
53
|
+
} catch (err) {
|
|
48
54
|
behind = 0;
|
|
55
|
+
unread.push({ what: 'compare', pr: pr.number, reason: reasonOf(err) });
|
|
49
56
|
}
|
|
50
57
|
prs.push({
|
|
51
58
|
number: pr.number, issue, url: pr.html_url, author: pr.user.login,
|
|
@@ -54,7 +61,7 @@ export function gatherStatus({ gh, ghLinks = gh, repo, config }) {
|
|
|
54
61
|
approved: approvedBy.length > 0, behind_by: behind,
|
|
55
62
|
});
|
|
56
63
|
});
|
|
57
|
-
return { issues: issues.map(({ body, ...rest }) => rest), prs, milestones, externalBlocks: readExternalBlocks({ ghLinks, issues, repo, config, names }), cloudBuilds: readCloudBuilds({ gh, repo }) };
|
|
64
|
+
return { issues: issues.map(({ body, ...rest }) => rest), prs, milestones, externalBlocks: readExternalBlocks({ ghLinks, issues, repo, config, names }), cloudBuilds: readCloudBuilds({ gh, repo, unread }), unread };
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
@@ -90,20 +97,24 @@ function readExternalBlocks({ ghLinks, issues, repo, config, names }) {
|
|
|
90
97
|
* is not `off`. When the workflow cannot be read the answer is `true` (the cautious one: no extra rows, no claim
|
|
91
98
|
* that a person must build by hand).
|
|
92
99
|
*/
|
|
93
|
-
function readCloudBuilds({ gh, repo }) {
|
|
100
|
+
function readCloudBuilds({ gh, repo, unread = [] }) {
|
|
94
101
|
let workflowPresent;
|
|
95
102
|
try {
|
|
96
103
|
gh(['api', `repos/${repo}/contents/.github/workflows/relevo-go.yml`]);
|
|
97
104
|
workflowPresent = true;
|
|
98
105
|
} catch (err) {
|
|
99
|
-
if (
|
|
106
|
+
if (!notFound(err)) {
|
|
107
|
+
unread.push({ what: 'workflow', reason: reasonOf(err) });
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
100
110
|
workflowPresent = false;
|
|
101
111
|
}
|
|
102
112
|
let variable = null;
|
|
103
113
|
try {
|
|
104
114
|
variable = JSON.parse(gh(['api', `repos/${repo}/actions/variables/${CLOUD_VARIABLE}`])).value ?? null;
|
|
105
|
-
} catch {
|
|
115
|
+
} catch (err) {
|
|
106
116
|
variable = null;
|
|
117
|
+
if (!notFound(err)) unread.push({ what: 'variable', reason: reasonOf(err) });
|
|
107
118
|
}
|
|
108
119
|
return cloudBuilds({ workflowPresent, variable });
|
|
109
120
|
}
|
package/scripts/lib/status.mjs
CHANGED
|
@@ -28,7 +28,7 @@ function stateOf(issue, names) {
|
|
|
28
28
|
return order.find((k) => issue.labels.includes(names[k])) ?? null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function buildStatus({ config, issues, prs = [], milestones = [], externalBlocks = {}, cloudBuilds = true, me = null, now = new Date() }) {
|
|
31
|
+
export function buildStatus({ config, issues, prs = [], milestones = [], externalBlocks = {}, cloudBuilds = true, unread = [], me = null, now = new Date() }) {
|
|
32
32
|
const names = resolveLabelNames(config.labels);
|
|
33
33
|
const entries = issues.map((i) => ({
|
|
34
34
|
number: i.number, title: i.title, state: stateOf(i, names), assignee: i.assignees[0] ?? null, milestone: i.milestone ?? null,
|
|
@@ -116,14 +116,19 @@ export function buildStatus({ config, issues, prs = [], milestones = [], externa
|
|
|
116
116
|
return {
|
|
117
117
|
me, view: isSpecWriter ? 'spec-writer' : 'person',
|
|
118
118
|
onMe, toReview, toShip, readyForMe, waitingOnOthers, specWriter: specWriterView,
|
|
119
|
-
milestones: summaries, alerts, waiting,
|
|
119
|
+
milestones: summaries, alerts, waiting, unread,
|
|
120
120
|
readOnly: true,
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
const T = {
|
|
125
125
|
en: {
|
|
126
|
-
nothing: 'Nothing is waiting on you.',
|
|
126
|
+
nothing: 'Nothing is waiting on you.', fromRead: '(from what could be read)', unreadTitle: 'Could not read, so this report may be incomplete',
|
|
127
|
+
unread: {
|
|
128
|
+
variable: (u) => `the variable RELEVO_CLOUD_GO (${u.reason}): the issues you could build by hand may be missing`,
|
|
129
|
+
workflow: (u) => `the workflow relevo-go.yml (${u.reason}): the cloud is assumed to build, so the issues you could build by hand may be missing`,
|
|
130
|
+
compare: (u) => `the comparison of the branch of pull request #${u.pr} with the base (${u.reason}): a branch that is behind may not be reported`,
|
|
131
|
+
}, onMe: 'Waiting on you', review: 'Pull requests to review', ship: 'Approved, ready to ship',
|
|
127
132
|
others: 'Waiting on others', spec: 'Back with the spec writer', blocked: 'Blocked', needsReviewer: 'Need a reviewer', milestone: 'Milestone',
|
|
128
133
|
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
134
|
alerts: 'Alerts', staleReview: 'review waiting', staleBranch: 'branch behind the base by',
|
|
@@ -131,7 +136,12 @@ const T = {
|
|
|
131
136
|
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
137
|
},
|
|
133
138
|
es: {
|
|
134
|
-
nothing: 'Nada espera por ti.',
|
|
139
|
+
nothing: 'Nada espera por ti.', fromRead: '(con lo que se pudo leer)', unreadTitle: 'No se pudo leer, así que este informe puede estar incompleto',
|
|
140
|
+
unread: {
|
|
141
|
+
variable: (u) => `la variable RELEVO_CLOUD_GO (${u.reason}): pueden faltar los issues que podrías construir a mano`,
|
|
142
|
+
workflow: (u) => `el workflow relevo-go.yml (${u.reason}): se supone que la nube construye, así que pueden faltar los issues que podrías construir a mano`,
|
|
143
|
+
compare: (u) => `la comparación de la rama del pull request #${u.pr} con la base (${u.reason}): puede que no se avise de una rama atrasada`,
|
|
144
|
+
}, onMe: 'Esperando por ti', review: 'Pull requests por revisar', ship: 'Aprobadas, listas para entregar',
|
|
135
145
|
others: 'Esperando a otros', spec: 'De vuelta con quien escribe las specs', blocked: 'Bloqueadas', needsReviewer: 'Sin revisor', milestone: 'Milestone',
|
|
136
146
|
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
147
|
alerts: 'Alertas', staleReview: 'revisión esperando', staleBranch: 'rama atrasada respecto a la base por',
|
|
@@ -160,7 +170,8 @@ export function renderStatus(report, language = 'en') {
|
|
|
160
170
|
section(t.review, report.toReview.map((r) => `#${r.number} ${r.pr}`));
|
|
161
171
|
section(t.ship, report.toShip.map((r) => `#${r.number} → ${r.command}`));
|
|
162
172
|
section(t.others, report.waitingOnOthers.map((e) => withWaits(e, `#${e.number} ${e.title} (${e.state})`)));
|
|
163
|
-
|
|
173
|
+
const unread = report.unread ?? [];
|
|
174
|
+
if (!sw && !report.onMe.length && !report.toReview.length && !report.toShip.length) out.unshift(unread.length ? `${t.nothing} ${t.fromRead}` : t.nothing, '');
|
|
164
175
|
report.milestones.forEach((m) => {
|
|
165
176
|
out.push(`${t.milestone} "${m.title}": ${m.merged} ${t.merged}, ${m.inReview} ${t.inReview}, ${m.queued} ${t.queued}`);
|
|
166
177
|
if (!m.dueOn) out.push(` ${t.milestone} ${t.noDue}`);
|
|
@@ -173,6 +184,10 @@ export function renderStatus(report, language = 'en') {
|
|
|
173
184
|
? `#${a.issue} ${t.staleReview} ${a.hours}h (@${a.reviewer}) ${a.pr}`
|
|
174
185
|
: `#${a.issue} ${t.staleBranch} ${a.behind} ${a.pr}`;
|
|
175
186
|
}));
|
|
187
|
+
if (unread.length) {
|
|
188
|
+
if (out.length && out[out.length - 1] !== '') out.push('');
|
|
189
|
+
section(t.unreadTitle, unread.map((u) => t.unread[u.what](u)));
|
|
190
|
+
}
|
|
176
191
|
while (out.length && out[out.length - 1] === '') out.pop();
|
|
177
192
|
return out.join('\n');
|
|
178
193
|
}
|