Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions packages/core/src/git-interaction/prStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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];
58 changes: 56 additions & 2 deletions packages/core/src/git-interaction/prStatus.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,50 @@
import type { PrActionType } from "@posthog/shared";

export type PrVisualIcon = "merged" | "pull-request";
export type PrVisualIcon = "merged" | "pull-request" | "queue";

export interface PrAction {
id: PrActionType;
label: string;
}

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 {
Expand Down Expand Up @@ -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" },
],
Expand All @@ -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 };
}
}

Expand All @@ -75,6 +127,8 @@ export const PR_ACTION_LABELS: Record<PrActionType, string> = {
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 {
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/git/router-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,30 @@ export const getPrDetailsByUrlOutput = z.object({
});
export type PrDetailsByUrlOutput = z.infer<typeof getPrDetailsByUrlOutput>;

// getPrMergeQueueStatus schemas. Mirrors `prMergeQueueStatusSchema` in
// `@posthog/workspace-server`'s git schemas. Null = PR not in the Trunk 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<typeof prMergeQueueStatusSchema>;

export const getPrMergeQueueStatusInput = z.object({ prUrl: z.string() });
export const getPrMergeQueueStatusOutput = prMergeQueueStatusSchema.nullable();

export {
prActionTypeSchema,
prReviewCommentSchema,
Expand Down
11 changes: 11 additions & 0 deletions packages/host-router/src/routers/git.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import {
getPrDetailsByUrlOutput,
getPrDiffStatsBatchInput,
getPrDiffStatsBatchOutput,
getPrMergeQueueStatusInput,
getPrMergeQueueStatusOutput,
getPrReviewCommentsInput,
getPrReviewCommentsOutput,
getPrTemplateInput,
Expand Down Expand Up @@ -519,6 +521,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)
Expand Down
13 changes: 11 additions & 2 deletions packages/shared/src/git-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ 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<typeof prActionTypeSchema>;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getPrVisualConfig,
type MergeQueueVisualState,
type PrVisualConfig,
parsePrNumber,
} from "@posthog/core/git-interaction/prStatus";
Expand All @@ -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
Expand All @@ -30,6 +33,7 @@ const COMPACT_COLOR_CLASSES: Record<PrVisualConfig["color"], string> = {
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)",
};

/**
Expand All @@ -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 (
Expand All @@ -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 ? (
<Spinner size="1" />
) : (
<PrIcon size={10} weight="bold" />
Expand Down Expand Up @@ -88,7 +95,7 @@ export function PRBadgeLink({
onClick={(e) => e.stopPropagation()}
>
<Flex align="center" gap="2">
{isPrPending ? (
{showSpinner ? (
<Spinner size="1" />
) : (
<PrIcon size={12} weight="bold" />
Expand Down
Loading
Loading