Blame · Line-by-line history
first-repo-scaffold.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 47b1dc7 | 1 | /** |
| 2 | * Seed a brand-new repository so it arrives working rather than empty. | |
| 3 | * | |
| 4 | * `POST /new` already calls bootstrapRepository(), which creates the DB side — | |
| 5 | * gate settings, branch protection, labels, a welcome issue. But it creates no | |
| 6 | * git content, so a new user landed on an empty repository: no commit, no | |
| 7 | * README, no workflow, and therefore no CI run to be green. Everything the | |
| 8 | * product does on push had nothing to act on, and the first thing a new user | |
| 9 | * saw was an empty-state page telling them to go and configure something. | |
| 10 | * | |
| 11 | * This writes an initial commit containing: | |
| 12 | * | |
| 13 | * README.md what the repo is, and what the platform | |
| 14 | * already did to it | |
| 15 | * .gluecron/workflows/ci.yml a workflow that runs on push and passes | |
| 16 | * | |
| 17 | * ...then calls the same syncAndEnqueuePushWorkflows() the git post-receive | |
| 18 | * hook calls. That matters: these files are written through the git plumbing | |
| 19 | * API rather than an actual push, so the post-receive hook never fires and | |
| 20 | * the workflow would otherwise sit undiscovered on disk. Calling the shared | |
| 21 | * seam means scaffolded repos take exactly the same path as pushed ones. | |
| 22 | * | |
| 23 | * Best-effort throughout. A repository that exists but has no README is a | |
| 24 | * cosmetic disappointment; a failed repo creation is a broken product. So | |
| 25 | * every step is caught and reported, never thrown. | |
| 26 | */ | |
| 27 | ||
| 28 | export interface ScaffoldResult { | |
| 29 | readmeCommitted: boolean; | |
| 30 | workflowCommitted: boolean; | |
| 31 | /** Workflows discovered and upserted by the shared sync seam. */ | |
| 32 | workflowsSynced: number; | |
| 33 | /** CI runs enqueued — this is what makes the repo show a green check. */ | |
| 34 | runsEnqueued: number; | |
| 35 | errors: string[]; | |
| 36 | } | |
| 37 | ||
| 38 | /** The CI workflow every new repo starts with. Deliberately trivial and | |
| 39 | * dependency-free so it passes on a bare runner: the point is to prove the | |
| 40 | * pipeline works end to end, not to guess the user's stack. */ | |
| 41 | function ciWorkflow(repoName: string): string { | |
| 42 | return `name: CI | |
| 43 | ||
| 44 | on: | |
| 45 | - push | |
| 46 | ||
| 47 | jobs: | |
| 48 | verify: | |
| 49 | runs-on: ubuntu-latest | |
| 50 | steps: | |
| 51 | - name: Say hello | |
| 52 | run: echo "CI is running for ${repoName}" | |
| 53 | - name: Check the workspace exists | |
| 54 | run: ls -a | |
| 55 | `; | |
| 56 | } | |
| 57 | ||
| 58 | function readme(owner: string, repoName: string): string { | |
| 59 | return `# ${repoName} | |
| 60 | ||
| 61 | Created on Gluecron. This repository was set up for you — nothing here needed | |
| 62 | configuring. | |
| 63 | ||
| 64 | ## What is already switched on | |
| 65 | ||
| 66 | - **Branch protection** on the default branch | |
| 67 | - **Secret + security scanning** on every push | |
| 68 | - **AI review** on every pull request | |
| 69 | - **CI** — see \`.gluecron/workflows/ci.yml\`, which ran when this commit landed | |
| 70 | ||
| 71 | ## Next | |
| 72 | ||
| 73 | Push some code: | |
| 74 | ||
| 75 | \`\`\`bash | |
| 76 | git clone https://gluecron.com/${owner}/${repoName}.git | |
| 77 | cd ${repoName} | |
| 78 | # ... | |
| 79 | git push | |
| 80 | \`\`\` | |
| 81 | ||
| 82 | Every push is scanned, reviewed and gated automatically. | |
| 83 | `; | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Injectable dependencies. | |
| 88 | * | |
| 89 | * Deliberately mirrors the pattern in push-workflow-sync.ts. Both of these | |
| 90 | * modules are imported by dozens of unrelated test files, so a | |
| 91 | * `mock.module()` on either leaks a global mock across the entire test run — | |
| 92 | * mocking them from this file's tests broke six tests in | |
| 93 | * push-workflow-sync.test.ts, which is precisely what that file's header | |
| 94 | * comment warns about. | |
| 95 | */ | |
| 96 | export interface ScaffoldDeps { | |
| 97 | createOrUpdateFileOnBranch: (input: { | |
| 98 | owner: string; | |
| 99 | name: string; | |
| 100 | branch: string; | |
| 101 | filePath: string; | |
| 102 | bytes: Uint8Array; | |
| 103 | message: string; | |
| 104 | authorName: string; | |
| 105 | authorEmail: string; | |
| 106 | }) => Promise< | |
| 107 | { commitSha: string; blobSha: string; parentSha: string | null } | { error: string } | |
| 108 | >; | |
| 109 | syncAndEnqueuePushWorkflows: (opts: { | |
| 110 | owner: string; | |
| 111 | repo: string; | |
| 112 | repositoryId: string; | |
| 113 | branch: string; | |
| 114 | commitSha: string; | |
| 115 | triggeredBy?: string | null; | |
| 116 | }) => Promise<{ synced: number; enqueued: number; errors: string[] }>; | |
| 117 | } | |
| 118 | ||
| 119 | async function realDeps(): Promise<ScaffoldDeps> { | |
| 120 | const { createOrUpdateFileOnBranch } = await import("../git/repository"); | |
| 121 | const { syncAndEnqueuePushWorkflows } = await import("./push-workflow-sync"); | |
| 122 | return { | |
| 123 | createOrUpdateFileOnBranch: createOrUpdateFileOnBranch as ScaffoldDeps["createOrUpdateFileOnBranch"], | |
| 124 | syncAndEnqueuePushWorkflows, | |
| 125 | }; | |
| 126 | } | |
| 127 | ||
| 128 | export async function scaffoldFirstRepo( | |
| 129 | opts: { | |
| 130 | owner: string; | |
| 131 | repoName: string; | |
| 132 | repositoryId: string; | |
| 133 | defaultBranch: string; | |
| 134 | authorName: string; | |
| 135 | authorEmail: string; | |
| 136 | userId: string; | |
| 137 | }, | |
| 138 | injected?: ScaffoldDeps | |
| 139 | ): Promise<ScaffoldResult> { | |
| 140 | const result: ScaffoldResult = { | |
| 141 | readmeCommitted: false, | |
| 142 | workflowCommitted: false, | |
| 143 | workflowsSynced: 0, | |
| 144 | runsEnqueued: 0, | |
| 145 | errors: [], | |
| 146 | }; | |
| 147 | ||
| 148 | const deps = injected ?? (await realDeps()); | |
| 149 | const { createOrUpdateFileOnBranch } = deps; | |
| 150 | const enc = new TextEncoder(); | |
| 151 | let headSha: string | null = null; | |
| 152 | ||
| 153 | const write = async (filePath: string, body: string, message: string) => { | |
| 154 | const res = await createOrUpdateFileOnBranch({ | |
| 155 | owner: opts.owner, | |
| 156 | name: opts.repoName, | |
| 157 | branch: opts.defaultBranch, | |
| 158 | filePath, | |
| 159 | bytes: enc.encode(body), | |
| 160 | message, | |
| 161 | authorName: opts.authorName, | |
| 162 | authorEmail: opts.authorEmail, | |
| 163 | }); | |
| 164 | if ("error" in res) throw new Error(`${filePath}: ${res.error}`); | |
| 165 | return res.commitSha; | |
| 166 | }; | |
| 167 | ||
| 168 | try { | |
| 169 | headSha = await write( | |
| 170 | "README.md", | |
| 171 | readme(opts.owner, opts.repoName), | |
| 172 | "Add README" | |
| 173 | ); | |
| 174 | result.readmeCommitted = true; | |
| 175 | } catch (err) { | |
| 176 | result.errors.push( | |
| 177 | `readme: ${err instanceof Error ? err.message : String(err)}` | |
| 178 | ); | |
| 179 | } | |
| 180 | ||
| 181 | try { | |
| 182 | headSha = await write( | |
| 183 | ".gluecron/workflows/ci.yml", | |
| 184 | ciWorkflow(opts.repoName), | |
| 185 | "Add CI workflow" | |
| 186 | ); | |
| 187 | result.workflowCommitted = true; | |
| 188 | } catch (err) { | |
| 189 | result.errors.push( | |
| 190 | `workflow: ${err instanceof Error ? err.message : String(err)}` | |
| 191 | ); | |
| 192 | } | |
| 193 | ||
| 194 | // Discover + enqueue via the SAME seam post-receive uses. Without this the | |
| 195 | // workflow file exists on disk but no `workflows` row and no run are ever | |
| 196 | // created, because writing through the plumbing API does not fire the git | |
| 197 | // hook — the repo would look configured and do nothing. | |
| 198 | if (result.workflowCommitted && headSha) { | |
| 199 | try { | |
| 200 | const sync = await deps.syncAndEnqueuePushWorkflows({ | |
| 201 | owner: opts.owner, | |
| 202 | repo: opts.repoName, | |
| 203 | repositoryId: opts.repositoryId, | |
| 204 | branch: opts.defaultBranch, | |
| 205 | commitSha: headSha, | |
| 206 | triggeredBy: opts.userId, | |
| 207 | }); | |
| 208 | result.workflowsSynced = sync.synced ?? 0; | |
| 209 | result.runsEnqueued = sync.enqueued ?? 0; | |
| 210 | } catch (err) { | |
| 211 | result.errors.push( | |
| 212 | `sync: ${err instanceof Error ? err.message : String(err)}` | |
| 213 | ); | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | return result; | |
| 218 | } |