| 1 | /** |
| 2 | * A first CI workflow for a repository that has none, worked out from what |
| 3 | * is at its root: one job per stack found (Node, Rust, Go, Python, Ruby, |
| 4 | * Java, .NET), each installing, linting where the project says how, |
| 5 | * building and testing as that stack usually does. Pure, so it is tested on |
| 6 | * its own; `add-ci.server.ts` reads the repository and opens the pull |
| 7 | * request. |
| 8 | * |
| 9 | * The workflow is named `CI`: that is the check it reports, and the name a |
| 10 | * branch requires. It runs on pull requests, on pushes to the default |
| 11 | * branch, and on the merge queue's states (`merge_group`), so that a |
| 12 | * required `CI` can pass everywhere a merge waits for it. |
| 13 | */ |
| 14 | |
| 15 | /** Where the workflow goes. */ |
| 16 | export const STARTER_PATH = ".g1t/workflows/ci.yml"; |
| 17 | /** The check it reports. */ |
| 18 | export const STARTER_CHECK = "CI"; |
| 19 | |
| 20 | export type Stack = "bun" | "pnpm" | "yarn" | "npm" | "rust" | "go" | "uv" | "python" | "ruby" | "maven" | "gradle" | "dotnet" | "make"; |
| 21 | |
| 22 | /** What the root of a repository says about how it is built. */ |
| 23 | export type RootFacts = { |
| 24 | /** The names of the files and directories at the root. */ |
| 25 | names: string[]; |
| 26 | /** The text of `package.json`, when there is one and it could be read. */ |
| 27 | packageJson?: string | null; |
| 28 | }; |
| 29 | |
| 30 | const LABELS: Record<Stack, string> = { |
| 31 | bun: "Bun", |
| 32 | pnpm: "Node (pnpm)", |
| 33 | yarn: "Node (Yarn)", |
| 34 | npm: "Node (npm)", |
| 35 | rust: "Rust", |
| 36 | go: "Go", |
| 37 | uv: "Python (uv)", |
| 38 | python: "Python", |
| 39 | ruby: "Ruby", |
| 40 | maven: "Java (Maven)", |
| 41 | gradle: "Java (Gradle)", |
| 42 | dotnet: ".NET", |
| 43 | make: "Make", |
| 44 | }; |
| 45 | |
| 46 | /** A stack's name, for people. */ |
| 47 | export function stackLabel(stack: Stack): string { |
| 48 | return LABELS[stack]; |
| 49 | } |
| 50 | |
| 51 | /** The stacks at a repository's root, most telling first; at most three. */ |
| 52 | export function detectStacks({ names }: RootFacts): Stack[] { |
| 53 | const has = (name: string) => names.includes(name); |
| 54 | const any = (test: (name: string) => boolean) => names.some(test); |
| 55 | const found: Stack[] = []; |
| 56 | if (has("package.json")) { |
| 57 | if (has("bun.lockb") || has("bun.lock")) found.push("bun"); |
| 58 | else if (has("pnpm-lock.yaml")) found.push("pnpm"); |
| 59 | else if (has("yarn.lock")) found.push("yarn"); |
| 60 | else found.push("npm"); |
| 61 | } |
| 62 | if (has("Cargo.toml")) found.push("rust"); |
| 63 | if (has("go.mod")) found.push("go"); |
| 64 | if (has("uv.lock")) found.push("uv"); |
| 65 | else if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) found.push("python"); |
| 66 | if (has("Gemfile")) found.push("ruby"); |
| 67 | if (has("pom.xml")) found.push("maven"); |
| 68 | else if (has("build.gradle") || has("build.gradle.kts")) found.push("gradle"); |
| 69 | if (any((name) => name.endsWith(".sln") || name.endsWith(".csproj") || name.endsWith(".fsproj"))) found.push("dotnet"); |
| 70 | if (found.length === 0 && (has("Makefile") || has("makefile"))) found.push("make"); |
| 71 | return found.slice(0, 3); |
| 72 | } |
| 73 | |
| 74 | /** The scripts a `package.json` names, or null when it cannot be read. */ |
| 75 | function scripts(packageJson: string | null | undefined): Set<string> | null { |
| 76 | if (!packageJson) return null; |
| 77 | try { |
| 78 | const parsed = JSON.parse(packageJson) as { scripts?: Record<string, unknown> }; |
| 79 | return new Set(Object.keys(parsed.scripts ?? {})); |
| 80 | } catch { |
| 81 | return null; |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /** One step, as YAML lines indented for a job's `steps:`. */ |
| 86 | type Step = string[]; |
| 87 | |
| 88 | const uses = (action: string, name?: string, with_?: Record<string, string>): Step => [ |
| 89 | ...(name ? [`- name: ${name}`, ` uses: ${action}`] : [`- uses: ${action}`]), |
| 90 | ...(with_ ? [" with:", ...Object.entries(with_).map(([key, value]) => ` ${key}: ${value}`)] : []), |
| 91 | ]; |
| 92 | const run = (command: string, name?: string): Step => (name ? [`- name: ${name}`, ` run: ${command}`] : [`- run: ${command}`]); |
| 93 | |
| 94 | /** The steps that lint, build and test a Node project, with the runner it uses. */ |
| 95 | function nodeSteps(stack: "bun" | "pnpm" | "yarn" | "npm", facts: RootFacts): Step[] { |
| 96 | const known = scripts(facts.packageJson); |
| 97 | // Only the scripts it has; when they cannot be read, the ones that exist run. |
| 98 | const wanted = ["lint", "typecheck", "build", "test"].filter((script) => !known || known.has(script)); |
| 99 | const missing = known != null && wanted.length === 0; |
| 100 | const steps: Step[] = []; |
| 101 | if (stack === "bun") { |
| 102 | steps.push(uses("oven-sh/setup-bun@v2")); |
| 103 | steps.push(run("bun install --frozen-lockfile")); |
| 104 | for (const script of wanted) steps.push(run(`bun run ${script}`)); |
| 105 | } else { |
| 106 | if (stack === "pnpm") steps.push(uses("pnpm/action-setup@v4")); |
| 107 | steps.push(uses("actions/setup-node@v5", undefined, { "node-version": "24", cache: stack })); |
| 108 | const install = |
| 109 | stack === "pnpm" |
| 110 | ? "pnpm install --frozen-lockfile" |
| 111 | : stack === "yarn" |
| 112 | ? "yarn install --frozen-lockfile" |
| 113 | : facts.names.includes("package-lock.json") |
| 114 | ? "npm ci" |
| 115 | : "npm install"; |
| 116 | steps.push(run(install)); |
| 117 | for (const script of wanted) { |
| 118 | steps.push( |
| 119 | run( |
| 120 | stack === "npm" |
| 121 | ? known |
| 122 | ? `npm run ${script}` |
| 123 | : `npm run ${script} --if-present` |
| 124 | : stack === "pnpm" |
| 125 | ? known |
| 126 | ? `pnpm run ${script}` |
| 127 | : `pnpm run --if-present ${script}` |
| 128 | : `yarn run ${script}`, |
| 129 | ), |
| 130 | ); |
| 131 | } |
| 132 | } |
| 133 | if (missing) steps.push(run('echo "package.json has no lint, build or test script yet: add the ones this project needs."')); |
| 134 | return steps; |
| 135 | } |
| 136 | |
| 137 | /** Each stack's job: its key, its name, and its steps after the checkout. */ |
| 138 | function job(stack: Stack, facts: RootFacts): { key: string; name: string; steps: Step[] } { |
| 139 | const has = (name: string) => facts.names.includes(name); |
| 140 | switch (stack) { |
| 141 | case "bun": |
| 142 | case "pnpm": |
| 143 | case "yarn": |
| 144 | case "npm": |
| 145 | return { key: "node", name: "Node", steps: nodeSteps(stack, facts) }; |
| 146 | case "rust": |
| 147 | return { |
| 148 | key: "rust", |
| 149 | name: "Rust", |
| 150 | steps: [ |
| 151 | uses("dtolnay/rust-toolchain@stable", undefined, { components: "clippy" }), |
| 152 | uses("Swatinem/rust-cache@v2"), |
| 153 | run("cargo clippy --all-targets"), |
| 154 | run("cargo test --all-targets"), |
| 155 | ], |
| 156 | }; |
| 157 | case "go": |
| 158 | return { |
| 159 | key: "go", |
| 160 | name: "Go", |
| 161 | steps: [ |
| 162 | uses("actions/setup-go@v5", undefined, { "go-version-file": "go.mod" }), |
| 163 | run("go vet ./..."), |
| 164 | run("go build ./..."), |
| 165 | run("go test ./..."), |
| 166 | ], |
| 167 | }; |
| 168 | case "uv": |
| 169 | return { |
| 170 | key: "python", |
| 171 | name: "Python", |
| 172 | steps: [uses("astral-sh/setup-uv@v6"), run("uv sync"), run("uv run pytest")], |
| 173 | }; |
| 174 | case "python": { |
| 175 | const install = has("requirements.txt") |
| 176 | ? "pip install -r requirements.txt pytest" |
| 177 | : "pip install -e . pytest"; |
| 178 | return { |
| 179 | key: "python", |
| 180 | name: "Python", |
| 181 | steps: [uses("actions/setup-python@v5", undefined, { "python-version": '"3.12"', cache: "pip" }), run(install), run("pytest")], |
| 182 | }; |
| 183 | } |
| 184 | case "ruby": |
| 185 | return { |
| 186 | key: "ruby", |
| 187 | name: "Ruby", |
| 188 | steps: [ |
| 189 | uses("ruby/setup-ruby@v1", undefined, { "bundler-cache": "true" }), |
| 190 | run(has("Rakefile") ? "bundle exec rake" : "bundle exec rspec"), |
| 191 | ], |
| 192 | }; |
| 193 | case "maven": |
| 194 | return { |
| 195 | key: "java", |
| 196 | name: "Java", |
| 197 | steps: [uses("actions/setup-java@v4", undefined, { distribution: "temurin", "java-version": '"21"', cache: "maven" }), run("mvn -B verify")], |
| 198 | }; |
| 199 | case "gradle": |
| 200 | return { |
| 201 | key: "java", |
| 202 | name: "Java", |
| 203 | steps: [ |
| 204 | uses("actions/setup-java@v4", undefined, { distribution: "temurin", "java-version": '"21"' }), |
| 205 | uses("gradle/actions/setup-gradle@v4"), |
| 206 | run(has("gradlew") ? "./gradlew build" : "gradle build"), |
| 207 | ], |
| 208 | }; |
| 209 | case "dotnet": |
| 210 | return { |
| 211 | key: "dotnet", |
| 212 | name: ".NET", |
| 213 | steps: [ |
| 214 | uses("actions/setup-dotnet@v4", undefined, { "dotnet-version": "8.0.x" }), |
| 215 | run("dotnet restore"), |
| 216 | run("dotnet build --no-restore"), |
| 217 | run("dotnet test --no-build"), |
| 218 | ], |
| 219 | }; |
| 220 | case "make": |
| 221 | return { key: "build", name: "Build", steps: [run("make"), run("make test")] }; |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /** The starter workflow for a repository, and the stacks it was made for. */ |
| 226 | export function starterWorkflow(facts: RootFacts, defaultBranch: string): { yaml: string; stacks: Stack[] } { |
| 227 | const stacks = detectStacks(facts); |
| 228 | const jobs = stacks.map((stack) => job(stack, facts)); |
| 229 | const lines = [ |
| 230 | `# ${STARTER_CHECK}: what g1t checks on every pull request before it can merge.`, |
| 231 | "# Generated for this repository; change the steps to match how you build and test.", |
| 232 | `# Require it in Settings > Branches and merging once it has run.`, |
| 233 | `name: ${STARTER_CHECK}`, |
| 234 | "", |
| 235 | "on:", |
| 236 | " pull_request:", |
| 237 | " push:", |
| 238 | ` branches: [${JSON.stringify(defaultBranch)}]`, |
| 239 | " merge_group:", |
| 240 | "", |
| 241 | "jobs:", |
| 242 | ]; |
| 243 | if (jobs.length === 0) { |
| 244 | lines.push( |
| 245 | " check:", |
| 246 | " runs-on: ubuntu-latest", |
| 247 | " steps:", |
| 248 | " - uses: actions/checkout@v5", |
| 249 | " # g1t could not tell how this project is built. Replace this step", |
| 250 | " # with the commands that build and test it.", |
| 251 | ' - run: echo "Add the commands that build and test this project." && exit 1', |
| 252 | ); |
| 253 | } |
| 254 | for (const { key, name, steps } of jobs) { |
| 255 | lines.push(` ${key}:`, ` name: ${name}`, " runs-on: ubuntu-latest", " steps:", " - uses: actions/checkout@v5"); |
| 256 | for (const step of steps) for (const line of step) lines.push(` ${line}`); |
| 257 | } |
| 258 | return { yaml: `${lines.join("\n")}\n`, stacks }; |
| 259 | } |
| 260 | |
| 261 | /** What the pull request that adds it says. */ |
| 262 | export function starterDescription(stacks: Stack[], defaultBranch: string): string { |
| 263 | const found = stacks.length > 0 ? `g1t found ${stacks.map(stackLabel).join(", ")} at the root and wrote a job for each.` : "g1t could not tell how this project is built, so the job is a placeholder: replace its last step with the commands that build and test it."; |
| 264 | return [ |
| 265 | `Adds \`${STARTER_PATH}\`, a workflow named **${STARTER_CHECK}** that runs on every pull request, on pushes to \`${defaultBranch}\`, and on the merge queue. ${found}`, |
| 266 | "Its runs are this repository's checks: they show on every pull request, whoever opened it, and an agent whose change fails them is sent back with what the failing jobs printed.", |
| 267 | `Once this has run, require **${STARTER_CHECK}** in Settings, Branches and merging, so that nothing merges into \`${defaultBranch}\` unless it passes.`, |
| 268 | ].join("\n\n"); |
| 269 | } |
| 270 | |
| 271 | /** A branch name for the pull request that is not one of `taken`. */ |
| 272 | export function freeBranch(taken: string[], base = "add-ci"): string { |
| 273 | const names = new Set(taken); |
| 274 | if (!names.has(base)) return base; |
| 275 | for (let n = 2; ; n += 1) if (!names.has(`${base}-${n}`)) return `${base}-${n}`; |
| 276 | } |