@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.
- package/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- package/package.json +66 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase B - end-to-end shape tests for the read-tool suite.
|
|
3
|
+
*
|
|
4
|
+
* Each tool gets one happy-path test that confirms it hits the
|
|
5
|
+
* expected API endpoints in order and produces structured content
|
|
6
|
+
* with the advertised keys. The fixtures below use the REAL shapes
|
|
7
|
+
* from `@orboto/shared-schema` - if a schema drift lands, these tests
|
|
8
|
+
* break, which is exactly what we want.
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
12
|
+
import { makeGetProjectHandler } from './get-project.js';
|
|
13
|
+
import { makeListTicketsHandler } from './list-tickets.js';
|
|
14
|
+
import { makeGetTicketHandler } from './get-ticket.js';
|
|
15
|
+
import { makeMyTicketsHandler } from './my-tickets.js';
|
|
16
|
+
import { makeListMilestonesHandler, makeGetMilestoneHandler } from './milestones.js';
|
|
17
|
+
import { makeSearchHandler } from './search.js';
|
|
18
|
+
import { makeListDocSpacesHandler, makeGetDocHandler } from './docs.js';
|
|
19
|
+
import { makeGetTimerHandler } from './get-timer.js';
|
|
20
|
+
import { makeGetChecklistsHandler } from './get-checklists.js';
|
|
21
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
22
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
23
|
+
function stub(responses) {
|
|
24
|
+
const calls = [];
|
|
25
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
26
|
+
calls.push(url.toString());
|
|
27
|
+
const r = responses.shift();
|
|
28
|
+
if (!r)
|
|
29
|
+
throw new Error(`unexpected extra fetch to ${url}`);
|
|
30
|
+
return {
|
|
31
|
+
ok: r.ok ?? true,
|
|
32
|
+
status: r.status ?? 200,
|
|
33
|
+
statusText: 'OK',
|
|
34
|
+
// `'json' in r` so an explicit `json: null` preserves null;
|
|
35
|
+
// `r.json ?? {}` would collapse null → {} and hide nullable
|
|
36
|
+
// endpoints (e.g. GET /time/timer returns the row or null).
|
|
37
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
38
|
+
text: async () => '',
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return calls;
|
|
42
|
+
}
|
|
43
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
44
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: 'Customer portal', status: 'active' };
|
|
45
|
+
describe('orboto_get_project', () => {
|
|
46
|
+
it('aggregates project + milestones + labels + members into structured content', async () => {
|
|
47
|
+
const calls = stub([
|
|
48
|
+
{ json: PROJ },
|
|
49
|
+
{ json: [{ id: 'm1', name: 'v1', status: 'active', startDate: '2026-01-01', endDate: '2026-03-01' }] },
|
|
50
|
+
{ json: [{ id: 'l1', name: 'bug', color: '#f00' }] },
|
|
51
|
+
// Members endpoint response: nested user + role objects per the real schema.
|
|
52
|
+
{ json: [{ userId: 'u1', projectId: 'p1', roleId: 'r1',
|
|
53
|
+
user: { id: 'u1', email: 'ada@acme', fullName: 'Ada', avatarUrl: null },
|
|
54
|
+
role: { id: 'r1', name: 'developer' } }] },
|
|
55
|
+
]);
|
|
56
|
+
const res = await makeGetProjectHandler(client)({ projectKey: 'acme' });
|
|
57
|
+
expect(calls).toHaveLength(4);
|
|
58
|
+
expect(calls[1]).toContain('/projects/p1/milestones');
|
|
59
|
+
expect(calls[2]).toContain('/projects/p1/labels');
|
|
60
|
+
expect(calls[3]).toContain('/projects/p1/members');
|
|
61
|
+
const sc = res.structuredContent;
|
|
62
|
+
expect(sc.members[0]).toEqual({
|
|
63
|
+
userId: 'u1', fullName: 'Ada', email: 'ada@acme', roleName: 'developer',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('orboto_list_tickets', () => {
|
|
68
|
+
it('passes limit + statusCategory to the API', async () => {
|
|
69
|
+
const calls = stub([
|
|
70
|
+
{ json: PROJ },
|
|
71
|
+
{ json: { items: [], nextCursor: null } },
|
|
72
|
+
]);
|
|
73
|
+
await makeListTicketsHandler(client)({ projectKey: 'ACME', statusCategory: 'in_progress', limit: 10 });
|
|
74
|
+
expect(calls[1]).toContain('/projects/p1/tickets?');
|
|
75
|
+
expect(calls[1]).toContain('statusCategory=in_progress');
|
|
76
|
+
expect(calls[1]).toContain('limit=10');
|
|
77
|
+
});
|
|
78
|
+
// ORB-1696 - the shared resolver contract: key, name, OR UUID; an
|
|
79
|
+
// ambiguous name errors listing candidates. One test per form.
|
|
80
|
+
it('resolves milestone KEY (ORB-1696)', async () => {
|
|
81
|
+
const calls = stub([
|
|
82
|
+
{ json: PROJ },
|
|
83
|
+
{ json: [{ id: 'm1', name: 'v1', milestoneKey: 'ACME-M1' }, { id: 'm2', name: 'v2', milestoneKey: 'ACME-M2' }] },
|
|
84
|
+
{ json: { items: [], nextCursor: null } },
|
|
85
|
+
]);
|
|
86
|
+
await makeListTicketsHandler(client)({ projectKey: 'ACME', milestone: 'acme-m2' });
|
|
87
|
+
expect(calls[2]).toContain('milestoneId=m2');
|
|
88
|
+
});
|
|
89
|
+
it('resolves milestone UUID (ORB-1696)', async () => {
|
|
90
|
+
const uuid = '3b000000-0000-4000-8000-000000000001';
|
|
91
|
+
const calls = stub([
|
|
92
|
+
{ json: PROJ },
|
|
93
|
+
{ json: [{ id: uuid, name: 'v1', milestoneKey: 'ACME-M1' }] },
|
|
94
|
+
{ json: { items: [], nextCursor: null } },
|
|
95
|
+
]);
|
|
96
|
+
await makeListTicketsHandler(client)({ projectKey: 'ACME', milestone: uuid });
|
|
97
|
+
expect(calls[2]).toContain(`milestoneId=${uuid}`);
|
|
98
|
+
});
|
|
99
|
+
it('an ambiguous milestone NAME errors listing the candidates (ORB-1696)', async () => {
|
|
100
|
+
stub([
|
|
101
|
+
{ json: PROJ },
|
|
102
|
+
{ json: [{ id: 'm1', name: 'dup', milestoneKey: 'ACME-M1' }, { id: 'm2', name: 'dup', milestoneKey: 'ACME-M2' }] },
|
|
103
|
+
]);
|
|
104
|
+
await expect(makeListTicketsHandler(client)({ projectKey: 'ACME', milestone: 'dup' })).rejects.toThrow(/ambiguous/);
|
|
105
|
+
});
|
|
106
|
+
it('resolves milestone name → milestoneId query param', async () => {
|
|
107
|
+
const calls = stub([
|
|
108
|
+
{ json: PROJ },
|
|
109
|
+
{ json: [{ id: 'm1', name: 'v1' }, { id: 'm2', name: 'v2' }] },
|
|
110
|
+
{ json: { items: [], nextCursor: null } },
|
|
111
|
+
]);
|
|
112
|
+
await makeListTicketsHandler(client)({ projectKey: 'ACME', milestone: 'v2' });
|
|
113
|
+
expect(calls[2]).toContain('milestoneId=m2');
|
|
114
|
+
});
|
|
115
|
+
it('resolves assigneeEmail via the nested members shape', async () => {
|
|
116
|
+
const calls = stub([
|
|
117
|
+
{ json: PROJ },
|
|
118
|
+
{
|
|
119
|
+
json: [
|
|
120
|
+
{ userId: 'u1', user: { email: 'alice@acme' } },
|
|
121
|
+
{ userId: 'u2', user: { email: 'bob@acme' } },
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
{ json: { items: [], nextCursor: null } },
|
|
125
|
+
]);
|
|
126
|
+
await makeListTicketsHandler(client)({ projectKey: 'ACME', assigneeEmail: 'bob@acme' });
|
|
127
|
+
expect(calls[2]).toContain('assigneeId=u2');
|
|
128
|
+
});
|
|
129
|
+
it('throws when the milestone name does not exist', async () => {
|
|
130
|
+
stub([
|
|
131
|
+
{ json: PROJ },
|
|
132
|
+
{ json: [{ id: 'm1', name: 'v1' }] },
|
|
133
|
+
]);
|
|
134
|
+
await expect(makeListTicketsHandler(client)({ projectKey: 'ACME', milestone: 'ghost' })).rejects.toThrow(/Milestone "ghost" not found/);
|
|
135
|
+
});
|
|
136
|
+
// ORB-1605 - the stalled-ingestion signal passes through into structuredContent.
|
|
137
|
+
it('surfaces waitingForGitIngestion per ticket', async () => {
|
|
138
|
+
stub([
|
|
139
|
+
{ json: PROJ },
|
|
140
|
+
{
|
|
141
|
+
json: {
|
|
142
|
+
items: [{
|
|
143
|
+
id: 't1', projectId: 'p1', ticketKey: 'ACME-11', title: 'Docs change',
|
|
144
|
+
status: 'IN_REVIEW', statusName: 'In Review', statusCategory: 'in_review',
|
|
145
|
+
type: 'task', priority: 'normal', estimatedTimeMinutes: 0, dueDate: null,
|
|
146
|
+
waitingForGitIngestion: true,
|
|
147
|
+
}],
|
|
148
|
+
nextCursor: null,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
const res = await makeListTicketsHandler(client)({ projectKey: 'ACME' });
|
|
153
|
+
const text = res.content[0].text;
|
|
154
|
+
expect(text).toContain('[waiting on Git ingestion]');
|
|
155
|
+
const sc = res.structuredContent;
|
|
156
|
+
expect(sc.tickets[0].waitingForGitIngestion).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('orboto_get_ticket', () => {
|
|
160
|
+
// Fixture: empty children page (children METADATA is always fetched; the
|
|
161
|
+
// rows only surface with include: ["children"] since ORB-1698).
|
|
162
|
+
const NO_CHILDREN = { json: { items: [], nextCursor: null } };
|
|
163
|
+
it('ORB-1698 default card: only enriched + children + attachments are fetched - comments/checklists/git untouched', async () => {
|
|
164
|
+
const calls = stub([
|
|
165
|
+
{ json: PROJ },
|
|
166
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', type: 'bug', priority: 'normal', gitActivityCount: 0, parentTicketId: null } },
|
|
167
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'bug', priority: 'normal', commentCount: 3, gitActivityCount: 0, checklistProgress: { done: 1, total: 2 } } },
|
|
168
|
+
NO_CHILDREN,
|
|
169
|
+
{ json: [] }, // attachments metadata
|
|
170
|
+
]);
|
|
171
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5' });
|
|
172
|
+
expect(calls).toHaveLength(5);
|
|
173
|
+
expect(calls.some((c) => c.includes('/comments'))).toBe(false);
|
|
174
|
+
expect(calls.some((c) => c.includes('/checklists'))).toBe(false);
|
|
175
|
+
expect(calls.some((c) => c.includes('/git-activity'))).toBe(false);
|
|
176
|
+
const sc = res.structuredContent;
|
|
177
|
+
// Counts always present; bodies absent by default - never silently.
|
|
178
|
+
expect(sc.commentCount).toBe(3);
|
|
179
|
+
expect(sc.checklistProgress).toEqual({ done: 1, total: 2 });
|
|
180
|
+
expect(sc.attachmentCount).toBe(0);
|
|
181
|
+
expect(sc.childCount).toBe(0);
|
|
182
|
+
expect(sc.comments).toBeUndefined();
|
|
183
|
+
expect(sc.checklists).toBeUndefined();
|
|
184
|
+
expect(sc.gitActivity).toBeUndefined();
|
|
185
|
+
expect(sc.attachments).toBeUndefined();
|
|
186
|
+
expect(sc.children).toBeUndefined();
|
|
187
|
+
expect(String(sc.includeHint)).toContain('comments (3)');
|
|
188
|
+
const text = res.content[0].text;
|
|
189
|
+
expect(text).toContain('Comments: 3');
|
|
190
|
+
expect(text).toContain('Checklists: 1/2 done');
|
|
191
|
+
});
|
|
192
|
+
it('ORB-1698: the default response for a 30+-comment ticket stays under the 4k budget', async () => {
|
|
193
|
+
stub([
|
|
194
|
+
{ json: PROJ },
|
|
195
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', gitActivityCount: 0, parentTicketId: null } },
|
|
196
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'bug', priority: 'normal', commentCount: 34, gitActivityCount: 7, checklistProgress: { done: 0, total: 0 }, description: 'Repro steps here.' } },
|
|
197
|
+
NO_CHILDREN,
|
|
198
|
+
{ json: [] },
|
|
199
|
+
]);
|
|
200
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5' });
|
|
201
|
+
const size = JSON.stringify(res.structuredContent).length;
|
|
202
|
+
expect(size).toBeLessThan(4000);
|
|
203
|
+
expect(res.structuredContent.commentCount).toBe(34);
|
|
204
|
+
});
|
|
205
|
+
it('include: ["comments"] restores cursor-paged bodies exactly as before', async () => {
|
|
206
|
+
const calls = stub([
|
|
207
|
+
{ json: PROJ },
|
|
208
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-6', title: 'X', status: 'TODO', gitActivityCount: 0, parentTicketId: null } },
|
|
209
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-6', title: 'X', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'task', priority: 'normal', commentCount: 1 } },
|
|
210
|
+
NO_CHILDREN,
|
|
211
|
+
{ json: [] }, // attachments
|
|
212
|
+
{
|
|
213
|
+
json: {
|
|
214
|
+
items: [{ id: 'c1', ticketId: 't1', userId: 'u1', content: 'first thought', isInternal: false, createdAt: 'now', userName: 'Ada' }],
|
|
215
|
+
nextCursor: 'next-page-token',
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-6', include: ['comments'] });
|
|
220
|
+
expect(calls.some((c) => c.includes('/tickets/t1/comments?limit='))).toBe(true);
|
|
221
|
+
const sc = res.structuredContent;
|
|
222
|
+
expect(sc.comments[0]).toEqual({
|
|
223
|
+
id: 'c1', // ORB-1285 - comment id is surfaced for edit/delete targeting
|
|
224
|
+
body: 'first thought',
|
|
225
|
+
author: 'Ada',
|
|
226
|
+
createdAt: 'now',
|
|
227
|
+
editedAt: null,
|
|
228
|
+
isInternal: false,
|
|
229
|
+
});
|
|
230
|
+
expect(sc.commentsHasMore).toBe(true);
|
|
231
|
+
expect(sc.parentTicket).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
// ORB-1605 - the stalled-ingestion signal surfaces in both the header
|
|
234
|
+
// text and structuredContent.
|
|
235
|
+
it('surfaces waitingForGitIngestion in the header and structured content', async () => {
|
|
236
|
+
stub([
|
|
237
|
+
{ json: PROJ },
|
|
238
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-10', title: 'Docs change', status: 'IN_REVIEW', gitActivityCount: 0, parentTicketId: null } },
|
|
239
|
+
{
|
|
240
|
+
json: {
|
|
241
|
+
id: 't1', projectId: 'p1', ticketKey: 'ACME-10', title: 'Docs change',
|
|
242
|
+
status: 'IN_REVIEW', statusName: 'In Review', statusCategory: 'in_review',
|
|
243
|
+
type: 'task', priority: 'normal', waitingForGitIngestion: true,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
NO_CHILDREN,
|
|
247
|
+
{ json: [] },
|
|
248
|
+
]);
|
|
249
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-10' });
|
|
250
|
+
const text = res.content[0].text;
|
|
251
|
+
expect(text).toContain('Waiting for Git ingestion');
|
|
252
|
+
const sc = res.structuredContent;
|
|
253
|
+
expect(sc.waitingForGitIngestion).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
it('include: ["checklistItems"] reads effectiveCompleted (not done) + surfaces linked-ticket suffix', async () => {
|
|
256
|
+
stub([
|
|
257
|
+
{ json: PROJ },
|
|
258
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-8', title: 'Epic', status: 'IN_PROGRESS', gitActivityCount: 0, parentTicketId: null } },
|
|
259
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-8', title: 'Epic', status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress', type: 'task', priority: 'normal', checklistProgress: { done: 0, total: 1 } } },
|
|
260
|
+
NO_CHILDREN,
|
|
261
|
+
{ json: [] }, // attachments
|
|
262
|
+
{
|
|
263
|
+
json: [{
|
|
264
|
+
id: 'cl1', title: 'Gate items', triggersDone: false,
|
|
265
|
+
progress: { done: 0, total: 1 },
|
|
266
|
+
items: [{
|
|
267
|
+
id: 'i1', content: 'Sub-task', storedCompleted: false, effectiveCompleted: false,
|
|
268
|
+
linkedTicketId: 't9', linkedTicketKey: 'ACME-9', linkedTicketTitle: 'Race fix', linkedTicketStatusCategory: 'in_progress', sortOrder: 0,
|
|
269
|
+
}],
|
|
270
|
+
}],
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
273
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-8', include: ['checklistItems'] });
|
|
274
|
+
const text = res.content[0].text;
|
|
275
|
+
expect(text).toContain('Gate items (0/1)');
|
|
276
|
+
expect(text).toContain('[ ] Sub-task ↪ [ACME-9]');
|
|
277
|
+
});
|
|
278
|
+
it('surfaces parent by default; child rows only with include: ["children"]', async () => {
|
|
279
|
+
stub([
|
|
280
|
+
{ json: PROJ },
|
|
281
|
+
{ json: { id: 't5', projectId: 'p1', ticketKey: 'ACME-5', title: 'Phase A', status: 'DONE', statusName: 'Done', statusCategory: 'done', type: 'task', priority: 'normal', gitActivityCount: 0, parentTicketId: 't1' } },
|
|
282
|
+
// Enriched by-id refetch
|
|
283
|
+
{ json: { id: 't5', projectId: 'p1', ticketKey: 'ACME-5', title: 'Phase A', status: 'DONE', statusName: 'Done', statusCategory: 'done', type: 'task', priority: 'normal' } },
|
|
284
|
+
// Parent ticket fetch
|
|
285
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-1', title: 'Epic foo', status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
|
|
286
|
+
NO_CHILDREN,
|
|
287
|
+
{ json: [] },
|
|
288
|
+
]);
|
|
289
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5' });
|
|
290
|
+
const sc = res.structuredContent;
|
|
291
|
+
expect(sc.parentTicket).toEqual({
|
|
292
|
+
key: 'ACME-1',
|
|
293
|
+
title: 'Epic foo',
|
|
294
|
+
status: 'In Progress',
|
|
295
|
+
statusCategory: 'in_progress',
|
|
296
|
+
});
|
|
297
|
+
expect(sc.childCount).toBe(0);
|
|
298
|
+
expect(sc.children).toBeUndefined();
|
|
299
|
+
const text = res.content[0].text;
|
|
300
|
+
expect(text).toContain('Parent: [ACME-1] Epic foo (In Progress)');
|
|
301
|
+
});
|
|
302
|
+
it('include: ["git"] fetches git activity', async () => {
|
|
303
|
+
const calls = stub([
|
|
304
|
+
{ json: PROJ },
|
|
305
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-7', title: 'Feature', status: 'IN_PROGRESS', gitActivityCount: 2, parentTicketId: null } },
|
|
306
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-7', title: 'Feature', status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress', type: 'task', priority: 'normal', gitActivityCount: 2 } },
|
|
307
|
+
NO_CHILDREN,
|
|
308
|
+
{ json: [] }, // attachments
|
|
309
|
+
{ json: [{ type: 'pr', title: 'fix', state: 'open', externalId: '1', authorName: 'x', createdAt: 'now', url: 'x' }] },
|
|
310
|
+
]);
|
|
311
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-7', include: ['git'] });
|
|
312
|
+
expect(calls.some((c) => c.includes('/git-activity'))).toBe(true);
|
|
313
|
+
const sc = res.structuredContent;
|
|
314
|
+
expect(sc.gitActivity).toHaveLength(1);
|
|
315
|
+
});
|
|
316
|
+
it('ORB-1023: surfaces milestone (name, not UUID) + statusCategory from the enriched by-id refetch', async () => {
|
|
317
|
+
stub([
|
|
318
|
+
{ json: PROJ },
|
|
319
|
+
// Bare by-key resolver row: milestoneId set, but no milestoneName /
|
|
320
|
+
// statusCategory - this is the shape that dropped the milestone.
|
|
321
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-9', title: 'Bug', status: 'DONE', milestoneId: 'm1', gitActivityCount: 0, parentTicketId: null } },
|
|
322
|
+
// Enriched by-id refetch: carries statusCategory + the resolved name.
|
|
323
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-9', title: 'Bug', status: 'DONE', statusName: 'Done', statusCategory: 'done', type: 'bug', priority: 'normal', milestoneId: 'm1', milestoneName: 'Sprint 7' } },
|
|
324
|
+
NO_CHILDREN,
|
|
325
|
+
{ json: [] },
|
|
326
|
+
]);
|
|
327
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-9' });
|
|
328
|
+
const sc = res.structuredContent;
|
|
329
|
+
expect(sc.milestone).toEqual({ id: 'm1', name: 'Sprint 7' });
|
|
330
|
+
expect(sc.statusCategory).toBe('done');
|
|
331
|
+
expect(res.content[0].text).toContain('Milestone: Sprint 7');
|
|
332
|
+
});
|
|
333
|
+
it('ORB-1455/ORB-1698: attachment COUNT by default, rows with include: ["attachments"]', async () => {
|
|
334
|
+
const attId = 'a0000000-0000-0000-0000-000000000001';
|
|
335
|
+
const ATT = { json: [{ id: attId, filename: 'screenshot.png', contentType: 'image/png', sizeBytes: 4096, downloadUrl: `/attachments/${attId}` }] };
|
|
336
|
+
stub([
|
|
337
|
+
{ json: PROJ },
|
|
338
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', gitActivityCount: 0, parentTicketId: null } },
|
|
339
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'bug', priority: 'normal' } },
|
|
340
|
+
NO_CHILDREN,
|
|
341
|
+
ATT,
|
|
342
|
+
]);
|
|
343
|
+
const byDefault = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5' });
|
|
344
|
+
const scDefault = byDefault.structuredContent;
|
|
345
|
+
expect(scDefault.attachmentCount).toBe(1);
|
|
346
|
+
expect(scDefault.attachments).toBeUndefined();
|
|
347
|
+
stub([
|
|
348
|
+
{ json: PROJ },
|
|
349
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', gitActivityCount: 0, parentTicketId: null } },
|
|
350
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-5', title: 'Bug', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'bug', priority: 'normal' } },
|
|
351
|
+
NO_CHILDREN,
|
|
352
|
+
ATT,
|
|
353
|
+
]);
|
|
354
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-5', include: ['attachments'] });
|
|
355
|
+
const sc = res.structuredContent;
|
|
356
|
+
expect(sc.attachments).toEqual([
|
|
357
|
+
{ id: attId, filename: 'screenshot.png', contentType: 'image/png', sizeBytes: 4096, downloadUrl: `/attachments/${attId}` },
|
|
358
|
+
]);
|
|
359
|
+
const text = res.content[0].text;
|
|
360
|
+
expect(text).toContain('Attachments (1)');
|
|
361
|
+
expect(text).toContain('screenshot.png');
|
|
362
|
+
expect(text).toContain(attId);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
describe('orboto_my_tickets', () => {
|
|
366
|
+
it('defaults to TODO,IN_PROGRESS,IN_REVIEW when no category is given', async () => {
|
|
367
|
+
const calls = stub([{ json: { items: [], nextCursor: null } }]);
|
|
368
|
+
await makeMyTicketsHandler(client)({});
|
|
369
|
+
expect(calls[0]).toContain('statuses=TODO%2CIN_PROGRESS%2CIN_REVIEW');
|
|
370
|
+
});
|
|
371
|
+
it('maps category → legacy status for the API', async () => {
|
|
372
|
+
const calls = stub([{ json: { items: [], nextCursor: null } }]);
|
|
373
|
+
await makeMyTicketsHandler(client)({ statusCategory: 'done' });
|
|
374
|
+
expect(calls[0]).toContain('statuses=DONE');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
describe('milestone tools', () => {
|
|
378
|
+
it('list: returns structured milestones array incl. milestoneKey (ORB-1068)', async () => {
|
|
379
|
+
stub([
|
|
380
|
+
{ json: PROJ },
|
|
381
|
+
{ json: [{ id: 'm1', projectId: 'p1', milestoneKey: 'ACME-M1', name: 'v1', status: 'active', startDate: null, endDate: null, isPrivate: false }] },
|
|
382
|
+
]);
|
|
383
|
+
const res = await makeListMilestonesHandler(client)({ projectKey: 'ACME' });
|
|
384
|
+
const sc = res.structuredContent;
|
|
385
|
+
expect(sc.milestones[0].name).toBe('v1');
|
|
386
|
+
expect(sc.milestones[0].milestoneKey).toBe('ACME-M1');
|
|
387
|
+
expect(res.content[0].text).toContain('ACME-M1 · v1');
|
|
388
|
+
});
|
|
389
|
+
it('get: maps byStatus into percent + per-category counts', async () => {
|
|
390
|
+
const calls = stub([
|
|
391
|
+
{ json: PROJ },
|
|
392
|
+
{ json: [{ id: 'm1', projectId: 'p1', milestoneKey: 'ACME-M1', name: 'v1', status: 'active', startDate: null, endDate: null, isPrivate: false }] },
|
|
393
|
+
{ json: { total: 10, byStatus: { DONE: 5, IN_PROGRESS: 3, TODO: 2 } } },
|
|
394
|
+
]);
|
|
395
|
+
const res = await makeGetMilestoneHandler(client)({ projectKey: 'ACME', milestone: 'v1' });
|
|
396
|
+
expect(calls[2]).toContain('/projects/p1/milestones/m1/progress');
|
|
397
|
+
const text = res.content[0].text;
|
|
398
|
+
expect(text).toContain('50% done (5/10)');
|
|
399
|
+
expect(text).toContain('to do: 2 · in progress: 3');
|
|
400
|
+
const sc = res.structuredContent;
|
|
401
|
+
expect(sc.milestone.id).toBe('m1');
|
|
402
|
+
expect(sc.milestone.milestoneKey).toBe('ACME-M1');
|
|
403
|
+
});
|
|
404
|
+
it('get: resolves the milestone by its key, case-insensitive (ORB-1068)', async () => {
|
|
405
|
+
const calls = stub([
|
|
406
|
+
{ json: PROJ },
|
|
407
|
+
{ json: [{ id: 'm1', projectId: 'p1', milestoneKey: 'ACME-M19', name: 'Phase 35', status: 'active', startDate: null, endDate: null, isPrivate: false }] },
|
|
408
|
+
{ json: { total: 12, byStatus: { TODO: 12 } } },
|
|
409
|
+
]);
|
|
410
|
+
const res = await makeGetMilestoneHandler(client)({ projectKey: 'ACME', milestone: 'acme-m19' });
|
|
411
|
+
// The resolver spans closed milestones too.
|
|
412
|
+
expect(calls[1]).toContain('includeClosed=true');
|
|
413
|
+
const sc = res.structuredContent;
|
|
414
|
+
expect(sc.milestone.name).toBe('Phase 35');
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
describe('orboto_search', () => {
|
|
418
|
+
it('includes projectId when projectKey filter is set', async () => {
|
|
419
|
+
const calls = stub([
|
|
420
|
+
{ json: PROJ },
|
|
421
|
+
{ json: { items: [], nextCursor: null, total: 0 } },
|
|
422
|
+
]);
|
|
423
|
+
await makeSearchHandler(client)({ query: 'foo', projectKey: 'ACME' });
|
|
424
|
+
expect(calls[1]).toContain('projectId=p1');
|
|
425
|
+
});
|
|
426
|
+
it('renders real SearchResult shape with excerpt field + hasMore flag', async () => {
|
|
427
|
+
stub([
|
|
428
|
+
{
|
|
429
|
+
json: {
|
|
430
|
+
items: [
|
|
431
|
+
{ type: 'ticket', id: 't1', title: 'Bug', excerpt: '…login fails…', projectId: 'p1', projectName: 'Acme', spaceId: null, spaceName: null, url: '/projects/acme/tickets/ACME-1', ticketKey: 'ACME-1', rank: 1 },
|
|
432
|
+
{ type: 'doc', id: 'd1', title: 'Runbook', excerpt: '…restart…', projectId: null, projectName: null, spaceId: 's1', spaceName: 'Ops', url: '/spaces/ops/docs/runbook', rank: 0.5 },
|
|
433
|
+
],
|
|
434
|
+
nextCursor: 'more',
|
|
435
|
+
total: 42,
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
]);
|
|
439
|
+
const res = await makeSearchHandler(client)({ query: 'login' });
|
|
440
|
+
const text = res.content[0].text;
|
|
441
|
+
expect(text).toContain('[TICKET ACME-1 · Acme]');
|
|
442
|
+
expect(text).toContain('login fails');
|
|
443
|
+
const sc = res.structuredContent;
|
|
444
|
+
expect(sc.total).toBe(42);
|
|
445
|
+
expect(sc.hasMore).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
describe('doc tools', () => {
|
|
449
|
+
it('list: uses DocSpace.type to label scope, not a joined projectName', async () => {
|
|
450
|
+
stub([{ json: [
|
|
451
|
+
{ id: 's1', name: 'Team', slug: 'team', type: 'project', description: null, icon: null, projectId: 'p1', isPublic: false },
|
|
452
|
+
{ id: 's2', name: 'Global', slug: 'global', type: 'global', description: null, icon: null, projectId: null, isPublic: true },
|
|
453
|
+
] }]);
|
|
454
|
+
const res = await makeListDocSpacesHandler(client)();
|
|
455
|
+
const text = res.content[0].text;
|
|
456
|
+
expect(text).toContain('project-scoped');
|
|
457
|
+
expect(text).toContain('workspace-wide');
|
|
458
|
+
});
|
|
459
|
+
it('get: reads doc.content (not body) + backlink.sourceDocTitle', async () => {
|
|
460
|
+
stub([
|
|
461
|
+
{ json: { id: 'd1', spaceId: 's1', parentDocId: null, title: 'Runbook', content: '# Restart\n\n1. Kill the process.', slug: 'runbook', visibility: 'workspace', sortOrder: 0, icon: null, updatedAt: 'now' } },
|
|
462
|
+
{ json: [{ type: 'ticket', id: 't1', label: null, sourceDocId: 'd2', sourceDocTitle: 'Incident playbook', sourceSpaceId: 's1' }] },
|
|
463
|
+
]);
|
|
464
|
+
const res = await makeGetDocHandler(client)({ docId: '00000000-0000-0000-0000-000000000001' });
|
|
465
|
+
const text = res.content[0].text;
|
|
466
|
+
expect(text).toContain('# Runbook');
|
|
467
|
+
expect(text).toContain('1. Kill');
|
|
468
|
+
expect(text).toContain('Incident playbook');
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
describe('orboto_get_checklists', () => {
|
|
472
|
+
it('surfaces effectiveCompleted + linked-ticket metadata (ORB-234 shape)', async () => {
|
|
473
|
+
stub([
|
|
474
|
+
{ json: PROJ },
|
|
475
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-10', title: 'Epic', status: 'IN_PROGRESS' } },
|
|
476
|
+
{
|
|
477
|
+
json: [{
|
|
478
|
+
id: 'cl1', title: 'Before ship', triggersDone: true,
|
|
479
|
+
progress: { done: 1, total: 2 },
|
|
480
|
+
items: [
|
|
481
|
+
// One plain item, one linked-to-another-ticket item.
|
|
482
|
+
{ id: 'i1', content: 'Docs updated', storedCompleted: true, effectiveCompleted: true,
|
|
483
|
+
linkedTicketId: null, linkedTicketKey: null, linkedTicketTitle: null, linkedTicketStatusCategory: null, sortOrder: 0 },
|
|
484
|
+
{ id: 'i2', content: 'Sub-task done', storedCompleted: false, effectiveCompleted: false,
|
|
485
|
+
linkedTicketId: 't99', linkedTicketKey: 'ACME-99', linkedTicketTitle: 'Fix race', linkedTicketStatusCategory: 'in_progress', sortOrder: 1 },
|
|
486
|
+
],
|
|
487
|
+
}],
|
|
488
|
+
},
|
|
489
|
+
]);
|
|
490
|
+
const res = await makeGetChecklistsHandler(client)({ ticketKey: 'ACME-10' });
|
|
491
|
+
const text = res.content[0].text;
|
|
492
|
+
expect(text).toContain('Before ship (1/2)');
|
|
493
|
+
expect(text).toContain('triggers ticket done');
|
|
494
|
+
expect(text).toContain('[x] Docs updated');
|
|
495
|
+
expect(text).toContain('[ ] Sub-task done ↪ [ACME-99] Fix race (in_progress)');
|
|
496
|
+
const sc = res.structuredContent;
|
|
497
|
+
expect(sc.checklists[0].progress).toEqual({ done: 1, total: 2 });
|
|
498
|
+
expect(sc.checklists[0].items[0].linkedTicket).toBeNull();
|
|
499
|
+
expect(sc.checklists[0].items[1].linkedTicket).toEqual({
|
|
500
|
+
key: 'ACME-99', title: 'Fix race', statusCategory: 'in_progress',
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
it('renders the empty-state line when a ticket has no checklists', async () => {
|
|
504
|
+
stub([
|
|
505
|
+
{ json: PROJ },
|
|
506
|
+
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-11', title: 'Plain ticket' } },
|
|
507
|
+
{ json: [] },
|
|
508
|
+
]);
|
|
509
|
+
const res = await makeGetChecklistsHandler(client)({ ticketKey: 'ACME-11' });
|
|
510
|
+
expect(res.content[0].text).toBe('[ACME-11] has no checklists.');
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
describe('orboto_get_timer', () => {
|
|
514
|
+
it('returns null when the endpoint returns null directly (not wrapped)', async () => {
|
|
515
|
+
stub([{ json: null }]);
|
|
516
|
+
const res = await makeGetTimerHandler(client)();
|
|
517
|
+
expect(res.structuredContent).toEqual({ timer: null });
|
|
518
|
+
});
|
|
519
|
+
it('computes totalSeconds including elapsed time since startedAt', async () => {
|
|
520
|
+
const startedAt = new Date(Date.now() - 120_000).toISOString(); // 2 min ago
|
|
521
|
+
stub([{ json: { id: 'tm1', userId: 'u1', ticketId: 't1', ticketTitle: 'Bug', startedAt, pausedAt: null, accumulatedSeconds: 300, description: 'debug' } }]);
|
|
522
|
+
const res = await makeGetTimerHandler(client)();
|
|
523
|
+
const sc = res.structuredContent;
|
|
524
|
+
// 300 accumulated + ~120 elapsed. Allow ±5s for test timing jitter.
|
|
525
|
+
expect(sc.timer.totalSeconds).toBeGreaterThanOrEqual(418);
|
|
526
|
+
expect(sc.timer.totalSeconds).toBeLessThanOrEqual(425);
|
|
527
|
+
expect(sc.timer.paused).toBe(false);
|
|
528
|
+
expect(sc.timer.ticketTitle).toBe('Bug');
|
|
529
|
+
});
|
|
530
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1222 - `orboto_portfolio_summary`. Read-only portfolio / program-level
|
|
3
|
+
* cross-project rollup. With no `portfolioId` it lists the portfolios the
|
|
4
|
+
* caller can see; with one it returns the aggregate rollup (RAG per project,
|
|
5
|
+
* progress, budget, Earned Value, at-risk milestones). The rollup is
|
|
6
|
+
* ACL-filtered server-side to what the caller may see - a project the caller
|
|
7
|
+
* isn't a member of is omitted from every aggregate. Requires the
|
|
8
|
+
* admin:portfolio:read permission (the API returns 403 otherwise).
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
12
|
+
export const portfolioSummaryToolConfig = {
|
|
13
|
+
title: 'Portfolio summary',
|
|
14
|
+
description: 'Read a portfolio / program-level cross-project rollup. With no `portfolioId`, lists the portfolios you can see (id, name, project count). With a `portfolioId`, returns the aggregate rollup: per-project RAG (red/amber/green) health, overall progress (done/total tickets), effort hours, aggregate Earned Value (SPI/CPI across projects with a baseline), a per-currency budget rollup, and the at-risk milestones (overdue or soon-due) across the whole portfolio. '
|
|
15
|
+
+ 'Every figure is ACL-filtered server-side to only the projects you are a member of (super-admins see all) and the tickets / milestones / budgets you may see - so it never leaks a project you cannot otherwise access. Requires the admin:portfolio:read permission (you get a permission error otherwise).',
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
portfolioId: z.string().uuid().optional().describe('Portfolio UUID. Omit to list all portfolios you can see.'),
|
|
18
|
+
}).shape,
|
|
19
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
20
|
+
};
|
|
21
|
+
export function makePortfolioSummaryHandler(client) {
|
|
22
|
+
return async (input) => {
|
|
23
|
+
try {
|
|
24
|
+
if (!input.portfolioId) {
|
|
25
|
+
const list = await client.get('/admin/portfolios');
|
|
26
|
+
const lines = list.length
|
|
27
|
+
? list.map((p) => `- ${p.name} (${p.projectCount} projects) - id ${p.id}`).join('\n')
|
|
28
|
+
: 'No portfolios found.';
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: 'text', text: `Portfolios:\n${lines}` }],
|
|
31
|
+
structuredContent: { portfolios: list },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const rollup = await client.get(`/admin/portfolios/${input.portfolioId}/rollup`);
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: 'text', text: `Portfolio rollup:\n${JSON.stringify(rollup, null, 2)}` }],
|
|
37
|
+
structuredContent: { rollup },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err instanceof OrbotoApiError && err.status === 403) {
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: 'text', text: 'Not permitted: portfolio reporting needs the admin:portfolio:read permission.' }],
|
|
44
|
+
structuredContent: { error: 'forbidden', requiredPermission: 'admin:portfolio:read' },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (err instanceof OrbotoApiError && err.status === 404) {
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: 'Portfolio not found.' }],
|
|
50
|
+
structuredContent: { error: 'not_found' },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|