bopodev-api 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/app.ts +2 -0
- package/src/lib/instance-paths.ts +12 -0
- package/src/lib/opencode-model.ts +35 -0
- package/src/lib/workspace-policy.ts +5 -0
- package/src/realtime/heartbeat-runs.ts +78 -0
- package/src/realtime/hub.ts +37 -1
- package/src/realtime/office-space.ts +10 -1
- package/src/routes/agents.ts +89 -2
- package/src/routes/companies.ts +2 -0
- package/src/routes/governance.ts +9 -2
- package/src/routes/heartbeats.ts +2 -1
- package/src/routes/issues.ts +321 -0
- package/src/routes/observability.ts +546 -18
- package/src/routes/plugins.ts +257 -0
- package/src/scripts/onboard-seed.ts +57 -12
- package/src/server.ts +62 -3
- package/src/services/governance-service.ts +97 -23
- package/src/services/heartbeat-service.ts +633 -31
- package/src/services/memory-file-service.ts +249 -0
- package/src/services/plugin-manifest-loader.ts +65 -0
- package/src/services/plugin-runtime.ts +580 -0
- package/src/services/plugin-webhook-executor.ts +94 -0
package/src/routes/issues.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
2
4
|
import { and, eq } from "drizzle-orm";
|
|
5
|
+
import multer from "multer";
|
|
3
6
|
import { z } from "zod";
|
|
4
7
|
import {
|
|
8
|
+
addIssueAttachment,
|
|
5
9
|
addIssueComment,
|
|
6
10
|
agents,
|
|
7
11
|
appendActivity,
|
|
8
12
|
appendAuditEvent,
|
|
9
13
|
createIssue,
|
|
14
|
+
deleteIssueAttachment,
|
|
10
15
|
deleteIssueComment,
|
|
11
16
|
deleteIssue,
|
|
17
|
+
getIssueAttachment,
|
|
12
18
|
issues,
|
|
19
|
+
listIssueAttachments,
|
|
13
20
|
listIssueActivity,
|
|
14
21
|
listIssueComments,
|
|
15
22
|
listIssues,
|
|
@@ -17,8 +24,10 @@ import {
|
|
|
17
24
|
updateIssueComment,
|
|
18
25
|
updateIssue
|
|
19
26
|
} from "bopodev-db";
|
|
27
|
+
import { nanoid } from "nanoid";
|
|
20
28
|
import type { AppContext } from "../context";
|
|
21
29
|
import { sendError, sendOk } from "../http";
|
|
30
|
+
import { isInsidePath, normalizeAbsolutePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
|
|
22
31
|
import { requireCompanyScope } from "../middleware/company-scope";
|
|
23
32
|
import { requirePermission } from "../middleware/request-actor";
|
|
24
33
|
|
|
@@ -51,6 +60,31 @@ const updateIssueCommentSchema = z.object({
|
|
|
51
60
|
body: z.string().min(1)
|
|
52
61
|
});
|
|
53
62
|
|
|
63
|
+
const MAX_ATTACHMENTS_PER_REQUEST = parsePositiveIntEnv("BOPO_ISSUE_ATTACHMENTS_MAX_FILES", 10);
|
|
64
|
+
const MAX_ATTACHMENT_SIZE_BYTES = parsePositiveIntEnv("BOPO_ISSUE_ATTACHMENTS_MAX_BYTES", 20 * 1024 * 1024);
|
|
65
|
+
const ALLOWED_ATTACHMENT_MIME_TYPES = parseCsvSet(
|
|
66
|
+
process.env.BOPO_ISSUE_ATTACHMENTS_ALLOWED_MIME_TYPES,
|
|
67
|
+
[
|
|
68
|
+
"image/png",
|
|
69
|
+
"image/jpeg",
|
|
70
|
+
"image/webp",
|
|
71
|
+
"image/gif",
|
|
72
|
+
"application/pdf",
|
|
73
|
+
"text/plain",
|
|
74
|
+
"text/markdown",
|
|
75
|
+
"application/json",
|
|
76
|
+
"text/csv",
|
|
77
|
+
"application/zip",
|
|
78
|
+
"application/x-zip-compressed"
|
|
79
|
+
]
|
|
80
|
+
);
|
|
81
|
+
const ALLOWED_ATTACHMENT_EXTENSIONS = parseCsvSet(
|
|
82
|
+
process.env.BOPO_ISSUE_ATTACHMENTS_ALLOWED_EXTENSIONS,
|
|
83
|
+
["png", "jpg", "jpeg", "webp", "gif", "pdf", "txt", "md", "json", "csv", "zip"]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
type IssueAttachmentResponse = Record<string, unknown> & { id: string; downloadPath: string };
|
|
87
|
+
|
|
54
88
|
function parseStringArray(value: unknown) {
|
|
55
89
|
if (Array.isArray(value)) {
|
|
56
90
|
return value.map((entry) => String(entry));
|
|
@@ -92,6 +126,13 @@ const updateIssueSchema = z
|
|
|
92
126
|
|
|
93
127
|
export function createIssuesRouter(ctx: AppContext) {
|
|
94
128
|
const router = Router();
|
|
129
|
+
const upload = multer({
|
|
130
|
+
storage: multer.memoryStorage(),
|
|
131
|
+
limits: {
|
|
132
|
+
fileSize: MAX_ATTACHMENT_SIZE_BYTES,
|
|
133
|
+
files: MAX_ATTACHMENTS_PER_REQUEST
|
|
134
|
+
}
|
|
135
|
+
});
|
|
95
136
|
router.use(requireCompanyScope);
|
|
96
137
|
|
|
97
138
|
router.get("/", async (req, res) => {
|
|
@@ -142,6 +183,199 @@ export function createIssuesRouter(ctx: AppContext) {
|
|
|
142
183
|
return sendOk(res, toIssueResponse(issue as unknown as Record<string, unknown>));
|
|
143
184
|
});
|
|
144
185
|
|
|
186
|
+
router.post("/:issueId/attachments", async (req, res) => {
|
|
187
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
188
|
+
if (res.headersSent) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
upload.array("files", MAX_ATTACHMENTS_PER_REQUEST)(req, res, async (uploadError) => {
|
|
193
|
+
if (uploadError) {
|
|
194
|
+
if (uploadError instanceof multer.MulterError) {
|
|
195
|
+
if (uploadError.code === "LIMIT_FILE_SIZE") {
|
|
196
|
+
return sendError(
|
|
197
|
+
res,
|
|
198
|
+
`Attachment exceeds max file size of ${MAX_ATTACHMENT_SIZE_BYTES} bytes.`,
|
|
199
|
+
422
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (uploadError.code === "LIMIT_FILE_COUNT") {
|
|
203
|
+
return sendError(
|
|
204
|
+
res,
|
|
205
|
+
`Too many files. Max ${MAX_ATTACHMENTS_PER_REQUEST} attachment(s) per request.`,
|
|
206
|
+
422
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return sendError(res, uploadError.message, 422);
|
|
210
|
+
}
|
|
211
|
+
return sendError(res, "Failed to parse multipart attachment payload.", 422);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
|
216
|
+
if (files.length === 0) {
|
|
217
|
+
return sendError(res, "At least one attachment file is required.", 422);
|
|
218
|
+
}
|
|
219
|
+
const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
|
|
220
|
+
if (!issueContext) {
|
|
221
|
+
return sendError(res, "Issue not found.", 404);
|
|
222
|
+
}
|
|
223
|
+
const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
|
|
224
|
+
const attachmentDir = join(workspacePath, ".bopo", "issues", issueContext.issueId, "attachments");
|
|
225
|
+
await mkdir(attachmentDir, { recursive: true });
|
|
226
|
+
|
|
227
|
+
const uploaded: IssueAttachmentResponse[] = [];
|
|
228
|
+
for (const file of files) {
|
|
229
|
+
if (!isAllowedAttachmentFile(file)) {
|
|
230
|
+
return sendError(
|
|
231
|
+
res,
|
|
232
|
+
`Unsupported attachment type for '${file.originalname}'. Allowed extensions: ${Array.from(ALLOWED_ATTACHMENT_EXTENSIONS).join(", ")}`,
|
|
233
|
+
422
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const attachmentId = nanoid(14);
|
|
238
|
+
const safeFileName = sanitizeAttachmentFileName(file.originalname);
|
|
239
|
+
const storedFileName = `${attachmentId}-${safeFileName}`;
|
|
240
|
+
const relativePath = join(".bopo", "issues", issueContext.issueId, "attachments", storedFileName);
|
|
241
|
+
const absolutePath = resolve(workspacePath, relativePath);
|
|
242
|
+
if (!isInsidePath(workspacePath, absolutePath)) {
|
|
243
|
+
return sendError(res, "Invalid attachment destination path.", 422);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await writeFile(absolutePath, file.buffer);
|
|
247
|
+
try {
|
|
248
|
+
const attachment = await addIssueAttachment(ctx.db, {
|
|
249
|
+
id: attachmentId,
|
|
250
|
+
companyId: req.companyId!,
|
|
251
|
+
issueId: issueContext.issueId,
|
|
252
|
+
projectId: issueContext.projectId,
|
|
253
|
+
fileName: file.originalname,
|
|
254
|
+
mimeType: file.mimetype || null,
|
|
255
|
+
fileSizeBytes: file.size,
|
|
256
|
+
relativePath,
|
|
257
|
+
uploadedByActorType: req.actor?.type === "agent" ? "agent" : "human",
|
|
258
|
+
uploadedByActorId: req.actor?.id
|
|
259
|
+
});
|
|
260
|
+
uploaded.push(toIssueAttachmentResponse(attachment as unknown as Record<string, unknown>, issueContext.issueId));
|
|
261
|
+
} catch (error) {
|
|
262
|
+
await rm(absolutePath, { force: true }).catch(() => undefined);
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await appendActivity(ctx.db, {
|
|
268
|
+
companyId: req.companyId!,
|
|
269
|
+
issueId: issueContext.issueId,
|
|
270
|
+
actorType: req.actor?.type === "agent" ? "agent" : "human",
|
|
271
|
+
actorId: req.actor?.id,
|
|
272
|
+
eventType: "issue.attachments_added",
|
|
273
|
+
payload: { count: uploaded.length, attachmentIds: uploaded.map((entry) => entry.id) }
|
|
274
|
+
});
|
|
275
|
+
await appendAuditEvent(ctx.db, {
|
|
276
|
+
companyId: req.companyId!,
|
|
277
|
+
actorType: req.actor?.type === "agent" ? "agent" : "human",
|
|
278
|
+
actorId: req.actor?.id,
|
|
279
|
+
eventType: "issue.attachments_added",
|
|
280
|
+
entityType: "issue",
|
|
281
|
+
entityId: issueContext.issueId,
|
|
282
|
+
payload: { attachments: uploaded }
|
|
283
|
+
});
|
|
284
|
+
return sendOk(res, uploaded);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// eslint-disable-next-line no-console
|
|
287
|
+
console.error(error);
|
|
288
|
+
return sendError(res, "Failed to upload attachments.", 500);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
router.get("/:issueId/attachments", async (req, res) => {
|
|
294
|
+
const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
|
|
295
|
+
if (!issueContext) {
|
|
296
|
+
return sendError(res, "Issue not found.", 404);
|
|
297
|
+
}
|
|
298
|
+
const attachments = await listIssueAttachments(ctx.db, req.companyId!, req.params.issueId);
|
|
299
|
+
return sendOk(
|
|
300
|
+
res,
|
|
301
|
+
attachments.map((attachment) =>
|
|
302
|
+
toIssueAttachmentResponse(attachment as unknown as Record<string, unknown>, req.params.issueId)
|
|
303
|
+
)
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
router.get("/:issueId/attachments/:attachmentId/download", async (req, res) => {
|
|
308
|
+
const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
|
|
309
|
+
if (!issueContext) {
|
|
310
|
+
return sendError(res, "Issue not found.", 404);
|
|
311
|
+
}
|
|
312
|
+
const attachment = await getIssueAttachment(ctx.db, req.companyId!, req.params.issueId, req.params.attachmentId);
|
|
313
|
+
if (!attachment) {
|
|
314
|
+
return sendError(res, "Attachment not found.", 404);
|
|
315
|
+
}
|
|
316
|
+
const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
|
|
317
|
+
const absolutePath = resolve(workspacePath, attachment.relativePath);
|
|
318
|
+
if (!isInsidePath(workspacePath, absolutePath)) {
|
|
319
|
+
return sendError(res, "Invalid attachment path.", 422);
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
await stat(absolutePath);
|
|
323
|
+
} catch {
|
|
324
|
+
return sendError(res, "Attachment file is missing on disk.", 404);
|
|
325
|
+
}
|
|
326
|
+
const fileBuffer = await readFile(absolutePath);
|
|
327
|
+
if (attachment.mimeType) {
|
|
328
|
+
res.setHeader("content-type", attachment.mimeType);
|
|
329
|
+
} else {
|
|
330
|
+
res.setHeader("content-type", "application/octet-stream");
|
|
331
|
+
}
|
|
332
|
+
res.setHeader("content-disposition", `attachment; filename="${encodeURIComponent(attachment.fileName)}"`);
|
|
333
|
+
return res.send(fileBuffer);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
router.delete("/:issueId/attachments/:attachmentId", async (req, res) => {
|
|
337
|
+
requirePermission("issues:write")(req, res, () => {});
|
|
338
|
+
if (res.headersSent) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const issueContext = await getIssueContextForAttachment(ctx, req.companyId!, req.params.issueId);
|
|
342
|
+
if (!issueContext) {
|
|
343
|
+
return sendError(res, "Issue not found.", 404);
|
|
344
|
+
}
|
|
345
|
+
const attachment = await getIssueAttachment(ctx.db, req.companyId!, req.params.issueId, req.params.attachmentId);
|
|
346
|
+
if (!attachment) {
|
|
347
|
+
return sendError(res, "Attachment not found.", 404);
|
|
348
|
+
}
|
|
349
|
+
const workspacePath = resolveWorkspacePath(issueContext.companyId, issueContext.projectId, issueContext.workspaceLocalPath);
|
|
350
|
+
const absolutePath = resolve(workspacePath, attachment.relativePath);
|
|
351
|
+
if (!isInsidePath(workspacePath, absolutePath)) {
|
|
352
|
+
return sendError(res, "Invalid attachment path.", 422);
|
|
353
|
+
}
|
|
354
|
+
await rm(absolutePath, { force: true }).catch(() => undefined);
|
|
355
|
+
const deleted = await deleteIssueAttachment(ctx.db, req.companyId!, req.params.issueId, req.params.attachmentId);
|
|
356
|
+
if (!deleted) {
|
|
357
|
+
return sendError(res, "Attachment not found.", 404);
|
|
358
|
+
}
|
|
359
|
+
await appendActivity(ctx.db, {
|
|
360
|
+
companyId: req.companyId!,
|
|
361
|
+
issueId: issueContext.issueId,
|
|
362
|
+
actorType: req.actor?.type === "agent" ? "agent" : "human",
|
|
363
|
+
actorId: req.actor?.id,
|
|
364
|
+
eventType: "issue.attachment_deleted",
|
|
365
|
+
payload: { attachmentId: req.params.attachmentId }
|
|
366
|
+
});
|
|
367
|
+
await appendAuditEvent(ctx.db, {
|
|
368
|
+
companyId: req.companyId!,
|
|
369
|
+
actorType: req.actor?.type === "agent" ? "agent" : "human",
|
|
370
|
+
actorId: req.actor?.id,
|
|
371
|
+
eventType: "issue.attachment_deleted",
|
|
372
|
+
entityType: "issue_attachment",
|
|
373
|
+
entityId: req.params.attachmentId,
|
|
374
|
+
payload: deleted as unknown as Record<string, unknown>
|
|
375
|
+
});
|
|
376
|
+
return sendOk(res, { deleted: true });
|
|
377
|
+
});
|
|
378
|
+
|
|
145
379
|
router.get("/:issueId/comments", async (req, res) => {
|
|
146
380
|
const comments = await listIssueComments(ctx.db, req.companyId!, req.params.issueId);
|
|
147
381
|
return sendOk(res, comments);
|
|
@@ -435,3 +669,90 @@ async function validateIssueAssignmentScope(
|
|
|
435
669
|
return null;
|
|
436
670
|
}
|
|
437
671
|
|
|
672
|
+
function parsePositiveIntEnv(name: string, fallback: number) {
|
|
673
|
+
const raw = process.env[name];
|
|
674
|
+
if (!raw) {
|
|
675
|
+
return fallback;
|
|
676
|
+
}
|
|
677
|
+
const parsed = Number.parseInt(raw, 10);
|
|
678
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
679
|
+
return fallback;
|
|
680
|
+
}
|
|
681
|
+
return parsed;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function parseCsvSet(raw: string | undefined, fallback: string[]) {
|
|
685
|
+
if (!raw || raw.trim().length === 0) {
|
|
686
|
+
return new Set(fallback);
|
|
687
|
+
}
|
|
688
|
+
return new Set(
|
|
689
|
+
raw
|
|
690
|
+
.split(",")
|
|
691
|
+
.map((value) => value.trim().toLowerCase())
|
|
692
|
+
.filter(Boolean)
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function sanitizeAttachmentFileName(input: string) {
|
|
697
|
+
const original = basename(input || "attachment");
|
|
698
|
+
const sanitized = original.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
699
|
+
return sanitized.length > 0 ? sanitized : "attachment";
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function isAllowedAttachmentFile(file: Express.Multer.File) {
|
|
703
|
+
const extension = extname(file.originalname).slice(1).toLowerCase();
|
|
704
|
+
const mime = (file.mimetype ?? "").toLowerCase();
|
|
705
|
+
if (extension && ALLOWED_ATTACHMENT_EXTENSIONS.has(extension)) {
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
return mime.length > 0 && ALLOWED_ATTACHMENT_MIME_TYPES.has(mime);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function toIssueAttachmentResponse(attachment: Record<string, unknown>, issueId: string) {
|
|
712
|
+
const id = String(attachment.id ?? "");
|
|
713
|
+
return {
|
|
714
|
+
...attachment,
|
|
715
|
+
id,
|
|
716
|
+
downloadPath: `/issues/${issueId}/attachments/${id}/download`
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function resolveWorkspacePath(companyId: string, projectId: string, workspaceLocalPath: string | null) {
|
|
721
|
+
if (workspaceLocalPath && workspaceLocalPath.trim().length > 0) {
|
|
722
|
+
return normalizeAbsolutePath(workspaceLocalPath);
|
|
723
|
+
}
|
|
724
|
+
return resolveProjectWorkspacePath(companyId, projectId);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function getIssueContextForAttachment(ctx: AppContext, companyId: string, issueId: string) {
|
|
728
|
+
const [issue] = await ctx.db
|
|
729
|
+
.select({
|
|
730
|
+
issueId: issues.id,
|
|
731
|
+
companyId: issues.companyId,
|
|
732
|
+
projectId: issues.projectId
|
|
733
|
+
})
|
|
734
|
+
.from(issues)
|
|
735
|
+
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
|
|
736
|
+
.limit(1);
|
|
737
|
+
if (!issue) {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
const [project] = await ctx.db
|
|
741
|
+
.select({
|
|
742
|
+
id: projects.id,
|
|
743
|
+
workspaceLocalPath: projects.workspaceLocalPath
|
|
744
|
+
})
|
|
745
|
+
.from(projects)
|
|
746
|
+
.where(and(eq(projects.companyId, companyId), eq(projects.id, issue.projectId)))
|
|
747
|
+
.limit(1);
|
|
748
|
+
if (!project) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
issueId: issue.issueId,
|
|
753
|
+
companyId: issue.companyId,
|
|
754
|
+
projectId: issue.projectId,
|
|
755
|
+
workspaceLocalPath: project.workspaceLocalPath
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|