From d4419625c8211db9f8f256239492255ee1f5e3a5 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 25 Jul 2026 19:48:59 -0300 Subject: [PATCH 1/4] feat: list the commits a pull request would land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pulls screen showed what a pull request changes — the description, the files, the checks — but never what it is made of. Reading how the branch got there meant leaving for the browser, and the diff alone never tells that story: one 40-file diff can be one commit or fifteen. A "Commits" tab now lists them, counted next to its name, oldest first as gh lists them: subject, author and date, with the message body under it the way git prints one. The commits ride along with the gh pr view the screen already makes, so the tab costs no extra round-trip. --- CHANGELOG.md | 9 +++ frontend/src/components/pulls/Pulls.tsx | 16 ++++-- .../src/components/pulls/PullsCommits.tsx | 46 ++++++++++++++++ frontend/src/lib/api-types.ts | 15 +++++ internal/project/pr.go | 54 +++++++++++++++++- internal/project/pr_test.go | 55 +++++++++++++++++++ 6 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/pulls/PullsCommits.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b75ba53..50719427d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **The Pulls screen now lists the commits a pull request would land.** A new + "Commits" tab, counted next to its name, shows every commit oldest first with + the subject, the author and the date, and the message body under it the way + git prints one — the branch's story, which the diff and the file list never + tell. The commits ride along with the lookup the screen already makes, so it + costs no extra round-trip. + ### Fixed - **A new directory of files now counts as the files it holds, not as one.** The diff --git a/frontend/src/components/pulls/Pulls.tsx b/frontend/src/components/pulls/Pulls.tsx index f245c2e32..c1f170a6a 100644 --- a/frontend/src/components/pulls/Pulls.tsx +++ b/frontend/src/components/pulls/Pulls.tsx @@ -47,6 +47,7 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Skeleton } from "@/components/ui/skeleton" import { PullsChecks } from "./PullsChecks" +import { PullsCommits } from "./PullsCommits" import { PullsFiles } from "./PullsFiles" // Ragged widths, so the body placeholder reads as prose instead of a block. @@ -213,7 +214,8 @@ function PullRequestView({ }: PullRequestViewProps) { const [merging, setMerging] = useState(false) const [edit, setEdit] = useState(null) - const [tab, setTab] = useState<"overview" | "files" | "checks">("overview") + const [tab, setTab] = useState<"overview" | "commits" | "files" | "checks">("overview") + const commitCount = detail.commits?.length ?? 0 const blocked = detail.isDraft ? "Pull request is a draft" : detail.mergeable === "CONFLICTING" @@ -338,6 +340,12 @@ function PullRequestView({ setTab("overview")}> Overview + setTab("commits")}> + Commits + {commitCount > 0 && ( + {commitCount} + )} + setTab("files")}> Files changed {detail.changedFiles > 0 && ( @@ -358,9 +366,9 @@ function PullRequestView({ ) : (
- {tab === "checks" ? ( - - ) : ( + {tab === "checks" && } + {tab === "commits" && } + {tab === "overview" && (
{detail.body.trim() !== "" ? ( {detail.body} diff --git a/frontend/src/components/pulls/PullsCommits.tsx b/frontend/src/components/pulls/PullsCommits.tsx new file mode 100644 index 000000000..e08c0f57f --- /dev/null +++ b/frontend/src/components/pulls/PullsCommits.tsx @@ -0,0 +1,46 @@ +import { Notice } from "@/components/common/Notice" +import type { PullRequestCommit } from "@/lib/api-types" + +// PullsCommits is the "Commits" tab of the Pulls screen: every commit the pull +// request would land, oldest first as gh lists them, subject and body as git +// itself shows a message — the story of the branch, which the diff never tells. +export function PullsCommits({ commits }: { commits: PullRequestCommit[] | null }) { + if (!commits || commits.length === 0) { + return No commits. + } + return ( +
+ {commits.map((commit) => ( + + ))} +
+ ) +} + +function CommitEntry({ commit }: { commit: PullRequestCommit }) { + const body = commit.body.trim() + const meta = [commit.author, commitDate(commit.date)].filter(Boolean).join(" · ") + return ( +
+
+

{commit.headline}

+ + {commit.oid.slice(0, 7)} + +
+ {meta &&

{meta}

} + {body && ( +

+ {body} +

+ )} +
+ ) +} + +// The day is what dates a commit against the others; the hour is noise beside +// the message. An unparseable stamp drops out of the meta line entirely. +function commitDate(iso: string): string { + const at = new Date(iso) + return Number.isNaN(at.getTime()) ? "" : at.toLocaleDateString() +} diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 6788b0498..8b583d06e 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -49,6 +49,19 @@ export interface CheckItem { completedAt: string } +/** internal/project.PRCommit — one commit of the PR, for the Commits tab. */ +export interface PullRequestCommit { + oid: string + /** The message's subject line. */ + headline: string + /** The rest of the message; "" for a one-line commit. */ + body: string + /** The first author's login, or their name; "" when gh reports none. */ + author: string + /** gh's ISO committedDate. */ + date: string +} + /** internal/project.PRDetail — the branch's open PR in full, for the Pulls panel. */ export interface PullRequestDetail { number: number @@ -64,6 +77,8 @@ export interface PullRequestDetail { checks: ChecksRollup /** The rollup itself, worst state first; null when the PR reports no checks. */ checkRuns: CheckItem[] | null + /** Every commit the PR would land, oldest first; null when gh reports none. */ + commits: PullRequestCommit[] | null } /** internal/project.Worktree — a git worktree checkout: branch and path. */ diff --git a/internal/project/pr.go b/internal/project/pr.go index 41585052d..1e5752486 100644 --- a/internal/project/pr.go +++ b/internal/project/pr.go @@ -55,7 +55,7 @@ func runGH(timeout time.Duration, dir string, args ...string) ([]byte, error) { } // prViewFields is the gh `pr view --json` selection backing the Pulls panel. -const prViewFields = "number,url,state,title,body,isDraft,mergeable,baseRefName,headRefName,statusCheckRollup,changedFiles" +const prViewFields = "number,url,state,title,body,isDraft,mergeable,baseRefName,headRefName,statusCheckRollup,changedFiles,commits" // PRDetail is the full view of a branch's open pull request — richer than the // footer badge's PullRequest — driving the dock's Pulls panel: the title, body, @@ -73,6 +73,17 @@ type PRDetail struct { ChangedFiles int `json:"changedFiles"` Checks ChecksRollup `json:"checks"` CheckRuns []CheckItem `json:"checkRuns"` + Commits []PRCommit `json:"commits"` +} + +// PRCommit is one commit the pull request would land, split the way git itself +// splits a message: the subject line, then the body (empty for a one-liner). +type PRCommit struct { + OID string `json:"oid"` + Headline string `json:"headline"` + Body string `json:"body"` + Author string `json:"author"` + Date string `json:"date"` // gh's ISO committedDate } // ChecksRollup collapses gh's statusCheckRollup array into the counts the panel @@ -111,6 +122,22 @@ type ghPRView struct { HeadRefName string `json:"headRefName"` ChangedFiles int `json:"changedFiles"` StatusCheckRollup []checkItem `json:"statusCheckRollup"` + Commits []ghCommit `json:"commits"` +} + +// ghCommit is one entry of gh's commits array. Authors is a list because of +// co-authored commits; the first one is the one the list names. +type ghCommit struct { + OID string `json:"oid"` + MessageHeadline string `json:"messageHeadline"` + MessageBody string `json:"messageBody"` + CommittedDate string `json:"committedDate"` + Authors []ghCommitAuthor `json:"authors"` +} + +type ghCommitAuthor struct { + Login string `json:"login"` + Name string `json:"name"` } // checkItem is one statusCheckRollup entry. gh emits two shapes: a CheckRun @@ -171,9 +198,34 @@ func parsePRDetail(out []byte) (*PRDetail, error) { ChangedFiles: v.ChangedFiles, Checks: reduceChecks(v.StatusCheckRollup), CheckRuns: toCheckItems(v.StatusCheckRollup), + Commits: toCommits(v.Commits), }, nil } +// toCommits flattens gh's commits into the list the Commits tab renders, in +// gh's own order — oldest first, the order the branch built them. Returns nil +// when gh reports none, so the tab can tell "no commits" from a list. +func toCommits(commits []ghCommit) []PRCommit { + if len(commits) == 0 { + return nil + } + out := make([]PRCommit, 0, len(commits)) + for _, c := range commits { + author := "" + if len(c.Authors) > 0 { + author = firstNonEmpty(c.Authors[0].Login, c.Authors[0].Name) + } + out = append(out, PRCommit{ + OID: c.OID, + Headline: c.MessageHeadline, + Body: c.MessageBody, + Author: author, + Date: c.CommittedDate, + }) + } + return out +} + // The verdict of one check, shared by the rollup counts and the Checks tab. const ( checkPassed = "passed" diff --git a/internal/project/pr_test.go b/internal/project/pr_test.go index 78862bf65..d56582b02 100644 --- a/internal/project/pr_test.go +++ b/internal/project/pr_test.go @@ -24,6 +24,9 @@ func TestParsePRDetail(t *testing.T) { "statusCheckRollup": [ {"status": "COMPLETED", "conclusion": "SUCCESS"}, {"state": "SUCCESS"} + ], + "commits": [ + {"oid": "a4dbc1f", "messageHeadline": "feat: the panel", "messageBody": "why"} ] }`) pr, err := parsePRDetail(out) @@ -45,6 +48,9 @@ func TestParsePRDetail(t *testing.T) { if pr.ChangedFiles != 6 { t.Errorf("wrong changed files: %d", pr.ChangedFiles) } + if len(pr.Commits) != 1 || pr.Commits[0].Headline != "feat: the panel" { + t.Errorf("wrong commits: %+v", pr.Commits) + } }) t.Run("draft flag survives", func(t *testing.T) { @@ -221,6 +227,55 @@ func TestToCheckItems(t *testing.T) { }) } +func TestToCommits(t *testing.T) { + t.Run("no commits yields no list", func(t *testing.T) { + if got := toCommits(nil); got != nil { + t.Errorf("toCommits(nil) = %+v, want nil", got) + } + }) + + t.Run("subject, body and author survive in gh's order", func(t *testing.T) { + got := toCommits([]ghCommit{ + { + OID: "a4dbc1f", + MessageHeadline: "fix: retire the badge", + MessageBody: "The footer badge only looked up again on HEAD moves.", + CommittedDate: "2026-07-25T22:32:47Z", + Authors: []ghCommitAuthor{{Login: "omartelo", Name: "omartelo"}}, + }, + {OID: "f55ebe9", MessageHeadline: "docs: changelog"}, + }) + if len(got) != 2 { + t.Fatalf("want 2 commits, got %+v", got) + } + first := got[0] + if first.OID != "a4dbc1f" || first.Headline != "fix: retire the badge" { + t.Errorf("wrong header: %+v", first) + } + if !strings.Contains(first.Body, "HEAD moves") || first.Author != "omartelo" { + t.Errorf("lost body or author: %+v", first) + } + if first.Date != "2026-07-25T22:32:47Z" { + t.Errorf("wrong date: %q", first.Date) + } + // A one-line commit has no body and a web-flow merge commit no author; + // both are rows, not errors. + if got[1].Body != "" || got[1].Author != "" { + t.Errorf("second commit should be bare: %+v", got[1]) + } + }) + + t.Run("a nameless login falls back to the name", func(t *testing.T) { + got := toCommits([]ghCommit{{ + OID: "c0ffee0", + Authors: []ghCommitAuthor{{Name: "Ada Lovelace"}}, + }}) + if len(got) != 1 || got[0].Author != "Ada Lovelace" { + t.Errorf("author fallback failed: %+v", got) + } + }) +} + func TestMergeArgs(t *testing.T) { // An unrecognised method must be refused before any gh shell-out. if _, err := mergeArgs("force", "", ""); err == nil { From cd9bbe9ec19e3c81308f3243c32c12add0c4cbba Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 25 Jul 2026 19:58:59 -0300 Subject: [PATCH 2/4] fix: keep a commit row to its subject line The first tab printed every message in full, so a branch of fifteen commits became a wall of prose you had to scroll past to count them. A row is now the subject line, who committed it and when; the body sits behind the row's toggle, the way GitHub's own list reads. --- CHANGELOG.md | 10 ++-- .../src/components/pulls/PullsCommits.tsx | 59 +++++++++++++------ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50719427d..2295e8a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **The Pulls screen now lists the commits a pull request would land.** A new - "Commits" tab, counted next to its name, shows every commit oldest first with - the subject, the author and the date, and the message body under it the way - git prints one — the branch's story, which the diff and the file list never - tell. The commits ride along with the lookup the screen already makes, so it - costs no extra round-trip. + "Commits" tab, counted next to its name, shows every commit oldest first: the + subject line, who committed it and when, with the message body a click away + behind the row's toggle — the branch's story, which the diff and the file list + never tell. The commits ride along with the lookup the screen already makes, + so it costs no extra round-trip. ### Fixed diff --git a/frontend/src/components/pulls/PullsCommits.tsx b/frontend/src/components/pulls/PullsCommits.tsx index e08c0f57f..86321f07f 100644 --- a/frontend/src/components/pulls/PullsCommits.tsx +++ b/frontend/src/components/pulls/PullsCommits.tsx @@ -1,36 +1,56 @@ +import { useState } from "react" +import { Ellipsis } from "lucide-react" import { Notice } from "@/components/common/Notice" import type { PullRequestCommit } from "@/lib/api-types" +import { cn } from "@/lib/utils" // PullsCommits is the "Commits" tab of the Pulls screen: every commit the pull -// request would land, oldest first as gh lists them, subject and body as git -// itself shows a message — the story of the branch, which the diff never tells. +// request would land, oldest first as gh lists them — the story of the branch, +// which the diff never tells. A row is its subject line; the message body waits +// behind the toggle, so a branch of fifteen commits stays a list you can scan. export function PullsCommits({ commits }: { commits: PullRequestCommit[] | null }) { if (!commits || commits.length === 0) { return No commits. } return ( -
+
{commits.map((commit) => ( - + ))}
) } -function CommitEntry({ commit }: { commit: PullRequestCommit }) { +function CommitRow({ commit }: { commit: PullRequestCommit }) { + const [open, setOpen] = useState(false) const body = commit.body.trim() - const meta = [commit.author, commitDate(commit.date)].filter(Boolean).join(" · ") return ( -
-
-

{commit.headline}

+
+
+ + {commit.headline} + + {body && ( + + )} {commit.oid.slice(0, 7)}
- {meta &&

{meta}

} - {body && ( -

+

{commitMeta(commit)}

+ {open && ( +

{body}

)} @@ -38,9 +58,14 @@ function CommitEntry({ commit }: { commit: PullRequestCommit }) { ) } -// The day is what dates a commit against the others; the hour is noise beside -// the message. An unparseable stamp drops out of the meta line entirely. -function commitDate(iso: string): string { - const at = new Date(iso) - return Number.isNaN(at.getTime()) ? "" : at.toLocaleDateString() +// Who landed it and when, in git's own phrasing. Either half can be missing — a +// merge commit from the web flow carries no author — so the line is built from +// what is there instead of printing an empty "committed on". +function commitMeta(commit: PullRequestCommit): string { + const at = new Date(commit.date) + const date = Number.isNaN(at.getTime()) ? "" : at.toLocaleDateString() + if (commit.author && date) { + return `${commit.author} committed ${date}` + } + return commit.author || (date && `Committed ${date}`) || "" } From 0db86e6c6f199e9bf98ba0d2589a2757f814cdf2 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 25 Jul 2026 20:04:02 -0300 Subject: [PATCH 3/4] fix: open a commit's body by clicking the commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading a message meant hitting a small ellipsis button beside the subject — GitHub's affordance, and a target you have to aim at for something the row already stands for. The row itself is the button now: it fills on hover, a chevron marks the commits that have a body, and one that doesn't stays inert instead of offering a toggle with nothing behind it. --- CHANGELOG.md | 5 +- .../src/components/pulls/PullsCommits.tsx | 47 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2295e8a40..8c89717c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The Pulls screen now lists the commits a pull request would land.** A new "Commits" tab, counted next to its name, shows every commit oldest first: the - subject line, who committed it and when, with the message body a click away - behind the row's toggle — the branch's story, which the diff and the file list - never tell. The commits ride along with the lookup the screen already makes, + subject line, who committed it and when, and clicking a row opens its message + body — the branch's story, which the diff and the file list never tell. The commits ride along with the lookup the screen already makes, so it costs no extra round-trip. ### Fixed diff --git a/frontend/src/components/pulls/PullsCommits.tsx b/frontend/src/components/pulls/PullsCommits.tsx index 86321f07f..ffa3e89c8 100644 --- a/frontend/src/components/pulls/PullsCommits.tsx +++ b/frontend/src/components/pulls/PullsCommits.tsx @@ -1,13 +1,13 @@ import { useState } from "react" -import { Ellipsis } from "lucide-react" +import { ChevronRight } from "lucide-react" import { Notice } from "@/components/common/Notice" import type { PullRequestCommit } from "@/lib/api-types" import { cn } from "@/lib/utils" // PullsCommits is the "Commits" tab of the Pulls screen: every commit the pull // request would land, oldest first as gh lists them — the story of the branch, -// which the diff never tells. A row is its subject line; the message body waits -// behind the toggle, so a branch of fifteen commits stays a list you can scan. +// which the diff never tells. A row is its subject line and clicking it opens +// the message body, so a branch of fifteen commits stays a list you can scan. export function PullsCommits({ commits }: { commits: PullRequestCommit[] | null }) { if (!commits || commits.length === 0) { return No commits. @@ -25,32 +25,37 @@ function CommitRow({ commit }: { commit: PullRequestCommit }) { const [open, setOpen] = useState(false) const body = commit.body.trim() return ( -
-
- - {commit.headline} +
+ {/* The whole row is the affordance — a body is read by clicking the commit + it belongs to, not a control beside it. A one-line commit has nothing + to open, so its row stays inert rather than offering an empty toggle. */} + + /> )} {commit.oid.slice(0, 7)} -
-

{commitMeta(commit)}

+ {open && ( -

+

{body}

)} From d69eab2307224560f6e1e73bae99b4e93b565076 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 25 Jul 2026 20:08:00 -0300 Subject: [PATCH 4/4] fix: keep the pointer off the commit rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app turns every enabled button into a pointer target, which reads as "this goes somewhere". A commit row goes nowhere — it unfolds in place — so it keeps the arrow, like the text it is. --- frontend/src/components/pulls/PullsCommits.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/pulls/PullsCommits.tsx b/frontend/src/components/pulls/PullsCommits.tsx index ffa3e89c8..8c4bb0fd3 100644 --- a/frontend/src/components/pulls/PullsCommits.tsx +++ b/frontend/src/components/pulls/PullsCommits.tsx @@ -34,7 +34,7 @@ function CommitRow({ commit }: { commit: PullRequestCommit }) { onClick={() => setOpen(!open)} disabled={body === ""} aria-expanded={body === "" ? undefined : open} - className="flex w-full items-center gap-2 px-6 py-2 text-left transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent" + className="flex w-full cursor-default items-center gap-2 px-6 py-2 text-left transition-colors hover:bg-accent/50 disabled:hover:bg-transparent" >