@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,778 @@
1
+ /**
2
+ * ORB-244 Phase C Group 1 - ticket mutation tools.
3
+ *
4
+ * Eight tools that round-trip the API's existing PBAC cascade. Every
5
+ * write is gated on the caller's project-level permissions; a 403
6
+ * from the API surfaces as `isError: true` on the MCP response with
7
+ * the API's error message intact, so the model can either retry
8
+ * elsewhere or explain the lock to the user.
9
+ *
10
+ * Resolution patterns mirror Phase B:
11
+ * - `projectKey` (`ACME`) → UUID via `/projects/by-key/:key`
12
+ * - `ticketKey` (`ACME-42`) → UUID via `/projects/:id/tickets/by-key/:n`
13
+ * - `assigneeEmail` → userId via the project members list
14
+ * - `milestone` (name) → milestoneId via the project milestones list
15
+ *
16
+ * Tools in this file:
17
+ * - orboto_create_ticket
18
+ * - orboto_update_ticket
19
+ * - orboto_move_ticket
20
+ * - orboto_close_ticket
21
+ * - orboto_comment
22
+ * - orboto_assign
23
+ * - orboto_unassign
24
+ * - orboto_set_milestone
25
+ * - orboto_add_ticket_dependency (ORB-453)
26
+ * - orboto_remove_ticket_dependency (ORB-453)
27
+ * - orboto_list_ticket_dependencies (ORB-453)
28
+ */
29
+ import { z } from 'zod';
30
+ import { trimSimilarEntries } from './similar-projection.js';
31
+ import { OrbotoApiError } from '../orboto-client.js';
32
+ import { resolveProjectByKey, resolveTicketByKey, resolveByName } from './shared.js';
33
+ import { resolveMilestoneByNameOrId } from './milestones.js';
34
+ // ---------------------------------------------------------------------------
35
+ // Local helpers
36
+ // ---------------------------------------------------------------------------
37
+ const STATUS_CATEGORIES = ['todo', 'in_progress', 'in_review', 'done', 'wont_fix'];
38
+ const CATEGORY_TO_LEGACY = {
39
+ todo: 'TODO',
40
+ in_progress: 'IN_PROGRESS',
41
+ in_review: 'IN_REVIEW',
42
+ done: 'DONE',
43
+ wont_fix: 'WONT_FIX',
44
+ };
45
+ async function resolveAssigneeId(client, projectId, email) {
46
+ const members = await client.get(`/projects/${projectId}/members`);
47
+ const m = members.find((x) => x.user.email.toLowerCase() === email.toLowerCase());
48
+ if (!m)
49
+ throw new Error(`No project member with email "${email}".`);
50
+ return m.userId;
51
+ }
52
+ /** Resolve a milestone reference (name OR UUID) to its id. Delegates to
53
+ * the shared resolver so UUID lookups + ambiguous-name rejection behave
54
+ * identically here and on the milestone CRUD tools (ORB-1058). */
55
+ async function resolveMilestoneId(client, projectId, milestoneNameOrId) {
56
+ return (await resolveMilestoneByNameOrId(client, projectId, milestoneNameOrId)).id;
57
+ }
58
+ /** Render the "ticket created/updated" line every mutation tool ends
59
+ * with - keeps responses uniform and easy to chain. */
60
+ function ticketSummaryText(action, t) {
61
+ return `${action}: [${t.ticketKey}] ${t.title} (${t.statusName ?? t.status})`;
62
+ }
63
+ function ticketStructured(t) {
64
+ return {
65
+ key: t.ticketKey,
66
+ title: t.title,
67
+ status: t.statusName ?? t.status,
68
+ statusCategory: t.statusCategory ?? null,
69
+ type: t.type,
70
+ priority: t.priority,
71
+ // ORB-1608 - role-aware commit policy. Absent on responses the API's
72
+ // enrich pipeline didn't touch; the API itself defaults unset rows to
73
+ // 'implementation'.
74
+ deliveryMode: t.deliveryMode ?? 'implementation',
75
+ dueDate: t.dueDate,
76
+ isPrivate: t.isPrivate,
77
+ };
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // orboto_create_ticket
81
+ // ---------------------------------------------------------------------------
82
+ export const createTicketToolConfig = {
83
+ title: 'Create a ticket',
84
+ description: 'Create a new ticket in the given project. Creating more than ~3 tickets? Use `orboto_bulk_create_tickets` instead - one call, one compact response. Returns the new ticket\'s key (e.g. "ACME-42") so callers can chain follow-ups. **Read the new key from `structuredContent.createdTicketKey` (ORB-1176) - never from `similarWarnings[].ticketKey`, which are OTHER, possibly-duplicate tickets.** The caller must have `ticket:create` on the project. **Duplicate-detection safety-net (ORB-831):** if `similarWarnings` appears in the response with one or more entries, the ticket WAS created but you should review whether to close it as a duplicate of the listed ticket(s) instead. The warnings are advisory - never blocking - but each entry is a ticket the system thinks the new one overlaps with. Prefer `orboto_check_similar` BEFORE creating when you want a dry-run. **Deferred check under load (ORB-1437):** if `duplicateCheckDeferred: true` appears, the project was under a create burst so the duplicate-check was run in the background instead of inline - `similarWarnings` is then empty because it did NOT run synchronously, which is NOT the same as "no duplicates found". A strong match, if any, is posted as an advisory comment on the new ticket a moment later; check the ticket comments before treating it as new work. **Duplicate-check recall (ORB-1121):** when you search/check-similar first, results rank by term co-occurrence - a long, solution-framed title with rare terms can return 0 hits even when a short, symptom-framed dup sharing one distinctive token exists. Probe with a single distinctive STABLE token (file/component/error-string fragment), keep queries SHORT, and search the SYMPTOM not your fix; a 0-result long query is not "no dup". **Language-mismatch warning (ORB-890):** if `languageWarning` appears, the ticket was written in a language different from the workspace default. Consider rewriting in the expected language so search + duplicate-detection stay consistent. Non-blocking. **Before a mass-create (ORB-989):** call `orboto_whoami` first - its `workspaceLocale` field is the language you should write every ticket in. If the same `languageWarning` repeats, stop and clarify the intended language rather than pushing through the whole batch. **Strict mode (ORB-990):** if the workspace enforces ticket language, a mismatch is rejected (the tool returns a `blocked` result, not a created ticket) - rewrite in the workspace language, or set `allowLanguageMismatch: true` only when the language is genuinely intentional. **Hard duplicate-block (ORB-1471):** some workspaces REFUSE a create whose top similarity match is at/above a configured threshold - the tool returns a `duplicateBlocked` result (NOT a created ticket) listing the matching tickets. Extend or comment on one of those instead. If you have confirmed none of them cover this work, retry with `allowDuplicate: true` AND a `duplicateJustification` explaining why - the justification is persisted as a comment on the new ticket. '
85
+ // ORB-1805 - parameter prose moved out of the input schema (which
86
+ // every client pays for on connect) into this text, which the
87
+ // manifest summarises to one sentence and orboto_help serves in
88
+ // full. Same rows also live in the skill's REFERENCE.md.
89
+ + '**Parameter notes.** `deliveryMode` (ORB-1608) is the role-aware commit policy that replaced the blanket one-commit-per-ticket rule: implementation/docs expect exactly one linked commit (closing without one is a non-blocking warning); review/admin/epic never expect one - reviews are read-only, admin work carries external evidence, epics derive completion from their children; unset defaults to "epic" when type=epic, else "implementation". `milestone` takes a key ("ORB-M3"), a name, or a UUID and is looked up in the project including closed milestones - unknown or ambiguous is an error, so pass the key/UUID when a name repeats. `labels` and `assigneeEmails` attach ATOMICALLY inside the create (ORB-1416): an unknown label or non-member email rolls the whole create back with a 400, leaving no orphan ticket - there is no separate attach call to retry.',
90
+ inputSchema: z.object({
91
+ projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
92
+ title: z.string().min(1).max(255),
93
+ description: z.string().optional(),
94
+ type: z.enum(['task', 'bug', 'story', 'epic']).optional().describe('Default: task.'),
95
+ priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional().describe('Default: normal.'),
96
+ deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional().describe('Default: epic when type=epic, else implementation.'),
97
+ milestone: z.string().optional().describe('Key ("ORB-M3"), name, or UUID.'),
98
+ assigneeEmails: z.array(z.string().email()).optional().describe('Project-member emails.'),
99
+ labels: z.array(z.string()).optional().describe('Existing label names.'),
100
+ parentTicketKey: z.string().optional().describe('Parent key - makes this a sub-ticket.'),
101
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD.'),
102
+ isPrivate: z.boolean().optional(),
103
+ allowLanguageMismatch: z.boolean().optional().describe('Override the language block.'),
104
+ allowDuplicate: z.boolean().optional().describe('Override the duplicate block.'),
105
+ duplicateJustification: z.string().optional().describe('Why this is not a duplicate.'),
106
+ }).shape,
107
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
108
+ };
109
+ export function makeCreateTicketHandler(client) {
110
+ return async (input) => {
111
+ const project = await resolveProjectByKey(client, input.projectKey);
112
+ const body = {
113
+ title: input.title,
114
+ description: input.description ?? null,
115
+ type: input.type ?? 'task',
116
+ priority: input.priority ?? 'normal',
117
+ isPrivate: input.isPrivate ?? false,
118
+ // ORB-1608 - leave unset when the caller didn't pass one; the API
119
+ // defaults it (epic for type=epic, else implementation).
120
+ ...(input.deliveryMode ? { deliveryMode: input.deliveryMode } : {}),
121
+ };
122
+ // ORB-1471 - the override justification rides in the body; the override
123
+ // flag itself is a querystring param (see createPath below).
124
+ if (input.allowDuplicate && input.duplicateJustification)
125
+ body.duplicateJustification = input.duplicateJustification;
126
+ if (input.dueDate)
127
+ body.dueDate = input.dueDate;
128
+ if (input.milestone)
129
+ body.milestoneId = await resolveMilestoneId(client, project.id, input.milestone);
130
+ if (input.parentTicketKey) {
131
+ const parent = await resolveTicketByKey(client, input.parentTicketKey);
132
+ body.parentTicketId = parent.id;
133
+ }
134
+ // ORB-1416 - labels + assignees attach INLINE in the create
135
+ // transaction so the whole thing is atomic (one round-trip). No more
136
+ // post-create attach loops: a bad label/assignee reference now rolls
137
+ // the create back on the API side (clean 400, no orphan ticket) rather
138
+ // than leaving a half-created ticket the agent retries into a dup.
139
+ if (input.labels && input.labels.length > 0)
140
+ body.labelNames = input.labels;
141
+ if (input.assigneeEmails && input.assigneeEmails.length > 0)
142
+ body.assigneeEmails = input.assigneeEmails;
143
+ // ORB-990 - strict ticket-language enforcement may reject this with a
144
+ // 422; ORB-1471 - the hard duplicate-block may reject it with a 409.
145
+ // Surface each as a clear block result instead of a raw error.
146
+ const qs = [];
147
+ if (input.allowLanguageMismatch)
148
+ qs.push('allowLanguageMismatch=true');
149
+ if (input.allowDuplicate)
150
+ qs.push('allowDuplicate=true');
151
+ const createPath = `/projects/${project.id}/tickets${qs.length ? `?${qs.join('&')}` : ''}`;
152
+ let created;
153
+ try {
154
+ created = await client.post(createPath, body);
155
+ }
156
+ catch (err) {
157
+ const dup = duplicateBlockResult(err);
158
+ if (dup)
159
+ return dup;
160
+ const blocked = languageBlockResult(err, 'Ticket create');
161
+ if (blocked)
162
+ return blocked;
163
+ throw err;
164
+ }
165
+ // ORB-831 / ORB-887 - surface the backend's `similarWarnings` to the
166
+ // calling agent. The text block prepends a clearly-visible warning
167
+ // when matches exist so a model scanning the result for "warning"
168
+ // / "duplicate" notices and self-corrects.
169
+ // ORB-890 / ORB-891 - same surface for `languageWarning` when the
170
+ // detected language doesn't match the workspace default.
171
+ const warnings = created.similarWarnings ?? [];
172
+ const langWarning = created.languageWarning;
173
+ const deferred = created.duplicateCheckDeferred === true;
174
+ const baseText = ticketSummaryText('Created', created);
175
+ const parts = [baseText];
176
+ // ORB-1437 - under a create burst the backend defers the duplicate-check
177
+ // to a background job (empty similarWarnings, duplicateCheckDeferred:true).
178
+ // Tell the agent so an empty warnings list isn't read as "no duplicates":
179
+ // a strong match will land as an advisory comment on the new ticket shortly.
180
+ if (deferred) {
181
+ parts.push(`\nℹ Duplicate-check deferred - the project is under high create load, so the check runs in the background. `
182
+ + `An empty duplicate list here does NOT mean "no duplicates". If a strong match is found, an advisory comment `
183
+ + `will be posted on [${created.ticketKey}] shortly; review it before treating this as new work.`);
184
+ }
185
+ if (warnings.length > 0) {
186
+ parts.push(`\n⚠ Potential duplicates found - review before treating this as new work:\n${warnings.map((w) => ` - [${w.ticketKey ?? w.id.slice(0, 8)}] "${w.title}" (${formatSimilarity(w)})`).join('\n')}\n If one of these covers the work, close [${created.ticketKey}] as a duplicate via orboto_close_ticket.`);
187
+ }
188
+ if (langWarning) {
189
+ parts.push(`\n⚠ Language mismatch - this ticket reads as "${langWarning.detected}" but the workspace default is "${langWarning.expected}". Consider rewriting in ${langWarning.expected.toUpperCase()} to keep search + duplicate-detection consistent across the project.`);
190
+ }
191
+ const text = parts.join('\n');
192
+ return {
193
+ content: [{ type: 'text', text }],
194
+ structuredContent: {
195
+ ...ticketStructured(created),
196
+ // ORB-1176 - the one unambiguous field for the NEW ticket's key.
197
+ // `similarWarnings[].ticketKey` are OTHER tickets; read
198
+ // `createdTicketKey` (never a warning's key) to avoid grabbing the
199
+ // wrong one.
200
+ createdTicketKey: created.ticketKey,
201
+ // ORB-1693 - agent projection: key/title/category/2dp-similarity/
202
+ // relation only. The rich shape (UUID, colours, statusName, 15dp)
203
+ // stays on the REST response for the web UI.
204
+ similarWarnings: trimSimilarEntries(warnings),
205
+ ...(deferred ? { duplicateCheckDeferred: true } : {}),
206
+ ...(langWarning ? { languageWarning: langWarning } : {}),
207
+ },
208
+ };
209
+ };
210
+ }
211
+ /** Append the summaryWarning (ORB-1332) to a move/close result - extra text
212
+ * line + the structured field - so both the model's prose read and any
213
+ * structured consumer see it. No-op when the backend didn't warn. */
214
+ function withSummaryWarning(result, warning) {
215
+ if (!warning)
216
+ return result;
217
+ const existing = result.content[0];
218
+ const baseText = existing && existing.type === 'text' ? existing.text : '';
219
+ return {
220
+ content: [{ type: 'text', text: `${baseText}\n⚠ ${warning.message}` }],
221
+ structuredContent: { ...(result.structuredContent ?? {}), summaryWarning: warning },
222
+ };
223
+ }
224
+ /** Append the deliveryModeWarning (ORB-1608) to a move/close result - same
225
+ * shape as `withSummaryWarning` above, sibling advisory. Chainable: both
226
+ * can append onto the same result independently. No-op when the backend
227
+ * didn't warn. */
228
+ function withDeliveryModeWarning(result, warning) {
229
+ if (!warning)
230
+ return result;
231
+ const existing = result.content[0];
232
+ const baseText = existing && existing.type === 'text' ? existing.text : '';
233
+ return {
234
+ content: [{ type: 'text', text: `${baseText}\n⚠ ${warning.message}` }],
235
+ structuredContent: { ...(result.structuredContent ?? {}), deliveryModeWarning: warning },
236
+ };
237
+ }
238
+ /**
239
+ * ORB-990 - turn a strict-language 422 into a clear, non-throwing tool
240
+ * result. The backend body is `{ error, languageWarning }`; we surface
241
+ * the block reason and tell the agent how to proceed (rewrite, or retry
242
+ * with the override) instead of letting the raw API error bubble up.
243
+ * Returns null if the error isn't a language-enforcement 422.
244
+ */
245
+ function languageBlockResult(err, verb) {
246
+ if (!(err instanceof OrbotoApiError) || err.status !== 422)
247
+ return null;
248
+ let parsed = {};
249
+ try {
250
+ parsed = JSON.parse(err.body);
251
+ }
252
+ catch { /* non-JSON body */ }
253
+ if (!parsed.languageWarning)
254
+ return null;
255
+ const lw = parsed.languageWarning;
256
+ const text = `⛔ ${verb} blocked - strict ticket-language enforcement is on.\n` +
257
+ `This content reads as "${lw.detected}" but the workspace language is "${lw.expected}".\n` +
258
+ `Rewrite it in ${lw.expected.toUpperCase()}, or - only if you are sure the language is intentional - retry the same call with allowLanguageMismatch=true.`;
259
+ return {
260
+ content: [{ type: 'text', text }],
261
+ structuredContent: { blocked: true, languageWarning: lw },
262
+ isError: true,
263
+ };
264
+ }
265
+ /**
266
+ * ORB-1471 - turn the hard duplicate-block 409 into a clear, non-throwing
267
+ * tool result that surfaces the candidate list VERBATIM plus the exact
268
+ * override recipe. The backend body is
269
+ * `{ error, errorKey, similarWarnings, threshold, topSimilarity }`.
270
+ * Returns null if the error isn't a duplicate-block 409.
271
+ */
272
+ function duplicateBlockResult(err) {
273
+ if (!(err instanceof OrbotoApiError) || err.status !== 409)
274
+ return null;
275
+ let parsed = {};
276
+ try {
277
+ parsed = JSON.parse(err.body);
278
+ }
279
+ catch { /* non-JSON body */ }
280
+ // Only OUR duplicate-block 409 carries similarWarnings; a different 409
281
+ // (none currently on this route) should bubble as a normal error.
282
+ if (!parsed.similarWarnings)
283
+ return null;
284
+ const candidates = parsed.similarWarnings;
285
+ const lines = candidates.map((w) => ` - [${w.ticketKey ?? w.id.slice(0, 8)}] "${w.title}" (${formatSimilarity(w)})`);
286
+ const text = `⛔ Ticket create BLOCKED - this looks like a duplicate (top match ${Math.round((parsed.topSimilarity ?? 0) * 100)}%, block threshold ${Math.round((parsed.threshold ?? 0) * 100)}%).\n` +
287
+ `Existing tickets it overlaps with:\n${lines.join('\n')}\n` +
288
+ `Extend or comment on one of these instead. If you have confirmed NONE of them cover this work, retry orboto_create_ticket with allowDuplicate: true AND a duplicateJustification explaining why it is not a duplicate (it is saved as a comment on the new ticket).`;
289
+ return {
290
+ content: [{ type: 'text', text }],
291
+ structuredContent: {
292
+ duplicateBlocked: true,
293
+ threshold: parsed.threshold ?? null,
294
+ topSimilarity: parsed.topSimilarity ?? null,
295
+ similarWarnings: trimSimilarEntries(candidates),
296
+ },
297
+ isError: true,
298
+ };
299
+ }
300
+ function formatSimilarity(w) {
301
+ const pct = `${Math.round(w.similarity * 100)}% ${w.matchMode === 'embedding' ? 'AI match' : 'text match'}`;
302
+ return w.statusName ? `${w.statusName}, ${pct}` : pct;
303
+ }
304
+ // ---------------------------------------------------------------------------
305
+ // orboto_update_ticket
306
+ // ---------------------------------------------------------------------------
307
+ export const updateTicketToolConfig = {
308
+ title: 'Update a ticket',
309
+ description: 'Patch one or more fields on a ticket. Patchable: title, description, customerSummary, type, priority, deliveryMode, dueDate, startDate, isPrivate, estimatedTimeMinutes. `customerSummary` is the customer-facing text shown in the customer project report instead of the internal description; null or empty falls back to an AI distillation or the title. `deliveryMode` is the role-aware commit policy (ORB-1608) - see `orboto_create_ticket` for the mode semantics. Use `orboto_move_ticket` for status, `orboto_set_milestone` for milestone, and `orboto_assign` / `orboto_unassign` for members.',
310
+ inputSchema: z.object({
311
+ ticketKey: z.string().min(3),
312
+ patch: z.object({
313
+ title: z.string().min(1).max(255).optional(),
314
+ description: z.string().optional(),
315
+ customerSummary: z.string().max(2000).nullable().optional().describe('Customer-facing report text; null falls back to AI/title.'),
316
+ type: z.enum(['task', 'bug', 'story', 'epic']).optional(),
317
+ priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional(),
318
+ deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional().describe('Commit policy; see orboto_create_ticket.'),
319
+ dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
320
+ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
321
+ isPrivate: z.boolean().optional(),
322
+ estimatedTimeMinutes: z.number().int().nonnegative().optional(),
323
+ }).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
324
+ allowLanguageMismatch: z.boolean().optional().describe('Override the language block; only after a call was blocked.'),
325
+ }).shape,
326
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
327
+ };
328
+ export function makeUpdateTicketHandler(client) {
329
+ return async ({ ticketKey, patch, allowLanguageMismatch }) => {
330
+ const ticket = await resolveTicketByKey(client, ticketKey);
331
+ // ORB-990 - strict ticket-language enforcement may reject a
332
+ // title/description patch with a 422; surface a clear block result.
333
+ const patchPath = `/projects/${ticket.projectId}/tickets/${ticket.id}${allowLanguageMismatch ? '?allowLanguageMismatch=true' : ''}`;
334
+ let updated;
335
+ try {
336
+ updated = await client.patch(patchPath, patch);
337
+ }
338
+ catch (err) {
339
+ const blocked = languageBlockResult(err, 'Ticket update');
340
+ if (blocked)
341
+ return blocked;
342
+ throw err;
343
+ }
344
+ const langWarning = updated.languageWarning;
345
+ const parts = [ticketSummaryText('Updated', updated)];
346
+ if (langWarning) {
347
+ parts.push(`\n⚠ Language mismatch - this ticket reads as "${langWarning.detected}" but the workspace default is "${langWarning.expected}". Consider rewriting in ${langWarning.expected.toUpperCase()} to keep search + duplicate-detection consistent.`);
348
+ }
349
+ return {
350
+ content: [{ type: 'text', text: parts.join('\n') }],
351
+ structuredContent: {
352
+ ...ticketStructured(updated),
353
+ ...(langWarning ? { languageWarning: langWarning } : {}),
354
+ },
355
+ };
356
+ };
357
+ }
358
+ // ---------------------------------------------------------------------------
359
+ // orboto_move_ticket
360
+ // ---------------------------------------------------------------------------
361
+ export const moveTicketToolConfig = {
362
+ title: 'Move a ticket between status categories',
363
+ description: 'Move a ticket to a new status category - todo / in_progress / in_review / done / wont_fix. The API picks the project\'s first status with that category. Caller must have `ticket:change_status`. **Summary warning (ORB-1332):** moving to `in_review` or `done` without having just posted a summary comment returns a non-blocking `summaryWarning` - the move still succeeds, but you should post what changed, the commit SHA, and how to verify. Use `orboto_close_ticket` with a `comment` (or comment first, then move) to avoid it. **Delivery-mode warning (ORB-1608/1642):** moving to `done` on an `implementation`/`docs` ticket with zero linked commits returns a non-blocking `deliveryModeWarning` - UNLESS the project has no git connection at all, in which case it\'s omitted (a link isn\'t possible there). Code `no_commit_linked` means the connection looks healthy - link a commit, or change deliveryMode via `orboto_update_ticket` if the work genuinely is not commit-shaped. Code `git_delivery_failing` means the connection itself looks unhealthy - a commit may already exist and just hasn\'t arrived; check the connection before assuming none was made.',
364
+ inputSchema: z.object({
365
+ ticketKey: z.string().min(3),
366
+ statusCategory: z.enum(STATUS_CATEGORIES),
367
+ }).shape,
368
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
369
+ };
370
+ export function makeMoveTicketHandler(client) {
371
+ return async ({ ticketKey, statusCategory }) => {
372
+ const ticket = await resolveTicketByKey(client, ticketKey);
373
+ const updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { status: CATEGORY_TO_LEGACY[statusCategory] });
374
+ return withDeliveryModeWarning(withSummaryWarning({
375
+ content: [{ type: 'text', text: ticketSummaryText('Moved', updated) }],
376
+ structuredContent: ticketStructured(updated),
377
+ }, updated.summaryWarning), updated.deliveryModeWarning);
378
+ };
379
+ }
380
+ // ---------------------------------------------------------------------------
381
+ // orboto_close_ticket
382
+ // ---------------------------------------------------------------------------
383
+ export const closeTicketToolConfig = {
384
+ title: 'Close a ticket',
385
+ description: 'Move a ticket to `done` and optionally post a closing comment in one call. Closing more than ~3 tickets? Use `orboto_bulk_close_tickets` instead. Convenience wrapper around `orboto_move_ticket` + `orboto_comment` so the model doesn\'t need to chain two writes. Passing a `comment` is the recommended way to close - it doubles as the transition summary and suppresses the ORB-1332 `summaryWarning`. Closing with no comment (and none posted in the last few minutes) returns a non-blocking `summaryWarning`; the close still succeeds. **Delivery-mode warning (ORB-1608/1642):** on an `implementation`/`docs` ticket, closing with zero linked commits returns a non-blocking `deliveryModeWarning` too - UNLESS the project has no git connection at all, in which case it\'s omitted. `no_commit_linked` = healthy connection, link a commit or change deliveryMode via `orboto_update_ticket`. `git_delivery_failing` = the connection itself looks unhealthy - check it before assuming no commit was made.',
386
+ inputSchema: z.object({
387
+ ticketKey: z.string().min(3),
388
+ comment: z.string().min(1).optional().describe('Closing comment; doubles as the transition summary.'),
389
+ }).shape,
390
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
391
+ };
392
+ export function makeCloseTicketHandler(client) {
393
+ return async ({ ticketKey, comment }) => {
394
+ const ticket = await resolveTicketByKey(client, ticketKey);
395
+ if (comment) {
396
+ // Comment first so the close-comment lands in the audit trail
397
+ // even if the status move 403s. Mirrors the wrapper's `close`
398
+ // behaviour.
399
+ await client.post(`/tickets/${ticket.id}/comments`, { content: comment });
400
+ }
401
+ const updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { status: 'DONE' });
402
+ return withDeliveryModeWarning(withSummaryWarning({
403
+ content: [{ type: 'text', text: ticketSummaryText('Closed', updated) }],
404
+ structuredContent: ticketStructured(updated),
405
+ }, updated.summaryWarning), updated.deliveryModeWarning);
406
+ };
407
+ }
408
+ // ---------------------------------------------------------------------------
409
+ // orboto_delete_ticket
410
+ // ---------------------------------------------------------------------------
411
+ export const deleteTicketToolConfig = {
412
+ title: 'Permanently delete a ticket',
413
+ description: 'DESTRUCTIVE, IRREVERSIBLE hard-delete of a ticket (by key): the row and its history are gone, and a `ticket.deleted` event + webhook fire. Strongly prefer moving the ticket to `wont_fix` (orboto_move_ticket) or closing it instead - wont_fix keeps the history and analytics intact. Only hard-delete a ticket that should truly never have existed (accidental duplicate, spam). Caller must have `ticket:delete`.',
414
+ inputSchema: z.object({
415
+ ticketKey: z.string().min(3),
416
+ }).shape,
417
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
418
+ };
419
+ export function makeDeleteTicketHandler(client) {
420
+ return async ({ ticketKey }) => {
421
+ const ticket = await resolveTicketByKey(client, ticketKey);
422
+ await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}`);
423
+ const key = ticket.ticketKey ?? ticketKey;
424
+ return {
425
+ content: [{ type: 'text', text: `Permanently deleted ${key}. This cannot be undone.` }],
426
+ structuredContent: { deleted: true, ticketKey: key, id: ticket.id },
427
+ };
428
+ };
429
+ }
430
+ export const commentToolConfig = {
431
+ title: 'Post a comment on a ticket',
432
+ description: 'Append a comment. Supports Markdown. `isInternal=true` hides the comment from external/guest users (use for implementation chatter the customer shouldn\'t see).',
433
+ inputSchema: z.object({
434
+ ticketKey: z.string().min(3),
435
+ text: z.string().min(1),
436
+ isInternal: z.boolean().optional().describe('Default: false (visible to all members + guests).'),
437
+ }).shape,
438
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
439
+ };
440
+ export function makeCommentHandler(client) {
441
+ return async ({ ticketKey, text, isInternal }) => {
442
+ const ticket = await resolveTicketByKey(client, ticketKey);
443
+ const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false });
444
+ return {
445
+ content: [{
446
+ type: 'text',
447
+ text: `Posted comment on [${ticket.ticketKey}]${created.isInternal ? ' (internal)' : ''}.`,
448
+ }],
449
+ structuredContent: {
450
+ ticketKey: ticket.ticketKey,
451
+ commentId: created.id,
452
+ isInternal: created.isInternal,
453
+ createdAt: created.createdAt,
454
+ },
455
+ };
456
+ };
457
+ }
458
+ // ---------------------------------------------------------------------------
459
+ // orboto_update_comment (ORB-1285)
460
+ // ---------------------------------------------------------------------------
461
+ export const updateCommentToolConfig = {
462
+ title: 'Edit a ticket comment',
463
+ description: 'Edit the text of a comment you authored (super-admins can edit any). Markdown. The prior text is snapshotted into the comment\'s revision history. Pass the commentId - from orboto_comment\'s response or orboto_get_ticket.',
464
+ inputSchema: z.object({
465
+ ticketKey: z.string().min(3),
466
+ commentId: z.string().uuid(),
467
+ text: z.string().min(1).describe('New comment body (replaces the old text).'),
468
+ }).shape,
469
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
470
+ };
471
+ export function makeUpdateCommentHandler(client) {
472
+ return async ({ ticketKey, commentId, text }) => {
473
+ const ticket = await resolveTicketByKey(client, ticketKey);
474
+ const updated = await client.patch(`/tickets/${ticket.id}/comments/${commentId}`, { content: text });
475
+ return {
476
+ content: [{ type: 'text', text: `Edited comment ${commentId.slice(0, 8)} on [${ticket.ticketKey}].` }],
477
+ structuredContent: { ticketKey: ticket.ticketKey, commentId: updated.id, editedAt: updated.createdAt },
478
+ };
479
+ };
480
+ }
481
+ // ---------------------------------------------------------------------------
482
+ // orboto_delete_comment (ORB-1285)
483
+ // ---------------------------------------------------------------------------
484
+ export const deleteCommentToolConfig = {
485
+ title: 'Delete a ticket comment',
486
+ description: 'Delete a comment. You can always delete your own; deleting another user\'s comment needs the `comment:delete_others` permission in the project (super-admins always can). Pass the commentId - from orboto_comment\'s response or orboto_get_ticket.',
487
+ inputSchema: z.object({
488
+ ticketKey: z.string().min(3),
489
+ commentId: z.string().uuid(),
490
+ }).shape,
491
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
492
+ };
493
+ export function makeDeleteCommentHandler(client) {
494
+ return async ({ ticketKey, commentId }) => {
495
+ const ticket = await resolveTicketByKey(client, ticketKey);
496
+ await client.delete(`/tickets/${ticket.id}/comments/${commentId}`);
497
+ return {
498
+ content: [{ type: 'text', text: `Deleted comment ${commentId.slice(0, 8)} on [${ticket.ticketKey}].` }],
499
+ structuredContent: { ticketKey: ticket.ticketKey, commentId, deleted: true },
500
+ };
501
+ };
502
+ }
503
+ // ---------------------------------------------------------------------------
504
+ // orboto_assign / orboto_unassign
505
+ // ---------------------------------------------------------------------------
506
+ export const assignToolConfig = {
507
+ title: 'Assign a user to a ticket',
508
+ description: 'Add a project member as an assignee on a ticket. Multi-assignee is supported - this adds, it does not replace. Use `orboto_unassign` to remove.',
509
+ inputSchema: z.object({
510
+ ticketKey: z.string().min(3),
511
+ assigneeEmail: z.string().email(),
512
+ }).shape,
513
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
514
+ };
515
+ export function makeAssignHandler(client) {
516
+ return async ({ ticketKey, assigneeEmail }) => {
517
+ const ticket = await resolveTicketByKey(client, ticketKey);
518
+ const userId = await resolveAssigneeId(client, ticket.projectId, assigneeEmail);
519
+ try {
520
+ await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/assignees/${userId}`, {});
521
+ }
522
+ catch (err) {
523
+ if (err instanceof OrbotoApiError && err.status === 409) {
524
+ // Already assigned - idempotent success.
525
+ return {
526
+ content: [{ type: 'text', text: `[${ticket.ticketKey}] already assigned to ${assigneeEmail}.` }],
527
+ structuredContent: { ticketKey: ticket.ticketKey, alreadyAssigned: true },
528
+ };
529
+ }
530
+ throw err;
531
+ }
532
+ return {
533
+ content: [{ type: 'text', text: `Assigned ${assigneeEmail} to [${ticket.ticketKey}].` }],
534
+ structuredContent: { ticketKey: ticket.ticketKey, assignedEmail: assigneeEmail },
535
+ };
536
+ };
537
+ }
538
+ export const unassignToolConfig = {
539
+ title: 'Unassign a user from a ticket',
540
+ description: 'Remove a project member as an assignee. The ticket can become unassigned.',
541
+ inputSchema: z.object({
542
+ ticketKey: z.string().min(3),
543
+ assigneeEmail: z.string().email(),
544
+ }).shape,
545
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
546
+ };
547
+ export function makeUnassignHandler(client) {
548
+ return async ({ ticketKey, assigneeEmail }) => {
549
+ const ticket = await resolveTicketByKey(client, ticketKey);
550
+ const userId = await resolveAssigneeId(client, ticket.projectId, assigneeEmail);
551
+ try {
552
+ await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}/assignees/${userId}`);
553
+ }
554
+ catch (err) {
555
+ // 404 = wasn't assigned. Idempotent success.
556
+ if (err instanceof OrbotoApiError && err.status === 404) {
557
+ return {
558
+ content: [{ type: 'text', text: `${assigneeEmail} wasn't assigned to [${ticket.ticketKey}].` }],
559
+ structuredContent: { ticketKey: ticket.ticketKey, alreadyUnassigned: true },
560
+ };
561
+ }
562
+ throw err;
563
+ }
564
+ return {
565
+ content: [{ type: 'text', text: `Unassigned ${assigneeEmail} from [${ticket.ticketKey}].` }],
566
+ structuredContent: { ticketKey: ticket.ticketKey, unassignedEmail: assigneeEmail },
567
+ };
568
+ };
569
+ }
570
+ // ---------------------------------------------------------------------------
571
+ // orboto_label_ticket / orboto_unlabel_ticket - ORB-1043
572
+ // ---------------------------------------------------------------------------
573
+ async function resolveLabelId(client, projectId, name) {
574
+ const labels = await client.get(`/projects/${projectId}/labels`);
575
+ // ORB-1826 - exact match wins first, then a unique normalised match
576
+ // (HTML-entity-decoded, trimmed, whitespace-collapsed, casefolded) so
577
+ // case/whitespace/entity variants of an existing label name resolve
578
+ // instead of rejecting. Labels have no unique-name constraint, so an
579
+ // ambiguous normalised match (two labels differing only by case) still
580
+ // errors rather than silently picking one.
581
+ const { match, ambiguous } = resolveByName(labels, name, (l) => l.name);
582
+ if (ambiguous) {
583
+ const list = ambiguous.map((l) => `"${l.name}" (${l.id})`).join(', ');
584
+ throw new Error(`Label name "${name}" is ambiguous - ${ambiguous.length} labels match: ${list}.`);
585
+ }
586
+ if (!match) {
587
+ throw new Error(`No label named "${name}" in this project. Create it first with orboto_create_label.`);
588
+ }
589
+ return match.id;
590
+ }
591
+ export const labelTicketToolConfig = {
592
+ title: 'Add a label to a ticket',
593
+ description: 'Attach an existing label (by name) to an existing ticket (by key). The label must already exist - create it with orboto_create_label first. Idempotent: a label already on the ticket is a no-op. Needs ticket:edit.',
594
+ inputSchema: z.object({
595
+ ticketKey: z.string().min(3),
596
+ label: z.string().min(1).describe('Label name (must exist on the project).'),
597
+ }).shape,
598
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
599
+ };
600
+ export function makeLabelTicketHandler(client) {
601
+ return async ({ ticketKey, label }) => {
602
+ const ticket = await resolveTicketByKey(client, ticketKey);
603
+ const labelId = await resolveLabelId(client, ticket.projectId, label);
604
+ await client.post(`/projects/tickets/${ticket.id}/labels/${labelId}`, {});
605
+ return {
606
+ content: [{ type: 'text', text: `Added label "${label}" to [${ticket.ticketKey}].` }],
607
+ structuredContent: { ticketKey: ticket.ticketKey, label },
608
+ };
609
+ };
610
+ }
611
+ export const unlabelTicketToolConfig = {
612
+ title: 'Remove a label from a ticket',
613
+ description: 'Detach a label (by name) from a ticket (by key). Idempotent: a label not on the ticket is a no-op. Needs ticket:edit.',
614
+ inputSchema: z.object({
615
+ ticketKey: z.string().min(3),
616
+ label: z.string().min(1).describe('Label name.'),
617
+ }).shape,
618
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
619
+ };
620
+ export function makeUnlabelTicketHandler(client) {
621
+ return async ({ ticketKey, label }) => {
622
+ const ticket = await resolveTicketByKey(client, ticketKey);
623
+ const labelId = await resolveLabelId(client, ticket.projectId, label);
624
+ await client.delete(`/projects/tickets/${ticket.id}/labels/${labelId}`);
625
+ return {
626
+ content: [{ type: 'text', text: `Removed label "${label}" from [${ticket.ticketKey}].` }],
627
+ structuredContent: { ticketKey: ticket.ticketKey, label, removed: true },
628
+ };
629
+ };
630
+ }
631
+ // ---------------------------------------------------------------------------
632
+ // orboto_set_milestone
633
+ // ---------------------------------------------------------------------------
634
+ export const setMilestoneToolConfig = {
635
+ title: 'Set a ticket\'s milestone',
636
+ description: 'Move a ticket onto a different milestone (or off all milestones with milestone=null/undefined). Resolves the milestone by key (e.g. "ORB-M3"), name, OR UUID within the ticket\'s project (including closed/archived). If two milestones share a name, pass the key or UUID - an ambiguous name is rejected, not silently guessed.',
637
+ inputSchema: z.object({
638
+ ticketKey: z.string().min(3),
639
+ milestone: z.string().nullable().optional().describe('Milestone key (e.g. "ORB-M3"), name, or UUID. Pass the key/UUID when the name is ambiguous. Pass null to remove from any milestone.'),
640
+ }).shape,
641
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
642
+ };
643
+ export function makeSetMilestoneHandler(client) {
644
+ return async ({ ticketKey, milestone }) => {
645
+ const ticket = await resolveTicketByKey(client, ticketKey);
646
+ let milestoneId = null;
647
+ if (milestone) {
648
+ milestoneId = await resolveMilestoneId(client, ticket.projectId, milestone);
649
+ }
650
+ const updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { milestoneId });
651
+ return {
652
+ content: [{
653
+ type: 'text',
654
+ text: milestoneId
655
+ ? `Moved [${ticket.ticketKey}] to milestone "${milestone}".`
656
+ : `Removed [${ticket.ticketKey}] from any milestone.`,
657
+ }],
658
+ structuredContent: ticketStructured(updated),
659
+ };
660
+ };
661
+ }
662
+ export const addTicketDependencyToolConfig = {
663
+ title: 'Add a ticket dependency',
664
+ description: 'Mark `ticketKey` as depending on `dependsOnKey` - i.e. `dependsOnKey` blocks `ticketKey`. Wiring more than ~3 edges? Use `orboto_bulk_add_ticket_dependencies` instead. ORB-1614: the two tickets may live in DIFFERENT projects, as long as you can read both - the API 403s with a forbidden error otherwise. Self-dependencies and cycles (including cycles that span projects) are rejected.',
665
+ inputSchema: z.object({
666
+ ticketKey: z.string().min(3).describe('The blocked ticket.'),
667
+ dependsOnKey: z.string().min(3).describe('The blocker; may be in another project.'),
668
+ }).shape,
669
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
670
+ };
671
+ export function makeAddTicketDependencyHandler(client) {
672
+ return async ({ ticketKey, dependsOnKey }) => {
673
+ const ticket = await resolveTicketByKey(client, ticketKey);
674
+ const dependsOn = await resolveTicketByKey(client, dependsOnKey);
675
+ try {
676
+ await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies`, { dependsOnId: dependsOn.id });
677
+ }
678
+ catch (err) {
679
+ // 409 = edge already exists - idempotent success.
680
+ if (err instanceof OrbotoApiError && err.status === 409) {
681
+ return {
682
+ content: [{ type: 'text', text: `[${ticket.ticketKey}] already depends on [${dependsOn.ticketKey}].` }],
683
+ structuredContent: { ticketKey: ticket.ticketKey, dependsOnKey: dependsOn.ticketKey, alreadyExisted: true },
684
+ };
685
+ }
686
+ throw err;
687
+ }
688
+ return {
689
+ content: [{
690
+ type: 'text',
691
+ text: `[${ticket.ticketKey}] now depends on [${dependsOn.ticketKey}] - must complete first.`,
692
+ }],
693
+ structuredContent: {
694
+ ticketKey: ticket.ticketKey,
695
+ dependsOnKey: dependsOn.ticketKey,
696
+ },
697
+ };
698
+ };
699
+ }
700
+ export const removeTicketDependencyToolConfig = {
701
+ title: 'Remove a ticket dependency',
702
+ description: 'Drop the dependency edge from `ticketKey` to `dependsOnKey`. Idempotent - removing an edge that isn\'t there returns the same success.',
703
+ inputSchema: z.object({
704
+ ticketKey: z.string().min(3),
705
+ dependsOnKey: z.string().min(3),
706
+ }).shape,
707
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
708
+ };
709
+ export function makeRemoveTicketDependencyHandler(client) {
710
+ return async ({ ticketKey, dependsOnKey }) => {
711
+ const ticket = await resolveTicketByKey(client, ticketKey);
712
+ const dependsOn = await resolveTicketByKey(client, dependsOnKey);
713
+ try {
714
+ await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies/${dependsOn.id}`);
715
+ }
716
+ catch (err) {
717
+ // 404 = edge wasn't there. Idempotent success.
718
+ if (err instanceof OrbotoApiError && err.status === 404) {
719
+ return {
720
+ content: [{ type: 'text', text: `[${ticket.ticketKey}] didn't depend on [${dependsOn.ticketKey}].` }],
721
+ structuredContent: { ticketKey: ticket.ticketKey, dependsOnKey: dependsOn.ticketKey, alreadyAbsent: true },
722
+ };
723
+ }
724
+ throw err;
725
+ }
726
+ return {
727
+ content: [{
728
+ type: 'text',
729
+ text: `Removed dependency [${ticket.ticketKey}] → [${dependsOn.ticketKey}].`,
730
+ }],
731
+ structuredContent: {
732
+ ticketKey: ticket.ticketKey,
733
+ dependsOnKey: dependsOn.ticketKey,
734
+ },
735
+ };
736
+ };
737
+ }
738
+ export const listTicketDependenciesToolConfig = {
739
+ title: 'List a ticket\'s dependencies',
740
+ description: 'Show both directions of the dependency graph for a ticket: `blockedBy` (tickets that must finish first) and `blocks` (tickets waiting on this one). ORB-1614: an edge to a ticket in another project you cannot read comes back as an opaque "external dependency" entry (no title/key/status - just whether it is still open).',
741
+ inputSchema: z.object({
742
+ ticketKey: z.string().min(3),
743
+ }).shape,
744
+ annotations: { readOnlyHint: true, idempotentHint: true },
745
+ };
746
+ /** ORB-1614 - a stubbed cross-project entry has `title: null`; render a
747
+ * fixed, non-identifying placeholder instead of the literal "null". */
748
+ function fmtDependencyEntry(e) {
749
+ const key = e.ticketKey ?? e.id.slice(0, 8);
750
+ if (e.title == null) {
751
+ return `- [${key}] External dependency (access restricted)${e.resolved ? ' - resolved' : ' - still open'}`;
752
+ }
753
+ return `- [${key}] ${e.title}${e.statusName ? ` - ${e.statusName}` : ''}`;
754
+ }
755
+ export function makeListTicketDependenciesHandler(client) {
756
+ return async ({ ticketKey }) => {
757
+ const ticket = await resolveTicketByKey(client, ticketKey);
758
+ const data = await client.get(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies`);
759
+ const fmt = (edges) => edges.length === 0 ? '_(none)_' : edges.map(fmtDependencyEntry).join('\n');
760
+ const lines = [
761
+ `# Dependencies for [${ticket.ticketKey}]`,
762
+ '',
763
+ '## Blocked by (must complete first)',
764
+ fmt(data.blockedBy),
765
+ '',
766
+ '## Blocks (waiting on this ticket)',
767
+ fmt(data.blocks),
768
+ ];
769
+ return {
770
+ content: [{ type: 'text', text: lines.join('\n') }],
771
+ structuredContent: {
772
+ ticketKey: ticket.ticketKey,
773
+ blockedBy: data.blockedBy.map((e) => ({ ticketKey: e.ticketKey, title: e.title, statusName: e.statusName, external: e.external, resolved: e.resolved })),
774
+ blocks: data.blocks.map((e) => ({ ticketKey: e.ticketKey, title: e.title, statusName: e.statusName, external: e.external, resolved: e.resolved })),
775
+ },
776
+ };
777
+ };
778
+ }