diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b75ba53..8c89717c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ 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: the + 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 - **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..8c4bb0fd3 --- /dev/null +++ b/frontend/src/components/pulls/PullsCommits.tsx @@ -0,0 +1,76 @@ +import { useState } from "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 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. + } + return ( +
+ {commits.map((commit) => ( + + ))} +
+ ) +} + +function CommitRow({ commit }: { commit: PullRequestCommit }) { + const [open, setOpen] = useState(false) + const body = commit.body.trim() + return ( +
+ {/* 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. */} + + {open && ( +

+ {body} +

+ )} +
+ ) +} + +// 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}`) || "" +} 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 {