Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

issue-templates.tsBlame280 lines · 1 contributor
9a4eb42Claude1/**
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
14import { getBlob, getDefaultBranch, getTree } from "../git/repository";
15import type { GitTreeEntry } from "../git/repository";
16
17export const TEMPLATE_DIRS = [
18 ".github/ISSUE_TEMPLATE",
19 ".github/issue_template",
20 ".gluecron/ISSUE_TEMPLATE",
21 ".gluecron/issue_template",
22];
23
24const MAX_TEMPLATE_BYTES = 32 * 1024;
25const MAX_TEMPLATES = 20;
26
27export interface IssueTemplateMeta {
28 name: string | null;
29 about: string | null;
30 title: string | null;
31 labels: string[];
32 assignees: string[];
33}
34
35export 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 */
54export 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
72function 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
84function 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 */
108export 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. */
155export 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. */
165export 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
197async 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 */
215export 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. */
263export 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
271export const __internal = {
272 splitFrontmatter,
273 parseFrontmatterMeta,
274 slugFromFilename,
275 buildTemplateFromFile,
276 findTemplateBySlug,
277 TEMPLATE_DIRS,
278 MAX_TEMPLATE_BYTES,
279 MAX_TEMPLATES,
280};