@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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - `orboto_attach_to_ticket` unit tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers upload-only, embed=true (re-PATCHes description), and the
|
|
5
|
+
* empty-base64 refusal. The multipart wire format is opaque to JSON
|
|
6
|
+
* inspection so we just assert the request shape (route, method,
|
|
7
|
+
* FormData body).
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
11
|
+
import { makeAttachToTicketHandler } from './attach.js';
|
|
12
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
13
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
14
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
15
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
16
|
+
const TICKET = {
|
|
17
|
+
id: 't1', projectId: 'p1', ticketKey: 'ACME-1', ticketNumber: 1,
|
|
18
|
+
title: 'Bug', description: 'existing body', status: 'TODO', statusName: 'To Do',
|
|
19
|
+
statusCategory: 'todo', type: 'bug', priority: 'normal',
|
|
20
|
+
estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
|
|
21
|
+
};
|
|
22
|
+
const ATTACHMENT = {
|
|
23
|
+
id: 'att1', filename: 'logo.png', mimetype: 'image/png',
|
|
24
|
+
sizeBytes: 5, downloadUrl: '/attachments/att1',
|
|
25
|
+
};
|
|
26
|
+
function makeFetchMock(stages) {
|
|
27
|
+
const calls = [];
|
|
28
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
29
|
+
const ct = init?.headers
|
|
30
|
+
? init.headers['Content-Type']
|
|
31
|
+
: undefined;
|
|
32
|
+
let parsedBody;
|
|
33
|
+
if (typeof init?.body === 'string' && ct?.startsWith('application/json')) {
|
|
34
|
+
parsedBody = JSON.parse(init.body);
|
|
35
|
+
}
|
|
36
|
+
calls.push({
|
|
37
|
+
url: url.toString(),
|
|
38
|
+
method: init?.method ?? 'GET',
|
|
39
|
+
bodyType: init?.body?.constructor?.name ?? 'undefined',
|
|
40
|
+
body: parsedBody,
|
|
41
|
+
});
|
|
42
|
+
const r = stages.shift();
|
|
43
|
+
if (!r)
|
|
44
|
+
throw new Error('unexpected extra fetch');
|
|
45
|
+
return {
|
|
46
|
+
ok: r.ok ?? true,
|
|
47
|
+
status: r.status ?? 200,
|
|
48
|
+
statusText: 'OK',
|
|
49
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
50
|
+
text: async () => '',
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
return calls;
|
|
54
|
+
}
|
|
55
|
+
describe('orboto_attach_to_ticket', () => {
|
|
56
|
+
it('upload-only (embed=false) returns markdown line + URL', async () => {
|
|
57
|
+
const calls = makeFetchMock([
|
|
58
|
+
{ json: PROJ }, // resolveTicketByKey: project
|
|
59
|
+
{ json: TICKET }, // resolveTicketByKey: ticket
|
|
60
|
+
{ json: ATTACHMENT }, // multipart POST attachments
|
|
61
|
+
]);
|
|
62
|
+
const base64 = Buffer.from('PNGBYTES').toString('base64');
|
|
63
|
+
const res = await makeAttachToTicketHandler(client)({
|
|
64
|
+
ticketKey: 'ACME-1', filename: 'logo.png', contentBase64: base64,
|
|
65
|
+
});
|
|
66
|
+
const uploadCall = calls.find((c) => c.url.endsWith('/attachments'));
|
|
67
|
+
expect(uploadCall).toMatchObject({
|
|
68
|
+
method: 'POST',
|
|
69
|
+
bodyType: 'FormData',
|
|
70
|
+
});
|
|
71
|
+
expect(res.structuredContent).toMatchObject({
|
|
72
|
+
ticketKey: 'ACME-1',
|
|
73
|
+
attachmentId: 'att1',
|
|
74
|
+
url: '/attachments/att1',
|
|
75
|
+
markdown: '',
|
|
76
|
+
embedded: false,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it('embed=true re-fetches the ticket and PATCHes the description with appended markdown', async () => {
|
|
80
|
+
const calls = makeFetchMock([
|
|
81
|
+
{ json: PROJ },
|
|
82
|
+
{ json: TICKET },
|
|
83
|
+
{ json: ATTACHMENT }, // multipart upload
|
|
84
|
+
{ json: TICKET }, // GET ticket (current description)
|
|
85
|
+
{ json: { ...TICKET, description: 'existing body\n\n' } }, // PATCH
|
|
86
|
+
]);
|
|
87
|
+
const base64 = Buffer.from('PNGBYTES').toString('base64');
|
|
88
|
+
const res = await makeAttachToTicketHandler(client)({
|
|
89
|
+
ticketKey: 'ACME-1', filename: 'logo.png', contentBase64: base64, embed: true,
|
|
90
|
+
});
|
|
91
|
+
const patch = calls.find((c) => c.method === 'PATCH');
|
|
92
|
+
expect(patch?.body).toMatchObject({
|
|
93
|
+
description: 'existing body\n\n',
|
|
94
|
+
});
|
|
95
|
+
expect(res.structuredContent).toMatchObject({ embedded: true });
|
|
96
|
+
});
|
|
97
|
+
it('refuses an empty base64 payload', async () => {
|
|
98
|
+
await expect(makeAttachToTicketHandler(client)({
|
|
99
|
+
ticketKey: 'ACME-1', filename: 'empty.txt', contentBase64: '',
|
|
100
|
+
})).rejects.toThrow();
|
|
101
|
+
});
|
|
102
|
+
it('honors a custom altText', async () => {
|
|
103
|
+
makeFetchMock([
|
|
104
|
+
{ json: PROJ },
|
|
105
|
+
{ json: TICKET },
|
|
106
|
+
{ json: ATTACHMENT },
|
|
107
|
+
]);
|
|
108
|
+
const base64 = Buffer.from('PNGBYTES').toString('base64');
|
|
109
|
+
const res = await makeAttachToTicketHandler(client)({
|
|
110
|
+
ticketKey: 'ACME-1', filename: 'logo.png', contentBase64: base64,
|
|
111
|
+
altText: 'Brand mark for orboto',
|
|
112
|
+
});
|
|
113
|
+
expect(res.structuredContent).toMatchObject({
|
|
114
|
+
markdown: '',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1301 - backup MCP tools. Four-way parity with the skill/UI, which can
|
|
3
|
+
* create an on-demand full backup and download the ZIP. The MCP previously only
|
|
4
|
+
* had orboto_trigger_backup (run a pre-configured named job).
|
|
5
|
+
*
|
|
6
|
+
* Binary archives are returned as MCP `resource` content attachments (base64
|
|
7
|
+
* blob + mimeType + uri), exactly like orboto_export_doc_pdf (ORB-915) - no new
|
|
8
|
+
* download mechanism. `OrbotoClient.postBinary` / `getBinary` fetch the bytes.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
const mb = (n) => `${Math.round((n / 1024 / 1024) * 10) / 10} MB`;
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// orboto_create_full_backup
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export const createFullBackupToolConfig = {
|
|
16
|
+
title: 'Create + download a full workspace backup',
|
|
17
|
+
description: 'Create an on-demand FULL workspace backup (database + storage) and return the ZIP as a base64 application/zip resource attachment - create, wait and download in one call (the server runs the export as a background run and this tool polls it; large workspaces can take minutes). Requires admin:backup:export. For the scheduled named jobs use orboto_trigger_backup instead.',
|
|
18
|
+
inputSchema: z.object({}).shape,
|
|
19
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
20
|
+
};
|
|
21
|
+
export function makeCreateFullBackupHandler(client) {
|
|
22
|
+
return async () => {
|
|
23
|
+
// ORB-1717 - the export is async server-side (the old synchronous
|
|
24
|
+
// stream aborted silently under load). Enqueue, poll the run row to a
|
|
25
|
+
// terminal state, then download the stored artifact - so an aborted
|
|
26
|
+
// wait never yields a partial ZIP.
|
|
27
|
+
const { runId } = await client.post('/admin/backup/full', {});
|
|
28
|
+
const t0 = Date.now();
|
|
29
|
+
for (;;) {
|
|
30
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
31
|
+
const run = await client.get(`/admin/backup/runs/${runId}`);
|
|
32
|
+
if (run.status === 'success')
|
|
33
|
+
break;
|
|
34
|
+
if (run.status === 'failed')
|
|
35
|
+
throw new Error(`backup export failed: ${run.errorMessage ?? 'unknown error'}`);
|
|
36
|
+
if (Date.now() - t0 > 30 * 60_000)
|
|
37
|
+
throw new Error(`backup export still running after 30 min - check run ${runId} in the backups list`);
|
|
38
|
+
}
|
|
39
|
+
const { bytes, contentType } = await client.getBinary(`/admin/backup/runs/${runId}/download`);
|
|
40
|
+
const base64 = Buffer.from(bytes).toString('base64');
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{ type: 'resource', resource: { uri: 'orboto://backup/full.zip', mimeType: contentType, blob: base64 } },
|
|
44
|
+
{ type: 'text', text: `Created full workspace backup (${mb(bytes.byteLength)}), run ${runId}.` },
|
|
45
|
+
],
|
|
46
|
+
structuredContent: { sizeBytes: bytes.byteLength, contentType, runId },
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// orboto_list_backups
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
export const listBackupsToolConfig = {
|
|
54
|
+
title: 'List backup runs',
|
|
55
|
+
description: 'List recent backup runs (id, status, size, time) so you can pick one to download with orboto_download_backup. Requires admin:backup:read.',
|
|
56
|
+
inputSchema: z.object({}).shape,
|
|
57
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
58
|
+
};
|
|
59
|
+
export function makeListBackupsHandler(client) {
|
|
60
|
+
return async () => {
|
|
61
|
+
const page = await client.get('/admin/backup/runs');
|
|
62
|
+
const items = page.items ?? [];
|
|
63
|
+
const lines = items.map((r) => `- ${r.id} - ${r.status}${r.fileSizeBytes ? ` (${mb(r.fileSizeBytes)})` : ''} - ${r.completedAt ?? r.startedAt}`);
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: 'text', text: items.length ? `Backup runs:\n${lines.join('\n')}` : 'No backup runs yet.' }],
|
|
66
|
+
structuredContent: { runs: items },
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// orboto_download_backup
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
export const downloadBackupToolConfig = {
|
|
74
|
+
title: 'Download a stored backup run',
|
|
75
|
+
description: 'Download a stored backup run\'s ZIP by run id (from orboto_list_backups - e.g. the nightly job\'s run) as a base64 application/zip resource attachment. Requires admin:backup:read.',
|
|
76
|
+
inputSchema: z.object({
|
|
77
|
+
runId: z.string().uuid().describe('Backup run id from orboto_list_backups.'),
|
|
78
|
+
}).shape,
|
|
79
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
80
|
+
};
|
|
81
|
+
export function makeDownloadBackupHandler(client) {
|
|
82
|
+
return async ({ runId }) => {
|
|
83
|
+
const { bytes, contentType } = await client.getBinary(`/admin/backup/runs/${runId}/download`);
|
|
84
|
+
const base64 = Buffer.from(bytes).toString('base64');
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{ type: 'resource', resource: { uri: `orboto://backup/run-${runId}.zip`, mimeType: contentType, blob: base64 } },
|
|
88
|
+
{ type: 'text', text: `Downloaded backup run ${runId.slice(0, 8)} (${mb(bytes.byteLength)}).` },
|
|
89
|
+
],
|
|
90
|
+
structuredContent: { runId, sizeBytes: bytes.byteLength, contentType },
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1301 - backup tool tests.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { makeCreateFullBackupHandler, makeListBackupsHandler, makeDownloadBackupHandler, } from './backup.js';
|
|
7
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
10
|
+
function stubBinary(bytes, contentType = 'application/zip') {
|
|
11
|
+
const calls = [];
|
|
12
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
13
|
+
calls.push({ url: url.toString(), method: init?.method ?? 'GET' });
|
|
14
|
+
return {
|
|
15
|
+
ok: true, status: 200, statusText: 'OK',
|
|
16
|
+
headers: new Headers({ 'content-type': contentType }),
|
|
17
|
+
text: async () => '',
|
|
18
|
+
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
|
19
|
+
json: async () => { throw new Error('not json'); },
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
return calls;
|
|
23
|
+
}
|
|
24
|
+
function stubJson(body) {
|
|
25
|
+
const calls = [];
|
|
26
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
27
|
+
calls.push({ url: url.toString(), method: init?.method ?? 'GET' });
|
|
28
|
+
return {
|
|
29
|
+
ok: true, status: 200, statusText: 'OK',
|
|
30
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
31
|
+
json: async () => body,
|
|
32
|
+
text: async () => JSON.stringify(body),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
return calls;
|
|
36
|
+
}
|
|
37
|
+
describe('orboto_create_full_backup (ORB-1301 / async since ORB-1717)', () => {
|
|
38
|
+
it('enqueues, polls the run to success, then downloads the stored ZIP', async () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
const zip = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // PK\x03\x04
|
|
41
|
+
const calls = [];
|
|
42
|
+
let polls = 0;
|
|
43
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
44
|
+
const u = url.toString();
|
|
45
|
+
calls.push({ url: u, method: init?.method ?? 'GET' });
|
|
46
|
+
if (u.endsWith('/admin/backup/full')) {
|
|
47
|
+
return { ok: true, status: 202, statusText: 'Accepted', headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ runId: 'run-1' }), text: async () => '' };
|
|
48
|
+
}
|
|
49
|
+
if (u.includes('/admin/backup/runs/run-1/download')) {
|
|
50
|
+
return { ok: true, status: 200, statusText: 'OK', headers: new Headers({ 'content-type': 'application/zip' }), arrayBuffer: async () => zip.buffer.slice(0), text: async () => '' };
|
|
51
|
+
}
|
|
52
|
+
// poll: running twice, then success
|
|
53
|
+
polls += 1;
|
|
54
|
+
return { ok: true, status: 200, statusText: 'OK', headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ status: polls < 3 ? 'running' : 'success', errorMessage: null }), text: async () => '' };
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
const pending = makeCreateFullBackupHandler(client)();
|
|
58
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
59
|
+
const res = await pending;
|
|
60
|
+
expect(calls[0].method).toBe('POST');
|
|
61
|
+
expect(calls[0].url).toContain('/admin/backup/full');
|
|
62
|
+
const resource = res.content.find((c) => c.type === 'resource');
|
|
63
|
+
expect(resource.resource.mimeType).toBe('application/zip');
|
|
64
|
+
expect(Buffer.from(resource.resource.blob, 'base64')).toEqual(Buffer.from(zip));
|
|
65
|
+
expect(res.structuredContent.runId).toBe('run-1');
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
vi.useRealTimers();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
it('a failed run surfaces the error instead of a partial ZIP', async () => {
|
|
72
|
+
vi.useFakeTimers();
|
|
73
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, _init) => {
|
|
74
|
+
const u = url.toString();
|
|
75
|
+
if (u.endsWith('/admin/backup/full')) {
|
|
76
|
+
return { ok: true, status: 202, statusText: 'Accepted', headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ runId: 'run-2' }), text: async () => '' };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, status: 200, statusText: 'OK', headers: new Headers({ 'content-type': 'application/json' }), json: async () => ({ status: 'failed', errorMessage: 'disk full' }), text: async () => '' };
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
const pending = makeCreateFullBackupHandler(client)();
|
|
82
|
+
const assertion = expect(pending).rejects.toThrow('disk full');
|
|
83
|
+
await vi.advanceTimersByTimeAsync(6_000);
|
|
84
|
+
await assertion;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
vi.useRealTimers();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('orboto_list_backups (ORB-1301)', () => {
|
|
92
|
+
it('GETs /admin/backup/runs and returns the runs', async () => {
|
|
93
|
+
const calls = stubJson({ items: [{ id: 'r1', status: 'success', fileSizeBytes: 1048576, completedAt: 'now' }] });
|
|
94
|
+
const res = await makeListBackupsHandler(client)();
|
|
95
|
+
expect(calls[0].url).toContain('/admin/backup/runs');
|
|
96
|
+
expect(res.structuredContent.runs).toHaveLength(1);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('orboto_download_backup (ORB-1301)', () => {
|
|
100
|
+
it('GETs the run download and returns the ZIP as a resource', async () => {
|
|
101
|
+
const zip = new Uint8Array([0x50, 0x4b]);
|
|
102
|
+
const calls = stubBinary(zip);
|
|
103
|
+
const res = await makeDownloadBackupHandler(client)({ runId: 'r1' });
|
|
104
|
+
expect(calls[0].method).toBe('GET');
|
|
105
|
+
expect(calls[0].url).toContain('/admin/backup/runs/r1/download');
|
|
106
|
+
expect(res.structuredContent.runId).toBe('r1');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1694 - bulk create + bulk dependency writes.
|
|
3
|
+
*
|
|
4
|
+
* Measured over 32 transcripts: `orboto_create_ticket` ran in 48
|
|
5
|
+
* consecutive-call clusters (longest 49 in a row) and
|
|
6
|
+
* `orboto_add_ticket_dependency` in 13 (longest 27) - the two longest
|
|
7
|
+
* runs in the whole tool corpus, each call paying a full round trip
|
|
8
|
+
* plus a ~1.1k-character response into the calling agent's context.
|
|
9
|
+
* These two tools collapse a milestone-planning session into one call
|
|
10
|
+
* with ONE compact aggregated response.
|
|
11
|
+
*
|
|
12
|
+
* Same family contract as bulk-writes.ts: serial per-item against the
|
|
13
|
+
* existing single-item REST endpoints (per-tenant rate-limit posture),
|
|
14
|
+
* per-item error reporting - one bad item never drops the rest - and a
|
|
15
|
+
* `{successful, failed}` outcome the model can branch on. Duplicate
|
|
16
|
+
* findings are reported COMPACTLY: one line per flagged draft, never
|
|
17
|
+
* the single-tool's full warning block (pairs with ORB-1693).
|
|
18
|
+
*/
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
21
|
+
import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
|
|
22
|
+
import { resolveMilestoneByNameOrId } from './milestones.js';
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// orboto_bulk_create_tickets
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const TicketDraftSchema = z.object({
|
|
27
|
+
title: z.string().min(1).max(255),
|
|
28
|
+
description: z.string().optional(),
|
|
29
|
+
type: z.enum(['task', 'bug', 'story', 'epic']).optional().describe('Default: task.'),
|
|
30
|
+
priority: z.enum(['blocker', 'high', 'normal', 'low', 'trivial']).optional().describe('Default: normal.'),
|
|
31
|
+
deliveryMode: z.enum(['implementation', 'docs', 'review', 'admin', 'epic']).optional(),
|
|
32
|
+
milestone: z.string().optional().describe('Key ("ORB-M3"), name, or UUID; overrides the call-level one.'),
|
|
33
|
+
assigneeEmails: z.array(z.string().email()).optional(),
|
|
34
|
+
labels: z.array(z.string()).optional().describe('Existing label names.'),
|
|
35
|
+
parentTicketKey: z.string().optional(),
|
|
36
|
+
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
37
|
+
isPrivate: z.boolean().optional(),
|
|
38
|
+
});
|
|
39
|
+
export const bulkCreateTicketsToolConfig = {
|
|
40
|
+
title: 'Create many tickets in one call',
|
|
41
|
+
description: 'Create up to 50 tickets in one call - the right tool whenever you are about to create more than ~3 tickets (milestone planning, epic breakdown, import). Per-draft error isolation: a rejected draft (bad label, language block, hard duplicate-block) is reported in `failed` while the rest are still created. Duplicate detection runs per draft and is reported compactly - one line per flagged draft in `duplicateFlags`; review those before treating them as new work. A call-level `milestone` / `parentTicketKey` applies to every draft unless the draft overrides it. Drafts are created in array order, so a draft can NOT reference a sibling draft as parent - create parents first (or in a first call).',
|
|
42
|
+
inputSchema: z.object({
|
|
43
|
+
projectKey: z.string().min(1).describe('Project key - all drafts land here.'),
|
|
44
|
+
tickets: z.array(TicketDraftSchema).min(1).max(50).describe('The ticket drafts, created in order.'),
|
|
45
|
+
milestone: z.string().optional().describe('Applied to every draft without its own.'),
|
|
46
|
+
parentTicketKey: z.string().optional().describe('Applied to every draft without its own.'),
|
|
47
|
+
allowLanguageMismatch: z.boolean().optional().describe('Override the language block for every draft.'),
|
|
48
|
+
}).shape,
|
|
49
|
+
outputSchema: z.object({
|
|
50
|
+
created: z.array(z.string()).describe('Keys, in draft order.'),
|
|
51
|
+
failed: z.array(z.object({ index: z.number().int(), title: z.string(), error: z.string() })),
|
|
52
|
+
duplicateFlags: z.array(z.object({
|
|
53
|
+
key: z.string().describe('The new, flagged ticket.'),
|
|
54
|
+
matches: z.array(z.object({ ticketKey: z.string().nullable(), similarity: z.number() })),
|
|
55
|
+
})),
|
|
56
|
+
languageWarnings: z.number().int().describe('Drafts with a language warning.'),
|
|
57
|
+
}).shape,
|
|
58
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
59
|
+
};
|
|
60
|
+
export function makeBulkCreateTicketsHandler(client) {
|
|
61
|
+
return async (input) => {
|
|
62
|
+
const project = await resolveProjectByKey(client, input.projectKey);
|
|
63
|
+
// Resolve shared + per-draft milestone/parent references ONCE per
|
|
64
|
+
// distinct value - 20 drafts on one milestone = one lookup.
|
|
65
|
+
const milestoneIds = new Map();
|
|
66
|
+
const resolveMilestone = async (ref) => {
|
|
67
|
+
const cached = milestoneIds.get(ref);
|
|
68
|
+
if (cached)
|
|
69
|
+
return cached;
|
|
70
|
+
const id = (await resolveMilestoneByNameOrId(client, project.id, ref)).id;
|
|
71
|
+
milestoneIds.set(ref, id);
|
|
72
|
+
return id;
|
|
73
|
+
};
|
|
74
|
+
const parentIds = new Map();
|
|
75
|
+
const resolveParent = async (key) => {
|
|
76
|
+
const cached = parentIds.get(key);
|
|
77
|
+
if (cached)
|
|
78
|
+
return cached;
|
|
79
|
+
const id = (await resolveTicketByKey(client, key)).id;
|
|
80
|
+
parentIds.set(key, id);
|
|
81
|
+
return id;
|
|
82
|
+
};
|
|
83
|
+
const created = [];
|
|
84
|
+
const failed = [];
|
|
85
|
+
const duplicateFlags = [];
|
|
86
|
+
let languageWarnings = 0;
|
|
87
|
+
for (let i = 0; i < input.tickets.length; i++) {
|
|
88
|
+
const draft = input.tickets[i];
|
|
89
|
+
try {
|
|
90
|
+
const body = {
|
|
91
|
+
title: draft.title,
|
|
92
|
+
description: draft.description ?? null,
|
|
93
|
+
type: draft.type ?? 'task',
|
|
94
|
+
priority: draft.priority ?? 'normal',
|
|
95
|
+
isPrivate: draft.isPrivate ?? false,
|
|
96
|
+
...(draft.deliveryMode ? { deliveryMode: draft.deliveryMode } : {}),
|
|
97
|
+
...(draft.dueDate ? { dueDate: draft.dueDate } : {}),
|
|
98
|
+
...(draft.labels?.length ? { labelNames: draft.labels } : {}),
|
|
99
|
+
...(draft.assigneeEmails?.length ? { assigneeEmails: draft.assigneeEmails } : {}),
|
|
100
|
+
};
|
|
101
|
+
const milestoneRef = draft.milestone ?? input.milestone;
|
|
102
|
+
if (milestoneRef)
|
|
103
|
+
body.milestoneId = await resolveMilestone(milestoneRef);
|
|
104
|
+
const parentRef = draft.parentTicketKey ?? input.parentTicketKey;
|
|
105
|
+
if (parentRef)
|
|
106
|
+
body.parentTicketId = await resolveParent(parentRef);
|
|
107
|
+
const qs = input.allowLanguageMismatch ? '?allowLanguageMismatch=true' : '';
|
|
108
|
+
const res = await client.post(`/projects/${project.id}/tickets${qs}`, body);
|
|
109
|
+
created.push(res.ticketKey ?? res.id);
|
|
110
|
+
if (res.languageWarning)
|
|
111
|
+
languageWarnings++;
|
|
112
|
+
const warnings = res.similarWarnings ?? [];
|
|
113
|
+
if (warnings.length > 0) {
|
|
114
|
+
duplicateFlags.push({
|
|
115
|
+
key: res.ticketKey ?? res.id,
|
|
116
|
+
matches: warnings.slice(0, 3).map((w) => ({
|
|
117
|
+
ticketKey: w.ticketKey,
|
|
118
|
+
similarity: Math.round(w.similarity * 100) / 100,
|
|
119
|
+
})),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
const msg = err instanceof OrbotoApiError
|
|
125
|
+
? `${err.status}: ${err.message}`
|
|
126
|
+
: err instanceof Error ? err.message : String(err);
|
|
127
|
+
failed.push({ index: i, title: draft.title.slice(0, 60), error: msg.slice(0, 200) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const lines = [
|
|
131
|
+
`bulk_create - ${created.length} created, ${failed.length} failed${duplicateFlags.length ? `, ${duplicateFlags.length} flagged as possible duplicates` : ''}${languageWarnings ? `, ${languageWarnings} language warnings` : ''}.`,
|
|
132
|
+
];
|
|
133
|
+
if (created.length > 0)
|
|
134
|
+
lines.push(`Created: ${created.join(', ')}`);
|
|
135
|
+
for (const f of duplicateFlags) {
|
|
136
|
+
lines.push(`⚠ ${f.key} may duplicate ${f.matches.map((m) => `${m.ticketKey ?? '?'} (${m.similarity})`).join(', ')} - review before treating as new work.`);
|
|
137
|
+
}
|
|
138
|
+
for (const f of failed) {
|
|
139
|
+
lines.push(`✗ draft ${f.index} "${f.title}": ${f.error}`);
|
|
140
|
+
}
|
|
141
|
+
if (languageWarnings > 0) {
|
|
142
|
+
lines.push(`⚠ ${languageWarnings} draft(s) in a non-workspace language - consider rewriting for search/duplicate consistency.`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
146
|
+
structuredContent: { created, failed, duplicateFlags, languageWarnings },
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// orboto_bulk_add_ticket_dependencies
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
export const bulkAddTicketDependenciesToolConfig = {
|
|
154
|
+
title: 'Add many ticket dependencies in one call',
|
|
155
|
+
description: 'Create up to 200 blocked-by edges in one call - use instead of repeated orboto_add_ticket_dependency whenever wiring more than ~3 edges (dependency graphs after a bulk create). Each pair is (ticketKey, dependsOnKey) = "ticket is blocked by dependsOn". Per-pair error isolation; an already-existing edge counts as ok, a cycle rejection lands in `failed` with the API\'s reason.',
|
|
156
|
+
inputSchema: z.object({
|
|
157
|
+
pairs: z.array(z.object({
|
|
158
|
+
ticketKey: z.string().min(3).describe('The blocked ticket.'),
|
|
159
|
+
dependsOnKey: z.string().min(3).describe('The blocking ticket.'),
|
|
160
|
+
})).min(1).max(200),
|
|
161
|
+
}).shape,
|
|
162
|
+
outputSchema: z.object({
|
|
163
|
+
successful: z.array(z.string()).describe('"A->B" pairs written (or already present).'),
|
|
164
|
+
failed: z.array(z.object({ pair: z.string(), error: z.string() })),
|
|
165
|
+
}).shape,
|
|
166
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
167
|
+
};
|
|
168
|
+
export function makeBulkAddTicketDependenciesHandler(client) {
|
|
169
|
+
return async (input) => {
|
|
170
|
+
// Resolve every distinct key once, not once per edge.
|
|
171
|
+
const resolved = new Map();
|
|
172
|
+
const resolve = async (key) => {
|
|
173
|
+
const cached = resolved.get(key);
|
|
174
|
+
if (cached)
|
|
175
|
+
return cached;
|
|
176
|
+
const t = await resolveTicketByKey(client, key);
|
|
177
|
+
resolved.set(key, t);
|
|
178
|
+
return t;
|
|
179
|
+
};
|
|
180
|
+
const successful = [];
|
|
181
|
+
const failed = [];
|
|
182
|
+
for (const { ticketKey, dependsOnKey } of input.pairs) {
|
|
183
|
+
const label = `${ticketKey}->${dependsOnKey}`;
|
|
184
|
+
try {
|
|
185
|
+
const ticket = await resolve(ticketKey);
|
|
186
|
+
const dependsOn = await resolve(dependsOnKey);
|
|
187
|
+
try {
|
|
188
|
+
await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies`, { dependsOnId: dependsOn.id });
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
// 409 = edge already exists - idempotent success, same as the
|
|
192
|
+
// single-edge tool.
|
|
193
|
+
if (!(err instanceof OrbotoApiError && err.status === 409))
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
successful.push(label);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
const msg = err instanceof OrbotoApiError
|
|
200
|
+
? `${err.status}: ${err.message}`
|
|
201
|
+
: err instanceof Error ? err.message : String(err);
|
|
202
|
+
failed.push({ pair: label, error: msg.slice(0, 200) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const lines = [`bulk_add_dependencies - ${successful.length} ok, ${failed.length} failed.`];
|
|
206
|
+
for (const f of failed)
|
|
207
|
+
lines.push(`✗ ${f.pair}: ${f.error}`);
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
210
|
+
structuredContent: { successful, failed },
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1694 - bulk create + bulk dependency writes.
|
|
3
|
+
*
|
|
4
|
+
* Pins the family contract: per-item error isolation (one bad draft
|
|
5
|
+
* never drops the rest), compact one-line duplicate flags, reference
|
|
6
|
+
* resolution cached per distinct value, 409-on-existing-edge counted
|
|
7
|
+
* as success - and the acceptance criterion that a 20-item bulk create
|
|
8
|
+
* answers in ONE response under ~2k characters.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
12
|
+
import { makeBulkCreateTicketsHandler, makeBulkAddTicketDependenciesHandler, } from './bulk-create.js';
|
|
13
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
14
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
15
|
+
/** Route-aware fetch stub: project + milestone resolution, then per-draft
|
|
16
|
+
* create responses driven by the draft title. */
|
|
17
|
+
function stubApi(opts = {}) {
|
|
18
|
+
const calls = [];
|
|
19
|
+
let seq = 0;
|
|
20
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
21
|
+
const u = String(url);
|
|
22
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
23
|
+
calls.push({ method: init?.method ?? 'GET', url: u, body });
|
|
24
|
+
const json = (payload, status = 200) => ({
|
|
25
|
+
ok: status < 400, status, statusText: 'X',
|
|
26
|
+
json: async () => payload, text: async () => JSON.stringify(payload),
|
|
27
|
+
});
|
|
28
|
+
if (u.includes('/projects/by-key/')) {
|
|
29
|
+
return json({ id: 'proj-1', key: 'ACME', name: 'Acme' });
|
|
30
|
+
}
|
|
31
|
+
if (u.includes('/milestones')) {
|
|
32
|
+
return json([{ id: 'ms-1', name: 'Sprint 1', milestoneKey: 'ACME-M1' }]);
|
|
33
|
+
}
|
|
34
|
+
if (u.includes('/tickets') && init?.method === 'POST') {
|
|
35
|
+
const title = body.title;
|
|
36
|
+
if (opts.failTitles?.includes(title)) {
|
|
37
|
+
return json({ error: 'label not found' }, 400);
|
|
38
|
+
}
|
|
39
|
+
seq++;
|
|
40
|
+
return json({
|
|
41
|
+
id: `t-${seq}`, ticketKey: `ACME-${seq}`, title,
|
|
42
|
+
status: 'TODO', statusName: 'Todo', type: 'task', priority: 'normal',
|
|
43
|
+
...(opts.dupTitles?.includes(title)
|
|
44
|
+
? { similarWarnings: [{ id: 'x', ticketKey: 'ACME-99', title: 'existing', similarity: 0.87, matchMode: 'embedding' }] }
|
|
45
|
+
: {}),
|
|
46
|
+
}, 201);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`unexpected fetch ${u}`);
|
|
49
|
+
});
|
|
50
|
+
return calls;
|
|
51
|
+
}
|
|
52
|
+
describe('orboto_bulk_create_tickets', () => {
|
|
53
|
+
it('20 drafts -> one compact response under 2k chars, milestone resolved once', async () => {
|
|
54
|
+
const calls = stubApi();
|
|
55
|
+
const handler = makeBulkCreateTicketsHandler(client);
|
|
56
|
+
const result = await handler({
|
|
57
|
+
projectKey: 'ACME',
|
|
58
|
+
milestone: 'Sprint 1',
|
|
59
|
+
tickets: Array.from({ length: 20 }, (_, i) => ({ title: `Task number ${i + 1}` })),
|
|
60
|
+
});
|
|
61
|
+
const sc = result.structuredContent;
|
|
62
|
+
expect(sc.created).toHaveLength(20);
|
|
63
|
+
expect(sc.failed).toHaveLength(0);
|
|
64
|
+
const text = result.content[0].text;
|
|
65
|
+
expect(text.length).toBeLessThan(2000);
|
|
66
|
+
expect(JSON.stringify(result.structuredContent).length).toBeLessThan(2000);
|
|
67
|
+
// Milestone resolved once, not once per draft.
|
|
68
|
+
expect(calls.filter((c) => c.url.includes('/milestones')).length).toBe(1);
|
|
69
|
+
// Every create carried the resolved milestone id.
|
|
70
|
+
const creates = calls.filter((c) => c.method === 'POST' && c.url.includes('/tickets'));
|
|
71
|
+
expect(creates).toHaveLength(20);
|
|
72
|
+
expect(creates.every((c) => c.body.milestoneId === 'ms-1')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
it('per-draft isolation: a failed draft is reported while the rest are created; duplicates flag compactly', async () => {
|
|
75
|
+
stubApi({ failTitles: ['broken draft'], dupTitles: ['maybe dup'] });
|
|
76
|
+
const handler = makeBulkCreateTicketsHandler(client);
|
|
77
|
+
const result = await handler({
|
|
78
|
+
projectKey: 'ACME',
|
|
79
|
+
tickets: [{ title: 'good one' }, { title: 'broken draft' }, { title: 'maybe dup' }],
|
|
80
|
+
});
|
|
81
|
+
const sc = result.structuredContent;
|
|
82
|
+
expect(sc.created).toHaveLength(2);
|
|
83
|
+
expect(sc.failed).toEqual([expect.objectContaining({ index: 1, title: 'broken draft' })]);
|
|
84
|
+
expect(sc.duplicateFlags).toHaveLength(1);
|
|
85
|
+
expect(sc.duplicateFlags[0].matches[0]).toMatchObject({ ticketKey: 'ACME-99', similarity: 0.87 });
|
|
86
|
+
const text = result.content[0].text;
|
|
87
|
+
expect(text).toContain('may duplicate ACME-99');
|
|
88
|
+
expect(text).toContain('draft 1 "broken draft"');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('orboto_bulk_add_ticket_dependencies', () => {
|
|
92
|
+
it('resolves each key once, treats an existing edge (409) as ok, isolates per-pair failures', async () => {
|
|
93
|
+
const calls = [];
|
|
94
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
95
|
+
const u = String(url);
|
|
96
|
+
calls.push({ method: init?.method ?? 'GET', url: u, body: init?.body ? JSON.parse(init.body) : undefined });
|
|
97
|
+
const json = (payload, status = 200) => ({
|
|
98
|
+
ok: status < 400, status, statusText: 'X',
|
|
99
|
+
json: async () => payload, text: async () => JSON.stringify(payload),
|
|
100
|
+
});
|
|
101
|
+
if (u.includes('/projects/by-key/')) {
|
|
102
|
+
return json({ id: 'proj-1', key: 'ACME', name: 'Acme' });
|
|
103
|
+
}
|
|
104
|
+
if (u.includes('/tickets/by-key/')) {
|
|
105
|
+
const num = decodeURIComponent(u.split('/tickets/by-key/')[1]);
|
|
106
|
+
if (num === '404')
|
|
107
|
+
return json({ error: 'not found' }, 404);
|
|
108
|
+
return json({ id: `id-ACME-${num}`, ticketKey: `ACME-${num}`, projectId: 'proj-1', title: `ACME-${num}`, status: 'TODO', type: 'task', priority: 'normal' });
|
|
109
|
+
}
|
|
110
|
+
if (u.includes('/dependencies') && init?.method === 'POST') {
|
|
111
|
+
const dependsOnId = JSON.parse(init.body).dependsOnId;
|
|
112
|
+
if (dependsOnId === 'id-ACME-1')
|
|
113
|
+
return json({ error: 'exists' }, 409);
|
|
114
|
+
return json({ ok: true }, 201);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`unexpected fetch ${u}`);
|
|
117
|
+
});
|
|
118
|
+
const handler = makeBulkAddTicketDependenciesHandler(client);
|
|
119
|
+
const result = await handler({
|
|
120
|
+
pairs: [
|
|
121
|
+
{ ticketKey: 'ACME-2', dependsOnKey: 'ACME-1' }, // 409 -> ok
|
|
122
|
+
{ ticketKey: 'ACME-3', dependsOnKey: 'ACME-2' }, // fresh edge
|
|
123
|
+
{ ticketKey: 'ACME-404', dependsOnKey: 'ACME-1' }, // unresolvable
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
const sc = result.structuredContent;
|
|
127
|
+
expect(sc.successful).toEqual(['ACME-2->ACME-1', 'ACME-3->ACME-2']);
|
|
128
|
+
expect(sc.failed).toHaveLength(1);
|
|
129
|
+
expect(sc.failed[0].pair).toBe('ACME-404->ACME-1');
|
|
130
|
+
// Distinct keys resolved once each (ACME-1, ACME-2, ACME-3, ACME-404).
|
|
131
|
+
expect(calls.filter((c) => c.url.includes('/tickets/by-key/')).length).toBe(4);
|
|
132
|
+
// 409 on the first edge write still counted as successful (idempotent).
|
|
133
|
+
expect(calls.filter((c) => c.method === 'POST' && c.url.includes('/dependencies')).length).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
});
|