@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/package.json
CHANGED
|
@@ -266,8 +266,12 @@ async function connectServer(taigaClient: TaigaClient, wait?: WaitOptions) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
/** Extract sorted tool names from a listTools response. */
|
|
269
|
+
/** The tools a key was given, beside taiga_whoami, which comes with any of them (PRO-2800). */
|
|
269
270
|
function toolNames(tools: { name: string }[]) {
|
|
270
|
-
return tools
|
|
271
|
+
return tools
|
|
272
|
+
.map((t) => t.name)
|
|
273
|
+
.filter((name) => name !== 'taiga_whoami')
|
|
274
|
+
.sort();
|
|
271
275
|
}
|
|
272
276
|
|
|
273
277
|
/** Extract parsed JSON from the first text content block of a tool call result. */
|
|
@@ -2030,9 +2034,9 @@ describe('MCP server integration', () => {
|
|
|
2030
2034
|
expect(bundle.policies).toEqual([
|
|
2031
2035
|
{ type: 'policy_security', name: 'Security Policy', version: 3, sections: ['summary', 'scope'] },
|
|
2032
2036
|
]);
|
|
2033
|
-
expect(bundle.
|
|
2034
|
-
expect(bundle.
|
|
2035
|
-
expect(bundle.
|
|
2037
|
+
expect(bundle.initiatives.items.map((row: { id: string }) => row.id)).toEqual([ITEM_ID]);
|
|
2038
|
+
expect(bundle.initiatives.items[0]).not.toHaveProperty('description');
|
|
2039
|
+
expect(bundle.initiatives.truncated).toBe(false);
|
|
2036
2040
|
expect(bundle.repositories).toEqual({
|
|
2037
2041
|
items: [
|
|
2038
2042
|
{
|
|
@@ -2046,7 +2050,7 @@ describe('MCP server integration', () => {
|
|
|
2046
2050
|
],
|
|
2047
2051
|
truncated: false,
|
|
2048
2052
|
});
|
|
2049
|
-
expect(bundle.
|
|
2053
|
+
expect(bundle.runs).toEqual([
|
|
2050
2054
|
{
|
|
2051
2055
|
id: BUILD_ID,
|
|
2052
2056
|
runNumber: 1,
|
|
@@ -2057,13 +2061,17 @@ describe('MCP server integration', () => {
|
|
|
2057
2061
|
},
|
|
2058
2062
|
]);
|
|
2059
2063
|
// PRO-2800: "how does it stand" in the same call, as counts.
|
|
2060
|
-
expect(bundle.
|
|
2064
|
+
expect(bundle.actionRequired).toEqual({ open: 3, byKind: { review_pr: 2, setup_secret: 1 }, snoozed: 1 });
|
|
2061
2065
|
expect(bundle.maintenance).toEqual({
|
|
2062
2066
|
openFindings: 1,
|
|
2063
2067
|
bySeverity: { critical: 0, high: 1 },
|
|
2064
2068
|
lastSweep: { status: 'completed', completedAt: '2026-10-01T00:00:00.000Z' },
|
|
2065
2069
|
});
|
|
2066
|
-
expect(bundle.
|
|
2070
|
+
expect(bundle.policyStanding).toEqual({
|
|
2071
|
+
openViolations: 3,
|
|
2072
|
+
bySeverity: { critical: 1, high: 2 },
|
|
2073
|
+
controlsViolated: 2,
|
|
2074
|
+
});
|
|
2067
2075
|
expect(bundle.notGranted).toEqual([]);
|
|
2068
2076
|
expect(bundle.unavailable).toEqual({});
|
|
2069
2077
|
});
|
|
@@ -2078,7 +2086,7 @@ describe('MCP server integration', () => {
|
|
|
2078
2086
|
expect(bundle.notGranted).toEqual([
|
|
2079
2087
|
'factory (needs factories:read)',
|
|
2080
2088
|
'policies (needs tenant:documents:read)',
|
|
2081
|
-
'
|
|
2089
|
+
'initiatives (needs backlog:read)',
|
|
2082
2090
|
]);
|
|
2083
2091
|
expect(bundle).not.toHaveProperty('factory');
|
|
2084
2092
|
expect(bundle).not.toHaveProperty('policies');
|
|
@@ -2098,9 +2106,9 @@ describe('MCP server integration', () => {
|
|
|
2098
2106
|
await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
|
|
2099
2107
|
);
|
|
2100
2108
|
|
|
2101
|
-
expect(bundle.unavailable).toEqual({
|
|
2102
|
-
expect(bundle).not.toHaveProperty('
|
|
2103
|
-
expect(bundle.
|
|
2109
|
+
expect(bundle.unavailable).toEqual({ initiatives: '429 RATE_LIMITED: Slow down' });
|
|
2110
|
+
expect(bundle).not.toHaveProperty('initiatives');
|
|
2111
|
+
expect(bundle.runs).toHaveLength(1);
|
|
2104
2112
|
});
|
|
2105
2113
|
|
|
2106
2114
|
it('should read every backlog page, since the open items are the newest and the list is oldest first', async () => {
|
|
@@ -2124,7 +2132,7 @@ describe('MCP server integration', () => {
|
|
|
2124
2132
|
await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
|
|
2125
2133
|
);
|
|
2126
2134
|
|
|
2127
|
-
expect(bundle.
|
|
2135
|
+
expect(bundle.initiatives.items.map((row: { title: string }) => row.title)).toEqual(['Add login', 'Newest']);
|
|
2128
2136
|
});
|
|
2129
2137
|
|
|
2130
2138
|
it('should list an item once when it moved between the two status reads', async () => {
|
|
@@ -2141,8 +2149,8 @@ describe('MCP server integration', () => {
|
|
|
2141
2149
|
await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
|
|
2142
2150
|
);
|
|
2143
2151
|
|
|
2144
|
-
expect(bundle.
|
|
2145
|
-
expect(bundle.
|
|
2152
|
+
expect(bundle.initiatives.items).toHaveLength(1);
|
|
2153
|
+
expect(bundle.initiatives.truncated).toBe(false);
|
|
2146
2154
|
const statuses = fetchSpy.mock.calls
|
|
2147
2155
|
.map((c: unknown[]) => new URL(c[0] as string))
|
|
2148
2156
|
.filter((u: URL) => u.pathname.endsWith('/backlog'))
|
|
@@ -2170,10 +2178,10 @@ describe('MCP server integration', () => {
|
|
|
2170
2178
|
);
|
|
2171
2179
|
|
|
2172
2180
|
// Ten pages per open status, two statuses.
|
|
2173
|
-
expect(bundle.
|
|
2174
|
-
expect(bundle.
|
|
2181
|
+
expect(bundle.initiatives.items).toHaveLength(20);
|
|
2182
|
+
expect(bundle.initiatives.truncated).toBe(true);
|
|
2175
2183
|
// The list pages oldest first: past the cap, the newest were never read, and the bundle says so.
|
|
2176
|
-
expect(bundle.
|
|
2184
|
+
expect(bundle.initiatives.note).toContain('only the oldest of them were read');
|
|
2177
2185
|
});
|
|
2178
2186
|
|
|
2179
2187
|
it('should carry the newest fifty open items across both statuses and say the backlog is truncated', async () => {
|
|
@@ -2200,10 +2208,10 @@ describe('MCP server integration', () => {
|
|
|
2200
2208
|
await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
|
|
2201
2209
|
);
|
|
2202
2210
|
|
|
2203
|
-
expect(bundle.
|
|
2204
|
-
expect(bundle.
|
|
2205
|
-
expect(bundle.
|
|
2206
|
-
expect(bundle.
|
|
2211
|
+
expect(bundle.initiatives.items).toHaveLength(50);
|
|
2212
|
+
expect(bundle.initiatives.items[0].title).toBe('Item 11');
|
|
2213
|
+
expect(bundle.initiatives.items.at(-1).title).toBe('Newest');
|
|
2214
|
+
expect(bundle.initiatives.truncated).toBe(true);
|
|
2207
2215
|
});
|
|
2208
2216
|
|
|
2209
2217
|
it('should carry the two documents as their outline when asked for it', async () => {
|
|
@@ -3445,7 +3453,7 @@ describe('MCP server integration', () => {
|
|
|
3445
3453
|
const call = fetchSpy.mock.calls.find((c: unknown[]) => (c[0] as string).endsWith('/resume'))!;
|
|
3446
3454
|
expect((call[1] as RequestInit).method).toBe('POST');
|
|
3447
3455
|
expect((call[1] as RequestInit).body).toBeNull();
|
|
3448
|
-
expect(outcomeOf(result).lifecycle).toEqual(resumed.lifecycle);
|
|
3456
|
+
expect(outcomeOf(result).lifecycle).toEqual(asSeen(resumed.lifecycle));
|
|
3449
3457
|
});
|
|
3450
3458
|
|
|
3451
3459
|
it('should read the active plan with its steps and say when there is none', async () => {
|
|
@@ -4336,7 +4344,7 @@ describe('MCP server integration', () => {
|
|
|
4336
4344
|
})
|
|
4337
4345
|
);
|
|
4338
4346
|
|
|
4339
|
-
expect(result).toMatchObject({ reached: true, item: {
|
|
4347
|
+
expect(result).toMatchObject({ reached: true, item: { latestRun: { prState: 'closed' } } });
|
|
4340
4348
|
});
|
|
4341
4349
|
|
|
4342
4350
|
it('should end the read in flight when the call is cancelled', async () => {
|
|
@@ -4473,7 +4481,7 @@ describe('MCP server integration', () => {
|
|
|
4473
4481
|
);
|
|
4474
4482
|
|
|
4475
4483
|
// Not stopped at the landed plan: the open gate builds it without anyone.
|
|
4476
|
-
expect(result).toMatchObject({ reached: true, item: {
|
|
4484
|
+
expect(result).toMatchObject({ reached: true, item: { latestRun: { prState: 'open' } } });
|
|
4477
4485
|
});
|
|
4478
4486
|
|
|
4479
4487
|
it("should take a landed plan for a person's turn when the product's closed gate holds it, reading the product once", async () => {
|
|
@@ -6430,7 +6438,7 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6430
6438
|
expect(parseToolResult(item)).toEqual({
|
|
6431
6439
|
error: 'NOT_FOUND',
|
|
6432
6440
|
message:
|
|
6433
|
-
"
|
|
6441
|
+
"Initiative not found. taiga_list_initiatives lists a product's initiatives with their IDs; a run or " +
|
|
6434
6442
|
'plan ID is not one.',
|
|
6435
6443
|
});
|
|
6436
6444
|
|
|
@@ -6488,7 +6496,7 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6488
6496
|
|
|
6489
6497
|
expect(noPlan.message).toContain('taiga_plan_again asks for a new one');
|
|
6490
6498
|
expect(noItem.message).toBe(
|
|
6491
|
-
"
|
|
6499
|
+
"Initiative not found. taiga_list_initiatives lists a product's initiatives with their IDs; a run or " +
|
|
6492
6500
|
'plan ID is not one.'
|
|
6493
6501
|
);
|
|
6494
6502
|
});
|
|
@@ -6616,8 +6624,8 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6616
6624
|
expect(product.readiness).toEqual({
|
|
6617
6625
|
ready: false,
|
|
6618
6626
|
repository: { linked: false },
|
|
6619
|
-
import: '
|
|
6620
|
-
missingDocuments: ['
|
|
6627
|
+
import: 'pending',
|
|
6628
|
+
missingDocuments: ['specification', 'architecture'],
|
|
6621
6629
|
openChecks: [],
|
|
6622
6630
|
nextStep:
|
|
6623
6631
|
'Link its repository (taiga_list_linkable_repositories, then taiga_link_repository), then taiga_start_import.',
|
|
@@ -6813,10 +6821,36 @@ describe('what the agent review found (PRO-2759)', () => {
|
|
|
6813
6821
|
expect(Object.keys(standing)[0]).toBe('summary');
|
|
6814
6822
|
expect(standing.summary).toEqual({
|
|
6815
6823
|
controls: 1,
|
|
6824
|
+
controlsInPolicies: 2,
|
|
6825
|
+
controlsWithNothingRecorded: 1,
|
|
6816
6826
|
violations: 2,
|
|
6817
6827
|
bySeverity: { high: 1, critical: 1 },
|
|
6818
6828
|
resolvedViolations: 1,
|
|
6819
6829
|
});
|
|
6830
|
+
expect(standing).not.toHaveProperty('note');
|
|
6831
|
+
});
|
|
6832
|
+
|
|
6833
|
+
it('says a never-assessed standing is no all-clear (PRO-2800)', async () => {
|
|
6834
|
+
const { client } = await bootMcpPair({
|
|
6835
|
+
capabilities: ['products:read'],
|
|
6836
|
+
routes: {
|
|
6837
|
+
[`GET /v1/products/${PRODUCT_ID}/maintenance/policies`]: {
|
|
6838
|
+
data: {
|
|
6839
|
+
productId: PRODUCT_ID,
|
|
6840
|
+
hasPolicies: true,
|
|
6841
|
+
assessment: { state: 'never' },
|
|
6842
|
+
controls: [],
|
|
6843
|
+
divergences: [],
|
|
6844
|
+
},
|
|
6845
|
+
},
|
|
6846
|
+
},
|
|
6847
|
+
});
|
|
6848
|
+
|
|
6849
|
+
const standing = parseToolResult(
|
|
6850
|
+
await client.callTool({ name: 'taiga_get_policy_standing', arguments: { productId: PRODUCT_ID } })
|
|
6851
|
+
);
|
|
6852
|
+
|
|
6853
|
+
expect(standing.note).toMatch(/^Never assessed: .* nothing here is an all-clear/);
|
|
6820
6854
|
});
|
|
6821
6855
|
});
|
|
6822
6856
|
|
|
@@ -7039,12 +7073,11 @@ describe('what the second agent review found (PRO-2778, PRO-2780)', () => {
|
|
|
7039
7073
|
for (const tool of tools) {
|
|
7040
7074
|
const notes = tool.annotations ?? {};
|
|
7041
7075
|
const description = tool.description ?? '';
|
|
7042
|
-
if (notes.readOnlyHint) expect(description, tool.name).not.toMatch(/^(Writes\.|
|
|
7043
|
-
else if (notes.destructiveHint)
|
|
7044
|
-
expect(description, tool.name).toMatch(/^Acts on paid or shared work: ask the person first\. /);
|
|
7076
|
+
if (notes.readOnlyHint) expect(description, tool.name).not.toMatch(/^(Writes\.|Ask first:)/);
|
|
7077
|
+
else if (notes.destructiveHint) expect(description, tool.name).toMatch(/^Ask first: paid or shared work\. /);
|
|
7045
7078
|
else expect(description, tool.name).toMatch(/^Writes\. /);
|
|
7046
7079
|
}
|
|
7047
|
-
expect(tools.find((t) => t.name === 'taiga_queue_initiative')!.description).toMatch(/^
|
|
7080
|
+
expect(tools.find((t) => t.name === 'taiga_queue_initiative')!.description).toMatch(/^Ask first:/);
|
|
7048
7081
|
expect(tools.find((t) => t.name === 'taiga_create_factory')!.description).toMatch(/^Writes\. /);
|
|
7049
7082
|
});
|
|
7050
7083
|
|
|
@@ -7089,6 +7122,219 @@ describe('what the third agent review found (PRO-2800)', () => {
|
|
|
7089
7122
|
vi.restoreAllMocks();
|
|
7090
7123
|
});
|
|
7091
7124
|
|
|
7125
|
+
it('says who the key acts as and which of its tools write', async () => {
|
|
7126
|
+
const { client } = await bootMcpPair({
|
|
7127
|
+
capabilities: ['products:read', 'backlog:read', 'backlog:write', 'products:write'],
|
|
7128
|
+
});
|
|
7129
|
+
|
|
7130
|
+
const me = parseToolResult(await client.callTool({ name: 'taiga_whoami', arguments: {} }));
|
|
7131
|
+
|
|
7132
|
+
expect(me.role).toBeTypeOf('string');
|
|
7133
|
+
expect(me.capabilities).toEqual(expect.arrayContaining(['products:read', 'backlog:write']));
|
|
7134
|
+
expect(me.tools.reads).toContain('taiga_get_product_context');
|
|
7135
|
+
expect(me.tools.askFirst).toContain('taiga_queue_initiative');
|
|
7136
|
+
expect(me.tools.reads).not.toContain('taiga_whoami');
|
|
7137
|
+
});
|
|
7138
|
+
|
|
7139
|
+
it('marks a pull request review that no longer stands, and counts those in the summary', async () => {
|
|
7140
|
+
const RUN_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4e02';
|
|
7141
|
+
const ITEM_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4e03';
|
|
7142
|
+
const review = (id: string, subjectId: string) => ({
|
|
7143
|
+
id,
|
|
7144
|
+
productId: PRODUCT_ID,
|
|
7145
|
+
productName: 'Web',
|
|
7146
|
+
kind: 'review_pr',
|
|
7147
|
+
subjectType: 'build_run',
|
|
7148
|
+
subjectId,
|
|
7149
|
+
status: 'open',
|
|
7150
|
+
createdAt: new Date().toISOString(),
|
|
7151
|
+
});
|
|
7152
|
+
const { client } = await bootMcpPair({
|
|
7153
|
+
capabilities: ['products:read', 'backlog:read'],
|
|
7154
|
+
routes: {
|
|
7155
|
+
'GET /v1/inbox': { data: [review('i1', BUILD_ID), review('i2', RUN_2)], nextCursor: null },
|
|
7156
|
+
[`GET /v1/builds/${BUILD_ID}`]: {
|
|
7157
|
+
data: { id: BUILD_ID, prState: 'open', prMerged: false, backlogItemId: ITEM_ID },
|
|
7158
|
+
},
|
|
7159
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: { id: ITEM_ID, state: 'build', status: 'in_progress' } },
|
|
7160
|
+
[`GET /v1/builds/${RUN_2}`]: { data: { id: RUN_2, prState: 'open', prMerged: false, backlogItemId: ITEM_2 } },
|
|
7161
|
+
[`GET /v1/backlog/${ITEM_2}`]: { data: { id: ITEM_2, state: 'canceled', status: 'canceled' } },
|
|
7162
|
+
},
|
|
7163
|
+
});
|
|
7164
|
+
|
|
7165
|
+
const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
|
|
7166
|
+
|
|
7167
|
+
expect(list.data[0]).not.toHaveProperty('stale');
|
|
7168
|
+
expect(list.data[1].stale).toBe('its initiative was canceled; the pull request is still open on the forge');
|
|
7169
|
+
expect(list.summary).toEqual([
|
|
7170
|
+
{ productId: PRODUCT_ID, product: 'Web', open: 2, byKind: { review_pr: 2 }, stale: 1 },
|
|
7171
|
+
]);
|
|
7172
|
+
});
|
|
7173
|
+
|
|
7174
|
+
it("names a handback's own next step, counts rounds in words, and keeps a multi-line error to its first line", async () => {
|
|
7175
|
+
const base = {
|
|
7176
|
+
productId: PRODUCT_ID,
|
|
7177
|
+
completedSteps: 2,
|
|
7178
|
+
totalSteps: 3,
|
|
7179
|
+
prNumber: 5,
|
|
7180
|
+
prUrl: 'https://x/5',
|
|
7181
|
+
prDraft: false,
|
|
7182
|
+
};
|
|
7183
|
+
const { client } = await bootMcpPair({
|
|
7184
|
+
capabilities: ['products:read'],
|
|
7185
|
+
routes: {
|
|
7186
|
+
[`/v1/products/${PRODUCT_ID}/builds`]: {
|
|
7187
|
+
data: [
|
|
7188
|
+
{ ...base, id: 'a', status: 'needs_review', reviewEscalation: { reason: 'ci_never_settled' } },
|
|
7189
|
+
{ ...base, id: 'b', status: 'needs_review', reviewEscalation: { reason: 'ci_fix_cap', rounds: 1 } },
|
|
7190
|
+
{
|
|
7191
|
+
...base,
|
|
7192
|
+
id: 'c',
|
|
7193
|
+
status: 'failed',
|
|
7194
|
+
prUrl: null,
|
|
7195
|
+
prNumber: null,
|
|
7196
|
+
error: 'TypeError: boom\n at x (y.js:1)',
|
|
7197
|
+
},
|
|
7198
|
+
],
|
|
7199
|
+
nextCursor: null,
|
|
7200
|
+
},
|
|
7201
|
+
},
|
|
7202
|
+
});
|
|
7203
|
+
|
|
7204
|
+
const runs = parseToolResult(
|
|
7205
|
+
await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
|
|
7206
|
+
).data as Record<string, unknown>[];
|
|
7207
|
+
|
|
7208
|
+
expect(runs[0]!['outcome']).toMatch(/look at what is still running or queued in CI/);
|
|
7209
|
+
expect(runs[1]!['outcome']).toMatch(/CI is still failing after 1 automatic round\./);
|
|
7210
|
+
expect(runs[2]!['outcome']).toBe('Failed: TypeError: boom (2 of 3 steps done).');
|
|
7211
|
+
// A row says the handback in its outcome; the full read keeps the record.
|
|
7212
|
+
expect(runs[0]).not.toHaveProperty('reviewEscalation');
|
|
7213
|
+
});
|
|
7214
|
+
|
|
7215
|
+
it('counts a review it could not check as unchecked, not live', async () => {
|
|
7216
|
+
const { client } = await bootMcpPair({
|
|
7217
|
+
// products:read alone: the run is readable, its initiative is not.
|
|
7218
|
+
capabilities: ['products:read'],
|
|
7219
|
+
routes: {
|
|
7220
|
+
'GET /v1/inbox': {
|
|
7221
|
+
data: [
|
|
7222
|
+
{
|
|
7223
|
+
id: 'i1',
|
|
7224
|
+
productId: PRODUCT_ID,
|
|
7225
|
+
kind: 'review_pr',
|
|
7226
|
+
subjectType: 'build_run',
|
|
7227
|
+
subjectId: BUILD_ID,
|
|
7228
|
+
status: 'open',
|
|
7229
|
+
createdAt: new Date().toISOString(),
|
|
7230
|
+
},
|
|
7231
|
+
],
|
|
7232
|
+
nextCursor: null,
|
|
7233
|
+
},
|
|
7234
|
+
[`GET /v1/builds/${BUILD_ID}`]: { data: { id: BUILD_ID, prState: 'open', backlogItemId: ITEM_ID } },
|
|
7235
|
+
},
|
|
7236
|
+
});
|
|
7237
|
+
|
|
7238
|
+
const list = parseToolResult(await client.callTool({ name: 'taiga_list_action_required', arguments: {} }));
|
|
7239
|
+
|
|
7240
|
+
expect(list.data[0]).not.toHaveProperty('stale');
|
|
7241
|
+
expect(list.staleNote).toMatch(/^1 pull request review was not checked for staleness/);
|
|
7242
|
+
});
|
|
7243
|
+
|
|
7244
|
+
it('names factories past the first page of them', async () => {
|
|
7245
|
+
const FACTORY_2 = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4e10';
|
|
7246
|
+
const { client } = await bootMcpPair({
|
|
7247
|
+
capabilities: ['products:read', 'factories:read'],
|
|
7248
|
+
routes: {
|
|
7249
|
+
'GET /v1/products': {
|
|
7250
|
+
data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', factoryId: FACTORY_2 }],
|
|
7251
|
+
nextCursor: null,
|
|
7252
|
+
},
|
|
7253
|
+
'GET /v1/factories': (url: string) => {
|
|
7254
|
+
const second = new URL(url).searchParams.get('cursor') === 'p2';
|
|
7255
|
+
const body = second
|
|
7256
|
+
? { data: [{ id: FACTORY_2, name: 'Second' }], nextCursor: null }
|
|
7257
|
+
: { data: [{ id: FACTORY_ID, name: 'First' }], nextCursor: 'p2' };
|
|
7258
|
+
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
|
7259
|
+
},
|
|
7260
|
+
},
|
|
7261
|
+
});
|
|
7262
|
+
|
|
7263
|
+
const products = parseToolResult(await client.callTool({ name: 'taiga_list_products', arguments: {} }));
|
|
7264
|
+
|
|
7265
|
+
expect(products.data[0].factoryName).toBe('Second');
|
|
7266
|
+
});
|
|
7267
|
+
|
|
7268
|
+
it('says why the agent handed a run back, where the initiative and the line say whose turn it is', async () => {
|
|
7269
|
+
const item = {
|
|
7270
|
+
id: ITEM_ID,
|
|
7271
|
+
title: 'HTTPS',
|
|
7272
|
+
lifecycle: {
|
|
7273
|
+
turn: 'you',
|
|
7274
|
+
stage: 'in_review',
|
|
7275
|
+
cta: 'review_pr',
|
|
7276
|
+
planFailureCause: null,
|
|
7277
|
+
latestBuild: {
|
|
7278
|
+
id: BUILD_ID,
|
|
7279
|
+
status: 'needs_review',
|
|
7280
|
+
prNumber: 29,
|
|
7281
|
+
reviewEscalation: { reason: 'ci_fix_stalled', failingChecks: [{ name: 'secret-scan', url: null }] },
|
|
7282
|
+
},
|
|
7283
|
+
},
|
|
7284
|
+
};
|
|
7285
|
+
const { client } = await bootMcpPair({
|
|
7286
|
+
capabilities: ['backlog:read'],
|
|
7287
|
+
routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: item } },
|
|
7288
|
+
});
|
|
7289
|
+
|
|
7290
|
+
const got = parseToolResult(
|
|
7291
|
+
await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
7292
|
+
);
|
|
7293
|
+
|
|
7294
|
+
expect(got.situation).toContain(
|
|
7295
|
+
'handed back because CI is still failing on secret-scan, and the last fix round changed nothing'
|
|
7296
|
+
);
|
|
7297
|
+
expect(got.lifecycle.latestRun.id).toBe(BUILD_ID);
|
|
7298
|
+
});
|
|
7299
|
+
|
|
7300
|
+
it('reads the sections that state a policy control id', async () => {
|
|
7301
|
+
const { client } = await bootMcpPair({
|
|
7302
|
+
capabilities: ALL_CAPABILITIES,
|
|
7303
|
+
routes: {
|
|
7304
|
+
'GET /v1/tenants/current/documents/policy_security': {
|
|
7305
|
+
data: {
|
|
7306
|
+
id: 'doc-1',
|
|
7307
|
+
type: 'policy_security',
|
|
7308
|
+
version: 1,
|
|
7309
|
+
isDraft: false,
|
|
7310
|
+
data: {
|
|
7311
|
+
version: '1.0.0',
|
|
7312
|
+
sections: {
|
|
7313
|
+
scope: { content: 'All systems.' },
|
|
7314
|
+
secretsManagement: { content: '| SEC-5.1 | Secrets in a vault |\n| SEC-5.10 | Rotate |' },
|
|
7315
|
+
logging: { content: '| SEC-5.10 | Log access |' },
|
|
7316
|
+
appendix: { content: 'See SEC-5.1A and SEC-5.1-appendix, and SEC-5.1.2.' },
|
|
7317
|
+
summary: { content: 'Secrets follow SEC-5.1.' },
|
|
7318
|
+
},
|
|
7319
|
+
},
|
|
7320
|
+
},
|
|
7321
|
+
},
|
|
7322
|
+
},
|
|
7323
|
+
});
|
|
7324
|
+
|
|
7325
|
+
const doc = parseToolResult(
|
|
7326
|
+
await client.callTool({
|
|
7327
|
+
name: 'taiga_get_document',
|
|
7328
|
+
arguments: { type: 'policy_security', sections: ['SEC-5.1', 'SEC-9.9'] },
|
|
7329
|
+
})
|
|
7330
|
+
);
|
|
7331
|
+
|
|
7332
|
+
// A whole id only, and a sentence's full stop after it is still the id.
|
|
7333
|
+
expect(Object.keys(doc.data.sections).sort()).toEqual(['secretsManagement', 'summary']);
|
|
7334
|
+
expect(doc.controls).toEqual({ 'SEC-5.1': ['secretsManagement', 'summary'] });
|
|
7335
|
+
expect(doc.unknownSections).toEqual(['SEC-9.9']);
|
|
7336
|
+
});
|
|
7337
|
+
|
|
7092
7338
|
it('loads the three entry tools upfront in Claude Code, and only those', async () => {
|
|
7093
7339
|
const { client } = await bootMcpPair({ capabilities: ALL_CAPABILITIES });
|
|
7094
7340
|
|
|
@@ -7185,6 +7431,43 @@ describe('what the third agent review found (PRO-2800)', () => {
|
|
|
7185
7431
|
expect(archive.data[0].waitingDays).toBe(2);
|
|
7186
7432
|
});
|
|
7187
7433
|
|
|
7434
|
+
it("reads a draft product's links once for the context's repositories and its readiness", async () => {
|
|
7435
|
+
const draft = { id: PRODUCT_ID, name: 'Web', slug: 'web', status: 'draft', factoryId: FACTORY_ID, settings: {} };
|
|
7436
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
7437
|
+
capabilities: ['products:read'],
|
|
7438
|
+
routes: {
|
|
7439
|
+
[`GET /v1/products/${PRODUCT_ID}`]: { data: { ...draft, discoveryStep: 'documents' } },
|
|
7440
|
+
[`GET /v1/products/${PRODUCT_ID}/repositories`]: { data: [], nextCursor: null },
|
|
7441
|
+
},
|
|
7442
|
+
});
|
|
7443
|
+
|
|
7444
|
+
const bundle = parseToolResult(
|
|
7445
|
+
await client.callTool({ name: 'taiga_get_product_context', arguments: { productId: PRODUCT_ID } })
|
|
7446
|
+
);
|
|
7447
|
+
|
|
7448
|
+
expect(bundle.product.readiness.repository).toEqual({ linked: false });
|
|
7449
|
+
expect(bundle.repositories.items).toEqual([]);
|
|
7450
|
+
const reads = fetchSpy.mock.calls.filter((call: unknown[]) =>
|
|
7451
|
+
new URL(call[0] as string).pathname.endsWith('/repositories')
|
|
7452
|
+
);
|
|
7453
|
+
expect(reads).toHaveLength(1);
|
|
7454
|
+
});
|
|
7455
|
+
|
|
7456
|
+
it("keeps a stopped run's draft state when it opened a pull request", async () => {
|
|
7457
|
+
const { client } = await bootMcpPair({
|
|
7458
|
+
capabilities: ['products:read', 'products:write'],
|
|
7459
|
+
routes: {
|
|
7460
|
+
[`POST /v1/builds/${BUILD_ID}/stop`]: {
|
|
7461
|
+
data: { id: BUILD_ID, status: 'stopped', prNumber: 4, prUrl: 'https://x/4', prDraft: true, actions: [] },
|
|
7462
|
+
},
|
|
7463
|
+
},
|
|
7464
|
+
});
|
|
7465
|
+
|
|
7466
|
+
const stopped = parseToolResult(await client.callTool({ name: 'taiga_stop_run', arguments: { runId: BUILD_ID } }));
|
|
7467
|
+
|
|
7468
|
+
expect(stopped.prDraft).toBe(true);
|
|
7469
|
+
});
|
|
7470
|
+
|
|
7188
7471
|
it('lists a variable filed under two categories once, naming the other record', async () => {
|
|
7189
7472
|
const task = (id: string, category: string, key: string | null) => ({
|
|
7190
7473
|
id,
|
|
@@ -7211,6 +7494,8 @@ describe('what the third agent review found (PRO-2800)', () => {
|
|
|
7211
7494
|
|
|
7212
7495
|
expect(tasks.data.map((row: { id: string }) => row.id)).toEqual(['t1', 't2']);
|
|
7213
7496
|
expect(tasks.data[0].alsoFiledAs).toEqual([{ id: 't3', category: 'env_var', title: 'Set JWT_SECRET' }]);
|
|
7497
|
+
// Three records folded into two rows: the page says so beside the rows.
|
|
7498
|
+
expect(tasks.recordsOnPage).toBe(3);
|
|
7214
7499
|
});
|
|
7215
7500
|
|
|
7216
7501
|
it('leads a variable with its open record when an older one is done', async () => {
|
|
@@ -7286,6 +7571,24 @@ describe('what the third agent review found (PRO-2800)', () => {
|
|
|
7286
7571
|
prUrl: 'https://x/8',
|
|
7287
7572
|
baseBranch: 'main',
|
|
7288
7573
|
},
|
|
7574
|
+
{
|
|
7575
|
+
...base,
|
|
7576
|
+
id: 'r7',
|
|
7577
|
+
status: 'needs_review',
|
|
7578
|
+
prNumber: 29,
|
|
7579
|
+
prUrl: 'https://x/29',
|
|
7580
|
+
reviewEscalation: { reason: 'ci_fix_stalled', failingChecks: [{ name: 'secret-scan', url: null }] },
|
|
7581
|
+
},
|
|
7582
|
+
{
|
|
7583
|
+
...base,
|
|
7584
|
+
id: 'r8',
|
|
7585
|
+
status: 'failed',
|
|
7586
|
+
failedReason: 'dispatch_rejected',
|
|
7587
|
+
error: 'The engineer environment is unavailable: the instance terminated while starting. Retry later.',
|
|
7588
|
+
completedSteps: 0,
|
|
7589
|
+
totalSteps: 3,
|
|
7590
|
+
prUrl: null,
|
|
7591
|
+
},
|
|
7289
7592
|
],
|
|
7290
7593
|
nextCursor: null,
|
|
7291
7594
|
},
|
|
@@ -7300,9 +7603,18 @@ describe('what the third agent review found (PRO-2800)', () => {
|
|
|
7300
7603
|
['rejected', 'PR #28 was closed without merging, so the work did not land.'],
|
|
7301
7604
|
['succeeded', 'Merged: PR #7 is in main.'],
|
|
7302
7605
|
['failed', 'Failed: agent_error (2 of 6 steps done).'],
|
|
7303
|
-
['gated', 'Waiting for a person to review and merge PR #9.'],
|
|
7606
|
+
['gated', 'Waiting for a person to review and merge PR #9, still a draft.'],
|
|
7304
7607
|
['in_flight', 'Running: hardening: running the security scanners and fixing what they find (6 of 6 steps done).'],
|
|
7305
7608
|
['succeeded', 'Merged: PR #8 is in main.'],
|
|
7609
|
+
[
|
|
7610
|
+
'gated',
|
|
7611
|
+
'Handed back to a person: CI is still failing on secret-scan, and the last fix round changed nothing. ' +
|
|
7612
|
+
'PR #29, still a draft, waits for them to fix it by hand or review it back to the agent.',
|
|
7613
|
+
],
|
|
7614
|
+
[
|
|
7615
|
+
'failed',
|
|
7616
|
+
'Failed: The engineer environment is unavailable: the instance terminated while starting (0 of 3 steps done).',
|
|
7617
|
+
],
|
|
7306
7618
|
]);
|
|
7307
7619
|
expect(runs[0]).toHaveProperty('prDraft', false);
|
|
7308
7620
|
expect(runs[2]).not.toHaveProperty('prDraft');
|
package/src/tools/autonomy.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function registerAutonomyTools(server: McpServer, client: TaigaClient) {
|
|
|
64
64
|
{
|
|
65
65
|
title: "Set whether an initiative's pull request merges on its own",
|
|
66
66
|
description:
|
|
67
|
-
"
|
|
67
|
+
"Set whether one initiative's pull request merges on its own: true lets its pull request merge itself once the " +
|
|
68
68
|
"repository's own rules pass (required checks, reviews, a code reviewer you made Taiga wait for); false " +
|
|
69
69
|
"makes it wait for a person; null follows the product's default. The final word within what the " +
|
|
70
70
|
'organization and factories allow: where either has turned autonomous merge off, true changes nothing. ' +
|
package/src/tools/backlogs.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
|
20
20
|
import { jobReport } from './jobs.js';
|
|
21
21
|
import { requestPrompt } from './request-prompt.js';
|
|
22
22
|
import { queueView } from './queue.js';
|
|
23
|
-
import { landedDisposition } from './builds.js';
|
|
23
|
+
import { handbackNextStep, handbackSaid, landedDisposition } from './builds.js';
|
|
24
24
|
import {
|
|
25
25
|
SEARCH_NOTE,
|
|
26
26
|
SEARCH_PAGE_SIZE,
|
|
@@ -139,6 +139,9 @@ export function situationOf(item: Record<string, unknown>): string | null {
|
|
|
139
139
|
? RUN_STANDS[landedDisposition(build.status as BuildRunStatus, build.prMerged === true)]
|
|
140
140
|
: build.status;
|
|
141
141
|
stage += `; its latest run ${said}${pr}`;
|
|
142
|
+
// Handed back: what the agent could not get past, which "in review" alone hides.
|
|
143
|
+
const handback = handbackSaid((build as { reviewEscalation?: unknown }).reviewEscalation);
|
|
144
|
+
if (handback) stage += `, handed back because ${handback}`;
|
|
142
145
|
}
|
|
143
146
|
parts.push(`${stage}.`);
|
|
144
147
|
if (typeof item['needsHumanCause'] === 'string') {
|
|
@@ -152,7 +155,13 @@ export function situationOf(item: Record<string, unknown>): string | null {
|
|
|
152
155
|
if (why) parts.push(`The line stopped on it: ${why}.`);
|
|
153
156
|
}
|
|
154
157
|
const turn = life.turn === 'you' ? "A person's turn" : life.turn === 'agent' ? "The agent's turn" : null;
|
|
155
|
-
const
|
|
158
|
+
const handedBack = handbackSaid((build as { reviewEscalation?: unknown } | null | undefined)?.reviewEscalation);
|
|
159
|
+
const move =
|
|
160
|
+
life.cta === 'review_pr' && handedBack
|
|
161
|
+
? `a person should ${handbackNextStep((build as { reviewEscalation?: unknown }).reviewEscalation)}`
|
|
162
|
+
: typeof life.cta === 'string'
|
|
163
|
+
? CTA_MOVE[life.cta]
|
|
164
|
+
: undefined;
|
|
156
165
|
if (life.cta === 'view_pr') parts.push(`Nothing to do: ${move}.`);
|
|
157
166
|
else if (turn && move) parts.push(`${turn}: ${move}.`);
|
|
158
167
|
else if (turn) parts.push(`${turn}.`);
|
|
@@ -295,7 +304,7 @@ function registerBacklogReads(server: McpServer, client: TaigaClient) {
|
|
|
295
304
|
{
|
|
296
305
|
title: 'List initiatives',
|
|
297
306
|
description:
|
|
298
|
-
"List a product's initiatives
|
|
307
|
+
"List a product's initiatives (its backlog): titles, category, status, where each one is on the " +
|
|
299
308
|
'line (backlog, todo, queue, plan, build, done), situation (where it stands and what moves it on, in a ' +
|
|
300
309
|
'sentence) and criteria, how many of its acceptance criteria are met ' +
|
|
301
310
|
'(null before it is planned; met counts the criteria the agent confirmed with evidence or a person checked ' +
|
|
@@ -457,7 +466,7 @@ function registerMove(server: McpServer, client: TaigaClient) {
|
|
|
457
466
|
{
|
|
458
467
|
title: 'Move initiative on the board',
|
|
459
468
|
description:
|
|
460
|
-
'Move an initiative on the board, or order it within a column: backlog and todo are yours and the line ' +
|
|
469
|
+
'Move an initiative on the board, take it off the line, or order it within a column: backlog and todo are yours and the line ' +
|
|
461
470
|
'never moves an initiative out of them; queue puts it in the line (taiga_queue_initiative says more); ' +
|
|
462
471
|
'done and canceled finish it by hand. The queue is the only way to start work: there is no "plan this ' +
|
|
463
472
|
'now", and nothing is moved into Build by a person. Moving an initiative out of Build stops the plan ' +
|
|
@@ -678,7 +687,7 @@ function registerResume(server: McpServer, client: TaigaClient) {
|
|
|
678
687
|
{
|
|
679
688
|
title: 'Resume initiative',
|
|
680
689
|
description:
|
|
681
|
-
'
|
|
690
|
+
'Carry on an initiative the line stopped on, which is when needsHumanCause is set (the line sets it): a ' +
|
|
682
691
|
'failed or cancelled plan is planned again, a stopped or failed run resumes from its first unfinished ' +
|
|
683
692
|
'part (the steps already committed are kept), a refused build start is cleared and retried. This is the ' +
|
|
684
693
|
'way to carry work on: a run is the record of an attempt, and decisions about the work are made on the ' +
|