@taiga-ai/mcp-server 0.25.0-dev.465 → 0.25.0-dev.467
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 +96 -75
- package/package.json +1 -1
- package/src/client.ts +15 -9
- package/src/server.integration.test.ts +118 -63
- package/src/tools/builds.ts +41 -10
- package/src/tools/sections.ts +3 -16
- package/src/tools/setup.ts +56 -64
- package/taiga-ai-mcp-server-0.25.0-dev.467.tgz +0 -0
- package/taiga-ai-mcp-server-0.25.0-dev.465.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -670,6 +670,29 @@ const POLICY_TYPE_BY_CONTROL_PREFIX = {
|
|
|
670
670
|
SCS: "policy_supply_chain",
|
|
671
671
|
CLD: "policy_cloud_governance"
|
|
672
672
|
};
|
|
673
|
+
/** The policy document a control id belongs to, or undefined for an unknown prefix. */
|
|
674
|
+
function policyTypeForControl(controlId) {
|
|
675
|
+
return POLICY_TYPE_BY_CONTROL_PREFIX[controlId.split("-")[0] ?? ""];
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* The policy a control id points a reader to (PRO-2809): its rightmost base
|
|
679
|
+
* prefix. An overlay family names its host policy last: TEL-TIC-1.1 is a
|
|
680
|
+
* telecom control in policy_tic, HC-FI-DATA-1.1 a Finnish healthcare control in
|
|
681
|
+
* policy_data, ARC-AWS-5 an AWS preset in policy_architecture. A regulatory
|
|
682
|
+
* family with no base prefix (GDPR, NIS2, AIACT) spans several policies and
|
|
683
|
+
* names none.
|
|
684
|
+
*
|
|
685
|
+
* For saying where to read a control, not for deciding who holds it:
|
|
686
|
+
* {@link policyTypeForControl} keeps an overlay control with the document it is
|
|
687
|
+
* written in, whatever its id says.
|
|
688
|
+
*/
|
|
689
|
+
function policyNamedByControl(controlId) {
|
|
690
|
+
const prefixes = controlId.split("-").filter((part) => /^[A-Z]/.test(part));
|
|
691
|
+
for (let at = prefixes.length - 1; at >= 0; at -= 1) {
|
|
692
|
+
const owner = POLICY_TYPE_BY_CONTROL_PREFIX[prefixes[at]];
|
|
693
|
+
if (owner) return owner;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
673
696
|
/**
|
|
674
697
|
* The shape of a control id in a policy's control table: `SEC-3.4`, and the
|
|
675
698
|
* overlay families too (`TEL-TIC-1.1`, `HC-FI-DATA-1.1`, `GDPR-1.1`). One
|
|
@@ -2663,17 +2686,6 @@ const sectionsInput = z.array(z.string().min(1).max(200)).max(50).optional().des
|
|
|
2663
2686
|
/** The sections input of a company document, where a policy control id also names the sections that state it. */
|
|
2664
2687
|
const policySectionsInput = sectionsInput.describe("Only these sections, by id, or a policy control id (SEC-5.1) for the sections that state it; [] for the outline (under outline: every section id and its size, no text); omit for the whole document");
|
|
2665
2688
|
/**
|
|
2666
|
-
* The policy that states a control: the rightmost policy prefix in its id, so an overlay family's
|
|
2667
|
-
* TEL-TIC-1.1 is policy_tic and HC-FI-DATA-1.1 policy_data, as the overlays file them.
|
|
2668
|
-
*/
|
|
2669
|
-
function policyOfControl(id) {
|
|
2670
|
-
const prefixes = id.split("-").filter((part) => /^[A-Z]/.test(part));
|
|
2671
|
-
for (const prefix of prefixes.reverse()) {
|
|
2672
|
-
const policy = POLICY_TYPE_BY_CONTROL_PREFIX[prefix];
|
|
2673
|
-
if (policy) return policy;
|
|
2674
|
-
}
|
|
2675
|
-
}
|
|
2676
|
-
/**
|
|
2677
2689
|
* The sections that state each control id asked for (PRO-2800): policy
|
|
2678
2690
|
* standing cites SEC-5.1, and the section holding it has an id of its own
|
|
2679
2691
|
* (secretsManagement) an agent could only guess. A control id is found in
|
|
@@ -2699,7 +2711,7 @@ function withControls(outline, sections, policyType) {
|
|
|
2699
2711
|
return outline.map((entry) => {
|
|
2700
2712
|
const text = sectionContent(sections, entry.id) ?? "";
|
|
2701
2713
|
const ids = [...new Set([...text.matchAll(pattern)].filter((match) => !/^_?Reserved_?/i.test(match[2] ?? "")).map((match) => match[1]).filter((id) => {
|
|
2702
|
-
const owner =
|
|
2714
|
+
const owner = policyTypeForControl(id);
|
|
2703
2715
|
return owner === void 0 || owner === policyType;
|
|
2704
2716
|
}))];
|
|
2705
2717
|
return ids.length > 0 ? {
|
|
@@ -2727,7 +2739,7 @@ function sectionReadResult(doc, asked, label, options = {}) {
|
|
|
2727
2739
|
const known = ids.filter((id) => all.includes(id));
|
|
2728
2740
|
const found = Object.keys(controls).length > 0 ? { controls } : {};
|
|
2729
2741
|
const asControl = options.controlIds ? void 0 : unknownSections.find((id) => isControlId(id));
|
|
2730
|
-
const policy = asControl ?
|
|
2742
|
+
const policy = asControl ? policyNamedByControl(asControl) : void 0;
|
|
2731
2743
|
const controlNote = asControl ? { note: policy ? `${asControl} is a control of ${policy}: read it with taiga_get_document (type: ${policy}, sections: ["${asControl}"]).` : `${asControl} looks like a policy control id, which only a policy resolves: taiga_get_document without type lists them.` } : {};
|
|
2732
2744
|
if (known.length === 0) {
|
|
2733
2745
|
const { data: _data, ...meta } = cut.doc;
|
|
@@ -3769,20 +3781,27 @@ const INITIATIVE_CHECKS = 10;
|
|
|
3769
3781
|
* The status of the initiative behind each run still waiting on its pull
|
|
3770
3782
|
* request (PRO-2800). A reviewing agent read a run of a canceled initiative as
|
|
3771
3783
|
* "waiting for a person to review and merge" while Action Required called the
|
|
3772
|
-
* same review stale.
|
|
3773
|
-
*
|
|
3774
|
-
*
|
|
3784
|
+
* same review stale.
|
|
3785
|
+
*
|
|
3786
|
+
* The run says it itself since PRO-2808 (initiativeClosed: done, canceled,
|
|
3787
|
+
* archived, deleted, or null while live), for every key that can read it.
|
|
3788
|
+
* Against an API from before that, each initiative is read: a 404 is
|
|
3789
|
+
* 'deleted', and an initiative past the cap, one whose read failed, and every
|
|
3790
|
+
* one without backlog:read is 'unchecked', which withInitiativeState adds to
|
|
3791
|
+
* the row's outcome.
|
|
3775
3792
|
*/
|
|
3776
3793
|
async function initiativeStates(client, rows) {
|
|
3777
3794
|
const states = /* @__PURE__ */ new Map();
|
|
3778
3795
|
const waiting = rows.filter((row) => ["needs_review", "completed"].includes(String(row["status"])) && row["prMerged"] !== true);
|
|
3779
|
-
const
|
|
3796
|
+
const told = waiting.filter((row) => "initiativeClosed" in row);
|
|
3797
|
+
for (const row of told) if (typeof row["backlogItemId"] === "string") states.set(row["backlogItemId"], typeof row["initiativeClosed"] === "string" ? row["initiativeClosed"] : "live");
|
|
3798
|
+
const all = [...new Set(waiting.filter((row) => !("initiativeClosed" in row)).map((row) => row["backlogItemId"]).filter((id) => typeof id === "string" && !states.has(id)))];
|
|
3780
3799
|
const ids = client.hasCapability("backlog:read") ? all.slice(0, INITIATIVE_CHECKS) : [];
|
|
3781
3800
|
for (const id of all) if (!ids.includes(id)) states.set(id, "unchecked");
|
|
3782
3801
|
await Promise.all(ids.map(async (id) => {
|
|
3783
3802
|
try {
|
|
3784
3803
|
const item = (await client.getBacklogItem(id)).data;
|
|
3785
|
-
states.set(id, String(item["status"] ?? item["state"]));
|
|
3804
|
+
states.set(id, item["archivedAt"] ? "archived" : String(item["status"] ?? item["state"]));
|
|
3786
3805
|
} catch (error) {
|
|
3787
3806
|
if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, "deleted");
|
|
3788
3807
|
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
@@ -3791,7 +3810,18 @@ async function initiativeStates(client, rows) {
|
|
|
3791
3810
|
}));
|
|
3792
3811
|
return states;
|
|
3793
3812
|
}
|
|
3794
|
-
/**
|
|
3813
|
+
/**
|
|
3814
|
+
* How each way an initiative ends reads on a run it left waiting: the same set
|
|
3815
|
+
* Action Required closes a run's review for (PRO-2806). A done one's waiting
|
|
3816
|
+
* run is not the one that landed it: that run is merged and never waiting.
|
|
3817
|
+
*/
|
|
3818
|
+
const INITIATIVE_ENDED = {
|
|
3819
|
+
done: "was marked done",
|
|
3820
|
+
canceled: "was canceled",
|
|
3821
|
+
archived: "was archived",
|
|
3822
|
+
deleted: "was deleted"
|
|
3823
|
+
};
|
|
3824
|
+
/** A readable run row that also says when its initiative ended while its pull request waited. */
|
|
3795
3825
|
function withInitiativeState(row, states) {
|
|
3796
3826
|
const state = states.get(String(row["backlogItemId"]));
|
|
3797
3827
|
if (!["gated", "unmerged"].includes(String(row["disposition"]))) return row;
|
|
@@ -3799,14 +3829,15 @@ function withInitiativeState(row, states) {
|
|
|
3799
3829
|
...row,
|
|
3800
3830
|
outcome: `${String(row["outcome"])} Whether its initiative still wants it was not checked.`
|
|
3801
3831
|
};
|
|
3802
|
-
|
|
3832
|
+
const ended = state ? INITIATIVE_ENDED[state] : void 0;
|
|
3833
|
+
if (!ended) return row;
|
|
3803
3834
|
const pr = typeof row["prNumber"] === "number" ? `PR #${row["prNumber"]}` : "its pull request";
|
|
3804
3835
|
const open = row["prState"] === "open" ? ` ${pr} is still open on the forge.` : "";
|
|
3805
3836
|
return {
|
|
3806
3837
|
...row,
|
|
3807
3838
|
disposition: "abandoned",
|
|
3808
3839
|
actions: [],
|
|
3809
|
-
outcome: `Its initiative
|
|
3840
|
+
outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`
|
|
3810
3841
|
};
|
|
3811
3842
|
}
|
|
3812
3843
|
/** A step that changed only prose: no code of its own could have broken a test. */
|
|
@@ -3913,7 +3944,7 @@ function registerBuildTools(server, client) {
|
|
|
3913
3944
|
if (!client.hasCapability("products:read")) return;
|
|
3914
3945
|
server.registerTool("taiga_list_runs", {
|
|
3915
3946
|
title: "List runs",
|
|
3916
|
-
description: "List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (initiative canceled or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run reads one; carry work on from the initiative (taiga_resume_initiative).",
|
|
3947
|
+
description: "List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (its initiative ended: done, canceled, archived or deleted). Without productId, every product's runs; active: true keeps running ones. taiga_get_run reads one; carry work on from the initiative (taiga_resume_initiative).",
|
|
3917
3948
|
inputSchema: z.object({
|
|
3918
3949
|
productId: productId.optional(),
|
|
3919
3950
|
initiativeId: idInput("initiative", "taiga_list_initiatives (query finds one by title) gives it").optional().describe("Only this initiative's runs -- get from taiga_list_initiatives; needs productId"),
|
|
@@ -21408,41 +21439,29 @@ const MISSING_DOCUMENTS_HOW = "An imported product gets them from its import (ta
|
|
|
21408
21439
|
/**
|
|
21409
21440
|
* Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
|
|
21410
21441
|
* creates a second one, so a retried call or two agents at once left
|
|
21411
|
-
* duplicates nobody asked for.
|
|
21442
|
+
* duplicates nobody asked for. The API checks it in the create's transaction
|
|
21443
|
+
* (PRO-2808), so names past any page and two creates at once are caught too.
|
|
21444
|
+
* allowDuplicateName says the person means it.
|
|
21412
21445
|
*/
|
|
21413
21446
|
const allowDuplicateName = z.boolean().optional().describe("Create it even when one with the same name exists (default false: refused, naming the existing one)");
|
|
21414
|
-
/** The same name, as a person would read it: case and surrounding space aside. */
|
|
21415
|
-
const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
21416
|
-
/** Pages read looking for a taken name; past this the create goes ahead. */
|
|
21417
|
-
const NAME_PAGES = 5;
|
|
21418
21447
|
/**
|
|
21419
|
-
* The
|
|
21420
|
-
*
|
|
21421
|
-
|
|
21422
|
-
|
|
21423
|
-
|
|
21424
|
-
|
|
21425
|
-
|
|
21426
|
-
|
|
21427
|
-
|
|
21428
|
-
|
|
21429
|
-
|
|
21430
|
-
|
|
21431
|
-
|
|
21432
|
-
|
|
21433
|
-
|
|
21434
|
-
|
|
21435
|
-
|
|
21436
|
-
}
|
|
21437
|
-
/**
|
|
21438
|
-
* The refusal names the one that exists and leaves the choice to the person: another session may have made it, so
|
|
21439
|
-
* "use it" would hand the agent someone else's work (PRO-2800, review 6).
|
|
21440
|
-
*/
|
|
21441
|
-
function duplicateName(kind, existing, where) {
|
|
21442
|
-
const made = typeof existing.createdAt === "string" ? `, created ${existing.createdAt}` : "";
|
|
21443
|
-
return toolError("DUPLICATE_NAME", `A ${kind} named "${existing.name}" already exists ${where} (${existing.id}${made}). It may not be this person's: ask whether they meant that one, want another name, or want a second (allowDuplicateName: true).`, {
|
|
21444
|
-
existingId: existing.id,
|
|
21445
|
-
...existing.slug ? { existingSlug: existing.slug } : {}
|
|
21448
|
+
* The API's refusal of a taken name, as the tool's: it names the one that
|
|
21449
|
+
* exists when the caller may read it, and leaves the choice to the person.
|
|
21450
|
+
* Another session may have made it, so "use it" would hand the agent someone
|
|
21451
|
+
* else's work (PRO-2800, review 6). Anything else is not this refusal.
|
|
21452
|
+
*/
|
|
21453
|
+
function duplicateName(kind, error, where) {
|
|
21454
|
+
if (!(error instanceof TaigaApiError) || error.status !== 409 || error.details?.["reason"] !== "duplicate_name") return null;
|
|
21455
|
+
const said = (key) => typeof error.details?.[key] === "string" ? error.details[key] : void 0;
|
|
21456
|
+
const [existingId, existingSlug, name, createdAt] = [
|
|
21457
|
+
said("existingId"),
|
|
21458
|
+
said("existingSlug"),
|
|
21459
|
+
said("existingName"),
|
|
21460
|
+
said("existingCreatedAt")
|
|
21461
|
+
];
|
|
21462
|
+
return toolError("DUPLICATE_NAME", `${name ? `A ${kind} named "${name}"` : `A ${kind} with this name`} already exists ${where}${existingId ? ` (${existingId}${createdAt ? `, created ${createdAt}` : ""})` : ""}. ${existingId ? "It may not be this person's: ask whether they meant that one, want another name, or want a second" : "The caller cannot read it: ask whether they want another name or a second"} (allowDuplicateName: true).`, {
|
|
21463
|
+
...existingId ? { existingId } : {},
|
|
21464
|
+
...existingSlug ? { existingSlug } : {}
|
|
21446
21465
|
});
|
|
21447
21466
|
}
|
|
21448
21467
|
/** A product row the model can act on: how it started, and while in setup what it still needs. */
|
|
@@ -21483,14 +21502,16 @@ function registerSetupTools(server, client) {
|
|
|
21483
21502
|
}),
|
|
21484
21503
|
annotations: WRITE
|
|
21485
21504
|
}, (params) => runTool(async () => {
|
|
21486
|
-
|
|
21487
|
-
|
|
21488
|
-
|
|
21489
|
-
|
|
21490
|
-
}));
|
|
21491
|
-
|
|
21505
|
+
try {
|
|
21506
|
+
return toolResult(pickFields([(await client.createFactory({
|
|
21507
|
+
name: params.name,
|
|
21508
|
+
refuseDuplicateName: !params.allowDuplicateName
|
|
21509
|
+
})).data], FACTORY_FIELDS)[0]);
|
|
21510
|
+
} catch (error) {
|
|
21511
|
+
const refused = duplicateName("factory", error, "in this organization");
|
|
21512
|
+
if (refused) return refused;
|
|
21513
|
+
throw error;
|
|
21492
21514
|
}
|
|
21493
|
-
return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
|
|
21494
21515
|
}));
|
|
21495
21516
|
server.registerTool("taiga_rename_factory", {
|
|
21496
21517
|
title: "Rename a factory",
|
|
@@ -21555,22 +21576,22 @@ function registerSetupTools(server, client) {
|
|
|
21555
21576
|
}, (params) => runTool(async () => {
|
|
21556
21577
|
const contradiction = notesWithNothingToDescribe(params);
|
|
21557
21578
|
if (contradiction) return contradiction;
|
|
21558
|
-
|
|
21559
|
-
|
|
21579
|
+
let product;
|
|
21580
|
+
try {
|
|
21581
|
+
product = (await client.createProduct({
|
|
21582
|
+
name: params.name,
|
|
21560
21583
|
factoryId: params.factoryId,
|
|
21561
|
-
|
|
21562
|
-
|
|
21563
|
-
|
|
21564
|
-
|
|
21584
|
+
...params.start === "import" ? { projectType: "brownfield" } : {},
|
|
21585
|
+
...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
|
|
21586
|
+
...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
|
|
21587
|
+
...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {},
|
|
21588
|
+
refuseDuplicateName: !params.allowDuplicateName
|
|
21589
|
+
})).data;
|
|
21590
|
+
} catch (error) {
|
|
21591
|
+
const refused = duplicateName("product", error, "in this factory");
|
|
21592
|
+
if (refused) return refused;
|
|
21593
|
+
throw error;
|
|
21565
21594
|
}
|
|
21566
|
-
const product = (await client.createProduct({
|
|
21567
|
-
name: params.name,
|
|
21568
|
-
factoryId: params.factoryId,
|
|
21569
|
-
...params.start === "import" ? { projectType: "brownfield" } : {},
|
|
21570
|
-
...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
|
|
21571
|
-
...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
|
|
21572
|
-
...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {}
|
|
21573
|
-
})).data;
|
|
21574
21595
|
return toolResult(await productRow(client, product));
|
|
21575
21596
|
}));
|
|
21576
21597
|
server.registerTool("taiga_update_product", {
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -44,6 +44,8 @@ import type {
|
|
|
44
44
|
SetupTaskListEntry,
|
|
45
45
|
TenantDocument,
|
|
46
46
|
UptimeReport,
|
|
47
|
+
CreateFactoryInput,
|
|
48
|
+
CreateProductInput,
|
|
47
49
|
} from '@taiga/types';
|
|
48
50
|
import { SERVER_VERSION } from './version.js';
|
|
49
51
|
|
|
@@ -844,7 +846,7 @@ export class TaigaClient {
|
|
|
844
846
|
return this.request<Page<Factory>>('GET', `/factories${qs ? `?${qs}` : ''}`);
|
|
845
847
|
}
|
|
846
848
|
|
|
847
|
-
async createFactory(body:
|
|
849
|
+
async createFactory(body: CreateFactoryInput) {
|
|
848
850
|
return this.request<One<Factory>>('POST', '/factories', body);
|
|
849
851
|
}
|
|
850
852
|
|
|
@@ -856,14 +858,18 @@ export class TaigaClient {
|
|
|
856
858
|
// Setting a product up
|
|
857
859
|
// ---------------------------------------------------------------------------
|
|
858
860
|
|
|
859
|
-
async createProduct(
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
861
|
+
async createProduct(
|
|
862
|
+
body: Pick<
|
|
863
|
+
CreateProductInput,
|
|
864
|
+
| 'name'
|
|
865
|
+
| 'factoryId'
|
|
866
|
+
| 'projectType'
|
|
867
|
+
| 'ownsInfrastructure'
|
|
868
|
+
| 'ownsDeployment'
|
|
869
|
+
| 'deploymentNotes'
|
|
870
|
+
| 'refuseDuplicateName'
|
|
871
|
+
>
|
|
872
|
+
) {
|
|
867
873
|
return this.request<One<Product>>('POST', '/products', body);
|
|
868
874
|
}
|
|
869
875
|
|
|
@@ -5790,13 +5790,14 @@ describe('MCP server integration', () => {
|
|
|
5790
5790
|
);
|
|
5791
5791
|
|
|
5792
5792
|
// Following the organization is saying nothing, as the web's dialog does.
|
|
5793
|
-
expect(firstBody).toEqual({ name: 'Billing', factoryId: FACTORY_ID });
|
|
5793
|
+
expect(firstBody).toEqual({ name: 'Billing', factoryId: FACTORY_ID, refuseDuplicateName: true });
|
|
5794
5794
|
expect(sentBody(fetchSpy, 'POST', '/v1/products')).toEqual({
|
|
5795
5795
|
name: 'Billing',
|
|
5796
5796
|
factoryId: FACTORY_ID,
|
|
5797
5797
|
projectType: 'brownfield',
|
|
5798
5798
|
ownsDeployment: false,
|
|
5799
5799
|
deploymentNotes: 'Platform team owns the pipelines',
|
|
5800
|
+
refuseDuplicateName: true,
|
|
5800
5801
|
});
|
|
5801
5802
|
expect(scratch).toMatchObject({ id: PRODUCT_ID, projectType: 'greenfield' });
|
|
5802
5803
|
expect(imported).toMatchObject({ projectType: 'brownfield', ownsDeployment: false });
|
|
@@ -5889,7 +5890,7 @@ describe('MCP server integration', () => {
|
|
|
5889
5890
|
arguments: { productId: PRODUCT_ID, factoryId: OTHER_FACTORY_ID, stopLine: true },
|
|
5890
5891
|
});
|
|
5891
5892
|
|
|
5892
|
-
expect(factoryBody).toEqual({ name: 'Payments' });
|
|
5893
|
+
expect(factoryBody).toEqual({ name: 'Payments', refuseDuplicateName: true });
|
|
5893
5894
|
expect(created).toEqual({ id: OTHER_FACTORY_ID, name: 'Payments', slug: 'payments', isDefault: false });
|
|
5894
5895
|
expect(renamed.name).toBe('Payments EU');
|
|
5895
5896
|
expect(seen).toEqual(preview);
|
|
@@ -7810,58 +7811,142 @@ describe('what the fourth agent review found (PRO-2800)', () => {
|
|
|
7810
7811
|
expect(runs[0]!['actions']).toEqual([]);
|
|
7811
7812
|
});
|
|
7812
7813
|
|
|
7813
|
-
it(
|
|
7814
|
+
it("reads how a run's initiative ended from the run itself, with no backlog read (PRO-2808)", async () => {
|
|
7815
|
+
const base = { productId: PRODUCT_ID, status: 'needs_review', prState: 'open', completedSteps: 3, totalSteps: 3 };
|
|
7814
7816
|
const { client, fetchSpy } = await bootMcpPair({
|
|
7815
|
-
|
|
7817
|
+
// No backlog:read: before PRO-2808 these rows said only that nobody checked.
|
|
7818
|
+
capabilities: ['products:read'],
|
|
7816
7819
|
routes: {
|
|
7817
|
-
|
|
7818
|
-
|
|
7820
|
+
[`/v1/products/${PRODUCT_ID}/builds`]: {
|
|
7821
|
+
data: [
|
|
7822
|
+
{ ...base, id: 'r1', backlogItemId: 'i1', prNumber: 10, initiativeClosed: 'archived' },
|
|
7823
|
+
{ ...base, id: 'r2', backlogItemId: 'i2', prNumber: 11, initiativeClosed: 'done' },
|
|
7824
|
+
{ ...base, id: 'r3', backlogItemId: 'i3', prNumber: 12, initiativeClosed: null },
|
|
7825
|
+
],
|
|
7826
|
+
nextCursor: null,
|
|
7827
|
+
},
|
|
7819
7828
|
},
|
|
7820
7829
|
});
|
|
7821
7830
|
|
|
7822
|
-
const
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
arguments: { name: 'platform', allowDuplicateName: true },
|
|
7826
|
-
});
|
|
7831
|
+
const runs = parseToolResult(
|
|
7832
|
+
await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
|
|
7833
|
+
).data as Record<string, unknown>[];
|
|
7827
7834
|
|
|
7828
|
-
expect(
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7835
|
+
expect(runs.map((run) => [run['disposition'], run['outcome']])).toEqual([
|
|
7836
|
+
[
|
|
7837
|
+
'abandoned',
|
|
7838
|
+
'Its initiative was archived, so nobody is waiting on this run. PR #10 is still open on the forge.',
|
|
7839
|
+
],
|
|
7840
|
+
[
|
|
7841
|
+
'abandoned',
|
|
7842
|
+
'Its initiative was marked done, so nobody is waiting on this run. PR #11 is still open on the forge.',
|
|
7843
|
+
],
|
|
7844
|
+
[runs[2]!['disposition'], expect.not.stringMatching(/not checked|abandoned|nobody/)],
|
|
7845
|
+
]);
|
|
7846
|
+
expect(runs[2]!['disposition']).not.toBe('abandoned');
|
|
7847
|
+
expect(fetchSpy.mock.calls.some(([url]) => String(url).includes('/v1/backlog/'))).toBe(false);
|
|
7833
7848
|
});
|
|
7834
7849
|
|
|
7835
|
-
it('
|
|
7836
|
-
const {
|
|
7837
|
-
|
|
7850
|
+
it('reads an archived initiative as archived from an API that does not say it on the run (PRO-2808)', async () => {
|
|
7851
|
+
const base = { productId: PRODUCT_ID, status: 'needs_review', prState: 'open', completedSteps: 3, totalSteps: 3 };
|
|
7852
|
+
const { client } = await bootMcpPair({
|
|
7853
|
+
capabilities: ['products:read', 'backlog:read'],
|
|
7838
7854
|
routes: {
|
|
7839
|
-
|
|
7840
|
-
data: [{
|
|
7855
|
+
[`/v1/products/${PRODUCT_ID}/builds`]: {
|
|
7856
|
+
data: [{ ...base, id: 'r1', backlogItemId: ITEM_ID, prNumber: 10 }],
|
|
7841
7857
|
nextCursor: null,
|
|
7842
7858
|
},
|
|
7843
|
-
|
|
7844
|
-
|
|
7859
|
+
// Archiving keeps the status the initiative had.
|
|
7860
|
+
[`GET /v1/backlog/${ITEM_ID}`]: {
|
|
7861
|
+
data: { id: ITEM_ID, status: 'in_progress', state: 'build', archivedAt: '2026-10-09T10:00:00.000Z' },
|
|
7845
7862
|
},
|
|
7846
7863
|
},
|
|
7847
7864
|
});
|
|
7848
7865
|
|
|
7849
|
-
const
|
|
7850
|
-
name: '
|
|
7851
|
-
|
|
7866
|
+
const runs = parseToolResult(
|
|
7867
|
+
await client.callTool({ name: 'taiga_list_runs', arguments: { productId: PRODUCT_ID } })
|
|
7868
|
+
).data as Record<string, unknown>[];
|
|
7869
|
+
|
|
7870
|
+
expect(runs[0]).toMatchObject({
|
|
7871
|
+
disposition: 'abandoned',
|
|
7872
|
+
outcome: expect.stringMatching(/^Its initiative was archived/),
|
|
7852
7873
|
});
|
|
7874
|
+
});
|
|
7875
|
+
|
|
7876
|
+
it('asks the API to refuse a taken name, and says so naming the one that exists (PRO-2808)', async () => {
|
|
7877
|
+
const reply = (ok: boolean, status: number, body: unknown) => ({
|
|
7878
|
+
ok,
|
|
7879
|
+
status,
|
|
7880
|
+
json: () => Promise.resolve(body),
|
|
7881
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
7882
|
+
});
|
|
7883
|
+
const taken = reply(false, 409, {
|
|
7884
|
+
error: 'CONFLICT',
|
|
7885
|
+
code: 'CONFLICT',
|
|
7886
|
+
message: 'A factory named "Platform" already exists in this organization.',
|
|
7887
|
+
details: {
|
|
7888
|
+
reason: 'duplicate_name',
|
|
7889
|
+
existingId: FACTORY_ID,
|
|
7890
|
+
existingSlug: 'platform',
|
|
7891
|
+
existingName: 'Platform',
|
|
7892
|
+
existingCreatedAt: '2026-10-10T10:00:00.000Z',
|
|
7893
|
+
},
|
|
7894
|
+
});
|
|
7895
|
+
const { client, fetchSpy } = await bootMcpPair({
|
|
7896
|
+
capabilities: ALL_CAPABILITIES,
|
|
7897
|
+
routes: {
|
|
7898
|
+
'POST /v1/factories': (_url: string, init: RequestInit) =>
|
|
7899
|
+
JSON.parse(init.body as string).refuseDuplicateName
|
|
7900
|
+
? taken
|
|
7901
|
+
: reply(true, 201, { data: { id: 'new', name: 'platform', slug: 'platform-2', isDefault: false } }),
|
|
7902
|
+
},
|
|
7903
|
+
});
|
|
7904
|
+
|
|
7905
|
+
const refused = parseToolResult(
|
|
7906
|
+
await client.callTool({ name: 'taiga_create_factory', arguments: { name: ' platform ' } })
|
|
7907
|
+
);
|
|
7853
7908
|
const made = await client.callTool({
|
|
7854
|
-
name: '
|
|
7855
|
-
arguments: {
|
|
7909
|
+
name: 'taiga_create_factory',
|
|
7910
|
+
arguments: { name: 'platform', allowDuplicateName: true },
|
|
7856
7911
|
});
|
|
7857
7912
|
|
|
7858
|
-
expect(
|
|
7913
|
+
expect(refused).toMatchObject({ error: 'DUPLICATE_NAME', details: { existingId: FACTORY_ID } });
|
|
7914
|
+
expect(refused.message).toBe(
|
|
7915
|
+
`A factory named "Platform" already exists in this organization (${FACTORY_ID}, created ` +
|
|
7916
|
+
"2026-10-10T10:00:00.000Z). It may not be this person's: ask whether they meant that one, want another " +
|
|
7917
|
+
'name, or want a second (allowDuplicateName: true).'
|
|
7918
|
+
);
|
|
7859
7919
|
expect(made.isError).toBeFalsy();
|
|
7860
|
-
const
|
|
7861
|
-
(call: unknown[]) =>
|
|
7862
|
-
|
|
7920
|
+
const bodies = fetchSpy.mock.calls
|
|
7921
|
+
.filter((call: unknown[]) => (call[1] as RequestInit | undefined)?.method === 'POST')
|
|
7922
|
+
.map((call: unknown[]) => JSON.parse((call[1] as RequestInit).body as string));
|
|
7923
|
+
expect(bodies.map((body: Record<string, unknown>) => body['refuseDuplicateName'])).toEqual([true, false]);
|
|
7924
|
+
// No list read first: the API's check is the one that holds.
|
|
7925
|
+
expect(fetchSpy.mock.calls.some((call: unknown[]) => String(call[0]).includes('/v1/factories?'))).toBe(false);
|
|
7926
|
+
});
|
|
7927
|
+
|
|
7928
|
+
it('says a product name is taken without naming the one a caller cannot read', async () => {
|
|
7929
|
+
const { client } = await bootMcpPair({
|
|
7930
|
+
capabilities: ALL_CAPABILITIES,
|
|
7931
|
+
routes: {
|
|
7932
|
+
'POST /v1/products': refusal(409, 'CONFLICT', 'A product with this name already exists in this factory.', {
|
|
7933
|
+
reason: 'duplicate_name',
|
|
7934
|
+
}),
|
|
7935
|
+
},
|
|
7936
|
+
});
|
|
7937
|
+
|
|
7938
|
+
const refused = parseToolResult(
|
|
7939
|
+
await client.callTool({
|
|
7940
|
+
name: 'taiga_create_product',
|
|
7941
|
+
arguments: { factoryId: FACTORY_ID, name: 'WEB', start: 'scratch' },
|
|
7942
|
+
})
|
|
7943
|
+
);
|
|
7944
|
+
|
|
7945
|
+
expect(refused).toMatchObject({ error: 'DUPLICATE_NAME', details: {} });
|
|
7946
|
+
expect(refused.message).toBe(
|
|
7947
|
+
'A product with this name already exists in this factory. The caller cannot read it: ask whether they want ' +
|
|
7948
|
+
'another name or a second (allowDuplicateName: true).'
|
|
7863
7949
|
);
|
|
7864
|
-
expect(posts).toHaveLength(1);
|
|
7865
7950
|
});
|
|
7866
7951
|
|
|
7867
7952
|
it('bounds the full policy standing and reads one violation whole', async () => {
|
|
@@ -8172,36 +8257,6 @@ describe('what the fourth agent review found (PRO-2800)', () => {
|
|
|
8172
8257
|
expect(list.staleNote).toMatch(/^1 review was not checked for staleness/);
|
|
8173
8258
|
});
|
|
8174
8259
|
|
|
8175
|
-
it('looks for a taken product name in its own factory only, and leaves the choice to the person', async () => {
|
|
8176
|
-
const { client, fetchSpy } = await bootMcpPair({
|
|
8177
|
-
capabilities: ALL_CAPABILITIES,
|
|
8178
|
-
routes: {
|
|
8179
|
-
'GET /v1/products': {
|
|
8180
|
-
data: [{ id: PRODUCT_ID, name: 'Web', slug: 'web', createdAt: '2026-10-10T10:00:00Z' }],
|
|
8181
|
-
nextCursor: null,
|
|
8182
|
-
},
|
|
8183
|
-
},
|
|
8184
|
-
});
|
|
8185
|
-
|
|
8186
|
-
const refused = parseToolResult(
|
|
8187
|
-
await client.callTool({
|
|
8188
|
-
name: 'taiga_create_product',
|
|
8189
|
-
arguments: { factoryId: FACTORY_ID, name: 'web', start: 'scratch' },
|
|
8190
|
-
})
|
|
8191
|
-
);
|
|
8192
|
-
|
|
8193
|
-
const listed = fetchSpy.mock.calls
|
|
8194
|
-
.map((call: unknown[]) => String(call[0]))
|
|
8195
|
-
.find((url: string) => url.includes('/v1/products?'));
|
|
8196
|
-
expect(new URL(listed!).searchParams.get('factoryId')).toBe(FACTORY_ID);
|
|
8197
|
-
expect(refused.message).toBe(
|
|
8198
|
-
`A product named "Web" already exists in this factory (${PRODUCT_ID}, created 2026-10-10T10:00:00Z). It may ` +
|
|
8199
|
-
"not be this person's: ask whether they meant that one, want another name, or want a second " +
|
|
8200
|
-
'(allowDuplicateName: true).'
|
|
8201
|
-
);
|
|
8202
|
-
expect(refused.message).not.toMatch(/Use it/);
|
|
8203
|
-
});
|
|
8204
|
-
|
|
8205
8260
|
it('names a factory ID passed where a product ID belongs, and drops the transport text from a miss', async () => {
|
|
8206
8261
|
const { client } = await bootMcpPair({
|
|
8207
8262
|
capabilities: ['products:read', 'backlog:read', 'factories:read'],
|
package/src/tools/builds.ts
CHANGED
|
@@ -273,9 +273,14 @@ const INITIATIVE_CHECKS = 10;
|
|
|
273
273
|
* The status of the initiative behind each run still waiting on its pull
|
|
274
274
|
* request (PRO-2800). A reviewing agent read a run of a canceled initiative as
|
|
275
275
|
* "waiting for a person to review and merge" while Action Required called the
|
|
276
|
-
* same review stale.
|
|
277
|
-
*
|
|
278
|
-
*
|
|
276
|
+
* same review stale.
|
|
277
|
+
*
|
|
278
|
+
* The run says it itself since PRO-2808 (initiativeClosed: done, canceled,
|
|
279
|
+
* archived, deleted, or null while live), for every key that can read it.
|
|
280
|
+
* Against an API from before that, each initiative is read: a 404 is
|
|
281
|
+
* 'deleted', and an initiative past the cap, one whose read failed, and every
|
|
282
|
+
* one without backlog:read is 'unchecked', which withInitiativeState adds to
|
|
283
|
+
* the row's outcome.
|
|
279
284
|
*/
|
|
280
285
|
export async function initiativeStates(
|
|
281
286
|
client: TaigaClient,
|
|
@@ -286,8 +291,19 @@ export async function initiativeStates(
|
|
|
286
291
|
const waiting = rows.filter(
|
|
287
292
|
(row) => ['needs_review', 'completed'].includes(String(row['status'])) && row['prMerged'] !== true
|
|
288
293
|
);
|
|
294
|
+
const told = waiting.filter((row) => 'initiativeClosed' in row);
|
|
295
|
+
for (const row of told) {
|
|
296
|
+
if (typeof row['backlogItemId'] === 'string') {
|
|
297
|
+
states.set(row['backlogItemId'], typeof row['initiativeClosed'] === 'string' ? row['initiativeClosed'] : 'live');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
289
300
|
const all = [
|
|
290
|
-
...new Set(
|
|
301
|
+
...new Set(
|
|
302
|
+
waiting
|
|
303
|
+
.filter((row) => !('initiativeClosed' in row))
|
|
304
|
+
.map((row) => row['backlogItemId'])
|
|
305
|
+
.filter((id): id is string => typeof id === 'string' && !states.has(id))
|
|
306
|
+
),
|
|
291
307
|
];
|
|
292
308
|
// Without backlog:read, or past the cap, a waiting run's initiative is not read: it is marked unchecked so
|
|
293
309
|
// the row does not claim a person is waiting when nobody may be.
|
|
@@ -297,7 +313,8 @@ export async function initiativeStates(
|
|
|
297
313
|
ids.map(async (id) => {
|
|
298
314
|
try {
|
|
299
315
|
const item = (await client.getBacklogItem(id)).data as unknown as Record<string, unknown>;
|
|
300
|
-
|
|
316
|
+
// Archiving keeps the status it had, so it is read apart, as the API reads it (initiativeClosed).
|
|
317
|
+
states.set(id, item['archivedAt'] ? 'archived' : String(item['status'] ?? item['state']));
|
|
301
318
|
} catch (error) {
|
|
302
319
|
if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, 'deleted');
|
|
303
320
|
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
@@ -308,7 +325,19 @@ export async function initiativeStates(
|
|
|
308
325
|
return states;
|
|
309
326
|
}
|
|
310
327
|
|
|
311
|
-
/**
|
|
328
|
+
/**
|
|
329
|
+
* How each way an initiative ends reads on a run it left waiting: the same set
|
|
330
|
+
* Action Required closes a run's review for (PRO-2806). A done one's waiting
|
|
331
|
+
* run is not the one that landed it: that run is merged and never waiting.
|
|
332
|
+
*/
|
|
333
|
+
const INITIATIVE_ENDED: Record<string, string> = {
|
|
334
|
+
done: 'was marked done',
|
|
335
|
+
canceled: 'was canceled',
|
|
336
|
+
archived: 'was archived',
|
|
337
|
+
deleted: 'was deleted',
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/** A readable run row that also says when its initiative ended while its pull request waited. */
|
|
312
341
|
export function withInitiativeState(
|
|
313
342
|
row: Record<string, unknown>,
|
|
314
343
|
states: Map<string, string>
|
|
@@ -320,7 +349,8 @@ export function withInitiativeState(
|
|
|
320
349
|
if (state === 'unchecked') {
|
|
321
350
|
return { ...row, outcome: `${String(row['outcome'])} Whether its initiative still wants it was not checked.` };
|
|
322
351
|
}
|
|
323
|
-
|
|
352
|
+
const ended = state ? INITIATIVE_ENDED[state] : undefined;
|
|
353
|
+
if (!ended) return row;
|
|
324
354
|
const pr = typeof row['prNumber'] === 'number' ? `PR #${row['prNumber']}` : 'its pull request';
|
|
325
355
|
const open = row['prState'] === 'open' ? ` ${pr} is still open on the forge.` : '';
|
|
326
356
|
return {
|
|
@@ -328,7 +358,7 @@ export function withInitiativeState(
|
|
|
328
358
|
disposition: 'abandoned',
|
|
329
359
|
// Retry or start over cannot succeed once the initiative is gone.
|
|
330
360
|
actions: [],
|
|
331
|
-
outcome: `Its initiative
|
|
361
|
+
outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`,
|
|
332
362
|
};
|
|
333
363
|
}
|
|
334
364
|
|
|
@@ -485,8 +515,9 @@ export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
|
485
515
|
description:
|
|
486
516
|
'List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), ' +
|
|
487
517
|
'actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), ' +
|
|
488
|
-
'failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (initiative
|
|
489
|
-
"or deleted). Without productId, every product's runs; active: true keeps
|
|
518
|
+
'failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (its initiative ended: ' +
|
|
519
|
+
"done, canceled, archived or deleted). Without productId, every product's runs; active: true keeps " +
|
|
520
|
+
'running ones. taiga_get_run ' +
|
|
490
521
|
'reads one; carry work on from the initiative (taiga_resume_initiative).',
|
|
491
522
|
inputSchema: z.object({
|
|
492
523
|
productId: productId.optional(),
|
package/src/tools/sections.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
15
15
|
import { z } from 'zod';
|
|
16
|
-
import { CONTROL_ID_SOURCE, isControlId,
|
|
16
|
+
import { CONTROL_ID_SOURCE, isControlId, policyNamedByControl, policyTypeForControl } from '@taiga/types';
|
|
17
17
|
import { toolError, toolResult } from './errors.js';
|
|
18
18
|
|
|
19
19
|
/** A document's section map, keyed by section id: each value a `StoredSection` or (older rows) a bare string. */
|
|
@@ -177,19 +177,6 @@ export const policySectionsInput = sectionsInput.describe(
|
|
|
177
177
|
'Only these sections, by id, or a policy control id (SEC-5.1) for the sections that state it; [] for the outline (under outline: every section id and its size, no text); omit for the whole document'
|
|
178
178
|
);
|
|
179
179
|
|
|
180
|
-
/**
|
|
181
|
-
* The policy that states a control: the rightmost policy prefix in its id, so an overlay family's
|
|
182
|
-
* TEL-TIC-1.1 is policy_tic and HC-FI-DATA-1.1 policy_data, as the overlays file them.
|
|
183
|
-
*/
|
|
184
|
-
function policyOfControl(id: string): string | undefined {
|
|
185
|
-
const prefixes = id.split('-').filter((part) => /^[A-Z]/.test(part));
|
|
186
|
-
for (const prefix of prefixes.reverse()) {
|
|
187
|
-
const policy = POLICY_TYPE_BY_CONTROL_PREFIX[prefix];
|
|
188
|
-
if (policy) return policy;
|
|
189
|
-
}
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
180
|
/**
|
|
194
181
|
* The sections that state each control id asked for (PRO-2800): policy
|
|
195
182
|
* standing cites SEC-5.1, and the section holding it has an id of its own
|
|
@@ -231,7 +218,7 @@ function withControls(outline: { id: string; characters: number }[], sections: S
|
|
|
231
218
|
// A base control belongs to the policy its prefix names: an SEC row in the data policy is a stale mention,
|
|
232
219
|
// as heldControlMap reads it. An overlay prefix stays where it is written.
|
|
233
220
|
.filter((id) => {
|
|
234
|
-
const owner =
|
|
221
|
+
const owner = policyTypeForControl(id);
|
|
235
222
|
return owner === undefined || owner === policyType;
|
|
236
223
|
})
|
|
237
224
|
),
|
|
@@ -267,7 +254,7 @@ export function sectionReadResult(
|
|
|
267
254
|
const found = Object.keys(controls).length > 0 ? { controls } : {};
|
|
268
255
|
// A control id asked of a document that does not state controls: name the policy that does.
|
|
269
256
|
const asControl = options.controlIds ? undefined : unknownSections.find((id) => isControlId(id));
|
|
270
|
-
const policy = asControl ?
|
|
257
|
+
const policy = asControl ? policyNamedByControl(asControl) : undefined;
|
|
271
258
|
const controlNote = asControl
|
|
272
259
|
? {
|
|
273
260
|
note: policy
|
package/src/tools/setup.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { Product } from '@taiga/types';
|
|
|
5
5
|
import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
|
|
6
6
|
import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
|
|
7
7
|
import { isImportedProduct } from './catalogue.js';
|
|
8
|
-
import {
|
|
8
|
+
import { runTool, toolError, toolResult } from './errors.js';
|
|
9
9
|
import { setupReadiness } from './readiness.js';
|
|
10
10
|
import { withStop } from './hints.js';
|
|
11
11
|
import { idInput, productId } from './inputs.js';
|
|
@@ -68,58 +68,42 @@ type ProductRow = WithoutTenant<Product>;
|
|
|
68
68
|
/**
|
|
69
69
|
* Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
|
|
70
70
|
* creates a second one, so a retried call or two agents at once left
|
|
71
|
-
* duplicates nobody asked for.
|
|
71
|
+
* duplicates nobody asked for. The API checks it in the create's transaction
|
|
72
|
+
* (PRO-2808), so names past any page and two creates at once are caught too.
|
|
73
|
+
* allowDuplicateName says the person means it.
|
|
72
74
|
*/
|
|
73
75
|
const allowDuplicateName = z
|
|
74
76
|
.boolean()
|
|
75
77
|
.optional()
|
|
76
78
|
.describe('Create it even when one with the same name exists (default false: refused, naming the existing one)');
|
|
77
79
|
|
|
78
|
-
/** The same name, as a person would read it: case and surrounding space aside. */
|
|
79
|
-
const sameName = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
80
|
-
|
|
81
|
-
/** Pages read looking for a taken name; past this the create goes ahead. */
|
|
82
|
-
const NAME_PAGES = 5;
|
|
83
|
-
|
|
84
80
|
/**
|
|
85
|
-
* The
|
|
86
|
-
*
|
|
81
|
+
* The API's refusal of a taken name, as the tool's: it names the one that
|
|
82
|
+
* exists when the caller may read it, and leaves the choice to the person.
|
|
83
|
+
* Another session may have made it, so "use it" would hand the agent someone
|
|
84
|
+
* else's work (PRO-2800, review 6). Anything else is not this refusal.
|
|
87
85
|
*/
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
): Promise<T | null> {
|
|
92
|
-
let cursor: string | undefined;
|
|
93
|
-
try {
|
|
94
|
-
for (let pageIndex = 0; pageIndex < NAME_PAGES; pageIndex += 1) {
|
|
95
|
-
const result = await read(cursor);
|
|
96
|
-
const same = result.data.find((row) => sameName(row.name, name));
|
|
97
|
-
if (same) return same;
|
|
98
|
-
if (!result.nextCursor) return null;
|
|
99
|
-
cursor = result.nextCursor;
|
|
100
|
-
}
|
|
101
|
-
} catch (error) {
|
|
102
|
-
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
86
|
+
function duplicateName(kind: 'factory' | 'product', error: unknown, where: string) {
|
|
87
|
+
if (!(error instanceof TaigaApiError) || error.status !== 409 || error.details?.['reason'] !== 'duplicate_name') {
|
|
88
|
+
return null;
|
|
103
89
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return toolError(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{ existingId: existing.id, ...(existing.slug ? { existingSlug: existing.slug } : {}) }
|
|
122
|
-
);
|
|
90
|
+
const said = (key: string) => (typeof error.details?.[key] === 'string' ? (error.details[key] as string) : undefined);
|
|
91
|
+
const [existingId, existingSlug, name, createdAt] = [
|
|
92
|
+
said('existingId'),
|
|
93
|
+
said('existingSlug'),
|
|
94
|
+
said('existingName'),
|
|
95
|
+
said('existingCreatedAt'),
|
|
96
|
+
];
|
|
97
|
+
const which = name ? `A ${kind} named "${name}"` : `A ${kind} with this name`;
|
|
98
|
+
const ids = existingId ? ` (${existingId}${createdAt ? `, created ${createdAt}` : ''})` : '';
|
|
99
|
+
// Without the holder's id the caller cannot read it, so "that one" is not a choice they have.
|
|
100
|
+
const choices = existingId
|
|
101
|
+
? "It may not be this person's: ask whether they meant that one, want another name, or want a second"
|
|
102
|
+
: 'The caller cannot read it: ask whether they want another name or a second';
|
|
103
|
+
return toolError('DUPLICATE_NAME', `${which} already exists ${where}${ids}. ${choices} (allowDuplicateName: true).`, {
|
|
104
|
+
...(existingId ? { existingId } : {}),
|
|
105
|
+
...(existingSlug ? { existingSlug } : {}),
|
|
106
|
+
});
|
|
123
107
|
}
|
|
124
108
|
|
|
125
109
|
/** A product row the model can act on: how it started, and while in setup what it still needs. */
|
|
@@ -186,11 +170,17 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
186
170
|
},
|
|
187
171
|
(params) =>
|
|
188
172
|
runTool(async () => {
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
173
|
+
try {
|
|
174
|
+
const made = await client.createFactory({
|
|
175
|
+
name: params.name,
|
|
176
|
+
refuseDuplicateName: !params.allowDuplicateName,
|
|
177
|
+
});
|
|
178
|
+
return toolResult(pickFields([made.data], FACTORY_FIELDS)[0]);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const refused = duplicateName('factory', error, 'in this organization');
|
|
181
|
+
if (refused) return refused;
|
|
182
|
+
throw error;
|
|
192
183
|
}
|
|
193
|
-
return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
|
|
194
184
|
})
|
|
195
185
|
);
|
|
196
186
|
|
|
@@ -308,23 +298,25 @@ export function registerSetupTools(server: McpServer, client: TaigaClient) {
|
|
|
308
298
|
runTool(async () => {
|
|
309
299
|
const contradiction = notesWithNothingToDescribe(params);
|
|
310
300
|
if (contradiction) return contradiction;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
client.
|
|
315
|
-
|
|
316
|
-
|
|
301
|
+
let product: ProductRow;
|
|
302
|
+
try {
|
|
303
|
+
product = (
|
|
304
|
+
await client.createProduct({
|
|
305
|
+
name: params.name,
|
|
306
|
+
factoryId: params.factoryId,
|
|
307
|
+
...(params.start === 'import' ? { projectType: 'brownfield' as const } : {}),
|
|
308
|
+
...(params.ownsInfrastructure !== undefined ? { ownsInfrastructure: params.ownsInfrastructure } : {}),
|
|
309
|
+
...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
|
|
310
|
+
...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
|
|
311
|
+
// Within the factory it goes into: the same name in another factory is a different product.
|
|
312
|
+
refuseDuplicateName: !params.allowDuplicateName,
|
|
313
|
+
})
|
|
314
|
+
).data;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const refused = duplicateName('product', error, 'in this factory');
|
|
317
|
+
if (refused) return refused;
|
|
318
|
+
throw error;
|
|
317
319
|
}
|
|
318
|
-
const product = (
|
|
319
|
-
await client.createProduct({
|
|
320
|
-
name: params.name,
|
|
321
|
-
factoryId: params.factoryId,
|
|
322
|
-
...(params.start === 'import' ? { projectType: 'brownfield' as const } : {}),
|
|
323
|
-
...(params.ownsInfrastructure !== undefined ? { ownsInfrastructure: params.ownsInfrastructure } : {}),
|
|
324
|
-
...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
|
|
325
|
-
...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
|
|
326
|
-
})
|
|
327
|
-
).data;
|
|
328
320
|
return toolResult(await productRow(client, product));
|
|
329
321
|
})
|
|
330
322
|
);
|
|
Binary file
|
|
Binary file
|