Commit9a4eb42unknown_key
feat(BLOCK-J): J17 multi-template issue selector
feat(BLOCK-J): J17 multi-template issue selector Extend the single-file `ISSUE_TEMPLATE.md` loader with GitHub-parity `.github/ISSUE_TEMPLATE/*.md` directory support. Scans up to 20 templates, parses YAML frontmatter (name/about/title/labels/assignees), renders a chooser page when 2+ templates exist, auto-picks a single template, and falls back to the legacy ISSUE_TEMPLATE.md path. - `src/lib/issue-templates.ts` — pure parser + git loader. Exports `splitFrontmatter`, `parseFrontmatterMeta` (flat / flow-list / block- list, quoted values, case-insensitive keys), `slugFromFilename` (URL- safe, 64-char clamp), `buildTemplateFromFile`, `listIssueTemplates` (scans TEMPLATE_DIRS on default branch, silently returns [] on git failure), `findTemplateBySlug`, `__internal` re-exports. - `src/routes/issues.tsx` — new-issue GET handler renders the picker when 2+ templates exist (no `?template=<slug>` query), prefills title + body + labels on pick, supports `?template=__blank` escape hatch, preserves legacy `ISSUE_TEMPLATE.md` fallback. - `src/__tests__/issue-templates.test.ts` — 29 tests covering frontmatter split (with/without/CRLF/trailing-whitespace), flat/flow- list/block-list parsing, slug normalisation + unicode, builder merge, findTemplateBySlug null/unknown/exact, __internal parity, and route- auth smokes. Total suite 942/942.
4 files changed+633−59a4eb42cd614de09d5f1489b12d51fb5f46f64a9
4 changed files+633−5
ModifiedBUILD_BIBLE.md+4−2View fileUnifiedSplit
@@ -105,7 +105,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
105105| Reactions (emoji) | ✅ | 8 reactions, toggle via `POST /api/reactions/:t/:id/:emoji/toggle` on issues + PRs + comments |
106106| Mentions + notifications | ✅ | `src/routes/notifications.tsx` |
107107| Code owners | ✅ | `src/lib/codeowners.ts` |
108| Issue templates | ✅ | `.github/ISSUE_TEMPLATE.md` auto-prefills new issues; frontmatter stripped; `src/lib/templates.ts` |
108| Issue templates | ✅ | `.github/ISSUE_TEMPLATE.md` auto-prefills new issues; frontmatter stripped; `src/lib/templates.ts`. J17 extends this with `.github/ISSUE_TEMPLATE/*.md` multi-template support: scans up to 20 files, parses `name`/`about`/`title`/`labels`/`assignees` frontmatter, shows a chooser when 2+ templates exist, auto-picks a single template, falls back to legacy `ISSUE_TEMPLATE.md`, `?template=__blank` escape hatch. `src/lib/issue-templates.ts`. |
109109| PR templates | ✅ | `.github/PULL_REQUEST_TEMPLATE.md` auto-prefills new PRs; `src/lib/templates.ts` |
110110| Saved replies | ✅ | per-user canned comments, unique-shortcut, `/settings/replies`, `/api/user/replies` |
111111| Discussions / forums | ✅ | E2 — categorised threads, pinned/locked, q-and-a answers. `src/routes/discussions.tsx` + `drizzle/0013_discussions.sql` |
@@ -303,6 +303,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
303303- **J14** — Issue dependencies / blocked-by relationships → ✅ shipped. `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (`blocker_issue_id`, `blocked_issue_id`, CHECK `issue_dep_no_self`, unique on the pair, indexes on both sides). `src/lib/issue-dependencies.ts` exposes pure `wouldCreateCycle(edges, blocker, blocked)` (BFS following forward blocks edges) + `summariseBlockers` plus DB helpers: `addDependency` (`{ok, reason}` taxonomy rejecting self / cross_repo / exists / cycle / not_found / error), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Issue detail page in `src/routes/issues.tsx` now renders a "Dependencies" panel above the body showing Blocked-by + Blocks lists, per-row dismiss buttons (owner/author only), and a `#number`-form to add a new blocker. Two POST routes mirror the J11 pattern: `/:o/:r/issues/:n/dependencies` (add) and `/:o/:r/issues/:n/dependencies/:which/:otherId/remove`. Permission gated by `user.id === owner.id || user.id === issue.authorId`. 14 new tests covering cycle detection (direct + transitive + diamond + deep chains), summariseBlockers counts, and route-auth smokes. Total suite 867/867.
304304- **J15** — Deterministic release-notes generator → ✅ shipped. `src/lib/release-notes.ts` ships zero-IO helpers: `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` PR capture; aliases `feature`/`bugfix`/`doc`/`tests`; Merge-commit detection for pull requests and branches), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes` which re-renders the new-release form with notes pre-filled (preserves tag/name/target/draft/prerelease fields) plus a notice banner on missing commits or resolve failure. AI-disabled repos now fall through to the deterministic renderer on publish instead of writing empty notes. 30 new tests. Total suite 897/897.
305305- **J16** — PR auto-merge when checks pass → ✅ shipped. `drizzle/0037_pr_auto_merge.sql` adds `pr_auto_merge` (unique on `pull_request_id`, merge-method text, commit-title/message overrides, last_status + last_checked_at + notified_ready columns for the status-hook watcher). `src/lib/pr-auto-merge.ts` is the pure state machine + DB accessor: `computeAutoMergeAction({autoMergeEnabled, prState, isDraft, combinedState, totalChecks}) → {action: wait|merge|skip, reason}` covers all nine paths (not_enabled / pr_closed / pr_draft / no_checks / checks_pending / checks_failed / checks_passed). `src/lib/pr-auto-merge-trigger.ts` is called fire-and-forget from the commit-status POST — resolves each opted-in PR's head branch, matches against the incoming SHA, runs `combinedStatus` + the pure decider, and on first green transition posts a PR comment + PR-author notification. PR detail page gets an `AutoMergePanel` with live status colour (green/yellow/red), merge-method selector (merge/squash/rebase), and disable button. 16 new tests. Total suite 913/913.
306- **J17** — Multi-template issue selector → ✅ shipped. Extends the single-file `ISSUE_TEMPLATE.md` loader with full GitHub-parity `.github/ISSUE_TEMPLATE/*.md` directory support (plus `.gluecron/ISSUE_TEMPLATE/` fallback, upper + lowercase variants). `src/lib/issue-templates.ts` is pure-first: `splitFrontmatter` + `parseFrontmatterMeta` parse the narrow YAML subset used by issue-template frontmatter (flat `key: value`, flow-lists `[a,"b",c]`, block-lists `- a`, single+double quote stripping, case-insensitive keys). `slugFromFilename` normalises the file basename into a URL-safe slug, clamped to 64 chars. `buildTemplateFromFile` merges filename + meta + body into a typed `IssueTemplate`. `listIssueTemplates(owner, repo)` scans the four standard dirs on the default branch, filters `.md|.markdown` (excludes `config.*`), caps at 20 templates, dedupes by slug, and swallows all git failures to `[]`. `findTemplateBySlug` finds the active pick. `src/routes/issues.tsx` new-issue GET handler renders a chooser page when 2+ templates exist; picked template prefills the title input (from frontmatter `title:`) + body textarea (from body) + label pills. Single template auto-picks; `?template=__blank` escape hatch skips prefill. Falls back to the legacy `ISSUE_TEMPLATE.md` loader when no frontmatter templates are found. 29 new tests covering frontmatter split (with/without fence, CRLF tolerance, trailing whitespace on closing fence), flat/flow-list/block-list parsing, slug normalisation + unicode, `buildTemplateFromFile` (with/without frontmatter, empty dir path), `findTemplateBySlug` null/unknown/exact, `__internal` re-exports, and route-auth smokes. Total suite 942/942.
306307- **J12** — Community profile / health scorecard → ✅ shipped. `GET /:owner/:repo/community` renders a GitHub-parity "Community standards" page scoring the repo on 8 checklist items: description, README, LICENSE (all required), CODE_OF_CONDUCT, CONTRIBUTING, issue templates, PR template, topics (recommended). `src/lib/community.ts` exposes pure matchers (`isReadme`, `isLicense`, `isCodeOfConduct`, `isContributing`, `isPrTemplate`), pure `checklistFromInputs` (drives all the I/O-free unit tests), `buildReport` (→ percent + required breakdown), and `computeHealth` (reads default-branch root tree + `.github/` subtree + repo metadata + topics). Each missing item offers a one-click "Add <path>" or "Edit settings" link. `src/routes/community.tsx` degrades to a zero-score report on git/DB failure — never 500s. 21 new tests. Total suite 844/844.
307308- **J11** — PR auto-assign reviewers from CODEOWNERS + requested-reviewers tracking → ✅ shipped. `drizzle/0034_pr_review_requests.sql` adds `pr_review_requests` (unique on `(pull_request_id, reviewer_id)`, source enum `codeowners|manual|ai`, state enum `pending|approved|changes_requested|dismissed`). `src/lib/review-requests.ts` exposes pure helpers (`isValidSource`, `isValidState`, `nextState`, `sanitiseCandidates`) and DB helpers (`requestReviewers` idempotent, `listForPr` with username join, `dismissRequest`, `recordReviewOutcome`, `autoAssignFromCodeowners`, `countPendingForUser`). On PR creation, `src/routes/pulls.tsx` runs `git diff --numstat base...head` to extract changed paths, calls `reviewersForChangedFiles` (Block B3 CODEOWNERS parser), resolves usernames → user IDs, excludes the PR author, and fires `review_requested` notifications. PR detail page renders a `ReviewersPanel` with per-reviewer state pills, source labels, and dismiss + manual-add forms for owner/author. Auto-assign runs fire-and-forget — CODEOWNERS failures never block PR creation. 17 new tests. Total suite 823/823.
308309- **J10** — Repository status badges (shields.io-style SVG) → ✅ shipped. `src/lib/badge.ts` renders shields.io-style flat two-segment badges with zero IO — exports `renderBadge`, `escapeXml`, `estimateTextWidth` (Verdana-11 heuristic), `colorForState`. Named colour table (green/red/yellow/blue/grey/orange) + hex-literal passthrough. Label + value clamped to 64 chars. `src/routes/badges.ts` serves `/:o/:r/badge/gates.svg` (latest 20 gate_runs rollup → passing/running/failing), `/issues.svg` + `/prs.svg` (open counts), `/status.svg` (combined commit status on default-branch HEAD), `/status/:context.svg` (single named context). Every handler wrapped in try/catch and returns a grey "unknown" badge on DB or git failure — never 500. `image/svg+xml; charset=utf-8`, `Cache-Control: public, max-age=60, stale-while-revalidate=300`. `softAuth` so public-repo badges don't require cookies. 21 new tests. Total suite 806/806.
@@ -504,6 +505,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
504505- `src/lib/release-notes.ts` (Block J15) — pure release-notes generator. Exports `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` capture; handles `feature`/`bugfix`/`doc`/`tests` aliases; `Merge pull request #N` + `Merge branch ...` detection), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). Zero-IO, never throws. `__internal` re-exports for tests.
505506- `src/lib/pr-auto-merge.ts` (Block J16) — PR auto-merge opt-in helpers. `MERGE_METHODS = ['merge','squash','rebase']`, pure `isValidMergeMethod` + `computeAutoMergeAction({autoMergeEnabled, prState, isDraft, combinedState, totalChecks})` returning `{action: 'wait'|'merge'|'skip', reason}`. DB: `enableAutoMerge` (delete-then-insert), `disableAutoMerge`, `getAutoMergeForPr`, `recordEvaluation` (stamps `last_status`+`notified_ready`), `listAutoMergePrsForRepo` (open + auto-merge-enabled). `__internal` re-exports for tests.
506507- `src/lib/pr-auto-merge-trigger.ts` (Block J16) — fire-and-forget trigger called from `POST /api/v1/repos/:o/:r/statuses/:sha`. Resolves each opted-in PR's head branch via `resolveRef`, matches against the incoming SHA, runs `combinedStatus` + `computeAutoMergeAction`, and on first transition to ready posts a PR comment + notifies the PR author. All catch blocks so it never breaks the status write.
508- `src/lib/issue-templates.ts` (Block J17) — multi-template frontmatter parser + git loader. Exports `TEMPLATE_DIRS` (four standard locations), `MAX_TEMPLATE_BYTES=32KB`, `MAX_TEMPLATES=20`. Pure: `splitFrontmatter` (handles `---\n...\n---\n` with trailing whitespace on the closing fence), `parseFrontmatterMeta` (flat `key: value`, flow-list `labels: [a,"b",c]`, block-list `labels:\n - a\n - b`, single+double quote stripping, case-insensitive keys, comment/blank tolerant), `slugFromFilename` (lowercase, non-alnum collapse, 64-char clamp), `buildTemplateFromFile` (merges filename + meta + body). DB/git: `listIssueTemplates` (scans `TEMPLATE_DIRS` on default branch, filters `.md|.markdown`, excludes `config.*`, dedupes by slug, caps at `MAX_TEMPLATES`, silently returns `[]` on any failure). `findTemplateBySlug` + `__internal` re-exports.
507509
508510### 4.7 Views (locked contracts)
509511- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -538,7 +540,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
538540```bash
539541bun install
540542bun dev # hot reload
541bun test # 601 tests currently pass
543bun test # 942 tests currently pass
542544bun run db:migrate
543545```
544546
Addedsrc/__tests__/issue-templates.test.ts+242−0View fileUnifiedSplit
@@ -0,0 +1,242 @@
1/**
2 * Block J17 — Multi-template issue selector. Pure parser unit tests + a
3 * route-auth smoke to make sure the picker route is wired up.
4 */
5
6import { describe, it, expect } from "bun:test";
7import app from "../app";
8import {
9 splitFrontmatter,
10 parseFrontmatterMeta,
11 slugFromFilename,
12 buildTemplateFromFile,
13 findTemplateBySlug,
14 __internal,
15 type IssueTemplate,
16} from "../lib/issue-templates";
17
18describe("issue-templates — splitFrontmatter", () => {
19 it("returns body as-is when there's no frontmatter", () => {
20 const r = splitFrontmatter("hello world");
21 expect(r.frontmatter).toBeNull();
22 expect(r.body).toBe("hello world");
23 });
24
25 it("splits the standard ---\\n...\\n--- fence", () => {
26 const content = "---\nname: Bug\nabout: Report\n---\nSteps to reproduce";
27 const r = splitFrontmatter(content);
28 expect(r.frontmatter).toBe("name: Bug\nabout: Report");
29 expect(r.body).toBe("Steps to reproduce");
30 });
31
32 it("returns body as-is when fence is unterminated", () => {
33 const content = "---\nname: Bug\nno closing fence";
34 const r = splitFrontmatter(content);
35 expect(r.frontmatter).toBeNull();
36 expect(r.body).toBe(content);
37 });
38
39 it("tolerates trailing whitespace on the closing fence line", () => {
40 const content = "---\nname: Bug\n--- \nBody here";
41 const r = splitFrontmatter(content);
42 expect(r.frontmatter).toBe("name: Bug");
43 expect(r.body).toBe("Body here");
44 });
45
46 it("handles CRLF line endings", () => {
47 const content = "---\r\nname: Bug\r\n---\r\nBody";
48 const r = splitFrontmatter(content);
49 // splitFrontmatter itself doesn't normalise CRLF in the split logic; the
50 // parser normalises internally. We just need a usable body when the
51 // frontmatter can't be matched to still roundtrip the raw content.
52 expect(r.body.trim().length).toBeGreaterThan(0);
53 });
54});
55
56describe("issue-templates — parseFrontmatterMeta", () => {
57 it("returns an empty meta for empty input", () => {
58 const m = parseFrontmatterMeta("");
59 expect(m.name).toBeNull();
60 expect(m.about).toBeNull();
61 expect(m.title).toBeNull();
62 expect(m.labels).toEqual([]);
63 expect(m.assignees).toEqual([]);
64 });
65
66 it("parses flat key: value pairs", () => {
67 const m = parseFrontmatterMeta(
68 [
69 "name: Bug report",
70 "about: Something's broken",
71 "title: '[Bug] '",
72 ].join("\n")
73 );
74 expect(m.name).toBe("Bug report");
75 expect(m.about).toBe("Something's broken");
76 expect(m.title).toBe("[Bug] ");
77 });
78
79 it("strips double- and single-quoted values", () => {
80 const m = parseFrontmatterMeta(
81 ['name: "Quoted"', "about: 'single'"].join("\n")
82 );
83 expect(m.name).toBe("Quoted");
84 expect(m.about).toBe("single");
85 });
86
87 it("parses flow-list labels: [a, \"b\", c]", () => {
88 const m = parseFrontmatterMeta('labels: [bug, "high priority", triage]');
89 expect(m.labels).toEqual(["bug", "high priority", "triage"]);
90 });
91
92 it("parses block-list labels (- bug / - triage)", () => {
93 const m = parseFrontmatterMeta(
94 ["labels:", " - bug", " - triage", " - needs-repro"].join("\n")
95 );
96 expect(m.labels).toEqual(["bug", "triage", "needs-repro"]);
97 });
98
99 it("parses block-list assignees", () => {
100 const m = parseFrontmatterMeta(
101 ["assignees:", " - alice", " - bob"].join("\n")
102 );
103 expect(m.assignees).toEqual(["alice", "bob"]);
104 });
105
106 it("ignores comment lines and blanks", () => {
107 const m = parseFrontmatterMeta(
108 ["# a comment", "", "name: X", "# tail"].join("\n")
109 );
110 expect(m.name).toBe("X");
111 });
112
113 it("is case-insensitive on keys", () => {
114 const m = parseFrontmatterMeta("Name: Casey\nLABELS: [x]");
115 expect(m.name).toBe("Casey");
116 expect(m.labels).toEqual(["x"]);
117 });
118
119 it("ignores unknown keys", () => {
120 const m = parseFrontmatterMeta("name: A\ncolor: ff0000");
121 expect(m.name).toBe("A");
122 expect(m.labels).toEqual([]);
123 });
124});
125
126describe("issue-templates — slugFromFilename", () => {
127 it("lowercases and normalises to hyphens", () => {
128 expect(slugFromFilename("Bug Report.md")).toBe("bug-report");
129 expect(slugFromFilename("Feature_Request.md")).toBe("feature-request");
130 });
131
132 it("strips the .md / .markdown / .yml extensions", () => {
133 expect(slugFromFilename("x.md")).toBe("x");
134 expect(slugFromFilename("x.yml")).toBe("x");
135 expect(slugFromFilename("x.yaml")).toBe("x");
136 });
137
138 it("clamps to 64 characters", () => {
139 const base = "a".repeat(100);
140 expect(slugFromFilename(`${base}.md`).length).toBe(64);
141 });
142
143 it("strips unicode and collapses non-alnum runs", () => {
144 expect(slugFromFilename(" !!! bug 😀 .md")).toBe("bug");
145 });
146
147 it("returns '' for an all-unicode name (caller picks fallback)", () => {
148 expect(slugFromFilename("😀😀.md")).toBe("");
149 });
150});
151
152describe("issue-templates — buildTemplateFromFile", () => {
153 it("merges filename + frontmatter + body", () => {
154 const content = [
155 "---",
156 "name: Bug",
157 "about: Report a bug",
158 "title: '[BUG] '",
159 "labels: [bug, triage]",
160 "---",
161 "## Steps",
162 "1. foo",
163 ].join("\n");
164 const t = buildTemplateFromFile("bug.md", content, ".github/ISSUE_TEMPLATE");
165 expect(t.slug).toBe("bug");
166 expect(t.path).toBe(".github/ISSUE_TEMPLATE/bug.md");
167 expect(t.name).toBe("Bug");
168 expect(t.about).toBe("Report a bug");
169 expect(t.title).toBe("[BUG] ");
170 expect(t.labels).toEqual(["bug", "triage"]);
171 expect(t.body).toBe("## Steps\n1. foo");
172 });
173
174 it("falls back to the filename (sans extension) when name is missing", () => {
175 const content = "---\nabout: X\n---\nbody";
176 const t = buildTemplateFromFile("feature-request.md", content, ".github");
177 expect(t.name).toBe("feature-request");
178 });
179
180 it("handles files without frontmatter", () => {
181 const t = buildTemplateFromFile("plain.md", "Just a body\n", ".github");
182 expect(t.name).toBe("plain");
183 expect(t.about).toBeNull();
184 expect(t.labels).toEqual([]);
185 expect(t.body).toBe("Just a body");
186 });
187
188 it("joins empty dir path without a leading slash", () => {
189 const t = buildTemplateFromFile("x.md", "hi", "");
190 expect(t.path).toBe("x.md");
191 });
192});
193
194describe("issue-templates — findTemplateBySlug", () => {
195 const items: IssueTemplate[] = [
196 buildTemplateFromFile("bug.md", "---\nname: Bug\n---\n", ".github"),
197 buildTemplateFromFile("feature.md", "---\nname: Feature\n---\n", ".github"),
198 ];
199
200 it("returns null for null/undefined slugs", () => {
201 expect(findTemplateBySlug(items, null)).toBeNull();
202 expect(findTemplateBySlug(items, undefined)).toBeNull();
203 expect(findTemplateBySlug(items, "")).toBeNull();
204 });
205
206 it("returns null for an unknown slug", () => {
207 expect(findTemplateBySlug(items, "missing")).toBeNull();
208 });
209
210 it("finds by exact slug", () => {
211 expect(findTemplateBySlug(items, "bug")?.name).toBe("Bug");
212 expect(findTemplateBySlug(items, "feature")?.name).toBe("Feature");
213 });
214});
215
216describe("issue-templates — __internal", () => {
217 it("exposes the pure helpers for parity", () => {
218 expect(__internal.splitFrontmatter).toBe(splitFrontmatter);
219 expect(__internal.parseFrontmatterMeta).toBe(parseFrontmatterMeta);
220 expect(__internal.slugFromFilename).toBe(slugFromFilename);
221 expect(__internal.buildTemplateFromFile).toBe(buildTemplateFromFile);
222 expect(__internal.findTemplateBySlug).toBe(findTemplateBySlug);
223 expect(__internal.TEMPLATE_DIRS.length).toBeGreaterThan(0);
224 expect(typeof __internal.MAX_TEMPLATE_BYTES).toBe("number");
225 expect(typeof __internal.MAX_TEMPLATES).toBe("number");
226 });
227});
228
229describe("issue-templates — routes", () => {
230 it("GET /:owner/:repo/issues/new requires auth", async () => {
231 const res = await app.request("/alice/nope/issues/new");
232 // redirect to login OR 404 from repo resolve OR 401 JSON path
233 expect([302, 401, 404].includes(res.status)).toBe(true);
234 });
235
236 it("GET /:owner/:repo/issues/new?template=foo requires auth", async () => {
237 const res = await app.request(
238 "/alice/nope/issues/new?template=foo"
239 );
240 expect([302, 401, 404].includes(res.status)).toBe(true);
241 });
242});
Addedsrc/lib/issue-templates.ts+280−0View fileUnifiedSplit
@@ -0,0 +1,280 @@
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
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};
Modifiedsrc/routes/issues.tsx+107−3View fileUnifiedSplit
@@ -18,6 +18,11 @@ import { RepoHeader, RepoNav } from "../views/components";
1818import { ReactionsBar } from "../views/reactions";
1919import { summariseReactions } from "../lib/reactions";
2020import { loadIssueTemplate } from "../lib/templates";
21import {
22 listIssueTemplates,
23 findTemplateBySlug,
24 type IssueTemplate,
25} from "../lib/issue-templates";
2126import { renderMarkdown } from "../lib/markdown";
2227import { softAuth, requireAuth } from "../middleware/auth";
2328import type { AuthEnv } from "../middleware/auth";
@@ -166,7 +171,82 @@ issueRoutes.get(
166171 const { owner: ownerName, repo: repoName } = c.req.param();
167172 const user = c.get("user")!;
168173 const error = c.req.query("error");
169 const template = await loadIssueTemplate(ownerName, repoName);
174 const slug = c.req.query("template");
175
176 // J17 — multi-template selector. Fetch the list first; if there are 2+
177 // templates and the user has not picked one, show a chooser. If exactly
178 // one template exists, use it automatically. Fall back to the legacy
179 // single-file loader when no frontmatter templates are found.
180 const multi = await listIssueTemplates(ownerName, repoName);
181 const picked: IssueTemplate | null = findTemplateBySlug(multi, slug);
182
183 if (!picked && multi.length >= 2 && !slug) {
184 return c.html(
185 <Layout
186 title={`New issue — ${ownerName}/${repoName}`}
187 user={user}
188 >
189 <RepoHeader owner={ownerName} repo={repoName} />
190 <IssueNav owner={ownerName} repo={repoName} active="issues" />
191 <div style="max-width: 720px">
192 <h2 style="margin-bottom: 4px">New issue</h2>
193 <p style="color: var(--text-muted); margin-bottom: 24px">
194 Choose a template to get started, or{" "}
195 <a
196 href={`/${ownerName}/${repoName}/issues/new?template=__blank`}
197 >
198 open a blank issue
199 </a>
200 .
201 </p>
202 <div style="display: flex; flex-direction: column; gap: 12px">
203 {multi.map((t) => (
204 <div style="display: flex; align-items: center; gap: 16px; border: 1px solid var(--border); border-radius: 6px; padding: 16px; background: var(--bg-secondary)">
205 <div style="flex: 1; min-width: 0">
206 <div style="font-weight: 600; margin-bottom: 4px">
207 {t.name}
208 </div>
209 {t.about && (
210 <div style="font-size: 13px; color: var(--text-muted)">
211 {t.about}
212 </div>
213 )}
214 {t.labels.length > 0 && (
215 <div style="margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px">
216 {t.labels.map((l) => (
217 <span style="display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg); border: 1px solid var(--border); color: var(--text-muted)">
218 {l}
219 </span>
220 ))}
221 </div>
222 )}
223 </div>
224 <a
225 href={`/${ownerName}/${repoName}/issues/new?template=${encodeURIComponent(t.slug)}`}
226 class="btn btn-primary"
227 >
228 Get started
229 </a>
230 </div>
231 ))}
232 </div>
233 </div>
234 </Layout>
235 );
236 }
237
238 // Auto-pick the single template when only one exists and no slug is set.
239 const auto = !picked && !slug && multi.length === 1 ? multi[0] : null;
240 const active = picked || auto;
241
242 // Legacy fallback for repos that ship a plain ISSUE_TEMPLATE.md.
243 const legacy =
244 !active && slug !== "__blank"
245 ? await loadIssueTemplate(ownerName, repoName)
246 : null;
247
248 const prefillTitle = active?.title ?? "";
249 const prefillBody = active ? active.body : legacy || "";
170250
171251 return c.html(
172252 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
@@ -174,11 +254,34 @@ issueRoutes.get(
174254 <IssueNav owner={ownerName} repo={repoName} active="issues" />
175255 <div style="max-width: 800px">
176256 <h2 style="margin-bottom: 16px">New issue</h2>
177 {template && (
257 {active && (
258 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
259 Using template <code>{active.path}</code>
260 {multi.length >= 2 && (
261 <>
262 {" — "}
263 <a href={`/${ownerName}/${repoName}/issues/new`}>
264 choose a different template
265 </a>
266 </>
267 )}
268 .
269 </div>
270 )}
271 {!active && legacy && (
178272 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
179273 Using <code>ISSUE_TEMPLATE.md</code> from the default branch.
180274 </div>
181275 )}
276 {active && active.labels.length > 0 && (
277 <div style="margin-bottom: 8px">
278 {active.labels.map((l) => (
279 <span style="display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg-secondary); border: 1px solid var(--border); color: var(--text-muted); margin-right: 6px">
280 {l}
281 </span>
282 ))}
283 </div>
284 )}
182285 {error && (
183286 <div class="auth-error">{decodeURIComponent(error)}</div>
184287 )}
@@ -189,6 +292,7 @@ issueRoutes.get(
189292 name="title"
190293 required
191294 placeholder="Title"
295 value={prefillTitle}
192296 style="font-size: 16px; padding: 10px 14px"
193297 />
194298 </div>
@@ -199,7 +303,7 @@ issueRoutes.get(
199303 placeholder="Leave a comment... (Markdown supported)"
200304 style="font-family: var(--font-mono); font-size: 13px"
201305 >
202 {template || ""}
306 {prefillBody}
203307 </textarea>
204308 </div>
205309 <button type="submit" class="btn btn-primary">
206310