@taiga-ai/mcp-server 0.19.0-rc.452 → 0.20.0-dev.454
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +479 -240
- package/package.json +1 -1
- package/src/server.integration.test.ts +345 -33
- package/src/tools/autonomy.ts +1 -1
- package/src/tools/backlogs.ts +14 -5
- package/src/tools/builds.ts +98 -11
- package/src/tools/catalogue.test.ts +1 -1
- package/src/tools/context.ts +45 -28
- package/src/tools/errors.ts +14 -2
- package/src/tools/inbox.ts +92 -11
- package/src/tools/index.ts +54 -4
- package/src/tools/instructions.ts +14 -9
- package/src/tools/knowledge.ts +1 -1
- package/src/tools/maintenance.ts +34 -9
- package/src/tools/observe.ts +3 -3
- package/src/tools/product-documents.ts +1 -1
- package/src/tools/products.ts +47 -5
- package/src/tools/queue.ts +7 -1
- package/src/tools/readiness.ts +11 -6
- package/src/tools/repositories.ts +2 -2
- package/src/tools/sections.ts +32 -2
- package/src/tools/setup.ts +2 -2
- package/src/tools/wait.ts +6 -4
- package/taiga-ai-mcp-server-0.20.0-dev.454.tgz +0 -0
- package/taiga-ai-mcp-server-0.19.0-rc.452.tgz +0 -0
package/src/tools/builds.ts
CHANGED
|
@@ -35,6 +35,8 @@ export const BUILD_SUMMARY_FIELDS = [
|
|
|
35
35
|
'prState',
|
|
36
36
|
'prDraft',
|
|
37
37
|
'prMerged',
|
|
38
|
+
'reviewEscalation',
|
|
39
|
+
'error',
|
|
38
40
|
'actions',
|
|
39
41
|
'createdAt',
|
|
40
42
|
'startedAt',
|
|
@@ -52,7 +54,10 @@ const RUN_OUTCOME_FIELDS = [
|
|
|
52
54
|
'totalSteps',
|
|
53
55
|
'prNumber',
|
|
54
56
|
'prUrl',
|
|
57
|
+
'prDraft',
|
|
55
58
|
'prMerged',
|
|
59
|
+
'reviewEscalation',
|
|
60
|
+
'error',
|
|
56
61
|
'actions',
|
|
57
62
|
] as const;
|
|
58
63
|
|
|
@@ -111,6 +116,75 @@ const RUNNING: Partial<Record<BuildRunStatus, string>> = {
|
|
|
111
116
|
reviewing: 'answering review comments on the pull request',
|
|
112
117
|
};
|
|
113
118
|
|
|
119
|
+
/** Why the agent handed a run back, in the web's words (apps/web handback), with the checks it names. */
|
|
120
|
+
export function handbackSaid(escalation: unknown): string | null {
|
|
121
|
+
if (!escalation || typeof escalation !== 'object') return null;
|
|
122
|
+
const e = escalation as {
|
|
123
|
+
reason?: unknown;
|
|
124
|
+
rounds?: unknown;
|
|
125
|
+
failingChecks?: unknown;
|
|
126
|
+
file?: unknown;
|
|
127
|
+
blockers?: unknown;
|
|
128
|
+
};
|
|
129
|
+
const checks = Array.isArray(e.failingChecks)
|
|
130
|
+
? (e.failingChecks as { name?: unknown }[]).map((check) => String(check.name)).join(', ')
|
|
131
|
+
: '';
|
|
132
|
+
const on = checks ? ` on ${checks}` : '';
|
|
133
|
+
const rounds =
|
|
134
|
+
typeof e.rounds === 'number' && e.rounds > 0
|
|
135
|
+
? ` after ${e.rounds} automatic round${e.rounds === 1 ? '' : 's'}`
|
|
136
|
+
: '';
|
|
137
|
+
const blockers = Array.isArray(e.blockers)
|
|
138
|
+
? (e.blockers as unknown[]).map((b) => READY_BLOCKER_SAID[String(b)] ?? String(b))
|
|
139
|
+
: [];
|
|
140
|
+
switch (e.reason) {
|
|
141
|
+
case 'ci_fix_cap':
|
|
142
|
+
return `CI is still failing${on}${rounds}`;
|
|
143
|
+
case 'ci_fix_stalled':
|
|
144
|
+
return `CI is still failing${on}, and the last fix round changed nothing`;
|
|
145
|
+
case 'conflict_cap':
|
|
146
|
+
return `the branch still conflicts with its base${rounds}`;
|
|
147
|
+
case 'review_bot_cap':
|
|
148
|
+
return `review bots still have open comments${rounds}`;
|
|
149
|
+
case 'ci_never_settled':
|
|
150
|
+
return 'CI did not finish in time';
|
|
151
|
+
case 'ready_blocked':
|
|
152
|
+
return `CI is green, but Taiga kept the pull request a draft${blockers.length ? `: ${blockers.join('; ')}` : ''}`;
|
|
153
|
+
case 'check_weakened':
|
|
154
|
+
return `CI went green only after a check was loosened${typeof e.file === 'string' ? ` in ${e.file}` : ''}`;
|
|
155
|
+
default:
|
|
156
|
+
return typeof e.reason === 'string' ? e.reason : null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** What a person does about a handback, by its reason, as the web's handback detail says it. */
|
|
161
|
+
export function handbackNextStep(escalation: unknown): string {
|
|
162
|
+
const reason = escalation && typeof escalation === 'object' ? (escalation as { reason?: unknown }).reason : null;
|
|
163
|
+
if (reason === 'check_weakened') return 'review the loosened check before merging, or review it back to the agent';
|
|
164
|
+
if (reason === 'ci_never_settled')
|
|
165
|
+
return 'look at what is still running or queued in CI, then review it back to the agent';
|
|
166
|
+
return 'fix it by hand or review it back to the agent';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The web's words for why Taiga keeps a pull request a draft. */
|
|
170
|
+
const READY_BLOCKER_SAID: Record<string, string> = {
|
|
171
|
+
plan_incomplete: 'not every planned step shipped',
|
|
172
|
+
tests_failed: 'the tests Taiga ran after the build failed',
|
|
173
|
+
bar_lowering: 'a fix lowered a check instead of fixing the code',
|
|
174
|
+
bar_lowering_unchecked: 'Taiga could not check whether a fix lowered a check',
|
|
175
|
+
criticals_unresolved: "Taiga's own review left a critical issue unresolved",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** The first sentence of a run's error, short enough for a row. */
|
|
179
|
+
function errorSaid(error: unknown): string | null {
|
|
180
|
+
if (typeof error !== 'string' || !error.trim()) return null;
|
|
181
|
+
const first = error
|
|
182
|
+
.trim()
|
|
183
|
+
.split(/\n|(?<=[.!?])\s/)[0]!
|
|
184
|
+
.replace(/[.!?]$/, '');
|
|
185
|
+
return first.length > 240 ? `${first.slice(0, 240).trimEnd()}...` : first;
|
|
186
|
+
}
|
|
187
|
+
|
|
114
188
|
/**
|
|
115
189
|
* How a run stands, in one sentence, with its disposition: the meaning the
|
|
116
190
|
* status and the pull request facts carry between them
|
|
@@ -127,18 +201,24 @@ export function runOutcome(run: Record<string, unknown>): { disposition: string;
|
|
|
127
201
|
typeof run['completedSteps'] === 'number' && typeof run['totalSteps'] === 'number'
|
|
128
202
|
? ` (${run['completedSteps']} of ${run['totalSteps']} steps done)`
|
|
129
203
|
: '';
|
|
130
|
-
|
|
204
|
+
// The error says what happened; the failedReason is only its code (PRO-2800).
|
|
205
|
+
const why = errorSaid(run['error']) ?? (typeof run['failedReason'] === 'string' ? run['failedReason'] : null);
|
|
206
|
+
const handback = handbackSaid(run['reviewEscalation']);
|
|
207
|
+
const draft = run['prDraft'] === true ? `, still a draft` : '';
|
|
131
208
|
const sentence: Record<string, string> = {
|
|
132
209
|
in_flight: status === 'pending' ? 'Queued: waiting to start.' : `Running: ${RUNNING[status] ?? status}${progress}.`,
|
|
133
|
-
|
|
210
|
+
// A handback is a person's turn too, but not a plain review: say what the agent could not get past.
|
|
211
|
+
gated: handback
|
|
212
|
+
? `Handed back to a person: ${handback}. ${pr}${draft ? `${draft},` : ''} waits for them to ${handbackNextStep(run['reviewEscalation'])}.`
|
|
213
|
+
: `Waiting for a person to review and merge ${pr}${draft}.`,
|
|
134
214
|
succeeded: `Merged: ${pr} is in ${typeof run['baseBranch'] === 'string' ? run['baseBranch'] : 'the base branch'}.`,
|
|
135
215
|
unmerged:
|
|
136
216
|
typeof run['prNumber'] === 'number' || run['prUrl']
|
|
137
217
|
? `Settled without merging: ${pr} was left unmerged.`
|
|
138
218
|
: 'Settled without a merged pull request.',
|
|
139
|
-
failed: `Failed${
|
|
219
|
+
failed: `Failed${why ? `: ${why}` : ''}${progress}.`,
|
|
140
220
|
stopped: `Stopped before it finished${progress}.`,
|
|
141
|
-
rejected: `${pr} was closed without merging, so the work did not land
|
|
221
|
+
rejected: `${pr} was closed without merging, so the work did not land.${handback ? ` It had been handed back: ${handback}.` : ''}`,
|
|
142
222
|
};
|
|
143
223
|
return { disposition, outcome: sentence[disposition] ?? status };
|
|
144
224
|
}
|
|
@@ -146,19 +226,26 @@ export function runOutcome(run: Record<string, unknown>): { disposition: string;
|
|
|
146
226
|
/**
|
|
147
227
|
* A run row as an agent reads it: its outcome added, and prDraft only where
|
|
148
228
|
* there is a pull request. The column defaults to true, so a run that never
|
|
149
|
-
* opened one said it had a draft (PRO-2800).
|
|
229
|
+
* opened one said it had a draft (PRO-2800). A list row leaves the full error
|
|
230
|
+
* text out: its first sentence is in the outcome, and taiga_get_run has it all.
|
|
150
231
|
*/
|
|
151
|
-
export function readableRun(run: Record<string, unknown
|
|
152
|
-
const { prDraft, ...rest } = run;
|
|
232
|
+
export function readableRun(run: Record<string, unknown>, opts: { row?: boolean } = {}): Record<string, unknown> {
|
|
233
|
+
const { prDraft, error, reviewEscalation, ...rest } = run;
|
|
153
234
|
const said = runOutcome(run);
|
|
154
235
|
return {
|
|
155
236
|
...rest,
|
|
237
|
+
// A row's outcome says the error and the handback; the full read keeps them whole.
|
|
238
|
+
...(opts.row || error === undefined ? {} : { error }),
|
|
239
|
+
...(opts.row || reviewEscalation === undefined ? {} : { reviewEscalation }),
|
|
156
240
|
// A PR number proves a PR exists (isSettledWithoutPr); the URL is nullable apart from it.
|
|
157
241
|
...(typeof rest['prNumber'] === 'number' || rest['prUrl'] ? { prDraft } : {}),
|
|
158
242
|
...(said ?? {}),
|
|
159
243
|
};
|
|
160
244
|
}
|
|
161
245
|
|
|
246
|
+
/** readableRun for a list row. */
|
|
247
|
+
export const readableRow = (run: Record<string, unknown>) => readableRun(run, { row: true });
|
|
248
|
+
|
|
162
249
|
/** How many products the org-wide list names; past this a row keeps its productId only. */
|
|
163
250
|
const NAMED_PRODUCTS = 100;
|
|
164
251
|
|
|
@@ -218,7 +305,7 @@ async function activityRows(
|
|
|
218
305
|
nameById.set(product.id, { name: product.name, slug: product.slug });
|
|
219
306
|
}
|
|
220
307
|
const rows = pickFields(runs.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS)
|
|
221
|
-
.map(
|
|
308
|
+
.map(readableRow)
|
|
222
309
|
.map((row) => {
|
|
223
310
|
const product = nameById.get(String(row['productId']));
|
|
224
311
|
return product ? { ...row, product } : row;
|
|
@@ -293,7 +380,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
293
380
|
});
|
|
294
381
|
return toolResult(
|
|
295
382
|
page(
|
|
296
|
-
pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map(
|
|
383
|
+
pickFields(result.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS).map(readableRow),
|
|
297
384
|
result.nextCursor
|
|
298
385
|
)
|
|
299
386
|
);
|
|
@@ -307,7 +394,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
307
394
|
{
|
|
308
395
|
title: 'Get run',
|
|
309
396
|
description:
|
|
310
|
-
'
|
|
397
|
+
'Read one run with its steps: how it stands (outcome, disposition), its pull request and hardening, and ' +
|
|
311
398
|
"each step's title, status, commit, files changed, verification and headline, the first paragraph of " +
|
|
312
399
|
"what the agent reported. A step's verification is Taiga's own check after the step (tests, lint, build), " +
|
|
313
400
|
'independent of what the agent says it ran: a docs-only step can show tests failed because the ' +
|
|
@@ -367,7 +454,7 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
367
454
|
(params) =>
|
|
368
455
|
runTool(async () => {
|
|
369
456
|
const result = await client.stopBuildRun(params.runId);
|
|
370
|
-
return toolResult(
|
|
457
|
+
return toolResult(readableRow(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]!));
|
|
371
458
|
})
|
|
372
459
|
);
|
|
373
460
|
}
|
|
@@ -167,6 +167,6 @@ describe('writeNotice', () => {
|
|
|
167
167
|
it('says nothing for a read, and what a write does otherwise', () => {
|
|
168
168
|
expect(writeNotice('r', { readOnlyHint: true })).toBe('');
|
|
169
169
|
expect(writeNotice('w', { readOnlyHint: false })).toBe('Writes. ');
|
|
170
|
-
expect(writeNotice('o', { readOnlyHint: false, destructiveHint: true })).toMatch(/^
|
|
170
|
+
expect(writeNotice('o', { readOnlyHint: false, destructiveHint: true })).toMatch(/^Ask first: paid or shared work/);
|
|
171
171
|
});
|
|
172
172
|
});
|
package/src/tools/context.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type StoredDocument,
|
|
29
29
|
} from './sections.js';
|
|
30
30
|
import { pickFields } from './summarize.js';
|
|
31
|
-
import {
|
|
31
|
+
import { readableRow } from './builds.js';
|
|
32
32
|
import { setupReadiness } from './readiness.js';
|
|
33
33
|
import { standingSummary } from './maintenance.js';
|
|
34
34
|
|
|
@@ -72,6 +72,9 @@ const CONTEXT_BUILD_FIELDS = [
|
|
|
72
72
|
'prUrl',
|
|
73
73
|
'prState',
|
|
74
74
|
'prMerged',
|
|
75
|
+
'prDraft',
|
|
76
|
+
'reviewEscalation',
|
|
77
|
+
'error',
|
|
75
78
|
'createdAt',
|
|
76
79
|
] as const;
|
|
77
80
|
|
|
@@ -127,12 +130,12 @@ const PARTS = {
|
|
|
127
130
|
factory: 'factories:read',
|
|
128
131
|
documents: 'products:read',
|
|
129
132
|
policies: 'tenant:documents:read',
|
|
130
|
-
|
|
133
|
+
initiatives: 'backlog:read',
|
|
131
134
|
repositories: 'products:read',
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
runs: 'products:read',
|
|
136
|
+
actionRequired: 'products:read',
|
|
134
137
|
maintenance: 'products:read',
|
|
135
|
-
|
|
138
|
+
policyStanding: 'products:read',
|
|
136
139
|
} as const;
|
|
137
140
|
type Part = keyof typeof PARTS;
|
|
138
141
|
|
|
@@ -316,8 +319,8 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
316
319
|
* The linked repositories. The route serves one page of 100 and takes no
|
|
317
320
|
* cursor, so past that the bundle says the list is cut rather than complete.
|
|
318
321
|
*/
|
|
319
|
-
async function repositoriesPart(
|
|
320
|
-
const result = await
|
|
322
|
+
async function repositoriesPart(links: () => ReturnType<TaigaClient['listProductRepositories']>) {
|
|
323
|
+
const result = await links();
|
|
321
324
|
return {
|
|
322
325
|
items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
|
|
323
326
|
truncated: result.nextCursor !== null,
|
|
@@ -326,7 +329,7 @@ async function repositoriesPart(client: TaigaClient, productId: string) {
|
|
|
326
329
|
|
|
327
330
|
async function buildsPart(client: TaigaClient, productId: string) {
|
|
328
331
|
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data as Record<string, unknown>[];
|
|
329
|
-
return pickFields(rows, CONTEXT_BUILD_FIELDS).map(
|
|
332
|
+
return pickFields(rows, CONTEXT_BUILD_FIELDS).map(readableRow);
|
|
330
333
|
}
|
|
331
334
|
|
|
332
335
|
/**
|
|
@@ -370,8 +373,20 @@ async function maintenancePart(client: TaigaClient, productId: string) {
|
|
|
370
373
|
|
|
371
374
|
/** Where the product stands against the policies, as counts; taiga_get_policy_standing has each violation. */
|
|
372
375
|
async function standingPart(client: TaigaClient, productId: string) {
|
|
373
|
-
const
|
|
374
|
-
|
|
376
|
+
const standing = (await client.getPolicyStanding(productId)).data;
|
|
377
|
+
const summary = standingSummary(standing);
|
|
378
|
+
// No violations from an assessment that never ran, or failed, is no all-clear (PRO-2800).
|
|
379
|
+
const state = (standing.assessment as { state?: unknown } | null | undefined)?.state;
|
|
380
|
+
return {
|
|
381
|
+
openViolations: summary.violations,
|
|
382
|
+
bySeverity: summary.bySeverity,
|
|
383
|
+
controlsViolated: summary.controls,
|
|
384
|
+
...(state === 'never' || state === 'failed'
|
|
385
|
+
? {
|
|
386
|
+
note: `The assessment ${state === 'never' ? 'has never run' : 'failed'}: no violations here is not an all-clear.`,
|
|
387
|
+
}
|
|
388
|
+
: {}),
|
|
389
|
+
};
|
|
375
390
|
}
|
|
376
391
|
|
|
377
392
|
export async function productContext(
|
|
@@ -383,21 +398,24 @@ export async function productContext(
|
|
|
383
398
|
const granted = (part: Part) => client.hasCapability(PARTS[part]);
|
|
384
399
|
const notGranted = (Object.keys(PARTS) as Part[]).filter((part) => !granted(part));
|
|
385
400
|
|
|
401
|
+
// One read of the links serves the repositories part and the setup readiness.
|
|
402
|
+
let linksRead: ReturnType<TaigaClient['listProductRepositories']> | undefined;
|
|
403
|
+
const links = () => (linksRead ??= client.listProductRepositories(productId));
|
|
386
404
|
const fetchers: Record<Part, () => Promise<unknown>> = {
|
|
387
405
|
factory: () => factoryPart(client, String(product.factoryId)),
|
|
388
406
|
documents: () => documentsPart(client, product, options.documents),
|
|
389
407
|
policies: () => policiesPart(client),
|
|
390
|
-
|
|
391
|
-
repositories: () => repositoriesPart(
|
|
392
|
-
|
|
393
|
-
|
|
408
|
+
initiatives: () => backlogPart(client, productId),
|
|
409
|
+
repositories: () => repositoriesPart(links),
|
|
410
|
+
runs: () => buildsPart(client, productId),
|
|
411
|
+
actionRequired: () => attentionPart(client, productId),
|
|
394
412
|
maintenance: () => maintenancePart(client, productId),
|
|
395
|
-
|
|
413
|
+
policyStanding: () => standingPart(client, productId),
|
|
396
414
|
};
|
|
397
415
|
const parts = (Object.keys(fetchers) as Part[]).filter(granted);
|
|
398
416
|
const [settled, readiness] = await Promise.all([
|
|
399
417
|
Promise.allSettled(parts.map((part) => fetchers[part]())),
|
|
400
|
-
setupReadiness(client, product),
|
|
418
|
+
setupReadiness(client, product, links),
|
|
401
419
|
]);
|
|
402
420
|
|
|
403
421
|
const bundle: Record<string, unknown> = {
|
|
@@ -445,18 +463,17 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
|
445
463
|
{
|
|
446
464
|
title: 'Get product context',
|
|
447
465
|
description:
|
|
448
|
-
'Where a product stands
|
|
449
|
-
'
|
|
450
|
-
'
|
|
451
|
-
'
|
|
452
|
-
'open
|
|
453
|
-
'
|
|
454
|
-
'
|
|
455
|
-
'
|
|
456
|
-
'
|
|
457
|
-
`
|
|
458
|
-
'
|
|
459
|
-
'product record and its settings alone. Call this first, then read what you need.',
|
|
466
|
+
'Where a product stands, in one call: the first read for any question about a product. Name it by ID, ' +
|
|
467
|
+
'slug or the git remote URL of a checkout. The parts: product (and, while in setup, readiness: what it ' +
|
|
468
|
+
'still needs and the next move); factory, with its instructions in full; documents, the specification and ' +
|
|
469
|
+
'architecture as outlines (section ids and sizes); policies, each published policy with its section ids; ' +
|
|
470
|
+
'initiatives, the open ones (taiga_list_initiatives has the done and canceled); repositories; runs, the ' +
|
|
471
|
+
'latest ten, each with its outcome in a sentence; actionRequired, what waits on a person, counted by kind; ' +
|
|
472
|
+
'maintenance, open findings by severity and the last sweep; policyStanding, open policy violations by ' +
|
|
473
|
+
'severity. A part the API refused or failed is named under unavailable, a part the key cannot read under ' +
|
|
474
|
+
'notGranted; neither is shown as empty. Before writing code, documents: budget brings the whole sections ' +
|
|
475
|
+
`that fit, about ${DOCUMENT_BUDGET / 1000}k characters each, and full brings them whole. taiga_get_product ` +
|
|
476
|
+
'is the product record and its settings alone.',
|
|
460
477
|
inputSchema: z.object({
|
|
461
478
|
productId: productId.optional(),
|
|
462
479
|
slug: slug.optional(),
|
package/src/tools/errors.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { withNotFoundHint } from './hints.js';
|
|
|
8
8
|
* as taiga_get_initiative and taiga_get_run ask for them, rather than the
|
|
9
9
|
* storage names backlogItemId and buildRunId.
|
|
10
10
|
*/
|
|
11
|
-
const PRODUCT_WORDS: Record<string, string> = {
|
|
11
|
+
const PRODUCT_WORDS: Record<string, string> = {
|
|
12
|
+
backlogItemId: 'initiativeId',
|
|
13
|
+
buildRunId: 'runId',
|
|
14
|
+
latestBuild: 'latestRun',
|
|
15
|
+
};
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* Maps whose keys are content, not API fields: a document's sections are keyed
|
|
@@ -34,6 +38,14 @@ export function inProductWords(value: unknown): unknown {
|
|
|
34
38
|
return out;
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
/** The API's 404 sentence in the product's words: "Backlog item not found" is an initiative not found. */
|
|
42
|
+
export function inProductWording(said: string): string {
|
|
43
|
+
return said
|
|
44
|
+
.replace(/^Backlog item not found/i, 'Initiative not found')
|
|
45
|
+
.replace(/^Build run step not found/i, 'Run step not found')
|
|
46
|
+
.replace(/^Build run not found/i, 'Run not found');
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
/** A successful tool result: one JSON text block the model reads. */
|
|
38
50
|
export function toolResult(value: unknown): CallToolResult {
|
|
39
51
|
return { content: [{ type: 'text', text: JSON.stringify(inProductWords(value), null, 2) }] };
|
|
@@ -116,7 +128,7 @@ export async function runTool(handler: () => Promise<CallToolResult>): Promise<C
|
|
|
116
128
|
const code = error.code ?? CODE_BY_STATUS[error.status] ?? 'API_ERROR';
|
|
117
129
|
const said = error.apiMessage ?? `Taiga API answered ${error.status}.`;
|
|
118
130
|
// A miss says where the right IDs are (hints.ts): the API's own word is bare.
|
|
119
|
-
const message = error.status === 404 ? withNotFoundHint(said) : said;
|
|
131
|
+
const message = error.status === 404 ? withNotFoundHint(inProductWording(said)) : said;
|
|
120
132
|
return toolError(code, message, error.details);
|
|
121
133
|
}
|
|
122
134
|
if (isNetworkFailure(error)) {
|
package/src/tools/inbox.ts
CHANGED
|
@@ -64,21 +64,75 @@ function itemRow(row: Row, now: number, full: boolean): Row {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** What is open, counted by product and kind: the answer to "what needs attention" without reading every row. */
|
|
67
|
-
function summaryOf(rows: Row[]): Row[] {
|
|
67
|
+
function summaryOf(rows: Row[], stale: Map<string, string>): Row[] {
|
|
68
68
|
const byProduct = new Map<
|
|
69
69
|
string,
|
|
70
|
-
{ productId: string; product
|
|
70
|
+
{ productId: string; product?: unknown; open: number; byKind: Record<string, number>; stale?: number }
|
|
71
71
|
>();
|
|
72
72
|
for (const row of rows) {
|
|
73
73
|
const key = String(row['productId']);
|
|
74
|
-
|
|
74
|
+
// A product-scoped read carries no names; the id is the product's, not a name.
|
|
75
|
+
const entry =
|
|
76
|
+
byProduct.get(key) ??
|
|
77
|
+
(row['productName']
|
|
78
|
+
? { productId: key, product: row['productName'], open: 0, byKind: {} }
|
|
79
|
+
: { productId: key, open: 0, byKind: {} });
|
|
75
80
|
entry.open += 1;
|
|
76
81
|
entry.byKind[String(row['kind'])] = (entry.byKind[String(row['kind'])] ?? 0) + 1;
|
|
82
|
+
if (stale.has(String(row['id']))) entry.stale = (entry.stale ?? 0) + 1;
|
|
77
83
|
byProduct.set(key, entry);
|
|
78
84
|
}
|
|
79
85
|
return [...byProduct.values()].sort((a, b) => b.open - a.open);
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
/** Pull request reviews checked against their run and initiative; past this a row is left as it is. */
|
|
89
|
+
const STALE_CHECKS = 10;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Which "Review & merge" items no longer stand, and why (PRO-2800): the run's
|
|
93
|
+
* pull request closed or merged, or its initiative was canceled or finished
|
|
94
|
+
* without it. Two reviewing agents counted such leftovers as live gates; the
|
|
95
|
+
* row still is open in Action Required, so this says what it is now rather
|
|
96
|
+
* than hiding it. Best effort, a handful of runs: a failed read leaves the
|
|
97
|
+
* row as it was.
|
|
98
|
+
*/
|
|
99
|
+
async function staleReviews(
|
|
100
|
+
client: TaigaClient,
|
|
101
|
+
rows: Row[]
|
|
102
|
+
): Promise<{ stale: Map<string, string>; unchecked: number }> {
|
|
103
|
+
const byId = new Map(
|
|
104
|
+
rows
|
|
105
|
+
.filter((row) => row['kind'] === 'review_pr' && row['subjectType'] === 'build_run')
|
|
106
|
+
.map((row) => [String(row['id']), row])
|
|
107
|
+
);
|
|
108
|
+
const candidates = [...byId.values()].slice(0, STALE_CHECKS);
|
|
109
|
+
// Past the cap, and any check the API refused or failed (a key without
|
|
110
|
+
// backlog:read cannot read the initiative): unknown, not live.
|
|
111
|
+
let unchecked = byId.size - candidates.length;
|
|
112
|
+
const out = new Map<string, string>();
|
|
113
|
+
await Promise.all(
|
|
114
|
+
candidates.map(async (row) => {
|
|
115
|
+
try {
|
|
116
|
+
const run = (await client.getBuildRun(String(row['subjectId']))).data as unknown as Row;
|
|
117
|
+
if (run['prMerged'] === true) return void out.set(String(row['id']), 'its pull request has merged');
|
|
118
|
+
if (run['prState'] === 'closed') return void out.set(String(row['id']), 'its pull request was closed');
|
|
119
|
+
if (typeof run['backlogItemId'] !== 'string') return;
|
|
120
|
+
if (!client.hasCapability('backlog:read')) return void (unchecked += 1);
|
|
121
|
+
const item = (await client.getBacklogItem(run['backlogItemId'])).data as unknown as Row;
|
|
122
|
+
if (item['state'] === 'canceled' || item['status'] === 'canceled') {
|
|
123
|
+
out.set(String(row['id']), 'its initiative was canceled; the pull request is still open on the forge');
|
|
124
|
+
} else if (item['state'] === 'done' || item['status'] === 'done') {
|
|
125
|
+
out.set(String(row['id']), 'its initiative is done; this run is not the one that landed it');
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
129
|
+
unchecked += 1;
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
return { stale: out, unchecked };
|
|
134
|
+
}
|
|
135
|
+
|
|
82
136
|
/** Pages read for the summary; past this it says it counted only those. */
|
|
83
137
|
const SUMMARY_PAGE = 100;
|
|
84
138
|
|
|
@@ -146,6 +200,11 @@ export function sameKeyOnce(rows: Row[]): Row[] {
|
|
|
146
200
|
return out;
|
|
147
201
|
}
|
|
148
202
|
|
|
203
|
+
/** A page of folded rows, saying how many records it folded so totalCount (records) and the rows reconcile. */
|
|
204
|
+
function folded(rows: Row[], nextCursor: string | null, records: number) {
|
|
205
|
+
return { ...page(rows, nextCursor), ...(rows.length < records ? { recordsOnPage: records } : {}) };
|
|
206
|
+
}
|
|
207
|
+
|
|
149
208
|
export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
150
209
|
if (!client.hasCapability('products:read')) return;
|
|
151
210
|
|
|
@@ -154,13 +213,16 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
154
213
|
{
|
|
155
214
|
title: 'List what needs a person',
|
|
156
215
|
description:
|
|
157
|
-
|
|
216
|
+
"What waits on a person: reviews and setup items, with a summary by product. The inbox is the team's, not one " +
|
|
217
|
+
"person's. It holds things to review (a plan, " +
|
|
158
218
|
'a set of initiatives, a document decision, a pull request, maintenance findings, new policy violations) ' +
|
|
159
219
|
'and things to set up that only a person can (a secret, an environment variable, an account, a package ' +
|
|
160
220
|
'registry, a cloud permission). Across every product the key can open, or one product. The first page ' +
|
|
161
221
|
'opens with summary: what is open, counted by product and kind, so "what needs attention" is one call, ' +
|
|
162
222
|
'and totalCount. Each row says how many days it has waited (waitingDays) and, for a pull request review, ' +
|
|
163
|
-
'the prUrl
|
|
223
|
+
'the prUrl. A pull request review that no longer stands (its pull request merged or closed, or its ' +
|
|
224
|
+
'initiative canceled or done) says so under stale, and the summary counts those per product. ' +
|
|
225
|
+
'category gate is the reviews, setup the setup items; ' +
|
|
164
226
|
'resolved: true reads the archive. An item snoozed until later is left out until then (snoozedHidden ' +
|
|
165
227
|
"counts them). full: true adds each item's metadata. Unlike taiga_list_setup_tasks, which lists the setup " +
|
|
166
228
|
'items with exactly what to set and where.',
|
|
@@ -210,19 +272,34 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
210
272
|
const rows = result.data as Row[];
|
|
211
273
|
const due = rows.filter(isDue);
|
|
212
274
|
const hidden = rows.length - due.length;
|
|
275
|
+
const overviewDue = overview ? (overview.data as Row[]).filter(isDue) : [];
|
|
276
|
+
const checked = resolved
|
|
277
|
+
? { stale: new Map<string, string>(), unchecked: 0 }
|
|
278
|
+
: await staleReviews(client, [...due, ...overviewDue]);
|
|
279
|
+
const stale = checked.stale;
|
|
213
280
|
return toolResult({
|
|
214
281
|
...(overview
|
|
215
282
|
? {
|
|
216
|
-
summary: summaryOf(
|
|
283
|
+
summary: summaryOf(overviewDue, stale),
|
|
217
284
|
...(overview.nextCursor ? { summaryNote: `The summary counts the first ${SUMMARY_PAGE} items.` } : {}),
|
|
218
285
|
}
|
|
219
286
|
: {}),
|
|
220
287
|
...page(
|
|
221
|
-
due.map((row) =>
|
|
288
|
+
due.map((row) => {
|
|
289
|
+
const item = itemRow(row, now, full === true);
|
|
290
|
+
const why = stale.get(String(row['id']));
|
|
291
|
+
return why ? { ...item, stale: why } : item;
|
|
292
|
+
}),
|
|
222
293
|
result.nextCursor
|
|
223
294
|
),
|
|
224
295
|
...totalOf(result),
|
|
225
296
|
...(hidden > 0 ? { snoozedHidden: hidden } : {}),
|
|
297
|
+
// Past the cap a review is not checked, so no stale on it is not "live".
|
|
298
|
+
...(checked.unchecked > 0
|
|
299
|
+
? {
|
|
300
|
+
staleNote: `${checked.unchecked} pull request review${checked.unchecked === 1 ? ' was' : 's were'} not checked for staleness (past the cap, or the read was refused).`,
|
|
301
|
+
}
|
|
302
|
+
: {}),
|
|
226
303
|
});
|
|
227
304
|
})
|
|
228
305
|
);
|
|
@@ -232,7 +309,7 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
232
309
|
{
|
|
233
310
|
title: 'List setup tasks',
|
|
234
311
|
description:
|
|
235
|
-
'
|
|
312
|
+
'What a person must set up for a product (secrets, variables, accounts), with exactly what and where. These are the tasks agents raised for work only a person can do: the ' +
|
|
236
313
|
'name of an environment variable, secret or API key to create (key; never its value, which Taiga does ' +
|
|
237
314
|
'not store or display), the provider page to do it on, and the phase that says when it blocks you ' +
|
|
238
315
|
'(prereq: before the deploy, which fails without it; wiring: connecting generated values back to ' +
|
|
@@ -241,8 +318,8 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
241
318
|
'product), or one. The tasks still to do, oldest first, the order they depend on each other; ' +
|
|
242
319
|
'includeDone: true adds the done and dismissed ones. The first page says how many there are on every ' +
|
|
243
320
|
'page (totalCount). One row per variable: a variable filed twice (as a secret and as an environment ' +
|
|
244
|
-
'variable) lists the other records under alsoFiledAs, an open one first
|
|
245
|
-
'
|
|
321
|
+
'variable) lists the other records under alsoFiledAs, an open one first (folded within a page). totalCount counts ' +
|
|
322
|
+
'records, and a page that folded some says recordsOnPage. Unlike taiga_list_action_required, whose setup ' +
|
|
246
323
|
'items are the same work as rows to resolve, this says exactly what to set and where.',
|
|
247
324
|
inputSchema: z.object({
|
|
248
325
|
productId: productId.optional(),
|
|
@@ -260,7 +337,11 @@ export function registerInboxTools(server: McpServer, client: TaigaClient) {
|
|
|
260
337
|
...(params.cursor === undefined ? { withTotal: true } : {}),
|
|
261
338
|
});
|
|
262
339
|
return toolResult({
|
|
263
|
-
...
|
|
340
|
+
...folded(
|
|
341
|
+
sameKeyOnce(pickFields(result.data as Row[], SETUP_TASK_FIELDS)),
|
|
342
|
+
result.nextCursor,
|
|
343
|
+
result.data.length
|
|
344
|
+
),
|
|
264
345
|
...totalOf(result),
|
|
265
346
|
});
|
|
266
347
|
})
|