@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,115 @@
1
+ /**
2
+ * ORB-799 - `orboto_set_parent` unit tests.
3
+ *
4
+ * Covers: re-parent happy path, detach (parentTicketKey=null),
5
+ * cross-project rejection, self-parent rejection, API cycle rejection.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { OrbotoClient } from '../orboto-client.js';
9
+ import { makeSetParentHandler } from './set-parent.js';
10
+ beforeEach(() => { vi.restoreAllMocks(); });
11
+ afterEach(() => { vi.restoreAllMocks(); });
12
+ function stub(responses) {
13
+ const calls = [];
14
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
15
+ calls.push({
16
+ url: url.toString(),
17
+ method: init?.method ?? 'GET',
18
+ body: init?.body ? JSON.parse(init.body) : undefined,
19
+ });
20
+ const r = responses.shift();
21
+ if (!r)
22
+ throw new Error(`unexpected extra fetch`);
23
+ return {
24
+ ok: r.ok ?? true,
25
+ status: r.status ?? 200,
26
+ statusText: 'OK',
27
+ json: async () => ('json' in r ? r.json : {}),
28
+ text: async () => '',
29
+ };
30
+ });
31
+ return calls;
32
+ }
33
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
34
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
35
+ const TICKET = {
36
+ id: 't1', projectId: 'p1', ticketKey: 'ACME-1', ticketNumber: 1,
37
+ title: 'Child', status: 'TODO', statusName: 'To Do', statusCategory: 'todo',
38
+ type: 'task', priority: 'normal', estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
39
+ };
40
+ const PARENT = {
41
+ ...TICKET, id: 't10', ticketKey: 'ACME-10', ticketNumber: 10, title: 'Parent epic', type: 'epic',
42
+ };
43
+ describe('orboto_set_parent', () => {
44
+ it('happy path: re-parents and PATCHes parentTicketId', async () => {
45
+ const calls = stub([
46
+ { json: PROJ }, // ticket: by-key project
47
+ { json: TICKET }, // ticket: by-key ticket
48
+ { json: PROJ }, // parent: by-key project
49
+ { json: PARENT }, // parent: by-key ticket
50
+ { json: { ...TICKET, parentTicketId: 't10' } }, // PATCH
51
+ ]);
52
+ const res = await makeSetParentHandler(client)({
53
+ ticketKey: 'ACME-1', parentTicketKey: 'ACME-10',
54
+ });
55
+ expect(calls[4]).toMatchObject({
56
+ method: 'PATCH',
57
+ url: 'https://orboto.example.com/projects/p1/tickets/t1',
58
+ body: { parentTicketId: 't10' },
59
+ });
60
+ expect(res.structuredContent).toMatchObject({
61
+ ticketKey: 'ACME-1',
62
+ parentTicketKey: 'ACME-10',
63
+ parentTicketId: 't10',
64
+ });
65
+ });
66
+ it('detach: parentTicketKey=null PATCHes parentTicketId=null', async () => {
67
+ const calls = stub([
68
+ { json: PROJ },
69
+ { json: TICKET },
70
+ { json: { ...TICKET, parentTicketId: null } },
71
+ ]);
72
+ await makeSetParentHandler(client)({
73
+ ticketKey: 'ACME-1', parentTicketKey: null,
74
+ });
75
+ expect(calls[2]).toMatchObject({
76
+ method: 'PATCH',
77
+ body: { parentTicketId: null },
78
+ });
79
+ });
80
+ it('refuses cross-project parenting', async () => {
81
+ const OTHER_PROJ = { ...PROJ, id: 'p2', key: 'OTHER' };
82
+ stub([
83
+ { json: PROJ }, // ACME-1 project
84
+ { json: TICKET }, // ACME-1 ticket (projectId p1)
85
+ { json: OTHER_PROJ }, // OTHER-5 project
86
+ { json: { ...PARENT, projectId: 'p2', ticketKey: 'OTHER-5' } }, // OTHER-5 ticket
87
+ ]);
88
+ await expect(makeSetParentHandler(client)({
89
+ ticketKey: 'ACME-1', parentTicketKey: 'OTHER-5',
90
+ })).rejects.toThrow(/Cross-project parenting/);
91
+ });
92
+ it('refuses self-parenting', async () => {
93
+ stub([
94
+ { json: PROJ },
95
+ { json: TICKET },
96
+ { json: PROJ },
97
+ { json: TICKET }, // same ticket
98
+ ]);
99
+ await expect(makeSetParentHandler(client)({
100
+ ticketKey: 'ACME-1', parentTicketKey: 'ACME-1',
101
+ })).rejects.toThrow(/cannot be its own parent/);
102
+ });
103
+ it('translates a 400 from the PATCH (API cycle-detection) into a clear message', async () => {
104
+ stub([
105
+ { json: PROJ },
106
+ { json: TICKET },
107
+ { json: PROJ },
108
+ { json: PARENT },
109
+ { ok: false, status: 400, json: { error: 'cycle detected' } },
110
+ ]);
111
+ await expect(makeSetParentHandler(client)({
112
+ ticketKey: 'ACME-1', parentTicketKey: 'ACME-10',
113
+ })).rejects.toThrow(/Re-parent rejected by the API/);
114
+ });
115
+ });
@@ -0,0 +1,226 @@
1
+ import { OrbotoApiError } from '../orboto-client.js';
2
+ export async function resolveProjectByKey(client, key) {
3
+ try {
4
+ return await client.get(`/projects/by-key/${encodeURIComponent(key)}`);
5
+ }
6
+ catch (err) {
7
+ if (err instanceof OrbotoApiError && err.status === 404) {
8
+ throw new Error(`Project "${key}" not found (or not visible to your account).`);
9
+ }
10
+ throw err;
11
+ }
12
+ }
13
+ /**
14
+ * ORB-1699 - the ONE agent-facing list-row builder. A list call is a
15
+ * decision aid ("which of these do I open?"); the default row carries
16
+ * exactly what that decision + the filters read back. `verbose: true`
17
+ * restores the full shape (uuid, labels, minutes, ingestion signal).
18
+ * list_tickets, my_tickets and query all consume THIS builder - trimming
19
+ * one tool and leaving the siblings fat is the class-not-instance
20
+ * failure the workspace rules warn about.
21
+ */
22
+ export function agentTicketListRow(t, verbose = false) {
23
+ if (verbose) {
24
+ return {
25
+ // ORB-1179 - uuid for write tools that want it without a lookup.
26
+ id: t.id,
27
+ key: t.ticketKey,
28
+ title: t.title,
29
+ status: t.statusName ?? t.status,
30
+ statusCategory: t.statusCategory ?? null,
31
+ priority: t.priority,
32
+ type: t.type,
33
+ dueDate: t.dueDate ?? null,
34
+ assigneeNames: (t.assignees ?? []).map((a) => a.fullName || a.email),
35
+ labels: (t.labels ?? []).map((l) => l.name),
36
+ estimatedTimeMinutes: t.estimatedTimeMinutes,
37
+ loggedMinutes: t.loggedMinutes ?? 0,
38
+ milestoneName: t.milestoneName ?? null,
39
+ createdAt: t.createdAt ?? null,
40
+ updatedAt: t.updatedAt ?? null,
41
+ ...(t.waitingForGitIngestion ? { waitingForGitIngestion: true } : {}),
42
+ };
43
+ }
44
+ // Lean row: fields at their DEFAULT value are omitted entirely - a
45
+ // reader treats absence as "task / normal / no due date / unassigned".
46
+ // The list is a decision aid; the full picture is one get_ticket away.
47
+ const assigneeNames = (t.assignees ?? []).map((a) => a.fullName || a.email);
48
+ return {
49
+ key: t.ticketKey,
50
+ title: t.title,
51
+ statusCategory: t.statusCategory ?? null,
52
+ ...(t.priority && t.priority !== 'normal' ? { priority: t.priority } : {}),
53
+ ...(t.type && t.type !== 'task' ? { type: t.type } : {}),
54
+ ...(t.dueDate ? { dueDate: t.dueDate } : {}),
55
+ ...(assigneeNames.length > 0 ? { assigneeNames } : {}),
56
+ // ORB-1605 - only present when it fires; absent costs zero chars.
57
+ ...(t.waitingForGitIngestion ? { waitingForGitIngestion: true } : {}),
58
+ };
59
+ }
60
+ /**
61
+ * Resolve a `PROJ-123` ticket key to a fully-hydrated ticket row.
62
+ * Splits on the first `-` - project keys are upper-case alphanumerics
63
+ * (max 20 chars) and never contain `-`, so the split is unambiguous.
64
+ */
65
+ export async function resolveTicketByKey(client, ticketKey) {
66
+ const idx = ticketKey.indexOf('-');
67
+ if (idx <= 0) {
68
+ throw new Error(`Invalid ticket key "${ticketKey}" - expected format "PROJ-123".`);
69
+ }
70
+ const projectKey = ticketKey.slice(0, idx);
71
+ const numberPart = ticketKey.slice(idx + 1);
72
+ const project = await resolveProjectByKey(client, projectKey);
73
+ try {
74
+ return await client.get(`/projects/${project.id}/tickets/by-key/${encodeURIComponent(numberPart)}`);
75
+ }
76
+ catch (err) {
77
+ if (err instanceof OrbotoApiError && err.status === 404) {
78
+ throw new Error(`Ticket "${ticketKey}" not found in project "${project.key}".`);
79
+ }
80
+ throw err;
81
+ }
82
+ }
83
+ /** Shorten a ticket row to the single-line summary used by list tools. */
84
+ export function ticketLine(t) {
85
+ const parts = [];
86
+ if (t.ticketKey)
87
+ parts.push(`[${t.ticketKey}]`);
88
+ parts.push(t.title);
89
+ parts.push(`(${t.statusName ?? t.status})`);
90
+ if (t.priority && t.priority !== 'normal')
91
+ parts.push(`<${t.priority}>`);
92
+ if (t.assignees && t.assignees.length > 0) {
93
+ parts.push(`→ ${t.assignees.map((a) => a.fullName || a.email).join(', ')}`);
94
+ }
95
+ // ORB-1605 - flag a ticket that's genuinely just waiting on stalled
96
+ // commit/PR ingestion, not a ticket someone forgot to close.
97
+ if (t.waitingForGitIngestion)
98
+ parts.push('[waiting on Git ingestion]');
99
+ return parts.join(' ');
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // ORB-1252 / ORB-1283 / ORB-1609 - the agent-instance token.
103
+ //
104
+ // One MCP server process = one agent instance. Every surface that scopes work
105
+ // to an instance (timers, and since ORB-1609 work-session leases) MUST derive
106
+ // the token the same way, or the same agent ends up in two lanes: a claim
107
+ // starting a timer in lane A while its work session holds the lease in lane B
108
+ // is precisely the class of bug ORB-1603 had to paper over.
109
+ //
110
+ // A per-process random UUID rather than the PID: PIDs are recycled by the OS,
111
+ // so a fresh process could inherit a recycled PID and adopt a previous
112
+ // instance's stale timer or lease.
113
+ // ---------------------------------------------------------------------------
114
+ import { randomUUID } from 'node:crypto';
115
+ const MCP_PROCESS_INSTANCE = `mcp-${randomUUID()}`;
116
+ /** Precedence: explicit caller-supplied token > per-connection MCP session id
117
+ * (distinct per client even on a shared HTTP server) > per-process id (stdio). */
118
+ export function mcpInstanceToken(explicit, extra) {
119
+ return explicit ?? (extra?.sessionId ? `mcp-${extra.sessionId}` : MCP_PROCESS_INSTANCE);
120
+ }
121
+ /** ORB-1753 - the caller's self-declared agent profile from the process
122
+ * environment (stdio servers / runners). The api-key standing profile
123
+ * (ORB-1751) already covers keys server-side; these env vars let a
124
+ * keyless or per-process deployment declare without code. Explicit tool
125
+ * inputs override. */
126
+ export function envAgentProfile() {
127
+ const kind = process.env.ORBOTO_AGENT_KIND?.trim().toLowerCase();
128
+ const tier = process.env.ORBOTO_MODEL_TIER?.trim().toLowerCase();
129
+ return { ...(kind ? { agentKind: kind } : {}), ...(tier ? { modelTier: tier } : {}) };
130
+ }
131
+ /** ORB-1753 - append the resolved profile to a querystring. */
132
+ export function applyAgentProfile(params, explicit) {
133
+ const env = envAgentProfile();
134
+ const kind = explicit?.agentKind ?? env.agentKind;
135
+ const tier = explicit?.modelTier ?? env.modelTier;
136
+ if (kind)
137
+ params.set('agentKind', kind);
138
+ if (tier)
139
+ params.set('modelTier', tier);
140
+ }
141
+ export function sizeBlockResult(err, verb) {
142
+ if (!(err instanceof OrbotoApiError) || err.status !== 422)
143
+ return null;
144
+ let parsed = {};
145
+ try {
146
+ parsed = JSON.parse(err.body);
147
+ }
148
+ catch { /* non-JSON body */ }
149
+ if (!parsed.sizeWarning)
150
+ return null;
151
+ const sw = parsed.sizeWarning;
152
+ const text = `⛔ ${verb} blocked - content is ${sw.chars} characters, over the ${sw.limit}-character hard limit.\n` +
153
+ `${sw.hint}\n` +
154
+ `Only if you are sure the length is genuinely necessary, retry the same call with allowOversize=true and an oversizeReason (10+ characters explaining why).`;
155
+ return {
156
+ content: [{ type: 'text', text }],
157
+ structuredContent: { blocked: true, sizeWarning: sw },
158
+ isError: true,
159
+ };
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // ORB-1826 - shared name-matching normaliser for free-text milestone /
163
+ // label / status resolvers. An agent surface can hand back a name that
164
+ // differs from the canonical DB row only in mechanically-recoverable ways:
165
+ // leftover HTML entities from a rendering step upstream of the agent
166
+ // (`QA &amp; Testing`), a different case, or extra/collapsed whitespace.
167
+ // The workspace rule is normalise, never reject - if the canonical form is
168
+ // mechanically derivable, derive it instead of rejecting the call.
169
+ //
170
+ // Resolution order for every call site that uses `resolveByName`: raw exact
171
+ // match first (zero-cost, preserves today's behaviour for clean names),
172
+ // then a UNIQUE normalised match, then the existing ambiguity error listing
173
+ // the candidates.
174
+ //
175
+ // Twin: `apps/api/src/lib/name-normalize.ts` (same two functions, same
176
+ // semantics). This package ships standalone to npm with a deliberately
177
+ // minimal dependency set (MCP SDK + zod only) and does not depend on the
178
+ // API workspace package, so the ~20 lines below are duplicated rather than
179
+ // imported. The Go CLI (`cli/internal/cmd/normalize.go`) carries a third
180
+ // copy for the same reason. Keep all three in sync.
181
+ // ---------------------------------------------------------------------------
182
+ const NAMED_HTML_ENTITIES = {
183
+ amp: '&',
184
+ lt: '<',
185
+ gt: '>',
186
+ quot: '"',
187
+ apos: "'",
188
+ };
189
+ /** Decode the five XML-safe named entities plus numeric character
190
+ * references (`&#38;`, `&#x26;`). Intentionally NOT a general HTML
191
+ * decoder - just enough to undo a stray HTML-escaping step. */
192
+ export function decodeHtmlEntities(input) {
193
+ return input.replace(/&(#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
194
+ if (entity[0] === '#') {
195
+ const isHex = entity[1] === 'x' || entity[1] === 'X';
196
+ const codePoint = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
197
+ return Number.isFinite(codePoint) && codePoint > 0 ? String.fromCodePoint(codePoint) : match;
198
+ }
199
+ const decoded = NAMED_HTML_ENTITIES[entity.toLowerCase()];
200
+ return decoded ?? match;
201
+ });
202
+ }
203
+ /** Canonical comparison key for a free-text name: HTML-entity-decoded,
204
+ * trimmed, internal whitespace collapsed to a single space, casefolded. */
205
+ export function normalizeName(input) {
206
+ return decodeHtmlEntities(input).trim().replace(/\s+/g, ' ').toLowerCase();
207
+ }
208
+ /**
209
+ * Resolve `ref` against `candidates` by name: exact (raw) match wins
210
+ * first; falls back to a unique normalised match. `ambiguous` is set
211
+ * whenever more than one candidate ties at either stage.
212
+ */
213
+ export function resolveByName(candidates, ref, getName) {
214
+ const exactMatches = candidates.filter((c) => getName(c) === ref);
215
+ if (exactMatches.length === 1)
216
+ return { match: exactMatches[0], ambiguous: null };
217
+ if (exactMatches.length > 1)
218
+ return { match: null, ambiguous: exactMatches };
219
+ const normalizedRef = normalizeName(ref);
220
+ const normMatches = candidates.filter((c) => normalizeName(getName(c)) === normalizedRef);
221
+ if (normMatches.length === 1)
222
+ return { match: normMatches[0], ambiguous: null };
223
+ if (normMatches.length > 1)
224
+ return { match: null, ambiguous: normMatches };
225
+ return { match: null, ambiguous: null };
226
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * ORB-244 Phase B - key-resolver helpers.
3
+ *
4
+ * The two-step ticket resolution (project key → project UUID →
5
+ * ticket) is the most reused code path in the tool suite, so
6
+ * exercising the edge cases here beats duplicating them in every
7
+ * tool test.
8
+ */
9
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { OrbotoClient } from '../orboto-client.js';
11
+ import { resolveProjectByKey, resolveTicketByKey, ticketLine, normalizeName, resolveByName } from './shared.js';
12
+ beforeEach(() => { vi.restoreAllMocks(); });
13
+ afterEach(() => { vi.restoreAllMocks(); });
14
+ function sequence(responses) {
15
+ const calls = [];
16
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
17
+ calls.push(url.toString());
18
+ const r = responses.shift();
19
+ if (!r)
20
+ throw new Error('unexpected extra fetch');
21
+ return {
22
+ ok: r.ok ?? true,
23
+ status: r.status ?? 200,
24
+ statusText: 'OK',
25
+ json: async () => r.json ?? {},
26
+ text: async () => r.text ?? '',
27
+ };
28
+ });
29
+ return calls;
30
+ }
31
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
32
+ describe('resolveProjectByKey', () => {
33
+ it('returns the project on success', async () => {
34
+ sequence([{ json: { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' } }]);
35
+ const p = await resolveProjectByKey(client, 'acme');
36
+ expect(p.key).toBe('ACME');
37
+ });
38
+ it('rewrites a 404 into a helpful message', async () => {
39
+ sequence([{ ok: false, status: 404, text: '{"error":"Project not found"}' }]);
40
+ await expect(resolveProjectByKey(client, 'NOPE')).rejects.toThrow(/Project "NOPE" not found/);
41
+ });
42
+ it('URL-encodes the key', async () => {
43
+ const calls = sequence([{ json: { id: 'p1', key: 'AB CD', name: 'x', description: null, status: 'active' } }]);
44
+ await resolveProjectByKey(client, 'AB CD');
45
+ expect(calls[0]).toBe('https://orboto.example.com/projects/by-key/AB%20CD');
46
+ });
47
+ });
48
+ describe('resolveTicketByKey', () => {
49
+ it('splits on the first dash, resolves project then ticket', async () => {
50
+ const calls = sequence([
51
+ { json: { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' } },
52
+ { json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-42', title: 'Fix it' } },
53
+ ]);
54
+ const t = await resolveTicketByKey(client, 'ACME-42');
55
+ expect(t.ticketKey).toBe('ACME-42');
56
+ expect(calls[0]).toContain('/projects/by-key/ACME');
57
+ expect(calls[1]).toContain('/projects/p1/tickets/by-key/42');
58
+ });
59
+ it('rejects malformed keys (no dash)', async () => {
60
+ await expect(resolveTicketByKey(client, 'PLAIN')).rejects.toThrow(/expected format "PROJ-123"/);
61
+ });
62
+ it('rewrites a 404 into a ticket-scoped message', async () => {
63
+ sequence([
64
+ { json: { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' } },
65
+ { ok: false, status: 404, text: 'not found' },
66
+ ]);
67
+ await expect(resolveTicketByKey(client, 'ACME-999')).rejects.toThrow(/Ticket "ACME-999" not found in project "ACME"/);
68
+ });
69
+ });
70
+ describe('ticketLine', () => {
71
+ it('renders key, title, status, priority, assignees', () => {
72
+ const line = ticketLine({
73
+ id: 't1', projectId: 'p1', milestoneId: null,
74
+ ticketKey: 'ACME-1', ticketNumber: 1,
75
+ title: 'Login broken',
76
+ status: 'IN_PROGRESS', statusName: 'In Progress',
77
+ type: 'bug', priority: 'high',
78
+ estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
79
+ assignees: [{ id: 'u1', email: 'a@b.c', fullName: 'Ada Lovelace' }],
80
+ });
81
+ expect(line).toBe('[ACME-1] Login broken (In Progress) <high> → Ada Lovelace');
82
+ });
83
+ it('omits priority marker when normal, omits assignee clause when none', () => {
84
+ const line = ticketLine({
85
+ id: 't1', projectId: 'p1', milestoneId: null,
86
+ ticketKey: 'ACME-2', ticketNumber: 2,
87
+ title: 'Docs update',
88
+ status: 'TODO', statusName: 'To Do',
89
+ type: 'task', priority: 'normal',
90
+ estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
91
+ });
92
+ expect(line).toBe('[ACME-2] Docs update (To Do)');
93
+ });
94
+ // ORB-1605 - flags the stalled-ingestion signal in the one-line summary.
95
+ it('appends the waiting-on-ingestion marker when waitingForGitIngestion is true', () => {
96
+ const line = ticketLine({
97
+ id: 't1', projectId: 'p1', milestoneId: null,
98
+ ticketKey: 'ACME-3', ticketNumber: 3,
99
+ title: 'Docs change',
100
+ status: 'IN_REVIEW', statusName: 'In Review',
101
+ type: 'task', priority: 'normal',
102
+ estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
103
+ waitingForGitIngestion: true,
104
+ });
105
+ expect(line).toBe('[ACME-3] Docs change (In Review) [waiting on Git ingestion]');
106
+ });
107
+ it('omits the marker when waitingForGitIngestion is false or absent', () => {
108
+ const line = ticketLine({
109
+ id: 't1', projectId: 'p1', milestoneId: null,
110
+ ticketKey: 'ACME-4', ticketNumber: 4,
111
+ title: 'Docs change',
112
+ status: 'IN_REVIEW', statusName: 'In Review',
113
+ type: 'task', priority: 'normal',
114
+ estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
115
+ waitingForGitIngestion: false,
116
+ });
117
+ expect(line).toBe('[ACME-4] Docs change (In Review)');
118
+ });
119
+ });
120
+ // ORB-1826 - production MCP error log showed `Milestone "QA &amp; Testing"
121
+ // not found` for a milestone literally named "QA & Testing": some upstream
122
+ // surface handed the agent an HTML-escaped name. "Normalise, never reject."
123
+ describe('normalizeName', () => {
124
+ it('decodes the five XML-safe named entities', () => {
125
+ expect(normalizeName('QA &amp; Testing')).toBe('qa & testing');
126
+ expect(normalizeName('A &lt;b&gt; &quot;C&quot; &apos;D&apos;')).toBe(`a <b> "c" 'd'`);
127
+ });
128
+ it('decodes decimal and hex numeric character references', () => {
129
+ expect(normalizeName('Caf&#233;')).toBe('café');
130
+ expect(normalizeName('Caf&#xe9;')).toBe('café');
131
+ });
132
+ it('trims and collapses internal whitespace', () => {
133
+ expect(normalizeName(' QA & Testing ')).toBe('qa & testing');
134
+ });
135
+ it('leaves an unrecognised entity-shaped sequence untouched (case-folded)', () => {
136
+ expect(normalizeName('R&D Sprint')).toBe('r&d sprint');
137
+ });
138
+ });
139
+ describe('resolveByName', () => {
140
+ const rows = [
141
+ { id: 'm1', name: 'QA & Testing' },
142
+ { id: 'm2', name: 'Launch Readiness' },
143
+ ];
144
+ it('matches the raw exact name with zero normalisation', () => {
145
+ const { match, ambiguous } = resolveByName(rows, 'QA & Testing', (r) => r.name);
146
+ expect(match?.id).toBe('m1');
147
+ expect(ambiguous).toBeNull();
148
+ });
149
+ it('falls back to a unique normalised match for an HTML-escaped name', () => {
150
+ const { match, ambiguous } = resolveByName(rows, 'QA &amp; Testing', (r) => r.name);
151
+ expect(match?.id).toBe('m1');
152
+ expect(ambiguous).toBeNull();
153
+ });
154
+ it('falls back to a unique normalised match for case/whitespace variants', () => {
155
+ expect(resolveByName(rows, 'qa & testing', (r) => r.name).match?.id).toBe('m1');
156
+ expect(resolveByName(rows, ' QA & Testing ', (r) => r.name).match?.id).toBe('m1');
157
+ });
158
+ it('returns no match for an unknown name', () => {
159
+ const { match, ambiguous } = resolveByName(rows, 'Nonexistent', (r) => r.name);
160
+ expect(match).toBeNull();
161
+ expect(ambiguous).toBeNull();
162
+ });
163
+ it('reports ambiguity when two names normalise to the same key', () => {
164
+ const dup = [...rows, { id: 'm3', name: 'qa &amp; testing' }];
165
+ const { match, ambiguous } = resolveByName(dup, 'QA &amp; Testing', (r) => r.name);
166
+ expect(match).toBeNull();
167
+ expect(ambiguous?.map((r) => r.id).sort()).toEqual(['m1', 'm3']);
168
+ });
169
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * ORB-1693 - the agent-facing projection of a duplicate-warning entry.
3
+ *
4
+ * Measured: `similarWarnings` was 76% of the entire create_ticket response
5
+ * volume (~329k tokens over the corpus), and most of each entry was
6
+ * decision-irrelevant: a CSS hex colour, a UUID next to the key, a
7
+ * redundant statusName next to statusCategory, and similarity at 15
8
+ * decimal places. The agent decision needs exactly:
9
+ *
10
+ * { ticketKey, title, statusCategory, similarity (2dp), relation }
11
+ *
12
+ * The rich shape stays on the REST response for the web UI - this trims
13
+ * only what agent surfaces emit. Same projection everywhere a warning
14
+ * reaches an agent: create_ticket, the duplicate-block 409, check_similar
15
+ * (mirrored in the chat registry + skill wrapper - keep them in sync).
16
+ */
17
+ export function trimSimilarEntry(w) {
18
+ return {
19
+ // Keys over UUIDs (repo rule); the short-id fallback only exists for
20
+ // pre-backfill rows that never got a key.
21
+ ticketKey: w.ticketKey ?? w.id.slice(0, 8),
22
+ title: w.title,
23
+ statusCategory: w.statusCategory ?? null,
24
+ similarity: Math.round(w.similarity * 100) / 100,
25
+ relation: w.relation ?? null,
26
+ };
27
+ }
28
+ export function trimSimilarEntries(list) {
29
+ return (list ?? []).map(trimSimilarEntry);
30
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ORB-1455 - read + view ticket attachments from MCP.
3
+ *
4
+ * Every agent surface could WRITE attachments (orboto_attach_to_ticket) but
5
+ * none could READ them: an agent working a ticket could not tell the ticket
6
+ * HAD files, let alone SEE an attached screenshot. These two tools close that
7
+ * hole (orboto_get_ticket also gains an `attachments` array):
8
+ *
9
+ * - orboto_list_ticket_attachments - thin wrapper on the ticket-attachment
10
+ * list route; mirrors orboto_list_doc_attachments (ORB-914).
11
+ * - orboto_get_attachment - fetch an attachment's bytes via the new
12
+ * authenticated base64 route and return them to the model:
13
+ * * images (image/*) -> an MCP image content block so the model
14
+ * actually VIEWS the screenshot, plus a text
15
+ * line with filename/size.
16
+ * * everything else -> a text block with metadata + the base64
17
+ * (small files) or a pointer to the skill
18
+ * download shortcut (large files).
19
+ * Works for ticket/doc/comment attachments - the id is global.
20
+ */
21
+ import { z } from 'zod';
22
+ import { resolveTicketByKey } from './shared.js';
23
+ /** Max bytes we inline into a tool result. Screenshots are well under this;
24
+ * anything larger is pointed at the skill download shortcut so we don't
25
+ * bloat the model's context with a multi-MB base64 blob. */
26
+ const MAX_INLINE_BYTES = 5 * 1024 * 1024;
27
+ function isImage(mimetype) {
28
+ return mimetype.startsWith('image/');
29
+ }
30
+ // ---------------------------------------------------------------------------
31
+ // orboto_list_ticket_attachments
32
+ // ---------------------------------------------------------------------------
33
+ export const listTicketAttachmentsToolConfig = {
34
+ title: 'List attachments on a ticket',
35
+ description: 'Return a ticket\'s attachments newest-first, with each attachment\'s id, filename, content type, size, and stable download URL. Use the returned id with orboto_get_attachment to actually view an image or fetch the bytes. Empty list = no attachments. Input is the ticket key like "ACME-42".',
36
+ inputSchema: z.object({
37
+ ticketKey: z.string().min(3).describe('Ticket key like "ACME-42".'),
38
+ }).shape,
39
+ annotations: { readOnlyHint: true, idempotentHint: true },
40
+ };
41
+ export function makeListTicketAttachmentsHandler(client) {
42
+ return async ({ ticketKey }) => {
43
+ const ticket = await resolveTicketByKey(client, ticketKey);
44
+ const rows = await client.get(`/tickets/${ticket.id}/attachments`);
45
+ if (rows.length === 0) {
46
+ return {
47
+ content: [{ type: 'text', text: `No attachments on [${ticket.ticketKey}].` }],
48
+ structuredContent: { ticketKey: ticket.ticketKey, attachments: [] },
49
+ };
50
+ }
51
+ const lines = rows.map((r) => {
52
+ const url = r.downloadUrl ?? `/attachments/${r.id}`;
53
+ const kb = Math.round(r.sizeBytes / 1024);
54
+ return `- ${r.filename} (${kb} KB, ${r.contentType}) id=${r.id} → ${url}`;
55
+ });
56
+ return {
57
+ content: [{
58
+ type: 'text',
59
+ text: `Attachments on [${ticket.ticketKey}]:\n${lines.join('\n')}\n\nUse orboto_get_attachment with an id to view an image or fetch bytes.`,
60
+ }],
61
+ structuredContent: {
62
+ ticketKey: ticket.ticketKey,
63
+ attachments: rows.map((r) => ({
64
+ id: r.id,
65
+ filename: r.filename,
66
+ contentType: r.contentType,
67
+ sizeBytes: r.sizeBytes,
68
+ uploadedAt: r.uploadedAt ?? null,
69
+ uploadedBy: r.uploadedBy ?? null,
70
+ downloadUrl: r.downloadUrl ?? `/attachments/${r.id}`,
71
+ })),
72
+ },
73
+ };
74
+ };
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // orboto_get_attachment
78
+ // ---------------------------------------------------------------------------
79
+ export const getAttachmentToolConfig = {
80
+ title: 'View or fetch an attachment\'s bytes',
81
+ description: 'Fetch a single attachment by its id (from orboto_list_ticket_attachments, orboto_get_ticket, or orboto_list_doc_attachments) and return its content. For an image, the model receives an image content block so it actually SEES the screenshot, plus a text line with filename and size. For a non-image, the model gets a text block with metadata and, for small files, the base64 content; large binaries point at the skill\'s download-attachment shortcut. Works for ticket, doc, and comment attachments (the id is global). Enforces the same project/space access check as listing the attachment.',
82
+ inputSchema: z.object({
83
+ attachmentId: z.string().uuid().describe('Attachment UUID from a list/get call.'),
84
+ }).shape,
85
+ annotations: { readOnlyHint: true, idempotentHint: true },
86
+ };
87
+ export function makeGetAttachmentHandler(client) {
88
+ return async ({ attachmentId }) => {
89
+ const att = await client.get(`/attachments/${attachmentId}/base64`);
90
+ const kb = Math.round(att.sizeBytes / 1024);
91
+ // Over the inline cap: refuse to spill a multi-MB blob into the model's
92
+ // context. Point at the skill download shortcut, which streams to disk.
93
+ if (att.sizeBytes > MAX_INLINE_BYTES) {
94
+ return {
95
+ content: [{
96
+ type: 'text',
97
+ text: `${att.filename} (${kb} KB, ${att.contentType}) is larger than the ${Math.round(MAX_INLINE_BYTES / (1024 * 1024))} MB inline limit. Download it with the skill shortcut: \`orboto.mjs download-attachment ${att.id}\`.`,
98
+ }],
99
+ structuredContent: {
100
+ id: att.id, filename: att.filename, contentType: att.contentType, sizeBytes: att.sizeBytes, inlined: false,
101
+ },
102
+ };
103
+ }
104
+ if (isImage(att.contentType)) {
105
+ return {
106
+ content: [
107
+ { type: 'text', text: `${att.filename} (${kb} KB, ${att.contentType})` },
108
+ { type: 'image', data: att.contentBase64, mimeType: att.contentType },
109
+ ],
110
+ structuredContent: {
111
+ id: att.id, filename: att.filename, contentType: att.contentType, sizeBytes: att.sizeBytes, inlined: true,
112
+ },
113
+ };
114
+ }
115
+ // Non-image: return metadata + the base64 so an agent can decode / save it.
116
+ return {
117
+ content: [{
118
+ type: 'text',
119
+ text: `${att.filename} (${kb} KB, ${att.contentType})\nbase64:\n${att.contentBase64}`,
120
+ }],
121
+ structuredContent: {
122
+ id: att.id,
123
+ filename: att.filename,
124
+ contentType: att.contentType,
125
+ sizeBytes: att.sizeBytes,
126
+ inlined: true,
127
+ contentBase64: att.contentBase64,
128
+ },
129
+ };
130
+ };
131
+ }