Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

A push that brings an open pull request's head commit into the branch it merges into marks it merged, by whoever pushed, and a commit pushed to the default branch with Closes #N closes that issue (#48).

A pull request into a branch is merged when a push to that branch brings in its head commit: its branch merged in a clone and pushed, or its commits pushed straight to the base. It goes through the same bookkeeping as a merge from the site: its issues close, the other pull requests for its issue close as superseded, it leaves the merge queue and pull.merged is published. Its timeline says "merged this by pushing <commit> to <branch>". A head the branch already held before the push does not count. The merge button's and the queue's own pushes name the pull request they landed, and whoever landed it still records that one. A commit pushed to the default branch whose message names an issue after a closing keyword (fixes, closes, resolves and the rest) closes it as completed, where the repository closes issues that way (auto_close_issues). The issue's timeline says who pushed it and "closed this in <commit>". The issue page shows "Closed by commit", the issues list shows "closed by <commit>", and the commit page lists the issues it closed. Issues carry resolved_commit and resolved_pushed_by, issue.closed carries commit, and reopening clears both. A #12 in a commit message links to that issue or pull request, with its title and state on hover. This works in the commit list, on the commit page, in blame, in the latest-commit bar and file list, on tags, in compare and in Home's push rows. Timeline notes link full commit hashes. The card comes from /<owner>/<repo>/ref/<n>, which anyone who may read the repository can use; it is kept out of robots. Repos has a new services-only RPC, pushed: one walk from the new head that stops at what the branch held. Work calls it after branch pushes (services/work/src/pushed.rs). Migration 0047 adds issues.resolved_commit and resolved_pushed_by.

Checked:
cargo check --workspace --tests, and wasm32 for work, repos and api. Tests pass for work (200), repos (363), contracts (312) and api (209), including new ones for the walk, the candidate and close SQL against the migrations, commit closes and activity. In apps/web: tsc -b --force, react-router build and npm test (952 passing). Also npm test --workspaces, scripts/deploy (92) and plugins/claude-code (50). Screenshots of the commit list, commit page, issue page and timeline with mocked data at 1280 and 390 px.
G1t-Session:
ses_01m4ydqs6bers9ng6w4mg9bm79
syntaqxcommitted Parentf574400Browse files
40 files+276−4924 files not counted0/40 viewed
+32−0
221221 if let Some(pull) = data.get("resolvedBy").and_then(Value::as_u64) {
222222 shown["resolved_by"] = json!(pull);
223223 }
224+ // Closed by a commit pushed to the default branch.
225+ if let Some(commit) = text(data, "commit") {
226+ shown["resolved_commit"] = json!(commit);
227+ }
224228 shown["url"] = json!(format!("{page}/issues/{number}"));
225229 }
226230 "release.published" => {
933937 assert_eq!(listed[5]["resolved_by"], 7);
934938 assert_eq!(shown["complete"], true);
935939 }
940+
941+ #[test]
942+ fn an_issue_a_commit_closed_names_the_commit() {
943+ let repos = [repo("rep_1", "web")];
944+ let events = [event(
945+ "issue.closed",
946+ "rep_1",
947+ "2026-10-12T10:00:00Z",
948+ "usr_1",
949+ json!({ "number": 3, "title": "Login fails", "reason": "completed", "commit": "0123456789abcdef0123456789abcdef01234567" }),
950+ )];
951+ let names = HashMap::from([("usr_1".to_owned(), "ana".to_owned())]);
952+ let shown = shape(&Found {
953+ workspace: "acme",
954+ site: "https://g1t.sh/",
955+ since: parse_rfc3339("2026-10-12T00:00:00Z").unwrap(),
956+ until: parse_rfc3339("2026-10-12T18:00:00Z").unwrap(),
957+ repos: &repos,
958+ events: &events,
959+ more: false,
960+ pull_titles: &HashMap::new(),
961+ usernames: &names,
962+ });
963+ let closed = &shown["events"][0];
964+ assert_eq!(closed["resolved_commit"], "0123456789abcdef0123456789abcdef01234567");
965+ assert!(closed.get("resolved_by").is_none());
966+ assert_eq!(closed["actor"], "ana");
967+ }
936968 }
+1−1
16051605 "Issues on a repository, newest first. An issue is something that should change: a bug, a feature, a question. Pull requests are made against it. Filter by state, by a label's name, or by a milestone's number."
16061606 }
16071607 Op::GetIssue => {
1608− "An issue: its description (which may say what done means, under \"Definition of done\"), labels, its comments, and every pull request made against it with its status. If the issue is closed, resolved_by is the number of the pull request that was merged for it. Read this before opening a pull request, to see what others have already tried. A comment one of the workspace's agents wrote as itself has `agent` (its `id`, `handle`, `display_name` and `avatar_seed`) and `acting_for` (the person it acted for, whose access capped it); its `author` is the agent, of kind `agent`."
1608+ "An issue: its description (which may say what done means, under \"Definition of done\"), labels, its comments, and every pull request made against it with its status. If the issue is closed, resolved_by is the number of the pull request that was merged for it, or resolved_commit the commit pushed to the default branch whose message closed it (with resolved_pushed_by, who pushed it). Read this before opening a pull request, to see what others have already tried. A comment one of the workspace's agents wrote as itself has `agent` (its `id`, `handle`, `display_name` and `avatar_seed`) and `acting_for` (the person it acted for, whose access capped it); its `author` is the agent, of kind `agent`."
16091609 }
16101610 Op::CreateIssue => {
16111611 "Open an issue on a repository. Say what done means in the body if it helps, for instance under a \"Definition of done\" heading; what must pass before a pull request for it merges is the default branch's required checks, the same for every pull request. labels are the repository's labels by name; a name it does not have yet is created when you have the Triage role or higher, and refused otherwise. milestone, a milestone's number, needs the Triage role."
+3−1
12891289 "state": "closed",
12901290 "reason": "completed",
12911291 "resolved_by": 14,
1292+ "resolved_commit": null,
1293+ "resolved_pushed_by": null,
12921294 "author": {
12931295 "id": "usr_01kkntcg1eeb98j62xjm7eh09p",
12941296 "username": "syntaqx",
14181420 }
14191421 ]
14201422 },
1421− "notes": "A closed issue says how it was closed: `reason` is `completed` or `not_planned`, and `resolved_by` is the number of the pull request whose merge closed it. Each pull request made for it is listed; one closed because another was merged has `superseded_by` set."
1423+ "notes": "A closed issue says how it was closed: `reason` is `completed` or `not_planned`, and `resolved_by` is the number of the pull request whose merge closed it, or `resolved_commit` the commit pushed to the default branch whose message closed it (`Fixes #12`), with `resolved_pushed_by` saying who pushed it. Each pull request made for it is listed; one closed because another was merged has `superseded_by` set."
14221424 },
14231425 "update_issue": {
14241426 "request": {
+1−1
169169 | Setting | Field | Default | What it does |
170170 | --- | --- | --- | --- |
171171 | Issues | `has_issues` | On; off for a [fork](/concepts/forks/) | Whether issues can be opened. Off keeps existing issues readable. `null` goes back to the default. `issues_enabled` says whether they can be opened now. |
172−| Close issues when pull requests merge | `auto_close_issues` | On | A pull request that merges into the default branch closes its issue and those it names after a [closing keyword](/guides/pull-requests/#closing-issues). |
172+| Close issues when pull requests merge | `auto_close_issues` | On | A pull request that merges into the default branch closes its issue and those it names after a [closing keyword](/guides/pull-requests/#closing-issues), and so does a commit pushed to the default branch whose message names them. |
173173
174174 ```sh
175175 curl -X PATCH https://api.g1t.sh/repos/acme/rocket/settings \
+34−1
11 ---
22 title: Pull requests and checks
3−description: What the merge box shows before a pull request can merge, the checks your workflows report and which ones a merge needs, reviewers, sessions, how a large change's files load, drafts, closing and reopening, editing and deleting comments, merge methods, commit messages, auto-merge, closing issues, deleting branches, comments on commits, conflicts, and keeping a branch up to date.
3+description: What the merge box shows before a pull request can merge, the checks your workflows report and which ones a merge needs, reviewers, sessions, how a large change's files load, drafts, closing and reopening, editing and deleting comments, merge methods, commit messages, auto-merge, merging by pushing, closing issues, deleting branches, comments on commits, conflicts, and keeping a branch up to date.
44 ---
55
66 At the foot of an open pull request's conversation, the **merge box** says
453453 queue decides; that turns auto-merge off too, but nothing is left for you
454454 to do, so it tells no one.
455455
456+### Merging by pushing
457+
458+A pull request also merges when its head commit reaches the branch it
459+merges into some other way: you merge the branch in a clone and push, or
460+push its commits straight to the base. When a push to a branch brings in
461+the head commit of an open pull request into that branch (a draft too),
462+the pull request is marked merged, by whoever pushed, with `merge_commit`
463+set to the commit the branch was pushed to. It is the same as any other
464+merge: the issues it closes close, the other pull requests for its issue
465+close as superseded, it leaves the merge queue, and `pull.merged` is
466+published. Its timeline says **merged this by pushing** that commit **to**
467+the branch.
468+
469+Only the head commit counts: a push that brings in some of a pull
470+request's commits but not the last leaves it open. A head commit the branch
471+already held before the push (a branch opened at a commit of the base, with
472+nothing on it yet) does not merge it either. Its branch is not deleted.
473+
456474 ### Closing issues
457475
458476 When a pull request merges into the default branch, it closes the issue it
470488 nothing. A repository can turn this off with `auto_close_issues`; the
471489 issues then stay open after the merge.
472490
491+Commit messages close issues the same way. When a push to the default
492+branch brings in a commit whose message names an issue after a closing
493+keyword, that issue closes as completed. This applies to commits pushed
494+directly, merged in a clone, or landed by a pull request, and up to 250
495+commits per push are read. The issue's timeline says who pushed it and
496+**closed this in** that commit, its page shows **Closed by commit**, and
497+the commit's page lists the issues it closed. `issue.closed` carries the
498+commit as `commit`, and an issue has `resolved_commit` and
499+`resolved_pushed_by`; reopening it clears both. Commits pushed to other
500+branches close nothing until they reach the default branch, and
501+`auto_close_issues` turns this off as well.
502+
503+Wherever a commit's message shows, `#12` links to that issue or pull
504+request, with its title and state when you hover over it.
505+
473506 ### Deleting the branch
474507
475508 After a pull request from a branch merges or closes, the merge box offers
+1−1
8787 | `issue.labeled`, `issue.unlabeled`, `pull.labeled`, `pull.unlabeled` | A [label](/guides/labels/) was put on an issue or a pull request, or taken off: one event for each label. `data.number` and `data.label` (`name` and `color`). |
8888 | `issue.milestoned`, `issue.demilestoned`, `pull.milestoned`, `pull.demilestoned` | An issue or a pull request was put in a [milestone](/guides/milestones/), or taken out of one. `data.milestone` (`number` and `title`). Moving it from one to another is both. |
8989 | `pull.base_changed` | The branch a pull request merges into changed. `data.base` names it. See [pull requests into other branches](/guides/base-branches/). |
90−| `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number` and `data.author` (`id` and `username`); on close, `data.reason` and `data.resolved_by`; on assignment, `data.assignees` and the newly assigned `data.added`. |
90+| `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number` and `data.author` (`id` and `username`); on close, `data.reason` and `data.resolved_by` (the pull request that closed it) or `data.commit` (the commit pushed to the default branch that did); on assignment, `data.assignees` and the newly assigned `data.added`. |
9191 | `comment.created` | A comment or review on an issue or pull request. |
9292 | `comment.edited` | A comment's text was [edited](/guides/pull-requests/#editing-and-deleting-comments). `data.comment_id`, `data.number`, `data.pull_id` on a pull request, and `data.changes.body.from`, what it said before. |
9393 | `comment.deleted` | A comment was [deleted](/guides/pull-requests/#editing-and-deleting-comments). `data.comment_id`, `data.number`, `data.pull_id` on a pull request, and `data.comment` as it was: `id`, `body`, `author` (`id` and `username`), `created_at`, `path` and `line`. |
+0−0

Binary or large file; its contents are not shown.

+10−7
1616
1717 import type { loader as whyLoader } from "../routes/repo/why";
1818 import { repoAt } from "../lib/markdown-plugins";
19+import { CommitText } from "./commit-message";
1920 import { CommitAvatars, CommitNames } from "./commit-person";
2021 import { DecisionLine, IntegrationFailure } from "./coordination";
2122 import { resolutionOf } from "../lib/coordination";
128129 <span className="flex items-center gap-2 py-0.5 text-xs">
129130 <CommitAvatars commit={commit} size={16} max={2} />
130131 <span className={`min-w-0 grow truncate ${selected ? "text-fg" : "text-muted"}`}>
131− {commit.message.split("\n")[0]}
132+ <CommitText text={commit.message.split("\n")[0] ?? ""} base={base} refClassName="hover:text-accent hover:underline" />
132133 </span>
133134 <span className="shrink-0 text-faint">
134135 <TimeAgo at={commit.authoredAt} />
232233 <GitCommitHorizontal size={13} />
233234 Last changed in
234235 </p>
235− <Link
236− to={`${base}/commit/${current.commit.hash}`}
237− className="mt-1.5 block font-medium hover:text-accent"
238− >
239− {current.commit.message.split("\n")[0]}
240− </Link>
236+ <p className="mt-1.5 font-medium">
237+ <CommitText
238+ text={current.commit.message.split("\n")[0] ?? ""}
239+ base={base}
240+ to={`${base}/commit/${current.commit.hash}`}
241+ linkClassName="hover:text-accent"
242+ />
243+ </p>
241244 <p className="mt-1.5 flex items-center gap-2 text-xs text-muted">
242245 <CommitAvatars commit={current.commit} size={14} />
243246 <CommitNames commit={current.commit} className="text-fg" /> · <TimeAgo at={current.commit.authoredAt} /> ·{" "}
+14−10
2424
2525 const PULL_STATUSES: PullStatus[] = ["draft", "open", "merged", "closed"];
2626
27−/** Cards already asked for in this tab, by `repo#number`: a chip seen twice asks once. */
27+/** Cards already asked for in this tab, by where they come from: a chip seen twice asks once. */
2828 const cards = new Map<string, Promise<RefCard | null>>();
2929
30−function cardFor(slug: string, repo: string, number: number): Promise<RefCard | null> {
31− const key = `${slug}:${repo}#${number}`;
32− let found = cards.get(key);
30+function cardAt(url: string): Promise<RefCard | null> {
31+ let found = cards.get(url);
3332 if (!found) {
34− found = fetch(`/${slug}/-/chat/ref?repo=${encodeURIComponent(repo)}&number=${number}`, { headers: { accept: "application/json" } })
33+ found = fetch(url, { headers: { accept: "application/json" } })
3534 .then((response) => (response.ok ? (response.json() as Promise<RefCard>) : null))
3635 .catch(() => null);
37− cards.set(key, found);
36+ cards.set(url, found);
3837 }
3938 return found;
4039 }
4140
42−function RefCardBody({ slug, repo, number }: { slug: string; repo: string; number: number }) {
41+/**
42+ * The card itself, asked for from `url` once it opens: chat's
43+ * (routes/workspace/chat/ref.ts) for chips there, a repository's
44+ * (routes/repo/ref-card.ts) for references in its commits and timelines.
45+ */
46+export function RefCardBody({ url, repo, number }: { url: string; repo: string; number: number }) {
4347 const [card, setCard] = useState<RefCard | null | "loading">("loading");
4448 useEffect(() => {
4549 let live = true;
46− void cardFor(slug, repo, number).then((found) => live && setCard(found));
50+ void cardAt(url).then((found) => live && setCard(found));
4751 return () => {
4852 live = false;
4953 };
50− }, [slug, repo, number]);
54+ }, [url]);
5155 if (card === "loading") {
5256 return (
5357 <div className="space-y-2">
8993 </Link>
9094 </HoverCardTrigger>
9195 <HoverCardContent className="w-80">
92− <RefCardBody slug={slug} repo={repo} number={number} />
96+ <RefCardBody url={`/${slug}/-/chat/ref?repo=${encodeURIComponent(repo)}&number=${number}`} repo={repo} number={number} />
9397 </HoverCardContent>
9498 </HoverCard>
9599 );
+79−0
1+/**
2+ * A commit message's text with its references linked: `#12` and
3+ * `acme/web#12` to the issue or pull request, with its card on hover, and
4+ * a full hash to its commit. Where the whole message links to its commit
5+ * (a list of commits), the text between references links there and each
6+ * reference links on its own, so no link sits inside another.
7+ */
8+import type { ReactNode } from "react";
9+import { Link } from "react-router";
10+
11+import { RefCardBody } from "./chat/ref-chip";
12+import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
13+import { cn } from "../lib/cn";
14+import { messageParts } from "../lib/commit-message";
15+
16+/** `#12` in `repo` (`owner/name`): a link to it, its card on hover. */
17+export function RefLink({ repo, number, className, children }: { repo: string; number: number; className?: string; children: ReactNode }) {
18+ return (
19+ <HoverCard>
20+ <HoverCardTrigger asChild>
21+ {/* Issues and pull requests share numbers; the issue page forwards. */}
22+ <Link to={`/${repo}/issues/${number}`} className={cn("relative z-10", className)} data-ref="issue">
23+ {children}
24+ </Link>
25+ </HoverCardTrigger>
26+ <HoverCardContent className="w-80">
27+ <RefCardBody url={`/${repo}/ref/${number}`} repo={repo} number={number} />
28+ </HoverCardContent>
29+ </HoverCard>
30+ );
31+}
32+
33+/**
34+ * `text` with its references linked, in the repository at `base`
35+ * (`/owner/name`). With `to`, the text between them links there, styled
36+ * by `linkClassName`.
37+ */
38+export function CommitText({
39+ text,
40+ base,
41+ to,
42+ linkClassName,
43+ refClassName = "text-accent hover:underline",
44+}: {
45+ text: string;
46+ base: string;
47+ to?: string;
48+ linkClassName?: string;
49+ refClassName?: string;
50+}) {
51+ const repo = base.replace(/^\//, "");
52+ return (
53+ <>
54+ {messageParts(text).map((part, index) => {
55+ if (part.kind === "ref") {
56+ return (
57+ <RefLink key={index} repo={part.repo ?? repo} number={part.number} className={refClassName}>
58+ {part.text}
59+ </RefLink>
60+ );
61+ }
62+ if (part.kind === "commit") {
63+ return (
64+ <Link key={index} to={`${base}/commit/${part.sha}`} className={cn("relative z-10 font-mono text-[0.9em]", refClassName)}>
65+ {part.sha.slice(0, 7)}
66+ </Link>
67+ );
68+ }
69+ return to ? (
70+ <Link key={index} to={to} prefetch="intent" className={linkClassName}>
71+ {part.text}
72+ </Link>
73+ ) : (
74+ part.text
75+ );
76+ })}
77+ </>
78+ );
79+}
+11−3
5252 } from "../lib/home";
5353 import { cn } from "../lib/cn";
5454 import { money } from "../lib/money";
55+import { CommitText } from "./commit-message";
5556 import { ButtonLink } from "./ui";
5657 import { AgentMarker, Avatar } from "./ui/avatar";
5758 import { Badge, type BadgeTone } from "./ui/badge";
548549 <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span>
549550 <div className="min-w-0 grow">
550551 <div className="flex min-w-0 items-center gap-1.5">
551− <Link to={change.to} className="line-clamp-2 text-sm text-fg hover:underline sm:truncate">
552− {change.title}
553− </Link>
552+ {change.refsIn ? (
553+ // A commit's subject: its `#12`s link to their issues, the rest to the commit.
554+ <span className="line-clamp-2 text-sm text-fg sm:truncate">
555+ <CommitText text={change.title} base={`/${change.refsIn}`} to={change.to} linkClassName="hover:underline" />
556+ </span>
557+ ) : (
558+ <Link to={change.to} className="line-clamp-2 text-sm text-fg hover:underline sm:truncate">
559+ {change.title}
560+ </Link>
561+ )}
554562 <Agent change={change} />
555563 </div>
556564 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
+7−6
66
77 import type { FileCommits, ShownBlame, ShownCommit } from "../lib/commit-people";
88 import { CommitAvatars, CommitNames } from "./commit-person";
9+import { CommitText } from "./commit-message";
910
1011 import type { DeploymentEnvironments } from "@g1t/contracts";
1112
101102 <span className="shrink-0 font-medium">
102103 <CommitNames commit={commit} />
103104 </span>
104− <Link to={`${base}/commit/${commit.hash}`} className="truncate text-muted hover:text-fg hover:underline">
105− {commit.message.split("\n")[0]}
106− </Link>
105+ <span className="min-w-0 truncate text-muted">
106+ <CommitText text={commit.message.split("\n")[0] ?? ""} base={base} to={`${base}/commit/${commit.hash}`} linkClassName="hover:text-fg hover:underline" />
107+ </span>
107108 <CommitChecksBadge checks={checks} sha={commit.hash} className="-ml-1.5" />
108109 <span className="ml-auto flex shrink-0 items-center gap-3 text-xs text-faint sm:gap-4">
109110 <Link to={`${base}/commit/${commit.hash}`} className="hidden font-mono hover:text-fg sm:inline">
309310 <span className="hidden min-w-0 sm:block">
310311 {commit ? (
311312 <Hint label={commit.message.split("\n")[0]}>
312− <Link to={`${base}/commit/${commit.hash}`} className="block animate-fade-in truncate text-muted hover:text-fg hover:underline">
313− {commit.message.split("\n")[0]}
314− </Link>
313+ <span className="block animate-fade-in truncate text-muted">
314+ <CommitText text={commit.message.split("\n")[0] ?? ""} base={base} to={`${base}/commit/${commit.hash}`} linkClassName="hover:text-fg hover:underline" />
315+ </span>
315316 </Hint>
316317 ) : last === undefined ? (
317318 <SkeletonLine width="10rem" barClassName="max-w-full" />
+26−17
55 import type { AgentRef, Comment, Issue, Pull, State } from "@g1t/contracts";
66
77 import { mayChangeComment } from "../lib/comments";
8+import { messageParts } from "../lib/commit-message";
89 import { chipStyle } from "../lib/labels";
910 import { repoAt } from "../lib/markdown-plugins";
11+import { CommitChip } from "./chat/ref-chip";
12+import { RefLink } from "./commit-message";
1013 import { Markdown } from "./markdown";
1114 import { Reactions } from "./reactions";
1215 import { IssueIcon, PullIcon } from "./work-icons";
371374 type Review = { changesUrl: string; canJudge: boolean };
372375
373376 /**
374− * Text with each `#12` linked to the issue or pull request of that number,
375− * and each file named in backticks shown as code, linked to its diff when
377+ * Text with each `#12` linked to the issue or pull request of that number
378+ * (its card on hover), each full commit hash linked to its commit, and
379+ * each file named in backticks shown as code, linked to its diff when
376380 * `filesUrl` says where the changes are.
377381 */
378382 function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) {
379383 if (!base) return <>{text}</>;
384+ const repo = base.slice(1);
380385 return (
381386 <>
382− {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) =>
383− /^`[^`]+`$/.test(part) ? (
387+ {text.split(/(`[^`\n]+`)/).map((chunk, index) =>
388+ /^`[^`]+`$/.test(chunk) ? (
384389 filesUrl ? (
385390 <Link
386391 key={index}
387− to={`${filesUrl}#file-${part.slice(1, -1)}`}
392+ to={`${filesUrl}#file-${chunk.slice(1, -1)}`}
388393 className="font-mono text-xs text-fg hover:underline"
389394 >
390− {part.slice(1, -1)}
395+ {chunk.slice(1, -1)}
391396 </Link>
392397 ) : (
393398 <code key={index} className="font-mono text-xs text-fg">
394− {part.slice(1, -1)}
399+ {chunk.slice(1, -1)}
395400 </code>
396401 )
397− ) : /^#\d+$/.test(part) ? (
398− // Issues and pull requests share numbers; the issue page forwards.
399− <Link
400− key={index}
401− to={`${base}/issues/${part.slice(1)}`}
402− className="font-medium text-fg hover:underline"
403− >
404− {part}
405− </Link>
406402 ) : (
407− part
403+ <Fragment key={index}>
404+ {messageParts(chunk).map((part, at) =>
405+ part.kind === "ref" ? (
406+ <RefLink key={at} repo={part.repo ?? repo} number={part.number} className="font-medium text-fg hover:underline">
407+ {part.text}
408+ </RefLink>
409+ ) : part.kind === "commit" ? (
410+ // The commit that closed or merged it, by its short hash.
411+ <CommitChip key={at} href={`${base}/commit/${part.sha}`} sha={part.sha} />
412+ ) : (
413+ part.text
414+ ),
415+ )}
416+ </Fragment>
408417 ),
409418 )}
410419 </>
+26−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { parseCommitMessage } from "./commit-message.ts";
4+import { messageParts, parseCommitMessage } from "./commit-message.ts";
55
66 test("co-authors leave the body and keep only their names", () => {
77 const parsed = parseCommitMessage(
3030 assert.equal(parsed.body, "Note: this also renames a function.\nAnd tidies the tests.");
3131 assert.deepEqual(parsed.coAuthors, []);
3232 });
33+
34+test("references split from the text around them", () => {
35+ assert.deepEqual(messageParts("Fix the login form. Closes #12"), [
36+ { kind: "text", text: "Fix the login form. Closes " },
37+ { kind: "ref", text: "#12", repo: null, number: 12 },
38+ ]);
39+ assert.deepEqual(messageParts("#3 and acme/web#4."), [
40+ { kind: "ref", text: "#3", repo: null, number: 3 },
41+ { kind: "text", text: " and " },
42+ { kind: "ref", text: "acme/web#4", repo: "acme/web", number: 4 },
43+ { kind: "text", text: "." },
44+ ]);
45+});
46+
47+test("a full hash is a commit; a short one, a link's fragment or a word's # are text", () => {
48+ const sha = "0123456789abcdef0123456789abcdef01234567";
49+ assert.deepEqual(messageParts(`closed this in ${sha}`), [
50+ { kind: "text", text: "closed this in " },
51+ { kind: "commit", text: sha, sha },
52+ ]);
53+ for (const text of ["see 0123abc", "https://example.com/page#12", "C#12 support", "issue#12", "##12"]) {
54+ assert.deepEqual(messageParts(text), [{ kind: "text", text }], text);
55+ }
56+ assert.deepEqual(messageParts(""), []);
57+});
+28−0
3737 }
3838 return { subject, body: prose.join("\n").trim(), coAuthors, trailers };
3939 }
40+
41+/**
42+ * A run of a commit message, or of a note on an issue's timeline: plain
43+ * text, a reference to an issue or pull request (`#12` in the repository
44+ * it is in, `acme/web#12` in another), or a commit by its full hash.
45+ */
46+export type MessagePart =
47+ | { kind: "text"; text: string }
48+ | { kind: "ref"; text: string; repo: string | null; number: number }
49+ | { kind: "commit"; text: string; sha: string };
50+
51+// `#12` and `acme/web#12`, but not `a#12` or a link's `/page#12`; a full hash on its own.
52+const REFERENCE = /(?<![\w/#.-])(?:([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+))?#(\d+)\b|\b([0-9a-f]{40})\b/g;
53+
54+/** Splits text into its references and the text between them; `Closes #12` keeps `Closes ` as text. */
55+export function messageParts(text: string): MessagePart[] {
56+ const parts: MessagePart[] = [];
57+ let at = 0;
58+ for (const match of text.matchAll(REFERENCE)) {
59+ const start = match.index;
60+ if (start > at) parts.push({ kind: "text", text: text.slice(at, start) });
61+ if (match[3]) parts.push({ kind: "commit", text: match[0], sha: match[3] });
62+ else parts.push({ kind: "ref", text: match[0], repo: match[1] ?? null, number: Number(match[2]) });
63+ at = start + match[0].length;
64+ }
65+ if (at < text.length) parts.push({ kind: "text", text: text.slice(at) });
66+ return parts;
67+}
+3−0
523523 shipped?: ShipInfo | null;
524524 /** Made in a harness session (`withSessions`, or a `G1t-Session:` trailer): the agent marker, and the session. */
525525 agent?: AgentMark | null;
526+ /** A title that is a commit's subject: its repository (`owner/name`), whose issues its `#12`s are. */
527+ refsIn?: string;
526528 };
527529
528530 /** Whether a change is live: shipped by a production deployment, on its way, waiting, or the deployment after it failed. */
690692 sha: push.data.after,
691693 repoId,
692694 agent: session ? { harness: null, to: `/${repo.namespace}/${repo.name}/work/sessions/${session}` } : null,
695+ ...(brought.length === 1 && subject(newest.message) ? { refsIn: `${repo.namespace}/${repo.name}` } : {}),
693696 });
694697 }
695698 return rows;
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…

Comments

Sign in to comment.