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:
@@ -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',
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user