Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

repo-onboarding.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.

repo-onboarding.tsBlame492 lines · 1 contributor
641aa42Claude1/**
2 * Smart empty states — repo onboarding generation.
3 *
4 * generateRepoOnboarding() — analyse a repo's file tree + detect language/
5 * framework, then call Claude Sonnet to produce a
6 * README draft, suggested labels, a gates.yml
7 * starter, and first-commit suggestions.
8 *
9 * ensureRepoOnboarding() — idempotent wrapper called on first push;
10 * skips if onboarding_shown is already set OR if
11 * the repo_onboarding_data row already exists.
12 *
13 * All external calls are swallowed — a missing AI key or DB error must never
14 * break the push path.
15 */
16
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { repositories, repoOnboardingData } from "../db/schema";
20import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
21import { getRepoPath } from "../git/repository";
22
23// ---------------------------------------------------------------------------
24// Types
25// ---------------------------------------------------------------------------
26
27export interface RepoOnboarding {
28 detectedLanguage: string;
29 detectedFramework?: string;
30 suggestedReadme: string;
31 suggestedLabels: Array<{ name: string; color: string; description: string }>;
32 suggestedGatesConfig: string;
33 firstCommitSuggestions: string[];
34}
35
36// ---------------------------------------------------------------------------
37// Language + framework detection helpers
38// ---------------------------------------------------------------------------
39
40function detectLanguage(files: string[]): string {
41 const counts: Record<string, number> = {};
42 for (const f of files) {
43 const ext = f.split(".").pop()?.toLowerCase() ?? "";
44 if (ext === "ts" || ext === "tsx") counts.TypeScript = (counts.TypeScript ?? 0) + 1;
45 else if (ext === "js" || ext === "jsx" || ext === "mjs" || ext === "cjs") counts.JavaScript = (counts.JavaScript ?? 0) + 1;
46 else if (ext === "py") counts.Python = (counts.Python ?? 0) + 1;
47 else if (ext === "go") counts.Go = (counts.Go ?? 0) + 1;
48 else if (ext === "rs") counts.Rust = (counts.Rust ?? 0) + 1;
49 else if (ext === "java") counts.Java = (counts.Java ?? 0) + 1;
50 else if (ext === "rb") counts.Ruby = (counts.Ruby ?? 0) + 1;
51 else if (ext === "php") counts.PHP = (counts.PHP ?? 0) + 1;
52 else if (ext === "cs") counts["C#"] = (counts["C#"] ?? 0) + 1;
53 else if (ext === "cpp" || ext === "cc" || ext === "cxx") counts["C++"] = (counts["C++"] ?? 0) + 1;
54 else if (ext === "c" || ext === "h") counts.C = (counts.C ?? 0) + 1;
55 else if (ext === "swift") counts.Swift = (counts.Swift ?? 0) + 1;
56 else if (ext === "kt") counts.Kotlin = (counts.Kotlin ?? 0) + 1;
57 }
58 const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
59 return sorted[0]?.[0] ?? "Unknown";
60}
61
62async function detectFramework(
63 ownerName: string,
64 repoName: string,
65 files: string[]
66): Promise<string | undefined> {
67 const fileSet = new Set(files.map((f) => f.toLowerCase()));
68
69 // Check next.config.* — Next.js
70 if (files.some((f) => /next\.config\.(js|ts|mjs|cjs)/.test(f))) return "Next.js";
71
72 // Check Cargo.toml for Actix / Axum / Warp
73 if (fileSet.has("cargo.toml")) {
74 const content = await readFileFromGit(ownerName, repoName, "Cargo.toml");
75 if (content) {
76 if (/actix-web/.test(content)) return "Actix";
77 if (/axum/.test(content)) return "Axum";
78 if (/warp/.test(content)) return "Warp";
79 }
80 }
81
82 // Check requirements.txt for FastAPI / Django / Flask
83 if (fileSet.has("requirements.txt")) {
84 const content = await readFileFromGit(ownerName, repoName, "requirements.txt");
85 if (content) {
86 if (/fastapi/i.test(content)) return "FastAPI";
87 if (/django/i.test(content)) return "Django";
88 if (/flask/i.test(content)) return "Flask";
89 }
90 }
91
92 // Check package.json for various JS frameworks
93 if (fileSet.has("package.json")) {
94 const content = await readFileFromGit(ownerName, repoName, "package.json");
95 if (content) {
96 try {
97 const pkg = JSON.parse(content) as Record<string, unknown>;
98 const deps = {
99 ...((pkg.dependencies as Record<string, string>) ?? {}),
100 ...((pkg.devDependencies as Record<string, string>) ?? {}),
101 };
102 if ("hono" in deps) return "Hono";
103 if ("next" in deps) return "Next.js";
104 if ("express" in deps) return "Express";
105 if ("fastify" in deps) return "Fastify";
106 if ("@nestjs/core" in deps) return "NestJS";
107 if ("nuxt" in deps) return "Nuxt";
108 if ("svelte" in deps) return "SvelteKit";
109 if ("remix" in deps || "@remix-run/node" in deps) return "Remix";
110 if ("react" in deps) return "React";
111 if ("vue" in deps) return "Vue";
112 } catch {
113 /* ignore parse error */
114 }
115 }
116 }
117
118 // go.mod check
119 if (fileSet.has("go.mod")) {
120 const content = await readFileFromGit(ownerName, repoName, "go.mod");
121 if (content) {
122 if (/gin-gonic\/gin/.test(content)) return "Gin";
123 if (/labstack\/echo/.test(content)) return "Echo";
124 if (/gofiber\/fiber/.test(content)) return "Fiber";
125 }
126 }
127
128 return undefined;
129}
130
131// ---------------------------------------------------------------------------
132// Git helpers
133// ---------------------------------------------------------------------------
134
135async function listAllFiles(ownerName: string, repoName: string): Promise<string[]> {
136 const cwd = getRepoPath(ownerName, repoName);
137 try {
138 const proc = Bun.spawn(
139 ["git", "ls-tree", "-r", "--name-only", "HEAD"],
140 { cwd, stdout: "pipe", stderr: "pipe" }
141 );
142 const text = await new Response(proc.stdout).text();
143 await proc.exited;
144 return text.split("\n").map((s) => s.trim()).filter(Boolean);
145 } catch {
146 return [];
147 }
148}
149
150async function readFileFromGit(
151 ownerName: string,
152 repoName: string,
153 path: string
154): Promise<string | null> {
155 const cwd = getRepoPath(ownerName, repoName);
156 try {
157 const proc = Bun.spawn(
158 ["git", "show", `HEAD:${path}`],
159 { cwd, stdout: "pipe", stderr: "pipe" }
160 );
161 const text = await new Response(proc.stdout).text();
162 const exit = await proc.exited;
163 return exit === 0 ? text : null;
164 } catch {
165 return null;
166 }
167}
168
169// ---------------------------------------------------------------------------
170// Default labels by language (no AI required)
171// ---------------------------------------------------------------------------
172
173const DEFAULT_LABELS: Array<{ name: string; color: string; description: string }> = [
174 { name: "bug", color: "d73a4a", description: "Something isn't working" },
175 { name: "feature", color: "0075ca", description: "New feature or request" },
176 { name: "docs", color: "0052cc", description: "Improvements or additions to documentation" },
177 { name: "performance", color: "e4e669", description: "Performance improvement" },
178 { name: "security", color: "e11d48", description: "Security vulnerability or fix" },
179 { name: "breaking-change", color: "b60205", description: "Breaking change that requires version bump" },
180];
181
182const LANGUAGE_LABELS: Record<string, Array<{ name: string; color: string; description: string }>> = {
183 TypeScript: [
184 { name: "types", color: "3178c6", description: "TypeScript type definitions or fixes" },
185 { name: "deps", color: "0366d6", description: "Dependency update" },
186 ],
187 Python: [
188 { name: "pep8", color: "ffd43b", description: "Code style: PEP 8 compliance" },
189 { name: "deps", color: "0366d6", description: "Dependency update" },
190 ],
191 Go: [
192 { name: "goroutine", color: "00add8", description: "Concurrency / goroutine related" },
193 { name: "deps", color: "0366d6", description: "Go module dependency" },
194 ],
195 Rust: [
196 { name: "unsafe", color: "e83e8c", description: "Involves unsafe Rust code" },
197 { name: "deps", color: "0366d6", description: "Cargo dependency" },
198 ],
199};
200
201function buildDefaultLabels(
202 language: string
203): Array<{ name: string; color: string; description: string }> {
204 const extras = LANGUAGE_LABELS[language] ?? [];
205 return [...DEFAULT_LABELS, ...extras];
206}
207
208// ---------------------------------------------------------------------------
209// gates.yml starters
210// ---------------------------------------------------------------------------
211
212function buildDefaultGatesConfig(language: string, framework?: string): string {
213 const lang = language.toLowerCase();
214 if (lang === "typescript" || lang === "javascript") {
215 return `version: 1
216gates:
217 - id: type-check
218 run: npx tsc --noEmit
219 on: push
220 - id: lint
221 run: npx eslint . --ext .ts,.tsx,.js,.jsx
222 on: push
223 - id: test
224 run: ${framework === "Hono" || lang === "typescript" ? "bun test" : "npm test"}
225 on: push
226`;
227 }
228 if (lang === "python") {
229 return `version: 1
230gates:
231 - id: lint
232 run: ruff check .
233 on: push
234 - id: type-check
235 run: mypy .
236 on: push
237 - id: test
238 run: pytest
239 on: push
240`;
241 }
242 if (lang === "go") {
243 return `version: 1
244gates:
245 - id: vet
246 run: go vet ./...
247 on: push
248 - id: test
249 run: go test ./...
250 on: push
251`;
252 }
253 if (lang === "rust") {
254 return `version: 1
255gates:
256 - id: check
257 run: cargo check
258 on: push
259 - id: clippy
260 run: cargo clippy -- -D warnings
261 on: push
262 - id: test
263 run: cargo test
264 on: push
265`;
266 }
267 return `version: 1
268gates:
269 - id: test
270 run: echo "Add your test command here"
271 on: push
272`;
273}
274
275// ---------------------------------------------------------------------------
276// First-commit suggestions
277// ---------------------------------------------------------------------------
278
279function buildFirstCommitSuggestions(language: string): string[] {
280 const lang = language.toLowerCase();
281 const suggestions = [`Add a .gitignore for ${language}`];
282 if (lang === "typescript" || lang === "javascript") {
283 suggestions.push("Add a tsconfig.json");
284 suggestions.push("Add ESLint + Prettier config");
285 suggestions.push("Add a README.md");
286 suggestions.push("Add tests/ directory with a sample test");
287 } else if (lang === "python") {
288 suggestions.push("Add requirements.txt or pyproject.toml");
289 suggestions.push("Add a README.md");
290 suggestions.push("Add tests/ directory with pytest");
291 } else if (lang === "go") {
292 suggestions.push("Initialize go.mod (go mod init)");
293 suggestions.push("Add a README.md");
294 suggestions.push("Add *_test.go files");
295 } else if (lang === "rust") {
296 suggestions.push("Cargo.toml is auto-generated — add a README.md");
297 suggestions.push("Add integration tests in tests/");
298 } else {
299 suggestions.push("Add a README.md");
300 suggestions.push("Add tests/");
301 }
302 return suggestions;
303}
304
305// ---------------------------------------------------------------------------
306// Main generation function
307// ---------------------------------------------------------------------------
308
309export async function generateRepoOnboarding(
310 repoId: string,
311 ownerName: string,
312 repoName: string
313): Promise<RepoOnboarding> {
314 const files = await listAllFiles(ownerName, repoName);
315 const language = detectLanguage(files);
316 const framework = await detectFramework(ownerName, repoName, files);
317
318 const existingReadme =
319 (await readFileFromGit(ownerName, repoName, "README.md")) ||
320 (await readFileFromGit(ownerName, repoName, "readme.md")) ||
321 (await readFileFromGit(ownerName, repoName, "README.txt")) ||
322 null;
323
324 const defaultLabels = buildDefaultLabels(language);
325 const defaultGatesConfig = buildDefaultGatesConfig(language, framework);
326 const defaultSuggestions = buildFirstCommitSuggestions(language);
327
328 // Without AI: return sensible defaults immediately
329 if (!isAiAvailable()) {
330 const readmeDraft = existingReadme ?? buildFallbackReadme(repoName, language, framework);
331 return {
332 detectedLanguage: language,
333 detectedFramework: framework,
334 suggestedReadme: readmeDraft,
335 suggestedLabels: defaultLabels,
336 suggestedGatesConfig: defaultGatesConfig,
337 firstCommitSuggestions: defaultSuggestions,
338 };
339 }
340
341 // With AI: call Claude Sonnet 4.6 for richer content
342 try {
343 const fileTree = files.slice(0, 120).join("\n");
344 const readmeContext = existingReadme
345 ? existingReadme.slice(0, 2000)
346 : "(none)";
347
348 const prompt = `Generate onboarding content for a new ${language}${framework ? ` ${framework}` : ""} repository named "${repoName}".
349
350File tree (if any):
351${fileTree || "(empty — no commits yet)"}
352
353Existing README (if any):
354${readmeContext}
355
356Return ONLY valid JSON (no prose, no fenced block) with this exact shape:
357{
358 "suggestedReadme": "# ${repoName}\\n\\n...",
359 "suggestedLabels": [{"name": "bug", "color": "d73a4a", "description": "Something isn't working"}, ...],
360 "suggestedGatesConfig": "version: 1\\ngates:\\n ...",
361 "firstCommitSuggestions": ["Add a .gitignore for ${language}", ...]
362}
363
364Rules:
365- suggestedReadme must include ## About, ## Installation, ## Usage, ## Contributing sections. Use placeholder content where specifics are unknown.
366- suggestedLabels: 6-8 labels appropriate for this type of project. Include bug, feature, docs, security, breaking-change and 1-3 language-specific ones.
367- suggestedGatesConfig: a real gates.yml starter for ${language}${framework ? ` / ${framework}` : ""} with lint + test gates.
368- firstCommitSuggestions: 3-5 practical next steps for this language/framework.`;
369
370 const anthropic = getAnthropic();
371 const message = await anthropic.messages.create({
372 model: MODEL_SONNET,
373 max_tokens: 2048,
374 messages: [{ role: "user", content: prompt }],
375 });
376
377 const text = extractText(message);
378 const parsed = parseJsonResponse<{
379 suggestedReadme?: string;
380 suggestedLabels?: Array<{ name: string; color: string; description: string }>;
381 suggestedGatesConfig?: string;
382 firstCommitSuggestions?: string[];
383 }>(text);
384
385 if (parsed) {
386 return {
387 detectedLanguage: language,
388 detectedFramework: framework,
389 suggestedReadme: parsed.suggestedReadme ?? buildFallbackReadme(repoName, language, framework),
390 suggestedLabels: parsed.suggestedLabels ?? defaultLabels,
391 suggestedGatesConfig: parsed.suggestedGatesConfig ?? defaultGatesConfig,
392 firstCommitSuggestions: parsed.firstCommitSuggestions ?? defaultSuggestions,
393 };
394 }
395 } catch (err) {
396 console.warn("[repo-onboarding] AI generation failed:", err instanceof Error ? err.message : err);
397 }
398
399 // AI failed — fall back to defaults
400 return {
401 detectedLanguage: language,
402 detectedFramework: framework,
403 suggestedReadme: existingReadme ?? buildFallbackReadme(repoName, language, framework),
404 suggestedLabels: defaultLabels,
405 suggestedGatesConfig: defaultGatesConfig,
406 firstCommitSuggestions: defaultSuggestions,
407 };
408}
409
410function buildFallbackReadme(repoName: string, language: string, framework?: string): string {
411 const stack = framework ? `${language} / ${framework}` : language;
412 return `# ${repoName}
413
414> A ${stack} project.
415
416## About
417
418TODO: Describe what this project does and why it exists.
419
420## Installation
421
422\`\`\`bash
423# TODO: Add installation steps
424\`\`\`
425
426## Usage
427
428\`\`\`bash
429# TODO: Add usage examples
430\`\`\`
431
432## Contributing
433
4341. Fork the repository
4352. Create a feature branch (\`git checkout -b feat/my-feature\`)
4363. Commit your changes
4374. Push and open a pull request
438
439## License
440
441TODO: Add license
442`;
443}
444
445// ---------------------------------------------------------------------------
446// Idempotent store / retrieve
447// ---------------------------------------------------------------------------
448
449/**
450 * ensureRepoOnboarding — called fire-and-forget on first push.
451 * Generates onboarding data and stores it if not already present.
452 * Never throws.
453 */
454export async function ensureRepoOnboarding(
455 repoId: string,
456 ownerName: string,
457 repoName: string
458): Promise<void> {
459 try {
460 // Check if already generated
461 const [existing] = await db
462 .select({ repositoryId: repoOnboardingData.repositoryId })
463 .from(repoOnboardingData)
464 .where(eq(repoOnboardingData.repositoryId, repoId))
465 .limit(1);
466 if (existing) return; // already generated
467
468 const onboarding = await generateRepoOnboarding(repoId, ownerName, repoName);
469
470 await db
471 .insert(repoOnboardingData)
472 .values({
473 repositoryId: repoId,
474 detectedLanguage: onboarding.detectedLanguage,
475 detectedFramework: onboarding.detectedFramework ?? null,
476 suggestedReadme: onboarding.suggestedReadme,
477 suggestedLabels: onboarding.suggestedLabels,
478 suggestedGatesConfig: onboarding.suggestedGatesConfig,
479 firstCommitSuggestions: onboarding.firstCommitSuggestions,
480 })
481 .onConflictDoNothing();
482
483 console.log(
484 `[repo-onboarding] generated for ${ownerName}/${repoName}: ${onboarding.detectedLanguage}${onboarding.detectedFramework ? ` / ${onboarding.detectedFramework}` : ""}`
485 );
486 } catch (err) {
487 console.warn(
488 `[repo-onboarding] ensureRepoOnboarding failed for ${ownerName}/${repoName}:`,
489 err instanceof Error ? err.message : err
490 );
491 }
492}