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; | |
| 5d13cf5 | 35 | /** Code files embedded, so semantic search works from the first minute. */ |
| 36 | filesIndexed: number; | |
| 47b1dc7 | 37 | errors: string[]; |
| 38 | } | |
| 39 | ||
| 40 | /** The CI workflow every new repo starts with. Deliberately trivial and | |
| 41 | * dependency-free so it passes on a bare runner: the point is to prove the | |
| 42 | * pipeline works end to end, not to guess the user's stack. */ | |
| 43 | function ciWorkflow(repoName: string): string { | |
| 44 | return `name: CI | |
| 45 | ||
| 46 | on: | |
| 47 | - push | |
| 48 | ||
| 49 | jobs: | |
| 50 | verify: | |
| 51 | runs-on: ubuntu-latest | |
| 52 | steps: | |
| 53 | - name: Say hello | |
| 54 | run: echo "CI is running for ${repoName}" | |
| 55 | - name: Check the workspace exists | |
| 56 | run: ls -a | |
| 57 | `; | |
| 58 | } | |
| 59 | ||
| 60 | function readme(owner: string, repoName: string): string { | |
| 61 | return `# ${repoName} | |
| 62 | ||
| 63 | Created on Gluecron. This repository was set up for you — nothing here needed | |
| 64 | configuring. | |
| 65 | ||
| 66 | ## What is already switched on | |
| 67 | ||
| 68 | - **Branch protection** on the default branch | |
| 69 | - **Secret + security scanning** on every push | |
| 70 | - **AI review** on every pull request | |
| 71 | - **CI** — see \`.gluecron/workflows/ci.yml\`, which ran when this commit landed | |
| 72 | ||
| 73 | ## Next | |
| 74 | ||
| 75 | Push some code: | |
| 76 | ||
| 77 | \`\`\`bash | |
| 78 | git clone https://gluecron.com/${owner}/${repoName}.git | |
| 79 | cd ${repoName} | |
| 80 | # ... | |
| 81 | git push | |
| 82 | \`\`\` | |
| 83 | ||
| 84 | Every push is scanned, reviewed and gated automatically. | |
| 85 | `; | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Injectable dependencies. | |
| 90 | * | |
| 91 | * Deliberately mirrors the pattern in push-workflow-sync.ts. Both of these | |
| 92 | * modules are imported by dozens of unrelated test files, so a | |
| 93 | * `mock.module()` on either leaks a global mock across the entire test run — | |
| 94 | * mocking them from this file's tests broke six tests in | |
| 95 | * push-workflow-sync.test.ts, which is precisely what that file's header | |
| 96 | * comment warns about. | |
| 97 | */ | |
| 98 | export interface ScaffoldDeps { | |
| 99 | createOrUpdateFileOnBranch: (input: { | |
| 100 | owner: string; | |
| 101 | name: string; | |
| 102 | branch: string; | |
| 103 | filePath: string; | |
| 104 | bytes: Uint8Array; | |
| 105 | message: string; | |
| 106 | authorName: string; | |
| 107 | authorEmail: string; | |
| 108 | }) => Promise< | |
| 109 | { commitSha: string; blobSha: string; parentSha: string | null } | { error: string } | |
| 110 | >; | |
| 111 | syncAndEnqueuePushWorkflows: (opts: { | |
| 112 | owner: string; | |
| 113 | repo: string; | |
| 114 | repositoryId: string; | |
| 115 | branch: string; | |
| 116 | commitSha: string; | |
| 117 | triggeredBy?: string | null; | |
| 118 | }) => Promise<{ synced: number; enqueued: number; errors: string[] }>; | |
| 119 | } | |
| 120 | ||
| 121 | async function realDeps(): Promise<ScaffoldDeps> { | |
| 122 | const { createOrUpdateFileOnBranch } = await import("../git/repository"); | |
| 123 | const { syncAndEnqueuePushWorkflows } = await import("./push-workflow-sync"); | |
| 124 | return { | |
| 125 | createOrUpdateFileOnBranch: createOrUpdateFileOnBranch as ScaffoldDeps["createOrUpdateFileOnBranch"], | |
| 126 | syncAndEnqueuePushWorkflows, | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | export async function scaffoldFirstRepo( | |
| 131 | opts: { | |
| 132 | owner: string; | |
| 133 | repoName: string; | |
| 134 | repositoryId: string; | |
| 135 | defaultBranch: string; | |
| 136 | authorName: string; | |
| 137 | authorEmail: string; | |
| 138 | userId: string; | |
| 139 | }, | |
| 140 | injected?: ScaffoldDeps | |
| 141 | ): Promise<ScaffoldResult> { | |
| 142 | const result: ScaffoldResult = { | |
| 143 | readmeCommitted: false, | |
| 144 | workflowCommitted: false, | |
| 145 | workflowsSynced: 0, | |
| 146 | runsEnqueued: 0, | |
| 5d13cf5 | 147 | filesIndexed: 0, |
| 47b1dc7 | 148 | errors: [], |
| 149 | }; | |
| 150 | ||
| 151 | const deps = injected ?? (await realDeps()); | |
| 152 | const { createOrUpdateFileOnBranch } = deps; | |
| 153 | const enc = new TextEncoder(); | |
| 154 | let headSha: string | null = null; | |
| 155 | ||
| 156 | const write = async (filePath: string, body: string, message: string) => { | |
| 157 | const res = await createOrUpdateFileOnBranch({ | |
| 158 | owner: opts.owner, | |
| 159 | name: opts.repoName, | |
| 160 | branch: opts.defaultBranch, | |
| 161 | filePath, | |
| 162 | bytes: enc.encode(body), | |
| 163 | message, | |
| 164 | authorName: opts.authorName, | |
| 165 | authorEmail: opts.authorEmail, | |
| 166 | }); | |
| 167 | if ("error" in res) throw new Error(`${filePath}: ${res.error}`); | |
| 168 | return res.commitSha; | |
| 169 | }; | |
| 170 | ||
| 171 | try { | |
| 172 | headSha = await write( | |
| 173 | "README.md", | |
| 174 | readme(opts.owner, opts.repoName), | |
| 175 | "Add README" | |
| 176 | ); | |
| 177 | result.readmeCommitted = true; | |
| 178 | } catch (err) { | |
| 179 | result.errors.push( | |
| 180 | `readme: ${err instanceof Error ? err.message : String(err)}` | |
| 181 | ); | |
| 182 | } | |
| 183 | ||
| 184 | try { | |
| 185 | headSha = await write( | |
| 186 | ".gluecron/workflows/ci.yml", | |
| 187 | ciWorkflow(opts.repoName), | |
| 188 | "Add CI workflow" | |
| 189 | ); | |
| 190 | result.workflowCommitted = true; | |
| 191 | } catch (err) { | |
| 192 | result.errors.push( | |
| 193 | `workflow: ${err instanceof Error ? err.message : String(err)}` | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 197 | // Discover + enqueue via the SAME seam post-receive uses. Without this the | |
| 198 | // workflow file exists on disk but no `workflows` row and no run are ever | |
| 199 | // created, because writing through the plumbing API does not fire the git | |
| 200 | // hook — the repo would look configured and do nothing. | |
| 201 | if (result.workflowCommitted && headSha) { | |
| 202 | try { | |
| 203 | const sync = await deps.syncAndEnqueuePushWorkflows({ | |
| 204 | owner: opts.owner, | |
| 205 | repo: opts.repoName, | |
| 206 | repositoryId: opts.repositoryId, | |
| 207 | branch: opts.defaultBranch, | |
| 208 | commitSha: headSha, | |
| 209 | triggeredBy: opts.userId, | |
| 210 | }); | |
| 211 | result.workflowsSynced = sync.synced ?? 0; | |
| 212 | result.runsEnqueued = sync.enqueued ?? 0; | |
| 213 | } catch (err) { | |
| 214 | result.errors.push( | |
| 215 | `sync: ${err instanceof Error ? err.message : String(err)}` | |
| 216 | ); | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 5d13cf5 | 220 | // Semantic index. Same reason the workflow sync is here: indexChangedFiles() |
| 221 | // runs only from the git post-receive hook, and these files were written | |
| 222 | // through the plumbing API. Without this the repo's semantic search, repo | |
| 223 | // chat and "ask this repo" return nothing — and an empty index is | |
| 224 | // indistinguishable from "no matches", so nobody notices. | |
| 225 | if (headSha) { | |
| 226 | try { | |
| 227 | const { indexExistingRepo } = await import("./index-existing-repo"); | |
| 228 | const idx = await indexExistingRepo({ | |
| 229 | repositoryId: opts.repositoryId, | |
| 230 | owner: opts.owner, | |
| 231 | repoName: opts.repoName, | |
| 232 | ref: opts.defaultBranch, | |
| 233 | commitSha: headSha, | |
| 234 | }); | |
| 235 | result.filesIndexed = idx.indexed; | |
| 236 | if (idx.error) result.errors.push(`index: ${idx.error}`); | |
| 237 | } catch (err) { | |
| 238 | result.errors.push( | |
| 239 | `index: ${err instanceof Error ? err.message : String(err)}` | |
| 240 | ); | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 47b1dc7 | 244 | return result; |
| 245 | } |