remove git identity settings again, add back admin name&email env vars, drop .diff support, use metadata from .patch files

AuthorKonata <konata@posteo.jp>
Date
Commit6f66d1c03bc86dfd9c1da10ac53bae45b55eb01b
Parentf2122aa
13 files changed, 310 insertions(+), 210 deletions(-)
▾MREADME.md
@@ -48,22 +48,24 @@ podman compose up
All settings are environment variables:
| Variable | Default | Description |
|-------------------------|-------------------------|-------------------------------------------------|
| `PORT` | `3000` | HTTP port |
| `SSH_PORT` | `2222` | SSH port |
| `DATA_DIR` | `./data` | Repos, database, uploads |
| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
| `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups |
| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
| Variable | Default | Description |
|-------------------------|----------------------------------|-------------------------------------------------|
| `PORT` | `3000` | HTTP port |
| `SSH_PORT` | `2222` | SSH port |
| `DATA_DIR` | `./data` | Repos, database, uploads |
| `ADMIN_PASSWORD` | `changeme` | Initial admin password |
| `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner |
| `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links |
| `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups |
| `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) |
| `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) |
| `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view |
| `SSH_DISABLED` | `0` | Disable the embedded SSH-server |
| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` |
| `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
| `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* |
| `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches |
| `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | Git committer email used when merging patches |
\* More workers mean more CPU cores can be used to parallelize highlighting of files.
Because of the language grammars, which can't be shared across workers, the memory usage per worker is quite high, at about 200MB.
@@ -92,3 +94,5 @@ 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/config.ts
@@ -18,3 +18,7 @@ export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`;
export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data");
export const HIGHLIGHT_WORKERS =
parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4;
export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME;
export const COMMITTER_EMAIL =
process.env.COMMITTER_EMAIL ??
`${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`;
▾Msrc/db/index.ts
@@ -10,8 +10,6 @@ interface UserTable {
password_hash: string | null;
created_at: string;
avatar_version: Generated<number>;
git_name: string | null;
git_email: string | null;
}
interface PasskeyTable {
▾Msrc/middleware/session.ts
@@ -6,8 +6,6 @@ export interface SessionUser {
username: string;
isAdmin: boolean;
avatar_version: number;
git_name: string | null;
git_email: string | null;
}
export async function resolveSession(
@@ -22,8 +20,6 @@ export async function resolveSession(
"users.id",
"users.username",
"users.avatar_version",
"users.git_name",
"users.git_email",
"sessions.expires_at",
])
.where("sessions.id", "=", cookie)
@@ -35,8 +31,6 @@ export async function resolveSession(
username: session.username,
isAdmin: session.username === ADMIN_USERNAME,
avatar_version: session.avatar_version,
git_name: session.git_name,
git_email: session.git_email,
};
}
▾Msrc/routes/patches.tsx
@@ -1,6 +1,10 @@
import { Elysia, t } from "elysia";
import { sql } from "kysely";
import { MAX_USER_UPLOAD_BYTES } from "../config.ts";
import {
COMMITTER_EMAIL,
COMMITTER_NAME,
MAX_USER_UPLOAD_BYTES,
} from "../config.ts";
import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import {
@@ -8,7 +12,7 @@ import {
requireAuth,
resolveSession,
} from "../middleware/session.ts";
import { git } from "../services/git.ts";
import { extractPatchMeta, git } from "../services/git.ts";
import { prepareDiff } from "../services/highlightWorker.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { patchCache } from "../services/patchCache.ts";
@@ -152,16 +156,6 @@ export const patchRoutes = new Elysia()
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
if (!user!.git_name?.trim() || !user!.git_email?.trim()) {
return html(
<NewPatch
user={user!}
repo={repo}
error="You must set your git name and email in Settings before creating a patch"
/>,
);
}
if (!body.title?.trim()) {
return html(
<NewPatch
@@ -203,17 +197,45 @@ export const patchRoutes = new Elysia()
);
}
// Validate it looks like a patch/diff file
// Validate it looks like a patch file
if (!isValidPatch(patchContent)) {
return html(
<NewPatch
user={user!}
repo={repo}
error="File does not appear to be a valid patch or diff file"
error="File does not appear to be a valid patch file"
/>,
);
}
const uploadMeta = extractPatchMeta(patchContent);
if (!uploadMeta.subject) {
return html(
<NewPatch
user={user!}
repo={repo}
error="Patch is missing a Subject header. Make sure to upload a patch created with git format-patch."
/>,
);
}
if (!uploadMeta.author || !uploadMeta.email) {
return html(
<NewPatch
user={user!}
repo={repo}
error="Patch is missing a From header with name and email."
/>,
);
}
if (!uploadMeta.date) {
return html(
<NewPatch
user={user!}
repo={repo}
error="Patch is missing a Date header."
/>,
);
}
const now = new Date().toISOString();
const { number, result } = await db
.transaction()
@@ -234,8 +256,8 @@ export const patchRoutes = new Elysia()
description: body.description?.trim() ?? "",
patch_content: patchContent,
status: "open",
author_name: user!.git_name!,
author_email: user!.git_email!,
author_name: uploadMeta.author,
author_email: uploadMeta.email,
created_at: now,
updated_at: now,
})
@@ -353,6 +375,8 @@ export const patchRoutes = new Elysia()
? ("changes" as const)
: ("conversation" as const);
const patchMeta = extractPatchMeta(patch.patch_content);
return html(
<PatchDetail
user={user}
@@ -369,6 +393,7 @@ export const patchRoutes = new Elysia()
applyResult={applyResult}
files={files}
tab={tab}
patchMeta={patchMeta}
comments={
commentsWithHtml as ((typeof commentsWithHtml)[0] & {
author_username: string;
@@ -390,28 +415,13 @@ export const patchRoutes = new Elysia()
const deny = requireAdmin(user);
if (deny) return deny;
if (!user!.git_name?.trim() || !user!.git_email?.trim()) {
return new Response(
"Set your git name and email in Settings before merging",
{ status: 400, headers: { "Content-Type": "text/plain" } },
);
}
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const patchNum = parseInt(params.number, 10);
const patch = await db
.selectFrom("patches")
.select([
"id",
"title",
"description",
"patch_content",
"status",
"author_name",
"author_email",
])
.select(["id", "patch_content", "status"])
.where("repo_id", "=", repo.id)
.where("number", "=", patchNum)
.executeTakeFirst();
@@ -428,16 +438,15 @@ export const patchRoutes = new Elysia()
if (!claimed || claimed.numUpdatedRows === 0n)
return new Response("Patch is not open", { status: 400 });
const mergeMeta = extractPatchMeta(patch.patch_content);
try {
await git.applyPatch(
repo.name,
patch.patch_content,
patch.title,
patch.description,
patch.author_name,
patch.author_email,
user!.git_name!,
user!.git_email!,
mergeMeta.author,
mergeMeta.email,
COMMITTER_NAME,
COMMITTER_EMAIL,
);
} catch (err) {
// Roll back the status if the git operation fails
▾Msrc/routes/releases.tsx
@@ -1,6 +1,7 @@
import { mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
@@ -130,17 +131,6 @@ export const releasesRoutes = new Elysia()
include_source_code: body.include_source_code === "on",
};
if (createTag && (!user!.git_name?.trim() || !user!.git_email?.trim())) {
return html(
<NewRelease
user={user!}
repo={repo}
error="You must set your git name and email in Settings before creating a tagged release"
values={formValues}
/>,
);
}
if (!name) {
return html(
<NewRelease
@@ -194,8 +184,8 @@ export const releasesRoutes = new Elysia()
tagName,
revision,
tagMessage,
user!.git_name!,
user!.git_email!,
COMMITTER_NAME,
COMMITTER_EMAIL,
);
if (tagResult === "already_exists") {
return html(
▾Msrc/routes/settings.tsx
@@ -34,7 +34,7 @@ export const settingsRoutes = new Elysia()
const userRow = await db
.selectFrom("users")
.select(["id", "password_hash", "git_name", "git_email"])
.select(["id", "password_hash"])
.where("id", "=", user.id)
.executeTakeFirst();
@@ -61,8 +61,6 @@ export const settingsRoutes = new Elysia()
<Settings
user={user}
hasPassword={hasPassword}
gitName={userRow?.git_name ?? null}
gitEmail={userRow?.git_email ?? null}
passkeys={passkeys}
sshKeys={sshKeys}
theme={theme}
@@ -346,40 +344,6 @@ export const settingsRoutes = new Elysia()
},
)
.post(
"/settings/git-identity",
async ({ cookie, body }) => {
const user = await resolveSession(
cookie.session?.value as string | undefined,
);
if (!user) return redirect("/login");
const name = body.git_name.trim();
const email = body.git_email.trim();
if (!name) {
return redirect("/settings?error=Git+name+is+required");
}
if (!email) {
return redirect("/settings?error=Git+email+is+required");
}
await db
.updateTable("users")
.set({ git_name: name, git_email: email })
.where("id", "=", user.id)
.execute();
return redirect("/settings?success=git_identity");
},
{
body: t.Object({
git_name: t.String(),
git_email: t.String(),
}),
},
)
.post(
"/settings/ssh-keys",
async ({ cookie, body }) => {
▾Msrc/services/git.ts
@@ -173,7 +173,7 @@ function parseLsTree(out: string): TreeEntry[] {
});
}
function extractPatchSubject(patch: string): string {
export function extractPatchSubject(patch: string): string {
for (const line of patch.split("\n").slice(0, 30)) {
if (line.startsWith("Subject: ")) {
// Strip "[PATCH ...] " prefix added by git format-patch
@@ -183,6 +183,58 @@ function extractPatchSubject(patch: string): string {
return "";
}
export interface PatchMeta {
subject: string;
body: string;
author: string;
email: string;
date: string;
}
export function extractPatchMeta(patch: string): PatchMeta {
const lines = patch.split("\n");
let subject = "";
let author = "";
let email = "";
let date = "";
const bodyLines: string[] = [];
let inHeaders = true;
let pastSubject = false;
for (const line of lines) {
if (inHeaders) {
if (line.startsWith("From: ")) {
const match = line.slice(6).match(/^(.*?)\s*<([^>]+)>/);
if (match) {
author = match[1]!.trim();
email = match[2]!;
} else {
author = line.slice(6).trim();
}
} else if (line.startsWith("Date: ")) {
date = line.slice(6).trim();
} else if (line.startsWith("Subject: ")) {
subject = line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, "");
pastSubject = true;
} else if (pastSubject && line === "") {
inHeaders = false;
}
} else {
if (line === "---") break;
bodyLines.push(line);
}
}
while (
bodyLines.length > 0 &&
bodyLines[bodyLines.length - 1]!.trim() === ""
) {
bodyLines.pop();
}
return { subject, body: bodyLines.join("\n"), author, email, date };
}
export const git = {
async init(name: string, branch = "main") {
return withRepoLock(name, async () => {
@@ -371,8 +423,6 @@ export const git = {
async applyPatch(
name: string,
patchContent: string,
title: string,
description: string,
authorName: string,
authorEmail: string,
committerName: string,
@@ -390,10 +440,7 @@ export const git = {
const parent = (
await $`git -C ${p} rev-parse HEAD`.text()
).trim();
const fallback = description.trim()
? `${title}\n\n${description.trim()}`
: title;
const msg = extractPatchSubject(patchContent) || fallback;
const msg = extractPatchSubject(patchContent);
const commit = (
await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}`
.env({
▾Msrc/views/Settings.tsx
@@ -6,8 +6,6 @@ import { Layout } from "./layout.tsx";
interface SettingsProps {
user: SessionUser;
hasPassword: boolean;
gitName: string | null;
gitEmail: string | null;
passkeys: { id: number; created_at: string }[];
sshKeys: {
id: number;
@@ -29,14 +27,11 @@ const successMessages: Record<string, string> = {
user_deleted: "Account deleted.",
ssh_key_added: "SSH key added.",
ssh_key_deleted: "SSH key removed.",
git_identity: "Git identity saved.",
};
export function Settings({
user,
hasPassword,
gitName,
gitEmail,
passkeys,
sshKeys,
theme,
@@ -102,55 +97,6 @@ export function Settings({
</div>
</div>
{/* Git Identity */}
<div class="form-card">
<h2 class="section-title">Git Identity</h2>
<p class="text-muted">
Used as the author when creating patches. Required to
submit patches.
</p>
<form
method="POST"
action="/settings/git-identity"
class="settings-form"
style="margin-top: var(--space-4)"
>
<div class="form-group">
<label class="form-label" for="git_name">
Name
</label>
<input
class="form-input"
type="text"
id="git_name"
name="git_name"
value={gitName ?? ""}
autocomplete="name"
required
/>
</div>
<div class="form-group">
<label class="form-label" for="git_email">
Email
</label>
<input
class="form-input"
type="email"
id="git_email"
name="git_email"
value={gitEmail ?? ""}
autocomplete="email"
required
/>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">
Save
</button>
</div>
</form>
</div>
{/* Appearance */}
<div class="form-card">
<h2 class="section-title">Appearance</h2>
▾Msrc/views/issues/NewIssue.tsx
@@ -36,7 +36,9 @@ export function NewIssue({ user, repo, error }: NewIssueProps) {
<div class="form-group">
<label for="body">
Description{" "}
<span class="text-muted">(Markdown supported)</span>
<span class="text-muted">
(Markdown supported, optional)
</span>
</label>
<textarea
id="body"
▾Msrc/views/patches/NewPatch.tsx
@@ -49,14 +49,13 @@ export function NewPatch({ user, repo, error }: NewPatchProps) {
</div>
<div class="form-group">
<label for="patch_file">
Patch file{" "}
<span class="text-muted">(.patch or .diff)</span>
Patch file <span class="text-muted">(.patch)</span>
</label>
<input
id="patch_file"
name="patch_file"
type="file"
accept=".patch,.diff,text/plain"
accept=".patch"
required
/>
</div>
▾Msrc/views/patches/PatchDetail.tsx
@@ -4,9 +4,11 @@ import type {
PatchRow,
RepositoryRow,
} from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
import type { PatchMeta } from "../../services/git.ts";
import type { ApplyResult } from "../../services/patchCache.ts";
import { Avatar } from "../Avatar.tsx";
import { DateWithEdited } from "../DateWithEdited.tsx";
@@ -29,6 +31,7 @@ interface PatchDetailProps {
applyResult: ApplyResult | null;
files: RenderedDiffFile[];
tab: "conversation" | "changes";
patchMeta: PatchMeta;
comments: (PatchCommentRow & {
author_username: string;
author_avatar_version: number | null;
@@ -46,6 +49,7 @@ export function PatchDetail({
applyResult,
files,
tab,
patchMeta,
comments,
reactions,
commentReactions,
@@ -419,7 +423,40 @@ export function PatchDetail({
)}
</div>
) : (
<DiffView files={files} repo={repo} />
<div>
<div class="commit-card">
<h2 class="commit-card-subject">
{escapeHtml(patchMeta.subject)}
</h2>
{patchMeta.body && (
<pre class="commit-card-body">
{escapeHtml(patchMeta.body)}
</pre>
)}
<div class="commit-card-meta">
<div class="commit-card-meta-row">
<span class="commit-meta-label">
Author
</span>
<span class="commit-meta-value">
{escapeHtml(
`${patchMeta.author} <${patchMeta.email}>`,
)}
</span>
</div>
<div class="commit-card-meta-row">
<span class="commit-meta-label">Date</span>
<time
class="commit-meta-value"
datetime={patchMeta.date}
>
{formatDateTime(patchMeta.date)}
</time>
</div>
</div>
</div>
<DiffView files={files} repo={repo} />
</div>
)}
</div>
</Layout>
▾Mtests/e2e.test.ts
@@ -59,6 +59,12 @@ async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number
// Helper: create N patches in a repo using the server API
async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) {
const VALID_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'index 0000000..9daeafb',
@@ -641,6 +647,12 @@ describe('patches', () => {
// Adds a new file — applies cleanly to my-repo
const CLEAN_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add patch-test.txt',
'',
'---',
'diff --git a/patch-test.txt b/patch-test.txt',
'new file mode 100644',
'index 0000000..9daeafb',
@@ -653,6 +665,12 @@ describe('patches', () => {
// References non-existent lines in README.md — always conflicts
const CONFLICT_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Modify README',
'',
'---',
'diff --git a/README.md b/README.md',
'index abc1234..def5678 100644',
'--- a/README.md',
@@ -666,6 +684,12 @@ describe('patches', () => {
// Adds another new file — for testing close flow
const CLOSE_PATCH = [
'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001',
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add patch-close.txt',
'',
'---',
'diff --git a/patch-close.txt b/patch-close.txt',
'new file mode 100644',
'index 0000000..9daeafb',
@@ -678,12 +702,6 @@ describe('patches', () => {
beforeAll(async () => {
adminCtx = await loggedInContext();
// Git identity is required to submit patches
await adminCtx.request.fetch(`${BASE}/settings/git-identity`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: 'git_name=Admin+User&git_email=admin%40test.com',
});
});
afterAll(async () => { await adminCtx.close(); });
@@ -700,6 +718,78 @@ describe('patches', () => {
} finally { await page.close(); }
});
test('reject patch missing Subject header', async () => {
writeTempFile('/tmp/no-subject.patch', [
'From: Test User <test@example.com>',
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No subject');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Subject');
} finally { await page.close(); }
});
test('reject patch missing From header', async () => {
writeTempFile('/tmp/no-from.patch', [
'Date: Mon, 01 Jan 2024 12:00:00 +0000',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No from');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('From');
} finally { await page.close(); }
});
test('reject patch missing Date header', async () => {
writeTempFile('/tmp/no-date.patch', [
'From: Test User <test@example.com>',
'Subject: [PATCH] Add f.txt',
'',
'---',
'diff --git a/f.txt b/f.txt',
'new file mode 100644',
'--- /dev/null',
'+++ b/f.txt',
'@@ -0,0 +1 @@',
'+x',
'',
].join('\n'));
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/my-repo/patches/new`);
await page.fill('[name=title]', 'No date');
await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch');
await page.click('form[action$="/patches"] button[type=submit]');
expect(await page.locator('.form-error').textContent()).toContain('Date');
} finally { await page.close(); }
});
test('upload clean patch', async () => {
writeTempFile('/tmp/clean.patch', CLEAN_PATCH);
const page = await adminCtx.newPage();
@@ -715,6 +805,27 @@ describe('patches', () => {
} finally { await page.close(); }
});
test('author on patch comes from patch From header', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl);
expect(await page.locator('.patch-author-identity').textContent()).toContain('Test User');
expect(await page.locator('.patch-author-identity').textContent()).toContain('test@example.com');
} finally { await page.close(); }
});
test('changes tab shows commit metadata card', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(cleanPatchUrl + '?tab=changes');
expect(await page.locator('.commit-card').isVisible()).toBe(true);
expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt');
expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User');
expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com');
expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true);
} finally { await page.close(); }
});
test('patch description renders markdown', async () => {
const page = await adminCtx.newPage();
try {
@@ -806,6 +917,16 @@ describe('patches', () => {
} finally { await page.close(); }
});
test('merge uses patch From header as git author', async () => {
const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`;
const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim();
const authorEmail = (await $`git -C ${repoPath} log -1 --format=%aE`.quiet()).text().trim();
const subject = (await $`git -C ${repoPath} log -1 --format=%s`.quiet()).text().trim();
expect(authorName).toBe('Test User');
expect(authorEmail).toBe('test@example.com');
expect(subject).toBe('Add patch-test.txt');
});
test('merged patch appears in merged list', async () => {
const page = await adminCtx.newPage();
try {
@@ -923,12 +1044,6 @@ describe('pagination', () => {
beforeAll(async () => {
adminCtx = await loggedInContext();
// Git identity is required to submit patches
await adminCtx.request.fetch(`${BASE}/settings/git-identity`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: 'git_name=Admin+User&git_email=admin%40test.com',
});
// Create a repo dedicated to pagination testing
const page = await adminCtx.newPage();
@@ -1893,31 +2008,22 @@ describe('settings', () => {
expect(resp.headers()['location']).toContain('error');
});
test('git identity: empty name shows error', async () => {
const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
form: { git_name: ' ', git_email: 'test@example.com' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('error=Git+name+is+required');
test('settings page has no git identity section', async () => {
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/settings`);
expect(await page.locator('text=Git Identity').count()).toBe(0);
expect(await page.locator('[name=git_name]').count()).toBe(0);
expect(await page.locator('[name=git_email]').count()).toBe(0);
} finally { await page.close(); }
});
test('git identity: empty email shows error', async () => {
test('git identity route no longer exists', async () => {
const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, {
form: { git_name: 'Test User', git_email: ' ' },
form: { git_name: 'Test', git_email: 'test@example.com' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('error=Git+email+is+required');
});
test('git identity: valid values redirect with success', async () => {
const resp = await aliceCtx.request.post(`${BASE}/settings/git-identity`, {
form: { git_name: 'Alice Smith', git_email: 'alice@example.com' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(resp.headers()['location']).toContain('success=git_identity');
expect(resp.status()).toBe(404);
});
});