@taiga-ai/mcp-server 0.22.0-dev.457 → 0.23.0-dev.459
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 +859 -716
- package/package.json +1 -1
- package/src/server.integration.test.ts +232 -5
- package/src/tools/backlogs.ts +18 -0
- package/src/tools/builds.ts +27 -3
- package/src/tools/context.ts +15 -20
- package/src/tools/errors.ts +5 -0
- package/src/tools/inbox.ts +115 -23
- package/src/tools/instructions.ts +20 -19
- package/src/tools/maintenance.ts +14 -1
- package/src/tools/products.ts +33 -0
- package/src/tools/repositories.ts +8 -3
- package/taiga-ai-mcp-server-0.23.0-dev.459.tgz +0 -0
- package/taiga-ai-mcp-server-0.22.0-dev.457.tgz +0 -0
package/package.json
CHANGED
|
@@ -2009,6 +2009,8 @@ describe('MCP server integration', () => {
|
|
|
2009
2009
|
expect(bundle.initiatives.items.map((row: { id: string }) => row.id)).toEqual([ITEM_ID]);
|
|
2010
2010
|
expect(bundle.initiatives.items[0]).not.toHaveProperty('description');
|
|
2011
2011
|
expect(bundle.initiatives.truncated).toBe(false);
|
|
2012
|
+
// Every open column, zeros too, so an empty backlog reads as empty (PRO-2800).
|
|
2013
|
+
expect(bundle.initiatives.byColumn).toEqual({ backlog: 0, todo: 0, queue: 0, plan: 0, build: 1 });
|
|
2012
2014
|
expect(bundle.repositories).toEqual({
|
|
2013
2015
|
items: [
|
|
2014
2016
|
{
|
|
@@ -2033,7 +2035,8 @@ describe('MCP server integration', () => {
|
|
|
2033
2035
|
},
|
|
2034
2036
|
]);
|
|
2035
2037
|
// PRO-2800: "how does it stand" in the same call, as counts.
|
|
2036
|
-
|
|
2038
|
+
// Counted as the Action Required summary counts: a put-off item is snoozed, not open (PRO-2800).
|
|
2039
|
+
expect(bundle.actionRequired).toEqual({ open: 2, byKind: { review_pr: 2 }, snoozed: 1 });
|
|
2037
2040
|
expect(bundle.maintenance).toEqual({
|
|
2038
2041
|
openFindings: 1,
|
|
2039
2042
|
bySeverity: { critical: 0, high: 1 },
|
|
@@ -3546,6 +3549,7 @@ describe('MCP server integration', () => {
|
|
|
3546
3549
|
initiativeId: ITEM_ID,
|
|
3547
3550
|
status: 'stopped',
|
|
3548
3551
|
failedReason: 'user_stopped',
|
|
3552
|
+
failedReasonMeans: 'it was stopped, by a person or because its initiative was settled',
|
|
3549
3553
|
completedSteps: 1,
|
|
3550
3554
|
totalSteps: 3,
|
|
3551
3555
|
prUrl: null,
|
|
@@ -6296,7 +6300,8 @@ describe('MCP server integration', () => {
|
|
|
6296
6300
|
await client.callTool({ name: 'taiga_list_linkable_repositories', arguments: { installationId: INSTALL_ID_2 } })
|
|
6297
6301
|
);
|
|
6298
6302
|
|
|
6299
|
-
|
|
6303
|
+
// Only the account asked for (PRO-2800): the others were noise on every filtered call.
|
|
6304
|
+
expect(found.accounts.map((a: { accountLogin: string }) => a.accountLogin)).toEqual(['acme-labs']);
|
|
6300
6305
|
expect(found.repositories).toEqual({ data: [], nextCursor: null });
|
|
6301
6306
|
expect(sentUrl(fetchSpy, 'cursor=p2').searchParams.get('limit')).toBe('100');
|
|
6302
6307
|
});
|
|
@@ -8069,8 +8074,15 @@ describe('what the fourth agent review found (PRO-2800)', () => {
|
|
|
8069
8074
|
],
|
|
8070
8075
|
nextCursor: null,
|
|
8071
8076
|
},
|
|
8072
|
-
|
|
8073
|
-
[`GET /v1/products/${
|
|
8077
|
+
// One initiative in Build is past review; the other product's one in todo still waits on it.
|
|
8078
|
+
[`GET /v1/products/${PRODUCT_ID}/backlog`]: {
|
|
8079
|
+
data: [{ id: 'b0', status: 'in_progress', state: 'build' }],
|
|
8080
|
+
nextCursor: null,
|
|
8081
|
+
},
|
|
8082
|
+
[`GET /v1/products/${OTHER}/backlog`]: {
|
|
8083
|
+
data: [{ id: 'b1', status: 'in_progress', state: 'todo' }],
|
|
8084
|
+
nextCursor: null,
|
|
8085
|
+
},
|
|
8074
8086
|
},
|
|
8075
8087
|
});
|
|
8076
8088
|
|
|
@@ -8080,7 +8092,7 @@ describe('what the fourth agent review found (PRO-2800)', () => {
|
|
|
8080
8092
|
);
|
|
8081
8093
|
|
|
8082
8094
|
expect(list.data.map((row: { id: string; stale?: string }) => [row.id, row.stale ?? null])).toEqual([
|
|
8083
|
-
['emptied', 'no initiative is
|
|
8095
|
+
['emptied', 'no initiative is in the backlog or todo now: the ones it asked about have moved on'],
|
|
8084
8096
|
['waiting', null],
|
|
8085
8097
|
['pr', null],
|
|
8086
8098
|
]);
|
|
@@ -8363,3 +8375,218 @@ describe('what the fourth agent review found (PRO-2800)', () => {
|
|
|
8363
8375
|
expect(me.capabilities.some((capability: string) => /^(projects|teams):/.test(capability))).toBe(false);
|
|
8364
8376
|
});
|
|
8365
8377
|
});
|
|
8378
|
+
|
|
8379
|
+
describe('what the sixth agent review found (PRO-2800)', () => {
|
|
8380
|
+
beforeEach(() => {
|
|
8381
|
+
vi.restoreAllMocks();
|
|
8382
|
+
});
|
|
8383
|
+
|
|
8384
|
+
it('keeps Action Required rows to what differs, a sentence of summary, and the read that opens each', async () => {
|
|
8385
|
+
const row = (id: string, kind: string, subjectType: string, extra: Record<string, unknown> = {}) => ({
|
|
8386
|
+
id,
|
|
8387
|
+
productId: PRODUCT_ID,
|
|
8388
|
+
kind,
|
|
8389
|
+
subjectType,
|
|
8390
|
+
subjectId: 'x',
|
|
8391
|
+
title: id,
|
|
8392
|
+
summary: 'First sentence here. ' + 'Quoted document text. '.repeat(40),
|
|
8393
|
+
status: 'open',
|
|
8394
|
+
blocking: false,
|
|
8395
|
+
createdAt: new Date().toISOString(),
|
|
8396
|
+
...extra,
|
|
8397
|
+
});
|
|
8398
|
+
const { client } = await bootMcpPair({
|
|
8399
|
+
capabilities: ['products:read', 'backlog:read'],
|
|
8400
|
+
routes: {
|
|
8401
|
+
'GET /v1/inbox': {
|
|
8402
|
+
data: [
|
|
8403
|
+
row('doc', 'review_doc', 'product_document', {
|
|
8404
|
+
summary:
|
|
8405
|
+
'The architecture document has 2 issues it could not resolve:\n1. This document says ' +
|
|
8406
|
+
'x'.repeat(900),
|
|
8407
|
+
}),
|
|
8408
|
+
row('plan', 'review_plan', 'backlog_item', { blocking: true }),
|
|
8409
|
+
row('task', 'setup_secret', 'inbox_task', { status: 'in_progress' }),
|
|
8410
|
+
],
|
|
8411
|
+
nextCursor: null,
|
|
8412
|
+
},
|
|
8413
|
+
},
|
|
8414
|
+
});
|
|
8415
|
+
|
|
8416
|
+
const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
|
|
8417
|
+
const full = parseToolResult(
|
|
8418
|
+
await client.callTool({ name: 'taiga_list_action_required', arguments: { full: true } })
|
|
8419
|
+
);
|
|
8420
|
+
|
|
8421
|
+
const [doc, plan, task] = list.data;
|
|
8422
|
+
// The opening paragraph, not the quoted list after it; a long one cut at a sentence.
|
|
8423
|
+
expect(doc.summary).toBe('The architecture document has 2 issues it could not resolve: (full: true has the rest)');
|
|
8424
|
+
expect(plan.summary).toMatch(/^First sentence here\. Quoted document text\..*\.\.\. \(full: true has the rest\)$/);
|
|
8425
|
+
expect(plan.summary.length).toBeLessThan(240);
|
|
8426
|
+
expect(doc).not.toHaveProperty('status');
|
|
8427
|
+
expect(doc).not.toHaveProperty('blocking');
|
|
8428
|
+
expect(doc.readWith).toBe('taiga_list_product_documents (productId)');
|
|
8429
|
+
expect(plan.blocking).toBe(true);
|
|
8430
|
+
expect(plan.readWith).toBe('taiga_get_plan (initiativeId: subjectId)');
|
|
8431
|
+
expect(task.status).toBe('in_progress');
|
|
8432
|
+
expect(task.readWith).toBe('taiga_list_setup_tasks (productId)');
|
|
8433
|
+
expect(full.data[0].summary.length).toBeGreaterThan(500);
|
|
8434
|
+
expect(full.data[0].status).toBe('open');
|
|
8435
|
+
|
|
8436
|
+
// Without backlog:read the initiative reads are not offered, so no row names one.
|
|
8437
|
+
const { client: productsOnly } = await bootMcpPair({
|
|
8438
|
+
capabilities: ['products:read'],
|
|
8439
|
+
routes: {
|
|
8440
|
+
'GET /v1/inbox': { data: [row('plan', 'review_plan', 'backlog_item')], nextCursor: null },
|
|
8441
|
+
},
|
|
8442
|
+
});
|
|
8443
|
+
const bare = parseToolResult(await productsOnly.callTool({ name: 'taiga_list_action_required', arguments: {} }));
|
|
8444
|
+
expect(bare.data[0]).not.toHaveProperty('readWith');
|
|
8445
|
+
});
|
|
8446
|
+
|
|
8447
|
+
it("says once that a violation's words may be older than its evidence", async () => {
|
|
8448
|
+
const VIOLATION = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f0a';
|
|
8449
|
+
const { client } = await bootMcpPair({
|
|
8450
|
+
capabilities: ['products:read'],
|
|
8451
|
+
routes: {
|
|
8452
|
+
[`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
|
|
8453
|
+
data: {
|
|
8454
|
+
productId: PRODUCT_ID,
|
|
8455
|
+
controls: [{ controlId: 'SEC-5.1', divergenceCount: 1 }],
|
|
8456
|
+
divergences: [{ id: VIOLATION, controlId: 'SEC-5.1', severity: 'high', title: 'Old words' }],
|
|
8457
|
+
resolvedDivergences: [],
|
|
8458
|
+
},
|
|
8459
|
+
},
|
|
8460
|
+
},
|
|
8461
|
+
});
|
|
8462
|
+
|
|
8463
|
+
const standing = parseToolResult(
|
|
8464
|
+
await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
|
|
8465
|
+
);
|
|
8466
|
+
const one = parseToolResult(
|
|
8467
|
+
await client.callTool({
|
|
8468
|
+
name: 'taiga_get_policy_standing',
|
|
8469
|
+
arguments: { productId: PRODUCT_ID, violationId: VIOLATION },
|
|
8470
|
+
})
|
|
8471
|
+
);
|
|
8472
|
+
|
|
8473
|
+
expect(standing.wordingNote).toMatch(/^A violation's evidence is from its assessedAt/);
|
|
8474
|
+
expect(one.wordingNote).toBe(standing.wordingNote);
|
|
8475
|
+
|
|
8476
|
+
// Resolved violations carry the same words, so a product with only those says it too.
|
|
8477
|
+
const { client: resolvedOnly } = await bootMcpPair({
|
|
8478
|
+
capabilities: ['products:read'],
|
|
8479
|
+
routes: {
|
|
8480
|
+
[`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
|
|
8481
|
+
data: { productId: PRODUCT_ID, controls: [], divergences: [], resolvedDivergences: [{ id: VIOLATION }] },
|
|
8482
|
+
},
|
|
8483
|
+
},
|
|
8484
|
+
});
|
|
8485
|
+
const closed = parseToolResult(
|
|
8486
|
+
await resolvedOnly.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
|
|
8487
|
+
);
|
|
8488
|
+
expect(closed.wordingNote).toBe(standing.wordingNote);
|
|
8489
|
+
});
|
|
8490
|
+
|
|
8491
|
+
it('refuses a board column and a status that cannot meet', async () => {
|
|
8492
|
+
const { client } = await bootMcpPair({ capabilities: ['backlog:read'] });
|
|
8493
|
+
|
|
8494
|
+
const result = parseToolResult(
|
|
8495
|
+
await client.callTool({
|
|
8496
|
+
name: 'taiga_list_initiatives',
|
|
8497
|
+
arguments: { productId: PRODUCT_ID, state: 'backlog', status: 'done' },
|
|
8498
|
+
})
|
|
8499
|
+
);
|
|
8500
|
+
|
|
8501
|
+
expect(result).toMatchObject({ error: 'VALIDATION_ERROR' });
|
|
8502
|
+
expect(result.message).toMatch(/^No initiative is in the backlog column with status done/);
|
|
8503
|
+
});
|
|
8504
|
+
|
|
8505
|
+
it('answers a factory that does not exist with NOT_FOUND, not an empty list', async () => {
|
|
8506
|
+
const { client } = await bootMcpPair({
|
|
8507
|
+
capabilities: ['products:read', 'factories:read'],
|
|
8508
|
+
routes: {
|
|
8509
|
+
'GET /v1/products': { data: [], nextCursor: null },
|
|
8510
|
+
[`GET /v1/factories/${FACTORY_ID}`]: refusal(404, 'NOT_FOUND', 'Factory not found'),
|
|
8511
|
+
},
|
|
8512
|
+
});
|
|
8513
|
+
|
|
8514
|
+
const result = parseToolResult(
|
|
8515
|
+
await client.callTool({ name: 'taiga_list_products', arguments: { factoryId: FACTORY_ID } })
|
|
8516
|
+
);
|
|
8517
|
+
const searched = parseToolResult(
|
|
8518
|
+
await client.callTool({ name: 'taiga_list_products', arguments: { factoryId: FACTORY_ID, query: 'web' } })
|
|
8519
|
+
);
|
|
8520
|
+
|
|
8521
|
+
expect(result).toMatchObject({ error: 'NOT_FOUND' });
|
|
8522
|
+
expect(result.message).toMatch(/taiga_list_factories/);
|
|
8523
|
+
expect(searched).toMatchObject({ error: 'NOT_FOUND' });
|
|
8524
|
+
|
|
8525
|
+
// A key that cannot read factories says it could not check, rather than answer an unexplained empty list.
|
|
8526
|
+
const { client: productsOnly } = await bootMcpPair({
|
|
8527
|
+
capabilities: ['products:read'],
|
|
8528
|
+
routes: { 'GET /v1/products': { data: [], nextCursor: null } },
|
|
8529
|
+
});
|
|
8530
|
+
const unchecked = parseToolResult(
|
|
8531
|
+
await productsOnly.callTool({ name: 'taiga_list_products', arguments: { factoryId: FACTORY_ID } })
|
|
8532
|
+
);
|
|
8533
|
+
expect(unchecked.data).toEqual([]);
|
|
8534
|
+
expect(unchecked.note).toMatch(/cannot read factories/);
|
|
8535
|
+
});
|
|
8536
|
+
|
|
8537
|
+
it('names the branch list when a branch does not exist', async () => {
|
|
8538
|
+
const LINK = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f09';
|
|
8539
|
+
const { client } = await bootMcpPair({
|
|
8540
|
+
capabilities: ALL_CAPABILITIES,
|
|
8541
|
+
routes: {
|
|
8542
|
+
[`PATCH /v1/products/${PRODUCT_ID}/repositories/${LINK}`]: refusal(
|
|
8543
|
+
400,
|
|
8544
|
+
'VALIDATION_ERROR',
|
|
8545
|
+
'Branch "mian" does not exist in acme/web'
|
|
8546
|
+
),
|
|
8547
|
+
},
|
|
8548
|
+
});
|
|
8549
|
+
|
|
8550
|
+
const result = parseToolResult(
|
|
8551
|
+
await client.callTool({
|
|
8552
|
+
name: 'taiga_set_repository_branch',
|
|
8553
|
+
arguments: { productId: PRODUCT_ID, repositoryId: LINK, baseBranch: 'mian' },
|
|
8554
|
+
})
|
|
8555
|
+
);
|
|
8556
|
+
|
|
8557
|
+
expect(result.message).toBe(
|
|
8558
|
+
'Branch "mian" does not exist in acme/web. taiga_list_repository_branches lists the branches it has.'
|
|
8559
|
+
);
|
|
8560
|
+
});
|
|
8561
|
+
|
|
8562
|
+
it('says what a failure reason means and that hardening left blocking findings, without the runtime', async () => {
|
|
8563
|
+
const { client } = await bootMcpPair({
|
|
8564
|
+
capabilities: ['products:read'],
|
|
8565
|
+
routes: {
|
|
8566
|
+
[`/v1/builds/${BUILD_ID}/steps`]: { data: [] },
|
|
8567
|
+
[`/v1/builds/${BUILD_ID}`]: {
|
|
8568
|
+
data: {
|
|
8569
|
+
id: BUILD_ID,
|
|
8570
|
+
productId: PRODUCT_ID,
|
|
8571
|
+
status: 'failed',
|
|
8572
|
+
failedReason: 'dispatch_rejected',
|
|
8573
|
+
runtime: 'legacy',
|
|
8574
|
+
completedSteps: 0,
|
|
8575
|
+
totalSteps: 3,
|
|
8576
|
+
hardeningResult: { passed: false, scanners: {}, blockingFindings: 1, advisoryFindings: 0 },
|
|
8577
|
+
},
|
|
8578
|
+
},
|
|
8579
|
+
},
|
|
8580
|
+
});
|
|
8581
|
+
|
|
8582
|
+
const run = parseToolResult(await client.callTool({ name: 'taiga_get_run', arguments: { runId: BUILD_ID } }));
|
|
8583
|
+
|
|
8584
|
+
expect(run.failedReasonMeans).toBe(
|
|
8585
|
+
'the agent never started (its environment or credentials failed); resuming dispatches it again'
|
|
8586
|
+
);
|
|
8587
|
+
expect(run.hardeningNote).toBe(
|
|
8588
|
+
'Hardening ended with 1 blocking finding it could not fix; the run records how many, not which.'
|
|
8589
|
+
);
|
|
8590
|
+
expect(run).not.toHaveProperty('runtime');
|
|
8591
|
+
});
|
|
8592
|
+
});
|
package/src/tools/backlogs.ts
CHANGED
|
@@ -259,6 +259,17 @@ const outcomeOf = (item: Item) =>
|
|
|
259
259
|
|
|
260
260
|
const outcome = (item: Item) => toolResult(outcomeOf(item));
|
|
261
261
|
|
|
262
|
+
/** The statuses an initiative in each board column can have: the API keeps in_progress after a first run. */
|
|
263
|
+
const COLUMN_STATUSES: Record<(typeof INITIATIVE_STATES)[number], readonly string[]> = {
|
|
264
|
+
backlog: ['all', 'open', 'backlog', 'in_progress'],
|
|
265
|
+
todo: ['all', 'open', 'backlog', 'in_progress'],
|
|
266
|
+
queue: ['all', 'open', 'backlog', 'in_progress'],
|
|
267
|
+
plan: ['all', 'open', 'backlog', 'in_progress'],
|
|
268
|
+
build: ['all', 'open', 'in_progress'],
|
|
269
|
+
done: ['all', 'done'],
|
|
270
|
+
canceled: ['all', 'canceled'],
|
|
271
|
+
};
|
|
272
|
+
|
|
262
273
|
/**
|
|
263
274
|
* Each tool is registered under the capability its route requires, so a
|
|
264
275
|
* key sees exactly what it may call: reads on backlog:read; the board move on
|
|
@@ -335,6 +346,13 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
335
346
|
(params) =>
|
|
336
347
|
runTool(async () => {
|
|
337
348
|
const { query, state, status = state === undefined ? 'open' : 'all', ...filters } = params;
|
|
349
|
+
// A column and a status that cannot meet answered an empty list with no complaint (PRO-2800).
|
|
350
|
+
if (state !== undefined && !COLUMN_STATUSES[state].includes(status)) {
|
|
351
|
+
return toolError(
|
|
352
|
+
'VALIDATION_ERROR',
|
|
353
|
+
`No initiative is in the ${state} column with status ${status}: pass state alone, or a status it can have (${COLUMN_STATUSES[state].join(', ')}).`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
338
356
|
// Two reviews asked "what is in the backlog" and got the whole history, done and canceled first
|
|
339
357
|
// (PRO-2800): open is the default. The API filters by one status only, so open, a board column and a
|
|
340
358
|
// title are read across pages here.
|
package/src/tools/builds.ts
CHANGED
|
@@ -24,7 +24,6 @@ export const BUILD_SUMMARY_FIELDS = [
|
|
|
24
24
|
'planId',
|
|
25
25
|
'status',
|
|
26
26
|
'failedReason',
|
|
27
|
-
'runtime',
|
|
28
27
|
'repositoryFullName',
|
|
29
28
|
'branch',
|
|
30
29
|
'baseBranch',
|
|
@@ -223,6 +222,20 @@ export function runOutcome(run: Record<string, unknown>): { disposition: string;
|
|
|
223
222
|
return { disposition, outcome: sentence[disposition] ?? status };
|
|
224
223
|
}
|
|
225
224
|
|
|
225
|
+
/**
|
|
226
|
+
* What each failedReason means for the next move (PRO-2800): reviewers met dispatch_rejected and agent_reported as
|
|
227
|
+
* bare enums. As @taiga/types documents them.
|
|
228
|
+
*/
|
|
229
|
+
const FAILED_REASON_MEANS: Record<string, string> = {
|
|
230
|
+
agent_reported: 'the agent stopped and said why (error); resuming would likely hit the same wall',
|
|
231
|
+
server_orphan: 'the run stopped reporting (its machine died or ran out of memory); resuming can recover',
|
|
232
|
+
agent_stalled: 'the agent kept running but stopped making progress; resuming can recover',
|
|
233
|
+
user_stopped: 'it was stopped, by a person or because its initiative was settled',
|
|
234
|
+
timeout: 'it ran past its time limit; resuming continues from the unfinished step',
|
|
235
|
+
dispatch_rejected: 'the agent never started (its environment or credentials failed); resuming dispatches it again',
|
|
236
|
+
context_exhaustion: 'the agent ran out of context on a step; resuming starts that step fresh',
|
|
237
|
+
};
|
|
238
|
+
|
|
226
239
|
/**
|
|
227
240
|
* A run row as an agent reads it: its outcome added, and prDraft only where
|
|
228
241
|
* there is a pull request. The column defaults to true, so a run that never
|
|
@@ -230,10 +243,20 @@ export function runOutcome(run: Record<string, unknown>): { disposition: string;
|
|
|
230
243
|
* text out: its first sentence is in the outcome, and taiga_get_run has it all.
|
|
231
244
|
*/
|
|
232
245
|
export function readableRun(run: Record<string, unknown>, opts: { row?: boolean } = {}): Record<string, unknown> {
|
|
233
|
-
|
|
246
|
+
// runtime names the machinery a run used (legacy or engineer): nothing an agent acts on.
|
|
247
|
+
const { prDraft, error, reviewEscalation, runtime: _runtime, ...rest } = run;
|
|
234
248
|
const said = runOutcome(run);
|
|
249
|
+
const means = FAILED_REASON_MEANS[String(run['failedReason'])];
|
|
250
|
+
const hardening = run['hardeningResult'] as { blockingFindings?: unknown } | null | undefined;
|
|
251
|
+
const blocking = typeof hardening?.blockingFindings === 'number' ? hardening.blockingFindings : 0;
|
|
235
252
|
return {
|
|
236
253
|
...rest,
|
|
254
|
+
...(means ? { failedReasonMeans: means } : {}),
|
|
255
|
+
...(blocking > 0
|
|
256
|
+
? {
|
|
257
|
+
hardeningNote: `Hardening ended with ${blocking} blocking finding${blocking === 1 ? '' : 's'} it could not fix; the run records how many, not which.`,
|
|
258
|
+
}
|
|
259
|
+
: {}),
|
|
237
260
|
// A row's outcome says the error and the handback; the full read keeps them whole.
|
|
238
261
|
...(opts.row || error === undefined ? {} : { error }),
|
|
239
262
|
...(opts.row || reviewEscalation === undefined ? {} : { reviewEscalation }),
|
|
@@ -514,7 +537,8 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
514
537
|
'Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, ' +
|
|
515
538
|
"status, commit, files changed, verification and headline (first paragraph of the agent's report). " +
|
|
516
539
|
"verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on " +
|
|
517
|
-
"the repository's failing tests
|
|
540
|
+
"the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped " +
|
|
541
|
+
'run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true ' +
|
|
518
542
|
'adds complete instructions and reports. actions: resume and retry_from_failed_step are ' +
|
|
519
543
|
'taiga_resume_initiative; start_over is web only; to stop it, move the initiative out of Build. A ' +
|
|
520
544
|
'maintenance run has no initiative and is retried from its finding.',
|
package/src/tools/context.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { pickFields } from './summarize.js';
|
|
|
31
31
|
import { initiativeStates, readableRow, withInitiativeState } from './builds.js';
|
|
32
32
|
import { setupReadiness } from './readiness.js';
|
|
33
33
|
import { standingSummary } from './maintenance.js';
|
|
34
|
+
import { attentionCounts } from './inbox.js';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* One call at the start of a coding session: everything the model would
|
|
@@ -301,7 +302,11 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
301
302
|
String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0
|
|
302
303
|
);
|
|
303
304
|
if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
|
|
305
|
+
// Every open column counted, zeros too: two reviews could not tell an empty backlog from a cut list (PRO-2800).
|
|
306
|
+
const byColumn: Record<string, number> = { backlog: 0, todo: 0, queue: 0, plan: 0, build: 0 };
|
|
307
|
+
for (const row of rows) if (row.state) byColumn[row.state] = (byColumn[row.state] ?? 0) + 1;
|
|
304
308
|
return {
|
|
309
|
+
byColumn,
|
|
305
310
|
items: situatedRows(rows.slice(-BACKLOG_IN_CONTEXT)),
|
|
306
311
|
truncated,
|
|
307
312
|
// The list pages oldest first and takes no order, so a status past the
|
|
@@ -311,7 +316,7 @@ async function backlogPart(client: TaigaClient, productId: string) {
|
|
|
311
316
|
? {
|
|
312
317
|
note:
|
|
313
318
|
`More than ${BACKLOG_PAGE_LIMIT * 100} open items in one status: only the oldest of them were read, so ` +
|
|
314
|
-
'the newest may be missing here; taiga_list_initiatives pages the rest.',
|
|
319
|
+
'the newest may be missing here and byColumn counts only those read; taiga_list_initiatives pages the rest.',
|
|
315
320
|
}
|
|
316
321
|
: {}),
|
|
317
322
|
};
|
|
@@ -338,28 +343,17 @@ async function buildsPart(client: TaigaClient, productId: string) {
|
|
|
338
343
|
}
|
|
339
344
|
|
|
340
345
|
/**
|
|
341
|
-
* What waits on a person for this product, counted
|
|
342
|
-
* reviewing agent called four tools to answer "how does it stand"
|
|
343
|
-
*
|
|
346
|
+
* What waits on a person for this product, counted as taiga_list_action_required's summary counts it (PRO-2800):
|
|
347
|
+
* the reviewing agent called four tools to answer "how does it stand", and two later reviews found this count and
|
|
348
|
+
* the list's disagree (18 open here, 16 there), because this one counted stale reviews and put-off items as open.
|
|
344
349
|
*/
|
|
345
350
|
async function attentionPart(client: TaigaClient, productId: string) {
|
|
346
|
-
const result = await client.listInbox({ productId, limit: 100
|
|
347
|
-
const
|
|
348
|
-
const rows = result.data as Record<string, unknown>[];
|
|
349
|
-
const byKind: Record<string, number> = {};
|
|
350
|
-
for (const row of rows) byKind[String(row['kind'])] = (byKind[String(row['kind'])] ?? 0) + 1;
|
|
351
|
-
// Snoozed items are open too, so they count; the number says how many of
|
|
352
|
-
// them the team has put off for now.
|
|
353
|
-
const snoozed = rows.filter(
|
|
354
|
-
(row) =>
|
|
355
|
-
row['status'] === 'snoozed' && typeof row['snoozedUntil'] === 'string' && Date.parse(row['snoozedUntil']) > now
|
|
356
|
-
).length;
|
|
351
|
+
const result = await client.listInbox({ productId, limit: 100 });
|
|
352
|
+
const counts = await attentionCounts(client, result.data as Record<string, unknown>[]);
|
|
357
353
|
return {
|
|
358
|
-
|
|
359
|
-
byKind,
|
|
360
|
-
...(snoozed > 0 ? { snoozed } : {}),
|
|
354
|
+
...counts,
|
|
361
355
|
...(result.nextCursor
|
|
362
|
-
? { note: '
|
|
356
|
+
? { note: 'These count the first 100 items; taiga_list_action_required pages the rest.' }
|
|
363
357
|
: {}),
|
|
364
358
|
};
|
|
365
359
|
}
|
|
@@ -471,7 +465,8 @@ export function registerContextTools(server: McpServer, client: TaigaClient) {
|
|
|
471
465
|
'Read where a product stands in one call; the first read for any product question. Name it by ID, slug ' +
|
|
472
466
|
'or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, ' +
|
|
473
467
|
'specification and architecture outlines (section ids, sizes), policies with section counts, open ' +
|
|
474
|
-
"initiatives (taiga_list_initiatives status: 'all' has the done
|
|
468
|
+
"initiatives with a count per board column (byColumn; taiga_list_initiatives status: 'all' has the done " +
|
|
469
|
+
'and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and ' +
|
|
475
470
|
'policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; ' +
|
|
476
471
|
`neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1000}k ` +
|
|
477
472
|
'characters each, full returns all.',
|
package/src/tools/errors.ts
CHANGED
|
@@ -46,6 +46,11 @@ export function inProductWording(said: string): string {
|
|
|
46
46
|
.replace(/^Build run step not found/i, 'Run step not found')
|
|
47
47
|
.replace(/^Build run not found/i, 'Run not found')
|
|
48
48
|
.replace(/^Custom document not found/i, 'Knowledge file not found')
|
|
49
|
+
// A missing branch names the list of the ones that exist, as every other miss does.
|
|
50
|
+
.replace(
|
|
51
|
+
/^(Branch "[^"]*" does not exist in \S+?)\.?$/i,
|
|
52
|
+
'$1. taiga_list_repository_branches lists the branches it has.'
|
|
53
|
+
)
|
|
49
54
|
// The API's word for a second link names no way out; the tool has two.
|
|
50
55
|
.replace(
|
|
51
56
|
/^This product already has a repository\. Change it instead of linking another\./i,
|