@taiga-ai/mcp-server 0.25.0-dev.471 → 0.25.0-dev.475
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 +42 -14
- package/package.json +1 -1
- package/src/client.ts +5 -4
- package/src/server.integration.test.ts +140 -1
- package/src/tools/backlogs.ts +36 -0
- package/src/tools/catalogue.ts +12 -5
- package/src/tools/maintenance.ts +10 -3
- package/src/tools/readiness.ts +2 -2
- package/src/tools/setup.ts +3 -2
- package/taiga-ai-mcp-server-0.25.0-dev.475.tgz +0 -0
- package/taiga-ai-mcp-server-0.25.0-dev.471.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -356,11 +356,11 @@ var TaigaClient = class {
|
|
|
356
356
|
async getPolicyStanding(productId) {
|
|
357
357
|
return this.request("GET", `/products/${seg(productId)}/maintenance/policies`);
|
|
358
358
|
}
|
|
359
|
-
async remediatePolicyControl(productId, controlId) {
|
|
360
|
-
return this.request("POST", `/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`,
|
|
359
|
+
async remediatePolicyControl(productId, controlId, body = {}) {
|
|
360
|
+
return this.request("POST", `/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`, body);
|
|
361
361
|
}
|
|
362
|
-
async remediatePolicyViolation(productId, divergenceId) {
|
|
363
|
-
return this.request("POST", `/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`,
|
|
362
|
+
async remediatePolicyViolation(productId, divergenceId, body = {}) {
|
|
363
|
+
return this.request("POST", `/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`, body);
|
|
364
364
|
}
|
|
365
365
|
async listInbox(opts) {
|
|
366
366
|
const { productId, ...rest } = opts;
|
|
@@ -2256,6 +2256,11 @@ function emptyOriginNote(origin, product) {
|
|
|
2256
2256
|
}
|
|
2257
2257
|
}
|
|
2258
2258
|
/**
|
|
2259
|
+
* What finishing does, in every next step that ends at it (PRO-2857): it
|
|
2260
|
+
* starts paid planning, so a client must ask the person before calling it.
|
|
2261
|
+
*/
|
|
2262
|
+
const FINISH_DISCOVERY = "taiga_finish_discovery opens the product and starts planning its first initiatives (paid agent work: ask the person first).";
|
|
2263
|
+
/**
|
|
2259
2264
|
* What a product in setup waits for, as the next call to make. Nothing on a
|
|
2260
2265
|
* draft product says it otherwise: discoveryStep names the step and
|
|
2261
2266
|
* importStatus the import, and the way on from each lives in tool
|
|
@@ -2266,17 +2271,17 @@ function setupNextStep(product) {
|
|
|
2266
2271
|
if (product?.status !== "draft") return null;
|
|
2267
2272
|
if (isImportedProduct(product)) switch (importStatusOf(product)) {
|
|
2268
2273
|
case "analyzing":
|
|
2269
|
-
case "generating_docs": return
|
|
2270
|
-
case "complete": return "The import is done: review the reality documents (taiga_get_product_document without type), then taiga_finish_discovery opens the product.";
|
|
2274
|
+
case "generating_docs": return `The import is running and derives the documents from the code (taiga_wait_for_job follows its job). Then review them, and ${FINISH_DISCOVERY}`;
|
|
2275
|
+
case "complete": return "The import is done: review the reality documents (taiga_get_product_document without type), then taiga_finish_discovery opens the product and starts planning its first initiatives (paid agent work: ask the person first).";
|
|
2271
2276
|
case "failed":
|
|
2272
2277
|
case "cancelled": return "The last import did not finish: taiga_start_import runs it again.";
|
|
2273
2278
|
default: return "Link its repository if it has none (taiga_list_linkable_repositories, taiga_link_repository), then taiga_start_import: the import derives the documents from the code and runs by itself.";
|
|
2274
2279
|
}
|
|
2275
2280
|
if (product.discoveryStep === void 0) return null;
|
|
2276
2281
|
switch (product.discoveryStep) {
|
|
2277
|
-
case "documents": return
|
|
2278
|
-
case "checks": return
|
|
2279
|
-
default: return
|
|
2282
|
+
case "documents": return `The specification is done; the other documents are generated from it (taiga_generate_product_documents starts the ones still missing). Once every required one is published, ${FINISH_DISCOVERY}`;
|
|
2283
|
+
case "checks": return `The documents are published; the checks are stamped in the web, then ${FINISH_DISCOVERY}`;
|
|
2284
|
+
default: return `Discovery is a conversation held in the web: the person describes the product there and the specification comes out of it. The other documents follow, then ${FINISH_DISCOVERY}`;
|
|
2280
2285
|
}
|
|
2281
2286
|
}
|
|
2282
2287
|
/** What discoveryStep says, for a description that shows it. */
|
|
@@ -2823,7 +2828,7 @@ async function setupReadiness(client, product, links) {
|
|
|
2823
2828
|
};
|
|
2824
2829
|
}
|
|
2825
2830
|
function nextStepFor(state) {
|
|
2826
|
-
const finish = "taiga_finish_discovery opens the product
|
|
2831
|
+
const finish = "taiga_finish_discovery opens the product, locks its documents and starts planning the first initiatives (paid agent work; ask the person: it is their decision that the documents are right).";
|
|
2827
2832
|
if (state.imported) {
|
|
2828
2833
|
if (state.linked === false) return "Link its repository (taiga_list_linkable_repositories, then taiga_link_repository), then taiga_start_import.";
|
|
2829
2834
|
if (state.linked === null && state.importState === "not started") return setupNextStep(state.product);
|
|
@@ -4284,6 +4289,7 @@ function situationOf(item) {
|
|
|
4284
4289
|
const handback = handbackSaid(build.reviewEscalation);
|
|
4285
4290
|
if (handback) stage += `, handed back because ${handback}`;
|
|
4286
4291
|
}
|
|
4292
|
+
if (life.stage === "done" || life.stage === "canceled") stage += endingSaid(item);
|
|
4287
4293
|
parts.push(`${stage}.`);
|
|
4288
4294
|
if (typeof item["needsHumanCause"] === "string") {
|
|
4289
4295
|
const why = [renderHalt({
|
|
@@ -4301,6 +4307,26 @@ function situationOf(item) {
|
|
|
4301
4307
|
else if (move) parts.push(`Next: ${move}.`);
|
|
4302
4308
|
return parts.join(" ");
|
|
4303
4309
|
}
|
|
4310
|
+
/**
|
|
4311
|
+
* How a settled initiative ended, as the rest of its "Done" or "Canceled"
|
|
4312
|
+
* sentence (PRO-2821): the pull request that landed it, the move that settled
|
|
4313
|
+
* it and who made it, and what took over. Only the single read carries
|
|
4314
|
+
* `ending`; a list row says nothing more.
|
|
4315
|
+
*/
|
|
4316
|
+
function endingSaid(item) {
|
|
4317
|
+
const ending = item["ending"];
|
|
4318
|
+
const merged = typeof ending?.landedPr?.number === "number" ? ending.landedPr.number : null;
|
|
4319
|
+
const said = [];
|
|
4320
|
+
if (merged !== null) said.push(`: PR #${merged} merged`);
|
|
4321
|
+
else if (ending && (ending.via === "moved" || ending.via === "off_the_line")) {
|
|
4322
|
+
const who = typeof ending.by?.name === "string" ? ending.by.name : ending.by?.type === "system" ? "Taiga" : "someone";
|
|
4323
|
+
const when = typeof ending.at === "string" ? ` on ${ending.at.slice(0, 10)}` : "";
|
|
4324
|
+
said.push(ending.via === "off_the_line" ? ` by ${who}${when}, who took it off the line` : ` by ${who}${when}`);
|
|
4325
|
+
}
|
|
4326
|
+
const next = ending?.supersededBy;
|
|
4327
|
+
if (next && typeof next.id === "string") said.push(`; superseded by "${String(next.title)}" (${next.id})`);
|
|
4328
|
+
return said.join("");
|
|
4329
|
+
}
|
|
4304
4330
|
/** A row or item with its situation added when it has a lifecycle. */
|
|
4305
4331
|
function withSituation(item, row = item) {
|
|
4306
4332
|
const situation = situationOf(item);
|
|
@@ -5018,16 +5044,18 @@ function registerMaintenanceTools(server, client) {
|
|
|
5018
5044
|
}));
|
|
5019
5045
|
server.registerTool("taiga_fix_policy_violation", {
|
|
5020
5046
|
title: "Fix policy violations",
|
|
5021
|
-
description: "Raise one initiative that fixes a product's policy violations and add it to the end of the line. Name a control to cover all its violations with no initiative yet, or one violationId when the control has several. The violation stays listed until the next assessment no longer finds it, even while the fix is under way.",
|
|
5047
|
+
description: "Raise one initiative that fixes a product's policy violations and add it to the end of the line. Name a control to cover all its violations with no initiative yet, or one violationId when the control has several. The violation stays listed until the next assessment no longer finds it, even while the fix is under way. A violation whose last fix was done after it was last assessed is refused: refresh the reality documents to reassess it first, or pass fixAgain: true if the person wants another fix anyway.",
|
|
5022
5048
|
inputSchema: z.object({
|
|
5023
5049
|
productId,
|
|
5024
5050
|
controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
|
|
5025
|
-
violationId: idInput("violation", "taiga_get_policy_standing gives it").optional()
|
|
5051
|
+
violationId: idInput("violation", "taiga_get_policy_standing gives it").optional(),
|
|
5052
|
+
fixAgain: z.boolean().optional().describe("Raise a fix even though the last one was done after the last assessment (default false)")
|
|
5026
5053
|
}),
|
|
5027
5054
|
annotations: OPERATE
|
|
5028
5055
|
}, (params) => runTool(async () => {
|
|
5029
5056
|
if (params.controlId === void 0 === (params.violationId === void 0)) return toolError("VALIDATION_ERROR", "Name either a control (controlId) or one violation (violationId), not both or neither.");
|
|
5030
|
-
|
|
5057
|
+
const body = { refuseIfFixedSinceAssessment: params.fixAgain !== true };
|
|
5058
|
+
return toolResult((params.controlId ? await client.remediatePolicyControl(params.productId, params.controlId, body) : await client.remediatePolicyViolation(params.productId, params.violationId, body)).data);
|
|
5031
5059
|
}));
|
|
5032
5060
|
}
|
|
5033
5061
|
//#endregion
|
|
@@ -21649,7 +21677,7 @@ function registerSetupTools(server, client) {
|
|
|
21649
21677
|
}, (params) => runTool(async () => toolResult((await client.refreshRealityDocuments(params.productId, params.docTypes)).data)));
|
|
21650
21678
|
server.registerTool("taiga_finish_discovery", {
|
|
21651
21679
|
title: "Finish Discovery",
|
|
21652
|
-
description: "Finish a product's Discovery: it locks the documents
|
|
21680
|
+
description: "Finish a product's Discovery: it locks the documents, opens the rest of the product and starts planning the first initiatives (paid agent work; an imported product plans once its scan and policy assessment settle). Refused (CONFLICT) with details.reason: documents_incomplete (details.missingDocuments lists them and how they are made), not_in_setup (already finished), or a required check not yet stamped (stamped in the web). A locked document changes only after taiga_reopen_discovery. The person decides the documents are right: ask first.",
|
|
21653
21681
|
inputSchema: z.object({ productId }),
|
|
21654
21682
|
annotations: OPERATE
|
|
21655
21683
|
}, (params) => runTool(async () => {
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
MonitoringHistory,
|
|
32
32
|
MoveProductResult,
|
|
33
33
|
PolicyConformance,
|
|
34
|
+
PolicyRemediationInput,
|
|
34
35
|
PolicyRemediationResult,
|
|
35
36
|
Product,
|
|
36
37
|
ProductDocument,
|
|
@@ -647,19 +648,19 @@ export class TaigaClient {
|
|
|
647
648
|
return this.request<One<PolicyConformance>>('GET', `/products/${seg(productId)}/maintenance/policies`);
|
|
648
649
|
}
|
|
649
650
|
|
|
650
|
-
async remediatePolicyControl(productId: string, controlId: string) {
|
|
651
|
+
async remediatePolicyControl(productId: string, controlId: string, body: PolicyRemediationInput = {}) {
|
|
651
652
|
return this.request<One<PolicyRemediationResult>>(
|
|
652
653
|
'POST',
|
|
653
654
|
`/products/${seg(productId)}/maintenance/policies/controls/${seg(controlId)}/remediate`,
|
|
654
|
-
|
|
655
|
+
body
|
|
655
656
|
);
|
|
656
657
|
}
|
|
657
658
|
|
|
658
|
-
async remediatePolicyViolation(productId: string, divergenceId: string) {
|
|
659
|
+
async remediatePolicyViolation(productId: string, divergenceId: string, body: PolicyRemediationInput = {}) {
|
|
659
660
|
return this.request<One<PolicyRemediationResult>>(
|
|
660
661
|
'POST',
|
|
661
662
|
`/products/${seg(productId)}/maintenance/policies/divergences/${seg(divergenceId)}/remediate`,
|
|
662
|
-
|
|
663
|
+
body
|
|
663
664
|
);
|
|
664
665
|
}
|
|
665
666
|
|
|
@@ -5189,7 +5189,7 @@ describe('MCP server integration', () => {
|
|
|
5189
5189
|
);
|
|
5190
5190
|
await client.callTool({
|
|
5191
5191
|
name: 'taiga_fix_policy_violation',
|
|
5192
|
-
arguments: { productId: PRODUCT_ID, violationId: VIOLATION_ID },
|
|
5192
|
+
arguments: { productId: PRODUCT_ID, violationId: VIOLATION_ID, fixAgain: true },
|
|
5193
5193
|
});
|
|
5194
5194
|
const both = await client.callTool({
|
|
5195
5195
|
name: 'taiga_fix_policy_violation',
|
|
@@ -5201,6 +5201,19 @@ describe('MCP server integration', () => {
|
|
|
5201
5201
|
`/v1/products/${PRODUCT_ID}/maintenance/policies/controls/SEC-3.4/remediate`,
|
|
5202
5202
|
`/v1/products/${PRODUCT_ID}/maintenance/policies/divergences/${VIOLATION_ID}/remediate`,
|
|
5203
5203
|
]);
|
|
5204
|
+
// An unassessed last fix is refused unless the person wants another (PRO-2821).
|
|
5205
|
+
expect(
|
|
5206
|
+
sentBody(fetchSpy, 'POST', `/v1/products/${PRODUCT_ID}/maintenance/policies/controls/SEC-3.4/remediate`)
|
|
5207
|
+
).toEqual({
|
|
5208
|
+
refuseIfFixedSinceAssessment: true,
|
|
5209
|
+
});
|
|
5210
|
+
expect(
|
|
5211
|
+
sentBody(
|
|
5212
|
+
fetchSpy,
|
|
5213
|
+
'POST',
|
|
5214
|
+
`/v1/products/${PRODUCT_ID}/maintenance/policies/divergences/${VIOLATION_ID}/remediate`
|
|
5215
|
+
)
|
|
5216
|
+
).toEqual({ refuseIfFixedSinceAssessment: false });
|
|
5204
5217
|
expect(both.isError).toBe(true);
|
|
5205
5218
|
});
|
|
5206
5219
|
|
|
@@ -7683,6 +7696,132 @@ describe('what the third agent review found (PRO-2800)', () => {
|
|
|
7683
7696
|
expect(runs[3]).toHaveProperty('prDraft', true);
|
|
7684
7697
|
});
|
|
7685
7698
|
|
|
7699
|
+
it('says how a settled initiative ended: the merged PR, who canceled it and what took over (PRO-2821)', async () => {
|
|
7700
|
+
const NEXT = '0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4f21';
|
|
7701
|
+
const canceled = {
|
|
7702
|
+
id: ITEM_ID,
|
|
7703
|
+
title: 'SDLC-3.5: No secret detection',
|
|
7704
|
+
status: 'canceled',
|
|
7705
|
+
state: 'canceled',
|
|
7706
|
+
needsHumanCause: null,
|
|
7707
|
+
lifecycle: { turn: null, stage: 'canceled', cta: null, planFailureCause: null, latestBuild: null },
|
|
7708
|
+
ending: {
|
|
7709
|
+
status: 'canceled',
|
|
7710
|
+
via: 'off_the_line',
|
|
7711
|
+
at: '2026-10-08T05:36:08.000Z',
|
|
7712
|
+
by: { type: 'user', id: 'u1', name: 'Riley' },
|
|
7713
|
+
landedPr: null,
|
|
7714
|
+
supersededBy: { id: NEXT, title: 'SDLC-3.5: No secret detection' },
|
|
7715
|
+
},
|
|
7716
|
+
};
|
|
7717
|
+
const done = {
|
|
7718
|
+
id: NEXT,
|
|
7719
|
+
title: 'SDLC-3.5: No secret detection',
|
|
7720
|
+
status: 'done',
|
|
7721
|
+
state: 'done',
|
|
7722
|
+
needsHumanCause: null,
|
|
7723
|
+
lifecycle: {
|
|
7724
|
+
turn: null,
|
|
7725
|
+
stage: 'done',
|
|
7726
|
+
cta: 'view_pr',
|
|
7727
|
+
planFailureCause: null,
|
|
7728
|
+
latestBuild: { status: 'completed', prNumber: 20, prMerged: true },
|
|
7729
|
+
},
|
|
7730
|
+
};
|
|
7731
|
+
const { client } = await bootMcpPair({
|
|
7732
|
+
capabilities: ['backlog:read'],
|
|
7733
|
+
routes: {
|
|
7734
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: canceled },
|
|
7735
|
+
[`/v1/products/${PRODUCT_ID}/backlog`]: { data: [done], nextCursor: null },
|
|
7736
|
+
},
|
|
7737
|
+
});
|
|
7738
|
+
|
|
7739
|
+
const got = parseToolResult(
|
|
7740
|
+
await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
7741
|
+
);
|
|
7742
|
+
const listed = parseToolResult(
|
|
7743
|
+
await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID, status: 'all' } })
|
|
7744
|
+
);
|
|
7745
|
+
|
|
7746
|
+
expect(got.situation).toBe(
|
|
7747
|
+
`Canceled by Riley on 2026-10-08, who took it off the line; superseded by "SDLC-3.5: No secret detection" (${NEXT}).`
|
|
7748
|
+
);
|
|
7749
|
+
// A list row has no ending: its latest run cannot say how it ended.
|
|
7750
|
+
expect(listed.data[0].situation).toMatch(/^Done\./);
|
|
7751
|
+
expect(listed.data[0].situation).not.toMatch(/merged/);
|
|
7752
|
+
});
|
|
7753
|
+
|
|
7754
|
+
it('names the pull request a merge landed on the single read (PRO-2821)', async () => {
|
|
7755
|
+
const done = {
|
|
7756
|
+
id: ITEM_ID,
|
|
7757
|
+
title: 'Add login',
|
|
7758
|
+
status: 'done',
|
|
7759
|
+
state: 'done',
|
|
7760
|
+
needsHumanCause: null,
|
|
7761
|
+
lifecycle: { turn: null, stage: 'done', cta: 'view_pr', planFailureCause: null, latestBuild: null },
|
|
7762
|
+
ending: {
|
|
7763
|
+
status: 'done',
|
|
7764
|
+
via: 'merged',
|
|
7765
|
+
at: null,
|
|
7766
|
+
by: null,
|
|
7767
|
+
landedPr: { number: 20, url: 'https://x/20' },
|
|
7768
|
+
supersededBy: null,
|
|
7769
|
+
},
|
|
7770
|
+
};
|
|
7771
|
+
const { client } = await bootMcpPair({
|
|
7772
|
+
capabilities: ['backlog:read'],
|
|
7773
|
+
routes: { [`GET /v1/backlog/${ITEM_ID}`]: { data: done } },
|
|
7774
|
+
});
|
|
7775
|
+
|
|
7776
|
+
const got = parseToolResult(
|
|
7777
|
+
await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
7778
|
+
);
|
|
7779
|
+
|
|
7780
|
+
expect(got.situation).toMatch(/^Done: PR #20 merged\./);
|
|
7781
|
+
});
|
|
7782
|
+
|
|
7783
|
+
it('does not read a merge into a canceled initiative that kept its merged run (PRO-2821)', async () => {
|
|
7784
|
+
const item = {
|
|
7785
|
+
id: ITEM_ID,
|
|
7786
|
+
title: 'Add login',
|
|
7787
|
+
status: 'canceled',
|
|
7788
|
+
state: 'canceled',
|
|
7789
|
+
needsHumanCause: null,
|
|
7790
|
+
lifecycle: {
|
|
7791
|
+
turn: null,
|
|
7792
|
+
stage: 'canceled',
|
|
7793
|
+
cta: null,
|
|
7794
|
+
planFailureCause: null,
|
|
7795
|
+
latestBuild: { status: 'completed', prNumber: 20, prMerged: true },
|
|
7796
|
+
},
|
|
7797
|
+
};
|
|
7798
|
+
const ending = {
|
|
7799
|
+
status: 'canceled',
|
|
7800
|
+
via: 'moved',
|
|
7801
|
+
at: '2026-10-09T10:00:00.000Z',
|
|
7802
|
+
by: { type: 'user', name: 'Riley' },
|
|
7803
|
+
landedPr: null,
|
|
7804
|
+
supersededBy: null,
|
|
7805
|
+
};
|
|
7806
|
+
const { client } = await bootMcpPair({
|
|
7807
|
+
capabilities: ['backlog:read'],
|
|
7808
|
+
routes: {
|
|
7809
|
+
[`GET /v1/backlog/${ITEM_ID}`]: { data: { ...item, ending } },
|
|
7810
|
+
[`/v1/products/${PRODUCT_ID}/backlog`]: { data: [item], nextCursor: null },
|
|
7811
|
+
},
|
|
7812
|
+
});
|
|
7813
|
+
|
|
7814
|
+
const got = parseToolResult(
|
|
7815
|
+
await client.callTool({ name: 'taiga_get_initiative', arguments: { initiativeId: ITEM_ID } })
|
|
7816
|
+
);
|
|
7817
|
+
const listed = parseToolResult(
|
|
7818
|
+
await client.callTool({ name: 'taiga_list_initiatives', arguments: { productId: PRODUCT_ID, status: 'all' } })
|
|
7819
|
+
);
|
|
7820
|
+
|
|
7821
|
+
expect(got.situation).toBe('Canceled by Riley on 2026-10-09.');
|
|
7822
|
+
expect(listed.data[0].situation).toBe('Canceled.');
|
|
7823
|
+
});
|
|
7824
|
+
|
|
7686
7825
|
it('says where an initiative stands and the call that moves it on, in one sentence', async () => {
|
|
7687
7826
|
const item = {
|
|
7688
7827
|
id: ITEM_ID,
|
package/src/tools/backlogs.ts
CHANGED
|
@@ -134,6 +134,7 @@ export function situationOf(item: Record<string, unknown>): string | null {
|
|
|
134
134
|
const handback = handbackSaid((build as { reviewEscalation?: unknown }).reviewEscalation);
|
|
135
135
|
if (handback) stage += `, handed back because ${handback}`;
|
|
136
136
|
}
|
|
137
|
+
if (life.stage === 'done' || life.stage === 'canceled') stage += endingSaid(item);
|
|
137
138
|
parts.push(`${stage}.`);
|
|
138
139
|
if (typeof item['needsHumanCause'] === 'string') {
|
|
139
140
|
const halt = renderHalt({ cause: item['needsHumanCause'], params: {} } as unknown as Halt);
|
|
@@ -160,6 +161,41 @@ export function situationOf(item: Record<string, unknown>): string | null {
|
|
|
160
161
|
return parts.join(' ');
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
/**
|
|
165
|
+
* How a settled initiative ended, as the rest of its "Done" or "Canceled"
|
|
166
|
+
* sentence (PRO-2821): the pull request that landed it, the move that settled
|
|
167
|
+
* it and who made it, and what took over. Only the single read carries
|
|
168
|
+
* `ending`; a list row says nothing more.
|
|
169
|
+
*/
|
|
170
|
+
function endingSaid(item: Record<string, unknown>) {
|
|
171
|
+
const ending = item['ending'] as
|
|
172
|
+
| {
|
|
173
|
+
via?: unknown;
|
|
174
|
+
at?: unknown;
|
|
175
|
+
by?: { name?: unknown; type?: unknown } | null;
|
|
176
|
+
landedPr?: { number?: unknown } | null;
|
|
177
|
+
supersededBy?: { id?: unknown; title?: unknown } | null;
|
|
178
|
+
}
|
|
179
|
+
| null
|
|
180
|
+
| undefined;
|
|
181
|
+
// The single read's ending decides. A list row has none, and its latest run cannot say how it ended: a merged
|
|
182
|
+
// initiative can be reopened and moved to Done by hand, keeping that run. It says Done or Canceled alone.
|
|
183
|
+
const merged = typeof ending?.landedPr?.number === 'number' ? ending.landedPr.number : null;
|
|
184
|
+
const said: string[] = [];
|
|
185
|
+
if (merged !== null) said.push(`: PR #${merged} merged`);
|
|
186
|
+
else if (ending && (ending.via === 'moved' || ending.via === 'off_the_line')) {
|
|
187
|
+
const who =
|
|
188
|
+
typeof ending.by?.name === 'string' ? ending.by.name : ending.by?.type === 'system' ? 'Taiga' : 'someone';
|
|
189
|
+
const when = typeof ending.at === 'string' ? ` on ${ending.at.slice(0, 10)}` : '';
|
|
190
|
+
said.push(ending.via === 'off_the_line' ? ` by ${who}${when}, who took it off the line` : ` by ${who}${when}`);
|
|
191
|
+
}
|
|
192
|
+
const next = ending?.supersededBy;
|
|
193
|
+
if (next && typeof next.id === 'string') {
|
|
194
|
+
said.push(`; superseded by "${String(next.title)}" (${next.id})`);
|
|
195
|
+
}
|
|
196
|
+
return said.join('');
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
/** A row or item with its situation added when it has a lifecycle. */
|
|
164
200
|
export function withSituation(
|
|
165
201
|
item: Record<string, unknown>,
|
package/src/tools/catalogue.ts
CHANGED
|
@@ -161,6 +161,13 @@ export function emptyOriginNote(
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* What finishing does, in every next step that ends at it (PRO-2857): it
|
|
166
|
+
* starts paid planning, so a client must ask the person before calling it.
|
|
167
|
+
*/
|
|
168
|
+
const FINISH_DISCOVERY =
|
|
169
|
+
'taiga_finish_discovery opens the product and starts planning its first initiatives (paid agent work: ask the person first).';
|
|
170
|
+
|
|
164
171
|
/**
|
|
165
172
|
* What a product in setup waits for, as the next call to make. Nothing on a
|
|
166
173
|
* draft product says it otherwise: discoveryStep names the step and
|
|
@@ -178,12 +185,12 @@ export function setupNextStep(
|
|
|
178
185
|
case 'generating_docs':
|
|
179
186
|
return (
|
|
180
187
|
'The import is running and derives the documents from the code (taiga_wait_for_job follows its job). ' +
|
|
181
|
-
|
|
188
|
+
`Then review them, and ${FINISH_DISCOVERY}`
|
|
182
189
|
);
|
|
183
190
|
case 'complete':
|
|
184
191
|
return (
|
|
185
192
|
'The import is done: review the reality documents (taiga_get_product_document without type), then ' +
|
|
186
|
-
|
|
193
|
+
FINISH_DISCOVERY
|
|
187
194
|
);
|
|
188
195
|
case 'failed':
|
|
189
196
|
case 'cancelled':
|
|
@@ -204,14 +211,14 @@ export function setupNextStep(
|
|
|
204
211
|
return (
|
|
205
212
|
'The specification is done; the other documents are generated from it ' +
|
|
206
213
|
'(taiga_generate_product_documents starts the ones still missing). Once every required one is ' +
|
|
207
|
-
|
|
214
|
+
`published, ${FINISH_DISCOVERY}`
|
|
208
215
|
);
|
|
209
216
|
case 'checks':
|
|
210
|
-
return
|
|
217
|
+
return `The documents are published; the checks are stamped in the web, then ${FINISH_DISCOVERY}`;
|
|
211
218
|
default:
|
|
212
219
|
return (
|
|
213
220
|
'Discovery is a conversation held in the web: the person describes the product there and the ' +
|
|
214
|
-
|
|
221
|
+
`specification comes out of it. The other documents follow, then ${FINISH_DISCOVERY}`
|
|
215
222
|
);
|
|
216
223
|
}
|
|
217
224
|
}
|
package/src/tools/maintenance.ts
CHANGED
|
@@ -515,11 +515,16 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
515
515
|
"Raise one initiative that fixes a product's policy violations and add it to the end of the line. Name a " +
|
|
516
516
|
'control to cover all its violations with no initiative yet, or one violationId when the control has ' +
|
|
517
517
|
'several. The violation stays listed until the next assessment no longer finds it, even while the fix is ' +
|
|
518
|
-
'under way.'
|
|
518
|
+
'under way. A violation whose last fix was done after it was last assessed is refused: refresh the ' +
|
|
519
|
+
'reality documents to reassess it first, or pass fixAgain: true if the person wants another fix anyway.',
|
|
519
520
|
inputSchema: z.object({
|
|
520
521
|
productId,
|
|
521
522
|
controlId: z.string().min(1).max(40).optional().describe("The control's identifier, e.g. SEC-3.4"),
|
|
522
523
|
violationId: idInput('violation', 'taiga_get_policy_standing gives it').optional(),
|
|
524
|
+
fixAgain: z
|
|
525
|
+
.boolean()
|
|
526
|
+
.optional()
|
|
527
|
+
.describe('Raise a fix even though the last one was done after the last assessment (default false)'),
|
|
523
528
|
}),
|
|
524
529
|
annotations: OPERATE,
|
|
525
530
|
},
|
|
@@ -531,9 +536,11 @@ export function registerMaintenanceTools(server: McpServer, client: TaigaClient)
|
|
|
531
536
|
'Name either a control (controlId) or one violation (violationId), not both or neither.'
|
|
532
537
|
);
|
|
533
538
|
}
|
|
539
|
+
// The API refuses a violation whose last fix is unassessed, unless the person wants another (PRO-2821).
|
|
540
|
+
const body = { refuseIfFixedSinceAssessment: params.fixAgain !== true };
|
|
534
541
|
const result = params.controlId
|
|
535
|
-
? await client.remediatePolicyControl(params.productId, params.controlId)
|
|
536
|
-
: await client.remediatePolicyViolation(params.productId, params.violationId
|
|
542
|
+
? await client.remediatePolicyControl(params.productId, params.controlId, body)
|
|
543
|
+
: await client.remediatePolicyViolation(params.productId, params.violationId!, body);
|
|
537
544
|
return toolResult(result.data);
|
|
538
545
|
})
|
|
539
546
|
);
|
package/src/tools/readiness.ts
CHANGED
|
@@ -127,8 +127,8 @@ function nextStepFor(state: {
|
|
|
127
127
|
product: Row;
|
|
128
128
|
}): string | null {
|
|
129
129
|
const finish =
|
|
130
|
-
'taiga_finish_discovery opens the product
|
|
131
|
-
'that the documents are right).';
|
|
130
|
+
'taiga_finish_discovery opens the product, locks its documents and starts planning the first initiatives ' +
|
|
131
|
+
'(paid agent work; ask the person: it is their decision that the documents are right).';
|
|
132
132
|
if (state.imported) {
|
|
133
133
|
if (state.linked === false) {
|
|
134
134
|
return 'Link its repository (taiga_list_linkable_repositories, then taiga_link_repository), then taiga_start_import.';
|
package/src/tools/setup.ts
CHANGED
|
@@ -386,8 +386,9 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
386
386
|
{
|
|
387
387
|
title: 'Finish Discovery',
|
|
388
388
|
description:
|
|
389
|
-
"Finish a product's Discovery: it locks the documents
|
|
390
|
-
'
|
|
389
|
+
"Finish a product's Discovery: it locks the documents, opens the rest of the product and starts planning " +
|
|
390
|
+
'the first initiatives (paid agent work; an imported product plans once its scan and policy assessment ' +
|
|
391
|
+
'settle). Refused (CONFLICT) with details.reason: documents_incomplete ' +
|
|
391
392
|
'(details.missingDocuments lists them and how they are made), not_in_setup (already finished), or a ' +
|
|
392
393
|
'required check not yet stamped (stamped in the web). A locked document changes only after ' +
|
|
393
394
|
'taiga_reopen_discovery. The person decides the documents are right: ask first.',
|
|
Binary file
|
|
Binary file
|