mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* docs(claude): add API Response Compatibility section Narrows the existing "no backwards compat" rule to internal code only, and adds a new section that codifies the defensive boundary at API edges: parse-don't-cast, never pin UI to a single field, enum drift must downgrade not crash. Driven by #2143/#2147/#2192 — all three were the desktop client white- screening on backend response shape changes the client wasn't built against. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(core): add zod-based API response validation layer Introduces a defensive boundary so a malformed backend response degrades into a safe fallback (empty page, [], etc.) instead of throwing inside React render. - Adds zod to the pnpm catalog and as a @multica/core dependency. - New parseWithFallback helper in core/api/schema.ts that runs safeParse, logs a warn with the endpoint + zod issues on failure, and returns the caller-supplied fallback. Never throws. - Schemas in core/api/schemas.ts are deliberately lenient (string enums kept as z.string() so unknown values still parse, optional fields default, nested records use .loose() for unknown keys). - Wires setSchemaLogger from CoreProvider so warnings flow through the same logger as the rest of the API client. This is the primitive — see the next commit for the call-site wiring. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(api): guard top 5 high-risk endpoints with parseWithFallback Wraps the response of the five endpoints whose UIs white-screened in past incidents (#2143/#2147/#2192) so a contract drift returns a safe fallback instead of crashing the consumer: - listIssues → ListIssuesResponseSchema, fallback { issues: [], total: 0 } - listTimeline → TimelinePageSchema, fallback empty page - listComments → CommentsListSchema, fallback [] - listIssueSubscribers → SubscribersListSchema, fallback [] - listChildIssues → ChildIssuesResponseSchema, fallback { issues: [] } getIssue is intentionally NOT wrapped: there is no sensible "empty issue" — the entire detail page depends on real fields. The page-level ErrorBoundary (separate commit) catches that case. Adds schema.test.ts with 9 cases covering the five failure modes listed in MUL-1828: missing fields, wrong types, enum drift, null body, and null arrays. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(ui): add ErrorBoundary and wrap high-risk pages Section-level error boundary (no third-party dep — class component + default fallback in @multica/ui). Supports a fallback render prop and resetKeys for auto-recovery on resource navigation. Wraps the surfaces that white-screened in past incidents: - IssueDetail (web + desktop + inbox split-pane) — keyed on issueId so navigating to a different issue clears the boundary automatically. - IssuesPage (web + desktop). Boundaries are placed at consumer call sites rather than inside IssueDetail itself so we don't have to refactor the 1100-line component, and so a crash inside one inbox split-pane doesn't take down the inbox list next to it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(core): make all API schemas .loose() to preserve unknown fields zod 4 z.object() defaults to STRIP, which silently drops fields the schema didn't list. That makes the schema layer a sync point: a future PR adding a TS field but forgetting the schema would have the field disappear at runtime while TS still claims it exists — the exact bug- class this PR is meant to prevent, just inverted. Apply .loose() to every object schema (TimelineEntry, TimelinePage, Comment, Issue, ListIssuesResponse, Subscriber, ChildIssuesResponse) so unknown server-side fields pass through unchanged. Add a regression test that feeds a payload with extra fields at both entry and page level, and a direct unit test for parseWithFallback decoupled from any endpoint. Update the listIssues fallback test to use a wrong-type payload — under .loose() the previous "{ unexpected: true }" payload parses successfully (every declared field has a default) instead of triggering the fallback path it was meant to exercise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(claude): strip field-specific examples from API Compatibility section The original wording embedded current schema field names (entries, has_more_before, has_more_after, cursor, status, type) directly in the rules. CLAUDE.md should state the rule, not the implementation — once a field is renamed the doc drifts out of sync with the code, and the specific names don't add anything the abstract rule doesn't. Keep the rule, drop the field-level archaeology. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
1270 lines
40 KiB
TypeScript
1270 lines
40 KiB
TypeScript
import type {
|
|
Issue,
|
|
CreateIssueRequest,
|
|
UpdateIssueRequest,
|
|
ListIssuesResponse,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
UpdateMeRequest,
|
|
CreateMemberRequest,
|
|
UpdateMemberRequest,
|
|
ListIssuesParams,
|
|
Agent,
|
|
CreateAgentRequest,
|
|
UpdateAgentRequest,
|
|
AgentTask,
|
|
AgentActivityBucket,
|
|
AgentRunCount,
|
|
AgentRuntime,
|
|
InboxItem,
|
|
IssueSubscriber,
|
|
Comment,
|
|
Reaction,
|
|
IssueReaction,
|
|
Workspace,
|
|
WorkspaceRepo,
|
|
MemberWithUser,
|
|
User,
|
|
Skill,
|
|
SkillSummary,
|
|
CreateSkillRequest,
|
|
UpdateSkillRequest,
|
|
SetAgentSkillsRequest,
|
|
PersonalAccessToken,
|
|
CreatePersonalAccessTokenRequest,
|
|
CreatePersonalAccessTokenResponse,
|
|
RuntimeUsage,
|
|
IssueUsageSummary,
|
|
RuntimeHourlyActivity,
|
|
RuntimeUsageByAgent,
|
|
RuntimeUsageByHour,
|
|
RuntimeUpdate,
|
|
RuntimeModelListRequest,
|
|
RuntimeLocalSkillListRequest,
|
|
CreateRuntimeLocalSkillImportRequest,
|
|
RuntimeLocalSkillImportRequest,
|
|
TimelinePage,
|
|
TimelinePageParam,
|
|
AssigneeFrequencyEntry,
|
|
TaskMessagePayload,
|
|
Attachment,
|
|
ChatSession,
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
PendingChatTasksResponse,
|
|
SendChatMessageResponse,
|
|
Project,
|
|
CreateProjectRequest,
|
|
UpdateProjectRequest,
|
|
ListProjectsResponse,
|
|
ProjectResource,
|
|
CreateProjectResourceRequest,
|
|
ListProjectResourcesResponse,
|
|
Label,
|
|
CreateLabelRequest,
|
|
UpdateLabelRequest,
|
|
ListLabelsResponse,
|
|
IssueLabelsResponse,
|
|
PinnedItem,
|
|
CreatePinRequest,
|
|
PinnedItemType,
|
|
ReorderPinsRequest,
|
|
Invitation,
|
|
Autopilot,
|
|
AutopilotTrigger,
|
|
AutopilotRun,
|
|
CreateAutopilotRequest,
|
|
UpdateAutopilotRequest,
|
|
CreateAutopilotTriggerRequest,
|
|
UpdateAutopilotTriggerRequest,
|
|
ListAutopilotsResponse,
|
|
GetAutopilotResponse,
|
|
ListAutopilotRunsResponse,
|
|
NotificationPreferenceResponse,
|
|
NotificationPreferences,
|
|
} from "../types";
|
|
import type { OnboardingCompletionPath } from "../onboarding/types";
|
|
import { type Logger, noopLogger } from "../logger";
|
|
import { createRequestId } from "../utils";
|
|
import { getCurrentSlug } from "../platform/workspace-storage";
|
|
import { parseWithFallback } from "./schema";
|
|
import {
|
|
ChildIssuesResponseSchema,
|
|
CommentsListSchema,
|
|
EMPTY_LIST_ISSUES_RESPONSE,
|
|
EMPTY_TIMELINE_PAGE,
|
|
ListIssuesResponseSchema,
|
|
SubscribersListSchema,
|
|
TimelinePageSchema,
|
|
} from "./schemas";
|
|
|
|
/** Identifies the calling client to the server.
|
|
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
|
|
* X-Client-OS so the backend can log, gate, or split metrics by client.
|
|
* See server/internal/middleware/client.go for the receiving end. */
|
|
export interface ApiClientIdentity {
|
|
/** Logical client kind. Server expects: "web" | "desktop" | "cli" | "daemon". */
|
|
platform?: string;
|
|
/** Client/app version string (e.g. "0.1.0", git tag, commit). */
|
|
version?: string;
|
|
/** Operating system the client is running on: "macos" | "windows" | "linux". */
|
|
os?: string;
|
|
}
|
|
|
|
export interface ApiClientOptions {
|
|
logger?: Logger;
|
|
onUnauthorized?: () => void;
|
|
/** Identifies the client to the server. Sent as X-Client-* headers. */
|
|
identity?: ApiClientIdentity;
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
token: string;
|
|
user: User;
|
|
}
|
|
|
|
// --- Starter content (post-onboarding import) -----------------------------
|
|
// Shape mirrors the Go request/response in handler/onboarding.go.
|
|
//
|
|
// The client sends both branches of sub-issues and an unbound welcome
|
|
// issue template (title + description, no `agent_id`). The SERVER picks
|
|
// the branch by inspecting the workspace's agent list inside the
|
|
// import transaction. This removes the client as a trusted decider —
|
|
// even if the client has a stale agent cache or lies, the server uses
|
|
// the DB as source of truth.
|
|
|
|
export interface ImportStarterIssuePayload {
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string;
|
|
/** Server uses `user_id` (per app-wide AssigneePicker convention)
|
|
* as assignee when true. No member_id is threaded through. */
|
|
assign_to_self: boolean;
|
|
}
|
|
|
|
export interface ImportStarterWelcomeIssueTemplate {
|
|
title: string;
|
|
description: string;
|
|
/** Defaults to "high" on server when empty. */
|
|
priority: string;
|
|
}
|
|
|
|
export interface ImportStarterContentPayload {
|
|
workspace_id: string;
|
|
project: { title: string; description: string; icon: string };
|
|
/** Always sent. Server creates it only when an agent exists in the
|
|
* workspace; ignored otherwise. Agent id is picked by the server. */
|
|
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
|
|
/** Used when the workspace has at least one agent. */
|
|
agent_guided_sub_issues: ImportStarterIssuePayload[];
|
|
/** Used when the workspace has zero agents. */
|
|
self_serve_sub_issues: ImportStarterIssuePayload[];
|
|
}
|
|
|
|
export interface ImportStarterContentResponse {
|
|
user: User;
|
|
project_id: string;
|
|
/** Non-null when server took the agent-guided branch. */
|
|
welcome_issue_id: string | null;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly statusText: string;
|
|
// Raw decoded JSON body (when the server returned one). Carries structured
|
|
// error fields like `code` so callers can branch on machine-readable
|
|
// identifiers instead of pattern-matching the human-readable message.
|
|
readonly body?: unknown;
|
|
|
|
constructor(message: string, status: number, statusText: string, body?: unknown) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.statusText = statusText;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
export class ApiClient {
|
|
private baseUrl: string;
|
|
private token: string | null = null;
|
|
private logger: Logger;
|
|
private options: ApiClientOptions;
|
|
|
|
constructor(baseUrl: string, options?: ApiClientOptions) {
|
|
this.baseUrl = baseUrl;
|
|
this.options = options ?? {};
|
|
this.logger = options?.logger ?? noopLogger;
|
|
}
|
|
|
|
getBaseUrl(): string {
|
|
return this.baseUrl;
|
|
}
|
|
|
|
setToken(token: string | null) {
|
|
this.token = token;
|
|
}
|
|
|
|
private readCsrfToken(): string | null {
|
|
if (typeof document === "undefined") return null;
|
|
const match = document.cookie
|
|
.split("; ")
|
|
.find((c) => c.startsWith("multica_csrf="));
|
|
return match ? match.split("=")[1] ?? null : null;
|
|
}
|
|
|
|
private authHeaders(): Record<string, string> {
|
|
const headers: Record<string, string> = {};
|
|
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
const slug = getCurrentSlug();
|
|
if (slug) headers["X-Workspace-Slug"] = slug;
|
|
const csrf = this.readCsrfToken();
|
|
if (csrf) headers["X-CSRF-Token"] = csrf;
|
|
const id = this.options.identity;
|
|
if (id?.platform) headers["X-Client-Platform"] = id.platform;
|
|
if (id?.version) headers["X-Client-Version"] = id.version;
|
|
if (id?.os) headers["X-Client-OS"] = id.os;
|
|
return headers;
|
|
}
|
|
|
|
private handleUnauthorized() {
|
|
this.token = null;
|
|
// Workspace id is owned by the URL-driven workspace-storage singleton
|
|
// (set by [workspaceSlug]/layout.tsx). On 401, the auth flow navigates
|
|
// to /login which leaves the workspace route, and the next workspace
|
|
// entry will overwrite the id. No clear needed here.
|
|
this.options.onUnauthorized?.();
|
|
}
|
|
|
|
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
|
try {
|
|
const data = await res.json() as { error?: string };
|
|
if (typeof data.error === "string" && data.error) return data.error;
|
|
} catch {
|
|
// Ignore non-JSON error bodies.
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// Reads the response body once for both human-readable error message and
|
|
// structured fields. The Response stream can only be consumed once, so
|
|
// both pieces have to come from a single read.
|
|
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
|
|
try {
|
|
const data = await res.json() as { error?: string };
|
|
const message = typeof data.error === "string" && data.error ? data.error : fallback;
|
|
return { message, body: data };
|
|
} catch {
|
|
return { message: fallback, body: undefined };
|
|
}
|
|
}
|
|
|
|
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const rid = createRequestId();
|
|
const start = Date.now();
|
|
const method = init?.method ?? "GET";
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
"X-Request-ID": rid,
|
|
...this.authHeaders(),
|
|
...((init?.headers as Record<string, string>) ?? {}),
|
|
};
|
|
|
|
this.logger.info(`→ ${method} ${path}`, { rid });
|
|
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
...init,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) this.handleUnauthorized();
|
|
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
|
|
const logLevel = res.status === 404 ? "warn" : "error";
|
|
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
|
throw new ApiError(message, res.status, res.statusText, body);
|
|
}
|
|
|
|
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
|
|
|
// Handle 204 No Content
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
// Auth
|
|
async sendCode(email: string): Promise<void> {
|
|
await this.fetch("/auth/send-code", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
}
|
|
|
|
async verifyCode(email: string, code: string): Promise<LoginResponse> {
|
|
return this.fetch("/auth/verify-code", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, code }),
|
|
});
|
|
}
|
|
|
|
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
|
|
return this.fetch("/auth/google", {
|
|
method: "POST",
|
|
body: JSON.stringify({ code, redirect_uri: redirectUri }),
|
|
});
|
|
}
|
|
|
|
async logout(): Promise<void> {
|
|
await this.fetch("/auth/logout", { method: "POST" });
|
|
}
|
|
|
|
async issueCliToken(): Promise<{ token: string }> {
|
|
return this.fetch("/api/cli-token", { method: "POST" });
|
|
}
|
|
|
|
async getMe(): Promise<User> {
|
|
return this.fetch("/api/me");
|
|
}
|
|
|
|
async markOnboardingComplete(payload?: {
|
|
completion_path?: OnboardingCompletionPath;
|
|
}): Promise<User> {
|
|
return this.fetch("/api/me/onboarding/complete", {
|
|
method: "POST",
|
|
body: payload ? JSON.stringify(payload) : undefined,
|
|
});
|
|
}
|
|
|
|
async joinCloudWaitlist(payload: {
|
|
email: string;
|
|
reason?: string;
|
|
}): Promise<User> {
|
|
return this.fetch("/api/me/onboarding/cloud-waitlist", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
async patchOnboarding(payload: {
|
|
questionnaire?: Record<string, unknown>;
|
|
}): Promise<User> {
|
|
return this.fetch("/api/me/onboarding", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Imports the Getting Started project + optional welcome issue + sub-issues
|
|
* in a single server-side transaction. Gated by an atomic
|
|
* starter_content_state: NULL → 'imported' claim — a second call returns
|
|
* 409 (already decided) and creates nothing new.
|
|
*
|
|
* The content templates live in TypeScript (see
|
|
* @multica/views/onboarding/utils/starter-content-templates) and are
|
|
* rendered from the user's questionnaire answers before being sent.
|
|
*/
|
|
async importStarterContent(
|
|
payload: ImportStarterContentPayload,
|
|
): Promise<ImportStarterContentResponse> {
|
|
return this.fetch("/api/me/starter-content/import", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
async dismissStarterContent(payload?: {
|
|
workspace_id?: string;
|
|
}): Promise<User> {
|
|
return this.fetch("/api/me/starter-content/dismiss", {
|
|
method: "POST",
|
|
body: payload ? JSON.stringify(payload) : undefined,
|
|
});
|
|
}
|
|
|
|
async updateMe(data: UpdateMeRequest): Promise<User> {
|
|
return this.fetch("/api/me", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Issues
|
|
async listIssues(params?: ListIssuesParams): Promise<ListIssuesResponse> {
|
|
const search = new URLSearchParams();
|
|
if (params?.limit) search.set("limit", String(params.limit));
|
|
if (params?.offset) search.set("offset", String(params.offset));
|
|
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
|
if (params?.status) search.set("status", params.status);
|
|
if (params?.priority) search.set("priority", params.priority);
|
|
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
|
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
|
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
|
if (params?.project_id) search.set("project_id", params.project_id);
|
|
if (params?.open_only) search.set("open_only", "true");
|
|
const path = `/api/issues?${search}`;
|
|
const raw = await this.fetch<unknown>(path);
|
|
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
|
endpoint: "GET /api/issues",
|
|
});
|
|
}
|
|
|
|
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
|
|
const search = new URLSearchParams({ q: params.q });
|
|
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
|
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
|
if (params.include_closed) search.set("include_closed", "true");
|
|
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
|
}
|
|
|
|
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
|
|
const search = new URLSearchParams({ q: params.q });
|
|
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
|
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
|
if (params.include_closed) search.set("include_closed", "true");
|
|
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
|
}
|
|
|
|
async getIssue(id: string): Promise<Issue> {
|
|
return this.fetch(`/api/issues/${id}`);
|
|
}
|
|
|
|
async createIssue(data: CreateIssueRequest): Promise<Issue> {
|
|
return this.fetch("/api/issues", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
|
return this.fetch("/api/issues/quick-create", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async createFeedback(data: {
|
|
message: string;
|
|
url?: string;
|
|
workspace_id?: string;
|
|
}): Promise<{ id: string; created_at: string }> {
|
|
return this.fetch("/api/feedback", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
|
|
return this.fetch(`/api/issues/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
|
const raw = await this.fetch<unknown>(`/api/issues/${id}/children`);
|
|
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
|
|
endpoint: "GET /api/issues/:id/children",
|
|
});
|
|
}
|
|
|
|
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
|
|
return this.fetch("/api/issues/child-progress");
|
|
}
|
|
|
|
async deleteIssue(id: string): Promise<void> {
|
|
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> {
|
|
return this.fetch("/api/issues/batch-update", {
|
|
method: "POST",
|
|
body: JSON.stringify({ issue_ids: issueIds, updates }),
|
|
});
|
|
}
|
|
|
|
async batchDeleteIssues(issueIds: string[]): Promise<{ deleted: number }> {
|
|
return this.fetch("/api/issues/batch-delete", {
|
|
method: "POST",
|
|
body: JSON.stringify({ issue_ids: issueIds }),
|
|
});
|
|
}
|
|
|
|
// Comments
|
|
async listComments(issueId: string): Promise<Comment[]> {
|
|
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments`);
|
|
return parseWithFallback(raw, CommentsListSchema, [], {
|
|
endpoint: "GET /api/issues/:id/comments",
|
|
});
|
|
}
|
|
|
|
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise<Comment> {
|
|
return this.fetch(`/api/issues/${issueId}/comments`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
content,
|
|
type: type ?? "comment",
|
|
...(parentId ? { parent_id: parentId } : {}),
|
|
...(attachmentIds?.length ? { attachment_ids: attachmentIds } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
async listTimeline(
|
|
issueId: string,
|
|
pageParam: TimelinePageParam = { mode: "latest" },
|
|
limit = 50,
|
|
): Promise<TimelinePage> {
|
|
const params = new URLSearchParams();
|
|
params.set("limit", String(limit));
|
|
if (pageParam.mode === "before") params.set("before", pageParam.cursor);
|
|
else if (pageParam.mode === "after") params.set("after", pageParam.cursor);
|
|
else if (pageParam.mode === "around") params.set("around", pageParam.id);
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/issues/${issueId}/timeline?${params.toString()}`,
|
|
);
|
|
return parseWithFallback(raw, TimelinePageSchema, EMPTY_TIMELINE_PAGE, {
|
|
endpoint: "GET /api/issues/:id/timeline",
|
|
});
|
|
}
|
|
|
|
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
|
return this.fetch("/api/assignee-frequency");
|
|
}
|
|
|
|
async updateComment(commentId: string, content: string): Promise<Comment> {
|
|
return this.fetch(`/api/comments/${commentId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
}
|
|
|
|
async deleteComment(commentId: string): Promise<void> {
|
|
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
|
|
}
|
|
|
|
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
|
|
return this.fetch(`/api/comments/${commentId}/reactions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async removeReaction(commentId: string, emoji: string): Promise<void> {
|
|
await this.fetch(`/api/comments/${commentId}/reactions`, {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async addIssueReaction(issueId: string, emoji: string): Promise<IssueReaction> {
|
|
return this.fetch(`/api/issues/${issueId}/reactions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async removeIssueReaction(issueId: string, emoji: string): Promise<void> {
|
|
await this.fetch(`/api/issues/${issueId}/reactions`, {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
// Subscribers
|
|
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
|
|
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/subscribers`);
|
|
return parseWithFallback(raw, SubscribersListSchema, [], {
|
|
endpoint: "GET /api/issues/:id/subscribers",
|
|
});
|
|
}
|
|
|
|
async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
|
|
const body: Record<string, string> = {};
|
|
if (userId) body.user_id = userId;
|
|
if (userType) body.user_type = userType;
|
|
await this.fetch(`/api/issues/${issueId}/subscribe`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async unsubscribeFromIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
|
|
const body: Record<string, string> = {};
|
|
if (userId) body.user_id = userId;
|
|
if (userType) body.user_type = userType;
|
|
await this.fetch(`/api/issues/${issueId}/unsubscribe`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
// Agents
|
|
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
|
if (params?.include_archived) search.set("include_archived", "true");
|
|
return this.fetch(`/api/agents?${search}`);
|
|
}
|
|
|
|
async getAgent(id: string): Promise<Agent> {
|
|
return this.fetch(`/api/agents/${id}`);
|
|
}
|
|
|
|
async createAgent(data: CreateAgentRequest): Promise<Agent> {
|
|
return this.fetch("/api/agents", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
|
|
return this.fetch(`/api/agents/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async archiveAgent(id: string): Promise<Agent> {
|
|
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
|
|
}
|
|
|
|
async restoreAgent(id: string): Promise<Agent> {
|
|
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
|
}
|
|
|
|
// Bulk-cancel every active task (queued/dispatched/running) for the agent.
|
|
// Permission: agent owner or workspace admin/owner. Server returns the
|
|
// count of cancelled rows; broadcasts task:cancelled for each so other
|
|
// surfaces can clear their live cards.
|
|
async cancelAgentTasks(id: string): Promise<{ cancelled: number }> {
|
|
return this.fetch(`/api/agents/${id}/cancel-tasks`, { method: "POST" });
|
|
}
|
|
|
|
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
|
if (params?.owner) search.set("owner", params.owner);
|
|
return this.fetch(`/api/runtimes?${search}`);
|
|
}
|
|
|
|
async deleteRuntime(runtimeId: string): Promise<void> {
|
|
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
|
}
|
|
|
|
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.days) search.set("days", String(params.days));
|
|
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
|
|
}
|
|
|
|
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
|
}
|
|
|
|
async getRuntimeUsageByAgent(
|
|
runtimeId: string,
|
|
params?: { days?: number },
|
|
): Promise<RuntimeUsageByAgent[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.days) search.set("days", String(params.days));
|
|
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
|
|
}
|
|
|
|
async getRuntimeUsageByHour(
|
|
runtimeId: string,
|
|
params?: { days?: number },
|
|
): Promise<RuntimeUsageByHour[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.days) search.set("days", String(params.days));
|
|
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
|
}
|
|
|
|
async initiateUpdate(
|
|
runtimeId: string,
|
|
targetVersion: string,
|
|
): Promise<RuntimeUpdate> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/update`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ target_version: targetVersion }),
|
|
});
|
|
}
|
|
|
|
async getUpdateResult(
|
|
runtimeId: string,
|
|
updateId: string,
|
|
): Promise<RuntimeUpdate> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
|
|
}
|
|
|
|
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
|
|
}
|
|
|
|
async getListModelsResult(
|
|
runtimeId: string,
|
|
requestId: string,
|
|
): Promise<RuntimeModelListRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
|
|
}
|
|
|
|
async initiateListLocalSkills(
|
|
runtimeId: string,
|
|
): Promise<RuntimeLocalSkillListRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/local-skills`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async getListLocalSkillsResult(
|
|
runtimeId: string,
|
|
requestId: string,
|
|
): Promise<RuntimeLocalSkillListRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/${requestId}`);
|
|
}
|
|
|
|
async initiateImportLocalSkill(
|
|
runtimeId: string,
|
|
data: CreateRuntimeLocalSkillImportRequest,
|
|
): Promise<RuntimeLocalSkillImportRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async getImportLocalSkillResult(
|
|
runtimeId: string,
|
|
requestId: string,
|
|
): Promise<RuntimeLocalSkillImportRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import/${requestId}`);
|
|
}
|
|
|
|
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
|
return this.fetch(`/api/agents/${agentId}/tasks`);
|
|
}
|
|
|
|
// Workspace-scoped agent task snapshot: every active task
|
|
// (queued/dispatched/running) plus each agent's most recent terminal task.
|
|
// Powers the front-end's "active wins, else latest terminal" presence
|
|
// derivation; one fetch backs every per-agent presence read in the app.
|
|
// Workspace is resolved server-side from the X-Workspace-Slug header.
|
|
async getAgentTaskSnapshot(): Promise<AgentTask[]> {
|
|
return this.fetch(`/api/agent-task-snapshot`);
|
|
}
|
|
|
|
// Per-agent daily activity for the last 30 days, anchored on
|
|
// completed_at. One workspace-wide fetch backs both the Agents-list
|
|
// sparkline (uses trailing 7 buckets) and the agent detail "Last 30
|
|
// days" panel (uses all 30).
|
|
async getWorkspaceAgentActivity30d(): Promise<AgentActivityBucket[]> {
|
|
return this.fetch(`/api/agent-activity-30d`);
|
|
}
|
|
|
|
// Per-agent 30-day total run count for the Agents-list RUNS column.
|
|
async getWorkspaceAgentRunCounts(): Promise<AgentRunCount[]> {
|
|
return this.fetch(`/api/agent-run-counts`);
|
|
}
|
|
|
|
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
|
|
return this.fetch(`/api/issues/${issueId}/active-task`);
|
|
}
|
|
|
|
async listTaskMessages(taskId: string): Promise<TaskMessagePayload[]> {
|
|
return this.fetch(`/api/tasks/${taskId}/messages`);
|
|
}
|
|
|
|
async listTasksByIssue(issueId: string): Promise<AgentTask[]> {
|
|
return this.fetch(`/api/issues/${issueId}/task-runs`);
|
|
}
|
|
|
|
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
|
|
return this.fetch(`/api/issues/${issueId}/usage`);
|
|
}
|
|
|
|
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
|
|
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// Inbox
|
|
async listInbox(): Promise<InboxItem[]> {
|
|
return this.fetch("/api/inbox");
|
|
}
|
|
|
|
async markInboxRead(id: string): Promise<InboxItem> {
|
|
return this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
|
|
}
|
|
|
|
async archiveInbox(id: string): Promise<InboxItem> {
|
|
return this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
|
|
}
|
|
|
|
async getUnreadInboxCount(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/unread-count");
|
|
}
|
|
|
|
async markAllInboxRead(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
|
|
}
|
|
|
|
async archiveAllInbox(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/archive-all", { method: "POST" });
|
|
}
|
|
|
|
async archiveAllReadInbox(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
|
|
}
|
|
|
|
async archiveCompletedInbox(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
|
}
|
|
|
|
// Notification preferences
|
|
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
|
|
return this.fetch("/api/notification-preferences");
|
|
}
|
|
|
|
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
|
|
return this.fetch("/api/notification-preferences", {
|
|
method: "PUT",
|
|
body: JSON.stringify({ preferences }),
|
|
});
|
|
}
|
|
|
|
// App Config
|
|
async getConfig(): Promise<{
|
|
cdn_domain: string;
|
|
allow_signup: boolean;
|
|
google_client_id?: string;
|
|
posthog_key?: string;
|
|
posthog_host?: string;
|
|
}> {
|
|
return this.fetch("/api/config");
|
|
}
|
|
|
|
// Workspaces
|
|
async listWorkspaces(): Promise<Workspace[]> {
|
|
return this.fetch("/api/workspaces");
|
|
}
|
|
|
|
async getWorkspace(id: string): Promise<Workspace> {
|
|
return this.fetch(`/api/workspaces/${id}`);
|
|
}
|
|
|
|
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
|
|
return this.fetch("/api/workspaces", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
|
|
return this.fetch(`/api/workspaces/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Members
|
|
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/members`);
|
|
}
|
|
|
|
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteMember(workspaceId: string, memberId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
async leaveWorkspace(workspaceId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// Invitations
|
|
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
|
|
}
|
|
|
|
async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
async listMyInvitations(): Promise<Invitation[]> {
|
|
return this.fetch("/api/invitations");
|
|
}
|
|
|
|
async getInvitation(invitationId: string): Promise<Invitation> {
|
|
return this.fetch(`/api/invitations/${invitationId}`);
|
|
}
|
|
|
|
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
|
|
return this.fetch(`/api/invitations/${invitationId}/accept`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async declineInvitation(invitationId: string): Promise<void> {
|
|
await this.fetch(`/api/invitations/${invitationId}/decline`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async deleteWorkspace(workspaceId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Skills
|
|
async listSkills(): Promise<SkillSummary[]> {
|
|
return this.fetch("/api/skills");
|
|
}
|
|
|
|
async getSkill(id: string): Promise<Skill> {
|
|
return this.fetch(`/api/skills/${id}`);
|
|
}
|
|
|
|
async createSkill(data: CreateSkillRequest): Promise<Skill> {
|
|
return this.fetch("/api/skills", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateSkill(id: string, data: UpdateSkillRequest): Promise<Skill> {
|
|
return this.fetch(`/api/skills/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteSkill(id: string): Promise<void> {
|
|
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async importSkill(data: { url: string }): Promise<Skill> {
|
|
return this.fetch("/api/skills/import", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
|
|
return this.fetch(`/api/agents/${agentId}/skills`);
|
|
}
|
|
|
|
async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
|
|
await this.fetch(`/api/agents/${agentId}/skills`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Personal Access Tokens
|
|
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
|
|
return this.fetch("/api/tokens");
|
|
}
|
|
|
|
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
|
|
return this.fetch("/api/tokens", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async revokePersonalAccessToken(id: string): Promise<void> {
|
|
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// File Upload & Attachments
|
|
async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise<Attachment> {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
|
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
|
|
|
const rid = createRequestId();
|
|
const start = Date.now();
|
|
this.logger.info("→ POST /api/upload-file", { rid });
|
|
|
|
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
|
|
method: "POST",
|
|
headers: this.authHeaders(),
|
|
body: formData,
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) this.handleUnauthorized();
|
|
const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`);
|
|
this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
|
throw new Error(message);
|
|
}
|
|
|
|
this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
|
|
return res.json() as Promise<Attachment>;
|
|
}
|
|
|
|
// Chat Sessions
|
|
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
|
|
const query = params?.status ? `?status=${params.status}` : "";
|
|
return this.fetch(`/api/chat/sessions${query}`);
|
|
}
|
|
|
|
async getChatSession(id: string): Promise<ChatSession> {
|
|
return this.fetch(`/api/chat/sessions/${id}`);
|
|
}
|
|
|
|
async createChatSession(data: { agent_id: string; title?: string }): Promise<ChatSession> {
|
|
return this.fetch("/api/chat/sessions", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteChatSession(id: string): Promise<void> {
|
|
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
|
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
|
}
|
|
|
|
async sendChatMessage(sessionId: string, content: string): Promise<SendChatMessageResponse> {
|
|
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
}
|
|
|
|
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
|
|
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
|
|
}
|
|
|
|
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
|
|
return this.fetch(`/api/chat/pending-tasks`);
|
|
}
|
|
|
|
async markChatSessionRead(sessionId: string): Promise<void> {
|
|
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
|
}
|
|
|
|
async cancelTaskById(taskId: string): Promise<void> {
|
|
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
|
}
|
|
|
|
async listAttachments(issueId: string): Promise<Attachment[]> {
|
|
return this.fetch(`/api/issues/${issueId}/attachments`);
|
|
}
|
|
|
|
async deleteAttachment(id: string): Promise<void> {
|
|
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// Projects
|
|
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
|
|
const search = new URLSearchParams();
|
|
if (params?.status) search.set("status", params.status);
|
|
return this.fetch(`/api/projects?${search}`);
|
|
}
|
|
|
|
async getProject(id: string): Promise<Project> {
|
|
return this.fetch(`/api/projects/${id}`);
|
|
}
|
|
|
|
async createProject(data: CreateProjectRequest): Promise<Project> {
|
|
return this.fetch("/api/projects", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
|
return this.fetch(`/api/projects/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteProject(id: string): Promise<void> {
|
|
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// Project resources
|
|
async listProjectResources(
|
|
projectId: string,
|
|
): Promise<ListProjectResourcesResponse> {
|
|
return this.fetch(`/api/projects/${projectId}/resources`);
|
|
}
|
|
|
|
async createProjectResource(
|
|
projectId: string,
|
|
data: CreateProjectResourceRequest,
|
|
): Promise<ProjectResource> {
|
|
return this.fetch(`/api/projects/${projectId}/resources`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteProjectResource(
|
|
projectId: string,
|
|
resourceId: string,
|
|
): Promise<void> {
|
|
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Labels
|
|
async listLabels(): Promise<ListLabelsResponse> {
|
|
return this.fetch(`/api/labels`);
|
|
}
|
|
|
|
async getLabel(id: string): Promise<Label> {
|
|
return this.fetch(`/api/labels/${id}`);
|
|
}
|
|
|
|
async createLabel(data: CreateLabelRequest): Promise<Label> {
|
|
return this.fetch(`/api/labels`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateLabel(id: string, data: UpdateLabelRequest): Promise<Label> {
|
|
return this.fetch(`/api/labels/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteLabel(id: string): Promise<void> {
|
|
await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async listLabelsForIssue(issueId: string): Promise<IssueLabelsResponse> {
|
|
return this.fetch(`/api/issues/${issueId}/labels`);
|
|
}
|
|
|
|
async attachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
|
return this.fetch(`/api/issues/${issueId}/labels`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ label_id: labelId }),
|
|
});
|
|
}
|
|
|
|
async detachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
|
return this.fetch(`/api/issues/${issueId}/labels/${labelId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Pins
|
|
async listPins(): Promise<PinnedItem[]> {
|
|
return this.fetch("/api/pins");
|
|
}
|
|
|
|
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
|
|
return this.fetch("/api/pins", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
|
|
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
|
|
}
|
|
|
|
async reorderPins(data: ReorderPinsRequest): Promise<void> {
|
|
await this.fetch("/api/pins/reorder", {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Autopilots
|
|
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
|
const search = new URLSearchParams();
|
|
if (params?.status) search.set("status", params.status);
|
|
return this.fetch(`/api/autopilots?${search}`);
|
|
}
|
|
|
|
async getAutopilot(id: string): Promise<GetAutopilotResponse> {
|
|
return this.fetch(`/api/autopilots/${id}`);
|
|
}
|
|
|
|
async createAutopilot(data: CreateAutopilotRequest): Promise<Autopilot> {
|
|
return this.fetch("/api/autopilots", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateAutopilot(id: string, data: UpdateAutopilotRequest): Promise<Autopilot> {
|
|
return this.fetch(`/api/autopilots/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteAutopilot(id: string): Promise<void> {
|
|
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async triggerAutopilot(id: string): Promise<AutopilotRun> {
|
|
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
|
|
}
|
|
|
|
async listAutopilotRuns(id: string, params?: { limit?: number; offset?: number }): Promise<ListAutopilotRunsResponse> {
|
|
const search = new URLSearchParams();
|
|
if (params?.limit) search.set("limit", params.limit.toString());
|
|
if (params?.offset) search.set("offset", params.offset.toString());
|
|
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
|
|
}
|
|
|
|
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
|
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateAutopilotTrigger(autopilotId: string, triggerId: string, data: UpdateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
|
return this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
|
|
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
|
|
}
|
|
}
|