@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,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase C Group 1 - ticket-mutation tool unit tests.
|
|
3
|
+
*
|
|
4
|
+
* One happy-path + one permission-denied test per tool. The mutation
|
|
5
|
+
* tools share the same resolution chain (project → ticket → optional
|
|
6
|
+
* member/milestone) so the bulk of the surface is covered by the
|
|
7
|
+
* happy paths; the 403 test on each pinpoint how the API's existing
|
|
8
|
+
* PBAC cascade surfaces through the MCP layer.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
12
|
+
import { makeCreateTicketHandler, makeUpdateTicketHandler, makeMoveTicketHandler, makeCloseTicketHandler, makeDeleteTicketHandler, makeCommentHandler, makeAssignHandler, makeUpdateCommentHandler, makeDeleteCommentHandler, makeUnassignHandler, makeSetMilestoneHandler, makeAddTicketDependencyHandler, makeRemoveTicketDependencyHandler, makeListTicketDependenciesHandler, makeLabelTicketHandler, makeUnlabelTicketHandler, } from './ticket-writes.js';
|
|
13
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
14
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
15
|
+
function stub(responses) {
|
|
16
|
+
const calls = [];
|
|
17
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
18
|
+
const u = url.toString();
|
|
19
|
+
const m = (init?.method ?? 'GET');
|
|
20
|
+
const b = init?.body ? JSON.parse(init.body) : undefined;
|
|
21
|
+
calls.push({ url: u, method: m, body: b });
|
|
22
|
+
const r = responses.shift();
|
|
23
|
+
if (!r)
|
|
24
|
+
throw new Error(`unexpected extra fetch ${m} ${u}`);
|
|
25
|
+
return {
|
|
26
|
+
ok: r.ok ?? true,
|
|
27
|
+
status: r.status ?? 200,
|
|
28
|
+
statusText: 'OK',
|
|
29
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
30
|
+
text: async () => '',
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
return calls;
|
|
34
|
+
}
|
|
35
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
36
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
37
|
+
const TICKET = {
|
|
38
|
+
id: 't1', projectId: 'p1', ticketKey: 'ACME-1', ticketNumber: 1,
|
|
39
|
+
title: 'Bug', description: null, status: 'TODO', statusName: 'To Do',
|
|
40
|
+
statusCategory: 'todo', type: 'bug', priority: 'normal',
|
|
41
|
+
estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
|
|
42
|
+
};
|
|
43
|
+
describe('orboto_create_ticket', () => {
|
|
44
|
+
it('creates with body fields, no extra calls when no labels/assignees', async () => {
|
|
45
|
+
const calls = stub([
|
|
46
|
+
{ json: PROJ },
|
|
47
|
+
{ json: { ...TICKET, ticketKey: 'ACME-7', title: 'New' } },
|
|
48
|
+
]);
|
|
49
|
+
const res = await makeCreateTicketHandler(client)({
|
|
50
|
+
projectKey: 'ACME', title: 'New', priority: 'high',
|
|
51
|
+
});
|
|
52
|
+
expect(calls[1].method).toBe('POST');
|
|
53
|
+
expect(calls[1].url).toBe('https://orboto.example.com/projects/p1/tickets');
|
|
54
|
+
expect(calls[1].body).toMatchObject({ title: 'New', priority: 'high', type: 'task' });
|
|
55
|
+
expect(res.content[0].text).toContain('Created: [ACME-7]');
|
|
56
|
+
});
|
|
57
|
+
it('attaches labels + assignees INLINE in the create body, with no separate attach POSTs (ORB-1416)', async () => {
|
|
58
|
+
const calls = stub([
|
|
59
|
+
{ json: PROJ }, // resolveProjectByKey
|
|
60
|
+
{ json: { ...TICKET, ticketKey: 'ACME-8', title: 'Labelled' } }, // POST create (the ONLY POST)
|
|
61
|
+
]);
|
|
62
|
+
await makeCreateTicketHandler(client)({
|
|
63
|
+
projectKey: 'ACME', title: 'Labelled', labels: ['bug', 'ui'],
|
|
64
|
+
assigneeEmails: ['dev@example.com'],
|
|
65
|
+
});
|
|
66
|
+
// Exactly one POST - the atomic create - carrying labels + assignees
|
|
67
|
+
// in its body. No follow-up /labels/ or /assignees/ round-trips, so a
|
|
68
|
+
// bad reference can't leave an orphan ticket the agent retries into a dup.
|
|
69
|
+
const posts = calls.filter((c) => c.method === 'POST');
|
|
70
|
+
expect(posts).toHaveLength(1);
|
|
71
|
+
expect(posts[0].url).toBe('https://orboto.example.com/projects/p1/tickets');
|
|
72
|
+
expect(posts[0].body).toMatchObject({
|
|
73
|
+
title: 'Labelled',
|
|
74
|
+
labelNames: ['bug', 'ui'],
|
|
75
|
+
assigneeEmails: ['dev@example.com'],
|
|
76
|
+
});
|
|
77
|
+
expect(calls.some((c) => c.url.includes('/labels/'))).toBe(false);
|
|
78
|
+
expect(calls.some((c) => c.url.includes('/assignees/'))).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
it('resolves milestone name + parent ticket key', async () => {
|
|
81
|
+
const calls = stub([
|
|
82
|
+
{ json: PROJ }, // resolveProjectByKey
|
|
83
|
+
{ json: [{ id: 'm1', name: 'v1' }] }, // resolveMilestoneId
|
|
84
|
+
{ json: PROJ }, // resolveTicketByKey: project
|
|
85
|
+
{ json: { ...TICKET, ticketKey: 'ACME-10' } }, // resolveTicketByKey: ticket
|
|
86
|
+
{ json: { ...TICKET, ticketKey: 'ACME-11' } }, // POST create
|
|
87
|
+
]);
|
|
88
|
+
await makeCreateTicketHandler(client)({
|
|
89
|
+
projectKey: 'ACME', title: 'sub', milestone: 'v1', parentTicketKey: 'ACME-10',
|
|
90
|
+
});
|
|
91
|
+
expect(calls[4].body).toMatchObject({ milestoneId: 'm1', parentTicketId: 't1' });
|
|
92
|
+
});
|
|
93
|
+
it('surfaces a 403 from the API as an OrbotoApiError', async () => {
|
|
94
|
+
stub([
|
|
95
|
+
{ json: PROJ },
|
|
96
|
+
{ ok: false, status: 403, json: { error: 'Forbidden - missing ticket:create' } },
|
|
97
|
+
]);
|
|
98
|
+
await expect(makeCreateTicketHandler(client)({ projectKey: 'ACME', title: 'x' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
99
|
+
});
|
|
100
|
+
it('surfaces similarWarnings from POST /tickets in both content + structuredContent (ORB-887)', async () => {
|
|
101
|
+
stub([
|
|
102
|
+
{ json: PROJ },
|
|
103
|
+
{
|
|
104
|
+
json: {
|
|
105
|
+
...TICKET, ticketKey: 'ACME-99', title: 'Auth breaks',
|
|
106
|
+
similarWarnings: [
|
|
107
|
+
{ id: 't42', ticketKey: 'ACME-42', title: 'Authentication breaks for SAML', statusName: 'In Progress', statusColor: '#fc0', statusCategory: 'in_progress', similarity: 0.91, matchMode: 'embedding' },
|
|
108
|
+
{ id: 't13', ticketKey: 'ACME-13', title: 'Auth flow regressed', statusName: 'Done', statusColor: '#7d7', statusCategory: 'done', similarity: 0.72, matchMode: 'tsvector' },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
const res = await makeCreateTicketHandler(client)({
|
|
114
|
+
projectKey: 'ACME', title: 'Auth breaks',
|
|
115
|
+
});
|
|
116
|
+
const text = res.content[0].text;
|
|
117
|
+
expect(text).toContain('Created: [ACME-99]');
|
|
118
|
+
expect(text).toContain('Potential duplicates found');
|
|
119
|
+
expect(text).toContain('ACME-42');
|
|
120
|
+
expect(text).toContain('91% AI match');
|
|
121
|
+
expect(text).toContain('ACME-13');
|
|
122
|
+
const sc = res.structuredContent;
|
|
123
|
+
expect(sc.similarWarnings).toHaveLength(2);
|
|
124
|
+
// ORB-1176 - createdTicketKey is the NEW key, never a warning's key.
|
|
125
|
+
expect(sc.createdTicketKey).toBe('ACME-99');
|
|
126
|
+
expect(sc.similarWarnings.map((w) => w.ticketKey)).not.toContain(sc.createdTicketKey);
|
|
127
|
+
// ORB-1693 - the agent projection is pinned: exactly these fields, no
|
|
128
|
+
// UUID, no colours, no statusName, similarity at 2dp. A re-grow of the
|
|
129
|
+
// rich shape is a regression, not an enhancement.
|
|
130
|
+
for (const w of sc.similarWarnings) {
|
|
131
|
+
expect(Object.keys(w).sort()).toEqual(['relation', 'similarity', 'statusCategory', 'ticketKey', 'title']);
|
|
132
|
+
}
|
|
133
|
+
expect(sc.similarWarnings[0]).toEqual({
|
|
134
|
+
ticketKey: 'ACME-42', title: 'Authentication breaks for SAML',
|
|
135
|
+
statusCategory: 'in_progress', similarity: 0.91, relation: null,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
it('returns no warning block when similarWarnings is empty', async () => {
|
|
139
|
+
stub([
|
|
140
|
+
{ json: PROJ },
|
|
141
|
+
{ json: { ...TICKET, ticketKey: 'ACME-100', similarWarnings: [] } },
|
|
142
|
+
]);
|
|
143
|
+
const res = await makeCreateTicketHandler(client)({
|
|
144
|
+
projectKey: 'ACME', title: 'totally unrelated work',
|
|
145
|
+
});
|
|
146
|
+
const text = res.content[0].text;
|
|
147
|
+
expect(text).not.toContain('duplicates');
|
|
148
|
+
expect(text).not.toContain('⚠');
|
|
149
|
+
});
|
|
150
|
+
it('surfaces languageWarning from POST /tickets in both content + structuredContent (ORB-891)', async () => {
|
|
151
|
+
stub([
|
|
152
|
+
{ json: PROJ },
|
|
153
|
+
{
|
|
154
|
+
json: {
|
|
155
|
+
...TICKET, ticketKey: 'ACME-101', title: 'Authentifizierung schlägt fehl',
|
|
156
|
+
similarWarnings: [],
|
|
157
|
+
languageWarning: { detected: 'de', expected: 'en' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
const res = await makeCreateTicketHandler(client)({
|
|
162
|
+
projectKey: 'ACME', title: 'Authentifizierung schlägt fehl',
|
|
163
|
+
});
|
|
164
|
+
const text = res.content[0].text;
|
|
165
|
+
expect(text).toContain('Created: [ACME-101]');
|
|
166
|
+
expect(text).toContain('Language mismatch');
|
|
167
|
+
expect(text).toContain('"de"');
|
|
168
|
+
expect(text).toContain('"en"');
|
|
169
|
+
const sc = res.structuredContent;
|
|
170
|
+
expect(sc.languageWarning).toEqual({ detected: 'de', expected: 'en' });
|
|
171
|
+
});
|
|
172
|
+
it('combines both similarWarnings and languageWarning when both are present (ORB-891)', async () => {
|
|
173
|
+
stub([
|
|
174
|
+
{ json: PROJ },
|
|
175
|
+
{
|
|
176
|
+
json: {
|
|
177
|
+
...TICKET, ticketKey: 'ACME-102', title: 'duplicate auth',
|
|
178
|
+
similarWarnings: [
|
|
179
|
+
{ id: 't1', ticketKey: 'ACME-1', title: 'Auth breaks', statusName: 'Done', statusColor: '#7d7', statusCategory: 'done', similarity: 0.92, matchMode: 'embedding' },
|
|
180
|
+
],
|
|
181
|
+
languageWarning: { detected: 'de', expected: 'en' },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
const res = await makeCreateTicketHandler(client)({
|
|
186
|
+
projectKey: 'ACME', title: 'duplicate auth',
|
|
187
|
+
});
|
|
188
|
+
const text = res.content[0].text;
|
|
189
|
+
expect(text).toContain('Potential duplicates found');
|
|
190
|
+
expect(text).toContain('Language mismatch');
|
|
191
|
+
const sc = res.structuredContent;
|
|
192
|
+
expect(sc.similarWarnings).toHaveLength(1);
|
|
193
|
+
expect(sc.languageWarning).toEqual({ detected: 'de', expected: 'en' });
|
|
194
|
+
});
|
|
195
|
+
it('turns a strict-language 422 into a clear block result, not a thrown error (ORB-990)', async () => {
|
|
196
|
+
const blockBody = JSON.stringify({
|
|
197
|
+
error: 'Ticket language (de) does not match the workspace language (en).',
|
|
198
|
+
languageWarning: { code: 'language_mismatch', severity: 'block', detected: 'de', expected: 'en' },
|
|
199
|
+
});
|
|
200
|
+
let createUrl = '';
|
|
201
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
202
|
+
const u = url.toString();
|
|
203
|
+
const m = init?.method ?? 'GET';
|
|
204
|
+
if (m === 'GET') {
|
|
205
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
|
|
206
|
+
}
|
|
207
|
+
createUrl = u; // the POST /tickets call
|
|
208
|
+
return { ok: false, status: 422, statusText: 'Unprocessable', json: async () => ({}), text: async () => blockBody };
|
|
209
|
+
});
|
|
210
|
+
const res = await makeCreateTicketHandler(client)({
|
|
211
|
+
projectKey: 'ACME', title: 'Authentifizierung schlägt fehl bei externen Nutzern',
|
|
212
|
+
});
|
|
213
|
+
// No override flag → no query param on the create call.
|
|
214
|
+
expect(createUrl).not.toContain('allowLanguageMismatch');
|
|
215
|
+
expect(res.isError).toBe(true);
|
|
216
|
+
const text = res.content[0].text;
|
|
217
|
+
expect(text).toContain('blocked');
|
|
218
|
+
expect(text).toContain('allowLanguageMismatch=true');
|
|
219
|
+
const sc = res.structuredContent;
|
|
220
|
+
expect(sc.blocked).toBe(true);
|
|
221
|
+
expect(sc.languageWarning.severity).toBe('block');
|
|
222
|
+
});
|
|
223
|
+
it('passes allowLanguageMismatch as a query param when the override is set (ORB-990)', async () => {
|
|
224
|
+
let createUrl = '';
|
|
225
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
226
|
+
const u = url.toString();
|
|
227
|
+
const m = init?.method ?? 'GET';
|
|
228
|
+
if (m === 'GET') {
|
|
229
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
|
|
230
|
+
}
|
|
231
|
+
createUrl = u;
|
|
232
|
+
return { ok: true, status: 201, statusText: 'Created', json: async () => ({ ...TICKET, ticketKey: 'ACME-9' }), text: async () => '' };
|
|
233
|
+
});
|
|
234
|
+
await makeCreateTicketHandler(client)({
|
|
235
|
+
projectKey: 'ACME', title: 'Authentifizierung schlägt fehl', allowLanguageMismatch: true,
|
|
236
|
+
});
|
|
237
|
+
expect(createUrl).toContain('allowLanguageMismatch=true');
|
|
238
|
+
});
|
|
239
|
+
// ORB-1471 - hard duplicate-block 409 becomes a clear duplicateBlocked
|
|
240
|
+
// result that surfaces the candidate list verbatim + the override recipe.
|
|
241
|
+
it('turns a hard duplicate-block 409 into a duplicateBlocked result listing the candidates (ORB-1471)', async () => {
|
|
242
|
+
const blockBody = JSON.stringify({
|
|
243
|
+
error: 'This ticket looks like a duplicate (98% match to an existing ticket).',
|
|
244
|
+
errorKey: 'errors.tickets.duplicate_blocked',
|
|
245
|
+
threshold: 0.9,
|
|
246
|
+
topSimilarity: 0.98,
|
|
247
|
+
similarWarnings: [
|
|
248
|
+
{ id: 't1', ticketKey: 'ACME-7', title: 'Webhook signature mismatch', statusName: 'To Do', statusColor: null, statusCategory: 'todo', similarity: 0.98, matchMode: 'tsvector' },
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
252
|
+
const m = init?.method ?? 'GET';
|
|
253
|
+
if (m === 'GET') {
|
|
254
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
|
|
255
|
+
}
|
|
256
|
+
return { ok: false, status: 409, statusText: 'Conflict', json: async () => ({}), text: async () => blockBody };
|
|
257
|
+
});
|
|
258
|
+
const res = await makeCreateTicketHandler(client)({ projectKey: 'ACME', title: 'Webhook signature mismatch' });
|
|
259
|
+
expect(res.isError).toBe(true);
|
|
260
|
+
const text = res.content[0].text;
|
|
261
|
+
expect(text).toContain('BLOCKED');
|
|
262
|
+
expect(text).toContain('ACME-7');
|
|
263
|
+
expect(text).toContain('allowDuplicate: true');
|
|
264
|
+
const sc = res.structuredContent;
|
|
265
|
+
expect(sc.duplicateBlocked).toBe(true);
|
|
266
|
+
expect(sc.similarWarnings).toHaveLength(1);
|
|
267
|
+
});
|
|
268
|
+
it('passes allowDuplicate as a query param and the justification in the body when overriding (ORB-1471)', async () => {
|
|
269
|
+
let createUrl = '';
|
|
270
|
+
let createBody = {};
|
|
271
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
272
|
+
const u = url.toString();
|
|
273
|
+
const m = init?.method ?? 'GET';
|
|
274
|
+
if (m === 'GET') {
|
|
275
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
|
|
276
|
+
}
|
|
277
|
+
createUrl = u;
|
|
278
|
+
createBody = init?.body ? JSON.parse(init.body) : {};
|
|
279
|
+
return { ok: true, status: 201, statusText: 'Created', json: async () => ({ ...TICKET, ticketKey: 'ACME-9' }), text: async () => '' };
|
|
280
|
+
});
|
|
281
|
+
await makeCreateTicketHandler(client)({
|
|
282
|
+
projectKey: 'ACME', title: 'Webhook signature mismatch',
|
|
283
|
+
allowDuplicate: true, duplicateJustification: 'Different provider - Stripe vs PayPal.',
|
|
284
|
+
});
|
|
285
|
+
expect(createUrl).toContain('allowDuplicate=true');
|
|
286
|
+
expect(createBody.duplicateJustification).toBe('Different provider - Stripe vs PayPal.');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
describe('orboto_update_ticket', () => {
|
|
290
|
+
it('PATCHes only the supplied fields, leaves others untouched', async () => {
|
|
291
|
+
const calls = stub([
|
|
292
|
+
{ json: PROJ },
|
|
293
|
+
{ json: TICKET },
|
|
294
|
+
{ json: { ...TICKET, title: 'Renamed' } },
|
|
295
|
+
]);
|
|
296
|
+
await makeUpdateTicketHandler(client)({
|
|
297
|
+
ticketKey: 'ACME-1',
|
|
298
|
+
patch: { title: 'Renamed', priority: 'high' },
|
|
299
|
+
});
|
|
300
|
+
expect(calls[2].method).toBe('PATCH');
|
|
301
|
+
expect(calls[2].body).toEqual({ title: 'Renamed', priority: 'high' });
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
describe('orboto_move_ticket', () => {
|
|
305
|
+
it('maps statusCategory → legacy status enum on the wire', async () => {
|
|
306
|
+
const calls = stub([
|
|
307
|
+
{ json: PROJ },
|
|
308
|
+
{ json: TICKET },
|
|
309
|
+
{ json: { ...TICKET, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
|
|
310
|
+
]);
|
|
311
|
+
await makeMoveTicketHandler(client)({ ticketKey: 'ACME-1', statusCategory: 'in_progress' });
|
|
312
|
+
expect(calls[2].body).toEqual({ status: 'IN_PROGRESS' });
|
|
313
|
+
});
|
|
314
|
+
it('surfaces summaryWarning from the move response in content + structuredContent (ORB-1332)', async () => {
|
|
315
|
+
stub([
|
|
316
|
+
{ json: PROJ },
|
|
317
|
+
{ json: TICKET },
|
|
318
|
+
{
|
|
319
|
+
json: {
|
|
320
|
+
...TICKET, status: 'DONE', statusName: 'Done', statusCategory: 'done',
|
|
321
|
+
summaryWarning: { code: 'missing_transition_summary', message: 'Ticket moved to done without a summary comment - post what changed, the commit SHA, and how to verify.' },
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
325
|
+
const res = await makeMoveTicketHandler(client)({ ticketKey: 'ACME-1', statusCategory: 'done' });
|
|
326
|
+
const text = res.content[0].text;
|
|
327
|
+
expect(text).toContain('Moved: [ACME-1]');
|
|
328
|
+
expect(text).toContain('without a summary comment');
|
|
329
|
+
const sc = res.structuredContent;
|
|
330
|
+
expect(sc.summaryWarning?.code).toBe('missing_transition_summary');
|
|
331
|
+
});
|
|
332
|
+
it('omits summaryWarning when the move response carries none', async () => {
|
|
333
|
+
stub([
|
|
334
|
+
{ json: PROJ },
|
|
335
|
+
{ json: TICKET },
|
|
336
|
+
{ json: { ...TICKET, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
|
|
337
|
+
]);
|
|
338
|
+
const res = await makeMoveTicketHandler(client)({ ticketKey: 'ACME-1', statusCategory: 'in_progress' });
|
|
339
|
+
expect(res.content[0].text).not.toContain('summary comment');
|
|
340
|
+
expect(res.structuredContent.summaryWarning).toBeUndefined();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
describe('orboto_close_ticket', () => {
|
|
344
|
+
it('posts the closing comment BEFORE the status move (audit-trail order)', async () => {
|
|
345
|
+
const calls = stub([
|
|
346
|
+
{ json: PROJ }, // resolveTicketByKey: project
|
|
347
|
+
{ json: TICKET }, // resolveTicketByKey: ticket
|
|
348
|
+
{ json: { id: 'c1', content: 'wrap-up', isInternal: false, createdAt: 'now' } },
|
|
349
|
+
{ json: { ...TICKET, status: 'DONE', statusName: 'Done', statusCategory: 'done' } },
|
|
350
|
+
]);
|
|
351
|
+
await makeCloseTicketHandler(client)({ ticketKey: 'ACME-1', comment: 'wrap-up' });
|
|
352
|
+
expect(calls[2].url).toContain('/tickets/t1/comments');
|
|
353
|
+
expect(calls[2].method).toBe('POST');
|
|
354
|
+
expect(calls[3].method).toBe('PATCH');
|
|
355
|
+
expect(calls[3].body).toEqual({ status: 'DONE' });
|
|
356
|
+
});
|
|
357
|
+
it('skips the comment call when none provided', async () => {
|
|
358
|
+
const calls = stub([
|
|
359
|
+
{ json: PROJ },
|
|
360
|
+
{ json: TICKET },
|
|
361
|
+
{ json: { ...TICKET, status: 'DONE' } },
|
|
362
|
+
]);
|
|
363
|
+
await makeCloseTicketHandler(client)({ ticketKey: 'ACME-1' });
|
|
364
|
+
expect(calls).toHaveLength(3);
|
|
365
|
+
expect(calls[2].method).toBe('PATCH');
|
|
366
|
+
});
|
|
367
|
+
it('surfaces summaryWarning when closing without a comment (ORB-1332)', async () => {
|
|
368
|
+
stub([
|
|
369
|
+
{ json: PROJ },
|
|
370
|
+
{ json: TICKET },
|
|
371
|
+
{
|
|
372
|
+
json: {
|
|
373
|
+
...TICKET, status: 'DONE', statusName: 'Done', statusCategory: 'done',
|
|
374
|
+
summaryWarning: { code: 'missing_transition_summary', message: 'Ticket moved to done without a summary comment - post what changed, the commit SHA, and how to verify.' },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
const res = await makeCloseTicketHandler(client)({ ticketKey: 'ACME-1' });
|
|
379
|
+
expect(res.content[0].text).toContain('without a summary comment');
|
|
380
|
+
expect(res.structuredContent.summaryWarning?.code).toBe('missing_transition_summary');
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
describe('orboto_delete_ticket', () => {
|
|
384
|
+
it('resolves the key then hard-DELETEs the ticket by id', async () => {
|
|
385
|
+
const calls = stub([
|
|
386
|
+
{ json: PROJ }, // resolveTicketByKey: project by-key
|
|
387
|
+
{ json: TICKET }, // resolveTicketByKey: ticket by-key
|
|
388
|
+
{ status: 204 }, // DELETE /projects/p1/tickets/t1
|
|
389
|
+
]);
|
|
390
|
+
const res = await makeDeleteTicketHandler(client)({ ticketKey: 'ACME-1' });
|
|
391
|
+
expect(calls).toHaveLength(3);
|
|
392
|
+
expect(calls[2].method).toBe('DELETE');
|
|
393
|
+
expect(calls[2].url).toContain('/projects/p1/tickets/t1');
|
|
394
|
+
expect(res.structuredContent).toMatchObject({ deleted: true, ticketKey: 'ACME-1', id: 't1' });
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
describe('orboto_comment', () => {
|
|
398
|
+
it('posts a regular (non-internal) comment by default', async () => {
|
|
399
|
+
const calls = stub([
|
|
400
|
+
{ json: PROJ },
|
|
401
|
+
{ json: TICKET },
|
|
402
|
+
{ json: { id: 'c1', content: 'hi', isInternal: false, createdAt: 'now' } },
|
|
403
|
+
]);
|
|
404
|
+
await makeCommentHandler(client)({ ticketKey: 'ACME-1', text: 'hi' });
|
|
405
|
+
expect(calls[2].body).toEqual({ content: 'hi', isInternal: false });
|
|
406
|
+
});
|
|
407
|
+
it('surfaces internal flag both on the wire and in the text response', async () => {
|
|
408
|
+
stub([
|
|
409
|
+
{ json: PROJ },
|
|
410
|
+
{ json: TICKET },
|
|
411
|
+
{ json: { id: 'c1', content: 'shh', isInternal: true, createdAt: 'now' } },
|
|
412
|
+
]);
|
|
413
|
+
const res = await makeCommentHandler(client)({
|
|
414
|
+
ticketKey: 'ACME-1', text: 'shh', isInternal: true,
|
|
415
|
+
});
|
|
416
|
+
expect(res.content[0].text).toContain('(internal)');
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
describe('orboto_update_comment / orboto_delete_comment (ORB-1285)', () => {
|
|
420
|
+
it('update PATCHes the comment by id with the new content', async () => {
|
|
421
|
+
const calls = stub([
|
|
422
|
+
{ json: PROJ },
|
|
423
|
+
{ json: TICKET },
|
|
424
|
+
{ json: { id: 'c1', content: 'fixed', isInternal: false, createdAt: 'now' } },
|
|
425
|
+
]);
|
|
426
|
+
await makeUpdateCommentHandler(client)({ ticketKey: 'ACME-1', commentId: 'c1', text: 'fixed' });
|
|
427
|
+
expect(calls[2].method).toBe('PATCH');
|
|
428
|
+
expect(calls[2].url).toContain('/tickets/t1/comments/c1');
|
|
429
|
+
expect(calls[2].body).toEqual({ content: 'fixed' });
|
|
430
|
+
});
|
|
431
|
+
it('delete DELETEs the comment by id', async () => {
|
|
432
|
+
const calls = stub([
|
|
433
|
+
{ json: PROJ },
|
|
434
|
+
{ json: TICKET },
|
|
435
|
+
{ json: {} },
|
|
436
|
+
]);
|
|
437
|
+
const res = await makeDeleteCommentHandler(client)({ ticketKey: 'ACME-1', commentId: 'c1' });
|
|
438
|
+
expect(calls[2].method).toBe('DELETE');
|
|
439
|
+
expect(calls[2].url).toContain('/tickets/t1/comments/c1');
|
|
440
|
+
expect(res.structuredContent.deleted).toBe(true);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
describe('orboto_assign / orboto_unassign', () => {
|
|
444
|
+
const MEMBERS = [{ userId: 'u1', user: { email: 'ada@acme', fullName: 'Ada' }, role: { name: 'developer' } }];
|
|
445
|
+
it('assign POSTs to /assignees/:userId', async () => {
|
|
446
|
+
const calls = stub([
|
|
447
|
+
{ json: PROJ },
|
|
448
|
+
{ json: TICKET },
|
|
449
|
+
{ json: MEMBERS },
|
|
450
|
+
{ status: 204 }, // POST returns 204
|
|
451
|
+
]);
|
|
452
|
+
await makeAssignHandler(client)({ ticketKey: 'ACME-1', assigneeEmail: 'ada@acme' });
|
|
453
|
+
expect(calls[3].method).toBe('POST');
|
|
454
|
+
expect(calls[3].url).toContain('/assignees/u1');
|
|
455
|
+
});
|
|
456
|
+
it('assign treats 409 as idempotent already-assigned', async () => {
|
|
457
|
+
stub([
|
|
458
|
+
{ json: PROJ },
|
|
459
|
+
{ json: TICKET },
|
|
460
|
+
{ json: MEMBERS },
|
|
461
|
+
{ ok: false, status: 409, json: { error: 'already assigned' } },
|
|
462
|
+
]);
|
|
463
|
+
const res = await makeAssignHandler(client)({ ticketKey: 'ACME-1', assigneeEmail: 'ada@acme' });
|
|
464
|
+
expect(res.content[0].text).toContain('already assigned');
|
|
465
|
+
});
|
|
466
|
+
it('unassign treats 404 as idempotent already-unassigned', async () => {
|
|
467
|
+
stub([
|
|
468
|
+
{ json: PROJ },
|
|
469
|
+
{ json: TICKET },
|
|
470
|
+
{ json: MEMBERS },
|
|
471
|
+
{ ok: false, status: 404, json: { error: 'not assigned' } },
|
|
472
|
+
]);
|
|
473
|
+
const res = await makeUnassignHandler(client)({ ticketKey: 'ACME-1', assigneeEmail: 'ada@acme' });
|
|
474
|
+
expect(res.content[0].text).toContain("wasn't assigned");
|
|
475
|
+
});
|
|
476
|
+
it('throws on missing project member', async () => {
|
|
477
|
+
stub([
|
|
478
|
+
{ json: PROJ },
|
|
479
|
+
{ json: TICKET },
|
|
480
|
+
{ json: [] },
|
|
481
|
+
]);
|
|
482
|
+
await expect(makeAssignHandler(client)({ ticketKey: 'ACME-1', assigneeEmail: 'ghost@acme' })).rejects.toThrow(/No project member with email/);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
describe('orboto_set_milestone', () => {
|
|
486
|
+
it('resolves milestone name + sends milestoneId', async () => {
|
|
487
|
+
const calls = stub([
|
|
488
|
+
{ json: PROJ },
|
|
489
|
+
{ json: TICKET },
|
|
490
|
+
{ json: [{ id: 'm1', name: 'v1' }] },
|
|
491
|
+
{ json: { ...TICKET, milestoneId: 'm1' } },
|
|
492
|
+
]);
|
|
493
|
+
await makeSetMilestoneHandler(client)({ ticketKey: 'ACME-1', milestone: 'v1' });
|
|
494
|
+
expect(calls[3].body).toEqual({ milestoneId: 'm1' });
|
|
495
|
+
});
|
|
496
|
+
it('null milestone clears the milestone', async () => {
|
|
497
|
+
const calls = stub([
|
|
498
|
+
{ json: PROJ },
|
|
499
|
+
{ json: TICKET },
|
|
500
|
+
{ json: { ...TICKET, milestoneId: null } },
|
|
501
|
+
]);
|
|
502
|
+
await makeSetMilestoneHandler(client)({ ticketKey: 'ACME-1', milestone: null });
|
|
503
|
+
expect(calls[2].body).toEqual({ milestoneId: null });
|
|
504
|
+
});
|
|
505
|
+
it('resolves a milestone passed as a UUID + sends that id (ORB-1058)', async () => {
|
|
506
|
+
const MS_UUID = '11111111-1111-1111-1111-111111111111';
|
|
507
|
+
const calls = stub([
|
|
508
|
+
{ json: PROJ },
|
|
509
|
+
{ json: TICKET },
|
|
510
|
+
{ json: [{ id: MS_UUID, name: 'v1' }] },
|
|
511
|
+
{ json: { ...TICKET, milestoneId: MS_UUID } },
|
|
512
|
+
]);
|
|
513
|
+
await makeSetMilestoneHandler(client)({ ticketKey: 'ACME-1', milestone: MS_UUID });
|
|
514
|
+
expect(calls[3].body).toEqual({ milestoneId: MS_UUID });
|
|
515
|
+
});
|
|
516
|
+
it('rejects an ambiguous milestone name listing every candidate UUID (ORB-1058)', async () => {
|
|
517
|
+
stub([
|
|
518
|
+
{ json: PROJ },
|
|
519
|
+
{ json: TICKET },
|
|
520
|
+
{ json: [{ id: 'm1', name: 'dup' }, { id: 'm2', name: 'dup' }] },
|
|
521
|
+
]);
|
|
522
|
+
let err;
|
|
523
|
+
try {
|
|
524
|
+
await makeSetMilestoneHandler(client)({ ticketKey: 'ACME-1', milestone: 'dup' });
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
err = e;
|
|
528
|
+
}
|
|
529
|
+
expect(err?.message).toMatch(/ambiguous/i);
|
|
530
|
+
expect(err?.message).toContain('m1');
|
|
531
|
+
expect(err?.message).toContain('m2');
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
describe('orboto_add_ticket_dependency / remove / list - ORB-453', () => {
|
|
535
|
+
const TICKET_B = { ...TICKET, id: 't2', ticketKey: 'ACME-2', ticketNumber: 2, title: 'Other' };
|
|
536
|
+
it('add resolves both ticket keys then POSTs dependsOnId', async () => {
|
|
537
|
+
const calls = stub([
|
|
538
|
+
{ json: PROJ }, // resolve ACME-1 project
|
|
539
|
+
{ json: TICKET }, // resolve ACME-1 ticket
|
|
540
|
+
{ json: PROJ }, // resolve ACME-2 project
|
|
541
|
+
{ json: TICKET_B }, // resolve ACME-2 ticket
|
|
542
|
+
{ status: 204 }, // POST /dependencies
|
|
543
|
+
]);
|
|
544
|
+
await makeAddTicketDependencyHandler(client)({ ticketKey: 'ACME-1', dependsOnKey: 'ACME-2' });
|
|
545
|
+
expect(calls[4].method).toBe('POST');
|
|
546
|
+
expect(calls[4].url).toContain(`/projects/p1/tickets/t1/dependencies`);
|
|
547
|
+
expect(calls[4].body).toEqual({ dependsOnId: 't2' });
|
|
548
|
+
});
|
|
549
|
+
it('add treats 409 as idempotent', async () => {
|
|
550
|
+
stub([
|
|
551
|
+
{ json: PROJ }, { json: TICKET },
|
|
552
|
+
{ json: PROJ }, { json: TICKET_B },
|
|
553
|
+
{ ok: false, status: 409, json: { error: 'edge already exists' } },
|
|
554
|
+
]);
|
|
555
|
+
const res = await makeAddTicketDependencyHandler(client)({ ticketKey: 'ACME-1', dependsOnKey: 'ACME-2' });
|
|
556
|
+
expect(res.content[0].text).toContain('already depends on');
|
|
557
|
+
});
|
|
558
|
+
it('remove sends DELETE on /dependencies/:dependsOnId', async () => {
|
|
559
|
+
const calls = stub([
|
|
560
|
+
{ json: PROJ }, { json: TICKET },
|
|
561
|
+
{ json: PROJ }, { json: TICKET_B },
|
|
562
|
+
{ status: 204 },
|
|
563
|
+
]);
|
|
564
|
+
await makeRemoveTicketDependencyHandler(client)({ ticketKey: 'ACME-1', dependsOnKey: 'ACME-2' });
|
|
565
|
+
expect(calls[4].method).toBe('DELETE');
|
|
566
|
+
expect(calls[4].url).toContain(`/projects/p1/tickets/t1/dependencies/t2`);
|
|
567
|
+
});
|
|
568
|
+
it('remove treats 404 as idempotent already-absent', async () => {
|
|
569
|
+
stub([
|
|
570
|
+
{ json: PROJ }, { json: TICKET },
|
|
571
|
+
{ json: PROJ }, { json: TICKET_B },
|
|
572
|
+
{ ok: false, status: 404, json: { error: 'not found' } },
|
|
573
|
+
]);
|
|
574
|
+
const res = await makeRemoveTicketDependencyHandler(client)({ ticketKey: 'ACME-1', dependsOnKey: 'ACME-2' });
|
|
575
|
+
expect(res.content[0].text).toContain("didn't depend on");
|
|
576
|
+
});
|
|
577
|
+
it('list returns both directions formatted', async () => {
|
|
578
|
+
stub([
|
|
579
|
+
{ json: PROJ }, { json: TICKET },
|
|
580
|
+
{ json: {
|
|
581
|
+
blockedBy: [{ id: 't2', ticketKey: 'ACME-2', title: 'Other', projectId: 'p1', statusName: 'In Progress', statusCategory: 'in_progress', external: false, resolved: false }],
|
|
582
|
+
blocks: [],
|
|
583
|
+
} },
|
|
584
|
+
]);
|
|
585
|
+
const res = await makeListTicketDependenciesHandler(client)({ ticketKey: 'ACME-1' });
|
|
586
|
+
const text = res.content[0].text;
|
|
587
|
+
expect(text).toContain('Blocked by');
|
|
588
|
+
expect(text).toContain('[ACME-2] Other');
|
|
589
|
+
expect(text).toContain('Blocks');
|
|
590
|
+
expect(text).toContain('_(none)_');
|
|
591
|
+
});
|
|
592
|
+
// ORB-1614 - a cross-project blocker the caller cannot read comes back
|
|
593
|
+
// as an opaque stub (title/ticketKey/projectId/status all null). The
|
|
594
|
+
// rendered text must never print the literal "null" and must not need
|
|
595
|
+
// any of those fields to say something useful.
|
|
596
|
+
it('list renders an opaque placeholder for an unreadable cross-project stub, never "null"', async () => {
|
|
597
|
+
stub([
|
|
598
|
+
{ json: PROJ }, { json: TICKET },
|
|
599
|
+
{ json: {
|
|
600
|
+
blockedBy: [{
|
|
601
|
+
id: 'foreign-1', ticketKey: null, title: null, projectId: null,
|
|
602
|
+
statusId: null, statusName: null, statusColor: null, statusCategory: null,
|
|
603
|
+
external: true, resolved: false,
|
|
604
|
+
}],
|
|
605
|
+
blocks: [],
|
|
606
|
+
} },
|
|
607
|
+
]);
|
|
608
|
+
const res = await makeListTicketDependenciesHandler(client)({ ticketKey: 'ACME-1' });
|
|
609
|
+
const text = res.content[0].text;
|
|
610
|
+
expect(text).not.toContain('null');
|
|
611
|
+
expect(text).toContain('External dependency (access restricted)');
|
|
612
|
+
expect(text).toContain('still open');
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
describe('orboto_label_ticket / orboto_unlabel_ticket (ORB-1043)', () => {
|
|
616
|
+
const LABELS = [{ id: 'lb1', name: 'bug' }, { id: 'lb2', name: 'Security' }];
|
|
617
|
+
it('label resolves the name and POSTs to /tickets/:id/labels/:labelId', async () => {
|
|
618
|
+
const calls = stub([
|
|
619
|
+
{ json: PROJ }, // resolveTicketByKey: project
|
|
620
|
+
{ json: TICKET }, // resolveTicketByKey: ticket
|
|
621
|
+
{ json: LABELS }, // GET labels
|
|
622
|
+
{ status: 204 }, // POST attach
|
|
623
|
+
]);
|
|
624
|
+
await makeLabelTicketHandler(client)({ ticketKey: 'ACME-1', label: 'security' });
|
|
625
|
+
const post = calls.find((c) => c.method === 'POST');
|
|
626
|
+
expect(post.url).toContain(`/projects/tickets/${TICKET.id}/labels/lb2`);
|
|
627
|
+
});
|
|
628
|
+
it('unlabel DELETEs the resolved label', async () => {
|
|
629
|
+
const calls = stub([
|
|
630
|
+
{ json: PROJ },
|
|
631
|
+
{ json: TICKET },
|
|
632
|
+
{ json: LABELS },
|
|
633
|
+
{ status: 204 },
|
|
634
|
+
]);
|
|
635
|
+
await makeUnlabelTicketHandler(client)({ ticketKey: 'ACME-1', label: 'bug' });
|
|
636
|
+
const del = calls.find((c) => c.method === 'DELETE');
|
|
637
|
+
expect(del.url).toContain(`/projects/tickets/${TICKET.id}/labels/lb1`);
|
|
638
|
+
});
|
|
639
|
+
it('errors on an unknown label name, pointing to create_label', async () => {
|
|
640
|
+
stub([{ json: PROJ }, { json: TICKET }, { json: LABELS }]);
|
|
641
|
+
await expect(makeLabelTicketHandler(client)({ ticketKey: 'ACME-1', label: 'nope' })).rejects.toThrow(/orboto_create_label/);
|
|
642
|
+
});
|
|
643
|
+
});
|