Commit pages credit co-authors instead of printing their trailers #6
Mergedsyntaqx merged commit-trailers into main
5 files+80−20/5 viewed
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { parseCommitMessage } from "./commit-message.ts"; | |
| 5 | + | ||
| 6 | + | test("co-authors leave the body and keep only their names", () => { | |
| 7 | + | const parsed = parseCommitMessage( | |
| 8 | + | "Document every option\n\nAdds the missing rows.\n\nCo-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>", | |
| 9 | + | ); | |
| 10 | + | assert.equal(parsed.subject, "Document every option"); | |
| 11 | + | assert.equal(parsed.body, "Adds the missing rows."); | |
| 12 | + | assert.deepEqual(parsed.coAuthors, ["Claude Sonnet 5.5"]); | |
| 13 | + | assert.deepEqual(parsed.trailers, []); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("a message that is only trailers has no body", () => { | |
| 17 | + | const parsed = parseCommitMessage("Fix it\n\nCo-Authored-By: Ada <ada@example.com>\nG1t-Pull: syntaqx/hello#72"); | |
| 18 | + | assert.equal(parsed.body, ""); | |
| 19 | + | assert.deepEqual(parsed.coAuthors, ["Ada"]); | |
| 20 | + | assert.deepEqual(parsed.trailers, [{ key: "G1t-Pull", value: "syntaqx/hello#72" }]); | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | test("prose that happens to contain a colon stays prose", () => { | |
| 24 | + | const parsed = parseCommitMessage("Fix it\n\nNote: this also renames a function.\nAnd tidies the tests."); | |
| 25 | + | assert.equal(parsed.body, "Note: this also renames a function.\nAnd tidies the tests."); | |
| 26 | + | assert.deepEqual(parsed.coAuthors, []); | |
| 27 | + | }); |
| 1 | + | /** | |
| 2 | + | * A commit message's parts: its subject, the prose of its body, and the | |
| 3 | + | * trailers git and tools add at the end (`Co-Authored-By: …`), which read | |
| 4 | + | * better as metadata than as prose. | |
| 5 | + | */ | |
| 6 | + | export type CommitMessage = { | |
| 7 | + | subject: string; | |
| 8 | + | body: string; | |
| 9 | + | /** The names in `Co-Authored-By` trailers, without their addresses. */ | |
| 10 | + | coAuthors: string[]; | |
| 11 | + | /** Every other trailer, as written. */ | |
| 12 | + | trailers: { key: string; value: string }[]; | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s+(.+)$/; | |
| 16 | + | ||
| 17 | + | export function parseCommitMessage(message: string): CommitMessage { | |
| 18 | + | const [subject = "", ...rest] = message.split("\n"); | |
| 19 | + | const lines = rest.join("\n").trim().split("\n"); | |
| 20 | + | // Trailers are the last paragraph, when every line of it is one. | |
| 21 | + | const lastBreak = lines.lastIndexOf(""); | |
| 22 | + | const last = lines.slice(lastBreak + 1); | |
| 23 | + | const isTrailers = last.length > 0 && last.every((line) => TRAILER.test(line.trim())); | |
| 24 | + | const prose = isTrailers ? lines.slice(0, Math.max(lastBreak, 0)) : lines; | |
| 25 | + | const coAuthors: string[] = []; | |
| 26 | + | const trailers: { key: string; value: string }[] = []; | |
| 27 | + | if (isTrailers) { | |
| 28 | + | for (const line of last) { | |
| 29 | + | const [, key, value] = TRAILER.exec(line.trim())!; | |
| 30 | + | if (key.toLowerCase() === "co-authored-by") coAuthors.push(value.replace(/\s*<[^>]*>\s*$/, "").trim()); | |
| 31 | + | else trailers.push({ key, value }); | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | return { subject, body: prose.join("\n").trim(), coAuthors, trailers }; | |
| 35 | + | } |
| 4 | 4 | ||
| 5 | 5 | import type { Comparison, Pull } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | + | import { parseCommitMessage } from "../../lib/commit-message"; | |
| 7 | 8 | import type { Route } from "./+types/commit"; | |
| 8 | 9 | import { DiffView } from "../../components/diff-view"; | |
| 9 | 10 | import { Avatar, TimeAgo } from "../../components/ui"; | |
| 121 | 122 | export default function CommitPage({ loaderData, params }: Route.ComponentProps) { | |
| 122 | 123 | const { commit, pull, comparison } = loaderData; | |
| 123 | 124 | const base = `/${params.owner}/${params.repo}`; | |
| 124 | − | const [subject, ...rest] = commit.message.split("\n"); | |
| 125 | − | const body = rest.join("\n").trim(); | |
| 125 | + | const { subject, body, coAuthors, trailers } = parseCommitMessage(commit.message); | |
| 126 | 126 | return ( | |
| 127 | 127 | <div> | |
| 128 | 128 | <section className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 135 | 135 | </p> | |
| 136 | 136 | <h2 className="mt-2 text-xl font-semibold tracking-tight text-balance">{subject}</h2> | |
| 137 | 137 | {body && <p className="mt-3 max-w-3xl text-sm whitespace-pre-wrap text-muted">{body}</p>} | |
| 138 | + | {trailers.length > 0 && ( | |
| 139 | + | <dl className="mt-3 space-y-0.5 font-mono text-xs text-faint"> | |
| 140 | + | {trailers.map((trailer) => ( | |
| 141 | + | <div key={`${trailer.key}:${trailer.value}`}> | |
| 142 | + | <dt className="inline">{trailer.key}:</dt> <dd className="inline text-muted">{trailer.value}</dd> | |
| 143 | + | </div> | |
| 144 | + | ))} | |
| 145 | + | </dl> | |
| 146 | + | )} | |
| 138 | 147 | </div> | |
| 139 | 148 | <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-line bg-bg/40 px-5 py-3 text-sm"> | |
| 140 | 149 | <span className="flex items-center gap-2"> | |
| 141 | 150 | <Avatar name={commit.author.name} size={20} /> | |
| 142 | 151 | <span className="font-medium">{commit.author.name}</span> | |
| 152 | + | {coAuthors.length > 0 && ( | |
| 153 | + | <span className="text-muted"> | |
| 154 | + | and <span className="font-medium text-fg">{coAuthors.join(", ")}</span> | |
| 155 | + | </span> | |
| 156 | + | )} | |
| 143 | 157 | <span className="text-muted"> | |
| 144 | 158 | committed <TimeAgo at={commit.authoredAt} /> | |
| 145 | 159 | </span> |
| 4 | 4 | "type": "module", | |
| 5 | 5 | "scripts": { | |
| 6 | 6 | "build": "react-router build", | |
| 7 | + | "test": "node --test app/**/*.test.ts", | |
| 7 | 8 | "dev": "react-router dev", | |
| 8 | 9 | "typecheck": "wrangler types --include-env=false && react-router typegen && tsc -b --force", | |
| 9 | 10 | "deploy": "npm run build && wrangler deploy", |
| 8 | 8 | "workers/**/*", | |
| 9 | 9 | "worker-configuration.d.ts" | |
| 10 | 10 | ], | |
| 11 | + | "exclude": ["app/**/*.test.ts"], | |
| 11 | 12 | "compilerOptions": { | |
| 12 | 13 | "composite": true, | |
| 13 | 14 | "strict": true, |