feat(runs): link stored pull requests

This commit is contained in:
Bryan Helmkamp 2026-05-08 13:17:08 -07:00
parent 12b475d692
commit 0b29dbe6d8
No known key found for this signature in database
15 changed files with 282 additions and 39 deletions

View file

@ -0,0 +1,7 @@
export function GitPullRequestIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" className={className} aria-hidden="true">
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
}

View file

@ -27,6 +27,10 @@ describe("mapRunListItem", () => {
created_at: "2026-04-08T12:00:00Z",
start_time: "2026-04-08T12:00:00Z",
pending_control: null,
pull_request: {
number: 123,
html_url: "https://github.com/fabro-sh/fabro/pull/123",
},
} as const;
const item = mapRunListItem(summary);
expect(item.id).toBe("01ABC");
@ -37,6 +41,8 @@ describe("mapRunListItem", () => {
expect(item.elapsed).toBeDefined();
expect(item.column).toBe("running");
expect(item.lifecycleStatus).toBe("paused");
expect(item.number).toBe(123);
expect(item.pullRequestUrl).toBe("https://github.com/fabro-sh/fabro/pull/123");
});
test("uses a fallback title when the server title is blank", () => {
@ -81,6 +87,15 @@ describe("mapRunSummaryToRunItem", () => {
created_at: "2026-04-08T12:00:00Z",
start_time: "2026-04-08T12:00:00Z",
pending_control: null,
pull_request: {
html_url: "https://github.com/fabro-sh/fabro/pull/456",
number: 456,
owner: "fabro-sh",
repo: "fabro",
base_branch: "main",
head_branch: "fabro/run/demo",
title: "Add run PR chip",
},
};
const item = mapRunSummaryToRunItem(summary);
expect(item.id).toBe("01ABC");
@ -90,6 +105,8 @@ describe("mapRunSummaryToRunItem", () => {
expect(item.sourceDirectory).toBe("/home/user/myrepo");
expect(item.elapsed).toBeDefined();
expect(item.lifecycleStatus).toBe("running");
expect(item.number).toBe(456);
expect(item.pullRequestUrl).toBe("https://github.com/fabro-sh/fabro/pull/456");
});
test("handles missing optional fields", () => {

View file

@ -26,6 +26,7 @@ export interface RunItem {
lifecycleStatus?: RunStatus | null;
lifecycleStatusLabel?: string;
number?: number;
pullRequestUrl?: string;
additions?: number;
deletions?: number;
checks?: CheckRun[];
@ -92,6 +93,7 @@ export function mapRunListItem(item: RunListItem): RunItem {
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(item.status),
number: item.pull_request?.number,
pullRequestUrl: item.pull_request?.html_url,
additions: item.pull_request?.additions,
deletions: item.pull_request?.deletions,
checks: item.pull_request?.checks?.map((c) => ({
@ -122,6 +124,8 @@ export function mapRunSummaryToRunItem(summary: RunSummary): RunItem {
workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown",
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(summary.status),
number: summary.pull_request?.number,
pullRequestUrl: summary.pull_request?.html_url,
sourceDirectory: summary.source_directory ?? undefined,
elapsed:
summary.elapsed_secs != null

View file

@ -71,7 +71,7 @@ type RunDetailActionResult = import("./run-detail").RunDetailActionResult;
const h = createElement;
function makeRunSummary(status = "succeeded", diffSummary: any = null) {
function makeRunSummary(status = "succeeded", diffSummary: any = null, pullRequest: any = null) {
return {
run_id: "run_1",
title: "Run 1",
@ -83,6 +83,7 @@ function makeRunSummary(status = "succeeded", diffSummary: any = null) {
elapsed_secs: null,
source_directory: null,
diff_summary: diffSummary,
pull_request: pullRequest,
};
}
@ -109,13 +110,15 @@ async function renderRunDetail({
status = "succeeded",
questions = [],
diffSummary = null,
pullRequest = null,
}: {
initialEntry: string;
status?: string;
questions?: any[];
diffSummary?: any;
pullRequest?: any;
}) {
currentRunSummary = makeRunSummary(status, diffSummary);
currentRunSummary = makeRunSummary(status, diffSummary, pullRequest);
currentQuestions = questions;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@ -399,6 +402,31 @@ describe("RunDetail full-height child routes", () => {
expect(tabCountBadges(renderer)).toHaveLength(0);
});
test("shows a linked pull request chip in the run header", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1",
pullRequest: {
html_url: "https://github.com/fabro-sh/fabro/pull/123",
number: 123,
owner: "fabro-sh",
repo: "fabro",
base_branch: "main",
head_branch: "fabro/run/demo",
title: "Add run PR chip",
},
});
const links = renderer.root.findAll(
(node) =>
node.type === "a" &&
node.props.href === "https://github.com/fabro-sh/fabro/pull/123",
);
expect(links).toHaveLength(1);
expect(links[0].props.target).toBe("_blank");
expect(links[0].children.filter((child) => typeof child !== "object").join("")).toBe("#123");
});
test("keeps blocked full-height children clear of the interview dock without an h-72 sibling", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1/files",

View file

@ -17,6 +17,7 @@ import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { InterviewDock } from "../components/interview-dock";
import { GitPullRequestIcon } from "../components/icons";
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
@ -323,6 +324,17 @@ export default function RunDetail({ params }: { params: { id: string } }) {
</span>
</Tooltip>
)}
{run.number != null && run.pullRequestUrl && (
<a
href={run.pullRequestUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 font-mono text-xs text-fg-muted hover:text-fg"
>
<GitPullRequestIcon className="size-3.5" />
#{run.number}
</a>
)}
</div>
</div>

View file

@ -25,6 +25,7 @@ import { ciConfig, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunLi
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { formatRelativeTime } from "../lib/format";
import { EmptyState } from "../components/state";
import { GitPullRequestIcon } from "../components/icons";
import { useToast } from "../components/toast";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries";
@ -128,19 +129,6 @@ function listLifecycleStatusLabel(run: Pick<RunWithStatus, "statusLabel" | "life
}
function GitPullRequestIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="currentColor"
className={className}
aria-hidden="true"
>
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
}
function CheckStatusIcon({ status }: { status: CheckStatus }) {
switch (status) {
case "success":
@ -298,10 +286,23 @@ function PrCard({
</span>
)}
{pr.number != null && (
<span className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor}`}>
<GitPullRequestIcon className="size-3.5 shrink-0" />
#{pr.number}
</span>
pr.pullRequestUrl ? (
<a
href={pr.pullRequestUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor} hover:text-fg`}
>
<GitPullRequestIcon className="size-3.5 shrink-0" />
#{pr.number}
</a>
) : (
<span className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor}`}>
<GitPullRequestIcon className="size-3.5 shrink-0" />
#{pr.number}
</span>
)
)}
</div>
@ -637,11 +638,25 @@ function RunRow({ run }: { run: RunWithStatus }) {
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
{run.number != null && (
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
run.pullRequestUrl ? (
<a
href={run.pullRequestUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className="inline-flex items-center gap-1.5 hover:text-fg"
>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</a>
) : (
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
)
)}
</span>
</Link>

View file

@ -1,6 +1,7 @@
import { useState } from "react";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { Link, useParams } from "react-router";
import { GitPullRequestIcon } from "../components/icons";
import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
import type { ColumnStatus, RunWithStatus } from "../data/runs";
import { useWorkflowRuns } from "../lib/queries";
@ -21,14 +22,6 @@ function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithSt
.filter((run): run is RunWithStatus => run != null);
}
function GitPullRequestIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="currentColor" className={className} aria-hidden="true">
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
}
function RunRow({ run }: { run: RunWithStatus }) {
const colors = columnStatusDisplay[run.status];
return (
@ -61,11 +54,25 @@ function RunRow({ run }: { run: RunWithStatus }) {
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs text-fg-muted">
{run.number != null && (
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
run.pullRequestUrl ? (
<a
href={run.pullRequestUrl}
target="_blank"
rel="noreferrer"
onClick={(event) => event.stopPropagation()}
className="inline-flex items-center gap-1.5 hover:text-fg"
>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</a>
) : (
<>
<GitPullRequestIcon className="size-3" />
#{run.number}
{run.checks != null && <span className={`size-1.5 rounded-full ${ciConfig[deriveCiStatus(run.checks)].dot}`} />}
</>
)
)}
</span>
</Link>

View file

@ -6165,6 +6165,10 @@ components:
oneOf:
- $ref: "#/components/schemas/DiffSummary"
- type: "null"
pull_request:
oneOf:
- $ref: "#/components/schemas/PullRequestRecord"
- type: "null"
ForkRequest:
description: Request body for creating a new run from a source run checkpoint.
@ -6427,6 +6431,11 @@ components:
type: integer
description: Pull request number.
example: 889
html_url:
type: string
format: uri
description: Stored GitHub pull request URL.
example: https://github.com/fabro-sh/fabro/pull/889
additions:
type: integer
description: Lines added.

View file

@ -6,7 +6,7 @@ use fabro_api::types::{
RepositoryReference as ApiRepositoryReference, RunSummary as ApiRunSummary,
};
use fabro_types::status::{RunStatus, SuccessReason, TerminalStatus};
use fabro_types::{DiffSummary, RepositoryReference, RunId, RunSummary};
use fabro_types::{DiffSummary, PullRequestRecord, RepositoryReference, RunId, RunSummary};
use serde_json::json;
#[test]
@ -46,6 +46,15 @@ fn run_summary_json_matches_openapi_shape() {
additions: 12,
deletions: 4,
}),
Some(PullRequestRecord {
html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
number: 123,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
base_branch: "main".to_string(),
head_branch: "fabro/run/demo".to_string(),
title: "Add run PR chip".to_string(),
}),
);
assert_eq!(
@ -84,6 +93,15 @@ fn run_summary_json_matches_openapi_shape() {
"files_changed": 3,
"additions": 12,
"deletions": 4
},
"pull_request": {
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
"repo": "fabro",
"base_branch": "main",
"head_branch": "fabro/run/demo",
"title": "Add run PR chip"
}
})
);
@ -128,6 +146,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.total_usd_micros, None);
assert_eq!(summary.superseded_by, None);
assert_eq!(summary.diff_summary, None);
assert_eq!(summary.pull_request, None);
}
fn assert_same_type<T: 'static, U: 'static>() {

View file

@ -944,6 +944,7 @@ mod runs {
total_usd_micros,
None,
None,
None,
)
}
@ -1064,6 +1065,7 @@ mod runs {
) -> RunPullRequest {
RunPullRequest {
number,
html_url: Some(format!("https://github.com/demo/fabro/pull/{number}")),
additions: Some(additions),
deletions: Some(deletions),
comments: Some(comments),

View file

@ -143,6 +143,7 @@ fn board_run_metadata_from_projection(
"pull_request".to_string(),
serde_json::json!({
"number": pull_request.number,
"html_url": pull_request.html_url,
}),
);
}
@ -183,6 +184,37 @@ fn board_run_metadata_from_projection(
metadata
}
#[cfg(test)]
mod tests {
use fabro_types::PullRequestRecord;
use super::board_run_metadata_from_projection;
#[test]
fn board_run_metadata_includes_pull_request_url() {
let mut projection = fabro_store::RunProjection::default();
projection.pull_request = Some(PullRequestRecord {
html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
number: 123,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
base_branch: "main".to_string(),
head_branch: "fabro/run/demo".to_string(),
title: "Add run PR chip".to_string(),
});
let metadata = board_run_metadata_from_projection(&projection);
assert_eq!(
metadata.get("pull_request"),
Some(&serde_json::json!({
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
}))
);
}
}
fn paginate_items<T>(items: Vec<T>, pagination: &PaginationParams) -> (Vec<T>, bool) {
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset.min(MAX_PAGE_OFFSET) as usize;

View file

@ -560,6 +560,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
.and_then(|billing| billing.total_usd_micros),
state.superseded_by,
state.diff_summary,
state.pull_request.clone(),
)
}
@ -1959,6 +1960,42 @@ mod tests {
assert_eq!(summary.superseded_by, Some(fixtures::RUN_2));
}
#[test]
fn pull_request_created_populates_projection_and_summary() {
use fabro_types::run_event::PullRequestCreatedProps;
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::PullRequestCreated(PullRequestCreatedProps {
pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
pr_number: 123,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
base_branch: "main".to_string(),
head_branch: "fabro/run/demo".to_string(),
title: "Add run PR chip".to_string(),
draft: false,
}),
None,
))
.unwrap();
let pull_request = state
.pull_request
.as_ref()
.expect("projection should store pull request");
assert_eq!(
pull_request.html_url,
"https://github.com/fabro-sh/fabro/pull/123"
);
assert_eq!(pull_request.number, 123);
let summary = build_summary(&state, &fixtures::RUN_1);
assert_eq!(summary.pull_request, state.pull_request);
}
#[test]
fn run_unarchived_restores_prior_status() {
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};

View file

@ -4,7 +4,9 @@ use chrono::{DateTime, Utc};
use fabro_util::text::strip_goal_decoration;
use serde::{Deserialize, Serialize};
use crate::{DiffSummary, RepositoryReference, RunControlAction, RunId, RunStatus};
use crate::{
DiffSummary, PullRequestRecord, RepositoryReference, RunControlAction, RunId, RunStatus,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSummary {
@ -41,6 +43,8 @@ pub struct RunSummary {
pub superseded_by: Option<RunId>,
#[serde(default)]
pub diff_summary: Option<DiffSummary>,
#[serde(default)]
pub pull_request: Option<PullRequestRecord>,
}
impl RunSummary {
@ -65,6 +69,7 @@ impl RunSummary {
total_usd_micros: Option<i64>,
superseded_by: Option<RunId>,
diff_summary: Option<DiffSummary>,
pull_request: Option<PullRequestRecord>,
) -> Self {
let title = truncate_goal(&goal);
let repository = RepositoryReference {
@ -94,6 +99,7 @@ impl RunSummary {
total_usd_micros,
superseded_by,
diff_summary,
pull_request,
}
}
}
@ -191,6 +197,7 @@ mod tests {
Some(123),
Some(fixtures::RUN_2),
None,
None,
);
assert_eq!(summary.title, "ship it");
@ -249,6 +256,43 @@ mod tests {
);
}
#[test]
fn summary_round_trips_pull_request() {
let summary: RunSummary = serde_json::from_value(json!({
"run_id": fixtures::RUN_1,
"goal": "ship it",
"title": "ship it",
"labels": {},
"status": { "kind": "running" },
"repository": { "name": "fabro" },
"created_at": fixtures::RUN_1.created_at(),
"pull_request": {
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
"repo": "fabro",
"base_branch": "main",
"head_branch": "fabro/run/demo",
"title": "Add run PR chip"
}
}))
.unwrap();
let value = serde_json::to_value(&summary).unwrap();
assert_eq!(
value["pull_request"],
json!({
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
"repo": "fabro",
"base_branch": "main",
"head_branch": "fabro/run/demo",
"title": "Add run PR chip"
})
);
}
#[test]
fn summary_falls_back_to_source_directory_then_unknown() {
let source_only = RunSummary::new(
@ -268,6 +312,7 @@ mod tests {
None,
None,
None,
None,
);
assert_eq!(source_only.repository.name, "local-checkout");
assert_eq!(source_only.last_event_at, None);
@ -289,6 +334,7 @@ mod tests {
None,
None,
None,
None,
);
assert_eq!(unknown.repository.name, "unknown");
}

View file

@ -25,6 +25,10 @@ export interface RunPullRequest {
* Pull request number.
*/
'number': number;
/**
* Stored GitHub pull request URL.
*/
'html_url'?: string;
/**
* Lines added.
*/

View file

@ -18,6 +18,9 @@
import type { DiffSummary } from './diff-summary';
// May contain unused imports in some cases
// @ts-ignore
import type { PullRequestRecord } from './pull-request-record';
// May contain unused imports in some cases
// @ts-ignore
import type { RepositoryReference } from './repository-reference';
// May contain unused imports in some cases
// @ts-ignore
@ -50,6 +53,7 @@ export interface RunSummary {
'total_usd_micros'?: number | null;
'superseded_by'?: string | null;
'diff_summary'?: DiffSummary | null;
'pull_request'?: PullRequestRecord | null;
}