add issue and patch templates

AuthorKonata <konata@posteo.jp>
Date
Commit1949c20535325d6d65712c612715c0000d7e0f3e
Parent6f66d1c
11 files changed, 130 insertions(+), 17 deletions(-)
MREADME.md
@@ -6,10 +6,11 @@ Frontend works without any JS at all enabled, just required for WebAuthn (with g
66
77 ## Features
88
9-- **Repository browser** — file tree, blob view, commit log, README rendering
9+- **Repository browser** — file tree, blob view, commit log, README rendering, media previews
1010 - **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
1314 - **SSH push/pull** — built-in SSH server, no external git daemon needed
1415 - **Auth** — password login or passkeys (WebAuthn/FIDO2)
1516 - **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
9495 - Issue labels
9596 - Repository list reordering (e.g. last committed) and starring
9697 - redirect image urls in readme
97-- issue/patch templates
9898 - commit signing
Msrc/db/index.ts
@@ -37,6 +37,8 @@ interface RepositoryTable {
3737 created_at: string;
3838 issue_seq: Generated<number>;
3939 patch_seq: Generated<number>;
40+ issue_template: string | null;
41+ patch_template: string | null;
4042 }
4143
4244 interface IssueTable {
Msrc/db/schema.sql
@@ -32,7 +32,9 @@ CREATE TABLE IF NOT EXISTS repositories (
3232 default_branch TEXT NOT NULL DEFAULT 'main',
3333 created_at TEXT NOT NULL,
3434 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
3638 );
3739
3840 CREATE TABLE IF NOT EXISTS issues (
Msrc/routes/issues.tsx
@@ -108,7 +108,7 @@ export const issueRoutes = new Elysia()
108108 if (deny) return deny;
109109 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
110110 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} />);
112112 })
113113
114114 .post(
Msrc/routes/patches.tsx
@@ -144,7 +144,7 @@ export const patchRoutes = new Elysia()
144144 if (deny) return deny;
145145 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
146146 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} />);
148148 })
149149
150150 .post(
Msrc/routes/repos.tsx
@@ -569,7 +569,7 @@ export const repoRoutes = new Elysia()
569569 const repo = await getRepo(params.repo, true);
570570 if (!repo) return new Response("Not found", { status: 404 });
571571
572- const { description, is_private, default_branch } = body;
572+ const { description, is_private, default_branch, issue_template, patch_template } = body;
573573
574574 const branches = await git.branches(repo.name);
575575 const newBranch = default_branch?.trim() || repo.default_branch;
@@ -592,6 +592,8 @@ export const repoRoutes = new Elysia()
592592 description: description?.trim() || null,
593593 is_private: is_private === "1" ? 1 : 0,
594594 default_branch: newBranch,
595+ issue_template: issue_template?.trim() || null,
596+ patch_template: patch_template?.trim() || null,
595597 })
596598 .where("id", "=", repo.id)
597599 .execute();
@@ -620,6 +622,8 @@ export const repoRoutes = new Elysia()
620622 description: t.Optional(t.String()),
621623 is_private: t.Optional(t.String()),
622624 default_branch: t.Optional(t.String()),
625+ issue_template: t.Optional(t.String()),
626+ patch_template: t.Optional(t.String()),
623627 }),
624628 },
625629 )
Msrc/views/issues/NewIssue.tsx
@@ -8,12 +8,13 @@ interface NewIssueProps {
88 user: SessionUser;
99 repo: RepositoryRow;
1010 error?: string;
11+ template?: string;
1112 }
1213
13-export function NewIssue({ user, repo, error }: NewIssueProps) {
14+export function NewIssue({ user, repo, error, template }: NewIssueProps) {
1415 return (
1516 <Layout user={user} title={`New issue — ${repo.name}`}>
16- <div class="container container-narrow">
17+ <div class="container">
1718 <RepoHeader repo={repo} />
1819 <RepoNav repo={repo} active="issues" user={user} />
1920 <h2 class="section-title">New issue</h2>
@@ -45,7 +46,9 @@ export function NewIssue({ user, repo, error }: NewIssueProps) {
4546 name="body"
4647 rows="10"
4748 placeholder="Describe the issue..."
48- />
49+ >
50+ {template ?? ""}
51+ </textarea>
4952 </div>
5053 <div class="form-actions">
5154 <button type="submit" class="btn btn-primary">
Msrc/views/patches/NewPatch.tsx
@@ -8,12 +8,13 @@ interface NewPatchProps {
88 user: SessionUser;
99 repo: RepositoryRow;
1010 error?: string;
11+ template?: string;
1112 }
1213
13-export function NewPatch({ user, repo, error }: NewPatchProps) {
14+export function NewPatch({ user, repo, error, template }: NewPatchProps) {
1415 return (
1516 <Layout user={user} title={`New patch — ${repo.name}`}>
16- <div class="container container-narrow">
17+ <div class="container">
1718 <RepoHeader repo={repo} />
1819 <RepoNav repo={repo} active="patches" user={user} />
1920 <h2 class="section-title">Upload patch</h2>
@@ -45,7 +46,9 @@ export function NewPatch({ user, repo, error }: NewPatchProps) {
4546 id="description"
4647 name="description"
4748 rows="5"
48- />
49+ >
50+ {template ?? ""}
51+ </textarea>
4952 </div>
5053 <div class="form-group">
5154 <label for="patch_file">
Msrc/views/repos/NewRepo.tsx
@@ -54,7 +54,7 @@ export function NewRepo({ user, error }: NewRepoProps) {
5454 name="is_private"
5555 value="1"
5656 />
57- Private repository
57+ Private repository (only visible to you)
5858 </label>
5959 </div>
6060 <button type="submit" class="btn btn-primary">
Msrc/views/repos/RepoSettings.tsx
@@ -21,7 +21,7 @@ export function RepoSettings({
2121 }: RepoSettingsProps) {
2222 return (
2323 <Layout user={user} title={`Settings — ${repo.name}`}>
24- <div class="container container-narrow">
24+ <div class="container">
2525 <RepoHeader repo={repo} />
2626 <RepoNav repo={repo} active="settings" user={user} />
2727 {success && <p class="form-success">{success}</p>}
@@ -77,9 +77,41 @@ export function RepoSettings({
7777 value="1"
7878 checked={repo.is_private === 1}
7979 />
80- Private repository
80+ Private repository (only visible to you)
8181 </label>
8282 </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&#10;&#10;## Steps to reproduce&#10;&#10;## 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&#10;&#10;## Testing"
111+ >
112+ {repo.patch_template ?? ""}
113+ </textarea>
114+ </div>
83115 <button type="submit" class="btn btn-primary">
84116 Save settings
85117 </button>
Mtests/e2e.test.ts
@@ -2296,3 +2296,70 @@ describe('repo description', () => {
22962296 } finally { await page.close(); }
22972297 });
22982298 });
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+});