diff --git a/packages/core/src/git-interaction/prStatus.test.ts b/packages/core/src/git-interaction/prStatus.test.ts new file mode 100644 index 0000000000..5edd85e083 --- /dev/null +++ b/packages/core/src/git-interaction/prStatus.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + deriveMergeQueueState, + getOptimisticPrState, + getPrVisualConfig, + type MergeQueueVisualState, + PR_ACTION_LABELS, +} from "./prStatus"; + +describe("deriveMergeQueueState", () => { + it.each([ + ["queued", null, "queued"], + ["in_progress", null, "testing"], + ["completed", "failure", "failed"], + ["completed", "timed_out", "failed"], + ["completed", "success", null], + ["completed", "cancelled", null], + ["completed", "neutral", null], + [null, null, null], + [undefined, undefined, null], + ] as const)( + "status=%s conclusion=%s -> %s", + (status, conclusion, expected) => { + expect(deriveMergeQueueState(status, conclusion)).toBe(expected); + }, + ); +}); + +describe("getPrVisualConfig", () => { + it("merged wins over an active queue state", () => { + const config = getPrVisualConfig("open", true, false, "testing"); + expect(config.label).toBe("Merged"); + expect(config.color).toBe("purple"); + }); + + it("closed wins over an active queue state", () => { + const config = getPrVisualConfig("closed", false, false, "queued"); + expect(config.label).toBe("Closed"); + expect(config.color).toBe("red"); + }); + + it("draft wins over an active queue state", () => { + const config = getPrVisualConfig("open", false, true, "queued"); + expect(config.label).toBe("Draft"); + expect(config.color).toBe("gray"); + }); + + it.each([ + ["queued", "Queued"], + ["testing", "Testing"], + ] as const)("shows %s state as orange with a cancel action", (mq, label) => { + const config = getPrVisualConfig("open", false, false, mq); + expect(config).toMatchObject({ + color: "orange", + icon: "queue", + label, + actions: [{ id: "merge-queue-cancel", label: "Cancel queue run" }], + }); + }); + + it("shows a failed queue run in red with a retry action", () => { + const config = getPrVisualConfig("open", false, false, "failed"); + expect(config.color).toBe("red"); + expect(config.label).toBe("Queue failed"); + expect(config.actions.map((a) => a.id)).toEqual(["merge-queue", "close"]); + }); + + it("open PR offers 'Merge via queue' as the first action", () => { + const config = getPrVisualConfig("open", false, false, null); + expect(config.label).toBe("Open"); + expect(config.color).toBe("green"); + expect(config.actions[0]).toEqual({ + id: "merge-queue", + label: "Merge via queue", + }); + }); + + it("defaults mergeQueue to null (plain open badge)", () => { + expect(getPrVisualConfig("open", false, false).label).toBe("Open"); + }); +}); + +describe("getOptimisticPrState", () => { + it.each(["merge-queue", "merge-queue-cancel"] as const)( + "%s keeps the PR open", + (action) => { + expect(getOptimisticPrState(action)).toEqual({ + state: "open", + merged: false, + draft: false, + }); + }, + ); +}); + +describe("PR_ACTION_LABELS", () => { + it("covers the merge-queue actions", () => { + expect(PR_ACTION_LABELS["merge-queue"]).toBe("PR submitted to merge queue"); + expect(PR_ACTION_LABELS["merge-queue-cancel"]).toBe( + "Merge queue run cancelled", + ); + }); +}); + +// Type-only guard so the test file fails to compile if the union drifts. +const _states: MergeQueueVisualState[] = ["queued", "testing", "failed", null]; diff --git a/packages/core/src/git-interaction/prStatus.ts b/packages/core/src/git-interaction/prStatus.ts index 9d9e990902..81818961d7 100644 --- a/packages/core/src/git-interaction/prStatus.ts +++ b/packages/core/src/git-interaction/prStatus.ts @@ -1,6 +1,6 @@ import type { PrActionType } from "@posthog/shared"; -export type PrVisualIcon = "merged" | "pull-request"; +export type PrVisualIcon = "merged" | "pull-request" | "queue"; export interface PrAction { id: PrActionType; @@ -8,16 +8,43 @@ export interface PrAction { } export interface PrVisualConfig { - color: "gray" | "green" | "red" | "purple"; + color: "gray" | "green" | "red" | "purple" | "orange"; icon: PrVisualIcon; label: string; actions: PrAction[]; } +/** + * The PR's live Trunk merge-queue state, derived from the `Trunk Merge Queue` + * check-run status/conclusion. `null` means the PR is not (or no longer) in the + * queue — a completed+success run resolves to null because the PR flips to + * merged moments later, and a cancelled run just returns to the plain Open badge. + */ +export type MergeQueueVisualState = "queued" | "testing" | "failed" | null; + +export function deriveMergeQueueState( + status: "queued" | "in_progress" | "completed" | null | undefined, + conclusion: string | null | undefined, +): MergeQueueVisualState { + switch (status) { + case "queued": + return "queued"; + case "in_progress": + return "testing"; + case "completed": + return conclusion === "failure" || conclusion === "timed_out" + ? "failed" + : null; + default: + return null; + } +} + export function getPrVisualConfig( state: string, merged: boolean, draft: boolean, + mergeQueue: MergeQueueVisualState = null, ): PrVisualConfig { if (merged) { return { @@ -46,11 +73,31 @@ export function getPrVisualConfig( ], }; } + if (mergeQueue === "queued" || mergeQueue === "testing") { + return { + color: "orange", + icon: "queue", + label: mergeQueue === "queued" ? "Queued" : "Testing", + actions: [{ id: "merge-queue-cancel", label: "Cancel queue run" }], + }; + } + if (mergeQueue === "failed") { + return { + color: "red", + icon: "pull-request", + label: "Queue failed", + actions: [ + { id: "merge-queue", label: "Retry merge via queue" }, + { id: "close", label: "Close PR" }, + ], + }; + } return { color: "green", icon: "pull-request", label: "Open", actions: [ + { id: "merge-queue", label: "Merge via queue" }, { id: "draft", label: "Convert to draft" }, { id: "close", label: "Close PR" }, ], @@ -67,6 +114,11 @@ export function getOptimisticPrState(action: PrActionType) { return { state: "open", merged: false, draft: false }; case "draft": return { state: "open", merged: false, draft: true }; + // Merge-queue actions don't change the PR's own lifecycle state — the queue + // status is patched on a separate cache. The PR stays open until Trunk merges it. + case "merge-queue": + case "merge-queue-cancel": + return { state: "open", merged: false, draft: false }; } } @@ -75,6 +127,8 @@ export const PR_ACTION_LABELS: Record = { reopen: "PR reopened", ready: "PR marked as ready for review", draft: "PR converted to draft", + "merge-queue": "PR submitted to merge queue", + "merge-queue-cancel": "Merge queue run cancelled", }; export function parsePrNumber(prUrl: string): string | undefined { diff --git a/packages/core/src/git/router-schemas.ts b/packages/core/src/git/router-schemas.ts index 54c94a490e..0aaf09b99d 100644 --- a/packages/core/src/git/router-schemas.ts +++ b/packages/core/src/git/router-schemas.ts @@ -387,6 +387,31 @@ export const getPrDetailsByUrlOutput = z.object({ }); export type PrDetailsByUrlOutput = z.infer; +// getPrMergeQueueStatus schemas. Mirrors `prMergeQueueStatusSchema` in +// `@posthog/workspace-server`'s git schemas. Provider-agnostic; null = PR not +// in any merge queue. +export const prMergeQueueStatusSchema = z.object({ + status: z.enum(["queued", "in_progress", "completed"]), + conclusion: z + .enum([ + "success", + "failure", + "cancelled", + "neutral", + "skipped", + "timed_out", + "action_required", + "stale", + ]) + .nullable(), + detailsUrl: z.string().nullable(), + name: z.string(), +}); +export type PrMergeQueueStatus = z.infer; + +export const getPrMergeQueueStatusInput = z.object({ prUrl: z.string() }); +export const getPrMergeQueueStatusOutput = prMergeQueueStatusSchema.nullable(); + export { prActionTypeSchema, prReviewCommentSchema, diff --git a/packages/host-router/src/routers/git.router.ts b/packages/host-router/src/routers/git.router.ts index 79ffa22dfb..d5d086785f 100644 --- a/packages/host-router/src/routers/git.router.ts +++ b/packages/host-router/src/routers/git.router.ts @@ -70,6 +70,8 @@ import { getPrDiffStatsBatchOutput, getPrInfoByUrlInput, getPrInfoByUrlOutput, + getPrMergeQueueStatusInput, + getPrMergeQueueStatusOutput, getPrReviewCommentsInput, getPrReviewCommentsOutput, getPrTemplateInput, @@ -529,6 +531,15 @@ export const gitRouter = router({ ); }), + getPrMergeQueueStatus: publicProcedure + .input(getPrMergeQueueStatusInput) + .output(getPrMergeQueueStatusOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getPrMergeQueueStatus.query({ + prUrl: input.prUrl, + }), + ), + updatePrByUrl: publicProcedure .input(updatePrByUrlInput) .output(updatePrByUrlOutput) diff --git a/packages/shared/src/git-domain.ts b/packages/shared/src/git-domain.ts index 24cb09add1..13d7edbe53 100644 --- a/packages/shared/src/git-domain.ts +++ b/packages/shared/src/git-domain.ts @@ -64,8 +64,17 @@ export type GitHubIssue = GithubRef; export type GithubPullRequest = GithubRef; // PR action intent. Shared between the git host service (updatePrByUrl) and the -// git-interaction UI (PR status menu actions). -export const prActionTypeSchema = z.enum(["close", "reopen", "ready", "draft"]); +// git-interaction UI (PR status menu actions). `merge-queue` submits the PR to +// the Trunk merge queue (posts a `/trunk merge` comment) and `merge-queue-cancel` +// pulls it back out (`/trunk cancel`). +export const prActionTypeSchema = z.enum([ + "close", + "reopen", + "ready", + "draft", + "merge-queue", + "merge-queue-cancel", +]); export type PrActionType = z.infer; // Native PR review schemas (PR overview, approve/merge, CI checks, diff --git a/packages/ui/src/features/git-interaction/components/PRBadgeLink.tsx b/packages/ui/src/features/git-interaction/components/PRBadgeLink.tsx index 9893e21a67..523a0ac764 100644 --- a/packages/ui/src/features/git-interaction/components/PRBadgeLink.tsx +++ b/packages/ui/src/features/git-interaction/components/PRBadgeLink.tsx @@ -1,5 +1,6 @@ import { getPrVisualConfig, + type MergeQueueVisualState, type PrVisualConfig, parsePrNumber, } from "@posthog/core/git-interaction/prStatus"; @@ -11,6 +12,8 @@ interface PRBadgeLinkProps { prState: string; merged: boolean; draft: boolean; + /** Live Trunk merge-queue state; drives the queued/testing/failed badge. */ + mergeQueueState?: MergeQueueVisualState; isPrPending?: boolean; /** * When true, flatten the right edge so a dropdown trigger button can sit @@ -30,6 +33,7 @@ const COMPACT_COLOR_CLASSES: Record = { green: "bg-(--green-3) text-(--green-11) hover:bg-(--green-4)", red: "bg-(--red-3) text-(--red-11) hover:bg-(--red-4)", purple: "bg-(--purple-3) text-(--purple-11) hover:bg-(--purple-4)", + orange: "bg-(--orange-3) text-(--orange-11) hover:bg-(--orange-4)", }; /** @@ -43,13 +47,16 @@ export function PRBadgeLink({ prState, merged, draft, + mergeQueueState = null, isPrPending = false, attachedRight = false, compact = false, }: PRBadgeLinkProps) { - const config = getPrVisualConfig(prState, merged, draft); + const config = getPrVisualConfig(prState, merged, draft, mergeQueueState); const PrIcon = getPrVisualIcon(config.icon); const prNumber = parsePrNumber(prUrl); + // Spin while the queue is actively testing, in addition to any pending action. + const showSpinner = isPrPending || mergeQueueState === "testing"; if (compact) { return ( @@ -60,7 +67,7 @@ export function PRBadgeLink({ onClick={(e) => e.stopPropagation()} className={`inline-flex items-center gap-0.5 rounded px-1 py-0.5 text-[10px] no-underline ${COMPACT_COLOR_CLASSES[config.color]}`} > - {isPrPending ? ( + {showSpinner ? ( ) : ( @@ -88,7 +95,7 @@ export function PRBadgeLink({ onClick={(e) => e.stopPropagation()} > - {isPrPending ? ( + {showSpinner ? ( ) : ( diff --git a/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx b/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx index 945bdc605d..1594bef574 100644 --- a/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx +++ b/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx @@ -8,7 +8,10 @@ import { GitFork, GitPullRequest, } from "@phosphor-icons/react"; -import { getPrVisualConfig } from "@posthog/core/git-interaction/prStatus"; +import { + getPrVisualConfig, + type MergeQueueVisualState, +} from "@posthog/core/git-interaction/prStatus"; import { ButtonGroup, DropdownMenuContent, @@ -30,6 +33,7 @@ import { type GitMenuActionId, useGitInteraction, } from "../useGitInteraction"; +import { useMergeQueueStatus } from "../useMergeQueueStatus"; import { usePrActions } from "../usePrActions"; import { usePrDetails } from "../usePrDetails"; import { useTaskPrUrl } from "../useTaskPrUrl"; @@ -84,6 +88,9 @@ export function TaskActionsMenu({ taskId, isCloud }: TaskActionsMenuProps) { } = usePrDetails(prUrl); const { execute: executePrAction, isPending: isPrActionPending } = usePrActions(prUrl); + const { mergeQueueState } = useMergeQueueStatus(prUrl, { + enabled: prState === "open" && !merged, + }); const pr = prUrl && prState !== null ? { url: prUrl, state: prState } : null; @@ -109,6 +116,7 @@ export function TaskActionsMenu({ taskId, isCloud }: TaskActionsMenuProps) { prState={pr.state} merged={merged} draft={draft} + mergeQueueState={mergeQueueState} branchName={headRefName} isPrPending={isPrActionPending} gitItems={gitItems} @@ -211,6 +219,7 @@ interface PrBadgeControlProps { prState: string; merged: boolean; draft: boolean; + mergeQueueState: MergeQueueVisualState; branchName: string | null; isPrPending: boolean; gitItems: GitMenuAction[]; @@ -223,13 +232,14 @@ function PrBadgeControl({ prState, merged, draft, + mergeQueueState, branchName, isPrPending, gitItems, onGitSelect, onPrSelect, }: PrBadgeControlProps) { - const config = getPrVisualConfig(prState, merged, draft); + const config = getPrVisualConfig(prState, merged, draft, mergeQueueState); const lifecycleItems = config.actions; const hasMenuItems = gitItems.length + lifecycleItems.length > 0; const hasDropdown = hasMenuItems || !!branchName; @@ -251,6 +261,7 @@ function PrBadgeControl({ prState={prState} merged={merged} draft={draft} + mergeQueueState={mergeQueueState} isPrPending={isPrPending} attachedRight={hasDropdown} /> diff --git a/packages/ui/src/features/git-interaction/prIcon.tsx b/packages/ui/src/features/git-interaction/prIcon.tsx index 1e18cc4311..de0bb2261f 100644 --- a/packages/ui/src/features/git-interaction/prIcon.tsx +++ b/packages/ui/src/features/git-interaction/prIcon.tsx @@ -4,6 +4,7 @@ import { GitPullRequest, type Icon, PencilSimple, + Queue, X, } from "@phosphor-icons/react"; import type { PrVisualIcon } from "@posthog/core/git-interaction/prStatus"; @@ -15,6 +16,8 @@ export function getPrVisualIcon(icon: PrVisualIcon): Icon { return GitMerge; case "pull-request": return GitPullRequest; + case "queue": + return Queue; } } @@ -28,5 +31,9 @@ export function getPrActionIcon(action: PrActionType): React.ReactNode { return ; case "draft": return ; + case "merge-queue": + return ; + case "merge-queue-cancel": + return ; } } diff --git a/packages/ui/src/features/git-interaction/useMergeQueueStatus.ts b/packages/ui/src/features/git-interaction/useMergeQueueStatus.ts new file mode 100644 index 0000000000..b85f06d763 --- /dev/null +++ b/packages/ui/src/features/git-interaction/useMergeQueueStatus.ts @@ -0,0 +1,67 @@ +import { deriveMergeQueueState } from "@posthog/core/git-interaction/prStatus"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; + +interface UseMergeQueueStatusOptions { + /** Only poll when the PR could be in the queue (open and not yet merged). */ + enabled: boolean; +} + +/** + * Reads the PR's merge-queue status (whichever queue the repo uses — resolved + * provider-agnostically server-side) and polls every 30s while it is actively + * queued/testing. When the run settles (completes), it invalidates the + * PR-details and task-PR-status caches so the badge flips to Merged and the + * server re-emits `taskPrInfoChanged` (which the workspace-events contribution + * turns into the "PR merged" notification). + */ +export function useMergeQueueStatus( + prUrl: string | null, + { enabled }: UseMergeQueueStatusOptions, +) { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + + const query = useQuery({ + ...trpc.git.getPrMergeQueueStatus.queryOptions({ prUrl: prUrl as string }), + enabled: !!prUrl && enabled, + staleTime: 25_000, + retry: 1, + refetchInterval: (q) => { + const s = deriveMergeQueueState( + q.state.data?.status, + q.state.data?.conclusion, + ); + return s === "queued" || s === "testing" ? 30_000 : false; + }, + }); + + const mergeQueueState = deriveMergeQueueState( + query.data?.status, + query.data?.conclusion, + ); + + // When the run leaves an active state (queued/testing -> settled), the PR is + // about to merge or has dropped out. Refresh the PR lifecycle + task PR status + // so the merged badge and merge notification land promptly. + const wasActive = useRef(false); + useEffect(() => { + const active = + mergeQueueState === "queued" || mergeQueueState === "testing"; + if (wasActive.current && !active) { + void queryClient.invalidateQueries( + trpc.git.getPrDetailsByUrl.pathFilter(), + ); + void queryClient.invalidateQueries( + trpc.workspace.getTaskPrStatus.pathFilter(), + ); + } + wasActive.current = active; + }, [mergeQueueState, queryClient, trpc]); + + return { + mergeQueueState, + detailsUrl: query.data?.detailsUrl ?? null, + }; +} diff --git a/packages/ui/src/features/git-interaction/usePrActions.ts b/packages/ui/src/features/git-interaction/usePrActions.ts index 2b2932dc35..95e7d5f58c 100644 --- a/packages/ui/src/features/git-interaction/usePrActions.ts +++ b/packages/ui/src/features/git-interaction/usePrActions.ts @@ -33,6 +33,38 @@ export function usePrActions(prUrl: string | null) { void queryClient.invalidateQueries( trpc.git.getPrDiffStatsBatch.pathFilter(), ); + + // Merge-queue submit/cancel change queue status, not the PR lifecycle. + // Patch the queue-status cache optimistically so the badge flips + // immediately, then invalidate so the next poll reconciles with Trunk. + if (variables.action === "merge-queue") { + queryClient.setQueryData( + trpc.git.getPrMergeQueueStatus.queryKey({ + prUrl: variables.prUrl, + }), + () => ({ + status: "queued" as const, + conclusion: null, + detailsUrl: null, + name: "Trunk Merge Queue", + }), + ); + } else if (variables.action === "merge-queue-cancel") { + queryClient.setQueryData( + trpc.git.getPrMergeQueueStatus.queryKey({ + prUrl: variables.prUrl, + }), + () => null, + ); + } + if ( + variables.action === "merge-queue" || + variables.action === "merge-queue-cancel" + ) { + void queryClient.invalidateQueries( + trpc.git.getPrMergeQueueStatus.pathFilter(), + ); + } } else { toast.error("Failed to update PR", { description: data.message }); } diff --git a/packages/ui/src/features/workspace/workspace-events.contribution.test.ts b/packages/ui/src/features/workspace/workspace-events.contribution.test.ts index f0df80bd9b..e5fd8bd333 100644 --- a/packages/ui/src/features/workspace/workspace-events.contribution.test.ts +++ b/packages/ui/src/features/workspace/workspace-events.contribution.test.ts @@ -1,6 +1,7 @@ import type { HostTrpcClient } from "@posthog/host-router/client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ImperativeQueryClient } from "../../shell/queryClient"; +import type { NotificationBus } from "../notifications/notifications"; const invalidateQueries = vi.hoisted(() => vi.fn()); const setQueriesData = vi.hoisted(() => vi.fn()); @@ -11,6 +12,9 @@ const queryClient = { setQueryData, } as unknown as ImperativeQueryClient; +const notify = vi.hoisted(() => vi.fn()); +const notificationBus = { notify } as unknown as NotificationBus; + const toast = vi.hoisted(() => ({ error: vi.fn(), info: vi.fn() })); vi.mock("../../primitives/toast", () => ({ toast })); @@ -48,6 +52,7 @@ describe("WorkspaceEventsContribution", () => { new WorkspaceEventsContribution( client as unknown as HostTrpcClient, queryClient, + notificationBus, ).start(); expect(Object.keys(client.handlers).sort()).toEqual([ "onBranchChanged", @@ -63,6 +68,7 @@ describe("WorkspaceEventsContribution", () => { new WorkspaceEventsContribution( client as unknown as HostTrpcClient, queryClient, + notificationBus, ).start(); client.handlers.onPromoted({ fromBranch: "feat/x" }); expect(invalidateQueries).toHaveBeenCalledWith({ @@ -76,6 +82,7 @@ describe("WorkspaceEventsContribution", () => { new WorkspaceEventsContribution( client as unknown as HostTrpcClient, queryClient, + notificationBus, ).start(); client.handlers.onError({ message: "boom" }); expect(toast.error).toHaveBeenCalledWith("Workspace error", { @@ -89,6 +96,7 @@ describe("WorkspaceEventsContribution", () => { new WorkspaceEventsContribution( client as unknown as HostTrpcClient, queryClient, + notificationBus, ).start(); client.handlers.onBranchChanged(undefined); expect(invalidateQueries).toHaveBeenCalledWith({ @@ -101,6 +109,7 @@ describe("WorkspaceEventsContribution", () => { new WorkspaceEventsContribution( client as unknown as HostTrpcClient, queryClient, + notificationBus, ).start(); client.handlers.onTaskPrInfoChanged({ taskId: "task-1", @@ -141,4 +150,38 @@ describe("WorkspaceEventsContribution", () => { { prUrl: "https://github.com/o/r/pull/1" }, ); }); + + it("notifies when a PR transitions to merged", () => { + const client = makeClient(); + new WorkspaceEventsContribution( + client as unknown as HostTrpcClient, + queryClient, + notificationBus, + ).start(); + client.handlers.onTaskPrInfoChanged({ + taskId: "task-1", + prUrl: "https://github.com/o/r/pull/1", + prState: "merged", + }); + expect(notify).toHaveBeenCalledWith({ + body: "Pull request merged", + target: { kind: "task", taskId: "task-1" }, + toast: { level: "success" }, + }); + }); + + it("does not notify for non-merged PR states", () => { + const client = makeClient(); + new WorkspaceEventsContribution( + client as unknown as HostTrpcClient, + queryClient, + notificationBus, + ).start(); + client.handlers.onTaskPrInfoChanged({ + taskId: "task-1", + prUrl: "https://github.com/o/r/pull/1", + prState: "open", + }); + expect(notify).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/features/workspace/workspace-events.contribution.ts b/packages/ui/src/features/workspace/workspace-events.contribution.ts index de4280d022..8714d27063 100644 --- a/packages/ui/src/features/workspace/workspace-events.contribution.ts +++ b/packages/ui/src/features/workspace/workspace-events.contribution.ts @@ -11,6 +11,7 @@ import { IMPERATIVE_QUERY_CLIENT, type ImperativeQueryClient, } from "../../shell/queryClient"; +import { NotificationBus } from "../notifications/notifications"; import { WORKSPACE_QUERY_KEY } from "./identifiers"; /** @@ -27,6 +28,8 @@ export class WorkspaceEventsContribution implements Contribution { private readonly hostClient: HostTrpcClient, @inject(IMPERATIVE_QUERY_CLIENT) private readonly queryClient: ImperativeQueryClient, + @inject(NotificationBus) + private readonly notificationBus: NotificationBus, ) {} start(): void { @@ -85,6 +88,17 @@ export class WorkspaceEventsContribution implements Contribution { options.workspace.getCachedPrUrl.queryKey({ taskId }), { prUrl }, ); + + // The server emits this event only on a real state change, so a + // `merged` transition fires the notification exactly once. The bus + // decides suppress / toast / native based on whether the task is in view. + if (prState === "merged") { + this.notificationBus.notify({ + body: "Pull request merged", + target: { kind: "task", taskId }, + toast: { level: "success" }, + }); + } }, }); } diff --git a/packages/workspace-server/src/services/git/merge-queue-providers.test.ts b/packages/workspace-server/src/services/git/merge-queue-providers.test.ts new file mode 100644 index 0000000000..a3e6da73ab --- /dev/null +++ b/packages/workspace-server/src/services/git/merge-queue-providers.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + type CheckRun, + mapNativeMergeQueueState, + resolveMergeQueueFromCheckRuns, +} from "./merge-queue-providers"; + +const run = (partial: Partial & { name: string }): CheckRun => ({ + status: "queued", + conclusion: null, + ...partial, +}); + +describe("resolveMergeQueueFromCheckRuns", () => { + it("returns null when there are no check runs", () => { + expect(resolveMergeQueueFromCheckRuns([])).toBeNull(); + }); + + it("returns null when no known provider check is present", () => { + expect( + resolveMergeQueueFromCheckRuns([ + run({ name: "build", status: "completed", conclusion: "success" }), + ]), + ).toBeNull(); + }); + + it.each([ + { provider: "trunk", name: "Trunk Merge Queue (main)" }, + { provider: "mergify", name: "Queue: embarked in merge train" }, + { provider: "mergify", name: "Mergify — automatic merge" }, + { provider: "aviator", name: "aviator/queue" }, + { provider: "kodiak", name: "kodiakhq: status" }, + { provider: "graphite", name: "Graphite / mergeability_check" }, + { provider: "bors", name: "bors" }, + ])("matches the $provider queue check ($name)", ({ name }) => { + const result = resolveMergeQueueFromCheckRuns([ + run({ name, status: "in_progress" }), + ]); + expect(result).toEqual({ + status: "in_progress", + conclusion: null, + detailsUrl: null, + name, + }); + }); + + it("maps a completed+failure run", () => { + const result = resolveMergeQueueFromCheckRuns([ + run({ + name: "Trunk Merge Queue (main)", + status: "completed", + conclusion: "failure", + }), + ]); + expect(result?.status).toBe("completed"); + expect(result?.conclusion).toBe("failure"); + }); + + it("picks the most recently started run across re-enqueues", () => { + const result = resolveMergeQueueFromCheckRuns([ + run({ + name: "Trunk Merge Queue (main)", + status: "completed", + conclusion: "failure", + started_at: "2026-01-01T00:00:00Z", + }), + run({ + name: "Trunk Merge Queue (main)", + status: "queued", + started_at: "2026-01-02T00:00:00Z", + }), + ]); + expect(result?.status).toBe("queued"); + }); + + it("falls back to html_url when details_url is absent", () => { + const result = resolveMergeQueueFromCheckRuns([ + run({ + name: "Trunk Merge Queue (main)", + details_url: null, + html_url: "https://github.com/o/r/runs/1", + }), + ]); + expect(result?.detailsUrl).toBe("https://github.com/o/r/runs/1"); + }); + + it("ignores a run whose status is not a queue status", () => { + expect( + resolveMergeQueueFromCheckRuns([ + run({ name: "Trunk Merge Queue (main)", status: "waiting" }), + ]), + ).toBeNull(); + }); +}); + +describe("mapNativeMergeQueueState", () => { + it.each([ + ["QUEUED", "queued", null], + ["AWAITING_CHECKS", "in_progress", null], + ["LOCKED", "in_progress", null], + ["MERGEABLE", "in_progress", null], + ["UNMERGEABLE", "completed", "failure"], + ] as const)("maps %s -> %s", (state, status, conclusion) => { + expect(mapNativeMergeQueueState(state)).toEqual({ + status, + conclusion, + detailsUrl: null, + name: "GitHub merge queue", + }); + }); + + it.each([null, undefined, "SOMETHING_NEW"])( + "returns null for %s", + (state) => { + expect(mapNativeMergeQueueState(state)).toBeNull(); + }, + ); +}); diff --git a/packages/workspace-server/src/services/git/merge-queue-providers.ts b/packages/workspace-server/src/services/git/merge-queue-providers.ts new file mode 100644 index 0000000000..203513ab98 --- /dev/null +++ b/packages/workspace-server/src/services/git/merge-queue-providers.ts @@ -0,0 +1,119 @@ +import { type PrMergeQueueStatus, prMergeQueueStatusSchema } from "./schemas"; + +/** + * A GitHub check-run as returned by the REST `commits/{sha}/check-runs` + * endpoint (the subset we read). Kept loose so callers can pass the raw JSON. + */ +export interface CheckRun { + name: string; + status: string; + conclusion?: string | null; + details_url?: string | null; + html_url?: string | null; + started_at?: string | null; +} + +/** + * A merge-queue provider that surfaces its state as a GitHub check run on the + * PR head commit (Trunk, Mergify, bors, Aviator, Kodiak, Graphite, ...). We + * identify the provider by the check-run name so the badge works on any repo + * with zero configuration. GitHub's *native* merge queue posts no named check + * run and is handled separately via the GraphQL `mergeQueueEntry` field + * (see `mapNativeMergeQueueState`). + * + * Matching is best-effort by design: the check-run names below are the public, + * observable names each provider posts today. Add or refine a `matches` entry + * as new queues show up — nothing else in the pipeline is provider-aware. + */ +export interface CheckRunMergeQueueProvider { + id: string; + matches: (checkRunName: string) => boolean; +} + +const startsWith = (prefix: string) => (name: string) => + name.toLowerCase().startsWith(prefix.toLowerCase()); + +const includes = (needle: string) => (name: string) => + name.toLowerCase().includes(needle.toLowerCase()); + +// Ordered by specificity; the first provider with a matching run wins. Trunk is +// first so its behavior is unchanged from the original single-provider code. +export const CHECK_RUN_MERGE_QUEUE_PROVIDERS: CheckRunMergeQueueProvider[] = [ + { id: "trunk", matches: startsWith("Trunk Merge Queue") }, + { + id: "mergify", + matches: (name) => startsWith("Queue:")(name) || includes("mergify")(name), + }, + { id: "aviator", matches: includes("aviator") }, + { id: "kodiak", matches: startsWith("kodiakhq") }, + { id: "graphite", matches: includes("graphite") }, + { id: "bors", matches: startsWith("bors") }, +]; + +function toStatus( + run: CheckRun, + providerName: string, +): PrMergeQueueStatus | null { + const status = prMergeQueueStatusSchema.shape.status.safeParse(run.status); + if (!status.success) return null; + const conclusion = prMergeQueueStatusSchema.shape.conclusion.safeParse( + run.conclusion, + ); + return { + status: status.data, + conclusion: conclusion.success ? conclusion.data : null, + detailsUrl: run.details_url ?? run.html_url ?? null, + name: run.name || providerName, + }; +} + +/** + * Resolve the live merge-queue status from a PR head commit's check runs by + * matching the first known provider. Returns null when no provider's queue + * check is present. Pure — the service supplies the fetched check runs. + */ +export function resolveMergeQueueFromCheckRuns( + checkRuns: CheckRun[], +): PrMergeQueueStatus | null { + for (const provider of CHECK_RUN_MERGE_QUEUE_PROVIDERS) { + const runs = checkRuns.filter((run) => provider.matches(run.name)); + if (runs.length === 0) continue; + + // A PR can accumulate stale runs across re-enqueues; take the most recently + // started one as the live status. + const latest = runs.reduce((a, b) => + (b.started_at ?? "") > (a.started_at ?? "") ? b : a, + ); + + const mapped = toStatus(latest, provider.id); + if (mapped) return mapped; + } + return null; +} + +/** + * Map GitHub's native merge-queue `PullRequest.mergeQueueEntry.state` to the + * shared status shape. Native queue posts no named check run, so this reads the + * GraphQL enum instead. Null when the PR has no queue entry. + * + * @see https://docs.github.com/en/graphql/reference/enums#mergequeueentrystate + */ +export function mapNativeMergeQueueState( + state: string | null | undefined, +): PrMergeQueueStatus | null { + const base = { detailsUrl: null, name: "GitHub merge queue" } as const; + switch (state) { + case "QUEUED": + return { status: "queued", conclusion: null, ...base }; + // Awaiting/locked/mergeable all mean "at the front, checks running or about + // to merge" — surface as the testing state until the PR flips to merged. + case "AWAITING_CHECKS": + case "LOCKED": + case "MERGEABLE": + return { status: "in_progress", conclusion: null, ...base }; + case "UNMERGEABLE": + return { status: "completed", conclusion: "failure", ...base }; + default: + return null; + } +} diff --git a/packages/workspace-server/src/services/git/schemas.ts b/packages/workspace-server/src/services/git/schemas.ts index 66941df7eb..d0ab9cb5e1 100644 --- a/packages/workspace-server/src/services/git/schemas.ts +++ b/packages/workspace-server/src/services/git/schemas.ts @@ -413,7 +413,14 @@ export const replyToPrCommentOutput = z.object({ export type ReplyToPrCommentOutput = z.infer; -export const prActionType = z.enum(["close", "reopen", "ready", "draft"]); +export const prActionType = z.enum([ + "close", + "reopen", + "ready", + "draft", + "merge-queue", + "merge-queue-cancel", +]); export type PrActionType = z.infer; export const updatePrByUrlInput = z.object({ @@ -459,6 +466,33 @@ export { prMergeMethodSchema, } from "@posthog/shared"; +// Provider-agnostic merge-queue status for a PR, modelled on the GitHub +// check-run shape most queues expose (Trunk, Mergify, bors, ...) and mapped +// from GitHub's native `mergeQueueEntry` state where there is no check run. +// Null when the PR is not in any queue. Mirrors `prMergeQueueStatusSchema` in +// `@posthog/core/git/router-schemas`. +export const prMergeQueueStatusSchema = z.object({ + status: z.enum(["queued", "in_progress", "completed"]), + conclusion: z + .enum([ + "success", + "failure", + "cancelled", + "neutral", + "skipped", + "timed_out", + "action_required", + "stale", + ]) + .nullable(), + detailsUrl: z.string().nullable(), + name: z.string(), +}); +export type PrMergeQueueStatus = z.infer; + +export const getPrMergeQueueStatusInput = z.object({ prUrl: z.string() }); +export const getPrMergeQueueStatusOutput = prMergeQueueStatusSchema.nullable(); + export const getPrTemplateInput = directoryPathInput; export const getPrTemplateOutput = z.object({ diff --git a/packages/workspace-server/src/services/git/service.merge-queue.test.ts b/packages/workspace-server/src/services/git/service.merge-queue.test.ts new file mode 100644 index 0000000000..b6cbacfb9d --- /dev/null +++ b/packages/workspace-server/src/services/git/service.merge-queue.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const execGh = vi.hoisted(() => vi.fn()); +vi.mock("@posthog/git/gh", () => ({ execGh })); + +import { GitService } from "./service"; + +const PR_URL = "https://github.com/o/r/pull/1"; +const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 }); +const fail = (stderr = "boom") => ({ stdout: "", stderr, exitCode: 1 }); + +function checkRunsResponse( + runs: Array<{ + name: string; + status: string; + conclusion?: string | null; + details_url?: string | null; + html_url?: string | null; + started_at?: string | null; + }>, +) { + return JSON.stringify({ check_runs: runs }); +} + +function mergeQueueEntryResponse(state: string | null) { + return JSON.stringify({ + data: { + repository: { + pullRequest: { mergeQueueEntry: state ? { state } : null }, + }, + }, + }); +} + +describe("GitService.getPrMergeQueueStatus", () => { + let git: GitService; + beforeEach(() => { + execGh.mockReset(); + git = new GitService(); + }); + + it("returns the Trunk check run mapped to the schema", async () => { + execGh + .mockResolvedValueOnce(ok("abc123\n")) // head sha + .mockResolvedValueOnce( + ok( + checkRunsResponse([ + { name: "build", status: "completed", conclusion: "success" }, + { + name: "Trunk Merge Queue (main)", + status: "in_progress", + conclusion: null, + details_url: "https://app.trunk.io/x", + started_at: "2026-01-01T00:00:00Z", + }, + ]), + ), + ); + + const result = await git.getPrMergeQueueStatus(PR_URL); + expect(result).toEqual({ + status: "in_progress", + conclusion: null, + detailsUrl: "https://app.trunk.io/x", + name: "Trunk Merge Queue (main)", + }); + + // First call resolves the head sha; second reads that commit's check runs. + expect(execGh.mock.calls[0][0]).toEqual([ + "api", + "repos/o/r/pulls/1", + "--jq", + ".head.sha", + ]); + expect(execGh.mock.calls[1][0]).toEqual([ + "api", + "repos/o/r/commits/abc123/check-runs?per_page=100", + ]); + }); + + it("returns null when no queue check matches and there is no native entry", async () => { + execGh + .mockResolvedValueOnce(ok("abc123")) + .mockResolvedValueOnce( + ok(checkRunsResponse([{ name: "build", status: "completed" }])), + ) + // No named queue check -> falls back to the native merge-queue query. + .mockResolvedValueOnce(ok(mergeQueueEntryResponse(null))); + expect(await git.getPrMergeQueueStatus(PR_URL)).toBeNull(); + }); + + it("reads GitHub's native merge queue via GraphQL when no check matches", async () => { + execGh + .mockResolvedValueOnce(ok("abc123")) + .mockResolvedValueOnce( + ok(checkRunsResponse([{ name: "build", status: "completed" }])), + ) + .mockResolvedValueOnce(ok(mergeQueueEntryResponse("QUEUED"))); + + const result = await git.getPrMergeQueueStatus(PR_URL); + expect(result).toEqual({ + status: "queued", + conclusion: null, + detailsUrl: null, + name: "GitHub merge queue", + }); + + // Third call is the GraphQL mergeQueueEntry lookup for the PR. + const graphqlArgs = execGh.mock.calls[2][0] as string[]; + expect(graphqlArgs[0]).toBe("api"); + expect(graphqlArgs[1]).toBe("graphql"); + expect(graphqlArgs[3]).toContain("mergeQueueEntry"); + expect(graphqlArgs[3]).toContain('owner: "o"'); + expect(graphqlArgs[3]).toContain('name: "r"'); + expect(graphqlArgs[3]).toContain("pullRequest(number: 1)"); + }); + + it("matches a non-Trunk provider check (Mergify) without a native call", async () => { + execGh.mockResolvedValueOnce(ok("abc123")).mockResolvedValueOnce( + ok( + checkRunsResponse([ + { + name: "Queue: embarked in merge train", + status: "in_progress", + conclusion: null, + started_at: "2026-01-01T00:00:00Z", + }, + ]), + ), + ); + const result = await git.getPrMergeQueueStatus(PR_URL); + expect(result?.status).toBe("in_progress"); + // No GraphQL fallback needed once a check-run provider matches. + expect(execGh).toHaveBeenCalledTimes(2); + }); + + it("picks the most recently started Trunk run", async () => { + execGh.mockResolvedValueOnce(ok("abc123")).mockResolvedValueOnce( + ok( + checkRunsResponse([ + { + name: "Trunk Merge Queue (main)", + status: "completed", + conclusion: "failure", + started_at: "2026-01-01T00:00:00Z", + }, + { + name: "Trunk Merge Queue (main)", + status: "queued", + conclusion: null, + started_at: "2026-01-02T00:00:00Z", + }, + ]), + ), + ); + const result = await git.getPrMergeQueueStatus(PR_URL); + expect(result?.status).toBe("queued"); + }); + + it("falls back to html_url when details_url is absent", async () => { + execGh.mockResolvedValueOnce(ok("abc123")).mockResolvedValueOnce( + ok( + checkRunsResponse([ + { + name: "Trunk Merge Queue (main)", + status: "queued", + conclusion: null, + details_url: null, + html_url: "https://github.com/o/r/runs/1", + started_at: "2026-01-01T00:00:00Z", + }, + ]), + ), + ); + const result = await git.getPrMergeQueueStatus(PR_URL); + expect(result?.detailsUrl).toBe("https://github.com/o/r/runs/1"); + }); + + it("returns null for a non-PR URL without calling gh", async () => { + expect( + await git.getPrMergeQueueStatus("https://github.com/o/r/issues/1"), + ).toBeNull(); + expect(execGh).not.toHaveBeenCalled(); + }); + + it("returns null when the sha lookup fails", async () => { + execGh.mockResolvedValueOnce(fail()); + expect(await git.getPrMergeQueueStatus(PR_URL)).toBeNull(); + }); + + it("returns null when the check-runs call fails", async () => { + execGh.mockResolvedValueOnce(ok("abc123")).mockResolvedValueOnce(fail()); + expect(await git.getPrMergeQueueStatus(PR_URL)).toBeNull(); + }); +}); + +describe("GitService.updatePrByUrl merge-queue actions", () => { + let git: GitService; + beforeEach(() => { + execGh.mockReset(); + git = new GitService(); + }); + + it("posts '/trunk merge' for the merge-queue action", async () => { + execGh.mockResolvedValueOnce(ok("commented")); + const result = await git.updatePrByUrl(PR_URL, "merge-queue"); + expect(result.success).toBe(true); + expect(execGh).toHaveBeenCalledWith([ + "pr", + "comment", + "1", + "--repo", + "o/r", + "--body", + "/trunk merge", + ]); + }); + + it("posts '/trunk cancel' for the merge-queue-cancel action", async () => { + execGh.mockResolvedValueOnce(ok("commented")); + await git.updatePrByUrl(PR_URL, "merge-queue-cancel"); + expect(execGh).toHaveBeenCalledWith([ + "pr", + "comment", + "1", + "--repo", + "o/r", + "--body", + "/trunk cancel", + ]); + }); + + it("surfaces a gh failure as an unsuccessful result", async () => { + execGh.mockResolvedValueOnce(fail("no write access")); + const result = await git.updatePrByUrl(PR_URL, "merge-queue"); + expect(result).toEqual({ success: false, message: "no write access" }); + }); + + it("still routes lifecycle actions through the gh pr subcommand", async () => { + execGh.mockResolvedValueOnce(ok("closed")); + await git.updatePrByUrl(PR_URL, "close"); + expect(execGh).toHaveBeenCalledWith(["pr", "close", "1", "--repo", "o/r"]); + }); +}); diff --git a/packages/workspace-server/src/services/git/service.ts b/packages/workspace-server/src/services/git/service.ts index 7fcfd86b02..c370625d69 100644 --- a/packages/workspace-server/src/services/git/service.ts +++ b/packages/workspace-server/src/services/git/service.ts @@ -47,6 +47,11 @@ import { parseGithubUrl } from "@posthog/git/utils"; import { TypedEventEmitter } from "@posthog/shared"; import { injectable } from "inversify"; import type { SidebarPrState } from "../workspace/schemas"; +import { + type CheckRun, + mapNativeMergeQueueState, + resolveMergeQueueFromCheckRuns, +} from "./merge-queue-providers"; import type { ApprovePrOutput, ChangedFile, @@ -79,6 +84,7 @@ import type { PrDiffStats, PrInfoByUrlOutput, PrMergeMethod, + PrMergeQueueStatus, PrReviewComment, PrReviewThread, PrStatusOutput, @@ -1015,6 +1021,80 @@ export class GitService extends TypedEventEmitter { } } + /** + * Read a PR's live merge-queue status, provider-agnostically. Most queues + * (Trunk, Mergify, bors, Aviator, Kodiak, Graphite, ...) post a check run on + * the PR head commit, matched by name in `merge-queue-providers`. GitHub's + * native merge queue posts no such check, so we fall back to its GraphQL + * `mergeQueueEntry` state. Returns null when the PR is not in any queue or the + * lookup fails — callers treat null as "not queued" and show the plain PR + * lifecycle badge. + */ + async getPrMergeQueueStatus( + prUrl: string, + ): Promise { + const pr = parseGithubUrl(prUrl); + if (pr?.kind !== "pr") return null; + + try { + const shaResult = await execGh([ + "api", + `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`, + "--jq", + ".head.sha", + ]); + if (shaResult.exitCode !== 0) return null; + const headSha = shaResult.stdout.trim(); + if (!headSha) return null; + + const runsResult = await execGh([ + "api", + `repos/${pr.owner}/${pr.repo}/commits/${headSha}/check-runs?per_page=100`, + ]); + if (runsResult.exitCode !== 0) return null; + + const { check_runs: checkRuns } = JSON.parse(runsResult.stdout) as { + check_runs?: CheckRun[]; + }; + + const fromCheckRun = resolveMergeQueueFromCheckRuns(checkRuns ?? []); + if (fromCheckRun) return fromCheckRun; + + // No named queue check — the repo may be on GitHub's native merge queue. + return await this.getNativeMergeQueueStatus(pr); + } catch { + return null; + } + } + + /** + * Read GitHub's native merge-queue state for a PR via the GraphQL + * `mergeQueueEntry` field. Null when the PR has no queue entry (or the repo + * doesn't use the native queue). Used only as a fallback after the check-run + * providers miss, so repos on Trunk/Mergify/etc. never pay for this call. + */ + private async getNativeMergeQueueStatus(pr: { + owner: string; + repo: string; + number: number; + }): Promise { + const query = `query { repository(owner: "${escapeGraphqlString(pr.owner)}", name: "${escapeGraphqlString(pr.repo)}") { pullRequest(number: ${pr.number}) { mergeQueueEntry { state } } } }`; + const result = await execGh(["api", "graphql", "-f", `query=${query}`]); + if (result.exitCode !== 0) return null; + + const state = ( + JSON.parse(result.stdout) as { + data?: { + repository?: { + pullRequest?: { mergeQueueEntry?: { state?: string } | null }; + }; + }; + } + ).data?.repository?.pullRequest?.mergeQueueEntry?.state; + + return mapNativeMergeQueueState(state); + } + async getPrChangedFiles(prUrl: string): Promise { const pr = parseGithubUrl(prUrl); if (pr?.kind !== "pr") return []; @@ -1306,6 +1386,30 @@ export class GitService extends TypedEventEmitter { return { success: false, message: "Invalid PR URL" }; } + // Enqueue/cancel currently targets Trunk's GitHub comment commands. Status + // reads (getPrMergeQueueStatus) are provider-agnostic, but submitting to a + // queue is provider-specific; only Trunk is wired up for now. + if (action === "merge-queue" || action === "merge-queue-cancel") { + const body = action === "merge-queue" ? "/trunk merge" : "/trunk cancel"; + const commentResult = await execGh([ + "pr", + "comment", + String(pr.number), + "--repo", + `${pr.owner}/${pr.repo}`, + "--body", + body, + ]); + if (commentResult.exitCode !== 0) { + return { + success: false, + message: + commentResult.stderr || commentResult.error || "Unknown error", + }; + } + return { success: true, message: commentResult.stdout }; + } + try { const args = action === "draft" diff --git a/packages/workspace-server/src/trpc.ts b/packages/workspace-server/src/trpc.ts index ed12f56a96..7bcf9cc596 100644 --- a/packages/workspace-server/src/trpc.ts +++ b/packages/workspace-server/src/trpc.ts @@ -91,6 +91,8 @@ import { getPrDiffStatsBatchOutput, getPrInfoByUrlInput, getPrInfoByUrlOutput, + getPrMergeQueueStatusInput, + getPrMergeQueueStatusOutput, getPrReviewCommentsInput, getPrReviewCommentsOutput, getPrTemplateInput, @@ -584,6 +586,11 @@ export function createAppRouter({ .output(getPrCommentsOutput) .query(({ input }) => gitService().getPrComments(input.prUrl)), + getPrMergeQueueStatus: t.procedure + .input(getPrMergeQueueStatusInput) + .output(getPrMergeQueueStatusOutput) + .query(({ input }) => gitService().getPrMergeQueueStatus(input.prUrl)), + getPrChangedFiles: t.procedure .input(getPrChangedFilesInput) .output(changedFilesOutput)