Add multi-select with batch update/delete support in the list view. Users can select issues via checkboxes (per-row or per-status-group) and apply bulk status, priority, assignee changes or delete via a floating toolbar.
479 lines
14 KiB
TypeScript
479 lines
14 KiB
TypeScript
import type {
|
|
Issue,
|
|
CreateIssueRequest,
|
|
UpdateIssueRequest,
|
|
ListIssuesResponse,
|
|
UpdateMeRequest,
|
|
CreateMemberRequest,
|
|
UpdateMemberRequest,
|
|
ListIssuesParams,
|
|
Agent,
|
|
CreateAgentRequest,
|
|
UpdateAgentRequest,
|
|
AgentTask,
|
|
AgentRuntime,
|
|
DaemonPairingSession,
|
|
ApproveDaemonPairingSessionRequest,
|
|
InboxItem,
|
|
IssueSubscriber,
|
|
Comment,
|
|
Workspace,
|
|
WorkspaceRepo,
|
|
MemberWithUser,
|
|
User,
|
|
Skill,
|
|
CreateSkillRequest,
|
|
UpdateSkillRequest,
|
|
SetAgentSkillsRequest,
|
|
PersonalAccessToken,
|
|
CreatePersonalAccessTokenRequest,
|
|
CreatePersonalAccessTokenResponse,
|
|
RuntimeUsage,
|
|
RuntimeHourlyActivity,
|
|
RuntimePing,
|
|
TimelineEntry,
|
|
} from "@/shared/types";
|
|
import { type Logger, noopLogger } from "@/shared/logger";
|
|
|
|
export interface LoginResponse {
|
|
token: string;
|
|
user: User;
|
|
}
|
|
|
|
export class ApiClient {
|
|
private baseUrl: string;
|
|
private token: string | null = null;
|
|
private workspaceId: string | null = null;
|
|
private logger: Logger;
|
|
|
|
constructor(baseUrl: string, options?: { logger?: Logger }) {
|
|
this.baseUrl = baseUrl;
|
|
this.logger = options?.logger ?? noopLogger;
|
|
}
|
|
|
|
setToken(token: string | null) {
|
|
this.token = token;
|
|
}
|
|
|
|
setWorkspaceId(id: string | null) {
|
|
this.workspaceId = id;
|
|
}
|
|
|
|
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const rid = crypto.randomUUID().slice(0, 8);
|
|
const start = Date.now();
|
|
const method = init?.method ?? "GET";
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
"X-Request-ID": rid,
|
|
...((init?.headers as Record<string, string>) ?? {}),
|
|
};
|
|
if (this.token) {
|
|
headers["Authorization"] = `Bearer ${this.token}`;
|
|
}
|
|
if (this.workspaceId) {
|
|
headers["X-Workspace-ID"] = this.workspaceId;
|
|
}
|
|
|
|
this.logger.info(`→ ${method} ${path}`, { rid });
|
|
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
...init,
|
|
headers,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401 && typeof window !== "undefined") {
|
|
localStorage.removeItem("multica_token");
|
|
localStorage.removeItem("multica_workspace_id");
|
|
this.token = null;
|
|
this.workspaceId = null;
|
|
if (window.location.pathname !== "/login") {
|
|
window.location.href = "/login";
|
|
}
|
|
}
|
|
|
|
let message = `API error: ${res.status} ${res.statusText}`;
|
|
try {
|
|
const data = await res.json() as { error?: string };
|
|
if (typeof data.error === "string" && data.error) {
|
|
message = data.error;
|
|
}
|
|
} catch {
|
|
// Ignore non-JSON error bodies.
|
|
}
|
|
this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
|
throw new Error(message);
|
|
}
|
|
|
|
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 getMe(): Promise<User> {
|
|
return this.fetch("/api/me");
|
|
}
|
|
|
|
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));
|
|
const wsId = params?.workspace_id ?? this.workspaceId;
|
|
if (wsId) search.set("workspace_id", wsId);
|
|
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);
|
|
return this.fetch(`/api/issues?${search}`);
|
|
}
|
|
|
|
async getIssue(id: string): Promise<Issue> {
|
|
return this.fetch(`/api/issues/${id}`);
|
|
}
|
|
|
|
async createIssue(data: CreateIssueRequest): Promise<Issue> {
|
|
const search = new URLSearchParams();
|
|
if (this.workspaceId) search.set("workspace_id", this.workspaceId);
|
|
return this.fetch(`/api/issues?${search}`, {
|
|
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 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[]> {
|
|
return this.fetch(`/api/issues/${issueId}/comments`);
|
|
}
|
|
|
|
async createComment(issueId: string, content: string, type?: string, parentId?: string): Promise<Comment> {
|
|
return this.fetch(`/api/issues/${issueId}/comments`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
content,
|
|
type: type ?? "comment",
|
|
...(parentId ? { parent_id: parentId } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
|
|
return this.fetch(`/api/issues/${issueId}/timeline`);
|
|
}
|
|
|
|
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" });
|
|
}
|
|
|
|
// Subscribers
|
|
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
|
|
return this.fetch(`/api/issues/${issueId}/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 }): Promise<Agent[]> {
|
|
const search = new URLSearchParams();
|
|
const wsId = params?.workspace_id ?? this.workspaceId;
|
|
if (wsId) search.set("workspace_id", wsId);
|
|
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 deleteAgent(id: string): Promise<void> {
|
|
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
|
|
const search = new URLSearchParams();
|
|
const wsId = params?.workspace_id ?? this.workspaceId;
|
|
if (wsId) search.set("workspace_id", wsId);
|
|
return this.fetch(`/api/runtimes?${search}`);
|
|
}
|
|
|
|
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 pingRuntime(runtimeId: string): Promise<RuntimePing> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/ping`, { method: "POST" });
|
|
}
|
|
|
|
async getPingResult(runtimeId: string, pingId: string): Promise<RuntimePing> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/ping/${pingId}`);
|
|
}
|
|
|
|
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
|
return this.fetch(`/api/agents/${agentId}/tasks`);
|
|
}
|
|
|
|
async getDaemonPairingSession(token: string): Promise<DaemonPairingSession> {
|
|
return this.fetch(`/api/daemon/pairing-sessions/${token}`);
|
|
}
|
|
|
|
async approveDaemonPairingSession(
|
|
token: string,
|
|
data: ApproveDaemonPairingSessionRequest,
|
|
): Promise<DaemonPairingSession> {
|
|
return this.fetch(`/api/daemon/pairing-sessions/${token}/approve`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// 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" });
|
|
}
|
|
|
|
// 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<MemberWithUser> {
|
|
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",
|
|
});
|
|
}
|
|
|
|
async deleteWorkspace(workspaceId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Skills
|
|
async listSkills(): Promise<Skill[]> {
|
|
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<Skill[]> {
|
|
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" });
|
|
}
|
|
}
|