Skip to content

Commit 67cccf2

Browse files
authored
feat(mobile): show live PR status on inbox report cards and detail (port #2695, #2694) (#2698)
1 parent 2b4cf36 commit 67cccf2

5 files changed

Lines changed: 159 additions & 3 deletions

File tree

apps/mobile/src/app/inbox/[...id].tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
formatSignalReportSummaryMarkdown,
5858
inboxStatusLabel,
5959
} from "@/features/inbox/utils";
60+
import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
6061
import {
6162
computeReportAgeHours,
6263
type InboxReportActionType,
@@ -472,6 +473,15 @@ export default function ReportDetailScreen() {
472473
</Text>
473474
</View>
474475
<Text className="text-[12px] text-gray-9">Updated {timeDisplay}</Text>
476+
{report.implementation_pr_url ? (
477+
<View className="ml-auto">
478+
<PrStatusBadge
479+
prUrl={report.implementation_pr_url}
480+
hideWhenUnresolved
481+
size="sm"
482+
/>
483+
</View>
484+
) : null}
475485
</View>
476486

477487
{/* Failed warning */}

apps/mobile/src/features/inbox/components/ReportListRow.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Text } from "@components/text";
22
import { differenceInHours, format, formatDistanceToNow } from "date-fns";
33
import { memo } from "react";
44
import { Pressable, View } from "react-native";
5+
import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
56
import { useThemeColors } from "@/lib/theme";
67
import type { SignalReport } from "../types";
78

@@ -88,6 +89,16 @@ function ReportListRowComponent({ report, onPress }: ReportListRowProps) {
8889
</Text>
8990
</View>
9091
</View>
92+
93+
{report.implementation_pr_url ? (
94+
<View className="self-center">
95+
<PrStatusBadge
96+
prUrl={report.implementation_pr_url}
97+
hideWhenUnresolved
98+
size="sm"
99+
/>
100+
</View>
101+
) : null}
91102
</Pressable>
92103
);
93104
}

apps/mobile/src/features/inbox/components/SwipeableReportCard.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GithubLogo, Lightning } from "phosphor-react-native";
55
import { useRef } from "react";
66
import { Animated, PanResponder, Pressable, View } from "react-native";
77
import { MarkdownText } from "@/features/chat/components/MarkdownText";
8+
import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
89
import { useThemeColors } from "@/lib/theme";
910
import type {
1011
SignalReport,
@@ -290,6 +291,15 @@ function CardContent({
290291
</View>
291292
</>
292293
)}
294+
{report.implementation_pr_url ? (
295+
<View className="ml-auto">
296+
<PrStatusBadge
297+
prUrl={report.implementation_pr_url}
298+
hideWhenUnresolved
299+
size="sm"
300+
/>
301+
</View>
302+
) : null}
293303
</View>
294304
</View>
295305
);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { createElement } from "react";
2+
import { act, create } from "react-test-renderer";
3+
import { describe, expect, it, vi } from "vitest";
4+
import type { PrStatus } from "../hooks/usePrStatus";
5+
import { PrStatusBadge } from "./PrStatusBadge";
6+
7+
vi.mock("phosphor-react-native", () => ({
8+
GitMerge: (props: Record<string, unknown>) =>
9+
createElement("GitMerge", props),
10+
GitPullRequest: (props: Record<string, unknown>) =>
11+
createElement("GitPullRequest", props),
12+
}));
13+
14+
vi.mock("@/lib/theme", () => ({
15+
useThemeColors: () => ({
16+
gray: { 11: "#444444" },
17+
status: { success: "#00aa00", error: "#cc0000" },
18+
}),
19+
toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`,
20+
}));
21+
22+
vi.mock("@/lib/openExternalUrl", () => ({ openExternalUrl: vi.fn() }));
23+
24+
vi.mock("../hooks/usePrStatus", () => ({ usePrStatus: vi.fn() }));
25+
26+
import { usePrStatus } from "../hooks/usePrStatus";
27+
28+
const mockUsePrStatus = vi.mocked(usePrStatus);
29+
30+
function setStatus(data: PrStatus | null | undefined) {
31+
mockUsePrStatus.mockReturnValue({ data } as ReturnType<typeof usePrStatus>);
32+
}
33+
34+
function render(props: Parameters<typeof PrStatusBadge>[0]) {
35+
let renderer: ReturnType<typeof create> | null = null;
36+
act(() => {
37+
renderer = create(createElement(PrStatusBadge, props));
38+
});
39+
if (!renderer) throw new Error("Renderer not created");
40+
return renderer as ReturnType<typeof create>;
41+
}
42+
43+
function label(renderer: ReturnType<typeof create>): string | undefined {
44+
const node = renderer.root.findAll(
45+
(n) => typeof n.props?.accessibilityLabel === "string",
46+
)[0];
47+
return node?.props.accessibilityLabel as string | undefined;
48+
}
49+
50+
function iconCount(renderer: ReturnType<typeof create>, type: string): number {
51+
return renderer.root.findAll((n) => n.type === type).length;
52+
}
53+
54+
const base: PrStatus = {
55+
state: "open",
56+
merged: false,
57+
draft: false,
58+
additions: 0,
59+
deletions: 0,
60+
};
61+
62+
describe("PrStatusBadge", () => {
63+
it("renders an open PR badge", () => {
64+
setStatus({ ...base, state: "open" });
65+
const r = render({ prUrl: "https://github.com/a/b/pull/1" });
66+
expect(label(r)).toBe("Open PR");
67+
expect(iconCount(r, "GitPullRequest")).toBe(1);
68+
});
69+
70+
it("renders a merged PR badge with the merge icon", () => {
71+
setStatus({ ...base, state: "closed", merged: true });
72+
const r = render({ prUrl: "https://github.com/a/b/pull/1" });
73+
expect(label(r)).toBe("Open merged PR");
74+
expect(iconCount(r, "GitMerge")).toBe(1);
75+
expect(iconCount(r, "GitPullRequest")).toBe(0);
76+
});
77+
78+
it("renders a closed PR badge", () => {
79+
setStatus({ ...base, state: "closed" });
80+
const r = render({ prUrl: "https://github.com/a/b/pull/1" });
81+
expect(label(r)).toBe("Open closed PR");
82+
});
83+
84+
it("renders a draft PR badge", () => {
85+
setStatus({ ...base, state: "open", draft: true });
86+
const r = render({ prUrl: "https://github.com/a/b/pull/1" });
87+
expect(label(r)).toBe("Open draft PR");
88+
});
89+
90+
it.each([
91+
{ data: undefined, label: "loading" },
92+
{ data: null, label: "unresolved (private/404/non-GitHub)" },
93+
])(
94+
"renders nothing when hideWhenUnresolved is set and status is $label",
95+
({ data }) => {
96+
setStatus(data);
97+
const r = render({
98+
prUrl: "https://github.com/a/b/pull/1",
99+
hideWhenUnresolved: true,
100+
});
101+
expect(r.toJSON()).toBeNull();
102+
},
103+
);
104+
105+
it("still renders a neutral badge for an unresolved PR by default", () => {
106+
setStatus(null);
107+
const r = render({ prUrl: "https://github.com/a/b/pull/1" });
108+
expect(r.toJSON()).not.toBeNull();
109+
expect(label(r)).toBe("Open PR");
110+
});
111+
});

apps/mobile/src/features/tasks/components/PrStatusBadge.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,28 @@ import { usePrStatus } from "../hooks/usePrStatus";
66

77
interface PrStatusBadgeProps {
88
prUrl: string;
9+
// Render nothing until the PR state resolves, and only for a canonical
10+
// GitHub PR URL. Inbox surfaces use this so an always-on neutral icon never
11+
// implies a status we couldn't confirm (private repo, 404, unparseable URL).
12+
hideWhenUnresolved?: boolean;
13+
size?: "sm" | "md";
914
}
1015

1116
// Mirrors the desktop "merged" PR color (Radix purple-9 family). Theme tokens
1217
// don't include a purple, and merged-PR purple is recognisable enough that a
1318
// fixed value works in both light and dark.
1419
const MERGED_COLOR = "#8e4ec6";
1520

16-
export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) {
21+
export function PrStatusBadge({
22+
prUrl,
23+
hideWhenUnresolved = false,
24+
size = "md",
25+
}: PrStatusBadgeProps) {
1726
const themeColors = useThemeColors();
1827
const { data: status } = usePrStatus(prUrl);
1928

29+
if (hideWhenUnresolved && !status) return null;
30+
2031
const handlePress = () => {
2132
openExternalUrl(prUrl);
2233
};
@@ -40,19 +51,22 @@ export function PrStatusBadge({ prUrl }: PrStatusBadgeProps) {
4051
label = "Open PR";
4152
}
4253

54+
const box = size === "sm" ? "h-7 w-7" : "h-9 w-9";
55+
const iconSize = size === "sm" ? 16 : 20;
56+
4357
return (
4458
<Pressable
4559
onPress={handlePress}
4660
hitSlop={10}
47-
className="h-9 w-9 items-center justify-center rounded-lg border active:opacity-60"
61+
className={`${box} items-center justify-center rounded-lg border active:opacity-60`}
4862
style={{
4963
backgroundColor: toRgba(color, 0.12),
5064
borderColor: toRgba(color, 0.35),
5165
}}
5266
accessibilityRole="link"
5367
accessibilityLabel={label}
5468
>
55-
<Icon size={20} weight="bold" color={color} />
69+
<Icon size={iconSize} weight="bold" color={color} />
5670
</Pressable>
5771
);
5872
}

0 commit comments

Comments
 (0)