diff --git a/README.md b/README.md index 7e9ba6f..a2ff3cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ ![devcontext logo](public/logo.png) -# devcontext Build high-signal agent and instruction files from community-proven best practices. diff --git a/app/api/scan-repo/route.ts b/app/api/scan-repo/route.ts new file mode 100644 index 0000000..b4408a4 --- /dev/null +++ b/app/api/scan-repo/route.ts @@ -0,0 +1,787 @@ +import { NextRequest, NextResponse } from "next/server" + +import type { + RepoScanErrorResponse, + RepoScanResponse, + RepoScanSummary, + RepoStructureSummary, +} from "@/types/repo-scan" + +const GITHUB_API_BASE_URL = "https://api.github.com" +const GITHUB_HOSTNAMES = new Set(["github.com", "www.github.com"]) + +const JSON_HEADERS = { + Accept: "application/vnd.github+json", +} + +interface GitHubTreeItem { + path: string + type: "blob" | "tree" | string +} + +interface PackageJson { + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + optionalDependencies?: Record + engines?: { node?: string } + workspaces?: string[] | { packages?: string[] } +} + +const dependencyHas = (pkg: PackageJson, names: string[]): boolean => { + const sources = [ + pkg.dependencies, + pkg.devDependencies, + pkg.peerDependencies, + pkg.optionalDependencies, + ] + + return sources.some((source) => + source ? names.some((name) => Object.prototype.hasOwnProperty.call(source, name)) : false, + ) +} + +const isNullishOrEmpty = (value: unknown): value is null | undefined | "" => value === null || value === undefined || value === "" + +const extractRateLimitRemaining = (response: Response): number | null => { + const header = response.headers.get("x-ratelimit-remaining") + + if (header === null) { + return null + } + + const value = Number.parseInt(header, 10) + + return Number.isNaN(value) ? null : value +} + +const parseGitHubRepo = (input: string | null): { owner: string; repo: string } | null => { + if (!input) { + return null + } + + const trimmed = input.trim() + + if (trimmed === "") { + return null + } + + try { + const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://github.com/${trimmed}`) + + if (!GITHUB_HOSTNAMES.has(url.hostname.toLowerCase())) { + return null + } + + const [owner, repo] = url.pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean) + + if (!owner || !repo) { + return null + } + + return { + owner, + repo: repo.replace(/\.git$/i, ""), + } + } catch (error) { + console.error("Failed to parse GitHub repository URL", error) + return null + } +} + +const dedupeAndSort = (values: Iterable): string[] => Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)) + +const detectStructure = (paths: string[]): RepoStructureSummary => { + const lowerCasePaths = paths.map((path) => path.toLowerCase()) + + const hasPrefix = (prefix: string) => lowerCasePaths.some((path) => path.startsWith(prefix)) + + return { + src: hasPrefix("src/"), + components: hasPrefix("components/"), + tests: + hasPrefix("tests/") || + hasPrefix("test/") || + hasPrefix("__tests__/") || + lowerCasePaths.some((path) => path.includes("/__tests__/")), + apps: hasPrefix("apps/"), + packages: hasPrefix("packages/"), + } +} + +const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: string[]; testing: string[]; frameworks: string[] } => { + const tooling = new Set() + const testing = new Set() + const frameworks = new Set() + + const lowerCasePaths = paths.map((path) => path.toLowerCase()) + + const matchers: Array<{ pattern: RegExp; value: string; target: Set }> = [ + { pattern: /^requirements\.txt$/, value: "pip", target: tooling }, + { pattern: /^pyproject\.toml$/, value: "Poetry", target: tooling }, + { pattern: /pom\.xml$/, value: "Maven", target: tooling }, + { pattern: /build\.gradle(\.kts)?$/, value: "Gradle", target: tooling }, + { pattern: /(^|\/)dockerfile$/, value: "Docker", target: tooling }, + { pattern: /(^|\/)docker-compose\.ya?ml$/, value: "Docker Compose", target: tooling }, + { pattern: /(^|\/)compose\.ya?ml$/, value: "Docker Compose", target: tooling }, + { pattern: /^tsconfig\.json$/, value: "TypeScript", target: tooling }, + { pattern: /(^|\/)eslint\.config\.(js|cjs|mjs|ts|tsx)?$/, value: "ESLint", target: tooling }, + { pattern: /(^|\/)\.eslintrc(\.[a-z]+)?$/, value: "ESLint", target: tooling }, + { pattern: /(^|\/)prettier\.config\.(js|cjs|mjs|ts)?$/, value: "Prettier", target: tooling }, + { pattern: /(^|\/)\.prettierrc(\.[a-z]+)?$/, value: "Prettier", target: tooling }, + { pattern: /(^|\/)\.babelrc(\.[a-z]+)?$/, value: "Babel", target: tooling }, + { pattern: /babel\.config\.(js|cjs|mjs|ts)?$/, value: "Babel", target: tooling }, + { pattern: /webpack\.config\.(js|cjs|mjs|ts)?$/, value: "Webpack", target: tooling }, + { pattern: /vite\.config\.(js|cjs|mjs|ts)?$/, value: "Vite", target: tooling }, + { pattern: /rollup\.config\.(js|cjs|mjs|ts)?$/, value: "Rollup", target: tooling }, + { pattern: /tailwind\.config\.(js|cjs|mjs|ts)?$/, value: "Tailwind CSS", target: tooling }, + { pattern: /jest\.config\.(js|cjs|mjs|ts|json)?$/, value: "Jest", target: testing }, + { pattern: /vitest\.(config|setup)/, value: "Vitest", target: testing }, + { pattern: /(^|\/)cypress\//, value: "Cypress", target: testing }, + { pattern: /cypress\.config\.(js|cjs|mjs|ts)?$/, value: "Cypress", target: testing }, + { pattern: /playwright\.config\.(js|cjs|mjs|ts)?$/, value: "Playwright", target: testing }, + { pattern: /karma\.conf(\.js)?$/, value: "Karma", target: testing }, + { pattern: /mocha\./, value: "Mocha", target: testing }, + ] + + for (const { pattern, value, target } of matchers) { + if (lowerCasePaths.some((path) => pattern.test(path))) { + target.add(value) + } + } + + if (pkg) { + if (dependencyHas(pkg, ["next", "nextjs"])) { + frameworks.add("Next.js") + } + + if (dependencyHas(pkg, ["react", "react-dom"])) { + frameworks.add("React") + } + + if (dependencyHas(pkg, ["@angular/core"])) { + frameworks.add("Angular") + } + + if (dependencyHas(pkg, ["vue", "vue-router", "@vue/runtime-core"])) { + frameworks.add("Vue") + } + + if (dependencyHas(pkg, ["svelte"])) { + frameworks.add("Svelte") + } + + if (dependencyHas(pkg, ["nuxt", "nuxt3", "@nuxt/kit"])) { + frameworks.add("Nuxt") + } + + if (dependencyHas(pkg, ["gatsby"])) { + frameworks.add("Gatsby") + } + + if (dependencyHas(pkg, ["remix", "@remix-run/node", "@remix-run/react"])) { + frameworks.add("Remix") + } + + if (dependencyHas(pkg, ["@nestjs/common", "@nestjs/core"])) { + frameworks.add("NestJS") + } + + if (dependencyHas(pkg, ["express"])) { + frameworks.add("Express") + } + + if (dependencyHas(pkg, ["@sveltejs/kit"])) { + frameworks.add("SvelteKit") + } + + if (dependencyHas(pkg, ["astro"])) { + frameworks.add("Astro") + } + + if (dependencyHas(pkg, ["solid-js"])) { + frameworks.add("SolidJS") + } + + if (dependencyHas(pkg, ["react-native"])) { + frameworks.add("React Native") + } + + if (dependencyHas(pkg, ["expo"])) { + frameworks.add("Expo") + } + + if (dependencyHas(pkg, ["@storybook/react", "@storybook/nextjs", "@storybook/vue"])) { + tooling.add("Storybook") + } + + if (dependencyHas(pkg, ["eslint"])) { + tooling.add("ESLint") + } + + if (dependencyHas(pkg, ["prettier"])) { + tooling.add("Prettier") + } + + if (dependencyHas(pkg, ["typescript"])) { + tooling.add("TypeScript") + } + + if (dependencyHas(pkg, ["jest", "@types/jest", "ts-jest"])) { + testing.add("Jest") + } + + if (dependencyHas(pkg, ["vitest"])) { + testing.add("Vitest") + } + + if (dependencyHas(pkg, ["cypress"])) { + testing.add("Cypress") + } + + if (dependencyHas(pkg, ["@playwright/test"])) { + testing.add("Playwright") + } + + if (dependencyHas(pkg, ["mocha"])) { + testing.add("Mocha") + } + + if (dependencyHas(pkg, ["ava"])) { + testing.add("AVA") + } + + if (dependencyHas(pkg, ["@testing-library/react", "@testing-library/dom"])) { + testing.add("Testing Library") + } + } + + return { + tooling: dedupeAndSort(tooling), + testing: dedupeAndSort(testing), + frameworks: dedupeAndSort(frameworks), + } +} + +const readPackageJson = async ( + owner: string, + repo: string, + ref: string, + headers: Record, +): Promise => { + try { + const packageResponse = await fetch( + `${GITHUB_API_BASE_URL}/repos/${owner}/${repo}/contents/package.json?ref=${encodeURIComponent(ref)}`, + { + headers, + cache: "no-store", + }, + ) + + if (!packageResponse.ok) { + return null + } + + const payload = (await packageResponse.json()) as { content?: string; encoding?: string } + + if (!payload.content) { + return null + } + + const encoding = payload.encoding || "base64" + const decoded = Buffer.from(payload.content, encoding as BufferEncoding).toString("utf8") + + return JSON.parse(decoded) as PackageJson + } catch (error) { + console.error("Failed to read package.json", error) + return null + } +} + +const collectPathsFromTree = (items: GitHubTreeItem[]): string[] => + items.filter((item) => item.type === "blob").map((item) => item.path) + +const readTextFile = async ( + owner: string, + repo: string, + ref: string, + filePath: string, + headers: Record, +): Promise => { + try { + const res = await fetch( + `${GITHUB_API_BASE_URL}/repos/${owner}/${repo}/contents/${encodeURIComponent(filePath)}?ref=${encodeURIComponent(ref)}`, + { headers, cache: "no-store" }, + ) + if (!res.ok) return null + const payload = (await res.json()) as { content?: string; encoding?: string } + if (!payload.content) return null + const encoding = (payload.encoding || "base64") as BufferEncoding + return Buffer.from(payload.content, encoding).toString("utf8").trim() + } catch { + return null + } +} + +type FileStyleKey = "pascal" | "camel" | "kebab" | "snake" + +const stripExtension = (name: string) => name.replace(/\.[^.]+$/u, "") + +const sanitizeBaseName = (name: string) => { + const withoutExtension = stripExtension(name) + const segments = withoutExtension.split(".") + const candidate = segments[0] ?? "" + return candidate +} + +const classifyNameStyle = (rawName: string): FileStyleKey | null => { + const name = rawName.trim() + if (!name) { + return null + } + + if (/^[a-z0-9]+(?:-[a-z0-9]+)+$/u.test(name)) { + return "kebab" + } + + if (/^[a-z0-9]+(?:_[a-z0-9]+)+$/u.test(name)) { + return "snake" + } + + if (/^[A-Z][A-Za-z0-9]+$/u.test(name)) { + return "pascal" + } + + if (/^[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*$/u.test(name)) { + return "camel" + } + + return null +} + +const pickDominantStyle = (counts: Record): FileStyleKey | null => { + let winner: FileStyleKey | null = null + let winnerCount = 0 + + for (const key of Object.keys(counts) as FileStyleKey[]) { + const value = counts[key] + if (value > winnerCount) { + winner = key + winnerCount = value + } + } + + return winnerCount > 0 ? winner : null +} + +const analyzeNamingStyles = (paths: string[]) => { + const fileCounts: Record = { pascal: 0, camel: 0, kebab: 0, snake: 0 } + const componentCounts: Record = { pascal: 0, camel: 0, kebab: 0, snake: 0 } + + const componentDirPattern = /(^|\/(src|app|packages)\/)?.*\b(components?|ui|shared)\b\//i + + paths.forEach((path) => { + const filename = path.split("/").pop() + if (!filename || filename.startsWith(".")) { + return + } + + const baseName = sanitizeBaseName(filename) + if (!baseName) { + return + } + + if (["index", "page", "layout", "route", "default", "middleware"].includes(baseName.toLowerCase())) { + return + } + + const cleanName = baseName.replace(/-(test|spec|stories)$/iu, "").replace(/(test|spec|stories)$/iu, "") + const style = classifyNameStyle(cleanName) + if (!style) { + return + } + + const lowerPath = path.toLowerCase() + const extension = lowerPath.split(".").pop() ?? "" + const isCodeFile = /^(ts|tsx|js|jsx|mjs|cjs)$/u.test(extension) + const isStyleFile = /^(css|scss|sass|less|styl)$/u.test(extension) + + if (isCodeFile || isStyleFile) { + fileCounts[style] += 1 + } + + if ((/^(tsx|jsx)$/u.test(extension) || componentDirPattern.test(lowerPath))) { + componentCounts[style] += 1 + } + }) + + const styleMapping: Record = { + kebab: "kebab-case", + snake: "snake_case", + pascal: "PascalCase", + camel: "camelCase", + } + + const defaultComponentMapping: Record = { + kebab: "camelCase", + snake: "camelCase", + pascal: "PascalCase", + camel: "camelCase", + } + + const dominantFileStyle = pickDominantStyle(fileCounts) + const dominantComponentStyle = pickDominantStyle(componentCounts) + + return { + fileNamingStyle: dominantFileStyle ? styleMapping[dominantFileStyle] : null, + componentNamingStyle: dominantComponentStyle + ? styleMapping[dominantComponentStyle] + : dominantFileStyle + ? defaultComponentMapping[dominantFileStyle] + : null, + } +} + +const detectEnrichedSignals = async ( + owner: string, + repo: string, + ref: string, + paths: string[], + pkg: PackageJson | null, + headers: Record, +) => { + const lower = paths.map((p) => p.toLowerCase()) + + const hasExact = (p: string) => lower.includes(p.toLowerCase()) + const hasPrefix = (prefix: string) => lower.some((p) => p.startsWith(prefix.toLowerCase())) + const hasMatch = (re: RegExp) => lower.some((p) => re.test(p)) + + // Package manager + const packageManager = hasExact("pnpm-lock.yaml") + ? "pnpm" + : hasExact("yarn.lock") + ? "yarn" + : hasExact("bun.lockb") + ? "bun" + : hasExact("package-lock.json") || hasExact("npm-shrinkwrap.json") + ? "npm" + : null + + // Node version + let nodeVersion: string | null = pkg?.engines?.node ?? null + if (!nodeVersion && hasExact(".nvmrc")) { + nodeVersion = (await readTextFile(owner, repo, ref, ".nvmrc", headers)) || null + } + if (!nodeVersion && hasExact(".node-version")) { + nodeVersion = (await readTextFile(owner, repo, ref, ".node-version", headers)) || null + } + + // Monorepo/workspaces + const monorepo = hasExact("pnpm-workspace.yaml") || hasExact("turbo.json") || hasExact("nx.json") || hasExact("lerna.json") || hasPrefix("apps/") || hasPrefix("packages/") + let workspaces: string[] | undefined + if (pkg?.workspaces) { + workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages + } + + // Routing (Next.js) + const routing: RepoScanSummary["routing"] = hasPrefix("app/") && hasPrefix("pages/") + ? "hybrid" + : hasPrefix("app/") + ? "app" + : hasPrefix("pages/") + ? "pages" + : null + + // Styling + const styling = hasMatch(/tailwind\.config\.(js|cjs|mjs|ts)$/) ? "tailwind" + : hasMatch(/styled(-|)components|styled-components/) || (pkg && dependencyHas(pkg, ["styled-components"])) ? "styled-components" + : hasMatch(/\.module\.(css|scss|sass)$/) ? "cssmodules" + : null + + // State mgmt + const stateMgmt = pkg && (dependencyHas(pkg, ["zustand"]) ? "zustand" + : dependencyHas(pkg, ["@reduxjs/toolkit", "redux"]) ? "redux" + : dependencyHas(pkg, ["recoil"]) ? "recoil" + : dependencyHas(pkg, ["jotai"]) ? "jotai" + : null) + + // Data fetching + const dataFetching = pkg && (dependencyHas(pkg, ["@tanstack/react-query"]) ? "react-query" + : dependencyHas(pkg, ["swr"]) ? "swr" + : null) + + // Auth + const auth = pkg && (dependencyHas(pkg, ["next-auth"]) ? "next-auth" + : dependencyHas(pkg, ["@clerk/nextjs"]) ? "clerk" + : dependencyHas(pkg, ["@auth0/nextjs-auth0"]) ? "auth0" + : null) + + // Validation + const validation = pkg && (dependencyHas(pkg, ["zod"]) ? "zod" + : dependencyHas(pkg, ["yup"]) ? "yup" + : dependencyHas(pkg, ["ajv"]) ? "ajv" + : null) + + // Logging + const logging = pkg && (dependencyHas(pkg, ["@sentry/nextjs", "@sentry/node"]) ? "sentry" + : dependencyHas(pkg, ["pino"]) ? "pino" + : dependencyHas(pkg, ["winston"]) ? "winston" + : hasExact("vercel.json") || hasPrefix(".vercel/") ? "vercel-observability" + : null) + + // CI/CD + const ci: string[] = [] + if (hasPrefix(".github/workflows/")) ci.push("GitHub Actions") + if (hasExact("vercel.json") || hasPrefix(".vercel/")) ci.push("Vercel") + if (hasExact("netlify.toml")) ci.push("Netlify") + if (hasPrefix(".circleci/")) ci.push("CircleCI") + if (hasExact(".gitlab-ci.yml")) ci.push("GitLab CI") + if (hasExact("azure-pipelines.yml")) ci.push("Azure Pipelines") + + // Code Quality / releases + const codeQuality: string[] = [] + if (hasPrefix(".husky/")) codeQuality.push("husky") + if (pkg && dependencyHas(pkg, ["husky"])) codeQuality.push("husky") + if (hasMatch(/^commitlint\.config\./) || (pkg && dependencyHas(pkg, ["@commitlint/cli", "@commitlint/config-conventional"])) ) codeQuality.push("commitlint") + if (hasMatch(/^\.lintstagedrc/) || (pkg && dependencyHas(pkg, ["lint-staged"])) ) codeQuality.push("lint-staged") + if (pkg && dependencyHas(pkg, ["semantic-release"])) codeQuality.push("semantic-release") + if (pkg && (dependencyHas(pkg, ["@changesets/cli"]) || hasPrefix(".changeset/"))) codeQuality.push("changesets") + + // Editor + const editor: string[] = [] + if (hasExact(".editorconfig")) editor.push("editorconfig") + if (hasMatch(/(^|\/)eslint\.config\.(js|cjs|mjs|ts|tsx)?$/) || hasMatch(/(^|\/)\.eslintrc(\.[a-z]+)?$/)) editor.push("eslint") + if (hasMatch(/(^|\/)prettier\.config\.(js|cjs|mjs|ts)?$/) || hasMatch(/(^|\/)\.prettierrc(\.[a-z]+)?$/)) editor.push("prettier") + + const { fileNamingStyle, componentNamingStyle } = analyzeNamingStyles(paths) + + // Code style detection (ESLint presets) + let codeStylePreference: string | null = null + if (pkg) { + if (dependencyHas(pkg, ["eslint-config-airbnb", "eslint-config-airbnb-base"])) { + codeStylePreference = "airbnb" + } else if (dependencyHas(pkg, ["eslint-config-standard"])) { + codeStylePreference = "standardjs" + } + } + + if (!codeStylePreference) { + const eslintPath = paths.find((p) => /eslint\.(config\.(js|cjs|mjs|ts|json)|json)$/iu.test(p) || /\.eslintrc(\.[a-z]+)?$/iu.test(p)) + if (eslintPath) { + const contents = await readTextFile(owner, repo, ref, eslintPath, headers) + if (contents) { + if (/airbnb/iu.test(contents)) { + codeStylePreference = "airbnb" + } else if (/standard/iu.test(contents)) { + codeStylePreference = "standardjs" + } + } + } + } + + // Commit message conventions + let commitMessageStyle: string | null = null + const hasGitmoji = hasExact(".gitmojirc") || hasExact("gitmoji.config.js") || hasExact("gitmoji.config.cjs") || (pkg && dependencyHas(pkg, ["gitmoji", "gitmoji-cli"])) + if (hasGitmoji) { + commitMessageStyle = "gitmoji" + } else if (codeQuality.some((value) => ["commitlint", "semantic-release", "changesets"].includes(value))) { + commitMessageStyle = "conventional" + } + + return { + packageManager, + nodeVersion: nodeVersion || null, + monorepo: Boolean(monorepo), + workspaces, + routing, + styling, + stateMgmt: stateMgmt || null, + dataFetching: dataFetching || null, + auth: auth || null, + validation: validation || null, + logging: logging || null, + ci, + codeQuality, + editor, + fileNamingStyle, + componentNamingStyle, + codeStylePreference, + commitMessageStyle, + } +} + +export async function GET(request: NextRequest): Promise> { + const { searchParams } = new URL(request.url) + const repoUrl = searchParams.get("url") + + const parsed = parseGitHubRepo(repoUrl) + + if (!parsed) { + return NextResponse.json({ error: "Invalid GitHub repository URL." }, { status: 400 }) + } + + const token = process.env.GITHUB_TOKEN + + if (isNullishOrEmpty(token)) { + return NextResponse.json( + { error: "GitHub token is not configured on the server." }, + { status: 500 }, + ) + } + + const headers: Record = { + ...JSON_HEADERS, + Authorization: `Bearer ${token}`, + "User-Agent": "DevContext-Repo-Scanner", + } + + const { owner, repo } = parsed + + try { + const warnings: string[] = [] + let lowestRateLimit: number | null = null + + const repoResponse = await fetch(`${GITHUB_API_BASE_URL}/repos/${owner}/${repo}`, { + headers, + cache: "no-store", + }) + + if (repoResponse.status === 404) { + return NextResponse.json({ error: "Repository not found." }, { status: 404 }) + } + + if (repoResponse.status === 403) { + return NextResponse.json({ error: "Access to this repository is forbidden." }, { status: 403 }) + } + + if (!repoResponse.ok) { + return NextResponse.json( + { error: `Failed to fetch repository metadata (status ${repoResponse.status}).` }, + { status: repoResponse.status }, + ) + } + + lowestRateLimit = extractRateLimitRemaining(repoResponse) + + const repoJson = (await repoResponse.json()) as { + default_branch?: string + language?: string + topics?: string[] + } + + const defaultBranch = repoJson.default_branch ?? "main" + + const languagesResponse = await fetch(`${GITHUB_API_BASE_URL}/repos/${owner}/${repo}/languages`, { + headers, + cache: "no-store", + }) + + if (languagesResponse.status === 403) { + return NextResponse.json({ error: "Access forbidden while fetching languages." }, { status: 403 }) + } + + if (!languagesResponse.ok) { + return NextResponse.json( + { error: `Failed to fetch repository languages (status ${languagesResponse.status}).` }, + { status: languagesResponse.status }, + ) + } + + const languagesRemaining = extractRateLimitRemaining(languagesResponse) + + if (typeof languagesRemaining === "number") { + if (lowestRateLimit === null || languagesRemaining < lowestRateLimit) { + lowestRateLimit = languagesRemaining + } + } + + const languagesJson = (await languagesResponse.json()) as Record + const languages = Object.entries(languagesJson) + .sort(([, bytesA], [, bytesB]) => bytesB - bytesA) + .map(([name]) => name) + + const treeResponse = await fetch( + `${GITHUB_API_BASE_URL}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(defaultBranch)}?recursive=1`, + { + headers, + cache: "no-store", + }, + ) + + if (treeResponse.status === 404) { + return NextResponse.json( + { error: "Repository tree could not be retrieved." }, + { status: 404 }, + ) + } + + if (treeResponse.status === 403) { + return NextResponse.json( + { error: "Access forbidden while fetching repository tree." }, + { status: 403 }, + ) + } + + if (!treeResponse.ok) { + return NextResponse.json( + { error: `Failed to fetch repository tree (status ${treeResponse.status}).` }, + { status: treeResponse.status }, + ) + } + + const treeRemaining = extractRateLimitRemaining(treeResponse) + + if (typeof treeRemaining === "number") { + if (lowestRateLimit === null || treeRemaining < lowestRateLimit) { + lowestRateLimit = treeRemaining + } + } + + const treeJson = (await treeResponse.json()) as { tree: GitHubTreeItem[]; truncated?: boolean } + + if (treeJson.truncated) { + warnings.push("GitHub truncated the repository tree; results may be incomplete.") + } + + const paths = collectPathsFromTree(treeJson.tree) + const structure = detectStructure(paths) + + const hasPackageJson = paths.some((path) => path.toLowerCase() === "package.json") + + const packageJson = hasPackageJson ? await readPackageJson(owner, repo, defaultBranch, headers) : null + + const { tooling, testing, frameworks } = detectTooling(paths, packageJson) + + if (lowestRateLimit !== null && lowestRateLimit < 5) { + warnings.push(`GitHub API rate limit is low (remaining: ${lowestRateLimit}).`) + } + + const enriched = await detectEnrichedSignals(owner, repo, defaultBranch, paths, packageJson, headers) + + const summary: RepoScanSummary = { + repo: `${owner}/${repo}`, + defaultBranch, + language: repoJson.language ?? (languages.length > 0 ? languages[0] : null), + languages: dedupeAndSort(languages), + frameworks, + tooling, + testing, + structure, + topics: repoJson.topics ? dedupeAndSort(repoJson.topics) : [], + warnings, + ...enriched, + } + + return NextResponse.json(summary) + } catch (error) { + console.error("Unexpected error while scanning repository", error) + + return NextResponse.json( + { error: "Unexpected error while scanning repository." }, + { status: 500 }, + ) + } +} diff --git a/app/existing/[repoUrl]/page.tsx b/app/existing/[repoUrl]/page.tsx new file mode 100644 index 0000000..7c9a7ab --- /dev/null +++ b/app/existing/[repoUrl]/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next" + +import RepoScanClient from "./repo-scan-client" + +import { decodeRepoRouteParam, normalizeGitHubRepoInput } from "@/lib/github" +import { absoluteUrl } from "@/lib/site-metadata" +import type { RepoScanRouteParams } from "@/types/repo-scan" + +type RepoScanPageProps = { + params: RepoScanRouteParams +} + +const toSlug = (repoUrl: string) => repoUrl.replace(/^https:\/\/github.com\//, "") + +export function generateMetadata({ params }: RepoScanPageProps): Metadata { + const decoded = decodeRepoRouteParam(params.repoUrl) + const normalized = decoded ? normalizeGitHubRepoInput(decoded) ?? decoded : null + const repoSlug = normalized ? toSlug(normalized) : null + const title = repoSlug ? `Repo scan · ${repoSlug}` : "Repo scan" + const description = repoSlug + ? `Analyze ${repoSlug} to pre-fill DevContext instructions with detected stack, tooling, and testing.` + : "Analyze a GitHub repository to pre-fill DevContext instructions with detected stack, tooling, and testing." + const canonicalPath = normalized ? `/existing/${encodeURIComponent(normalized)}` : "/existing" + const canonicalUrl = absoluteUrl(canonicalPath) + + return { + title, + description, + alternates: { + canonical: canonicalUrl, + }, + robots: { + index: false, + follow: true, + }, + openGraph: { + title, + description, + url: canonicalUrl, + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + description, + }, + } +} + +export default function RepoScanPage({ params }: RepoScanPageProps) { + const decoded = decodeRepoRouteParam(params.repoUrl) + const normalized = decoded ? normalizeGitHubRepoInput(decoded) ?? decoded : null + + return +} diff --git a/app/existing/[repoUrl]/repo-scan-client.tsx b/app/existing/[repoUrl]/repo-scan-client.tsx new file mode 100644 index 0000000..53c2925 --- /dev/null +++ b/app/existing/[repoUrl]/repo-scan-client.tsx @@ -0,0 +1,346 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" + +import { AlertTriangle, CheckCircle2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { normalizeGitHubRepoInput } from "@/lib/github" +import type { RepoScanSummary } from "@/types/repo-scan" +import { getFileOptions } from "@/lib/wizard-config" +import { generateFromRepoScan } from "@/lib/scan-generate" +import { prefillWizardFromScan } from "@/lib/scan-prefill" +import FinalOutputView from "@/components/final-output-view" +import RepoScanLoader from "@/components/repo-scan-loader" +import type { GeneratedFileResult } from "@/types/output" + +const buildQuery = (url: string) => `/api/scan-repo?url=${encodeURIComponent(url)}` + +const formatList = (values: string[]) => (values.length > 0 ? values.join(", ") : "Not detected") + +const toSlug = (repoUrl: string) => repoUrl.replace(/^https:\/\/github.com\//, "") + +type RepoScanClientProps = { + initialRepoUrl: string | null +} + +export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps) { + const router = useRouter() + const [scanResult, setScanResult] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [hasConfirmed, setHasConfirmed] = useState(false) + const [scanToken, setScanToken] = useState(0) + + const repoUrlForScan = useMemo(() => { + if (!initialRepoUrl) { + return null + } + + return normalizeGitHubRepoInput(initialRepoUrl) ?? initialRepoUrl + }, [initialRepoUrl]) + + useEffect(() => { + setHasConfirmed(false) + setScanResult(null) + setIsLoading(false) + setScanToken(0) + + if (!repoUrlForScan) { + setError("The repository URL could not be decoded. Please check the link and try again.") + return + } + + setError(null) + }, [repoUrlForScan]) + + useEffect(() => { + if (!repoUrlForScan || scanToken === 0) { + return + } + + const controller = new AbortController() + + setIsLoading(true) + setScanResult(null) + setError(null) + + fetch(buildQuery(repoUrlForScan), { signal: controller.signal }) + .then(async (response) => { + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null + const message = payload?.error ?? "This repository is private or could not be scanned." + throw new Error(message) + } + + return (await response.json()) as RepoScanSummary + }) + .then((data) => { + setScanResult(data) + }) + .catch((fetchError) => { + if ((fetchError as Error).name === "AbortError") { + return + } + + setError((fetchError as Error).message) + }) + .finally(() => { + setIsLoading(false) + }) + + return () => { + controller.abort() + } + }, [repoUrlForScan, scanToken]) + + const structureEntries = useMemo(() => { + if (!scanResult) { + return [] + } + + return Object.entries(scanResult.structure).map(([key, value]) => ({ + key, + value, + })) + }, [scanResult]) + + const handleStartScan = () => { + if (!repoUrlForScan) { + return + } + + setHasConfirmed(true) + setScanToken((token) => token + 1) + } + + const handleRetryScan = () => { + if (!repoUrlForScan) { + return + } + + setScanToken((token) => token + 1) + } + + const warnings = scanResult?.warnings ?? [] + const repoSlug = repoUrlForScan ? toSlug(repoUrlForScan) : null + const promptVisible = Boolean(repoUrlForScan && !hasConfirmed && !isLoading && !scanResult && !error) + const canRetry = hasConfirmed && !isLoading + const decodeFailed = repoUrlForScan === null + + // Generation state + const fileOptions = getFileOptions() + const [isGeneratingMap, setIsGeneratingMap] = useState>({}) + const [generatedFile, setGeneratedFile] = useState(null) + const [isPrefilling, setIsPrefilling] = useState(false) + const [prefillError, setPrefillError] = useState(null) + + const handleGenerate = useCallback(async (fileId: string) => { + if (!scanResult) return + setIsGeneratingMap((prev) => ({ ...prev, [fileId]: true })) + setGeneratedFile(null) + try { + const result = await generateFromRepoScan(scanResult, fileId as any) + if (result) setGeneratedFile(result) + } catch (e) { + console.error('Failed to generate from scan', e) + } finally { + setIsGeneratingMap((prev) => ({ ...prev, [fileId]: false })) + } + }, [scanResult]) + + const handlePrefillWizard = useCallback(async () => { + if (!scanResult) { + return + } + + setIsPrefilling(true) + setPrefillError(null) + + try { + const { stackId } = await prefillWizardFromScan(scanResult) + router.push(`/new/stack/${stackId}/user/summary`) + } catch (prefillProblem) { + console.error("Failed to prefill wizard from scan", prefillProblem) + setPrefillError("We couldn't prefill the wizard. Try again or open the wizard manually.") + } finally { + setIsPrefilling(false) + } + }, [scanResult, router]) + + return ( +
+ + ) +} diff --git a/app/existing/existing-repo-entry-client.tsx b/app/existing/existing-repo-entry-client.tsx new file mode 100644 index 0000000..9aac144 --- /dev/null +++ b/app/existing/existing-repo-entry-client.tsx @@ -0,0 +1,90 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { normalizeGitHubRepoInput } from "@/lib/github" + +export function ExistingRepoEntryClient() { + const router = useRouter() + const [value, setValue] = useState("") + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault() + + const normalized = normalizeGitHubRepoInput(value) + + if (!normalized) { + setError("Enter a valid public GitHub repository URL (e.g. https://github.com/owner/repo).") + return + } + + setError(null) + setIsSubmitting(true) + + const encoded = encodeURIComponent(normalized) + + router.push(`/existing/${encoded}`) + } + + return ( +
+ + ) +} diff --git a/app/existing/page.tsx b/app/existing/page.tsx index 9f33e09..f0759cc 100644 --- a/app/existing/page.tsx +++ b/app/existing/page.tsx @@ -1,21 +1,32 @@ -import Link from "next/link" +import type { Metadata } from "next" -import { Button } from "@/components/ui/button" +import { absoluteUrl } from "@/lib/site-metadata" +import { ExistingRepoEntryClient } from "./existing-repo-entry-client" -export default function ExistingProjectPage() { - return ( -
-
-
-

Existing projects are coming soon

-

- We're crafting guided flows to ingest your current instructions, audit gaps, and align new guidance with your repository. Leave your email in the wizard and we'll reach out the moment it's live. -

-
- -
-
- ) +const title = "Analyze an existing repository | DevContext" +const description = + "Scan a GitHub repository to auto-detect languages, frameworks, tooling, and testing so DevContext can prefill your AI instructions." +const canonicalUrl = absoluteUrl("/existing") + +export const metadata: Metadata = { + title, + description, + alternates: { + canonical: canonicalUrl, + }, + openGraph: { + title, + description, + url: canonicalUrl, + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + description, + }, +} + +export default function ExistingRepoEntryPage() { + return } diff --git a/app/globals.css b/app/globals.css index 6729170..675227c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,8 +1,7 @@ -@import "tailwindcss/preflight"; +@import "tailwindcss"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); -@tailwind utilities; :root { --background: oklch(0.9383 0.0042 236.4993); @@ -192,4 +191,122 @@ @apply bg-background text-foreground; letter-spacing: var(--tracking-normal); } -} \ No newline at end of file +} +@layer components { + .repo-scan-loader { + @apply flex flex-col items-center gap-4 text-sm text-muted-foreground; + } + + .repo-scan-loader__orb { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 6rem; + height: 6rem; + filter: drop-shadow(0 0 12px color-mix(in srgb, var(--color-primary) 40%, transparent)); + } + + .repo-scan-loader__core { + position: relative; + width: 2.75rem; + height: 2.75rem; + border-radius: 9999px; + background: radial-gradient( + circle at 35% 30%, + color-mix(in srgb, var(--color-primary) 96%, transparent) 0%, + var(--color-primary) 70%, + color-mix(in srgb, var(--color-primary) 45%, transparent) 100% + ); + box-shadow: 0 0 28px color-mix(in srgb, var(--color-primary) 65%, transparent); + animation: repo-scan-core-glow 2.6s ease-in-out infinite; + } + + .repo-scan-loader__pulse { + position: absolute; + inset: 0; + border-radius: 9999px; + border: 1px solid color-mix(in srgb, var(--color-primary) 60%, transparent); + animation: repo-scan-pulse 2.4s ease-out infinite; + } + + .repo-scan-loader__pulse--delayed { + animation-delay: 0.8s; + } + + .repo-scan-loader__orbit { + position: absolute; + inset: 0; + animation: repo-scan-orbit 3.2s linear infinite; + } + + .repo-scan-loader__satellite { + position: absolute; + top: 0; + left: 50%; + width: 0.9rem; + height: 0.9rem; + border-radius: 9999px; + transform: translate(-50%, -50%); + background: color-mix(in srgb, var(--color-primary) 72%, var(--color-primary-foreground) 28%); + box-shadow: 0 0 16px color-mix(in srgb, var(--color-primary) 60%, transparent); + } + + .repo-scan-loader__satellite--secondary { + top: auto; + bottom: 0; + transform: translate(-50%, 50%); + animation: repo-scan-satellite 3.2s ease-in-out infinite; + } + + .repo-scan-loader__label { + font-size: 0.875rem; + color: var(--color-muted-foreground); + text-align: center; + } +} + +@keyframes repo-scan-pulse { + 0% { + transform: scale(0.65); + opacity: 0.9; + } + 60% { + opacity: 0.4; + } + 100% { + transform: scale(1.4); + opacity: 0; + } +} + +@keyframes repo-scan-orbit { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes repo-scan-core-glow { + 0%, + 100% { + box-shadow: 0 0 20px color-mix(in srgb, var(--color-primary) 55%, transparent); + } + 50% { + box-shadow: 0 0 32px color-mix(in srgb, var(--color-primary) 85%, transparent); + } +} + +@keyframes repo-scan-satellite { + 0%, + 100% { + transform: translate(-50%, 50%) scale(0.9); + opacity: 0.8; + } + 50% { + transform: translate(-50%, 70%) scale(1.1); + opacity: 1; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index f282f22..a5ceef7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -96,9 +96,6 @@ export const metadata: Metadata = { creator: "DevContext", publisher: "DevContext", category: "Technology", - alternates: { - canonical: siteUrl, - }, openGraph: { title: siteTitle, description: siteDescription, diff --git a/app/new/stack/[[...stackSegments]]/page.tsx b/app/new/stack/[[...stackSegments]]/page.tsx index 5b73a5d..935ad4d 100644 --- a/app/new/stack/[[...stackSegments]]/page.tsx +++ b/app/new/stack/[[...stackSegments]]/page.tsx @@ -2,8 +2,9 @@ import { notFound } from "next/navigation" import type { Metadata } from "next" import stacksData from "@/data/stacks.json" -import type { DataQuestionSource } from "@/types/wizard" +import type { DataQuestionSource, WizardStep } from "@/types/wizard" import { StackWizardShell } from "@/components/stack-wizard-shell" +import { loadStackWizardStep } from "@/lib/wizard-config" import { StackWizardClient } from "../stack-wizard-client" import { StackSummaryPage } from "../stack-summary-page" @@ -90,11 +91,14 @@ export async function generateMetadata({ params }: MetadataProps): Promise 0) { const potentialStackId = stackSegments[0] const stackMatch = stackAnswers.find((answer) => answer.value === potentialStackId) @@ -120,12 +124,27 @@ export default function StackRoutePage({ params }: PageProps) { } } + if (stackIdFromRoute && !summaryMode) { + try { + const { step, label } = await loadStackWizardStep(stackIdFromRoute) + initialStackStep = step + initialStackLabel = label + } catch (error) { + console.error(`Unable to preload questions for stack "${stackIdFromRoute}"`, error) + notFound() + } + } + return ( {summaryMode ? ( ) : ( - + )} ) diff --git a/app/new/stack/stack-wizard-client.tsx b/app/new/stack/stack-wizard-client.tsx index 995e085..a3c9fa9 100644 --- a/app/new/stack/stack-wizard-client.tsx +++ b/app/new/stack/stack-wizard-client.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation" import { InstructionsWizard } from "@/components/instructions-wizard" +import type { WizardStep } from "@/types/wizard" const buildStackPath = (stackId?: string | null, view?: "summary" | "default" | "user") => { if (!stackId) { @@ -26,9 +27,15 @@ const buildStackPath = (stackId?: string | null, view?: "summary" | "default" | type StackWizardClientProps = { stackIdFromRoute: string | null + initialStackLabel?: string | null + initialStackStep?: WizardStep | null } -export function StackWizardClient({ stackIdFromRoute }: StackWizardClientProps) { +export function StackWizardClient({ + stackIdFromRoute, + initialStackLabel = null, + initialStackStep = null, +}: StackWizardClientProps) { const router = useRouter() const initialStackId = stackIdFromRoute @@ -51,6 +58,8 @@ export function StackWizardClient({ stackIdFromRoute }: StackWizardClientProps) return ( + (initialStackStep ? 1 : 0)) const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) - const [responses, setResponses] = useState({}) - const [dynamicSteps, setDynamicSteps] = useState([]) - const [isStackFastTrackPromptVisible, setIsStackFastTrackPromptVisible] = useState(false) + const [responses, setResponses] = useState(() => + initialStackId ? { [STACK_QUESTION_ID]: initialStackId } : {} + ) + const [dynamicSteps, setDynamicSteps] = useState(() => + initialStackStep ? [initialStackStep] : [] + ) + const [isStackFastTrackPromptVisible, setIsStackFastTrackPromptVisible] = useState(() => { + if (!initialStackStep) { + return false + } + + if (autoStartAfterStackSelection) { + return false + } + + return initialStackStep.questions.length > 0 + }) const [pendingConfirmation, setPendingConfirmation] = useState(null) const [autoFilledQuestionMap, setAutoFilledQuestionMap] = useState>({}) - const hasAppliedInitialStack = useRef(null) - const [activeStackLabel, setActiveStackLabel] = useState(null) + const hasAppliedInitialStack = useRef( + initialStackStep && initialStackId ? initialStackId : null + ) + const [activeStackLabel, setActiveStackLabel] = useState(initialStackLabel) const wizardSteps = useMemo(() => [stacksStep, ...dynamicSteps, ...suffixSteps], [dynamicSteps]) const currentStep = wizardSteps[currentStepIndex] ?? null @@ -138,6 +156,40 @@ export function InstructionsWizard({ [] ) + const applyStackStep = useCallback( + (step: WizardStep, label: string | null, options?: { skipFastTrackPrompt?: boolean; stackId?: string }) => { + const skipFastTrackPrompt = options?.skipFastTrackPrompt ?? false + const nextStackId = options?.stackId + + setActiveStackLabel(label) + setDynamicSteps([step]) + setIsStackFastTrackPromptVisible(!skipFastTrackPrompt && step.questions.length > 0) + + setResponses((prev) => { + const next: Responses = { ...prev } + + if (nextStackId) { + next[STACK_QUESTION_ID] = nextStackId + } + + step.questions.forEach((question) => { + if (question.id === STACK_QUESTION_ID) { + return + } + + delete next[question.id] + }) + + return next + }) + + setCurrentStepIndex(1) + setCurrentQuestionIndex(0) + setAutoFilledQuestionMap({}) + }, + [] + ) + const loadStackQuestions = useCallback( async ( stackId: string, @@ -146,42 +198,42 @@ export function InstructionsWizard({ ) => { try { const { step, label } = await loadStackWizardStep(stackId, stackLabelFromAnswer) - setActiveStackLabel(label) - - setDynamicSteps([step]) - setIsStackFastTrackPromptVisible(!options?.skipFastTrackPrompt && step.questions.length > 0) - - setResponses((prev) => { - const next: Responses = { ...prev } - step.questions.forEach((question) => { - delete next[question.id] - }) - return next + applyStackStep(step, label, { + skipFastTrackPrompt: options?.skipFastTrackPrompt, + stackId, }) - setCurrentStepIndex(1) - setCurrentQuestionIndex(0) - setAutoFilledQuestionMap({}) } catch (error) { console.error(`Unable to load questions for stack "${stackId}"`, error) setDynamicSteps([]) setIsStackFastTrackPromptVisible(false) + setActiveStackLabel(null) } }, - [] + [applyStackStep] ) useEffect(() => { if (!initialStackId) { + hasAppliedInitialStack.current = null return } - if (hasAppliedInitialStack.current === initialStackId) { + const stackAnswer = stackQuestion?.answers.find((answer) => answer.value === initialStackId) + + if (!stackAnswer) { return } - const stackAnswer = stackQuestion?.answers.find((answer) => answer.value === initialStackId) + if (initialStackStep) { + hasAppliedInitialStack.current = initialStackId + applyStackStep(initialStackStep, initialStackLabel ?? stackAnswer.label ?? null, { + skipFastTrackPrompt: autoStartAfterStackSelection, + stackId: stackAnswer.value, + }) + return + } - if (!stackAnswer) { + if (hasAppliedInitialStack.current === initialStackId) { return } @@ -195,7 +247,14 @@ export function InstructionsWizard({ void loadStackQuestions(stackAnswer.value, stackAnswer.label, { skipFastTrackPrompt: autoStartAfterStackSelection, }) - }, [initialStackId, loadStackQuestions, autoStartAfterStackSelection]) + }, [ + initialStackId, + initialStackStep, + initialStackLabel, + applyStackStep, + loadStackQuestions, + autoStartAfterStackSelection, + ]) useEffect(() => { if (!selectedStackId) { diff --git a/components/repo-scan-loader.tsx b/components/repo-scan-loader.tsx new file mode 100644 index 0000000..c1b805f --- /dev/null +++ b/components/repo-scan-loader.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes } from "react" + +interface RepoScanLoaderProps extends HTMLAttributes { + message?: string +} + +export default function RepoScanLoader({ message = "Scanning repository…", className = "", ...rest }: RepoScanLoaderProps) { + return ( +
+ +

{message}

+
+ ) +} diff --git a/components/stack-wizard-shell.tsx b/components/stack-wizard-shell.tsx index cf805c2..42ee018 100644 --- a/components/stack-wizard-shell.tsx +++ b/components/stack-wizard-shell.tsx @@ -1,6 +1,5 @@ import Link from "next/link" -import { AnimatedBackground } from "@/components/AnimatedBackground" import { Button } from "@/components/ui/button" import { Github } from "lucide-react" import { getHomeMainClasses } from "@/lib/utils" @@ -9,7 +8,11 @@ import type { StackWizardShellProps } from "@/types/wizard" export function StackWizardShell({ children, showWizard = true }: StackWizardShellProps) { return (
- +