@taiga-ai/mcp-server 0.25.0-dev.466 → 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 +71 -62
- 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/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.466.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -3781,20 +3781,27 @@ const INITIATIVE_CHECKS = 10;
|
|
|
3781
3781
|
* The status of the initiative behind each run still waiting on its pull
|
|
3782
3782
|
* request (PRO-2800). A reviewing agent read a run of a canceled initiative as
|
|
3783
3783
|
* "waiting for a person to review and merge" while Action Required called the
|
|
3784
|
-
* same review stale.
|
|
3785
|
-
*
|
|
3786
|
-
*
|
|
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.
|
|
3787
3792
|
*/
|
|
3788
3793
|
async function initiativeStates(client, rows) {
|
|
3789
3794
|
const states = /* @__PURE__ */ new Map();
|
|
3790
3795
|
const waiting = rows.filter((row) => ["needs_review", "completed"].includes(String(row["status"])) && row["prMerged"] !== true);
|
|
3791
|
-
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)))];
|
|
3792
3799
|
const ids = client.hasCapability("backlog:read") ? all.slice(0, INITIATIVE_CHECKS) : [];
|
|
3793
3800
|
for (const id of all) if (!ids.includes(id)) states.set(id, "unchecked");
|
|
3794
3801
|
await Promise.all(ids.map(async (id) => {
|
|
3795
3802
|
try {
|
|
3796
3803
|
const item = (await client.getBacklogItem(id)).data;
|
|
3797
|
-
states.set(id, String(item["status"] ?? item["state"]));
|
|
3804
|
+
states.set(id, item["archivedAt"] ? "archived" : String(item["status"] ?? item["state"]));
|
|
3798
3805
|
} catch (error) {
|
|
3799
3806
|
if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, "deleted");
|
|
3800
3807
|
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
@@ -3803,7 +3810,18 @@ async function initiativeStates(client, rows) {
|
|
|
3803
3810
|
}));
|
|
3804
3811
|
return states;
|
|
3805
3812
|
}
|
|
3806
|
-
/**
|
|
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. */
|
|
3807
3825
|
function withInitiativeState(row, states) {
|
|
3808
3826
|
const state = states.get(String(row["backlogItemId"]));
|
|
3809
3827
|
if (!["gated", "unmerged"].includes(String(row["disposition"]))) return row;
|
|
@@ -3811,14 +3829,15 @@ function withInitiativeState(row, states) {
|
|
|
3811
3829
|
...row,
|
|
3812
3830
|
outcome: `${String(row["outcome"])} Whether its initiative still wants it was not checked.`
|
|
3813
3831
|
};
|
|
3814
|
-
|
|
3832
|
+
const ended = state ? INITIATIVE_ENDED[state] : void 0;
|
|
3833
|
+
if (!ended) return row;
|
|
3815
3834
|
const pr = typeof row["prNumber"] === "number" ? `PR #${row["prNumber"]}` : "its pull request";
|
|
3816
3835
|
const open = row["prState"] === "open" ? ` ${pr} is still open on the forge.` : "";
|
|
3817
3836
|
return {
|
|
3818
3837
|
...row,
|
|
3819
3838
|
disposition: "abandoned",
|
|
3820
3839
|
actions: [],
|
|
3821
|
-
outcome: `Its initiative
|
|
3840
|
+
outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`
|
|
3822
3841
|
};
|
|
3823
3842
|
}
|
|
3824
3843
|
/** A step that changed only prose: no code of its own could have broken a test. */
|
|
@@ -3925,7 +3944,7 @@ function registerBuildTools(server, client) {
|
|
|
3925
3944
|
if (!client.hasCapability("products:read")) return;
|
|
3926
3945
|
server.registerTool("taiga_list_runs", {
|
|
3927
3946
|
title: "List runs",
|
|
3928
|
-
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).",
|
|
3929
3948
|
inputSchema: z.object({
|
|
3930
3949
|
productId: productId.optional(),
|
|
3931
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"),
|
|
@@ -21420,41 +21439,29 @@ const MISSING_DOCUMENTS_HOW = "An imported product gets them from its import (ta
|
|
|
21420
21439
|
/**
|
|
21421
21440
|
* Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
|
|
21422
21441
|
* creates a second one, so a retried call or two agents at once left
|
|
21423
|
-
* 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.
|
|
21424
21445
|
*/
|
|
21425
21446
|
const allowDuplicateName = z.boolean().optional().describe("Create it even when one with the same name exists (default false: refused, naming the existing one)");
|
|
21426
|
-
/** The same name, as a person would read it: case and surrounding space aside. */
|
|
21427
|
-
const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
21428
|
-
/** Pages read looking for a taken name; past this the create goes ahead. */
|
|
21429
|
-
const NAME_PAGES = 5;
|
|
21430
|
-
/**
|
|
21431
|
-
* The first row with this name, across pages. Best effort: a list the API
|
|
21432
|
-
* refuses or fails leaves the check out rather than the create.
|
|
21433
|
-
*/
|
|
21434
|
-
async function firstNamed(name, read) {
|
|
21435
|
-
let cursor;
|
|
21436
|
-
try {
|
|
21437
|
-
for (let pageIndex = 0; pageIndex < NAME_PAGES; pageIndex += 1) {
|
|
21438
|
-
const result = await read(cursor);
|
|
21439
|
-
const same = result.data.find((row) => sameName(row.name, name));
|
|
21440
|
-
if (same) return same;
|
|
21441
|
-
if (!result.nextCursor) return null;
|
|
21442
|
-
cursor = result.nextCursor;
|
|
21443
|
-
}
|
|
21444
|
-
} catch (error) {
|
|
21445
|
-
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
21446
|
-
}
|
|
21447
|
-
return null;
|
|
21448
|
-
}
|
|
21449
21447
|
/**
|
|
21450
|
-
* The refusal
|
|
21451
|
-
*
|
|
21452
|
-
|
|
21453
|
-
|
|
21454
|
-
|
|
21455
|
-
|
|
21456
|
-
|
|
21457
|
-
|
|
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 } : {}
|
|
21458
21465
|
});
|
|
21459
21466
|
}
|
|
21460
21467
|
/** A product row the model can act on: how it started, and while in setup what it still needs. */
|
|
@@ -21495,14 +21502,16 @@ function registerSetupTools(server, client) {
|
|
|
21495
21502
|
}),
|
|
21496
21503
|
annotations: WRITE
|
|
21497
21504
|
}, (params) => runTool(async () => {
|
|
21498
|
-
|
|
21499
|
-
|
|
21500
|
-
|
|
21501
|
-
|
|
21502
|
-
}));
|
|
21503
|
-
|
|
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;
|
|
21504
21514
|
}
|
|
21505
|
-
return toolResult(pickFields([(await client.createFactory({ name: params.name })).data], FACTORY_FIELDS)[0]);
|
|
21506
21515
|
}));
|
|
21507
21516
|
server.registerTool("taiga_rename_factory", {
|
|
21508
21517
|
title: "Rename a factory",
|
|
@@ -21567,22 +21576,22 @@ function registerSetupTools(server, client) {
|
|
|
21567
21576
|
}, (params) => runTool(async () => {
|
|
21568
21577
|
const contradiction = notesWithNothingToDescribe(params);
|
|
21569
21578
|
if (contradiction) return contradiction;
|
|
21570
|
-
|
|
21571
|
-
|
|
21579
|
+
let product;
|
|
21580
|
+
try {
|
|
21581
|
+
product = (await client.createProduct({
|
|
21582
|
+
name: params.name,
|
|
21572
21583
|
factoryId: params.factoryId,
|
|
21573
|
-
|
|
21574
|
-
|
|
21575
|
-
|
|
21576
|
-
|
|
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;
|
|
21577
21594
|
}
|
|
21578
|
-
const product = (await client.createProduct({
|
|
21579
|
-
name: params.name,
|
|
21580
|
-
factoryId: params.factoryId,
|
|
21581
|
-
...params.start === "import" ? { projectType: "brownfield" } : {},
|
|
21582
|
-
...params.ownsInfrastructure !== void 0 ? { ownsInfrastructure: params.ownsInfrastructure } : {},
|
|
21583
|
-
...params.ownsDeployment !== void 0 ? { ownsDeployment: params.ownsDeployment } : {},
|
|
21584
|
-
...params.deploymentNotes !== void 0 ? { deploymentNotes: params.deploymentNotes } : {}
|
|
21585
|
-
})).data;
|
|
21586
21595
|
return toolResult(await productRow(client, product));
|
|
21587
21596
|
}));
|
|
21588
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/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
|