@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.
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Reading items: search_items and get_item.
3
+ */
4
+ import { z } from 'zod';
5
+ import { defineTool } from '../toolsets/registry.js';
6
+ import { sanitizeLabel, wrapUntrusted } from '../untrusted.js';
7
+ import { apiAtLeast, commentOut, CommentOutSchema, commentText, CursorInput, customLine, historyOut, HistoryOutSchema, ItemFullSchema, itemFull, itemLine, itemOut, ItemOutSchema, itemRef, ItemRefInput, linkOut, LinkOutSchema, nextPageHint, personText, requireApi, scopesOf, searchItemOut, SearchItemOutSchema, wrapLines, } from './shared.js';
8
+ // ---------------------------------------------------------------------------
9
+ // search_items
10
+ // ---------------------------------------------------------------------------
11
+ const SORT_RE = /^-?(rank|number|title|priority|status_category|due_date|start_date|estimate|created_at|updated_at)$/;
12
+ /** detail="full" returns descriptions, so pages are smaller and descriptions are cut. */
13
+ const FULL_MAX_LIMIT = 20;
14
+ const FULL_DESCRIPTION_CHARS = 1500;
15
+ const names = (max, what) => z.array(z.string().min(1).max(320)).min(1).max(max).optional().describe(what);
16
+ export const searchItemsTool = defineTool({
17
+ name: 'search_items',
18
+ toolset: 'items',
19
+ title: 'Search items',
20
+ description: `Finds items (epics, stories, tasks, bugs, subtasks) by filters and, optionally, text.
21
+ - Filters combine with AND; the values inside one filter combine with OR. Statuses, types and labels are names (describe_project lists them); people are "me", "none" (unassigned), an email or a display name.
22
+ - Without query: sorted by rank (or sort) and paged; pass next_cursor as cursor for the next page.
23
+ - With query: best matches first, at most 50 a page, paged with cursor too. With query and sort: only the items matching every word, in the sort's order. query can be an exact key (DEMO-12), a number, or words that must all match the title, description or key.
24
+ - has_link: a link kind (blocks, blocked_by, relates, duplicates, duplicated_by), then optionally ":" and the other item's category or "open" ("blocked_by:open"). activity_since counts comments; updated_since doesn't.
25
+ - detail="concise" (default) returns one line per item; detail="full" adds descriptions (cut at ${FULL_DESCRIPTION_CHARS} characters) and custom fields, at most ${FULL_MAX_LIMIT} per page. For one item, get_item is better.
26
+ Examples: my open work: assignees=["me"], categories=["not_started","started"]. Bugs in a sprint: projects=["DEMO"], types=["Bug"], sprint="active". An epic's children: parent="DEMO-12".`,
27
+ scopes: scopesOf('search_items'),
28
+ annotations: {
29
+ title: 'Search items',
30
+ readOnlyHint: true,
31
+ idempotentHint: true,
32
+ openWorldHint: false,
33
+ },
34
+ inputSchema: z.object({
35
+ query: z
36
+ .string()
37
+ .min(1)
38
+ .max(200)
39
+ .optional()
40
+ .describe('Text to search for: a key, a number, or words.'),
41
+ projects: names(20, 'Project keys, such as ["DEMO"].'),
42
+ types: names(20, 'Type names, such as ["Bug", "Story"].'),
43
+ statuses: names(20, 'Status names, such as ["In review"].'),
44
+ categories: z
45
+ .array(z.enum(['not_started', 'started', 'done', 'canceled']))
46
+ .min(1)
47
+ .max(4)
48
+ .optional()
49
+ .describe('Status categories; open work is ["not_started", "started"].'),
50
+ priorities: z
51
+ .array(z.enum(['none', 'low', 'medium', 'high', 'urgent']))
52
+ .min(1)
53
+ .max(5)
54
+ .optional(),
55
+ assignees: names(20, '"me", "none" (unassigned), emails or display names.'),
56
+ labels: names(20, 'Items with any of these labels.'),
57
+ sprint: z
58
+ .string()
59
+ .min(1)
60
+ .max(80)
61
+ .optional()
62
+ .describe('A sprint number, name, "active", or "none" (in no sprint).'),
63
+ release: z.string().min(1).max(80).optional().describe('A fix version by name, or "none".'),
64
+ parent: ItemRefInput.optional().describe('Only the children of this item (an epic or a story).'),
65
+ updated_since: z.iso
66
+ .datetime({ offset: true })
67
+ .optional()
68
+ .describe('Only items changed after this time, RFC 3339 (2026-10-01T00:00:00Z).'),
69
+ has_link: z.array(z.string().min(1).max(40)).min(1).max(10).optional(),
70
+ activity_since: z
71
+ .string()
72
+ .min(1)
73
+ .max(40)
74
+ .optional()
75
+ .describe('RFC 3339: changed or commented on after it.'),
76
+ sort: z
77
+ .array(z.string().regex(SORT_RE))
78
+ .min(1)
79
+ .max(5)
80
+ .optional()
81
+ .describe('Sort keys: rank, number, title, priority, status_category, due_date, start_date, estimate, created_at, updated_at; a leading "-" sorts descending ("-updated_at").'),
82
+ detail: z.enum(['concise', 'full']).optional().describe('concise (default) or full.'),
83
+ cursor: CursorInput.optional(),
84
+ limit: z
85
+ .number()
86
+ .int()
87
+ .min(1)
88
+ .max(100)
89
+ .optional()
90
+ .describe(`Page size, 1 to 100 (default 25; at most ${FULL_MAX_LIMIT} with detail="full").`),
91
+ }),
92
+ outputSchema: z.object({
93
+ items: z.array(SearchItemOutSchema),
94
+ next_cursor: z.string().nullable(),
95
+ text_search: z.boolean().describe('True when query was used without sort: best first.'),
96
+ }),
97
+ async run(args, ctx) {
98
+ if (args.query !== undefined && (args.sort !== undefined || args.cursor !== undefined)) {
99
+ requireApi(ctx.api, '1.1.0', 'query with sort or cursor');
100
+ }
101
+ if (args.has_link !== undefined)
102
+ requireApi(ctx.api, '1.2.0', 'has_link');
103
+ if (args.activity_since !== undefined)
104
+ requireApi(ctx.api, '1.2.0', 'activity_since');
105
+ const detail = args.detail ?? 'concise';
106
+ let limit = args.limit;
107
+ let clamped = false;
108
+ if (detail === 'full' && (limit ?? 25) > FULL_MAX_LIMIT) {
109
+ limit = FULL_MAX_LIMIT;
110
+ clamped = true;
111
+ }
112
+ const query = {
113
+ ...(args.query !== undefined ? { q: args.query } : {}),
114
+ ...(args.projects ? { project: args.projects } : {}),
115
+ ...(args.types ? { type: args.types } : {}),
116
+ ...(args.statuses ? { status: args.statuses } : {}),
117
+ ...(args.categories ? { category: args.categories } : {}),
118
+ ...(args.priorities ? { priority: args.priorities } : {}),
119
+ ...(args.assignees ? { assignee: args.assignees } : {}),
120
+ ...(args.labels ? { label: args.labels } : {}),
121
+ ...(args.sprint !== undefined ? { sprint: args.sprint } : {}),
122
+ ...(args.release !== undefined ? { release: args.release } : {}),
123
+ ...(args.parent !== undefined ? { parent: itemRef(args.parent) } : {}),
124
+ ...(args.updated_since !== undefined ? { updated_since: args.updated_since } : {}),
125
+ ...(args.has_link ? { has_link: args.has_link } : {}),
126
+ ...(args.activity_since !== undefined ? { activity_since: args.activity_since } : {}),
127
+ ...(args.sort ? { sort: args.sort } : {}),
128
+ ...(detail === 'full' ? { detail } : {}),
129
+ ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
130
+ ...(limit !== undefined ? { limit } : {}),
131
+ };
132
+ const page = await ctx.call('search_items', { query });
133
+ const items = page.items.map((i) => searchItemOut(i, FULL_DESCRIPTION_CHARS));
134
+ const textSearch = args.query !== undefined && args.sort === undefined;
135
+ const parts = [];
136
+ if (items.length === 0) {
137
+ parts.push('No items match.');
138
+ }
139
+ else {
140
+ parts.push(`${items.length} item(s)${textSearch ? ', best match first' : ''}${clamped ? ` (page size cut to ${FULL_MAX_LIMIT} for detail="full")` : ''}:`);
141
+ // With the link or activity filters, each line says what matched.
142
+ const line = (i) => {
143
+ const extra = [];
144
+ const c = i.link_counts;
145
+ if (args.has_link && c) {
146
+ const kinds = [
147
+ c.blocks > 0 ? `blocks ${c.blocks}` : '',
148
+ c.blocked_by > 0 ? `blocked by ${c.blocked_by} (${c.blocked_by_open} open)` : '',
149
+ c.relates > 0 ? `relates ${c.relates}` : '',
150
+ c.duplicates > 0 ? `duplicates ${c.duplicates}` : '',
151
+ c.duplicated_by > 0 ? `duplicated by ${c.duplicated_by}` : '',
152
+ ].filter(Boolean);
153
+ if (kinds.length > 0)
154
+ extra.push(`links: ${kinds.join(', ')}`);
155
+ }
156
+ if (args.activity_since !== undefined && i.activity_at) {
157
+ extra.push(`activity ${i.activity_at}`);
158
+ }
159
+ return [itemLine(i), ...extra].join(' · ');
160
+ };
161
+ if (detail === 'full') {
162
+ for (const i of items) {
163
+ parts.push(wrapLines([line(i)], 'item'));
164
+ if (i.description !== undefined)
165
+ parts.push(i.description);
166
+ }
167
+ }
168
+ else {
169
+ // The snippet goes into the list's one block as a flattened line.
170
+ const lines = items.map((i, n) => {
171
+ const snippet = page.items[n]?.match?.snippet;
172
+ return snippet ? `${line(i)}\n match: ${sanitizeLabel(snippet, 300)}` : line(i);
173
+ });
174
+ parts.push(wrapLines(lines, 'item_list'));
175
+ }
176
+ }
177
+ // An API older than 1.1.0 answers a text search with one page of at most 50.
178
+ parts.push(textSearch &&
179
+ page.next_cursor === null &&
180
+ items.length >= 50 &&
181
+ apiAtLeast(ctx.api, '1.1.0') !== true
182
+ ? 'This may be all: an older buildIt.Social server returns one page of text results; narrow with filters for more.'
183
+ : nextPageHint('search_items', page.next_cursor));
184
+ return {
185
+ structured: { items, next_cursor: page.next_cursor, text_search: textSearch },
186
+ text: parts.join('\n'),
187
+ };
188
+ },
189
+ });
190
+ // ---------------------------------------------------------------------------
191
+ // get_item
192
+ // ---------------------------------------------------------------------------
193
+ const CONCISE_DESCRIPTION_CHARS = 2000;
194
+ const FULL_DESCRIPTION_MAX = 20_000;
195
+ /** A window is 20,000 code points, which can be twice as many UTF-16 characters. */
196
+ const WINDOW_MAX_CHARS = 40_000;
197
+ const COMMENT_CHARS = { concise: 2000, full: 600 };
198
+ const ChildOut = ItemOutSchema.pick({
199
+ key: true,
200
+ title: true,
201
+ type: true,
202
+ status: true,
203
+ status_category: true,
204
+ assignee: true,
205
+ estimate: true,
206
+ });
207
+ // The development block: pull requests, branches and commits on GitHub that
208
+ // name the item's key. Their titles, branch names and author names are written on
209
+ // GitHub, outside buildIt.Social, so each is wrapped (source "git"); the text
210
+ // output lists them all inside one block (links only for pull requests: the
211
+ // structured content has every link). Values are cut short, so 20 of each
212
+ // with the longest values stay a small part of a response.
213
+ const GIT_SOURCE = 'git';
214
+ const GIT_TITLE_CHARS = 120;
215
+ const GIT_NAME_CHARS = 100;
216
+ const GIT_AUTHOR_CHARS = 50;
217
+ /** The API sends at most 20 of each; the server keeps to that whatever it gets. */
218
+ const DEVELOPMENT_MAX = 20;
219
+ /** A GitHub login: letters, digits and single hyphens, at most 39 characters. */
220
+ const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
221
+ /** One line of outside text, cut with an ellipsis, in its own block. */
222
+ function wrappedLine(value, maxChars, source) {
223
+ const flat = value.replace(/\s+/g, ' ').trim();
224
+ let end = maxChars - 1;
225
+ const code = flat.charCodeAt(end - 1);
226
+ if (code >= 0xd800 && code <= 0xdbff)
227
+ end -= 1; // don't split a surrogate pair
228
+ const short = flat.length > maxChars ? `${flat.slice(0, end)}…` : flat;
229
+ return wrapUntrusted(short, { source, maxChars: maxChars + 1 });
230
+ }
231
+ /** One line of GitHub text (the link has the rest), in its own git block. */
232
+ const gitText = (value, maxChars) => wrappedLine(value, maxChars, GIT_SOURCE);
233
+ const gitNullable = (value, maxChars) => value === null || value === undefined || value === '' ? null : gitText(value, maxChars);
234
+ /** An author: a GitHub login is a plain label; anything else (a name from git) is free text, wrapped. */
235
+ const gitAuthor = (value) => value !== null && value !== undefined && GITHUB_LOGIN.test(value)
236
+ ? value
237
+ : gitNullable(value, GIT_AUTHOR_CHARS);
238
+ const DevelopmentOutSchema = z
239
+ .object({
240
+ pull_requests: z.array(z.object({
241
+ number: z.number(),
242
+ title: z.string().nullable(),
243
+ state: z.string(),
244
+ repo: z.string(),
245
+ url: z.string(),
246
+ author: z.string().nullable(),
247
+ updated_at: z.string(),
248
+ })),
249
+ branches: z.array(z.object({ name: z.string(), state: z.string(), repo: z.string(), url: z.string() })),
250
+ commits: z.array(z.object({
251
+ sha: z.string(),
252
+ title: z.string().nullable(),
253
+ repo: z.string(),
254
+ url: z.string(),
255
+ author: z.string().nullable(),
256
+ committed_at: z.string(),
257
+ })),
258
+ counts: z
259
+ .object({ pull_requests: z.number(), branches: z.number(), commits: z.number() })
260
+ .describe('Totals; the lists hold at most 20 of each.'),
261
+ })
262
+ .describe('Titles, branch names and authors that are not GitHub logins are wrapped as untrusted content (source "git").');
263
+ function developmentOut(d) {
264
+ return {
265
+ pull_requests: d.pull_requests.slice(0, DEVELOPMENT_MAX).map((p) => ({
266
+ number: p.number,
267
+ title: gitNullable(p.title, GIT_TITLE_CHARS),
268
+ state: sanitizeLabel(p.state, 30),
269
+ repo: sanitizeLabel(p.repo, 200),
270
+ url: sanitizeLabel(p.url, 2048),
271
+ author: gitAuthor(p.author),
272
+ updated_at: sanitizeLabel(p.updated_at, 40),
273
+ })),
274
+ branches: d.branches.slice(0, DEVELOPMENT_MAX).map((b) => ({
275
+ name: gitText(b.name, GIT_NAME_CHARS),
276
+ state: sanitizeLabel(b.state, 30),
277
+ repo: sanitizeLabel(b.repo, 200),
278
+ url: sanitizeLabel(b.url, 2048),
279
+ })),
280
+ commits: d.commits.slice(0, DEVELOPMENT_MAX).map((c) => ({
281
+ sha: sanitizeLabel(c.sha, 64),
282
+ title: gitNullable(c.title, GIT_TITLE_CHARS),
283
+ repo: sanitizeLabel(c.repo, 200),
284
+ url: sanitizeLabel(c.url, 2048),
285
+ author: gitAuthor(c.author),
286
+ committed_at: sanitizeLabel(c.committed_at, 40),
287
+ })),
288
+ counts: {
289
+ pull_requests: d.counts.pull_requests,
290
+ branches: d.counts.branches,
291
+ commits: d.counts.commits,
292
+ },
293
+ };
294
+ }
295
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
296
+ // The feedback block: the report behind an item that another organization
297
+ // filed through the Feedback app. The reporter's name, their organization's
298
+ // name and label, and the device values were written in that organization,
299
+ // so each is wrapped (source "feedback"); the text output lists them in one
300
+ // block. kind, severity and state are the API's words.
301
+ const FEEDBACK_SOURCE = 'feedback';
302
+ const FEEDBACK_NAME_CHARS = 100;
303
+ const FEEDBACK_DEVICE_CHARS = 80;
304
+ const FEEDBACK_NOTE = 'Your comments never reach the reporter: only a person can ask the customer.';
305
+ const FeedbackOutSchema = z
306
+ .object({
307
+ reporter_name: z.string().nullable(),
308
+ org_name: z.string(),
309
+ org_label: z.string(),
310
+ kind: z.string(),
311
+ severity: z.string(),
312
+ device: z.object({
313
+ app: z.string().nullable(),
314
+ build: z.string().nullable(),
315
+ platform: z.string().nullable(),
316
+ os: z.string().nullable(),
317
+ form: z.string().nullable(),
318
+ }),
319
+ plus_ones: z.number(),
320
+ state: z.string().describe('open: updates reach the reporter.'),
321
+ })
322
+ .describe('Names, label and device values: untrusted content (source "feedback").');
323
+ const feedbackNullable = (value, maxChars) => value === null || value === undefined || value === ''
324
+ ? null
325
+ : wrappedLine(value, maxChars, FEEDBACK_SOURCE);
326
+ function feedbackOut(f) {
327
+ return {
328
+ reporter_name: feedbackNullable(f.reporter_name, FEEDBACK_NAME_CHARS),
329
+ org_name: wrappedLine(f.org_name, FEEDBACK_NAME_CHARS, FEEDBACK_SOURCE),
330
+ org_label: wrappedLine(f.org_label, FEEDBACK_NAME_CHARS, FEEDBACK_SOURCE),
331
+ kind: sanitizeLabel(f.kind, 30),
332
+ severity: sanitizeLabel(f.severity, 30),
333
+ device: {
334
+ app: feedbackNullable(f.device.app, FEEDBACK_DEVICE_CHARS),
335
+ build: feedbackNullable(f.device.build, FEEDBACK_DEVICE_CHARS),
336
+ platform: feedbackNullable(f.device.platform, FEEDBACK_DEVICE_CHARS),
337
+ os: feedbackNullable(f.device.os, FEEDBACK_DEVICE_CHARS),
338
+ form: feedbackNullable(f.device.form, FEEDBACK_DEVICE_CHARS),
339
+ },
340
+ plus_ones: f.plus_ones,
341
+ state: sanitizeLabel(f.state, 30),
342
+ };
343
+ }
344
+ /** The feedback block for the text output: the API's facts, then the reporter's words in one block. */
345
+ function feedbackText(f) {
346
+ const one = (v, max) => v === null || v === undefined || v === '' ? 'unknown' : sanitizeLabel(v, max);
347
+ const d = f.device;
348
+ const device = [
349
+ `app ${one(d.app, FEEDBACK_DEVICE_CHARS)}`,
350
+ `build ${one(d.build, FEEDBACK_DEVICE_CHARS)}`,
351
+ `platform ${one(d.platform, FEEDBACK_DEVICE_CHARS)}`,
352
+ `os ${one(d.os, FEEDBACK_DEVICE_CHARS)}`,
353
+ `form ${one(d.form, FEEDBACK_DEVICE_CHARS)}`,
354
+ ].join(' · ');
355
+ return [
356
+ `Feedback report, filed through the Feedback app by another organization: ${sanitizeLabel(f.kind, 30)} · severity ${sanitizeLabel(f.severity, 30)} · ${plural(f.plus_ones, 'plus-one', 'plus-ones')} · state ${sanitizeLabel(f.state, 30)}`,
357
+ wrapLines([
358
+ `reporter: ${f.reporter_name === null ? 'gone (account or organization deleted)' : one(f.reporter_name, FEEDBACK_NAME_CHARS)}`,
359
+ `organization: ${one(f.org_name, FEEDBACK_NAME_CHARS)} (label ${one(f.org_label, FEEDBACK_NAME_CHARS)})`,
360
+ `device: ${device}`,
361
+ ], FEEDBACK_SOURCE),
362
+ FEEDBACK_NOTE,
363
+ ].join('\n');
364
+ }
365
+ /** The development block for the text output: a line of counts, then every ref in one git block. */
366
+ function developmentText(key, d) {
367
+ const { counts } = d;
368
+ if (counts.pull_requests + counts.branches + counts.commits === 0) {
369
+ return `Development on GitHub: nothing names ${key} yet (or the org hasn't connected GitHub).`;
370
+ }
371
+ const shown = (list, total) => list.length < total ? ` (the ${list.length} latest shown)` : '';
372
+ const head = [
373
+ plural(counts.pull_requests, 'pull request', 'pull requests') +
374
+ shown(d.pull_requests.slice(0, DEVELOPMENT_MAX), counts.pull_requests),
375
+ plural(counts.branches, 'branch', 'branches') +
376
+ shown(d.branches.slice(0, DEVELOPMENT_MAX), counts.branches),
377
+ plural(counts.commits, 'commit', 'commits') +
378
+ shown(d.commits.slice(0, DEVELOPMENT_MAX), counts.commits),
379
+ ];
380
+ const one = (v, max) => v === null || v === undefined || v === '' ? 'unknown' : sanitizeLabel(v, max);
381
+ const lines = [];
382
+ if (d.pull_requests.length > 0) {
383
+ lines.push('Pull requests:');
384
+ for (const p of d.pull_requests.slice(0, DEVELOPMENT_MAX)) {
385
+ lines.push(`- #${p.number} ${one(p.state, 30)} · ${one(p.repo, 200)} · by ${one(p.author, GIT_AUTHOR_CHARS)} · updated ${one(p.updated_at, 40)} — ${one(p.title, GIT_TITLE_CHARS)} · ${one(p.url, 300)}`);
386
+ }
387
+ }
388
+ if (d.branches.length > 0) {
389
+ lines.push('Branches:');
390
+ for (const b of d.branches.slice(0, DEVELOPMENT_MAX)) {
391
+ lines.push(`- ${one(b.name, GIT_NAME_CHARS)} (${one(b.state, 30)}) · ${one(b.repo, 200)}`);
392
+ }
393
+ }
394
+ if (d.commits.length > 0) {
395
+ lines.push('Commits:');
396
+ for (const c of d.commits.slice(0, DEVELOPMENT_MAX)) {
397
+ lines.push(`- ${one(c.sha, 64).slice(0, 7)} · ${one(c.repo, 200)} · by ${one(c.author, GIT_AUTHOR_CHARS)} · ${one(c.committed_at, 40)} — ${one(c.title, GIT_TITLE_CHARS)}`);
398
+ }
399
+ }
400
+ return (`Development on GitHub: ${head.join(', ')}, newest first:\n` + wrapLines(lines, GIT_SOURCE));
401
+ }
402
+ export const getItemTool = defineTool({
403
+ name: 'get_item',
404
+ toolset: 'items',
405
+ title: 'Get an item',
406
+ description: `Reads one item by key (DEMO-12): its fields, description (Markdown), custom fields, children, links, the latest comments and, optionally, its history.
407
+ - detail="concise" (default): the description cut at ${CONCISE_DESCRIPTION_CHARS} characters and the 5 latest comments.
408
+ - detail="full": the description up to 20,000 characters and the 20 latest comments (each cut at ${COMMENT_CHARS.full} characters).
409
+ - description_offset reads a longer description in windows of 20,000 characters: start at 0, then pass description_next_offset until it is null.
410
+ - include_history=true adds the 50 latest history events.
411
+ - include_development=true adds the GitHub pull requests, branches and commits that name the item's key (at most 20 of each, newest first, with totals); their text comes from GitHub, wrapped as untrusted content (source "git").
412
+ - An item filed through the Feedback app by another organization adds feedback: the report, with the reporter, organization and device as untrusted content. Your comments never reach the reporter.
413
+ The result carries version and description_version: pass them to update_item (if_version, and description_version to replace the description) and transition_item. For older comments use list_comments.`,
414
+ scopes: scopesOf('get_item'),
415
+ annotations: {
416
+ title: 'Get an item',
417
+ readOnlyHint: true,
418
+ idempotentHint: true,
419
+ openWorldHint: false,
420
+ },
421
+ inputSchema: z.object({
422
+ item: ItemRefInput,
423
+ detail: z.enum(['concise', 'full']).optional().describe('concise (default) or full.'),
424
+ include_history: z.boolean().optional().describe('Add the 50 latest history events.'),
425
+ include_development: z
426
+ .boolean()
427
+ .optional()
428
+ .describe('Add its GitHub pull requests, branches and commits.'),
429
+ description_offset: z
430
+ .number()
431
+ .int()
432
+ .min(0)
433
+ .max(262_144)
434
+ .optional()
435
+ .describe('Read the description in a window from this character.'),
436
+ }),
437
+ outputSchema: z.object({
438
+ item: ItemFullSchema,
439
+ description_window: z
440
+ .object({ offset: z.number(), next_offset: z.number().nullable(), length: z.number() })
441
+ .optional()
442
+ .describe('With description_offset: next_offset is null at the end.'),
443
+ children: z.array(ChildOut),
444
+ links: z.array(LinkOutSchema),
445
+ comments: z.array(CommentOutSchema).describe('The latest comments, oldest first.'),
446
+ comments_next_cursor: z
447
+ .string()
448
+ .nullable()
449
+ .describe('For list_comments with order="desc", to read older comments.'),
450
+ history: z.array(HistoryOutSchema).optional(),
451
+ development: DevelopmentOutSchema.optional(),
452
+ feedback: FeedbackOutSchema.optional(),
453
+ }),
454
+ async run(args, ctx) {
455
+ const detail = args.detail ?? 'concise';
456
+ const windowAt = args.description_offset;
457
+ if (windowAt !== undefined)
458
+ requireApi(ctx.api, '1.1.0', 'description_offset');
459
+ const r = await ctx.call('get_item', {
460
+ path: { key: itemRef(args.item) },
461
+ query: {
462
+ ...(detail === 'full' ? { detail } : {}),
463
+ ...(args.include_history ? { include_history: 'true' } : {}),
464
+ ...(args.include_development ? { include_development: 'true' } : {}),
465
+ ...(windowAt !== undefined ? { description_offset: windowAt } : {}),
466
+ },
467
+ });
468
+ // A window answers with its offsets; an API older than 1.1.0 ignores the parameter.
469
+ const window = windowAt !== undefined && r.item.description_offset !== undefined
470
+ ? {
471
+ offset: r.item.description_offset,
472
+ next_offset: r.item.description_next_offset ?? null,
473
+ length: r.item.description_length ?? r.item.description.length,
474
+ }
475
+ : undefined;
476
+ const item = itemFull(r.item, window
477
+ ? WINDOW_MAX_CHARS
478
+ : detail === 'full'
479
+ ? FULL_DESCRIPTION_MAX
480
+ : CONCISE_DESCRIPTION_CHARS, detail === 'full', detail === 'full' && apiAtLeast(ctx.api, '1.1.0') !== false
481
+ ? `Call get_item with item="${r.item.key}" and description_offset=0 to read all of it in windows.`
482
+ : undefined);
483
+ const children = r.children.map((c) => {
484
+ const o = itemOut(c);
485
+ return {
486
+ key: o.key,
487
+ title: o.title,
488
+ type: o.type,
489
+ status: o.status,
490
+ status_category: o.status_category,
491
+ assignee: o.assignee,
492
+ estimate: o.estimate,
493
+ };
494
+ });
495
+ const links = r.links.map(linkOut);
496
+ const comments = r.comments.map((c) => commentOut(c, COMMENT_CHARS[detail], `Call list_comments with item="${r.item.key}" to read it.`));
497
+ const history = r.history?.map(historyOut);
498
+ const i = item;
499
+ // The server's facts, then the fields that carry names people wrote
500
+ // (type, status, people, labels, sprint, release) in their own block.
501
+ const facts = [
502
+ [
503
+ `${i.key} · project ${i.project}`,
504
+ ...(i.parent ? [`parent ${i.parent}`] : []),
505
+ ...(i.initiative ? [`initiative ${i.initiative}`] : []),
506
+ `estimate ${i.estimate ?? 'none'}`,
507
+ `start ${i.start_date ?? 'none'}`,
508
+ `due ${i.due_date ?? 'none'}`,
509
+ ].join(' · '),
510
+ `version ${i.version} · description_version ${i.description_version} · created ${i.created_at} · updated ${i.updated_at}`,
511
+ ];
512
+ const fields = [
513
+ `${i.key} · ${i.type} (${i.level}) · ${i.status} (${i.status_category}) · priority ${i.priority}`,
514
+ `assignee ${personText(i.assignee)} · reporter ${personText(i.reporter)}`,
515
+ [
516
+ `labels ${i.labels.join(', ') || 'none'}`,
517
+ `sprint ${i.sprint ?? 'none'}`,
518
+ `fix release ${i.fix_release ?? 'none'}`,
519
+ ].join(' · '),
520
+ ];
521
+ const parts = [
522
+ facts.join('\n'),
523
+ wrapLines(fields, 'item_fields'),
524
+ wrapUntrusted(r.item.title, { source: 'title', maxChars: 300 }),
525
+ ...(window
526
+ ? [
527
+ `Description, characters ${window.offset} to ${window.next_offset ?? window.length} of ${window.length}: ${window.next_offset === null ? 'the last window.' : `the next window is description_offset=${window.next_offset}.`}`,
528
+ ]
529
+ : windowAt !== undefined
530
+ ? [
531
+ 'This buildIt.Social server ignored description_offset (its API is older than 1.1.0), so the description is cut as usual.',
532
+ ]
533
+ : []),
534
+ i.description === '' ? '(no description)' : i.description,
535
+ ];
536
+ if (r.item.custom.length > 0) {
537
+ parts.push(`Custom fields (${r.item.custom.length}):\n` +
538
+ wrapLines(r.item.custom.map(customLine), 'custom_fields'));
539
+ }
540
+ if (children.length > 0) {
541
+ parts.push(`Children (${children.length}):\n` +
542
+ wrapLines(children.map((c) => `${c.key} · ${c.type} · ${c.status} (${c.status_category}) · ${c.assignee ? `@${c.assignee.name}` : 'unassigned'}${c.estimate !== null ? ` · est ${c.estimate}` : ''} — ${c.title}`), 'item_list'));
543
+ }
544
+ if (links.length > 0) {
545
+ parts.push(`Links (${links.length}):\n` +
546
+ wrapLines(links.map((l) => l.key
547
+ ? `${l.kind} ${l.key} · ${l.status ?? ''} — ${l.title ?? ''} (link id ${l.id})`
548
+ : `${l.kind} an item you can't see (link id ${l.id})`), 'link_list'));
549
+ }
550
+ if (comments.length > 0) {
551
+ parts.push(`Latest comments (${comments.length}, oldest first):`);
552
+ for (const c of comments)
553
+ parts.push(commentText(c));
554
+ if (r.comments_next_cursor !== null) {
555
+ parts.push(`Older comments: call list_comments with item="${r.item.key}", order="desc" and this cursor:\n${sanitizeLabel(r.comments_next_cursor, 2048)}`);
556
+ }
557
+ }
558
+ else {
559
+ parts.push('No comments.');
560
+ }
561
+ if (history && r.history) {
562
+ // One block for the whole history: old and new values can be people-written.
563
+ // Labels (names and keys as people read them now) are shown; when the API
564
+ // gives none (a deleted or hidden value), the raw value is.
565
+ const value = (label, raw) => {
566
+ if (label !== null)
567
+ return sanitizeLabel(label, 200);
568
+ if (raw === null || raw === undefined)
569
+ return 'none';
570
+ return sanitizeLabel(typeof raw === 'string' ? raw : JSON.stringify(raw), 200);
571
+ };
572
+ parts.push(`History (${history.length}, newest first):\n` +
573
+ wrapLines(r.history.map((h, n) => {
574
+ const o = history[n];
575
+ const who = `${o?.actor ?? 'system'}${o?.via_agent ? ` via ${o.via_agent}` : ''}`;
576
+ const change = h.old !== null || h.new !== null
577
+ ? `: ${value(h.old_label, h.old)} -> ${value(h.new_label, h.new)}`
578
+ : '';
579
+ return `- ${h.at} · ${who} · ${h.kind}${o?.field ? ` ${o.field}` : ''}${change}`;
580
+ }), 'history'));
581
+ }
582
+ const feedback = r.feedback ? feedbackOut(r.feedback) : undefined;
583
+ if (r.feedback)
584
+ parts.push(feedbackText(r.feedback));
585
+ const development = r.development ? developmentOut(r.development) : undefined;
586
+ if (r.development)
587
+ parts.push(developmentText(r.item.key, r.development));
588
+ return {
589
+ structured: {
590
+ item,
591
+ children,
592
+ links,
593
+ comments,
594
+ comments_next_cursor: r.comments_next_cursor,
595
+ ...(history ? { history } : {}),
596
+ ...(development ? { development } : {}),
597
+ ...(feedback ? { feedback } : {}),
598
+ ...(window ? { description_window: window } : {}),
599
+ },
600
+ text: parts.join('\n\n'),
601
+ };
602
+ },
603
+ });