@taiga-ai/mcp-server 0.25.0-rc.474 → 0.26.0-dev.481
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/README.md +111 -0
- package/dist/index.js +292 -115
- package/package.json +15 -2
- package/scripts/build.mjs +0 -37
- package/scripts/prepare-publish.cjs +0 -49
- package/src/client.test.ts +0 -424
- package/src/client.ts +0 -977
- package/src/config.test.ts +0 -24
- package/src/config.ts +0 -28
- package/src/index.ts +0 -45
- package/src/modern.integration.test.ts +0 -153
- package/src/server.integration.test.ts +0 -9214
- package/src/server.ts +0 -18
- package/src/tools/annotations.ts +0 -38
- package/src/tools/autonomy.ts +0 -86
- package/src/tools/backlogs.ts +0 -802
- package/src/tools/builds.ts +0 -668
- package/src/tools/catalogue.test.ts +0 -170
- package/src/tools/catalogue.ts +0 -223
- package/src/tools/context.ts +0 -513
- package/src/tools/documents.ts +0 -375
- package/src/tools/errors.ts +0 -172
- package/src/tools/hints.ts +0 -99
- package/src/tools/inbox.ts +0 -546
- package/src/tools/index.ts +0 -239
- package/src/tools/inputs.ts +0 -47
- package/src/tools/instructions.ts +0 -41
- package/src/tools/jobs.ts +0 -90
- package/src/tools/knowledge.ts +0 -249
- package/src/tools/maintenance.ts +0 -547
- package/src/tools/markdown.test.ts +0 -249
- package/src/tools/markdown.ts +0 -151
- package/src/tools/observe.ts +0 -311
- package/src/tools/product-documents.ts +0 -184
- package/src/tools/products.ts +0 -529
- package/src/tools/prompts.ts +0 -265
- package/src/tools/queue.ts +0 -250
- package/src/tools/readiness.ts +0 -177
- package/src/tools/remote.test.ts +0 -44
- package/src/tools/remote.ts +0 -107
- package/src/tools/repositories.ts +0 -360
- package/src/tools/request-prompt.test.ts +0 -32
- package/src/tools/request-prompt.ts +0 -28
- package/src/tools/resources.ts +0 -217
- package/src/tools/sections.test.ts +0 -143
- package/src/tools/sections.ts +0 -287
- package/src/tools/setup.ts +0 -455
- package/src/tools/summarize.test.ts +0 -20
- package/src/tools/summarize.ts +0 -96
- package/src/tools/wait.ts +0 -490
- package/src/version.test.ts +0 -10
- package/src/version.ts +0 -14
- package/taiga-ai-mcp-server-0.25.0-rc.474.tgz +0 -0
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -9
package/src/tools/inbox.ts
DELETED
|
@@ -1,546 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { TaigaApiError, type TaigaClient } from '../client.js';
|
|
4
|
-
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
5
|
-
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
6
|
-
import { idInput, pagination, productId } from './inputs.js';
|
|
7
|
-
import { page, pickFields } from './summarize.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Action Required (docs.tai.ga, Deliver): where work comes back to a person.
|
|
11
|
-
* Things to review (a plan, a pull request, findings, policy violations) and
|
|
12
|
-
* things to set up that only a person can (a secret, an account, a cloud
|
|
13
|
-
* permission). The inbox is the team's: resolving an item clears it for
|
|
14
|
-
* everybody, so these tools say what they clear.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
type Row = Record<string, unknown>;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* An item as a row: what it is, whose product, how long it has waited. Not
|
|
21
|
-
* its metadata by default: a document decision carried its whole text there,
|
|
22
|
-
* and fifty rows came to 57 KB (PRO-2800); full: true brings it.
|
|
23
|
-
*/
|
|
24
|
-
const ITEM_FIELDS = [
|
|
25
|
-
'id',
|
|
26
|
-
'productId',
|
|
27
|
-
'productName',
|
|
28
|
-
'category',
|
|
29
|
-
'kind',
|
|
30
|
-
'subjectType',
|
|
31
|
-
'subjectId',
|
|
32
|
-
'title',
|
|
33
|
-
'summary',
|
|
34
|
-
'status',
|
|
35
|
-
'blocking',
|
|
36
|
-
'snoozedUntil',
|
|
37
|
-
'createdAt',
|
|
38
|
-
'resolvedAt',
|
|
39
|
-
] as const;
|
|
40
|
-
|
|
41
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
42
|
-
|
|
43
|
-
/** When an item stopped waiting: its resolution, or now while it is open. */
|
|
44
|
-
function waitedUntil(resolvedAt: unknown, now: number): number {
|
|
45
|
-
const at = typeof resolvedAt === 'string' ? Date.parse(resolvedAt) : NaN;
|
|
46
|
-
return Number.isNaN(at) ? now : at;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The read that opens an item's subject (PRO-2800): rows carried subjectType and subjectId, and the reviewing agent
|
|
51
|
-
* had to infer which tool takes them.
|
|
52
|
-
*/
|
|
53
|
-
function readWith(row: Row, backlogRead: boolean): string | undefined {
|
|
54
|
-
switch (row['subjectType']) {
|
|
55
|
-
case 'build_run':
|
|
56
|
-
return 'taiga_get_run (runId: subjectId)';
|
|
57
|
-
case 'backlog_item':
|
|
58
|
-
// Only a tool the key has: the backlog reads need backlog:read.
|
|
59
|
-
return backlogRead ? 'taiga_get_plan (initiativeId: subjectId)' : undefined;
|
|
60
|
-
case 'product_document': {
|
|
61
|
-
// A document decision's subject is "<productId>:<type>:decision:<hash>", which no tool takes: name the
|
|
62
|
-
// document it is about, and say where the decision's own text is.
|
|
63
|
-
const type = /^[^:]+:([a-z_]+):decision:/.exec(String(row['subjectId']))?.[1];
|
|
64
|
-
return type
|
|
65
|
-
? `taiga_get_product_document (productId, type: ${type}); the decision's text is this row with full: true`
|
|
66
|
-
: 'taiga_get_product_document (productId, no type)';
|
|
67
|
-
}
|
|
68
|
-
case 'inbox_task':
|
|
69
|
-
return 'taiga_list_setup_tasks (productId)';
|
|
70
|
-
case 'product':
|
|
71
|
-
return {
|
|
72
|
-
// Open, not one column: the item stands while one sits in backlog or todo.
|
|
73
|
-
review_backlog: backlogRead ? 'taiga_list_initiatives (productId)' : undefined,
|
|
74
|
-
review_maintenance: 'taiga_list_findings (productId)',
|
|
75
|
-
review_policy: 'taiga_get_policy_standing (productId)',
|
|
76
|
-
review_doc: 'taiga_get_product_document (productId, no type)',
|
|
77
|
-
}[String(row['kind'])];
|
|
78
|
-
default:
|
|
79
|
-
return undefined;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** A summary past its first sentence: review_doc rows quoted two documents, 800 to 1,200 characters each. */
|
|
84
|
-
const SUMMARY_LIMIT = 200;
|
|
85
|
-
|
|
86
|
-
function firstSentence(text: unknown): unknown {
|
|
87
|
-
if (typeof text !== 'string') return text;
|
|
88
|
-
// The opening paragraph, as far as SUMMARY_LIMIT: a list after a colon ("could not resolve:\n1. ...") is where
|
|
89
|
-
// the quoted documents start, and a short summary keeps its second sentence ("N manual setup tasks flagged").
|
|
90
|
-
const head = text.split(/\n/)[0]!.trim();
|
|
91
|
-
if (head.length <= SUMMARY_LIMIT)
|
|
92
|
-
return head.length < text.trim().length ? `${head} (full: true has the rest)` : head;
|
|
93
|
-
const cut = head.slice(0, SUMMARY_LIMIT);
|
|
94
|
-
const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('; '));
|
|
95
|
-
return `${end > 40 ? cut.slice(0, end + 1) : cut}... (full: true has the rest)`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** A row as an agent reads it: the pull request to open when there is one, and how many days it has waited. */
|
|
99
|
-
function itemRow(row: Row, now: number, full: boolean, backlogRead: boolean): Row {
|
|
100
|
-
const picked = pickFields([row], ITEM_FIELDS)[0]!;
|
|
101
|
-
const { snoozedUntil, resolvedAt, ...rest } = picked;
|
|
102
|
-
const metadata = (row['metadata'] ?? {}) as Row;
|
|
103
|
-
const created = typeof row['createdAt'] === 'string' ? Date.parse(row['createdAt']) : NaN;
|
|
104
|
-
// A pull request review's summary is one sentence per row ("The coding agent finished its turn on X. Review PR
|
|
105
|
-
// #N...") that the title and kind already say: the org-wide page was 5k tokens of it (PRO-2800).
|
|
106
|
-
// Fields at their usual value say nothing: open, not blocking (the org-wide page repeated them on every row).
|
|
107
|
-
const { summary, status, blocking, ...lean } = rest;
|
|
108
|
-
const read = readWith(row, backlogRead);
|
|
109
|
-
return {
|
|
110
|
-
...lean,
|
|
111
|
-
...(full ? { summary } : row['kind'] === 'review_pr' ? {} : { summary: firstSentence(summary) }),
|
|
112
|
-
...(full || status !== 'open' ? { status } : {}),
|
|
113
|
-
...(full || blocking === true ? { blocking } : {}),
|
|
114
|
-
...(read ? { readWith: read } : {}),
|
|
115
|
-
...(snoozedUntil ? { snoozedUntil } : {}),
|
|
116
|
-
...(resolvedAt ? { resolvedAt } : {}),
|
|
117
|
-
...(typeof metadata['prUrl'] === 'string' ? { prUrl: metadata['prUrl'] } : {}),
|
|
118
|
-
// A resolved item waited until it was resolved, not until now.
|
|
119
|
-
...(Number.isNaN(created) ? {} : { waitingDays: Math.floor((waitedUntil(resolvedAt, now) - created) / DAY_MS) }),
|
|
120
|
-
...(full ? { metadata: row['metadata'] } : {}),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* What is open, counted by product and kind: the answer to "what needs attention" without reading every row. An item
|
|
126
|
-
* that no longer stands is counted under stale, not open or byKind, so nobody subtracts by hand (PRO-2800).
|
|
127
|
-
*/
|
|
128
|
-
function summaryOf(rows: Row[], stale: Map<string, string>): Row[] {
|
|
129
|
-
const byProduct = new Map<
|
|
130
|
-
string,
|
|
131
|
-
{ productId: string; product?: unknown; open: number; byKind: Record<string, number>; stale?: number }
|
|
132
|
-
>();
|
|
133
|
-
for (const row of rows) {
|
|
134
|
-
const key = String(row['productId']);
|
|
135
|
-
// A product-scoped read carries no names; the id is the product's, not a name.
|
|
136
|
-
const entry =
|
|
137
|
-
byProduct.get(key) ??
|
|
138
|
-
(row['productName']
|
|
139
|
-
? { productId: key, product: row['productName'], open: 0, byKind: {} }
|
|
140
|
-
: { productId: key, open: 0, byKind: {} });
|
|
141
|
-
if (stale.has(String(row['id']))) {
|
|
142
|
-
entry.stale = (entry.stale ?? 0) + 1;
|
|
143
|
-
} else {
|
|
144
|
-
entry.open += 1;
|
|
145
|
-
entry.byKind[String(row['kind'])] = (entry.byKind[String(row['kind'])] ?? 0) + 1;
|
|
146
|
-
}
|
|
147
|
-
byProduct.set(key, entry);
|
|
148
|
-
}
|
|
149
|
-
return [...byProduct.values()].sort((a, b) => b.open - a.open);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Pull request reviews checked against their run and initiative; past this a row is left as it is. */
|
|
153
|
-
const STALE_CHECKS = 10;
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Which "Review & merge" items no longer stand, and why (PRO-2800): the run's
|
|
157
|
-
* pull request closed or merged, or its initiative was canceled or finished
|
|
158
|
-
* without it. Two reviewing agents counted such leftovers as live gates; the
|
|
159
|
-
* row still is open in Action Required, so this says what it is now rather
|
|
160
|
-
* than hiding it. Best effort, a handful of runs: a failed read leaves the
|
|
161
|
-
* row as it was.
|
|
162
|
-
*/
|
|
163
|
-
async function staleReviews(
|
|
164
|
-
client: TaigaClient,
|
|
165
|
-
rows: Row[]
|
|
166
|
-
): Promise<{ stale: Map<string, string>; unchecked: number }> {
|
|
167
|
-
const byId = new Map(
|
|
168
|
-
rows
|
|
169
|
-
.filter((row) => row['kind'] === 'review_pr' && row['subjectType'] === 'build_run')
|
|
170
|
-
.map((row) => [String(row['id']), row])
|
|
171
|
-
);
|
|
172
|
-
const candidates = [...byId.values()].slice(0, STALE_CHECKS);
|
|
173
|
-
// Past the cap, and any check the API refused or failed (a key without
|
|
174
|
-
// backlog:read cannot read the initiative): unknown, not live.
|
|
175
|
-
let unchecked = byId.size - candidates.length;
|
|
176
|
-
const out = new Map<string, string>();
|
|
177
|
-
await Promise.all(
|
|
178
|
-
candidates.map(async (row) => {
|
|
179
|
-
try {
|
|
180
|
-
const run = (await client.getBuildRun(String(row['subjectId']))).data as unknown as Row;
|
|
181
|
-
if (run['prMerged'] === true) return void out.set(String(row['id']), 'its pull request has merged');
|
|
182
|
-
if (run['prState'] === 'closed') return void out.set(String(row['id']), 'its pull request was closed');
|
|
183
|
-
if (typeof run['backlogItemId'] !== 'string') return;
|
|
184
|
-
if (!client.hasCapability('backlog:read')) return void (unchecked += 1);
|
|
185
|
-
const item = (await client.getBacklogItem(run['backlogItemId'])).data as unknown as Row;
|
|
186
|
-
if (item['state'] === 'canceled' || item['status'] === 'canceled') {
|
|
187
|
-
out.set(String(row['id']), 'its initiative was canceled; the pull request is still open on the forge');
|
|
188
|
-
} else if (item['state'] === 'done' || item['status'] === 'done') {
|
|
189
|
-
out.set(String(row['id']), 'its initiative is done; this run is not the one that landed it');
|
|
190
|
-
}
|
|
191
|
-
} catch (error) {
|
|
192
|
-
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
193
|
-
unchecked += 1;
|
|
194
|
-
}
|
|
195
|
-
})
|
|
196
|
-
);
|
|
197
|
-
unchecked += await staleBacklogReviews(client, rows, out);
|
|
198
|
-
return { stale: out, unchecked };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* "Review Initiatives" items whose product has no initiative left in its backlog or todo column (PRO-2800): two reviews found one
|
|
203
|
-
* waiting 15 days on a product whose initiatives had all moved on, with nothing an agent could act on. Best effort,
|
|
204
|
-
* a handful of products; returns how many could not be checked.
|
|
205
|
-
*/
|
|
206
|
-
async function staleBacklogReviews(client: TaigaClient, rows: Row[], out: Map<string, string>): Promise<number> {
|
|
207
|
-
// The page and the summary's read can carry the same row: each item once.
|
|
208
|
-
const asks = [
|
|
209
|
-
...new Map(
|
|
210
|
-
rows
|
|
211
|
-
.filter((row) => row['kind'] === 'review_backlog' && typeof row['productId'] === 'string')
|
|
212
|
-
.map((row) => [String(row['id']), row])
|
|
213
|
-
).values(),
|
|
214
|
-
];
|
|
215
|
-
if (asks.length === 0) return 0;
|
|
216
|
-
if (!client.hasCapability('backlog:read')) return asks.length;
|
|
217
|
-
const products = [...new Set(asks.map((row) => String(row['productId'])))];
|
|
218
|
-
const checked = products.slice(0, STALE_CHECKS);
|
|
219
|
-
let unchecked = asks.filter((row) => !checked.includes(String(row['productId']))).length;
|
|
220
|
-
const empty = new Set<string>();
|
|
221
|
-
await Promise.all(
|
|
222
|
-
checked.map(async (productId) => {
|
|
223
|
-
try {
|
|
224
|
-
// The item asks for initiatives to be reviewed before they are planned: it stands while one sits in the
|
|
225
|
-
// backlog or todo column. Read by column, not status: status stays in_progress after a first run even when
|
|
226
|
-
// the initiative is moved back to todo, and one in Build is past review.
|
|
227
|
-
const pages = await Promise.all(
|
|
228
|
-
(['backlog', 'in_progress'] as const).map((status) =>
|
|
229
|
-
client.listBacklogItems(productId, { status, limit: 100 })
|
|
230
|
-
)
|
|
231
|
-
);
|
|
232
|
-
const waiting = pages.some((page) =>
|
|
233
|
-
page.data.some((item) => item.state === 'backlog' || item.state === 'todo')
|
|
234
|
-
);
|
|
235
|
-
if (waiting) return;
|
|
236
|
-
if (pages.some((page) => page.nextCursor)) {
|
|
237
|
-
unchecked += asks.filter((row) => row['productId'] === productId).length;
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
empty.add(productId);
|
|
241
|
-
} catch (error) {
|
|
242
|
-
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
243
|
-
unchecked += asks.filter((row) => row['productId'] === productId).length;
|
|
244
|
-
}
|
|
245
|
-
})
|
|
246
|
-
);
|
|
247
|
-
for (const row of asks) {
|
|
248
|
-
if (empty.has(String(row['productId']))) {
|
|
249
|
-
out.set(String(row['id']), 'no initiative is in the backlog or todo now: the ones it asked about have moved on');
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return unchecked;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* One product's Action Required as counts, the way the list's summary counts them: open is what still stands and is
|
|
257
|
-
* not put off, stale and snoozed are counted apart. The product context reports these, so the two reads agree.
|
|
258
|
-
*/
|
|
259
|
-
export async function attentionCounts(
|
|
260
|
-
client: TaigaClient,
|
|
261
|
-
rows: Row[]
|
|
262
|
-
): Promise<{ open: number; byKind: Record<string, number>; stale?: number; snoozed?: number; staleNote?: string }> {
|
|
263
|
-
const now = Date.now();
|
|
264
|
-
const due = rows.filter((row) => isDue(row, now));
|
|
265
|
-
const { stale, unchecked } = await staleReviews(client, due);
|
|
266
|
-
const [entry] = summaryOf(due, stale);
|
|
267
|
-
const snoozed = rows.length - due.length;
|
|
268
|
-
return {
|
|
269
|
-
open: entry ? Number(entry['open']) : 0,
|
|
270
|
-
byKind: entry ? (entry['byKind'] as Record<string, number>) : {},
|
|
271
|
-
...(entry && typeof entry['stale'] === 'number' ? { stale: entry['stale'] } : {}),
|
|
272
|
-
...(snoozed > 0 ? { snoozed } : {}),
|
|
273
|
-
// Past the cap a review is counted open: say so, as the list does.
|
|
274
|
-
...(unchecked > 0
|
|
275
|
-
? {
|
|
276
|
-
staleNote: `${unchecked} review${unchecked === 1 ? ' was' : 's were'} not checked for staleness and count as open.`,
|
|
277
|
-
}
|
|
278
|
-
: {}),
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** An item the team has not put off: the page hides one until its snooze ends, and so does this. */
|
|
283
|
-
function isDue(row: Row, now: number): boolean {
|
|
284
|
-
return !(
|
|
285
|
-
row['status'] === 'snoozed' &&
|
|
286
|
-
typeof row['snoozedUntil'] === 'string' &&
|
|
287
|
-
Date.parse(row['snoozedUntil']) > now
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Pages read for the summary; past this it says it counted only those. */
|
|
292
|
-
const SUMMARY_PAGE = 100;
|
|
293
|
-
|
|
294
|
-
const SETUP_TASK_FIELDS = [
|
|
295
|
-
'id',
|
|
296
|
-
'productId',
|
|
297
|
-
'productName',
|
|
298
|
-
'category',
|
|
299
|
-
'phase',
|
|
300
|
-
'target',
|
|
301
|
-
'key',
|
|
302
|
-
'isSecret',
|
|
303
|
-
'title',
|
|
304
|
-
'providerDocUrl',
|
|
305
|
-
'providerActionUrl',
|
|
306
|
-
'inboxItemId',
|
|
307
|
-
'inboxItemStatus',
|
|
308
|
-
'createdAt',
|
|
309
|
-
'stale',
|
|
310
|
-
] as const;
|
|
311
|
-
|
|
312
|
-
/** The API's count across every page, on the first page; nothing from an API that does not send one. */
|
|
313
|
-
function totalOf(result: { totalCount?: unknown }): { totalCount?: number } {
|
|
314
|
-
return typeof result.totalCount === 'number' ? { totalCount: result.totalCount } : {};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* One row per variable: tasks filed before the API merged a key's categories
|
|
319
|
-
* (PRO-2782) still list JWT_SECRET once as a secret and once as an env_var.
|
|
320
|
-
* The oldest row stays, and names the others under alsoFiledAs, so the
|
|
321
|
-
* duplicates read as one thing to set with several records, not several things.
|
|
322
|
-
*/
|
|
323
|
-
export function sameKeyOnce(rows: Row[]): Row[] {
|
|
324
|
-
const first = new Map<string, Row>();
|
|
325
|
-
const out: Row[] = [];
|
|
326
|
-
const isOpen = (row: Row) => row['inboxItemStatus'] !== 'done' && row['inboxItemStatus'] !== 'dismissed';
|
|
327
|
-
for (const row of rows) {
|
|
328
|
-
if (typeof row['key'] !== 'string' || !row['key']) {
|
|
329
|
-
out.push(row);
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
const key = `${String(row['productId'])}:${row['key']}`;
|
|
333
|
-
const kept = first.get(key);
|
|
334
|
-
if (!kept) {
|
|
335
|
-
const copy = { ...row };
|
|
336
|
-
first.set(key, copy);
|
|
337
|
-
out.push(copy);
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
const also = (kept['alsoFiledAs'] as Row[] | undefined) ?? [];
|
|
341
|
-
const record = (from: Row) => ({
|
|
342
|
-
id: from['id'],
|
|
343
|
-
category: from['category'],
|
|
344
|
-
title: from['title'],
|
|
345
|
-
inboxItemStatus: from['inboxItemStatus'],
|
|
346
|
-
});
|
|
347
|
-
if (!isOpen(kept) && isOpen(row)) {
|
|
348
|
-
// An open record of the variable leads: a done one must not make it read as done.
|
|
349
|
-
const { alsoFiledAs: _also, ...was } = kept;
|
|
350
|
-
for (const field of Object.keys(kept)) delete kept[field];
|
|
351
|
-
Object.assign(kept, row, { alsoFiledAs: [...also, record(was)] });
|
|
352
|
-
} else {
|
|
353
|
-
kept['alsoFiledAs'] = [...also, record(row)];
|
|
354
|
-
// Stale only while every open record is (PRO-2819): one raised after the code's documents were derived
|
|
355
|
-
// is a live ask, whichever record leads.
|
|
356
|
-
if (isOpen(row) && row['stale'] === undefined) delete kept['stale'];
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
return out;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** A page of folded rows, saying how many records it folded so totalCount (records) and the rows reconcile. */
|
|
363
|
-
function folded(rows: Row[], nextCursor: string | null, records: number) {
|
|
364
|
-
return { ...page(rows, nextCursor), ...(rows.length < records ? { recordsOnPage: records } : {}) };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
368
|
-
if (!client.hasCapability('products:read')) return;
|
|
369
|
-
|
|
370
|
-
server.registerTool(
|
|
371
|
-
'taiga_list_action_required',
|
|
372
|
-
{
|
|
373
|
-
title: 'List what needs a person',
|
|
374
|
-
description:
|
|
375
|
-
'List what waits on a person (Action Required), across every product the key can open or one; the list ' +
|
|
376
|
-
"is the team's. Reviews (plans, initiative sets, documents, pull requests, findings, policy violations) " +
|
|
377
|
-
'and setup only a person can do (secrets, variables, accounts, permissions). The first page opens with ' +
|
|
378
|
-
'summary (open items by product and kind; stale ones apart) and totalCount. Rows carry waitingDays, ' +
|
|
379
|
-
'readWith (the read that opens the subject), summary (its opening paragraph, up to 200 characters; none ' +
|
|
380
|
-
'on a pull request review, whose title says it) and, for a pull request, prUrl; ' +
|
|
381
|
-
'status and blocking appear when not open or when blocking; a review that no longer stands is marked stale. category: gate or setup; resolved: true ' +
|
|
382
|
-
'reads the archive; snoozed items are hidden (snoozedHidden); full: true adds metadata. Unlike ' +
|
|
383
|
-
'taiga_list_setup_tasks, which says exactly what to set and where.',
|
|
384
|
-
inputSchema: z.object({
|
|
385
|
-
productId: productId.optional(),
|
|
386
|
-
category: z.enum(['gate', 'setup']).optional().describe('gate: things to review; setup: things to set up'),
|
|
387
|
-
resolved: z.boolean().optional().describe('Read the resolved archive (done and dismissed) instead'),
|
|
388
|
-
full: z
|
|
389
|
-
.boolean()
|
|
390
|
-
.optional()
|
|
391
|
-
.describe("Carry each item's metadata, such as a document decision's whole text (default false)"),
|
|
392
|
-
...pagination,
|
|
393
|
-
}),
|
|
394
|
-
annotations: READ_ONLY,
|
|
395
|
-
},
|
|
396
|
-
(params) =>
|
|
397
|
-
runTool(async () => {
|
|
398
|
-
// The route coerces any value to a boolean, so "false" would read the
|
|
399
|
-
// archive: send resolved only when it is asked for.
|
|
400
|
-
const { resolved, full, ...rest } = params;
|
|
401
|
-
const query = { ...rest, ...(resolved ? { resolved: true } : {}) };
|
|
402
|
-
const first = params.cursor === undefined;
|
|
403
|
-
const result = await client.listInbox({ ...query, ...(first ? { withTotal: true } : {}) });
|
|
404
|
-
// The summary counts what is open across products, on the first page:
|
|
405
|
-
// from the page itself when it holds everything, else from one read
|
|
406
|
-
// of up to SUMMARY_PAGE items. Best effort: a failed read leaves the
|
|
407
|
-
// summary out, not the page the agent asked for.
|
|
408
|
-
const overview =
|
|
409
|
-
!first || resolved
|
|
410
|
-
? null
|
|
411
|
-
: !result.nextCursor
|
|
412
|
-
? result
|
|
413
|
-
: await client.listInbox({ ...query, limit: SUMMARY_PAGE, cursor: undefined }).catch((error: unknown) => {
|
|
414
|
-
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
415
|
-
throw error;
|
|
416
|
-
});
|
|
417
|
-
// The API returns snoozed items, as they are not done; the page hides
|
|
418
|
-
// one until its snooze ends, and so does this, so an agent does not
|
|
419
|
-
// act on what the team put off.
|
|
420
|
-
const now = Date.now();
|
|
421
|
-
const due_ = (row: Row) => isDue(row, now);
|
|
422
|
-
const rows = result.data as Row[];
|
|
423
|
-
const due = rows.filter(due_);
|
|
424
|
-
const hidden = rows.length - due.length;
|
|
425
|
-
const overviewDue = overview ? (overview.data as Row[]).filter(due_) : [];
|
|
426
|
-
const checked = resolved
|
|
427
|
-
? { stale: new Map<string, string>(), unchecked: 0 }
|
|
428
|
-
: await staleReviews(client, [...due, ...overviewDue]);
|
|
429
|
-
const stale = checked.stale;
|
|
430
|
-
return toolResult({
|
|
431
|
-
...(overview
|
|
432
|
-
? {
|
|
433
|
-
summary: summaryOf(overviewDue, stale),
|
|
434
|
-
...(overview.nextCursor ? { summaryNote: `The summary counts the first ${SUMMARY_PAGE} items.` } : {}),
|
|
435
|
-
}
|
|
436
|
-
: {}),
|
|
437
|
-
...page(
|
|
438
|
-
due.map((row) => {
|
|
439
|
-
const item = itemRow(row, now, full === true, client.hasCapability('backlog:read'));
|
|
440
|
-
const why = stale.get(String(row['id']));
|
|
441
|
-
return why ? { ...item, stale: why } : item;
|
|
442
|
-
}),
|
|
443
|
-
result.nextCursor
|
|
444
|
-
),
|
|
445
|
-
...totalOf(result),
|
|
446
|
-
// totalCount is the API's, every item; the summary's open leaves stale and put-off ones out. A reviewer
|
|
447
|
-
// summed one and compared it with the other (68 against 63).
|
|
448
|
-
...(overview &&
|
|
449
|
-
typeof result.totalCount === 'number' &&
|
|
450
|
-
(stale.size > 0 || overview.data.length > overviewDue.length || overview.nextCursor)
|
|
451
|
-
? {
|
|
452
|
-
totalNote:
|
|
453
|
-
'totalCount counts every item, stale and snoozed ones too; summary.open does not' +
|
|
454
|
-
(overview.nextCursor ? `, and counts only the first ${SUMMARY_PAGE}.` : '.'),
|
|
455
|
-
}
|
|
456
|
-
: {}),
|
|
457
|
-
...(hidden > 0 ? { snoozedHidden: hidden } : {}),
|
|
458
|
-
// Past the cap a review is not checked, so no stale on it is not "live".
|
|
459
|
-
...(checked.unchecked > 0
|
|
460
|
-
? {
|
|
461
|
-
staleNote: `${checked.unchecked} review${checked.unchecked === 1 ? ' was' : 's were'} not checked for staleness (past the cap, or the read was refused).`,
|
|
462
|
-
}
|
|
463
|
-
: {}),
|
|
464
|
-
});
|
|
465
|
-
})
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
server.registerTool(
|
|
469
|
-
'taiga_list_setup_tasks',
|
|
470
|
-
{
|
|
471
|
-
title: 'List setup tasks',
|
|
472
|
-
description:
|
|
473
|
-
'List what a person must set up for a product (secrets, variables, accounts), with exactly what and ' +
|
|
474
|
-
'where: key (the name to create; Taiga never stores values), the provider page, and phase: prereq (the ' +
|
|
475
|
-
'deploy fails without it), wiring (generated values back to providers) or followup (can wait until after ' +
|
|
476
|
-
'launch). Runs do not wait for them. One product or all, oldest first; includeDone: true adds done and ' +
|
|
477
|
-
'dismissed. A variable filed twice lists the others under alsoFiledAs; totalCount counts records, and a ' +
|
|
478
|
-
'page that folded some says recordsOnPage. stale on an open task says no document derived from the code ' +
|
|
479
|
-
'since it was raised mentions its key: it may not apply any more, so check before asking anyone to set ' +
|
|
480
|
-
'it. Unlike ' +
|
|
481
|
-
'taiga_list_action_required, which lists the same work as items to resolve.',
|
|
482
|
-
inputSchema: z.object({
|
|
483
|
-
productId: productId.optional(),
|
|
484
|
-
includeDone: z.boolean().optional().describe('Also the tasks already done or dismissed (default false)'),
|
|
485
|
-
...pagination,
|
|
486
|
-
}),
|
|
487
|
-
annotations: READ_ONLY,
|
|
488
|
-
},
|
|
489
|
-
(params) =>
|
|
490
|
-
runTool(async () => {
|
|
491
|
-
const { includeDone, ...rest } = params;
|
|
492
|
-
const result = await client.listSetupTasks({
|
|
493
|
-
...rest,
|
|
494
|
-
...(includeDone ? {} : { openOnly: true }),
|
|
495
|
-
...(params.cursor === undefined ? { withTotal: true } : {}),
|
|
496
|
-
});
|
|
497
|
-
return toolResult({
|
|
498
|
-
...folded(
|
|
499
|
-
sameKeyOnce(pickFields(result.data as Row[], SETUP_TASK_FIELDS)),
|
|
500
|
-
result.nextCursor,
|
|
501
|
-
result.data.length
|
|
502
|
-
),
|
|
503
|
-
...totalOf(result),
|
|
504
|
-
});
|
|
505
|
-
})
|
|
506
|
-
);
|
|
507
|
-
|
|
508
|
-
if (!client.hasCapability('products:write')) return;
|
|
509
|
-
|
|
510
|
-
server.registerTool(
|
|
511
|
-
'taiga_resolve_action',
|
|
512
|
-
{
|
|
513
|
-
title: 'Resolve or snooze an Action Required item',
|
|
514
|
-
description:
|
|
515
|
-
'Set an Action Required item for everybody: done, dismissed (does not apply), in_progress, or snooze it ' +
|
|
516
|
-
'until a time (null wakes it). Resolving a review approves or merges nothing; answer the plan or pull ' +
|
|
517
|
-
'request where it lives. Marking the product documents review done records, in your name, that every ' +
|
|
518
|
-
'published document was reviewed (in_progress withdraws it): only when the person says they read them. ' +
|
|
519
|
-
'Ask before clearing what the person has not dealt with.',
|
|
520
|
-
inputSchema: z.object({
|
|
521
|
-
actionId: idInput('Action Required item', 'taiga_list_action_required gives it'),
|
|
522
|
-
status: z.enum(['in_progress', 'done', 'dismissed']).optional().describe('What became of it'),
|
|
523
|
-
snoozedUntil: z
|
|
524
|
-
.string()
|
|
525
|
-
.datetime()
|
|
526
|
-
// format: date-time says it; the leap-year pattern beside it ran to hundreds of characters (PRO-2800).
|
|
527
|
-
.meta({ pattern: undefined })
|
|
528
|
-
.nullable()
|
|
529
|
-
.optional()
|
|
530
|
-
.describe('Hide it until this time (ISO 8601 in UTC, ending in Z); null wakes it now'),
|
|
531
|
-
}),
|
|
532
|
-
annotations: OPERATE,
|
|
533
|
-
},
|
|
534
|
-
(params) =>
|
|
535
|
-
runTool(async () => {
|
|
536
|
-
if (params.status === undefined && params.snoozedUntil === undefined) {
|
|
537
|
-
return toolError('VALIDATION_ERROR', 'Say what became of the item (status) or until when to snooze it.');
|
|
538
|
-
}
|
|
539
|
-
const result = await client.updateInboxItem(params.actionId, {
|
|
540
|
-
...(params.status !== undefined ? { status: params.status } : {}),
|
|
541
|
-
...(params.snoozedUntil !== undefined ? { snoozedUntil: params.snoozedUntil } : {}),
|
|
542
|
-
});
|
|
543
|
-
return toolResult(pickFields([result.data], ITEM_FIELDS)[0]);
|
|
544
|
-
})
|
|
545
|
-
);
|
|
546
|
-
}
|