From 7c72533f2c8ba04a3ea1d338c6207f6b804e29f1 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 5 Jun 2026 11:45:15 +0100 Subject: [PATCH 1/2] feat(mobile): show PR badge on task list rows (port #2422) Adds a compact pull-request badge to each task row in the mobile task list, mirroring the desktop sidebar behavior (#2422). When a task has a PR url (`latest_run.output.pr_url`), the badge shows the PR number and opens the PR on tap, taking the timestamp's slot. Reuses the existing `parseGithubIssueUrl` helper to extract the PR number. Generated-By: PostHog Code Task-Id: eb5935b3-fcab-4471-b623-693834c6017e --- .../tasks/components/TaskItem.test.tsx | 90 +++++++++++++++++++ .../features/tasks/components/TaskItem.tsx | 33 +++++-- 2 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/features/tasks/components/TaskItem.test.tsx diff --git a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx new file mode 100644 index 0000000000..7f32917b43 --- /dev/null +++ b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx @@ -0,0 +1,90 @@ +import { createElement } from "react"; +import { act, create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import type { Task } from "../types"; +import { TaskItem } from "./TaskItem"; + +vi.mock("phosphor-react-native", () => ({ + Check: (props: Record) => createElement("Check", props), + GitPullRequest: (props: Record) => + createElement("GitPullRequest", props), +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + gray: { 11: "#444444" }, + accent: { 9: "#ff5500" }, + }), +})); + +vi.mock("@components/text", () => ({ + Text: (props: Record) => createElement("Text", props), +})); + +vi.mock("./TaskStatusIcon", () => ({ + TaskStatusIcon: (props: Record) => + createElement("TaskStatusIcon", props), +})); + +function makeTask(prUrl?: string): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Test task", + description: "", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + origin_product: "code", + latest_run: prUrl + ? { + id: "run-1", + task: "task-1", + team: 1, + branch: null, + stage: null, + environment: "cloud", + status: "completed", + log_url: "", + error_message: null, + output: { pr_url: prUrl }, + state: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + } + : undefined, + }; +} + +function render(task: Task) { + let renderer!: ReturnType; + act(() => { + renderer = create(createElement(TaskItem, { task, onPress: () => {} })); + }); + return renderer; +} + +describe("TaskItem", () => { + function prIcons(renderer: ReturnType) { + return renderer.root.findAll( + (node) => String(node.type) === "GitPullRequest", + ); + } + + it("shows the PR badge with the parsed number when a PR url is present", () => { + const renderer = render( + makeTask("https://github.com/PostHog/code/pull/2422"), + ); + + expect(prIcons(renderer)).toHaveLength(1); + const number = renderer.root.findAll( + (node) => String(node.type) === "Text" && node.props.children === "#2422", + ); + expect(number).toHaveLength(1); + }); + + it("does not show the PR badge when there is no PR url", () => { + expect(prIcons(render(makeTask()))).toHaveLength(0); + }); +}); diff --git a/apps/mobile/src/features/tasks/components/TaskItem.tsx b/apps/mobile/src/features/tasks/components/TaskItem.tsx index 715c70ebb0..e99bdfb768 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.tsx @@ -1,12 +1,29 @@ import { Text } from "@components/text"; import { differenceInHours, format, formatDistanceToNow } from "date-fns"; -import { Check } from "phosphor-react-native"; +import { Check, GitPullRequest } from "phosphor-react-native"; import { memo } from "react"; -import { Pressable, View } from "react-native"; +import { Linking, Pressable, View } from "react-native"; +import { parseGithubIssueUrl } from "@/lib/githubIssueUrl"; import { useThemeColors } from "@/lib/theme"; import type { Task } from "../types"; import { TaskStatusIcon } from "./TaskStatusIcon"; +function PrBadge({ prUrl, number }: { prUrl: string; number: number }) { + const themeColors = useThemeColors(); + return ( + Linking.openURL(prUrl).catch(() => {})} + hitSlop={8} + className="shrink-0 flex-row items-center gap-0.5 rounded border border-gray-6 bg-gray-3 px-1.5 py-0.5 active:opacity-60" + accessibilityRole="link" + accessibilityLabel={`Open pull request #${number}`} + > + + {`#${number}`} + + ); +} + interface TaskItemProps { task: Task; onPress: (task: Task) => void; @@ -29,6 +46,8 @@ function TaskItemComponent({ hoursSinceCreated < 24 ? formatDistanceToNow(createdAt, { addSuffix: true }) : format(createdAt, "MMM d"); + const prUrl = task.latest_run?.output?.pr_url; + const prRef = typeof prUrl === "string" ? parseGithubIssueUrl(prUrl) : null; return ( {task.title} - - {timeDisplay} - + {prRef?.kind === "pr" ? ( + + ) : ( + + {timeDisplay} + + )} {task.description ? ( From 342499769b5a4b1fe4e4507f8ec6ddcb7fe687c0 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Fri, 5 Jun 2026 12:10:53 +0100 Subject: [PATCH 2/2] test(mobile): parameterise the PR-badge "no badge" cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the additional no-badge paths via it.each: null output, a GitHub issue url (kind !== "pr"), and a non-GitHub url — alongside the no-run case. Generated-By: PostHog Code Task-Id: eb5935b3-fcab-4471-b623-693834c6017e --- .../tasks/components/TaskItem.test.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx index 7f32917b43..d62cfd7665 100644 --- a/apps/mobile/src/features/tasks/components/TaskItem.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskItem.test.tsx @@ -26,7 +26,7 @@ vi.mock("./TaskStatusIcon", () => ({ createElement("TaskStatusIcon", props), })); -function makeTask(prUrl?: string): Task { +function makeTask(run?: Partial>): Task { return { id: "task-1", task_number: 1, @@ -36,7 +36,7 @@ function makeTask(prUrl?: string): Task { created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", origin_product: "code", - latest_run: prUrl + latest_run: run ? { id: "run-1", task: "task-1", @@ -47,11 +47,12 @@ function makeTask(prUrl?: string): Task { status: "completed", log_url: "", error_message: null, - output: { pr_url: prUrl }, + output: null, state: {}, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", completed_at: null, + ...run, } : undefined, }; @@ -74,7 +75,9 @@ describe("TaskItem", () => { it("shows the PR badge with the parsed number when a PR url is present", () => { const renderer = render( - makeTask("https://github.com/PostHog/code/pull/2422"), + makeTask({ + output: { pr_url: "https://github.com/PostHog/code/pull/2422" }, + }), ); expect(prIcons(renderer)).toHaveLength(1); @@ -84,7 +87,20 @@ describe("TaskItem", () => { expect(number).toHaveLength(1); }); - it("does not show the PR badge when there is no PR url", () => { - expect(prIcons(render(makeTask()))).toHaveLength(0); + it.each([ + ["the task has no run", makeTask()], + ["the run has no output", makeTask({ output: null })], + [ + "the url is a GitHub issue, not a PR", + makeTask({ + output: { pr_url: "https://github.com/PostHog/code/issues/42" }, + }), + ], + [ + "the url is not a GitHub url", + makeTask({ output: { pr_url: "https://example.com/not-a-pr" } }), + ], + ])("does not show the PR badge when %s", (_label, task) => { + expect(prIcons(render(task))).toHaveLength(0); }); });