add issue and patch templates
MREADME.md
| @@ -6,10 +6,11 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g | |||
|---|---|---|---|
| 6 | 6 | ||
| 7 | 7 | ## Features | |
| 8 | 8 | ||
| 9 | - | - **Repository browser** — file tree, blob view, commit log, README rendering | |
| 9 | + | - **Repository browser** — file tree, blob view, commit log, README rendering, media previews | |
| 10 | 10 | - **Issues** — create, comment, react | |
| 11 | - | - **Patches** — submit diffs for review & comments. Admin can merge applicable patches directly into the repository. | |
| 12 | - | - **Releases** — tag-based releases with source archives and extra uploaded assets | |
| 11 | + | - **Patches** — submit git .patch files for review & comments. Admin can merge applicable patches directly into the repository. | |
| 12 | + | - **Templates** — issue/patch templates | |
| 13 | + | - **Releases** — releases with source archives, extra uploaded assets, and optional tag creation | |
| 13 | 14 | - **SSH push/pull** — built-in SSH server, no external git daemon needed | |
| 14 | 15 | - **Auth** — password login or passkeys (WebAuthn/FIDO2) | |
| 15 | 16 | - **Optional registration** — others can create accounts to file issues and patches; can be disabled | |
| @@ -94,5 +95,4 @@ bun run test # Playwright E2E tests (don't use bun test, it doesn't res | |||
|---|---|---|---|
| 94 | 95 | - Issue labels | |
| 95 | 96 | - Repository list reordering (e.g. last committed) and starring | |
| 96 | 97 | - redirect image urls in readme | |
| 97 | - | - issue/patch templates | |
| 98 | 98 | - commit signing | |
Msrc/db/index.ts
| @@ -37,6 +37,8 @@ interface RepositoryTable { | |||
|---|---|---|---|
| 37 | 37 | created_at: string; | |
| 38 | 38 | issue_seq: Generated<number>; | |
| 39 | 39 | patch_seq: Generated<number>; | |
| 40 | + | issue_template: string | null; | |
| 41 | + | patch_template: string | null; | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | 44 | interface IssueTable { | |
Msrc/db/schema.sql
| @@ -32,7 +32,9 @@ CREATE TABLE IF NOT EXISTS repositories ( | |||
|---|---|---|---|
| 32 | 32 | default_branch TEXT NOT NULL DEFAULT 'main', | |
| 33 | 33 | created_at TEXT NOT NULL, | |
| 34 | 34 | issue_seq INTEGER NOT NULL DEFAULT 0, | |
| 35 | - | patch_seq INTEGER NOT NULL DEFAULT 0 | |
| 35 | + | patch_seq INTEGER NOT NULL DEFAULT 0, | |
| 36 | + | issue_template TEXT, | |
| 37 | + | patch_template TEXT | |
| 36 | 38 | ); | |
| 37 | 39 | ||
| 38 | 40 | CREATE TABLE IF NOT EXISTS issues ( | |
Msrc/routes/issues.tsx
| @@ -108,7 +108,7 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 108 | 108 | if (deny) return deny; | |
| 109 | 109 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 110 | 110 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 111 | - | return html(<NewIssue user={user!} repo={repo} />); | |
| 111 | + | return html(<NewIssue user={user!} repo={repo} template={repo.issue_template ?? undefined} />); | |
| 112 | 112 | }) | |
| 113 | 113 | ||
| 114 | 114 | .post( | |
Msrc/routes/patches.tsx
| @@ -144,7 +144,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 144 | 144 | if (deny) return deny; | |
| 145 | 145 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 146 | 146 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 147 | - | return html(<NewPatch user={user!} repo={repo} />); | |
| 147 | + | return html(<NewPatch user={user!} repo={repo} template={repo.patch_template ?? undefined} />); | |
| 148 | 148 | }) | |
| 149 | 149 | ||
| 150 | 150 | .post( | |
Msrc/routes/repos.tsx
| @@ -569,7 +569,7 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 569 | 569 | const repo = await getRepo(params.repo, true); | |
| 570 | 570 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 571 | 571 | ||
| 572 | - | const { description, is_private, default_branch } = body; | |
| 572 | + | const { description, is_private, default_branch, issue_template, patch_template } = body; | |
| 573 | 573 | ||
| 574 | 574 | const branches = await git.branches(repo.name); | |
| 575 | 575 | const newBranch = default_branch?.trim() || repo.default_branch; | |
| @@ -592,6 +592,8 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 592 | 592 | description: description?.trim() || null, | |
| 593 | 593 | is_private: is_private === "1" ? 1 : 0, | |
| 594 | 594 | default_branch: newBranch, | |
| 595 | + | issue_template: issue_template?.trim() || null, | |
| 596 | + | patch_template: patch_template?.trim() || null, | |
| 595 | 597 | }) | |
| 596 | 598 | .where("id", "=", repo.id) | |
| 597 | 599 | .execute(); | |
| @@ -620,6 +622,8 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 620 | 622 | description: t.Optional(t.String()), | |
| 621 | 623 | is_private: t.Optional(t.String()), | |
| 622 | 624 | default_branch: t.Optional(t.String()), | |
| 625 | + | issue_template: t.Optional(t.String()), | |
| 626 | + | patch_template: t.Optional(t.String()), | |
| 623 | 627 | }), | |
| 624 | 628 | }, | |
| 625 | 629 | ) | |
Msrc/views/issues/NewIssue.tsx
| @@ -8,12 +8,13 @@ interface NewIssueProps { | |||
|---|---|---|---|
| 8 | 8 | user: SessionUser; | |
| 9 | 9 | repo: RepositoryRow; | |
| 10 | 10 | error?: string; | |
| 11 | + | template?: string; | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | - | export function NewIssue({ user, repo, error }: NewIssueProps) { | |
| 14 | + | export function NewIssue({ user, repo, error, template }: NewIssueProps) { | |
| 14 | 15 | return ( | |
| 15 | 16 | <Layout user={user} title={`New issue — ${repo.name}`}> | |
| 16 | - | <div class="container container-narrow"> | |
| 17 | + | <div class="container"> | |
| 17 | 18 | <RepoHeader repo={repo} /> | |
| 18 | 19 | <RepoNav repo={repo} active="issues" user={user} /> | |
| 19 | 20 | <h2 class="section-title">New issue</h2> | |
| @@ -45,7 +46,9 @@ export function NewIssue({ user, repo, error }: NewIssueProps) { | |||
|---|---|---|---|
| 45 | 46 | name="body" | |
| 46 | 47 | rows="10" | |
| 47 | 48 | placeholder="Describe the issue..." | |
| 48 | - | /> | |
| 49 | + | > | |
| 50 | + | {template ?? ""} | |
| 51 | + | </textarea> | |
| 49 | 52 | </div> | |
| 50 | 53 | <div class="form-actions"> | |
| 51 | 54 | <button type="submit" class="btn btn-primary"> | |
Msrc/views/patches/NewPatch.tsx
| @@ -8,12 +8,13 @@ interface NewPatchProps { | |||
|---|---|---|---|
| 8 | 8 | user: SessionUser; | |
| 9 | 9 | repo: RepositoryRow; | |
| 10 | 10 | error?: string; | |
| 11 | + | template?: string; | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 13 | - | export function NewPatch({ user, repo, error }: NewPatchProps) { | |
| 14 | + | export function NewPatch({ user, repo, error, template }: NewPatchProps) { | |
| 14 | 15 | return ( | |
| 15 | 16 | <Layout user={user} title={`New patch — ${repo.name}`}> | |
| 16 | - | <div class="container container-narrow"> | |
| 17 | + | <div class="container"> | |
| 17 | 18 | <RepoHeader repo={repo} /> | |
| 18 | 19 | <RepoNav repo={repo} active="patches" user={user} /> | |
| 19 | 20 | <h2 class="section-title">Upload patch</h2> | |
| @@ -45,7 +46,9 @@ export function NewPatch({ user, repo, error }: NewPatchProps) { | |||
|---|---|---|---|
| 45 | 46 | id="description" | |
| 46 | 47 | name="description" | |
| 47 | 48 | rows="5" | |
| 48 | - | /> | |
| 49 | + | > | |
| 50 | + | {template ?? ""} | |
| 51 | + | </textarea> | |
| 49 | 52 | </div> | |
| 50 | 53 | <div class="form-group"> | |
| 51 | 54 | <label for="patch_file"> | |
Msrc/views/repos/NewRepo.tsx
| @@ -54,7 +54,7 @@ export function NewRepo({ user, error }: NewRepoProps) { | |||
|---|---|---|---|
| 54 | 54 | name="is_private" | |
| 55 | 55 | value="1" | |
| 56 | 56 | /> | |
| 57 | - | Private repository | |
| 57 | + | Private repository (only visible to you) | |
| 58 | 58 | </label> | |
| 59 | 59 | </div> | |
| 60 | 60 | <button type="submit" class="btn btn-primary"> | |
Msrc/views/repos/RepoSettings.tsx
| @@ -21,7 +21,7 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 21 | 21 | }: RepoSettingsProps) { | |
| 22 | 22 | return ( | |
| 23 | 23 | <Layout user={user} title={`Settings — ${repo.name}`}> | |
| 24 | - | <div class="container container-narrow"> | |
| 24 | + | <div class="container"> | |
| 25 | 25 | <RepoHeader repo={repo} /> | |
| 26 | 26 | <RepoNav repo={repo} active="settings" user={user} /> | |
| 27 | 27 | {success && <p class="form-success">{success}</p>} | |
| @@ -77,9 +77,41 @@ export function RepoSettings({ | |||
|---|---|---|---|
| 77 | 77 | value="1" | |
| 78 | 78 | checked={repo.is_private === 1} | |
| 79 | 79 | /> | |
| 80 | - | Private repository | |
| 80 | + | Private repository (only visible to you) | |
| 81 | 81 | </label> | |
| 82 | 82 | </div> | |
| 83 | + | <div class="form-group"> | |
| 84 | + | <label for="issue_template"> | |
| 85 | + | Issue template{" "} | |
| 86 | + | <span class="text-muted"> | |
| 87 | + | (Markdown, prefilled when opening a new issue) | |
| 88 | + | </span> | |
| 89 | + | </label> | |
| 90 | + | <textarea | |
| 91 | + | id="issue_template" | |
| 92 | + | name="issue_template" | |
| 93 | + | rows="8" | |
| 94 | + | placeholder="## Description ## Steps to reproduce ## Expected behavior" | |
| 95 | + | > | |
| 96 | + | {repo.issue_template ?? ""} | |
| 97 | + | </textarea> | |
| 98 | + | </div> | |
| 99 | + | <div class="form-group"> | |
| 100 | + | <label for="patch_template"> | |
| 101 | + | Patch template{" "} | |
| 102 | + | <span class="text-muted"> | |
| 103 | + | (Markdown, prefilled when submitting a new patch) | |
| 104 | + | </span> | |
| 105 | + | </label> | |
| 106 | + | <textarea | |
| 107 | + | id="patch_template" | |
| 108 | + | name="patch_template" | |
| 109 | + | rows="8" | |
| 110 | + | placeholder="## Summary ## Testing" | |
| 111 | + | > | |
| 112 | + | {repo.patch_template ?? ""} | |
| 113 | + | </textarea> | |
| 114 | + | </div> | |
| 83 | 115 | <button type="submit" class="btn btn-primary"> | |
| 84 | 116 | Save settings | |
| 85 | 117 | </button> | |
Mtests/e2e.test.ts
| @@ -2296,3 +2296,70 @@ describe('repo description', () => { | |||
|---|---|---|---|
| 2296 | 2296 | } finally { await page.close(); } | |
| 2297 | 2297 | }); | |
| 2298 | 2298 | }); | |
| 2299 | + | ||
| 2300 | + | // ─── Issue and patch templates ──────────────────────────────────────────────── | |
| 2301 | + | ||
| 2302 | + | describe('issue and patch templates', () => { | |
| 2303 | + | let adminCtx: BrowserContext; | |
| 2304 | + | ||
| 2305 | + | beforeAll(async () => { adminCtx = await loggedInContext(); }); | |
| 2306 | + | afterAll(async () => { await adminCtx.close(); }); | |
| 2307 | + | ||
| 2308 | + | test('issue template can be saved and is prefilled on new issue form', async () => { | |
| 2309 | + | const page = await adminCtx.newPage(); | |
| 2310 | + | try { | |
| 2311 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 2312 | + | await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior'); | |
| 2313 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2314 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2315 | + | ||
| 2316 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 2317 | + | const body = await page.locator('[name=body]').inputValue(); | |
| 2318 | + | expect(body).toContain('## Steps to reproduce'); | |
| 2319 | + | expect(body).toContain('## Expected behavior'); | |
| 2320 | + | } finally { await page.close(); } | |
| 2321 | + | }); | |
| 2322 | + | ||
| 2323 | + | test('patch template can be saved and is prefilled on new patch form', async () => { | |
| 2324 | + | const page = await adminCtx.newPage(); | |
| 2325 | + | try { | |
| 2326 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 2327 | + | await page.fill('[name=patch_template]', '## Summary\n\n## Testing'); | |
| 2328 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2329 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2330 | + | ||
| 2331 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 2332 | + | const desc = await page.locator('[name=description]').inputValue(); | |
| 2333 | + | expect(desc).toContain('## Summary'); | |
| 2334 | + | expect(desc).toContain('## Testing'); | |
| 2335 | + | } finally { await page.close(); } | |
| 2336 | + | }); | |
| 2337 | + | ||
| 2338 | + | test('clearing the issue template removes prefill', async () => { | |
| 2339 | + | const page = await adminCtx.newPage(); | |
| 2340 | + | try { | |
| 2341 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 2342 | + | await page.fill('[name=issue_template]', ''); | |
| 2343 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2344 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2345 | + | ||
| 2346 | + | await page.goto(`${BASE}/my-repo/issues/new`); | |
| 2347 | + | const body = await page.locator('[name=body]').inputValue(); | |
| 2348 | + | expect(body).toBe(''); | |
| 2349 | + | } finally { await page.close(); } | |
| 2350 | + | }); | |
| 2351 | + | ||
| 2352 | + | test('clearing the patch template removes prefill', async () => { | |
| 2353 | + | const page = await adminCtx.newPage(); | |
| 2354 | + | try { | |
| 2355 | + | await page.goto(`${BASE}/my-repo/settings`); | |
| 2356 | + | await page.fill('[name=patch_template]', ''); | |
| 2357 | + | await page.click('form[action$="/settings"] button[type=submit]'); | |
| 2358 | + | expect(await page.locator('.form-success').isVisible()).toBe(true); | |
| 2359 | + | ||
| 2360 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 2361 | + | const desc = await page.locator('[name=description]').inputValue(); | |
| 2362 | + | expect(desc).toBe(''); | |
| 2363 | + | } finally { await page.close(); } | |
| 2364 | + | }); | |
| 2365 | + | }); | |