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.
| 641aa42 | 1 | /** |
| 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 | ||
| 17 | import { eq } from "drizzle-orm"; | |
| 18 | import { db } from "../db"; | |
| 19 | import { repositories, repoOnboardingData } from "../db/schema"; | |
| 20 | import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client"; | |
| 21 | import { getRepoPath } from "../git/repository"; | |
| 22 | ||
| 23 | // --------------------------------------------------------------------------- | |
| 24 | // Types | |
| 25 | // --------------------------------------------------------------------------- | |
| 26 | ||
| 27 | export 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 | ||
| 40 | function 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 | ||
| 62 | async 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 | ||
| 135 | async 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 | ||
| 150 | async 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 | ||
| 173 | const 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 | ||
| 182 | const 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 | ||
| 201 | function 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 | ||
| 212 | function buildDefaultGatesConfig(language: string, framework?: string): string { | |
| 213 | const lang = language.toLowerCase(); | |
| 214 | if (lang === "typescript" || lang === "javascript") { | |
| 215 | return `version: 1 | |
| 216 | gates: | |
| 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 | |
| 230 | gates: | |
| 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 | |
| 244 | gates: | |
| 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 | |
| 255 | gates: | |
| 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 | |
| 268 | gates: | |
| 269 | - id: test | |
| 270 | run: echo "Add your test command here" | |
| 271 | on: push | |
| 272 | `; | |
| 273 | } | |
| 274 | ||
| 275 | // --------------------------------------------------------------------------- | |
| 276 | // First-commit suggestions | |
| 277 | // --------------------------------------------------------------------------- | |
| 278 | ||
| 279 | function 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 | ||
| 309 | export 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 | ||
| 350 | File tree (if any): | |
| 351 | ${fileTree || "(empty — no commits yet)"} | |
| 352 | ||
| 353 | Existing README (if any): | |
| 354 | ${readmeContext} | |
| 355 | ||
| 356 | Return 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 | ||
| 364 | Rules: | |
| 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 | ||
| 410 | function 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 | ||
| 418 | TODO: 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 | ||
| 434 | 1. Fork the repository | |
| 435 | 2. Create a feature branch (\`git checkout -b feat/my-feature\`) | |
| 436 | 3. Commit your changes | |
| 437 | 4. Push and open a pull request | |
| 438 | ||
| 439 | ## License | |
| 440 | ||
| 441 | TODO: 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 | */ | |
| 454 | export 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 | } |