feat(forge): pluggable forge abstraction + GitLab and Gitea/Forgejo/Codeberg (#1913)
* refactor(forge): forge-neutral foundation (GitHub-only) Decouple git-hosting from GitHub behind a neutral abstraction (issue #1616), GitHub-only for now; existing GitHub behaviour is unchanged. - Forge manifest, neutral ForgeService contract, forge registry + resolver, and a client forge-module registry. - GitHub code renamed to the neutral shape; PR/Issue attachment wording preserved. - forge.search.response enums parse tolerantly (unknown kind/auth state degrade instead of breaking the client). - createPullRequest reports typed CLI/auth errors instead of a generic message. - forge-resolver host/remote caches are LRU-bounded. - Forge host trust is explicit: only a known cloud host or a CLI-authenticated host is ever talked to; an unauthenticated GitHub Enterprise host fails resolution instead of routing to github.com. - Docs: forge-providers guide, glossary and i18n forge-copy conventions, architecture and rpc-namespacing terminology. - Vitest React Native mocks (unistyles, svg, linking, lucide) consolidated into shared aliased test-stubs. * feat(forge): GitLab adapter, forge-aware UI, pipelines and approvals GitLab adapter over the glab CLI on the neutral contracts: MR status, forge-aware UI, pipeline tree, and N-of-M approvals. - threadIsResolved is part of the neutral timeline item. - Pipeline load failures show an error instead of an empty section. - Manual pipeline jobs render as pending. - Fork/detached MR head pipelines are fetched by MR iid (glab ci get --merge-request). * feat(forge): Gitea family adapter (Gitea, Forgejo, Codeberg) One adapter over the tea CLI serving Gitea, Forgejo, and Codeberg on the neutral contracts. - CI status aggregates commit statuses and Actions runs together. - Gitea's terminal "warning" state maps to failure on server and client. - Gitea Actions check details are reachable from the PR pane by workflowRunId. * refactor(forge): localize compatibility handling * test(forge): expect normalized GitLab facts --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
04d1ebdce0
commit
a8ebd390fa
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Declarative manifest of the git forges Paseo knows how to present, mirroring
|
||||
* provider-manifest.ts. Pure build-time data shared by BOTH the client (icon,
|
||||
* brand label, PR↔MR relabel) and the server (registry host-matching, prompt
|
||||
* branding). It is NEVER serialized over the wire, so adding a forge here is not
|
||||
* a protocol change.
|
||||
*
|
||||
* Keep this a pure leaf: no imports, no zod, no functions with runtime deps.
|
||||
* Behavioural concerns (CLI invocation, host probing, REST adapters) live in the
|
||||
* server adapter keyed by {@link ForgeDefinition.id}; this file is only the
|
||||
* declarative half.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Declarative sign-in recipe for a forge. The client renders install/sign-in
|
||||
* hints from this data alone — no per-CLI switch — so a new forge wires its auth
|
||||
* UX entirely from the manifest. Behavioural auth (the actual host probe) stays
|
||||
* in the server adapter; this is only what the user is told to run.
|
||||
*/
|
||||
export interface ForgeSignInCommand {
|
||||
/** Binary the user installs, e.g. "gh" — shown in the install-CLI hint. */
|
||||
cli: string;
|
||||
/** Full sign-in command, e.g. "gh auth login". */
|
||||
command: string;
|
||||
/**
|
||||
* Flag that targets a self-hosted host, e.g. "--hostname". When present and a
|
||||
* host is known, the client appends `${command} ${hostnameFlag} ${host}`.
|
||||
* Omit when the command already targets the right host on its own.
|
||||
*/
|
||||
hostnameFlag?: string;
|
||||
}
|
||||
|
||||
export interface ForgeDefinition {
|
||||
/** Registry id, matches the server adapter and the wire `forge` value. */
|
||||
id: string;
|
||||
/** Human brand name, e.g. for "Open on GitLab". */
|
||||
displayName: string;
|
||||
/** Short change-request noun: "PR" for GitHub, "MR" for GitLab. */
|
||||
changeRequestAbbrev: string;
|
||||
/** Full change-request noun: "pull request" vs "merge request". */
|
||||
changeRequestNoun: string;
|
||||
/** Prefix before a change-request number: "#" vs "!". */
|
||||
changeRequestNumberPrefix: string;
|
||||
/** Prefix before an issue number ("#" on every forge so far). */
|
||||
issueNumberPrefix: string;
|
||||
/** Icon key; the client falls back to a generic git icon for unknown values. */
|
||||
iconKind: string;
|
||||
/** Sign-in recipe, or null when the forge has no Paseo-driven sign-in. */
|
||||
signIn: ForgeSignInCommand | null;
|
||||
/**
|
||||
* Public cloud hosts this forge owns exactly. A BOUNDED list, never an
|
||||
* allowlist for self-hosted detection — self-hosted/Enterprise instances are
|
||||
* recognized at runtime by the adapter's host probe, not by this field.
|
||||
*/
|
||||
cloudHosts?: string[];
|
||||
}
|
||||
|
||||
export const FORGE_DEFINITIONS: ForgeDefinition[] = [
|
||||
{
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
changeRequestAbbrev: "PR",
|
||||
changeRequestNoun: "pull request",
|
||||
changeRequestNumberPrefix: "#",
|
||||
issueNumberPrefix: "#",
|
||||
iconKind: "github",
|
||||
signIn: { cli: "gh", command: "gh auth login" },
|
||||
cloudHosts: ["github.com", "ssh.github.com"],
|
||||
},
|
||||
{
|
||||
id: "gitlab",
|
||||
displayName: "GitLab",
|
||||
changeRequestAbbrev: "MR",
|
||||
changeRequestNoun: "merge request",
|
||||
changeRequestNumberPrefix: "!",
|
||||
issueNumberPrefix: "#",
|
||||
iconKind: "gitlab",
|
||||
signIn: { cli: "glab", command: "glab auth login", hostnameFlag: "--hostname" },
|
||||
cloudHosts: ["gitlab.com"],
|
||||
},
|
||||
{
|
||||
id: "gitea",
|
||||
displayName: "Gitea",
|
||||
changeRequestAbbrev: "PR",
|
||||
changeRequestNoun: "pull request",
|
||||
changeRequestNumberPrefix: "#",
|
||||
issueNumberPrefix: "#",
|
||||
iconKind: "gitea",
|
||||
signIn: { cli: "tea", command: "tea login add" },
|
||||
cloudHosts: ["gitea.com"],
|
||||
},
|
||||
{
|
||||
id: "forgejo",
|
||||
displayName: "Forgejo",
|
||||
changeRequestAbbrev: "PR",
|
||||
changeRequestNoun: "pull request",
|
||||
changeRequestNumberPrefix: "#",
|
||||
issueNumberPrefix: "#",
|
||||
iconKind: "forgejo",
|
||||
signIn: { cli: "tea", command: "tea login add" },
|
||||
},
|
||||
{
|
||||
id: "codeberg",
|
||||
displayName: "Codeberg",
|
||||
changeRequestAbbrev: "PR",
|
||||
changeRequestNoun: "pull request",
|
||||
changeRequestNumberPrefix: "#",
|
||||
issueNumberPrefix: "#",
|
||||
iconKind: "codeberg",
|
||||
signIn: { cli: "tea", command: "tea login add" },
|
||||
cloudHosts: ["codeberg.org"],
|
||||
},
|
||||
];
|
||||
|
||||
/** Forge definitions only present in dev builds (none today; mirrors providers). */
|
||||
export const DEV_FORGE_DEFINITIONS: ForgeDefinition[] = [];
|
||||
|
||||
export const FORGE_IDS: string[] = FORGE_DEFINITIONS.map((definition) => definition.id);
|
||||
|
||||
export function getForgeDefinition(
|
||||
id: string,
|
||||
definitions: ForgeDefinition[] = [...FORGE_DEFINITIONS, ...DEV_FORGE_DEFINITIONS],
|
||||
): ForgeDefinition | null {
|
||||
return definitions.find((definition) => definition.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a forge definition, synthesizing a neutral one for a forge id the
|
||||
* client has never heard of (e.g. a self-hosted forge a newer daemon reports to
|
||||
* an older client). The neutral shape renders generic, never GitHub-branded.
|
||||
*/
|
||||
export function getForgeDefinitionOrNeutral(id: string): ForgeDefinition {
|
||||
return (
|
||||
getForgeDefinition(id) ?? {
|
||||
id,
|
||||
displayName: id,
|
||||
changeRequestAbbrev: "PR",
|
||||
changeRequestNoun: "pull request",
|
||||
changeRequestNumberPrefix: "#",
|
||||
issueNumberPrefix: "#",
|
||||
iconKind: "git",
|
||||
signIn: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
|
||||
import { getForgeDefinition } from "./forge-manifest.js";
|
||||
|
||||
const GITHUB_HOSTS = new Set(getForgeDefinition("github")?.cloudHosts ?? []);
|
||||
|
||||
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
||||
"https:": "https",
|
||||
|
||||
@@ -7,10 +7,57 @@ import {
|
||||
CheckoutGithubSetAutoMergeResponseSchema,
|
||||
CheckoutPrMergeRequestSchema,
|
||||
CheckoutPrStatusSchema,
|
||||
ForgeSearchResponseSchema,
|
||||
GitHubSearchResponseSchema,
|
||||
ServerInfoStatusPayloadSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("checkout PR schemas", () => {
|
||||
test("defaults missing forge identity for old daemon payloads", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "Ship it",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/ship-it",
|
||||
isMerged: false,
|
||||
});
|
||||
|
||||
expect(parsed.forge).toBe("github");
|
||||
});
|
||||
|
||||
test("round-trips forge and neutral project identity", () => {
|
||||
const payload = {
|
||||
forge: "github",
|
||||
projectPath: "getpaseo/paseo",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "Ship it",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/ship-it",
|
||||
isMerged: false,
|
||||
isDraft: false,
|
||||
mergeable: "UNKNOWN" as const,
|
||||
checks: [],
|
||||
};
|
||||
|
||||
expect(CheckoutPrStatusSchema.parse(payload)).toEqual(payload);
|
||||
});
|
||||
|
||||
test("accepts unknown future forge identities", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
forge: "someforge",
|
||||
url: "https://someforge.example/getpaseo/paseo/pulls/42",
|
||||
title: "Ship it",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/ship-it",
|
||||
isMerged: false,
|
||||
});
|
||||
|
||||
expect(parsed.forge).toBe("someforge");
|
||||
});
|
||||
|
||||
test("parses PR status payloads without mergeability", () => {
|
||||
expect(
|
||||
CheckoutPrStatusSchema.parse({
|
||||
@@ -89,6 +136,248 @@ describe("checkout PR schemas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps forgeSpecific absent for old daemons that only send github facts", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "Ship it",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/ship-it",
|
||||
isMerged: false,
|
||||
github: {
|
||||
mergeStateStatus: "CLEAN",
|
||||
autoMergeRequest: null,
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "MERGE",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.github?.mergeStateStatus).toBe("CLEAN");
|
||||
expect(parsed.forgeSpecific).toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves a github forgeSpecific envelope", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 7,
|
||||
url: "https://github.com/getpaseo/paseo/pull/7",
|
||||
title: "Ship it",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/ship-it",
|
||||
isMerged: false,
|
||||
github: {
|
||||
mergeStateStatus: "CLEAN",
|
||||
autoMergeRequest: null,
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "MERGE",
|
||||
},
|
||||
},
|
||||
forgeSpecific: {
|
||||
forge: "github",
|
||||
mergeStateStatus: "CLEAN",
|
||||
autoMergeRequest: null,
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "MERGE",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.forgeSpecific).toMatchObject({ forge: "github", mergeStateStatus: "CLEAN" });
|
||||
});
|
||||
|
||||
test("preserves a gitlab forgeSpecific envelope without protocol-side defaults", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 1,
|
||||
url: "https://gitlab.com/group/subgroup/repo/-/merge_requests/1",
|
||||
title: "Add sample change",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feat/sample-change",
|
||||
isMerged: false,
|
||||
projectPath: "group/subgroup/repo",
|
||||
forgeSpecific: {
|
||||
forge: "gitlab",
|
||||
detailedMergeStatus: "mergeable",
|
||||
hasConflicts: false,
|
||||
blockingDiscussionsResolved: true,
|
||||
approvalsRequired: 1,
|
||||
approvalsGiven: 1,
|
||||
pipelineStatus: "success",
|
||||
mergeWhenPipelineSucceeds: false,
|
||||
},
|
||||
});
|
||||
expect(parsed.forgeSpecific).toEqual({
|
||||
forge: "gitlab",
|
||||
detailedMergeStatus: "mergeable",
|
||||
hasConflicts: false,
|
||||
blockingDiscussionsResolved: true,
|
||||
approvalsRequired: 1,
|
||||
approvalsGiven: 1,
|
||||
pipelineStatus: "success",
|
||||
mergeWhenPipelineSucceeds: false,
|
||||
});
|
||||
expect(parsed.github).toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves a gitea forgeSpecific envelope", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 5,
|
||||
url: "https://codeberg.org/example/repo/pulls/5",
|
||||
title: "Add sample change",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feat/sample-change",
|
||||
isMerged: false,
|
||||
forgeSpecific: {
|
||||
forge: "gitea",
|
||||
mergeable: true,
|
||||
hasMerged: false,
|
||||
ciStatus: "success",
|
||||
},
|
||||
});
|
||||
expect(parsed.forgeSpecific).toEqual({
|
||||
forge: "gitea",
|
||||
mergeable: true,
|
||||
hasMerged: false,
|
||||
ciStatus: "success",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves an unknown forgeSpecific envelope without failing the parse", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 9,
|
||||
url: "https://example.com/forge/9",
|
||||
title: "Future forge",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/future",
|
||||
isMerged: false,
|
||||
forgeSpecific: { forge: "bitbucket", somethingNew: true },
|
||||
});
|
||||
expect(parsed.forgeSpecific).toEqual({ forge: "bitbucket", somethingNew: true });
|
||||
});
|
||||
|
||||
test("preserves forgejo-specific facts as runtime-owned facts", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 10,
|
||||
url: "https://codeberg.org/example/repo/pulls/10",
|
||||
title: "Future Forgejo facts",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/forgejo",
|
||||
isMerged: false,
|
||||
forgeSpecific: { forge: "forgejo", approvalsRequired: 2 },
|
||||
});
|
||||
expect(parsed.forgeSpecific).toEqual({ forge: "forgejo", approvalsRequired: 2 });
|
||||
});
|
||||
|
||||
test("preserves malformed known-family facts for runtime validation", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 11,
|
||||
url: "https://gitlab.com/group/repo/-/merge_requests/11",
|
||||
title: "Runtime facts",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/runtime-facts",
|
||||
isMerged: false,
|
||||
forgeSpecific: {
|
||||
forge: "gitlab",
|
||||
pipelineId: "not-a-number",
|
||||
approvalsRequired: "also-runtime-owned",
|
||||
},
|
||||
});
|
||||
expect(parsed.forgeSpecific).toEqual({
|
||||
forge: "gitlab",
|
||||
pipelineId: "not-a-number",
|
||||
approvalsRequired: "also-runtime-owned",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps forge-specific facts structural for feature-owned interpretation", () => {
|
||||
const parsed = CheckoutPrStatusSchema.parse({
|
||||
number: 12,
|
||||
url: "https://example.com/forge/12",
|
||||
title: "Missing facts tag",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/missing-tag",
|
||||
isMerged: false,
|
||||
forgeSpecific: { approvalsRequired: 2 },
|
||||
});
|
||||
|
||||
expect(parsed.forgeSpecific).toEqual({ approvalsRequired: 2 });
|
||||
});
|
||||
|
||||
test("keeps forge search responses structural", () => {
|
||||
expect(
|
||||
ForgeSearchResponseSchema.parse({
|
||||
type: "forge.search.response",
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
kind: "change_request",
|
||||
number: 17,
|
||||
title: "Fix search",
|
||||
url: "https://gitlab.com/acme/repo/-/merge_requests/17",
|
||||
state: "open",
|
||||
body: null,
|
||||
labels: [],
|
||||
},
|
||||
{ kind: "future_kind", futureField: true },
|
||||
],
|
||||
authState: "future_auth_state",
|
||||
error: null,
|
||||
requestId: "search-2",
|
||||
},
|
||||
}).payload,
|
||||
).toEqual({
|
||||
items: [
|
||||
{
|
||||
kind: "change_request",
|
||||
number: 17,
|
||||
title: "Fix search",
|
||||
url: "https://gitlab.com/acme/repo/-/merge_requests/17",
|
||||
state: "open",
|
||||
body: null,
|
||||
labels: [],
|
||||
},
|
||||
{ kind: "future_kind", futureField: true },
|
||||
],
|
||||
authState: "future_auth_state",
|
||||
error: null,
|
||||
requestId: "search-2",
|
||||
});
|
||||
|
||||
expect(
|
||||
GitHubSearchResponseSchema.parse({
|
||||
type: "github_search_response",
|
||||
payload: {
|
||||
items: [],
|
||||
githubFeaturesEnabled: false,
|
||||
error: null,
|
||||
requestId: "search-legacy",
|
||||
},
|
||||
}).payload,
|
||||
).toEqual({
|
||||
items: [],
|
||||
githubFeaturesEnabled: false,
|
||||
error: null,
|
||||
requestId: "search-legacy",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses optional GitHub check identifiers on PR checks", () => {
|
||||
expect(
|
||||
CheckoutPrStatusSchema.parse({
|
||||
@@ -287,6 +576,83 @@ describe("checkout PR schemas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts a GitLab pipeline through the existing check-details response", () => {
|
||||
expect(
|
||||
CheckoutGithubGetCheckDetailsRequestSchema.parse({
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: "/tmp/repo",
|
||||
checkRunId: 306,
|
||||
requestId: "request-pipeline",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: "/tmp/repo",
|
||||
checkRunId: 306,
|
||||
requestId: "request-pipeline",
|
||||
});
|
||||
|
||||
const details = CheckoutGithubGetCheckDetailsResponseSchema.parse({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
details: {
|
||||
checkRunId: 306,
|
||||
name: "Pipeline (feat/x)",
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
pipeline: {
|
||||
id: 306,
|
||||
status: "future_pipeline_status",
|
||||
rawStatus: "future_pipeline_status",
|
||||
stages: [
|
||||
{
|
||||
name: "test",
|
||||
status: "future_stage_status",
|
||||
jobs: [
|
||||
{
|
||||
id: 929,
|
||||
name: "unit",
|
||||
stage: "test",
|
||||
status: "future_job_status",
|
||||
rawStatus: "future_job_status",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
requestId: "request-pipeline",
|
||||
},
|
||||
}).payload.details;
|
||||
|
||||
expect(details?.pipeline).toMatchObject({
|
||||
id: 306,
|
||||
status: "future_pipeline_status",
|
||||
stages: [{ jobs: [{ status: "future_job_status", allowFailure: false }] }],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps pipeline absent for legacy and GitHub check-details responses", () => {
|
||||
const details = CheckoutGithubGetCheckDetailsResponseSchema.parse({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
details: {
|
||||
checkRunId: 12345,
|
||||
name: "server tests",
|
||||
},
|
||||
error: null,
|
||||
requestId: "legacy-check-details",
|
||||
},
|
||||
}).payload.details;
|
||||
|
||||
expect(details).not.toHaveProperty("pipeline");
|
||||
});
|
||||
|
||||
test("rejects invalid GitHub check details request identities", () => {
|
||||
const request = {
|
||||
type: "checkout.github.get_check_details.request",
|
||||
@@ -341,6 +707,20 @@ describe("checkout PR schemas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the forgeProviders server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
forgeProviders: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({
|
||||
forgeProviders: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the project removal server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
|
||||
@@ -870,6 +870,19 @@ export const GitHubPrAttachmentSchema = z.object({
|
||||
headRefName: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ForgeChangeRequestAttachmentSchema = z.object({
|
||||
type: z.literal("forge_change_request"),
|
||||
mimeType: z.literal("application/paseo-forge-change-request"),
|
||||
forge: z.string().optional().default("github"),
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
body: z.string().nullable().optional(),
|
||||
projectPath: z.string().optional(),
|
||||
baseRefName: z.string().nullable().optional(),
|
||||
headRefName: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const GitHubIssueAttachmentSchema = z.object({
|
||||
type: z.literal("github_issue"),
|
||||
mimeType: z.literal("application/github-issue"),
|
||||
@@ -879,6 +892,17 @@ export const GitHubIssueAttachmentSchema = z.object({
|
||||
body: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ForgeIssueAttachmentSchema = z.object({
|
||||
type: z.literal("forge_issue"),
|
||||
mimeType: z.literal("application/paseo-forge-issue"),
|
||||
forge: z.string().optional().default("github"),
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
body: z.string().nullable().optional(),
|
||||
projectPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TextAttachmentSchema = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
@@ -930,6 +954,8 @@ export const UploadedFileAttachmentSchema = z.object({
|
||||
});
|
||||
|
||||
export const AgentAttachmentSchema = z.discriminatedUnion("type", [
|
||||
ForgeChangeRequestAttachmentSchema,
|
||||
ForgeIssueAttachmentSchema,
|
||||
GitHubPrAttachmentSchema,
|
||||
GitHubIssueAttachmentSchema,
|
||||
TextAttachmentSchema,
|
||||
@@ -953,6 +979,13 @@ function normalizeAgentAttachments(input: unknown): AgentAttachment[] {
|
||||
|
||||
const AgentAttachmentsSchema = z.unknown().transform(normalizeAgentAttachments).optional();
|
||||
|
||||
export const ChangeRequestCheckoutSourceSchema = z.object({
|
||||
kind: z.literal("change_request"),
|
||||
forge: z.string().optional().default("github"),
|
||||
number: z.number().int().positive(),
|
||||
projectPath: z.string().optional(),
|
||||
});
|
||||
|
||||
const ImageAttachmentSchema = z.object({
|
||||
data: z.string(), // base64 encoded image
|
||||
mimeType: z.string(), // e.g., "image/jpeg", "image/png"
|
||||
@@ -1169,6 +1202,9 @@ const GitSetupOptionsSchema = z.object({
|
||||
worktreeSlug: z.string().optional(),
|
||||
refName: z.string().min(1).optional(),
|
||||
action: z.enum(["branch-off", "checkout"]).optional(),
|
||||
checkoutSource: ChangeRequestCheckoutSourceSchema.optional(),
|
||||
// COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once
|
||||
// clients send checkoutSource.
|
||||
githubPrNumber: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
@@ -1637,6 +1673,16 @@ export const CheckoutPrMergeRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutForgeSetAutoMergeRequestSchema = z.object({
|
||||
type: z.literal("checkout.forge.set_auto_merge.request"),
|
||||
cwd: z.string(),
|
||||
enabled: z.boolean(),
|
||||
mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||
export const CheckoutGithubSetAutoMergeRequestSchema = z.object({
|
||||
type: z.literal("checkout.github.set_auto_merge.request"),
|
||||
cwd: z.string(),
|
||||
@@ -1678,16 +1724,38 @@ export const CheckoutCommitFileDiffRequestSchema = z.object({
|
||||
|
||||
const GitHubRepoSegmentSchema = z.string().regex(/^[A-Za-z0-9._-]+$/);
|
||||
|
||||
export const CheckoutGithubGetCheckDetailsRequestSchema = z.object({
|
||||
type: z.literal("checkout.github.get_check_details.request"),
|
||||
const CheckoutCheckDetailsRequestPayloadSchema = z.object({
|
||||
cwd: z.string(),
|
||||
repoOwner: GitHubRepoSegmentSchema,
|
||||
repoName: GitHubRepoSegmentSchema,
|
||||
checkRunId: z.number().int().positive(),
|
||||
// GitHub addresses check runs by owner/name. GitLab resolves the project from
|
||||
// cwd and omits these GitHub-only single-segment fields.
|
||||
repoOwner: GitHubRepoSegmentSchema.optional(),
|
||||
repoName: GitHubRepoSegmentSchema.optional(),
|
||||
// Permanently optional: a check addressed only by workflowRunId (Gitea
|
||||
// Actions runs carry no check-run id) is fetchable. Callers send at least one
|
||||
// of checkRunId/workflowRunId; the gated forge RPC only reaches daemons that
|
||||
// understand this.
|
||||
checkRunId: z.number().int().positive().optional(),
|
||||
workflowRunId: z.number().int().positive().optional(),
|
||||
// Permanent forge-routing field, optional because only some forges need it:
|
||||
// GitLab routes the check-details fetch to the change request's head pipeline
|
||||
// by iid, so a fork/detached MR pipeline (which lives in the source project,
|
||||
// not the checkout's target project) resolves correctly. GitHub ignores it.
|
||||
changeRequestNumber: z.number().int().positive().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutForgeGetCheckDetailsRequestSchema =
|
||||
CheckoutCheckDetailsRequestPayloadSchema.extend({
|
||||
type: z.literal("checkout.forge.get_check_details.request"),
|
||||
});
|
||||
|
||||
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.get_check_details.*.
|
||||
export const CheckoutGithubGetCheckDetailsRequestSchema =
|
||||
CheckoutCheckDetailsRequestPayloadSchema.extend({
|
||||
type: z.literal("checkout.github.get_check_details.request"),
|
||||
});
|
||||
|
||||
export const CheckoutPrStatusRequestSchema = z.object({
|
||||
type: z.literal("checkout_pr_status_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1758,19 +1826,46 @@ export const BranchSuggestionsRequestSchema = z.object({
|
||||
|
||||
export const GitHubSearchItemSchema = z.object({
|
||||
kind: z.enum(["issue", "pr"]),
|
||||
forge: z.string().optional(),
|
||||
number: z.number(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
state: z.string(),
|
||||
body: z.string().nullable(),
|
||||
labels: z.array(z.string()),
|
||||
projectPath: z.string().optional(),
|
||||
baseRefName: z.string().nullable().optional(),
|
||||
headRefName: z.string().nullable().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GitHubSearchKindSchema = z.enum(["github-issue", "github-pr"]);
|
||||
export const ForgeSearchItemSchema = GitHubSearchItemSchema.extend({
|
||||
kind: z.enum(["issue", "change_request"]),
|
||||
});
|
||||
|
||||
// COMPAT(githubSearchKind): added in v0.1.106, remove with the legacy
|
||||
// github_search_request RPC after 2026-12-28.
|
||||
export const ForgeSearchKindSchema = z.enum([
|
||||
"issue",
|
||||
"change_request",
|
||||
"github-issue",
|
||||
"github-pr",
|
||||
"pr",
|
||||
]);
|
||||
|
||||
export const GitHubSearchKindSchema = ForgeSearchKindSchema;
|
||||
|
||||
export const ForgeSearchRequestSchema = z.object({
|
||||
type: z.literal("forge.search.request"),
|
||||
cwd: z.string(),
|
||||
query: z.string(),
|
||||
limit: z.number().int().min(1).max(50).optional(),
|
||||
kinds: z.array(ForgeSearchKindSchema).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// clients use forge.search.*.
|
||||
export const GitHubSearchRequestSchema = z.object({
|
||||
type: z.literal("github_search_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1837,6 +1932,9 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
firstAgentContext: FirstAgentContextSchema.optional(),
|
||||
refName: z.string().min(1).optional(),
|
||||
action: z.enum(["branch-off", "checkout"]).optional(),
|
||||
checkoutSource: ChangeRequestCheckoutSourceSchema.optional(),
|
||||
// COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once
|
||||
// clients send checkoutSource: { kind: "change_request", forge, number }.
|
||||
githubPrNumber: z.number().int().positive().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
@@ -1944,6 +2042,9 @@ export const WorkspaceCreateRequestSchema = z.object({
|
||||
// Target branch name for checkout, or new branch name for branch-off.
|
||||
refName: z.string().min(1).optional(),
|
||||
baseBranch: z.string().optional(),
|
||||
checkoutSource: ChangeRequestCheckoutSourceSchema.optional(),
|
||||
// COMPAT(githubPrNumber): added in v0.1.106, remove after 2026-12-28 once
|
||||
// clients send checkoutSource.
|
||||
githubPrNumber: z.number().int().positive().optional(),
|
||||
worktreeSlug: z.string().optional(),
|
||||
}),
|
||||
@@ -2277,9 +2378,11 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutRefreshRequestSchema,
|
||||
CheckoutPrCreateRequestSchema,
|
||||
CheckoutPrMergeRequestSchema,
|
||||
CheckoutForgeSetAutoMergeRequestSchema,
|
||||
CheckoutGithubSetAutoMergeRequestSchema,
|
||||
CheckoutCommitsListRequestSchema,
|
||||
CheckoutCommitFileDiffRequestSchema,
|
||||
CheckoutForgeGetCheckDetailsRequestSchema,
|
||||
CheckoutGithubGetCheckDetailsRequestSchema,
|
||||
CheckoutPrStatusRequestSchema,
|
||||
PullRequestTimelineRequestSchema,
|
||||
@@ -2290,6 +2393,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
StashListRequestSchema,
|
||||
ValidateBranchRequestSchema,
|
||||
BranchSuggestionsRequestSchema,
|
||||
ForgeSearchRequestSchema,
|
||||
GitHubSearchRequestSchema,
|
||||
DirectorySuggestionsRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
@@ -2514,9 +2618,16 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
features: z
|
||||
.object({
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
// COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old
|
||||
// checkoutGithubSetAutoMerge fallback after 2026-12-28.
|
||||
checkoutForgeSetAutoMerge: z.boolean().optional(),
|
||||
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
||||
// COMPAT(githubCheckDetails): added in v0.1.92, remove gate after 2026-12-08.
|
||||
githubCheckDetails: z.boolean().optional(),
|
||||
// COMPAT(forgeCheckDetails): added in v0.1.106, remove githubCheckDetails fallback after 2026-12-28.
|
||||
forgeCheckDetails: z.boolean().optional(),
|
||||
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
|
||||
forgeSearch: z.boolean().optional(),
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: z.boolean().optional(),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
@@ -2563,6 +2674,10 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
providerRemoval: z.boolean().optional(),
|
||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||
importSessionWorkspaceTarget: z.boolean().optional(),
|
||||
// COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106.
|
||||
// Daemon advertises pluggable non-GitHub forge support (the forge registry);
|
||||
// the client gates non-GitHub setup UI on it.
|
||||
forgeProviders: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -2864,6 +2979,10 @@ export const WorkspaceDescriptorPayloadSchema = z
|
||||
scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
|
||||
gitRuntime: WorkspaceGitRuntimePayloadSchema,
|
||||
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
|
||||
// COMPAT(forge): added in v0.1.106, remove after 2026-12-27. The forge resolved
|
||||
// for this workspace, so the sidebar/hover-card render the right brand mark.
|
||||
// Old daemons omit it; absent means the client falls back to GitHub.
|
||||
forge: z.string().optional(),
|
||||
project: ProjectPlacementPayloadSchema.optional(),
|
||||
})
|
||||
.transform((workspace) => ({
|
||||
@@ -3649,21 +3768,36 @@ const CheckoutPrGithubRepositoryPolicySchema = z
|
||||
viewerDefaultMergeMethod: null,
|
||||
});
|
||||
|
||||
const CheckoutPrGithubStatusSchema = z
|
||||
.object({
|
||||
mergeStateStatus: z.string().nullable().optional().default(null),
|
||||
autoMergeRequest: CheckoutPrGithubAutoMergeRequestSchema,
|
||||
viewerCanEnableAutoMerge: z.boolean().optional().default(false),
|
||||
viewerCanDisableAutoMerge: z.boolean().optional().default(false),
|
||||
viewerCanMergeAsAdmin: z.boolean().optional().default(false),
|
||||
viewerCanUpdateBranch: z.boolean().optional().default(false),
|
||||
repository: CheckoutPrGithubRepositoryPolicySchema,
|
||||
isMergeQueueEnabled: z.boolean().optional().default(false),
|
||||
isInMergeQueue: z.boolean().optional().default(false),
|
||||
})
|
||||
.optional();
|
||||
const CheckoutPrGithubStatusObjectSchema = z.object({
|
||||
mergeStateStatus: z.string().nullable().optional().default(null),
|
||||
autoMergeRequest: CheckoutPrGithubAutoMergeRequestSchema,
|
||||
viewerCanEnableAutoMerge: z.boolean().optional().default(false),
|
||||
viewerCanDisableAutoMerge: z.boolean().optional().default(false),
|
||||
viewerCanMergeAsAdmin: z.boolean().optional().default(false),
|
||||
viewerCanUpdateBranch: z.boolean().optional().default(false),
|
||||
repository: CheckoutPrGithubRepositoryPolicySchema,
|
||||
isMergeQueueEnabled: z.boolean().optional().default(false),
|
||||
isInMergeQueue: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const CheckoutPrGithubStatusSchema = CheckoutPrGithubStatusObjectSchema.optional();
|
||||
|
||||
// The open facts envelope for forge-specific PR facts. Permanent — non-GitHub
|
||||
// forges deliver their native facts through it. The transitional piece is the
|
||||
// `github` mirror above, which stays populated for clients predating this
|
||||
// envelope; see COMPAT(forgeSpecific) in status-projection.ts for the shim.
|
||||
//
|
||||
// NOTE: `forgeSpecific.forge` is a FACTS-FAMILY tag, not the workspace brand id.
|
||||
// The whole Gitea family (gitea, forgejo, codeberg) emits `forge: "gitea"` here
|
||||
// because they share one facts shape, while the top-level `forge` above carries
|
||||
// the specific brand. Validation of family-specific payloads happens at runtime
|
||||
// in the consumer that knows that forge family.
|
||||
const CheckoutPrForgeSpecificSchema = z.unknown().optional();
|
||||
|
||||
export const CheckoutPrStatusSchema = z.object({
|
||||
// COMPAT(forge): added in v0.1.106, remove the default after 2026-12-27 once daemon floor >= v0.1.106.
|
||||
forge: z.string().optional().default("github"),
|
||||
projectPath: z.string().optional(),
|
||||
number: z.number().optional(),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
@@ -3696,12 +3830,32 @@ export const CheckoutPrStatusSchema = z.object({
|
||||
repoOwner: z.string().optional(),
|
||||
repoName: z.string().optional(),
|
||||
github: CheckoutPrGithubStatusSchema,
|
||||
forgeSpecific: CheckoutPrForgeSpecificSchema,
|
||||
});
|
||||
|
||||
// Why a forge's PR/MR features are (un)available, so the client can offer the
|
||||
// precise next step instead of a generic dead-end. Kept open on the wire so
|
||||
// feature consumers can ignore values introduced by newer daemons.
|
||||
export type ForgeAuthState =
|
||||
| "authenticated"
|
||||
| "unauthenticated"
|
||||
| "cli_missing"
|
||||
| "no_remote"
|
||||
| "error";
|
||||
|
||||
export const ForgeAuthStateSchema = z.unknown().optional();
|
||||
|
||||
const CheckoutPrStatusPayloadSchema = z.object({
|
||||
cwd: z.string(),
|
||||
status: CheckoutPrStatusSchema.nullable(),
|
||||
githubFeaturesEnabled: z.boolean(),
|
||||
// COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27. Optional richer
|
||||
// signal that supersedes githubFeaturesEnabled. The legacy boolean stays for old clients
|
||||
// and may remain true for non-auth error payloads so old clients still show the error.
|
||||
// Drop the boolean once the daemon floor >= v0.1.106.
|
||||
authState: ForgeAuthStateSchema,
|
||||
// COMPAT(forge): added in v0.1.106, remove the default after 2026-12-27 once daemon floor >= v0.1.106.
|
||||
forge: z.string().optional().default("github"),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
@@ -3821,6 +3975,19 @@ export const CheckoutPrMergeResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const CheckoutForgeSetAutoMergeResponseSchema = z.object({
|
||||
type: z.literal("checkout.forge.set_auto_merge.response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
enabled: z.boolean(),
|
||||
success: z.boolean(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||
export const CheckoutGithubSetAutoMergeResponseSchema = z.object({
|
||||
type: z.literal("checkout.github.set_auto_merge.response"),
|
||||
payload: z.object({
|
||||
@@ -3877,6 +4044,34 @@ const CheckoutGithubCheckJobSchema = z.object({
|
||||
logTruncated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Statuses stay open strings so future forge values cannot break parsing.
|
||||
const CheckoutPipelineJobSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
stage: z.string(),
|
||||
status: z.string(),
|
||||
rawStatus: z.string(),
|
||||
url: z.string().nullable().optional().default(null),
|
||||
allowFailure: z.boolean().optional().default(false),
|
||||
durationSeconds: z.number().nullable().optional().default(null),
|
||||
});
|
||||
|
||||
const CheckoutPipelineStageSchema = z.object({
|
||||
name: z.string(),
|
||||
status: z.string(),
|
||||
jobs: z.array(CheckoutPipelineJobSchema).optional().default([]),
|
||||
});
|
||||
|
||||
const CheckoutPipelineSchema = z.object({
|
||||
id: z.number(),
|
||||
status: z.string(),
|
||||
rawStatus: z.string(),
|
||||
url: z.string().nullable().optional().default(null),
|
||||
ref: z.string().nullable().optional().default(null),
|
||||
sha: z.string().nullable().optional().default(null),
|
||||
stages: z.array(CheckoutPipelineStageSchema).optional().default([]),
|
||||
});
|
||||
|
||||
export const CheckoutGithubCheckDetailsSchema = z.object({
|
||||
checkRunId: z.number(),
|
||||
workflowRunId: z.number().nullable().optional(),
|
||||
@@ -3896,14 +4091,31 @@ export const CheckoutGithubCheckDetailsSchema = z.object({
|
||||
annotations: z.array(CheckoutGithubCheckAnnotationSchema).optional().default([]),
|
||||
failedJobs: z.array(CheckoutGithubCheckJobSchema).optional().default([]),
|
||||
truncated: z.boolean().optional().default(false),
|
||||
// No default: server CheckDetails keeps this optional and GitHub leaves it absent.
|
||||
pipeline: CheckoutPipelineSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export const CheckoutCheckDetailsSchema = CheckoutGithubCheckDetailsSchema;
|
||||
|
||||
export const CheckoutForgeGetCheckDetailsResponseSchema = z.object({
|
||||
type: z.literal("checkout.forge.get_check_details.response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
success: z.boolean(),
|
||||
details: CheckoutCheckDetailsSchema.nullable().optional().default(null),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.get_check_details.*.
|
||||
export const CheckoutGithubGetCheckDetailsResponseSchema = z.object({
|
||||
type: z.literal("checkout.github.get_check_details.response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
success: z.boolean(),
|
||||
details: CheckoutGithubCheckDetailsSchema.nullable().optional().default(null),
|
||||
details: CheckoutCheckDetailsSchema.nullable().optional().default(null),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
@@ -3968,6 +4180,16 @@ const PullRequestTimelineCommentItemSchema = z.object({
|
||||
// threads under their parent review. Absent on issue comments and on
|
||||
// timelines from daemons that predate the field.
|
||||
reviewId: z.string().optional(),
|
||||
// Forge-neutral discussion/thread id this comment belongs to, independent of a
|
||||
// file position. GitLab maps its discussion id here so general (non-file)
|
||||
// reply chains group into one thread; file-position threads also carry it.
|
||||
// Absent on standalone comments and on timelines from daemons that predate it.
|
||||
threadId: z.string().optional(),
|
||||
// Forge-neutral resolution state for a thread that has no file position, e.g. a
|
||||
// GitLab general (non-file) discussion that is resolvable. File-position threads
|
||||
// carry their resolution under `location.isResolved` instead. Absent on ordinary
|
||||
// comments, on forges that expose no thread resolution, and on older timelines.
|
||||
threadIsResolved: z.boolean().optional(),
|
||||
location: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
@@ -4008,6 +4230,10 @@ export const PullRequestTimelineResponseSchema = z.object({
|
||||
error: PullRequestTimelineErrorSchema.nullable().optional().default(null),
|
||||
requestId: z.string().optional().default(""),
|
||||
githubFeaturesEnabled: z.boolean().optional().default(true),
|
||||
// COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27. Optional richer
|
||||
// signal that supersedes githubFeaturesEnabled, mirroring CheckoutPrStatusPayloadSchema.
|
||||
// Drop the boolean once the daemon floor >= v0.1.106.
|
||||
authState: ForgeAuthStateSchema,
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
@@ -4103,14 +4329,32 @@ export const BranchSuggestionsResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const ForgeSearchResponsePayloadSchema = z.object({
|
||||
items: z.array(z.unknown()),
|
||||
authState: z.unknown().optional(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const GitHubSearchResponsePayloadSchema = z.object({
|
||||
items: z.array(z.unknown()),
|
||||
featuresEnabled: z.boolean().optional(),
|
||||
authState: z.unknown().optional(),
|
||||
githubFeaturesEnabled: z.boolean().optional(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ForgeSearchResponseSchema = z.object({
|
||||
type: z.literal("forge.search.response"),
|
||||
payload: ForgeSearchResponsePayloadSchema,
|
||||
});
|
||||
|
||||
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// clients use forge.search.*.
|
||||
export const GitHubSearchResponseSchema = z.object({
|
||||
type: z.literal("github_search_response"),
|
||||
payload: z.object({
|
||||
items: z.array(GitHubSearchItemSchema),
|
||||
githubFeaturesEnabled: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
payload: GitHubSearchResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const DirectorySuggestionsResponseSchema = z.object({
|
||||
@@ -4633,9 +4877,11 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutRefreshResponseSchema,
|
||||
CheckoutPrCreateResponseSchema,
|
||||
CheckoutPrMergeResponseSchema,
|
||||
CheckoutForgeSetAutoMergeResponseSchema,
|
||||
CheckoutGithubSetAutoMergeResponseSchema,
|
||||
CheckoutCommitsListResponseSchema,
|
||||
CheckoutCommitFileDiffResponseSchema,
|
||||
CheckoutForgeGetCheckDetailsResponseSchema,
|
||||
CheckoutGithubGetCheckDetailsResponseSchema,
|
||||
CheckoutPrStatusResponseSchema,
|
||||
PullRequestTimelineResponseSchema,
|
||||
@@ -4646,6 +4892,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
StashListResponseSchema,
|
||||
ValidateBranchResponseSchema,
|
||||
BranchSuggestionsResponseSchema,
|
||||
ForgeSearchResponseSchema,
|
||||
GitHubSearchResponseSchema,
|
||||
DirectorySuggestionsResponseSchema,
|
||||
PaseoWorktreeListResponseSchema,
|
||||
@@ -4871,6 +5118,8 @@ export type DictationStreamFinishMessage = z.infer<typeof DictationStreamFinishM
|
||||
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
|
||||
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
|
||||
export type ForgeChangeRequestAttachment = z.infer<typeof ForgeChangeRequestAttachmentSchema>;
|
||||
export type ForgeIssueAttachment = z.infer<typeof ForgeIssueAttachmentSchema>;
|
||||
export type UploadedFileAttachment = z.infer<typeof UploadedFileAttachmentSchema>;
|
||||
export type FirstAgentContext = z.infer<typeof FirstAgentContextSchema>;
|
||||
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
|
||||
@@ -4960,16 +5209,32 @@ export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSc
|
||||
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
|
||||
export type CheckoutPrMergeResponse = z.infer<typeof CheckoutPrMergeResponseSchema>;
|
||||
export type CheckoutPrMergeMethod = z.infer<typeof CheckoutPrMergeRequestSchema>["mergeMethod"];
|
||||
export type CheckoutForgeSetAutoMergeRequest = z.infer<
|
||||
typeof CheckoutForgeSetAutoMergeRequestSchema
|
||||
>;
|
||||
export type CheckoutForgeSetAutoMergeResponse = z.infer<
|
||||
typeof CheckoutForgeSetAutoMergeResponseSchema
|
||||
>;
|
||||
export type CheckoutGithubSetAutoMergeRequest = z.infer<
|
||||
typeof CheckoutGithubSetAutoMergeRequestSchema
|
||||
>;
|
||||
export type CheckoutGithubSetAutoMergeResponse = z.infer<
|
||||
typeof CheckoutGithubSetAutoMergeResponseSchema
|
||||
>;
|
||||
export type CheckoutForgeGetCheckDetailsRequest = z.infer<
|
||||
typeof CheckoutForgeGetCheckDetailsRequestSchema
|
||||
>;
|
||||
export type CheckoutGithubGetCheckDetailsRequest = z.infer<
|
||||
typeof CheckoutGithubGetCheckDetailsRequestSchema
|
||||
>;
|
||||
export type CheckoutCheckDetails = z.infer<typeof CheckoutCheckDetailsSchema>;
|
||||
export type CheckoutGithubCheckDetails = z.infer<typeof CheckoutGithubCheckDetailsSchema>;
|
||||
export type CheckoutPipeline = z.infer<typeof CheckoutPipelineSchema>;
|
||||
export type CheckoutPipelineStage = z.infer<typeof CheckoutPipelineStageSchema>;
|
||||
export type CheckoutPipelineJob = z.infer<typeof CheckoutPipelineJobSchema>;
|
||||
export type CheckoutForgeGetCheckDetailsResponse = z.infer<
|
||||
typeof CheckoutForgeGetCheckDetailsResponseSchema
|
||||
>;
|
||||
export type CheckoutGithubGetCheckDetailsResponse = z.infer<
|
||||
typeof CheckoutGithubGetCheckDetailsResponseSchema
|
||||
>;
|
||||
@@ -4994,10 +5259,15 @@ export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
|
||||
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
|
||||
export type BranchSuggestionsRequest = z.infer<typeof BranchSuggestionsRequestSchema>;
|
||||
export type BranchSuggestionsResponse = z.infer<typeof BranchSuggestionsResponseSchema>;
|
||||
export type ForgeSearchItem = z.infer<typeof ForgeSearchItemSchema>;
|
||||
export type ForgeSearchKind = "issue" | "change_request";
|
||||
export type ForgeSearchRequest = z.infer<typeof ForgeSearchRequestSchema>;
|
||||
export type ForgeSearchResponse = z.infer<typeof ForgeSearchResponseSchema>;
|
||||
export type GitHubSearchItem = z.infer<typeof GitHubSearchItemSchema>;
|
||||
export type GitHubSearchKind = z.infer<typeof GitHubSearchKindSchema>;
|
||||
export type GitHubSearchRequest = z.infer<typeof GitHubSearchRequestSchema>;
|
||||
export type GitHubSearchResponse = z.infer<typeof GitHubSearchResponseSchema>;
|
||||
export type ChangeRequestCheckoutSource = z.infer<typeof ChangeRequestCheckoutSourceSchema>;
|
||||
export type CreatePaseoWorktreeRequest = z.infer<typeof CreatePaseoWorktreeRequestSchema>;
|
||||
export type DirectorySuggestionsRequest = z.infer<typeof DirectorySuggestionsRequestSchema>;
|
||||
export type DirectorySuggestionsResponse = z.infer<typeof DirectorySuggestionsResponseSchema>;
|
||||
|
||||
@@ -982,6 +982,30 @@ describe("workspace message schemas", () => {
|
||||
expect(checkout?.worktreeRoot).toBe("C:\\repo");
|
||||
});
|
||||
|
||||
test("workspace summary parses without forge and round-trips forge when present", () => {
|
||||
const baseWorkspace = {
|
||||
id: "ws-forge",
|
||||
projectId: "proj",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: "feature",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
scripts: [],
|
||||
} as const;
|
||||
|
||||
// Old daemon: forge omitted -> parses, field absent (client falls back to github).
|
||||
expect(WorkspaceDescriptorPayloadSchema.parse(baseWorkspace).forge).toBeUndefined();
|
||||
|
||||
// New daemon: forge present -> round-trips (open string, like the PR-status forge).
|
||||
expect(
|
||||
WorkspaceDescriptorPayloadSchema.parse({ ...baseWorkspace, forge: "gitlab" }).forge,
|
||||
).toBe("gitlab");
|
||||
});
|
||||
|
||||
test("workspace.create.request rejects old flat backing shape and accepts new source envelope", () => {
|
||||
// Old flat shape with backing enum must be rejected.
|
||||
const oldFlat = WorkspaceCreateRequestSchema.safeParse({
|
||||
|
||||
Reference in New Issue
Block a user