@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,124 @@
1
+ // SPDX-License-Identifier: Orboto-Enterprise-1.0
2
+ /**
3
+ * ORB-632 / ORB-945 - Cross-Project Linking MCP tools.
4
+ *
5
+ * - orboto_list_cross_project_links - GET /tickets/:id/cross-project-links
6
+ * - orboto_add_cross_project_link - POST /tickets/:id/cross-project-links
7
+ * - orboto_update_cross_project_link - PATCH /tickets/:id/cross-project-links/:linkId
8
+ * - orboto_remove_cross_project_link - DELETE /tickets/:id/cross-project-links/:linkId
9
+ *
10
+ * Business-tier feature (`.ee.*`). The API records the EE soft-warn
11
+ * event on mutating calls; the tool surface here is identical to
12
+ * every other MCP tool and reads the existing license-state via the
13
+ * Authorization-header path.
14
+ *
15
+ * Accepts BOTH a UUID and a ticket-key (`OCP-42`, case-insensitive) on
16
+ * every input - the API resolves either form.
17
+ */
18
+ import { z } from 'zod';
19
+ const RELATION_TYPES = ['counterpart', 'depends_on', 'blocks', 'related'];
20
+ // ---------------------------------------------------------------------------
21
+ // orboto_list_cross_project_links
22
+ // ---------------------------------------------------------------------------
23
+ export const listCrossProjectLinksToolConfig = {
24
+ title: 'List cross-project links on a ticket',
25
+ description: 'Return every cross-project link touching the ticket - both outgoing (this → other) and incoming (other → this). Each row carries the other end\'s project key, ticket key, title, and current status, so the model can see the linked work\'s state without a follow-up tool call. ACL-filtered: rows whose other-end project the caller cannot read are dropped silently.',
26
+ inputSchema: z.object({
27
+ ticketKey: z.string().min(3).describe('Ticket key (e.g. "ORB-42") or UUID.'),
28
+ }).shape,
29
+ annotations: { readOnlyHint: true, idempotentHint: true },
30
+ };
31
+ export function makeListCrossProjectLinksHandler(client) {
32
+ return async ({ ticketKey }) => {
33
+ const rows = await client.get(`/tickets/${encodeURIComponent(ticketKey)}/cross-project-links`);
34
+ if (rows.length === 0) {
35
+ return {
36
+ content: [{ type: 'text', text: `No cross-project links on ${ticketKey}.` }],
37
+ structuredContent: { links: [] },
38
+ };
39
+ }
40
+ const lines = rows.map((r) => {
41
+ const arrow = r.direction === 'outgoing' ? '→' : '←';
42
+ const key = r.otherEnd.ticketKey ?? r.otherEnd.ticketId.slice(0, 8);
43
+ const status = r.otherEnd.statusName ? ` [${r.otherEnd.statusName}]` : '';
44
+ const sync = r.link.statusSyncEnabled ? ' (sync)' : '';
45
+ return ` ${arrow} ${r.link.relationType}${sync}: [${key}] ${r.otherEnd.title}${status}`;
46
+ });
47
+ return {
48
+ content: [{ type: 'text', text: `Cross-project links on ${ticketKey}:\n${lines.join('\n')}` }],
49
+ structuredContent: { links: rows },
50
+ };
51
+ };
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // orboto_add_cross_project_link
55
+ // ---------------------------------------------------------------------------
56
+ export const addCrossProjectLinkToolConfig = {
57
+ title: 'Add a cross-project link between two tickets',
58
+ description: 'Create a formal relation between two tickets in different projects. Caller must be a member of BOTH the source and target projects. Relation types: counterpart (parallel work in two repos - bidirectional), depends_on (this ticket waits for the other), blocks (the other waits for this), related (loose association). Pass statusSyncEnabled=true to opt into auto-close: when a counterpart-linked ticket moves to done, the other auto-closes too. Off by default.',
59
+ inputSchema: z.object({
60
+ sourceTicketKey: z.string().min(3),
61
+ targetTicketKey: z.string().min(3),
62
+ relationType: z.enum(RELATION_TYPES),
63
+ statusSyncEnabled: z.boolean().optional().default(false),
64
+ }).shape,
65
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
66
+ };
67
+ export function makeAddCrossProjectLinkHandler(client) {
68
+ return async ({ sourceTicketKey, targetTicketKey, relationType, statusSyncEnabled }) => {
69
+ const row = await client.post(`/tickets/${encodeURIComponent(sourceTicketKey)}/cross-project-links`, { targetTicketKey, relationType, statusSyncEnabled: statusSyncEnabled ?? false });
70
+ return {
71
+ content: [{
72
+ type: 'text',
73
+ text: `Linked ${sourceTicketKey} → ${targetTicketKey} (${relationType}${row.statusSyncEnabled ? ', sync enabled' : ''}).`,
74
+ }],
75
+ structuredContent: { id: row.id, sourceTicketId: row.sourceTicketId, targetTicketId: row.targetTicketId, relationType: row.relationType, statusSyncEnabled: row.statusSyncEnabled },
76
+ };
77
+ };
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // orboto_update_cross_project_link
81
+ // ---------------------------------------------------------------------------
82
+ export const updateCrossProjectLinkToolConfig = {
83
+ title: 'Toggle status-sync on a cross-project link',
84
+ description: 'Flip the statusSyncEnabled flag on an existing cross-project link. When enabled on a counterpart-typed link, the other end auto-closes when this one moves to done (and vice versa). Other relation types ignore the flag at runtime, so toggling it on them is a no-op as far as status-sync goes.',
85
+ inputSchema: z.object({
86
+ sourceTicketKey: z.string().min(3),
87
+ linkId: z.string().uuid(),
88
+ statusSyncEnabled: z.boolean(),
89
+ }).shape,
90
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
91
+ };
92
+ export function makeUpdateCrossProjectLinkHandler(client) {
93
+ return async ({ sourceTicketKey, linkId, statusSyncEnabled }) => {
94
+ const row = await client.patch(`/tickets/${encodeURIComponent(sourceTicketKey)}/cross-project-links/${linkId}`, { statusSyncEnabled });
95
+ return {
96
+ content: [{
97
+ type: 'text',
98
+ text: `Link ${linkId} status-sync ${row.statusSyncEnabled ? 'enabled' : 'disabled'}.`,
99
+ }],
100
+ structuredContent: { id: row.id, statusSyncEnabled: row.statusSyncEnabled },
101
+ };
102
+ };
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // orboto_remove_cross_project_link
106
+ // ---------------------------------------------------------------------------
107
+ export const removeCrossProjectLinkToolConfig = {
108
+ title: 'Remove a cross-project link',
109
+ description: 'DESTRUCTIVE - drops the cross-project link between two tickets. Caller must be a member of both source and target projects (or super-admin). The underlying tickets are unaffected; only the relation row is deleted.',
110
+ inputSchema: z.object({
111
+ sourceTicketKey: z.string().min(3),
112
+ linkId: z.string().uuid(),
113
+ }).shape,
114
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
115
+ };
116
+ export function makeRemoveCrossProjectLinkHandler(client) {
117
+ return async ({ sourceTicketKey, linkId }) => {
118
+ await client.delete(`/tickets/${encodeURIComponent(sourceTicketKey)}/cross-project-links/${linkId}`);
119
+ return {
120
+ content: [{ type: 'text', text: `Removed cross-project link ${linkId} from ${sourceTicketKey}.` }],
121
+ structuredContent: { sourceTicketKey, linkId, deleted: true },
122
+ };
123
+ };
124
+ }
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: Orboto-Enterprise-1.0
2
+ /**
3
+ * ORB-945 - cross-project-link MCP tools.
4
+ *
5
+ * - list: renders outgoing + incoming with arrow + sync marker, empty-state
6
+ * - add: POSTs the right body, surfaces sync-on hint
7
+ * - update: PATCHes statusSyncEnabled
8
+ * - remove: DELETEs the row, bubbles 403 on forbidden
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
12
+ import { makeListCrossProjectLinksHandler, makeAddCrossProjectLinkHandler, makeUpdateCrossProjectLinkHandler, makeRemoveCrossProjectLinkHandler, } from './cross-project-links.ee.js';
13
+ beforeEach(() => { vi.restoreAllMocks(); });
14
+ afterEach(() => { vi.restoreAllMocks(); });
15
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
16
+ const LINK_ID = '11111111-2222-3333-4444-555555555555';
17
+ function stubJSON(responses) {
18
+ const calls = [];
19
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
20
+ calls.push({
21
+ url: url.toString(),
22
+ method: init?.method ?? 'GET',
23
+ body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body,
24
+ });
25
+ const r = responses.shift();
26
+ if (!r)
27
+ throw new Error('unexpected extra fetch');
28
+ return {
29
+ ok: r.ok ?? true,
30
+ status: r.status ?? 200,
31
+ statusText: 'OK',
32
+ json: async () => ('json' in r ? r.json : {}),
33
+ text: async () => '',
34
+ };
35
+ });
36
+ return calls;
37
+ }
38
+ const OUTGOING_LINK = {
39
+ direction: 'outgoing',
40
+ link: {
41
+ id: LINK_ID,
42
+ sourceTicketId: 'src-1',
43
+ targetTicketId: 'tgt-1',
44
+ relationType: 'counterpart',
45
+ statusSyncEnabled: true,
46
+ createdBy: 'u1',
47
+ createdAt: '2026-05-20T16:00:00.000Z',
48
+ },
49
+ otherEnd: {
50
+ ticketId: 'tgt-1',
51
+ ticketKey: 'OCP-7',
52
+ title: 'OCP-side counterpart',
53
+ statusName: 'In Progress',
54
+ statusColor: '#3b82f6',
55
+ statusCategory: 'in_progress',
56
+ projectId: 'p-ocp',
57
+ projectKey: 'OCP',
58
+ projectName: 'orboto Control Plane',
59
+ },
60
+ };
61
+ describe('orboto_list_cross_project_links', () => {
62
+ it('renders outgoing + sync marker + status', async () => {
63
+ stubJSON([{ json: [OUTGOING_LINK] }]);
64
+ const res = await makeListCrossProjectLinksHandler(client)({ ticketKey: 'ORB-42' });
65
+ const text = res.content[0].text;
66
+ expect(text).toContain('counterpart');
67
+ expect(text).toContain('(sync)');
68
+ expect(text).toContain('OCP-side counterpart');
69
+ expect(text).toContain('[In Progress]');
70
+ expect(text).toContain('→');
71
+ });
72
+ it('reports the empty-links case', async () => {
73
+ stubJSON([{ json: [] }]);
74
+ const res = await makeListCrossProjectLinksHandler(client)({ ticketKey: 'ORB-42' });
75
+ expect(res.content[0].text).toContain('No cross-project links');
76
+ });
77
+ });
78
+ describe('orboto_add_cross_project_link', () => {
79
+ it('POSTs the source path + body', async () => {
80
+ const calls = stubJSON([{
81
+ status: 201,
82
+ json: {
83
+ id: LINK_ID,
84
+ sourceTicketId: 'src-1',
85
+ targetTicketId: 'tgt-1',
86
+ relationType: 'counterpart',
87
+ statusSyncEnabled: true,
88
+ createdBy: 'u1',
89
+ createdAt: '2026-05-20T16:00:00.000Z',
90
+ },
91
+ }]);
92
+ await makeAddCrossProjectLinkHandler(client)({
93
+ sourceTicketKey: 'ORB-42',
94
+ targetTicketKey: 'OCP-7',
95
+ relationType: 'counterpart',
96
+ statusSyncEnabled: true,
97
+ });
98
+ expect(calls[0]).toMatchObject({
99
+ method: 'POST',
100
+ url: 'https://orboto.example.com/tickets/ORB-42/cross-project-links',
101
+ body: { targetTicketKey: 'OCP-7', relationType: 'counterpart', statusSyncEnabled: true },
102
+ });
103
+ });
104
+ it('bubbles up 403 when caller is not a member of both projects', async () => {
105
+ stubJSON([{ ok: false, status: 403, json: { error: 'You must be a member of both projects' } }]);
106
+ await expect(makeAddCrossProjectLinkHandler(client)({
107
+ sourceTicketKey: 'ORB-42',
108
+ targetTicketKey: 'OCP-7',
109
+ relationType: 'related',
110
+ })).rejects.toBeInstanceOf(OrbotoApiError);
111
+ });
112
+ it('bubbles up 409 on duplicate', async () => {
113
+ stubJSON([{ ok: false, status: 409, json: { error: 'This link already exists' } }]);
114
+ await expect(makeAddCrossProjectLinkHandler(client)({
115
+ sourceTicketKey: 'ORB-42',
116
+ targetTicketKey: 'OCP-7',
117
+ relationType: 'counterpart',
118
+ })).rejects.toBeInstanceOf(OrbotoApiError);
119
+ });
120
+ });
121
+ describe('orboto_update_cross_project_link', () => {
122
+ it('PATCHes statusSyncEnabled on the linkId', async () => {
123
+ const calls = stubJSON([{
124
+ json: {
125
+ id: LINK_ID,
126
+ sourceTicketId: 'src-1',
127
+ targetTicketId: 'tgt-1',
128
+ relationType: 'counterpart',
129
+ statusSyncEnabled: false,
130
+ createdBy: 'u1',
131
+ createdAt: '2026-05-20T16:00:00.000Z',
132
+ },
133
+ }]);
134
+ await makeUpdateCrossProjectLinkHandler(client)({
135
+ sourceTicketKey: 'ORB-42',
136
+ linkId: LINK_ID,
137
+ statusSyncEnabled: false,
138
+ });
139
+ expect(calls[0]).toMatchObject({
140
+ method: 'PATCH',
141
+ url: `https://orboto.example.com/tickets/ORB-42/cross-project-links/${LINK_ID}`,
142
+ body: { statusSyncEnabled: false },
143
+ });
144
+ });
145
+ });
146
+ describe('orboto_remove_cross_project_link', () => {
147
+ it('DELETEs the linkId', async () => {
148
+ const calls = stubJSON([{ status: 204 }]);
149
+ const res = await makeRemoveCrossProjectLinkHandler(client)({
150
+ sourceTicketKey: 'ORB-42',
151
+ linkId: LINK_ID,
152
+ });
153
+ expect(calls[0]).toMatchObject({
154
+ method: 'DELETE',
155
+ url: `https://orboto.example.com/tickets/ORB-42/cross-project-links/${LINK_ID}`,
156
+ });
157
+ expect(res.structuredContent).toMatchObject({ deleted: true });
158
+ });
159
+ it('bubbles up 403 when caller can\'t access both ends', async () => {
160
+ stubJSON([{ ok: false, status: 403, json: { error: 'Forbidden' } }]);
161
+ await expect(makeRemoveCrossProjectLinkHandler(client)({ sourceTicketKey: 'ORB-42', linkId: LINK_ID })).rejects.toBeInstanceOf(OrbotoApiError);
162
+ });
163
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * ORB-1383 (epic ORB-1382) - `orboto_customer_report`.
3
+ *
4
+ * Generates the customer-facing project report as Markdown. Two presets:
5
+ * `scope` (proposal: milestones + epics, no progress) and `status` (all
6
+ * non-private tickets + progress). Private tickets/milestones are always
7
+ * excluded server-side. Wraps `POST /projects/:id/customer-report/generate`
8
+ * with `format: 'markdown'`. Money price mode needs budget:view on top of
9
+ * customer_report:generate (the API returns 403 otherwise).
10
+ */
11
+ import { z } from 'zod';
12
+ import { OrbotoApiError } from '../orboto-client.js';
13
+ import { resolveProjectByKey } from './shared.js';
14
+ const LOCALES = ['en', 'de', 'fr', 'it', 'es', 'sv'];
15
+ export const customerReportToolConfig = {
16
+ title: 'Customer project report',
17
+ description: 'Generate the customer-facing project report as Markdown. `preset`: "scope" (proposal character - milestones + epics, no progress/status) or "status" (status report - all non-private tickets + progress). '
18
+ + '`locale` selects the report language (en/de/fr/it/es/sv) independently of your own locale; structure labels are catalog-translated. '
19
+ + '`priceMode`: "hours" (estimates, default), "money" (customer rates - needs the budget:view permission), or "lumpSum" (a flat price - pass `lumpSumAmount`). '
20
+ + 'Private tickets and milestones are always excluded, and internal cost/overhead is never included. Toggle sections with `sections` (overview, milestones, epics, tickets, budget). Requires the customer_report:generate permission on the project.',
21
+ inputSchema: z.object({
22
+ projectKey: z.string().min(1).describe('Project key (e.g. "ORB").'),
23
+ preset: z.enum(['scope', 'status']).default('scope').describe('Report preset (default "scope").'),
24
+ locale: z.enum(LOCALES).default('en').describe('Report language (default "en").'),
25
+ priceMode: z.enum(['hours', 'money', 'lumpSum']).default('hours').describe('Pricing mode (default "hours"). "money" needs budget:view.'),
26
+ lumpSumAmount: z.number().nonnegative().optional().describe('Flat price for priceMode="lumpSum".'),
27
+ lumpSumCurrency: z.string().min(1).max(8).optional().describe('Currency for the lump sum (default EUR).'),
28
+ showAssigneeNames: z.boolean().optional().describe('Include assignee names (opt-in, default off).'),
29
+ showTicketKeys: z.boolean().optional().describe('Show ticket keys in the output (default on).'),
30
+ sections: z.object({
31
+ overview: z.boolean().optional(),
32
+ milestones: z.boolean().optional(),
33
+ epics: z.boolean().optional(),
34
+ tickets: z.boolean().optional(),
35
+ budget: z.boolean().optional(),
36
+ }).optional().describe('Section toggles on top of the preset default.'),
37
+ }).shape,
38
+ annotations: { readOnlyHint: true, idempotentHint: true },
39
+ };
40
+ export function makeCustomerReportHandler(client) {
41
+ return async (input) => {
42
+ const project = await resolveProjectByKey(client, input.projectKey);
43
+ const priceMode = input.priceMode ?? 'hours';
44
+ const options = {
45
+ priceMode,
46
+ showAssigneeNames: input.showAssigneeNames ?? false,
47
+ showTicketKeys: input.showTicketKeys ?? true,
48
+ };
49
+ if (priceMode === 'lumpSum') {
50
+ options.lumpSum = {
51
+ amount: input.lumpSumAmount ?? 0,
52
+ currency: input.lumpSumCurrency ?? 'EUR',
53
+ };
54
+ }
55
+ const body = {
56
+ preset: input.preset ?? 'scope',
57
+ locale: input.locale ?? 'en',
58
+ options,
59
+ format: 'markdown',
60
+ };
61
+ if (input.sections)
62
+ body.sections = input.sections;
63
+ let res;
64
+ try {
65
+ res = await client.post(`/projects/${project.id}/customer-report/generate`, body);
66
+ }
67
+ catch (err) {
68
+ if (err instanceof OrbotoApiError && err.status === 403 && priceMode === 'money') {
69
+ return {
70
+ content: [{ type: 'text', text: `Not permitted: money price mode needs the budget:view permission on ${project.key}.` }],
71
+ structuredContent: { error: 'forbidden', requiredPermission: 'budget:view' },
72
+ };
73
+ }
74
+ throw err;
75
+ }
76
+ return {
77
+ content: [{ type: 'text', text: res.markdown }],
78
+ structuredContent: {
79
+ projectKey: project.key,
80
+ preset: res.preset,
81
+ locale: res.locale,
82
+ priceMode: res.priceMode,
83
+ aiSkipped: res.aiSkipped,
84
+ reviewFlags: res.reviewFlags,
85
+ },
86
+ };
87
+ };
88
+ }
@@ -0,0 +1,55 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient } from '../orboto-client.js';
3
+ import { makeCustomerReportHandler } from './customer-report.js';
4
+ beforeEach(() => { vi.restoreAllMocks(); });
5
+ afterEach(() => { vi.restoreAllMocks(); });
6
+ function stub(responses) {
7
+ const calls = [];
8
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
9
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
10
+ calls.push({ url: url.toString(), body });
11
+ const r = responses.shift();
12
+ if (!r)
13
+ throw new Error(`unexpected extra fetch to ${url}`);
14
+ const status = r.status ?? 200;
15
+ return {
16
+ ok: status < 400, status, statusText: 'OK',
17
+ json: async () => ('json' in r ? r.json : {}), text: async () => '',
18
+ };
19
+ });
20
+ return calls;
21
+ }
22
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
23
+ const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: '', status: 'active' };
24
+ const MD = {
25
+ markdown: '# Project Report - Acme\n\n## Milestones\n',
26
+ preset: 'scope', locale: 'de', priceMode: 'hours',
27
+ aiSkipped: null,
28
+ reviewFlags: { executiveSummaryGenerated: false, contentTranslated: false, translatedFields: 0 },
29
+ };
30
+ describe('orboto_customer_report (ORB-1383)', () => {
31
+ it('resolves the project and posts a markdown generate with preset + locale', async () => {
32
+ const calls = stub([{ json: PROJ }, { json: MD }]);
33
+ const res = await makeCustomerReportHandler(client)({ projectKey: 'ACME', preset: 'scope', locale: 'de' });
34
+ expect(calls[1].url).toContain('/projects/p1/customer-report/generate');
35
+ expect(calls[1].body).toMatchObject({ preset: 'scope', locale: 'de', format: 'markdown' });
36
+ expect(res.content[0].text).toContain('Project Report');
37
+ expect(res.structuredContent).toMatchObject({ projectKey: 'ACME', preset: 'scope', locale: 'de', aiSkipped: null });
38
+ });
39
+ it('passes lump-sum amount/currency in the options', async () => {
40
+ const calls = stub([{ json: PROJ }, { json: { ...MD, priceMode: 'lumpSum' } }]);
41
+ await makeCustomerReportHandler(client)({ projectKey: 'ACME', priceMode: 'lumpSum', lumpSumAmount: 5000, lumpSumCurrency: 'USD' });
42
+ expect(calls[1].body).toMatchObject({ options: { priceMode: 'lumpSum', lumpSum: { amount: 5000, currency: 'USD' } } });
43
+ });
44
+ it('surfaces a budget:view 403 for money mode as a clean structured error', async () => {
45
+ stub([{ json: PROJ }, { status: 403, json: { error: 'Forbidden' } }]);
46
+ const res = await makeCustomerReportHandler(client)({ projectKey: 'ACME', priceMode: 'money' });
47
+ expect(res.structuredContent.requiredPermission).toBe('budget:view');
48
+ expect(res.content[0].text).toMatch(/budget:view/);
49
+ });
50
+ it('defaults to scope preset + hours mode', async () => {
51
+ const calls = stub([{ json: PROJ }, { json: MD }]);
52
+ await makeCustomerReportHandler(client)({ projectKey: 'ACME' });
53
+ expect(calls[1].body).toMatchObject({ preset: 'scope', options: { priceMode: 'hours' } });
54
+ });
55
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * ORB-914 - doc-attachments MCP tools (epic ORB-911 Phase 3).
3
+ *
4
+ * Mirrors the ticket-attachment surface (apps/mcp/src/tools/attach.ts)
5
+ * for doc pages. Three tools:
6
+ *
7
+ * - orboto_upload_doc_attachment - multipart upload + optional embed
8
+ * - orboto_list_doc_attachments - flat list with download URLs
9
+ * - orboto_delete_doc_attachment - destructive
10
+ *
11
+ * The embed branch on upload PATCHes the doc body to append a Markdown
12
+ * image-or-link line, same pattern as orboto_attach_to_ticket. Useful
13
+ * when an agent wants to drop a screenshot into a page in one call.
14
+ *
15
+ * MIME / extension policy is enforced server-side via `isAttachmentAllowed`
16
+ * - the tool just surfaces the 415 as an OrbotoApiError so the caller
17
+ * can pick a different file.
18
+ */
19
+ import { z } from 'zod';
20
+ import { resolveDocId } from './docs.js';
21
+ /**
22
+ * Very small MIME guesser - same shape as attach.ts. The API also sniffs
23
+ * the filename, so this only needs to land in the right ballpark for the
24
+ * Blob's Content-Type header.
25
+ */
26
+ function mimetypeFor(filename) {
27
+ const lower = filename.toLowerCase();
28
+ if (lower.endsWith('.png'))
29
+ return 'image/png';
30
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
31
+ return 'image/jpeg';
32
+ if (lower.endsWith('.gif'))
33
+ return 'image/gif';
34
+ if (lower.endsWith('.webp'))
35
+ return 'image/webp';
36
+ if (lower.endsWith('.pdf'))
37
+ return 'application/pdf';
38
+ if (lower.endsWith('.txt') || lower.endsWith('.log'))
39
+ return 'text/plain';
40
+ if (lower.endsWith('.md') || lower.endsWith('.markdown'))
41
+ return 'text/markdown';
42
+ if (lower.endsWith('.json'))
43
+ return 'application/json';
44
+ if (lower.endsWith('.csv'))
45
+ return 'text/csv';
46
+ if (lower.endsWith('.docx'))
47
+ return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
48
+ return 'application/octet-stream';
49
+ }
50
+ function isImage(mimetype) {
51
+ return mimetype.startsWith('image/');
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // orboto_upload_doc_attachment
55
+ // ---------------------------------------------------------------------------
56
+ export const uploadDocAttachmentToolConfig = {
57
+ title: 'Upload an attachment to a doc page',
58
+ description: 'Upload a file as an attachment on a wiki doc and return its Markdown image (or link) line + the stable download URL. Bytes come in as base64 (`contentBase64`) so this works without local FS access on the agent side. Set `embed=true` to also PATCH the doc body to append the Markdown line - useful for dropping a screenshot into a page in one call. MIME / extension policy is enforced server-side: blocked types (HTML, SVG, executables) surface a 415.',
59
+ inputSchema: z.object({
60
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
61
+ filename: z.string().min(1).describe('Display filename, e.g. "architecture-diagram.png".'),
62
+ contentBase64: z.string().min(1).describe('File content, base64-encoded.'),
63
+ altText: z.string().optional().describe('Alt-text for the Markdown image line. Defaults to filename.'),
64
+ embed: z.boolean().optional().describe('If true, PATCHes the doc body to append the Markdown line. Idempotent up to duplicate detection - re-running appends another copy.'),
65
+ }).shape,
66
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
67
+ };
68
+ export function makeUploadDocAttachmentHandler(client) {
69
+ return async ({ docId, filename, contentBase64, altText, embed }) => {
70
+ docId = await resolveDocId(client, docId);
71
+ let arrayBuffer;
72
+ try {
73
+ // Buffer → fresh ArrayBuffer copy (see attach.ts for the Blob
74
+ // type-constraint rationale).
75
+ const buf = Buffer.from(contentBase64, 'base64');
76
+ arrayBuffer = new ArrayBuffer(buf.byteLength);
77
+ new Uint8Array(arrayBuffer).set(buf);
78
+ }
79
+ catch {
80
+ throw new Error('contentBase64 is not valid base64.');
81
+ }
82
+ if (arrayBuffer.byteLength === 0) {
83
+ throw new Error('contentBase64 decoded to 0 bytes - refuse to upload an empty file.');
84
+ }
85
+ const form = new FormData();
86
+ form.append('file', new Blob([arrayBuffer], { type: mimetypeFor(filename) }), filename);
87
+ const att = await client.postMultipart(`/docs/${docId}/attachments`, form);
88
+ const alt = altText ?? att.filename;
89
+ const url = att.downloadUrl ?? `/attachments/${att.id}`;
90
+ const markdown = isImage(att.contentType)
91
+ ? `![${alt}](${url})`
92
+ : `[${alt}](${url})`;
93
+ let embedded = false;
94
+ if (embed === true) {
95
+ // Read the current body first - PATCH /docs/:id replaces, not
96
+ // appends. Then write the new body in one go.
97
+ const current = await client.get(`/docs/${docId}`);
98
+ const existing = current.content ?? '';
99
+ const next = existing ? `${existing}\n\n${markdown}` : markdown;
100
+ await client.patch(`/docs/${docId}`, { content: next });
101
+ embedded = true;
102
+ }
103
+ return {
104
+ content: [{
105
+ type: 'text',
106
+ text: `Attached ${att.filename} (${Math.round(att.sizeBytes / 1024)} KB) to doc ${docId}${embedded ? ' and embedded in body' : ''}.\n${markdown}`,
107
+ }],
108
+ structuredContent: {
109
+ docId,
110
+ attachmentId: att.id,
111
+ filename: att.filename,
112
+ sizeBytes: att.sizeBytes,
113
+ contentType: att.contentType,
114
+ downloadUrl: url,
115
+ markdown,
116
+ embedded,
117
+ },
118
+ };
119
+ };
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // orboto_list_doc_attachments
123
+ // ---------------------------------------------------------------------------
124
+ export const listDocAttachmentsToolConfig = {
125
+ title: 'List attachments on a doc page',
126
+ description: 'Return the doc\'s attachments newest-first, with stable download URLs the agent can hand to the user. Empty list = no attachments.',
127
+ inputSchema: z.object({
128
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
129
+ }).shape,
130
+ annotations: { readOnlyHint: true, idempotentHint: true },
131
+ };
132
+ export function makeListDocAttachmentsHandler(client) {
133
+ return async ({ docId }) => {
134
+ docId = await resolveDocId(client, docId);
135
+ const rows = await client.get(`/docs/${docId}/attachments`);
136
+ if (rows.length === 0) {
137
+ return {
138
+ content: [{ type: 'text', text: 'No attachments on this doc.' }],
139
+ structuredContent: { attachments: [] },
140
+ };
141
+ }
142
+ const lines = rows.map((r) => {
143
+ const url = r.downloadUrl ?? `/attachments/${r.id}`;
144
+ const kb = Math.round(r.sizeBytes / 1024);
145
+ return `- ${r.filename} (${kb} KB, ${r.contentType}) → ${url}`;
146
+ });
147
+ return {
148
+ content: [{ type: 'text', text: lines.join('\n') }],
149
+ structuredContent: {
150
+ attachments: rows.map((r) => ({
151
+ id: r.id,
152
+ filename: r.filename,
153
+ contentType: r.contentType,
154
+ sizeBytes: r.sizeBytes,
155
+ uploadedAt: r.uploadedAt,
156
+ uploadedBy: r.uploadedBy,
157
+ downloadUrl: r.downloadUrl ?? `/attachments/${r.id}`,
158
+ })),
159
+ },
160
+ };
161
+ };
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // orboto_delete_doc_attachment
165
+ // ---------------------------------------------------------------------------
166
+ export const deleteDocAttachmentToolConfig = {
167
+ title: 'Delete an attachment from a doc page',
168
+ description: 'DESTRUCTIVE - drops the file row + deletes the underlying S3 object. Does NOT rewrite the doc body, so embedded Markdown lines pointing at the deleted URL will surface as broken images / links. Walk those by hand if needed.',
169
+ inputSchema: z.object({
170
+ docId: z.string().min(1).describe('Doc UUID or human-readable doc key (ORB-D12 / DOC-5).'),
171
+ attachmentId: z.string().uuid(),
172
+ }).shape,
173
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
174
+ };
175
+ export function makeDeleteDocAttachmentHandler(client) {
176
+ return async ({ docId, attachmentId }) => {
177
+ docId = await resolveDocId(client, docId);
178
+ await client.delete(`/docs/${docId}/attachments/${attachmentId}`);
179
+ return {
180
+ content: [{ type: 'text', text: `Attachment ${attachmentId} removed from doc ${docId}.` }],
181
+ structuredContent: { docId, attachmentId, deleted: true },
182
+ };
183
+ };
184
+ }