@orboto/mcp 0.178.0

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.
Files changed (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * ORB-799 - bulk-* writes.
3
+ *
4
+ * The wrapper's `bulk-*` family is one of its most-used clusters, used
5
+ * in every phase-cleanup pass. MCP-side we collapse the wrapper's
6
+ * `--from=- | @file | "ORB-1,ORB-2"` UX into a single typed `ticketKeys:
7
+ * string[]` array - the calling agent already has the list as JSON, so
8
+ * the wrapper's stdin/file-roundtrip is a regression there.
9
+ *
10
+ * Each tool:
11
+ * - Takes `ticketKeys: string[]` (1..200 keys) + tool-specific params
12
+ * - Has a `dryRun: boolean` modifier - when true, resolves every key
13
+ * to verify visibility + permission but does NOT issue the
14
+ * mutating call. Returns the same outcome shape with a
15
+ * `dryRun: true` marker so the caller can preview.
16
+ * - Returns `{ successful: [...], failed: [{ticketKey, error}], skipped: [...] }`
17
+ * so the model can branch on partial failures instead of parsing
18
+ * stderr progress lines like the wrapper does.
19
+ *
20
+ * Concurrency: serial per-ticket. Bulk operations land on a tenant DB
21
+ * that other operators may be writing concurrently; spraying parallel
22
+ * PATCHes against the same tickets is the kind of thing tenant rate
23
+ * limits exist to slow down. A 200-ticket bulk-close at ~80ms/ticket
24
+ * is ~16s - acceptable.
25
+ */
26
+ import { z } from 'zod';
27
+ import { OrbotoApiError } from '../orboto-client.js';
28
+ import { resolveTicketByKey } from './shared.js';
29
+ const STATUS_CATEGORIES = ['todo', 'in_progress', 'in_review', 'done', 'wont_fix'];
30
+ const CATEGORY_TO_LEGACY = {
31
+ todo: 'TODO',
32
+ in_progress: 'IN_PROGRESS',
33
+ in_review: 'IN_REVIEW',
34
+ done: 'DONE',
35
+ wont_fix: 'WONT_FIX',
36
+ };
37
+ const TICKET_KEY_ARRAY = z.array(z.string().min(3)).min(1).max(200)
38
+ .describe('Ticket keys (e.g. ["ACME-1", "ACME-2"]). Capped at 200 per call to keep the per-tenant rate-limit budget reasonable.');
39
+ function emptyOutcome(dryRun) {
40
+ return { successful: [], failed: [], skipped: [], dryRun };
41
+ }
42
+ function bulkResult(action, outcome) {
43
+ const lines = [
44
+ `${outcome.dryRun ? '[dry-run] ' : ''}${action} - ${outcome.successful.length} ok, ${outcome.failed.length} failed${outcome.skipped.length ? `, ${outcome.skipped.length} skipped` : ''}.`,
45
+ ];
46
+ if (outcome.failed.length > 0) {
47
+ lines.push('');
48
+ lines.push('Failed:');
49
+ for (const f of outcome.failed)
50
+ lines.push(` - ${f.ticketKey}: ${f.error}`);
51
+ }
52
+ return {
53
+ content: [{ type: 'text', text: lines.join('\n') }],
54
+ structuredContent: outcome,
55
+ };
56
+ }
57
+ function errMessage(err) {
58
+ if (err instanceof OrbotoApiError) {
59
+ if (err.status === 403)
60
+ return 'Forbidden - caller lacks permission on this ticket.';
61
+ if (err.status === 404)
62
+ return 'Not found.';
63
+ return `HTTP ${err.status}: ${err.body || '(empty body)'}`;
64
+ }
65
+ if (err instanceof Error)
66
+ return err.message;
67
+ return String(err);
68
+ }
69
+ /** Resolve N keys to N TicketRows. On any failure, record the ticketKey
70
+ * + error in `failed` and continue; downstream loops skip those. */
71
+ async function resolveBatch(client, ticketKeys, outcome) {
72
+ const resolved = new Map();
73
+ for (const k of ticketKeys) {
74
+ try {
75
+ const t = await resolveTicketByKey(client, k);
76
+ resolved.set(k, t);
77
+ }
78
+ catch (err) {
79
+ outcome.failed.push({ ticketKey: k, error: errMessage(err) });
80
+ }
81
+ }
82
+ return resolved;
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // orboto_bulk_patch_tickets
86
+ // ---------------------------------------------------------------------------
87
+ export const bulkPatchTicketsToolConfig = {
88
+ title: 'Apply the same patch to many tickets',
89
+ description: 'PATCH every ticket in `ticketKeys` with the same `patch` body. The patch shape mirrors `orboto_update_ticket`: title, description, type, priority, dueDate, startDate, isPrivate, estimatedTimeMinutes. To move many tickets between status categories, use `orboto_bulk_move_tickets` instead. Returns `{successful, failed, skipped, dryRun}` so the caller can branch on partial failures.',
90
+ inputSchema: z.object({
91
+ ticketKeys: TICKET_KEY_ARRAY,
92
+ patch: z.object({
93
+ title: z.string().min(1).max(255).optional(),
94
+ description: z.string().optional(),
95
+ type: z.enum(['task', 'bug', 'story', 'epic']).optional(),
96
+ priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional(),
97
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
98
+ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
99
+ isPrivate: z.boolean().optional(),
100
+ estimatedTimeMinutes: z.number().int().nonnegative().optional(),
101
+ }).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
102
+ dryRun: z.boolean().optional().describe('Resolve every ticket to verify visibility/permission, but skip the actual PATCH.'),
103
+ }).shape,
104
+ // ORB-1669 - the mutating bulk_* tools are destructive by blast radius:
105
+ // they overwrite state across N tickets in one call with no undo. The
106
+ // purely additive ones (bulk_comment, bulk_assign) are not.
107
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
108
+ };
109
+ export function makeBulkPatchTicketsHandler(client) {
110
+ return async ({ ticketKeys, patch, dryRun }) => {
111
+ const outcome = emptyOutcome(dryRun === true);
112
+ const resolved = await resolveBatch(client, ticketKeys, outcome);
113
+ for (const [k, t] of resolved) {
114
+ if (dryRun) {
115
+ outcome.skipped.push(k);
116
+ continue;
117
+ }
118
+ try {
119
+ await client.patch(`/projects/${t.projectId}/tickets/${t.id}`, patch);
120
+ outcome.successful.push(k);
121
+ }
122
+ catch (err) {
123
+ outcome.failed.push({ ticketKey: k, error: errMessage(err) });
124
+ }
125
+ }
126
+ return bulkResult(`bulk_patch (${Object.keys(patch).join(',')})`, outcome);
127
+ };
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // orboto_bulk_move_tickets
131
+ // ---------------------------------------------------------------------------
132
+ export const bulkMoveTicketsToolConfig = {
133
+ title: 'Move many tickets to a status category',
134
+ description: 'Move every ticket in `ticketKeys` to the same status category (todo / in_progress / in_review / done / wont_fix). Each ticket lands on its own project\'s first status with that category. Returns the per-ticket outcome.',
135
+ inputSchema: z.object({
136
+ ticketKeys: TICKET_KEY_ARRAY,
137
+ statusCategory: z.enum(STATUS_CATEGORIES),
138
+ dryRun: z.boolean().optional(),
139
+ }).shape,
140
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
141
+ };
142
+ export function makeBulkMoveTicketsHandler(client) {
143
+ return async ({ ticketKeys, statusCategory, dryRun }) => {
144
+ const outcome = emptyOutcome(dryRun === true);
145
+ const resolved = await resolveBatch(client, ticketKeys, outcome);
146
+ const legacy = CATEGORY_TO_LEGACY[statusCategory];
147
+ for (const [k, t] of resolved) {
148
+ if (dryRun) {
149
+ outcome.skipped.push(k);
150
+ continue;
151
+ }
152
+ try {
153
+ await client.patch(`/projects/${t.projectId}/tickets/${t.id}`, { status: legacy });
154
+ outcome.successful.push(k);
155
+ }
156
+ catch (err) {
157
+ outcome.failed.push({ ticketKey: k, error: errMessage(err) });
158
+ }
159
+ }
160
+ return bulkResult(`bulk_move → ${statusCategory}`, outcome);
161
+ };
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // orboto_bulk_close_tickets
165
+ // ---------------------------------------------------------------------------
166
+ export const bulkCloseTicketsToolConfig = {
167
+ title: 'Close many tickets (optionally with a shared comment)',
168
+ description: 'For every ticket in `ticketKeys`: optionally post the same `comment` first, then move it to `done`. Mirrors `orboto.mjs bulk-close`. Comment-first ordering means the close note lands in the audit trail even if the status PATCH 403s.',
169
+ inputSchema: z.object({
170
+ ticketKeys: TICKET_KEY_ARRAY,
171
+ comment: z.string().min(1).optional().describe('Optional shared closing comment posted on each ticket before its status move.'),
172
+ dryRun: z.boolean().optional(),
173
+ }).shape,
174
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
175
+ };
176
+ export function makeBulkCloseTicketsHandler(client) {
177
+ return async ({ ticketKeys, comment, dryRun }) => {
178
+ const outcome = emptyOutcome(dryRun === true);
179
+ const resolved = await resolveBatch(client, ticketKeys, outcome);
180
+ for (const [k, t] of resolved) {
181
+ if (dryRun) {
182
+ outcome.skipped.push(k);
183
+ continue;
184
+ }
185
+ try {
186
+ if (comment) {
187
+ await client.post(`/tickets/${t.id}/comments`, { content: comment });
188
+ }
189
+ await client.patch(`/projects/${t.projectId}/tickets/${t.id}`, { status: 'DONE' });
190
+ outcome.successful.push(k);
191
+ }
192
+ catch (err) {
193
+ outcome.failed.push({ ticketKey: k, error: errMessage(err) });
194
+ }
195
+ }
196
+ return bulkResult(comment ? 'bulk_close + comment' : 'bulk_close', outcome);
197
+ };
198
+ }
199
+ // ---------------------------------------------------------------------------
200
+ // orboto_bulk_comment_tickets
201
+ // ---------------------------------------------------------------------------
202
+ export const bulkCommentTicketsToolConfig = {
203
+ title: 'Post the same comment on many tickets',
204
+ description: 'Append the same comment body to every ticket in `ticketKeys`. Useful for "I am back-propagating decision X to all affected tickets" workflows. `isInternal=true` hides from external/guest users.',
205
+ inputSchema: z.object({
206
+ ticketKeys: TICKET_KEY_ARRAY,
207
+ text: z.string().min(1),
208
+ isInternal: z.boolean().optional(),
209
+ dryRun: z.boolean().optional(),
210
+ }).shape,
211
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
212
+ };
213
+ export function makeBulkCommentTicketsHandler(client) {
214
+ return async ({ ticketKeys, text, isInternal, dryRun }) => {
215
+ const outcome = emptyOutcome(dryRun === true);
216
+ const resolved = await resolveBatch(client, ticketKeys, outcome);
217
+ for (const [k, t] of resolved) {
218
+ if (dryRun) {
219
+ outcome.skipped.push(k);
220
+ continue;
221
+ }
222
+ try {
223
+ await client.post(`/tickets/${t.id}/comments`, {
224
+ content: text,
225
+ isInternal: isInternal ?? false,
226
+ });
227
+ outcome.successful.push(k);
228
+ }
229
+ catch (err) {
230
+ outcome.failed.push({ ticketKey: k, error: errMessage(err) });
231
+ }
232
+ }
233
+ return bulkResult('bulk_comment', outcome);
234
+ };
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // orboto_bulk_assign_tickets / orboto_bulk_unassign_tickets
238
+ // ---------------------------------------------------------------------------
239
+ /** Resolve email → userId once per project. The bulk operation may
240
+ * touch tickets across multiple projects; we memoize the lookup so we
241
+ * don't refetch the members list for every ticket. */
242
+ async function makeAssigneeResolver(client, email) {
243
+ const cache = new Map();
244
+ return async (projectId) => {
245
+ const cached = cache.get(projectId);
246
+ if (cached)
247
+ return cached;
248
+ const members = await client.get(`/projects/${projectId}/members`);
249
+ const m = members.find((x) => x.user.email.toLowerCase() === email.toLowerCase());
250
+ if (!m)
251
+ throw new Error(`No project member with email "${email}" in this project.`);
252
+ cache.set(projectId, m.userId);
253
+ return m.userId;
254
+ };
255
+ }
256
+ export const bulkAssignTicketsToolConfig = {
257
+ title: 'Assign the same user to many tickets',
258
+ description: 'POST `assigneeEmail` as an additional assignee on every ticket in `ticketKeys`. Multi-assignee is supported - this adds, it does not replace. Idempotent: a 409 (already assigned) counts as success.',
259
+ inputSchema: z.object({
260
+ ticketKeys: TICKET_KEY_ARRAY,
261
+ assigneeEmail: z.string().email(),
262
+ dryRun: z.boolean().optional(),
263
+ }).shape,
264
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
265
+ };
266
+ export function makeBulkAssignTicketsHandler(client) {
267
+ return async ({ ticketKeys, assigneeEmail, dryRun }) => {
268
+ const outcome = emptyOutcome(dryRun === true);
269
+ const resolved = await resolveBatch(client, ticketKeys, outcome);
270
+ const resolveAssignee = await makeAssigneeResolver(client, assigneeEmail);
271
+ for (const [k, t] of resolved) {
272
+ if (dryRun) {
273
+ outcome.skipped.push(k);
274
+ continue;
275
+ }
276
+ try {
277
+ const userId = await resolveAssignee(t.projectId);
278
+ try {
279
+ await client.post(`/projects/${t.projectId}/tickets/${t.id}/assignees/${userId}`, {});
280
+ }
281
+ catch (err) {
282
+ if (err instanceof OrbotoApiError && err.status === 409) {
283
+ // Already assigned → idempotent success.
284
+ outcome.successful.push(k);
285
+ continue;
286
+ }
287
+ throw err;
288
+ }
289
+ outcome.successful.push(k);
290
+ }
291
+ catch (err) {
292
+ outcome.failed.push({ ticketKey: k, error: errMessage(err) });
293
+ }
294
+ }
295
+ return bulkResult(`bulk_assign ${assigneeEmail}`, outcome);
296
+ };
297
+ }
298
+ export const bulkUnassignTicketsToolConfig = {
299
+ title: 'Unassign the same user from many tickets',
300
+ description: 'DELETE `assigneeEmail` from every ticket in `ticketKeys`. Idempotent: a 404 (was not assigned) counts as success.',
301
+ inputSchema: z.object({
302
+ ticketKeys: TICKET_KEY_ARRAY,
303
+ assigneeEmail: z.string().email(),
304
+ dryRun: z.boolean().optional(),
305
+ }).shape,
306
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
307
+ };
308
+ export function makeBulkUnassignTicketsHandler(client) {
309
+ return async ({ ticketKeys, assigneeEmail, dryRun }) => {
310
+ const outcome = emptyOutcome(dryRun === true);
311
+ const resolved = await resolveBatch(client, ticketKeys, outcome);
312
+ const resolveAssignee = await makeAssigneeResolver(client, assigneeEmail);
313
+ for (const [k, t] of resolved) {
314
+ if (dryRun) {
315
+ outcome.skipped.push(k);
316
+ continue;
317
+ }
318
+ try {
319
+ const userId = await resolveAssignee(t.projectId);
320
+ try {
321
+ await client.delete(`/projects/${t.projectId}/tickets/${t.id}/assignees/${userId}`);
322
+ }
323
+ catch (err) {
324
+ if (err instanceof OrbotoApiError && err.status === 404) {
325
+ // Wasn't assigned → idempotent success.
326
+ outcome.successful.push(k);
327
+ continue;
328
+ }
329
+ throw err;
330
+ }
331
+ outcome.successful.push(k);
332
+ }
333
+ catch (err) {
334
+ outcome.failed.push({ ticketKey: k, error: errMessage(err) });
335
+ }
336
+ }
337
+ return bulkResult(`bulk_unassign ${assigneeEmail}`, outcome);
338
+ };
339
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * ORB-799 - bulk-* tools unit tests.
3
+ *
4
+ * Each tool gets one happy-path test (all keys succeed, structured
5
+ * outcome shape correct) and one partial-failure test (a mix of
6
+ * resolvable + 403/404 keys to verify the `failed` list shape).
7
+ *
8
+ * Resolution chain per ticket: GET /projects/by-key/PROJ +
9
+ * GET /projects/:id/tickets/by-key/:N. We mock both per ticket.
10
+ */
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import { OrbotoClient } from '../orboto-client.js';
13
+ import { makeBulkPatchTicketsHandler, makeBulkMoveTicketsHandler, makeBulkCloseTicketsHandler, makeBulkCommentTicketsHandler, makeBulkAssignTicketsHandler, makeBulkUnassignTicketsHandler, } from './bulk-writes.js';
14
+ beforeEach(() => { vi.restoreAllMocks(); });
15
+ afterEach(() => { vi.restoreAllMocks(); });
16
+ function stub(responses) {
17
+ const calls = [];
18
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
19
+ calls.push({
20
+ url: url.toString(),
21
+ method: init?.method ?? 'GET',
22
+ body: init?.body ? JSON.parse(init.body) : undefined,
23
+ });
24
+ const r = responses.shift();
25
+ if (!r)
26
+ throw new Error(`unexpected extra fetch`);
27
+ return {
28
+ ok: r.ok ?? true,
29
+ status: r.status ?? 200,
30
+ statusText: 'OK',
31
+ json: async () => ('json' in r ? r.json : {}),
32
+ text: async () => '',
33
+ };
34
+ });
35
+ return calls;
36
+ }
37
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
38
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
39
+ function ticket(n) {
40
+ return {
41
+ id: `t${n}`, projectId: 'p1', ticketKey: `ACME-${n}`, ticketNumber: n,
42
+ title: `Ticket ${n}`, status: 'TODO', statusName: 'To Do', statusCategory: 'todo',
43
+ type: 'task', priority: 'normal', estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
44
+ };
45
+ }
46
+ // Resolution responses for keys ACME-1, ACME-2 (project lookup is shared
47
+ // - but the client re-fetches each time. We supply one per key.)
48
+ function resolveOK(n) {
49
+ return [
50
+ { json: PROJ },
51
+ { json: ticket(n) },
52
+ ];
53
+ }
54
+ describe('orboto_bulk_patch_tickets', () => {
55
+ it('PATCHes each ticket with the supplied body', async () => {
56
+ const calls = stub([
57
+ ...resolveOK(1),
58
+ ...resolveOK(2),
59
+ { json: ticket(1) }, // PATCH 1
60
+ { json: ticket(2) }, // PATCH 2
61
+ ]);
62
+ const res = await makeBulkPatchTicketsHandler(client)({
63
+ ticketKeys: ['ACME-1', 'ACME-2'],
64
+ patch: { priority: 'high' },
65
+ });
66
+ const patchCalls = calls.filter((c) => c.method === 'PATCH');
67
+ expect(patchCalls).toHaveLength(2);
68
+ expect(patchCalls[0].body).toEqual({ priority: 'high' });
69
+ expect(res.structuredContent).toMatchObject({
70
+ successful: ['ACME-1', 'ACME-2'],
71
+ failed: [],
72
+ skipped: [],
73
+ dryRun: false,
74
+ });
75
+ });
76
+ it('dryRun=true resolves but does not PATCH', async () => {
77
+ const calls = stub([
78
+ ...resolveOK(1),
79
+ ...resolveOK(2),
80
+ ]);
81
+ const res = await makeBulkPatchTicketsHandler(client)({
82
+ ticketKeys: ['ACME-1', 'ACME-2'],
83
+ patch: { priority: 'high' },
84
+ dryRun: true,
85
+ });
86
+ expect(calls.some((c) => c.method === 'PATCH')).toBe(false);
87
+ expect(res.structuredContent).toMatchObject({
88
+ successful: [],
89
+ skipped: ['ACME-1', 'ACME-2'],
90
+ dryRun: true,
91
+ });
92
+ });
93
+ it('records per-ticket failures in `failed` and continues', async () => {
94
+ stub([
95
+ ...resolveOK(1),
96
+ ...resolveOK(2),
97
+ { json: ticket(1) }, // PATCH 1 OK
98
+ { ok: false, status: 403, json: { error: 'forbidden' } }, // PATCH 2 fails
99
+ ]);
100
+ const res = await makeBulkPatchTicketsHandler(client)({
101
+ ticketKeys: ['ACME-1', 'ACME-2'],
102
+ patch: { priority: 'high' },
103
+ });
104
+ expect(res.structuredContent).toMatchObject({
105
+ successful: ['ACME-1'],
106
+ failed: [{ ticketKey: 'ACME-2', error: expect.stringMatching(/Forbidden/) }],
107
+ });
108
+ });
109
+ it('records resolution failures in `failed` and continues with the rest', async () => {
110
+ stub([
111
+ ...resolveOK(1),
112
+ { ok: false, status: 404, json: { error: 'not found' } }, // ACME-2 by-key project fails
113
+ { json: ticket(1) }, // PATCH 1 OK
114
+ ]);
115
+ const res = await makeBulkPatchTicketsHandler(client)({
116
+ ticketKeys: ['ACME-1', 'ACME-2'],
117
+ patch: { priority: 'high' },
118
+ });
119
+ expect(res.structuredContent).toMatchObject({
120
+ successful: ['ACME-1'],
121
+ failed: [{ ticketKey: 'ACME-2' }],
122
+ });
123
+ });
124
+ });
125
+ describe('orboto_bulk_move_tickets', () => {
126
+ it('PATCHes status mapped to legacy enum', async () => {
127
+ const calls = stub([
128
+ ...resolveOK(1),
129
+ ...resolveOK(2),
130
+ { json: ticket(1) },
131
+ { json: ticket(2) },
132
+ ]);
133
+ await makeBulkMoveTicketsHandler(client)({
134
+ ticketKeys: ['ACME-1', 'ACME-2'],
135
+ statusCategory: 'in_review',
136
+ });
137
+ const patchCalls = calls.filter((c) => c.method === 'PATCH');
138
+ expect(patchCalls[0].body).toEqual({ status: 'IN_REVIEW' });
139
+ expect(patchCalls[1].body).toEqual({ status: 'IN_REVIEW' });
140
+ });
141
+ });
142
+ describe('orboto_bulk_close_tickets', () => {
143
+ it('POSTs comment before PATCH when comment is set', async () => {
144
+ const calls = stub([
145
+ ...resolveOK(1),
146
+ { json: { id: 'c1', content: 'closed', isInternal: false, createdAt: 'now' } }, // POST comment
147
+ { json: { ...ticket(1), status: 'DONE' } }, // PATCH
148
+ ]);
149
+ await makeBulkCloseTicketsHandler(client)({
150
+ ticketKeys: ['ACME-1'],
151
+ comment: 'closing the loop',
152
+ });
153
+ const commentCall = calls.find((c) => c.url.includes('/comments'));
154
+ const patchCall = calls.find((c) => c.method === 'PATCH');
155
+ expect(commentCall).toBeDefined();
156
+ expect(patchCall?.body).toEqual({ status: 'DONE' });
157
+ // Comment must be BEFORE the patch.
158
+ expect(calls.indexOf(commentCall)).toBeLessThan(calls.indexOf(patchCall));
159
+ });
160
+ it('without comment, only PATCHes status=DONE', async () => {
161
+ const calls = stub([
162
+ ...resolveOK(1),
163
+ { json: { ...ticket(1), status: 'DONE' } },
164
+ ]);
165
+ await makeBulkCloseTicketsHandler(client)({ ticketKeys: ['ACME-1'] });
166
+ expect(calls.some((c) => c.url.includes('/comments'))).toBe(false);
167
+ });
168
+ });
169
+ describe('orboto_bulk_comment_tickets', () => {
170
+ it('POSTs the same comment on each ticket', async () => {
171
+ const calls = stub([
172
+ ...resolveOK(1),
173
+ ...resolveOK(2),
174
+ { json: { id: 'c1', content: 'x', isInternal: false, createdAt: 'now' } },
175
+ { json: { id: 'c2', content: 'x', isInternal: false, createdAt: 'now' } },
176
+ ]);
177
+ await makeBulkCommentTicketsHandler(client)({
178
+ ticketKeys: ['ACME-1', 'ACME-2'],
179
+ text: 'heads up',
180
+ isInternal: true,
181
+ });
182
+ const commentCalls = calls.filter((c) => c.url.includes('/comments'));
183
+ expect(commentCalls).toHaveLength(2);
184
+ expect(commentCalls[0].body).toEqual({ content: 'heads up', isInternal: true });
185
+ });
186
+ });
187
+ describe('orboto_bulk_assign_tickets', () => {
188
+ it('resolves email → userId once per project, then POSTs assignee', async () => {
189
+ const calls = stub([
190
+ ...resolveOK(1),
191
+ ...resolveOK(2),
192
+ { json: [{ userId: 'u1', user: { email: 'who@orboto.io', fullName: 'Who' }, role: { name: 'dev' } }] }, // members for p1
193
+ { json: {} }, // POST assignees t1
194
+ { json: {} }, // POST assignees t2 (member cache hit)
195
+ ]);
196
+ const res = await makeBulkAssignTicketsHandler(client)({
197
+ ticketKeys: ['ACME-1', 'ACME-2'],
198
+ assigneeEmail: 'who@orboto.io',
199
+ });
200
+ // Members fetched exactly once thanks to the per-project cache.
201
+ expect(calls.filter((c) => c.url.includes('/members'))).toHaveLength(1);
202
+ expect(res.structuredContent).toMatchObject({
203
+ successful: ['ACME-1', 'ACME-2'],
204
+ failed: [],
205
+ });
206
+ });
207
+ it('treats 409 (already assigned) as success', async () => {
208
+ stub([
209
+ ...resolveOK(1),
210
+ { json: [{ userId: 'u1', user: { email: 'who@orboto.io', fullName: 'Who' }, role: { name: 'dev' } }] },
211
+ { ok: false, status: 409, json: { error: 'already assigned' } },
212
+ ]);
213
+ const res = await makeBulkAssignTicketsHandler(client)({
214
+ ticketKeys: ['ACME-1'],
215
+ assigneeEmail: 'who@orboto.io',
216
+ });
217
+ expect(res.structuredContent).toMatchObject({
218
+ successful: ['ACME-1'],
219
+ failed: [],
220
+ });
221
+ });
222
+ });
223
+ describe('orboto_bulk_unassign_tickets', () => {
224
+ it('DELETEs each assignee, treats 404 as success', async () => {
225
+ stub([
226
+ ...resolveOK(1),
227
+ { json: [{ userId: 'u1', user: { email: 'who@orboto.io', fullName: 'Who' }, role: { name: 'dev' } }] },
228
+ { ok: false, status: 404, json: { error: 'not assigned' } },
229
+ ]);
230
+ const res = await makeBulkUnassignTicketsHandler(client)({
231
+ ticketKeys: ['ACME-1'],
232
+ assigneeEmail: 'who@orboto.io',
233
+ });
234
+ expect(res.structuredContent).toMatchObject({
235
+ successful: ['ACME-1'],
236
+ failed: [],
237
+ });
238
+ });
239
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ORB-831 / ORB-887 - `orboto_check_similar`.
3
+ *
4
+ * Dry-run sibling of `orboto_create_ticket`: takes a proposed title +
5
+ * description and returns the tickets that would land in
6
+ * `similarWarnings` if the create were to happen now. Intended as the
7
+ * cautious agent's pre-create probe - call this first when the task
8
+ * scope feels close to existing work, decide whether to follow up on
9
+ * the existing ticket instead, then either commit (`orboto_create_ticket`)
10
+ * or pivot.
11
+ *
12
+ * Wraps the existing `GET /projects/:id/tickets/similar` route so the
13
+ * matching pipeline (tsvector + optional embedding rerank) is shared
14
+ * with both the UI new-ticket form and the POST-create safety-net.
15
+ */
16
+ import { z } from 'zod';
17
+ import { trimSimilarEntries } from './similar-projection.js';
18
+ import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
19
+ export const checkSimilarToolConfig = {
20
+ title: 'Check for similar tickets before creating',
21
+ description: 'Run the duplicate-detection pipeline (tsvector + AI-embedding rerank when configured) against a proposed title + description, without creating anything. Returns up to `limit` candidates ranked by similarity. Use this BEFORE `orboto_create_ticket` when you want to confirm a feature is not already tracked - if a high-similarity candidate exists, prefer to comment on / claim / extend it instead of opening a new ticket. Empty result = safe to create. `orboto_create_ticket` runs the same check after the fact and surfaces `similarWarnings` in its response, so this tool is optional but cheaper than a create-then-close round trip. An empty result from a LONG, solution-framed title is weak evidence - detection ranks by term co-occurrence, so also probe with a single distinctive STABLE token (file/component/error-string fragment) and the SYMPTOM wording, not just your intended fix.',
22
+ inputSchema: z.object({
23
+ projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
24
+ title: z.string().min(1).describe('Proposed ticket title.'),
25
+ description: z.string().optional().describe('Proposed description - improves recall.'),
26
+ limit: z.number().int().min(1).max(10).optional().describe('Max candidates. Default 5.'),
27
+ parentTicketKey: z.string().optional().describe('Intended parent; makes the check hierarchy-aware.'),
28
+ forType: z.string().optional().describe('Intended type (task/bug/story/epic).'),
29
+ }).shape,
30
+ annotations: { readOnlyHint: true, idempotentHint: true },
31
+ };
32
+ export function makeCheckSimilarHandler(client) {
33
+ return async ({ projectKey, title, description, limit, parentTicketKey, forType }) => {
34
+ const project = await resolveProjectByKey(client, projectKey);
35
+ const qs = new URLSearchParams({
36
+ title,
37
+ limit: String(limit ?? 5),
38
+ });
39
+ if (description)
40
+ qs.set('description', description);
41
+ // ORB-1604 - hierarchy-aware classification inputs.
42
+ if (parentTicketKey) {
43
+ const parent = await resolveTicketByKey(client, parentTicketKey);
44
+ qs.set('parentTicketId', parent.id);
45
+ }
46
+ if (forType)
47
+ qs.set('forType', forType);
48
+ const result = await client.get(`/projects/${project.id}/tickets/similar?${qs.toString()}`);
49
+ // ORB-1604 - only relation-free candidates are duplicate signals; the
50
+ // parent, siblings and epics are related context (they drove the 70%
51
+ // --allow-duplicate override rate in the field).
52
+ const realDuplicates = result.candidates.filter((c) => !c.relation);
53
+ const related = result.candidates.filter((c) => c.relation);
54
+ const recommendation = realDuplicates.length === 0
55
+ ? (related.length === 0
56
+ ? 'No similar tickets found - safe to create.'
57
+ : 'Only related context found (parent/sibling/epic) - safe to create; link them instead of treating as duplicates.')
58
+ : (realDuplicates[0].similarity >= 0.9)
59
+ ? `HIGH-SIMILARITY MATCH FOUND - review [${realDuplicates[0].ticketKey ?? realDuplicates[0].id.slice(0, 8)}] "${realDuplicates[0].title}" before creating; this may already be tracked.`
60
+ : 'Possible related tickets - review the list and decide whether the new ticket adds distinct scope.';
61
+ const text = result.candidates.length === 0
62
+ ? `${recommendation} (match mode: ${result.mode})`
63
+ : [
64
+ recommendation,
65
+ `Found ${result.candidates.length} candidate(s) via ${result.mode === 'embedding' ? 'AI embedding rerank' : 'tsvector search'}:`,
66
+ ...result.candidates.map((c) => {
67
+ const pct = `${Math.round(c.similarity * 100)}%`;
68
+ const status = c.statusName ? ` [${c.statusName}]` : '';
69
+ const key = c.ticketKey ?? c.id.slice(0, 8);
70
+ const rel = c.relation ? ` (related: ${c.relation})` : '';
71
+ return ` - ${key}${status} (${pct} ${c.matchMode})${rel}: ${c.title}`;
72
+ }),
73
+ ].join('\n');
74
+ return {
75
+ content: [{ type: 'text', text }],
76
+ structuredContent: {
77
+ // ORB-1693 - same agent projection as create_ticket's warnings.
78
+ similar: trimSimilarEntries(result.candidates),
79
+ mode: result.mode,
80
+ recommendation,
81
+ },
82
+ };
83
+ };
84
+ }