diff --git a/apps/fabro-web/app/components/icons.tsx b/apps/fabro-web/app/components/icons.tsx
new file mode 100644
index 000000000..6ef921ad6
--- /dev/null
+++ b/apps/fabro-web/app/components/icons.tsx
@@ -0,0 +1,7 @@
+export function GitPullRequestIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts
index 0ed38a8b0..04aabfee2 100644
--- a/apps/fabro-web/app/data/runs.test.ts
+++ b/apps/fabro-web/app/data/runs.test.ts
@@ -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", () => {
diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts
index 6546ecd35..8f47ef866 100644
--- a/apps/fabro-web/app/data/runs.ts
+++ b/apps/fabro-web/app/data/runs.ts
@@ -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
diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts
index f2d83db99..f6c4f0619 100644
--- a/apps/fabro-web/app/routes/run-detail.test.ts
+++ b/apps/fabro-web/app/routes/run-detail.test.ts
@@ -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",
diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx
index 82abec071..63bcffe0b 100644
--- a/apps/fabro-web/app/routes/run-detail.tsx
+++ b/apps/fabro-web/app/routes/run-detail.tsx
@@ -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 } }) {
)}
+ {run.number != null && run.pullRequestUrl && (
+
+
+ #{run.number}
+
+ )}
diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx
index 2f0f751e9..93dcbbaba 100644
--- a/apps/fabro-web/app/routes/runs.tsx
+++ b/apps/fabro-web/app/routes/runs.tsx
@@ -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
-
-
- );
-}
-
function CheckStatusIcon({ status }: { status: CheckStatus }) {
switch (status) {
case "success":
@@ -298,10 +286,23 @@ function PrCard({
)}
{pr.number != null && (
-
-
- #{pr.number}
-
+ pr.pullRequestUrl ? (
+ event.stopPropagation()}
+ className={`ml-auto inline-flex items-center gap-1 font-mono text-xs ${iconColor} hover:text-fg`}
+ >
+
+ #{pr.number}
+
+ ) : (
+
+
+ #{pr.number}
+
+ )
)}
@@ -637,11 +638,25 @@ function RunRow({ run }: { run: RunWithStatus }) {
{run.number != null && (
- <>
-
- #{run.number}
- {run.checks != null && }
- >
+ run.pullRequestUrl ? (
+ event.stopPropagation()}
+ className="inline-flex items-center gap-1.5 hover:text-fg"
+ >
+
+ #{run.number}
+ {run.checks != null && }
+
+ ) : (
+ <>
+
+ #{run.number}
+ {run.checks != null && }
+ >
+ )
)}
diff --git a/apps/fabro-web/app/routes/workflow-runs.tsx b/apps/fabro-web/app/routes/workflow-runs.tsx
index 7964a2c0c..c3b0190f4 100644
--- a/apps/fabro-web/app/routes/workflow-runs.tsx
+++ b/apps/fabro-web/app/routes/workflow-runs.tsx
@@ -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 (
-
- );
-}
-
function RunRow({ run }: { run: RunWithStatus }) {
const colors = columnStatusDisplay[run.status];
return (
@@ -61,11 +54,25 @@ function RunRow({ run }: { run: RunWithStatus }) {
{run.number != null && (
- <>
-
- #{run.number}
- {run.checks != null && }
- >
+ run.pullRequestUrl ? (
+ event.stopPropagation()}
+ className="inline-flex items-center gap-1.5 hover:text-fg"
+ >
+
+ #{run.number}
+ {run.checks != null && }
+
+ ) : (
+ <>
+
+ #{run.number}
+ {run.checks != null && }
+ >
+ )
)}
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml
index 2736d6846..62fa6eaae 100644
--- a/docs/public/api-reference/fabro-api.yaml
+++ b/docs/public/api-reference/fabro-api.yaml
@@ -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.
diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs
index 69b32fb66..5465c72dc 100644
--- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs
+++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs
@@ -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() {
diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs
index a72f44402..5ac92e2ea 100644
--- a/lib/crates/fabro-server/src/demo/mod.rs
+++ b/lib/crates/fabro-server/src/demo/mod.rs
@@ -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),
diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs
index ab3e92860..1bec57597 100644
--- a/lib/crates/fabro-server/src/server/handler/runs.rs
+++ b/lib/crates/fabro-server/src/server/handler/runs.rs
@@ -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(items: Vec, pagination: &PaginationParams) -> (Vec, bool) {
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset.min(MAX_PAGE_OFFSET) as usize;
diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs
index d1b1be619..ceb7a462c 100644
--- a/lib/crates/fabro-store/src/run_state.rs
+++ b/lib/crates/fabro-store/src/run_state.rs
@@ -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};
diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs
index b756cef4e..fc783b5f5 100644
--- a/lib/crates/fabro-types/src/run_summary.rs
+++ b/lib/crates/fabro-types/src/run_summary.rs
@@ -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,
#[serde(default)]
pub diff_summary: Option,
+ #[serde(default)]
+ pub pull_request: Option,
}
impl RunSummary {
@@ -65,6 +69,7 @@ impl RunSummary {
total_usd_micros: Option,
superseded_by: Option,
diff_summary: Option,
+ pull_request: Option,
) -> 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");
}
diff --git a/lib/packages/fabro-api-client/src/models/run-pull-request.ts b/lib/packages/fabro-api-client/src/models/run-pull-request.ts
index c76620366..84aa0d7cb 100644
--- a/lib/packages/fabro-api-client/src/models/run-pull-request.ts
+++ b/lib/packages/fabro-api-client/src/models/run-pull-request.ts
@@ -25,6 +25,10 @@ export interface RunPullRequest {
* Pull request number.
*/
'number': number;
+ /**
+ * Stored GitHub pull request URL.
+ */
+ 'html_url'?: string;
/**
* Lines added.
*/
diff --git a/lib/packages/fabro-api-client/src/models/run-summary.ts b/lib/packages/fabro-api-client/src/models/run-summary.ts
index 6e387ac06..7c4af77e7 100644
--- a/lib/packages/fabro-api-client/src/models/run-summary.ts
+++ b/lib/packages/fabro-api-client/src/models/run-summary.ts
@@ -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;
}