@ree-auz/nook 0.1.0 → 0.1.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/LICENSE +21 -21
- package/README.md +422 -410
- package/bin/nk.mjs +3 -3
- package/docs/setup-hooks.md +107 -107
- package/integrations/linear/README.md +84 -84
- package/integrations/linear/coding-tools.json +7 -7
- package/integrations/linear/open-issue.mjs +147 -147
- package/package.json +34 -34
- package/skills/nook/SKILL.md +71 -71
- package/src/agents.mjs +92 -92
- package/src/backup.mjs +74 -74
- package/src/ci.mjs +378 -378
- package/src/cli.mjs +151 -151
- package/src/commands/check-hooks.mjs +128 -128
- package/src/commands/clean.mjs +175 -175
- package/src/commands/doctor.mjs +100 -91
- package/src/commands/forget.mjs +57 -57
- package/src/commands/hooks.mjs +34 -27
- package/src/commands/init.mjs +154 -154
- package/src/commands/ls.mjs +107 -107
- package/src/commands/rm.mjs +190 -190
- package/src/commands/workspace.mjs +163 -163
- package/src/commands/write-hooks.mjs +185 -185
- package/src/compose-plan.mjs +228 -228
- package/src/compose.mjs +123 -123
- package/src/config.mjs +115 -115
- package/src/context.mjs +189 -189
- package/src/errors.mjs +8 -8
- package/src/forge.mjs +75 -75
- package/src/generate-hooks.mjs +367 -367
- package/src/git.mjs +86 -86
- package/src/home.mjs +48 -48
- package/src/hooks.mjs +135 -135
- package/src/names.mjs +65 -65
- package/src/occupancy-windows.ps1 +59 -59
- package/src/occupancy.mjs +131 -131
- package/src/ports.mjs +89 -45
- package/src/prompt.mjs +100 -100
- package/src/shared.mjs +97 -97
- package/src/status.mjs +30 -30
- package/src/tools.mjs +23 -23
- package/src/ui.mjs +60 -60
package/src/ci.mjs
CHANGED
|
@@ -1,378 +1,378 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { parse } from "yaml";
|
|
5
|
-
import { gitTry } from "./git.mjs";
|
|
6
|
-
import { composeFile, overrideFile } from "./compose-plan.mjs";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* docker run flags from a CI service's options, split as the runner's shell does: quotes keep
|
|
10
|
-
* values whole (--health-cmd "mysqladmin ping") and a backslash escapes the next character
|
|
11
|
-
* (\" inside double quotes). Unbalanced quotes give no options.
|
|
12
|
-
*/
|
|
13
|
-
function dockerOptions(text) {
|
|
14
|
-
const out = [];
|
|
15
|
-
let current = "";
|
|
16
|
-
let quote = null;
|
|
17
|
-
let started = false;
|
|
18
|
-
const chars = [...text.trim()];
|
|
19
|
-
for (let i = 0; i < chars.length; i++) {
|
|
20
|
-
const char = chars[i];
|
|
21
|
-
if (char === "\\" && quote !== "'" && i + 1 < chars.length) {
|
|
22
|
-
const next = chars[++i];
|
|
23
|
-
// Inside double quotes only these are escapes; elsewhere the backslash stays.
|
|
24
|
-
if (quote === '"' && !'"\\$`'.includes(next)) current += char;
|
|
25
|
-
current += next;
|
|
26
|
-
started = true;
|
|
27
|
-
} else if (quote) {
|
|
28
|
-
if (char === quote) quote = null;
|
|
29
|
-
else current += char;
|
|
30
|
-
} else if (char === '"' || char === "'") {
|
|
31
|
-
quote = char;
|
|
32
|
-
started = true;
|
|
33
|
-
} else if (/\s/.test(char)) {
|
|
34
|
-
if (started) out.push(current);
|
|
35
|
-
current = "";
|
|
36
|
-
started = false;
|
|
37
|
-
} else {
|
|
38
|
-
current += char;
|
|
39
|
-
started = true;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if (quote) return [];
|
|
43
|
-
if (started) out.push(current);
|
|
44
|
-
return out;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Reads a repository's GitHub Actions workflows and turns the setup part of
|
|
49
|
-
* one job into a plan for workspace hooks, without AI: CI already says how a
|
|
50
|
-
* clean machine gets the project running. What cannot be translated is
|
|
51
|
-
* listed, never guessed.
|
|
52
|
-
*/
|
|
53
|
-
|
|
54
|
-
const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
55
|
-
/** A step that checks the code rather than sets it up: setup ends here. */
|
|
56
|
-
const CHECK_STEP = /\b(test|tests|unittest|pytest|vitest|jest|e2e|lint|eslint|ruff|typecheck|tsc|mypy|format|prettier|check|knip|build|coverage|deploy|publish|release|audit)\b/i;
|
|
57
|
-
/** Actions that only matter on a CI machine. */
|
|
58
|
-
const CI_ONLY = /^(actions\/(checkout|cache|upload-artifact|download-artifact|github-script|labeler|stale)|codecov\/|docker\/(login|setup-buildx|setup-qemu|build-push|metadata)-action)/;
|
|
59
|
-
/** Actions that install a tool: on a developer machine they become a check. */
|
|
60
|
-
const TOOL_ACTIONS = [
|
|
61
|
-
{ match: /^actions\/setup-node@/, tool: "node", version: (w) => w["node-version"] },
|
|
62
|
-
{ match: /^actions\/setup-python@/, tool: "python", version: (w) => w["python-version"] },
|
|
63
|
-
{ match: /^actions\/setup-go@/, tool: "go", version: (w) => w["go-version"] },
|
|
64
|
-
{ match: /^actions\/setup-java@/, tool: "java", version: (w) => w["java-version"] },
|
|
65
|
-
{ match: /^pnpm\/action-setup@/, tool: "pnpm", version: (w) => w.version },
|
|
66
|
-
{ match: /^oven-sh\/setup-bun@/, tool: "bun", version: (w) => w["bun-version"] },
|
|
67
|
-
{ match: /^astral-sh\/setup-uv@/, tool: "uv", version: (w) => w.version, also: (w) => (w["python-version"] ? [{ name: "python", version: String(w["python-version"]) }] : []) },
|
|
68
|
-
{ match: /^denoland\/setup-deno@/, tool: "deno", version: (w) => w["deno-version"] },
|
|
69
|
-
{ match: /^dtolnay\/rust-toolchain@|^actions-rs\/toolchain@/, tool: "cargo" },
|
|
70
|
-
{ match: /^ruby\/setup-ruby@/, tool: "ruby", version: (w) => w["ruby-version"] },
|
|
71
|
-
{ match: /^erlef\/setup-beam@/, tool: "elixir", version: (w) => w["elixir-version"] },
|
|
72
|
-
{ match: /^shivammathur\/setup-php@/, tool: "php", version: (w) => w["php-version"] },
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
const EXPRESSION = /\$\{\{/;
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* CI runs in a throwaway machine, so its steps may assume nothing else is
|
|
79
|
-
* there. On a developer machine the real database may be. Only commands that
|
|
80
|
-
* install dependencies into the workspace folder are copied; any other step
|
|
81
|
-
* (a script, docker, a migration) is listed for a person or agent to review.
|
|
82
|
-
*/
|
|
83
|
-
const INSTALL = /^(yarn( install)?(\s+--?[\w-]+(=\S+)?)*$|npm (ci|install|i)|pnpm (install|i|fetch)|bun (install|ci)|corepack enable|uv sync|uv pip install|pip install|python3? -m pip install|poetry install|pipenv (install|sync)|bundle install|go mod download|cargo fetch|composer install|deno (cache|install)|(npx|pnpm exec|yarn) playwright install)(\s+[\w@./:=,+-]+)*$/;
|
|
84
|
-
const SHELL_SYNTAX = /[;|&`$<>()\\]|\n/;
|
|
85
|
-
|
|
86
|
-
export function isInstallCommand(command) {
|
|
87
|
-
const text = String(command).trim();
|
|
88
|
-
return !SHELL_SYNTAX.test(text) && INSTALL.test(text);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** A database or queue address that is not one of CI's services: on this machine it may be a real one. */
|
|
92
|
-
const SERVICE_URL = /\b(postgres(ql)?|mysql|mariadb|mongodb(\+srv)?|redis|rediss|amqp|nats|kafka):\/\//i;
|
|
93
|
-
const LOCAL_PORT = /\b(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)\b/g;
|
|
94
|
-
const label = (step, index) => step.name || step.run?.split("\n")[0] || step.uses || `step ${index + 1}`;
|
|
95
|
-
|
|
96
|
-
export function workflowFiles(repoPath) {
|
|
97
|
-
const listed = gitTry(["ls-files"], { cwd: repoPath });
|
|
98
|
-
return listed.ok ? listed.out.split(/\r?\n/).filter((file) => WORKFLOW.test(file)) : [];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** A fingerprint of the CI files (workflows and local actions) and the Compose files, so nk can tell when they changed after the hooks were tested. */
|
|
102
|
-
export function ciHash(repoPath) {
|
|
103
|
-
const compose = composeFile(repoPath);
|
|
104
|
-
const listed = gitTry(["ls-files", ".github/actions"], { cwd: repoPath });
|
|
105
|
-
const actions = listed.ok ? listed.out.split(/\r?\n/).filter((file) => /\/action\.ya?ml$/.test(file)) : [];
|
|
106
|
-
const files = [...workflowFiles(repoPath), ...actions, ...[compose, overrideFile(repoPath, compose)].filter(Boolean)];
|
|
107
|
-
if (!files.length) return null;
|
|
108
|
-
const hash = createHash("sha256");
|
|
109
|
-
for (const file of files) hash.update(`${file}\n`).update(readFileSync(join(repoPath, file), "utf8").replace(/\r\n/g, "\n"));
|
|
110
|
-
return hash.digest("hex");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** "5433:5432" → { host: 5433, container: 5432 }; "5432" or 5432 → both 5432. */
|
|
114
|
-
function servicePort(spec) {
|
|
115
|
-
const parts = String(spec).split(":");
|
|
116
|
-
const container = Number(parts.at(-1).split("/")[0]);
|
|
117
|
-
const host = parts.length > 1 ? Number(parts.at(-2)) : container;
|
|
118
|
-
return Number.isInteger(container) && Number.isInteger(host) ? { host, container } : null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function plainEnv(env, untranslated, where) {
|
|
122
|
-
const out = {};
|
|
123
|
-
for (const [key, value] of Object.entries(env ?? {})) {
|
|
124
|
-
if (EXPRESSION.test(String(value))) untranslated.push({ step: where, why: `${key} comes from a CI expression (${String(value).trim()})` });
|
|
125
|
-
else out[key] = String(value);
|
|
126
|
-
}
|
|
127
|
-
return out;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** A condition that reads only earlier steps' outputs (cache hits, changed files), not the OS or the event. */
|
|
131
|
-
function onlyStepOutputs(condition) {
|
|
132
|
-
const rest = condition
|
|
133
|
-
.replace(/\$\{\{|\}\}/g, " ")
|
|
134
|
-
.replace(/steps\.[\w-]+\.outputs\.[\w-]+/g, " ")
|
|
135
|
-
.replace(/'[^']*'/g, " ")
|
|
136
|
-
.replace(/==|!=|&&|\|\||!|\(|\)|\btrue\b|\bfalse\b/g, " ");
|
|
137
|
-
return /steps\./.test(condition) && !rest.trim();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** The plan for one job: tools, environment, services, setup commands, cleanup commands. */
|
|
141
|
-
export function planJob(file, jobName, job, workflowEnv = {}, knownPorts = []) {
|
|
142
|
-
const untranslated = [];
|
|
143
|
-
const skipped = [];
|
|
144
|
-
const tools = [];
|
|
145
|
-
const create = [];
|
|
146
|
-
const remove = [];
|
|
147
|
-
// defaults.run.working-directory (job, or workflow) is where every run: step runs unless it names its own.
|
|
148
|
-
const defaultDir = job.defaults?.run?.["working-directory"];
|
|
149
|
-
const where = (step) => step["working-directory"] ?? (EXPRESSION.test(String(defaultDir ?? "")) ? undefined : defaultDir);
|
|
150
|
-
const rawEnv = { ...plainEnv(workflowEnv, untranslated, "workflow env"), ...plainEnv(job.env, untranslated, `job ${jobName} env`) };
|
|
151
|
-
|
|
152
|
-
const services = [];
|
|
153
|
-
for (const [name, service] of Object.entries(job.services ?? {})) {
|
|
154
|
-
if (!service?.image || EXPRESSION.test(service.image)) {
|
|
155
|
-
untranslated.push({ step: `service ${name}`, why: "no plain image name" });
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
services.push({
|
|
159
|
-
name,
|
|
160
|
-
image: service.image,
|
|
161
|
-
env: plainEnv(service.env, untranslated, `service ${name}`),
|
|
162
|
-
ports: (service.ports ?? []).map(servicePort).filter(Boolean),
|
|
163
|
-
options: service.options && !EXPRESSION.test(service.options) ? dockerOptions(String(service.options)) : [],
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (job.container && services.some((service) => !service.ports.length))
|
|
168
|
-
untranslated.push({ step: "job container", why: "CI runs this job inside a container and reaches its services by name; here they need host ports, which the CI file does not list" });
|
|
169
|
-
|
|
170
|
-
// Ports the workspace has its own of: CI's services, and the Compose services nk starts.
|
|
171
|
-
const servicePorts = new Set([...services.flatMap((service) => service.ports.map((port) => port.host)), ...knownPorts.map(Number)]);
|
|
172
|
-
/** Drops values that could reach something outside the workspace's own services. */
|
|
173
|
-
const safeEnv = (values, where) => {
|
|
174
|
-
const kept = {};
|
|
175
|
-
for (const [key, value] of Object.entries(values)) {
|
|
176
|
-
const ports = [...value.matchAll(LOCAL_PORT)].map((match) => Number(match[2]));
|
|
177
|
-
const strayPort = ports.find((port) => !servicePorts.has(port));
|
|
178
|
-
if (strayPort !== undefined)
|
|
179
|
-
untranslated.push({
|
|
180
|
-
step: where,
|
|
181
|
-
// An http address is usually the app itself; anything else could be a real database on this machine.
|
|
182
|
-
why: /^https?:\/\//i.test(value.trim())
|
|
183
|
-
? `${key} points at local port ${strayPort}, which is not a service nk starts; likely the app's own address, so the shared .env keeps its value`
|
|
184
|
-
: `${key} points at local port ${strayPort}, which is not a service nk starts; on this machine it may be a real database`,
|
|
185
|
-
});
|
|
186
|
-
else if (SERVICE_URL.test(value) && !ports.length)
|
|
187
|
-
untranslated.push({ step: where, why: `${key} is a service address outside the workspace` });
|
|
188
|
-
else kept[key] = value;
|
|
189
|
-
}
|
|
190
|
-
return kept;
|
|
191
|
-
};
|
|
192
|
-
const env = safeEnv(rawEnv, "env");
|
|
193
|
-
|
|
194
|
-
let checking = false;
|
|
195
|
-
let checks = 0;
|
|
196
|
-
let tests = 0;
|
|
197
|
-
(job.steps ?? []).forEach((step, index) => {
|
|
198
|
-
const name = label(step, index);
|
|
199
|
-
const condition = step.if === undefined ? "" : String(step.if);
|
|
200
|
-
const cleanup = /\balways\(\)/.test(condition);
|
|
201
|
-
if (step.uses) {
|
|
202
|
-
const tool = TOOL_ACTIONS.find((entry) => entry.match.test(step.uses));
|
|
203
|
-
if (tool) {
|
|
204
|
-
const version = tool.version?.(step.with ?? {});
|
|
205
|
-
tools.push({ name: tool.tool, ...(version !== undefined && !EXPRESSION.test(String(version)) && { version: String(version) }) });
|
|
206
|
-
for (const extra of tool.also?.(step.with ?? {}) ?? []) tools.push(extra);
|
|
207
|
-
} else if (CI_ONLY.test(step.uses)) skipped.push({ step: name, why: "only needed on a CI machine" });
|
|
208
|
-
else untranslated.push({ step: name, why: `uses the action ${step.uses}` });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
if (!step.run) return;
|
|
212
|
-
if (/\$\{?GITHUB_(OUTPUT|ENV|PATH|STEP_SUMMARY)\b/.test(step.run)) {
|
|
213
|
-
skipped.push({ step: name, why: "only passes values between CI steps" });
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
const target = cleanup ? remove : create;
|
|
217
|
-
// Flags such as --no-audit say nothing about what a step is for.
|
|
218
|
-
if (!cleanup && (checking || CHECK_STEP.test(`${step.name ?? ""} ${step.run}`.replace(/(^|\s)--?[\w-]+(=\S*)?/g, " ")))) {
|
|
219
|
-
checking = true;
|
|
220
|
-
checks++;
|
|
221
|
-
if (TEST_STEP.test(`${step.name ?? ""} ${step.run}`)) tests++;
|
|
222
|
-
skipped.push({ step: name, why: "checks the code (tests, lint, build); not setup" });
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
if (EXPRESSION.test(step.run) || EXPRESSION.test(JSON.stringify(step.env ?? {}))) {
|
|
226
|
-
untranslated.push({ step: name, why: "uses a CI expression (${{ … }})" });
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
// An install that CI skips on a cache hit or when no files changed: a new workspace has neither.
|
|
230
|
-
if (condition && !cleanup && onlyStepOutputs(condition) && isInstallCommand(step.run)) {
|
|
231
|
-
create.push({ name, run: String(step.run).trim(), ...(where(step) && { cwd: where(step) }) });
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
if (condition && !cleanup) {
|
|
235
|
-
untranslated.push({ step: name, why: `runs only when ${condition}` });
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (!isInstallCommand(step.run)) {
|
|
239
|
-
untranslated.push({ step: name, why: "not a dependency install; CI runs it on a throwaway machine, so check what it touches before adding it" });
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
target.push({ name, run: String(step.run).trim(), ...(where(step) && { cwd: where(step) }), ...(step.env && { env: safeEnv(plainEnv(step.env, untranslated, name), name) }) });
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// A step that runs a project's own package (pnpm exec playwright) needs its dependencies. When CI installs
|
|
246
|
-
// them inside an action nk cannot read (vuetify's vuetifyjs/setup-action), install them first, as a person would.
|
|
247
|
-
const hidden = untranslated.find((entry) => /^uses the action /.test(entry.why));
|
|
248
|
-
const runner = create.map((step) => /^(pnpm|npx|npm|yarn|bunx|bun)\b/.exec(step.run)?.[1]).find(Boolean);
|
|
249
|
-
if (hidden && runner && !create.some((step) => /^(pnpm|npm|yarn|bun)\s+(i|ci|install)\b|^yarn\s*$/.test(step.run))) {
|
|
250
|
-
const install = { pnpm: "pnpm install --frozen-lockfile", npx: "npm ci", npm: "npm ci", yarn: "yarn install", bunx: "bun install", bun: "bun install" }[runner];
|
|
251
|
-
create.unshift({ name: `${install} (CI installs in ${hidden.step})`, run: install, ...(where({}) && { cwd: where({}) }) });
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// CI machines have corepack; Node 25 and later no longer include it, so check for it before anything starts.
|
|
255
|
-
if (create.some((step) => /^corepack\b/.test(step.run)) && !tools.some((tool) => tool.name === "corepack")) tools.push({ name: "corepack" });
|
|
256
|
-
|
|
257
|
-
return { source: file, job: jobName, tools, env, services, create, remove, skipped, untranslated, checks, tests };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** Jobs that publish, release or maintain rather than set up and test the project. */
|
|
261
|
-
const NOT_SETUP = /release|publish|deploy|bump|\bdocs?\b|nightly|cron|mutation|bench|label|stale|codeql|security|dependabot|backport|sync|cleanup|lighthouse|breaking|changelog|translation|i18n|token|artifact|cache|schema|lint|format|\btypes?\b|audit|triage|preview|licen[sc]e|deprecation|docker|smoke|helm|mobile|\bios\b|android|windows|macos|submit|detect|merge|report|size|openapi|shellcheck|hlint|link|depsreview|dependency|guard|semantic|goreleaser|configuration|validat|devcontainer|image|enterprise|\bwin\b|feature-branch/i;
|
|
262
|
-
/** A check step that runs the tests, which is what a workspace is set up for. */
|
|
263
|
-
const TEST_STEP = /\b(test|tests|unittest|pytest|vitest|jest|e2e|rspec|phpunit|tox|nox)\b/i;
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Steps of a local composite action (uses: ./.github/actions/setup) put in
|
|
267
|
-
* place of the step that uses it, so its installs are read like any other.
|
|
268
|
-
* Its inputs are filled from the step's with: or the input's default.
|
|
269
|
-
*/
|
|
270
|
-
function expandLocalActions(repoPath, steps, depth = 0) {
|
|
271
|
-
return steps.flatMap((step) => {
|
|
272
|
-
if (depth > 3 || typeof step?.uses !== "string" || !step.uses.startsWith("./")) return [step];
|
|
273
|
-
const dir = join(repoPath, step.uses);
|
|
274
|
-
let action;
|
|
275
|
-
try {
|
|
276
|
-
const file = ["action.yml", "action.yaml"].map((name) => join(dir, name)).find((path) => existsSync(path));
|
|
277
|
-
action = file && parse(readFileSync(file, "utf8"));
|
|
278
|
-
} catch {
|
|
279
|
-
return [step];
|
|
280
|
-
}
|
|
281
|
-
if (action?.runs?.using !== "composite" || !Array.isArray(action.runs.steps)) return [step];
|
|
282
|
-
const inputs = {};
|
|
283
|
-
for (const [name, input] of Object.entries(action.inputs ?? {})) if (input?.default !== undefined) inputs[name] = String(input.default);
|
|
284
|
-
for (const [name, value] of Object.entries(step.with ?? {})) inputs[name] = String(value);
|
|
285
|
-
const fill = (value) =>
|
|
286
|
-
typeof value === "string"
|
|
287
|
-
? value.replace(/\$\{\{\s*inputs\.([\w-]+)\s*\}\}/g, (whole, name) => (inputs[name] !== undefined && !EXPRESSION.test(inputs[name]) ? inputs[name] : whole))
|
|
288
|
-
: value && typeof value === "object"
|
|
289
|
-
? Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, fill(inner)]))
|
|
290
|
-
: value;
|
|
291
|
-
const inner = action.runs.steps.map((child) => {
|
|
292
|
-
const filled = fill(child);
|
|
293
|
-
// A step's condition holds for every step of the action.
|
|
294
|
-
return step.if !== undefined && filled.if === undefined ? { ...filled, if: step.if } : filled;
|
|
295
|
-
});
|
|
296
|
-
return expandLocalActions(repoPath, inner, depth + 1);
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Whether a workflow runs on pull requests or pushes, as the main CI does. */
|
|
301
|
-
function onChanges(on) {
|
|
302
|
-
const events = typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
|
|
303
|
-
return events.some((event) => event === "pull_request" || event === "push" || event === "pull_request_target" || event === "merge_group" || event === "workflow_call");
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Workflows that only answer repository events (issues, releases, comments) and never touch the code. */
|
|
307
|
-
function onlyRepositoryEvents(on) {
|
|
308
|
-
const events = typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
|
|
309
|
-
return events.length > 0 && events.every((event) => /^(issues|issue_comment|discussion|discussion_comment|release|label|milestone|project|project_card|project_column|fork|watch|create|delete|status|check_run|check_suite|registry_package|page_build|deployment|deployment_status|gollum|public|pull_request_review|pull_request_review_comment)$/.test(event));
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* A job that needs others often starts from what they installed, such as a
|
|
314
|
-
* downloaded artifact; when it downloads one, their installs and tools are its own.
|
|
315
|
-
*/
|
|
316
|
-
function planWithNeeds(repoPath, file, workflow, jobName, job, knownPorts) {
|
|
317
|
-
const plan = planJob(file, jobName, { defaults: workflow.defaults, ...job }, workflow.env, knownPorts);
|
|
318
|
-
if (!job.steps.some((step) => /^actions\/download-artifact@/.test(step.uses ?? ""))) return plan;
|
|
319
|
-
const seen = new Set([jobName]);
|
|
320
|
-
const queue = [job.needs ?? []].flat();
|
|
321
|
-
const before = [];
|
|
322
|
-
while (queue.length) {
|
|
323
|
-
const name = queue.shift();
|
|
324
|
-
if (seen.has(name) || !workflow.jobs[name]?.steps) continue;
|
|
325
|
-
seen.add(name);
|
|
326
|
-
const needed = workflow.jobs[name];
|
|
327
|
-
before.unshift(planJob(file, name, { defaults: workflow.defaults, ...needed, steps: expandLocalActions(repoPath, needed.steps) }, workflow.env, knownPorts));
|
|
328
|
-
queue.push(...[needed.needs ?? []].flat());
|
|
329
|
-
}
|
|
330
|
-
for (const other of before.reverse()) {
|
|
331
|
-
const runs = new Set(plan.create.map((step) => step.run));
|
|
332
|
-
plan.create.unshift(...other.create.filter((step) => !runs.has(step.run)));
|
|
333
|
-
const tools = new Set(plan.tools.map((tool) => tool.name));
|
|
334
|
-
plan.tools.push(...other.tools.filter((tool) => !tools.has(tool.name)));
|
|
335
|
-
}
|
|
336
|
-
return plan;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* The job most like "set up and test the project": it runs on pull requests,
|
|
341
|
-
* runs tests, and has the most setup steps and services. knownPorts are the
|
|
342
|
-
* host ports of Compose services nk starts, for when the job has none of its own.
|
|
343
|
-
*/
|
|
344
|
-
export function planFromCi(repoPath, knownPorts = []) {
|
|
345
|
-
const files = workflowFiles(repoPath);
|
|
346
|
-
const plans = [];
|
|
347
|
-
const sources = new Map();
|
|
348
|
-
for (const file of files) {
|
|
349
|
-
let workflow;
|
|
350
|
-
try {
|
|
351
|
-
workflow = parse(readFileSync(join(repoPath, file), "utf8"));
|
|
352
|
-
} catch (error) {
|
|
353
|
-
plans.push({ source: file, error: error.message });
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
for (let [jobName, job] of Object.entries(workflow?.jobs ?? {})) {
|
|
357
|
-
if (!job?.steps) continue;
|
|
358
|
-
job = { ...job, steps: expandLocalActions(repoPath, job.steps) };
|
|
359
|
-
const plan = planWithNeeds(repoPath, file, workflow, jobName, job);
|
|
360
|
-
sources.set(plan, { file, jobName, job, workflow, onChanges: onChanges(workflow.on) });
|
|
361
|
-
plans.push(plan);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
const score = (plan) => {
|
|
365
|
-
const { onChanges: main } = sources.get(plan);
|
|
366
|
-
return plan.create.length + plan.tools.length + 3 * plan.services.length + (plan.checks ? 4 : 0) + (plan.tests ? 3 : 0) + (main ? 3 : -5) - (NOT_SETUP.test(`${plan.source} ${plan.job}`) ? 10 : 0);
|
|
367
|
-
};
|
|
368
|
-
const ranked = plans.filter((plan) => !plan.error).sort((a, b) => score(b) - score(a));
|
|
369
|
-
// A job that scores below zero, or neither runs on code changes nor tests the code, only maintains the repository (issues, releases, licences).
|
|
370
|
-
const useful = (plan) => score(plan) >= 0 && (sources.get(plan).onChanges || (plan.tests > 0 && !onlyRepositoryEvents(sources.get(plan).workflow.on)));
|
|
371
|
-
let best = ranked[0] && useful(ranked[0]) ? ranked[0] : null;
|
|
372
|
-
// Without services of its own, the job's addresses may point at the Compose services nk starts.
|
|
373
|
-
if (best && !best.services.length && knownPorts.length) {
|
|
374
|
-
const { file, jobName, job, workflow } = sources.get(best);
|
|
375
|
-
best = planWithNeeds(repoPath, file, workflow, jobName, job, knownPorts);
|
|
376
|
-
}
|
|
377
|
-
return { files, best, others: ranked.slice(1).map((plan) => `${plan.source} › ${plan.job}`), errors: plans.filter((plan) => plan.error) };
|
|
378
|
-
}
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse } from "yaml";
|
|
5
|
+
import { gitTry } from "./git.mjs";
|
|
6
|
+
import { composeFile, overrideFile } from "./compose-plan.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* docker run flags from a CI service's options, split as the runner's shell does: quotes keep
|
|
10
|
+
* values whole (--health-cmd "mysqladmin ping") and a backslash escapes the next character
|
|
11
|
+
* (\" inside double quotes). Unbalanced quotes give no options.
|
|
12
|
+
*/
|
|
13
|
+
function dockerOptions(text) {
|
|
14
|
+
const out = [];
|
|
15
|
+
let current = "";
|
|
16
|
+
let quote = null;
|
|
17
|
+
let started = false;
|
|
18
|
+
const chars = [...text.trim()];
|
|
19
|
+
for (let i = 0; i < chars.length; i++) {
|
|
20
|
+
const char = chars[i];
|
|
21
|
+
if (char === "\\" && quote !== "'" && i + 1 < chars.length) {
|
|
22
|
+
const next = chars[++i];
|
|
23
|
+
// Inside double quotes only these are escapes; elsewhere the backslash stays.
|
|
24
|
+
if (quote === '"' && !'"\\$`'.includes(next)) current += char;
|
|
25
|
+
current += next;
|
|
26
|
+
started = true;
|
|
27
|
+
} else if (quote) {
|
|
28
|
+
if (char === quote) quote = null;
|
|
29
|
+
else current += char;
|
|
30
|
+
} else if (char === '"' || char === "'") {
|
|
31
|
+
quote = char;
|
|
32
|
+
started = true;
|
|
33
|
+
} else if (/\s/.test(char)) {
|
|
34
|
+
if (started) out.push(current);
|
|
35
|
+
current = "";
|
|
36
|
+
started = false;
|
|
37
|
+
} else {
|
|
38
|
+
current += char;
|
|
39
|
+
started = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (quote) return [];
|
|
43
|
+
if (started) out.push(current);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reads a repository's GitHub Actions workflows and turns the setup part of
|
|
49
|
+
* one job into a plan for workspace hooks, without AI: CI already says how a
|
|
50
|
+
* clean machine gets the project running. What cannot be translated is
|
|
51
|
+
* listed, never guessed.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
55
|
+
/** A step that checks the code rather than sets it up: setup ends here. */
|
|
56
|
+
const CHECK_STEP = /\b(test|tests|unittest|pytest|vitest|jest|e2e|lint|eslint|ruff|typecheck|tsc|mypy|format|prettier|check|knip|build|coverage|deploy|publish|release|audit)\b/i;
|
|
57
|
+
/** Actions that only matter on a CI machine. */
|
|
58
|
+
const CI_ONLY = /^(actions\/(checkout|cache|upload-artifact|download-artifact|github-script|labeler|stale)|codecov\/|docker\/(login|setup-buildx|setup-qemu|build-push|metadata)-action)/;
|
|
59
|
+
/** Actions that install a tool: on a developer machine they become a check. */
|
|
60
|
+
const TOOL_ACTIONS = [
|
|
61
|
+
{ match: /^actions\/setup-node@/, tool: "node", version: (w) => w["node-version"] },
|
|
62
|
+
{ match: /^actions\/setup-python@/, tool: "python", version: (w) => w["python-version"] },
|
|
63
|
+
{ match: /^actions\/setup-go@/, tool: "go", version: (w) => w["go-version"] },
|
|
64
|
+
{ match: /^actions\/setup-java@/, tool: "java", version: (w) => w["java-version"] },
|
|
65
|
+
{ match: /^pnpm\/action-setup@/, tool: "pnpm", version: (w) => w.version },
|
|
66
|
+
{ match: /^oven-sh\/setup-bun@/, tool: "bun", version: (w) => w["bun-version"] },
|
|
67
|
+
{ match: /^astral-sh\/setup-uv@/, tool: "uv", version: (w) => w.version, also: (w) => (w["python-version"] ? [{ name: "python", version: String(w["python-version"]) }] : []) },
|
|
68
|
+
{ match: /^denoland\/setup-deno@/, tool: "deno", version: (w) => w["deno-version"] },
|
|
69
|
+
{ match: /^dtolnay\/rust-toolchain@|^actions-rs\/toolchain@/, tool: "cargo" },
|
|
70
|
+
{ match: /^ruby\/setup-ruby@/, tool: "ruby", version: (w) => w["ruby-version"] },
|
|
71
|
+
{ match: /^erlef\/setup-beam@/, tool: "elixir", version: (w) => w["elixir-version"] },
|
|
72
|
+
{ match: /^shivammathur\/setup-php@/, tool: "php", version: (w) => w["php-version"] },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const EXPRESSION = /\$\{\{/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* CI runs in a throwaway machine, so its steps may assume nothing else is
|
|
79
|
+
* there. On a developer machine the real database may be. Only commands that
|
|
80
|
+
* install dependencies into the workspace folder are copied; any other step
|
|
81
|
+
* (a script, docker, a migration) is listed for a person or agent to review.
|
|
82
|
+
*/
|
|
83
|
+
const INSTALL = /^(yarn( install)?(\s+--?[\w-]+(=\S+)?)*$|npm (ci|install|i)|pnpm (install|i|fetch)|bun (install|ci)|corepack enable|uv sync|uv pip install|pip install|python3? -m pip install|poetry install|pipenv (install|sync)|bundle install|go mod download|cargo fetch|composer install|deno (cache|install)|(npx|pnpm exec|yarn) playwright install)(\s+[\w@./:=,+-]+)*$/;
|
|
84
|
+
const SHELL_SYNTAX = /[;|&`$<>()\\]|\n/;
|
|
85
|
+
|
|
86
|
+
export function isInstallCommand(command) {
|
|
87
|
+
const text = String(command).trim();
|
|
88
|
+
return !SHELL_SYNTAX.test(text) && INSTALL.test(text);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A database or queue address that is not one of CI's services: on this machine it may be a real one. */
|
|
92
|
+
const SERVICE_URL = /\b(postgres(ql)?|mysql|mariadb|mongodb(\+srv)?|redis|rediss|amqp|nats|kafka):\/\//i;
|
|
93
|
+
const LOCAL_PORT = /\b(localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)\b/g;
|
|
94
|
+
const label = (step, index) => step.name || step.run?.split("\n")[0] || step.uses || `step ${index + 1}`;
|
|
95
|
+
|
|
96
|
+
export function workflowFiles(repoPath) {
|
|
97
|
+
const listed = gitTry(["ls-files"], { cwd: repoPath });
|
|
98
|
+
return listed.ok ? listed.out.split(/\r?\n/).filter((file) => WORKFLOW.test(file)) : [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A fingerprint of the CI files (workflows and local actions) and the Compose files, so nk can tell when they changed after the hooks were tested. */
|
|
102
|
+
export function ciHash(repoPath) {
|
|
103
|
+
const compose = composeFile(repoPath);
|
|
104
|
+
const listed = gitTry(["ls-files", ".github/actions"], { cwd: repoPath });
|
|
105
|
+
const actions = listed.ok ? listed.out.split(/\r?\n/).filter((file) => /\/action\.ya?ml$/.test(file)) : [];
|
|
106
|
+
const files = [...workflowFiles(repoPath), ...actions, ...[compose, overrideFile(repoPath, compose)].filter(Boolean)];
|
|
107
|
+
if (!files.length) return null;
|
|
108
|
+
const hash = createHash("sha256");
|
|
109
|
+
for (const file of files) hash.update(`${file}\n`).update(readFileSync(join(repoPath, file), "utf8").replace(/\r\n/g, "\n"));
|
|
110
|
+
return hash.digest("hex");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** "5433:5432" → { host: 5433, container: 5432 }; "5432" or 5432 → both 5432. */
|
|
114
|
+
function servicePort(spec) {
|
|
115
|
+
const parts = String(spec).split(":");
|
|
116
|
+
const container = Number(parts.at(-1).split("/")[0]);
|
|
117
|
+
const host = parts.length > 1 ? Number(parts.at(-2)) : container;
|
|
118
|
+
return Number.isInteger(container) && Number.isInteger(host) ? { host, container } : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function plainEnv(env, untranslated, where) {
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
124
|
+
if (EXPRESSION.test(String(value))) untranslated.push({ step: where, why: `${key} comes from a CI expression (${String(value).trim()})` });
|
|
125
|
+
else out[key] = String(value);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A condition that reads only earlier steps' outputs (cache hits, changed files), not the OS or the event. */
|
|
131
|
+
function onlyStepOutputs(condition) {
|
|
132
|
+
const rest = condition
|
|
133
|
+
.replace(/\$\{\{|\}\}/g, " ")
|
|
134
|
+
.replace(/steps\.[\w-]+\.outputs\.[\w-]+/g, " ")
|
|
135
|
+
.replace(/'[^']*'/g, " ")
|
|
136
|
+
.replace(/==|!=|&&|\|\||!|\(|\)|\btrue\b|\bfalse\b/g, " ");
|
|
137
|
+
return /steps\./.test(condition) && !rest.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The plan for one job: tools, environment, services, setup commands, cleanup commands. */
|
|
141
|
+
export function planJob(file, jobName, job, workflowEnv = {}, knownPorts = []) {
|
|
142
|
+
const untranslated = [];
|
|
143
|
+
const skipped = [];
|
|
144
|
+
const tools = [];
|
|
145
|
+
const create = [];
|
|
146
|
+
const remove = [];
|
|
147
|
+
// defaults.run.working-directory (job, or workflow) is where every run: step runs unless it names its own.
|
|
148
|
+
const defaultDir = job.defaults?.run?.["working-directory"];
|
|
149
|
+
const where = (step) => step["working-directory"] ?? (EXPRESSION.test(String(defaultDir ?? "")) ? undefined : defaultDir);
|
|
150
|
+
const rawEnv = { ...plainEnv(workflowEnv, untranslated, "workflow env"), ...plainEnv(job.env, untranslated, `job ${jobName} env`) };
|
|
151
|
+
|
|
152
|
+
const services = [];
|
|
153
|
+
for (const [name, service] of Object.entries(job.services ?? {})) {
|
|
154
|
+
if (!service?.image || EXPRESSION.test(service.image)) {
|
|
155
|
+
untranslated.push({ step: `service ${name}`, why: "no plain image name" });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
services.push({
|
|
159
|
+
name,
|
|
160
|
+
image: service.image,
|
|
161
|
+
env: plainEnv(service.env, untranslated, `service ${name}`),
|
|
162
|
+
ports: (service.ports ?? []).map(servicePort).filter(Boolean),
|
|
163
|
+
options: service.options && !EXPRESSION.test(service.options) ? dockerOptions(String(service.options)) : [],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (job.container && services.some((service) => !service.ports.length))
|
|
168
|
+
untranslated.push({ step: "job container", why: "CI runs this job inside a container and reaches its services by name; here they need host ports, which the CI file does not list" });
|
|
169
|
+
|
|
170
|
+
// Ports the workspace has its own of: CI's services, and the Compose services nk starts.
|
|
171
|
+
const servicePorts = new Set([...services.flatMap((service) => service.ports.map((port) => port.host)), ...knownPorts.map(Number)]);
|
|
172
|
+
/** Drops values that could reach something outside the workspace's own services. */
|
|
173
|
+
const safeEnv = (values, where) => {
|
|
174
|
+
const kept = {};
|
|
175
|
+
for (const [key, value] of Object.entries(values)) {
|
|
176
|
+
const ports = [...value.matchAll(LOCAL_PORT)].map((match) => Number(match[2]));
|
|
177
|
+
const strayPort = ports.find((port) => !servicePorts.has(port));
|
|
178
|
+
if (strayPort !== undefined)
|
|
179
|
+
untranslated.push({
|
|
180
|
+
step: where,
|
|
181
|
+
// An http address is usually the app itself; anything else could be a real database on this machine.
|
|
182
|
+
why: /^https?:\/\//i.test(value.trim())
|
|
183
|
+
? `${key} points at local port ${strayPort}, which is not a service nk starts; likely the app's own address, so the shared .env keeps its value`
|
|
184
|
+
: `${key} points at local port ${strayPort}, which is not a service nk starts; on this machine it may be a real database`,
|
|
185
|
+
});
|
|
186
|
+
else if (SERVICE_URL.test(value) && !ports.length)
|
|
187
|
+
untranslated.push({ step: where, why: `${key} is a service address outside the workspace` });
|
|
188
|
+
else kept[key] = value;
|
|
189
|
+
}
|
|
190
|
+
return kept;
|
|
191
|
+
};
|
|
192
|
+
const env = safeEnv(rawEnv, "env");
|
|
193
|
+
|
|
194
|
+
let checking = false;
|
|
195
|
+
let checks = 0;
|
|
196
|
+
let tests = 0;
|
|
197
|
+
(job.steps ?? []).forEach((step, index) => {
|
|
198
|
+
const name = label(step, index);
|
|
199
|
+
const condition = step.if === undefined ? "" : String(step.if);
|
|
200
|
+
const cleanup = /\balways\(\)/.test(condition);
|
|
201
|
+
if (step.uses) {
|
|
202
|
+
const tool = TOOL_ACTIONS.find((entry) => entry.match.test(step.uses));
|
|
203
|
+
if (tool) {
|
|
204
|
+
const version = tool.version?.(step.with ?? {});
|
|
205
|
+
tools.push({ name: tool.tool, ...(version !== undefined && !EXPRESSION.test(String(version)) && { version: String(version) }) });
|
|
206
|
+
for (const extra of tool.also?.(step.with ?? {}) ?? []) tools.push(extra);
|
|
207
|
+
} else if (CI_ONLY.test(step.uses)) skipped.push({ step: name, why: "only needed on a CI machine" });
|
|
208
|
+
else untranslated.push({ step: name, why: `uses the action ${step.uses}` });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (!step.run) return;
|
|
212
|
+
if (/\$\{?GITHUB_(OUTPUT|ENV|PATH|STEP_SUMMARY)\b/.test(step.run)) {
|
|
213
|
+
skipped.push({ step: name, why: "only passes values between CI steps" });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const target = cleanup ? remove : create;
|
|
217
|
+
// Flags such as --no-audit say nothing about what a step is for.
|
|
218
|
+
if (!cleanup && (checking || CHECK_STEP.test(`${step.name ?? ""} ${step.run}`.replace(/(^|\s)--?[\w-]+(=\S*)?/g, " ")))) {
|
|
219
|
+
checking = true;
|
|
220
|
+
checks++;
|
|
221
|
+
if (TEST_STEP.test(`${step.name ?? ""} ${step.run}`)) tests++;
|
|
222
|
+
skipped.push({ step: name, why: "checks the code (tests, lint, build); not setup" });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (EXPRESSION.test(step.run) || EXPRESSION.test(JSON.stringify(step.env ?? {}))) {
|
|
226
|
+
untranslated.push({ step: name, why: "uses a CI expression (${{ … }})" });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// An install that CI skips on a cache hit or when no files changed: a new workspace has neither.
|
|
230
|
+
if (condition && !cleanup && onlyStepOutputs(condition) && isInstallCommand(step.run)) {
|
|
231
|
+
create.push({ name, run: String(step.run).trim(), ...(where(step) && { cwd: where(step) }) });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (condition && !cleanup) {
|
|
235
|
+
untranslated.push({ step: name, why: `runs only when ${condition}` });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!isInstallCommand(step.run)) {
|
|
239
|
+
untranslated.push({ step: name, why: "not a dependency install; CI runs it on a throwaway machine, so check what it touches before adding it" });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
target.push({ name, run: String(step.run).trim(), ...(where(step) && { cwd: where(step) }), ...(step.env && { env: safeEnv(plainEnv(step.env, untranslated, name), name) }) });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// A step that runs a project's own package (pnpm exec playwright) needs its dependencies. When CI installs
|
|
246
|
+
// them inside an action nk cannot read (vuetify's vuetifyjs/setup-action), install them first, as a person would.
|
|
247
|
+
const hidden = untranslated.find((entry) => /^uses the action /.test(entry.why));
|
|
248
|
+
const runner = create.map((step) => /^(pnpm|npx|npm|yarn|bunx|bun)\b/.exec(step.run)?.[1]).find(Boolean);
|
|
249
|
+
if (hidden && runner && !create.some((step) => /^(pnpm|npm|yarn|bun)\s+(i|ci|install)\b|^yarn\s*$/.test(step.run))) {
|
|
250
|
+
const install = { pnpm: "pnpm install --frozen-lockfile", npx: "npm ci", npm: "npm ci", yarn: "yarn install", bunx: "bun install", bun: "bun install" }[runner];
|
|
251
|
+
create.unshift({ name: `${install} (CI installs in ${hidden.step})`, run: install, ...(where({}) && { cwd: where({}) }) });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// CI machines have corepack; Node 25 and later no longer include it, so check for it before anything starts.
|
|
255
|
+
if (create.some((step) => /^corepack\b/.test(step.run)) && !tools.some((tool) => tool.name === "corepack")) tools.push({ name: "corepack" });
|
|
256
|
+
|
|
257
|
+
return { source: file, job: jobName, tools, env, services, create, remove, skipped, untranslated, checks, tests };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Jobs that publish, release or maintain rather than set up and test the project. */
|
|
261
|
+
const NOT_SETUP = /release|publish|deploy|bump|\bdocs?\b|nightly|cron|mutation|bench|label|stale|codeql|security|dependabot|backport|sync|cleanup|lighthouse|breaking|changelog|translation|i18n|token|artifact|cache|schema|lint|format|\btypes?\b|audit|triage|preview|licen[sc]e|deprecation|docker|smoke|helm|mobile|\bios\b|android|windows|macos|submit|detect|merge|report|size|openapi|shellcheck|hlint|link|depsreview|dependency|guard|semantic|goreleaser|configuration|validat|devcontainer|image|enterprise|\bwin\b|feature-branch/i;
|
|
262
|
+
/** A check step that runs the tests, which is what a workspace is set up for. */
|
|
263
|
+
const TEST_STEP = /\b(test|tests|unittest|pytest|vitest|jest|e2e|rspec|phpunit|tox|nox)\b/i;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Steps of a local composite action (uses: ./.github/actions/setup) put in
|
|
267
|
+
* place of the step that uses it, so its installs are read like any other.
|
|
268
|
+
* Its inputs are filled from the step's with: or the input's default.
|
|
269
|
+
*/
|
|
270
|
+
function expandLocalActions(repoPath, steps, depth = 0) {
|
|
271
|
+
return steps.flatMap((step) => {
|
|
272
|
+
if (depth > 3 || typeof step?.uses !== "string" || !step.uses.startsWith("./")) return [step];
|
|
273
|
+
const dir = join(repoPath, step.uses);
|
|
274
|
+
let action;
|
|
275
|
+
try {
|
|
276
|
+
const file = ["action.yml", "action.yaml"].map((name) => join(dir, name)).find((path) => existsSync(path));
|
|
277
|
+
action = file && parse(readFileSync(file, "utf8"));
|
|
278
|
+
} catch {
|
|
279
|
+
return [step];
|
|
280
|
+
}
|
|
281
|
+
if (action?.runs?.using !== "composite" || !Array.isArray(action.runs.steps)) return [step];
|
|
282
|
+
const inputs = {};
|
|
283
|
+
for (const [name, input] of Object.entries(action.inputs ?? {})) if (input?.default !== undefined) inputs[name] = String(input.default);
|
|
284
|
+
for (const [name, value] of Object.entries(step.with ?? {})) inputs[name] = String(value);
|
|
285
|
+
const fill = (value) =>
|
|
286
|
+
typeof value === "string"
|
|
287
|
+
? value.replace(/\$\{\{\s*inputs\.([\w-]+)\s*\}\}/g, (whole, name) => (inputs[name] !== undefined && !EXPRESSION.test(inputs[name]) ? inputs[name] : whole))
|
|
288
|
+
: value && typeof value === "object"
|
|
289
|
+
? Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, fill(inner)]))
|
|
290
|
+
: value;
|
|
291
|
+
const inner = action.runs.steps.map((child) => {
|
|
292
|
+
const filled = fill(child);
|
|
293
|
+
// A step's condition holds for every step of the action.
|
|
294
|
+
return step.if !== undefined && filled.if === undefined ? { ...filled, if: step.if } : filled;
|
|
295
|
+
});
|
|
296
|
+
return expandLocalActions(repoPath, inner, depth + 1);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Whether a workflow runs on pull requests or pushes, as the main CI does. */
|
|
301
|
+
function onChanges(on) {
|
|
302
|
+
const events = typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
|
|
303
|
+
return events.some((event) => event === "pull_request" || event === "push" || event === "pull_request_target" || event === "merge_group" || event === "workflow_call");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Workflows that only answer repository events (issues, releases, comments) and never touch the code. */
|
|
307
|
+
function onlyRepositoryEvents(on) {
|
|
308
|
+
const events = typeof on === "string" ? [on] : Array.isArray(on) ? on : Object.keys(on ?? {});
|
|
309
|
+
return events.length > 0 && events.every((event) => /^(issues|issue_comment|discussion|discussion_comment|release|label|milestone|project|project_card|project_column|fork|watch|create|delete|status|check_run|check_suite|registry_package|page_build|deployment|deployment_status|gollum|public|pull_request_review|pull_request_review_comment)$/.test(event));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A job that needs others often starts from what they installed, such as a
|
|
314
|
+
* downloaded artifact; when it downloads one, their installs and tools are its own.
|
|
315
|
+
*/
|
|
316
|
+
function planWithNeeds(repoPath, file, workflow, jobName, job, knownPorts) {
|
|
317
|
+
const plan = planJob(file, jobName, { defaults: workflow.defaults, ...job }, workflow.env, knownPorts);
|
|
318
|
+
if (!job.steps.some((step) => /^actions\/download-artifact@/.test(step.uses ?? ""))) return plan;
|
|
319
|
+
const seen = new Set([jobName]);
|
|
320
|
+
const queue = [job.needs ?? []].flat();
|
|
321
|
+
const before = [];
|
|
322
|
+
while (queue.length) {
|
|
323
|
+
const name = queue.shift();
|
|
324
|
+
if (seen.has(name) || !workflow.jobs[name]?.steps) continue;
|
|
325
|
+
seen.add(name);
|
|
326
|
+
const needed = workflow.jobs[name];
|
|
327
|
+
before.unshift(planJob(file, name, { defaults: workflow.defaults, ...needed, steps: expandLocalActions(repoPath, needed.steps) }, workflow.env, knownPorts));
|
|
328
|
+
queue.push(...[needed.needs ?? []].flat());
|
|
329
|
+
}
|
|
330
|
+
for (const other of before.reverse()) {
|
|
331
|
+
const runs = new Set(plan.create.map((step) => step.run));
|
|
332
|
+
plan.create.unshift(...other.create.filter((step) => !runs.has(step.run)));
|
|
333
|
+
const tools = new Set(plan.tools.map((tool) => tool.name));
|
|
334
|
+
plan.tools.push(...other.tools.filter((tool) => !tools.has(tool.name)));
|
|
335
|
+
}
|
|
336
|
+
return plan;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The job most like "set up and test the project": it runs on pull requests,
|
|
341
|
+
* runs tests, and has the most setup steps and services. knownPorts are the
|
|
342
|
+
* host ports of Compose services nk starts, for when the job has none of its own.
|
|
343
|
+
*/
|
|
344
|
+
export function planFromCi(repoPath, knownPorts = []) {
|
|
345
|
+
const files = workflowFiles(repoPath);
|
|
346
|
+
const plans = [];
|
|
347
|
+
const sources = new Map();
|
|
348
|
+
for (const file of files) {
|
|
349
|
+
let workflow;
|
|
350
|
+
try {
|
|
351
|
+
workflow = parse(readFileSync(join(repoPath, file), "utf8"));
|
|
352
|
+
} catch (error) {
|
|
353
|
+
plans.push({ source: file, error: error.message });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
for (let [jobName, job] of Object.entries(workflow?.jobs ?? {})) {
|
|
357
|
+
if (!job?.steps) continue;
|
|
358
|
+
job = { ...job, steps: expandLocalActions(repoPath, job.steps) };
|
|
359
|
+
const plan = planWithNeeds(repoPath, file, workflow, jobName, job);
|
|
360
|
+
sources.set(plan, { file, jobName, job, workflow, onChanges: onChanges(workflow.on) });
|
|
361
|
+
plans.push(plan);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const score = (plan) => {
|
|
365
|
+
const { onChanges: main } = sources.get(plan);
|
|
366
|
+
return plan.create.length + plan.tools.length + 3 * plan.services.length + (plan.checks ? 4 : 0) + (plan.tests ? 3 : 0) + (main ? 3 : -5) - (NOT_SETUP.test(`${plan.source} ${plan.job}`) ? 10 : 0);
|
|
367
|
+
};
|
|
368
|
+
const ranked = plans.filter((plan) => !plan.error).sort((a, b) => score(b) - score(a));
|
|
369
|
+
// A job that scores below zero, or neither runs on code changes nor tests the code, only maintains the repository (issues, releases, licences).
|
|
370
|
+
const useful = (plan) => score(plan) >= 0 && (sources.get(plan).onChanges || (plan.tests > 0 && !onlyRepositoryEvents(sources.get(plan).workflow.on)));
|
|
371
|
+
let best = ranked[0] && useful(ranked[0]) ? ranked[0] : null;
|
|
372
|
+
// Without services of its own, the job's addresses may point at the Compose services nk starts.
|
|
373
|
+
if (best && !best.services.length && knownPorts.length) {
|
|
374
|
+
const { file, jobName, job, workflow } = sources.get(best);
|
|
375
|
+
best = planWithNeeds(repoPath, file, workflow, jobName, job, knownPorts);
|
|
376
|
+
}
|
|
377
|
+
return { files, best, others: ranked.slice(1).map((plan) => `${plan.source} › ${plan.job}`), errors: plans.filter((plan) => plan.error) };
|
|
378
|
+
}
|