bopodev-api 0.1.24 → 0.1.26

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.
@@ -6,7 +6,13 @@ import {
6
6
  getAdapterModels,
7
7
  runAdapterEnvironmentTest
8
8
  } from "bopodev-agent-sdk";
9
- import { AgentCreateRequestSchema, AgentUpdateRequestSchema } from "bopodev-contracts";
9
+ import {
10
+ AGENT_ROLE_LABELS,
11
+ AgentCreateRequestSchema,
12
+ AgentRoleKeySchema,
13
+ AgentSchema,
14
+ AgentUpdateRequestSchema
15
+ } from "bopodev-contracts";
10
16
  import {
11
17
  appendAuditEvent,
12
18
  createAgent,
@@ -18,7 +24,7 @@ import {
18
24
  updateAgent
19
25
  } from "bopodev-db";
20
26
  import type { AppContext } from "../context";
21
- import { sendError, sendOk } from "../http";
27
+ import { sendError, sendOk, sendOkValidated } from "../http";
22
28
  import {
23
29
  normalizeRuntimeConfig,
24
30
  parseRuntimeConfigFromAgentRow,
@@ -33,6 +39,7 @@ import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany
33
39
  import { requireCompanyScope } from "../middleware/company-scope";
34
40
  import { requireBoardRole, requirePermission } from "../middleware/request-actor";
35
41
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
42
+ import { publishAttentionSnapshot } from "../realtime/attention";
36
43
  import {
37
44
  publishOfficeOccupantForAgent,
38
45
  publishOfficeOccupantForApproval
@@ -84,6 +91,8 @@ const runtimePreflightSchema = z.object({
84
91
  const UPDATE_AGENT_ALLOWED_KEYS = new Set([
85
92
  "managerAgentId",
86
93
  "role",
94
+ "roleKey",
95
+ "title",
87
96
  "name",
88
97
  "providerType",
89
98
  "status",
@@ -129,6 +138,10 @@ function providerRequiresNamedModel(providerType: string) {
129
138
  return providerType !== "http" && providerType !== "shell";
130
139
  }
131
140
 
141
+ const agentResponseSchema = AgentSchema.extend({
142
+ stateBlob: z.string().optional()
143
+ });
144
+
132
145
  function ensureNamedRuntimeModel(providerType: string, runtimeModel: string | undefined) {
133
146
  if (!providerRequiresNamedModel(providerType)) {
134
147
  return true;
@@ -142,9 +155,11 @@ export function createAgentsRouter(ctx: AppContext) {
142
155
 
143
156
  router.get("/", async (req, res) => {
144
157
  const rows = await listAgents(ctx.db, req.companyId!);
145
- return sendOk(
158
+ return sendOkValidated(
146
159
  res,
147
- rows.map((row) => toAgentResponse(row as unknown as Record<string, unknown>))
160
+ agentResponseSchema.array(),
161
+ rows.map((row) => toAgentResponse(row as unknown as Record<string, unknown>)),
162
+ "agents.list"
148
163
  );
149
164
  });
150
165
 
@@ -155,6 +170,7 @@ export function createAgentsRouter(ctx: AppContext) {
155
170
  id: row.id,
156
171
  name: row.name,
157
172
  role: row.role,
173
+ roleKey: row.roleKey,
158
174
  status: row.status,
159
175
  canHireAgents: row.canHireAgents
160
176
  }))
@@ -170,8 +186,7 @@ export function createAgentsRouter(ctx: AppContext) {
170
186
  return sendOk(
171
187
  res,
172
188
  rows.map((row) => {
173
- const normalizedRole = row.role.trim().toLowerCase();
174
- const isLeadership = normalizedRole === "ceo" || Boolean(row.canHireAgents);
189
+ const isLeadership = row.roleKey === "ceo" || Boolean(row.canHireAgents);
175
190
  const issues: string[] = [];
176
191
  const hasBootstrapPrompt = hasText(row.bootstrapPrompt);
177
192
  if (isLeadership && !hasBootstrapPrompt) {
@@ -184,6 +199,7 @@ export function createAgentsRouter(ctx: AppContext) {
184
199
  agentId: row.id,
185
200
  name: row.name,
186
201
  role: row.role,
202
+ roleKey: row.roleKey,
187
203
  providerType: row.providerType,
188
204
  canHireAgents: Boolean(row.canHireAgents),
189
205
  isLeadership,
@@ -366,7 +382,8 @@ export function createAgentsRouter(ctx: AppContext) {
366
382
  const shouldRequestApproval = (parsed.data.requestApproval || req.actor?.type === "agent") && isApprovalRequired("hire_agent");
367
383
  if (shouldRequestApproval) {
368
384
  const duplicate = await findDuplicateHireRequest(ctx.db, req.companyId!, {
369
- role: parsed.data.role,
385
+ role: parsed.data.role ?? "",
386
+ roleKey: parsed.data.roleKey ?? null,
370
387
  managerAgentId: parsed.data.managerAgentId ?? null
371
388
  });
372
389
  if (duplicate) {
@@ -397,6 +414,7 @@ export function createAgentsRouter(ctx: AppContext) {
397
414
  })
398
415
  );
399
416
  await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approvalId);
417
+ await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
400
418
  }
401
419
  return sendOk(res, { queuedForApproval: true, approvalId });
402
420
  }
@@ -404,7 +422,9 @@ export function createAgentsRouter(ctx: AppContext) {
404
422
  const agent = await createAgent(ctx.db, {
405
423
  companyId: req.companyId!,
406
424
  managerAgentId: parsed.data.managerAgentId,
407
- role: parsed.data.role,
425
+ role: resolveAgentRoleText(parsed.data.role, parsed.data.roleKey, parsed.data.title),
426
+ roleKey: normalizeRoleKey(parsed.data.roleKey),
427
+ title: normalizeTitle(parsed.data.title),
408
428
  name: parsed.data.name,
409
429
  providerType: parsed.data.providerType,
410
430
  heartbeatCron: parsed.data.heartbeatCron,
@@ -543,7 +563,16 @@ export function createAgentsRouter(ctx: AppContext) {
543
563
  companyId: req.companyId!,
544
564
  id: req.params.agentId,
545
565
  managerAgentId: parsed.data.managerAgentId,
546
- role: parsed.data.role,
566
+ role:
567
+ parsed.data.role !== undefined || parsed.data.roleKey !== undefined || parsed.data.title !== undefined
568
+ ? resolveAgentRoleText(
569
+ parsed.data.role ?? existingAgent.role,
570
+ parsed.data.roleKey ?? existingAgent.roleKey,
571
+ parsed.data.title ?? existingAgent.title
572
+ )
573
+ : undefined,
574
+ roleKey: parsed.data.roleKey !== undefined ? normalizeRoleKey(parsed.data.roleKey) : undefined,
575
+ title: parsed.data.title !== undefined ? normalizeTitle(parsed.data.title) : undefined,
547
576
  name: parsed.data.name,
548
577
  providerType: parsed.data.providerType,
549
578
  status: parsed.data.status,
@@ -715,14 +744,15 @@ function enforceRuntimeCwdPolicy(companyId: string, runtime: ReturnType<typeof n
715
744
  async function findDuplicateHireRequest(
716
745
  db: AppContext["db"],
717
746
  companyId: string,
718
- input: { role: string; managerAgentId: string | null }
747
+ input: { role: string; roleKey: string | null; managerAgentId: string | null }
719
748
  ) {
720
749
  const role = input.role.trim();
750
+ const roleKey = normalizeRoleKey(input.roleKey);
721
751
  const managerAgentId = input.managerAgentId ?? null;
722
752
  const agents = await listAgents(db, companyId);
723
753
  const existingAgent = agents.find(
724
754
  (agent) =>
725
- agent.role === role &&
755
+ ((roleKey && agent.roleKey === roleKey) || (!roleKey && role.length > 0 && agent.role === role)) &&
726
756
  (agent.managerAgentId ?? null) === managerAgentId &&
727
757
  agent.status !== "terminated"
728
758
  );
@@ -732,6 +762,10 @@ async function findDuplicateHireRequest(
732
762
  return false;
733
763
  }
734
764
  const payload = parseApprovalPayload(approval.payloadJson);
765
+ const payloadRoleKey = normalizeRoleKey(payload.roleKey);
766
+ if (roleKey && payloadRoleKey) {
767
+ return payloadRoleKey === roleKey && (payload.managerAgentId ?? null) === managerAgentId;
768
+ }
735
769
  return payload.role === role && (payload.managerAgentId ?? null) === managerAgentId;
736
770
  });
737
771
  if (!existingAgent && !pendingApproval) {
@@ -743,11 +777,12 @@ async function findDuplicateHireRequest(
743
777
  };
744
778
  }
745
779
 
746
- function parseApprovalPayload(payloadJson: string): { role?: string; managerAgentId?: string | null } {
780
+ function parseApprovalPayload(payloadJson: string): { role?: string; roleKey?: string | null; managerAgentId?: string | null } {
747
781
  try {
748
782
  const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
749
783
  return {
750
784
  role: typeof parsed.role === "string" ? parsed.role : undefined,
785
+ roleKey: typeof parsed.roleKey === "string" ? parsed.roleKey : null,
751
786
  managerAgentId: typeof parsed.managerAgentId === "string" ? parsed.managerAgentId : null
752
787
  };
753
788
  } catch {
@@ -780,6 +815,40 @@ function providerSupportsSkillInjection(providerType: string) {
780
815
  return providerType === "codex" || providerType === "cursor" || providerType === "opencode" || providerType === "claude_code";
781
816
  }
782
817
 
818
+ function normalizeRoleKey(input: string | null | undefined) {
819
+ const normalized = input?.trim().toLowerCase();
820
+ if (!normalized) {
821
+ return null;
822
+ }
823
+ const parsed = AgentRoleKeySchema.safeParse(normalized);
824
+ return parsed.success ? parsed.data : null;
825
+ }
826
+
827
+ function normalizeTitle(input: string | null | undefined) {
828
+ const normalized = input?.trim();
829
+ return normalized ? normalized : null;
830
+ }
831
+
832
+ function resolveAgentRoleText(
833
+ legacyRole: string | undefined,
834
+ roleKeyInput: string | null | undefined,
835
+ titleInput: string | null | undefined
836
+ ) {
837
+ const normalizedLegacy = legacyRole?.trim();
838
+ if (normalizedLegacy) {
839
+ return normalizedLegacy;
840
+ }
841
+ const normalizedTitle = normalizeTitle(titleInput);
842
+ if (normalizedTitle) {
843
+ return normalizedTitle;
844
+ }
845
+ const roleKey = normalizeRoleKey(roleKeyInput);
846
+ if (roleKey) {
847
+ return AGENT_ROLE_LABELS[roleKey];
848
+ }
849
+ return AGENT_ROLE_LABELS.general;
850
+ }
851
+
783
852
  function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: string } | undefined) {
784
853
  if (!actor) {
785
854
  return { actorType: "human" as const, actorId: null as string | null };
@@ -0,0 +1,112 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { sendError, sendOkValidated } from "../http";
4
+ import { requireCompanyScope } from "../middleware/company-scope";
5
+ import type { AppContext } from "../context";
6
+ import {
7
+ clearBoardAttentionDismissed,
8
+ listBoardAttentionItems,
9
+ markBoardAttentionAcknowledged,
10
+ markBoardAttentionDismissed,
11
+ markBoardAttentionResolved,
12
+ markBoardAttentionSeen
13
+ } from "../services/attention-service";
14
+ import { BoardAttentionListResponseSchema } from "bopodev-contracts";
15
+ import { createAttentionRealtimeEvent } from "../realtime/attention";
16
+
17
+ const itemParamsSchema = z.object({
18
+ itemKey: z.string().min(1)
19
+ });
20
+
21
+ export function createAttentionRouter(ctx: AppContext) {
22
+ const router = Router();
23
+ router.use(requireCompanyScope);
24
+
25
+ // Canonical board action queue endpoint for Inbox and board attention UX.
26
+ router.get("/", async (req, res) => {
27
+ const actorId = req.actor?.id ?? "local-board";
28
+ const items = await listBoardAttentionItems(ctx.db, req.companyId!, actorId);
29
+ return sendOkValidated(res, BoardAttentionListResponseSchema, { actorId, items }, "attention.list");
30
+ });
31
+
32
+ router.post("/:itemKey/seen", async (req, res) => {
33
+ const parsed = itemParamsSchema.safeParse(req.params);
34
+ if (!parsed.success) {
35
+ return sendError(res, parsed.error.message, 422);
36
+ }
37
+ const actorId = req.actor?.id ?? "local-board";
38
+ await markBoardAttentionSeen(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
39
+ await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
40
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.seen");
41
+ });
42
+
43
+ router.post("/:itemKey/acknowledge", async (req, res) => {
44
+ const parsed = itemParamsSchema.safeParse(req.params);
45
+ if (!parsed.success) {
46
+ return sendError(res, parsed.error.message, 422);
47
+ }
48
+ const actorId = req.actor?.id ?? "local-board";
49
+ await markBoardAttentionAcknowledged(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
50
+ await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
51
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.acknowledge");
52
+ });
53
+
54
+ router.post("/:itemKey/dismiss", async (req, res) => {
55
+ const parsed = itemParamsSchema.safeParse(req.params);
56
+ if (!parsed.success) {
57
+ return sendError(res, parsed.error.message, 422);
58
+ }
59
+ const actorId = req.actor?.id ?? "local-board";
60
+ await markBoardAttentionDismissed(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
61
+ await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
62
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.dismiss");
63
+ });
64
+
65
+ router.post("/:itemKey/undismiss", async (req, res) => {
66
+ const parsed = itemParamsSchema.safeParse(req.params);
67
+ if (!parsed.success) {
68
+ return sendError(res, parsed.error.message, 422);
69
+ }
70
+ const actorId = req.actor?.id ?? "local-board";
71
+ await clearBoardAttentionDismissed(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
72
+ await publishAttentionUpdate(ctx, req.companyId!, actorId, parsed.data.itemKey);
73
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.undismiss");
74
+ });
75
+
76
+ router.post("/:itemKey/resolve", async (req, res) => {
77
+ const parsed = itemParamsSchema.safeParse(req.params);
78
+ if (!parsed.success) {
79
+ return sendError(res, parsed.error.message, 422);
80
+ }
81
+ const actorId = req.actor?.id ?? "local-board";
82
+ await markBoardAttentionResolved(ctx.db, req.companyId!, actorId, parsed.data.itemKey);
83
+ await publishAttentionResolve(ctx, req.companyId!, actorId, parsed.data.itemKey);
84
+ return sendOkValidated(res, z.object({ ok: z.literal(true) }), { ok: true }, "attention.resolve");
85
+ });
86
+
87
+ return router;
88
+ }
89
+
90
+ async function publishAttentionUpdate(ctx: AppContext, companyId: string, actorId: string, itemKey: string) {
91
+ const items = await listBoardAttentionItems(ctx.db, companyId, actorId);
92
+ const item = items.find((entry) => entry.key === itemKey);
93
+ if (!item) {
94
+ return;
95
+ }
96
+ ctx.realtimeHub?.publish(
97
+ createAttentionRealtimeEvent(companyId, {
98
+ type: "attention.updated",
99
+ item
100
+ })
101
+ );
102
+ }
103
+
104
+ async function publishAttentionResolve(ctx: AppContext, companyId: string, actorId: string, itemKey: string) {
105
+ await publishAttentionUpdate(ctx, companyId, actorId, itemKey);
106
+ ctx.realtimeHub?.publish(
107
+ createAttentionRealtimeEvent(companyId, {
108
+ type: "attention.resolved",
109
+ key: itemKey
110
+ })
111
+ );
112
+ }
@@ -2,9 +2,10 @@ import { mkdir } from "node:fs/promises";
2
2
  import type { NextFunction, Request, Response } from "express";
3
3
  import { Router } from "express";
4
4
  import { z } from "zod";
5
+ import { CompanySchema } from "bopodev-contracts";
5
6
  import { createAgent, createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
6
7
  import type { AppContext } from "../context";
7
- import { sendError, sendOk } from "../http";
8
+ import { sendError, sendOk, sendOkValidated } from "../http";
8
9
  import { normalizeRuntimeConfig, resolveRuntimeModelForProvider, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
9
10
  import { buildDefaultCeoBootstrapPrompt } from "../lib/ceo-bootstrap-prompt";
10
11
  import { resolveOpencodeRuntimeModel } from "../lib/opencode-model";
@@ -37,14 +38,19 @@ export function createCompaniesRouter(ctx: AppContext) {
37
38
  const router = Router();
38
39
 
39
40
  router.get("/", async (req, res) => {
40
- const companies = await listCompanies(ctx.db);
41
+ const companies = (await listCompanies(ctx.db)).map((company) => ({
42
+ ...company,
43
+ createdAt: company.createdAt instanceof Date ? company.createdAt.toISOString() : String(company.createdAt)
44
+ }));
41
45
  if (req.actor?.type === "board") {
42
- return sendOk(res, companies);
46
+ return sendOkValidated(res, CompanySchema.array(), companies, "companies.list");
43
47
  }
44
48
  const visibleCompanyIds = new Set(req.actor?.companyIds ?? []);
45
- return sendOk(
49
+ return sendOkValidated(
46
50
  res,
47
- companies.filter((company) => visibleCompanyIds.has(company.id))
51
+ CompanySchema.array(),
52
+ companies.filter((company) => visibleCompanyIds.has(company.id)),
53
+ "companies.list.filtered"
48
54
  );
49
55
  });
50
56
 
@@ -91,6 +97,8 @@ export function createCompaniesRouter(ctx: AppContext) {
91
97
  await createAgent(ctx.db, {
92
98
  companyId: company.id,
93
99
  role: "CEO",
100
+ roleKey: "ceo",
101
+ title: "CEO",
94
102
  name: "CEO",
95
103
  providerType,
96
104
  heartbeatCron: "*/5 * * * *",
@@ -1,11 +1,13 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
+ import { GoalSchema } from "bopodev-contracts";
3
4
  import { appendAuditEvent, createApprovalRequest, createGoal, deleteGoal, getApprovalRequest, listGoals, updateGoal } from "bopodev-db";
4
5
  import type { AppContext } from "../context";
5
- import { sendError, sendOk } from "../http";
6
+ import { sendError, sendOk, sendOkValidated } from "../http";
6
7
  import { requireCompanyScope } from "../middleware/company-scope";
7
8
  import { requirePermission } from "../middleware/request-actor";
8
9
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
10
+ import { publishAttentionSnapshot } from "../realtime/attention";
9
11
  import { isApprovalRequired } from "../services/governance-service";
10
12
 
11
13
  const createGoalSchema = z.object({
@@ -33,7 +35,12 @@ export function createGoalsRouter(ctx: AppContext) {
33
35
  router.use(requireCompanyScope);
34
36
 
35
37
  router.get("/", async (req, res) => {
36
- return sendOk(res, await listGoals(ctx.db, req.companyId!));
38
+ return sendOkValidated(
39
+ res,
40
+ GoalSchema.array(),
41
+ await listGoals(ctx.db, req.companyId!),
42
+ "goals.list"
43
+ );
37
44
  });
38
45
 
39
46
  router.post("/", async (req, res) => {
@@ -60,6 +67,7 @@ export function createGoalsRouter(ctx: AppContext) {
60
67
  approval: serializeStoredApproval(approval)
61
68
  })
62
69
  );
70
+ await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
63
71
  }
64
72
  return sendOk(res, { queuedForApproval: true, approvalId });
65
73
  }
@@ -1,6 +1,7 @@
1
1
  import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import {
4
+ addIssueComment,
4
5
  appendAuditEvent,
5
6
  clearApprovalInboxDismissed,
6
7
  countPendingApprovalRequests,
@@ -15,6 +16,7 @@ import { sendError, sendOk } from "../http";
15
16
  import { requireCompanyScope } from "../middleware/company-scope";
16
17
  import { requirePermission } from "../middleware/request-actor";
17
18
  import { createGovernanceRealtimeEvent, serializeStoredApproval } from "../realtime/governance";
19
+ import { publishAttentionSnapshot } from "../realtime/attention";
18
20
  import {
19
21
  publishOfficeOccupantForAgent,
20
22
  publishOfficeOccupantForApproval
@@ -34,6 +36,9 @@ export function createGovernanceRouter(ctx: AppContext) {
34
36
  const router = Router();
35
37
  router.use(requireCompanyScope);
36
38
 
39
+ // Deprecated compatibility shim:
40
+ // board queue consumers should use /attention as the canonical source.
41
+ // Keep this endpoint until all downstream consumers migrate.
37
42
  router.get("/approvals", async (req, res) => {
38
43
  const approvals = await listApprovalRequests(ctx.db, req.companyId!);
39
44
  return sendOk(
@@ -200,10 +205,41 @@ export function createGovernanceRouter(ctx: AppContext) {
200
205
  approval: serializeStoredApproval(approval)
201
206
  })
202
207
  );
208
+ await publishAttentionSnapshot(ctx.db, ctx.realtimeHub, req.companyId!);
203
209
  await publishOfficeOccupantForApproval(ctx.db, ctx.realtimeHub, req.companyId!, approval.id);
204
210
  if (approval.requestedByAgentId) {
205
211
  await publishOfficeOccupantForAgent(ctx.db, ctx.realtimeHub, req.companyId!, approval.requestedByAgentId);
206
212
  }
213
+ if (parsed.data.status === "approved" && resolution.action === "hire_agent" && resolution.execution.applied) {
214
+ const hireContext = parseHireApprovalCommentContext(approval.payloadJson);
215
+ if (hireContext.issueIds.length > 0) {
216
+ const commentBody = buildHireApprovalIssueComment(hireContext.roleLabel);
217
+ try {
218
+ for (const issueId of hireContext.issueIds) {
219
+ await addIssueComment(ctx.db, {
220
+ companyId: req.companyId!,
221
+ issueId,
222
+ body: commentBody,
223
+ authorType: auditActor.actorType === "agent" ? "agent" : "human",
224
+ authorId: auditActor.actorId
225
+ });
226
+ }
227
+ } catch (error) {
228
+ await appendAuditEvent(ctx.db, {
229
+ companyId: req.companyId!,
230
+ actorType: "system",
231
+ actorId: null,
232
+ eventType: "governance.hire_approval_comment_failed",
233
+ entityType: "approval_request",
234
+ entityId: approval.id,
235
+ payload: {
236
+ error: String(error),
237
+ issueIds: hireContext.issueIds
238
+ }
239
+ });
240
+ }
241
+ }
242
+ }
207
243
  }
208
244
 
209
245
  if (resolution.execution.entityType === "agent" && resolution.execution.entityId) {
@@ -226,11 +262,58 @@ function resolveAuditActor(actor: { type: "board" | "member" | "agent"; id: stri
226
262
  return { actorType: "human" as const, actorId: actor.id };
227
263
  }
228
264
 
229
- function parsePayload(payloadJson: string) {
265
+ function parsePayload(payloadJson: string): Record<string, unknown> {
230
266
  try {
231
267
  const parsed = JSON.parse(payloadJson) as unknown;
232
- return typeof parsed === "object" && parsed !== null ? parsed : {};
268
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
233
269
  } catch {
234
270
  return {};
235
271
  }
236
272
  }
273
+
274
+ function parseHireApprovalCommentContext(payloadJson: string) {
275
+ const payload = parsePayload(payloadJson);
276
+ const issueIds = normalizeSourceIssueIds(
277
+ typeof payload.sourceIssueId === "string" ? payload.sourceIssueId : undefined,
278
+ Array.isArray(payload.sourceIssueIds) ? payload.sourceIssueIds : undefined
279
+ );
280
+ const roleLabel = resolveHireRoleLabel(payload);
281
+ return { issueIds, roleLabel };
282
+ }
283
+
284
+ function normalizeSourceIssueIds(sourceIssueId?: string, sourceIssueIds?: unknown[]) {
285
+ const normalized = new Set<string>();
286
+ for (const entry of [sourceIssueId, ...(sourceIssueIds ?? [])]) {
287
+ if (typeof entry !== "string") {
288
+ continue;
289
+ }
290
+ const trimmed = entry.trim();
291
+ if (trimmed.length > 0) {
292
+ normalized.add(trimmed);
293
+ }
294
+ }
295
+ return Array.from(normalized);
296
+ }
297
+
298
+ function resolveHireRoleLabel(payload: Record<string, unknown>) {
299
+ const title = typeof payload.title === "string" ? payload.title.trim() : "";
300
+ if (title.length > 0) {
301
+ return title;
302
+ }
303
+ const role = typeof payload.role === "string" ? payload.role.trim() : "";
304
+ if (role.length > 0) {
305
+ return role;
306
+ }
307
+ const roleKey = typeof payload.roleKey === "string" ? payload.roleKey.trim() : "";
308
+ if (roleKey.length > 0) {
309
+ return roleKey.replace(/_/g, " ");
310
+ }
311
+ return null;
312
+ }
313
+
314
+ function buildHireApprovalIssueComment(roleLabel: string | null) {
315
+ if (roleLabel) {
316
+ return `Approved hiring of ${roleLabel}.`;
317
+ }
318
+ return "Approved hiring request.";
319
+ }