@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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-887 - unit tests for `orboto_check_similar`.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { makeCheckSimilarHandler } from './check-similar.js';
|
|
7
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
function stub(responses) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
12
|
+
calls.push({
|
|
13
|
+
url: url.toString(),
|
|
14
|
+
method: init?.method ?? 'GET',
|
|
15
|
+
body: init?.body ? JSON.parse(init.body) : undefined,
|
|
16
|
+
});
|
|
17
|
+
const r = responses.shift();
|
|
18
|
+
if (!r)
|
|
19
|
+
throw new Error('unexpected extra fetch');
|
|
20
|
+
return {
|
|
21
|
+
ok: r.ok ?? true,
|
|
22
|
+
status: r.status ?? 200,
|
|
23
|
+
statusText: 'OK',
|
|
24
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
25
|
+
text: async () => '',
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
return calls;
|
|
29
|
+
}
|
|
30
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
31
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
32
|
+
describe('orboto_check_similar', () => {
|
|
33
|
+
it('returns a safe-to-create recommendation when no candidates match', async () => {
|
|
34
|
+
const calls = stub([
|
|
35
|
+
{ json: PROJ },
|
|
36
|
+
{ json: { candidates: [], mode: 'tsvector' } },
|
|
37
|
+
]);
|
|
38
|
+
const res = await makeCheckSimilarHandler(client)({
|
|
39
|
+
projectKey: 'ACME', title: 'Brand new scope',
|
|
40
|
+
});
|
|
41
|
+
expect(calls[1].url).toContain('/projects/p1/tickets/similar?');
|
|
42
|
+
expect(calls[1].url).toContain('title=Brand+new+scope');
|
|
43
|
+
const sc = res.structuredContent;
|
|
44
|
+
expect(sc.recommendation).toMatch(/safe to create/i);
|
|
45
|
+
expect(sc.similar).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
it('flags a HIGH-SIMILARITY MATCH when the top hit ≥ 0.9', async () => {
|
|
48
|
+
stub([
|
|
49
|
+
{ json: PROJ },
|
|
50
|
+
{
|
|
51
|
+
json: {
|
|
52
|
+
mode: 'embedding',
|
|
53
|
+
candidates: [
|
|
54
|
+
{ id: 't42', ticketKey: 'ACME-42', title: 'Existing auth work', statusName: 'In Progress', statusColor: '#fc0', statusCategory: 'in_progress', similarity: 0.94, matchMode: 'embedding' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
const res = await makeCheckSimilarHandler(client)({
|
|
60
|
+
projectKey: 'ACME', title: 'Authentication overhaul', description: 'rewrite SSO',
|
|
61
|
+
});
|
|
62
|
+
const sc = res.structuredContent;
|
|
63
|
+
expect(sc.recommendation).toMatch(/HIGH-SIMILARITY MATCH FOUND/);
|
|
64
|
+
expect(sc.recommendation).toContain('ACME-42');
|
|
65
|
+
});
|
|
66
|
+
it('returns the medium recommendation when the top hit is < 0.9', async () => {
|
|
67
|
+
stub([
|
|
68
|
+
{ json: PROJ },
|
|
69
|
+
{
|
|
70
|
+
json: {
|
|
71
|
+
mode: 'tsvector',
|
|
72
|
+
candidates: [
|
|
73
|
+
{ id: 't9', ticketKey: 'ACME-9', title: 'Loose match', statusName: 'Done', statusColor: '#7d7', statusCategory: 'done', similarity: 0.55, matchMode: 'tsvector' },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
const res = await makeCheckSimilarHandler(client)({
|
|
79
|
+
projectKey: 'ACME', title: 'Unrelated effort',
|
|
80
|
+
});
|
|
81
|
+
const sc = res.structuredContent;
|
|
82
|
+
expect(sc.recommendation).toMatch(/Possible related tickets/);
|
|
83
|
+
});
|
|
84
|
+
it('passes description through to the query string when provided', async () => {
|
|
85
|
+
const calls = stub([
|
|
86
|
+
{ json: PROJ },
|
|
87
|
+
{ json: { candidates: [], mode: 'tsvector' } },
|
|
88
|
+
]);
|
|
89
|
+
await makeCheckSimilarHandler(client)({
|
|
90
|
+
projectKey: 'ACME', title: 'foo', description: 'some long body',
|
|
91
|
+
});
|
|
92
|
+
expect(calls[1].url).toContain('description=some+long+body');
|
|
93
|
+
});
|
|
94
|
+
it('surfaces a 403 from the API as an OrbotoApiError', async () => {
|
|
95
|
+
stub([
|
|
96
|
+
{ json: PROJ },
|
|
97
|
+
{ ok: false, status: 403, json: { error: 'Forbidden - missing ticket:create' } },
|
|
98
|
+
]);
|
|
99
|
+
await expect(makeCheckSimilarHandler(client)({ projectKey: 'ACME', title: 'x' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase C Group 3 - checklist write tools (ORB-234).
|
|
3
|
+
*
|
|
4
|
+
* Four tools:
|
|
5
|
+
* - orboto_check / orboto_uncheck - toggle a single item's `isCompleted`
|
|
6
|
+
* - orboto_add_check - append a new item to a list (default: first list
|
|
7
|
+
* on the ticket)
|
|
8
|
+
* - orboto_new_checklist - create a fresh list with optional triggers-done
|
|
9
|
+
*
|
|
10
|
+
* Item identifier: agents prefer 1-based indexes ("check item 3 on
|
|
11
|
+
* ACME-42") because UUIDs are ergonomic disasters in a chat. The
|
|
12
|
+
* helper resolves either form by reading the ticket's checklists
|
|
13
|
+
* once. UUID input is honoured directly so callers that already have
|
|
14
|
+
* the ID can skip the lookup.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
18
|
+
import { resolveTicketByKey } from './shared.js';
|
|
19
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve an item identifier (1-based index OR UUID) to its UUID.
|
|
22
|
+
* Index is global across all checklists on the ticket - same model
|
|
23
|
+
* the wrapper's `check ORB-X 3` uses, so the agent's mental model
|
|
24
|
+
* is stable across the two surfaces.
|
|
25
|
+
*/
|
|
26
|
+
async function resolveItemId(client, ticketId, identifier) {
|
|
27
|
+
const checklists = await client.get(`/tickets/${ticketId}/checklists`);
|
|
28
|
+
if (typeof identifier === 'string' && UUID_RE.test(identifier)) {
|
|
29
|
+
return { itemId: identifier, checklists };
|
|
30
|
+
}
|
|
31
|
+
const idx = typeof identifier === 'number' ? identifier : parseInt(identifier, 10);
|
|
32
|
+
if (!Number.isFinite(idx) || idx < 1) {
|
|
33
|
+
throw new Error(`Invalid item identifier "${identifier}" - must be a 1-based index or a UUID.`);
|
|
34
|
+
}
|
|
35
|
+
const flat = checklists.flatMap((cl) => cl.items);
|
|
36
|
+
const target = flat[idx - 1];
|
|
37
|
+
if (!target) {
|
|
38
|
+
throw new Error(`Item index ${idx} is out of range - ticket has ${flat.length} items.`);
|
|
39
|
+
}
|
|
40
|
+
return { itemId: target.id, checklists };
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// orboto_check / orboto_uncheck
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
function makeToggleHandler(client, completed) {
|
|
46
|
+
return async ({ ticketKey, item }) => {
|
|
47
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
48
|
+
const { itemId, checklists } = await resolveItemId(client, ticket.id, item);
|
|
49
|
+
const before = checklists.flatMap((c) => c.items).find((i) => i.id === itemId);
|
|
50
|
+
await client.patch(`/checklist-items/${itemId}`, { isCompleted: completed });
|
|
51
|
+
return {
|
|
52
|
+
content: [{
|
|
53
|
+
type: 'text',
|
|
54
|
+
text: `${completed ? 'Checked' : 'Unchecked'} on [${ticket.ticketKey}]: ${before?.content ?? '(item)'}`,
|
|
55
|
+
}],
|
|
56
|
+
structuredContent: {
|
|
57
|
+
ticketKey: ticket.ticketKey,
|
|
58
|
+
itemId,
|
|
59
|
+
completed,
|
|
60
|
+
content: before?.content ?? null,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const toggleInputSchema = z.object({
|
|
66
|
+
ticketKey: z.string().min(3),
|
|
67
|
+
item: z.union([
|
|
68
|
+
z.number().int().min(1).describe('1-based index, global across all checklists on this ticket'),
|
|
69
|
+
z.string().describe('Item UUID - for callers that already have it'),
|
|
70
|
+
]),
|
|
71
|
+
}).shape;
|
|
72
|
+
export const checkToolConfig = {
|
|
73
|
+
title: 'Check a checklist item',
|
|
74
|
+
description: 'Mark a checklist item as completed. `item` is either a 1-based index (count across all checklists on the ticket, top to bottom) or the item\'s UUID. Linked-ticket items are checked automatically when the linked ticket transitions to `done` - checking those manually is ignored.',
|
|
75
|
+
inputSchema: toggleInputSchema,
|
|
76
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
77
|
+
};
|
|
78
|
+
export const uncheckToolConfig = {
|
|
79
|
+
title: 'Uncheck a checklist item',
|
|
80
|
+
description: 'Mark a checklist item as not-completed. Same `item` semantics as orboto_check.',
|
|
81
|
+
inputSchema: toggleInputSchema,
|
|
82
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
83
|
+
};
|
|
84
|
+
export const makeCheckHandler = (client) => makeToggleHandler(client, true);
|
|
85
|
+
export const makeUncheckHandler = (client) => makeToggleHandler(client, false);
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// orboto_remove_check (ORB-1095) - agent parity for DELETE
|
|
88
|
+
// /checklist-items/:id. The surfaces could append but not remove.
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
export const removeCheckToolConfig = {
|
|
91
|
+
title: 'Remove a checklist item',
|
|
92
|
+
description: 'Delete a checklist item from a ticket. `item` is either a 1-based index (global across all checklists on the ticket, top to bottom) or the item\'s UUID - same semantics as orboto_check. Destructive: the item is gone, not just unchecked.',
|
|
93
|
+
inputSchema: toggleInputSchema,
|
|
94
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
95
|
+
};
|
|
96
|
+
export function makeRemoveCheckHandler(client) {
|
|
97
|
+
return async ({ ticketKey, item }) => {
|
|
98
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
99
|
+
const { itemId, checklists } = await resolveItemId(client, ticket.id, item);
|
|
100
|
+
const before = checklists.flatMap((c) => c.items).find((i) => i.id === itemId);
|
|
101
|
+
await client.delete(`/checklist-items/${itemId}`);
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: 'text', text: `Removed from [${ticket.ticketKey}]: ${before?.content ?? '(item)'}` }],
|
|
104
|
+
structuredContent: { ticketKey: ticket.ticketKey, itemId, removed: true, content: before?.content ?? null },
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// orboto_add_check
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
export const addCheckToolConfig = {
|
|
112
|
+
title: 'Add a checklist item',
|
|
113
|
+
description: 'Append an item to a checklist on a ticket. By default appends to the FIRST checklist on the ticket; pass `listTitle` to target a specific one. Pass `linkedTicketKey` to make the item track another ticket\'s status (the new item\'s `effectiveCompleted` mirrors that ticket\'s done-status from then on).',
|
|
114
|
+
inputSchema: z.object({
|
|
115
|
+
ticketKey: z.string().min(3),
|
|
116
|
+
content: z.string().min(1).max(2000),
|
|
117
|
+
listTitle: z.string().optional().describe('Target checklist title. Default: first checklist on the ticket.'),
|
|
118
|
+
linkedTicketKey: z.string().optional().describe('Make this item track another ticket\'s done-status (e.g. "ACME-99").'),
|
|
119
|
+
}).shape,
|
|
120
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
121
|
+
};
|
|
122
|
+
export function makeAddCheckHandler(client) {
|
|
123
|
+
return async ({ ticketKey, content, listTitle, linkedTicketKey }) => {
|
|
124
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
125
|
+
const checklists = await client.get(`/tickets/${ticket.id}/checklists`);
|
|
126
|
+
if (checklists.length === 0) {
|
|
127
|
+
throw new Error(`[${ticket.ticketKey}] has no checklists yet. Create one with orboto_new_checklist first.`);
|
|
128
|
+
}
|
|
129
|
+
const target = listTitle
|
|
130
|
+
? checklists.find((c) => c.title === listTitle)
|
|
131
|
+
: checklists[0];
|
|
132
|
+
if (!target) {
|
|
133
|
+
throw new Error(`Checklist "${listTitle}" not found on [${ticket.ticketKey}]. Lists on this ticket: ${checklists.map((c) => `"${c.title}"`).join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
const body = { content };
|
|
136
|
+
if (linkedTicketKey) {
|
|
137
|
+
const linked = await resolveTicketByKey(client, linkedTicketKey);
|
|
138
|
+
body.linkedTicketId = linked.id;
|
|
139
|
+
}
|
|
140
|
+
const item = await client.post(`/tickets/${ticket.id}/checklists/${target.id}/items`, body);
|
|
141
|
+
return {
|
|
142
|
+
content: [{
|
|
143
|
+
type: 'text',
|
|
144
|
+
text: `Added to "${target.title}" on [${ticket.ticketKey}]: ${content}${linkedTicketKey ? ` ↪ [${linkedTicketKey}]` : ''}`,
|
|
145
|
+
}],
|
|
146
|
+
structuredContent: {
|
|
147
|
+
ticketKey: ticket.ticketKey,
|
|
148
|
+
listTitle: target.title,
|
|
149
|
+
itemId: item.id,
|
|
150
|
+
content: item.content,
|
|
151
|
+
linkedTicketKey: linkedTicketKey ?? null,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// orboto_new_checklist
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
export const newChecklistToolConfig = {
|
|
160
|
+
title: 'Create a new checklist on a ticket',
|
|
161
|
+
description: 'Create a fresh checklist on a ticket. `triggersDone=true` gates the ticket\'s auto-done transition on this list (the ticket can\'t move to done until every item here is checked). Optional `items` seed the list with content in one call - saves a round-trip per item.',
|
|
162
|
+
inputSchema: z.object({
|
|
163
|
+
ticketKey: z.string().min(3),
|
|
164
|
+
title: z.string().min(1).max(300),
|
|
165
|
+
triggersDone: z.boolean().optional().describe('Default: false. Set true to gate the ticket\'s done-transition on this list.'),
|
|
166
|
+
items: z.array(z.string().min(1).max(2000)).max(100).optional().describe('Seed items in one call.'),
|
|
167
|
+
}).shape,
|
|
168
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
169
|
+
};
|
|
170
|
+
export function makeNewChecklistHandler(client) {
|
|
171
|
+
return async ({ ticketKey, title, triggersDone, items }) => {
|
|
172
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
173
|
+
const body = { title };
|
|
174
|
+
if (triggersDone)
|
|
175
|
+
body.triggersDone = true;
|
|
176
|
+
if (items && items.length > 0)
|
|
177
|
+
body.items = items.map((content) => ({ content }));
|
|
178
|
+
let list;
|
|
179
|
+
try {
|
|
180
|
+
list = await client.post(`/tickets/${ticket.id}/checklists`, body);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (err instanceof OrbotoApiError && err.status === 403) {
|
|
184
|
+
throw new Error(`Forbidden - you need ticket:edit on [${ticket.ticketKey}]'s project to add checklists.`);
|
|
185
|
+
}
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
content: [{
|
|
190
|
+
type: 'text',
|
|
191
|
+
text: `Created checklist "${title}" on [${ticket.ticketKey}]${triggersDone ? ' (triggers done)' : ''} with ${list.items.length} item(s).`,
|
|
192
|
+
}],
|
|
193
|
+
structuredContent: {
|
|
194
|
+
ticketKey: ticket.ticketKey,
|
|
195
|
+
checklistId: list.id,
|
|
196
|
+
title: list.title,
|
|
197
|
+
triggersDone: list.triggersDone,
|
|
198
|
+
itemCount: list.items.length,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase C Group 3 - checklist write tool tests.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { makeCheckHandler, makeUncheckHandler, makeAddCheckHandler, makeNewChecklistHandler, makeRemoveCheckHandler, } from './checklist-writes.js';
|
|
7
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
function stub(responses) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
12
|
+
const u = url.toString();
|
|
13
|
+
const m = init?.method ?? 'GET';
|
|
14
|
+
const b = init?.body ? JSON.parse(init.body) : undefined;
|
|
15
|
+
calls.push({ url: u, method: m, body: b });
|
|
16
|
+
const r = responses.shift();
|
|
17
|
+
if (!r)
|
|
18
|
+
throw new Error(`unexpected extra fetch ${m} ${u}`);
|
|
19
|
+
return {
|
|
20
|
+
ok: r.ok ?? true,
|
|
21
|
+
status: r.status ?? 200,
|
|
22
|
+
statusText: 'OK',
|
|
23
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
24
|
+
text: async () => '',
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
return calls;
|
|
28
|
+
}
|
|
29
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
30
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
31
|
+
const TICKET = { id: 't1', projectId: 'p1', ticketKey: 'ACME-1', title: 'Bug' };
|
|
32
|
+
const SAMPLE_LIST = {
|
|
33
|
+
id: 'cl1', title: 'Before ship', triggersDone: false,
|
|
34
|
+
progress: { done: 0, total: 2 },
|
|
35
|
+
items: [
|
|
36
|
+
{ id: 'i1', content: 'Tests', storedCompleted: false, effectiveCompleted: false, linkedTicketId: null, linkedTicketKey: null, sortOrder: 0 },
|
|
37
|
+
{ id: 'i2', content: 'Docs', storedCompleted: false, effectiveCompleted: false, linkedTicketId: null, linkedTicketKey: null, sortOrder: 1 },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
describe('orboto_check / orboto_uncheck', () => {
|
|
41
|
+
it('check resolves a 1-based index → item UUID, then PATCHes isCompleted=true', async () => {
|
|
42
|
+
const calls = stub([
|
|
43
|
+
{ json: PROJ },
|
|
44
|
+
{ json: TICKET },
|
|
45
|
+
{ json: [SAMPLE_LIST] }, // GET checklists for index lookup
|
|
46
|
+
{ json: { id: 'i2', isCompleted: true } }, // PATCH
|
|
47
|
+
]);
|
|
48
|
+
await makeCheckHandler(client)({ ticketKey: 'ACME-1', item: 2 });
|
|
49
|
+
expect(calls[3].method).toBe('PATCH');
|
|
50
|
+
expect(calls[3].url).toContain('/checklist-items/i2');
|
|
51
|
+
expect(calls[3].body).toEqual({ isCompleted: true });
|
|
52
|
+
});
|
|
53
|
+
it('check accepts a UUID directly without re-resolving', async () => {
|
|
54
|
+
const calls = stub([
|
|
55
|
+
{ json: PROJ },
|
|
56
|
+
{ json: TICKET },
|
|
57
|
+
{ json: [SAMPLE_LIST] },
|
|
58
|
+
{ json: { id: 'i2', isCompleted: true } },
|
|
59
|
+
]);
|
|
60
|
+
await makeCheckHandler(client)({
|
|
61
|
+
ticketKey: 'ACME-1',
|
|
62
|
+
item: '00000000-0000-0000-0000-000000000099',
|
|
63
|
+
});
|
|
64
|
+
expect(calls[3].url).toContain('/checklist-items/00000000-0000-0000-0000-000000000099');
|
|
65
|
+
});
|
|
66
|
+
it('uncheck PATCHes isCompleted=false', async () => {
|
|
67
|
+
const calls = stub([
|
|
68
|
+
{ json: PROJ },
|
|
69
|
+
{ json: TICKET },
|
|
70
|
+
{ json: [SAMPLE_LIST] },
|
|
71
|
+
{ json: { id: 'i1', isCompleted: false } },
|
|
72
|
+
]);
|
|
73
|
+
await makeUncheckHandler(client)({ ticketKey: 'ACME-1', item: 1 });
|
|
74
|
+
expect(calls[3].body).toEqual({ isCompleted: false });
|
|
75
|
+
});
|
|
76
|
+
it('rejects an out-of-range index with a clear count', async () => {
|
|
77
|
+
stub([
|
|
78
|
+
{ json: PROJ },
|
|
79
|
+
{ json: TICKET },
|
|
80
|
+
{ json: [SAMPLE_LIST] }, // 2 items
|
|
81
|
+
]);
|
|
82
|
+
await expect(makeCheckHandler(client)({ ticketKey: 'ACME-1', item: 99 })).rejects.toThrow(/out of range - ticket has 2 items/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('orboto_remove_check (ORB-1095)', () => {
|
|
86
|
+
it('resolves a 1-based index → item UUID, then DELETEs it', async () => {
|
|
87
|
+
const calls = stub([
|
|
88
|
+
{ json: PROJ },
|
|
89
|
+
{ json: TICKET },
|
|
90
|
+
{ json: [SAMPLE_LIST] }, // GET checklists for index lookup
|
|
91
|
+
{ json: {} }, // DELETE
|
|
92
|
+
]);
|
|
93
|
+
const res = await makeRemoveCheckHandler(client)({ ticketKey: 'ACME-1', item: 2 });
|
|
94
|
+
expect(calls[3].method).toBe('DELETE');
|
|
95
|
+
expect(calls[3].url).toContain('/checklist-items/i2');
|
|
96
|
+
expect(res.structuredContent).toMatchObject({ itemId: 'i2', removed: true, content: 'Docs' });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('orboto_add_check', () => {
|
|
100
|
+
it('appends to the FIRST list by default', async () => {
|
|
101
|
+
const calls = stub([
|
|
102
|
+
{ json: PROJ },
|
|
103
|
+
{ json: TICKET },
|
|
104
|
+
{ json: [SAMPLE_LIST] },
|
|
105
|
+
{ json: { id: 'i3', content: 'New item' } },
|
|
106
|
+
]);
|
|
107
|
+
await makeAddCheckHandler(client)({ ticketKey: 'ACME-1', content: 'New item' });
|
|
108
|
+
expect(calls[3].method).toBe('POST');
|
|
109
|
+
expect(calls[3].url).toContain('/tickets/t1/checklists/cl1/items');
|
|
110
|
+
expect(calls[3].body).toEqual({ content: 'New item' });
|
|
111
|
+
});
|
|
112
|
+
it('targets a named list when listTitle is supplied', async () => {
|
|
113
|
+
const calls = stub([
|
|
114
|
+
{ json: PROJ },
|
|
115
|
+
{ json: TICKET },
|
|
116
|
+
{ json: [
|
|
117
|
+
SAMPLE_LIST,
|
|
118
|
+
{ id: 'cl2', title: 'After ship', triggersDone: false, progress: { done: 0, total: 0 }, items: [] },
|
|
119
|
+
] },
|
|
120
|
+
{ json: { id: 'i9', content: 'Notify users' } },
|
|
121
|
+
]);
|
|
122
|
+
await makeAddCheckHandler(client)({
|
|
123
|
+
ticketKey: 'ACME-1', content: 'Notify users', listTitle: 'After ship',
|
|
124
|
+
});
|
|
125
|
+
expect(calls[3].url).toContain('/checklists/cl2/items');
|
|
126
|
+
});
|
|
127
|
+
it('resolves linkedTicketKey → linkedTicketId for cross-ticket items', async () => {
|
|
128
|
+
const calls = stub([
|
|
129
|
+
{ json: PROJ }, // resolveTicketByKey: project (parent ACME-1)
|
|
130
|
+
{ json: TICKET }, // resolveTicketByKey: ticket (parent)
|
|
131
|
+
{ json: [SAMPLE_LIST] }, // GET checklists
|
|
132
|
+
{ json: PROJ }, // resolveTicketByKey: project (linked ACME-99)
|
|
133
|
+
{ json: { id: 't99', projectId: 'p1', ticketKey: 'ACME-99', title: 'Race fix' } }, // resolveTicketByKey: ticket (linked)
|
|
134
|
+
{ json: { id: 'i9', content: 'Sub-task', linkedTicketId: 't99' } }, // POST item
|
|
135
|
+
]);
|
|
136
|
+
await makeAddCheckHandler(client)({
|
|
137
|
+
ticketKey: 'ACME-1', content: 'Sub-task', linkedTicketKey: 'ACME-99',
|
|
138
|
+
});
|
|
139
|
+
expect(calls[5].body).toEqual({ content: 'Sub-task', linkedTicketId: 't99' });
|
|
140
|
+
});
|
|
141
|
+
it('throws a clear error when the ticket has no checklists yet', async () => {
|
|
142
|
+
stub([
|
|
143
|
+
{ json: PROJ },
|
|
144
|
+
{ json: TICKET },
|
|
145
|
+
{ json: [] },
|
|
146
|
+
]);
|
|
147
|
+
await expect(makeAddCheckHandler(client)({ ticketKey: 'ACME-1', content: 'x' })).rejects.toThrow(/no checklists yet.*orboto_new_checklist/);
|
|
148
|
+
});
|
|
149
|
+
it('lists existing list titles when the requested one does not match', async () => {
|
|
150
|
+
stub([
|
|
151
|
+
{ json: PROJ },
|
|
152
|
+
{ json: TICKET },
|
|
153
|
+
{ json: [SAMPLE_LIST] }, // title is "Before ship"
|
|
154
|
+
]);
|
|
155
|
+
await expect(makeAddCheckHandler(client)({ ticketKey: 'ACME-1', content: 'x', listTitle: 'Wrong' })).rejects.toThrow(/"Before ship"/);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('orboto_new_checklist', () => {
|
|
159
|
+
it('POSTs title + optional triggersDone + seeded items', async () => {
|
|
160
|
+
const calls = stub([
|
|
161
|
+
{ json: PROJ },
|
|
162
|
+
{ json: TICKET },
|
|
163
|
+
{ json: { id: 'cl3', title: 'New', triggersDone: true, progress: { done: 0, total: 2 }, items: [{ id: 'i1' }, { id: 'i2' }] } },
|
|
164
|
+
]);
|
|
165
|
+
await makeNewChecklistHandler(client)({
|
|
166
|
+
ticketKey: 'ACME-1', title: 'New', triggersDone: true, items: ['a', 'b'],
|
|
167
|
+
});
|
|
168
|
+
expect(calls[2].method).toBe('POST');
|
|
169
|
+
expect(calls[2].url).toContain('/tickets/t1/checklists');
|
|
170
|
+
expect(calls[2].body).toEqual({
|
|
171
|
+
title: 'New',
|
|
172
|
+
triggersDone: true,
|
|
173
|
+
items: [{ content: 'a' }, { content: 'b' }],
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
it('rewrites a 403 from the API into a permission-clarifying error', async () => {
|
|
177
|
+
stub([
|
|
178
|
+
{ json: PROJ },
|
|
179
|
+
{ json: TICKET },
|
|
180
|
+
{ ok: false, status: 403, json: { error: 'Forbidden' } },
|
|
181
|
+
]);
|
|
182
|
+
await expect(makeNewChecklistHandler(client)({ ticketKey: 'ACME-1', title: 'x' })).rejects.toThrow(/ticket:edit/);
|
|
183
|
+
});
|
|
184
|
+
});
|