Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 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}`, | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 266 | "Its runs are this repository's checks: they show on every pull request, whoever or whatever opened it.", |
| Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.