From a8c25661bbf5abea29f3177bcd6eea59f587a969 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Wed, 17 Jun 2026 02:58:42 +0000 Subject: [PATCH 1/6] Surface active pull request status --- .../promptbox/FollowUpPromptBox.stories.tsx | 3 + .../ThreadPromptContextBanner.stories.tsx | 206 ++++++++++++--- .../banner/ThreadPromptContextBanner.test.tsx | 2 + .../banner/ThreadPromptContextBanner.tsx | 140 +++++++++- .../ThreadMetadataContent.fixtures.tsx | 34 ++- .../ThreadMetadataContent.rows.stories.tsx | 43 ++- .../ThreadMetadataContent.stories.tsx | 52 +++- .../secondary-panel/ThreadMetadataContent.tsx | 46 ++-- apps/app/src/lib/pull-request-display.ts | 193 ++++++++++++++ .../thread-detail/ThreadDetailPromptArea.tsx | 10 + .../views/thread-detail/ThreadDetailView.tsx | 1 + .../test/command/workspace-dispatch.test.ts | 20 +- .../environments/pull-request.test.ts | 107 ++++++++ .../src/services/environments/pull-request.ts | 187 +++++++++++++- packages/domain/src/thread.ts | 175 ++++++++++++- .../test/contract.test.ts | 15 ++ packages/host-workspace/src/git-host.ts | 244 +++++++++++++++++- packages/host-workspace/test/git-host.test.ts | 85 +++++- 18 files changed, 1466 insertions(+), 97 deletions(-) create mode 100644 apps/app/src/lib/pull-request-display.ts diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx index 8478b7c755..fb10e37c6e 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx @@ -342,6 +342,7 @@ const contextBannerElement: ReactNode = dirtyContextBannerSection ? ( gitSectionPending={false} parentThreadSection={null} childThreadsSection={null} + pullRequestSection={null} expandedSection={null} onToggleSection={noop} /> @@ -356,6 +357,7 @@ const archivedContextBannerElement: ReactNode = ( gitSectionPending={false} parentThreadSection={null} childThreadsSection={null} + pullRequestSection={null} expandedSection={null} onToggleSection={noop} /> @@ -370,6 +372,7 @@ const environmentGoneContextBannerElement: ReactNode = ( gitSectionPending={false} parentThreadSection={null} childThreadsSection={null} + pullRequestSection={null} expandedSection={null} onToggleSection={noop} /> diff --git a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx index d92ec0b6fc..db4e1e724c 100644 --- a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { + ThreadPullRequest, ThreadTimelinePendingTodos, WorkspaceFileStatus, WorkspaceStatus, @@ -90,25 +91,120 @@ const dirtyUncommittedStatus: WorkspaceStatus = { }; const dirtyUncommittedManyFiles: WorkspaceFileStatus[] = [ - { path: "apps/app/src/components/promptbox/FollowUpPromptBox.tsx", status: "M", insertions: 42, deletions: 18 }, - { path: "apps/app/src/components/promptbox/banner/PromptStackCard.tsx", status: "A", insertions: 96, deletions: 0 }, - { path: "apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx", status: "A", insertions: 74, deletions: 0 }, - { path: "apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx", status: "A", insertions: 88, deletions: 0 }, - { path: "apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx", status: "M", insertions: 21, deletions: 7 }, - { path: "apps/app/src/components/thread/WorkspaceChangesList.tsx", status: "M", insertions: 16, deletions: 4 }, - { path: "apps/app/src/components/workspace/workspace-change-summary.ts", status: "M", insertions: 8, deletions: 3 }, - { path: "apps/app/src/views/ThreadDetailPromptArea.tsx", status: "M", insertions: 12, deletions: 29 }, - { path: "apps/app/src/views/ThreadDetailSecondaryPanel.tsx", status: "M", insertions: 5, deletions: 5 }, - { path: "apps/app/src/hooks/useThreadPromptContext.ts", status: "M", insertions: 32, deletions: 14 }, - { path: "apps/app/src/lib/format-workspace-status.ts", status: "A", insertions: 24, deletions: 0 }, - { path: "apps/app/src/styles/promptbox.css", status: "M", insertions: 3, deletions: 1 }, - { path: "apps/app/.ladle/story-card.tsx", status: "M", insertions: 1, deletions: 1 }, - { path: "packages/domain/src/workspace.ts", status: "M", insertions: 10, deletions: 2 }, - { path: "packages/domain/src/thread.ts", status: "M", insertions: 6, deletions: 0 }, - { path: "apps/server/src/routes/threads.ts", status: "M", insertions: 18, deletions: 11 }, - { path: "apps/server/src/lifecycle/thread-prompt.ts", status: "M", insertions: 9, deletions: 4 }, - { path: "apps/host/src/workspace/status.ts", status: "M", insertions: 22, deletions: 8 }, - { path: "apps/app/src/components/promptbox/banner/__snapshots__/ThreadPromptContextBanner.test.tsx.snap", status: "D", insertions: 0, deletions: 187 }, + { + path: "apps/app/src/components/promptbox/FollowUpPromptBox.tsx", + status: "M", + insertions: 42, + deletions: 18, + }, + { + path: "apps/app/src/components/promptbox/banner/PromptStackCard.tsx", + status: "A", + insertions: 96, + deletions: 0, + }, + { + path: "apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx", + status: "A", + insertions: 74, + deletions: 0, + }, + { + path: "apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx", + status: "A", + insertions: 88, + deletions: 0, + }, + { + path: "apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx", + status: "M", + insertions: 21, + deletions: 7, + }, + { + path: "apps/app/src/components/thread/WorkspaceChangesList.tsx", + status: "M", + insertions: 16, + deletions: 4, + }, + { + path: "apps/app/src/components/workspace/workspace-change-summary.ts", + status: "M", + insertions: 8, + deletions: 3, + }, + { + path: "apps/app/src/views/ThreadDetailPromptArea.tsx", + status: "M", + insertions: 12, + deletions: 29, + }, + { + path: "apps/app/src/views/ThreadDetailSecondaryPanel.tsx", + status: "M", + insertions: 5, + deletions: 5, + }, + { + path: "apps/app/src/hooks/useThreadPromptContext.ts", + status: "M", + insertions: 32, + deletions: 14, + }, + { + path: "apps/app/src/lib/format-workspace-status.ts", + status: "A", + insertions: 24, + deletions: 0, + }, + { + path: "apps/app/src/styles/promptbox.css", + status: "M", + insertions: 3, + deletions: 1, + }, + { + path: "apps/app/.ladle/story-card.tsx", + status: "M", + insertions: 1, + deletions: 1, + }, + { + path: "packages/domain/src/workspace.ts", + status: "M", + insertions: 10, + deletions: 2, + }, + { + path: "packages/domain/src/thread.ts", + status: "M", + insertions: 6, + deletions: 0, + }, + { + path: "apps/server/src/routes/threads.ts", + status: "M", + insertions: 18, + deletions: 11, + }, + { + path: "apps/server/src/lifecycle/thread-prompt.ts", + status: "M", + insertions: 9, + deletions: 4, + }, + { + path: "apps/host/src/workspace/status.ts", + status: "M", + insertions: 22, + deletions: 8, + }, + { + path: "apps/app/src/components/promptbox/banner/__snapshots__/ThreadPromptContextBanner.test.tsx.snap", + status: "D", + insertions: 0, + deletions: 187, + }, ]; const dirtyUncommittedManyStatus: WorkspaceStatus = { @@ -294,6 +390,33 @@ const childThreadsLargeFixture: ThreadPromptChildThreadsSection = { })), }; +const pullRequestFixture: ThreadPullRequest = { + number: 128, + title: "Show pull request status in the prompt context banner", + state: "open", + url: "https://github.com/acme/bb/pull/128", + baseRefName: "main", + headRefName: "bb/pr-context-banner", + updatedAt: "2026-06-16T12:30:00Z", + checks: { + state: "failing", + totalCount: 3, + passedCount: 1, + failedCount: 1, + pendingCount: 1, + }, + review: { + state: "review_requested", + reviewRequestCount: 1, + }, + mergeability: { + state: "mergeable", + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", + }, + attention: "checks_failed", +}; + interface RowConfig { section?: WorkspaceChangedFilesSection; mergeBase?: ContextBannerMergeBaseConfig | null; @@ -302,6 +425,7 @@ interface RowConfig { environmentGone?: ThreadPromptEnvironmentGoneSection | null; parentThread?: ThreadPromptParentThreadSection | null; childThreads?: ThreadPromptChildThreadsSection | null; + pullRequest?: ThreadPullRequest | null; initiallyExpandedSection?: ThreadPromptContextBannerExpandedSection | null; } @@ -313,11 +437,13 @@ function Row({ environmentGone = null, parentThread = null, childThreads = null, + pullRequest = null, initiallyExpandedSection = null, }: RowConfig) { - const [expandedSection, setExpandedSection] = useState< - ThreadPromptContextBannerExpandedSection | null - >(initiallyExpandedSection); + const [expandedSection, setExpandedSection] = + useState( + initiallyExpandedSection, + ); return ( - setExpandedSection((previous) => - previous === next ? null : next, - ) + setExpandedSection((previous) => (previous === next ? null : next)) } /> @@ -426,10 +551,7 @@ export function Overview() { mergeBase={null} /> - + + + + + + + + + + { environmentGoneSection={null} parentThreadSection={null} childThreadsSection={null} + pullRequestSection={null} expandedSection={null} onToggleSection={noop} />, @@ -35,6 +36,7 @@ describe("ThreadPromptContextBanner", () => { environmentGoneSection={{ status: "destroyed" }} parentThreadSection={null} childThreadsSection={null} + pullRequestSection={null} expandedSection={null} onToggleSection={noop} />, diff --git a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx index d3329848eb..3ed32bf0b8 100644 --- a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx @@ -3,6 +3,7 @@ import { NavLink } from "react-router-dom"; import type { EnvironmentStatus, GitBranchRefClassification, + ThreadPullRequest, ThreadRuntimeDisplayStatus, ThreadTimelinePendingTodoItem, ThreadTimelinePendingTodoItemStatus, @@ -23,6 +24,11 @@ import { } from "@/components/workspace/workspace-change-summary"; import { cn } from "@/lib/utils"; import { Icon, type IconName } from "@/components/ui/icon.js"; +import { + getPullRequestAttentionDisplay, + getPullRequestSignalDisplays, + PULL_REQUEST_STATE_DISPLAY, +} from "@/lib/pull-request-display"; export interface ContextBannerMergeBaseConfig { branch: string; @@ -71,6 +77,10 @@ export interface ThreadPromptChildThreadsSection { items: readonly ThreadPromptChildThreadItem[]; } +export interface ThreadPromptPullRequestSection { + pullRequest: ThreadPullRequest; +} + /** * Archived-state segment for the banner. When present, the banner renders * only this row — archived threads are read-only, so suppressing the other @@ -114,7 +124,8 @@ export type ThreadPromptContextBannerExpandedSection = | "todos" | "git" | "parentThread" - | "childThreads"; + | "childThreads" + | "pullRequest"; /** * Pixel height of the banner's collapsed (single-row) state. Pinned via the @@ -151,6 +162,7 @@ export interface ThreadPromptContextBannerProps { environmentGoneSection: ThreadPromptEnvironmentGoneSection | null; parentThreadSection: ThreadPromptParentThreadSection | null; childThreadsSection: ThreadPromptChildThreadsSection | null; + pullRequestSection: ThreadPromptPullRequestSection | null; expandedSection: ThreadPromptContextBannerExpandedSection | null; onToggleSection: (section: ThreadPromptContextBannerExpandedSection) => void; } @@ -185,6 +197,10 @@ const SECTION_IDS = { toggle: "thread-prompt-banner-git-toggle", body: "thread-prompt-banner-git-body", }, + pullRequest: { + toggle: "thread-prompt-banner-pull-request-toggle", + body: "thread-prompt-banner-pull-request-body", + }, } as const; const STATUS_SORT_RANK: Record = { @@ -266,9 +282,7 @@ function SectionToggleButton({ {label !== null && label !== undefined ? ( {label} @@ -428,6 +442,59 @@ function ChildThreadsBody({ ); } +function PullRequestBody({ pullRequest }: { pullRequest: ThreadPullRequest }) { + const stateDisplay = PULL_REQUEST_STATE_DISPLAY[pullRequest.state]; + const signalDisplays = getPullRequestSignalDisplays(pullRequest); + return ( +
+ + PR #{pullRequest.number} + {pullRequest.title} + +
+
+
+ {signalDisplays.map((display) => ( + + + ))} +
+
+ ); +} + function AnimatedBody({ id, labelledBy, @@ -514,9 +581,7 @@ function ReadOnlyContextBanner({ />
- {render({ pullRequest: { number: 128, title, state: "open", url } })} + {render({ pullRequest: makePullRequest() })} - {render({ pullRequest: { number: 128, title, state: "draft", url } })} + {render({ + pullRequest: makePullRequest({ + state: "draft", + mergeability: { + state: "draft", + mergeStateStatus: "DRAFT", + mergeable: "UNKNOWN", + }, + attention: "draft", + }), + })} - {render({ pullRequest: { number: 128, title, state: "merged", url } })} + {render({ + pullRequest: makePullRequest({ + state: "merged", + attention: "merged", + }), + })} - {render({ pullRequest: { number: 128, title, state: "closed", url } })} + {render({ + pullRequest: makePullRequest({ + state: "closed", + attention: "closed", + }), + })} + + + {render({ + pullRequest: makePullRequest({ + checks: { + state: "failing", + totalCount: 3, + passedCount: 2, + failedCount: 1, + pendingCount: 0, + }, + attention: "checks_failed", + }), + })} {render({ pullRequest: null })} diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx index 15f74271be..820c20b733 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx @@ -5,7 +5,6 @@ import { Link } from "react-router-dom"; import type { Environment, GitBranchRefClassification, - PullRequestState, Thread, ThreadListEntry, ThreadPullRequest, @@ -62,6 +61,11 @@ import { useThreads } from "@/hooks/queries/thread-queries"; import { buildParentSelectorOptions } from "@/views/thread-detail/threadParentSelectorOptions"; import { getThreadRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; +import { + getPullRequestAttentionDisplay, + getPullRequestSignalDisplays, + PULL_REQUEST_STATE_DISPLAY, +} from "@/lib/pull-request-display"; // --------------------------------------------------------------------------- // Each row of the Info tab is a function component that owns its own raw @@ -389,22 +393,6 @@ export function BranchRow({ thread, workspaceStatus }: BranchRowProps) { ); } -interface PullRequestStateDisplay { - label: string; - /** Background utility for the leading state dot. */ - dotClass: string; -} - -const PULL_REQUEST_STATE_DISPLAY: Record< - PullRequestState, - PullRequestStateDisplay -> = { - open: { label: "Open", dotClass: "bg-success" }, - draft: { label: "Draft", dotClass: "bg-muted-foreground" }, - merged: { label: "Merged", dotClass: "bg-pr-merged" }, - closed: { label: "Closed", dotClass: "bg-destructive" }, -}; - export interface PullRequestRowProps { pullRequest: ThreadPullRequest | null; } @@ -412,6 +400,8 @@ export interface PullRequestRowProps { export function PullRequestRow({ pullRequest }: PullRequestRowProps) { if (!pullRequest) return null; const stateDisplay = PULL_REQUEST_STATE_DISPLAY[pullRequest.state]; + const attentionDisplay = getPullRequestAttentionDisplay(pullRequest); + const signalDisplays = getPullRequestSignalDisplays(pullRequest); return ( {stateDisplay.label} + · + + {attentionDisplay.label} +
+ {signalDisplays.map((display) => ( + + + {display.label} + + ))} +
); } diff --git a/apps/app/src/lib/pull-request-display.ts b/apps/app/src/lib/pull-request-display.ts new file mode 100644 index 0000000000..7d67737169 --- /dev/null +++ b/apps/app/src/lib/pull-request-display.ts @@ -0,0 +1,193 @@ +import type { + PullRequestState, + ThreadPullRequest, + ThreadPullRequestAttentionState, + ThreadPullRequestChecksState, + ThreadPullRequestMergeabilityState, + ThreadPullRequestReviewState, +} from "@bb/domain"; +import type { IconName } from "@/components/ui/icon.js"; + +interface PullRequestDisplay { + label: string; + icon: IconName; + className: string; +} + +export interface PullRequestStateDisplay extends PullRequestDisplay { + dotClass: string; +} + +export const PULL_REQUEST_STATE_DISPLAY: Record< + PullRequestState, + PullRequestStateDisplay +> = { + open: { + label: "Open", + icon: "GitMerge", + className: "text-success", + dotClass: "bg-success", + }, + draft: { + label: "Draft", + icon: "GitMerge", + className: "text-muted-foreground", + dotClass: "bg-muted-foreground", + }, + merged: { + label: "Merged", + icon: "GitMerge", + className: "text-pr-merged", + dotClass: "bg-pr-merged", + }, + closed: { + label: "Closed", + icon: "CircleX", + className: "text-destructive", + dotClass: "bg-destructive", + }, +}; + +const CHECKS_DISPLAY: Record = + { + passing: { + label: "Checks passing", + icon: "CircleCheck", + className: "text-success", + }, + failing: { + label: "Checks failing", + icon: "CircleX", + className: "text-destructive", + }, + pending: { + label: "Checks pending", + icon: "Clock", + className: "text-warning-text", + }, + no_checks: { + label: "No checks", + icon: "Circle", + className: "text-muted-foreground", + }, + unknown: { + label: "Checks unknown", + icon: "AlertTriangle", + className: "text-warning-text", + }, + }; + +const REVIEW_DISPLAY: Record = + { + approved: { + label: "Approved", + icon: "CircleCheck", + className: "text-success", + }, + changes_requested: { + label: "Changes requested", + icon: "CircleX", + className: "text-destructive", + }, + review_required: { + label: "Review required", + icon: "Clock", + className: "text-warning-text", + }, + review_requested: { + label: "Review requested", + icon: "Clock", + className: "text-warning-text", + }, + none: { + label: "No review", + icon: "Circle", + className: "text-muted-foreground", + }, + }; + +const MERGEABILITY_DISPLAY: Record< + ThreadPullRequestMergeabilityState, + PullRequestDisplay +> = { + mergeable: { + label: "Mergeable", + icon: "CircleCheck", + className: "text-success", + }, + conflicts: { + label: "Conflicts", + icon: "AlertTriangle", + className: "text-destructive", + }, + blocked: { + label: "Blocked", + icon: "AlertTriangle", + className: "text-warning-text", + }, + draft: { + label: "Draft", + icon: "Clock", + className: "text-muted-foreground", + }, + unknown: { + label: "Mergeability unknown", + icon: "AlertTriangle", + className: "text-warning-text", + }, +}; + +const ATTENTION_DISPLAY: Record< + ThreadPullRequestAttentionState, + PullRequestDisplay +> = { + checks_failed: CHECKS_DISPLAY.failing, + checks_pending: CHECKS_DISPLAY.pending, + changes_requested: REVIEW_DISPLAY.changes_requested, + review_requested: REVIEW_DISPLAY.review_requested, + conflicts: MERGEABILITY_DISPLAY.conflicts, + blocked: MERGEABILITY_DISPLAY.blocked, + draft: PULL_REQUEST_STATE_DISPLAY.draft, + ready_to_merge: { + label: "Ready to merge", + icon: "CircleCheck", + className: "text-success", + }, + merged: PULL_REQUEST_STATE_DISPLAY.merged, + closed: PULL_REQUEST_STATE_DISPLAY.closed, + none: PULL_REQUEST_STATE_DISPLAY.open, +}; + +export function getPullRequestChecksDisplay( + pullRequest: ThreadPullRequest, +): PullRequestDisplay { + return CHECKS_DISPLAY[pullRequest.checks.state]; +} + +export function getPullRequestReviewDisplay( + pullRequest: ThreadPullRequest, +): PullRequestDisplay { + return REVIEW_DISPLAY[pullRequest.review.state]; +} + +export function getPullRequestMergeabilityDisplay( + pullRequest: ThreadPullRequest, +): PullRequestDisplay { + return MERGEABILITY_DISPLAY[pullRequest.mergeability.state]; +} + +export function getPullRequestAttentionDisplay( + pullRequest: ThreadPullRequest, +): PullRequestDisplay { + return ATTENTION_DISPLAY[pullRequest.attention]; +} + +export function getPullRequestSignalDisplays( + pullRequest: ThreadPullRequest, +): readonly PullRequestDisplay[] { + return [ + getPullRequestChecksDisplay(pullRequest), + getPullRequestReviewDisplay(pullRequest), + getPullRequestMergeabilityDisplay(pullRequest), + ]; +} diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 8a4fefe5f5..55808c18c8 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -6,6 +6,7 @@ import type { EnvironmentStatus, PendingInteraction, ThreadQueuedMessage, + ThreadPullRequest, ThreadTimelineGoal, ThreadTimelinePendingTodos, ThreadWithRuntime, @@ -21,6 +22,7 @@ import { type ThreadPromptContextBannerExpandedSection, type ThreadPromptParentThreadSection, type ThreadPromptChildThreadsSection, + type ThreadPromptPullRequestSection, } from "@/components/promptbox/banner/ThreadPromptContextBanner"; import { ThreadGoalCard } from "@/components/promptbox/banner/ThreadGoalCard"; import { ThreadWorkflowCard } from "@/components/promptbox/banner/ThreadWorkflowCard"; @@ -136,6 +138,8 @@ interface ThreadDetailPromptAreaProps { parentThreadSection: ThreadPromptParentThreadSection | null; /** Active child threads for parent threads. Null otherwise. */ childThreadsSection: ThreadPromptChildThreadsSection | null; + /** Pull request summary for the active thread branch. Null when there is no PR. */ + pullRequest: ThreadPullRequest | null; sendMessage: SendMessageMutationLike; /** * Bumped by the timeline host each time a quote is appended to the shared @@ -179,6 +183,7 @@ export function ThreadDetailPromptArea({ activeWorkflow, parentThreadSection, childThreadsSection, + pullRequest, sendMessage, composerFocusRequestNonce, thread, @@ -282,6 +287,10 @@ export function ThreadDetailPromptArea({ const [attachmentError, setAttachmentError] = useState(null); const [expandedBannerSection, setExpandedBannerSection] = useState(null); + const pullRequestSection = useMemo( + () => (pullRequest ? { pullRequest } : null), + [pullRequest], + ); const [isGoalExpanded, setIsGoalExpanded] = useState(false); const [isWorkflowExpanded, setIsWorkflowExpanded] = useState(false); const [isFollowUpShortcutSending, setIsFollowUpShortcutSending] = @@ -941,6 +950,7 @@ export function ThreadDetailPromptArea({ } parentThreadSection={parentThreadSection} childThreadsSection={childThreadsSection} + pullRequestSection={pullRequestSection} gitSection={ workspaceChangedFilesSection ? { diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 30c9aa61c9..f0144ec28c 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -1822,6 +1822,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { activeWorkflow={activeWorkflow} parentThreadSection={parentThreadSection} childThreadsSection={childThreadsSection} + pullRequest={pullRequest} thread={thread} /> ); diff --git a/apps/host-daemon/test/command/workspace-dispatch.test.ts b/apps/host-daemon/test/command/workspace-dispatch.test.ts index 58f0d2fd28..b044027e6b 100644 --- a/apps/host-daemon/test/command/workspace-dispatch.test.ts +++ b/apps/host-daemon/test/command/workspace-dispatch.test.ts @@ -108,6 +108,14 @@ describe("workspace command dispatch", () => { state: "OPEN", url: "https://github.com/bb/bb/pull/42", isDraft: false, + baseRefName: "main", + headRefName: "bb/timeline-polish", + updatedAt: "2026-06-16T12:30:00Z", + checks: [], + reviewDecision: null, + reviewRequestCount: 0, + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", } as const; harness.workspaceState.pullRequest = pullRequest; @@ -148,6 +156,14 @@ describe("workspace command dispatch", () => { state: "OPEN", url: "https://github.com/bb/bb/pull/7", isDraft: false, + baseRefName: "main", + headRefName: "bb/hidden-pr", + updatedAt: "2026-06-16T12:30:00Z", + checks: [], + reviewDecision: null, + reviewRequestCount: 0, + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", }; await harness.manager.ensureEnvironment({ environmentId: "env-non-git-pr", @@ -514,7 +530,9 @@ describe("workspace command dispatch", () => { }); it("hides host.read_file_relative dotfiles when dotfiles are denied", async () => { - const tempDir = await makeTempDir("bb-dispatch-host-read-relative-dotfile-"); + const tempDir = await makeTempDir( + "bb-dispatch-host-read-relative-dotfile-", + ); await fs.writeFile(path.join(tempDir, ".env"), "secret"); const harness = createHarness(); diff --git a/apps/server/src/services/environments/pull-request.test.ts b/apps/server/src/services/environments/pull-request.test.ts index 17e8216aca..e4cf655f81 100644 --- a/apps/server/src/services/environments/pull-request.test.ts +++ b/apps/server/src/services/environments/pull-request.test.ts @@ -11,6 +11,14 @@ function rawPullRequest( state: "OPEN", url: "https://github.com/acme/bb/pull/42", isDraft: false, + baseRefName: "main", + headRefName: "bb/add-pr-section", + updatedAt: "2026-06-16T12:30:00Z", + checks: [], + reviewDecision: null, + reviewRequestCount: 0, + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", ...overrides, }; } @@ -26,6 +34,26 @@ describe("assembleThreadPullRequest", () => { title: "Add pull request section", url: "https://github.com/acme/bb/pull/42", state: "open", + baseRefName: "main", + headRefName: "bb/add-pr-section", + updatedAt: "2026-06-16T12:30:00Z", + checks: { + state: "no_checks", + totalCount: 0, + passedCount: 0, + failedCount: 0, + pendingCount: 0, + }, + review: { + state: "none", + reviewRequestCount: 0, + }, + mergeability: { + state: "mergeable", + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", + }, + attention: "none", }); }); @@ -56,4 +84,83 @@ describe("assembleThreadPullRequest", () => { )?.state, ).toBe("closed"); }); + + it("summarizes failed checks as attention", () => { + expect( + assembleThreadPullRequest( + rawPullRequest({ + checks: [ + { + name: "test", + status: "completed", + conclusion: "success", + url: null, + }, + { + name: "typecheck", + status: "completed", + conclusion: "failure", + url: "https://github.com/acme/bb/actions/runs/1", + }, + ], + }), + ), + ).toMatchObject({ + checks: { + state: "failing", + totalCount: 2, + passedCount: 1, + failedCount: 1, + pendingCount: 0, + }, + attention: "checks_failed", + }); + }); + + it("summarizes review requests and conflicts", () => { + expect( + assembleThreadPullRequest( + rawPullRequest({ + reviewDecision: "REVIEW_REQUIRED", + reviewRequestCount: 2, + mergeStateStatus: "DIRTY", + mergeable: "CONFLICTING", + }), + ), + ).toMatchObject({ + review: { + state: "review_requested", + reviewRequestCount: 2, + }, + mergeability: { + state: "conflicts", + mergeStateStatus: "DIRTY", + mergeable: "CONFLICTING", + }, + attention: "conflicts", + }); + }); + + it("marks passing mergeable PRs as ready to merge", () => { + expect( + assembleThreadPullRequest( + rawPullRequest({ + checks: [ + { + name: "test", + status: "completed", + conclusion: "success", + url: null, + }, + ], + reviewDecision: "APPROVED", + }), + ), + ).toMatchObject({ + checks: { state: "passing" }, + review: { state: "approved" }, + mergeability: { state: "mergeable" }, + attention: "ready_to_merge", + }); + }); }); diff --git a/apps/server/src/services/environments/pull-request.ts b/apps/server/src/services/environments/pull-request.ts index 3427798fe5..b0d8552477 100644 --- a/apps/server/src/services/environments/pull-request.ts +++ b/apps/server/src/services/environments/pull-request.ts @@ -1,4 +1,157 @@ -import type { GitHostPullRequest, ThreadPullRequest } from "@bb/domain"; +import type { + GitHostPullRequest, + GitHostPullRequestCheck, + ThreadPullRequest, + ThreadPullRequestAttentionState, + ThreadPullRequestChecks, + ThreadPullRequestChecksState, + ThreadPullRequestMergeability, + ThreadPullRequestMergeabilityState, + ThreadPullRequestReview, + ThreadPullRequestReviewState, +} from "@bb/domain"; + +function assembleThreadPullRequestChecks( + rawChecks: readonly GitHostPullRequestCheck[], +): ThreadPullRequestChecks { + let passedCount = 0; + let failedCount = 0; + let pendingCount = 0; + let unknownCount = 0; + + for (const check of rawChecks) { + if (check.status === "queued" || check.status === "in_progress") { + pendingCount += 1; + continue; + } + switch (check.conclusion) { + case "success": + case "skipped": + case "neutral": + passedCount += 1; + break; + case "failure": + case "cancelled": + case "timed_out": + case "action_required": + case "startup_failure": + case "stale": + failedCount += 1; + break; + case "unknown": + case null: + unknownCount += 1; + break; + } + } + + let state: ThreadPullRequestChecksState; + if (rawChecks.length === 0) { + state = "no_checks"; + } else if (failedCount > 0) { + state = "failing"; + } else if (pendingCount > 0) { + state = "pending"; + } else if (unknownCount > 0) { + state = "unknown"; + } else { + state = "passing"; + } + + return { + state, + totalCount: rawChecks.length, + passedCount, + failedCount, + pendingCount, + }; +} + +function assembleThreadPullRequestReview( + raw: GitHostPullRequest, +): ThreadPullRequestReview { + let state: ThreadPullRequestReviewState; + switch (raw.reviewDecision) { + case "APPROVED": + state = "approved"; + break; + case "CHANGES_REQUESTED": + state = "changes_requested"; + break; + case "REVIEW_REQUIRED": + state = + raw.reviewRequestCount > 0 ? "review_requested" : "review_required"; + break; + case null: + state = raw.reviewRequestCount > 0 ? "review_requested" : "none"; + break; + } + + return { + state, + reviewRequestCount: raw.reviewRequestCount, + }; +} + +function assembleThreadPullRequestMergeability( + raw: GitHostPullRequest, +): ThreadPullRequestMergeability { + let state: ThreadPullRequestMergeabilityState; + if (raw.state === "OPEN" && raw.isDraft) { + state = "draft"; + } else if ( + raw.mergeable === "CONFLICTING" || + raw.mergeStateStatus === "DIRTY" + ) { + state = "conflicts"; + } else if ( + raw.mergeStateStatus === "BLOCKED" || + raw.mergeStateStatus === "BEHIND" || + raw.mergeStateStatus === "HAS_HOOKS" || + raw.mergeStateStatus === "UNSTABLE" + ) { + state = "blocked"; + } else if ( + raw.mergeable === "MERGEABLE" || + raw.mergeStateStatus === "CLEAN" + ) { + state = "mergeable"; + } else { + state = "unknown"; + } + + return { + state, + mergeStateStatus: raw.mergeStateStatus, + mergeable: raw.mergeable, + }; +} + +function assemblePullRequestAttention( + state: ThreadPullRequest["state"], + checks: ThreadPullRequestChecks, + review: ThreadPullRequestReview, + mergeability: ThreadPullRequestMergeability, +): ThreadPullRequestAttentionState { + if (state === "merged") return "merged"; + if (state === "closed") return "closed"; + if (mergeability.state === "conflicts") return "conflicts"; + if (checks.state === "failing") return "checks_failed"; + if (review.state === "changes_requested") return "changes_requested"; + if (mergeability.state === "blocked") return "blocked"; + if (state === "draft") return "draft"; + if ( + review.state === "review_requested" || + review.state === "review_required" + ) { + return "review_requested"; + } + if (checks.state === "pending") return "checks_pending"; + if (mergeability.state === "mergeable" && checks.state === "passing") { + return "ready_to_merge"; + } + return "none"; +} /** * Server-owned product policy: fold the raw `gh` state plus `isDraft` from the @@ -11,17 +164,33 @@ export function assembleThreadPullRequest( if (!raw) { return null; } + const state = + raw.state === "MERGED" + ? "merged" + : raw.state === "CLOSED" + ? "closed" + : raw.isDraft + ? "draft" + : "open"; + const checks = assembleThreadPullRequestChecks(raw.checks); + const review = assembleThreadPullRequestReview(raw); + const mergeability = assembleThreadPullRequestMergeability(raw); return { number: raw.number, title: raw.title, url: raw.url, - state: - raw.state === "MERGED" - ? "merged" - : raw.state === "CLOSED" - ? "closed" - : raw.isDraft - ? "draft" - : "open", + state, + baseRefName: raw.baseRefName, + headRefName: raw.headRefName, + updatedAt: raw.updatedAt, + checks, + review, + mergeability, + attention: assemblePullRequestAttention( + state, + checks, + review, + mergeability, + ), }; } diff --git a/packages/domain/src/thread.ts b/packages/domain/src/thread.ts index 3210017767..d3f91c9b00 100644 --- a/packages/domain/src/thread.ts +++ b/packages/domain/src/thread.ts @@ -142,10 +142,80 @@ export const workspaceStatusSchema = z.object({ }); export type WorkspaceStatus = z.infer; +export const gitHostPullRequestCheckStatusSchema = z.enum([ + "queued", + "in_progress", + "completed", + "unknown", +]); +export type GitHostPullRequestCheckStatus = z.infer< + typeof gitHostPullRequestCheckStatusSchema +>; + +export const gitHostPullRequestCheckConclusionSchema = z.enum([ + "success", + "failure", + "cancelled", + "skipped", + "neutral", + "timed_out", + "action_required", + "startup_failure", + "stale", + "unknown", +]); +export type GitHostPullRequestCheckConclusion = z.infer< + typeof gitHostPullRequestCheckConclusionSchema +>; + +export const gitHostPullRequestCheckSchema = z + .object({ + name: z.string().min(1), + status: gitHostPullRequestCheckStatusSchema, + conclusion: gitHostPullRequestCheckConclusionSchema.nullable(), + url: z.string().url().nullable(), + }) + .strict(); +export type GitHostPullRequestCheck = z.infer< + typeof gitHostPullRequestCheckSchema +>; + +export const gitHostPullRequestReviewDecisionSchema = z.enum([ + "APPROVED", + "CHANGES_REQUESTED", + "REVIEW_REQUIRED", +]); +export type GitHostPullRequestReviewDecision = z.infer< + typeof gitHostPullRequestReviewDecisionSchema +>; + +export const gitHostPullRequestMergeStateStatusSchema = z.enum([ + "BEHIND", + "BLOCKED", + "CLEAN", + "DIRTY", + "DRAFT", + "HAS_HOOKS", + "UNKNOWN", + "UNSTABLE", +]); +export type GitHostPullRequestMergeStateStatus = z.infer< + typeof gitHostPullRequestMergeStateStatusSchema +>; + +export const gitHostPullRequestMergeableSchema = z.enum([ + "CONFLICTING", + "MERGEABLE", + "UNKNOWN", +]); +export type GitHostPullRequestMergeable = z.infer< + typeof gitHostPullRequestMergeableSchema +>; + /** - * Raw pull request data as emitted by the host git-host CLI (`gh pr view - * --json number,title,state,url,isDraft`). The host daemon returns this - * verbatim; the server maps it onto the product-facing `ThreadPullRequest`. + * Pull request data normalized from the host git-host CLI (`gh pr view`). + * The host daemon returns this verbatim; the server maps it onto the + * product-facing `ThreadPullRequest`. */ export const gitHostPullRequestSchema = z .object({ @@ -154,6 +224,14 @@ export const gitHostPullRequestSchema = z state: z.enum(["OPEN", "CLOSED", "MERGED"]), url: z.string().url(), isDraft: z.boolean(), + baseRefName: z.string(), + headRefName: z.string(), + updatedAt: z.string().datetime(), + checks: z.array(gitHostPullRequestCheckSchema), + reviewDecision: gitHostPullRequestReviewDecisionSchema.nullable(), + reviewRequestCount: z.number().int().nonnegative(), + mergeStateStatus: gitHostPullRequestMergeStateStatusSchema.nullable(), + mergeable: gitHostPullRequestMergeableSchema.nullable(), }) .strict(); export type GitHostPullRequest = z.infer; @@ -166,6 +244,90 @@ export const pullRequestStateSchema = z.enum([ ]); export type PullRequestState = z.infer; +export const threadPullRequestChecksStateSchema = z.enum([ + "passing", + "failing", + "pending", + "no_checks", + "unknown", +]); +export type ThreadPullRequestChecksState = z.infer< + typeof threadPullRequestChecksStateSchema +>; + +export const threadPullRequestChecksSchema = z + .object({ + state: threadPullRequestChecksStateSchema, + totalCount: z.number().int().nonnegative(), + passedCount: z.number().int().nonnegative(), + failedCount: z.number().int().nonnegative(), + pendingCount: z.number().int().nonnegative(), + }) + .strict(); +export type ThreadPullRequestChecks = z.infer< + typeof threadPullRequestChecksSchema +>; + +export const threadPullRequestReviewStateSchema = z.enum([ + "approved", + "changes_requested", + "review_required", + "review_requested", + "none", +]); +export type ThreadPullRequestReviewState = z.infer< + typeof threadPullRequestReviewStateSchema +>; + +export const threadPullRequestReviewSchema = z + .object({ + state: threadPullRequestReviewStateSchema, + reviewRequestCount: z.number().int().nonnegative(), + }) + .strict(); +export type ThreadPullRequestReview = z.infer< + typeof threadPullRequestReviewSchema +>; + +export const threadPullRequestMergeabilityStateSchema = z.enum([ + "mergeable", + "conflicts", + "blocked", + "draft", + "unknown", +]); +export type ThreadPullRequestMergeabilityState = z.infer< + typeof threadPullRequestMergeabilityStateSchema +>; + +export const threadPullRequestMergeabilitySchema = z + .object({ + state: threadPullRequestMergeabilityStateSchema, + mergeStateStatus: gitHostPullRequestMergeStateStatusSchema.nullable(), + mergeable: gitHostPullRequestMergeableSchema.nullable(), + }) + .strict(); +export type ThreadPullRequestMergeability = z.infer< + typeof threadPullRequestMergeabilitySchema +>; + +export const threadPullRequestAttentionStateSchema = z.enum([ + "checks_failed", + "checks_pending", + "changes_requested", + "review_requested", + "conflicts", + "blocked", + "draft", + "ready_to_merge", + "merged", + "closed", + "none", +]); +export type ThreadPullRequestAttentionState = z.infer< + typeof threadPullRequestAttentionStateSchema +>; + /** * A pull request associated with a thread's branch, assembled by the server * from {@link gitHostPullRequestSchema} (the server folds `isDraft` into the @@ -177,6 +339,13 @@ export const threadPullRequestSchema = z title: z.string(), state: pullRequestStateSchema, url: z.string().url(), + baseRefName: z.string(), + headRefName: z.string(), + updatedAt: z.string().datetime(), + checks: threadPullRequestChecksSchema, + review: threadPullRequestReviewSchema, + mergeability: threadPullRequestMergeabilitySchema, + attention: threadPullRequestAttentionStateSchema, }) .strict(); export type ThreadPullRequest = z.infer; diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 72242a9bc6..e043575b13 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -237,6 +237,21 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = { state: "OPEN", url: "https://github.com/acme/bb/pull/42", isDraft: false, + baseRefName: "main", + headRefName: "feature/host-rpc", + updatedAt: "2026-06-16T12:30:00Z", + checks: [ + { + name: "test", + status: "completed", + conclusion: "success", + url: null, + }, + ], + reviewDecision: "APPROVED", + reviewRequestCount: 0, + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", }, }, }; diff --git a/packages/host-workspace/src/git-host.ts b/packages/host-workspace/src/git-host.ts index 8df420f47d..c75069c01f 100644 --- a/packages/host-workspace/src/git-host.ts +++ b/packages/host-workspace/src/git-host.ts @@ -1,6 +1,15 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { type GitHostPullRequest, gitHostPullRequestSchema } from "@bb/domain"; +import { + type GitHostPullRequest, + type GitHostPullRequestCheck, + type GitHostPullRequestCheckConclusion, + type GitHostPullRequestCheckStatus, + type GitHostPullRequestMergeStateStatus, + type GitHostPullRequestMergeable, + type GitHostPullRequestReviewDecision, + gitHostPullRequestSchema, +} from "@bb/domain"; import { sanitizeInheritedChildProcessEnv } from "@bb/process-utils"; const execFileAsync = promisify(execFile); @@ -16,13 +25,241 @@ const GH_PR_VIEW_TIMEOUT_MS = 10_000; */ const GH_PR_VIEW_MAX_BUFFER_BYTES = 16 * 1024 * 1024; -const GH_PR_VIEW_JSON_FIELDS = "number,title,state,url,isDraft"; +const GH_PR_VIEW_JSON_FIELDS = [ + "number", + "title", + "state", + "url", + "isDraft", + "baseRefName", + "headRefName", + "updatedAt", + "statusCheckRollup", + "reviewDecision", + "reviewRequests", + "mergeStateStatus", + "mergeable", +].join(","); interface GetPullRequestForBranchArgs { cwd: string; branch: string; } +type JsonObject = Record; + +function asObject(value: unknown): JsonObject | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + return value as JsonObject; +} + +function getString(object: JsonObject, key: string): string | null { + const value = object[key]; + return typeof value === "string" ? value : null; +} + +function getNumber(object: JsonObject, key: string): number | null { + const value = object[key]; + return typeof value === "number" ? value : null; +} + +function getBoolean(object: JsonObject, key: string): boolean | null { + const value = object[key]; + return typeof value === "boolean" ? value : null; +} + +function normalizeUppercase(value: unknown): string | null { + return typeof value === "string" && value.trim() + ? value.trim().toUpperCase() + : null; +} + +function normalizeReviewDecision( + value: unknown, +): GitHostPullRequestReviewDecision | null { + switch (normalizeUppercase(value)) { + case "APPROVED": + return "APPROVED"; + case "CHANGES_REQUESTED": + return "CHANGES_REQUESTED"; + case "REVIEW_REQUIRED": + return "REVIEW_REQUIRED"; + default: + return null; + } +} + +function normalizeMergeStateStatus( + value: unknown, +): GitHostPullRequestMergeStateStatus | null { + switch (normalizeUppercase(value)) { + case "BEHIND": + return "BEHIND"; + case "BLOCKED": + return "BLOCKED"; + case "CLEAN": + return "CLEAN"; + case "DIRTY": + return "DIRTY"; + case "DRAFT": + return "DRAFT"; + case "HAS_HOOKS": + return "HAS_HOOKS"; + case "UNKNOWN": + return "UNKNOWN"; + case "UNSTABLE": + return "UNSTABLE"; + default: + return null; + } +} + +function normalizeMergeable( + value: unknown, +): GitHostPullRequestMergeable | null { + switch (normalizeUppercase(value)) { + case "CONFLICTING": + return "CONFLICTING"; + case "MERGEABLE": + return "MERGEABLE"; + case "UNKNOWN": + return "UNKNOWN"; + default: + return null; + } +} + +function normalizeCheckStatus(value: unknown): GitHostPullRequestCheckStatus { + switch (normalizeUppercase(value)) { + case "QUEUED": + case "REQUESTED": + case "WAITING": + return "queued"; + case "EXPECTED": + case "IN_PROGRESS": + case "PENDING": + return "in_progress"; + case "COMPLETED": + case "SUCCESS": + case "FAILURE": + case "ERROR": + case "CANCELLED": + case "SKIPPED": + case "NEUTRAL": + return "completed"; + default: + return "unknown"; + } +} + +function normalizeCheckConclusion( + value: unknown, +): GitHostPullRequestCheckConclusion | null { + switch (normalizeUppercase(value)) { + case "SUCCESS": + return "success"; + case "FAILURE": + case "ERROR": + return "failure"; + case "CANCELLED": + return "cancelled"; + case "SKIPPED": + return "skipped"; + case "NEUTRAL": + return "neutral"; + case "TIMED_OUT": + return "timed_out"; + case "ACTION_REQUIRED": + return "action_required"; + case "STARTUP_FAILURE": + return "startup_failure"; + case "STALE": + return "stale"; + case "UNKNOWN": + return "unknown"; + default: + return null; + } +} + +function getNullableUrl(object: JsonObject, key: string): string | null { + const value = object[key]; + if (typeof value !== "string" || value.length === 0) { + return null; + } + try { + return new URL(value).toString(); + } catch { + return null; + } +} + +function normalizeCheckName(object: JsonObject): string { + const explicitName = getString(object, "name"); + if (explicitName && explicitName.trim()) return explicitName.trim(); + const context = getString(object, "context"); + if (context && context.trim()) return context.trim(); + const workflowName = getString(object, "workflowName"); + if (workflowName && workflowName.trim()) return workflowName.trim(); + return "Unnamed check"; +} + +function normalizeChecks(value: unknown): GitHostPullRequestCheck[] { + if (!Array.isArray(value)) { + return []; + } + const checks: GitHostPullRequestCheck[] = []; + for (const item of value) { + const object = asObject(item); + if (!object) continue; + const status = normalizeCheckStatus(object.status ?? object.state); + const conclusion = + normalizeCheckConclusion(object.conclusion) ?? + normalizeCheckConclusion(object.state); + checks.push({ + name: normalizeCheckName(object), + status, + conclusion, + url: + getNullableUrl(object, "detailsUrl") ?? + getNullableUrl(object, "targetUrl"), + }); + } + return checks; +} + +function getArrayLength(value: unknown): number { + return Array.isArray(value) ? value.length : 0; +} + +function normalizeGitHubPullRequestView( + json: unknown, +): GitHostPullRequest | null { + const object = asObject(json); + if (!object) { + return null; + } + const candidate = { + number: getNumber(object, "number"), + title: getString(object, "title"), + state: normalizeUppercase(object.state), + url: getString(object, "url"), + isDraft: getBoolean(object, "isDraft"), + baseRefName: getString(object, "baseRefName"), + headRefName: getString(object, "headRefName"), + updatedAt: getString(object, "updatedAt"), + checks: normalizeChecks(object.statusCheckRollup), + reviewDecision: normalizeReviewDecision(object.reviewDecision), + reviewRequestCount: getArrayLength(object.reviewRequests), + mergeStateStatus: normalizeMergeStateStatus(object.mergeStateStatus), + mergeable: normalizeMergeable(object.mergeable), + }; + const parsed = gitHostPullRequestSchema.safeParse(candidate); + return parsed.success ? parsed.data : null; +} + /** * Parse the stdout of `gh pr view --json ` into a validated * {@link GitHostPullRequest}. Returns `null` for any output that is not a @@ -42,8 +279,7 @@ export function parseGitHostPullRequest( } catch { return null; } - const parsed = gitHostPullRequestSchema.safeParse(json); - return parsed.success ? parsed.data : null; + return normalizeGitHubPullRequestView(json); } /** diff --git a/packages/host-workspace/test/git-host.test.ts b/packages/host-workspace/test/git-host.test.ts index 0f30c5bd95..0310120007 100644 --- a/packages/host-workspace/test/git-host.test.ts +++ b/packages/host-workspace/test/git-host.test.ts @@ -8,6 +8,14 @@ function ghJson(overrides: Record = {}): string { state: "OPEN", url: "https://github.com/acme/bb/pull/42", isDraft: false, + baseRefName: "main", + headRefName: "bb/add-pr-section", + updatedAt: "2026-06-16T12:30:00Z", + statusCheckRollup: [], + reviewDecision: null, + reviewRequests: [], + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", ...overrides, }); } @@ -20,6 +28,14 @@ describe("parseGitHostPullRequest", () => { state: "OPEN", url: "https://github.com/acme/bb/pull/42", isDraft: false, + baseRefName: "main", + headRefName: "bb/add-pr-section", + updatedAt: "2026-06-16T12:30:00Z", + checks: [], + reviewDecision: null, + reviewRequestCount: 0, + mergeStateStatus: "CLEAN", + mergeable: "MERGEABLE", }); }); @@ -39,6 +55,68 @@ describe("parseGitHostPullRequest", () => { expect(parseGitHostPullRequest(`\n ${ghJson()}\n`)?.number).toBe(42); }); + it("normalizes checks, review requests, and mergeability", () => { + expect( + parseGitHostPullRequest( + ghJson({ + statusCheckRollup: [ + { + __typename: "CheckRun", + name: "typecheck", + status: "COMPLETED", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/acme/bb/actions/runs/1", + }, + { + __typename: "StatusContext", + context: "ci/build", + state: "FAILURE", + targetUrl: "https://ci.example.test/build/42", + }, + { + __typename: "CheckRun", + workflowName: "lint", + status: "IN_PROGRESS", + conclusion: null, + }, + ], + reviewDecision: "REVIEW_REQUIRED", + reviewRequests: [ + { requestedReviewer: { login: "octocat" } }, + { requestedReviewer: { login: "hubot" } }, + ], + mergeStateStatus: "DIRTY", + mergeable: "CONFLICTING", + }), + ), + ).toMatchObject({ + checks: [ + { + name: "typecheck", + status: "completed", + conclusion: "success", + url: "https://github.com/acme/bb/actions/runs/1", + }, + { + name: "ci/build", + status: "completed", + conclusion: "failure", + url: "https://ci.example.test/build/42", + }, + { + name: "lint", + status: "in_progress", + conclusion: null, + url: null, + }, + ], + reviewDecision: "REVIEW_REQUIRED", + reviewRequestCount: 2, + mergeStateStatus: "DIRTY", + mergeable: "CONFLICTING", + }); + }); + it.each([ ["empty output", ""], ["whitespace only", " \n"], @@ -50,9 +128,12 @@ describe("parseGitHostPullRequest", () => { it.each([ ["an unknown state", ghJson({ state: "QUEUED" })], - ["a missing field", JSON.stringify({ number: 1, title: "x", state: "OPEN" })], + [ + "a missing field", + JSON.stringify({ number: 1, title: "x", state: "OPEN" }), + ], ["a non-positive number", ghJson({ number: 0 })], - ["an extra field", ghJson({ mergeable: "MERGEABLE" })], + ["an invalid updatedAt", ghJson({ updatedAt: "yesterday" })], ["a non-url", ghJson({ url: "not-a-url" })], ])("returns null for %s", (_label, stdout) => { expect(parseGitHostPullRequest(stdout)).toBeNull(); From caae1c25f7f301dc90de7cde2332e48c32f58361 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Wed, 17 Jun 2026 17:48:55 +0000 Subject: [PATCH 2/6] Refine pull request status UI --- .../ThreadPromptContextBanner.stories.tsx | 14 +- .../banner/ThreadPromptContextBanner.tsx | 121 ++------ .../pull-request/PullRequestStatusPill.tsx | 186 ++++++++++++ .../ThreadMetadataContent.rows.stories.tsx | 286 +++++++++++++++--- .../ThreadMetadataContent.stories.tsx | 61 ---- .../secondary-panel/ThreadMetadataContent.tsx | 115 ++++--- apps/app/src/components/ui/icon.stories.tsx | 4 + apps/app/src/components/ui/icon.tsx | 8 + apps/app/src/lib/pull-request-display.test.ts | 62 ++++ apps/app/src/lib/pull-request-display.ts | 44 ++- 10 files changed, 639 insertions(+), 262 deletions(-) create mode 100644 apps/app/src/components/pull-request/PullRequestStatusPill.tsx create mode 100644 apps/app/src/lib/pull-request-display.test.ts diff --git a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx index db4e1e724c..67d4b16bbc 100644 --- a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.stories.tsx @@ -603,21 +603,11 @@ export function Overview() { />
- - - = { @@ -442,56 +434,31 @@ function ChildThreadsBody({ ); } -function PullRequestBody({ pullRequest }: { pullRequest: ThreadPullRequest }) { - const stateDisplay = PULL_REQUEST_STATE_DISPLAY[pullRequest.state]; - const signalDisplays = getPullRequestSignalDisplays(pullRequest); +function PullRequestBannerLink({ + pullRequest, + hideLabelInCompact, +}: { + pullRequest: ThreadPullRequest; + hideLabelInCompact: boolean; +}) { + const attentionDisplay = getPullRequestAttentionDisplay(pullRequest); return ( -
- + + - PR #{pullRequest.number} - {pullRequest.title} - -
-
-
- {signalDisplays.map((display) => ( - - - ))} -
-
+ #{pullRequest.number} + + ); } @@ -681,9 +648,6 @@ export function ThreadPromptContextBanner({ expandedSection === "parentThread" && showParentThread; const isChildThreadsExpanded = expandedSection === "childThreads" && showChildThreads; - const isPullRequestExpanded = - expandedSection === "pullRequest" && showPullRequest; - const gitTally = showGit ? toChangeTally(gitSection.changedFiles.stats) : null; @@ -718,9 +682,6 @@ export function ThreadPromptContextBanner({ !showPullRequest; const pullRequest = pullRequestSection?.pullRequest ?? null; - const pullRequestAttentionDisplay = pullRequest - ? getPullRequestAttentionDisplay(pullRequest) - : null; return ( onToggleSection("childThreads")} /> ) : null} - {showPullRequest && pullRequest && pullRequestAttentionDisplay ? ( -