remove git identity settings again, add back admin name&email env vars, drop .diff support, use metadata from .patch files
MREADME.md
| @@ -48,22 +48,24 @@ podman compose up | |||
|---|---|---|---|
| 48 | 48 | ||
| 49 | 49 | All settings are environment variables: | |
| 50 | 50 | ||
| 51 | - | | Variable | Default | Description | | |
| 52 | - | |-------------------------|-------------------------|-------------------------------------------------| | |
| 53 | - | | `PORT` | `3000` | HTTP port | | |
| 54 | - | | `SSH_PORT` | `2222` | SSH port | | |
| 55 | - | | `DATA_DIR` | `./data` | Repos, database, uploads | | |
| 56 | - | | `ADMIN_PASSWORD` | `changeme` | Initial admin password | | |
| 57 | - | | `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner | | |
| 58 | - | | `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links | | |
| 59 | - | | `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups | | |
| 60 | - | | `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) | | |
| 61 | - | | `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) | | |
| 62 | - | | `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view | | |
| 63 | - | | `SSH_DISABLED` | `0` | Disable the embedded SSH-server | | |
| 64 | - | | `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` | | |
| 65 | - | | `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting | | |
| 66 | - | | `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* | | |
| 51 | + | | Variable | Default | Description | | |
| 52 | + | |-------------------------|----------------------------------|-------------------------------------------------| | |
| 53 | + | | `PORT` | `3000` | HTTP port | | |
| 54 | + | | `SSH_PORT` | `2222` | SSH port | | |
| 55 | + | | `DATA_DIR` | `./data` | Repos, database, uploads | | |
| 56 | + | | `ADMIN_PASSWORD` | `changeme` | Initial admin password | | |
| 57 | + | | `OWNER_DISPLAY_NAME` | `Admin` | Display name for the owner | | |
| 58 | + | | `BASE_URL` | `http://localhost:3000` | Used in clone URLs and links | | |
| 59 | + | | `REGISTRATION_DISABLED` | `0` | Set to `1` to disable signups | | |
| 60 | + | | `MAX_UPLOAD_BYTES` | `10485760` | Max request body size (any uploads/requests) | | |
| 61 | + | | `MAX_USER_UPLOAD_BYTES` | `2097152` | Max request body size (user uploads) | | |
| 62 | + | | `INLINE_MAX_BYTES` | `524288` | Max file size to render inline in the file view | | |
| 63 | + | | `SSH_DISABLED` | `0` | Disable the embedded SSH-server | | |
| 64 | + | | `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` | | |
| 65 | + | | `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting | | |
| 66 | + | | `HIGHLIGHT_WORKERS` | `4` | Number of syntax highlighting workers* | | |
| 67 | + | | `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name used when merging patches | | |
| 68 | + | | `COMMITTER_EMAIL` | `$OWNER_DISPLAY_NAME@<hostname>` | Git committer email used when merging patches | | |
| 67 | 69 | ||
| 68 | 70 | \* More workers mean more CPU cores can be used to parallelize highlighting of files. | |
| 69 | 71 | 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 | |||
|---|---|---|---|
| 92 | 94 | - Issue labels | |
| 93 | 95 | - Repository list reordering (e.g. last committed) and starring | |
| 94 | 96 | - redirect image urls in readme | |
| 97 | + | - issue/patch templates | |
| 98 | + | - commit signing | |
Msrc/config.ts
| @@ -18,3 +18,7 @@ export const BASE_URL = process.env.BASE_URL ?? `http://localhost:${PORT}`; | |||
|---|---|---|---|
| 18 | 18 | export const DATA_DIR = path.resolve(process.env.DATA_DIR ?? "./data"); | |
| 19 | 19 | export const HIGHLIGHT_WORKERS = | |
| 20 | 20 | parseInt(process.env.HIGHLIGHT_WORKERS ?? "", 10) || 4; | |
| 21 | + | export const COMMITTER_NAME = process.env.COMMITTER_NAME ?? OWNER_DISPLAY_NAME; | |
| 22 | + | export const COMMITTER_EMAIL = | |
| 23 | + | process.env.COMMITTER_EMAIL ?? | |
| 24 | + | `${OWNER_DISPLAY_NAME}@${new URL(BASE_URL).hostname}`; | |
Msrc/db/index.ts
| @@ -10,8 +10,6 @@ interface UserTable { | |||
|---|---|---|---|
| 10 | 10 | password_hash: string | null; | |
| 11 | 11 | created_at: string; | |
| 12 | 12 | avatar_version: Generated<number>; | |
| 13 | - | git_name: string | null; | |
| 14 | - | git_email: string | null; | |
| 15 | 13 | } | |
| 16 | 14 | ||
| 17 | 15 | interface PasskeyTable { | |
Msrc/middleware/session.ts
| @@ -6,8 +6,6 @@ export interface SessionUser { | |||
|---|---|---|---|
| 6 | 6 | username: string; | |
| 7 | 7 | isAdmin: boolean; | |
| 8 | 8 | avatar_version: number; | |
| 9 | - | git_name: string | null; | |
| 10 | - | git_email: string | null; | |
| 11 | 9 | } | |
| 12 | 10 | ||
| 13 | 11 | export async function resolveSession( | |
| @@ -22,8 +20,6 @@ export async function resolveSession( | |||
|---|---|---|---|
| 22 | 20 | "users.id", | |
| 23 | 21 | "users.username", | |
| 24 | 22 | "users.avatar_version", | |
| 25 | - | "users.git_name", | |
| 26 | - | "users.git_email", | |
| 27 | 23 | "sessions.expires_at", | |
| 28 | 24 | ]) | |
| 29 | 25 | .where("sessions.id", "=", cookie) | |
| @@ -35,8 +31,6 @@ export async function resolveSession( | |||
|---|---|---|---|
| 35 | 31 | username: session.username, | |
| 36 | 32 | isAdmin: session.username === ADMIN_USERNAME, | |
| 37 | 33 | avatar_version: session.avatar_version, | |
| 38 | - | git_name: session.git_name, | |
| 39 | - | git_email: session.git_email, | |
| 40 | 34 | }; | |
| 41 | 35 | } | |
| 42 | 36 | ||
Msrc/routes/patches.tsx
| @@ -1,6 +1,10 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Elysia, t } from "elysia"; | |
| 2 | 2 | import { sql } from "kysely"; | |
| 3 | - | import { MAX_USER_UPLOAD_BYTES } from "../config.ts"; | |
| 3 | + | import { | |
| 4 | + | COMMITTER_EMAIL, | |
| 5 | + | COMMITTER_NAME, | |
| 6 | + | MAX_USER_UPLOAD_BYTES, | |
| 7 | + | } from "../config.ts"; | |
| 4 | 8 | import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts"; | |
| 5 | 9 | import { db, getRepo } from "../db/index.ts"; | |
| 6 | 10 | import { | |
| @@ -8,7 +12,7 @@ import { | |||
|---|---|---|---|
| 8 | 12 | requireAuth, | |
| 9 | 13 | resolveSession, | |
| 10 | 14 | } from "../middleware/session.ts"; | |
| 11 | - | import { git } from "../services/git.ts"; | |
| 15 | + | import { extractPatchMeta, git } from "../services/git.ts"; | |
| 12 | 16 | import { prepareDiff } from "../services/highlightWorker.ts"; | |
| 13 | 17 | import { renderMarkdown } from "../services/markdown.ts"; | |
| 14 | 18 | import { patchCache } from "../services/patchCache.ts"; | |
| @@ -152,16 +156,6 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 152 | 156 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 153 | 157 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 154 | 158 | ||
| 155 | - | if (!user!.git_name?.trim() || !user!.git_email?.trim()) { | |
| 156 | - | return html( | |
| 157 | - | <NewPatch | |
| 158 | - | user={user!} | |
| 159 | - | repo={repo} | |
| 160 | - | error="You must set your git name and email in Settings before creating a patch" | |
| 161 | - | />, | |
| 162 | - | ); | |
| 163 | - | } | |
| 164 | - | ||
| 165 | 159 | if (!body.title?.trim()) { | |
| 166 | 160 | return html( | |
| 167 | 161 | <NewPatch | |
| @@ -203,17 +197,45 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 203 | 197 | ); | |
| 204 | 198 | } | |
| 205 | 199 | ||
| 206 | - | // Validate it looks like a patch/diff file | |
| 200 | + | // Validate it looks like a patch file | |
| 207 | 201 | if (!isValidPatch(patchContent)) { | |
| 208 | 202 | return html( | |
| 209 | 203 | <NewPatch | |
| 210 | 204 | user={user!} | |
| 211 | 205 | repo={repo} | |
| 212 | - | error="File does not appear to be a valid patch or diff file" | |
| 206 | + | error="File does not appear to be a valid patch file" | |
| 213 | 207 | />, | |
| 214 | 208 | ); | |
| 215 | 209 | } | |
| 216 | 210 | ||
| 211 | + | const uploadMeta = extractPatchMeta(patchContent); | |
| 212 | + | if (!uploadMeta.subject) { | |
| 213 | + | return html( | |
| 214 | + | <NewPatch | |
| 215 | + | user={user!} | |
| 216 | + | repo={repo} | |
| 217 | + | error="Patch is missing a Subject header. Make sure to upload a patch created with git format-patch." | |
| 218 | + | />, | |
| 219 | + | ); | |
| 220 | + | } | |
| 221 | + | if (!uploadMeta.author || !uploadMeta.email) { | |
| 222 | + | return html( | |
| 223 | + | <NewPatch | |
| 224 | + | user={user!} | |
| 225 | + | repo={repo} | |
| 226 | + | error="Patch is missing a From header with name and email." | |
| 227 | + | />, | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | if (!uploadMeta.date) { | |
| 231 | + | return html( | |
| 232 | + | <NewPatch | |
| 233 | + | user={user!} | |
| 234 | + | repo={repo} | |
| 235 | + | error="Patch is missing a Date header." | |
| 236 | + | />, | |
| 237 | + | ); | |
| 238 | + | } | |
| 217 | 239 | const now = new Date().toISOString(); | |
| 218 | 240 | const { number, result } = await db | |
| 219 | 241 | .transaction() | |
| @@ -234,8 +256,8 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 234 | 256 | description: body.description?.trim() ?? "", | |
| 235 | 257 | patch_content: patchContent, | |
| 236 | 258 | status: "open", | |
| 237 | - | author_name: user!.git_name!, | |
| 238 | - | author_email: user!.git_email!, | |
| 259 | + | author_name: uploadMeta.author, | |
| 260 | + | author_email: uploadMeta.email, | |
| 239 | 261 | created_at: now, | |
| 240 | 262 | updated_at: now, | |
| 241 | 263 | }) | |
| @@ -353,6 +375,8 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 353 | 375 | ? ("changes" as const) | |
| 354 | 376 | : ("conversation" as const); | |
| 355 | 377 | ||
| 378 | + | const patchMeta = extractPatchMeta(patch.patch_content); | |
| 379 | + | ||
| 356 | 380 | return html( | |
| 357 | 381 | <PatchDetail | |
| 358 | 382 | user={user} | |
| @@ -369,6 +393,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 369 | 393 | applyResult={applyResult} | |
| 370 | 394 | files={files} | |
| 371 | 395 | tab={tab} | |
| 396 | + | patchMeta={patchMeta} | |
| 372 | 397 | comments={ | |
| 373 | 398 | commentsWithHtml as ((typeof commentsWithHtml)[0] & { | |
| 374 | 399 | author_username: string; | |
| @@ -390,28 +415,13 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 390 | 415 | const deny = requireAdmin(user); | |
| 391 | 416 | if (deny) return deny; | |
| 392 | 417 | ||
| 393 | - | if (!user!.git_name?.trim() || !user!.git_email?.trim()) { | |
| 394 | - | return new Response( | |
| 395 | - | "Set your git name and email in Settings before merging", | |
| 396 | - | { status: 400, headers: { "Content-Type": "text/plain" } }, | |
| 397 | - | ); | |
| 398 | - | } | |
| 399 | - | ||
| 400 | 418 | const repo = await getRepo(params.repo, true); | |
| 401 | 419 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 402 | 420 | ||
| 403 | 421 | const patchNum = parseInt(params.number, 10); | |
| 404 | 422 | const patch = await db | |
| 405 | 423 | .selectFrom("patches") | |
| 406 | - | .select([ | |
| 407 | - | "id", | |
| 408 | - | "title", | |
| 409 | - | "description", | |
| 410 | - | "patch_content", | |
| 411 | - | "status", | |
| 412 | - | "author_name", | |
| 413 | - | "author_email", | |
| 414 | - | ]) | |
| 424 | + | .select(["id", "patch_content", "status"]) | |
| 415 | 425 | .where("repo_id", "=", repo.id) | |
| 416 | 426 | .where("number", "=", patchNum) | |
| 417 | 427 | .executeTakeFirst(); | |
| @@ -428,16 +438,15 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 428 | 438 | if (!claimed || claimed.numUpdatedRows === 0n) | |
| 429 | 439 | return new Response("Patch is not open", { status: 400 }); | |
| 430 | 440 | ||
| 441 | + | const mergeMeta = extractPatchMeta(patch.patch_content); | |
| 431 | 442 | try { | |
| 432 | 443 | await git.applyPatch( | |
| 433 | 444 | repo.name, | |
| 434 | 445 | patch.patch_content, | |
| 435 | - | patch.title, | |
| 436 | - | patch.description, | |
| 437 | - | patch.author_name, | |
| 438 | - | patch.author_email, | |
| 439 | - | user!.git_name!, | |
| 440 | - | user!.git_email!, | |
| 446 | + | mergeMeta.author, | |
| 447 | + | mergeMeta.email, | |
| 448 | + | COMMITTER_NAME, | |
| 449 | + | COMMITTER_EMAIL, | |
| 441 | 450 | ); | |
| 442 | 451 | } catch (err) { | |
| 443 | 452 | // Roll back the status if the git operation fails | |
Msrc/routes/releases.tsx
| @@ -1,6 +1,7 @@ | |||
|---|---|---|---|
| 1 | 1 | import { mkdirSync, rmSync } from "node:fs"; | |
| 2 | 2 | import path from "node:path"; | |
| 3 | 3 | import { Elysia, t } from "elysia"; | |
| 4 | + | import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts"; | |
| 4 | 5 | import { RELEASES_DIR, RELEASES_PER_PAGE } from "../constants.ts"; | |
| 5 | 6 | import { db, getRepo } from "../db/index.ts"; | |
| 6 | 7 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| @@ -130,17 +131,6 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 130 | 131 | include_source_code: body.include_source_code === "on", | |
| 131 | 132 | }; | |
| 132 | 133 | ||
| 133 | - | if (createTag && (!user!.git_name?.trim() || !user!.git_email?.trim())) { | |
| 134 | - | return html( | |
| 135 | - | <NewRelease | |
| 136 | - | user={user!} | |
| 137 | - | repo={repo} | |
| 138 | - | error="You must set your git name and email in Settings before creating a tagged release" | |
| 139 | - | values={formValues} | |
| 140 | - | />, | |
| 141 | - | ); | |
| 142 | - | } | |
| 143 | - | ||
| 144 | 134 | if (!name) { | |
| 145 | 135 | return html( | |
| 146 | 136 | <NewRelease | |
| @@ -194,8 +184,8 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 194 | 184 | tagName, | |
| 195 | 185 | revision, | |
| 196 | 186 | tagMessage, | |
| 197 | - | user!.git_name!, | |
| 198 | - | user!.git_email!, | |
| 187 | + | COMMITTER_NAME, | |
| 188 | + | COMMITTER_EMAIL, | |
| 199 | 189 | ); | |
| 200 | 190 | if (tagResult === "already_exists") { | |
| 201 | 191 | return html( | |
Msrc/routes/settings.tsx
| @@ -34,7 +34,7 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 34 | 34 | ||
| 35 | 35 | const userRow = await db | |
| 36 | 36 | .selectFrom("users") | |
| 37 | - | .select(["id", "password_hash", "git_name", "git_email"]) | |
| 37 | + | .select(["id", "password_hash"]) | |
| 38 | 38 | .where("id", "=", user.id) | |
| 39 | 39 | .executeTakeFirst(); | |
| 40 | 40 | ||
| @@ -61,8 +61,6 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 61 | 61 | <Settings | |
| 62 | 62 | user={user} | |
| 63 | 63 | hasPassword={hasPassword} | |
| 64 | - | gitName={userRow?.git_name ?? null} | |
| 65 | - | gitEmail={userRow?.git_email ?? null} | |
| 66 | 64 | passkeys={passkeys} | |
| 67 | 65 | sshKeys={sshKeys} | |
| 68 | 66 | theme={theme} | |
| @@ -346,40 +344,6 @@ export const settingsRoutes = new Elysia() | |||
|---|---|---|---|
| 346 | 344 | }, | |
| 347 | 345 | ) | |
| 348 | 346 | ||
| 349 | - | .post( | |
| 350 | - | "/settings/git-identity", | |
| 351 | - | async ({ cookie, body }) => { | |
| 352 | - | const user = await resolveSession( | |
| 353 | - | cookie.session?.value as string | undefined, | |
| 354 | - | ); | |
| 355 | - | if (!user) return redirect("/login"); | |
| 356 | - | ||
| 357 | - | const name = body.git_name.trim(); | |
| 358 | - | const email = body.git_email.trim(); | |
| 359 | - | ||
| 360 | - | if (!name) { | |
| 361 | - | return redirect("/settings?error=Git+name+is+required"); | |
| 362 | - | } | |
| 363 | - | if (!email) { | |
| 364 | - | return redirect("/settings?error=Git+email+is+required"); | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | await db | |
| 368 | - | .updateTable("users") | |
| 369 | - | .set({ git_name: name, git_email: email }) | |
| 370 | - | .where("id", "=", user.id) | |
| 371 | - | .execute(); | |
| 372 | - | ||
| 373 | - | return redirect("/settings?success=git_identity"); | |
| 374 | - | }, | |
| 375 | - | { | |
| 376 | - | body: t.Object({ | |
| 377 | - | git_name: t.String(), | |
| 378 | - | git_email: t.String(), | |
| 379 | - | }), | |
| 380 | - | }, | |
| 381 | - | ) | |
| 382 | - | ||
| 383 | 347 | .post( | |
| 384 | 348 | "/settings/ssh-keys", | |
| 385 | 349 | async ({ cookie, body }) => { | |
Msrc/services/git.ts
| @@ -173,7 +173,7 @@ function parseLsTree(out: string): TreeEntry[] { | |||
|---|---|---|---|
| 173 | 173 | }); | |
| 174 | 174 | } | |
| 175 | 175 | ||
| 176 | - | function extractPatchSubject(patch: string): string { | |
| 176 | + | export function extractPatchSubject(patch: string): string { | |
| 177 | 177 | for (const line of patch.split("\n").slice(0, 30)) { | |
| 178 | 178 | if (line.startsWith("Subject: ")) { | |
| 179 | 179 | // Strip "[PATCH ...] " prefix added by git format-patch | |
| @@ -183,6 +183,58 @@ function extractPatchSubject(patch: string): string { | |||
|---|---|---|---|
| 183 | 183 | return ""; | |
| 184 | 184 | } | |
| 185 | 185 | ||
| 186 | + | export interface PatchMeta { | |
| 187 | + | subject: string; | |
| 188 | + | body: string; | |
| 189 | + | author: string; | |
| 190 | + | email: string; | |
| 191 | + | date: string; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | export function extractPatchMeta(patch: string): PatchMeta { | |
| 195 | + | const lines = patch.split("\n"); | |
| 196 | + | let subject = ""; | |
| 197 | + | let author = ""; | |
| 198 | + | let email = ""; | |
| 199 | + | let date = ""; | |
| 200 | + | const bodyLines: string[] = []; | |
| 201 | + | let inHeaders = true; | |
| 202 | + | let pastSubject = false; | |
| 203 | + | ||
| 204 | + | for (const line of lines) { | |
| 205 | + | if (inHeaders) { | |
| 206 | + | if (line.startsWith("From: ")) { | |
| 207 | + | const match = line.slice(6).match(/^(.*?)\s*<([^>]+)>/); | |
| 208 | + | if (match) { | |
| 209 | + | author = match[1]!.trim(); | |
| 210 | + | email = match[2]!; | |
| 211 | + | } else { | |
| 212 | + | author = line.slice(6).trim(); | |
| 213 | + | } | |
| 214 | + | } else if (line.startsWith("Date: ")) { | |
| 215 | + | date = line.slice(6).trim(); | |
| 216 | + | } else if (line.startsWith("Subject: ")) { | |
| 217 | + | subject = line.slice(9).replace(/^\[PATCH[^\]]*\]\s*/, ""); | |
| 218 | + | pastSubject = true; | |
| 219 | + | } else if (pastSubject && line === "") { | |
| 220 | + | inHeaders = false; | |
| 221 | + | } | |
| 222 | + | } else { | |
| 223 | + | if (line === "---") break; | |
| 224 | + | bodyLines.push(line); | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | while ( | |
| 229 | + | bodyLines.length > 0 && | |
| 230 | + | bodyLines[bodyLines.length - 1]!.trim() === "" | |
| 231 | + | ) { | |
| 232 | + | bodyLines.pop(); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | return { subject, body: bodyLines.join("\n"), author, email, date }; | |
| 236 | + | } | |
| 237 | + | ||
| 186 | 238 | export const git = { | |
| 187 | 239 | async init(name: string, branch = "main") { | |
| 188 | 240 | return withRepoLock(name, async () => { | |
| @@ -371,8 +423,6 @@ export const git = { | |||
|---|---|---|---|
| 371 | 423 | async applyPatch( | |
| 372 | 424 | name: string, | |
| 373 | 425 | patchContent: string, | |
| 374 | - | title: string, | |
| 375 | - | description: string, | |
| 376 | 426 | authorName: string, | |
| 377 | 427 | authorEmail: string, | |
| 378 | 428 | committerName: string, | |
| @@ -390,10 +440,7 @@ export const git = { | |||
|---|---|---|---|
| 390 | 440 | const parent = ( | |
| 391 | 441 | await $`git -C ${p} rev-parse HEAD`.text() | |
| 392 | 442 | ).trim(); | |
| 393 | - | const fallback = description.trim() | |
| 394 | - | ? `${title}\n\n${description.trim()}` | |
| 395 | - | : title; | |
| 396 | - | const msg = extractPatchSubject(patchContent) || fallback; | |
| 443 | + | const msg = extractPatchSubject(patchContent); | |
| 397 | 444 | const commit = ( | |
| 398 | 445 | await $`git -C ${p} commit-tree ${tree} -p ${parent} -m ${msg}` | |
| 399 | 446 | .env({ | |
Msrc/views/Settings.tsx
| @@ -6,8 +6,6 @@ import { Layout } from "./layout.tsx"; | |||
|---|---|---|---|
| 6 | 6 | interface SettingsProps { | |
| 7 | 7 | user: SessionUser; | |
| 8 | 8 | hasPassword: boolean; | |
| 9 | - | gitName: string | null; | |
| 10 | - | gitEmail: string | null; | |
| 11 | 9 | passkeys: { id: number; created_at: string }[]; | |
| 12 | 10 | sshKeys: { | |
| 13 | 11 | id: number; | |
| @@ -29,14 +27,11 @@ const successMessages: Record<string, string> = { | |||
|---|---|---|---|
| 29 | 27 | user_deleted: "Account deleted.", | |
| 30 | 28 | ssh_key_added: "SSH key added.", | |
| 31 | 29 | ssh_key_deleted: "SSH key removed.", | |
| 32 | - | git_identity: "Git identity saved.", | |
| 33 | 30 | }; | |
| 34 | 31 | ||
| 35 | 32 | export function Settings({ | |
| 36 | 33 | user, | |
| 37 | 34 | hasPassword, | |
| 38 | - | gitName, | |
| 39 | - | gitEmail, | |
| 40 | 35 | passkeys, | |
| 41 | 36 | sshKeys, | |
| 42 | 37 | theme, | |
| @@ -102,55 +97,6 @@ export function Settings({ | |||
|---|---|---|---|
| 102 | 97 | </div> | |
| 103 | 98 | </div> | |
| 104 | 99 | ||
| 105 | - | {/* Git Identity */} | |
| 106 | - | <div class="form-card"> | |
| 107 | - | <h2 class="section-title">Git Identity</h2> | |
| 108 | - | <p class="text-muted"> | |
| 109 | - | Used as the author when creating patches. Required to | |
| 110 | - | submit patches. | |
| 111 | - | </p> | |
| 112 | - | <form | |
| 113 | - | method="POST" | |
| 114 | - | action="/settings/git-identity" | |
| 115 | - | class="settings-form" | |
| 116 | - | style="margin-top: var(--space-4)" | |
| 117 | - | > | |
| 118 | - | <div class="form-group"> | |
| 119 | - | <label class="form-label" for="git_name"> | |
| 120 | - | Name | |
| 121 | - | </label> | |
| 122 | - | <input | |
| 123 | - | class="form-input" | |
| 124 | - | type="text" | |
| 125 | - | id="git_name" | |
| 126 | - | name="git_name" | |
| 127 | - | value={gitName ?? ""} | |
| 128 | - | autocomplete="name" | |
| 129 | - | required | |
| 130 | - | /> | |
| 131 | - | </div> | |
| 132 | - | <div class="form-group"> | |
| 133 | - | <label class="form-label" for="git_email"> | |
| 134 | - | ||
| 135 | - | </label> | |
| 136 | - | <input | |
| 137 | - | class="form-input" | |
| 138 | - | type="email" | |
| 139 | - | id="git_email" | |
| 140 | - | name="git_email" | |
| 141 | - | value={gitEmail ?? ""} | |
| 142 | - | autocomplete="email" | |
| 143 | - | required | |
| 144 | - | /> | |
| 145 | - | </div> | |
| 146 | - | <div class="form-actions"> | |
| 147 | - | <button class="btn btn-primary" type="submit"> | |
| 148 | - | Save | |
| 149 | - | </button> | |
| 150 | - | </div> | |
| 151 | - | </form> | |
| 152 | - | </div> | |
| 153 | - | ||
| 154 | 100 | {/* Appearance */} | |
| 155 | 101 | <div class="form-card"> | |
| 156 | 102 | <h2 class="section-title">Appearance</h2> | |
Msrc/views/issues/NewIssue.tsx
| @@ -36,7 +36,9 @@ export function NewIssue({ user, repo, error }: NewIssueProps) { | |||
|---|---|---|---|
| 36 | 36 | <div class="form-group"> | |
| 37 | 37 | <label for="body"> | |
| 38 | 38 | Description{" "} | |
| 39 | - | <span class="text-muted">(Markdown supported)</span> | |
| 39 | + | <span class="text-muted"> | |
| 40 | + | (Markdown supported, optional) | |
| 41 | + | </span> | |
| 40 | 42 | </label> | |
| 41 | 43 | <textarea | |
| 42 | 44 | id="body" | |
Msrc/views/patches/NewPatch.tsx
| @@ -49,14 +49,13 @@ export function NewPatch({ user, repo, error }: NewPatchProps) { | |||
|---|---|---|---|
| 49 | 49 | </div> | |
| 50 | 50 | <div class="form-group"> | |
| 51 | 51 | <label for="patch_file"> | |
| 52 | - | Patch file{" "} | |
| 53 | - | <span class="text-muted">(.patch or .diff)</span> | |
| 52 | + | Patch file <span class="text-muted">(.patch)</span> | |
| 54 | 53 | </label> | |
| 55 | 54 | <input | |
| 56 | 55 | id="patch_file" | |
| 57 | 56 | name="patch_file" | |
| 58 | 57 | type="file" | |
| 59 | - | accept=".patch,.diff,text/plain" | |
| 58 | + | accept=".patch" | |
| 60 | 59 | required | |
| 61 | 60 | /> | |
| 62 | 61 | </div> | |
Msrc/views/patches/PatchDetail.tsx
| @@ -4,9 +4,11 @@ import type { | |||
|---|---|---|---|
| 4 | 4 | PatchRow, | |
| 5 | 5 | RepositoryRow, | |
| 6 | 6 | } from "../../db/index.ts"; | |
| 7 | + | import { formatDateTime } from "../../lib/formatDate.ts"; | |
| 7 | 8 | import { displayName } from "../../lib/users.ts"; | |
| 8 | 9 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 9 | 10 | import type { RenderedDiffFile } from "../../services/diffHighlight.ts"; | |
| 11 | + | import type { PatchMeta } from "../../services/git.ts"; | |
| 10 | 12 | import type { ApplyResult } from "../../services/patchCache.ts"; | |
| 11 | 13 | import { Avatar } from "../Avatar.tsx"; | |
| 12 | 14 | import { DateWithEdited } from "../DateWithEdited.tsx"; | |
| @@ -29,6 +31,7 @@ interface PatchDetailProps { | |||
|---|---|---|---|
| 29 | 31 | applyResult: ApplyResult | null; | |
| 30 | 32 | files: RenderedDiffFile[]; | |
| 31 | 33 | tab: "conversation" | "changes"; | |
| 34 | + | patchMeta: PatchMeta; | |
| 32 | 35 | comments: (PatchCommentRow & { | |
| 33 | 36 | author_username: string; | |
| 34 | 37 | author_avatar_version: number | null; | |
| @@ -46,6 +49,7 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 46 | 49 | applyResult, | |
| 47 | 50 | files, | |
| 48 | 51 | tab, | |
| 52 | + | patchMeta, | |
| 49 | 53 | comments, | |
| 50 | 54 | reactions, | |
| 51 | 55 | commentReactions, | |
| @@ -419,7 +423,40 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 419 | 423 | )} | |
| 420 | 424 | </div> | |
| 421 | 425 | ) : ( | |
| 422 | - | <DiffView files={files} repo={repo} /> | |
| 426 | + | <div> | |
| 427 | + | <div class="commit-card"> | |
| 428 | + | <h2 class="commit-card-subject"> | |
| 429 | + | {escapeHtml(patchMeta.subject)} | |
| 430 | + | </h2> | |
| 431 | + | {patchMeta.body && ( | |
| 432 | + | <pre class="commit-card-body"> | |
| 433 | + | {escapeHtml(patchMeta.body)} | |
| 434 | + | </pre> | |
| 435 | + | )} | |
| 436 | + | <div class="commit-card-meta"> | |
| 437 | + | <div class="commit-card-meta-row"> | |
| 438 | + | <span class="commit-meta-label"> | |
| 439 | + | Author | |
| 440 | + | </span> | |
| 441 | + | <span class="commit-meta-value"> | |
| 442 | + | {escapeHtml( | |
| 443 | + | `${patchMeta.author} <${patchMeta.email}>`, | |
| 444 | + | )} | |
| 445 | + | </span> | |
| 446 | + | </div> | |
| 447 | + | <div class="commit-card-meta-row"> | |
| 448 | + | <span class="commit-meta-label">Date</span> | |
| 449 | + | <time | |
| 450 | + | class="commit-meta-value" | |
| 451 | + | datetime={patchMeta.date} | |
| 452 | + | > | |
| 453 | + | {formatDateTime(patchMeta.date)} | |
| 454 | + | </time> | |
| 455 | + | </div> | |
| 456 | + | </div> | |
| 457 | + | </div> | |
| 458 | + | <DiffView files={files} repo={repo} /> | |
| 459 | + | </div> | |
| 423 | 460 | )} | |
| 424 | 461 | </div> | |
| 425 | 462 | </Layout> | |
Mtests/e2e.test.ts
| @@ -59,6 +59,12 @@ async function bulkCreateIssues(ctx: BrowserContext, repo: string, count: number | |||
|---|---|---|---|
| 59 | 59 | // Helper: create N patches in a repo using the server API | |
| 60 | 60 | async function bulkCreatePatches(ctx: BrowserContext, repo: string, count: number) { | |
| 61 | 61 | const VALID_PATCH = [ | |
| 62 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 63 | + | 'From: Test User <test@example.com>', | |
| 64 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 65 | + | 'Subject: [PATCH] Add f.txt', | |
| 66 | + | '', | |
| 67 | + | '---', | |
| 62 | 68 | 'diff --git a/f.txt b/f.txt', | |
| 63 | 69 | 'new file mode 100644', | |
| 64 | 70 | 'index 0000000..9daeafb', | |
| @@ -641,6 +647,12 @@ describe('patches', () => { | |||
|---|---|---|---|
| 641 | 647 | ||
| 642 | 648 | // Adds a new file — applies cleanly to my-repo | |
| 643 | 649 | const CLEAN_PATCH = [ | |
| 650 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 Mon Sep 17 00:00:00 2001', | |
| 651 | + | 'From: Test User <test@example.com>', | |
| 652 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 653 | + | 'Subject: [PATCH] Add patch-test.txt', | |
| 654 | + | '', | |
| 655 | + | '---', | |
| 644 | 656 | 'diff --git a/patch-test.txt b/patch-test.txt', | |
| 645 | 657 | 'new file mode 100644', | |
| 646 | 658 | 'index 0000000..9daeafb', | |
| @@ -653,6 +665,12 @@ describe('patches', () => { | |||
|---|---|---|---|
| 653 | 665 | ||
| 654 | 666 | // References non-existent lines in README.md — always conflicts | |
| 655 | 667 | const CONFLICT_PATCH = [ | |
| 668 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b3 Mon Sep 17 00:00:00 2001', | |
| 669 | + | 'From: Test User <test@example.com>', | |
| 670 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 671 | + | 'Subject: [PATCH] Modify README', | |
| 672 | + | '', | |
| 673 | + | '---', | |
| 656 | 674 | 'diff --git a/README.md b/README.md', | |
| 657 | 675 | 'index abc1234..def5678 100644', | |
| 658 | 676 | '--- a/README.md', | |
| @@ -666,6 +684,12 @@ describe('patches', () => { | |||
|---|---|---|---|
| 666 | 684 | ||
| 667 | 685 | // Adds another new file — for testing close flow | |
| 668 | 686 | const CLOSE_PATCH = [ | |
| 687 | + | 'From a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b4 Mon Sep 17 00:00:00 2001', | |
| 688 | + | 'From: Test User <test@example.com>', | |
| 689 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 690 | + | 'Subject: [PATCH] Add patch-close.txt', | |
| 691 | + | '', | |
| 692 | + | '---', | |
| 669 | 693 | 'diff --git a/patch-close.txt b/patch-close.txt', | |
| 670 | 694 | 'new file mode 100644', | |
| 671 | 695 | 'index 0000000..9daeafb', | |
| @@ -678,12 +702,6 @@ describe('patches', () => { | |||
|---|---|---|---|
| 678 | 702 | ||
| 679 | 703 | beforeAll(async () => { | |
| 680 | 704 | adminCtx = await loggedInContext(); | |
| 681 | - | // Git identity is required to submit patches | |
| 682 | - | await adminCtx.request.fetch(`${BASE}/settings/git-identity`, { | |
| 683 | - | method: 'POST', | |
| 684 | - | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 685 | - | data: 'git_name=Admin+User&git_email=admin%40test.com', | |
| 686 | - | }); | |
| 687 | 705 | }); | |
| 688 | 706 | ||
| 689 | 707 | afterAll(async () => { await adminCtx.close(); }); | |
| @@ -700,6 +718,78 @@ describe('patches', () => { | |||
|---|---|---|---|
| 700 | 718 | } finally { await page.close(); } | |
| 701 | 719 | }); | |
| 702 | 720 | ||
| 721 | + | test('reject patch missing Subject header', async () => { | |
| 722 | + | writeTempFile('/tmp/no-subject.patch', [ | |
| 723 | + | 'From: Test User <test@example.com>', | |
| 724 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 725 | + | '', | |
| 726 | + | '---', | |
| 727 | + | 'diff --git a/f.txt b/f.txt', | |
| 728 | + | 'new file mode 100644', | |
| 729 | + | '--- /dev/null', | |
| 730 | + | '+++ b/f.txt', | |
| 731 | + | '@@ -0,0 +1 @@', | |
| 732 | + | '+x', | |
| 733 | + | '', | |
| 734 | + | ].join('\n')); | |
| 735 | + | const page = await adminCtx.newPage(); | |
| 736 | + | try { | |
| 737 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 738 | + | await page.fill('[name=title]', 'No subject'); | |
| 739 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-subject.patch'); | |
| 740 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 741 | + | expect(await page.locator('.form-error').textContent()).toContain('Subject'); | |
| 742 | + | } finally { await page.close(); } | |
| 743 | + | }); | |
| 744 | + | ||
| 745 | + | test('reject patch missing From header', async () => { | |
| 746 | + | writeTempFile('/tmp/no-from.patch', [ | |
| 747 | + | 'Date: Mon, 01 Jan 2024 12:00:00 +0000', | |
| 748 | + | 'Subject: [PATCH] Add f.txt', | |
| 749 | + | '', | |
| 750 | + | '---', | |
| 751 | + | 'diff --git a/f.txt b/f.txt', | |
| 752 | + | 'new file mode 100644', | |
| 753 | + | '--- /dev/null', | |
| 754 | + | '+++ b/f.txt', | |
| 755 | + | '@@ -0,0 +1 @@', | |
| 756 | + | '+x', | |
| 757 | + | '', | |
| 758 | + | ].join('\n')); | |
| 759 | + | const page = await adminCtx.newPage(); | |
| 760 | + | try { | |
| 761 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 762 | + | await page.fill('[name=title]', 'No from'); | |
| 763 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-from.patch'); | |
| 764 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 765 | + | expect(await page.locator('.form-error').textContent()).toContain('From'); | |
| 766 | + | } finally { await page.close(); } | |
| 767 | + | }); | |
| 768 | + | ||
| 769 | + | test('reject patch missing Date header', async () => { | |
| 770 | + | writeTempFile('/tmp/no-date.patch', [ | |
| 771 | + | 'From: Test User <test@example.com>', | |
| 772 | + | 'Subject: [PATCH] Add f.txt', | |
| 773 | + | '', | |
| 774 | + | '---', | |
| 775 | + | 'diff --git a/f.txt b/f.txt', | |
| 776 | + | 'new file mode 100644', | |
| 777 | + | '--- /dev/null', | |
| 778 | + | '+++ b/f.txt', | |
| 779 | + | '@@ -0,0 +1 @@', | |
| 780 | + | '+x', | |
| 781 | + | '', | |
| 782 | + | ].join('\n')); | |
| 783 | + | const page = await adminCtx.newPage(); | |
| 784 | + | try { | |
| 785 | + | await page.goto(`${BASE}/my-repo/patches/new`); | |
| 786 | + | await page.fill('[name=title]', 'No date'); | |
| 787 | + | await page.locator('[name=patch_file]').setInputFiles('/tmp/no-date.patch'); | |
| 788 | + | await page.click('form[action$="/patches"] button[type=submit]'); | |
| 789 | + | expect(await page.locator('.form-error').textContent()).toContain('Date'); | |
| 790 | + | } finally { await page.close(); } | |
| 791 | + | }); | |
| 792 | + | ||
| 703 | 793 | test('upload clean patch', async () => { | |
| 704 | 794 | writeTempFile('/tmp/clean.patch', CLEAN_PATCH); | |
| 705 | 795 | const page = await adminCtx.newPage(); | |
| @@ -715,6 +805,27 @@ describe('patches', () => { | |||
|---|---|---|---|
| 715 | 805 | } finally { await page.close(); } | |
| 716 | 806 | }); | |
| 717 | 807 | ||
| 808 | + | test('author on patch comes from patch From header', async () => { | |
| 809 | + | const page = await adminCtx.newPage(); | |
| 810 | + | try { | |
| 811 | + | await page.goto(cleanPatchUrl); | |
| 812 | + | expect(await page.locator('.patch-author-identity').textContent()).toContain('Test User'); | |
| 813 | + | expect(await page.locator('.patch-author-identity').textContent()).toContain('test@example.com'); | |
| 814 | + | } finally { await page.close(); } | |
| 815 | + | }); | |
| 816 | + | ||
| 817 | + | test('changes tab shows commit metadata card', async () => { | |
| 818 | + | const page = await adminCtx.newPage(); | |
| 819 | + | try { | |
| 820 | + | await page.goto(cleanPatchUrl + '?tab=changes'); | |
| 821 | + | expect(await page.locator('.commit-card').isVisible()).toBe(true); | |
| 822 | + | expect(await page.locator('.commit-card-subject').textContent()).toContain('Add patch-test.txt'); | |
| 823 | + | expect(await page.locator('.commit-card-meta').textContent()).toContain('Test User'); | |
| 824 | + | expect(await page.locator('.commit-card-meta').textContent()).toContain('test@example.com'); | |
| 825 | + | expect(await page.locator('.commit-card-meta time').isVisible()).toBe(true); | |
| 826 | + | } finally { await page.close(); } | |
| 827 | + | }); | |
| 828 | + | ||
| 718 | 829 | test('patch description renders markdown', async () => { | |
| 719 | 830 | const page = await adminCtx.newPage(); | |
| 720 | 831 | try { | |
| @@ -806,6 +917,16 @@ describe('patches', () => { | |||
|---|---|---|---|
| 806 | 917 | } finally { await page.close(); } | |
| 807 | 918 | }); | |
| 808 | 919 | ||
| 920 | + | test('merge uses patch From header as git author', async () => { | |
| 921 | + | const repoPath = `${process.cwd()}/data-test/repos/my-repo.git`; | |
| 922 | + | const authorName = (await $`git -C ${repoPath} log -1 --format=%aN`.quiet()).text().trim(); | |
| 923 | + | const authorEmail = (await $`git -C ${repoPath} log -1 --format=%aE`.quiet()).text().trim(); | |
| 924 | + | const subject = (await $`git -C ${repoPath} log -1 --format=%s`.quiet()).text().trim(); | |
| 925 | + | expect(authorName).toBe('Test User'); | |
| 926 | + | expect(authorEmail).toBe('test@example.com'); | |
| 927 | + | expect(subject).toBe('Add patch-test.txt'); | |
| 928 | + | }); | |
| 929 | + | ||
| 809 | 930 | test('merged patch appears in merged list', async () => { | |
| 810 | 931 | const page = await adminCtx.newPage(); | |
| 811 | 932 | try { | |
| @@ -923,12 +1044,6 @@ describe('pagination', () => { | |||
|---|---|---|---|
| 923 | 1044 | ||
| 924 | 1045 | beforeAll(async () => { | |
| 925 | 1046 | adminCtx = await loggedInContext(); | |
| 926 | - | // Git identity is required to submit patches | |
| 927 | - | await adminCtx.request.fetch(`${BASE}/settings/git-identity`, { | |
| 928 | - | method: 'POST', | |
| 929 | - | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 930 | - | data: 'git_name=Admin+User&git_email=admin%40test.com', | |
| 931 | - | }); | |
| 932 | 1047 | ||
| 933 | 1048 | // Create a repo dedicated to pagination testing | |
| 934 | 1049 | const page = await adminCtx.newPage(); | |
| @@ -1893,31 +2008,22 @@ describe('settings', () => { | |||
|---|---|---|---|
| 1893 | 2008 | expect(resp.headers()['location']).toContain('error'); | |
| 1894 | 2009 | }); | |
| 1895 | 2010 | ||
| 1896 | - | test('git identity: empty name shows error', async () => { | |
| 1897 | - | const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, { | |
| 1898 | - | form: { git_name: ' ', git_email: 'test@example.com' }, | |
| 1899 | - | maxRedirects: 0, | |
| 1900 | - | }); | |
| 1901 | - | expect(resp.status()).toBe(302); | |
| 1902 | - | expect(resp.headers()['location']).toContain('error=Git+name+is+required'); | |
| 2011 | + | test('settings page has no git identity section', async () => { | |
| 2012 | + | const page = await adminCtx.newPage(); | |
| 2013 | + | try { | |
| 2014 | + | await page.goto(`${BASE}/settings`); | |
| 2015 | + | expect(await page.locator('text=Git Identity').count()).toBe(0); | |
| 2016 | + | expect(await page.locator('[name=git_name]').count()).toBe(0); | |
| 2017 | + | expect(await page.locator('[name=git_email]').count()).toBe(0); | |
| 2018 | + | } finally { await page.close(); } | |
| 1903 | 2019 | }); | |
| 1904 | 2020 | ||
| 1905 | - | test('git identity: empty email shows error', async () => { | |
| 2021 | + | test('git identity route no longer exists', async () => { | |
| 1906 | 2022 | const resp = await adminCtx.request.post(`${BASE}/settings/git-identity`, { | |
| 1907 | - | form: { git_name: 'Test User', git_email: ' ' }, | |
| 2023 | + | form: { git_name: 'Test', git_email: 'test@example.com' }, | |
| 1908 | 2024 | maxRedirects: 0, | |
| 1909 | 2025 | }); | |
| 1910 | - | expect(resp.status()).toBe(302); | |
| 1911 | - | expect(resp.headers()['location']).toContain('error=Git+email+is+required'); | |
| 1912 | - | }); | |
| 1913 | - | ||
| 1914 | - | test('git identity: valid values redirect with success', async () => { | |
| 1915 | - | const resp = await aliceCtx.request.post(`${BASE}/settings/git-identity`, { | |
| 1916 | - | form: { git_name: 'Alice Smith', git_email: 'alice@example.com' }, | |
| 1917 | - | maxRedirects: 0, | |
| 1918 | - | }); | |
| 1919 | - | expect(resp.status()).toBe(302); | |
| 1920 | - | expect(resp.headers()['location']).toContain('success=git_identity'); | |
| 2026 | + | expect(resp.status()).toBe(404); | |
| 1921 | 2027 | }); | |
| 1922 | 2028 | }); | |
| 1923 | 2029 | ||