bopodev-api 0.1.7 → 0.1.9

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.
@@ -1,14 +1,19 @@
1
1
  import { Router } from "express";
2
+ import { and, eq } from "drizzle-orm";
2
3
  import { z } from "zod";
3
4
  import {
4
5
  addIssueComment,
6
+ agents,
5
7
  appendActivity,
6
8
  appendAuditEvent,
7
9
  createIssue,
8
10
  deleteIssueComment,
9
11
  deleteIssue,
12
+ issues,
13
+ listIssueActivity,
10
14
  listIssueComments,
11
15
  listIssues,
16
+ projects,
12
17
  updateIssueComment,
13
18
  updateIssue
14
19
  } from "bopodev-db";
@@ -31,14 +36,14 @@ const createIssueSchema = z.object({
31
36
 
32
37
  const createIssueCommentSchema = z.object({
33
38
  body: z.string().min(1),
34
- authorType: z.enum(["human", "agent", "system"]).default("human"),
39
+ authorType: z.enum(["human", "agent", "system"]).optional(),
35
40
  authorId: z.string().optional()
36
41
  });
37
42
 
38
43
  const createIssueCommentLegacySchema = z.object({
39
44
  issueId: z.string().min(1),
40
45
  body: z.string().min(1),
41
- authorType: z.enum(["human", "agent", "system"]).default("human"),
46
+ authorType: z.enum(["human", "agent", "system"]).optional(),
42
47
  authorId: z.string().optional()
43
48
  });
44
49
 
@@ -107,6 +112,17 @@ export function createIssuesRouter(ctx: AppContext) {
107
112
  if (!parsed.success) {
108
113
  return sendError(res, parsed.error.message, 422);
109
114
  }
115
+ if (parsed.data.assigneeAgentId) {
116
+ const assignmentValidation = await validateIssueAssignmentScope(
117
+ ctx,
118
+ req.companyId!,
119
+ parsed.data.projectId,
120
+ parsed.data.assigneeAgentId
121
+ );
122
+ if (assignmentValidation) {
123
+ return sendError(res, assignmentValidation, 422);
124
+ }
125
+ }
110
126
  const issue = await createIssue(ctx.db, { companyId: req.companyId!, ...parsed.data });
111
127
  await appendActivity(ctx.db, {
112
128
  companyId: req.companyId!,
@@ -131,6 +147,17 @@ export function createIssuesRouter(ctx: AppContext) {
131
147
  return sendOk(res, comments);
132
148
  });
133
149
 
150
+ router.get("/:issueId/activity", async (req, res) => {
151
+ const activity = await listIssueActivity(ctx.db, req.companyId!, req.params.issueId);
152
+ return sendOk(
153
+ res,
154
+ activity.map((row) => ({
155
+ ...row,
156
+ payload: parsePayload(row.payloadJson)
157
+ }))
158
+ );
159
+ });
160
+
134
161
  router.post("/:issueId/comments", async (req, res) => {
135
162
  requirePermission("issues:write")(req, res, () => {});
136
163
  if (res.headersSent) {
@@ -140,10 +167,13 @@ export function createIssuesRouter(ctx: AppContext) {
140
167
  if (!parsed.success) {
141
168
  return sendError(res, parsed.error.message, 422);
142
169
  }
170
+ const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, parsed.data);
143
171
  const comment = await addIssueComment(ctx.db, {
144
172
  companyId: req.companyId!,
145
173
  issueId: req.params.issueId,
146
- ...parsed.data
174
+ body: parsed.data.body,
175
+ authorType: author.authorType,
176
+ authorId: author.authorId
147
177
  });
148
178
  await appendActivity(ctx.db, {
149
179
  companyId: req.companyId!,
@@ -175,7 +205,14 @@ export function createIssuesRouter(ctx: AppContext) {
175
205
  if (!parsed.success) {
176
206
  return sendError(res, parsed.error.message, 422);
177
207
  }
178
- const comment = await addIssueComment(ctx.db, { companyId: req.companyId!, ...parsed.data });
208
+ const author = resolveIssueCommentAuthor(req.actor?.type, req.actor?.id, parsed.data);
209
+ const comment = await addIssueComment(ctx.db, {
210
+ companyId: req.companyId!,
211
+ issueId: parsed.data.issueId,
212
+ body: parsed.data.body,
213
+ authorType: author.authorType,
214
+ authorId: author.authorId
215
+ });
179
216
  await appendActivity(ctx.db, {
180
217
  companyId: req.companyId!,
181
218
  issueId: comment.issueId,
@@ -271,6 +308,34 @@ export function createIssuesRouter(ctx: AppContext) {
271
308
  if (!parsed.success) {
272
309
  return sendError(res, parsed.error.message, 422);
273
310
  }
311
+ if (parsed.data.assigneeAgentId !== undefined || parsed.data.projectId !== undefined) {
312
+ const [existingIssue] = await ctx.db
313
+ .select({
314
+ id: issues.id,
315
+ projectId: issues.projectId,
316
+ assigneeAgentId: issues.assigneeAgentId
317
+ })
318
+ .from(issues)
319
+ .where(and(eq(issues.companyId, req.companyId!), eq(issues.id, req.params.issueId)))
320
+ .limit(1);
321
+ if (!existingIssue) {
322
+ return sendError(res, "Issue not found.", 404);
323
+ }
324
+ const effectiveProjectId = parsed.data.projectId ?? existingIssue.projectId;
325
+ const effectiveAssigneeAgentId =
326
+ parsed.data.assigneeAgentId === undefined ? existingIssue.assigneeAgentId : parsed.data.assigneeAgentId;
327
+ if (effectiveAssigneeAgentId) {
328
+ const assignmentValidation = await validateIssueAssignmentScope(
329
+ ctx,
330
+ req.companyId!,
331
+ effectiveProjectId,
332
+ effectiveAssigneeAgentId
333
+ );
334
+ if (assignmentValidation) {
335
+ return sendError(res, assignmentValidation, 422);
336
+ }
337
+ }
338
+ }
274
339
 
275
340
  const issue = await updateIssue(ctx.db, { companyId: req.companyId!, id: req.params.issueId, ...parsed.data });
276
341
  if (!issue) {
@@ -317,3 +382,56 @@ export function createIssuesRouter(ctx: AppContext) {
317
382
 
318
383
  return router;
319
384
  }
385
+
386
+ function parsePayload(payloadJson: string) {
387
+ try {
388
+ const parsed = JSON.parse(payloadJson) as unknown;
389
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
390
+ } catch {
391
+ return {};
392
+ }
393
+ }
394
+
395
+ function resolveIssueCommentAuthor(
396
+ actorType: "board" | "member" | "agent" | undefined,
397
+ actorId: string | undefined,
398
+ input: { authorType?: "human" | "agent" | "system"; authorId?: string }
399
+ ) {
400
+ const inferredAuthorType = actorType === "agent" ? "agent" : "human";
401
+ const authorType = input.authorType ?? inferredAuthorType;
402
+ if (input.authorId) {
403
+ return { authorType, authorId: input.authorId };
404
+ }
405
+ if (authorType === "agent" && actorId) {
406
+ return { authorType, authorId: actorId };
407
+ }
408
+ return { authorType, authorId: undefined };
409
+ }
410
+
411
+ async function validateIssueAssignmentScope(
412
+ ctx: AppContext,
413
+ companyId: string,
414
+ projectId: string,
415
+ assigneeAgentId: string
416
+ ) {
417
+ const [project] = await ctx.db
418
+ .select({ id: projects.id })
419
+ .from(projects)
420
+ .where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
421
+ .limit(1);
422
+ if (!project) {
423
+ return "Project not found.";
424
+ }
425
+
426
+ const [agent] = await ctx.db
427
+ .select({ id: agents.id })
428
+ .from(agents)
429
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, assigneeAgentId)))
430
+ .limit(1);
431
+ if (!agent) {
432
+ return "Assigned agent not found.";
433
+ }
434
+
435
+ return null;
436
+ }
437
+
@@ -1,8 +1,10 @@
1
1
  import { Router } from "express";
2
+ import { mkdir } from "node:fs/promises";
2
3
  import { z } from "zod";
3
4
  import { appendAuditEvent, createProject, deleteProject, listProjects, syncProjectGoals, updateProject } from "bopodev-db";
4
5
  import type { AppContext } from "../context";
5
6
  import { sendError, sendOk } from "../http";
7
+ import { normalizeAbsolutePath, resolveProjectWorkspacePath } from "../lib/instance-paths";
6
8
  import { requireCompanyScope } from "../middleware/company-scope";
7
9
  import { requirePermission } from "../middleware/request-actor";
8
10
 
@@ -59,15 +61,23 @@ export function createProjectsRouter(ctx: AppContext) {
59
61
  if (!parsed.success) {
60
62
  return sendError(res, parsed.error.message, 422);
61
63
  }
62
- const project = await createProject(ctx.db, {
64
+ const explicitWorkspaceLocalPath = normalizeOptionalPath(parsed.data.workspaceLocalPath);
65
+ const created = await createProject(ctx.db, {
63
66
  companyId: req.companyId!,
64
67
  name: parsed.data.name,
65
68
  description: parsed.data.description,
66
69
  status: parsed.data.status,
67
70
  plannedStartAt: parsePlannedStartAt(parsed.data.plannedStartAt),
68
- workspaceLocalPath: parsed.data.workspaceLocalPath,
71
+ workspaceLocalPath: explicitWorkspaceLocalPath ?? undefined,
69
72
  workspaceGithubRepo: parsed.data.workspaceGithubRepo
70
73
  });
74
+ const project =
75
+ explicitWorkspaceLocalPath
76
+ ? created
77
+ : await ensureAutoWorkspaceLocalPath(ctx, req.companyId!, created.id);
78
+ if (explicitWorkspaceLocalPath) {
79
+ await mkdir(explicitWorkspaceLocalPath, { recursive: true });
80
+ }
71
81
  await syncProjectGoals(ctx.db, {
72
82
  companyId: req.companyId!,
73
83
  projectId: project.id,
@@ -102,12 +112,38 @@ export function createProjectsRouter(ctx: AppContext) {
102
112
  status: parsed.data.status,
103
113
  plannedStartAt:
104
114
  parsed.data.plannedStartAt === undefined ? undefined : parsePlannedStartAt(parsed.data.plannedStartAt),
105
- workspaceLocalPath: parsed.data.workspaceLocalPath,
115
+ workspaceLocalPath:
116
+ parsed.data.workspaceLocalPath === undefined
117
+ ? undefined
118
+ : parsed.data.workspaceLocalPath === null
119
+ ? null
120
+ : normalizeAbsolutePath(parsed.data.workspaceLocalPath),
106
121
  workspaceGithubRepo: parsed.data.workspaceGithubRepo
107
122
  });
123
+ if (
124
+ parsed.data.workspaceLocalPath !== undefined &&
125
+ parsed.data.workspaceLocalPath !== null &&
126
+ parsed.data.workspaceLocalPath.trim().length > 0
127
+ ) {
128
+ await mkdir(normalizeAbsolutePath(parsed.data.workspaceLocalPath), { recursive: true });
129
+ }
108
130
  if (!project) {
109
131
  return sendError(res, "Project not found.", 404);
110
132
  }
133
+ if (parsed.data.workspaceLocalPath === null) {
134
+ const autoWorkspacePath = resolveProjectWorkspacePath(req.companyId!, project.id);
135
+ await mkdir(autoWorkspacePath, { recursive: true });
136
+ const updated = await updateProject(ctx.db, {
137
+ companyId: req.companyId!,
138
+ id: req.params.projectId,
139
+ workspaceLocalPath: autoWorkspacePath
140
+ });
141
+ if (!updated) {
142
+ return sendError(res, "Project not found.", 404);
143
+ }
144
+ Object.assign(project, updated);
145
+ }
146
+
111
147
  if (parsed.data.goalIds) {
112
148
  await syncProjectGoals(ctx.db, {
113
149
  companyId: req.companyId!,
@@ -150,3 +186,24 @@ export function createProjectsRouter(ctx: AppContext) {
150
186
 
151
187
  return router;
152
188
  }
189
+
190
+ async function ensureAutoWorkspaceLocalPath(ctx: AppContext, companyId: string, projectId: string) {
191
+ const workspaceLocalPath = resolveProjectWorkspacePath(companyId, projectId);
192
+ await mkdir(workspaceLocalPath, { recursive: true });
193
+ const updated = await updateProject(ctx.db, {
194
+ companyId,
195
+ id: projectId,
196
+ workspaceLocalPath
197
+ });
198
+ if (!updated) {
199
+ throw new Error("Project not found after creation.");
200
+ }
201
+ return updated;
202
+ }
203
+
204
+ function normalizeOptionalPath(value: string | undefined) {
205
+ if (!value || value.trim().length === 0) {
206
+ return null;
207
+ }
208
+ return normalizeAbsolutePath(value);
209
+ }
@@ -0,0 +1,118 @@
1
+ import { access, constants, mkdir } from "node:fs/promises";
2
+ import { isAbsolute } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { bootstrapDatabase, listCompanies, listProjects, updateProject } from "bopodev-db";
5
+ import { normalizeAbsolutePath, resolveBopoInstanceRoot, resolveProjectWorkspacePath, resolveStorageRoot } from "../lib/instance-paths";
6
+
7
+ export interface ProjectWorkspaceBackfillSummary {
8
+ scannedProjects: number;
9
+ missingWorkspaceLocalPath: number;
10
+ relativeWorkspaceLocalPath: number;
11
+ updatedProjects: number;
12
+ createdDirectories: number;
13
+ writableInstanceRoot: boolean;
14
+ writableStorageRoot: boolean;
15
+ dryRun: boolean;
16
+ }
17
+
18
+ export async function backfillProjectWorkspaces(input: { dbPath?: string; dryRun: boolean }) {
19
+ const { db, client } = await bootstrapDatabase(input.dbPath);
20
+ const instanceRoot = resolveBopoInstanceRoot();
21
+ const storageRoot = resolveStorageRoot();
22
+ let scannedProjects = 0;
23
+ let missingWorkspaceLocalPath = 0;
24
+ let relativeWorkspaceLocalPath = 0;
25
+ let updatedProjects = 0;
26
+ let createdDirectories = 0;
27
+
28
+ try {
29
+ const companies = await listCompanies(db);
30
+ for (const company of companies) {
31
+ const projects = await listProjects(db, company.id);
32
+ for (const project of projects) {
33
+ scannedProjects += 1;
34
+ const workspaceLocalPath = project.workspaceLocalPath?.trim() ?? "";
35
+ if (!workspaceLocalPath) {
36
+ missingWorkspaceLocalPath += 1;
37
+ const nextPath = resolveProjectWorkspacePath(company.id, project.id);
38
+ if (!input.dryRun) {
39
+ await mkdir(nextPath, { recursive: true });
40
+ createdDirectories += 1;
41
+ const updated = await updateProject(db, {
42
+ companyId: company.id,
43
+ id: project.id,
44
+ workspaceLocalPath: nextPath
45
+ });
46
+ if (updated) {
47
+ updatedProjects += 1;
48
+ }
49
+ }
50
+ continue;
51
+ }
52
+
53
+ if (!isAbsolute(workspaceLocalPath)) {
54
+ relativeWorkspaceLocalPath += 1;
55
+ const nextPath = normalizeAbsolutePath(workspaceLocalPath);
56
+ if (!input.dryRun) {
57
+ await mkdir(nextPath, { recursive: true });
58
+ createdDirectories += 1;
59
+ const updated = await updateProject(db, {
60
+ companyId: company.id,
61
+ id: project.id,
62
+ workspaceLocalPath: nextPath
63
+ });
64
+ if (updated) {
65
+ updatedProjects += 1;
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ if (!input.dryRun) {
73
+ await mkdir(instanceRoot, { recursive: true });
74
+ await mkdir(storageRoot, { recursive: true });
75
+ }
76
+ const writableInstanceRoot = await isDirectoryWritable(instanceRoot);
77
+ const writableStorageRoot = await isDirectoryWritable(storageRoot);
78
+
79
+ return {
80
+ scannedProjects,
81
+ missingWorkspaceLocalPath,
82
+ relativeWorkspaceLocalPath,
83
+ updatedProjects,
84
+ createdDirectories,
85
+ writableInstanceRoot,
86
+ writableStorageRoot,
87
+ dryRun: input.dryRun
88
+ } satisfies ProjectWorkspaceBackfillSummary;
89
+ } finally {
90
+ const maybeClose = (client as { close?: () => Promise<void> }).close;
91
+ if (maybeClose) {
92
+ await maybeClose.call(client);
93
+ }
94
+ }
95
+ }
96
+
97
+ async function isDirectoryWritable(path: string) {
98
+ try {
99
+ await mkdir(path, { recursive: true });
100
+ await access(path, constants.W_OK);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ async function main() {
108
+ const summary = await backfillProjectWorkspaces({
109
+ dbPath: process.env.BOPO_DB_PATH,
110
+ dryRun: process.env.BOPO_BACKFILL_DRY_RUN !== "0"
111
+ });
112
+ // eslint-disable-next-line no-console
113
+ console.log(JSON.stringify(summary));
114
+ }
115
+
116
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
117
+ void main();
118
+ }