From d8f6a9dd88f8a2db3c187684812be79c130b50cd Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Tue, 9 Jun 2026 10:40:25 -0700 Subject: [PATCH] feat(code): suggest PR work items on new task page --- apps/code/src/shared/constants.ts | 1 + packages/core/src/git/router-schemas.ts | 20 +++ .../host-router/src/routers/git.router.ts | 11 ++ packages/shared/src/analytics-events.ts | 20 +++ packages/shared/src/constants.ts | 1 + packages/shared/src/flags.ts | 1 + .../components/SuggestedTaskCard.tsx | 2 +- .../components/SuggestedTasksPanel.tsx | 121 ++++++++++++++++-- .../task-detail/components/TaskInput.tsx | 9 +- .../src/features/work-items/WorkItemCard.tsx | 81 ++++++++++++ .../work-items/buildWorkItemPrompt.test.ts | 34 +++++ .../work-items/buildWorkItemPrompt.ts | 35 +++++ .../dismissedWorkItemsStore.test.ts | 56 ++++++++ .../work-items/dismissedWorkItemsStore.ts | Bin 0 -> 1407 bytes .../work-items/useWorkItemSuggestions.ts | 26 ++++ .../src/services/git/schemas.ts | 20 +++ .../src/services/git/service.ts | 105 +++++++++++++++ .../src/services/git/work-items.test.ts | 107 ++++++++++++++++ packages/workspace-server/src/trpc.ts | 7 + 19 files changed, 641 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/features/work-items/WorkItemCard.tsx create mode 100644 packages/ui/src/features/work-items/buildWorkItemPrompt.test.ts create mode 100644 packages/ui/src/features/work-items/buildWorkItemPrompt.ts create mode 100644 packages/ui/src/features/work-items/dismissedWorkItemsStore.test.ts create mode 100644 packages/ui/src/features/work-items/dismissedWorkItemsStore.ts create mode 100644 packages/ui/src/features/work-items/useWorkItemSuggestions.ts create mode 100644 packages/workspace-server/src/services/git/work-items.test.ts diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts index b3fc672e90..20d6af7bc8 100644 --- a/apps/code/src/shared/constants.ts +++ b/apps/code/src/shared/constants.ts @@ -6,6 +6,7 @@ export const SELF_DRIVING_SETUP_TASK_FLAG = export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; export const HOME_TAB_FLAG = "posthog-code-home-tab"; export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; +export const WORK_ITEM_SUGGESTIONS_FLAG = "posthog-code-work-item-suggestions"; export const BRANCH_PREFIX = "posthog-code/"; export const DATA_DIR = ".posthog-code"; export const WORKTREES_DIR = ".posthog-code/worktrees"; diff --git a/packages/core/src/git/router-schemas.ts b/packages/core/src/git/router-schemas.ts index d50f37dadd..5ae1ddd4e9 100644 --- a/packages/core/src/git/router-schemas.ts +++ b/packages/core/src/git/router-schemas.ts @@ -593,3 +593,23 @@ export const createPrProgressPayload = z.object({ }); export type CreatePrProgressPayload = z.infer; + +// PR work items: the current user's open PRs that need action. Each kind is a +// distinct reason a single PR is "waiting on you" (a PR can yield several). +export const prWorkItemKindSchema = z.enum(["review", "ci", "conflict"]); +export type PrWorkItemKind = z.infer; + +export const prWorkItemSchema = z.object({ + kind: prWorkItemKindSchema, + prNumber: z.number(), + title: z.string(), + url: z.string(), + headRefName: z.string(), + // Head commit SHA — lets dismissals be commit-scoped (a new push re-surfaces). + headSha: z.string(), +}); + +export type PrWorkItem = z.infer; + +export const getPrWorkItemsInput = directoryPathInput; +export const getPrWorkItemsOutput = z.array(prWorkItemSchema); diff --git a/packages/host-router/src/routers/git.router.ts b/packages/host-router/src/routers/git.router.ts index aa27f3e2d7..ce6761a736 100644 --- a/packages/host-router/src/routers/git.router.ts +++ b/packages/host-router/src/routers/git.router.ts @@ -68,6 +68,8 @@ import { getPrTemplateOutput, getPrUrlForBranchInput, getPrUrlForBranchOutput, + getPrWorkItemsInput, + getPrWorkItemsOutput, ghAuthTokenOutput, ghStatusOutput, gitStateSnapshotSchema, @@ -449,6 +451,15 @@ export const gitRouter = router({ }), ), + getPrWorkItems: publicProcedure + .input(getPrWorkItemsInput) + .output(getPrWorkItemsOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getPrWorkItems.query({ + directoryPath: input.directoryPath, + }), + ), + createPr: publicProcedure .input(createPrInput) .output(createPrOutput) diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 1eaf429c35..60d4ba90f0 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -490,6 +490,22 @@ export interface SetupTaskDismissedProperties { total_discovered: number; } +export type WorkItemKind = "review" | "ci" | "conflict"; + +export interface SetupWorkItemSelectedProperties { + kind: WorkItemKind; + pr_number: number; + position: number; + total: number; +} + +export interface SetupWorkItemDismissedProperties { + kind: WorkItemKind; + pr_number: number; + position: number; + total: number; +} + // Inbox events export type InboxReportOpenMethod = | "click" @@ -1010,6 +1026,8 @@ export const ANALYTICS_EVENTS = { SETUP_DISCOVERY_FAILED: "Setup discovery failed", SETUP_TASK_SELECTED: "Setup task selected", SETUP_TASK_DISMISSED: "Setup task dismissed", + SETUP_WORK_ITEM_SELECTED: "Setup work item selected", + SETUP_WORK_ITEM_DISMISSED: "Setup work item dismissed", // Deep link events DEEP_LINK_NEW_TASK: "Deep link new task", @@ -1153,6 +1171,8 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED]: SetupDiscoveryFailedProperties; [ANALYTICS_EVENTS.SETUP_TASK_SELECTED]: SetupTaskSelectedProperties; [ANALYTICS_EVENTS.SETUP_TASK_DISMISSED]: SetupTaskDismissedProperties; + [ANALYTICS_EVENTS.SETUP_WORK_ITEM_SELECTED]: SetupWorkItemSelectedProperties; + [ANALYTICS_EVENTS.SETUP_WORK_ITEM_DISMISSED]: SetupWorkItemDismissedProperties; // Deep link events [ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK]: DeepLinkNewTaskProperties; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b0a3336e29..69e5c32057 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -4,6 +4,7 @@ export { EXPERIMENT_SUGGESTIONS_FLAG, HOME_TAB_FLAG, SYNC_CLOUD_TASKS_FLAG, + WORK_ITEM_SUGGESTIONS_FLAG, } from "./flags"; export const SELF_DRIVING_SETUP_TASK_FLAG = diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 399f118a9f..e4f79bd5b4 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -4,6 +4,7 @@ export const EXPERIMENT_SUGGESTIONS_FLAG = export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; export const HOME_TAB_FLAG = "posthog-code-home-tab"; export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; +export const WORK_ITEM_SUGGESTIONS_FLAG = "posthog-code-work-item-suggestions"; // Gates the entire canvas feature: the app rail's Channels space, the /website // routes, channels and dashboards. export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; diff --git a/packages/ui/src/features/task-detail/components/SuggestedTaskCard.tsx b/packages/ui/src/features/task-detail/components/SuggestedTaskCard.tsx index cf5464273f..7e8a0233fa 100644 --- a/packages/ui/src/features/task-detail/components/SuggestedTaskCard.tsx +++ b/packages/ui/src/features/task-detail/components/SuggestedTaskCard.tsx @@ -85,7 +85,7 @@ export function SuggestedTaskCard({ e.stopPropagation(); onDismiss(task); }} - className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-(--gray-9) hover:bg-(--gray-a3) hover:text-(--gray-12)" + className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-md bg-(--gray-3) text-(--gray-11) shadow-sm transition-colors hover:bg-(--gray-4) hover:text-(--gray-12)" > diff --git a/packages/ui/src/features/task-detail/components/SuggestedTasksPanel.tsx b/packages/ui/src/features/task-detail/components/SuggestedTasksPanel.tsx index 2ef609d818..476a5d8287 100644 --- a/packages/ui/src/features/task-detail/components/SuggestedTasksPanel.tsx +++ b/packages/ui/src/features/task-detail/components/SuggestedTasksPanel.tsx @@ -4,7 +4,19 @@ import { Lightning, MagnifyingGlass, } from "@phosphor-icons/react"; +import type { PrWorkItem } from "@posthog/core/git/router-schemas"; import type { DiscoveredTask } from "@posthog/core/setup/types"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { useDetectedCloudRepository } from "@posthog/ui/features/repo-files/useDetectedCloudRepository"; +import { buildWorkItemPrompt } from "@posthog/ui/features/work-items/buildWorkItemPrompt"; +import { + dismissedWorkItemKey, + useDismissedWorkItemsStore, +} from "@posthog/ui/features/work-items/dismissedWorkItemsStore"; +import { useWorkItemSuggestions } from "@posthog/ui/features/work-items/useWorkItemSuggestions"; +import { WorkItemCard } from "@posthog/ui/features/work-items/WorkItemCard"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { Flex, Text } from "@radix-ui/themes"; import { AnimatePresence, motion } from "framer-motion"; import { @@ -16,6 +28,7 @@ import { useState, } from "react"; import { useActiveRepoStore } from "../../../shell/activeRepoStore"; +import { track } from "../../../shell/analytics"; import { DiscoveredTaskDetailDialog } from "../../setup/DiscoveredTaskDetailDialog"; import { SetupScanFeed } from "../../setup/SetupScanFeed"; import { @@ -26,6 +39,12 @@ import { } from "../../setup/setupStore"; import { SuggestedTaskCard } from "./SuggestedTaskCard"; +const workItemKey = (item: PrWorkItem) => `${item.prNumber}:${item.kind}`; + +type PanelEntry = + | { kind: "work"; id: string; item: PrWorkItem } + | { kind: "discovered"; id: string; task: DiscoveredTask }; + const VISIBLE_LIMIT = 3; const DEFAULT_LOG_LINES = 4; @@ -66,6 +85,14 @@ export function SuggestedTasksPanel({ leading }: { leading?: ReactNode }) { ); const removeDiscoveredTask = useSetupStore((s) => s.removeDiscoveredTask); + const workItemsRaw = useWorkItemSuggestions(selectedDirectory); + const dismissedWorkItemKeys = useDismissedWorkItemsStore( + (s) => s.dismissedKeys, + ); + const dismissWorkItem = useDismissedWorkItemsStore((s) => s.dismiss); + const { folders } = useFolders(); + const detectedCloudRepository = useDetectedCloudRepository(selectedDirectory); + const [detailTask, setDetailTask] = useState(null); const [pageStart, setPageStart] = useState(0); const [pageDirection, setPageDirection] = useState<1 | -1>(1); @@ -120,14 +147,63 @@ export function SuggestedTasksPanel({ leading }: { leading?: ReactNode }) { setDetailTask(null); }, []); - const hasTasks = discoveredTasks.length > 0; + const handleSelectWorkItem = useCallback( + (item: PrWorkItem, position: number, total: number) => { + track(ANALYTICS_EVENTS.SETUP_WORK_ITEM_SELECTED, { + kind: item.kind, + pr_number: item.prNumber, + position, + total, + }); + const folderId = folders.find((f) => f.path === selectedDirectory)?.id; + openTaskInput({ + initialPrompt: buildWorkItemPrompt(item), + folderId, + initialCloudRepository: detectedCloudRepository ?? undefined, + }); + }, + [folders, selectedDirectory, detectedCloudRepository], + ); + + const handleDismissWorkItem = useCallback( + (item: PrWorkItem, position: number, total: number) => { + track(ANALYTICS_EVENTS.SETUP_WORK_ITEM_DISMISSED, { + kind: item.kind, + pr_number: item.prNumber, + position, + total, + }); + dismissWorkItem(dismissedWorkItemKey(selectedDirectory, item)); + }, + [dismissWorkItem, selectedDirectory], + ); + + const dismissed = new Set(dismissedWorkItemKeys); + const workItems = workItemsRaw.filter( + (item) => !dismissed.has(dismissedWorkItemKey(selectedDirectory, item)), + ); + + const entries: PanelEntry[] = [ + ...discoveredTasks.map((task) => ({ + kind: "discovered" as const, + id: task.id, + task, + })), + ...workItems.map((item) => ({ + kind: "work" as const, + id: `work:${workItemKey(item)}`, + item, + })), + ]; + + const hasTasks = entries.length > 0; const showEnricherFeed = !hasTasks && enricherStatus === "running"; const showDiscoveryFeed = discoveryStatus === "running"; if (!hasTasks && !showEnricherFeed && !showDiscoveryFeed && !leading) return null; - const totalTasks = discoveredTasks.length; + const totalTasks = entries.length; const desiredVisible = Math.min(totalTasks, VISIBLE_LIMIT); const discoveryFeedHasEntries = discoveryFeed.recentEntries.length > 0; @@ -163,13 +239,13 @@ export function SuggestedTasksPanel({ leading }: { leading?: ReactNode }) { const effectivePageStart = visibleCount > 0 && pageStart < totalTasks ? pageStart : 0; - const visibleTasks = discoveredTasks.slice( + const visibleEntries = entries.slice( effectivePageStart, effectivePageStart + visibleCount, ); const canGoPrev = effectivePageStart > 0; - const canGoNext = effectivePageStart + visibleTasks.length < totalTasks; + const canGoNext = effectivePageStart + visibleEntries.length < totalTasks; const showPager = visibleCount > 0 && totalTasks > visibleCount; const currentPage = visibleCount > 0 ? Math.floor(effectivePageStart / visibleCount) + 1 : 1; @@ -251,14 +327,35 @@ export function SuggestedTasksPanel({ leading }: { leading?: ReactNode }) { className="flex flex-col gap-2" > - {visibleTasks.map((task) => ( - - ))} + {visibleEntries.map((entry) => + entry.kind === "work" ? ( + + handleSelectWorkItem( + item, + workItems.indexOf(item), + workItems.length, + ) + } + onDismiss={(item) => + handleDismissWorkItem( + item, + workItems.indexOf(item), + workItems.length, + ) + } + /> + ) : ( + + ), + )} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 9230596e62..3d8f4dad0f 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -1,4 +1,5 @@ import { FileText, X } from "@phosphor-icons/react"; +import { xmlToContent } from "@posthog/core/message-editor/content"; import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { ButtonGroup } from "@posthog/quill"; @@ -207,9 +208,11 @@ export function TaskInput({ useEffect(() => { if (!initialPrompt || !prefillRequestKey) return; - useDraftStore.getState().actions.setPendingContent(sessionId, { - segments: [{ type: "text", text: initialPrompt }], - }); + // Hydrate chip tags (e.g. ) into real pills; plain prompts + // round-trip unchanged to a single text segment. + useDraftStore + .getState() + .actions.setPendingContent(sessionId, xmlToContent(initialPrompt)); }, [initialPrompt, prefillRequestKey, sessionId]); useEffect(() => { diff --git a/packages/ui/src/features/work-items/WorkItemCard.tsx b/packages/ui/src/features/work-items/WorkItemCard.tsx new file mode 100644 index 0000000000..c353be14e9 --- /dev/null +++ b/packages/ui/src/features/work-items/WorkItemCard.tsx @@ -0,0 +1,81 @@ +import { GitPullRequest, X } from "@phosphor-icons/react"; +import type { PrWorkItem } from "@posthog/core/git/router-schemas"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { motion } from "framer-motion"; + +const KIND_TITLE: Record string> = { + review: (n) => `Address review on PR #${n}`, + ci: (n) => `Fix failing CI on PR #${n}`, + conflict: (n) => `Resolve merge conflicts on PR #${n}`, +}; + +export interface WorkItemCardProps { + item: PrWorkItem; + onSelect: (item: PrWorkItem) => void; + onDismiss: (item: PrWorkItem) => void; +} + +export function WorkItemCard({ item, onSelect, onDismiss }: WorkItemCardProps) { + return ( + + + + + + + + + ); +} diff --git a/packages/ui/src/features/work-items/buildWorkItemPrompt.test.ts b/packages/ui/src/features/work-items/buildWorkItemPrompt.test.ts new file mode 100644 index 0000000000..0b44170fc9 --- /dev/null +++ b/packages/ui/src/features/work-items/buildWorkItemPrompt.test.ts @@ -0,0 +1,34 @@ +import type { PrWorkItem } from "@posthog/core/git/router-schemas"; +import { xmlToContent } from "@posthog/core/message-editor/content"; +import { describe, expect, it } from "vitest"; +import { buildWorkItemPrompt } from "./buildWorkItemPrompt"; + +const item: PrWorkItem = { + kind: "review", + prNumber: 2480, + title: "Configurable base branch", + url: "https://github.com/PostHog/code/pull/2480", + headRefName: "feat/base-branch", + headSha: "abc123", +}; + +describe("buildWorkItemPrompt", () => { + it("embeds the PR as a github_pr chip that hydrates into a pill", () => { + const content = xmlToContent(buildWorkItemPrompt(item)); + const chipSegment = content.segments.find((s) => s.type === "chip"); + expect(chipSegment).toEqual({ + type: "chip", + chip: { + type: "github_pr", + id: item.url, + label: "#2480 - Configurable base branch", + }, + }); + }); + + it("includes the kind instruction and branch hint", () => { + const xml = buildWorkItemPrompt(item); + expect(xml).toContain("Address the requested review changes"); + expect(xml).toContain("Branch: feat/base-branch"); + }); +}); diff --git a/packages/ui/src/features/work-items/buildWorkItemPrompt.ts b/packages/ui/src/features/work-items/buildWorkItemPrompt.ts new file mode 100644 index 0000000000..e577a6bd70 --- /dev/null +++ b/packages/ui/src/features/work-items/buildWorkItemPrompt.ts @@ -0,0 +1,35 @@ +import type { PrWorkItem } from "@posthog/core/git/router-schemas"; +import { + contentToXml, + type EditorContent, +} from "@posthog/core/message-editor/content"; +import { githubPullRequestToMentionChip } from "@posthog/core/message-editor/githubIssueChip"; + +const KIND_INSTRUCTION: Record = { + review: + "Address the requested review changes on this pull request. Read the unresolved review comments, make the changes, reply where useful, and push.", + ci: "Investigate and fix the failing CI checks on this pull request. Reproduce the failure locally, fix the root cause, and push.", + conflict: + "Resolve the merge conflicts on this pull request. Rebase the branch on the default branch, resolve each conflict, and push.", +}; + +/** + * Builds the pre-filled prompt for a PR work item as editor XML: the PR is a + * real `github_pr` mention chip (so it renders as a pill and the agent gets a + * structured reference), with the head branch as a hint to check out. + */ +export function buildWorkItemPrompt(item: PrWorkItem): string { + const chip = githubPullRequestToMentionChip({ + number: item.prNumber, + title: item.title, + url: item.url, + }); + const content: EditorContent = { + segments: [ + { type: "text", text: `${KIND_INSTRUCTION[item.kind]}\n\nPR: ` }, + { type: "chip", chip }, + { type: "text", text: `\nBranch: ${item.headRefName}` }, + ], + }; + return contentToXml(content); +} diff --git a/packages/ui/src/features/work-items/dismissedWorkItemsStore.test.ts b/packages/ui/src/features/work-items/dismissedWorkItemsStore.test.ts new file mode 100644 index 0000000000..fba147bd99 --- /dev/null +++ b/packages/ui/src/features/work-items/dismissedWorkItemsStore.test.ts @@ -0,0 +1,56 @@ +import type { PrWorkItem } from "@posthog/core/git/router-schemas"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + dismissedWorkItemKey, + useDismissedWorkItemsStore, +} from "./dismissedWorkItemsStore"; + +const item: PrWorkItem = { + kind: "ci", + prNumber: 7, + title: "Fix login", + url: "https://github.com/posthog/code/pull/7", + headRefName: "fix/login", + headSha: "sha-a", +}; + +describe("dismissedWorkItemKey", () => { + it("is commit-scoped — a new head SHA yields a different key", () => { + const a = dismissedWorkItemKey("/repo", item); + const b = dismissedWorkItemKey("/repo", { ...item, headSha: "sha-b" }); + expect(a).not.toBe(b); + }); + + it("distinguishes repo, pr number, and kind", () => { + const base = dismissedWorkItemKey("/repo", item); + expect(dismissedWorkItemKey("/other", item)).not.toBe(base); + expect(dismissedWorkItemKey("/repo", { ...item, prNumber: 8 })).not.toBe( + base, + ); + expect( + dismissedWorkItemKey("/repo", { ...item, kind: "conflict" }), + ).not.toBe(base); + }); +}); + +describe("useDismissedWorkItemsStore", () => { + beforeEach(() => { + useDismissedWorkItemsStore.setState({ dismissedKeys: [] }); + }); + + it("records a dismissal once (idempotent)", () => { + const { dismiss } = useDismissedWorkItemsStore.getState(); + dismiss("k1"); + dismiss("k1"); + expect(useDismissedWorkItemsStore.getState().dismissedKeys).toEqual(["k1"]); + }); + + it("caps the list so commit-scoped keys can't grow unbounded", () => { + const { dismiss } = useDismissedWorkItemsStore.getState(); + for (let i = 0; i < 550; i++) dismiss(`k${i}`); + const keys = useDismissedWorkItemsStore.getState().dismissedKeys; + expect(keys).toHaveLength(500); + expect(keys).not.toContain("k0"); // oldest dropped + expect(keys).toContain("k549"); // newest kept + }); +}); diff --git a/packages/ui/src/features/work-items/dismissedWorkItemsStore.ts b/packages/ui/src/features/work-items/dismissedWorkItemsStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..11b5d1fb4440666847a8d9cf81deb02b2b3b9def GIT binary patch literal 1407 zcmZux+iuf95arolapV{5C~^A2OF~>B*6z9?q>=atzK}0r z){dJCwXDR>oO5R8%xq*?Y2)D56=8!r^HrPW#8HVY=0=wY-&NYWTrY;HHZ)vFH#E9- zWCk|PsT4LG2eQ@JAT=Z$JyU)}z2wH(2$*fV;Hjj}C_1a-!^a_PeMKN{Shy{7YmE<;RhX-A& zEWzg@BQAuLGm=;XTF7AFF+61NA>SDxRV4El@@g-)dDUjf~y(}PoCvN7b5z)~u25mmNObH!`Zo}ITZ0l1WlDE2-kW+5 zXe;%c-R}TvL=hiTM~zgc8`eCML!*PH%pS_ z1+Bh6(v6oLYo~Tt$7x>&IXuf}ND5L5-iM3h(|CdeE?cxMWJ=K?ad1qG|6`6CX6L8R kyWKatjoU!?y@3)XjSxN?H{k!1<-qT^4YBoTrtlj615q=?_y7O^ literal 0 HcmV?d00001 diff --git a/packages/ui/src/features/work-items/useWorkItemSuggestions.ts b/packages/ui/src/features/work-items/useWorkItemSuggestions.ts new file mode 100644 index 0000000000..53d04aed93 --- /dev/null +++ b/packages/ui/src/features/work-items/useWorkItemSuggestions.ts @@ -0,0 +1,26 @@ +import type { PrWorkItem } from "@posthog/core/git/router-schemas"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { WORK_ITEM_SUGGESTIONS_FLAG } from "@posthog/shared/constants"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useQuery } from "@tanstack/react-query"; + +export function useWorkItemSuggestions( + selectedDirectory: string | null | undefined, +): PrWorkItem[] { + const trpc = useHostTRPC(); + const flagEnabled = useFeatureFlag( + WORK_ITEM_SUGGESTIONS_FLAG, + import.meta.env.DEV, + ); + + const { data } = useQuery({ + ...trpc.git.getPrWorkItems.queryOptions({ + directoryPath: selectedDirectory ?? "", + }), + enabled: flagEnabled && !!selectedDirectory, + staleTime: 5 * 60_000, + refetchOnWindowFocus: true, + }); + + return data ?? []; +} diff --git a/packages/workspace-server/src/services/git/schemas.ts b/packages/workspace-server/src/services/git/schemas.ts index 17314d0c13..ad658ec3a1 100644 --- a/packages/workspace-server/src/services/git/schemas.ts +++ b/packages/workspace-server/src/services/git/schemas.ts @@ -332,6 +332,26 @@ export const getPrDiffStatsBatchOutput = z.record( prDiffStatsSchema, ); +// PR work items: the current user's open PRs that need action. Mirrors +// `prWorkItemSchema` in `@posthog/core/git/router-schemas`. +export const prWorkItemKindSchema = z.enum(["review", "ci", "conflict"]); +export type PrWorkItemKind = z.infer; + +export const prWorkItemSchema = z.object({ + kind: prWorkItemKindSchema, + prNumber: z.number(), + title: z.string(), + url: z.string(), + headRefName: z.string(), + // Head commit SHA — lets dismissals be commit-scoped (a new push re-surfaces). + headSha: z.string(), +}); + +export type PrWorkItem = z.infer; + +export const getPrWorkItemsInput = directoryPathInput; +export const getPrWorkItemsOutput = z.array(prWorkItemSchema); + export const getBranchChangedFilesInput = z.object({ repo: z.string(), branch: z.string(), diff --git a/packages/workspace-server/src/services/git/service.ts b/packages/workspace-server/src/services/git/service.ts index 5cb3b3148f..e27c4c5835 100644 --- a/packages/workspace-server/src/services/git/service.ts +++ b/packages/workspace-server/src/services/git/service.ts @@ -73,6 +73,7 @@ import type { PrReviewComment, PrReviewThread, PrStatusOutput, + PrWorkItem, PublishOutput, PullOutput, PushOutput, @@ -155,6 +156,68 @@ export interface GitCloneEvents { const execFileAsync = promisify(execFile); +/** Shape of a `gh pr list --json …` row consumed by `derivePrWorkItems`. */ +interface GhPrListItem { + number: number; + title: string; + url: string; + headRefName: string; + headRefOid?: string; + mergeable?: string; + reviewDecision?: string; + isDraft?: boolean; + statusCheckRollup?: Array<{ state?: string; conclusion?: string }>; +} + +// Check-run `conclusion` / status-context `state` values that count as a real +// failure worth a fix-it task. Ambiguous outcomes (cancelled, action_required, +// pending) are intentionally excluded so we don't nag about non-failures. +const FAILED_CHECK_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT"]); +const FAILED_CHECK_STATES = new Set(["FAILURE", "ERROR"]); + +function hasFailingCheck(rollup: GhPrListItem["statusCheckRollup"]): boolean { + if (!rollup?.length) return false; + return rollup.some((check) => { + const conclusion = check.conclusion?.toUpperCase(); + const state = check.state?.toUpperCase(); + return ( + (!!conclusion && FAILED_CHECK_CONCLUSIONS.has(conclusion)) || + (!!state && FAILED_CHECK_STATES.has(state)) + ); + }); +} + +/** + * Derives 0..N work items from the current user's open PRs. A single PR can + * surface several (e.g. changes-requested *and* failing CI). + * + * Drafts surface only `conflict`: a merge conflict is the author's to resolve + * regardless of ready state and only rots, whereas changes-requested / failing + * CI on a draft is expected work-in-progress noise. + */ +export function derivePrWorkItems(prs: GhPrListItem[]): PrWorkItem[] { + const items: PrWorkItem[] = []; + for (const pr of prs) { + const base = { + prNumber: pr.number, + title: pr.title, + url: pr.url, + headRefName: pr.headRefName, + headSha: pr.headRefOid ?? "", + }; + if (!pr.isDraft && pr.reviewDecision === "CHANGES_REQUESTED") { + items.push({ ...base, kind: "review" }); + } + if (!pr.isDraft && hasFailingCheck(pr.statusCheckRollup)) { + items.push({ ...base, kind: "ci" }); + } + if (pr.mergeable === "CONFLICTING") { + items.push({ ...base, kind: "conflict" }); + } + } + return items; +} + @injectable() export class GitService extends TypedEventEmitter { async getDiffStats(directoryPath: string): Promise { @@ -903,6 +966,48 @@ export class GitService extends TypedEventEmitter { } } + /** + * Surfaces the current user's open PRs in this repo that need action: + * - `review`: changes requested + * - `ci`: a failing check + * - `conflict`: merge conflicts + * Returns `[]` (no noise) when this isn't a GitHub repo, gh is missing/ + * unauthenticated, or the call fails. + */ + public async getPrWorkItems(directoryPath: string): Promise { + try { + const remoteUrl = await getRemoteUrl(directoryPath); + const parsed = remoteUrl ? parseGithubUrl(remoteUrl) : null; + if (!parsed) return []; + + const result = await execGh( + [ + "pr", + "list", + "--author", + "@me", + "--state", + "open", + "--limit", + "20", + "--repo", + `${parsed.owner}/${parsed.repo}`, + "--json", + "number,title,url,headRefName,headRefOid,mergeable,reviewDecision,statusCheckRollup,isDraft", + ], + { cwd: directoryPath }, + ); + + if (result.exitCode !== 0) return []; + + const prs = JSON.parse(result.stdout) as GhPrListItem[]; + return derivePrWorkItems(prs); + } catch { + // Best-effort: any failure (no gh, network, parse) yields no suggestions. + return []; + } + } + async openPr(directoryPath: string): Promise { const result = await execGh(["pr", "view", "--json", "url"], { cwd: directoryPath, diff --git a/packages/workspace-server/src/services/git/work-items.test.ts b/packages/workspace-server/src/services/git/work-items.test.ts new file mode 100644 index 0000000000..d1a7bb4109 --- /dev/null +++ b/packages/workspace-server/src/services/git/work-items.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { derivePrWorkItems } from "./service"; + +/** Minimal `gh pr list --json …` row; spread to override per case. */ +function ghPr(overrides: Record = {}) { + return { + number: 100, + title: "Some PR", + url: "https://github.com/PostHog/code/pull/100", + headRefName: "feat/x", + headRefOid: "sha100", + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + isDraft: false, + statusCheckRollup: [], + ...overrides, + }; +} + +describe("derivePrWorkItems", () => { + it("surfaces a review item when changes are requested", () => { + const items = derivePrWorkItems([ + ghPr({ reviewDecision: "CHANGES_REQUESTED" }), + ]); + expect(items).toEqual([ + { + kind: "review", + prNumber: 100, + title: "Some PR", + url: "https://github.com/PostHog/code/pull/100", + headRefName: "feat/x", + headSha: "sha100", + }, + ]); + }); + + it.each([ + ["a failing check conclusion", [{ conclusion: "FAILURE" }]], + ["a timed-out check conclusion", [{ conclusion: "TIMED_OUT" }]], + ["a failing status state", [{ state: "FAILURE" }]], + ["an errored status state", [{ state: "ERROR" }]], + ["lowercase values", [{ conclusion: "failure" }]], + ])("surfaces a ci item for %s", (_label, statusCheckRollup) => { + const items = derivePrWorkItems([ghPr({ statusCheckRollup })]); + expect(items).toEqual([expect.objectContaining({ kind: "ci" })]); + }); + + it.each([ + ["pending", [{ state: "PENDING" }]], + ["cancelled", [{ conclusion: "CANCELLED" }]], + ["action_required", [{ conclusion: "ACTION_REQUIRED" }]], + ["successful", [{ conclusion: "SUCCESS" }]], + ["empty rollup", []], + ])("ignores %s checks", (_label, statusCheckRollup) => { + expect(derivePrWorkItems([ghPr({ statusCheckRollup })])).toEqual([]); + }); + + it("surfaces a conflict item when the PR is conflicting", () => { + const items = derivePrWorkItems([ghPr({ mergeable: "CONFLICTING" })]); + expect(items).toEqual([expect.objectContaining({ kind: "conflict" })]); + }); + + it("surfaces multiple items from a single PR", () => { + const items = derivePrWorkItems([ + ghPr({ + reviewDecision: "CHANGES_REQUESTED", + statusCheckRollup: [{ conclusion: "FAILURE" }], + mergeable: "CONFLICTING", + }), + ]); + expect(items.map((i) => i.kind)).toEqual(["review", "ci", "conflict"]); + }); + + it("yields nothing for a clean, approved PR", () => { + expect(derivePrWorkItems([ghPr()])).toEqual([]); + }); + + it("suppresses review and ci on drafts but keeps conflict", () => { + const items = derivePrWorkItems([ + ghPr({ + isDraft: true, + reviewDecision: "CHANGES_REQUESTED", + statusCheckRollup: [{ conclusion: "FAILURE" }], + mergeable: "CONFLICTING", + }), + ]); + expect(items.map((i) => i.kind)).toEqual(["conflict"]); + }); + + it("yields nothing for a draft with no conflict", () => { + const items = derivePrWorkItems([ + ghPr({ + isDraft: true, + reviewDecision: "CHANGES_REQUESTED", + statusCheckRollup: [{ conclusion: "FAILURE" }], + }), + ]); + expect(items).toEqual([]); + }); + + it("falls back to an empty headSha when headRefOid is absent", () => { + const items = derivePrWorkItems([ + ghPr({ headRefOid: undefined, mergeable: "CONFLICTING" }), + ]); + expect(items[0]?.headSha).toBe(""); + }); +}); diff --git a/packages/workspace-server/src/trpc.ts b/packages/workspace-server/src/trpc.ts index 629923dd30..9a37fa8194 100644 --- a/packages/workspace-server/src/trpc.ts +++ b/packages/workspace-server/src/trpc.ts @@ -89,6 +89,8 @@ import { getPrTemplateOutput, getPrUrlForBranchInput, getPrUrlForBranchOutput, + getPrWorkItemsInput, + getPrWorkItemsOutput, ghAuthTokenOutput, ghStatusOutput, gitBusyStateInput, @@ -545,6 +547,11 @@ export function createAppRouter({ gitService().getPrUrlForBranch(input.directoryPath, input.branchName), ), + getPrWorkItems: t.procedure + .input(getPrWorkItemsInput) + .output(getPrWorkItemsOutput) + .query(({ input }) => gitService().getPrWorkItems(input.directoryPath)), + openPr: t.procedure .input(openPrInput) .output(openPrOutput)