diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 3472988d..8376769f 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1062,6 +1062,12 @@ "description": "Comprehensive collection for writing tests, test automation, and test-driven development including unit tests, integration tests, and end-to-end testing strategies.", "version": "1.0.0" }, + { + "name": "tiny-tool-town-submitter", + "source": "extensions/tiny-tool-town-submitter", + "description": "Inspect a repository, improve Tiny Tool Town readiness, submit its listing issue, and launch remediation work.", + "version": "1.0.0" + }, { "name": "token-pacman", "source": "extensions/token-pacman", diff --git a/extensions/tiny-tool-town-submitter/.github/plugin/plugin.json b/extensions/tiny-tool-town-submitter/.github/plugin/plugin.json new file mode 100644 index 00000000..bdda3a08 --- /dev/null +++ b/extensions/tiny-tool-town-submitter/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "tiny-tool-town-submitter", + "description": "Inspect a repository, improve Tiny Tool Town readiness, submit its listing issue, and launch remediation work.", + "version": "1.0.0", + "author": { + "name": "James Montemagno", + "url": "https://github.com/jamesmontemagno" + }, + "keywords": [ + "github-issues", + "open-source", + "project-readiness", + "repository-analysis", + "submission-workflow", + "tiny-tool-town" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/tiny-tool-town-submitter/README.md b/extensions/tiny-tool-town-submitter/README.md new file mode 100644 index 00000000..c61e97f5 --- /dev/null +++ b/extensions/tiny-tool-town-submitter/README.md @@ -0,0 +1,18 @@ +# Tiny Tool Town Submitter + +Inspect the current Git repository, review every field required by Tiny Tool Town, and submit a complete listing issue from a Copilot canvas. + +The canvas also checks public-repository readiness, detects missing README guidance, licenses, and showcase imagery, and can request a dedicated Copilot project session to implement selected recommendations. + +## Actions + +- **Re-scan repository** refreshes detected metadata and readiness findings. +- **Generate options** uses the Copilot SDK to draft short, balanced, and detailed listing descriptions that can be reviewed before applying. +- **Start improvement session** hands selected findings to a new local project session. +- **Submit to Tiny Tool Town** checks required confirmations, searches for an existing submission, and creates the public issue through GitHub CLI. + +GitHub CLI must be installed and authenticated to verify repository visibility or submit an issue. + +## Assets + +- `assets/preview.png` shows the submission workshop canvas. diff --git a/extensions/tiny-tool-town-submitter/assets/preview.png b/extensions/tiny-tool-town-submitter/assets/preview.png new file mode 100644 index 00000000..0570f7b6 Binary files /dev/null and b/extensions/tiny-tool-town-submitter/assets/preview.png differ diff --git a/extensions/tiny-tool-town-submitter/extension.mjs b/extensions/tiny-tool-town-submitter/extension.mjs new file mode 100644 index 00000000..332c3e3b --- /dev/null +++ b/extensions/tiny-tool-town-submitter/extension.mjs @@ -0,0 +1,608 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk"; +import { CanvasError, createCanvas, joinSession } from "@github/copilot-sdk/extension"; +import { + buildIssueBody, + inspectRepository, + resolveRepoRoot, + validateSubmission, +} from "./lib/analyzer.mjs"; +import { generateDescriptionOptions } from "./lib/copy-generator.mjs"; +import { renderHtml } from "./lib/renderer.mjs"; + +const execFileAsync = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const servers = new Map(); +const states = new Map(); +const ISSUE_REPOSITORY = "shanselman/TinyToolTown"; +const MAX_REQUEST_BYTES = 1024 * 256; +let activeSession; +let copyClient; +let copyClientPromise; +let persisted = { repositories: {} }; +let persistenceLoaded = false; + +async function runGh(args, cwd) { + try { + const { stdout } = await execFileAsync("gh", args, { + cwd, + timeout: 30000, + maxBuffer: 1024 * 1024, + windowsHide: true, + }); + return stdout.trim(); + } catch (error) { + const detail = String(error?.stderr || error?.message || "GitHub CLI command failed.").trim(); + throw new Error(detail); + } +} + +function persistencePath() { + return activeSession?.workspacePath + ? join(activeSession.workspacePath, "files", "tiny-tool-town-submitter.json") + : ""; +} + +async function loadPersistence() { + if (persistenceLoaded) return; + persistenceLoaded = true; + const path = persistencePath(); + if (!path) return; + try { + persisted = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if (error?.code !== "ENOENT") { + await activeSession?.log("Tiny Tool Town Submitter could not load its saved draft.", { level: "warning" }); + } + } + if (!persisted || typeof persisted !== "object" || !persisted.repositories) { + persisted = { repositories: {} }; + } +} + +async function savePersistence() { + const path = persistencePath(); + if (!path) return; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(persisted, null, 2)}\n`, "utf8"); +} + +function publicState(state) { + return { + repoPath: state.repoPath, + scannedAt: state.scannedAt, + metadata: state.metadata, + facts: state.facts, + recommendations: state.recommendations, + themes: state.themes, + submission: state.submission || null, + }; +} + +async function inspect(repoPath, { preserveDraft = true } = {}) { + await loadPersistence(); + const fresh = await inspectRepository(repoPath); + const saved = persisted.repositories[fresh.repoPath]; + if (preserveDraft && saved?.metadata) { + const savedValues = Object.fromEntries( + Object.entries(saved.metadata).filter(([field, value]) => + field === "confirmations" + || (typeof value === "string" && (value.trim() || !fresh.metadata[field])), + ), + ); + fresh.metadata = { + ...fresh.metadata, + ...savedValues, + confirmations: { + ...fresh.metadata.confirmations, + ...saved.metadata.confirmations, + }, + }; + } + if (saved?.submission) fresh.submission = saved.submission; + states.set(fresh.repoPath, fresh); + persisted.repositories[fresh.repoPath] = { + metadata: fresh.metadata, + submission: fresh.submission || null, + }; + await savePersistence(); + return fresh; +} + +function repoPathFromContext(ctx) { + const repoPath = ctx.input?.repoPath || ctx.session?.workingDirectory; + if (!repoPath) { + throw new CanvasError( + "repository_unavailable", + "The active session did not provide a repository path. Open the canvas from a project session or pass repoPath explicitly.", + ); + } + return repoPath; +} + +async function stateFor(repoPath) { + const root = await resolveRepoRoot(repoPath); + return states.get(root) || inspect(root); +} + +function applyMetadata(state, metadata) { + const allowed = [ + "name", + "tagline", + "description", + "githubUrl", + "websiteUrl", + "thumbnailUrl", + "author", + "authorGitHub", + "tags", + "language", + "license", + "theme", + ]; + for (const field of allowed) { + if (typeof metadata?.[field] === "string") { + state.metadata[field] = metadata[field].trim(); + } + } + if (metadata?.confirmations && typeof metadata.confirmations === "object") { + state.metadata.confirmations = { + freeOpenSource: metadata.confirmations.freeOpenSource === true, + notEnterpriseSaas: metadata.confirmations.notEnterpriseSaas === true, + publicAndWorks: metadata.confirmations.publicAndWorks === true, + }; + } +} + +async function persistState(state) { + persisted.repositories[state.repoPath] = { + metadata: state.metadata, + submission: state.submission || null, + }; + await savePersistence(); +} + +async function getCopyClient() { + if (!copyClientPromise) { + copyClient = new CopilotClient({ + connection: RuntimeConnection.forStdio({ + path: process.env.COPILOT_CLI_PATH || process.execPath, + }), + logLevel: "error", + }); + copyClientPromise = copyClient.start() + .then(() => copyClient) + .catch((error) => { + copyClient = undefined; + copyClientPromise = undefined; + throw error; + }); + } + return copyClientPromise; +} + +async function stopCopyClient() { + const client = copyClient; + copyClient = undefined; + copyClientPromise = undefined; + if (client) { + await client.stop(); + } +} + +async function generateIsolatedDescriptionOptions(state) { + const client = await getCopyClient(); + const session = await client.createSession({ + workingDirectory: state.repoPath, + availableTools: [], + systemMessage: { + mode: "append", + content: "This is an isolated copywriting session. Never use tools or perform side effects. Return only the requested JSON.", + }, + }); + const sessionId = session.sessionId; + try { + return await generateDescriptionOptions(session, structuredClone(state.metadata)); + } finally { + await session.disconnect(); + try { + await client.deleteSession(sessionId); + } catch (error) { + await activeSession?.log( + `Tiny Tool Town Submitter could not delete copy session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + { level: "warning" }, + ); + } + } +} + +function titleFor(metadata) { + return `[Tool] ${metadata.name.trim()}`; +} + +function suppressGitHubMentions(value) { + return String(value || "").replace(/@(?=[A-Za-z0-9_-])/g, "@\u200B"); +} + +function publicIssueMetadata(metadata) { + return Object.fromEntries( + Object.entries(metadata).map(([field, value]) => [ + field, + typeof value === "string" ? suppressGitHubMentions(value) : value, + ]), + ); +} + +async function submitIssue(state, metadata, confirmed) { + if (confirmed !== true) { + throw new CanvasError("confirmation_required", "Set confirm to true after reviewing the public issue contents."); + } + applyMetadata(state, metadata); + const submissionMetadata = structuredClone(state.metadata); + const errors = validateSubmission(submissionMetadata, state.facts); + if (errors.length) { + throw new CanvasError("submission_invalid", errors.join(" ")); + } + + const search = await runGh([ + "issue", + "list", + "--repo", + ISSUE_REPOSITORY, + "--state", + "all", + "--search", + `${submissionMetadata.githubUrl} in:body`, + "--limit", + "10", + "--json", + "number,title,url,state", + ], state.repoPath); + const existing = JSON.parse(search || "[]").find((issue) => issue.url); + if (existing) { + state.submission = { + url: existing.url, + number: existing.number, + existing: true, + submittedAt: new Date().toISOString(), + }; + await persistState(state); + return { url: existing.url, existing: true, state: publicState(state) }; + } + + const issueMetadata = publicIssueMetadata(submissionMetadata); + const url = await runGh([ + "issue", + "create", + "--repo", + ISSUE_REPOSITORY, + "--title", + titleFor(issueMetadata), + "--label", + "new-tool", + "--body", + buildIssueBody(issueMetadata), + ], state.repoPath); + state.submission = { + url, + existing: false, + submittedAt: new Date().toISOString(), + }; + await persistState(state); + return { url, existing: false, state: publicState(state) }; +} + +async function requestImprovementSession(state, recommendationIds) { + if (!activeSession) throw new Error("Copilot session is unavailable."); + const selected = state.recommendations.filter((item) => recommendationIds.includes(item.id)); + if (!selected.length) { + throw new CanvasError("recommendations_required", "Select at least one current recommendation."); + } + const implementationPrompt = [ + "Prepare this repository for a Tiny Tool Town submission.", + `Repository path: ${state.repoPath}`, + "", + "Implement these findings:", + ...selected.map((item) => `- ${item.title}: ${item.prompt}`), + "", + "Preserve unrelated work. Follow repository conventions, add or update directly related documentation and assets, run the existing validation commands, and leave a PR-ready branch state.", + ].join("\n"); + const sessionRequest = [ + "Create a new local project session in the current project for Tiny Tool Town readiness improvements.", + "Use the create_session tool with the current project_id, coordinate_with_creator: true, notify_on_idle: once, and name: Tiny Tool readiness.", + "Set kickoff mode to autopilot and use this exact kickoff prompt:", + JSON.stringify(implementationPrompt), + "", + "After the tool succeeds, reply with a one-line confirmation. Do not implement the findings in this current session.", + ].join("\n"); + await activeSession.send({ + prompt: sessionRequest, + mode: "immediate", + displayPrompt: "Start Tiny Tool readiness session", + }); + return { + status: "requested", + message: `Requested an implementation session for ${selected.length} recommendation${selected.length === 1 ? "" : "s"}.`, + recommendations: selected.map((item) => item.id), + }; +} + +async function readJson(req) { + const chunks = []; + let bytes = 0; + for await (const chunk of req) { + bytes += chunk.length; + if (bytes > MAX_REQUEST_BYTES) { + const error = new Error("Request body is too large."); + error.statusCode = 413; + throw error; + } + chunks.push(chunk); + } + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + const error = new Error("Request body must be valid JSON."); + error.statusCode = 400; + throw error; + } +} + +function sendJson(res, statusCode, value) { + res.statusCode = statusCode; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(value)); +} + +async function closeServerEntry(entry) { + if (!entry?.server?.listening) return; + await new Promise((resolve, reject) => { + entry.server.close((error) => error ? reject(error) : resolve()); + }); +} + +async function closeCanvasServer(instanceId) { + const entry = servers.get(instanceId); + if (entry) { + servers.delete(instanceId); + await closeServerEntry(entry); + } +} + +async function closeAllCanvasServers() { + const entries = [...servers.values()]; + servers.clear(); + await Promise.all(entries.map((entry) => closeServerEntry(entry))); +} + +async function handleRequest(entry, req, res) { + const url = new URL(req.url || "/", entry.url); + if (url.pathname === "/" && req.method === "GET") { + const nonce = randomUUID().replaceAll("-", ""); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader( + "Content-Security-Policy", + `default-src 'self'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; img-src https: data:; base-uri 'none'; form-action 'none'`, + ); + res.end(renderHtml(nonce)); + return; + } + if (url.searchParams.get("token") !== entry.token) { + sendJson(res, 403, { error: "Invalid canvas token." }); + return; + } + + try { + let state = await stateFor(entry.repoPath); + if (url.pathname === "/state" && req.method === "GET") { + sendJson(res, 200, publicState(state)); + return; + } + if (url.pathname === "/save" && req.method === "POST") { + const body = await readJson(req); + applyMetadata(state, body.metadata); + await persistState(state); + sendJson(res, 200, publicState(state)); + return; + } + if (url.pathname === "/refresh" && req.method === "POST") { + const body = await readJson(req); + applyMetadata(state, body.metadata); + await persistState(state); + state = await inspect(entry.repoPath); + sendJson(res, 200, publicState(state)); + return; + } + if (url.pathname === "/generate-descriptions" && req.method === "POST") { + const body = await readJson(req); + applyMetadata(state, body.metadata); + await persistState(state); + const options = await generateIsolatedDescriptionOptions(state); + sendJson(res, 200, { options }); + return; + } + if (url.pathname === "/submit" && req.method === "POST") { + const body = await readJson(req); + sendJson(res, 200, await submitIssue(state, body.metadata, body.confirm)); + return; + } + if (url.pathname === "/implement" && req.method === "POST") { + const body = await readJson(req); + sendJson(res, 200, await requestImprovementSession(state, body.recommendationIds || [])); + return; + } + sendJson(res, 404, { error: "Not found." }); + } catch (error) { + sendJson(res, error?.statusCode || 400, { + error: error instanceof Error ? error.message : "Request failed.", + code: error?.code, + }); + } +} + +async function startServer(instanceId, repoPath) { + const token = randomUUID(); + const entry = { instanceId, repoPath, token, server: null, url: "" }; + const server = createServer((req, res) => { + handleRequest(entry, req, res).catch((error) => { + sendJson(res, 500, { error: error instanceof Error ? error.message : "Unexpected server error." }); + }); + }); + entry.server = server; + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + entry.url = `http://127.0.0.1:${port}/`; + return entry; +} + +const metadataSchema = { + type: "object", + properties: { + name: { type: "string" }, + tagline: { type: "string", maxLength: 100 }, + description: { type: "string" }, + githubUrl: { type: "string" }, + websiteUrl: { type: "string" }, + thumbnailUrl: { type: "string" }, + author: { type: "string" }, + authorGitHub: { type: "string" }, + tags: { type: "string" }, + language: { type: "string" }, + license: { type: "string" }, + theme: { type: "string" }, + confirmations: { + type: "object", + properties: { + freeOpenSource: { type: "boolean" }, + notEnterpriseSaas: { type: "boolean" }, + publicAndWorks: { type: "boolean" }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, +}; + +activeSession = await joinSession({ + canvases: [ + createCanvas({ + id: "tiny-tool-town-submitter", + displayName: "Tiny Tool Town Submitter", + description: "Inspect a repository, improve its Tiny Tool Town readiness, submit the listing issue, and launch remediation work.", + inputSchema: { + type: "object", + properties: { + repoPath: { type: "string", description: "Repository path to inspect; defaults to the current Git repository." }, + }, + additionalProperties: false, + }, + actions: [ + { + name: "inspect_repository", + description: "Re-scan a repository and return Tiny Tool Town metadata and readiness recommendations.", + inputSchema: { + type: "object", + properties: { repoPath: { type: "string" } }, + additionalProperties: false, + }, + handler: async (ctx) => publicState(await inspect(repoPathFromContext(ctx), { preserveDraft: false })), + }, + { + name: "update_submission", + description: "Update the saved Tiny Tool Town submission draft.", + inputSchema: { + type: "object", + properties: { + repoPath: { type: "string" }, + metadata: metadataSchema, + }, + required: ["metadata"], + additionalProperties: false, + }, + handler: async (ctx) => { + const state = await stateFor(repoPathFromContext(ctx)); + applyMetadata(state, ctx.input.metadata); + await persistState(state); + return publicState(state); + }, + }, + { + name: "start_improvement_session", + description: "Request a dedicated project session to implement selected readiness recommendations.", + inputSchema: { + type: "object", + properties: { + repoPath: { type: "string" }, + recommendationIds: { + type: "array", + minItems: 1, + uniqueItems: true, + items: { type: "string" }, + }, + }, + required: ["recommendationIds"], + additionalProperties: false, + }, + handler: async (ctx) => { + const state = await stateFor(repoPathFromContext(ctx)); + return requestImprovementSession(state, ctx.input.recommendationIds); + }, + }, + ], + open: async (ctx) => { + const repoPath = await resolveRepoRoot(repoPathFromContext(ctx)); + await stateFor(repoPath); + let entry = servers.get(ctx.instanceId); + if (entry && entry.repoPath !== repoPath) { + await closeCanvasServer(ctx.instanceId); + entry = null; + } + if (!entry) { + entry = await startServer(ctx.instanceId, repoPath); + servers.set(ctx.instanceId, entry); + } + return { + title: "Tiny Tool Town Submitter", + status: "Repository inspected", + url: `${entry.url}?token=${encodeURIComponent(entry.token)}`, + }; + }, + onClose: async (ctx) => { + await closeCanvasServer(ctx.instanceId); + }, + }), + ], +}); + +async function shutdownExtension() { + await Promise.all([ + stopCopyClient(), + closeAllCanvasServers(), + ]); +} + +activeSession.on("session.shutdown", () => { + void shutdownExtension().catch((error) => { + void activeSession?.log( + `Tiny Tool Town Submitter shutdown cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + { level: "warning" }, + ); + }); +}); + +process.once("SIGTERM", () => { + void shutdownExtension().finally(() => process.exit(0)); +}); diff --git a/extensions/tiny-tool-town-submitter/lib/analyzer.mjs b/extensions/tiny-tool-town-submitter/lib/analyzer.mjs new file mode 100644 index 00000000..29c0664b --- /dev/null +++ b/extensions/tiny-tool-town-submitter/lib/analyzer.mjs @@ -0,0 +1,658 @@ +import { execFile } from "node:child_process"; +import { access, readFile, readdir, stat } from "node:fs/promises"; +import { extname, join, relative, resolve } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const README_NAMES = ["README.md", "readme.md", "Readme.md", "README", "readme", "README.rst", "README.txt"]; +const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "LICENCE.txt"]; +const SOURCE_LANGUAGES = new Map([ + [".cs", "C#"], + [".fs", "F#"], + [".vb", "VB.NET"], + [".py", "Python"], + [".rs", "Rust"], + [".go", "Go"], + [".ts", "TypeScript"], + [".tsx", "TypeScript"], + [".js", "JavaScript"], + [".jsx", "JavaScript"], + [".mjs", "JavaScript"], + [".java", "Java"], + [".kt", "Kotlin"], + [".swift", "Swift"], + [".rb", "Ruby"], + [".cpp", "C++"], + [".cc", "C++"], + [".c", "C"], + [".zig", "Zig"], + [".lua", "Lua"], + [".php", "PHP"], + [".dart", "Dart"], + [".ex", "Elixir"], + [".exs", "Elixir"], + [".hs", "Haskell"], + [".scala", "Scala"], + [".r", "R"], + [".jl", "Julia"], + [".ps1", "PowerShell"], + [".sh", "Shell"], +]); +const SKIPPED_DIRECTORIES = new Set([ + ".git", + ".next", + ".nuxt", + ".output", + ".turbo", + "bin", + "build", + "coverage", + "dist", + "node_modules", + "obj", + "out", + "target", + "vendor", +]); +const BAD_IMAGE_HOSTS = new Set([ + "avatars.githubusercontent.com", + "badge.fury.io", + "contrib.rocks", + "img.shields.io", + "opencollective.com", +]); +const THEMES = [ + "None (site default)", + "terminal", + "neon", + "minimal", + "pastel", + "matrix", + "sunset", + "ocean", + "forest", + "candy", + "synthwave", + "newspaper", + "retro", +]; + +async function run(file, args, cwd, timeout = 15000) { + const { stdout } = await execFileAsync(file, args, { + cwd, + timeout, + maxBuffer: 1024 * 1024, + windowsHide: true, + }); + return stdout.trim(); +} + +async function runOptional(file, args, cwd, timeout) { + try { + return await run(file, args, cwd, timeout); + } catch { + return ""; + } +} + +async function pathExists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function findFirst(root, names) { + let rootEntries; + for (const name of names) { + if (name.startsWith("*.")) { + rootEntries ??= await readdir(root, { withFileTypes: true }); + const suffix = name.slice(1).toLowerCase(); + const match = rootEntries.find((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(suffix)); + if (match) { + return join(root, match.name); + } + continue; + } + const path = join(root, name); + if (await pathExists(path)) { + return path; + } + } + return ""; +} + +function parseJson(value, fallback = null) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function normalizeGitUrl(value) { + const url = String(value || "").trim(); + if (url.startsWith("git@github.com:")) { + return `https://github.com/${url.slice("git@github.com:".length).replace(/\.git$/i, "")}`; + } + try { + const parsed = new URL(url); + if (parsed.hostname.toLowerCase() !== "github.com") return url; + const segments = parsed.pathname.replace(/\.git$/i, "").split("/").filter(Boolean); + if (segments.length < 2) return url; + return `https://github.com/${segments[0]}/${segments[1]}`; + } catch { + return url.replace(/\.git$/i, "").replace(/\/$/, ""); + } +} + +function githubCoordinates(githubUrl) { + const match = String(githubUrl || "").match(/^https:\/\/github\.com\/([^/]+)\/([^/#?]+)$/i); + return match ? { owner: match[1], repo: match[2] } : null; +} + +function cleanMarkdown(text) { + return String(text || "") + .replace(//g, " ") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/`{1,3}[^`]*`{1,3}/g, " ") + .replace(/[*_>#|~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function firstReadmeParagraph(readme) { + for (const block of String(readme || "").split(/\r?\n\s*\r?\n/)) { + const trimmed = block.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("![") || trimmed.startsWith("[![")) { + continue; + } + const cleaned = cleanMarkdown(trimmed); + if (cleaned.length >= 30) { + return cleaned.slice(0, 600); + } + } + return ""; +} + +function sentenceDescription(name, source) { + const cleaned = cleanMarkdown(source); + if (!cleaned) { + return `${name} is an open source tool built to make a focused task simpler and more delightful. Explore the repository for installation and usage details.`; + } + const first = /[.!?]$/.test(cleaned) ? cleaned : `${cleaned}.`; + return `${first} The project is free and open source, with its source and usage details available on GitHub.`; +} + +function truncate(value, maxLength) { + const text = cleanMarkdown(value); + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`; +} + +function detectLicenseText(content) { + const text = String(content || "").toLowerCase(); + if (text.includes("mit license") || text.includes("licensed under the mit")) return "MIT"; + if (text.includes("apache license") || text.includes("apache-2.0")) return "Apache-2.0"; + if (text.includes("gnu general public license") || text.includes("gpl-3.0") || text.includes("gplv3")) return "GPL-3.0"; + if (text.includes("gpl-2.0") || text.includes("gplv2")) return "GPL-2.0"; + if (text.includes("bsd 2-clause")) return "BSD-2-Clause"; + if (text.includes("bsd 3-clause")) return "BSD-3-Clause"; + if (text.includes("mozilla public license") || text.includes("mpl-2.0")) return "MPL-2.0"; + if (text.includes("isc license")) return "ISC"; + if (text.includes("the unlicense")) return "Unlicense"; + return ""; +} + +async function scanLanguages(root) { + const counts = new Map(); + let visited = 0; + + async function visit(directory, depth) { + if (depth > 6 || visited > 12000) return; + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (visited > 12000) break; + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) { + await visit(join(directory, entry.name), depth + 1); + } + continue; + } + visited += 1; + const language = SOURCE_LANGUAGES.get(extname(entry.name).toLowerCase()); + if (language) { + counts.set(language, (counts.get(language) || 0) + 1); + } + } + } + + await visit(root, 0); + return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || ""; +} + +function isLikelyBadge(url) { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + const pathname = parsed.pathname.toLowerCase(); + return BAD_IMAGE_HOSTS.has(hostname) + || hostname.endsWith(".githubusercontent.com") && hostname.startsWith("avatars.") + || pathname.endsWith(".svg") + || pathname.includes("/contributors/") + || pathname.includes("/sponsors/"); + } catch { + return false; + } +} + +function normalizeImageUrl(rawUrl, githubUrl, defaultBranch, readmePath, root) { + const value = String(rawUrl || "").trim().replace(/^<|>$/g, "").split(/\s+["']/)[0]; + if (!value || value.startsWith("data:")) return ""; + if (/^https?:\/\//i.test(value)) return isLikelyBadge(value) ? "" : value; + if (!githubUrl || value.startsWith("#")) return ""; + + const relativeReadmeDirectory = value.startsWith("/") + ? "" + : relative(root, resolve(readmePath, "..")).replaceAll("\\", "/"); + const imagePath = [relativeReadmeDirectory, value] + .filter(Boolean) + .join("/") + .replace(/^\.\//, "") + .replace(/^\/+/, "") + .replaceAll("\\", "/"); + return `${githubUrl.replace("github.com", "raw.githubusercontent.com")}/${defaultBranch}/${imagePath}`; +} + +function detectThumbnail(readme, githubUrl, defaultBranch, readmePath, root) { + const candidates = []; + const imagePattern = /!\[(?[^\]]*)]\((?[^)]+)\)/g; + for (const match of readme.matchAll(imagePattern)) { + candidates.push({ + index: match.index, + url: match.groups?.url, + alt: match.groups?.alt, + }); + } + const htmlImagePattern = /]*>/gi; + for (const match of readme.matchAll(htmlImagePattern)) { + const tag = match[0]; + const srcMatch = tag.match(/\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i); + const altMatch = tag.match(/\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + if (srcMatch) { + candidates.push({ + index: match.index, + url: srcMatch[1] || srcMatch[2] || srcMatch[3], + alt: altMatch ? altMatch[1] || altMatch[2] || altMatch[3] || "" : "", + }); + } + } + candidates.sort((left, right) => left.index - right.index); + for (const candidate of candidates) { + const url = normalizeImageUrl(candidate.url, githubUrl, defaultBranch, readmePath, root); + if (url) { + return { url, alt: String(candidate.alt || "").trim() }; + } + } + return { url: "", alt: "" }; +} + +function detectWebsite(readme, githubUrl, packageJson, githubInfo) { + const candidates = [packageJson?.homepage, githubInfo?.homepageUrl]; + const markdownLinks = [...String(readme || "").matchAll(/\[(?