@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,279 @@
1
+ /**
2
+ * ORB-244 Phase B - milestone tools, expanded in ORB-799 with CRUD.
3
+ *
4
+ * `orboto_list_milestones` and `orboto_get_milestone` share this file
5
+ * because they're cheap neighbours (same API root, same resolution
6
+ * chain). The `get` tool also pulls the `/progress` endpoint so the
7
+ * model sees ticket-count breakdowns alongside the metadata.
8
+ *
9
+ * ORB-799 added the write half of the surface to close wrapper-parity:
10
+ *
11
+ * - orboto_create_milestone - mirrors `orboto.mjs create-milestone`.
12
+ * `startDate`/`endDate` are both optional; a milestone without dates
13
+ * is a legitimate object (analytics/Gantt/templates treat null dates
14
+ * as "no dates").
15
+ * - orboto_close_milestone - close + optional archive. Resolves the
16
+ * milestone by name OR UUID against the includeClosed=true list so
17
+ * re-closing an already-closed milestone is idempotent.
18
+ * - orboto_update_milestone - patch name / dates / private flag.
19
+ */
20
+ import { z } from 'zod';
21
+ import { OrbotoApiError } from '../orboto-client.js';
22
+ import { resolveProjectByKey, resolveByName } from './shared.js';
23
+ const UUID_RE = /^[0-9a-f-]{36}$/i;
24
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
25
+ /** ORB-1059 - a milestone key looks like `ORB-M3`. */
26
+ const MILESTONE_KEY_RE = /^[A-Za-z0-9]+-M\d+$/i;
27
+ // ---------------------------------------------------------------------------
28
+ // orboto_list_milestones
29
+ // ---------------------------------------------------------------------------
30
+ export const listMilestonesToolConfig = {
31
+ title: 'List milestones',
32
+ description: 'List milestones in a project, newest first.',
33
+ inputSchema: z.object({
34
+ projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
35
+ }).shape,
36
+ annotations: { readOnlyHint: true, idempotentHint: true },
37
+ };
38
+ export function makeListMilestonesHandler(client) {
39
+ return async ({ projectKey }) => {
40
+ const project = await resolveProjectByKey(client, projectKey);
41
+ const milestones = await client.get(`/projects/${project.id}/milestones`);
42
+ const text = milestones.length === 0
43
+ ? `No milestones in project ${project.key}.`
44
+ : milestones.map((m) => {
45
+ const range = [m.startDate, m.endDate].filter(Boolean).join(' → ') || 'no dates';
46
+ const key = m.milestoneKey ? `${m.milestoneKey} · ` : '';
47
+ return `- ${key}${m.name} [${m.status}] (${range})`;
48
+ }).join('\n');
49
+ return {
50
+ content: [{ type: 'text', text }],
51
+ structuredContent: {
52
+ project: { key: project.key },
53
+ milestones: milestones.map((m) => ({
54
+ // ORB-1179 - surface the uuid alongside the milestone key.
55
+ id: m.id,
56
+ milestoneKey: m.milestoneKey ?? null,
57
+ name: m.name,
58
+ status: m.status,
59
+ startDate: m.startDate,
60
+ endDate: m.endDate,
61
+ isPrivate: m.isPrivate,
62
+ })),
63
+ },
64
+ };
65
+ };
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // orboto_get_milestone
69
+ // ---------------------------------------------------------------------------
70
+ export const getMilestoneToolConfig = {
71
+ title: 'Get milestone details',
72
+ description: 'Return milestone metadata plus ticket-count breakdown by status (to do / in progress / done / …).',
73
+ inputSchema: z.object({
74
+ projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
75
+ milestone: z.string().min(1).describe('Key ("ORB-M3"), name, or UUID. Name matching normalises HTML entities/case/whitespace.'),
76
+ }).shape,
77
+ annotations: { readOnlyHint: true, idempotentHint: true },
78
+ };
79
+ export function makeGetMilestoneHandler(client) {
80
+ return async ({ projectKey, milestone }) => {
81
+ const project = await resolveProjectByKey(client, projectKey);
82
+ // ORB-1068 - resolve key / name / UUID through the shared resolver
83
+ // instead of an exact-name find, so `ORB-M19` works here too.
84
+ const m = await resolveMilestoneByNameOrId(client, project.id, milestone);
85
+ // Progress comes from a separate endpoint; tolerate 404 gracefully
86
+ // in case a future API rename drops it so the tool still returns
87
+ // the metadata half.
88
+ const progress = await client.get(`/projects/${project.id}/milestones/${m.id}/progress`).catch((err) => {
89
+ if (err instanceof OrbotoApiError && err.status === 404)
90
+ return null;
91
+ throw err;
92
+ });
93
+ const lines = [
94
+ `${m.milestoneKey ? `${m.milestoneKey} · ` : ''}${m.name} [${m.status}]`,
95
+ `Dates: ${m.startDate ?? '(no start)'} → ${m.endDate ?? '(no end)'}`,
96
+ m.isPrivate ? 'Private: yes' : null,
97
+ ].filter((l) => l !== null);
98
+ if (progress) {
99
+ const done = progress.byStatus.DONE ?? 0;
100
+ const inProgress = progress.byStatus.IN_PROGRESS ?? 0;
101
+ const inReview = progress.byStatus.IN_REVIEW ?? 0;
102
+ const todo = progress.byStatus.TODO ?? 0;
103
+ const wontFix = progress.byStatus.WONT_FIX ?? 0;
104
+ const percent = progress.total > 0 ? Math.round((done / progress.total) * 100) : 0;
105
+ lines.push('', `Progress: ${percent}% done (${done}/${progress.total})`, ` to do: ${todo} · in progress: ${inProgress} · in review: ${inReview} · won't fix: ${wontFix}`);
106
+ }
107
+ return {
108
+ content: [{ type: 'text', text: lines.join('\n') }],
109
+ structuredContent: {
110
+ milestone: {
111
+ id: m.id,
112
+ milestoneKey: m.milestoneKey ?? null,
113
+ name: m.name,
114
+ status: m.status,
115
+ startDate: m.startDate,
116
+ endDate: m.endDate,
117
+ isPrivate: m.isPrivate,
118
+ },
119
+ progress,
120
+ },
121
+ };
122
+ };
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // orboto_create_milestone - ORB-799
126
+ // ---------------------------------------------------------------------------
127
+ export const createMilestoneToolConfig = {
128
+ title: 'Create a milestone',
129
+ description: 'Create a milestone in a project. `startDate` + `endDate` are optional - omit them (or pass null) for a milestone with no dates. The caller must have `milestone:create`.',
130
+ inputSchema: z.object({
131
+ projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
132
+ name: z.string().min(1).describe('Milestone name (unique within the project).'),
133
+ startDate: z.string().regex(DATE_RE).nullable().optional().describe('YYYY-MM-DD or null.'),
134
+ endDate: z.string().regex(DATE_RE).nullable().optional().describe('YYYY-MM-DD or null.'),
135
+ isPrivate: z.boolean().optional().describe('Project members only. Default: false.'),
136
+ }).shape,
137
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
138
+ };
139
+ export function makeCreateMilestoneHandler(client) {
140
+ return async ({ projectKey, name, startDate, endDate, isPrivate }) => {
141
+ const project = await resolveProjectByKey(client, projectKey);
142
+ const body = {
143
+ name,
144
+ startDate,
145
+ endDate,
146
+ isPrivate: isPrivate ?? false,
147
+ };
148
+ const created = await client.post(`/projects/${project.id}/milestones`, body);
149
+ return {
150
+ content: [{
151
+ type: 'text',
152
+ text: `Created milestone "${created.name}" in ${project.key} (status: ${created.status ?? 'active'}).`,
153
+ }],
154
+ structuredContent: {
155
+ id: created.id,
156
+ name: created.name,
157
+ status: created.status,
158
+ startDate: created.startDate,
159
+ endDate: created.endDate,
160
+ isPrivate: created.isPrivate,
161
+ projectKey: project.key,
162
+ },
163
+ };
164
+ };
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // orboto_close_milestone - ORB-799
168
+ // ---------------------------------------------------------------------------
169
+ /** Resolve a milestone by name OR UUID against the includeClosed list,
170
+ * so closing an already-completed milestone (re-close), archiving a
171
+ * completed milestone, and re-pointing a ticket onto any milestone all
172
+ * work without the caller pre-fetching. UUID is the unambiguous handle:
173
+ * a name that matches more than one milestone throws (listing the
174
+ * candidate UUIDs) rather than silently returning the first match
175
+ * (ORB-1058). Accepts the human-readable milestone key (`ORB-M3`,
176
+ * ORB-1059) as well as name and UUID. */
177
+ export async function resolveMilestoneByNameOrId(client, projectId, nameOrId) {
178
+ const all = await client.get(`/projects/${projectId}/milestones?includeClosed=true`);
179
+ if (UUID_RE.test(nameOrId)) {
180
+ const byId = all.find((x) => x.id === nameOrId);
181
+ if (!byId) {
182
+ throw new Error(`No milestone with id "${nameOrId}" in the project (including closed/archived).`);
183
+ }
184
+ return byId;
185
+ }
186
+ // ORB-1059 - accept the human-readable key (`ORB-M3`), case-insensitive.
187
+ if (MILESTONE_KEY_RE.test(nameOrId)) {
188
+ const byKey = all.find((x) => x.milestoneKey?.toLowerCase() === nameOrId.toLowerCase());
189
+ if (!byKey) {
190
+ throw new Error(`No milestone with key "${nameOrId}" in the project (including closed/archived).`);
191
+ }
192
+ return byKey;
193
+ }
194
+ // ORB-1826 - exact (raw) name match wins first; falls back to a unique
195
+ // normalised match (HTML-entity-decoded, trimmed, whitespace-collapsed,
196
+ // casefolded) so `"QA & Testing"` resolves against a milestone
197
+ // literally named `"QA & Testing"`. Ambiguous either way still errors,
198
+ // listing the candidates.
199
+ const { match, ambiguous } = resolveByName(all, nameOrId, (m) => m.name);
200
+ if (ambiguous) {
201
+ const list = ambiguous.map((m) => `"${m.name}" (${m.id})`).join(', ');
202
+ throw new Error(`Milestone name "${nameOrId}" is ambiguous - ${ambiguous.length} milestones match: ${list}. Pass the milestone's UUID instead.`);
203
+ }
204
+ if (!match) {
205
+ throw new Error(`Milestone "${nameOrId}" not found in the project (including closed/archived).`);
206
+ }
207
+ return match;
208
+ }
209
+ export const closeMilestoneToolConfig = {
210
+ title: 'Close (or archive) a milestone',
211
+ description: 'Move a milestone to `completed` (default) or `archived` (pass `archive=true`). Looks up the milestone by name or UUID, including already-closed ones, so re-closing is idempotent. Useful when a release ships and you want to lock the milestone but keep the tickets on it.',
212
+ inputSchema: z.object({
213
+ projectKey: z.string().min(1),
214
+ milestone: z.string().min(1).describe('Milestone name or UUID.'),
215
+ archive: z.boolean().optional().describe('Set true to archive (status=archived) instead of merely completing.'),
216
+ }).shape,
217
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
218
+ };
219
+ export function makeCloseMilestoneHandler(client) {
220
+ return async ({ projectKey, milestone, archive }) => {
221
+ const project = await resolveProjectByKey(client, projectKey);
222
+ const found = await resolveMilestoneByNameOrId(client, project.id, milestone);
223
+ const target = archive ? 'archived' : 'completed';
224
+ const updated = await client.patch(`/projects/${project.id}/milestones/${found.id}`, { status: target });
225
+ return {
226
+ content: [{
227
+ type: 'text',
228
+ text: `Milestone "${updated.name}" → ${updated.status}.`,
229
+ }],
230
+ structuredContent: {
231
+ id: updated.id,
232
+ name: updated.name,
233
+ status: updated.status,
234
+ projectKey: project.key,
235
+ },
236
+ };
237
+ };
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // orboto_update_milestone - ORB-799
241
+ // ---------------------------------------------------------------------------
242
+ export const updateMilestoneToolConfig = {
243
+ title: 'Update a milestone\'s fields',
244
+ description: 'Patch a milestone (`name`, `customerSummary`, `startDate`, `endDate`, `isPrivate`). At least one field must be set. `customerSummary` is the customer-facing text shown in the customer project report instead of any internal text. Use `orboto_close_milestone` to flip status to completed/archived - this tool intentionally does NOT touch the status field so closing remains a clear semantic operation.',
245
+ inputSchema: z.object({
246
+ projectKey: z.string().min(1),
247
+ milestone: z.string().min(1).describe('Milestone name or UUID to identify the target.'),
248
+ patch: z.object({
249
+ name: z.string().min(1).optional(),
250
+ customerSummary: z.string().max(2000).nullable().optional().describe('Customer-facing summary shown in the customer report instead of internal text.'),
251
+ startDate: z.string().regex(DATE_RE).nullable().optional(),
252
+ endDate: z.string().regex(DATE_RE).nullable().optional(),
253
+ isPrivate: z.boolean().optional(),
254
+ }).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
255
+ }).shape,
256
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
257
+ };
258
+ export function makeUpdateMilestoneHandler(client) {
259
+ return async ({ projectKey, milestone, patch }) => {
260
+ const project = await resolveProjectByKey(client, projectKey);
261
+ const found = await resolveMilestoneByNameOrId(client, project.id, milestone);
262
+ const updated = await client.patch(`/projects/${project.id}/milestones/${found.id}`, patch);
263
+ return {
264
+ content: [{
265
+ type: 'text',
266
+ text: `Updated milestone "${updated.name}" (${Object.keys(patch).join(', ')}).`,
267
+ }],
268
+ structuredContent: {
269
+ id: updated.id,
270
+ name: updated.name,
271
+ status: updated.status,
272
+ startDate: updated.startDate,
273
+ endDate: updated.endDate,
274
+ isPrivate: updated.isPrivate,
275
+ projectKey: project.key,
276
+ },
277
+ };
278
+ };
279
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ORB-1826 - `resolveMilestoneByNameOrId` name-matching coverage.
3
+ *
4
+ * Production MCP error log: `Milestone "QA & Testing" not found` for a
5
+ * milestone literally named "QA & Testing" - the resolver did an exact,
6
+ * case-sensitive `===` with no entity decode. "Normalise, never reject."
7
+ */
8
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
9
+ import { OrbotoClient } from '../orboto-client.js';
10
+ import { resolveMilestoneByNameOrId } from './milestones.js';
11
+ beforeEach(() => { vi.restoreAllMocks(); });
12
+ afterEach(() => { vi.restoreAllMocks(); });
13
+ function mockMilestones(rows) {
14
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
15
+ ok: true,
16
+ status: 200,
17
+ statusText: 'OK',
18
+ json: async () => rows,
19
+ text: async () => JSON.stringify(rows),
20
+ }));
21
+ }
22
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
23
+ const M1_UUID = '11111111-1111-1111-1111-111111111111';
24
+ const M2_UUID = '22222222-2222-2222-2222-222222222222';
25
+ const ROWS = [
26
+ { id: M1_UUID, projectId: 'p1', name: 'QA & Testing', status: 'active', startDate: null, endDate: null, isPrivate: false, milestoneKey: 'ORB-M98' },
27
+ { id: M2_UUID, projectId: 'p1', name: 'Launch Readiness', status: 'active', startDate: null, endDate: null, isPrivate: false, milestoneKey: 'ORB-M99' },
28
+ ];
29
+ describe('resolveMilestoneByNameOrId', () => {
30
+ it('resolves an already-clean exact name', async () => {
31
+ mockMilestones(ROWS);
32
+ const m = await resolveMilestoneByNameOrId(client, 'p1', 'QA & Testing');
33
+ expect(m.id).toBe(M1_UUID);
34
+ });
35
+ it('resolves an HTML-entity-escaped name to the same milestone', async () => {
36
+ mockMilestones(ROWS);
37
+ const m = await resolveMilestoneByNameOrId(client, 'p1', 'QA & Testing');
38
+ expect(m.id).toBe(M1_UUID);
39
+ });
40
+ it('resolves case and whitespace variants', async () => {
41
+ mockMilestones(ROWS);
42
+ expect((await resolveMilestoneByNameOrId(client, 'p1', 'qa & testing')).id).toBe(M1_UUID);
43
+ mockMilestones(ROWS);
44
+ expect((await resolveMilestoneByNameOrId(client, 'p1', ' QA & Testing ')).id).toBe(M1_UUID);
45
+ });
46
+ it('still resolves by key and by UUID', async () => {
47
+ mockMilestones(ROWS);
48
+ expect((await resolveMilestoneByNameOrId(client, 'p1', 'orb-m98')).id).toBe(M1_UUID);
49
+ mockMilestones(ROWS);
50
+ expect((await resolveMilestoneByNameOrId(client, 'p1', M2_UUID)).id).toBe(M2_UUID);
51
+ });
52
+ it('throws not-found for an unknown name', async () => {
53
+ mockMilestones(ROWS);
54
+ await expect(resolveMilestoneByNameOrId(client, 'p1', 'Nonexistent')).rejects.toThrow(/not found/);
55
+ });
56
+ it('throws ambiguous, listing candidates, when two names normalise to the same key', async () => {
57
+ mockMilestones([...ROWS, { id: '33333333-3333-3333-3333-333333333333', name: 'qa & testing' }]);
58
+ await expect(resolveMilestoneByNameOrId(client, 'p1', 'QA & Testing')).rejects.toThrow(/ambiguous/);
59
+ });
60
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ORB-244 Phase B - `orboto_my_tickets`.
3
+ *
4
+ * The caller's own assignments. Maps to
5
+ * `GET /users/me/assigned-tickets`, which already does the
6
+ * right thing visibility-wise (only tickets the user can see,
7
+ * scoped to their project memberships).
8
+ */
9
+ import { z } from 'zod';
10
+ import { agentTicketListRow } from './shared.js';
11
+ export const myTicketsToolConfig = {
12
+ title: 'My assigned tickets',
13
+ description: 'List tickets assigned to the authenticated user, optionally filtered to a status category. Useful when the user says "what am I working on?"',
14
+ inputSchema: z.object({
15
+ statusCategory: z
16
+ .enum(['todo', 'in_progress', 'in_review', 'done', 'wont_fix'])
17
+ .optional()
18
+ .describe('Filter to one workflow category. Omit for all non-done.'),
19
+ limit: z.number().int().min(1).max(50).default(25),
20
+ verbose: z.boolean().default(false).describe('true = full rows; default is the decision fields only.'),
21
+ }).shape,
22
+ annotations: { readOnlyHint: true, idempotentHint: true },
23
+ };
24
+ // The API route's `statuses` param is the uppercase LEGACY status
25
+ // enum (TODO/IN_PROGRESS/IN_REVIEW/DONE/WONT_FIX). The MCP tool
26
+ // accepts the lower-case category enum the rest of the surface uses;
27
+ // map here.
28
+ const CATEGORY_TO_LEGACY = {
29
+ todo: 'TODO',
30
+ in_progress: 'IN_PROGRESS',
31
+ in_review: 'IN_REVIEW',
32
+ done: 'DONE',
33
+ wont_fix: 'WONT_FIX',
34
+ };
35
+ export function makeMyTicketsHandler(client) {
36
+ return async (input) => {
37
+ const qs = new URLSearchParams();
38
+ qs.set('limit', String(input.limit ?? 25));
39
+ if (input.statusCategory) {
40
+ qs.set('statuses', CATEGORY_TO_LEGACY[input.statusCategory]);
41
+ }
42
+ else {
43
+ // Default: exclude done + wont_fix so "what am I working on?"
44
+ // returns open work rather than a lifetime history.
45
+ qs.set('statuses', 'TODO,IN_PROGRESS,IN_REVIEW');
46
+ }
47
+ const page = await client.get(`/users/me/assigned-tickets?${qs}`);
48
+ const text = page.items.length === 0
49
+ ? 'No tickets assigned to you matching that filter.'
50
+ : page.items.map((t) => {
51
+ const key = t.ticketKey ?? t.id.slice(0, 8);
52
+ const due = t.dueDate ? ` (due ${t.dueDate})` : '';
53
+ return `- [${key}] ${t.title} - ${t.status}, ${t.priority}${due}`;
54
+ }).join('\n');
55
+ return {
56
+ content: [{ type: 'text', text }],
57
+ structuredContent: {
58
+ count: page.items.length,
59
+ hasMore: !!page.nextCursor,
60
+ // ORB-1699 - shared lean row; verbose restores uuid/labels/minutes.
61
+ tickets: page.items.map((t) => agentTicketListRow(t, input.verbose ?? false)),
62
+ },
63
+ };
64
+ };
65
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * ORB-862 (LLM-Wiki Phase L) - personal AI-preference facts.
3
+ *
4
+ * The caller's OWN personal primer facts (communication style, coding
5
+ * conventions, domain self-description). Privacy-strict: every route is
6
+ * owner-scoped server-side, so these tools can only ever touch the
7
+ * authenticated user's own facts - never another user's. English-only.
8
+ */
9
+ import { z } from 'zod';
10
+ function text(t, structured) {
11
+ return { content: [{ type: 'text', text: t }], ...(structured ? { structuredContent: structured } : {}) };
12
+ }
13
+ const CATEGORY = z.enum(['tech_stack', 'conventions', 'deployment', 'architecture', 'integrations', 'gotchas', 'commands', 'other']);
14
+ export const personalFactListToolConfig = {
15
+ title: 'List my personal AI preferences',
16
+ description: 'List the calling user\'s own personal primer facts (their AI preferences). Privacy-strict - only your own facts, never another user\'s. These load into the project primer for your sessions only when you have enabled the opt-in in Profile -> AI Preferences. Wraps GET /users/me/primer-facts.',
17
+ inputSchema: z.object({}).shape,
18
+ annotations: { readOnlyHint: true, idempotentHint: true },
19
+ };
20
+ export function makePersonalFactListHandler(client) {
21
+ return async () => {
22
+ const facts = await client.get('/users/me/primer-facts');
23
+ const body = facts.length === 0 ? 'No personal preferences set.' : facts.map((f) => `- [${f.category}] ${f.key}: ${f.value}`).join('\n');
24
+ return text(body, { facts });
25
+ };
26
+ }
27
+ export const personalFactAddToolConfig = {
28
+ title: 'Add a personal AI preference',
29
+ description: 'Record a personal primer fact for the calling user (e.g. communication style, coding convention). Owner-scoped. Wraps POST /users/me/primer-facts.',
30
+ inputSchema: z.object({
31
+ category: CATEGORY.describe('Closest category (e.g. conventions for communication/coding style).'),
32
+ key: z.string().min(1).max(100).describe('Short machine-readable key, e.g. "tone".'),
33
+ value: z.string().min(1).max(4000),
34
+ }).shape,
35
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
36
+ };
37
+ export function makePersonalFactAddHandler(client) {
38
+ return async (input) => {
39
+ const row = await client.post('/users/me/primer-facts', input);
40
+ return text(`Added personal preference "${row.key}" (id: ${row.id}).`, { id: row.id });
41
+ };
42
+ }
43
+ export const personalFactUpdateToolConfig = {
44
+ title: 'Update a personal AI preference',
45
+ description: 'Update one of the calling user\'s personal primer facts. Owner-scoped - you can only edit your own. Wraps PATCH /users/me/primer-facts/:id.',
46
+ inputSchema: z.object({
47
+ id: z.string().uuid(),
48
+ category: CATEGORY.optional(),
49
+ key: z.string().min(1).max(100).optional(),
50
+ value: z.string().min(1).max(4000).optional(),
51
+ }).shape,
52
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
53
+ };
54
+ export function makePersonalFactUpdateHandler(client) {
55
+ return async (input) => {
56
+ const { id, ...body } = input;
57
+ const row = await client.patch(`/users/me/primer-facts/${id}`, body);
58
+ return text(`Updated personal preference "${row.key}".`, { id: row.id });
59
+ };
60
+ }
61
+ export const personalFactDeleteToolConfig = {
62
+ title: 'Delete a personal AI preference',
63
+ description: 'Delete one of the calling user\'s personal primer facts. Owner-scoped. Wraps DELETE /users/me/primer-facts/:id.',
64
+ inputSchema: z.object({ id: z.string().uuid() }).shape,
65
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
66
+ };
67
+ export function makePersonalFactDeleteHandler(client) {
68
+ return async (input) => {
69
+ await client.delete(`/users/me/primer-facts/${input.id}`);
70
+ return text('Deleted.', { deleted: true });
71
+ };
72
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ORB-862 - personal-fact MCP tools route to the owner-scoped endpoints.
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { OrbotoClient } from '../orboto-client.js';
6
+ import { makePersonalFactListHandler, makePersonalFactAddHandler, makePersonalFactUpdateHandler, makePersonalFactDeleteHandler, } from './personal-facts.js';
7
+ beforeEach(() => { vi.restoreAllMocks(); });
8
+ afterEach(() => { vi.restoreAllMocks(); });
9
+ function stubJSON(responses) {
10
+ const calls = [];
11
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
12
+ calls.push({ url: url.toString(), method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body });
13
+ const r = responses.shift();
14
+ if (!r)
15
+ throw new Error('unexpected extra fetch');
16
+ return { ok: r.ok ?? true, status: r.status ?? 200, statusText: 'OK', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
17
+ });
18
+ return calls;
19
+ }
20
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
21
+ const ID = 'p0000000-0000-0000-0000-000000000001';
22
+ describe('personal-fact MCP tools (ORB-862)', () => {
23
+ it('list hits GET /users/me/primer-facts', async () => {
24
+ const calls = stubJSON([{ json: [{ id: ID, category: 'conventions', key: 'tone', value: 'terse' }] }]);
25
+ const res = await makePersonalFactListHandler(client)();
26
+ expect(calls[0].url).toBe('https://orboto.example.com/users/me/primer-facts');
27
+ expect(res.content[0].text).toContain('tone: terse');
28
+ });
29
+ it('add POSTs the fact', async () => {
30
+ const calls = stubJSON([{ status: 201, json: { id: ID, key: 'tone' } }]);
31
+ await makePersonalFactAddHandler(client)({ category: 'conventions', key: 'tone', value: 'terse' });
32
+ expect(calls[0]).toMatchObject({ method: 'POST', url: 'https://orboto.example.com/users/me/primer-facts', body: { category: 'conventions', key: 'tone', value: 'terse' } });
33
+ });
34
+ it('update PATCHes by id', async () => {
35
+ const calls = stubJSON([{ json: { id: ID, key: 'tone' } }]);
36
+ await makePersonalFactUpdateHandler(client)({ id: ID, value: 'very terse' });
37
+ expect(calls[0]).toMatchObject({ method: 'PATCH', url: `https://orboto.example.com/users/me/primer-facts/${ID}`, body: { value: 'very terse' } });
38
+ });
39
+ it('delete DELETEs by id', async () => {
40
+ const calls = stubJSON([{ status: 204 }]);
41
+ const res = await makePersonalFactDeleteHandler(client)({ id: ID });
42
+ expect(calls[0]).toMatchObject({ method: 'DELETE', url: `https://orboto.example.com/users/me/primer-facts/${ID}` });
43
+ expect(res.content[0].text).toBe('Deleted.');
44
+ });
45
+ });