feat(plugins): add document CRUD methods to Plugin SDK
Wire issue document list/get/upsert/delete operations through the JSON-RPC protocol so plugins can manage issue documents with the same capabilities available via the REST API. Fixes #940
This commit is contained in:
@@ -165,6 +165,14 @@ export interface HostServices {
|
||||
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */
|
||||
issueDocuments: {
|
||||
list(params: WorkerToHostMethods["issues.documents.list"][0]): Promise<WorkerToHostMethods["issues.documents.list"][1]>;
|
||||
get(params: WorkerToHostMethods["issues.documents.get"][0]): Promise<WorkerToHostMethods["issues.documents.get"][1]>;
|
||||
upsert(params: WorkerToHostMethods["issues.documents.upsert"][0]): Promise<WorkerToHostMethods["issues.documents.upsert"][1]>;
|
||||
delete(params: WorkerToHostMethods["issues.documents.delete"][0]): Promise<WorkerToHostMethods["issues.documents.delete"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
|
||||
agents: {
|
||||
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
|
||||
@@ -298,6 +306,12 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||
"issues.listComments": "issue.comments.read",
|
||||
"issues.createComment": "issue.comments.create",
|
||||
|
||||
// Issue Documents
|
||||
"issues.documents.list": "issue.documents.read",
|
||||
"issues.documents.get": "issue.documents.read",
|
||||
"issues.documents.upsert": "issue.documents.write",
|
||||
"issues.documents.delete": "issue.documents.write",
|
||||
|
||||
// Agents
|
||||
"agents.list": "agents.read",
|
||||
"agents.get": "agents.read",
|
||||
@@ -483,6 +497,20 @@ export function createHostClientHandlers(
|
||||
return services.issues.createComment(params);
|
||||
}),
|
||||
|
||||
// Issue Documents
|
||||
"issues.documents.list": gated("issues.documents.list", async (params) => {
|
||||
return services.issueDocuments.list(params);
|
||||
}),
|
||||
"issues.documents.get": gated("issues.documents.get", async (params) => {
|
||||
return services.issueDocuments.get(params);
|
||||
}),
|
||||
"issues.documents.upsert": gated("issues.documents.upsert", async (params) => {
|
||||
return services.issueDocuments.upsert(params);
|
||||
}),
|
||||
"issues.documents.delete": gated("issues.documents.delete", async (params) => {
|
||||
return services.issueDocuments.delete(params);
|
||||
}),
|
||||
|
||||
// Agents
|
||||
"agents.list": gated("agents.list", async (params) => {
|
||||
return services.agents.list(params);
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -601,6 +603,32 @@ export interface WorkerToHostMethods {
|
||||
result: IssueComment,
|
||||
];
|
||||
|
||||
// Issue Documents
|
||||
"issues.documents.list": [
|
||||
params: { issueId: string; companyId: string },
|
||||
result: IssueDocumentSummary[],
|
||||
];
|
||||
"issues.documents.get": [
|
||||
params: { issueId: string; key: string; companyId: string },
|
||||
result: IssueDocument | null,
|
||||
];
|
||||
"issues.documents.upsert": [
|
||||
params: {
|
||||
issueId: string;
|
||||
key: string;
|
||||
body: string;
|
||||
companyId: string;
|
||||
title?: string;
|
||||
format?: string;
|
||||
changeSummary?: string;
|
||||
},
|
||||
result: IssueDocument,
|
||||
];
|
||||
"issues.documents.delete": [
|
||||
params: { issueId: string; key: string; companyId: string },
|
||||
result: void,
|
||||
];
|
||||
|
||||
// Agents (read)
|
||||
"agents.list": [
|
||||
params: { companyId: string; status?: string; limit?: number; offset?: number },
|
||||
|
||||
@@ -422,6 +422,23 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
issueComments.set(issueId, current);
|
||||
return comment;
|
||||
},
|
||||
documents: {
|
||||
async list(_issueId, _companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.read" as any);
|
||||
return [];
|
||||
},
|
||||
async get(_issueId, _key, _companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.read" as any);
|
||||
return null;
|
||||
},
|
||||
async upsert(_input) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.write" as any);
|
||||
throw new Error("documents.upsert is not implemented in test context");
|
||||
},
|
||||
async delete(_issueId, _key, _companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.documents.write" as any);
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
async list(input) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -61,6 +63,8 @@ export type {
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueDocument,
|
||||
IssueDocumentSummary,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -774,6 +778,73 @@ export interface PluginCompaniesClient {
|
||||
get(companyId: string): Promise<Company | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.issues.documents` — read and write issue documents.
|
||||
*
|
||||
* Requires:
|
||||
* - `issue.documents.read` for `list` and `get`
|
||||
* - `issue.documents.write` for `upsert` and `delete`
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14 — SDK Surface
|
||||
*/
|
||||
export interface PluginIssueDocumentsClient {
|
||||
/**
|
||||
* List all documents attached to an issue.
|
||||
*
|
||||
* Returns summary metadata (id, key, title, format, timestamps) without
|
||||
* the full document body. Use `get()` to fetch a specific document's body.
|
||||
*
|
||||
* Requires the `issue.documents.read` capability.
|
||||
*/
|
||||
list(issueId: string, companyId: string): Promise<IssueDocumentSummary[]>;
|
||||
|
||||
/**
|
||||
* Get a single document by key, including its full body content.
|
||||
*
|
||||
* Returns `null` if no document exists with the given key.
|
||||
*
|
||||
* Requires the `issue.documents.read` capability.
|
||||
*
|
||||
* @param issueId - UUID of the issue
|
||||
* @param key - Document key (e.g. `"plan"`, `"design-spec"`)
|
||||
* @param companyId - UUID of the company
|
||||
*/
|
||||
get(issueId: string, key: string, companyId: string): Promise<IssueDocument | null>;
|
||||
|
||||
/**
|
||||
* Create or update a document on an issue.
|
||||
*
|
||||
* If a document with the given key already exists, it is updated and a new
|
||||
* revision is created. If it does not exist, it is created.
|
||||
*
|
||||
* Requires the `issue.documents.write` capability.
|
||||
*
|
||||
* @param input - Document data including issueId, key, body, and optional title/format/changeSummary
|
||||
*/
|
||||
upsert(input: {
|
||||
issueId: string;
|
||||
key: string;
|
||||
body: string;
|
||||
companyId: string;
|
||||
title?: string;
|
||||
format?: string;
|
||||
changeSummary?: string;
|
||||
}): Promise<IssueDocument>;
|
||||
|
||||
/**
|
||||
* Delete a document and all its revisions.
|
||||
*
|
||||
* No-ops silently if the document does not exist (idempotent).
|
||||
*
|
||||
* Requires the `issue.documents.write` capability.
|
||||
*
|
||||
* @param issueId - UUID of the issue
|
||||
* @param key - Document key to delete
|
||||
* @param companyId - UUID of the company
|
||||
*/
|
||||
delete(issueId: string, key: string, companyId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.issues` — read and mutate issues plus comments.
|
||||
*
|
||||
@@ -783,6 +854,8 @@ export interface PluginCompaniesClient {
|
||||
* - `issues.update` for update
|
||||
* - `issue.comments.read` for `listComments`
|
||||
* - `issue.comments.create` for `createComment`
|
||||
* - `issue.documents.read` for `documents.list` and `documents.get`
|
||||
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
|
||||
*/
|
||||
export interface PluginIssuesClient {
|
||||
list(input: {
|
||||
@@ -814,6 +887,8 @@ export interface PluginIssuesClient {
|
||||
): Promise<Issue>;
|
||||
listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
|
||||
createComment(issueId: string, body: string, companyId: string): Promise<IssueComment>;
|
||||
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
|
||||
documents: PluginIssueDocumentsClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1056,7 +1131,7 @@ export interface PluginContext {
|
||||
/** Read company metadata. Requires `companies.read`. */
|
||||
companies: PluginCompaniesClient;
|
||||
|
||||
/** Read and write issues/comments. Requires issue capabilities. */
|
||||
/** Read and write issues, comments, and documents. Requires issue capabilities. */
|
||||
issues: PluginIssuesClient;
|
||||
|
||||
/** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */
|
||||
|
||||
@@ -612,6 +612,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||
async createComment(issueId: string, body: string, companyId: string) {
|
||||
return callHost("issues.createComment", { issueId, body, companyId });
|
||||
},
|
||||
|
||||
documents: {
|
||||
async list(issueId: string, companyId: string) {
|
||||
return callHost("issues.documents.list", { issueId, companyId });
|
||||
},
|
||||
|
||||
async get(issueId: string, key: string, companyId: string) {
|
||||
return callHost("issues.documents.get", { issueId, key, companyId });
|
||||
},
|
||||
|
||||
async upsert(input) {
|
||||
return callHost("issues.documents.upsert", {
|
||||
issueId: input.issueId,
|
||||
key: input.key,
|
||||
body: input.body,
|
||||
companyId: input.companyId,
|
||||
title: input.title,
|
||||
format: input.format,
|
||||
changeSummary: input.changeSummary,
|
||||
});
|
||||
},
|
||||
|
||||
async delete(issueId: string, key: string, companyId: string) {
|
||||
return callHost("issues.documents.delete", { issueId, key, companyId });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
|
||||
@@ -315,6 +315,7 @@ export const PLUGIN_CAPABILITIES = [
|
||||
"project.workspaces.read",
|
||||
"issues.read",
|
||||
"issue.comments.read",
|
||||
"issue.documents.read",
|
||||
"agents.read",
|
||||
"goals.read",
|
||||
"goals.create",
|
||||
@@ -325,6 +326,7 @@ export const PLUGIN_CAPABILITIES = [
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.create",
|
||||
"issue.documents.write",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { agentService } from "./agents.js";
|
||||
import { projectService } from "./projects.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { goalService } from "./goals.js";
|
||||
import { documentService } from "./documents.js";
|
||||
import { heartbeatService } from "./heartbeat.js";
|
||||
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -450,6 +451,7 @@ export function buildHostServices(
|
||||
const heartbeat = heartbeatService(db);
|
||||
const projects = projectService(db);
|
||||
const issues = issueService(db);
|
||||
const documents = documentService(db);
|
||||
const goals = goalService(db);
|
||||
const activity = activityService(db);
|
||||
const costs = costService(db);
|
||||
@@ -796,6 +798,43 @@ export function buildHostServices(
|
||||
},
|
||||
},
|
||||
|
||||
issueDocuments: {
|
||||
async list(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const rows = await documents.listIssueDocuments(params.issueId);
|
||||
return rows as any;
|
||||
},
|
||||
async get(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const doc = await documents.getIssueDocumentByKey(params.issueId, params.key);
|
||||
return (doc ?? null) as any;
|
||||
},
|
||||
async upsert(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const result = await documents.upsertIssueDocument({
|
||||
issueId: params.issueId,
|
||||
key: params.key,
|
||||
body: params.body,
|
||||
title: params.title ?? null,
|
||||
format: params.format ?? "markdown",
|
||||
changeSummary: params.changeSummary ?? null,
|
||||
});
|
||||
return result.document as any;
|
||||
},
|
||||
async delete(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
await documents.deleteIssueDocument(params.issueId, params.key);
|
||||
},
|
||||
},
|
||||
|
||||
agents: {
|
||||
async list(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
|
||||
Reference in New Issue
Block a user