feat: service route branch handling, workspace hover card redesign, and agent form state improvements

- Add service-route-branch-handler for routing requests to branch-specific services
- Add service-hostname utility for generating hostnames from branch names
- Redesign workspace hover card with CI check status display
- Update combined model selector and sidebar workspace list
- Improve agent form state and input draft hooks
- Update checkout-git with enhanced branch handling
- Add workspace git watch and bootstrap improvements
This commit is contained in:
Mohamed Boudra
2026-04-06 14:38:29 +07:00
parent 5d1b3383d7
commit 6e5cad812e
19 changed files with 734 additions and 247 deletions
+16
View File
@@ -9,6 +9,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Logger } from "pino";
import { createBranchChangeRouteHandler } from "./service-route-branch-handler.js";
export type ListenTarget =
| { type: "tcp"; host: string; port: number }
@@ -244,6 +245,20 @@ export async function createPaseoDaemon(
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
}),
});
const handleBranchChange = createBranchChangeRouteHandler({
routeStore: serviceRouteStore,
emitServiceStatusUpdate: (workspaceId, services) => {
const message = {
type: "service_status_update" as const,
payload: { workspaceId, services },
};
const activeSessions = wsServer?.listActiveSessions() ?? [];
for (const session of activeSessions) {
session.emitServerMessage(message);
}
},
logger,
});
// Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation.
@@ -687,6 +702,7 @@ export async function createPaseoDaemon(
scheduleService,
checkoutDiffManager,
serviceRouteStore,
handleBranchChange,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
(hostname) => serviceHealthMonitor.getStatusForHostname(hostname),
);
@@ -0,0 +1,188 @@
import { describe, expect, it, vi } from "vitest";
import { ServiceRouteStore } from "./service-proxy.js";
import { createBranchChangeRouteHandler } from "./service-route-branch-handler.js";
function registerRoute(
routeStore: ServiceRouteStore,
{
hostname,
port,
workspaceId = "workspace-a",
serviceName,
}: {
hostname: string;
port: number;
workspaceId?: string;
serviceName: string;
},
): void {
routeStore.registerRoute({
hostname,
port,
workspaceId,
serviceName,
});
}
describe("service-route-branch-handler", () => {
it("updates routes on branch rename by removing old hostnames and registering new ones", () => {
const routeStore = new ServiceRouteStore();
registerRoute(routeStore, {
hostname: "feature-auth.api.localhost",
port: 3001,
serviceName: "api",
});
const emitServiceStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitServiceStatusUpdate,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.findRoute("feature-auth.api.localhost")).toBeNull();
expect(routeStore.findRoute("feature-billing.api.localhost")).toEqual({
hostname: "feature-billing.api.localhost",
port: 3001,
});
});
it("is a no-op when the workspace has no routes", () => {
const routeStore = new ServiceRouteStore();
const emitServiceStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitServiceStatusUpdate,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.listRoutes()).toEqual([]);
expect(emitServiceStatusUpdate).not.toHaveBeenCalled();
});
it("is a no-op when the resolved hostnames do not change", () => {
const routeStore = new ServiceRouteStore();
registerRoute(routeStore, {
hostname: "api.localhost",
port: 3001,
serviceName: "api",
});
const emitServiceStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitServiceStatusUpdate,
});
handleBranchChange("workspace-a", "main", "master");
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
},
]);
expect(emitServiceStatusUpdate).not.toHaveBeenCalled();
});
it("emits a status update with the refreshed route payload after a route change", () => {
const routeStore = new ServiceRouteStore();
registerRoute(routeStore, {
hostname: "feature-auth.api.localhost",
port: 3001,
serviceName: "api",
});
const emitServiceStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitServiceStatusUpdate,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(emitServiceStatusUpdate).toHaveBeenCalledWith("workspace-a", [
{
serviceName: "api",
hostname: "feature-billing.api.localhost",
port: 3001,
url: null,
status: "stopped",
},
]);
});
it("updates all services for a workspace when multiple routes are registered", () => {
const routeStore = new ServiceRouteStore();
registerRoute(routeStore, {
hostname: "feature-auth.api.localhost",
port: 3001,
serviceName: "api",
});
registerRoute(routeStore, {
hostname: "feature-auth.web.localhost",
port: 3002,
serviceName: "web",
});
registerRoute(routeStore, {
hostname: "docs.localhost",
port: 3003,
workspaceId: "workspace-b",
serviceName: "docs",
});
const emitServiceStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitServiceStatusUpdate,
});
handleBranchChange("workspace-a", "feature/auth", "feature/billing");
expect(routeStore.listRoutesForWorkspace("workspace-a")).toEqual([
{
hostname: "feature-billing.api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
},
{
hostname: "feature-billing.web.localhost",
port: 3002,
workspaceId: "workspace-a",
serviceName: "web",
},
]);
expect(routeStore.listRoutesForWorkspace("workspace-b")).toEqual([
{
hostname: "docs.localhost",
port: 3003,
workspaceId: "workspace-b",
serviceName: "docs",
},
]);
});
it("does not emit a status update when no changes are needed", () => {
const routeStore = new ServiceRouteStore();
registerRoute(routeStore, {
hostname: "web.localhost",
port: 3002,
serviceName: "web",
});
const emitServiceStatusUpdate = vi.fn();
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
emitServiceStatusUpdate,
});
handleBranchChange("workspace-a", null, "main");
expect(emitServiceStatusUpdate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,70 @@
import type { Logger } from "pino";
import type { WorkspaceServicePayload } from "../shared/messages.js";
import { buildServiceHostname } from "../utils/service-hostname.js";
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js";
interface BranchChangeRouteHandlerOptions {
routeStore: ServiceRouteStore;
emitServiceStatusUpdate: (
workspaceId: string,
services: WorkspaceServicePayload[],
) => void;
logger?: Logger;
}
interface RouteHostnameUpdate {
oldHostname: string;
newHostname: string;
route: ServiceRouteEntry;
}
export function createBranchChangeRouteHandler(
options: BranchChangeRouteHandlerOptions,
): (workspaceId: string, oldBranch: string | null, newBranch: string | null) => void {
return (workspaceId, _oldBranch, newBranch) => {
const routes = options.routeStore.listRoutesForWorkspace(workspaceId);
if (routes.length === 0) {
return;
}
const updates: RouteHostnameUpdate[] = [];
for (const route of routes) {
const newHostname = buildServiceHostname(newBranch, route.serviceName);
if (newHostname !== route.hostname) {
updates.push({
oldHostname: route.hostname,
newHostname,
route,
});
}
}
if (updates.length === 0) {
return;
}
for (const { oldHostname, newHostname, route } of updates) {
options.routeStore.removeRoute(oldHostname);
options.routeStore.registerRoute({
hostname: newHostname,
port: route.port,
workspaceId: route.workspaceId,
serviceName: route.serviceName,
});
options.logger?.info(
{
oldHostname,
newHostname,
serviceName: route.serviceName,
},
"Updated service route for branch rename",
);
}
options.emitServiceStatusUpdate(
workspaceId,
buildWorkspaceServicePayloads(options.routeStore, workspaceId, null),
);
};
}
+22
View File
@@ -257,6 +257,7 @@ type WorkspaceGitWatchTarget = {
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
latestFingerprint: string | null;
lastBranchName: string | null;
};
type ActiveTerminalStream = {
@@ -397,6 +398,11 @@ export type SessionOptions = {
terminalManager: TerminalManager | null;
providerSnapshotManager?: ProviderSnapshotManager;
serviceRouteStore?: ServiceRouteStore;
onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
newBranch: string | null,
) => void;
getDaemonTcpPort?: () => number | null;
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null;
voice?: {
@@ -575,6 +581,11 @@ export class Session {
private readonly providerSnapshotManager: ProviderSnapshotManager | null;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly serviceRouteStore: ServiceRouteStore | null;
private readonly onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
newBranch: string | null,
) => void;
private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
private readonly subscribedTerminalDirectories = new Set<string>();
@@ -633,6 +644,7 @@ export class Session {
terminalManager,
providerSnapshotManager,
serviceRouteStore,
onBranchChanged,
getDaemonTcpPort,
resolveServiceStatus,
voice,
@@ -674,6 +686,7 @@ export class Session {
this.terminalManager = terminalManager;
this.providerSnapshotManager = providerSnapshotManager ?? null;
this.serviceRouteStore = serviceRouteStore ?? null;
this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceStatus = resolveServiceStatus ?? null;
if (this.terminalManager) {
@@ -4087,6 +4100,7 @@ export class Session {
return;
}
target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace);
target.lastBranchName = workspace?.name ?? null;
}
private async primeWorkspaceGitWatchFingerprints(
@@ -4155,6 +4169,7 @@ export class Session {
refreshPromise: null,
refreshQueued: false,
latestFingerprint: null,
lastBranchName: null,
};
for (const watchPath of new Set([join(gitDir, "HEAD"), join(refsRoot, "refs", "heads")])) {
@@ -5703,6 +5718,13 @@ export class Session {
) {
continue;
}
const watchTarget = this.workspaceGitWatchTargets.get(normalizedCwd);
if (watchTarget && this.onBranchChanged) {
const newBranchName = nextWorkspace?.name ?? null;
if (newBranchName !== watchTarget.lastBranchName) {
this.onBranchChanged(normalizedCwd, watchTarget.lastBranchName, newBranchName);
}
}
this.rememberWorkspaceGitWatchFingerprint(normalizedCwd, nextWorkspace);
if (!nextWorkspace) {
@@ -233,8 +233,8 @@ describe("workspace git watch targets", () => {
};
let descriptor = {
id: 10,
projectId: 1,
id: "10",
projectId: "1",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
@@ -274,7 +274,7 @@ describe("workspace git watch targets", () => {
expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: 10,
id: "10",
name: "renamed-branch",
diffStat: { additions: 1, deletions: 0 },
},
@@ -262,6 +262,9 @@ export class VoiceAssistantWebSocketServer {
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly onBranchChanged:
| ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void)
| null;
private serverCapabilities: ServerCapabilities | undefined;
private runtimeWindowStartedAt = Date.now();
private readonly runtimeCounters: WebSocketRuntimeCounters = {
@@ -317,6 +320,11 @@ export class VoiceAssistantWebSocketServer {
scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager,
serviceRouteStore?: ServiceRouteStore | null,
onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
newBranch: string | null,
) => void,
getDaemonTcpPort?: () => number | null,
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null,
) {
@@ -363,6 +371,7 @@ export class VoiceAssistantWebSocketServer {
);
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.serviceRouteStore = serviceRouteStore ?? null;
this.onBranchChanged = onBranchChanged ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceStatus = resolveServiceStatus ?? null;
this.serverCapabilities = buildServerCapabilities({
@@ -682,6 +691,7 @@ export class VoiceAssistantWebSocketServer {
terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager,
serviceRouteStore: this.serviceRouteStore ?? undefined,
onBranchChanged: this.onBranchChanged ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
resolveServiceStatus: this.resolveServiceStatus ?? undefined,
voice: {
@@ -5,6 +5,7 @@ import { promisify } from "node:util";
import { sep } from "node:path";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import { buildServiceHostname } from "../utils/service-hostname.js";
import {
createWorktree,
getServiceConfigs,
@@ -13,7 +14,6 @@ import {
processCarriageReturns,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
@@ -792,13 +792,7 @@ export async function spawnWorktreeServices(options: {
try {
port = config.port ?? (await findFreePort());
const branchHostnameLabel = branchName ? slugify(branchName) : null;
const isDefaultBranch =
branchName === null || branchName === "main" || branchName === "master";
hostname = isDefaultBranch
? `${serviceName}.localhost`
: `${branchHostnameLabel}.${serviceName}.localhost`;
hostname = buildServiceHostname(branchName, serviceName);
routeStore.registerRoute({
hostname,
+106 -10
View File
@@ -33,6 +33,7 @@ import {
pushCurrentBranch,
resolveRepositoryDefaultBranch,
parseWorktreeList,
parseStatusCheckRollup,
isPaseoWorktreePath,
isDescendantPath,
warmCheckoutShortstatInBackground,
@@ -622,6 +623,101 @@ const x = 1;
}
});
it("parses real gh status check rollup output and dedupes by latest check run", () => {
expect(
parseStatusCheckRollup([
{
__typename: "CheckRun",
completedAt: "2026-04-02T13:53:59Z",
conclusion: "SUCCESS",
detailsUrl: "https://github.com/org/repo/actions/runs/123",
name: "review_app",
startedAt: "2026-04-02T13:49:31Z",
status: "COMPLETED",
workflowName: "Deploy PR Preview",
},
{
__typename: "CheckRun",
completedAt: "2026-04-02T13:58:59Z",
conclusion: "FAILURE",
detailsUrl: "https://github.com/org/repo/actions/runs/124",
name: "review_app",
startedAt: "2026-04-02T13:55:31Z",
status: "COMPLETED",
},
]),
).toEqual([
{
name: "review_app",
status: "failure",
url: "https://github.com/org/repo/actions/runs/124",
},
]);
});
it("parses mixed check run and status context entries", () => {
expect(
parseStatusCheckRollup([
{
__typename: "CheckRun",
name: "unit-tests",
status: "IN_PROGRESS",
conclusion: null,
detailsUrl: "https://github.com/org/repo/actions/runs/200",
startedAt: "2026-04-02T13:49:31Z",
},
{
__typename: "StatusContext",
context: "lint",
state: "SUCCESS",
targetUrl: "https://github.com/org/repo/status/300",
createdAt: "2026-04-02T13:48:00Z",
},
]),
).toEqual([
{
name: "unit-tests",
status: "pending",
url: "https://github.com/org/repo/actions/runs/200",
},
{
name: "lint",
status: "success",
url: "https://github.com/org/repo/status/300",
},
]);
});
it("returns an empty list for nullish or empty status check rollups", () => {
expect(parseStatusCheckRollup(undefined)).toEqual([]);
expect(parseStatusCheckRollup(null)).toEqual([]);
expect(parseStatusCheckRollup([])).toEqual([]);
});
it("ignores unknown status check rollup node types", () => {
expect(
parseStatusCheckRollup([
{
__typename: "Commit",
oid: "abc123",
},
{
__typename: "CheckRun",
name: "build",
status: "COMPLETED",
conclusion: "SUCCESS",
detailsUrl: "https://github.com/org/repo/actions/runs/500",
},
]),
).toEqual([
{
name: "build",
status: "success",
url: "https://github.com/org/repo/actions/runs/500",
},
]);
});
it("returns merged PR status when no open PR exists for the current branch", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
@@ -640,8 +736,8 @@ const x = 1;
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":"2026-02-18T00:00:00Z"}\'',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":"2026-02-18T00:00:00Z","statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
@@ -686,8 +782,8 @@ const x = 1;
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":null}\'',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
@@ -735,10 +831,10 @@ const x = 1;
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' printf "%s\\n" "$((count + 1))" > "$count_file"',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null}\'',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
@@ -783,11 +879,11 @@ const x = 1;
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' next="$((count + 1))"',
' printf "%s\\n" "$next" > "$count_file"',
' printf \'{"url":"https://github.com/getpaseo/paseo/pull/%s","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null}\\n\' "$next"',
' printf \'{"url":"https://github.com/getpaseo/paseo/pull/%s","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\\n\' "$next"',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
@@ -834,11 +930,11 @@ const x = 1;
" exit 0",
"fi",
'args="$*"',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then',
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision" ]]; then',
' count="$(cat "$count_file")"',
' printf "%s\\n" "$((count + 1))" > "$count_file"',
" sleep 0.2",
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null}\'',
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"OPEN","baseRefName":"main","headRefName":"feature","mergedAt":null,"statusCheckRollup":[],"reviewDecision":""}\'',
" exit 0",
"fi",
'echo "unexpected gh args: $args" >&2',
+67 -34
View File
@@ -4,6 +4,7 @@ import { resolve, dirname, basename } from "path";
import { realpathSync } from "fs";
import { open as openFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import { z } from "zod";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { findExecutable } from "./executable.js";
@@ -1851,24 +1852,52 @@ export type ChecksStatus = "none" | "pending" | "success" | "failure";
export type ReviewDecision = "approved" | "changes_requested" | "pending" | null;
type StatusCheckRollupContext = {
__typename?: unknown;
name?: unknown;
conclusion?: unknown;
status?: unknown;
detailsUrl?: unknown;
startedAt?: unknown;
completedAt?: unknown;
checkSuite?: {
workflowRun?: {
databaseId?: unknown;
} | null;
} | null;
context?: unknown;
state?: unknown;
targetUrl?: unknown;
createdAt?: unknown;
};
const CheckRunNodeSchema = z.object({
__typename: z.literal("CheckRun"),
name: z.string(),
conclusion: z.string().nullable().optional(),
status: z.string().nullable().optional(),
detailsUrl: z.string().nullable().optional(),
startedAt: z.string().nullable().optional(),
completedAt: z.string().nullable().optional(),
checkSuite: z
.object({
workflowRun: z
.object({
databaseId: z.number().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable()
.optional(),
});
const StatusContextNodeSchema = z.object({
__typename: z.literal("StatusContext"),
context: z.string(),
state: z.string().nullable().optional(),
targetUrl: z.string().nullable().optional(),
createdAt: z.string().nullable().optional(),
});
const StatusCheckRollupNodeSchema = z.discriminatedUnion("__typename", [
CheckRunNodeSchema,
StatusContextNodeSchema,
]);
const StatusCheckRollupArraySchema = z.array(z.unknown());
const LegacyStatusCheckRollupSchema = z.object({
contexts: z.array(z.unknown()),
});
const ReviewDecisionSchema = z
.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"])
.nullable()
.catch(null);
type CheckRunNode = z.infer<typeof CheckRunNodeSchema>;
type StatusContextNode = z.infer<typeof StatusContextNodeSchema>;
function resolveGhPath(): string {
if (cachedGhPath === undefined) {
@@ -1941,7 +1970,7 @@ function mapStatusContextState(state: unknown): PullRequestCheck["status"] {
}
}
function getCheckRunRecency(context: StatusCheckRollupContext): number {
function getCheckRunRecency(context: CheckRunNode): number {
const workflowRunId = context.checkSuite?.workflowRun?.databaseId;
if (typeof workflowRunId === "number") {
return workflowRunId;
@@ -1961,7 +1990,7 @@ function getCheckRunRecency(context: StatusCheckRollupContext): number {
return Number.isNaN(time) ? 0 : time;
}
function getStatusContextRecency(context: StatusCheckRollupContext): number {
function getStatusContextRecency(context: StatusContextNode): number {
if (typeof context.createdAt !== "string" || context.createdAt.length === 0) {
return 0;
}
@@ -1970,15 +1999,17 @@ function getStatusContextRecency(context: StatusCheckRollupContext): number {
return Number.isNaN(time) ? 0 : time;
}
function parseStatusCheckRollup(value: unknown): PullRequestCheck[] {
if (!value || typeof value !== "object") {
return [];
}
export function parseStatusCheckRollup(value: unknown): PullRequestCheck[] {
const directContexts = StatusCheckRollupArraySchema.safeParse(value);
if (!directContexts.success) {
const legacyContexts = LegacyStatusCheckRollupSchema.safeParse(value);
if (!legacyContexts.success) {
return [];
}
const contexts = (value as { contexts?: unknown }).contexts;
if (!Array.isArray(contexts)) {
return [];
return parseStatusCheckRollup(legacyContexts.data.contexts);
}
const contexts = directContexts.data;
const dedupedChecks = new Map<
string,
@@ -1988,21 +2019,22 @@ function parseStatusCheckRollup(value: unknown): PullRequestCheck[] {
>();
for (const entry of contexts) {
if (!entry || typeof entry !== "object") {
const parsed = StatusCheckRollupNodeSchema.safeParse(entry);
if (!parsed.success) {
continue;
}
const context = entry as StatusCheckRollupContext;
const context = parsed.data;
let check: (PullRequestCheck & { recency: number }) | null = null;
if (context.__typename === "CheckRun" && typeof context.name === "string") {
if (context.__typename === "CheckRun") {
check = {
name: context.name,
status: mapCheckRunStatus(context.status, context.conclusion),
url: typeof context.detailsUrl === "string" ? context.detailsUrl : null,
recency: getCheckRunRecency(context),
};
} else if (context.__typename === "StatusContext" && typeof context.context === "string") {
} else if (context.__typename === "StatusContext") {
check = {
name: context.context,
status: mapStatusContextState(context.state),
@@ -2038,13 +2070,14 @@ function computeChecksStatus(checks: PullRequestCheck[]): ChecksStatus {
}
function mapReviewDecision(value: unknown): ReviewDecision {
if (value === "APPROVED") {
const reviewDecision = ReviewDecisionSchema.parse(value);
if (reviewDecision === "APPROVED") {
return "approved";
}
if (value === "CHANGES_REQUESTED") {
if (reviewDecision === "CHANGES_REQUESTED") {
return "changes_requested";
}
if (value === "REVIEW_REQUIRED") {
if (reviewDecision === "REVIEW_REQUIRED") {
return "pending";
}
return null;
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildServiceHostname } from "./service-hostname.js";
describe("buildServiceHostname", () => {
it("slugifies service names with spaces on default branches", () => {
expect(buildServiceHostname(null, "npm run dev")).toBe("npm-run-dev.localhost");
});
it("slugifies service names with special characters", () => {
expect(buildServiceHostname(null, "Web/API @ Dev")).toBe("web-api-dev.localhost");
});
it("omits the branch prefix for main and master", () => {
expect(buildServiceHostname("main", "npm run dev")).toBe("npm-run-dev.localhost");
expect(buildServiceHostname("master", "npm run dev")).toBe("npm-run-dev.localhost");
});
it("adds a slugified branch prefix for non-default branches", () => {
expect(buildServiceHostname("feature/cool stuff", "api")).toBe(
"feature-cool-stuff.api.localhost",
);
});
it("slugifies both the branch name and service name together", () => {
expect(buildServiceHostname("feat/add auth", "npm run dev")).toBe(
"feat-add-auth.npm-run-dev.localhost",
);
});
});
@@ -0,0 +1,13 @@
import { slugify } from "./worktree.js";
export function buildServiceHostname(branchName: string | null, serviceName: string): string {
const serviceHostnameLabel = slugify(serviceName);
const isDefaultBranch =
branchName === null || branchName === "main" || branchName === "master";
if (isDefaultBranch) {
return `${serviceHostnameLabel}.localhost`;
}
return `${slugify(branchName)}.${serviceHostnameLabel}.localhost`;
}