Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **The pull request's description is editable in the app, and its reviewers are
picked there too.** The Overview tab was a read-only rendering of the body: a
description an agent wrote and you wanted to trim, and choosing who reviews,
were the last two things about a pull request that sent you to github.com in
the middle of the work. Overview now says who opened it, lists the review
roster — who was asked, who approved, who requested changes — with **Request
review** beside it, ticking anyone the repository allows on a review, and
carries an **Edit** for the description that saves straight to GitHub. A
reviewer who already answered comes back unticked: ticking them again is the
re-request. Merged and closed pull requests keep the roster and lose the
picker, which GitHub would refuse anyway.

### Changed

- **A review comment's box grows with the comment.** Every field review prose is
Expand Down
10 changes: 2 additions & 8 deletions frontend/src/components/pulls/PullRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {
} from "@/lib/pulls/merge-gate"
import { useBranchRules } from "@/lib/pulls/use-branch-rules"
import { cn, errorText } from "@/lib/utils"
import { Markdown } from "@/components/Markdown"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
Expand All @@ -47,6 +46,7 @@ import { PullsChecks } from "./PullsChecks"
import { PullsCommits } from "./PullsCommits"
import { PullsConversation } from "./PullsConversation"
import { PullsFiles } from "./PullsFiles"
import { PullsOverview } from "./PullsOverview"
import { ChecksStat, MergeableStat, ReviewStat, StateStat } from "./PullsStats"
import type { ThreadActions } from "./ReviewThread"
import { SubmitReviewDialog } from "./SubmitReviewDialog"
Expand Down Expand Up @@ -442,13 +442,7 @@ export function PullRequestView({
/>
)}
{tab === "overview" && (
<div className="max-w-3xl px-6 py-5">
{detail.body.trim() !== "" ? (
<Markdown>{detail.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground">No description.</p>
)}
</div>
<PullsOverview path={path} detail={detail} onRefresh={onRefresh} />
)}
</div>
)}
Expand Down
238 changes: 238 additions & 0 deletions frontend/src/components/pulls/PullsOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { useState } from "react"
import { toast } from "sonner"
import {
Check,
ChevronDown,
CircleDashed,
MessageSquare,
Pencil,
UserPlus,
Users,
X,
type LucideIcon,
} from "lucide-react"
import type { PullRequestDetail, PullRequestReviewer, ReviewCandidate } from "@/lib/api-types"
import { pendingReviewerLogins, pickableReviewers } from "@/lib/pulls/reviewers"
import { ProjectService } from "@/lib/rpc"
import { cn, errorText } from "@/lib/utils"
import { Markdown } from "@/components/Markdown"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { commentFieldClass } from "./CommentBox"

interface PullsOverviewProps {
path: string
detail: PullRequestDetail
/** Re-read the pull request: an edit here lands on GitHub, and GitHub's
* answer — not a local copy of it — is what the screen then shows. */
onRefresh: () => void
}

// PullsOverview is the "Overview" tab: who opened the pull request, who is
// reviewing it, and what it says. The description and the reviewers are both
// editable here — they were the last two things about a pull request that sent
// the reader out to github.com.
export function PullsOverview({ path, detail, onRefresh }: PullsOverviewProps) {
// null is "not editing"; an empty string is a description being cleared, which
// is a legitimate edit and must not read as the same thing.
const [draft, setDraft] = useState<string | null>(null)
const [saving, setSaving] = useState(false)

const save = async () => {
if (draft === null) {
return
}
setSaving(true)
try {
await ProjectService.EditPullRequestBody(path, detail.number, draft)
setDraft(null)
onRefresh()
} catch (err: unknown) {
toast.error(`Couldn’t save the description: ${errorText(err)}`)
} finally {
setSaving(false)
}
}

return (
<div className="flex max-w-3xl flex-col gap-5 px-6 py-5">
<div className="flex flex-col gap-2 text-xs">
{detail.author !== "" && (
<span className="text-muted-foreground">
Opened by <span className="text-foreground">{detail.author}</span>
</span>
)}
<Reviewers path={path} detail={detail} onRefresh={onRefresh} />
</div>

<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Description</span>
{draft === null && (
<Button variant="ghost" size="sm" onClick={() => setDraft(detail.body)}>
<Pencil />
Edit
</Button>
)}
</div>
{draft === null ? (
detail.body.trim() !== "" ? (
<Markdown>{detail.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground">No description.</p>
)
) : (
<div className="flex flex-col gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setDraft(null)
}
}}
placeholder="Describe the pull request"
// biome-ignore lint/a11y/noAutofocus: the box only exists because Edit was just clicked — typing is the next thing that happens.
autoFocus
// The shared field already grows with what it holds; what changes
// here is where it stops. A description is the longest prose on
// the screen, so the bounds are the viewport's rather than the
// comment box's — tall enough to read a page of it, short enough
// to leave Save in sight.
className={cn(commentFieldClass, "min-h-[40vh] max-h-[70vh]")}
/>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => void save()} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
<Button size="sm" variant="ghost" onClick={() => setDraft(null)} disabled={saving}>
Cancel
</Button>
</div>
</div>
)}
</div>
</div>
)
}

// The roster, and the one control that changes it. The picker is the only way a
// reviewer is added or dropped — a second affordance on the chips would be two
// ways to say the same thing, and the tick already reads as "asked".
function Reviewers({ path, detail, onRefresh }: PullsOverviewProps) {
// Read on first open rather than with the pull request: most readings of a
// pull request never touch the roster, and this is a gh round-trip.
const [candidates, setCandidates] = useState<ReviewCandidate[] | null>(null)
const [busy, setBusy] = useState(false)
const roster = detail.reviewers ?? []
const pending = pendingReviewerLogins(detail.reviewers)
const pickable = pickableReviewers(candidates, detail.author)

const load = async () => {
if (candidates !== null) {
return
}
try {
setCandidates((await ProjectService.AssignableReviewers(path)) ?? [])
} catch (err: unknown) {
setCandidates([])
toast.error(`Couldn’t read who can review: ${errorText(err)}`)
}
}

const toggle = async (login: string, requested: boolean) => {
setBusy(true)
try {
await ProjectService.RequestReview(path, detail.number, login, requested)
onRefresh()
} catch (err: unknown) {
const asked = requested ? "request a review from" : "withdraw the review request for"
toast.error(`Couldn’t ${asked} ${login}: ${errorText(err)}`)
} finally {
setBusy(false)
}
}

return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-muted-foreground">Reviewers</span>
{roster.length === 0 && <span className="text-muted-foreground">Nobody yet</span>}
{roster.map((reviewer) => (
<ReviewerChip key={reviewer.login} reviewer={reviewer} />
))}
{/* A closed or merged pull request takes no new reviewer: GitHub refuses
the request, so the control goes rather than failing on click. */}
{detail.state === "OPEN" && (
<DropdownMenu
onOpenChange={(open) => {
if (open) {
void load()
}
}}
>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm">
<UserPlus />
Request review
<ChevronDown />
</Button>
}
/>
<DropdownMenuContent align="start" className="max-h-80 overflow-y-auto">
{candidates === null ? (
<DropdownMenuItem disabled>Reading who can review…</DropdownMenuItem>
) : pickable.length === 0 ? (
<DropdownMenuItem disabled>Nobody else can review this</DropdownMenuItem>
) : (
pickable.map((candidate) => (
<DropdownMenuCheckboxItem
key={candidate.login}
checked={pending.has(candidate.login)}
disabled={busy}
onCheckedChange={(next) => void toggle(candidate.login, next)}
>
{candidate.login}
{candidate.name !== "" && (
<span className="text-muted-foreground">{candidate.name}</span>
)}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)
}

// What each verdict reads as on a chip. A reviewer with no verdict yet is the
// default below: the review is still owed.
const VERDICTS: Record<string, { icon: LucideIcon; tone: string; label: string }> = {
APPROVED: { icon: Check, tone: "text-emerald-500", label: "approved" },
CHANGES_REQUESTED: { icon: X, tone: "text-destructive", label: "requested changes" },
COMMENTED: { icon: MessageSquare, tone: "text-muted-foreground", label: "commented" },
DISMISSED: { icon: CircleDashed, tone: "text-muted-foreground", label: "review dismissed" },
}

const AWAITING = { icon: CircleDashed, tone: "text-muted-foreground", label: "review requested" }

function ReviewerChip({ reviewer }: { reviewer: PullRequestReviewer }) {
const verdict = VERDICTS[reviewer.state] ?? AWAITING
// A team only ever arrives on the roster as a pending request — a verdict
// belongs to the person who left it — so its own glyph hides nothing.
const Icon = reviewer.isTeam ? Users : verdict.icon
return (
<span className={cn("flex items-center gap-1 font-medium", verdict.tone)} title={verdict.label}>
<Icon className="size-3.5" />
{reviewer.login}
</span>
)
}
24 changes: 24 additions & 0 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export interface PullRequestDetail {
url: string
title: string
body: string
/** Who opened it: a login, or a display name when gh reports no login. */
author: string
/** gh: OPEN | CLOSED | MERGED. Only a number-addressed lookup returns a
* non-OPEN one; the branch lookup still hides them. */
state: string
Expand All @@ -132,6 +134,9 @@ export interface PullRequestDetail {
/** gh's aggregate verdict: APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED;
* "" where the repository requires no review. */
reviewDecision: string
/** Who was asked for a review and who answered, in one list; null when
* nobody is on it. */
reviewers: PullRequestReviewer[] | null
baseRefName: string
headRefName: string
changedFiles: number
Expand All @@ -148,6 +153,25 @@ export interface PullRequestDetail {
commits: PullRequestCommit[] | null
}

/** internal/project.PRReviewer — one entry of the review roster: somebody asked
* and still owing a review, or somebody who has answered. */
export interface PullRequestReviewer {
/** A login, or a team's slug. */
login: string
/** A team, which gh addresses as org/slug — so it is shown, never edited. */
isTeam: boolean
/** "" while the review is still owed, else gh's APPROVED | CHANGES_REQUESTED
* | COMMENTED | DISMISSED. */
state: string
}

/** internal/project.PRCandidate — one account the reviewer picker may offer. */
export interface ReviewCandidate {
login: string
/** The display name; "" when the account has none. */
name: string
}

/** internal/project.PRReview — one submitted review and its verdict. */
export interface PullRequestReview {
author: string
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/pulls/merge-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ const detail = (over: Partial<PullRequestDetail> = {}): PullRequestDetail => ({
url: "https://github.com/o/l/pull/130",
title: "feat: something",
body: "",
author: "omartelo",
state: "OPEN",
isDraft: false,
mergeable: "MERGEABLE",
mergeStateStatus: "CLEAN",
reviewDecision: "",
reviewers: null,
baseRefName: "main",
headRefName: "quiet-willow",
changedFiles: 1,
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/lib/pulls/reviewers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import type { PullRequestReviewer, ReviewCandidate } from "@/lib/api-types"
import { pendingReviewerLogins, pickableReviewers } from "./reviewers"

const candidate = (login: string, name = ""): ReviewCandidate => ({ login, name })
const reviewer = (login: string, state = "", isTeam = false): PullRequestReviewer => ({
login,
state,
isTeam,
})

describe("pickableReviewers", () => {
it("sorts by login", () => {
const picked = pickableReviewers([candidate("cai"), candidate("ana"), candidate("bo")], "zed")
expect(picked.map((c) => c.login)).toEqual(["ana", "bo", "cai"])
})

// GitHub refuses a review request addressed to the author, so offering them
// is offering a click that can only fail.
it("drops the pull request's own author", () => {
const picked = pickableReviewers([candidate("ana"), candidate("bo")], "ana")
expect(picked.map((c) => c.login)).toEqual(["bo"])
})

it("has nothing to offer before the list is read", () => {
expect(pickableReviewers(null, "ana")).toEqual([])
})
})

describe("pendingReviewerLogins", () => {
it("ticks only who still owes a review", () => {
const pending = pendingReviewerLogins([
reviewer("ana", "APPROVED"),
reviewer("bo"),
reviewer("platform", "", true),
])
expect([...pending].sort()).toEqual(["bo", "platform"])
})

it("is empty with no roster", () => {
expect(pendingReviewerLogins(null).size).toBe(0)
})
})
Loading