@orboto/mcp 0.218.0 → 0.219.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/dist/input-schema.js +1 -1
- package/dist/tool-docs.js +1 -1
- package/dist/tool-docs.test.js +1 -1
- package/dist/tools/comment-thread.js +60 -0
- package/dist/tools/comment-thread.test.js +72 -0
- package/dist/tools/get-ticket.js +33 -25
- package/dist/tools/list-tickets.js +15 -6
- package/dist/tools/phase-b-tools.test.js +4 -3
- package/package.json +1 -1
package/dist/input-schema.js
CHANGED
|
@@ -56,7 +56,7 @@ function applyAliases(toolName, shape, value) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
if ('id' in out && !('id' in shape)) {
|
|
59
|
-
const candidates = ID_LIKE_CANONICALS.filter((c) => c in shape && !(c in out));
|
|
59
|
+
const candidates = ID_LIKE_CANONICALS.filter((c) => c in shape && !(c in out) && !(toolName === 'orboto_get_ticket' && c === 'commentId'));
|
|
60
60
|
if (candidates.length === 1) {
|
|
61
61
|
const canonical = candidates[0];
|
|
62
62
|
const { id, ...rest } = out;
|
package/dist/tool-docs.js
CHANGED
|
@@ -13,7 +13,7 @@ export const SUMMARY_MAX_CHARS = 220;
|
|
|
13
13
|
*/
|
|
14
14
|
const SUMMARY_OVERRIDES = {
|
|
15
15
|
orboto_create_full_backup: 'Start a full-workspace backup job; returns the job id to poll via orboto_list_backups.',
|
|
16
|
-
orboto_get_ticket: 'Fetch
|
|
16
|
+
orboto_get_ticket: 'Fetch a ticket by key: fields, checklists, comments.',
|
|
17
17
|
orboto_requirements_spec: 'Generate a structured requirements spec for a project or milestone from its tickets.',
|
|
18
18
|
orboto_update_doc_space: 'Update a doc space\'s name, description, icon, project binding or access mode (open/restricted) by space id.',
|
|
19
19
|
orboto_search_docs: 'Full-text search over doc/wiki pages (query, optional space or project filter); returns matching pages with snippets.',
|
package/dist/tool-docs.test.js
CHANGED
|
@@ -26,7 +26,7 @@ describe('summarizeToolDescription', () => {
|
|
|
26
26
|
});
|
|
27
27
|
it('uses the hand-written override when one exists', () => {
|
|
28
28
|
const s = summarizeToolDescription('orboto_get_ticket', 'A very long first sentence that would otherwise be cut mid-flow because it enumerates every field the response carries and never stops');
|
|
29
|
-
expect(s).toContain('Fetch
|
|
29
|
+
expect(s).toContain('Fetch a ticket');
|
|
30
30
|
expect(s.length).toBeLessThanOrEqual(SUMMARY_MAX_CHARS);
|
|
31
31
|
});
|
|
32
32
|
it('falls back to a word-boundary cut when the first sentence overruns the cap', () => {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** ORB-2484 - the comment thread newest first, the newest few in full and older ones shortened. */
|
|
2
|
+
export const COMMENT_FULL_COUNT = 3;
|
|
3
|
+
export const COMMENT_EXCERPT_CHARS = 300;
|
|
4
|
+
const COMMENT_ID_MIN_PREFIX = 8;
|
|
5
|
+
const COMMENT_PAGE_SIZE = 50;
|
|
6
|
+
const COMMENT_MAX_PAGES = 20;
|
|
7
|
+
/** Reads the whole thread, oldest first, across cursor pages. */
|
|
8
|
+
export async function fetchThread(client, ticketId) {
|
|
9
|
+
const out = [];
|
|
10
|
+
let cursor = null;
|
|
11
|
+
for (let page = 0; page < COMMENT_MAX_PAGES; page++) {
|
|
12
|
+
const query = `limit=${COMMENT_PAGE_SIZE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
|
13
|
+
const res = await client
|
|
14
|
+
.get(`/tickets/${ticketId}/comments?${query}`)
|
|
15
|
+
.catch(() => ({ items: [], nextCursor: null }));
|
|
16
|
+
out.push(...res.items);
|
|
17
|
+
cursor = res.nextCursor;
|
|
18
|
+
if (!cursor)
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** Cuts at a word boundary within max characters and reports how many characters it left out. */
|
|
24
|
+
export function shortenComment(content, max) {
|
|
25
|
+
const chars = Array.from(content);
|
|
26
|
+
if (chars.length <= max)
|
|
27
|
+
return { excerpt: content, rest: 0 };
|
|
28
|
+
let end = max;
|
|
29
|
+
if (!/\s/u.test(chars[max])) {
|
|
30
|
+
for (let i = max - 1; i > 0; i--) {
|
|
31
|
+
if (/\s/u.test(chars[i])) {
|
|
32
|
+
end = i;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const excerpt = chars.slice(0, end).join('').trimEnd();
|
|
38
|
+
return { excerpt, rest: chars.length - Array.from(excerpt).length };
|
|
39
|
+
}
|
|
40
|
+
/** Newest first; a commentId (full or an 8-character prefix) selects one comment in full. */
|
|
41
|
+
export function selectComments(thread, opts) {
|
|
42
|
+
const entry = (row, whole) => {
|
|
43
|
+
const length = Array.from(row.content).length;
|
|
44
|
+
if (whole)
|
|
45
|
+
return { row, excerpt: row.content, length, rest: 0 };
|
|
46
|
+
return { row, length, ...shortenComment(row.content, COMMENT_EXCERPT_CHARS) };
|
|
47
|
+
};
|
|
48
|
+
if (opts.commentId) {
|
|
49
|
+
const want = opts.commentId.trim().toLowerCase();
|
|
50
|
+
const exact = thread.filter((c) => c.id.toLowerCase() === want);
|
|
51
|
+
const hits = exact.length > 0 ? exact : want.length >= COMMENT_ID_MIN_PREFIX ? thread.filter((c) => c.id.toLowerCase().startsWith(want)) : [];
|
|
52
|
+
if (hits.length === 0)
|
|
53
|
+
return { shown: [], error: `No comment with id "${opts.commentId}" on this ticket (a prefix needs at least ${COMMENT_ID_MIN_PREFIX} characters).` };
|
|
54
|
+
if (hits.length > 1)
|
|
55
|
+
return { shown: [], error: `The id prefix "${opts.commentId}" matches ${hits.length} comments: ${hits.map((c) => c.id).join(', ')}.` };
|
|
56
|
+
return { shown: [entry(hits[0], true)] };
|
|
57
|
+
}
|
|
58
|
+
const newestFirst = [...thread].reverse();
|
|
59
|
+
return { shown: newestFirst.map((row, n) => entry(row, opts.full === true || n < COMMENT_FULL_COUNT)) };
|
|
60
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
3
|
+
import { selectComments, shortenComment } from './comment-thread.js';
|
|
4
|
+
import { makeGetTicketHandler } from './get-ticket.js';
|
|
5
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
6
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
7
|
+
const words = (label, n) => Array.from({ length: n }, (_, i) => `${label}${String(i).padStart(2, '0')}`).join(' ');
|
|
8
|
+
function thread(bodies) {
|
|
9
|
+
return bodies.map((content, i) => ({
|
|
10
|
+
id: `0000000${i + 1}-aaaa-4000-8000-00000000000${i}`,
|
|
11
|
+
content,
|
|
12
|
+
createdAt: `2026-09-${String(i + 1).padStart(2, '0')}`,
|
|
13
|
+
userName: 'Ada',
|
|
14
|
+
isInternal: false,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
describe('ORB-2484 shortened comment thread', () => {
|
|
18
|
+
it('lists newest first, the 3 newest in full and older ones shortened', () => {
|
|
19
|
+
const { shown } = selectComments(thread([words('old', 120), 'b', 'c', 'd', 'e']), {});
|
|
20
|
+
expect(shown.map((s) => s.row.content.slice(0, 3))).toEqual(['e', 'd', 'c', 'b', 'old']);
|
|
21
|
+
expect(shown[3].rest).toBe(0);
|
|
22
|
+
expect(shown[4].rest).toBeGreaterThan(0);
|
|
23
|
+
expect(Array.from(shown[4].excerpt).length).toBeLessThanOrEqual(300);
|
|
24
|
+
});
|
|
25
|
+
it('full keeps every body, commentId selects one by id or 8-character prefix', () => {
|
|
26
|
+
const t = thread([words('old', 120), 'b', 'c', 'd']);
|
|
27
|
+
expect(selectComments(t, { full: true }).shown.every((s) => s.rest === 0)).toBe(true);
|
|
28
|
+
const byPrefix = selectComments(t, { commentId: '00000001' });
|
|
29
|
+
expect(byPrefix.shown).toHaveLength(1);
|
|
30
|
+
expect(byPrefix.shown[0].rest).toBe(0);
|
|
31
|
+
expect(selectComments(t, { commentId: t[1].id }).shown[0].row.content).toBe('b');
|
|
32
|
+
expect(selectComments(t, { commentId: '99999999' }).error).toMatch(/No comment with id/);
|
|
33
|
+
expect(selectComments(t, { commentId: '0000' }).error).toMatch(/at least 8/);
|
|
34
|
+
const twins = [{ ...t[0], id: 'abcdef01-1' }, { ...t[1], id: 'abcdef01-2' }];
|
|
35
|
+
expect(selectComments(twins, { commentId: 'abcdef01' }).error).toMatch(/matches 2 comments/);
|
|
36
|
+
});
|
|
37
|
+
it('cuts at a word boundary and never inside a multi-byte character', () => {
|
|
38
|
+
const cut = shortenComment(`${'x'.repeat(295)} ${'y'.repeat(40)}`, 300);
|
|
39
|
+
expect(cut.excerpt).toBe('x'.repeat(295));
|
|
40
|
+
const umlauts = shortenComment('ä'.repeat(500), 300);
|
|
41
|
+
expect(Array.from(umlauts.excerpt)).toHaveLength(300);
|
|
42
|
+
expect(umlauts.rest).toBe(200);
|
|
43
|
+
expect(shortenComment('short', 300)).toEqual({ excerpt: 'short', rest: 0 });
|
|
44
|
+
});
|
|
45
|
+
it('orboto_get_ticket prints the shortened thread with the pointer and honours commentId', async () => {
|
|
46
|
+
const rows = thread([words('old', 120), 'b', 'c', 'd']);
|
|
47
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
48
|
+
const route = (url) => {
|
|
49
|
+
if (url.includes('/comments'))
|
|
50
|
+
return { items: rows, nextCursor: null };
|
|
51
|
+
if (url.includes('/tickets?parentTicketId'))
|
|
52
|
+
return { items: [], nextCursor: null };
|
|
53
|
+
if (url.endsWith('/attachments'))
|
|
54
|
+
return [];
|
|
55
|
+
if (url.includes('/projects/by-key/'))
|
|
56
|
+
return { id: 'p1', key: 'ACME' };
|
|
57
|
+
return { id: 't1', projectId: 'p1', ticketKey: 'ACME-6', title: 'X', status: 'TODO', statusName: 'To Do', type: 'task', priority: 'normal', commentCount: 4 };
|
|
58
|
+
};
|
|
59
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => ({
|
|
60
|
+
ok: true, status: 200, statusText: 'OK', json: async () => route(url.toString()), text: async () => '',
|
|
61
|
+
}));
|
|
62
|
+
const res = await makeGetTicketHandler(client)({ ticketKey: 'ACME-6', include: ['comments'] });
|
|
63
|
+
const text = res.content[0].text;
|
|
64
|
+
expect(text).toContain('## Comments (4, newest first; the 3 newest in full, full: true prints all)');
|
|
65
|
+
expect(text).toContain('more characters: orboto_get_ticket { ticketKey: "ACME-6", commentId: "00000001-aaaa-4000-8000-000000000000" }');
|
|
66
|
+
expect(text).not.toContain('old119');
|
|
67
|
+
const one = await makeGetTicketHandler(client)({ ticketKey: 'ACME-6', commentId: '00000001' });
|
|
68
|
+
expect(one.content[0].text).toContain('old119');
|
|
69
|
+
const missing = await makeGetTicketHandler(client)({ ticketKey: 'ACME-6', commentId: 'ffffffff' });
|
|
70
|
+
expect(missing.isError).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/dist/tools/get-ticket.js
CHANGED
|
@@ -6,34 +6,34 @@ import { specReleaseInfo } from './shared.js';
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
9
|
+
import { COMMENT_FULL_COUNT, fetchThread, selectComments } from './comment-thread.js';
|
|
9
10
|
import { formatDependencySummary, resolveTicketByKey } from './shared.js';
|
|
10
|
-
const COMMENT_PAGE_SIZE = 50;
|
|
11
11
|
export const getTicketToolConfig = {
|
|
12
12
|
title: 'Get ticket details',
|
|
13
|
-
description: '
|
|
13
|
+
description: 'A ticket\'s decision card: description, status, priority, milestone, assignees, labels, dates, checklist progress, parent, open dependency edges and COUNTS of what is omitted (`include` opts blocks in). Comments: newest first, 3 newest in full, older shortened; `full` all, `commentId` one. Input: ticket key like "ACME-42".',
|
|
14
14
|
inputSchema: z.object({
|
|
15
|
-
ticketKey: z.string().min(3).describe('
|
|
15
|
+
ticketKey: z.string().min(3).describe('Key like "ACME-42".'),
|
|
16
16
|
include: z.array(z.enum(['comments', 'git', 'attachments', 'children', 'raci', 'checklistItems']))
|
|
17
17
|
.optional()
|
|
18
|
-
.describe('Blocks to inline
|
|
18
|
+
.describe('Blocks to inline.'),
|
|
19
|
+
full: z.boolean().optional().describe('Comments in full.'),
|
|
20
|
+
commentId: z.string().optional().describe('One comment (id or prefix).'),
|
|
19
21
|
}).shape,
|
|
20
22
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
21
23
|
};
|
|
22
24
|
export function makeGetTicketHandler(client) {
|
|
23
|
-
return async ({ ticketKey, include }) => {
|
|
25
|
+
return async ({ ticketKey, include, full: fullComments, commentId }) => {
|
|
24
26
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
25
|
-
const inc = new Set(include ?? []);
|
|
27
|
+
const inc = new Set(commentId ? [...(include ?? []), 'comments'] : (include ?? []));
|
|
26
28
|
const parentId = ticket.parentTicketId ?? null;
|
|
27
|
-
const [enriched, parent, childrenPage, attachments,
|
|
29
|
+
const [enriched, parent, childrenPage, attachments, thread, checklists, gitActivity] = await Promise.all([
|
|
28
30
|
client.get(`/projects/${ticket.projectId}/tickets/${ticket.id}`).catch(swallow404(null)),
|
|
29
31
|
parentId
|
|
30
32
|
? client.get(`/projects/${ticket.projectId}/tickets/${parentId}`).catch(swallow404(null))
|
|
31
33
|
: Promise.resolve(null),
|
|
32
34
|
client.get(`/projects/${ticket.projectId}/tickets?parentTicketId=${ticket.id}&limit=50`).catch(swallow404({ items: [], nextCursor: null })),
|
|
33
35
|
client.get(`/tickets/${ticket.id}/attachments`).catch(swallow404([])),
|
|
34
|
-
inc.has('comments')
|
|
35
|
-
? client.get(`/tickets/${ticket.id}/comments?limit=${COMMENT_PAGE_SIZE}`).catch(swallow404({ items: [], nextCursor: null }))
|
|
36
|
-
: Promise.resolve({ items: [], nextCursor: null }),
|
|
36
|
+
inc.has('comments') ? fetchThread(client, ticket.id) : Promise.resolve([]),
|
|
37
37
|
inc.has('checklistItems')
|
|
38
38
|
? client.get(`/tickets/${ticket.id}/checklists`).catch(swallow404([]))
|
|
39
39
|
: Promise.resolve([]),
|
|
@@ -41,11 +41,14 @@ export function makeGetTicketHandler(client) {
|
|
|
41
41
|
? client.get(`/tickets/${ticket.id}/git-activity`).catch(swallow404([]))
|
|
42
42
|
: Promise.resolve([]),
|
|
43
43
|
]);
|
|
44
|
-
const
|
|
45
|
-
|
|
44
|
+
const selected = selectComments(thread, { full: fullComments, commentId });
|
|
45
|
+
if (selected.error)
|
|
46
|
+
return { isError: true, content: [{ type: 'text', text: selected.error }] };
|
|
47
|
+
const comments = selected.shown;
|
|
48
|
+
const commentTotal = thread.length;
|
|
46
49
|
const children = childrenPage.items;
|
|
47
50
|
const full = enriched ?? ticket;
|
|
48
|
-
const commentCount = full.commentCount ?? (inc.has('comments') ?
|
|
51
|
+
const commentCount = full.commentCount ?? (inc.has('comments') ? commentTotal : 0);
|
|
49
52
|
const gitCount = full.gitActivityCount ?? (inc.has('git') ? gitActivity.length : 0);
|
|
50
53
|
const omitted = [];
|
|
51
54
|
if (!inc.has('comments') && commentCount > 0)
|
|
@@ -62,7 +65,7 @@ export function makeGetTicketHandler(client) {
|
|
|
62
65
|
? `Omitted blocks: ${omitted.join(', ')}. Re-call orboto_get_ticket with include: ["comments"|"git"|"attachments"|"children"|"raci"|"checklistItems"] for the ones you need.`
|
|
63
66
|
: undefined;
|
|
64
67
|
return {
|
|
65
|
-
content: [{ type: 'text', text: formatTicket(full, inc, comments,
|
|
68
|
+
content: [{ type: 'text', text: formatTicket(full, inc, comments, commentTotal, ticketKey, checklists, gitActivity, parent, children, attachments, includeHint) }],
|
|
66
69
|
structuredContent: {
|
|
67
70
|
id: full.id,
|
|
68
71
|
key: full.ticketKey,
|
|
@@ -119,15 +122,18 @@ export function makeGetTicketHandler(client) {
|
|
|
119
122
|
checklistProgress: full.checklistProgress ?? { done: 0, total: 0 },
|
|
120
123
|
...(includeHint ? { includeHint } : {}),
|
|
121
124
|
...(inc.has('comments') ? {
|
|
122
|
-
comments: comments.map((c) => ({
|
|
123
|
-
id: c.id,
|
|
125
|
+
comments: comments.map(({ row: c, excerpt, length, rest }) => ({
|
|
126
|
+
id: c.id,
|
|
124
127
|
author: c.userName ?? null,
|
|
125
|
-
body:
|
|
128
|
+
body: excerpt,
|
|
129
|
+
bodyLength: length,
|
|
130
|
+
...(rest > 0 ? { bodyTruncated: true } : {}),
|
|
126
131
|
createdAt: c.createdAt,
|
|
127
132
|
editedAt: c.editedAt ?? null,
|
|
128
133
|
isInternal: c.isInternal,
|
|
129
134
|
})),
|
|
130
|
-
|
|
135
|
+
commentTotal,
|
|
136
|
+
commentsHasMore: false,
|
|
131
137
|
} : {}),
|
|
132
138
|
...(inc.has('checklistItems') ? {
|
|
133
139
|
checklists: checklists.map((cl) => ({
|
|
@@ -183,7 +189,7 @@ function dependencyEdge(e) {
|
|
|
183
189
|
...(e.external ? { external: true } : {}),
|
|
184
190
|
};
|
|
185
191
|
}
|
|
186
|
-
function formatTicket(ticket, inc, comments,
|
|
192
|
+
function formatTicket(ticket, inc, comments, commentTotal, ticketKey, checklists, gitActivity, parent, children, attachments, includeHint) {
|
|
187
193
|
const header = [
|
|
188
194
|
`[${ticket.ticketKey}] ${ticket.title}`,
|
|
189
195
|
`Status: ${ticket.statusName ?? ticket.status} Priority: ${ticket.priority} Type: ${ticket.type}`,
|
|
@@ -214,6 +220,7 @@ function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitAct
|
|
|
214
220
|
ticket.labels && ticket.labels.length > 0
|
|
215
221
|
? `Labels: ${ticket.labels.map((l) => l.name).join(', ')}`
|
|
216
222
|
: null,
|
|
223
|
+
ticket.branchName ? `Branch: ${ticket.branchName}` : null,
|
|
217
224
|
ticket.webUrl ? `Link: ${ticket.webUrl}` : null,
|
|
218
225
|
formatDependencySummary('Blocked by', ticket.blockedByOpenCount, ticket.blockedByOpen),
|
|
219
226
|
formatDependencySummary('Blocks', ticket.blocksOpenCount, ticket.blocksOpen),
|
|
@@ -249,12 +256,13 @@ function formatTicket(ticket, inc, comments, hasMoreComments, checklists, gitAct
|
|
|
249
256
|
commentLines.push('', `Comments: ${ticket.commentCount}`);
|
|
250
257
|
}
|
|
251
258
|
if (inc.has('comments') && comments.length > 0) {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
259
|
+
const shortened = comments.some((c) => c.rest > 0);
|
|
260
|
+
commentLines.push('', `## Comments (${commentTotal}, newest first${shortened ? `; the ${COMMENT_FULL_COUNT} newest in full, full: true prints all` : ''})`);
|
|
261
|
+
for (const { row: c, excerpt, length, rest } of comments) {
|
|
262
|
+
commentLines.push(`**${c.userName ?? '(unknown author)'}** - ${c.createdAt}${c.isInternal ? ' [internal]' : ''} - ${length} chars - ${c.id}`, excerpt);
|
|
263
|
+
if (rest > 0)
|
|
264
|
+
commentLines.push(`... ${rest} more characters: orboto_get_ticket { ticketKey: "${ticketKey}", commentId: "${c.id}" }`);
|
|
265
|
+
commentLines.push('');
|
|
258
266
|
}
|
|
259
267
|
}
|
|
260
268
|
const gitLines = [];
|
|
@@ -13,32 +13,36 @@ import { resolveProjectByKey, resolveTicketByKey, ticketLine, agentTicketListRow
|
|
|
13
13
|
import { resolveMilestoneByNameOrId } from './milestones.js';
|
|
14
14
|
export const listTicketsToolConfig = {
|
|
15
15
|
title: 'List tickets',
|
|
16
|
-
description: 'List tickets in a project,
|
|
16
|
+
description: 'List tickets in a project, filtered by status category, milestone or assignee email. unscheduled=true = the backlog (no milestone yet). Up to 50 per call.',
|
|
17
17
|
inputSchema: z.object({
|
|
18
18
|
projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
|
|
19
19
|
specState: TicketSpecStateSchema.optional(),
|
|
20
20
|
statusCategory: z
|
|
21
21
|
.enum(['todo', 'in_progress', 'in_review', 'done', 'wont_fix'])
|
|
22
22
|
.optional()
|
|
23
|
-
.describe('
|
|
23
|
+
.describe('One workflow category. Omit for all.'),
|
|
24
|
+
open: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('true = only todo, in_progress, in_review. Not with statusCategory.'),
|
|
24
28
|
milestone: z
|
|
25
29
|
.string()
|
|
26
30
|
.optional()
|
|
27
|
-
.describe('Key (ORB-M3), name
|
|
31
|
+
.describe('Key (ORB-M3), name or UUID.'),
|
|
28
32
|
unscheduled: z
|
|
29
33
|
.boolean()
|
|
30
34
|
.optional()
|
|
31
|
-
.describe('true = only
|
|
35
|
+
.describe('true = only tickets with no milestone. Not with `milestone`.'),
|
|
32
36
|
assigneeEmail: z
|
|
33
37
|
.string()
|
|
34
38
|
.optional()
|
|
35
|
-
.describe('Project-member email.
|
|
39
|
+
.describe('Project-member email.'),
|
|
36
40
|
parentTicketKey: z
|
|
37
41
|
.string()
|
|
38
42
|
.optional()
|
|
39
43
|
.describe('Only children of this ticket - walks an epic.'),
|
|
40
44
|
limit: z.number().int().min(1).max(50).default(25).describe('Max rows to return.'),
|
|
41
|
-
verbose: z.boolean().default(false).describe('true = full rows
|
|
45
|
+
verbose: z.boolean().default(false).describe('true = full rows.'),
|
|
42
46
|
}).shape,
|
|
43
47
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
44
48
|
};
|
|
@@ -51,6 +55,11 @@ export function makeListTicketsHandler(client) {
|
|
|
51
55
|
qs.set('specState', input.specState);
|
|
52
56
|
if (input.statusCategory)
|
|
53
57
|
qs.set('statusCategory', input.statusCategory);
|
|
58
|
+
if (input.open && input.statusCategory) {
|
|
59
|
+
throw new Error('Pass either `open` or `statusCategory`, not both.');
|
|
60
|
+
}
|
|
61
|
+
if (input.open)
|
|
62
|
+
qs.set('open', 'true');
|
|
54
63
|
if (input.unscheduled && input.milestone) {
|
|
55
64
|
throw new Error('Pass either `milestone` or `unscheduled: true`, not both.');
|
|
56
65
|
}
|
|
@@ -256,7 +256,7 @@ describe('orboto_get_ticket', () => {
|
|
|
256
256
|
expect(size).toBeLessThan(4000);
|
|
257
257
|
expect(res.structuredContent.commentCount).toBe(34);
|
|
258
258
|
});
|
|
259
|
-
it('include: ["comments"]
|
|
259
|
+
it('include: ["comments"] returns the thread newest first with the body length', async () => {
|
|
260
260
|
const calls = stub([
|
|
261
261
|
{ json: PROJ },
|
|
262
262
|
{ json: { id: 't1', projectId: 'p1', ticketKey: 'ACME-6', title: 'X', status: 'TODO', gitActivityCount: 0, parentTicketId: null } },
|
|
@@ -266,7 +266,7 @@ describe('orboto_get_ticket', () => {
|
|
|
266
266
|
{
|
|
267
267
|
json: {
|
|
268
268
|
items: [{ id: 'c1', ticketId: 't1', userId: 'u1', content: 'first thought', isInternal: false, createdAt: 'now', userName: 'Ada' }],
|
|
269
|
-
nextCursor:
|
|
269
|
+
nextCursor: null,
|
|
270
270
|
},
|
|
271
271
|
},
|
|
272
272
|
]);
|
|
@@ -276,12 +276,13 @@ describe('orboto_get_ticket', () => {
|
|
|
276
276
|
expect(sc.comments[0]).toEqual({
|
|
277
277
|
id: 'c1', // ORB-1285 - comment id is surfaced for edit/delete targeting
|
|
278
278
|
body: 'first thought',
|
|
279
|
+
bodyLength: 13,
|
|
279
280
|
author: 'Ada',
|
|
280
281
|
createdAt: 'now',
|
|
281
282
|
editedAt: null,
|
|
282
283
|
isInternal: false,
|
|
283
284
|
});
|
|
284
|
-
expect(sc.
|
|
285
|
+
expect(sc.commentTotal).toBe(1);
|
|
285
286
|
expect(sc.parentTicket).toBeNull();
|
|
286
287
|
});
|
|
287
288
|
it('surfaces waitingForGitIngestion in the header and structured content', async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orboto/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.219.0",
|
|
4
4
|
"description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|