@elalfner/relevo 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/NOTICE +51 -0
- package/README.es.md +90 -0
- package/README.md +89 -2
- package/agents/dev.md +101 -0
- package/agents/qa.md +112 -0
- package/package.json +42 -4
- package/scripts/add-corrections.mjs +21 -0
- package/scripts/app-id.mjs +19 -0
- package/scripts/archive-state.mjs +31 -0
- package/scripts/branch-state.mjs +31 -0
- package/scripts/branch-work.mjs +25 -0
- package/scripts/can-edit-issue.mjs +45 -0
- package/scripts/capture.mjs +22 -0
- package/scripts/check-archive.mjs +19 -0
- package/scripts/check-issue.mjs +41 -0
- package/scripts/checklist.mjs +29 -0
- package/scripts/cli.mjs +28 -0
- package/scripts/commit-change.mjs +50 -0
- package/scripts/conflict-scope.mjs +19 -0
- package/scripts/design-fetch.mjs +103 -0
- package/scripts/device.mjs +12 -0
- package/scripts/digest.mjs +50 -0
- package/scripts/doctor.mjs +47 -0
- package/scripts/fix-guard.mjs +38 -0
- package/scripts/fix-stash.mjs +57 -0
- package/scripts/fix-state.mjs +28 -0
- package/scripts/fix-tasks.mjs +22 -0
- package/scripts/go-local.mjs +49 -0
- package/scripts/go-plan.mjs +82 -0
- package/scripts/init.mjs +109 -0
- package/scripts/intent-to-add.mjs +20 -0
- package/scripts/issue-coverage.mjs +30 -0
- package/scripts/issue-hash.mjs +44 -0
- package/scripts/issue-sections.mjs +42 -0
- package/scripts/label-change.mjs +35 -0
- package/scripts/lib/appid.mjs +12 -0
- package/scripts/lib/archive-check.mjs +29 -0
- package/scripts/lib/archive-state.mjs +15 -0
- package/scripts/lib/branch-work.mjs +13 -0
- package/scripts/lib/branch.mjs +43 -0
- package/scripts/lib/can-edit-issue.mjs +34 -0
- package/scripts/lib/checklist.mjs +68 -0
- package/scripts/lib/cli.mjs +82 -0
- package/scripts/lib/commit-change.mjs +57 -0
- package/scripts/lib/config.mjs +296 -0
- package/scripts/lib/design-fetch.mjs +156 -0
- package/scripts/lib/device.mjs +67 -0
- package/scripts/lib/doctor.mjs +614 -0
- package/scripts/lib/execution-mode.mjs +24 -0
- package/scripts/lib/fix-guard.mjs +23 -0
- package/scripts/lib/fix-stash.mjs +17 -0
- package/scripts/lib/fix.mjs +31 -0
- package/scripts/lib/github.mjs +161 -0
- package/scripts/lib/go-local.mjs +102 -0
- package/scripts/lib/init.mjs +324 -0
- package/scripts/lib/intent-to-add.mjs +32 -0
- package/scripts/lib/issue-check.mjs +63 -0
- package/scripts/lib/issue-coverage.mjs +47 -0
- package/scripts/lib/issue-hash.mjs +25 -0
- package/scripts/lib/issue-parse.mjs +194 -0
- package/scripts/lib/issue-sections.mjs +43 -0
- package/scripts/lib/labels.mjs +65 -0
- package/scripts/lib/language.mjs +9 -0
- package/scripts/lib/logaudit.mjs +123 -0
- package/scripts/lib/messages.mjs +328 -0
- package/scripts/lib/next-step.mjs +144 -0
- package/scripts/lib/openspec-commands.mjs +44 -0
- package/scripts/lib/openspec-status.mjs +16 -0
- package/scripts/lib/plan.mjs +256 -0
- package/scripts/lib/platform-of.mjs +32 -0
- package/scripts/lib/platform-scope.mjs +32 -0
- package/scripts/lib/platforms.mjs +90 -0
- package/scripts/lib/preflight.mjs +71 -0
- package/scripts/lib/protected-paths.mjs +102 -0
- package/scripts/lib/requeue.mjs +23 -0
- package/scripts/lib/review-adapters.mjs +37 -0
- package/scripts/lib/review-brief.mjs +43 -0
- package/scripts/lib/review-events.mjs +56 -0
- package/scripts/lib/review-state.mjs +157 -0
- package/scripts/lib/reviewers.mjs +39 -0
- package/scripts/lib/role-skills.mjs +112 -0
- package/scripts/lib/run-flags.mjs +16 -0
- package/scripts/lib/run-usage.mjs +65 -0
- package/scripts/lib/scan.mjs +73 -0
- package/scripts/lib/ship.mjs +161 -0
- package/scripts/lib/sla.mjs +90 -0
- package/scripts/lib/stack-profile.mjs +169 -0
- package/scripts/lib/staging.mjs +21 -0
- package/scripts/lib/status-data.mjs +109 -0
- package/scripts/lib/status.mjs +199 -0
- package/scripts/lib/tasks-file.mjs +81 -0
- package/scripts/lib/web-review.mjs +98 -0
- package/scripts/lib/yaml-lite.mjs +157 -0
- package/scripts/needs-reviewer.mjs +41 -0
- package/scripts/next-step.mjs +39 -0
- package/scripts/pick-reviewer.mjs +42 -0
- package/scripts/platform-of.mjs +49 -0
- package/scripts/preflight.mjs +32 -0
- package/scripts/render-message.mjs +35 -0
- package/scripts/requeue.mjs +53 -0
- package/scripts/review-adapter.mjs +32 -0
- package/scripts/review-brief.mjs +29 -0
- package/scripts/review-event.mjs +70 -0
- package/scripts/review-sla.mjs +41 -0
- package/scripts/review-state.mjs +171 -0
- package/scripts/review-who.mjs +21 -0
- package/scripts/role-skills.mjs +53 -0
- package/scripts/run-usage.mjs +22 -0
- package/scripts/scan-diff.mjs +16 -0
- package/scripts/ship-check.mjs +54 -0
- package/scripts/spec-ready.mjs +40 -0
- package/scripts/stack-profile.mjs +67 -0
- package/scripts/stage.mjs +14 -0
- package/scripts/status.mjs +47 -0
- package/scripts/usage-limit.mjs +17 -0
- package/scripts/validate-config.mjs +44 -0
- package/scripts/web-review.mjs +134 -0
- package/skills/doctor/SKILL.md +28 -0
- package/skills/fix/SKILL.md +128 -0
- package/skills/go/SKILL.md +242 -0
- package/skills/init/SKILL.md +72 -0
- package/skills/issue/SKILL.md +128 -0
- package/skills/issue/references/update.md +15 -0
- package/skills/review/SKILL.md +157 -0
- package/skills/ship/SKILL.md +128 -0
- package/skills/status/SKILL.md +27 -0
- package/skills/think/SKILL.md +76 -0
- package/stacks/flutter.md +63 -0
- package/stacks/laravel.md +65 -0
- package/stacks/vue.md +70 -0
- package/templates/issue.md +99 -0
- package/templates/relevo.yml +59 -0
- package/templates/workflows/relevo-digest.yml +54 -0
- package/templates/workflows/relevo-go.yml +263 -0
- package/templates/workflows/relevo-queue.yml +72 -0
- package/templates/workflows/relevo-review.yml +60 -0
- package/templates/workflows/relevo-sla.yml +53 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Builds the review checklist from the scenarios of a change specification.
|
|
2
|
+
// One checklist line per `#### Scenario:` found under `<change>/specs/**/spec.md`.
|
|
3
|
+
|
|
4
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SCENARIO_RE = /^####\s+Scenario:\s*(.+?)\s*$/;
|
|
8
|
+
const STEP_RE = /^\s*[-*]\s+\*\*(AND WHEN|WHEN|THEN|AND THEN|AND)\*\*\s*(.*?)\s*$/;
|
|
9
|
+
|
|
10
|
+
/** Parse scenarios out of the text of one spec file. */
|
|
11
|
+
export function scenariosFromSpec(text, source = '') {
|
|
12
|
+
const scenarios = [];
|
|
13
|
+
let current = null;
|
|
14
|
+
String(text).split('\n').forEach((line) => {
|
|
15
|
+
const heading = line.match(/^#{1,6}\s/);
|
|
16
|
+
const scenario = line.match(SCENARIO_RE);
|
|
17
|
+
if (scenario) {
|
|
18
|
+
current = { title: scenario[1], when: [], then: [], source };
|
|
19
|
+
scenarios.push(current);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (heading) {
|
|
23
|
+
current = null;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const step = current && line.match(STEP_RE);
|
|
27
|
+
if (!step) return;
|
|
28
|
+
const [, kind, rest] = step;
|
|
29
|
+
if (kind.endsWith('WHEN') || (kind === 'AND' && current.then.length === 0)) current.when.push(rest);
|
|
30
|
+
else current.then.push(rest);
|
|
31
|
+
});
|
|
32
|
+
return scenarios;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function specFiles(dir) {
|
|
36
|
+
const out = [];
|
|
37
|
+
(function walk(d) {
|
|
38
|
+
readdirSync(d).sort().forEach((name) => {
|
|
39
|
+
const path = join(d, name);
|
|
40
|
+
if (statSync(path).isDirectory()) walk(path);
|
|
41
|
+
else if (name === 'spec.md') out.push(path);
|
|
42
|
+
});
|
|
43
|
+
}(dir));
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** All scenarios of a change directory, in file order. Returns [{ id, title, when, then, source }]. */
|
|
48
|
+
export function checklistFromChange(changeDir) {
|
|
49
|
+
const specsDir = join(changeDir, 'specs');
|
|
50
|
+
const items = [];
|
|
51
|
+
specFiles(specsDir).forEach((file) => {
|
|
52
|
+
scenariosFromSpec(readFileSync(file, 'utf8'), file.slice(changeDir.length).replace(/^\/+/, '')).forEach((s) => items.push(s));
|
|
53
|
+
});
|
|
54
|
+
return items.map((s, i) => ({ id: i + 1, ...s }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Markdown checklist, optionally with the items already confirmed ticked. */
|
|
58
|
+
export function checklistMarkdown(items, statuses = {}, autos = {}) {
|
|
59
|
+
return items
|
|
60
|
+
.map((s) => {
|
|
61
|
+
const mark = statuses[s.id] === 'ok' ? 'x' : ' ';
|
|
62
|
+
const detail = [s.when.join(' and '), s.then.join(' and ')].filter(Boolean);
|
|
63
|
+
const text = detail.length === 2 ? `when ${detail[0]}, then ${detail[1]}` : detail.join('');
|
|
64
|
+
const auto = autos[s.id] ? ` _(verified automatically: ${autos[s.id]})_` : '';
|
|
65
|
+
return `- [${mark}] ${s.id}. **${s.title}**: ${text}${auto}`;
|
|
66
|
+
})
|
|
67
|
+
.join('\n');
|
|
68
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The commands behind `relevo`. They only drive `claude plugin`, so Claude Code stays the one place that installs
|
|
2
|
+
// and updates a plugin; nothing here writes a file. `run` and `has` are injected so a test can use a fake `claude`.
|
|
3
|
+
|
|
4
|
+
export const MARKETPLACE = 'elalfner/relevo';
|
|
5
|
+
export const MARKETPLACE_NAME = 'relevo';
|
|
6
|
+
export const PLUGIN_ID = 'relevo@relevo';
|
|
7
|
+
export const SCOPES = ['user', 'project', 'local'];
|
|
8
|
+
|
|
9
|
+
export const USAGE = [
|
|
10
|
+
'usage: relevo <command> [--scope user|project|local]',
|
|
11
|
+
'',
|
|
12
|
+
' install add the marketplace and install the plugin (default scope: user)',
|
|
13
|
+
' update update the marketplace and the plugin',
|
|
14
|
+
' doctor check this repository (the same as /relevo:doctor)',
|
|
15
|
+
' version print the version of this package',
|
|
16
|
+
'',
|
|
17
|
+
'user: every project of this machine. project: this repository, shared through .claude/settings.json. local: this repository, only you.'
|
|
18
|
+
].join('\n');
|
|
19
|
+
|
|
20
|
+
/** Parses the arguments; { error } when they are not valid. */
|
|
21
|
+
export function parseArgs(argv) {
|
|
22
|
+
const [command, ...rest] = argv;
|
|
23
|
+
if (!command || command === '--help' || command === '-h' || command === 'help') return { command: 'help' };
|
|
24
|
+
if (!['install', 'update', 'doctor', 'version'].includes(command)) return { error: `unknown command "${command}"` };
|
|
25
|
+
let scope = 'user';
|
|
26
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
27
|
+
const arg = rest[i];
|
|
28
|
+
if (arg === '--scope') { scope = rest[i + 1]; i += 1; } else if (arg.startsWith('--scope=')) scope = arg.slice(8);
|
|
29
|
+
else return { error: `unknown option "${arg}"` };
|
|
30
|
+
}
|
|
31
|
+
if (!SCOPES.includes(scope)) return { error: `the scope must be one of ${SCOPES.join(', ')}` };
|
|
32
|
+
return { command, scope };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The `claude` invocations of a command, in order. */
|
|
36
|
+
export function steps(command, scope) {
|
|
37
|
+
if (command === 'install') {
|
|
38
|
+
return [
|
|
39
|
+
{ label: 'add the marketplace (only .claude-plugin/ is downloaded)', args: ['plugin', 'marketplace', 'add', MARKETPLACE, '--scope', scope, '--sparse', '.claude-plugin'] },
|
|
40
|
+
{ label: 'install the plugin', args: ['plugin', 'install', PLUGIN_ID, '--scope', scope] }
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
if (command === 'update') {
|
|
44
|
+
return [
|
|
45
|
+
{ label: 'update the marketplace', args: ['plugin', 'marketplace', 'update', MARKETPLACE_NAME] },
|
|
46
|
+
{ label: 'update the plugin', args: ['plugin', 'update', PLUGIN_ID, '--scope', scope] }
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runs a command. Returns { code, lines }: the lines to print and the exit code. Stops at the first failing step
|
|
54
|
+
* and says which one, so the person knows what was already done.
|
|
55
|
+
*/
|
|
56
|
+
export function execute({ command, scope }, { run, has, version, doctorScript }) {
|
|
57
|
+
const lines = [];
|
|
58
|
+
if (command === 'version') return { code: 0, lines: [version] };
|
|
59
|
+
if (command === 'doctor') {
|
|
60
|
+
const r = run(process.execPath, [doctorScript]);
|
|
61
|
+
return { code: r.code, lines: [r.output.trimEnd()] };
|
|
62
|
+
}
|
|
63
|
+
if (!has('claude')) {
|
|
64
|
+
return { code: 1, lines: ['Claude Code is required and "claude" was not found on the path.', 'Install it (https://code.claude.com/docs) and run this command again; nothing was changed.'] };
|
|
65
|
+
}
|
|
66
|
+
const list = steps(command, scope);
|
|
67
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
68
|
+
const step = list[i];
|
|
69
|
+
lines.push(`[${i + 1}/${list.length}] ${step.label}`);
|
|
70
|
+
const r = run('claude', step.args);
|
|
71
|
+
if (r.output.trim()) lines.push(r.output.trimEnd());
|
|
72
|
+
if (r.code !== 0) {
|
|
73
|
+
lines.push(`The step "${step.label}" failed${i > 0 ? `; the steps before it were done (${list.slice(0, i).map((s) => s.label).join(', ')})` : ''}. Fix the cause and run the command again: it is safe to repeat.`);
|
|
74
|
+
return { code: r.code || 1, lines };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const where = { user: 'for this machine', project: 'for this repository (declared in .claude/settings.json, so commit it to share it)', local: 'for this repository, only for you' }[scope];
|
|
78
|
+
lines.push(command === 'install'
|
|
79
|
+
? `Relevo is installed ${where}. Restart Claude Code and run /relevo:doctor.`
|
|
80
|
+
: 'Relevo is up to date. Restart Claude Code to apply it.');
|
|
81
|
+
return { code: 0, lines };
|
|
82
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// One safe way to commit: name the files, check protected paths, scan the diff, and only then commit.
|
|
2
|
+
// Implements "Safe commits" of the go specification and "Protected paths" of the security specification.
|
|
3
|
+
//
|
|
4
|
+
// `git(args) -> stdout` is injected so the logic can be tested; the command-line wrapper runs the real git.
|
|
5
|
+
|
|
6
|
+
import { checkScope } from './platform-scope.mjs';
|
|
7
|
+
import { checkProtected, formatViolations } from './protected-paths.mjs';
|
|
8
|
+
import { formatFindings, scanDiff } from './scan.mjs';
|
|
9
|
+
import { explicitStageArgs } from './staging.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stage `paths`, check them and commit only them.
|
|
13
|
+
* message the commit message
|
|
14
|
+
* paths the files or folders to commit, named one by one (never "." or "-A")
|
|
15
|
+
* allow globs from the `Allow-paths:` line of the issue (dependency files only)
|
|
16
|
+
* rulesFile the project rules file (`qa.rules_from`), which is never changed
|
|
17
|
+
* within the folder of the platform of the issue: files outside it are refused (`.` or null: no limit)
|
|
18
|
+
* change the change id, whose specification folder may always be committed
|
|
19
|
+
* Returns { ok, commit, findings, violations }. When something is found nothing is committed and the named paths
|
|
20
|
+
* are unstaged again (files that were staged for other reasons stay staged).
|
|
21
|
+
*/
|
|
22
|
+
export function commitChange({ git, message, paths, allow = [], rulesFile = null, within = null, change = null }) {
|
|
23
|
+
if (!String(message ?? '').trim()) throw new Error('give a commit message');
|
|
24
|
+
const addArgs = explicitStageArgs(paths);
|
|
25
|
+
|
|
26
|
+
// A path that is protected is refused before anything is staged.
|
|
27
|
+
const named = checkProtected(paths, { allow, rulesFile });
|
|
28
|
+
if (named.violations.some((v) => v.tier === 'hard')) return { ok: false, commit: null, findings: [], violations: named.violations };
|
|
29
|
+
|
|
30
|
+
git(addArgs);
|
|
31
|
+
const unstage = () => git(['reset', '-q', '--', ...paths]);
|
|
32
|
+
|
|
33
|
+
// The folders named above may hold protected files: check what is really staged under them.
|
|
34
|
+
const staged = git(['diff', '--cached', '--name-only', '--', ...paths]).split('\n').filter(Boolean);
|
|
35
|
+
const violations = [
|
|
36
|
+
...checkProtected(staged, { allow, rulesFile }).violations,
|
|
37
|
+
...checkScope(staged, { within, change, allow }).violations,
|
|
38
|
+
];
|
|
39
|
+
const findings = scanDiff(git(['diff', '--cached', '-U0', '--', ...paths]));
|
|
40
|
+
if (violations.length > 0 || findings.length > 0) {
|
|
41
|
+
unstage();
|
|
42
|
+
return { ok: false, commit: null, findings, violations };
|
|
43
|
+
}
|
|
44
|
+
if (staged.length === 0) throw new Error('nothing to commit: none of the named paths has changes');
|
|
45
|
+
|
|
46
|
+
// `--` with paths commits only those paths, so files staged for another reason are left alone.
|
|
47
|
+
git(['commit', '-q', '-m', message, '--', ...paths]);
|
|
48
|
+
return { ok: true, commit: git(['rev-parse', '--short', 'HEAD']).trim(), findings: [], violations: [] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The lines to print when a commit was refused. Never contains a secret. */
|
|
52
|
+
export function refusalText({ findings, violations }) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
if (violations.length > 0) lines.push(formatViolations(violations));
|
|
55
|
+
if (findings.length > 0) lines.push(formatFindings(findings));
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Schema, defaults and validation for relevo.yml.
|
|
2
|
+
// Implements openspec/specs/configuration/spec.md.
|
|
3
|
+
|
|
4
|
+
import { parse, YamlLiteError } from './yaml-lite.mjs';
|
|
5
|
+
import { LABEL_KEYS } from './labels.mjs';
|
|
6
|
+
import { SUPPORTED_LANGUAGES } from './messages.mjs';
|
|
7
|
+
import { findCredentials } from './scan.mjs';
|
|
8
|
+
import { normalizePlatforms } from './platforms.mjs';
|
|
9
|
+
|
|
10
|
+
// One entry per supported setting. `required: true` means there is no default.
|
|
11
|
+
export const SETTINGS = [
|
|
12
|
+
{ path: 'branches.base', type: 'string', default: 'dev' },
|
|
13
|
+
{ path: 'branches.release', type: 'string', default: 'main' },
|
|
14
|
+
{ path: 'tracker', type: 'enum', allowed: ['github'], default: 'github' },
|
|
15
|
+
{ path: 'git_provider', type: 'enum', allowed: ['github'], default: 'github' },
|
|
16
|
+
{ path: 'team.members', type: 'string[]', default: [] },
|
|
17
|
+
{ path: 'team.spec_writer', type: 'string', default: null },
|
|
18
|
+
{ path: 'team.approvers', type: 'string[]', default: [] },
|
|
19
|
+
{ path: 'review.mode', type: 'enum', allowed: ['none', 'peer', 'roles'], default: 'peer' },
|
|
20
|
+
{ path: 'review.merger', type: 'enum', allowed: ['author', 'approver'], default: 'approver' },
|
|
21
|
+
{ path: 'review.autopilot', type: 'enum', allowed: ['off', 'precheck', 'verify'], default: 'off' },
|
|
22
|
+
{ path: 'review.allowed_origins', type: 'origin-list', default: [] },
|
|
23
|
+
{ path: 'limits.built_unreviewed_per_person', type: 'int', min: 1, default: 2 },
|
|
24
|
+
{ path: 'limits.concurrent_agents', type: 'int', min: 1, default: 3 },
|
|
25
|
+
{ path: 'limits.review_sla_hours', type: 'int', min: 1, default: 8 },
|
|
26
|
+
{ path: 'limits.run_max_turns', type: 'int', min: 1, default: 100 },
|
|
27
|
+
{ path: 'limits.building_alert_hours', type: 'int', min: 1, default: 3 },
|
|
28
|
+
{ path: 'limits.run_max_cost', type: 'number', exclusiveMin: 0, required: true },
|
|
29
|
+
{ path: 'qa.retries', type: 'int', min: 0, default: 1 },
|
|
30
|
+
{ path: 'qa.rules_from', type: 'path-list', default: [] },
|
|
31
|
+
{ path: 'skills.allow_scripts', type: 'string[]', default: [] },
|
|
32
|
+
{ path: 'links', type: 'repo-list', default: [] },
|
|
33
|
+
{ path: 'evidence.policy', type: 'enum', allowed: ['attach_to_pr', 'local_only'], default: 'attach_to_pr' },
|
|
34
|
+
{ path: 'digest.issue', type: 'int', min: 1, default: null },
|
|
35
|
+
{ path: 'language', type: 'string', default: 'en' },
|
|
36
|
+
{ path: 'design.images', type: 'bool', default: false },
|
|
37
|
+
{ path: 'trust', type: 'enum', allowed: ['private-team', 'public'], default: 'private-team' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const GROUPS = new Set(SETTINGS.filter((s) => s.path.includes('.')).map((s) => s.path.split('.')[0]));
|
|
41
|
+
const TOP_SCALARS = new Set(SETTINGS.filter((s) => !s.path.includes('.')).map((s) => s.path));
|
|
42
|
+
const KNOWN_TOP = new Set([...GROUPS, ...TOP_SCALARS, 'labels', 'platform', 'platforms']);
|
|
43
|
+
const ORIGIN_RE = /^https?:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?$/;
|
|
44
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
45
|
+
const LANGUAGE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
46
|
+
const GITHUB_LABEL_MAX = 50;
|
|
47
|
+
|
|
48
|
+
function getPath(obj, path) {
|
|
49
|
+
return path.split('.').reduce((acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined), obj);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setPath(obj, path, value) {
|
|
53
|
+
const keys = path.split('.');
|
|
54
|
+
let cursor = obj;
|
|
55
|
+
keys.slice(0, -1).forEach((key) => {
|
|
56
|
+
if (typeof cursor[key] !== 'object' || cursor[key] === null) cursor[key] = {};
|
|
57
|
+
cursor = cursor[key];
|
|
58
|
+
});
|
|
59
|
+
cursor[keys[keys.length - 1]] = value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function describeAllowed(allowed) {
|
|
63
|
+
return allowed.map((v) => `"${v}"`).join(', ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkType(setting, value, errors) {
|
|
67
|
+
const { path } = setting;
|
|
68
|
+
switch (setting.type) {
|
|
69
|
+
case 'string':
|
|
70
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
71
|
+
errors.push({ path, message: `${path} must be a non-empty string (got ${JSON.stringify(value)})` });
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
case 'string[]':
|
|
75
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
76
|
+
errors.push({ path, message: `${path} must be a list of non-empty strings (got ${JSON.stringify(value)})` });
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
case 'path-list':
|
|
80
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== 'string' || v.trim() === '')) {
|
|
81
|
+
errors.push({ path, message: `${path} must be a file name or a list of file names (got ${JSON.stringify(value)})` });
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
case 'repo-list': {
|
|
85
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
86
|
+
errors.push({ path, message: `${path} must be a list of repositories written owner/repo (got ${JSON.stringify(value)})` });
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
value.filter((v) => !REPO_RE.test(v)).forEach((v) => errors.push({ path, message: `${path}: "${v}" is not a repository; the form is owner/repo` }));
|
|
90
|
+
value.filter((v, i) => value.indexOf(v) !== i).forEach((v) => errors.push({ path, message: `${path} lists "${v}" more than once` }));
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case 'origin-list': {
|
|
94
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
95
|
+
errors.push({ path, message: `${path} must be a list of web origins written like https://staging.example.com (got ${JSON.stringify(value)})` });
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
value.filter((v) => !ORIGIN_RE.test(v)).forEach((v) => errors.push({ path, message: `${path}: "${v}" is not an origin; write the scheme, the host and optionally the port, with no path (https://staging.example.com)` }));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'enum':
|
|
102
|
+
if (!setting.allowed.includes(value)) {
|
|
103
|
+
errors.push({
|
|
104
|
+
path,
|
|
105
|
+
message: `${path} has invalid value ${JSON.stringify(value)}; allowed values: ${describeAllowed(setting.allowed)}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
case 'bool':
|
|
110
|
+
if (typeof value !== 'boolean') errors.push({ path, message: `${path} must be true or false (got ${JSON.stringify(value)})` });
|
|
111
|
+
break;
|
|
112
|
+
case 'int':
|
|
113
|
+
if (!Number.isInteger(value) || value < setting.min) {
|
|
114
|
+
errors.push({ path, message: `${path} must be an integer >= ${setting.min} (got ${JSON.stringify(value)})` });
|
|
115
|
+
}
|
|
116
|
+
break;
|
|
117
|
+
case 'number':
|
|
118
|
+
if (typeof value !== 'number' || !(value > setting.exclusiveMin)) {
|
|
119
|
+
errors.push({ path, message: `${path} must be a number > ${setting.exclusiveMin} (got ${JSON.stringify(value)})` });
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
default:
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function collectStrings(value, path, out) {
|
|
128
|
+
if (typeof value === 'string') out.push({ path, value });
|
|
129
|
+
else if (Array.isArray(value)) value.forEach((v, i) => collectStrings(v, `${path}[${i}]`, out));
|
|
130
|
+
else if (value && typeof value === 'object') {
|
|
131
|
+
Object.entries(value).forEach(([k, v]) => collectStrings(v, path ? `${path}.${k}` : k, out));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate a parsed configuration object.
|
|
137
|
+
* Returns { ok, errors, warnings, config } where config has defaults applied.
|
|
138
|
+
*/
|
|
139
|
+
export function validateConfig(raw, { knownTargets = null, knownChecks = null } = {}) {
|
|
140
|
+
const errors = [];
|
|
141
|
+
const warnings = [];
|
|
142
|
+
const user = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
143
|
+
|
|
144
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
145
|
+
errors.push({ path: '', message: 'relevo.yml must be a map of settings' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Unknown top-level keys and unknown keys inside groups (catches typos).
|
|
149
|
+
Object.keys(user).forEach((key) => {
|
|
150
|
+
if (!KNOWN_TOP.has(key)) errors.push({ path: key, message: `unknown setting "${key}"` });
|
|
151
|
+
});
|
|
152
|
+
GROUPS.forEach((group) => {
|
|
153
|
+
const value = user[group];
|
|
154
|
+
if (value === undefined || value === null) return;
|
|
155
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
156
|
+
errors.push({ path: group, message: `${group} must be a map` });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const known = new Set(SETTINGS.filter((s) => s.path.startsWith(`${group}.`)).map((s) => s.path.split('.')[1]));
|
|
160
|
+
Object.keys(value).forEach((k) => {
|
|
161
|
+
if (!known.has(k)) errors.push({ path: `${group}.${k}`, message: `unknown setting "${group}.${k}"` });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Per-setting checks and defaults.
|
|
166
|
+
const config = {};
|
|
167
|
+
SETTINGS.forEach((setting) => {
|
|
168
|
+
const value = getPath(user, setting.path);
|
|
169
|
+
if (value === undefined || value === null) {
|
|
170
|
+
if (setting.required) {
|
|
171
|
+
errors.push({
|
|
172
|
+
path: setting.path,
|
|
173
|
+
message: `${setting.path} is required: unattended runs must always have a cost limit (amount in US dollars per run)`,
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
setPath(config, setting.path, structuredClone(setting.default));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// A single file name is the same as a list of one.
|
|
181
|
+
const given = setting.type === 'path-list' && typeof value === 'string' ? [value] : value;
|
|
182
|
+
checkType(setting, given, errors);
|
|
183
|
+
setPath(config, setting.path, given);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Platforms: the registry, or the legacy shortcut turned into one entry.
|
|
187
|
+
const normalized = normalizePlatforms({ platform: user.platform, platforms: user.platforms }, { knownTargets });
|
|
188
|
+
errors.push(...normalized.errors);
|
|
189
|
+
config.platforms = normalized.platforms;
|
|
190
|
+
if (knownChecks) {
|
|
191
|
+
Object.entries(config.platforms).forEach(([name, entry]) => {
|
|
192
|
+
const valid = knownChecks(entry.stack);
|
|
193
|
+
if (!valid) return;
|
|
194
|
+
Object.keys(entry.checks ?? {}).filter((c) => !valid.includes(c)).forEach((c) => {
|
|
195
|
+
errors.push({ path: `platforms.${name}.checks.${c}`, message: `platforms.${name}.checks has unknown command "${c}"; the stack "${entry.stack}" defines: ${valid.join(', ')}` });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Labels: renameable, unique, within GitHub's length limit.
|
|
201
|
+
const labels = user.labels;
|
|
202
|
+
config.labels = {};
|
|
203
|
+
if (labels !== undefined && labels !== null) {
|
|
204
|
+
if (typeof labels !== 'object' || Array.isArray(labels)) {
|
|
205
|
+
errors.push({ path: 'labels', message: 'labels must be a map of label key to label name' });
|
|
206
|
+
} else {
|
|
207
|
+
Object.entries(labels).forEach(([key, name]) => {
|
|
208
|
+
if (!LABEL_KEYS.includes(key)) {
|
|
209
|
+
errors.push({ path: `labels.${key}`, message: `unknown label key "${key}"; allowed keys: ${describeAllowed(LABEL_KEYS)}` });
|
|
210
|
+
} else if (typeof name !== 'string' || name.trim() === '' || name.length > GITHUB_LABEL_MAX) {
|
|
211
|
+
errors.push({ path: `labels.${key}`, message: `labels.${key} must be a non-empty string of at most ${GITHUB_LABEL_MAX} characters` });
|
|
212
|
+
} else {
|
|
213
|
+
config.labels[key] = name;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
const names = Object.values(config.labels);
|
|
217
|
+
if (new Set(names).size !== names.length) {
|
|
218
|
+
errors.push({ path: 'labels', message: 'label names must be unique' });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Language: any language tag; only some have message catalogs.
|
|
224
|
+
const lang = config.language;
|
|
225
|
+
if (typeof lang === 'string') {
|
|
226
|
+
if (!LANGUAGE_RE.test(lang)) {
|
|
227
|
+
errors.push({ path: 'language', message: `language must look like "en" or "pt-BR" (got ${JSON.stringify(lang)})` });
|
|
228
|
+
} else if (!SUPPORTED_LANGUAGES.includes(lang.split('-')[0])) {
|
|
229
|
+
warnings.push({
|
|
230
|
+
path: 'language',
|
|
231
|
+
message: `no message catalog for "${lang}"; messages fall back to "en" (available: ${SUPPORTED_LANGUAGES.join(', ')})`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Cross-setting rules.
|
|
237
|
+
if (config.review.mode === 'roles' && config.team.approvers.length === 0) {
|
|
238
|
+
errors.push({ path: 'team.approvers', message: 'team.approvers must list at least one person when review.mode is "roles"' });
|
|
239
|
+
}
|
|
240
|
+
if (config.trust === 'public' && config.review.mode === 'none') {
|
|
241
|
+
errors.push({ path: 'review.mode', message: 'trust "public" needs a human review of generated code: review.mode cannot be "none"' });
|
|
242
|
+
}
|
|
243
|
+
if (config.review.mode === 'peer' || config.review.mode === 'roles') {
|
|
244
|
+
const eligible = config.team.members.filter((m) => m !== config.team.spec_writer);
|
|
245
|
+
if (eligible.length < 2) {
|
|
246
|
+
warnings.push({
|
|
247
|
+
path: 'team.members',
|
|
248
|
+
message: 'review.mode needs a second person to review: team.members should list at least 2 people besides the spec writer',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Credentials must never live in this file (checked on every value and key).
|
|
254
|
+
const strings = [];
|
|
255
|
+
collectStrings(user, '', strings);
|
|
256
|
+
strings.forEach(({ path, value }) => {
|
|
257
|
+
if (findCredentials(value, { strict: true }).length > 0) {
|
|
258
|
+
errors.push({
|
|
259
|
+
path,
|
|
260
|
+
message: `${path || 'value'} looks like a credential; move it to the repository secret store, never to relevo.yml`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
const flatKeys = [];
|
|
265
|
+
(function walk(obj, prefix) {
|
|
266
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;
|
|
267
|
+
Object.entries(obj).forEach(([k, v]) => {
|
|
268
|
+
flatKeys.push({ key: k, path: prefix ? `${prefix}.${k}` : k });
|
|
269
|
+
walk(v, prefix ? `${prefix}.${k}` : k);
|
|
270
|
+
});
|
|
271
|
+
}(user, ''));
|
|
272
|
+
flatKeys.forEach(({ key, path }) => {
|
|
273
|
+
if (/(api[_-]?key|secret|password|token)/i.test(key)) {
|
|
274
|
+
errors.push({
|
|
275
|
+
path,
|
|
276
|
+
message: `${path} looks like a credential setting; move it to the repository secret store, never to relevo.yml`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return { ok: errors.length === 0, errors, warnings, config };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Parse relevo.yml text and validate it. Parse errors are returned as errors. */
|
|
285
|
+
export function loadConfig(text, options = {}) {
|
|
286
|
+
let raw;
|
|
287
|
+
try {
|
|
288
|
+
raw = parse(text);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err instanceof YamlLiteError) {
|
|
291
|
+
return { ok: false, errors: [{ path: '', message: `relevo.yml is not valid: ${err.message}` }], warnings: [], config: null };
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
return validateConfig(raw, options);
|
|
296
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Downloads the references of a design reference, inside fixed limits. Implements "The fetcher stays inside fixed
|
|
2
|
+
// limits" of openspec/specs/go/spec.md. The network and `gh` are injected so the module is tested without either.
|
|
3
|
+
|
|
4
|
+
import { ATTACHMENT_HOSTS, MAX_REFERENCES } from './issue-parse.mjs';
|
|
5
|
+
|
|
6
|
+
export const IMAGE_MAX = 5 * 1024 * 1024;
|
|
7
|
+
export const TEXT_MAX = 200 * 1024;
|
|
8
|
+
export const FILES_MAX = MAX_REFERENCES;
|
|
9
|
+
export const HOPS_MAX = 5;
|
|
10
|
+
// Where a download may be redirected to: the attachment domains and the host that serves them signed.
|
|
11
|
+
export const REDIRECT_HOSTS = [...ATTACHMENT_HOSTS, 'objects.githubusercontent.com'];
|
|
12
|
+
// The token is sent only here.
|
|
13
|
+
const TOKEN_HOSTS = new Set(['github.com', 'api.github.com']);
|
|
14
|
+
|
|
15
|
+
/** png, jpeg, gif or webp from the first bytes, or null. The name and the Content-Type are never trusted. */
|
|
16
|
+
export function sniffImage(buf) {
|
|
17
|
+
const b = Buffer.from(buf);
|
|
18
|
+
if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png';
|
|
19
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpeg';
|
|
20
|
+
if (b.length >= 6 && ['GIF87a', 'GIF89a'].includes(b.subarray(0, 6).toString('latin1'))) return 'gif';
|
|
21
|
+
if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP') return 'webp';
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Is it UTF-8 text, with no NUL byte? */
|
|
26
|
+
export function isText(buf) {
|
|
27
|
+
const b = Buffer.from(buf);
|
|
28
|
+
if (b.includes(0)) return false;
|
|
29
|
+
try {
|
|
30
|
+
new TextDecoder('utf-8', { fatal: true }).decode(b);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hostOf = (url) => {
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(url);
|
|
40
|
+
return u.protocol === 'https:' ? u.hostname : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
async function readCapped(res, max) {
|
|
47
|
+
const declared = Number(res.headers?.get?.('content-length'));
|
|
48
|
+
if (Number.isFinite(declared) && declared > max) return { tooLarge: true };
|
|
49
|
+
if (!res.body?.getReader) {
|
|
50
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
51
|
+
return buf.length > max ? { tooLarge: true } : { buf };
|
|
52
|
+
}
|
|
53
|
+
const reader = res.body.getReader();
|
|
54
|
+
const chunks = [];
|
|
55
|
+
let total = 0;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const { done, value } = await reader.read();
|
|
58
|
+
if (done) break;
|
|
59
|
+
total += value.length;
|
|
60
|
+
if (total > max) {
|
|
61
|
+
await reader.cancel().catch(() => {});
|
|
62
|
+
return { tooLarge: true };
|
|
63
|
+
}
|
|
64
|
+
chunks.push(Buffer.from(value));
|
|
65
|
+
}
|
|
66
|
+
return { buf: Buffer.concat(chunks) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Download one pasted attachment. Redirects are followed by hand and only to REDIRECT_HOSTS; the token goes only to
|
|
71
|
+
* GitHub. Returns { ok: true, buf, type } or { ok: false, reason }.
|
|
72
|
+
*/
|
|
73
|
+
export async function downloadAttachment(url, { fetch, token = null, max = IMAGE_MAX } = {}) {
|
|
74
|
+
let current = url;
|
|
75
|
+
for (let hop = 0; hop <= HOPS_MAX; hop += 1) {
|
|
76
|
+
const host = hostOf(current);
|
|
77
|
+
if (!host || !REDIRECT_HOSTS.includes(host)) return { ok: false, reason: `${host ?? 'that address'} is not an allowed host` };
|
|
78
|
+
const headers = token && TOKEN_HOSTS.has(host) ? { authorization: `Bearer ${token}` } : {};
|
|
79
|
+
let res;
|
|
80
|
+
try {
|
|
81
|
+
res = await fetch(current, { redirect: 'manual', headers });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { ok: false, reason: `the download failed: ${String(error?.message ?? error).split('\n')[0]}` };
|
|
84
|
+
}
|
|
85
|
+
if (res.status >= 300 && res.status < 400) {
|
|
86
|
+
const location = res.headers?.get?.('location');
|
|
87
|
+
if (!location) return { ok: false, reason: 'the redirect has no destination' };
|
|
88
|
+
current = new URL(location, current).href;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (res.status !== 200) return { ok: false, reason: `GitHub answered ${res.status}${res.status === 404 || res.status === 403 ? ' (the token may not read it)' : ''}` };
|
|
92
|
+
const { buf, tooLarge } = await readCapped(res, max);
|
|
93
|
+
if (tooLarge) return { ok: false, reason: `the file is larger than ${Math.round(max / 1024 / 1024)} MB` };
|
|
94
|
+
const type = sniffImage(buf);
|
|
95
|
+
if (!type) return { ok: false, reason: 'the file is not a PNG, JPEG, GIF or WebP image' };
|
|
96
|
+
return { ok: true, buf, type };
|
|
97
|
+
}
|
|
98
|
+
return { ok: false, reason: `more than ${HOPS_MAX} redirects` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One file of a linked repository, read through `gh api` (injected). Returns { ok, buf, type } or { ok: false, reason }. */
|
|
102
|
+
export async function fetchRepoFile({ repo, path }, { gh, max = TEXT_MAX } = {}) {
|
|
103
|
+
let info;
|
|
104
|
+
try {
|
|
105
|
+
info = JSON.parse(gh(['api', `repos/${repo}/contents/${path}`]));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return { ok: false, reason: `${repo} could not be read: ${String(error?.message ?? error).split('\n')[0]}` };
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(info) || info.type !== 'file') return { ok: false, reason: `${path} is not a file` };
|
|
110
|
+
if (info.size > max) return { ok: false, reason: `the file is larger than ${Math.round(max / 1024)} KB` };
|
|
111
|
+
if (info.encoding !== 'base64' || typeof info.content !== 'string') return { ok: false, reason: 'the file could not be read' };
|
|
112
|
+
const buf = Buffer.from(info.content, 'base64');
|
|
113
|
+
if (buf.length > max) return { ok: false, reason: `the file is larger than ${Math.round(max / 1024)} KB` };
|
|
114
|
+
if (!isText(buf)) return { ok: false, reason: 'the file is not text' };
|
|
115
|
+
return { ok: true, buf, type: 'text' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The references of an issue (from parseDesign) turned into files.
|
|
120
|
+
* references [{ kind, target, repo?, path?, allowed?, match }]
|
|
121
|
+
* images design.images
|
|
122
|
+
* links the linked repositories
|
|
123
|
+
* exists (path) => boolean, for the references to files of the repository
|
|
124
|
+
* Returns { files: [{ reference, kind, buf?, type }], refused: [{ reference, reason }] }.
|
|
125
|
+
* A `path` reference needs no download: it is checked and the builder reads it where it is.
|
|
126
|
+
*/
|
|
127
|
+
export async function fetchReferences({ references, images = false, links = [], exists, fetch, gh, token = null }) {
|
|
128
|
+
const files = [];
|
|
129
|
+
const refused = [];
|
|
130
|
+
for (const ref of references) {
|
|
131
|
+
if (files.length >= FILES_MAX) {
|
|
132
|
+
refused.push({ reference: ref.target, reason: `more than ${FILES_MAX} references` });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (ref.kind === 'path') {
|
|
136
|
+
if (exists(ref.path)) files.push({ reference: ref.target, kind: 'path', path: ref.path, type: 'repository' });
|
|
137
|
+
else refused.push({ reference: ref.target, reason: 'the file does not exist in the repository' });
|
|
138
|
+
} else if (ref.kind === 'attachment') {
|
|
139
|
+
if (!ref.allowed) refused.push({ reference: ref.target, reason: 'it is not a GitHub attachment' });
|
|
140
|
+
else if (!images) refused.push({ reference: ref.target, reason: 'design.images is off: turn it on in relevo.yml, or commit the picture to the repository and point to the file' });
|
|
141
|
+
else {
|
|
142
|
+
const r = await downloadAttachment(ref.target, { fetch, token });
|
|
143
|
+
if (r.ok) files.push({ reference: ref.target, kind: 'attachment', buf: r.buf, type: r.type });
|
|
144
|
+
else refused.push({ reference: ref.target, reason: r.reason });
|
|
145
|
+
}
|
|
146
|
+
} else if (ref.kind === 'repo-file') {
|
|
147
|
+
if (!links.includes(ref.repo)) refused.push({ reference: ref.target, reason: `${ref.repo} is not in links of relevo.yml` });
|
|
148
|
+
else {
|
|
149
|
+
const r = await fetchRepoFile(ref, { gh });
|
|
150
|
+
if (r.ok) files.push({ reference: ref.target, kind: 'repo-file', buf: r.buf, type: r.type, path: ref.path });
|
|
151
|
+
else refused.push({ reference: ref.target, reason: r.reason });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { files, refused };
|
|
156
|
+
}
|