feat(workspace): generate repository API contracts from OpenAPI
This commit is contained in:
@@ -6,10 +6,12 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview",
|
||||
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
|
||||
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check"
|
||||
},
|
||||
"imports": {
|
||||
"$lib/": "./src/lib/",
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
# Generated TypeScript inventory
|
||||
# Generated frontend contracts
|
||||
|
||||
`server-api` is the Rust source authority for the checked-in Server HTTP DTO projections in this directory.
|
||||
Regenerate them from the repository root with:
|
||||
Checked-in files in this directory are generated API contracts. Do not edit them by hand.
|
||||
|
||||
## Repository list/detail/create API
|
||||
|
||||
`repository-api.ts` is the only TypeScript declaration authority for the Repository list, detail, and create request/response/error wire types. It is generated from the schema closure of the five Repository operations in the canonical `openapi/server-api.json` artifact. The generated header pins the in-repository generator name/version/options, canonical input path and digest, and output path.
|
||||
|
||||
Regenerate or verify it from the repository root:
|
||||
|
||||
```sh
|
||||
cargo run -q -p server-api --features typescript --example generate_legacy_typescript > web/workspace/src/lib/generated/legacy-server-api.ts
|
||||
cargo run -q -p server-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts
|
||||
cargo run -q -p server-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
cargo run -q -p server-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts
|
||||
cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts
|
||||
cargo run -q -p server-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts
|
||||
cargo run -q -p server-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts
|
||||
cargo run -q -p server-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts
|
||||
cargo run -q -p server-api --example generate_repository_openapi_types
|
||||
cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
```
|
||||
|
||||
`legacy-server-api.ts` is the bounded aggregate projection retained during the OpenAPI migration. It is not the final OpenAPI-derived Frontend artifact and must not become a second contract authority. The other files are narrower projections generated from the same Rust source.
|
||||
The generator fails closed on unsupported or lossy OpenAPI constructs, including ambiguous nullable unions, external references, unsupported enum literals, and integer ranges that cannot be represented safely by JavaScript numbers. The legacy TypeScript generator imports the two shared Repository source projection types it still needs and no longer declares the migrated Repository list/detail types.
|
||||
|
||||
`ticket-api.ts` and `protocol.ts` are owned by the separate `ticket` and `protocol` authorities respectively.
|
||||
## Other generated contracts
|
||||
|
||||
The remaining files retain their generator command in their header. `legacy-server-api.ts` is transitional: new bounded domain slices should use a focused contract artifact instead of adding another API surface to it.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.
|
||||
// Do not edit this file directly.
|
||||
|
||||
import type { RepositoryObservedStatus, RepositorySource } from "./repository-api";
|
||||
|
||||
export type WorkspaceSummary = { workspace_id: string, owner_account_id: string, display_name: string, state: string, created_at: string, updated_at: string, };
|
||||
|
||||
export type WorkspaceCatalogListResponse = Array<WorkspaceSummary>;
|
||||
@@ -63,31 +65,8 @@ export type WorkspaceProfileSourceSummary = { profile_source_id: string, display
|
||||
|
||||
export type WorkspaceProfileSourceProvenance = "project_profile_source_tree";
|
||||
|
||||
export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "invalid";
|
||||
|
||||
export type RepositorySource = { kind: RepositorySourceKind,
|
||||
/**
|
||||
* Canonical source representation. This is an absolute local path for
|
||||
* `local_path`, and a normalized URI/remote specification otherwise.
|
||||
*/
|
||||
uri: string, };
|
||||
|
||||
export type RepositoryObservedStatus = "unverified" | "ready" | "invalid";
|
||||
|
||||
export type RepositoryDiagnostic = { severity: string, code: string, message: string, };
|
||||
|
||||
export type GitRemoteSummary = { name: string, fetch_url: string, };
|
||||
|
||||
export type GitRepositorySummary = { status: string, head: string | null, branch: string | null, dirty: boolean, remotes: Array<GitRemoteSummary>, };
|
||||
|
||||
export type RepositorySummary = { repository_key: string, kind: string, provider: string, source: RepositorySource, source_revision: number, source_fingerprint: string, observed_status: RepositoryObservedStatus, observed_at?: string | null, default_selector?: string | null, record_authority: string, git?: GitRepositorySummary | null, diagnostics?: Array<RepositoryDiagnostic> | null, };
|
||||
|
||||
export type GitCommitSummary = { hash: string, short_hash: string, summary: string, author_name: string, author_email: string, author_date: string, parents: Array<string>, refs: Array<string>, };
|
||||
|
||||
export type RepositoryListResponse = { workspace_id: string, items: Array<RepositorySummary>, source: string, diagnostics: Array<Diagnostic>, };
|
||||
|
||||
export type RepositoryDetailResponse = { workspace_id: string, item: RepositorySummary, source: string, };
|
||||
|
||||
export type RepositorySshConnectionProbeRequest = { runtime_id: string, };
|
||||
|
||||
export type RepositorySshHostKeyCandidate = { algorithm: string, host_key: string, fingerprint: string, };
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
export type CreateWorkspaceRepositoryRequest = { default_ref?: string | null; repository_key: string; source: string; };
|
||||
|
||||
export type CreateWorkspaceRepositoryResponse = { replayed: boolean; repository_key: string; workspace_id: string; };
|
||||
|
||||
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type GitRemoteSummary = { fetch_url: string; name: string; };
|
||||
|
||||
export type GitRepositorySummary = { branch?: string | null; dirty: boolean; head?: string | null; remotes: Array<GitRemoteSummary>; status: string; };
|
||||
|
||||
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
|
||||
|
||||
export type RepositoryDetailResponse = { item: RepositorySummary; source: string; workspace_id: string; };
|
||||
|
||||
export type RepositoryDiagnostic = { code: string; message: string; severity: string; };
|
||||
|
||||
export type RepositoryListResponse = { diagnostics: Array<Diagnostic>; items: Array<RepositorySummary>; source: string; workspace_id: string; };
|
||||
|
||||
export type RepositoryObservedStatus = "unverified" | "ready" | "invalid";
|
||||
|
||||
export type RepositorySource = { kind: RepositorySourceKind; uri: string; };
|
||||
|
||||
export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "invalid";
|
||||
|
||||
export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array<RepositoryDiagnostic> | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; source_revision: number; };
|
||||
@@ -16,7 +16,7 @@ export type ApiResult<T> = {
|
||||
|
||||
export type { SkillCatalogResponse, SkillDetailResponse };
|
||||
|
||||
type JsonLoadPolicy = {
|
||||
export type JsonLoadPolicy = {
|
||||
diagnosticLabel: string;
|
||||
maxResponseBytes: number;
|
||||
};
|
||||
@@ -133,7 +133,7 @@ export async function loadJson<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedJson(
|
||||
export async function readBoundedJson(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<unknown> {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
CreateWorkspaceRepositoryRequest,
|
||||
CreateWorkspaceRepositoryResponse,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryListResponse,
|
||||
} from "$lib/generated/repository-api.ts";
|
||||
import {
|
||||
type ApiResult,
|
||||
loadJson,
|
||||
readBoundedJson,
|
||||
workspaceApiPath,
|
||||
} from "$lib/workspace/api/http.ts";
|
||||
import {
|
||||
parseCreateWorkspaceRepositoryResponse,
|
||||
parseRepositoryApiError,
|
||||
parseRepositoryDetailResponse,
|
||||
parseRepositoryListResponse,
|
||||
} from "$lib/workspace/api/workspace-model.ts";
|
||||
|
||||
export const REPOSITORY_API_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const REPOSITORY_API_LOAD_POLICY = {
|
||||
diagnosticLabel: "Repository API",
|
||||
maxResponseBytes: REPOSITORY_API_MAX_RESPONSE_BYTES,
|
||||
} as const;
|
||||
|
||||
export function workspaceRepositoryListPath(workspaceId: string): string {
|
||||
return workspaceApiPath(workspaceId, "/repositories");
|
||||
}
|
||||
|
||||
export function workspaceRepositoryDetailPath(
|
||||
workspaceId: string,
|
||||
repositoryKey: string,
|
||||
): string {
|
||||
return workspaceApiPath(
|
||||
workspaceId,
|
||||
`/repositories/${encodeURIComponent(repositoryKey)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorkspaceRepositoryList(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
): Promise<ApiResult<RepositoryListResponse>> {
|
||||
return loadJson(
|
||||
fetchFn,
|
||||
workspaceRepositoryListPath(workspaceId),
|
||||
undefined,
|
||||
parseRepositoryListResponse,
|
||||
REPOSITORY_API_LOAD_POLICY,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadWorkspaceRepositoryDetail(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
repositoryKey: string,
|
||||
): Promise<ApiResult<RepositoryDetailResponse>> {
|
||||
return loadJson(
|
||||
fetchFn,
|
||||
workspaceRepositoryDetailPath(workspaceId, repositoryKey),
|
||||
undefined,
|
||||
parseRepositoryDetailResponse,
|
||||
REPOSITORY_API_LOAD_POLICY,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createWorkspaceRepository(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
request: CreateWorkspaceRepositoryRequest,
|
||||
): Promise<ApiResult<CreateWorkspaceRepositoryResponse>> {
|
||||
const path = workspaceRepositoryListPath(workspaceId);
|
||||
try {
|
||||
const response = await fetchFn(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
const payload = await readBoundedJson(
|
||||
response,
|
||||
REPOSITORY_API_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const error = parseRepositoryApiError(payload);
|
||||
return { data: null, error: error.message };
|
||||
} catch {
|
||||
return {
|
||||
data: null,
|
||||
error: `Repository API request failed with HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: parseCreateWorkspaceRepositoryResponse(payload),
|
||||
error: null,
|
||||
};
|
||||
} catch {
|
||||
return { data: null, error: "Repository API response is invalid" };
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
parseRepositoryListResponse,
|
||||
parseWorkspaceCatalogResponse,
|
||||
parseWorkspaceCreateResponse,
|
||||
type RepositorySummary,
|
||||
type WorkspaceCreateResponse,
|
||||
type WorkspaceSummary,
|
||||
} from "$lib/workspace/api/workspace-model";
|
||||
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories.ts";
|
||||
|
||||
export type WorkspaceCatalogRecord = WorkspaceSummary;
|
||||
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
|
||||
@@ -48,12 +48,14 @@ export async function listWorkspaceRepositories(
|
||||
fetcher: Fetch,
|
||||
workspaceId: string,
|
||||
): Promise<RepositorySummary[]> {
|
||||
return parseRepositoryListResponse(
|
||||
await fetchJson(
|
||||
fetcher,
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
|
||||
),
|
||||
).items;
|
||||
const result = await loadWorkspaceRepositoryList(fetcher, workspaceId);
|
||||
if (result.data === null) {
|
||||
throw new WorkspaceCatalogError(
|
||||
null,
|
||||
result.error ?? "Repository API request failed",
|
||||
);
|
||||
}
|
||||
return result.data.items;
|
||||
}
|
||||
|
||||
export async function loadWorkspaceCatalog(
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
import type {
|
||||
Diagnostic,
|
||||
GitCommitSummary,
|
||||
GitRemoteSummary,
|
||||
GitRepositorySummary,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryDiagnostic,
|
||||
RepositoryListResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositorySource,
|
||||
RepositorySourceKind,
|
||||
RepositorySshConnectionProbeResponse,
|
||||
RepositorySshConnectionTrustState,
|
||||
RepositorySshHostKeyCandidate,
|
||||
RepositorySummary,
|
||||
WorkspaceAuthConfig,
|
||||
WorkspaceCatalogListResponse,
|
||||
WorkspaceCreateResponse,
|
||||
@@ -30,17 +21,25 @@ import type {
|
||||
WorkspaceResponse,
|
||||
WorkspaceSummary,
|
||||
} from "$lib/generated/legacy-server-api.ts";
|
||||
import type {
|
||||
CreateWorkspaceRepositoryResponse,
|
||||
Diagnostic,
|
||||
GitRemoteSummary,
|
||||
GitRepositorySummary,
|
||||
RepositoryApiError,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryDiagnostic,
|
||||
RepositoryListResponse,
|
||||
RepositorySource,
|
||||
RepositorySourceKind,
|
||||
RepositorySummary,
|
||||
} from "$lib/generated/repository-api.ts";
|
||||
|
||||
export type {
|
||||
GitCommitSummary,
|
||||
GitRemoteSummary,
|
||||
GitRepositorySummary,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryListResponse,
|
||||
RepositoryLogResponse,
|
||||
RepositorySshConnectionProbeResponse,
|
||||
RepositorySshHostKeyCandidate,
|
||||
RepositorySummary,
|
||||
WorkspaceCatalogListResponse,
|
||||
WorkspaceCreateResponse,
|
||||
WorkspaceDeletionOperationResponse,
|
||||
@@ -49,6 +48,18 @@ export type {
|
||||
WorkspaceResponse,
|
||||
WorkspaceSummary,
|
||||
} from "$lib/generated/legacy-server-api.ts";
|
||||
export type {
|
||||
CreateWorkspaceRepositoryRequest,
|
||||
CreateWorkspaceRepositoryResponse,
|
||||
GitRemoteSummary,
|
||||
GitRepositorySummary,
|
||||
RepositoryApiError,
|
||||
RepositoryDetailResponse,
|
||||
RepositoryListResponse,
|
||||
RepositorySource,
|
||||
RepositorySourceKind,
|
||||
RepositorySummary,
|
||||
} from "$lib/generated/repository-api.ts";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
@@ -62,6 +73,11 @@ const SOURCE_KINDS = new Set<RepositorySourceKind>([
|
||||
const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]);
|
||||
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
|
||||
|
||||
export const REPOSITORY_API_LIMITS = {
|
||||
maxStringCodeUnits: 65_536,
|
||||
maxCollectionEntries: 4_096,
|
||||
} as const;
|
||||
|
||||
function object(value: unknown, path: string): JsonObject {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${path} must be an object`);
|
||||
@@ -95,6 +111,26 @@ function nullableString(value: unknown, path: string): string | null {
|
||||
return value === null ? null : string(value, path);
|
||||
}
|
||||
|
||||
function repositoryString(value: unknown, path: string): string {
|
||||
const parsed = string(value, path);
|
||||
if (parsed.length > REPOSITORY_API_LIMITS.maxStringCodeUnits) {
|
||||
throw new Error(`${path} exceeds the Repository API string limit`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nullableRepositoryString(value: unknown, path: string): string | null {
|
||||
return value === null ? null : repositoryString(value, path);
|
||||
}
|
||||
|
||||
function repositoryArray(value: unknown, path: string): unknown[] {
|
||||
const parsed = array(value, path);
|
||||
if (parsed.length > REPOSITORY_API_LIMITS.maxCollectionEntries) {
|
||||
throw new Error(`${path} exceeds the Repository API collection limit`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalNullableString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
@@ -102,6 +138,15 @@ function optionalNullableString(
|
||||
return value === undefined ? undefined : nullableString(value, path);
|
||||
}
|
||||
|
||||
function optionalNullableRepositoryString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): string | null | undefined {
|
||||
return value === undefined
|
||||
? undefined
|
||||
: nullableRepositoryString(value, path);
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: JsonObject,
|
||||
keys: readonly string[],
|
||||
@@ -128,6 +173,20 @@ function diagnostic(value: unknown, path: string): Diagnostic {
|
||||
};
|
||||
}
|
||||
|
||||
function repositoryApiDiagnostic(value: unknown, path: string): Diagnostic {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["code", "severity", "message"], path);
|
||||
const severity = repositoryString(item.severity, `${path}.severity`);
|
||||
if (!DIAGNOSTIC_SEVERITIES.has(severity)) {
|
||||
throw new Error(`${path}.severity is invalid`);
|
||||
}
|
||||
return {
|
||||
code: repositoryString(item.code, `${path}.code`),
|
||||
severity: severity as Diagnostic["severity"],
|
||||
message: repositoryString(item.message, `${path}.message`),
|
||||
};
|
||||
}
|
||||
|
||||
function repositoryDiagnostic(
|
||||
value: unknown,
|
||||
path: string,
|
||||
@@ -135,22 +194,22 @@ function repositoryDiagnostic(
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["severity", "code", "message"], path);
|
||||
return {
|
||||
severity: string(item.severity, `${path}.severity`),
|
||||
code: string(item.code, `${path}.code`),
|
||||
message: string(item.message, `${path}.message`),
|
||||
severity: repositoryString(item.severity, `${path}.severity`),
|
||||
code: repositoryString(item.code, `${path}.code`),
|
||||
message: repositoryString(item.message, `${path}.message`),
|
||||
};
|
||||
}
|
||||
|
||||
function repositorySource(value: unknown, path: string): RepositorySource {
|
||||
const source = object(value, path);
|
||||
exactKeys(source, ["kind", "uri"], path);
|
||||
const kind = string(source.kind, `${path}.kind`);
|
||||
const kind = repositoryString(source.kind, `${path}.kind`);
|
||||
if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) {
|
||||
throw new Error(`${path}.kind is invalid`);
|
||||
}
|
||||
return {
|
||||
kind: kind as RepositorySourceKind,
|
||||
uri: string(source.uri, `${path}.uri`),
|
||||
uri: repositoryString(source.uri, `${path}.uri`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,8 +217,8 @@ function gitRemote(value: unknown, path: string): GitRemoteSummary {
|
||||
const remote = object(value, path);
|
||||
exactKeys(remote, ["name", "fetch_url"], path);
|
||||
return {
|
||||
name: string(remote.name, `${path}.name`),
|
||||
fetch_url: string(remote.fetch_url, `${path}.fetch_url`),
|
||||
name: repositoryString(remote.name, `${path}.name`),
|
||||
fetch_url: repositoryString(remote.fetch_url, `${path}.fetch_url`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,13 +226,14 @@ function gitSummary(value: unknown, path: string): GitRepositorySummary {
|
||||
const git = object(value, path);
|
||||
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
|
||||
return {
|
||||
status: string(git.status, `${path}.status`),
|
||||
head: nullableString(git.head, `${path}.head`),
|
||||
branch: nullableString(git.branch, `${path}.branch`),
|
||||
status: repositoryString(git.status, `${path}.status`),
|
||||
head: nullableRepositoryString(git.head, `${path}.head`),
|
||||
branch: nullableRepositoryString(git.branch, `${path}.branch`),
|
||||
dirty: boolean(git.dirty, `${path}.dirty`),
|
||||
remotes: array(git.remotes, `${path}.remotes`).map((item, index) =>
|
||||
gitRemote(item, `${path}.remotes[${index}]`)
|
||||
),
|
||||
remotes: repositoryArray(git.remotes, `${path}.remotes`).map((
|
||||
item,
|
||||
index,
|
||||
) => gitRemote(item, `${path}.remotes[${index}]`)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,7 +257,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
|
||||
],
|
||||
path,
|
||||
);
|
||||
const observedStatus = string(
|
||||
const observedStatus = repositoryString(
|
||||
item.observed_status,
|
||||
`${path}.observed_status`,
|
||||
);
|
||||
@@ -207,29 +267,36 @@ function repositorySummary(value: unknown, path: string): RepositorySummary {
|
||||
const diagnostics =
|
||||
item.diagnostics === undefined || item.diagnostics === null
|
||||
? item.diagnostics
|
||||
: array(item.diagnostics, `${path}.diagnostics`).map((entry, index) =>
|
||||
repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`)
|
||||
);
|
||||
: repositoryArray(item.diagnostics, `${path}.diagnostics`).map((
|
||||
entry,
|
||||
index,
|
||||
) => repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`));
|
||||
return {
|
||||
repository_key: string(item.repository_key, `${path}.repository_key`),
|
||||
kind: string(item.kind, `${path}.kind`),
|
||||
provider: string(item.provider, `${path}.provider`),
|
||||
repository_key: repositoryString(
|
||||
item.repository_key,
|
||||
`${path}.repository_key`,
|
||||
),
|
||||
kind: repositoryString(item.kind, `${path}.kind`),
|
||||
provider: repositoryString(item.provider, `${path}.provider`),
|
||||
source: repositorySource(item.source, `${path}.source`),
|
||||
source_revision: integer(item.source_revision, `${path}.source_revision`),
|
||||
source_fingerprint: string(
|
||||
source_fingerprint: repositoryString(
|
||||
item.source_fingerprint,
|
||||
`${path}.source_fingerprint`,
|
||||
),
|
||||
observed_status: observedStatus as RepositorySummary["observed_status"],
|
||||
observed_at: optionalNullableString(
|
||||
observed_at: optionalNullableRepositoryString(
|
||||
item.observed_at,
|
||||
`${path}.observed_at`,
|
||||
),
|
||||
default_selector: optionalNullableString(
|
||||
default_selector: optionalNullableRepositoryString(
|
||||
item.default_selector,
|
||||
`${path}.default_selector`,
|
||||
),
|
||||
record_authority: string(item.record_authority, `${path}.record_authority`),
|
||||
record_authority: repositoryString(
|
||||
item.record_authority,
|
||||
`${path}.record_authority`,
|
||||
),
|
||||
git: item.git === undefined || item.git === null
|
||||
? item.git
|
||||
: gitSummary(item.git, `${path}.git`),
|
||||
@@ -287,7 +354,7 @@ function workspaceRepositoryRecord(
|
||||
],
|
||||
path,
|
||||
);
|
||||
const observedStatus = string(
|
||||
const observedStatus = repositoryString(
|
||||
item.observed_status,
|
||||
`${path}.observed_status`,
|
||||
);
|
||||
@@ -302,7 +369,7 @@ function workspaceRepositoryRecord(
|
||||
source: repositorySource(item.source, `${path}.source`),
|
||||
default_ref: nullableString(item.default_ref, `${path}.default_ref`),
|
||||
source_revision: integer(item.source_revision, `${path}.source_revision`),
|
||||
source_fingerprint: string(
|
||||
source_fingerprint: repositoryString(
|
||||
item.source_fingerprint,
|
||||
`${path}.source_fingerprint`,
|
||||
),
|
||||
@@ -533,21 +600,28 @@ export function parseRepositoryListResponse(
|
||||
"repository list response",
|
||||
);
|
||||
return {
|
||||
workspace_id: string(
|
||||
workspace_id: repositoryString(
|
||||
response.workspace_id,
|
||||
"repository list response.workspace_id",
|
||||
),
|
||||
items: array(response.items, "repository list response.items").map((
|
||||
item,
|
||||
index,
|
||||
) => repositorySummary(item, `repository list response.items[${index}]`)),
|
||||
source: string(response.source, "repository list response.source"),
|
||||
diagnostics: array(
|
||||
items: repositoryArray(response.items, "repository list response.items")
|
||||
.map((
|
||||
item,
|
||||
index,
|
||||
) => repositorySummary(item, `repository list response.items[${index}]`)),
|
||||
source: repositoryString(
|
||||
response.source,
|
||||
"repository list response.source",
|
||||
),
|
||||
diagnostics: repositoryArray(
|
||||
response.diagnostics,
|
||||
"repository list response.diagnostics",
|
||||
).map(
|
||||
(item, index) =>
|
||||
diagnostic(item, `repository list response.diagnostics[${index}]`),
|
||||
repositoryApiDiagnostic(
|
||||
item,
|
||||
`repository list response.diagnostics[${index}]`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -578,12 +652,64 @@ export function parseRepositoryDetailResponse(
|
||||
"repository detail response",
|
||||
);
|
||||
return {
|
||||
workspace_id: string(
|
||||
workspace_id: repositoryString(
|
||||
response.workspace_id,
|
||||
"repository detail response.workspace_id",
|
||||
),
|
||||
item: repositorySummary(response.item, "repository detail response.item"),
|
||||
source: string(response.source, "repository detail response.source"),
|
||||
source: repositoryString(
|
||||
response.source,
|
||||
"repository detail response.source",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCreateWorkspaceRepositoryResponse(
|
||||
value: unknown,
|
||||
): CreateWorkspaceRepositoryResponse {
|
||||
const response = object(value, "repository create response");
|
||||
exactKeys(
|
||||
response,
|
||||
["replayed", "repository_key", "workspace_id"],
|
||||
"repository create response",
|
||||
);
|
||||
return {
|
||||
replayed: boolean(response.replayed, "repository create response.replayed"),
|
||||
repository_key: repositoryString(
|
||||
response.repository_key,
|
||||
"repository create response.repository_key",
|
||||
),
|
||||
workspace_id: repositoryString(
|
||||
response.workspace_id,
|
||||
"repository create response.workspace_id",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRepositoryApiError(value: unknown): RepositoryApiError {
|
||||
const response = object(value, "repository error response");
|
||||
exactKeys(
|
||||
response,
|
||||
["diagnostics", "error", "message"],
|
||||
"repository error response",
|
||||
);
|
||||
return {
|
||||
diagnostics: response.diagnostics === undefined
|
||||
? undefined
|
||||
: repositoryArray(
|
||||
response.diagnostics,
|
||||
"repository error response.diagnostics",
|
||||
).map((item, index) =>
|
||||
repositoryApiDiagnostic(
|
||||
item,
|
||||
`repository error response.diagnostics[${index}]`,
|
||||
)
|
||||
),
|
||||
error: repositoryString(response.error, "repository error response.error"),
|
||||
message: repositoryString(
|
||||
response.message,
|
||||
"repository error response.message",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
parseRepositoryListResponse,
|
||||
parseWorkspaceResponse,
|
||||
} from "$lib/workspace/api/workspace-model";
|
||||
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
|
||||
import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
|
||||
export const load: LayoutLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const [workspaceResult, repositoryResult] = await Promise.all([
|
||||
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/workspace")),
|
||||
loadJson<unknown>(fetch, workspaceApiPath(workspaceId, "/repositories")),
|
||||
loadWorkspaceRepositoryList(fetch, workspaceId),
|
||||
]);
|
||||
|
||||
let workspace = null;
|
||||
@@ -30,17 +28,8 @@ export const load: LayoutLoad = async ({ fetch, params }) => {
|
||||
});
|
||||
}
|
||||
|
||||
let repositories = null;
|
||||
let repositoriesError = repositoryResult.error;
|
||||
if (repositoryResult.data !== null) {
|
||||
try {
|
||||
repositories = parseRepositoryListResponse(repositoryResult.data);
|
||||
} catch (cause) {
|
||||
repositoriesError = cause instanceof Error
|
||||
? cause.message
|
||||
: "invalid repository list response";
|
||||
}
|
||||
}
|
||||
const repositories = repositoryResult.data;
|
||||
const repositoriesError = repositoryResult.error;
|
||||
|
||||
return {
|
||||
workspace,
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { loadWorkspaceRepositoryDetail } from "$lib/workspace/api/repositories";
|
||||
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
|
||||
import {
|
||||
parseRepositoryDetailResponse,
|
||||
parseRepositoryLogResponse,
|
||||
} from "$lib/workspace/api/workspace-model";
|
||||
import { parseRepositoryLogResponse } from "$lib/workspace/api/workspace-model";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const repositoryKey = params.repositoryKey;
|
||||
const [repositoryResult, logResult, runtimesResult] = await Promise.all([
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/repositories/${encodeURIComponent(repositoryKey)}`,
|
||||
),
|
||||
),
|
||||
loadWorkspaceRepositoryDetail(fetch, workspaceId, repositoryKey),
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
@@ -30,17 +22,8 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
),
|
||||
]);
|
||||
|
||||
let repository = null;
|
||||
let repositoryError = repositoryResult.error;
|
||||
if (repositoryResult.data !== null) {
|
||||
try {
|
||||
repository = parseRepositoryDetailResponse(repositoryResult.data);
|
||||
} catch (cause) {
|
||||
repositoryError = cause instanceof Error
|
||||
? cause.message
|
||||
: "invalid repository detail response";
|
||||
}
|
||||
}
|
||||
const repository = repositoryResult.data;
|
||||
const repositoryError = repositoryResult.error;
|
||||
|
||||
let log = null;
|
||||
let logError = logResult.error;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { workspaceApiPath, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type { RepositorySourceKind } from '$lib/generated/legacy-server-api';
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { createWorkspaceRepository } from '$lib/workspace/api/repositories';
|
||||
import type {
|
||||
CreateWorkspaceRepositoryRequest,
|
||||
RepositorySourceKind,
|
||||
} from '$lib/generated/repository-api';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -22,38 +26,27 @@
|
||||
let pending = $state(false);
|
||||
let requestError = $state<string | null>(null);
|
||||
|
||||
async function responseError(response: Response): Promise<string> {
|
||||
const payload = await response.json().catch(() => null) as
|
||||
| { message?: string; error?: string }
|
||||
| null;
|
||||
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
|
||||
}
|
||||
|
||||
async function addRepository(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
pending = true;
|
||||
requestError = null;
|
||||
try {
|
||||
const response = await fetch(workspaceApiPath(data.workspaceId, '/repositories'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
repository_key: repositoryKey,
|
||||
source,
|
||||
default_ref: defaultRef || null,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
repositoryKey = '';
|
||||
source = '';
|
||||
defaultRef = '';
|
||||
showAddRepository = false;
|
||||
await invalidateAll();
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
const request: CreateWorkspaceRepositoryRequest = {
|
||||
repository_key: repositoryKey,
|
||||
source,
|
||||
default_ref: defaultRef || null,
|
||||
};
|
||||
const result = await createWorkspaceRepository(fetch, data.workspaceId, request);
|
||||
if (result.data === null) {
|
||||
requestError = result.error ?? 'Repository creation failed';
|
||||
pending = false;
|
||||
return;
|
||||
}
|
||||
repositoryKey = '';
|
||||
source = '';
|
||||
defaultRef = '';
|
||||
showAddRepository = false;
|
||||
await invalidateAll();
|
||||
pending = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { parseRepositoryListResponse } from "$lib/workspace/api/workspace-model";
|
||||
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const repositories = await loadJson(
|
||||
const repositories = await loadWorkspaceRepositoryList(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/repositories"),
|
||||
undefined,
|
||||
parseRepositoryListResponse,
|
||||
params.workspaceId,
|
||||
);
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { parseRepositoryListApiResult } from "$lib/workspace/api/workspace-model";
|
||||
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
|
||||
import {
|
||||
canonicalResourceReference,
|
||||
resourceKey,
|
||||
@@ -17,10 +17,7 @@ export const load = (async ({ fetch, params }) => {
|
||||
);
|
||||
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
|
||||
loadJson<TicketDetail>(fetch, ticketPath),
|
||||
loadJson<unknown>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/repositories"),
|
||||
),
|
||||
loadWorkspaceRepositoryList(fetch, params.workspaceId),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
||||
@@ -40,7 +37,7 @@ export const load = (async ({ fetch, params }) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
const repositories = parseRepositoryListApiResult(repositoriesRaw);
|
||||
const repositories = repositoriesRaw;
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
|
||||
@@ -20,29 +20,39 @@ const loadSource = await Deno.readTextFile(
|
||||
),
|
||||
);
|
||||
|
||||
const apiSource = await Deno.readTextFile(
|
||||
new URL("../../src/lib/workspace/api/repositories.ts", import.meta.url),
|
||||
);
|
||||
|
||||
test("Repository settings use the scoped list and typed create collection", () => {
|
||||
for (const token of [
|
||||
"workspaceApiPath(params.workspaceId, \"/repositories\")",
|
||||
"workspaceApiPath(data.workspaceId, '/repositories')",
|
||||
"method: 'POST'",
|
||||
"repository_key: repositoryKey",
|
||||
"default_ref: defaultRef || null",
|
||||
"await invalidateAll()",
|
||||
]) {
|
||||
const source = `${pageSource}\n${loadSource}\n${apiSource}`;
|
||||
for (
|
||||
const token of [
|
||||
"loadWorkspaceRepositoryList",
|
||||
"createWorkspaceRepository",
|
||||
"workspaceRepositoryListPath(workspaceId)",
|
||||
'method: "POST"',
|
||||
"repository_key: repositoryKey",
|
||||
"default_ref: defaultRef || null",
|
||||
"await invalidateAll()",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
pageSource.includes(token) || loadSource.includes(token),
|
||||
source.includes(token),
|
||||
`Repository settings should include ${token}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Repository Add form keeps access secrets outside registration input", () => {
|
||||
for (const forbidden of [
|
||||
"private_key",
|
||||
"passphrase",
|
||||
"credential_id",
|
||||
"host_trust_id",
|
||||
]) {
|
||||
for (
|
||||
const forbidden of [
|
||||
"private_key",
|
||||
"passphrase",
|
||||
"credential_id",
|
||||
"host_trust_id",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
!pageSource.includes(forbidden),
|
||||
`Repository registration must not accept ${forbidden}`,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import {
|
||||
createWorkspaceRepository,
|
||||
loadWorkspaceRepositoryList,
|
||||
REPOSITORY_API_MAX_RESPONSE_BYTES,
|
||||
} from "../src/lib/workspace/api/repositories.ts";
|
||||
|
||||
const repository = {
|
||||
repository_key: "main",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
source: { kind: "local_path", uri: "/srv/main" },
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:main",
|
||||
observed_status: "ready",
|
||||
record_authority: "workspace-control-plane",
|
||||
};
|
||||
|
||||
Deno.test("Repository API loaders parse unknown JSON through the strict generated contract", async () => {
|
||||
const valid = await loadWorkspaceRepositoryList(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
workspace_id: "workspace-a",
|
||||
items: [repository],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
}),
|
||||
),
|
||||
"workspace-a",
|
||||
);
|
||||
if (valid.data?.items[0]?.repository_key !== "main") {
|
||||
throw new Error(`unexpected load result: ${valid.error}`);
|
||||
}
|
||||
|
||||
const invalid = await loadWorkspaceRepositoryList(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
workspace_id: "workspace-a",
|
||||
items: [{ ...repository, observed_status: "future_status" }],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
}),
|
||||
),
|
||||
"workspace-a",
|
||||
);
|
||||
if (
|
||||
invalid.data !== null ||
|
||||
!invalid.error?.includes("Repository API response is invalid")
|
||||
) {
|
||||
throw new Error("unknown enum values must fail closed in the loader");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository create client validates request, response, and typed API errors", async () => {
|
||||
const success = await createWorkspaceRepository(
|
||||
(_input, init) => {
|
||||
const request: unknown = JSON.parse(String(init?.body));
|
||||
if (
|
||||
typeof request !== "object" || request === null ||
|
||||
!("repository_key" in request) || request.repository_key !== "main"
|
||||
) {
|
||||
throw new Error("typed create request was not serialized");
|
||||
}
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
workspace_id: "workspace-a",
|
||||
repository_key: "main",
|
||||
replayed: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
"workspace-a",
|
||||
{ repository_key: "main", source: "/srv/main", default_ref: null },
|
||||
);
|
||||
if (success.data?.repository_key !== "main" || success.data.replayed) {
|
||||
throw new Error(`unexpected create result: ${success.error}`);
|
||||
}
|
||||
|
||||
const rejected = await createWorkspaceRepository(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json(
|
||||
{ error: "invalid_source", message: "source is invalid" },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
"workspace-a",
|
||||
{ repository_key: "main", source: "bad", default_ref: null },
|
||||
);
|
||||
if (rejected.data !== null || rejected.error !== "source is invalid") {
|
||||
throw new Error("typed RepositoryApiError message was not preserved");
|
||||
}
|
||||
|
||||
const invalid = await createWorkspaceRepository(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
workspace_id: "workspace-a",
|
||||
repository_key: "main",
|
||||
replayed: null,
|
||||
}),
|
||||
),
|
||||
"workspace-a",
|
||||
{ repository_key: "main", source: "/srv/main", default_ref: null },
|
||||
);
|
||||
if (
|
||||
invalid.data !== null ||
|
||||
invalid.error !== "Repository API response is invalid"
|
||||
) {
|
||||
throw new Error("invalid create success payload must fail closed");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository API clients bound response bodies before parsing JSON", async () => {
|
||||
const result = await loadWorkspaceRepositoryList(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response("{}", {
|
||||
headers: {
|
||||
"content-length": String(REPOSITORY_API_MAX_RESPONSE_BYTES + 1),
|
||||
},
|
||||
}),
|
||||
),
|
||||
"workspace-a",
|
||||
);
|
||||
if (
|
||||
result.data !== null || !result.error?.includes("exceeds its byte limit")
|
||||
) {
|
||||
throw new Error(`oversized response was not rejected: ${result.error}`);
|
||||
}
|
||||
});
|
||||
@@ -4,11 +4,14 @@ declare const Deno: {
|
||||
};
|
||||
|
||||
import {
|
||||
parseCreateWorkspaceRepositoryResponse,
|
||||
parseRepositoryApiError,
|
||||
parseRepositoryListApiResult,
|
||||
parseRepositoryListResponse,
|
||||
parseWorkspaceDeletionOperationResponse,
|
||||
parseWorkspaceDeletionPreflightResponse,
|
||||
parseWorkspaceResponse,
|
||||
REPOSITORY_API_LIMITS,
|
||||
} from "../src/lib/workspace/api/workspace-model.ts";
|
||||
|
||||
function assertThrows(operation: () => unknown, expected: string): void {
|
||||
@@ -71,6 +74,63 @@ Deno.test("stale repository aliases fail closed at the JSON boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("repository create/error responses use the generated strict contract", () => {
|
||||
const created = parseCreateWorkspaceRepositoryResponse({
|
||||
workspace_id: repositoryList.workspace_id,
|
||||
repository_key: "main",
|
||||
replayed: false,
|
||||
});
|
||||
if (created.replayed || created.repository_key !== "main") {
|
||||
throw new Error("create response fields were not preserved");
|
||||
}
|
||||
const error = parseRepositoryApiError({
|
||||
error: "invalid_request",
|
||||
message: "source is invalid",
|
||||
});
|
||||
if (error.error !== "invalid_request") {
|
||||
throw new Error("error response fields were not preserved");
|
||||
}
|
||||
assertThrows(
|
||||
() =>
|
||||
parseCreateWorkspaceRepositoryResponse({
|
||||
workspace_id: repositoryList.workspace_id,
|
||||
repository_key: "main",
|
||||
replayed: null,
|
||||
}),
|
||||
".replayed must be a boolean",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseRepositoryApiError({
|
||||
error: "invalid_request",
|
||||
message: "source is invalid",
|
||||
context: {},
|
||||
}),
|
||||
".context is not part",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("repository responses retain bounded strings and collections", () => {
|
||||
const oversizedString = structuredClone(repositoryList);
|
||||
oversizedString.items[0].provider = "x".repeat(
|
||||
REPOSITORY_API_LIMITS.maxStringCodeUnits + 1,
|
||||
);
|
||||
assertThrows(
|
||||
() => parseRepositoryListResponse(oversizedString),
|
||||
"string limit",
|
||||
);
|
||||
|
||||
const oversizedCollection = structuredClone(repositoryList);
|
||||
oversizedCollection.items = Array.from(
|
||||
{ length: REPOSITORY_API_LIMITS.maxCollectionEntries + 1 },
|
||||
() => repositoryList.items[0],
|
||||
);
|
||||
assertThrows(
|
||||
() => parseRepositoryListResponse(oversizedCollection),
|
||||
"collection limit",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("repository API result converts stale payloads into bounded page errors", () => {
|
||||
const result = parseRepositoryListApiResult({
|
||||
data: {
|
||||
@@ -227,7 +287,7 @@ Deno.test("Workspace settings exposes owner-gated typed destructive confirmation
|
||||
});
|
||||
|
||||
Deno.test("Repository settings consume the validated shared wire shape", async () => {
|
||||
const [loadSource, pageSource] = await Promise.all([
|
||||
const [loadSource, pageSource, apiSource, modelSource] = await Promise.all([
|
||||
Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/repositories/+page.ts",
|
||||
@@ -240,21 +300,41 @@ Deno.test("Repository settings consume the validated shared wire shape", async (
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
Deno.readTextFile(
|
||||
new URL("../src/lib/workspace/api/repositories.ts", import.meta.url),
|
||||
),
|
||||
Deno.readTextFile(
|
||||
new URL("../src/lib/workspace/api/workspace-model.ts", import.meta.url),
|
||||
),
|
||||
]);
|
||||
const targetSources = `${loadSource}\n${pageSource}\n${apiSource}`;
|
||||
|
||||
for (
|
||||
const token of [
|
||||
"loadWorkspaceRepositoryList",
|
||||
"createWorkspaceRepository",
|
||||
"parseRepositoryListResponse",
|
||||
"parseCreateWorkspaceRepositoryResponse",
|
||||
"parseRepositoryApiError",
|
||||
"repository.repository_key",
|
||||
"repository.observed_status",
|
||||
"sourceLabel(repository.source.kind)",
|
||||
"supportsRepositoryAccess(repository.source.kind)",
|
||||
]
|
||||
) {
|
||||
if (!loadSource.includes(token) && !pageSource.includes(token)) {
|
||||
if (!targetSources.includes(token) && !modelSource.includes(token)) {
|
||||
throw new Error(`Repository settings should include ${token}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
pageSource.includes("generated/legacy-server-api") ||
|
||||
pageSource.includes("response.json()") ||
|
||||
pageSource.includes(" as CreateWorkspaceRepository")
|
||||
) {
|
||||
throw new Error(
|
||||
"Repository create UI must use the generated contract and strict API client",
|
||||
);
|
||||
}
|
||||
for (const kind of ["ssh", "https"]) {
|
||||
if (!pageSource.includes(`kind === '${kind}'`)) {
|
||||
throw new Error(`Repository Access should support ${kind}`);
|
||||
|
||||
Reference in New Issue
Block a user