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
## Features
- **Repository browser** — file tree, blob view, commit log, README rendering
- **Repository browser** — file tree, blob view, commit log, README rendering, media previews
- **Issues** — create, comment, react
- **Patches** — submit diffs for review & comments. Admin can merge applicable patches directly into the repository.
- **Releases** — tag-based releases with source archives and extra uploaded assets
- **Patches** — submit git .patch files for review & comments. Admin can merge applicable patches directly into the repository.
- **Templates** — issue/patch templates
- **Releases** — releases with source archives, extra uploaded assets, and optional tag creation
- **SSH push/pull** — built-in SSH server, no external git daemon needed
- **Auth** — password login or passkeys (WebAuthn/FIDO2)
- **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
- Issue labels
- Repository list reordering (e.g. last committed) and starring
- redirect image urls in readme
- issue/patch templates
- commit signing
Msrc/db/index.ts
@@ -37,6 +37,8 @@ interface RepositoryTable {
created_at: string;
issue_seq: Generated<number>;
patch_seq: Generated<number>;
issue_template: string | null;
patch_template: string | null;
}
interface IssueTable {
Msrc/db/schema.sql
@@ -32,7 +32,9 @@ CREATE TABLE IF NOT EXISTS repositories (
default_branch TEXT NOT NULL DEFAULT 'main',
created_at TEXT NOT NULL,
issue_seq INTEGER NOT NULL DEFAULT 0,
patch_seq INTEGER NOT NULL DEFAULT 0
patch_seq INTEGER NOT NULL DEFAULT 0,
issue_template TEXT,
patch_template TEXT
);
CREATE TABLE IF NOT EXISTS issues (
Msrc/routes/issues.tsx
@@ -108,7 +108,7 @@ export const issueRoutes = new Elysia()
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
return html(<NewIssue user={user!} repo={repo} />);
return html(<NewIssue user={user!} repo={repo} template={repo.issue_template ?? undefined} />);
})
.post(
Msrc/routes/patches.tsx
@@ -144,7 +144,7 @@ export const patchRoutes = new Elysia()
if (deny) return deny;
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
return html(<NewPatch user={user!} repo={repo} />);
return html(<NewPatch user={user!} repo={repo} template={repo.patch_template ?? undefined} />);
})
.post(
Msrc/routes/repos.tsx
@@ -569,7 +569,7 @@ export const repoRoutes = new Elysia()
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const { description, is_private, default_branch } = body;
const { description, is_private, default_branch, issue_template, patch_template } = body;
const branches = await git.branches(repo.name);
const newBranch = default_branch?.trim() || repo.default_branch;
@@ -592,6 +592,8 @@ export const repoRoutes = new Elysia()
description: description?.trim() || null,
is_private: is_private === "1" ? 1 : 0,
default_branch: newBranch,
issue_template: issue_template?.trim() || null,
patch_template: patch_template?.trim() || null,
})
.where("id", "=", repo.id)
.execute();
@@ -620,6 +622,8 @@ export const repoRoutes = new Elysia()
description: t.Optional(t.String()),
is_private: t.Optional(t.String()),
default_branch: t.Optional(t.String()),
issue_template: t.Optional(t.String()),
patch_template: t.Optional(t.String()),
}),
},
)
Msrc/views/issues/NewIssue.tsx
@@ -8,12 +8,13 @@ interface NewIssueProps {
user: SessionUser;
repo: RepositoryRow;
error?: string;
template?: string;
}
export function NewIssue({ user, repo, error }: NewIssueProps) {
export function NewIssue({ user, repo, error, template }: NewIssueProps) {
return (
<Layout user={user} title={`New issue — ${repo.name}`}>
<div class="container container-narrow">
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="issues" user={user} />
<h2 class="section-title">New issue</h2>
@@ -45,7 +46,9 @@ export function NewIssue({ user, repo, error }: NewIssueProps) {
name="body"
rows="10"
placeholder="Describe the issue..."
/>
>
{template ?? ""}
</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Msrc/views/patches/NewPatch.tsx
@@ -8,12 +8,13 @@ interface NewPatchProps {
user: SessionUser;
repo: RepositoryRow;
error?: string;
template?: string;
}
export function NewPatch({ user, repo, error }: NewPatchProps) {
export function NewPatch({ user, repo, error, template }: NewPatchProps) {
return (
<Layout user={user} title={`New patch — ${repo.name}`}>
<div class="container container-narrow">
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="patches" user={user} />
<h2 class="section-title">Upload patch</h2>
@@ -45,7 +46,9 @@ export function NewPatch({ user, repo, error }: NewPatchProps) {
id="description"
name="description"
rows="5"
/>
>
{template ?? ""}
</textarea>
</div>
<div class="form-group">
<label for="patch_file">
Msrc/views/repos/NewRepo.tsx
@@ -54,7 +54,7 @@ export function NewRepo({ user, error }: NewRepoProps) {
name="is_private"
value="1"
/>
Private repository
Private repository (only visible to you)
</label>
</div>
<button type="submit" class="btn btn-primary">
Msrc/views/repos/RepoSettings.tsx
@@ -21,7 +21,7 @@ export function RepoSettings({
}: RepoSettingsProps) {
return (
<Layout user={user} title={`Settings — ${repo.name}`}>
<div class="container container-narrow">
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="settings" user={user} />
{success && <p class="form-success">{success}</p>}
@@ -77,9 +77,41 @@ export function RepoSettings({
value="1"
checked={repo.is_private === 1}
/>
Private repository
Private repository (only visible to you)
</label>
</div>
<div class="form-group">
<label for="issue_template">
Issue template{" "}
<span class="text-muted">
(Markdown, prefilled when opening a new issue)
</span>
</label>
<textarea
id="issue_template"
name="issue_template"
rows="8"
placeholder="## Description ## Steps to reproduce ## Expected behavior"
>
{repo.issue_template ?? ""}
</textarea>
</div>
<div class="form-group">
<label for="patch_template">
Patch template{" "}
<span class="text-muted">
(Markdown, prefilled when submitting a new patch)
</span>
</label>
<textarea
id="patch_template"
name="patch_template"
rows="8"
placeholder="## Summary ## Testing"
>
{repo.patch_template ?? ""}
</textarea>
</div>
<button type="submit" class="btn btn-primary">
Save settings
</button>
Mtests/e2e.test.ts
@@ -2296,3 +2296,70 @@ describe('repo description', () => {
} finally { await page.close(); }
});
});
// ─── Issue and patch templates ────────────────────────────────────────────────
describe('issue and patch templates', () => {
let adminCtx: BrowserContext;
beforeAll(async () => { adminCtx = await loggedInContext(); });
afterAll(async () => { await adminCtx.close(); });
test('issue template can be saved and is prefilled on new issue form', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=issue_template]', '## Steps to reproduce\n\n## Expected behavior');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/issues/new`);
const body = await page.locator('[name=body]').inputValue();
expect(body).toContain('## Steps to reproduce');
expect(body).toContain('## Expected behavior');
} finally { await page.close(); }
});
test('patch template can be saved and is prefilled on new patch form', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=patch_template]', '## Summary\n\n## Testing');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/patches/new`);
const desc = await page.locator('[name=description]').inputValue();
expect(desc).toContain('## Summary');
expect(desc).toContain('## Testing');
} finally { await page.close(); }
});
test('clearing the issue template removes prefill', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=issue_template]', '');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/issues/new`);
const body = await page.locator('[name=body]').inputValue();
expect(body).toBe('');
} finally { await page.close(); }
});
test('clearing the patch template removes prefill', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/settings`);
await page.fill('[name=patch_template]', '');
await page.click('form[action$="/settings"] button[type=submit]');
expect(await page.locator('.form-success').isVisible()).toBe(true);
await page.goto(`${BASE}/my-repo/patches/new`);
const desc = await page.locator('[name=description]').inputValue();
expect(desc).toBe('');
} finally { await page.close(); }
});
});