@kammcs/buildit-mcp 0.0.0-stage → 0.1.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.
@@ -0,0 +1,697 @@
1
+ /**
2
+ * Planning: list_sprints, plan_sprint, list_releases, plan_release and
3
+ * write_release_notes.
4
+ *
5
+ * plan_sprint and plan_release take an action, so an agent sees two tools
6
+ * for the whole lifecycle instead of ten. Writing release notes is its own
7
+ * tool because it can overwrite the notes page, which makes it destructive;
8
+ * the other planning actions only move items between sprints and releases.
9
+ */
10
+ import { z } from 'zod';
11
+ import { ToolInputError } from '../errors.js';
12
+ import { defineTool } from '../toolsets/registry.js';
13
+ import { sanitizeLabel, wrapUntrusted } from '../untrusted.js';
14
+ import { CursorInput, itemRef, ItemRefInput, limitInput, NAME_CHARS, nextPageHint, ProjectKeyInput, scopesOf, wrapLines, } from './shared.js';
15
+ const label = (v, max = 200) => sanitizeLabel(v, max);
16
+ const DateInput = z.iso.date();
17
+ const ItemsInput = z
18
+ .array(ItemRefInput)
19
+ .min(1)
20
+ .max(50)
21
+ .describe('Item keys of the project, such as ["DEMO-12", "DEMO-13"]; at most 50.');
22
+ // ---------------------------------------------------------------------------
23
+ // Sprints
24
+ // ---------------------------------------------------------------------------
25
+ const SprintOut = z.object({
26
+ id: z.string(),
27
+ number: z.number(),
28
+ name: z.string(),
29
+ goal: z.string().nullable().describe('People-written; wrapped as untrusted content.'),
30
+ state: z.string().describe('planned, active or completed.'),
31
+ starts_on: z.string().nullable(),
32
+ ends_on: z.string().nullable(),
33
+ started_at: z.string().nullable(),
34
+ completed_at: z.string().nullable(),
35
+ item_count: z.number(),
36
+ done_count: z.number(),
37
+ });
38
+ function sprintOut(s) {
39
+ return {
40
+ id: s.id,
41
+ number: s.number,
42
+ name: label(s.name, NAME_CHARS),
43
+ goal: s.goal === null ? null : wrapUntrusted(s.goal, { source: 'sprint_goal', maxChars: 500 }),
44
+ state: s.state,
45
+ starts_on: s.starts_on,
46
+ ends_on: s.ends_on,
47
+ started_at: s.started_at,
48
+ completed_at: s.completed_at,
49
+ item_count: s.item_count,
50
+ done_count: s.done_count,
51
+ };
52
+ }
53
+ /** A sprint's line; without its counts when the caller states them another way. */
54
+ function sprintLine(s, counts = true) {
55
+ const dates = s.starts_on || s.ends_on ? ` · ${s.starts_on ?? '?'} to ${s.ends_on ?? '?'}` : '';
56
+ return `- #${s.number} ${s.name} · ${s.state}${dates}${counts ? ` · done ${s.done_count} of ${s.item_count}` : ''}`;
57
+ }
58
+ /** A sprint's line, then its goal (people-written) flattened, for a block. */
59
+ function sprintLines(s, goal, counts = true) {
60
+ return [sprintLine(s, counts), ...(goal ? [` goal: ${label(goal, 500)}`] : [])];
61
+ }
62
+ /** One sprint, in its own block: its name and goal are people-written. */
63
+ function sprintText(s, goal, counts = true) {
64
+ return wrapLines(sprintLines(s, goal, counts), 'sprint');
65
+ }
66
+ const BoardMoveOut = z.object({
67
+ key: z.string(),
68
+ title: z.string(),
69
+ from: z.string().describe('The backlog status it left.'),
70
+ to: z.string().describe("Its workflow's first not-started status on the board."),
71
+ });
72
+ const NotMovedOut = z.object({
73
+ key: z.string(),
74
+ title: z.string(),
75
+ status: z.string().describe('Its status, which it keeps.'),
76
+ reason: z
77
+ .string()
78
+ .describe('no_board_status, transition_not_allowed, required_fields or admins_only.'),
79
+ required_fields: z.array(z.string()).describe('For required_fields: the empty fields.'),
80
+ });
81
+ /**
82
+ * What starting a sprint did to its backlog items: those moved onto the
83
+ * board, and those that stay, with why. Titles and status names are
84
+ * people-written, so both lists are one block.
85
+ */
86
+ function boardMovesText(moved, notMoved) {
87
+ const lines = [
88
+ ...moved.map((m) => `- moved ${m.key}: ${m.from} -> ${m.to} — ${m.title}`),
89
+ ...notMoved.map((n) => `- stays ${n.key} in ${n.status} (${n.reason}${n.required_fields.length > 0 ? `: needs ${n.required_fields.join(', ')}` : ''}) — ${n.title}`),
90
+ ];
91
+ if (lines.length === 0)
92
+ return [];
93
+ const head = [
94
+ ...(moved.length > 0 ? [`${moved.length} backlog item(s) moved onto the board`] : []),
95
+ ...(notMoved.length > 0
96
+ ? [`${notMoved.length} stay in the backlog (move them with transition_item)`]
97
+ : []),
98
+ ];
99
+ return [`${head.join('; ')}:`, wrapLines(lines, 'item_list')];
100
+ }
101
+ /**
102
+ * What completing a sprint did, with one set of counts: the completion's
103
+ * (committed, completed, carried), which agree with each other. The
104
+ * sprint's own counts are left out, since carried items no longer count
105
+ * in it.
106
+ */
107
+ function completionText(s, goal, r) {
108
+ const lines = [
109
+ `Completed sprint #${s.number}: done ${r.completed_count} of ${r.committed_count} item(s) committed (${r.completed_points} of ${r.committed_points} points).`,
110
+ ];
111
+ if (r.carried_count > 0) {
112
+ const to = r.carried_to ? `sprint #${r.carried_to.number}` : 'the backlog (no sprint)';
113
+ lines.push(`${r.carried_count} open item(s) were carried to ${to}; they count there now, no longer in this sprint.`);
114
+ }
115
+ else {
116
+ lines.push('No open items were left to carry.');
117
+ }
118
+ const rest = r.committed_count - r.completed_count - r.carried_count;
119
+ if (rest > 0)
120
+ lines.push(`${rest} canceled item(s) stay in this sprint, not done.`);
121
+ lines.push(sprintText(s, goal, false));
122
+ return lines.join('\n');
123
+ }
124
+ export const listSprintsTool = defineTool({
125
+ name: 'list_sprints',
126
+ toolset: 'planning',
127
+ title: 'List sprints',
128
+ description: 'Lists a project\'s sprints, newest first: number, name, goal, state (planned, active, completed), dates and how many items each holds and has done. Filter by state, for example state=["active"] for the current sprint. Sprints are named in other tools by number, name, or "active". Paged: pass next_cursor as cursor for more.',
129
+ scopes: scopesOf('list_sprints'),
130
+ annotations: {
131
+ title: 'List sprints',
132
+ readOnlyHint: true,
133
+ idempotentHint: true,
134
+ openWorldHint: false,
135
+ },
136
+ inputSchema: z.object({
137
+ project: ProjectKeyInput,
138
+ state: z
139
+ .array(z.enum(['planned', 'active', 'completed']))
140
+ .min(1)
141
+ .max(3)
142
+ .optional()
143
+ .describe('Only sprints in these states.'),
144
+ cursor: CursorInput.optional(),
145
+ limit: limitInput(100, 25),
146
+ }),
147
+ outputSchema: z.object({
148
+ project: z.string(),
149
+ sprints: z.array(SprintOut),
150
+ next_cursor: z.string().nullable(),
151
+ }),
152
+ async run(args, ctx) {
153
+ const page = await ctx.call('list_sprints', {
154
+ path: { key: args.project },
155
+ query: {
156
+ ...(args.state ? { state: args.state } : {}),
157
+ ...(args.cursor ? { cursor: args.cursor } : {}),
158
+ ...(args.limit ? { limit: args.limit } : {}),
159
+ },
160
+ });
161
+ const sprints = page.items.map(sprintOut);
162
+ const key = args.project.toUpperCase();
163
+ const text = [
164
+ sprints.length === 0 ? `No sprints in ${key}.` : `${sprints.length} sprint(s) in ${key}:`,
165
+ // One block for the list: names and goals are people-written.
166
+ ...(sprints.length > 0
167
+ ? [
168
+ wrapLines(sprints.flatMap((s, n) => sprintLines(s, page.items[n]?.goal ?? null)), 'sprint_list'),
169
+ ]
170
+ : []),
171
+ nextPageHint('list_sprints', page.next_cursor),
172
+ ].join('\n');
173
+ return { structured: { project: key, sprints, next_cursor: page.next_cursor }, text };
174
+ },
175
+ });
176
+ const SprintActions = ['create', 'start', 'complete', 'add_items', 'remove_items'];
177
+ export const planSprintTool = defineTool({
178
+ name: 'plan_sprint',
179
+ toolset: 'planning',
180
+ title: 'Plan a sprint',
181
+ description: `Creates, starts or completes a sprint, or moves items in or out of one. action:
182
+ - "create": a planned sprint; needs name, optional goal, starts_on and ends_on (YYYY-MM-DD). The number is assigned.
183
+ - "start": starts a planned sprint (project admins); optional starts_on, ends_on, goal. Only one sprint can be active. Its items in a backlog status move onto the board (To do); the result lists them, and those that stay and why.
184
+ - "complete": completes the active sprint (project admins); its open items go to carry_to: "next" (the next planned sprint, created if there is none; the default), "new" (a new sprint) or "backlog" (no sprint). Returns the committed, completed and carried counts.
185
+ - "add_items": puts up to 50 items of the project in the sprint (taking them out of any other).
186
+ - "remove_items": takes up to 50 items out of the sprint, to the backlog.
187
+ sprint names the sprint for every action but create: its number, name, or "active". Adding or removing items that are already where they should be changes nothing.`,
188
+ scopes: scopesOf('create_sprint', 'start_sprint', 'complete_sprint', 'add_sprint_items', 'remove_sprint_items'),
189
+ annotations: {
190
+ title: 'Plan a sprint',
191
+ readOnlyHint: false,
192
+ destructiveHint: false,
193
+ // Creating twice makes two sprints.
194
+ idempotentHint: false,
195
+ openWorldHint: false,
196
+ },
197
+ inputSchema: z.object({
198
+ project: ProjectKeyInput,
199
+ action: z.enum(SprintActions),
200
+ sprint: z
201
+ .string()
202
+ .min(1)
203
+ .max(80)
204
+ .optional()
205
+ .describe('The sprint: a number, a name or "active". Not for create.'),
206
+ name: z.string().min(1).max(80).optional().describe('For create: the sprint name.'),
207
+ goal: z.string().max(500).optional().describe('For create and start: the sprint goal.'),
208
+ starts_on: DateInput.optional().describe('For create and start: YYYY-MM-DD.'),
209
+ ends_on: DateInput.optional().describe('For create and start: YYYY-MM-DD.'),
210
+ carry_to: z
211
+ .enum(['next', 'new', 'backlog'])
212
+ .optional()
213
+ .describe('For complete: where open items go (default next).'),
214
+ items: ItemsInput.optional().describe('For add_items and remove_items: item keys, at most 50.'),
215
+ }),
216
+ outputSchema: z.object({
217
+ action: z.string(),
218
+ sprint: SprintOut,
219
+ changed: z
220
+ .array(z.string())
221
+ .optional()
222
+ .describe('add_items, remove_items: the keys of the items that moved.'),
223
+ summary: z
224
+ .object({
225
+ committed_count: z.number(),
226
+ committed_points: z.number(),
227
+ completed_count: z.number(),
228
+ completed_points: z.number(),
229
+ carried_count: z.number(),
230
+ carried_to: z.string().nullable(),
231
+ })
232
+ .optional()
233
+ .describe('complete: the sprint in numbers.'),
234
+ moved_to_board: z
235
+ .array(BoardMoveOut)
236
+ .optional()
237
+ .describe("start: the sprint's backlog items moved onto the board."),
238
+ not_moved: z
239
+ .array(NotMovedOut)
240
+ .optional()
241
+ .describe('start: backlog items that stay where they are, and why.'),
242
+ }),
243
+ async run(args, ctx) {
244
+ const key = args.project;
245
+ const needSprint = () => {
246
+ if (args.sprint === undefined) {
247
+ throw new ToolInputError(`action "${args.action}" needs sprint (a number, a name or "active").`);
248
+ }
249
+ return args.sprint;
250
+ };
251
+ const needItems = () => {
252
+ if (args.items === undefined)
253
+ throw new ToolInputError(`action "${args.action}" needs items.`);
254
+ return args.items.map(itemRef);
255
+ };
256
+ switch (args.action) {
257
+ case 'create': {
258
+ if (args.name === undefined)
259
+ throw new ToolInputError('action "create" needs name.');
260
+ const r = await ctx.call('create_sprint', {
261
+ path: { key },
262
+ body: {
263
+ name: args.name,
264
+ ...(args.goal !== undefined ? { goal: args.goal } : {}),
265
+ ...(args.starts_on !== undefined ? { starts_on: args.starts_on } : {}),
266
+ ...(args.ends_on !== undefined ? { ends_on: args.ends_on } : {}),
267
+ },
268
+ });
269
+ const sprint = sprintOut(r.sprint);
270
+ return {
271
+ structured: { action: args.action, sprint },
272
+ text: `Created sprint #${sprint.number} in ${key.toUpperCase()}:\n${sprintText(sprint, r.sprint.goal)}`,
273
+ };
274
+ }
275
+ case 'start': {
276
+ const r = await ctx.call('start_sprint', {
277
+ path: { key, sprint: needSprint() },
278
+ body: {
279
+ ...(args.goal !== undefined ? { goal: args.goal } : {}),
280
+ ...(args.starts_on !== undefined ? { starts_on: args.starts_on } : {}),
281
+ ...(args.ends_on !== undefined ? { ends_on: args.ends_on } : {}),
282
+ },
283
+ });
284
+ const sprint = sprintOut(r.sprint);
285
+ const moved = r.moved_to_board.map((m) => ({
286
+ key: m.key,
287
+ title: label(m.title, 300),
288
+ from: label(m.from, NAME_CHARS),
289
+ to: label(m.to, NAME_CHARS),
290
+ }));
291
+ const notMoved = r.not_moved.map((n) => ({
292
+ key: n.key,
293
+ title: label(n.title, 300),
294
+ status: label(n.status, NAME_CHARS),
295
+ reason: label(n.reason, 40),
296
+ required_fields: n.required_fields.map((f) => label(f, NAME_CHARS)),
297
+ }));
298
+ return {
299
+ structured: { action: args.action, sprint, moved_to_board: moved, not_moved: notMoved },
300
+ text: [
301
+ `Started sprint #${sprint.number}:\n${sprintText(sprint, r.sprint.goal)}`,
302
+ ...boardMovesText(moved, notMoved),
303
+ ].join('\n'),
304
+ };
305
+ }
306
+ case 'complete': {
307
+ const r = await ctx.call('complete_sprint', {
308
+ path: { key, sprint: needSprint() },
309
+ body: args.carry_to !== undefined ? { carry_to: args.carry_to } : {},
310
+ });
311
+ const sprint = sprintOut(r.sprint);
312
+ const carriedTo = r.carried_to
313
+ ? `#${r.carried_to.number} ${label(r.carried_to.name, NAME_CHARS)}`
314
+ : null;
315
+ const summary = {
316
+ committed_count: r.committed_count,
317
+ committed_points: r.committed_points,
318
+ completed_count: r.completed_count,
319
+ completed_points: r.completed_points,
320
+ carried_count: r.carried_count,
321
+ carried_to: carriedTo,
322
+ };
323
+ return {
324
+ structured: { action: args.action, sprint, summary },
325
+ text: completionText(sprint, r.sprint.goal, r),
326
+ };
327
+ }
328
+ case 'add_items':
329
+ case 'remove_items': {
330
+ const add = args.action === 'add_items';
331
+ const r = await ctx.call(add ? 'add_sprint_items' : 'remove_sprint_items', {
332
+ path: { key, sprint: needSprint() },
333
+ body: { items: needItems() },
334
+ });
335
+ const sprint = sprintOut(r.sprint);
336
+ const changed = r.changed.map((i) => i.key);
337
+ return {
338
+ structured: { action: args.action, sprint, changed },
339
+ text: [
340
+ changed.length === 0
341
+ ? 'Nothing changed: the items were already where they should be.'
342
+ : `${add ? 'Added to' : 'Removed from'} sprint #${sprint.number}: ${changed.join(', ')}.`,
343
+ wrapLines([sprintLine(sprint)], 'sprint'),
344
+ ].join('\n'),
345
+ };
346
+ }
347
+ }
348
+ },
349
+ });
350
+ // ---------------------------------------------------------------------------
351
+ // Releases
352
+ // ---------------------------------------------------------------------------
353
+ const ReleaseOut = z.object({
354
+ id: z.string(),
355
+ name: z.string(),
356
+ description: z.string().nullable().describe('People-written; wrapped as untrusted content.'),
357
+ status: z.string().describe('unreleased, released or archived.'),
358
+ start_date: z.string().nullable(),
359
+ target_date: z.string().nullable(),
360
+ released_at: z.string().nullable(),
361
+ notes_page_id: z.string().nullable().describe('The release notes page (get_page reads it).'),
362
+ item_count: z.number(),
363
+ done_count: z.number(),
364
+ });
365
+ function releaseOut(r) {
366
+ return {
367
+ id: r.id,
368
+ name: label(r.name, NAME_CHARS),
369
+ description: r.description === null
370
+ ? null
371
+ : wrapUntrusted(r.description, { source: 'release_description', maxChars: 1000 }),
372
+ status: r.status,
373
+ start_date: r.start_date,
374
+ target_date: r.target_date,
375
+ released_at: r.released_at,
376
+ notes_page_id: r.notes_page_id,
377
+ item_count: r.item_count,
378
+ done_count: r.done_count,
379
+ };
380
+ }
381
+ function releaseLine(r) {
382
+ return `- ${r.name} · ${r.status}${r.target_date ? ` · target ${r.target_date}` : ''}${r.released_at ? ` · released ${r.released_at}` : ''} · ${r.done_count}/${r.item_count} done`;
383
+ }
384
+ /** A release's line, then its description (people-written) flattened, for a block. */
385
+ function releaseLines(r, description) {
386
+ return [releaseLine(r), ...(description ? [` ${label(description, 500)}`] : [])];
387
+ }
388
+ /** One release, in its own block: its name and description are people-written. */
389
+ function releaseText(r, description) {
390
+ return wrapLines(releaseLines(r, description), 'release');
391
+ }
392
+ export const listReleasesTool = defineTool({
393
+ name: 'list_releases',
394
+ toolset: 'planning',
395
+ title: 'List releases',
396
+ description: "Lists a project's releases (fix versions): name, description, status (unreleased, released, archived), dates, the notes page and how many items each holds and has done. Filter with status. Releases are named in other tools by name. To see a release's items, use search_items with release. Paged: pass next_cursor as cursor for more.",
397
+ scopes: scopesOf('list_releases'),
398
+ annotations: {
399
+ title: 'List releases',
400
+ readOnlyHint: true,
401
+ idempotentHint: true,
402
+ openWorldHint: false,
403
+ },
404
+ inputSchema: z.object({
405
+ project: ProjectKeyInput,
406
+ status: z
407
+ .array(z.enum(['unreleased', 'released', 'archived']))
408
+ .min(1)
409
+ .max(3)
410
+ .optional()
411
+ .describe('Only releases with these statuses.'),
412
+ cursor: CursorInput.optional(),
413
+ limit: limitInput(100, 25),
414
+ }),
415
+ outputSchema: z.object({
416
+ project: z.string(),
417
+ releases: z.array(ReleaseOut),
418
+ next_cursor: z.string().nullable(),
419
+ }),
420
+ async run(args, ctx) {
421
+ const page = await ctx.call('list_releases', {
422
+ path: { key: args.project },
423
+ query: {
424
+ ...(args.status ? { status: args.status } : {}),
425
+ ...(args.cursor ? { cursor: args.cursor } : {}),
426
+ ...(args.limit ? { limit: args.limit } : {}),
427
+ },
428
+ });
429
+ const releases = page.items.map(releaseOut);
430
+ const key = args.project.toUpperCase();
431
+ const text = [
432
+ releases.length === 0 ? `No releases in ${key}.` : `${releases.length} release(s) in ${key}:`,
433
+ // One block for the list: names and descriptions are people-written.
434
+ ...(releases.length > 0
435
+ ? [
436
+ wrapLines(releases.flatMap((r, n) => releaseLines(r, page.items[n]?.description ?? null)), 'release_list'),
437
+ ]
438
+ : []),
439
+ nextPageHint('list_releases', page.next_cursor),
440
+ ].join('\n');
441
+ return { structured: { project: key, releases, next_cursor: page.next_cursor }, text };
442
+ },
443
+ });
444
+ const ReleaseActions = [
445
+ 'create',
446
+ 'release',
447
+ 'update',
448
+ 'reopen',
449
+ 'archive',
450
+ 'restore',
451
+ 'add_items',
452
+ 'remove_items',
453
+ ];
454
+ /** reopen, archive and restore: the operation each calls, and what the result says. */
455
+ const STATE_CHANGES = {
456
+ reopen: { op: 'reopen_release', done: 'Reopened: the release is unreleased again' },
457
+ archive: { op: 'archive_release', done: 'Archived the release' },
458
+ restore: { op: 'restore_release', done: 'Restored the release' },
459
+ };
460
+ export const planReleaseTool = defineTool({
461
+ name: 'plan_release',
462
+ toolset: 'planning',
463
+ title: 'Plan a release',
464
+ description: `Creates, ships, corrects or archives a release, or sets which items it holds. action:
465
+ - "create": an unreleased release; needs name (unique in the project), optional description, start_date and target_date (YYYY-MM-DD).
466
+ - "release": marks it released (project admins), on released_on (default today); its open items move to move_open_to (another release's name) or lose their fix version. Releasing it again changes nothing.
467
+ - "update": changes its name, description, start_date or target_date (null clears one) and, if it is released, released_on, which corrects the day it was released (project admins).
468
+ - "reopen": a released release becomes unreleased again, for one marked released too early (project admins). Its items keep their fix version.
469
+ - "archive": archives it once nothing more is expected from it; "restore" brings it back as it was (project admins). Items keep their fix version.
470
+ - "add_items": sets up to 50 items' fix version to this release.
471
+ - "remove_items": clears the fix version of up to 50 items, when it is this release.
472
+ release names the release (by name) for every action but create. To write its notes page, use write_release_notes.`,
473
+ scopes: scopesOf('create_release', 'release_version', 'update_release', 'reopen_release', 'archive_release', 'restore_release', 'add_release_items', 'remove_release_items'),
474
+ annotations: {
475
+ title: 'Plan a release',
476
+ readOnlyHint: false,
477
+ destructiveHint: false,
478
+ // Creating twice is refused (names are unique), but it is a create.
479
+ idempotentHint: false,
480
+ openWorldHint: false,
481
+ },
482
+ inputSchema: z.object({
483
+ project: ProjectKeyInput,
484
+ action: z.enum(ReleaseActions),
485
+ release: z.string().min(1).max(80).optional().describe('The release, by name. Not for create.'),
486
+ name: z
487
+ .string()
488
+ .min(1)
489
+ .max(80)
490
+ .optional()
491
+ .describe('For create: the release name, such as 1.2.0; for update: its new name.'),
492
+ description: z
493
+ .string()
494
+ .max(10_000)
495
+ .nullable()
496
+ .optional()
497
+ .describe('For create and update (null clears it in update).'),
498
+ start_date: DateInput.nullable()
499
+ .optional()
500
+ .describe('For create and update: YYYY-MM-DD (null clears it in update).'),
501
+ target_date: DateInput.nullable()
502
+ .optional()
503
+ .describe('For create and update: YYYY-MM-DD (null clears it in update).'),
504
+ released_on: DateInput.optional().describe('For release: YYYY-MM-DD (default today). For update: the corrected day it was released.'),
505
+ move_open_to: z
506
+ .string()
507
+ .min(1)
508
+ .max(80)
509
+ .optional()
510
+ .describe('For release: the release that open items move to.'),
511
+ items: ItemsInput.optional().describe('For add_items and remove_items: item keys, at most 50.'),
512
+ }),
513
+ outputSchema: z.object({
514
+ action: z.string(),
515
+ release: ReleaseOut,
516
+ changed: z
517
+ .array(z.string())
518
+ .optional()
519
+ .describe('add_items, remove_items: the keys of the items that changed.'),
520
+ }),
521
+ async run(args, ctx) {
522
+ const key = args.project;
523
+ const needRelease = () => {
524
+ if (args.release === undefined) {
525
+ throw new ToolInputError(`action "${args.action}" needs release (its name).`);
526
+ }
527
+ return args.release;
528
+ };
529
+ switch (args.action) {
530
+ case 'create': {
531
+ if (args.name === undefined)
532
+ throw new ToolInputError('action "create" needs name.');
533
+ if (args.description === null || args.start_date === null || args.target_date === null) {
534
+ throw new ToolInputError('null clears a field only with action "update"; leave the field out to create without it.');
535
+ }
536
+ const r = await ctx.call('create_release', {
537
+ path: { key },
538
+ body: {
539
+ name: args.name,
540
+ ...(args.description !== undefined ? { description: args.description } : {}),
541
+ ...(args.start_date !== undefined ? { start_date: args.start_date } : {}),
542
+ ...(args.target_date !== undefined ? { target_date: args.target_date } : {}),
543
+ },
544
+ });
545
+ const release = releaseOut(r.release);
546
+ return {
547
+ structured: { action: args.action, release },
548
+ text: `Created a release in ${key.toUpperCase()}:\n${releaseText(release, r.release.description)}`,
549
+ };
550
+ }
551
+ case 'release': {
552
+ const r = await ctx.call('release_version', {
553
+ path: { key, release: needRelease() },
554
+ body: {
555
+ ...(args.released_on !== undefined ? { released_on: args.released_on } : {}),
556
+ ...(args.move_open_to !== undefined ? { move_open_to: args.move_open_to } : {}),
557
+ },
558
+ });
559
+ const release = releaseOut(r.release);
560
+ return {
561
+ structured: { action: args.action, release },
562
+ text: `The release is ${release.status}:\n${wrapLines([releaseLine(release)], 'release')}`,
563
+ };
564
+ }
565
+ case 'update': {
566
+ const body = {
567
+ ...(args.name !== undefined ? { name: args.name } : {}),
568
+ ...(args.description !== undefined ? { description: args.description } : {}),
569
+ ...(args.start_date !== undefined ? { start_date: args.start_date } : {}),
570
+ ...(args.target_date !== undefined ? { target_date: args.target_date } : {}),
571
+ ...(args.released_on !== undefined ? { released_on: args.released_on } : {}),
572
+ };
573
+ if (Object.keys(body).length === 0) {
574
+ throw new ToolInputError('action "update" needs at least one of name, description, start_date, target_date or released_on.');
575
+ }
576
+ const r = await ctx.call('update_release', {
577
+ path: { key, release: needRelease() },
578
+ body,
579
+ });
580
+ const release = releaseOut(r.release);
581
+ return {
582
+ structured: { action: args.action, release },
583
+ text: `Updated the release:\n${releaseText(release, r.release.description)}`,
584
+ };
585
+ }
586
+ case 'reopen':
587
+ case 'archive':
588
+ case 'restore': {
589
+ const change = STATE_CHANGES[args.action];
590
+ const r = await ctx.call(change.op, { path: { key, release: needRelease() } });
591
+ const release = releaseOut(r.release);
592
+ return {
593
+ structured: { action: args.action, release },
594
+ text: `${change.done} (its items keep their fix version):\n${wrapLines([releaseLine(release)], 'release')}`,
595
+ };
596
+ }
597
+ case 'add_items':
598
+ case 'remove_items': {
599
+ if (args.items === undefined)
600
+ throw new ToolInputError(`action "${args.action}" needs items.`);
601
+ const add = args.action === 'add_items';
602
+ const r = await ctx.call(add ? 'add_release_items' : 'remove_release_items', {
603
+ path: { key, release: needRelease() },
604
+ body: { items: args.items.map(itemRef) },
605
+ });
606
+ const release = releaseOut(r.release);
607
+ const changed = r.changed.map((i) => i.key);
608
+ return {
609
+ structured: { action: args.action, release, changed },
610
+ text: [
611
+ changed.length === 0
612
+ ? 'Nothing changed: the items were already as asked.'
613
+ : `${add ? 'Added to' : 'Removed from'} the release: ${changed.join(', ')}.`,
614
+ wrapLines([releaseLine(release)], 'release'),
615
+ ].join('\n'),
616
+ };
617
+ }
618
+ }
619
+ },
620
+ });
621
+ /** The most of the written notes one result shows (the text and structured content both carry it). */
622
+ const NOTES_CHARS = 8000;
623
+ export const writeReleaseNotesTool = defineTool({
624
+ name: 'write_release_notes',
625
+ toolset: 'planning',
626
+ title: 'Write release notes',
627
+ description: `Writes a release's notes page (in the project channel's pages) from the items it holds.
628
+ - The first call creates the page; later calls rewrite it.
629
+ - If a person edited the page since it was last written, nothing is overwritten: the result says needs_confirmation and gives the page's current version. Show the person, and only if they agree, call again with page_version set to that version, which replaces their edits.
630
+ - locale: the language of the headings (en, es-419, fr-CA, sr-Cyrl or sr-Latn); default: the person's.
631
+ Needs pages:write as well, since the notes are a page. Returns the notes as written (cut at ${NOTES_CHARS.toLocaleString('en')} characters; get_page reads the whole page).`,
632
+ scopes: scopesOf('generate_release_notes'),
633
+ annotations: {
634
+ title: 'Write release notes',
635
+ readOnlyHint: false,
636
+ // It can replace a page that people edited (only with page_version).
637
+ destructiveHint: true,
638
+ idempotentHint: true,
639
+ openWorldHint: false,
640
+ },
641
+ inputSchema: z.object({
642
+ project: ProjectKeyInput,
643
+ release: z.string().min(1).max(80).describe('The release, by name.'),
644
+ locale: z.enum(['en', 'es-419', 'fr-CA', 'sr-Cyrl', 'sr-Latn']).optional(),
645
+ page_version: z
646
+ .number()
647
+ .int()
648
+ .min(1)
649
+ .optional()
650
+ .describe('Only after the person agreed: the version from needs_confirmation, to replace their edits.'),
651
+ }),
652
+ outputSchema: z.object({
653
+ page_id: z.string().nullable(),
654
+ page_version: z.number().nullable(),
655
+ created: z.boolean(),
656
+ overwrote_edits: z.boolean(),
657
+ needs_confirmation: z
658
+ .boolean()
659
+ .describe('true: people edited the page; nothing was written (see page_version).'),
660
+ markdown: z
661
+ .string()
662
+ .nullable()
663
+ .describe('The notes as written, wrapped as untrusted content and cut; null when nothing was written.'),
664
+ }),
665
+ async run(args, ctx) {
666
+ const r = await ctx.call('generate_release_notes', {
667
+ path: { key: args.project, release: args.release },
668
+ body: {
669
+ ...(args.locale !== undefined ? { locale: args.locale } : {}),
670
+ ...(args.page_version !== undefined ? { page_version: args.page_version } : {}),
671
+ },
672
+ });
673
+ const structured = {
674
+ page_id: r.page_id,
675
+ page_version: r.page_version,
676
+ created: r.created,
677
+ overwrote_edits: r.overwrote_edits,
678
+ needs_confirmation: r.needs_confirmation,
679
+ // The notes quote item titles, which people wrote.
680
+ markdown: r.markdown === undefined
681
+ ? null
682
+ : wrapUntrusted(r.markdown, {
683
+ source: 'release_notes',
684
+ maxChars: NOTES_CHARS,
685
+ moreHint: `Call get_page with page="${r.page_id ?? ''}" to read the whole page.`,
686
+ }),
687
+ };
688
+ // The release is named by what the agent sent, not repeated here: people wrote its name.
689
+ const text = r.needs_confirmation
690
+ ? `Nothing written: people edited the release notes page (page ${r.page_id ?? '?'}, version ${r.page_version ?? '?'}) since it was last written. Ask the person; only if they want their edits replaced, call write_release_notes again with page_version=${r.page_version ?? '?'}.`
691
+ : `${r.created ? 'Created' : 'Rewrote'} the release notes page (page ${r.page_id ?? '?'}, version ${r.page_version ?? '?'})${r.overwrote_edits ? ', replacing edits as confirmed' : ''}.`;
692
+ return {
693
+ structured,
694
+ text: structured.markdown ? `${text}\n${structured.markdown}` : text,
695
+ };
696
+ },
697
+ });