mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Workspace admins can now change the issue prefix from Settings → General. The change is gated by a confirmation dialog that warns about external references (PR titles, branch names, links) breaking, because issue identifiers are rendered as `prefix-N` on the fly — changing the prefix effectively renames every existing issue. Refs https://github.com/multica-ai/multica/issues/2797 Co-authored-by: multica-agent <github@multica.ai>
1713 lines
58 KiB
TypeScript
1713 lines
58 KiB
TypeScript
import type {
|
||
Issue,
|
||
CreateIssueRequest,
|
||
UpdateIssueRequest,
|
||
GroupedIssuesResponse,
|
||
ListIssuesResponse,
|
||
SearchIssuesResponse,
|
||
SearchProjectsResponse,
|
||
UpdateMeRequest,
|
||
CreateMemberRequest,
|
||
UpdateMemberRequest,
|
||
ListIssuesParams,
|
||
ListGroupedIssuesParams,
|
||
Agent,
|
||
CreateAgentRequest,
|
||
AgentTemplate,
|
||
AgentTemplateSummary,
|
||
CreateAgentFromTemplateRequest,
|
||
CreateAgentFromTemplateResponse,
|
||
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,
|
||
DashboardUsageDaily,
|
||
DashboardUsageByAgent,
|
||
DashboardAgentRunTime,
|
||
DashboardRunTimeDaily,
|
||
RuntimeUpdate,
|
||
RuntimeModelListRequest,
|
||
RuntimeLocalSkillListRequest,
|
||
CreateRuntimeLocalSkillImportRequest,
|
||
RuntimeLocalSkillImportRequest,
|
||
TimelineEntry,
|
||
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,
|
||
ListWebhookDeliveriesResponse,
|
||
WebhookDelivery,
|
||
NotificationPreferenceResponse,
|
||
NotificationPreferences,
|
||
GitHubPullRequest,
|
||
ListGitHubInstallationsResponse,
|
||
GitHubConnectResponse,
|
||
Squad,
|
||
SquadMember,
|
||
SquadMemberStatusListResponse,
|
||
} 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 {
|
||
AgentTemplateSchema,
|
||
AgentTemplateSummaryListSchema,
|
||
AttachmentResponseSchema,
|
||
ChildIssuesResponseSchema,
|
||
CommentsListSchema,
|
||
CreateAgentFromTemplateResponseSchema,
|
||
DashboardAgentRunTimeListSchema,
|
||
DashboardRunTimeDailyListSchema,
|
||
DashboardUsageByAgentListSchema,
|
||
DashboardUsageDailyListSchema,
|
||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||
EMPTY_ATTACHMENT,
|
||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||
EMPTY_LIST_ISSUES_RESPONSE,
|
||
EMPTY_SQUAD_MEMBER_STATUS_LIST,
|
||
EMPTY_TIMELINE_ENTRIES,
|
||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||
EMPTY_WEBHOOK_DELIVERY,
|
||
GroupedIssuesResponseSchema,
|
||
ListIssuesResponseSchema,
|
||
ListWebhookDeliveriesResponseSchema,
|
||
SquadMemberStatusListResponseSchema,
|
||
SubscribersListSchema,
|
||
TimelineEntriesSchema,
|
||
WebhookDeliveryResponseSchema,
|
||
} 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;
|
||
}
|
||
}
|
||
|
||
// Thrown by getAttachmentTextContent when the server refuses to inline a
|
||
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
|
||
// download" affordance with the Download CTA still available.
|
||
export class PreviewTooLargeError extends Error {
|
||
constructor() {
|
||
super("attachment too large for inline preview");
|
||
this.name = "PreviewTooLargeError";
|
||
}
|
||
}
|
||
|
||
// Thrown by getAttachmentTextContent when the server's text whitelist
|
||
// rejects the content type. Normally the client's isPreviewable() guard
|
||
// catches this earlier, but the two whitelists can drift — surfacing the
|
||
// 415 as a typed error makes the drift visible.
|
||
export class PreviewUnsupportedError extends Error {
|
||
constructor() {
|
||
super("attachment type not supported for inline preview");
|
||
this.name = "PreviewUnsupportedError";
|
||
}
|
||
}
|
||
|
||
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 };
|
||
}
|
||
}
|
||
|
||
// Sends the request with the standard headers (auth, CSRF, request id,
|
||
// client identity) and runs the shared error path (401 → handleUnauthorized,
|
||
// structured ApiError, status-aware log level). Returns the raw Response so
|
||
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
|
||
// path, plain text for the attachment-preview proxy, etc.
|
||
private async fetchRaw(
|
||
path: string,
|
||
init?: RequestInit & { extraHeaders?: Record<string, string> },
|
||
): Promise<Response> {
|
||
const rid = createRequestId();
|
||
const start = Date.now();
|
||
const method = init?.method ?? "GET";
|
||
|
||
const headers: Record<string, string> = {
|
||
"X-Request-ID": rid,
|
||
...this.authHeaders(),
|
||
...(init?.extraHeaders ?? {}),
|
||
...((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` });
|
||
return res;
|
||
}
|
||
|
||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await this.fetchRaw(path, {
|
||
...init,
|
||
extraHeaders: { "Content-Type": "application/json" },
|
||
});
|
||
// 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;
|
||
workspace_id?: string;
|
||
}): 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 listGroupedIssues(params: ListGroupedIssuesParams): Promise<GroupedIssuesResponse> {
|
||
const search = new URLSearchParams({ group_by: params.group_by });
|
||
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.statuses?.length) search.set("statuses", params.statuses.join(","));
|
||
if (params.priorities?.length) search.set("priorities", params.priorities.join(","));
|
||
if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
|
||
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.assignee_filters?.length) {
|
||
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||
}
|
||
if (params.include_no_assignee) search.set("include_no_assignee", "true");
|
||
if (params.creator_filters?.length) {
|
||
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||
}
|
||
if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
|
||
if (params.include_no_project) search.set("include_no_project", "true");
|
||
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
|
||
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
|
||
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
|
||
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
|
||
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
|
||
endpoint: "GET /api/issues/grouped",
|
||
});
|
||
}
|
||
|
||
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;
|
||
squad_id?: string;
|
||
prompt: string;
|
||
project_id?: string | null;
|
||
}): 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): Promise<TimelineEntry[]> {
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/issues/${issueId}/timeline`,
|
||
);
|
||
return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, {
|
||
endpoint: "GET /api/issues/:id/timeline",
|
||
});
|
||
}
|
||
|
||
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
||
return this.fetch("/api/assignee-frequency");
|
||
}
|
||
|
||
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
|
||
return this.fetch(`/api/comments/${commentId}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
|
||
});
|
||
}
|
||
|
||
async deleteComment(commentId: string): Promise<void> {
|
||
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
|
||
}
|
||
|
||
async resolveComment(commentId: string): Promise<Comment> {
|
||
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "POST" });
|
||
}
|
||
|
||
async unresolveComment(commentId: string): Promise<Comment> {
|
||
return this.fetch(`/api/comments/${commentId}/resolve`, { 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 listAgentTemplates(): Promise<AgentTemplateSummary[]> {
|
||
const raw = await this.fetch<unknown>("/api/agent-templates");
|
||
return parseWithFallback(
|
||
raw,
|
||
AgentTemplateSummaryListSchema,
|
||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||
{ endpoint: "GET /api/agent-templates" },
|
||
);
|
||
}
|
||
|
||
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/agent-templates/${encodeURIComponent(slug)}`,
|
||
);
|
||
// Round-trip the requested slug into the fallback so a malformed
|
||
// detail response still produces a navigable record matching the URL
|
||
// the user clicked.
|
||
return parseWithFallback(
|
||
raw,
|
||
AgentTemplateSchema,
|
||
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
|
||
{ endpoint: "GET /api/agent-templates/:slug" },
|
||
);
|
||
}
|
||
|
||
/** Creates an agent from a curated template. The server fetches every
|
||
* referenced skill URL in parallel, materializes them into the workspace
|
||
* (find-or-create by name), and writes the agent + skill bindings in a
|
||
* single transaction. On any upstream fetch failure, the entire write is
|
||
* rolled back and the API returns 422 with `failed_urls`. */
|
||
async createAgentFromTemplate(
|
||
data: CreateAgentFromTemplateRequest,
|
||
): Promise<CreateAgentFromTemplateResponse> {
|
||
const raw = await this.fetch<unknown>("/api/agents/from-template", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
return parseWithFallback(
|
||
raw,
|
||
CreateAgentFromTemplateResponseSchema,
|
||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||
{ endpoint: "POST /api/agents/from-template" },
|
||
);
|
||
}
|
||
|
||
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 updateRuntime(
|
||
runtimeId: string,
|
||
patch: { timezone?: string; visibility?: "private" | "public" },
|
||
): Promise<AgentRuntime> {
|
||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(patch),
|
||
});
|
||
}
|
||
|
||
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}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Workspace dashboard — three independent rollups for `/{slug}/dashboard`.
|
||
// Each accepts an optional `project_id` to narrow the scope to one project.
|
||
// Cost is computed client-side from the model pricing table (same contract
|
||
// as the per-runtime endpoints above).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async getDashboardUsageDaily(
|
||
params: { days?: number; project_id?: string | null },
|
||
): Promise<DashboardUsageDaily[]> {
|
||
const search = new URLSearchParams();
|
||
if (params.days) search.set("days", String(params.days));
|
||
if (params.project_id) search.set("project_id", params.project_id);
|
||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
|
||
return parseWithFallback<DashboardUsageDaily[]>(
|
||
raw,
|
||
DashboardUsageDailyListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/usage/daily" },
|
||
);
|
||
}
|
||
|
||
async getDashboardUsageByAgent(
|
||
params: { days?: number; project_id?: string | null },
|
||
): Promise<DashboardUsageByAgent[]> {
|
||
const search = new URLSearchParams();
|
||
if (params.days) search.set("days", String(params.days));
|
||
if (params.project_id) search.set("project_id", params.project_id);
|
||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
|
||
return parseWithFallback<DashboardUsageByAgent[]>(
|
||
raw,
|
||
DashboardUsageByAgentListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/usage/by-agent" },
|
||
);
|
||
}
|
||
|
||
async getDashboardAgentRunTime(
|
||
params: { days?: number; project_id?: string | null },
|
||
): Promise<DashboardAgentRunTime[]> {
|
||
const search = new URLSearchParams();
|
||
if (params.days) search.set("days", String(params.days));
|
||
if (params.project_id) search.set("project_id", params.project_id);
|
||
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
||
return parseWithFallback<DashboardAgentRunTime[]>(
|
||
raw,
|
||
DashboardAgentRunTimeListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/agent-runtime" },
|
||
);
|
||
}
|
||
|
||
async getDashboardRunTimeDaily(
|
||
params: { days?: number; project_id?: string | null },
|
||
): Promise<DashboardRunTimeDaily[]> {
|
||
const search = new URLSearchParams();
|
||
if (params.days) search.set("days", String(params.days));
|
||
if (params.project_id) search.set("project_id", params.project_id);
|
||
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
|
||
return parseWithFallback<DashboardRunTimeDaily[]>(
|
||
raw,
|
||
DashboardRunTimeDailyListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/runtime/daily" },
|
||
);
|
||
}
|
||
|
||
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",
|
||
});
|
||
}
|
||
|
||
async rerunIssue(issueId: string): Promise<AgentTask> {
|
||
return this.fetch(`/api/issues/${issueId}/rerun`, {
|
||
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;
|
||
analytics_environment?: 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[]; issue_prefix?: string }): 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; chatSessionId?: 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);
|
||
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
|
||
|
||
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` });
|
||
const raw = (await res.json()) as unknown;
|
||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||
endpoint: "POST /api/upload-file",
|
||
});
|
||
}
|
||
|
||
// 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 updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
|
||
return this.fetch(`/api/chat/sessions/${id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||
}
|
||
|
||
async sendChatMessage(
|
||
sessionId: string,
|
||
content: string,
|
||
attachmentIds?: string[],
|
||
): Promise<SendChatMessageResponse> {
|
||
const body: { content: string; attachment_ids?: string[] } = { content };
|
||
if (attachmentIds && attachmentIds.length > 0) {
|
||
body.attachment_ids = attachmentIds;
|
||
}
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
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`);
|
||
}
|
||
|
||
// Fetches a fresh attachment metadata record. The server re-signs
|
||
// `download_url` on every call (30 min expiry), so the click-time
|
||
// download flow uses this endpoint to avoid handing the user a stale
|
||
// signed URL cached in TanStack Query.
|
||
async getAttachment(id: string): Promise<Attachment> {
|
||
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
|
||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||
endpoint: "GET /api/attachments/{id}",
|
||
});
|
||
}
|
||
|
||
async deleteAttachment(id: string): Promise<void> {
|
||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// Fetches the raw bytes of a text-previewable attachment.
|
||
//
|
||
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
|
||
// bypasses Content-Disposition: attachment for the `text/*` family, both
|
||
// of which would otherwise prevent the renderer from getting the body.
|
||
// The server always replies with `text/plain; charset=utf-8` for safety;
|
||
// the original MIME ships back in the `X-Original-Content-Type` header so
|
||
// the preview dispatcher can choose between markdown / html / plain code.
|
||
//
|
||
// Routes through `fetchRaw` so it inherits the standard auth headers,
|
||
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
|
||
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
|
||
// the modal can render specific fallbacks instead of generic failure.
|
||
async getAttachmentTextContent(
|
||
id: string,
|
||
): Promise<{ text: string; originalContentType: string }> {
|
||
let res: Response;
|
||
try {
|
||
res = await this.fetchRaw(`/api/attachments/${id}/content`);
|
||
} catch (err) {
|
||
if (err instanceof ApiError) {
|
||
if (err.status === 413) throw new PreviewTooLargeError();
|
||
if (err.status === 415) throw new PreviewUnsupportedError();
|
||
}
|
||
throw err;
|
||
}
|
||
return {
|
||
text: await res.text(),
|
||
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
|
||
};
|
||
}
|
||
|
||
// 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),
|
||
});
|
||
}
|
||
|
||
// Squads
|
||
async listSquads(): Promise<Squad[]> {
|
||
return this.fetch(`/api/squads`);
|
||
}
|
||
|
||
async getSquad(id: string): Promise<Squad> {
|
||
return this.fetch(`/api/squads/${id}`);
|
||
}
|
||
|
||
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
|
||
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
|
||
}
|
||
|
||
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
|
||
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
|
||
}
|
||
|
||
async deleteSquad(id: string): Promise<void> {
|
||
await this.fetch(`/api/squads/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async listSquadMembers(squadId: string): Promise<SquadMember[]> {
|
||
return this.fetch(`/api/squads/${squadId}/members`);
|
||
}
|
||
|
||
async addSquadMember(squadId: string, data: { member_type: string; member_id: string; role?: string }): Promise<SquadMember> {
|
||
return this.fetch(`/api/squads/${squadId}/members`, { method: "POST", body: JSON.stringify(data) });
|
||
}
|
||
|
||
async removeSquadMember(squadId: string, data: { member_type: string; member_id: string }): Promise<void> {
|
||
await this.fetch(`/api/squads/${squadId}/members`, { method: "DELETE", body: JSON.stringify(data) });
|
||
}
|
||
|
||
async updateSquadMemberRole(squadId: string, data: { member_type: string; member_id: string; role: string }): Promise<SquadMember> {
|
||
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
|
||
}
|
||
|
||
// Per-squad members status snapshot: one row per member with derived
|
||
// working/idle/offline/unstable plus the issues each agent is currently
|
||
// running. Parsed with a lenient schema so a new server-side status
|
||
// value or extra field can't white-screen the Squad page (#2143).
|
||
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
|
||
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
|
||
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
|
||
endpoint: "GET /api/squads/:id/members/status",
|
||
}) as SquadMemberStatusListResponse;
|
||
}
|
||
|
||
// 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}`);
|
||
}
|
||
|
||
// Returns a single run including its full trigger_payload. List responses
|
||
// omit trigger_payload to keep them small (a webhook envelope can be
|
||
// up to 256 KiB × limit rows), so the detail view fetches via this route.
|
||
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
|
||
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
|
||
}
|
||
|
||
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" });
|
||
}
|
||
|
||
async rotateAutopilotTriggerWebhookToken(
|
||
autopilotId: string,
|
||
triggerId: string,
|
||
): Promise<AutopilotTrigger> {
|
||
return this.fetch(
|
||
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
|
||
{ method: "POST" },
|
||
);
|
||
}
|
||
|
||
// Webhook deliveries — list is slim (no raw_body / selected_headers /
|
||
// response_body); detail returns the full row. Both responses are parsed
|
||
// through a lenient schema so an unknown server-side `status` /
|
||
// `signature_status` value degrades to a generic row instead of dropping
|
||
// the whole list.
|
||
async listAutopilotDeliveries(
|
||
autopilotId: string,
|
||
params?: { limit?: number; offset?: number },
|
||
): Promise<ListWebhookDeliveriesResponse> {
|
||
const search = new URLSearchParams();
|
||
if (params?.limit) search.set("limit", params.limit.toString());
|
||
if (params?.offset) search.set("offset", params.offset.toString());
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/autopilots/${autopilotId}/deliveries?${search}`,
|
||
);
|
||
return parseWithFallback(
|
||
raw,
|
||
ListWebhookDeliveriesResponseSchema,
|
||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||
{ endpoint: "GET /api/autopilots/:id/deliveries" },
|
||
);
|
||
}
|
||
|
||
async getAutopilotDelivery(
|
||
autopilotId: string,
|
||
deliveryId: string,
|
||
): Promise<WebhookDelivery> {
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
|
||
);
|
||
return parseWithFallback(
|
||
raw,
|
||
WebhookDeliveryResponseSchema,
|
||
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
|
||
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
|
||
);
|
||
}
|
||
|
||
// Replay creates a NEW delivery row referencing the original via
|
||
// `replayed_from_delivery_id`. Server rejects replays of
|
||
// signature-invalid / rejected deliveries with 400 — the UI keeps the
|
||
// button disabled for those rows, but the server is the source of truth.
|
||
async replayAutopilotDelivery(
|
||
autopilotId: string,
|
||
deliveryId: string,
|
||
): Promise<WebhookDelivery> {
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
|
||
{ method: "POST" },
|
||
);
|
||
return parseWithFallback(
|
||
raw,
|
||
WebhookDeliveryResponseSchema,
|
||
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
|
||
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
|
||
);
|
||
}
|
||
|
||
// GitHub integration
|
||
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
|
||
}
|
||
|
||
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
|
||
}
|
||
|
||
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||
await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> {
|
||
return this.fetch(`/api/issues/${issueId}/pull-requests`);
|
||
}
|
||
}
|