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
| 221 | 221 | if let Some(pull) = data.get("resolvedBy").and_then(Value::as_u64) { | |
| 222 | 222 | shown["resolved_by"] = json!(pull); | |
| 223 | 223 | } | |
| 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 | + | } | |
| 224 | 228 | shown["url"] = json!(format!("{page}/issues/{number}")); | |
| 225 | 229 | } | |
| 226 | 230 | "release.published" => { | |
| ⋯ | |||
| 933 | 937 | assert_eq!(listed[5]["resolved_by"], 7); | |
| 934 | 938 | assert_eq!(shown["complete"], true); | |
| 935 | 939 | } | |
| 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 | + | } | |
| 936 | 968 | } | |
| 1605 | 1605 | "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." | |
| 1606 | 1606 | } | |
| 1607 | 1607 | 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`." | |
| 1609 | 1609 | } | |
| 1610 | 1610 | Op::CreateIssue => { | |
| 1611 | 1611 | "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." |
| 1289 | 1289 | "state": "closed", | |
| 1290 | 1290 | "reason": "completed", | |
| 1291 | 1291 | "resolved_by": 14, | |
| 1292 | + | "resolved_commit": null, | |
| 1293 | + | "resolved_pushed_by": null, | |
| 1292 | 1294 | "author": { | |
| 1293 | 1295 | "id": "usr_01kkntcg1eeb98j62xjm7eh09p", | |
| 1294 | 1296 | "username": "syntaqx", | |
| ⋯ | |||
| 1418 | 1420 | } | |
| 1419 | 1421 | ] | |
| 1420 | 1422 | }, | |
| 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." | |
| 1422 | 1424 | }, | |
| 1423 | 1425 | "update_issue": { | |
| 1424 | 1426 | "request": { | |
| 169 | 169 | | Setting | Field | Default | What it does | | |
| 170 | 170 | | --- | --- | --- | --- | | |
| 171 | 171 | | 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. | | |
| 173 | 173 | ||
| 174 | 174 | ```sh | |
| 175 | 175 | curl -X PATCH https://api.g1t.sh/repos/acme/rocket/settings \ |
| 1 | 1 | --- | |
| 2 | 2 | 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. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | At the foot of an open pull request's conversation, the **merge box** says | |
| ⋯ | |||
| 453 | 453 | queue decides; that turns auto-merge off too, but nothing is left for you | |
| 454 | 454 | to do, so it tells no one. | |
| 455 | 455 | ||
| 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 | + | ||
| 456 | 474 | ### Closing issues | |
| 457 | 475 | ||
| 458 | 476 | When a pull request merges into the default branch, it closes the issue it | |
| ⋯ | |||
| 470 | 488 | nothing. A repository can turn this off with `auto_close_issues`; the | |
| 471 | 489 | issues then stay open after the merge. | |
| 472 | 490 | ||
| 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 | + | ||
| 473 | 506 | ### Deleting the branch | |
| 474 | 507 | ||
| 475 | 508 | After a pull request from a branch merges or closes, the merge box offers | |
| 87 | 87 | | `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`). | | |
| 88 | 88 | | `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. | | |
| 89 | 89 | | `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`. | | |
| 91 | 91 | | `comment.created` | A comment or review on an issue or pull request. | | |
| 92 | 92 | | `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. | | |
| 93 | 93 | | `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`. | |
Binary or large file; its contents are not shown.
| 16 | 16 | ||
| 17 | 17 | import type { loader as whyLoader } from "../routes/repo/why"; | |
| 18 | 18 | import { repoAt } from "../lib/markdown-plugins"; | |
| 19 | + | import { CommitText } from "./commit-message"; | |
| 19 | 20 | import { CommitAvatars, CommitNames } from "./commit-person"; | |
| 20 | 21 | import { DecisionLine, IntegrationFailure } from "./coordination"; | |
| 21 | 22 | import { resolutionOf } from "../lib/coordination"; | |
| ⋯ | |||
| 128 | 129 | <span className="flex items-center gap-2 py-0.5 text-xs"> | |
| 129 | 130 | <CommitAvatars commit={commit} size={16} max={2} /> | |
| 130 | 131 | <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" /> | |
| 132 | 133 | </span> | |
| 133 | 134 | <span className="shrink-0 text-faint"> | |
| 134 | 135 | <TimeAgo at={commit.authoredAt} /> | |
| ⋯ | |||
| 232 | 233 | <GitCommitHorizontal size={13} /> | |
| 233 | 234 | Last changed in | |
| 234 | 235 | </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> | |
| 241 | 244 | <p className="mt-1.5 flex items-center gap-2 text-xs text-muted"> | |
| 242 | 245 | <CommitAvatars commit={current.commit} size={14} /> | |
| 243 | 246 | <CommitNames commit={current.commit} className="text-fg" /> · <TimeAgo at={current.commit.authoredAt} /> ·{" "} | |
| 24 | 24 | ||
| 25 | 25 | const PULL_STATUSES: PullStatus[] = ["draft", "open", "merged", "closed"]; | |
| 26 | 26 | ||
| 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. */ | |
| 28 | 28 | const cards = new Map<string, Promise<RefCard | null>>(); | |
| 29 | 29 | ||
| 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); | |
| 33 | 32 | 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" } }) | |
| 35 | 34 | .then((response) => (response.ok ? (response.json() as Promise<RefCard>) : null)) | |
| 36 | 35 | .catch(() => null); | |
| 37 | − | cards.set(key, found); | |
| 36 | + | cards.set(url, found); | |
| 38 | 37 | } | |
| 39 | 38 | return found; | |
| 40 | 39 | } | |
| 41 | 40 | ||
| 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 }) { | |
| 43 | 47 | const [card, setCard] = useState<RefCard | null | "loading">("loading"); | |
| 44 | 48 | useEffect(() => { | |
| 45 | 49 | let live = true; | |
| 46 | − | void cardFor(slug, repo, number).then((found) => live && setCard(found)); | |
| 50 | + | void cardAt(url).then((found) => live && setCard(found)); | |
| 47 | 51 | return () => { | |
| 48 | 52 | live = false; | |
| 49 | 53 | }; | |
| 50 | − | }, [slug, repo, number]); | |
| 54 | + | }, [url]); | |
| 51 | 55 | if (card === "loading") { | |
| 52 | 56 | return ( | |
| 53 | 57 | <div className="space-y-2"> | |
| ⋯ | |||
| 89 | 93 | </Link> | |
| 90 | 94 | </HoverCardTrigger> | |
| 91 | 95 | <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} /> | |
| 93 | 97 | </HoverCardContent> | |
| 94 | 98 | </HoverCard> | |
| 95 | 99 | ); | |
| 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 | + | } |
| 52 | 52 | } from "../lib/home"; | |
| 53 | 53 | import { cn } from "../lib/cn"; | |
| 54 | 54 | import { money } from "../lib/money"; | |
| 55 | + | import { CommitText } from "./commit-message"; | |
| 55 | 56 | import { ButtonLink } from "./ui"; | |
| 56 | 57 | import { AgentMarker, Avatar } from "./ui/avatar"; | |
| 57 | 58 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| ⋯ | |||
| 548 | 549 | <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span> | |
| 549 | 550 | <div className="min-w-0 grow"> | |
| 550 | 551 | <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 | + | )} | |
| 554 | 562 | <Agent change={change} /> | |
| 555 | 563 | </div> | |
| 556 | 564 | <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> | |
| 6 | 6 | ||
| 7 | 7 | import type { FileCommits, ShownBlame, ShownCommit } from "../lib/commit-people"; | |
| 8 | 8 | import { CommitAvatars, CommitNames } from "./commit-person"; | |
| 9 | + | import { CommitText } from "./commit-message"; | |
| 9 | 10 | ||
| 10 | 11 | import type { DeploymentEnvironments } from "@g1t/contracts"; | |
| 11 | 12 | ||
| ⋯ | |||
| 101 | 102 | <span className="shrink-0 font-medium"> | |
| 102 | 103 | <CommitNames commit={commit} /> | |
| 103 | 104 | </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> | |
| 107 | 108 | <CommitChecksBadge checks={checks} sha={commit.hash} className="-ml-1.5" /> | |
| 108 | 109 | <span className="ml-auto flex shrink-0 items-center gap-3 text-xs text-faint sm:gap-4"> | |
| 109 | 110 | <Link to={`${base}/commit/${commit.hash}`} className="hidden font-mono hover:text-fg sm:inline"> | |
| ⋯ | |||
| 309 | 310 | <span className="hidden min-w-0 sm:block"> | |
| 310 | 311 | {commit ? ( | |
| 311 | 312 | <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> | |
| 315 | 316 | </Hint> | |
| 316 | 317 | ) : last === undefined ? ( | |
| 317 | 318 | <SkeletonLine width="10rem" barClassName="max-w-full" /> | |
| 5 | 5 | import type { AgentRef, Comment, Issue, Pull, State } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { mayChangeComment } from "../lib/comments"; | |
| 8 | + | import { messageParts } from "../lib/commit-message"; | |
| 8 | 9 | import { chipStyle } from "../lib/labels"; | |
| 9 | 10 | import { repoAt } from "../lib/markdown-plugins"; | |
| 11 | + | import { CommitChip } from "./chat/ref-chip"; | |
| 12 | + | import { RefLink } from "./commit-message"; | |
| 10 | 13 | import { Markdown } from "./markdown"; | |
| 11 | 14 | import { Reactions } from "./reactions"; | |
| 12 | 15 | import { IssueIcon, PullIcon } from "./work-icons"; | |
| ⋯ | |||
| 371 | 374 | type Review = { changesUrl: string; canJudge: boolean }; | |
| 372 | 375 | ||
| 373 | 376 | /** | |
| 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 | |
| 376 | 380 | * `filesUrl` says where the changes are. | |
| 377 | 381 | */ | |
| 378 | 382 | function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) { | |
| 379 | 383 | if (!base) return <>{text}</>; | |
| 384 | + | const repo = base.slice(1); | |
| 380 | 385 | return ( | |
| 381 | 386 | <> | |
| 382 | − | {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) => | |
| 383 | − | /^`[^`]+`$/.test(part) ? ( | |
| 387 | + | {text.split(/(`[^`\n]+`)/).map((chunk, index) => | |
| 388 | + | /^`[^`]+`$/.test(chunk) ? ( | |
| 384 | 389 | filesUrl ? ( | |
| 385 | 390 | <Link | |
| 386 | 391 | key={index} | |
| 387 | − | to={`${filesUrl}#file-${part.slice(1, -1)}`} | |
| 392 | + | to={`${filesUrl}#file-${chunk.slice(1, -1)}`} | |
| 388 | 393 | className="font-mono text-xs text-fg hover:underline" | |
| 389 | 394 | > | |
| 390 | − | {part.slice(1, -1)} | |
| 395 | + | {chunk.slice(1, -1)} | |
| 391 | 396 | </Link> | |
| 392 | 397 | ) : ( | |
| 393 | 398 | <code key={index} className="font-mono text-xs text-fg"> | |
| 394 | − | {part.slice(1, -1)} | |
| 399 | + | {chunk.slice(1, -1)} | |
| 395 | 400 | </code> | |
| 396 | 401 | ) | |
| 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> | |
| 406 | 402 | ) : ( | |
| 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> | |
| 408 | 417 | ), | |
| 409 | 418 | )} | |
| 410 | 419 | </> | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { parseCommitMessage } from "./commit-message.ts"; | |
| 4 | + | import { messageParts, parseCommitMessage } from "./commit-message.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("co-authors leave the body and keep only their names", () => { | |
| 7 | 7 | const parsed = parseCommitMessage( | |
| ⋯ | |||
| 30 | 30 | assert.equal(parsed.body, "Note: this also renames a function.\nAnd tidies the tests."); | |
| 31 | 31 | assert.deepEqual(parsed.coAuthors, []); | |
| 32 | 32 | }); | |
| 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 | + | }); | |
| 37 | 37 | } | |
| 38 | 38 | return { subject, body: prose.join("\n").trim(), coAuthors, trailers }; | |
| 39 | 39 | } | |
| 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 | + | } |
| 523 | 523 | shipped?: ShipInfo | null; | |
| 524 | 524 | /** Made in a harness session (`withSessions`, or a `G1t-Session:` trailer): the agent marker, and the session. */ | |
| 525 | 525 | 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; | |
| 526 | 528 | }; | |
| 527 | 529 | ||
| 528 | 530 | /** Whether a change is live: shipped by a production deployment, on its way, waiting, or the deployment after it failed. */ | |
| ⋯ | |||
| 690 | 692 | sha: push.data.after, | |
| 691 | 693 | repoId, | |
| 692 | 694 | 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}` } : {}), | |
| 693 | 696 | }); | |
| 694 | 697 | } | |
| 695 | 698 | return rows; | |
Comments
Sign in to comment.