import { env } from "cloudflare:workers"; import { parse as parseYaml } from "yaml"; import { branchSha, listRefs } from "./git/refs"; import { withStore } from "./git/store"; import { issueFormSchema, markdownTemplateSchema, templateConfigSchema } from "~/lib/issue-templates"; import type { IssueTemplate, TemplateConfig } from "~/lib/issue-templates"; import type { RepoRow } from "~/lib/types"; const TEMPLATE_DIR = ".gitfor/ISSUE_TEMPLATE"; const MAX_TEMPLATES = 20; const MAX_TEMPLATE_BYTES = 64 * 1024; const CACHE_TTL_SECONDS = 7 * 24 * 60 * 60; const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; const decoder = new TextDecoder(); export type RepoTemplates = { templates: IssueTemplate[]; config: TemplateConfig }; const EMPTY: RepoTemplates = { templates: [], config: templateConfigSchema.parse({}) }; function parseTemplate(file: string, text: string): IssueTemplate | null { try { if (/\.ya?ml$/i.test(file)) { const form = issueFormSchema.safeParse(parseYaml(text)); if (!form.success) return null; return { kind: "form", file, name: form.data.name, about: form.data.description, title: form.data.title, labels: form.data.labels, fields: form.data.body, }; } const match = FRONT_MATTER.exec(text); if (!match) return null; const meta = markdownTemplateSchema.safeParse(parseYaml(match[1])); if (!meta.success) return null; return { kind: "markdown", file, name: meta.data.name, about: meta.data.about, title: meta.data.title, labels: meta.data.labels, body: match[2], }; } catch { return null; } } async function readTemplates(repo: RepoRow, commitSha: string): Promise { return withStore(repo.artifacts_name, async (store) => { const commit = await store.commit(commitSha); if (!commit) return EMPTY; const dir = await store.entryAtPath(commit.treeSha, TEMPLATE_DIR); if (!dir || dir.type !== "tree") return EMPTY; const entries = (await store.listTree(dir.sha, TEMPLATE_DIR)) .filter((entry) => entry.type !== "tree" && /\.(md|ya?ml)$/i.test(entry.name)) .slice(0, MAX_TEMPLATES + 1); const templates: IssueTemplate[] = []; let config = EMPTY.config; for (const entry of entries) { const bytes = await store.blob(entry.sha); if (!bytes || bytes.byteLength > MAX_TEMPLATE_BYTES) continue; const text = decoder.decode(bytes); if (/^config\.ya?ml$/i.test(entry.name)) { try { const parsed = templateConfigSchema.safeParse(parseYaml(text) ?? {}); if (parsed.success) config = parsed.data; } catch { continue; } continue; } const template = parseTemplate(entry.name, text); if (template && templates.length < MAX_TEMPLATES) templates.push(template); } return { templates: templates.sort((left, right) => left.name.localeCompare(right.name)), config }; }); } /** Issue templates and chooser config from the default branch, cached in KV per commit. */ export async function loadIssueTemplates(repo: RepoRow): Promise { const refs = await listRefs(repo); const sha = branchSha(refs, repo.default_branch); if (!sha) return EMPTY; const key = `issue-templates:${repo.id}:${sha}`; const cached = await env.KV.get(key, "json"); if (cached) return cached; const result = await readTemplates(repo, sha); await env.KV.put(key, JSON.stringify(result), { expirationTtl: CACHE_TTL_SECONDS }); return result; } /** Labels a template asks for; applied on issue creation regardless of the author's permission, like GitHub. */ export async function templateLabelsFor(repo: RepoRow, file: string): Promise { const { templates } = await loadIssueTemplates(repo); return templates.find((template) => template.file === file)?.labels ?? []; }