@orboto/mcp 0.192.0 → 0.194.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/event-bridge.js +1 -47
- package/dist/event-bridge.test.js +0 -19
- package/dist/http-transport-binding.test.js +1 -4
- package/dist/http-transport.js +1 -213
- package/dist/http-transport.test.js +0 -56
- package/dist/index.js +1 -48
- package/dist/input-schema.js +2 -76
- package/dist/input-schema.test.js +1 -15
- package/dist/instructions-budget.test.js +0 -2
- package/dist/manifest-size.test.js +3 -72
- package/dist/oauth-bootstrap.js +0 -47
- package/dist/oauth-bootstrap.test.js +4 -16
- package/dist/orboto-client.js +0 -30
- package/dist/orboto-client.test.js +0 -12
- package/dist/prompts.js +0 -39
- package/dist/protect-text-usage.test.js +1 -9
- package/dist/required-rules-wrapper.test.js +0 -5
- package/dist/required-rules.js +0 -2
- package/dist/resources.js +0 -62
- package/dist/resources.test.js +0 -16
- package/dist/response-budget.js +2 -150
- package/dist/response-budget.test.js +1 -32
- package/dist/server.js +4 -180
- package/dist/session-nudge.js +0 -9
- package/dist/session-nudge.test.js +2 -10
- package/dist/session-nudge.transport.test.js +0 -23
- package/dist/tool-annotations.test.js +0 -44
- package/dist/tool-docs.js +1 -12
- package/dist/tool-docs.test.js +2 -8
- package/dist/tool-parameters.test.js +0 -19
- package/dist/tools/absence-writes.js +1 -17
- package/dist/tools/admin-translations.js +0 -11
- package/dist/tools/admin-writes.js +0 -28
- package/dist/tools/admin-writes.test.js +0 -1
- package/dist/tools/agent-coordination.js +2 -24
- package/dist/tools/agent-coordination.test.js +0 -2
- package/dist/tools/agent-instructions.js +0 -10
- package/dist/tools/agent-instructions.test.js +0 -2
- package/dist/tools/agent-messages.js +0 -1
- package/dist/tools/ai-status.js +0 -14
- package/dist/tools/ai-usage.js +0 -10
- package/dist/tools/analytics.js +0 -2
- package/dist/tools/api-call.js +0 -12
- package/dist/tools/api-call.test.js +1 -2
- package/dist/tools/api-search.js +1 -6
- package/dist/tools/approvals.js +0 -6
- package/dist/tools/attach.js +0 -25
- package/dist/tools/backup.js +0 -13
- package/dist/tools/backup.test.js +1 -2
- package/dist/tools/bulk-create.js +1 -25
- package/dist/tools/bulk-create.test.js +0 -4
- package/dist/tools/bulk-writes.js +69 -61
- package/dist/tools/bulk-writes.test.js +34 -4
- package/dist/tools/check-similar.js +0 -17
- package/dist/tools/checklist-writes.js +82 -27
- package/dist/tools/checklist-writes.test.js +69 -1
- package/dist/tools/claim.js +1 -71
- package/dist/tools/claim.test.js +0 -2
- package/dist/tools/critical-path.js +0 -5
- package/dist/tools/critical-path.test.js +0 -3
- package/dist/tools/cross-project-links.ee.js +1 -24
- package/dist/tools/doc-attachments.js +0 -28
- package/dist/tools/doc-attachments.test.js +1 -4
- package/dist/tools/doc-comments.js +1 -37
- package/dist/tools/doc-comments.test.js +1 -2
- package/dist/tools/doc-edits.js +1 -31
- package/dist/tools/doc-edits.test.js +0 -9
- package/dist/tools/doc-export.js +0 -24
- package/dist/tools/doc-export.test.js +0 -12
- package/dist/tools/doc-revisions.js +0 -23
- package/dist/tools/docs-ai.js +0 -34
- package/dist/tools/docs-ai.test.js +0 -11
- package/dist/tools/docs.js +1 -75
- package/dist/tools/docs.test.js +1 -3
- package/dist/tools/embedding-status.js +0 -13
- package/dist/tools/embedding-status.test.js +0 -1
- package/dist/tools/feedback.js +1 -15
- package/dist/tools/feedback.test.js +0 -1
- package/dist/tools/get-checklists.js +6 -14
- package/dist/tools/get-project-primer.js +1 -25
- package/dist/tools/get-project.js +0 -17
- package/dist/tools/get-ticket.js +1 -61
- package/dist/tools/get-timer.js +0 -11
- package/dist/tools/git-health-reasons.js +0 -1
- package/dist/tools/identity.js +0 -13
- package/dist/tools/list-projects.js +0 -20
- package/dist/tools/list-projects.test.js +0 -1
- package/dist/tools/list-tickets.js +10 -9
- package/dist/tools/milestones-crud.test.js +0 -4
- package/dist/tools/milestones.js +0 -43
- package/dist/tools/my-tickets.js +0 -7
- package/dist/tools/phase-b-tools.test.js +14 -21
- package/dist/tools/primer-facts.js +1 -49
- package/dist/tools/primer-facts.test.js +0 -20
- package/dist/tools/project-listings.js +0 -25
- package/dist/tools/query.js +1 -12
- package/dist/tools/query.test.js +0 -8
- package/dist/tools/raci.js +0 -11
- package/dist/tools/requirements-spec.js +1 -12
- package/dist/tools/response-expand.js +0 -19
- package/dist/tools/review-policy.js +0 -24
- package/dist/tools/review-policy.test.js +0 -2
- package/dist/tools/search.js +1 -13
- package/dist/tools/session-start.js +1 -136
- package/dist/tools/session-start.test.js +1 -44
- package/dist/tools/set-parent.js +1 -19
- package/dist/tools/shared.js +0 -41
- package/dist/tools/shared.test.js +0 -4
- package/dist/tools/similar-projection.js +0 -15
- package/dist/tools/ticket-attachments.js +1 -25
- package/dist/tools/ticket-schedules.js +0 -9
- package/dist/tools/ticket-schedules.test.js +0 -1
- package/dist/tools/ticket-writes.js +1 -110
- package/dist/tools/ticket-writes.test.js +1 -15
- package/dist/tools/time-writes.js +26 -46
- package/dist/tools/time-writes.test.js +18 -0
- package/dist/tools/update-project.js +1 -27
- package/dist/tools/update-project.test.js +1 -1
- package/dist/tools/wiki.js +0 -8
- package/dist/tools/work-next.js +0 -5
- package/dist/tools/work-sessions-start.test.js +0 -2
- package/dist/tools/work-sessions.js +0 -48
- package/dist/tools/work-sessions.test.js +0 -5
- package/dist/toolset.js +2 -81
- package/dist/toolset.test.js +0 -20
- package/dist/version-guard.test.js +0 -4
- package/dist/version.js +0 -12
- package/dist/with-metrics-output-schema.test.js +1 -14
- package/dist/with-metrics.js +2 -74
- package/dist/with-metrics.test.js +7 -29
- package/package.json +1 -1
|
@@ -1,28 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-1694 - bulk create + bulk dependency writes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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).
|
|
4
|
+
* @see ORB-1693
|
|
18
5
|
*/
|
|
19
6
|
import { z } from 'zod';
|
|
20
7
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
21
8
|
import { resolveProjectByKey, resolveTicketByKey } from './shared.js';
|
|
22
9
|
import { resolveMilestoneByNameOrId } from './milestones.js';
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// orboto_bulk_create_tickets
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
10
|
const TicketDraftSchema = z.object({
|
|
27
11
|
title: z.string().min(1).max(255),
|
|
28
12
|
description: z.string().optional(),
|
|
@@ -60,8 +44,6 @@ export const bulkCreateTicketsToolConfig = {
|
|
|
60
44
|
export function makeBulkCreateTicketsHandler(client) {
|
|
61
45
|
return async (input) => {
|
|
62
46
|
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
47
|
const milestoneIds = new Map();
|
|
66
48
|
const resolveMilestone = async (ref) => {
|
|
67
49
|
const cached = milestoneIds.get(ref);
|
|
@@ -147,9 +129,6 @@ export function makeBulkCreateTicketsHandler(client) {
|
|
|
147
129
|
};
|
|
148
130
|
};
|
|
149
131
|
}
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
// orboto_bulk_add_ticket_dependencies
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
132
|
export const bulkAddTicketDependenciesToolConfig = {
|
|
154
133
|
title: 'Add many ticket dependencies in one call',
|
|
155
134
|
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.',
|
|
@@ -167,7 +146,6 @@ export const bulkAddTicketDependenciesToolConfig = {
|
|
|
167
146
|
};
|
|
168
147
|
export function makeBulkAddTicketDependenciesHandler(client) {
|
|
169
148
|
return async (input) => {
|
|
170
|
-
// Resolve every distinct key once, not once per edge.
|
|
171
149
|
const resolved = new Map();
|
|
172
150
|
const resolve = async (key) => {
|
|
173
151
|
const cached = resolved.get(key);
|
|
@@ -188,8 +166,6 @@ export function makeBulkAddTicketDependenciesHandler(client) {
|
|
|
188
166
|
await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies`, { dependsOnId: dependsOn.id });
|
|
189
167
|
}
|
|
190
168
|
catch (err) {
|
|
191
|
-
// 409 = edge already exists - idempotent success, same as the
|
|
192
|
-
// single-edge tool.
|
|
193
169
|
if (!(err instanceof OrbotoApiError && err.status === 409))
|
|
194
170
|
throw err;
|
|
195
171
|
}
|
|
@@ -64,9 +64,7 @@ describe('orboto_bulk_create_tickets', () => {
|
|
|
64
64
|
const text = result.content[0].text;
|
|
65
65
|
expect(text.length).toBeLessThan(2000);
|
|
66
66
|
expect(JSON.stringify(result.structuredContent).length).toBeLessThan(2000);
|
|
67
|
-
// Milestone resolved once, not once per draft.
|
|
68
67
|
expect(calls.filter((c) => c.url.includes('/milestones')).length).toBe(1);
|
|
69
|
-
// Every create carried the resolved milestone id.
|
|
70
68
|
const creates = calls.filter((c) => c.method === 'POST' && c.url.includes('/tickets'));
|
|
71
69
|
expect(creates).toHaveLength(20);
|
|
72
70
|
expect(creates.every((c) => c.body.milestoneId === 'ms-1')).toBe(true);
|
|
@@ -127,9 +125,7 @@ describe('orboto_bulk_add_ticket_dependencies', () => {
|
|
|
127
125
|
expect(sc.successful).toEqual(['ACME-2->ACME-1', 'ACME-3->ACME-2']);
|
|
128
126
|
expect(sc.failed).toHaveLength(1);
|
|
129
127
|
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
128
|
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
129
|
expect(calls.filter((c) => c.method === 'POST' && c.url.includes('/dependencies')).length).toBe(2);
|
|
134
130
|
});
|
|
135
131
|
});
|
|
@@ -1,27 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-799 - bulk-* writes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* in every phase-cleanup pass. MCP-side we collapse the wrapper's
|
|
6
|
-
* `--from=- | @file | "ORB-1,ORB-2"` UX into a single typed `ticketKeys:
|
|
7
|
-
* string[]` array - the calling agent already has the list as JSON, so
|
|
8
|
-
* the wrapper's stdin/file-roundtrip is a regression there.
|
|
9
|
-
*
|
|
10
|
-
* Each tool:
|
|
11
|
-
* - Takes `ticketKeys: string[]` (1..200 keys) + tool-specific params
|
|
12
|
-
* - Has a `dryRun: boolean` modifier - when true, resolves every key
|
|
13
|
-
* to verify visibility + permission but does NOT issue the
|
|
14
|
-
* mutating call. Returns the same outcome shape with a
|
|
15
|
-
* `dryRun: true` marker so the caller can preview.
|
|
16
|
-
* - Returns `{ successful: [...], failed: [{ticketKey, error}], skipped: [...] }`
|
|
17
|
-
* so the model can branch on partial failures instead of parsing
|
|
18
|
-
* stderr progress lines like the wrapper does.
|
|
19
|
-
*
|
|
20
|
-
* Concurrency: serial per-ticket. Bulk operations land on a tenant DB
|
|
21
|
-
* that other operators may be writing concurrently; spraying parallel
|
|
22
|
-
* PATCHes against the same tickets is the kind of thing tenant rate
|
|
23
|
-
* limits exist to slow down. A 200-ticket bulk-close at ~80ms/ticket
|
|
24
|
-
* is ~16s - acceptable.
|
|
4
|
+
* @see ORB-1, ORB-2
|
|
25
5
|
*/
|
|
26
6
|
import { z } from 'zod';
|
|
27
7
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
@@ -34,8 +14,44 @@ const CATEGORY_TO_LEGACY = {
|
|
|
34
14
|
done: 'DONE',
|
|
35
15
|
wont_fix: 'WONT_FIX',
|
|
36
16
|
};
|
|
37
|
-
const TICKET_KEY_ARRAY = z.array(z.string().min(3)).min(1).max(200)
|
|
38
|
-
.describe('Ticket keys (e.g. ["ACME-1", "ACME-2"]). Capped at 200 per call to keep the per-tenant rate-limit budget reasonable.');
|
|
17
|
+
const TICKET_KEY_ARRAY = z.array(z.string().min(3)).min(1).max(200).optional()
|
|
18
|
+
.describe('Ticket keys (e.g. ["ACME-1", "ACME-2"]). Capped at 200 per call to keep the per-tenant rate-limit budget reasonable. Give either ticketKeys or query.');
|
|
19
|
+
/** ORB-2054 - the target set can be an OQL query instead of a key list. */
|
|
20
|
+
const QUERY_SOURCE = z.string().min(1).max(8000).optional()
|
|
21
|
+
.describe('OQL query selecting the tickets instead of ticketKeys (same grammar as orboto_query, caller ACL applies). At most 200 matches per call - narrow it or use POST /projects/{id}/tickets/bulk with `query` for a server-side run.');
|
|
22
|
+
const QUERY_PROJECT = z.string().optional()
|
|
23
|
+
.describe('Project key that scopes `query` (prepended as project = KEY).');
|
|
24
|
+
/** Matches per call the MCP loop will process; above that the server-side bulk route is the right tool. */
|
|
25
|
+
export const BULK_QUERY_MAX = 200;
|
|
26
|
+
/** Resolve `query` to ticket keys through POST /query, paging until the cap. */
|
|
27
|
+
export async function resolveQueryKeys(client, query, projectKey) {
|
|
28
|
+
const oql = projectKey ? `project = ${projectKey} AND (${query})` : query;
|
|
29
|
+
const keys = [];
|
|
30
|
+
let cursor;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const page = await client.post('/query', {
|
|
33
|
+
oql, syntax: 'oql', limit: 100, ...(cursor ? { cursor } : {}),
|
|
34
|
+
});
|
|
35
|
+
for (const t of page.items)
|
|
36
|
+
keys.push(t.ticketKey ?? t.id);
|
|
37
|
+
if (keys.length > BULK_QUERY_MAX) {
|
|
38
|
+
throw new Error(`The query matches more than ${BULK_QUERY_MAX} tickets - narrow it, or call POST /projects/{id}/tickets/bulk with \`query\` (dryRun first) for a server-side run.`);
|
|
39
|
+
}
|
|
40
|
+
if (!page.nextCursor)
|
|
41
|
+
break;
|
|
42
|
+
cursor = page.nextCursor;
|
|
43
|
+
}
|
|
44
|
+
if (keys.length === 0)
|
|
45
|
+
throw new Error('The query matched no tickets.');
|
|
46
|
+
return keys;
|
|
47
|
+
}
|
|
48
|
+
/** One of ticketKeys / query, resolved to the key list the loops iterate. */
|
|
49
|
+
async function targetKeys(client, input) {
|
|
50
|
+
if ((input.ticketKeys ? 1 : 0) + (input.query ? 1 : 0) !== 1) {
|
|
51
|
+
throw new Error('Give exactly one of ticketKeys or query.');
|
|
52
|
+
}
|
|
53
|
+
return input.query ? resolveQueryKeys(client, input.query, input.projectKey) : input.ticketKeys;
|
|
54
|
+
}
|
|
39
55
|
function emptyOutcome(dryRun) {
|
|
40
56
|
return { successful: [], failed: [], skipped: [], dryRun };
|
|
41
57
|
}
|
|
@@ -81,14 +97,13 @@ async function resolveBatch(client, ticketKeys, outcome) {
|
|
|
81
97
|
}
|
|
82
98
|
return resolved;
|
|
83
99
|
}
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// orboto_bulk_patch_tickets
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
100
|
export const bulkPatchTicketsToolConfig = {
|
|
88
101
|
title: 'Apply the same patch to many tickets',
|
|
89
|
-
description: 'PATCH every ticket in `ticketKeys` with the same `patch` body. The patch shape mirrors `orboto_update_ticket`: title, description, type, priority, dueDate, startDate, isPrivate, estimatedTimeMinutes. To move many tickets between status categories, use `orboto_bulk_move_tickets` instead. Returns `{successful, failed, skipped, dryRun}` so the caller can branch on partial failures.',
|
|
102
|
+
description: 'PATCH every ticket in `ticketKeys` (or the tickets `query` selects) with the same `patch` body. The patch shape mirrors `orboto_update_ticket`: title, description, type, priority, dueDate, startDate, isPrivate, estimatedTimeMinutes. To move many tickets between status categories, use `orboto_bulk_move_tickets` instead. Returns `{successful, failed, skipped, dryRun}` so the caller can branch on partial failures.',
|
|
90
103
|
inputSchema: z.object({
|
|
91
104
|
ticketKeys: TICKET_KEY_ARRAY,
|
|
105
|
+
query: QUERY_SOURCE,
|
|
106
|
+
projectKey: QUERY_PROJECT,
|
|
92
107
|
patch: z.object({
|
|
93
108
|
title: z.string().min(1).max(255).optional(),
|
|
94
109
|
description: z.string().optional(),
|
|
@@ -101,15 +116,12 @@ export const bulkPatchTicketsToolConfig = {
|
|
|
101
116
|
}).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
|
|
102
117
|
dryRun: z.boolean().optional().describe('Resolve every ticket to verify visibility/permission, but skip the actual PATCH.'),
|
|
103
118
|
}).shape,
|
|
104
|
-
// ORB-1669 - the mutating bulk_* tools are destructive by blast radius:
|
|
105
|
-
// they overwrite state across N tickets in one call with no undo. The
|
|
106
|
-
// purely additive ones (bulk_comment, bulk_assign) are not.
|
|
107
119
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
108
120
|
};
|
|
109
121
|
export function makeBulkPatchTicketsHandler(client) {
|
|
110
|
-
return async ({ ticketKeys, patch, dryRun }) => {
|
|
122
|
+
return async ({ ticketKeys, query, projectKey, patch, dryRun }) => {
|
|
111
123
|
const outcome = emptyOutcome(dryRun === true);
|
|
112
|
-
const resolved = await resolveBatch(client, ticketKeys, outcome);
|
|
124
|
+
const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
|
|
113
125
|
for (const [k, t] of resolved) {
|
|
114
126
|
if (dryRun) {
|
|
115
127
|
outcome.skipped.push(k);
|
|
@@ -126,23 +138,22 @@ export function makeBulkPatchTicketsHandler(client) {
|
|
|
126
138
|
return bulkResult(`bulk_patch (${Object.keys(patch).join(',')})`, outcome);
|
|
127
139
|
};
|
|
128
140
|
}
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
// orboto_bulk_move_tickets
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
141
|
export const bulkMoveTicketsToolConfig = {
|
|
133
142
|
title: 'Move many tickets to a status category',
|
|
134
|
-
description: 'Move every ticket in `ticketKeys` to the same status category (todo / in_progress / in_review / done / wont_fix). Each ticket lands on its own project\'s first status with that category. Returns the per-ticket outcome.',
|
|
143
|
+
description: 'Move every ticket in `ticketKeys` (or the tickets `query` selects) to the same status category (todo / in_progress / in_review / done / wont_fix). Each ticket lands on its own project\'s first status with that category. Returns the per-ticket outcome.',
|
|
135
144
|
inputSchema: z.object({
|
|
136
145
|
ticketKeys: TICKET_KEY_ARRAY,
|
|
146
|
+
query: QUERY_SOURCE,
|
|
147
|
+
projectKey: QUERY_PROJECT,
|
|
137
148
|
statusCategory: z.enum(STATUS_CATEGORIES),
|
|
138
149
|
dryRun: z.boolean().optional(),
|
|
139
150
|
}).shape,
|
|
140
151
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
141
152
|
};
|
|
142
153
|
export function makeBulkMoveTicketsHandler(client) {
|
|
143
|
-
return async ({ ticketKeys, statusCategory, dryRun }) => {
|
|
154
|
+
return async ({ ticketKeys, query, projectKey, statusCategory, dryRun }) => {
|
|
144
155
|
const outcome = emptyOutcome(dryRun === true);
|
|
145
|
-
const resolved = await resolveBatch(client, ticketKeys, outcome);
|
|
156
|
+
const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
|
|
146
157
|
const legacy = CATEGORY_TO_LEGACY[statusCategory];
|
|
147
158
|
for (const [k, t] of resolved) {
|
|
148
159
|
if (dryRun) {
|
|
@@ -160,23 +171,22 @@ export function makeBulkMoveTicketsHandler(client) {
|
|
|
160
171
|
return bulkResult(`bulk_move → ${statusCategory}`, outcome);
|
|
161
172
|
};
|
|
162
173
|
}
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
// orboto_bulk_close_tickets
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
174
|
export const bulkCloseTicketsToolConfig = {
|
|
167
175
|
title: 'Close many tickets (optionally with a shared comment)',
|
|
168
|
-
description: 'For every ticket in `ticketKeys
|
|
176
|
+
description: 'For every ticket in `ticketKeys` (or the tickets `query` selects): optionally post the same `comment` first, then move it to `done`. Mirrors `orboto.mjs bulk-close`. Comment-first ordering means the close note lands in the audit trail even if the status PATCH 403s.',
|
|
169
177
|
inputSchema: z.object({
|
|
170
178
|
ticketKeys: TICKET_KEY_ARRAY,
|
|
179
|
+
query: QUERY_SOURCE,
|
|
180
|
+
projectKey: QUERY_PROJECT,
|
|
171
181
|
comment: z.string().min(1).optional().describe('Optional shared closing comment posted on each ticket before its status move.'),
|
|
172
182
|
dryRun: z.boolean().optional(),
|
|
173
183
|
}).shape,
|
|
174
184
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
175
185
|
};
|
|
176
186
|
export function makeBulkCloseTicketsHandler(client) {
|
|
177
|
-
return async ({ ticketKeys, comment, dryRun }) => {
|
|
187
|
+
return async ({ ticketKeys, query, projectKey, comment, dryRun }) => {
|
|
178
188
|
const outcome = emptyOutcome(dryRun === true);
|
|
179
|
-
const resolved = await resolveBatch(client, ticketKeys, outcome);
|
|
189
|
+
const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
|
|
180
190
|
for (const [k, t] of resolved) {
|
|
181
191
|
if (dryRun) {
|
|
182
192
|
outcome.skipped.push(k);
|
|
@@ -196,14 +206,13 @@ export function makeBulkCloseTicketsHandler(client) {
|
|
|
196
206
|
return bulkResult(comment ? 'bulk_close + comment' : 'bulk_close', outcome);
|
|
197
207
|
};
|
|
198
208
|
}
|
|
199
|
-
// ---------------------------------------------------------------------------
|
|
200
|
-
// orboto_bulk_comment_tickets
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
209
|
export const bulkCommentTicketsToolConfig = {
|
|
203
210
|
title: 'Post the same comment on many tickets',
|
|
204
|
-
description: 'Append the same comment body to every ticket in `ticketKeys
|
|
211
|
+
description: 'Append the same comment body to every ticket in `ticketKeys` (or the tickets `query` selects). Useful for "I am back-propagating decision X to all affected tickets" workflows. `isInternal=true` hides from external/guest users.',
|
|
205
212
|
inputSchema: z.object({
|
|
206
213
|
ticketKeys: TICKET_KEY_ARRAY,
|
|
214
|
+
query: QUERY_SOURCE,
|
|
215
|
+
projectKey: QUERY_PROJECT,
|
|
207
216
|
text: z.string().min(1),
|
|
208
217
|
isInternal: z.boolean().optional(),
|
|
209
218
|
dryRun: z.boolean().optional(),
|
|
@@ -211,9 +220,9 @@ export const bulkCommentTicketsToolConfig = {
|
|
|
211
220
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
212
221
|
};
|
|
213
222
|
export function makeBulkCommentTicketsHandler(client) {
|
|
214
|
-
return async ({ ticketKeys, text, isInternal, dryRun }) => {
|
|
223
|
+
return async ({ ticketKeys, query, projectKey, text, isInternal, dryRun }) => {
|
|
215
224
|
const outcome = emptyOutcome(dryRun === true);
|
|
216
|
-
const resolved = await resolveBatch(client, ticketKeys, outcome);
|
|
225
|
+
const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
|
|
217
226
|
for (const [k, t] of resolved) {
|
|
218
227
|
if (dryRun) {
|
|
219
228
|
outcome.skipped.push(k);
|
|
@@ -233,9 +242,6 @@ export function makeBulkCommentTicketsHandler(client) {
|
|
|
233
242
|
return bulkResult('bulk_comment', outcome);
|
|
234
243
|
};
|
|
235
244
|
}
|
|
236
|
-
// ---------------------------------------------------------------------------
|
|
237
|
-
// orboto_bulk_assign_tickets / orboto_bulk_unassign_tickets
|
|
238
|
-
// ---------------------------------------------------------------------------
|
|
239
245
|
/** Resolve email → userId once per project. The bulk operation may
|
|
240
246
|
* touch tickets across multiple projects; we memoize the lookup so we
|
|
241
247
|
* don't refetch the members list for every ticket. */
|
|
@@ -255,18 +261,20 @@ async function makeAssigneeResolver(client, email) {
|
|
|
255
261
|
}
|
|
256
262
|
export const bulkAssignTicketsToolConfig = {
|
|
257
263
|
title: 'Assign the same user to many tickets',
|
|
258
|
-
description: 'POST `assigneeEmail` as an additional assignee on every ticket in `ticketKeys
|
|
264
|
+
description: 'POST `assigneeEmail` as an additional assignee on every ticket in `ticketKeys` (or the tickets `query` selects). Multi-assignee is supported - this adds, it does not replace. Idempotent: a 409 (already assigned) counts as success.',
|
|
259
265
|
inputSchema: z.object({
|
|
260
266
|
ticketKeys: TICKET_KEY_ARRAY,
|
|
267
|
+
query: QUERY_SOURCE,
|
|
268
|
+
projectKey: QUERY_PROJECT,
|
|
261
269
|
assigneeEmail: z.string().email(),
|
|
262
270
|
dryRun: z.boolean().optional(),
|
|
263
271
|
}).shape,
|
|
264
272
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
265
273
|
};
|
|
266
274
|
export function makeBulkAssignTicketsHandler(client) {
|
|
267
|
-
return async ({ ticketKeys, assigneeEmail, dryRun }) => {
|
|
275
|
+
return async ({ ticketKeys, query, projectKey, assigneeEmail, dryRun }) => {
|
|
268
276
|
const outcome = emptyOutcome(dryRun === true);
|
|
269
|
-
const resolved = await resolveBatch(client, ticketKeys, outcome);
|
|
277
|
+
const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
|
|
270
278
|
const resolveAssignee = await makeAssigneeResolver(client, assigneeEmail);
|
|
271
279
|
for (const [k, t] of resolved) {
|
|
272
280
|
if (dryRun) {
|
|
@@ -280,7 +288,6 @@ export function makeBulkAssignTicketsHandler(client) {
|
|
|
280
288
|
}
|
|
281
289
|
catch (err) {
|
|
282
290
|
if (err instanceof OrbotoApiError && err.status === 409) {
|
|
283
|
-
// Already assigned → idempotent success.
|
|
284
291
|
outcome.successful.push(k);
|
|
285
292
|
continue;
|
|
286
293
|
}
|
|
@@ -297,18 +304,20 @@ export function makeBulkAssignTicketsHandler(client) {
|
|
|
297
304
|
}
|
|
298
305
|
export const bulkUnassignTicketsToolConfig = {
|
|
299
306
|
title: 'Unassign the same user from many tickets',
|
|
300
|
-
description: 'DELETE `assigneeEmail` from every ticket in `ticketKeys
|
|
307
|
+
description: 'DELETE `assigneeEmail` from every ticket in `ticketKeys` (or the tickets `query` selects). Idempotent: a 404 (was not assigned) counts as success.',
|
|
301
308
|
inputSchema: z.object({
|
|
302
309
|
ticketKeys: TICKET_KEY_ARRAY,
|
|
310
|
+
query: QUERY_SOURCE,
|
|
311
|
+
projectKey: QUERY_PROJECT,
|
|
303
312
|
assigneeEmail: z.string().email(),
|
|
304
313
|
dryRun: z.boolean().optional(),
|
|
305
314
|
}).shape,
|
|
306
315
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
307
316
|
};
|
|
308
317
|
export function makeBulkUnassignTicketsHandler(client) {
|
|
309
|
-
return async ({ ticketKeys, assigneeEmail, dryRun }) => {
|
|
318
|
+
return async ({ ticketKeys, query, projectKey, assigneeEmail, dryRun }) => {
|
|
310
319
|
const outcome = emptyOutcome(dryRun === true);
|
|
311
|
-
const resolved = await resolveBatch(client, ticketKeys, outcome);
|
|
320
|
+
const resolved = await resolveBatch(client, await targetKeys(client, { ticketKeys, query, projectKey }), outcome);
|
|
312
321
|
const resolveAssignee = await makeAssigneeResolver(client, assigneeEmail);
|
|
313
322
|
for (const [k, t] of resolved) {
|
|
314
323
|
if (dryRun) {
|
|
@@ -322,7 +331,6 @@ export function makeBulkUnassignTicketsHandler(client) {
|
|
|
322
331
|
}
|
|
323
332
|
catch (err) {
|
|
324
333
|
if (err instanceof OrbotoApiError && err.status === 404) {
|
|
325
|
-
// Wasn't assigned → idempotent success.
|
|
326
334
|
outcome.successful.push(k);
|
|
327
335
|
continue;
|
|
328
336
|
}
|
|
@@ -43,8 +43,6 @@ function ticket(n) {
|
|
|
43
43
|
type: 'task', priority: 'normal', estimatedTimeMinutes: 0, dueDate: null, isPrivate: false,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
// Resolution responses for keys ACME-1, ACME-2 (project lookup is shared
|
|
47
|
-
// - but the client re-fetches each time. We supply one per key.)
|
|
48
46
|
function resolveOK(n) {
|
|
49
47
|
return [
|
|
50
48
|
{ json: PROJ },
|
|
@@ -154,7 +152,6 @@ describe('orboto_bulk_close_tickets', () => {
|
|
|
154
152
|
const patchCall = calls.find((c) => c.method === 'PATCH');
|
|
155
153
|
expect(commentCall).toBeDefined();
|
|
156
154
|
expect(patchCall?.body).toEqual({ status: 'DONE' });
|
|
157
|
-
// Comment must be BEFORE the patch.
|
|
158
155
|
expect(calls.indexOf(commentCall)).toBeLessThan(calls.indexOf(patchCall));
|
|
159
156
|
});
|
|
160
157
|
it('without comment, only PATCHes status=DONE', async () => {
|
|
@@ -197,7 +194,6 @@ describe('orboto_bulk_assign_tickets', () => {
|
|
|
197
194
|
ticketKeys: ['ACME-1', 'ACME-2'],
|
|
198
195
|
assigneeEmail: 'who@orboto.io',
|
|
199
196
|
});
|
|
200
|
-
// Members fetched exactly once thanks to the per-project cache.
|
|
201
197
|
expect(calls.filter((c) => c.url.includes('/members'))).toHaveLength(1);
|
|
202
198
|
expect(res.structuredContent).toMatchObject({
|
|
203
199
|
successful: ['ACME-1', 'ACME-2'],
|
|
@@ -237,3 +233,37 @@ describe('orboto_bulk_unassign_tickets', () => {
|
|
|
237
233
|
});
|
|
238
234
|
});
|
|
239
235
|
});
|
|
236
|
+
describe('query-driven bulk targets', () => {
|
|
237
|
+
it('resolves the query through POST /query (paged) and then patches each match', async () => {
|
|
238
|
+
const calls = stub([
|
|
239
|
+
{ json: { items: [ticket(1)], nextCursor: 'c2' } },
|
|
240
|
+
{ json: { items: [ticket(2)], nextCursor: null } },
|
|
241
|
+
...resolveOK(1),
|
|
242
|
+
...resolveOK(2),
|
|
243
|
+
{ json: ticket(1) },
|
|
244
|
+
{ json: ticket(2) },
|
|
245
|
+
]);
|
|
246
|
+
const res = await makeBulkPatchTicketsHandler(client)({
|
|
247
|
+
query: 'status = todo AND labels = "triage"',
|
|
248
|
+
projectKey: 'ACME',
|
|
249
|
+
patch: { priority: 'high' },
|
|
250
|
+
});
|
|
251
|
+
const queryCalls = calls.filter((c) => c.url.endsWith('/query'));
|
|
252
|
+
expect(queryCalls).toHaveLength(2);
|
|
253
|
+
expect(queryCalls[0].body).toMatchObject({ oql: 'project = ACME AND (status = todo AND labels = "triage")', limit: 100 });
|
|
254
|
+
expect(queryCalls[1].body).toMatchObject({ cursor: 'c2' });
|
|
255
|
+
expect(calls.filter((c) => c.method === 'PATCH')).toHaveLength(2);
|
|
256
|
+
expect(res.structuredContent).toMatchObject({ successful: ['ACME-1', 'ACME-2'], failed: [] });
|
|
257
|
+
});
|
|
258
|
+
it('refuses both or neither source, an empty match, and more than the per-call cap', async () => {
|
|
259
|
+
await expect(makeBulkMoveTicketsHandler(client)({ ticketKeys: ['ACME-1'], query: 'x', statusCategory: 'done' }))
|
|
260
|
+
.rejects.toThrow(/exactly one/);
|
|
261
|
+
await expect(makeBulkMoveTicketsHandler(client)({ statusCategory: 'done' })).rejects.toThrow(/exactly one/);
|
|
262
|
+
stub([{ json: { items: [], nextCursor: null } }]);
|
|
263
|
+
await expect(makeBulkCloseTicketsHandler(client)({ query: 'status = todo' })).rejects.toThrow(/matched no tickets/);
|
|
264
|
+
stub([{ json: { items: Array.from({ length: 100 }, (_, i) => ticket(i + 1)), nextCursor: 'c2' } },
|
|
265
|
+
{ json: { items: Array.from({ length: 100 }, (_, i) => ticket(i + 101)), nextCursor: 'c3' } },
|
|
266
|
+
{ json: { items: [ticket(201)], nextCursor: null } }]);
|
|
267
|
+
await expect(makeBulkCommentTicketsHandler(client)({ query: 'status = todo', text: 'hi' })).rejects.toThrow(/more than 200/);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-831 / ORB-887 - `orboto_check_similar`.
|
|
3
|
-
*
|
|
4
|
-
* Dry-run sibling of `orboto_create_ticket`: takes a proposed title +
|
|
5
|
-
* description and returns the tickets that would land in
|
|
6
|
-
* `similarWarnings` if the create were to happen now. Intended as the
|
|
7
|
-
* cautious agent's pre-create probe - call this first when the task
|
|
8
|
-
* scope feels close to existing work, decide whether to follow up on
|
|
9
|
-
* the existing ticket instead, then either commit (`orboto_create_ticket`)
|
|
10
|
-
* or pivot.
|
|
11
|
-
*
|
|
12
|
-
* Wraps the existing `GET /projects/:id/tickets/similar` route so the
|
|
13
|
-
* matching pipeline (tsvector + optional embedding rerank) is shared
|
|
14
|
-
* with both the UI new-ticket form and the POST-create safety-net.
|
|
15
3
|
*/
|
|
16
4
|
import { z } from 'zod';
|
|
17
5
|
import { trimSimilarEntries } from './similar-projection.js';
|
|
@@ -38,7 +26,6 @@ export function makeCheckSimilarHandler(client) {
|
|
|
38
26
|
});
|
|
39
27
|
if (description)
|
|
40
28
|
qs.set('description', description);
|
|
41
|
-
// ORB-1604 - hierarchy-aware classification inputs.
|
|
42
29
|
if (parentTicketKey) {
|
|
43
30
|
const parent = await resolveTicketByKey(client, parentTicketKey);
|
|
44
31
|
qs.set('parentTicketId', parent.id);
|
|
@@ -46,9 +33,6 @@ export function makeCheckSimilarHandler(client) {
|
|
|
46
33
|
if (forType)
|
|
47
34
|
qs.set('forType', forType);
|
|
48
35
|
const result = await client.get(`/projects/${project.id}/tickets/similar?${qs.toString()}`);
|
|
49
|
-
// ORB-1604 - only relation-free candidates are duplicate signals; the
|
|
50
|
-
// parent, siblings and epics are related context (they drove the 70%
|
|
51
|
-
// --allow-duplicate override rate in the field).
|
|
52
36
|
const realDuplicates = result.candidates.filter((c) => !c.relation);
|
|
53
37
|
const related = result.candidates.filter((c) => c.relation);
|
|
54
38
|
const recommendation = realDuplicates.length === 0
|
|
@@ -74,7 +58,6 @@ export function makeCheckSimilarHandler(client) {
|
|
|
74
58
|
return {
|
|
75
59
|
content: [{ type: 'text', text }],
|
|
76
60
|
structuredContent: {
|
|
77
|
-
// ORB-1693 - same agent projection as create_ticket's warnings.
|
|
78
61
|
similar: trimSimilarEntries(result.candidates),
|
|
79
62
|
mode: result.mode,
|
|
80
63
|
recommendation,
|
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-244 Phase C Group 3 - checklist write tools (ORB-234).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - orboto_check / orboto_uncheck - toggle a single item's `isCompleted`
|
|
6
|
-
* - orboto_add_check - append a new item to a list (default: first list
|
|
7
|
-
* on the ticket)
|
|
8
|
-
* - orboto_new_checklist - create a fresh list with optional triggers-done
|
|
9
|
-
*
|
|
10
|
-
* Item identifier: agents prefer 1-based indexes ("check item 3 on
|
|
11
|
-
* ACME-42") because UUIDs are ergonomic disasters in a chat. The
|
|
12
|
-
* helper resolves either form by reading the ticket's checklists
|
|
13
|
-
* once. UUID input is honoured directly so callers that already have
|
|
14
|
-
* the ID can skip the lookup.
|
|
4
|
+
* @see ORB-235
|
|
15
5
|
*/
|
|
16
6
|
import { z } from 'zod';
|
|
17
7
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
18
8
|
import { resolveTicketByKey } from './shared.js';
|
|
9
|
+
const ISO_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
10
|
+
/**
|
|
11
|
+
* ORB-235 - resolve an assignee reference (user UUID, email, or full name)
|
|
12
|
+
* to a member of the ticket's project. Names the members on a miss so the
|
|
13
|
+
* agent can pick instead of guessing.
|
|
14
|
+
*/
|
|
15
|
+
async function resolveMemberId(client, projectId, ref) {
|
|
16
|
+
const members = await client.get(`/projects/${projectId}/members`);
|
|
17
|
+
if (UUID_RE.test(ref) && members.some((m) => m.userId === ref))
|
|
18
|
+
return ref;
|
|
19
|
+
const needle = ref.trim().toLowerCase();
|
|
20
|
+
const hit = members.find((m) => m.user.email.toLowerCase() === needle || m.user.fullName.toLowerCase() === needle);
|
|
21
|
+
if (!hit) {
|
|
22
|
+
throw new Error(`"${ref}" is not a member of this project. Members: ${members.map((m) => `${m.user.fullName} <${m.user.email}>`).join(', ') || '(none)'}`);
|
|
23
|
+
}
|
|
24
|
+
return hit.userId;
|
|
25
|
+
}
|
|
26
|
+
function assertIsoDay(value) {
|
|
27
|
+
if (!ISO_DAY_RE.test(value))
|
|
28
|
+
throw new Error(`dueDate must be YYYY-MM-DD, got "${value}".`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
19
31
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
32
|
/**
|
|
21
33
|
* Resolve an item identifier (1-based index OR UUID) to its UUID.
|
|
@@ -39,9 +51,6 @@ async function resolveItemId(client, ticketId, identifier) {
|
|
|
39
51
|
}
|
|
40
52
|
return { itemId: target.id, checklists };
|
|
41
53
|
}
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// orboto_check / orboto_uncheck
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
54
|
function makeToggleHandler(client, completed) {
|
|
46
55
|
return async ({ ticketKey, item }) => {
|
|
47
56
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
@@ -83,10 +92,6 @@ export const uncheckToolConfig = {
|
|
|
83
92
|
};
|
|
84
93
|
export const makeCheckHandler = (client) => makeToggleHandler(client, true);
|
|
85
94
|
export const makeUncheckHandler = (client) => makeToggleHandler(client, false);
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
// orboto_remove_check (ORB-1095) - agent parity for DELETE
|
|
88
|
-
// /checklist-items/:id. The surfaces could append but not remove.
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
95
|
export const removeCheckToolConfig = {
|
|
91
96
|
title: 'Remove a checklist item',
|
|
92
97
|
description: 'Delete a checklist item from a ticket. `item` is either a 1-based index (global across all checklists on the ticket, top to bottom) or the item\'s UUID - same semantics as orboto_check. Destructive: the item is gone, not just unchecked.',
|
|
@@ -105,22 +110,21 @@ export function makeRemoveCheckHandler(client) {
|
|
|
105
110
|
};
|
|
106
111
|
};
|
|
107
112
|
}
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
// orboto_add_check
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
113
|
export const addCheckToolConfig = {
|
|
112
114
|
title: 'Add a checklist item',
|
|
113
|
-
description: 'Append an item to a checklist on a ticket. By default appends to the FIRST checklist on the ticket; pass `listTitle` to target a specific one. Pass `linkedTicketKey` to make the item track another ticket\'s status (the new item\'s `effectiveCompleted` mirrors that ticket\'s done-status from then on).',
|
|
115
|
+
description: 'Append an item to a checklist on a ticket. By default appends to the FIRST checklist on the ticket; pass `listTitle` to target a specific one. Pass `linkedTicketKey` to make the item track another ticket\'s status (the new item\'s `effectiveCompleted` mirrors that ticket\'s done-status from then on). `assignee` (a project member: email, full name or user id) and `dueDate` (YYYY-MM-DD) give the item its own owner and deadline - the assignee is notified, reminded the day before, and sees the item in their upcoming deadlines.',
|
|
114
116
|
inputSchema: z.object({
|
|
115
117
|
ticketKey: z.string().min(3),
|
|
116
118
|
content: z.string().min(1).max(2000),
|
|
117
119
|
listTitle: z.string().optional().describe('Target checklist title. Default: first checklist on the ticket.'),
|
|
118
120
|
linkedTicketKey: z.string().optional().describe('Make this item track another ticket\'s done-status (e.g. "ACME-99").'),
|
|
121
|
+
assignee: z.string().optional().describe('Project member to own this item - email, full name or user id.'),
|
|
122
|
+
dueDate: z.string().optional().describe('Due date of this item, YYYY-MM-DD.'),
|
|
119
123
|
}).shape,
|
|
120
124
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
121
125
|
};
|
|
122
126
|
export function makeAddCheckHandler(client) {
|
|
123
|
-
return async ({ ticketKey, content, listTitle, linkedTicketKey }) => {
|
|
127
|
+
return async ({ ticketKey, content, listTitle, linkedTicketKey, assignee, dueDate }) => {
|
|
124
128
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
125
129
|
const checklists = await client.get(`/tickets/${ticket.id}/checklists`);
|
|
126
130
|
if (checklists.length === 0) {
|
|
@@ -137,11 +141,15 @@ export function makeAddCheckHandler(client) {
|
|
|
137
141
|
const linked = await resolveTicketByKey(client, linkedTicketKey);
|
|
138
142
|
body.linkedTicketId = linked.id;
|
|
139
143
|
}
|
|
144
|
+
if (assignee)
|
|
145
|
+
body.assignedTo = await resolveMemberId(client, ticket.projectId, assignee);
|
|
146
|
+
if (dueDate)
|
|
147
|
+
body.dueDate = assertIsoDay(dueDate);
|
|
140
148
|
const item = await client.post(`/tickets/${ticket.id}/checklists/${target.id}/items`, body);
|
|
141
149
|
return {
|
|
142
150
|
content: [{
|
|
143
151
|
type: 'text',
|
|
144
|
-
text: `Added to "${target.title}" on [${ticket.ticketKey}]: ${content}${linkedTicketKey ? ` ↪ [${linkedTicketKey}]` : ''}`,
|
|
152
|
+
text: `Added to "${target.title}" on [${ticket.ticketKey}]: ${content}${linkedTicketKey ? ` ↪ [${linkedTicketKey}]` : ''}${assignee ? ` @${assignee}` : ''}${dueDate ? ` due ${dueDate}` : ''}`,
|
|
145
153
|
}],
|
|
146
154
|
structuredContent: {
|
|
147
155
|
ticketKey: ticket.ticketKey,
|
|
@@ -149,13 +157,60 @@ export function makeAddCheckHandler(client) {
|
|
|
149
157
|
itemId: item.id,
|
|
150
158
|
content: item.content,
|
|
151
159
|
linkedTicketKey: linkedTicketKey ?? null,
|
|
160
|
+
assignedTo: body.assignedTo ?? null,
|
|
161
|
+
dueDate: body.dueDate ?? null,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export const updateCheckToolConfig = {
|
|
167
|
+
title: 'Update a checklist item',
|
|
168
|
+
description: 'Change a checklist item\'s text, assignee or due date. `item` is a 1-based index (global across all checklists on the ticket, top to bottom) or the item\'s UUID - same as orboto_check. `assignee` is a project member (email, full name or user id) or `null` to unassign; `dueDate` is YYYY-MM-DD or `null` to clear. A new assignee is notified; a new due date gets its own day-before reminder.',
|
|
169
|
+
inputSchema: z.object({
|
|
170
|
+
ticketKey: z.string().min(3),
|
|
171
|
+
item: z.union([
|
|
172
|
+
z.number().int().min(1).describe('1-based index, global across all checklists on this ticket'),
|
|
173
|
+
z.string().describe('Item UUID - for callers that already have it'),
|
|
174
|
+
]),
|
|
175
|
+
content: z.string().min(1).max(2000).optional().describe('New item text.'),
|
|
176
|
+
assignee: z.string().nullable().optional().describe('Project member (email, full name or user id); null unassigns.'),
|
|
177
|
+
dueDate: z.string().nullable().optional().describe('YYYY-MM-DD; null clears the due date.'),
|
|
178
|
+
}).shape,
|
|
179
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
180
|
+
};
|
|
181
|
+
export function makeUpdateCheckHandler(client) {
|
|
182
|
+
return async ({ ticketKey, item, content, assignee, dueDate }) => {
|
|
183
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
184
|
+
const { itemId, checklists } = await resolveItemId(client, ticket.id, item);
|
|
185
|
+
const before = checklists.flatMap((c) => c.items).find((i) => i.id === itemId);
|
|
186
|
+
const body = {};
|
|
187
|
+
if (content !== undefined)
|
|
188
|
+
body.content = content;
|
|
189
|
+
if (assignee !== undefined)
|
|
190
|
+
body.assignedTo = assignee === null ? null : await resolveMemberId(client, ticket.projectId, assignee);
|
|
191
|
+
if (dueDate !== undefined)
|
|
192
|
+
body.dueDate = dueDate === null ? null : assertIsoDay(dueDate);
|
|
193
|
+
if (Object.keys(body).length === 0)
|
|
194
|
+
throw new Error('Nothing to update - pass content, assignee and/or dueDate.');
|
|
195
|
+
const res = await client.patch(`/checklist-items/${itemId}`, body);
|
|
196
|
+
const after = res.item;
|
|
197
|
+
const parts = [
|
|
198
|
+
after.assigneeName ? `@${after.assigneeName}` : (assignee === null ? 'unassigned' : null),
|
|
199
|
+
after.dueDate ? `due ${after.dueDate}` : (dueDate === null ? 'no due date' : null),
|
|
200
|
+
].filter(Boolean);
|
|
201
|
+
return {
|
|
202
|
+
content: [{ type: 'text', text: `Updated on [${ticket.ticketKey}]: ${after.content ?? before?.content ?? '(item)'}${parts.length ? ` (${parts.join(', ')})` : ''}` }],
|
|
203
|
+
structuredContent: {
|
|
204
|
+
ticketKey: ticket.ticketKey,
|
|
205
|
+
itemId,
|
|
206
|
+
content: after.content ?? null,
|
|
207
|
+
assignedTo: after.assignedTo ?? null,
|
|
208
|
+
assigneeName: after.assigneeName ?? null,
|
|
209
|
+
dueDate: after.dueDate ?? null,
|
|
152
210
|
},
|
|
153
211
|
};
|
|
154
212
|
};
|
|
155
213
|
}
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
|
-
// orboto_new_checklist
|
|
158
|
-
// ---------------------------------------------------------------------------
|
|
159
214
|
export const newChecklistToolConfig = {
|
|
160
215
|
title: 'Create a new checklist on a ticket',
|
|
161
216
|
description: 'Create a fresh checklist on a ticket. `triggersDone=true` gates the ticket\'s auto-done transition on this list (the ticket can\'t move to done until every item here is checked). Optional `items` seed the list with content in one call - saves a round-trip per item.',
|