Blame · Line-by-line history
issue-templates.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.
| 9a4eb42 | 1 | /** |
| 2 | * Block J17 — Multi-template issue selector. | |
| 3 | * | |
| 4 | * Scans `.github/ISSUE_TEMPLATE/` (plus `.gluecron/ISSUE_TEMPLATE/`) on the | |
| 5 | * default branch for `*.md` files, parses their YAML-frontmatter metadata | |
| 6 | * (`name`, `about`, `title`, `labels`), and exposes the list to the | |
| 7 | * new-issue flow so users can pick which template to start from. | |
| 8 | * | |
| 9 | * The frontmatter parser is purpose-built for this narrow shape — it's not a | |
| 10 | * full YAML parser. All helpers are pure; `listIssueTemplates` wraps them | |
| 11 | * with git I/O and returns `[]` on any failure. | |
| 12 | */ | |
| 13 | ||
| 14 | import { getBlob, getDefaultBranch, getTree } from "../git/repository"; | |
| 15 | import type { GitTreeEntry } from "../git/repository"; | |
| 16 | ||
| 17 | export const TEMPLATE_DIRS = [ | |
| 18 | ".github/ISSUE_TEMPLATE", | |
| 19 | ".github/issue_template", | |
| 20 | ".gluecron/ISSUE_TEMPLATE", | |
| 21 | ".gluecron/issue_template", | |
| 22 | ]; | |
| 23 | ||
| 24 | const MAX_TEMPLATE_BYTES = 32 * 1024; | |
| 25 | const MAX_TEMPLATES = 20; | |
| 26 | ||
| 27 | export interface IssueTemplateMeta { | |
| 28 | name: string | null; | |
| 29 | about: string | null; | |
| 30 | title: string | null; | |
| 31 | labels: string[]; | |
| 32 | assignees: string[]; | |
| 33 | } | |
| 34 | ||
| 35 | export interface IssueTemplate { | |
| 36 | slug: string; | |
| 37 | path: string; | |
| 38 | name: string; | |
| 39 | about: string | null; | |
| 40 | title: string | null; | |
| 41 | labels: string[]; | |
| 42 | assignees: string[]; | |
| 43 | body: string; | |
| 44 | } | |
| 45 | ||
| 46 | // --------------------------------------------------------------------------- | |
| 47 | // Pure frontmatter parser | |
| 48 | // --------------------------------------------------------------------------- | |
| 49 | ||
| 50 | /** | |
| 51 | * Extract `---\n<frontmatter>\n---\n<body>` from a template file. | |
| 52 | * Returns `{meta: null, body: content}` if no frontmatter is present. | |
| 53 | */ | |
| 54 | export function splitFrontmatter(content: string): { | |
| 55 | frontmatter: string | null; | |
| 56 | body: string; | |
| 57 | } { | |
| 58 | if (!content.startsWith("---")) { | |
| 59 | return { frontmatter: null, body: content }; | |
| 60 | } | |
| 61 | const rest = content.slice(3); | |
| 62 | // Frontmatter ends at the first "\n---" on its own line. | |
| 63 | const match = rest.match(/\n---[\s]*\n/); | |
| 64 | if (!match || match.index === undefined) { | |
| 65 | return { frontmatter: null, body: content }; | |
| 66 | } | |
| 67 | const frontmatter = rest.slice(0, match.index).replace(/^\n/, ""); | |
| 68 | const body = rest.slice(match.index + match[0].length); | |
| 69 | return { frontmatter, body }; | |
| 70 | } | |
| 71 | ||
| 72 | function unquote(raw: string): string { | |
| 73 | const v = raw.trim(); | |
| 74 | if (!v) return ""; | |
| 75 | if ( | |
| 76 | (v.startsWith('"') && v.endsWith('"')) || | |
| 77 | (v.startsWith("'") && v.endsWith("'")) | |
| 78 | ) { | |
| 79 | return v.slice(1, -1); | |
| 80 | } | |
| 81 | return v; | |
| 82 | } | |
| 83 | ||
| 84 | function parseList(raw: string): string[] { | |
| 85 | const v = raw.trim(); | |
| 86 | if (!v) return []; | |
| 87 | // Flow-list: [a, "b", c] | |
| 88 | if (v.startsWith("[") && v.endsWith("]")) { | |
| 89 | return v | |
| 90 | .slice(1, -1) | |
| 91 | .split(",") | |
| 92 | .map((s) => unquote(s.trim())) | |
| 93 | .filter(Boolean); | |
| 94 | } | |
| 95 | // Comma-separated fallback | |
| 96 | return v | |
| 97 | .split(",") | |
| 98 | .map((s) => unquote(s.trim())) | |
| 99 | .filter(Boolean); | |
| 100 | } | |
| 101 | ||
| 102 | /** | |
| 103 | * Pure: parse the tiny subset of YAML that issue-template frontmatter uses. | |
| 104 | * Supports flat `key: value` pairs, block-scalar values on continuation lines | |
| 105 | * (not common here) are flattened into a single line, and YAML block-list | |
| 106 | * values (`labels:\n - bug\n - triage`). | |
| 107 | */ | |
| 108 | export function parseFrontmatterMeta(text: string): IssueTemplateMeta { | |
| 109 | const meta: IssueTemplateMeta = { | |
| 110 | name: null, | |
| 111 | about: null, | |
| 112 | title: null, | |
| 113 | labels: [], | |
| 114 | assignees: [], | |
| 115 | }; | |
| 116 | if (!text) return meta; | |
| 117 | const lines = text.replace(/\r\n?/g, "\n").split("\n"); | |
| 118 | let i = 0; | |
| 119 | while (i < lines.length) { | |
| 120 | const line = lines[i]; | |
| 121 | if (!line.trim() || line.trim().startsWith("#")) { | |
| 122 | i++; | |
| 123 | continue; | |
| 124 | } | |
| 125 | const colonIdx = line.indexOf(":"); | |
| 126 | if (colonIdx <= 0 || /^\s/.test(line)) { | |
| 127 | i++; | |
| 128 | continue; | |
| 129 | } | |
| 130 | const key = line.slice(0, colonIdx).trim().toLowerCase(); | |
| 131 | const rest = line.slice(colonIdx + 1).trim(); | |
| 132 | if (rest === "" || rest === ">" || rest === "|") { | |
| 133 | // Block list? Peek at next indented `- ` lines. | |
| 134 | const items: string[] = []; | |
| 135 | i++; | |
| 136 | while (i < lines.length && /^\s+-\s?/.test(lines[i])) { | |
| 137 | items.push(unquote(lines[i].replace(/^\s+-\s?/, ""))); | |
| 138 | i++; | |
| 139 | } | |
| 140 | if (key === "labels") meta.labels = items.filter(Boolean); | |
| 141 | else if (key === "assignees") meta.assignees = items.filter(Boolean); | |
| 142 | continue; | |
| 143 | } | |
| 144 | if (key === "name") meta.name = unquote(rest); | |
| 145 | else if (key === "about") meta.about = unquote(rest); | |
| 146 | else if (key === "title") meta.title = unquote(rest); | |
| 147 | else if (key === "labels") meta.labels = parseList(rest); | |
| 148 | else if (key === "assignees") meta.assignees = parseList(rest); | |
| 149 | i++; | |
| 150 | } | |
| 151 | return meta; | |
| 152 | } | |
| 153 | ||
| 154 | /** Pure: derive a URL-safe slug from the filename and fall back to the meta name. */ | |
| 155 | export function slugFromFilename(filename: string): string { | |
| 156 | const base = filename.replace(/\.(md|yml|yaml)$/i, ""); | |
| 157 | return base | |
| 158 | .toLowerCase() | |
| 159 | .replace(/[^a-z0-9]+/g, "-") | |
| 160 | .replace(/^-+|-+$/g, "") | |
| 161 | .slice(0, 64); | |
| 162 | } | |
| 163 | ||
| 164 | /** Pure: merge filename + parsed meta + body into a single template row. */ | |
| 165 | export function buildTemplateFromFile( | |
| 166 | filename: string, | |
| 167 | content: string, | |
| 168 | dirPath: string | |
| 169 | ): IssueTemplate { | |
| 170 | const { frontmatter, body } = splitFrontmatter(content); | |
| 171 | const meta = frontmatter | |
| 172 | ? parseFrontmatterMeta(frontmatter) | |
| 173 | : { | |
| 174 | name: null, | |
| 175 | about: null, | |
| 176 | title: null, | |
| 177 | labels: [], | |
| 178 | assignees: [], | |
| 179 | }; | |
| 180 | const slug = slugFromFilename(filename); | |
| 181 | return { | |
| 182 | slug, | |
| 183 | path: dirPath ? `${dirPath}/${filename}` : filename, | |
| 184 | name: meta.name || filename.replace(/\.(md|yml|yaml)$/i, ""), | |
| 185 | about: meta.about, | |
| 186 | title: meta.title, | |
| 187 | labels: meta.labels, | |
| 188 | assignees: meta.assignees, | |
| 189 | body: body.trim(), | |
| 190 | }; | |
| 191 | } | |
| 192 | ||
| 193 | // --------------------------------------------------------------------------- | |
| 194 | // Git-layer wrapper | |
| 195 | // --------------------------------------------------------------------------- | |
| 196 | ||
| 197 | async function safeTree( | |
| 198 | owner: string, | |
| 199 | repo: string, | |
| 200 | ref: string, | |
| 201 | treePath: string | |
| 202 | ): Promise<GitTreeEntry[]> { | |
| 203 | try { | |
| 204 | return await getTree(owner, repo, ref, treePath); | |
| 205 | } catch { | |
| 206 | return []; | |
| 207 | } | |
| 208 | } | |
| 209 | ||
| 210 | /** | |
| 211 | * Scan the template directories and return a de-duplicated list of issue | |
| 212 | * templates in the order they appear on disk (alphabetised by path). | |
| 213 | * Silently swallows all git failures. | |
| 214 | */ | |
| 215 | export async function listIssueTemplates( | |
| 216 | owner: string, | |
| 217 | repo: string | |
| 218 | ): Promise<IssueTemplate[]> { | |
| 219 | try { | |
| 220 | const ref = (await getDefaultBranch(owner, repo)) || "HEAD"; | |
| 221 | const seenSlugs = new Set<string>(); | |
| 222 | const out: IssueTemplate[] = []; | |
| 223 | for (const dir of TEMPLATE_DIRS) { | |
| 224 | const entries = await safeTree(owner, repo, ref, dir); | |
| 225 | if (!entries.length) continue; | |
| 226 | const files = entries | |
| 227 | .filter( | |
| 228 | (e) => | |
| 229 | e.type === "blob" && | |
| 230 | /\.(md|markdown)$/i.test(e.name) && | |
| 231 | !/^config\./i.test(e.name) | |
| 232 | ) | |
| 233 | .sort((a, b) => a.name.localeCompare(b.name)); | |
| 234 | for (const f of files) { | |
| 235 | if (out.length >= MAX_TEMPLATES) break; | |
| 236 | const fullPath = `${dir}/${f.name}`; | |
| 237 | let blob: Awaited<ReturnType<typeof getBlob>> | null = null; | |
| 238 | try { | |
| 239 | blob = await getBlob(owner, repo, ref, fullPath); | |
| 240 | } catch { | |
| 241 | blob = null; | |
| 242 | } | |
| 243 | if (!blob || blob.isBinary || !blob.content) continue; | |
| 244 | const content = | |
| 245 | blob.content.length > MAX_TEMPLATE_BYTES | |
| 246 | ? blob.content.slice(0, MAX_TEMPLATE_BYTES) | |
| 247 | : blob.content; | |
| 248 | const template = buildTemplateFromFile(f.name, content, dir); | |
| 249 | if (seenSlugs.has(template.slug)) continue; | |
| 250 | seenSlugs.add(template.slug); | |
| 251 | out.push(template); | |
| 252 | } | |
| 253 | if (out.length >= MAX_TEMPLATES) break; | |
| 254 | } | |
| 255 | return out; | |
| 256 | } catch (err) { | |
| 257 | console.error("[issue-templates] listIssueTemplates failed:", err); | |
| 258 | return []; | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 262 | /** Find a template by slug from a prefetched list. Pure. */ | |
| 263 | export function findTemplateBySlug( | |
| 264 | templates: IssueTemplate[], | |
| 265 | slug: string | null | undefined | |
| 266 | ): IssueTemplate | null { | |
| 267 | if (!slug) return null; | |
| 268 | return templates.find((t) => t.slug === slug) || null; | |
| 269 | } | |
| 270 | ||
| 271 | export const __internal = { | |
| 272 | splitFrontmatter, | |
| 273 | parseFrontmatterMeta, | |
| 274 | slugFromFilename, | |
| 275 | buildTemplateFromFile, | |
| 276 | findTemplateBySlug, | |
| 277 | TEMPLATE_DIRS, | |
| 278 | MAX_TEMPLATE_BYTES, | |
| 279 | MAX_TEMPLATES, | |
| 280 | }; |