@kammcs/buildit-mcp 0.0.0-stage → 0.2.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/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +325 -2
- package/SECURITY.md +48 -0
- package/dist/api/client.js +382 -0
- package/dist/api/generated/operations.js +1615 -0
- package/dist/api/generated/schemas.js +1413 -0
- package/dist/api/generated/strict.js +1681 -0
- package/dist/api/operations.js +43 -0
- package/dist/config.js +280 -0
- package/dist/errors.js +347 -0
- package/dist/identity.js +124 -0
- package/dist/index.js +73 -0
- package/dist/log.js +90 -0
- package/dist/prompts.js +114 -0
- package/dist/resources.js +55 -0
- package/dist/server.js +257 -0
- package/dist/tools/chat.js +273 -0
- package/dist/tools/comments.js +128 -0
- package/dist/tools/items-read.js +603 -0
- package/dist/tools/items-write.js +580 -0
- package/dist/tools/pages.js +405 -0
- package/dist/tools/planning.js +697 -0
- package/dist/tools/plans.js +906 -0
- package/dist/tools/projects.js +415 -0
- package/dist/tools/setup.js +292 -0
- package/dist/tools/shared.js +462 -0
- package/dist/tools/whoami.js +230 -0
- package/dist/toolsets/catalog.js +72 -0
- package/dist/toolsets/registry.js +97 -0
- package/dist/toolsets/toolsets.js +89 -0
- package/dist/transports/http.js +212 -0
- package/dist/transports/stdio.js +203 -0
- package/dist/untrusted.js +466 -0
- package/dist/version.js +28 -0
- package/package.json +75 -4
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changing items: create_item, update_item, transition_item, assign_item,
|
|
3
|
+
* link_items, unlink_items and rank_item.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { ApiError } from '../api/client.js';
|
|
8
|
+
import { ToolInputError } from '../errors.js';
|
|
9
|
+
import { defineTool } from '../toolsets/registry.js';
|
|
10
|
+
import { sanitizeLabel } from '../untrusted.js';
|
|
11
|
+
import { transitionText } from './projects.js';
|
|
12
|
+
import { commentOut, CommentOutSchema, commentText, IdempotencyKeyInput, IMAGE_LAYOUT_NOTE, itemLine, itemOut, ItemOutSchema, itemRef, ItemRefInput, KEEP_IMAGE_TITLES, ProjectKeyInput, scopesOf, UserRefInput, wrapLines, } from './shared.js';
|
|
13
|
+
const NameInput = (what) => z.string().min(1).max(60).describe(what);
|
|
14
|
+
const DateInput = z.iso.date();
|
|
15
|
+
const EstimateInput = z
|
|
16
|
+
.number()
|
|
17
|
+
.min(0)
|
|
18
|
+
.max(9999.99)
|
|
19
|
+
.describe('Points; must be a value the project allows (describe_project).');
|
|
20
|
+
const CustomInput = z
|
|
21
|
+
.record(z.string().min(1).max(60), z.union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.null()]))
|
|
22
|
+
.describe('Custom fields by name: {"Severity": "High", "Due QA": "2026-10-20"}. Select fields take option labels, people fields "me" or emails, and null clears a value.');
|
|
23
|
+
const PriorityInput = z.enum(['none', 'low', 'medium', 'high', 'urgent']);
|
|
24
|
+
const TitleInput = z
|
|
25
|
+
.string()
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(255)
|
|
28
|
+
.refine((s) => s.trim().length > 0, 'The title cannot be blank.')
|
|
29
|
+
.describe('1 to 255 characters.');
|
|
30
|
+
const DescriptionInput = z
|
|
31
|
+
.string()
|
|
32
|
+
.max(262_144)
|
|
33
|
+
.describe(`GitHub-flavored Markdown. Mention people as @email or @[Display Name]. Images by URL are refused. ${IMAGE_LAYOUT_NOTE}`);
|
|
34
|
+
/** A short text block for an item that just changed: facts, then the line (with its title) wrapped. */
|
|
35
|
+
function changedText(headline, item) {
|
|
36
|
+
return [
|
|
37
|
+
headline,
|
|
38
|
+
`version ${item.version} · description_version ${item.description_version}`,
|
|
39
|
+
wrapLines([itemLine(item)], 'item'),
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// create_item
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
export const createItemTool = defineTool({
|
|
46
|
+
name: 'create_item',
|
|
47
|
+
toolset: 'items',
|
|
48
|
+
title: 'Create an item',
|
|
49
|
+
description: `Creates an item at any level: an epic, a standard item (story, task, bug) or a subtask.
|
|
50
|
+
- Give project (a key such as DEMO), or parent: a story's parent is an epic, a subtask's parent is a story or task, and the project then comes from the parent.
|
|
51
|
+
- type, status, labels, sprint, fix_release and custom fields are names from describe_project. Without type, the project's default type at the parent's child level is used; without status, the workflow's initial status.
|
|
52
|
+
- assignee: "me", an email or an exact display name. Mention people in the description as @email or @[Display Name].
|
|
53
|
+
- position: {"before": "DEMO-3"} or {"after": "DEMO-3"}; default is last.
|
|
54
|
+
- idempotency_key: retrying with the same key returns the first item instead of creating a second; one is generated and returned if you leave it out.
|
|
55
|
+
Returns the new item's key and versions.`,
|
|
56
|
+
scopes: scopesOf('create_item'),
|
|
57
|
+
annotations: {
|
|
58
|
+
title: 'Create an item',
|
|
59
|
+
readOnlyHint: false,
|
|
60
|
+
destructiveHint: false,
|
|
61
|
+
idempotentHint: false,
|
|
62
|
+
openWorldHint: false,
|
|
63
|
+
},
|
|
64
|
+
inputSchema: z.object({
|
|
65
|
+
project: ProjectKeyInput.optional().describe('Required unless parent is given.'),
|
|
66
|
+
parent: ItemRefInput.optional().describe('An epic (for a story, task or bug) or a story or task (for a subtask).'),
|
|
67
|
+
type: NameInput('A type name, such as Story, Bug, Task, Epic or Subtask.').optional(),
|
|
68
|
+
title: TitleInput,
|
|
69
|
+
description: DescriptionInput.optional(),
|
|
70
|
+
status: NameInput('A status name; default: the initial status.').optional(),
|
|
71
|
+
assignee: UserRefInput.optional(),
|
|
72
|
+
priority: PriorityInput.optional(),
|
|
73
|
+
labels: z.array(z.string().min(1).max(40)).max(100).optional().describe('Label names.'),
|
|
74
|
+
estimate: EstimateInput.optional(),
|
|
75
|
+
start_date: DateInput.optional().describe('YYYY-MM-DD.'),
|
|
76
|
+
due_date: DateInput.optional().describe('YYYY-MM-DD.'),
|
|
77
|
+
sprint: z.string().min(1).max(80).optional().describe('A sprint number, name or "active".'),
|
|
78
|
+
fix_release: z.string().min(1).max(80).optional().describe('A release name.'),
|
|
79
|
+
initiative: z.string().min(1).max(50).optional().describe('An initiative key (epics only).'),
|
|
80
|
+
custom: CustomInput.optional(),
|
|
81
|
+
position: z
|
|
82
|
+
.object({
|
|
83
|
+
before: ItemRefInput.optional(),
|
|
84
|
+
after: ItemRefInput.optional(),
|
|
85
|
+
})
|
|
86
|
+
.optional()
|
|
87
|
+
.describe('Exactly one of before or after.'),
|
|
88
|
+
idempotency_key: IdempotencyKeyInput.optional(),
|
|
89
|
+
}),
|
|
90
|
+
outputSchema: z.object({
|
|
91
|
+
item: ItemOutSchema,
|
|
92
|
+
created: z.boolean().describe('false: an earlier call with this idempotency_key created it.'),
|
|
93
|
+
idempotency_key: z.string(),
|
|
94
|
+
}),
|
|
95
|
+
async run(args, ctx) {
|
|
96
|
+
if (args.project === undefined && args.parent === undefined) {
|
|
97
|
+
throw new ToolInputError('Give project (a key such as DEMO) or parent (an item key).');
|
|
98
|
+
}
|
|
99
|
+
if (args.position &&
|
|
100
|
+
(args.position.before === undefined) === (args.position.after === undefined)) {
|
|
101
|
+
throw new ToolInputError('position takes exactly one of before or after.');
|
|
102
|
+
}
|
|
103
|
+
const key = args.idempotency_key ?? randomUUID();
|
|
104
|
+
const r = await ctx.call('create_item', {
|
|
105
|
+
body: {
|
|
106
|
+
...(args.project !== undefined ? { project: args.project } : {}),
|
|
107
|
+
...(args.parent !== undefined ? { parent: itemRef(args.parent) } : {}),
|
|
108
|
+
...(args.type !== undefined ? { type: args.type } : {}),
|
|
109
|
+
title: args.title,
|
|
110
|
+
...(args.description !== undefined ? { description: args.description } : {}),
|
|
111
|
+
...(args.status !== undefined ? { status: args.status } : {}),
|
|
112
|
+
...(args.assignee !== undefined ? { assignee: args.assignee } : {}),
|
|
113
|
+
...(args.priority !== undefined ? { priority: args.priority } : {}),
|
|
114
|
+
...(args.labels !== undefined ? { labels: args.labels } : {}),
|
|
115
|
+
...(args.estimate !== undefined ? { estimate: args.estimate } : {}),
|
|
116
|
+
...(args.start_date !== undefined ? { start_date: args.start_date } : {}),
|
|
117
|
+
...(args.due_date !== undefined ? { due_date: args.due_date } : {}),
|
|
118
|
+
...(args.sprint !== undefined ? { sprint: args.sprint } : {}),
|
|
119
|
+
...(args.fix_release !== undefined ? { fix_release: args.fix_release } : {}),
|
|
120
|
+
...(args.initiative !== undefined ? { initiative: args.initiative } : {}),
|
|
121
|
+
...(args.custom !== undefined ? { custom: args.custom } : {}),
|
|
122
|
+
...(args.position
|
|
123
|
+
? {
|
|
124
|
+
position: args.position.before
|
|
125
|
+
? { before: itemRef(args.position.before) }
|
|
126
|
+
: { after: itemRef(args.position.after ?? '') },
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
idempotency_key: key,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const item = itemOut(r.item);
|
|
133
|
+
const headline = r.created
|
|
134
|
+
? `Created ${item.key} (${item.type}, ${item.status}).`
|
|
135
|
+
: `Nothing new: ${item.key} was already created with this idempotency_key.`;
|
|
136
|
+
return {
|
|
137
|
+
structured: { item, created: r.created, idempotency_key: key },
|
|
138
|
+
text: changedText(headline, item),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// update_item
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
const UpdateInput = z.object({
|
|
146
|
+
item: ItemRefInput,
|
|
147
|
+
if_version: z
|
|
148
|
+
.number()
|
|
149
|
+
.int()
|
|
150
|
+
.min(1)
|
|
151
|
+
.optional()
|
|
152
|
+
.describe("The item's version you read; the update is refused if someone changed it since."),
|
|
153
|
+
title: TitleInput.optional(),
|
|
154
|
+
description_append: DescriptionInput.optional().describe('Markdown added at the end of the description after a blank line; never overwrites anyone. Images as in create_item.'),
|
|
155
|
+
description_replace: DescriptionInput.optional().describe(`Markdown that replaces the whole description. Needs description_version. ${KEEP_IMAGE_TITLES}`),
|
|
156
|
+
description_version: z
|
|
157
|
+
.number()
|
|
158
|
+
.int()
|
|
159
|
+
.min(1)
|
|
160
|
+
.optional()
|
|
161
|
+
.describe('The description_version you read (get_item); required with description_replace.'),
|
|
162
|
+
type: NameInput('A new type name.').optional(),
|
|
163
|
+
status: NameInput('Only with type, when the new type follows another workflow: the status in it. To change status otherwise, use transition_item.').optional(),
|
|
164
|
+
parent: ItemRefInput.nullable().optional().describe('A new parent, or null to detach.'),
|
|
165
|
+
assignee: UserRefInput.nullable().optional().describe('A person, or null to unassign.'),
|
|
166
|
+
priority: PriorityInput.optional(),
|
|
167
|
+
labels: z.array(z.string().min(1).max(40)).max(100).optional().describe('Replaces all labels.'),
|
|
168
|
+
add_labels: z.array(z.string().min(1).max(40)).max(100).optional(),
|
|
169
|
+
remove_labels: z.array(z.string().min(1).max(40)).max(100).optional(),
|
|
170
|
+
estimate: EstimateInput.nullable().optional(),
|
|
171
|
+
start_date: DateInput.nullable().optional(),
|
|
172
|
+
due_date: DateInput.nullable().optional(),
|
|
173
|
+
sprint: z.string().min(1).max(80).nullable().optional().describe('A sprint, or null.'),
|
|
174
|
+
fix_release: z.string().min(1).max(80).nullable().optional().describe('A release, or null.'),
|
|
175
|
+
initiative: z.string().min(1).max(50).nullable().optional(),
|
|
176
|
+
custom: CustomInput.optional(),
|
|
177
|
+
});
|
|
178
|
+
function updateBody(args) {
|
|
179
|
+
if (args.description_append !== undefined && args.description_replace !== undefined) {
|
|
180
|
+
throw new ToolInputError('Give description_append or description_replace, not both.');
|
|
181
|
+
}
|
|
182
|
+
if (args.description_replace !== undefined && args.description_version === undefined) {
|
|
183
|
+
throw new ToolInputError('description_replace needs description_version: read the item with get_item first and pass its description_version. To add text without reading, use description_append.');
|
|
184
|
+
}
|
|
185
|
+
if (args.status !== undefined && args.type === undefined) {
|
|
186
|
+
throw new ToolInputError('status goes with type only (when the new type follows another workflow). To move the item to another status, use transition_item.');
|
|
187
|
+
}
|
|
188
|
+
const body = {
|
|
189
|
+
...(args.if_version !== undefined ? { if_version: args.if_version } : {}),
|
|
190
|
+
...(args.title !== undefined ? { title: args.title } : {}),
|
|
191
|
+
...(args.description_append !== undefined
|
|
192
|
+
? { description: { mode: 'append', text: args.description_append } }
|
|
193
|
+
: {}),
|
|
194
|
+
...(args.description_replace !== undefined && args.description_version !== undefined
|
|
195
|
+
? {
|
|
196
|
+
description: {
|
|
197
|
+
mode: 'replace',
|
|
198
|
+
text: args.description_replace,
|
|
199
|
+
description_version: args.description_version,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
203
|
+
...(args.type !== undefined ? { type: args.type } : {}),
|
|
204
|
+
...(args.status !== undefined ? { status: args.status } : {}),
|
|
205
|
+
...(args.parent !== undefined
|
|
206
|
+
? { parent: args.parent === null ? null : itemRef(args.parent) }
|
|
207
|
+
: {}),
|
|
208
|
+
...(args.assignee !== undefined ? { assignee: args.assignee } : {}),
|
|
209
|
+
...(args.priority !== undefined ? { priority: args.priority } : {}),
|
|
210
|
+
...(args.labels !== undefined ? { labels: args.labels } : {}),
|
|
211
|
+
...(args.add_labels !== undefined ? { add_labels: args.add_labels } : {}),
|
|
212
|
+
...(args.remove_labels !== undefined ? { remove_labels: args.remove_labels } : {}),
|
|
213
|
+
...(args.estimate !== undefined ? { estimate: args.estimate } : {}),
|
|
214
|
+
...(args.start_date !== undefined ? { start_date: args.start_date } : {}),
|
|
215
|
+
...(args.due_date !== undefined ? { due_date: args.due_date } : {}),
|
|
216
|
+
...(args.sprint !== undefined ? { sprint: args.sprint } : {}),
|
|
217
|
+
...(args.fix_release !== undefined ? { fix_release: args.fix_release } : {}),
|
|
218
|
+
...(args.initiative !== undefined ? { initiative: args.initiative } : {}),
|
|
219
|
+
...(args.custom !== undefined ? { custom: args.custom } : {}),
|
|
220
|
+
};
|
|
221
|
+
if (Object.keys(body).every((k) => k === 'if_version')) {
|
|
222
|
+
throw new ToolInputError('Nothing to change: give at least one field to update.');
|
|
223
|
+
}
|
|
224
|
+
return body;
|
|
225
|
+
}
|
|
226
|
+
export const updateItemTool = defineTool({
|
|
227
|
+
name: 'update_item',
|
|
228
|
+
toolset: 'items',
|
|
229
|
+
title: 'Update an item',
|
|
230
|
+
description: `Changes fields of one item. Only the fields you give change; null clears a field.
|
|
231
|
+
- Description: description_append adds Markdown at the end and never overwrites anyone's edit (prefer it); description_replace replaces it all and needs the description_version from get_item.
|
|
232
|
+
- if_version (from get_item or search_items) refuses the update if someone changed the item since you read it.
|
|
233
|
+
- Labels: labels replaces all; add_labels and remove_labels change some.
|
|
234
|
+
- To change the status use transition_item; to only assign, assign_item is simpler.
|
|
235
|
+
Returns the item with its new versions.`,
|
|
236
|
+
scopes: scopesOf('update_item'),
|
|
237
|
+
annotations: {
|
|
238
|
+
title: 'Update an item',
|
|
239
|
+
readOnlyHint: false,
|
|
240
|
+
destructiveHint: false,
|
|
241
|
+
idempotentHint: true,
|
|
242
|
+
openWorldHint: false,
|
|
243
|
+
},
|
|
244
|
+
inputSchema: UpdateInput,
|
|
245
|
+
outputSchema: z.object({ item: ItemOutSchema }),
|
|
246
|
+
async run(args, ctx) {
|
|
247
|
+
const body = updateBody(args);
|
|
248
|
+
const r = await ctx.call('update_item', { path: { key: itemRef(args.item) }, body });
|
|
249
|
+
const item = itemOut(r.item);
|
|
250
|
+
return { structured: { item }, text: changedText(`Updated ${item.key}.`, item) };
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// assign_item
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
export const assignItemTool = defineTool({
|
|
257
|
+
name: 'assign_item',
|
|
258
|
+
toolset: 'items',
|
|
259
|
+
title: 'Assign an item',
|
|
260
|
+
description: 'Assigns one item to a person, or unassigns it. assignee is "me", an email, or an exact display name among the project\'s members (find_users lists them); null or "none" unassigns. A display name shared by two people fails with the candidates to choose from. Returns the item.',
|
|
261
|
+
scopes: scopesOf('update_item'),
|
|
262
|
+
annotations: {
|
|
263
|
+
title: 'Assign an item',
|
|
264
|
+
readOnlyHint: false,
|
|
265
|
+
destructiveHint: false,
|
|
266
|
+
idempotentHint: true,
|
|
267
|
+
openWorldHint: false,
|
|
268
|
+
},
|
|
269
|
+
inputSchema: z.object({
|
|
270
|
+
item: ItemRefInput,
|
|
271
|
+
assignee: UserRefInput.nullable().describe('"me", an email or a display name; null or "none" to unassign.'),
|
|
272
|
+
if_version: z
|
|
273
|
+
.number()
|
|
274
|
+
.int()
|
|
275
|
+
.min(1)
|
|
276
|
+
.optional()
|
|
277
|
+
.describe('The version you read, to guard against concurrent changes.'),
|
|
278
|
+
}),
|
|
279
|
+
outputSchema: z.object({ item: ItemOutSchema }),
|
|
280
|
+
async run(args, ctx) {
|
|
281
|
+
const assignee = args.assignee === null || args.assignee.trim().toLowerCase() === 'none'
|
|
282
|
+
? null
|
|
283
|
+
: args.assignee;
|
|
284
|
+
const r = await ctx.call('update_item', {
|
|
285
|
+
path: { key: itemRef(args.item) },
|
|
286
|
+
body: {
|
|
287
|
+
assignee,
|
|
288
|
+
...(args.if_version !== undefined ? { if_version: args.if_version } : {}),
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
const item = itemOut(r.item);
|
|
292
|
+
// The assignee's name is people-written: the item's line (in a block) shows it.
|
|
293
|
+
const headline = item.assignee
|
|
294
|
+
? `${item.key} is assigned; the item now:`
|
|
295
|
+
: `${item.key} is unassigned.`;
|
|
296
|
+
return { structured: { item }, text: changedText(headline, item) };
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// transition_item
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
const MOVE_ADVICE = 'Call transition_item again with one of these; pass the fields a move needs in set (for example set={"assignee": "me"}). To reach another status, move in steps.';
|
|
303
|
+
/** The error's own moves (the API lists them in details.moves), as a note. */
|
|
304
|
+
function movesNote(err, key) {
|
|
305
|
+
const details = err.details;
|
|
306
|
+
if (!Array.isArray(details?.moves))
|
|
307
|
+
return undefined;
|
|
308
|
+
const moves = details.moves.map((m) => transitionText({
|
|
309
|
+
to: sanitizeLabel(typeof m.to === 'string' ? m.to : '', 60),
|
|
310
|
+
required_fields: Array.isArray(m.required_fields)
|
|
311
|
+
? m.required_fields.map((f) => sanitizeLabel(typeof f === 'string' ? f : '', 60))
|
|
312
|
+
: [],
|
|
313
|
+
admins_only: m.admins_only === true,
|
|
314
|
+
}));
|
|
315
|
+
const from = typeof details.from === 'string' ? sanitizeLabel(details.from, 60) : 'its status';
|
|
316
|
+
return [
|
|
317
|
+
`From ${from}, ${key.toUpperCase()} can move to: ${moves.join('; ') || 'nothing'}.`,
|
|
318
|
+
MOVE_ADVICE,
|
|
319
|
+
].join('\n');
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The moves allowed from the item's current status, read from its project,
|
|
323
|
+
* for an error from an older API that doesn't list them.
|
|
324
|
+
*/
|
|
325
|
+
async function allowedMovesNote(ctx, key) {
|
|
326
|
+
try {
|
|
327
|
+
const { item } = await ctx.call('get_item', { path: { key } });
|
|
328
|
+
const project = await ctx.call('describe_project', { path: { key: item.project.key } });
|
|
329
|
+
const type = project.types.find((t) => t.id === item.type.id);
|
|
330
|
+
const workflow = project.workflows.find((w) => w.id === type?.workflow.id);
|
|
331
|
+
const status = workflow?.statuses.find((s) => s.id === item.status.id);
|
|
332
|
+
if (!workflow || !status)
|
|
333
|
+
return undefined;
|
|
334
|
+
const moves = status.allowed.map((a) => transitionText({
|
|
335
|
+
to: sanitizeLabel(a.to, 60),
|
|
336
|
+
required_fields: a.required_fields.map((f) => sanitizeLabel(f, 60)),
|
|
337
|
+
admins_only: a.admins_only,
|
|
338
|
+
}));
|
|
339
|
+
return [
|
|
340
|
+
`From ${sanitizeLabel(status.name, 60)} (workflow ${sanitizeLabel(workflow.name, 60)}), ${item.key} can move to: ${moves.join('; ') || 'nothing'}.`,
|
|
341
|
+
MOVE_ADVICE,
|
|
342
|
+
].join('\n');
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const FIELD_REQUIRED_NOTE = 'Pass them in set: assignee ("me" or an email), estimate, due_date, sprint and fix_release by those names, and custom fields as set.custom, for example set={"assignee": "me", "custom": {"Severity": "High"}}.';
|
|
349
|
+
export const transitionItemTool = defineTool({
|
|
350
|
+
name: 'transition_item',
|
|
351
|
+
toolset: 'items',
|
|
352
|
+
title: 'Move an item to a status',
|
|
353
|
+
description: `Moves one item to another status, by the status name (In progress, Done, ...), following its workflow, as the app does.
|
|
354
|
+
- Some moves require fields (an assignee, an estimate, ...): set them in the same call with set, for example set={"assignee": "me"}.
|
|
355
|
+
- comment is posted on the item after the move (plain text; mention people as @email or @[Display Name]).
|
|
356
|
+
- if_version guards against concurrent changes.
|
|
357
|
+
- When the workflow doesn't allow the move, the error lists the statuses the item can move to from where it is and what each needs; move in steps if needed.
|
|
358
|
+
describe_project shows every workflow's statuses and allowed moves. Returns the item and the comment.`,
|
|
359
|
+
scopes: scopesOf('transition_item'),
|
|
360
|
+
annotations: {
|
|
361
|
+
title: 'Move an item to a status',
|
|
362
|
+
readOnlyHint: false,
|
|
363
|
+
destructiveHint: false,
|
|
364
|
+
idempotentHint: true,
|
|
365
|
+
openWorldHint: false,
|
|
366
|
+
},
|
|
367
|
+
inputSchema: z.object({
|
|
368
|
+
item: ItemRefInput,
|
|
369
|
+
status: NameInput('The status to move to, by name.'),
|
|
370
|
+
comment: z
|
|
371
|
+
.string()
|
|
372
|
+
.min(1)
|
|
373
|
+
.max(20_000)
|
|
374
|
+
.optional()
|
|
375
|
+
.describe('A comment to post after the move (plain text, at most 20,000 characters).'),
|
|
376
|
+
set: z
|
|
377
|
+
.object({
|
|
378
|
+
assignee: UserRefInput.nullable().optional(),
|
|
379
|
+
estimate: EstimateInput.nullable().optional(),
|
|
380
|
+
due_date: DateInput.nullable().optional(),
|
|
381
|
+
sprint: z.string().min(1).max(80).nullable().optional(),
|
|
382
|
+
fix_release: z.string().min(1).max(80).nullable().optional(),
|
|
383
|
+
custom: CustomInput.optional(),
|
|
384
|
+
})
|
|
385
|
+
.optional()
|
|
386
|
+
.describe("Fields to set in the same change, to meet the move's required fields."),
|
|
387
|
+
if_version: z.number().int().min(1).optional(),
|
|
388
|
+
}),
|
|
389
|
+
outputSchema: z.object({
|
|
390
|
+
item: ItemOutSchema,
|
|
391
|
+
comment: CommentOutSchema.nullable(),
|
|
392
|
+
}),
|
|
393
|
+
async run(args, ctx) {
|
|
394
|
+
const key = itemRef(args.item);
|
|
395
|
+
let r;
|
|
396
|
+
try {
|
|
397
|
+
r = await ctx.call('transition_item', {
|
|
398
|
+
path: { key },
|
|
399
|
+
body: {
|
|
400
|
+
status: args.status,
|
|
401
|
+
...(args.comment !== undefined ? { comment: args.comment } : {}),
|
|
402
|
+
...(args.set !== undefined ? { set: args.set } : {}),
|
|
403
|
+
...(args.if_version !== undefined ? { if_version: args.if_version } : {}),
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
if (err instanceof ApiError && err.code === 'transition_not_allowed') {
|
|
409
|
+
const note = movesNote(err, key) ?? (await allowedMovesNote(ctx, key));
|
|
410
|
+
throw note ? err.withNote(note) : err;
|
|
411
|
+
}
|
|
412
|
+
if (err instanceof ApiError && err.code === 'field_required') {
|
|
413
|
+
throw err.withNote(FIELD_REQUIRED_NOTE);
|
|
414
|
+
}
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
const item = itemOut(r.item);
|
|
418
|
+
const comment = r.comment ? commentOut(r.comment, 2000) : null;
|
|
419
|
+
const parts = [
|
|
420
|
+
// The status's name is people-written: the item's line (in a block) shows it.
|
|
421
|
+
changedText(`Moved ${item.key}; its status is now ${item.status_category}:`, item),
|
|
422
|
+
];
|
|
423
|
+
if (comment)
|
|
424
|
+
parts.push('Comment posted:', commentText(comment));
|
|
425
|
+
return { structured: { item, comment }, text: parts.join('\n') };
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// link_items
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
const LINK_KINDS = ['blocks', 'blocked_by', 'relates', 'duplicates', 'duplicated_by'];
|
|
432
|
+
function sameItem(a, b) {
|
|
433
|
+
return itemRef(a).toLowerCase() === itemRef(b).toLowerCase();
|
|
434
|
+
}
|
|
435
|
+
const LinkKindInput = z.enum(LINK_KINDS).describe("The link's kind, seen from item.");
|
|
436
|
+
export const linkItemsTool = defineTool({
|
|
437
|
+
name: 'link_items',
|
|
438
|
+
toolset: 'items',
|
|
439
|
+
title: 'Link two items',
|
|
440
|
+
description: `Adds a link between two items.
|
|
441
|
+
- kind, from item's side: blocks, blocked_by, relates, duplicates or duplicated_by. "DEMO-1 blocks DEMO-2" is the same link as "DEMO-2 blocked_by DEMO-1".
|
|
442
|
+
- Adding a link that exists changes nothing, so a retry is safe.
|
|
443
|
+
- The target can be in another project.
|
|
444
|
+
To remove a link, use unlink_items. Returns the link id and whether it was new.`,
|
|
445
|
+
scopes: scopesOf('add_link'),
|
|
446
|
+
annotations: {
|
|
447
|
+
title: 'Link two items',
|
|
448
|
+
readOnlyHint: false,
|
|
449
|
+
destructiveHint: false,
|
|
450
|
+
idempotentHint: true,
|
|
451
|
+
openWorldHint: false,
|
|
452
|
+
},
|
|
453
|
+
inputSchema: z.object({
|
|
454
|
+
item: ItemRefInput,
|
|
455
|
+
kind: LinkKindInput,
|
|
456
|
+
target: ItemRefInput.describe('The other item.'),
|
|
457
|
+
}),
|
|
458
|
+
outputSchema: z.object({
|
|
459
|
+
item: z.string(),
|
|
460
|
+
kind: z.string(),
|
|
461
|
+
target: z.string(),
|
|
462
|
+
link_id: z.string(),
|
|
463
|
+
created: z.boolean().describe('false: the link already existed.'),
|
|
464
|
+
}),
|
|
465
|
+
async run(args, ctx) {
|
|
466
|
+
const key = itemRef(args.item);
|
|
467
|
+
const r = await ctx.call('add_link', {
|
|
468
|
+
path: { key },
|
|
469
|
+
body: { kind: args.kind, target: itemRef(args.target) },
|
|
470
|
+
});
|
|
471
|
+
const target = r.link.item?.key ?? itemRef(args.target).toUpperCase();
|
|
472
|
+
const structured = {
|
|
473
|
+
item: key.toUpperCase(),
|
|
474
|
+
kind: r.link.kind,
|
|
475
|
+
target,
|
|
476
|
+
link_id: r.link.id,
|
|
477
|
+
created: r.created,
|
|
478
|
+
};
|
|
479
|
+
const text = r.created
|
|
480
|
+
? `Linked: ${structured.item} ${r.link.kind} ${target} (link id ${r.link.id}).`
|
|
481
|
+
: `Nothing changed: ${structured.item} already ${r.link.kind} ${target} (link id ${r.link.id}).`;
|
|
482
|
+
return { structured, text };
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
export const unlinkItemsTool = defineTool({
|
|
486
|
+
name: 'unlink_items',
|
|
487
|
+
toolset: 'items',
|
|
488
|
+
title: 'Remove a link between items',
|
|
489
|
+
description: `Removes a link between two items: the link of that kind between item and target, or the one with link_id (get_item lists links with their ids).
|
|
490
|
+
- kind, from item's side: blocks, blocked_by, relates, duplicates or duplicated_by.
|
|
491
|
+
- Removing a link that doesn't exist changes nothing, so a retry is safe.
|
|
492
|
+
Only the link is removed; both items stay. Returns whether a link was removed.`,
|
|
493
|
+
scopes: scopesOf('remove_link', 'get_item'),
|
|
494
|
+
annotations: {
|
|
495
|
+
title: 'Remove a link between items',
|
|
496
|
+
readOnlyHint: false,
|
|
497
|
+
destructiveHint: true,
|
|
498
|
+
idempotentHint: true,
|
|
499
|
+
openWorldHint: false,
|
|
500
|
+
},
|
|
501
|
+
inputSchema: z.object({
|
|
502
|
+
item: ItemRefInput,
|
|
503
|
+
kind: LinkKindInput.optional().describe("The link's kind, seen from item (with target)."),
|
|
504
|
+
target: ItemRefInput.optional().describe('The other item (with kind).'),
|
|
505
|
+
link_id: z.uuid().optional().describe('The link id from get_item, instead of kind and target.'),
|
|
506
|
+
}),
|
|
507
|
+
outputSchema: z.object({
|
|
508
|
+
item: z.string(),
|
|
509
|
+
link_id: z.string().nullable(),
|
|
510
|
+
removed: z.boolean().describe('false: there was no such link (or it was already gone).'),
|
|
511
|
+
}),
|
|
512
|
+
async run(args, ctx) {
|
|
513
|
+
const key = itemRef(args.item);
|
|
514
|
+
let linkId = args.link_id;
|
|
515
|
+
if (linkId === undefined) {
|
|
516
|
+
if (args.kind === undefined || args.target === undefined) {
|
|
517
|
+
throw new ToolInputError('Give link_id, or both kind and target.');
|
|
518
|
+
}
|
|
519
|
+
const { kind, target } = args;
|
|
520
|
+
const { links } = await ctx.call('get_item', { path: { key } });
|
|
521
|
+
const match = links.find((l) => l.kind === kind &&
|
|
522
|
+
l.item !== null &&
|
|
523
|
+
(sameItem(l.item.key, target) || l.item.id.toLowerCase() === target.toLowerCase()));
|
|
524
|
+
if (!match) {
|
|
525
|
+
return {
|
|
526
|
+
structured: { item: key.toUpperCase(), link_id: null, removed: false },
|
|
527
|
+
text: `Nothing changed: ${key.toUpperCase()} has no "${kind}" link to ${itemRef(target).toUpperCase()}. get_item lists its links.`,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
linkId = match.id;
|
|
531
|
+
}
|
|
532
|
+
const r = await ctx.call('remove_link', { path: { key, id: linkId } });
|
|
533
|
+
return {
|
|
534
|
+
structured: { item: key.toUpperCase(), link_id: r.id, removed: r.removed },
|
|
535
|
+
text: r.removed
|
|
536
|
+
? `Unlinked: removed link ${r.id} from ${key.toUpperCase()}.`
|
|
537
|
+
: `Nothing changed: link ${r.id} was already removed.`,
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// rank_item
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
export const rankItemTool = defineTool({
|
|
545
|
+
name: 'rank_item',
|
|
546
|
+
toolset: 'items',
|
|
547
|
+
title: 'Reorder an item',
|
|
548
|
+
description: "Moves one item in its project's order (the backlog and board order): directly before or directly after another item of the same project. Give exactly one of before or after. Returns the item's new rank. search_items sorts by rank by default.",
|
|
549
|
+
scopes: scopesOf('rank_item'),
|
|
550
|
+
annotations: {
|
|
551
|
+
title: 'Reorder an item',
|
|
552
|
+
readOnlyHint: false,
|
|
553
|
+
destructiveHint: false,
|
|
554
|
+
idempotentHint: true,
|
|
555
|
+
openWorldHint: false,
|
|
556
|
+
},
|
|
557
|
+
inputSchema: z.object({
|
|
558
|
+
item: ItemRefInput,
|
|
559
|
+
before: ItemRefInput.optional().describe('Put the item just before this one.'),
|
|
560
|
+
after: ItemRefInput.optional().describe('Put the item just after this one.'),
|
|
561
|
+
}),
|
|
562
|
+
outputSchema: z.object({ item: ItemOutSchema, rank: z.string() }),
|
|
563
|
+
async run(args, ctx) {
|
|
564
|
+
if ((args.before === undefined) === (args.after === undefined)) {
|
|
565
|
+
throw new ToolInputError('Give exactly one of before or after.');
|
|
566
|
+
}
|
|
567
|
+
const r = await ctx.call('rank_item', {
|
|
568
|
+
path: { key: itemRef(args.item) },
|
|
569
|
+
body: args.before ? { before: itemRef(args.before) } : { after: itemRef(args.after ?? '') },
|
|
570
|
+
});
|
|
571
|
+
const item = itemOut(r.item);
|
|
572
|
+
const where = args.before
|
|
573
|
+
? `before ${itemRef(args.before).toUpperCase()}`
|
|
574
|
+
: `after ${itemRef(args.after ?? '').toUpperCase()}`;
|
|
575
|
+
return {
|
|
576
|
+
structured: { item, rank: r.item.rank },
|
|
577
|
+
text: changedText(`Moved ${item.key} ${where}.`, item),
|
|
578
|
+
};
|
|
579
|
+
},
|
|
580
|
+
});
|