@taiga-ai/mcp-server 0.22.0-dev.457 → 0.23.0-dev.459
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +859 -716
- package/package.json +1 -1
- package/src/server.integration.test.ts +232 -5
- package/src/tools/backlogs.ts +18 -0
- package/src/tools/builds.ts +27 -3
- package/src/tools/context.ts +15 -20
- package/src/tools/errors.ts +5 -0
- package/src/tools/inbox.ts +115 -23
- package/src/tools/instructions.ts +20 -19
- package/src/tools/maintenance.ts +14 -1
- package/src/tools/products.ts +33 -0
- package/src/tools/repositories.ts +8 -3
- package/taiga-ai-mcp-server-0.23.0-dev.459.tgz +0 -0
- package/taiga-ai-mcp-server-0.22.0-dev.457.tgz +0 -0
package/dist/index.js
CHANGED
|
@@ -2379,7 +2379,7 @@ function inProductWords(value) {
|
|
|
2379
2379
|
}
|
|
2380
2380
|
/** The API's 404 sentence in the product's words: "Backlog item not found" is an initiative not found. */
|
|
2381
2381
|
function inProductWording(said) {
|
|
2382
|
-
return said.replace(/^Backlog item not found/i, "Initiative not found").replace(/^Build run step not found/i, "Run step not found").replace(/^Build run not found/i, "Run not found").replace(/^Custom document not found/i, "Knowledge file not found").replace(/^This product already has a repository\. Change it instead of linking another\./i, "This product already has a repository. Pass replacesRepositoryId to swap it in one step, or taiga_unlink_repository first (taiga_list_product_repositories gives its ID).");
|
|
2382
|
+
return said.replace(/^Backlog item not found/i, "Initiative not found").replace(/^Build run step not found/i, "Run step not found").replace(/^Build run not found/i, "Run not found").replace(/^Custom document not found/i, "Knowledge file not found").replace(/^(Branch "[^"]*" does not exist in \S+?)\.?$/i, "$1. taiga_list_repository_branches lists the branches it has.").replace(/^This product already has a repository\. Change it instead of linking another\./i, "This product already has a repository. Pass replacesRepositoryId to swap it in one step, or taiga_unlink_repository first (taiga_list_product_repositories gives its ID).");
|
|
2383
2383
|
}
|
|
2384
2384
|
/** A successful tool result: one JSON text block the model reads. */
|
|
2385
2385
|
function toolResult(value) {
|
|
@@ -3108,6 +3108,25 @@ async function resolveProductRef(client, ref, accepted, usage) {
|
|
|
3108
3108
|
}
|
|
3109
3109
|
return { product: products[0] };
|
|
3110
3110
|
}
|
|
3111
|
+
/**
|
|
3112
|
+
* NOT_FOUND for a factory ID the key cannot see; null when it exists or the read failed. A key that cannot read
|
|
3113
|
+
* factories cannot tell an empty factory from a wrong ID, and the answer says so.
|
|
3114
|
+
*/
|
|
3115
|
+
async function factoryMissing(client, factoryId) {
|
|
3116
|
+
if (!client.hasCapability("factories:read")) return toolResult({
|
|
3117
|
+
data: [],
|
|
3118
|
+
nextCursor: null,
|
|
3119
|
+
note: "No products here. This key cannot read factories, so whether that factoryId exists was not checked."
|
|
3120
|
+
});
|
|
3121
|
+
try {
|
|
3122
|
+
await client.getFactory(factoryId);
|
|
3123
|
+
return null;
|
|
3124
|
+
} catch (error) {
|
|
3125
|
+
if (error instanceof TaigaApiError && error.status === 404) return toolError("NOT_FOUND", "Factory not found. taiga_list_factories lists the factories the key can see.");
|
|
3126
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
3127
|
+
throw error;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3111
3130
|
function registerProductTools(server, client) {
|
|
3112
3131
|
if (client.hasCapability("factories:read")) server.registerTool("taiga_list_factories", {
|
|
3113
3132
|
title: "List factories",
|
|
@@ -3147,6 +3166,10 @@ function registerProductTools(server, client) {
|
|
|
3147
3166
|
const { query: text, ...filters } = params;
|
|
3148
3167
|
if (text === void 0) {
|
|
3149
3168
|
const result = await client.listProducts(filters);
|
|
3169
|
+
if (result.data.length === 0 && filters.factoryId && !filters.cursor) {
|
|
3170
|
+
const missing = await factoryMissing(client, filters.factoryId);
|
|
3171
|
+
if (missing) return missing;
|
|
3172
|
+
}
|
|
3150
3173
|
return toolResult(page(await withFactoryNames(client, pickFields(result.data, PRODUCT_SUMMARY_FIELDS)), result.nextCursor));
|
|
3151
3174
|
}
|
|
3152
3175
|
if (params.cursor !== void 0) return toolError("VALIDATION_ERROR", SEARCH_WITH_CURSOR);
|
|
@@ -3156,6 +3179,10 @@ function registerProductTools(server, client) {
|
|
|
3156
3179
|
limit: 100,
|
|
3157
3180
|
cursor
|
|
3158
3181
|
}), text, (row) => [row.name, row.slug], params.limit ?? 25);
|
|
3182
|
+
if (found.rows.length === 0 && filters.factoryId) {
|
|
3183
|
+
const missing = await factoryMissing(client, filters.factoryId);
|
|
3184
|
+
if (missing) return missing;
|
|
3185
|
+
}
|
|
3159
3186
|
return toolResult({
|
|
3160
3187
|
...page(await withFactoryNames(client, pickFields(found.rows, PRODUCT_SUMMARY_FIELDS)), null),
|
|
3161
3188
|
...found.more ? { more: SEARCH_NOTE } : {}
|
|
@@ -3556,7 +3583,6 @@ const BUILD_SUMMARY_FIELDS = [
|
|
|
3556
3583
|
"planId",
|
|
3557
3584
|
"status",
|
|
3558
3585
|
"failedReason",
|
|
3559
|
-
"runtime",
|
|
3560
3586
|
"repositoryFullName",
|
|
3561
3587
|
"branch",
|
|
3562
3588
|
"baseBranch",
|
|
@@ -3696,16 +3722,34 @@ function runOutcome(run) {
|
|
|
3696
3722
|
};
|
|
3697
3723
|
}
|
|
3698
3724
|
/**
|
|
3725
|
+
* What each failedReason means for the next move (PRO-2800): reviewers met dispatch_rejected and agent_reported as
|
|
3726
|
+
* bare enums. As @taiga/types documents them.
|
|
3727
|
+
*/
|
|
3728
|
+
const FAILED_REASON_MEANS = {
|
|
3729
|
+
agent_reported: "the agent stopped and said why (error); resuming would likely hit the same wall",
|
|
3730
|
+
server_orphan: "the run stopped reporting (its machine died or ran out of memory); resuming can recover",
|
|
3731
|
+
agent_stalled: "the agent kept running but stopped making progress; resuming can recover",
|
|
3732
|
+
user_stopped: "it was stopped, by a person or because its initiative was settled",
|
|
3733
|
+
timeout: "it ran past its time limit; resuming continues from the unfinished step",
|
|
3734
|
+
dispatch_rejected: "the agent never started (its environment or credentials failed); resuming dispatches it again",
|
|
3735
|
+
context_exhaustion: "the agent ran out of context on a step; resuming starts that step fresh"
|
|
3736
|
+
};
|
|
3737
|
+
/**
|
|
3699
3738
|
* A run row as an agent reads it: its outcome added, and prDraft only where
|
|
3700
3739
|
* there is a pull request. The column defaults to true, so a run that never
|
|
3701
3740
|
* opened one said it had a draft (PRO-2800). A list row leaves the full error
|
|
3702
3741
|
* text out: its first sentence is in the outcome, and taiga_get_run has it all.
|
|
3703
3742
|
*/
|
|
3704
3743
|
function readableRun(run, opts = {}) {
|
|
3705
|
-
const { prDraft, error, reviewEscalation, ...rest } = run;
|
|
3744
|
+
const { prDraft, error, reviewEscalation, runtime: _runtime, ...rest } = run;
|
|
3706
3745
|
const said = runOutcome(run);
|
|
3746
|
+
const means = FAILED_REASON_MEANS[String(run["failedReason"])];
|
|
3747
|
+
const hardening = run["hardeningResult"];
|
|
3748
|
+
const blocking = typeof hardening?.blockingFindings === "number" ? hardening.blockingFindings : 0;
|
|
3707
3749
|
return {
|
|
3708
3750
|
...rest,
|
|
3751
|
+
...means ? { failedReasonMeans: means } : {},
|
|
3752
|
+
...blocking > 0 ? { hardeningNote: `Hardening ended with ${blocking} blocking finding${blocking === 1 ? "" : "s"} it could not fix; the run records how many, not which.` } : {},
|
|
3709
3753
|
...opts.row || error === void 0 ? {} : { error },
|
|
3710
3754
|
...opts.row || reviewEscalation === void 0 ? {} : { reviewEscalation },
|
|
3711
3755
|
...typeof rest["prNumber"] === "number" || rest["prUrl"] ? { prDraft } : {},
|
|
@@ -3873,7 +3917,7 @@ function registerBuildTools(server, client) {
|
|
|
3873
3917
|
}));
|
|
3874
3918
|
server.registerTool("taiga_get_run", {
|
|
3875
3919
|
title: "Get run",
|
|
3876
|
-
description: "Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, status, commit, files changed, verification and headline (first paragraph of the agent's report). verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on the repository's failing tests. A failed or stopped run has stoppedIn and
|
|
3920
|
+
description: "Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, status, commit, files changed, verification and headline (first paragraph of the agent's report). verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true adds complete instructions and reports. actions: resume and retry_from_failed_step are taiga_resume_initiative; start_over is web only; to stop it, move the initiative out of Build. A maintenance run has no initiative and is retried from its finding.",
|
|
3877
3921
|
inputSchema: z.object({
|
|
3878
3922
|
runId,
|
|
3879
3923
|
full: z.boolean().optional().describe("Carry each step's complete instructions and report (default false: a summary)")
|
|
@@ -4271,6 +4315,40 @@ const outcomeOf = (item) => withSituation(item, {
|
|
|
4271
4315
|
criteria: criteriaCounts(item)
|
|
4272
4316
|
});
|
|
4273
4317
|
const outcome = (item) => toolResult(outcomeOf(item));
|
|
4318
|
+
/** The statuses an initiative in each board column can have: the API keeps in_progress after a first run. */
|
|
4319
|
+
const COLUMN_STATUSES = {
|
|
4320
|
+
backlog: [
|
|
4321
|
+
"all",
|
|
4322
|
+
"open",
|
|
4323
|
+
"backlog",
|
|
4324
|
+
"in_progress"
|
|
4325
|
+
],
|
|
4326
|
+
todo: [
|
|
4327
|
+
"all",
|
|
4328
|
+
"open",
|
|
4329
|
+
"backlog",
|
|
4330
|
+
"in_progress"
|
|
4331
|
+
],
|
|
4332
|
+
queue: [
|
|
4333
|
+
"all",
|
|
4334
|
+
"open",
|
|
4335
|
+
"backlog",
|
|
4336
|
+
"in_progress"
|
|
4337
|
+
],
|
|
4338
|
+
plan: [
|
|
4339
|
+
"all",
|
|
4340
|
+
"open",
|
|
4341
|
+
"backlog",
|
|
4342
|
+
"in_progress"
|
|
4343
|
+
],
|
|
4344
|
+
build: [
|
|
4345
|
+
"all",
|
|
4346
|
+
"open",
|
|
4347
|
+
"in_progress"
|
|
4348
|
+
],
|
|
4349
|
+
done: ["all", "done"],
|
|
4350
|
+
canceled: ["all", "canceled"]
|
|
4351
|
+
};
|
|
4274
4352
|
/**
|
|
4275
4353
|
* Each tool is registered under the capability its route requires, so a
|
|
4276
4354
|
* key sees exactly what it may call: reads on backlog:read; the board move on
|
|
@@ -4334,6 +4412,7 @@ function registerBacklogReads(server, client) {
|
|
|
4334
4412
|
annotations: READ_ONLY
|
|
4335
4413
|
}, (params) => runTool(async () => {
|
|
4336
4414
|
const { query, state, status = state === void 0 ? "open" : "all", ...filters } = params;
|
|
4415
|
+
if (state !== void 0 && !COLUMN_STATUSES[state].includes(status)) return toolError("VALIDATION_ERROR", `No initiative is in the ${state} column with status ${status}: pass state alone, or a status it can have (${COLUMN_STATUSES[state].join(", ")}).`);
|
|
4337
4416
|
const apiFilters = {
|
|
4338
4417
|
...filters,
|
|
4339
4418
|
status: status === "open" || status === "all" ? void 0 : status
|
|
@@ -4765,16 +4844,22 @@ function registerMaintenanceTools(server, client) {
|
|
|
4765
4844
|
const listed = controls.filter((c) => Number(c.divergenceCount ?? 0) > 0 || Number(c.findingCount ?? 0) > 0);
|
|
4766
4845
|
const summary = standingSummary(standing);
|
|
4767
4846
|
const note = standing.assessment?.state === "never" ? "Never assessed: the documents have not been checked against the policies yet, so nothing here is an all-clear. An assessment runs when the reality documents are derived or refreshed (taiga_refresh_reality_documents, paid work: ask first)." : null;
|
|
4847
|
+
const resolvedAny = Array.isArray(standing.resolvedDivergences) && standing.resolvedDivergences.length > 0;
|
|
4848
|
+
const wording = violations.length > 0 || resolvedAny ? { wordingNote: "A violation's evidence is from its assessedAt; its title and summary may be from when it was first recorded. Where they disagree, the evidence is current." } : {};
|
|
4768
4849
|
if (params.violationId) {
|
|
4769
4850
|
const one = [...violations, ...Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences : []].find((violation) => violation["id"] === params.violationId);
|
|
4770
4851
|
if (!one) return toolError("NOT_FOUND", "No violation with that ID on this product; this tool without violationId lists them.");
|
|
4771
|
-
return toolResult(
|
|
4852
|
+
return toolResult({
|
|
4853
|
+
...one,
|
|
4854
|
+
...wording
|
|
4855
|
+
});
|
|
4772
4856
|
}
|
|
4773
4857
|
if (params.full) {
|
|
4774
4858
|
const resolvedAll = Array.isArray(standing.resolvedDivergences) ? standing.resolvedDivergences : [];
|
|
4775
4859
|
return toolResult({
|
|
4776
4860
|
summary,
|
|
4777
4861
|
...note ? { note } : {},
|
|
4862
|
+
...wording,
|
|
4778
4863
|
...standing,
|
|
4779
4864
|
controls: listed,
|
|
4780
4865
|
divergences: cutLongText(violations),
|
|
@@ -4785,6 +4870,7 @@ function registerMaintenanceTools(server, client) {
|
|
|
4785
4870
|
return toolResult({
|
|
4786
4871
|
summary,
|
|
4787
4872
|
...note ? { note } : {},
|
|
4873
|
+
...wording,
|
|
4788
4874
|
productId: standing.productId,
|
|
4789
4875
|
hasPolicies: standing.hasPolicies,
|
|
4790
4876
|
assessment: standing.assessment,
|
|
@@ -4866,775 +4952,831 @@ function registerMaintenanceTools(server, client) {
|
|
|
4866
4952
|
}));
|
|
4867
4953
|
}
|
|
4868
4954
|
//#endregion
|
|
4869
|
-
//#region src/tools/
|
|
4955
|
+
//#region src/tools/inbox.ts
|
|
4870
4956
|
/**
|
|
4871
|
-
*
|
|
4872
|
-
*
|
|
4873
|
-
*
|
|
4874
|
-
* `unavailable` with what the API said, a part the key's capabilities do not
|
|
4875
|
-
* cover is named under `notGranted`, and neither is ever shown as empty. A
|
|
4876
|
-
* failure that is not the API's (a bug in this code) is not downgraded; it
|
|
4877
|
-
* fails the call so it surfaces.
|
|
4957
|
+
* An item as a row: what it is, whose product, how long it has waited. Not
|
|
4958
|
+
* its metadata by default: a document decision carried its whole text there,
|
|
4959
|
+
* and fifty rows came to 57 KB (PRO-2800); full: true brings it.
|
|
4878
4960
|
*/
|
|
4879
|
-
|
|
4880
|
-
const CONTEXT_DOCUMENT_TYPES = ["specification", "architecture"];
|
|
4881
|
-
const REPOSITORY_SUMMARY_FIELDS = [
|
|
4882
|
-
"id",
|
|
4883
|
-
"provider",
|
|
4884
|
-
"repositoryName",
|
|
4885
|
-
"repositoryUrl",
|
|
4886
|
-
"effectiveBranch",
|
|
4887
|
-
"isManaged"
|
|
4888
|
-
];
|
|
4889
|
-
/** How many of the latest runs to carry; taiga_list_runs pages the rest. */
|
|
4890
|
-
const BUILDS_IN_CONTEXT = 10;
|
|
4891
|
-
/** A run as the bundle carries it: enough to say what was built and where; taiga_get_run has the rest. */
|
|
4892
|
-
const CONTEXT_BUILD_FIELDS = [
|
|
4961
|
+
const ITEM_FIELDS = [
|
|
4893
4962
|
"id",
|
|
4894
|
-
"
|
|
4895
|
-
"
|
|
4896
|
-
"
|
|
4963
|
+
"productId",
|
|
4964
|
+
"productName",
|
|
4965
|
+
"category",
|
|
4966
|
+
"kind",
|
|
4967
|
+
"subjectType",
|
|
4968
|
+
"subjectId",
|
|
4969
|
+
"title",
|
|
4970
|
+
"summary",
|
|
4897
4971
|
"status",
|
|
4898
|
-
"
|
|
4899
|
-
"
|
|
4900
|
-
"
|
|
4901
|
-
"
|
|
4902
|
-
"prNumber",
|
|
4903
|
-
"prUrl",
|
|
4904
|
-
"prState",
|
|
4905
|
-
"prMerged",
|
|
4906
|
-
"prDraft",
|
|
4907
|
-
"reviewEscalation",
|
|
4908
|
-
"error",
|
|
4909
|
-
"createdAt"
|
|
4972
|
+
"blocking",
|
|
4973
|
+
"snoozedUntil",
|
|
4974
|
+
"createdAt",
|
|
4975
|
+
"resolvedAt"
|
|
4910
4976
|
];
|
|
4977
|
+
const DAY_MS = 864e5;
|
|
4978
|
+
/** When an item stopped waiting: its resolution, or now while it is open. */
|
|
4979
|
+
function waitedUntil(resolvedAt, now) {
|
|
4980
|
+
const at = typeof resolvedAt === "string" ? Date.parse(resolvedAt) : NaN;
|
|
4981
|
+
return Number.isNaN(at) ? now : at;
|
|
4982
|
+
}
|
|
4911
4983
|
/**
|
|
4912
|
-
*
|
|
4913
|
-
*
|
|
4914
|
-
|
|
4915
|
-
|
|
4984
|
+
* The read that opens an item's subject (PRO-2800): rows carried subjectType and subjectId, and the reviewing agent
|
|
4985
|
+
* had to infer which tool takes them.
|
|
4986
|
+
*/
|
|
4987
|
+
function readWith(row, backlogRead) {
|
|
4988
|
+
switch (row["subjectType"]) {
|
|
4989
|
+
case "build_run": return "taiga_get_run (runId: subjectId)";
|
|
4990
|
+
case "backlog_item": return backlogRead ? "taiga_get_plan (initiativeId: subjectId)" : void 0;
|
|
4991
|
+
case "product_document": return "taiga_list_product_documents (productId)";
|
|
4992
|
+
case "inbox_task": return "taiga_list_setup_tasks (productId)";
|
|
4993
|
+
case "product": return {
|
|
4994
|
+
review_backlog: backlogRead ? "taiga_list_initiatives (productId)" : void 0,
|
|
4995
|
+
review_maintenance: "taiga_list_findings (productId)",
|
|
4996
|
+
review_policy: "taiga_get_policy_standing (productId)",
|
|
4997
|
+
review_doc: "taiga_list_product_documents (productId)"
|
|
4998
|
+
}[String(row["kind"])];
|
|
4999
|
+
default: return;
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
5002
|
+
/** A summary past its first sentence: review_doc rows quoted two documents, 800 to 1,200 characters each. */
|
|
5003
|
+
const SUMMARY_LIMIT = 200;
|
|
5004
|
+
function firstSentence(text) {
|
|
5005
|
+
if (typeof text !== "string") return text;
|
|
5006
|
+
const head = text.split(/\n/)[0].trim();
|
|
5007
|
+
if (head.length <= SUMMARY_LIMIT) return head.length < text.trim().length ? `${head} (full: true has the rest)` : head;
|
|
5008
|
+
const cut = head.slice(0, SUMMARY_LIMIT);
|
|
5009
|
+
const end = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("; "));
|
|
5010
|
+
return `${end > 40 ? cut.slice(0, end + 1) : cut}... (full: true has the rest)`;
|
|
5011
|
+
}
|
|
5012
|
+
/** A row as an agent reads it: the pull request to open when there is one, and how many days it has waited. */
|
|
5013
|
+
function itemRow(row, now, full, backlogRead) {
|
|
5014
|
+
const { snoozedUntil, resolvedAt, ...rest } = pickFields([row], ITEM_FIELDS)[0];
|
|
5015
|
+
const metadata = row["metadata"] ?? {};
|
|
5016
|
+
const created = typeof row["createdAt"] === "string" ? Date.parse(row["createdAt"]) : NaN;
|
|
5017
|
+
const { summary, status, blocking, ...lean } = rest;
|
|
5018
|
+
const read = readWith(row, backlogRead);
|
|
5019
|
+
return {
|
|
5020
|
+
...lean,
|
|
5021
|
+
...full ? { summary } : row["kind"] === "review_pr" ? {} : { summary: firstSentence(summary) },
|
|
5022
|
+
...full || status !== "open" ? { status } : {},
|
|
5023
|
+
...full || blocking === true ? { blocking } : {},
|
|
5024
|
+
...read ? { readWith: read } : {},
|
|
5025
|
+
...snoozedUntil ? { snoozedUntil } : {},
|
|
5026
|
+
...resolvedAt ? { resolvedAt } : {},
|
|
5027
|
+
...typeof metadata["prUrl"] === "string" ? { prUrl: metadata["prUrl"] } : {},
|
|
5028
|
+
...Number.isNaN(created) ? {} : { waitingDays: Math.floor((waitedUntil(resolvedAt, now) - created) / DAY_MS) },
|
|
5029
|
+
...full ? { metadata: row["metadata"] } : {}
|
|
5030
|
+
};
|
|
5031
|
+
}
|
|
5032
|
+
/**
|
|
5033
|
+
* What is open, counted by product and kind: the answer to "what needs attention" without reading every row. An item
|
|
5034
|
+
* that no longer stands is counted under stale, not open or byKind, so nobody subtracts by hand (PRO-2800).
|
|
4916
5035
|
*/
|
|
4917
|
-
|
|
5036
|
+
function summaryOf(rows, stale) {
|
|
5037
|
+
const byProduct = /* @__PURE__ */ new Map();
|
|
5038
|
+
for (const row of rows) {
|
|
5039
|
+
const key = String(row["productId"]);
|
|
5040
|
+
const entry = byProduct.get(key) ?? (row["productName"] ? {
|
|
5041
|
+
productId: key,
|
|
5042
|
+
product: row["productName"],
|
|
5043
|
+
open: 0,
|
|
5044
|
+
byKind: {}
|
|
5045
|
+
} : {
|
|
5046
|
+
productId: key,
|
|
5047
|
+
open: 0,
|
|
5048
|
+
byKind: {}
|
|
5049
|
+
});
|
|
5050
|
+
if (stale.has(String(row["id"]))) entry.stale = (entry.stale ?? 0) + 1;
|
|
5051
|
+
else {
|
|
5052
|
+
entry.open += 1;
|
|
5053
|
+
entry.byKind[String(row["kind"])] = (entry.byKind[String(row["kind"])] ?? 0) + 1;
|
|
5054
|
+
}
|
|
5055
|
+
byProduct.set(key, entry);
|
|
5056
|
+
}
|
|
5057
|
+
return [...byProduct.values()].sort((a, b) => b.open - a.open);
|
|
5058
|
+
}
|
|
5059
|
+
/** Pull request reviews checked against their run and initiative; past this a row is left as it is. */
|
|
5060
|
+
const STALE_CHECKS = 10;
|
|
4918
5061
|
/**
|
|
4919
|
-
*
|
|
4920
|
-
*
|
|
4921
|
-
*
|
|
4922
|
-
*
|
|
5062
|
+
* Which "Review & merge" items no longer stand, and why (PRO-2800): the run's
|
|
5063
|
+
* pull request closed or merged, or its initiative was canceled or finished
|
|
5064
|
+
* without it. Two reviewing agents counted such leftovers as live gates; the
|
|
5065
|
+
* row still is open in Action Required, so this says what it is now rather
|
|
5066
|
+
* than hiding it. Best effort, a handful of runs: a failed read leaves the
|
|
5067
|
+
* row as it was.
|
|
4923
5068
|
*/
|
|
4924
|
-
function
|
|
4925
|
-
const
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
5069
|
+
async function staleReviews(client, rows) {
|
|
5070
|
+
const byId = new Map(rows.filter((row) => row["kind"] === "review_pr" && row["subjectType"] === "build_run").map((row) => [String(row["id"]), row]));
|
|
5071
|
+
const candidates = [...byId.values()].slice(0, STALE_CHECKS);
|
|
5072
|
+
let unchecked = byId.size - candidates.length;
|
|
5073
|
+
const out = /* @__PURE__ */ new Map();
|
|
5074
|
+
await Promise.all(candidates.map(async (row) => {
|
|
5075
|
+
try {
|
|
5076
|
+
const run = (await client.getBuildRun(String(row["subjectId"]))).data;
|
|
5077
|
+
if (run["prMerged"] === true) return void out.set(String(row["id"]), "its pull request has merged");
|
|
5078
|
+
if (run["prState"] === "closed") return void out.set(String(row["id"]), "its pull request was closed");
|
|
5079
|
+
if (typeof run["backlogItemId"] !== "string") return;
|
|
5080
|
+
if (!client.hasCapability("backlog:read")) return void (unchecked += 1);
|
|
5081
|
+
const item = (await client.getBacklogItem(run["backlogItemId"])).data;
|
|
5082
|
+
if (item["state"] === "canceled" || item["status"] === "canceled") out.set(String(row["id"]), "its initiative was canceled; the pull request is still open on the forge");
|
|
5083
|
+
else if (item["state"] === "done" || item["status"] === "done") out.set(String(row["id"]), "its initiative is done; this run is not the one that landed it");
|
|
5084
|
+
} catch (error) {
|
|
5085
|
+
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
5086
|
+
unchecked += 1;
|
|
5087
|
+
}
|
|
4936
5088
|
}));
|
|
5089
|
+
unchecked += await staleBacklogReviews(client, rows, out);
|
|
4937
5090
|
return {
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
data: {
|
|
4941
|
-
...doc.data,
|
|
4942
|
-
sections: kept
|
|
4943
|
-
}
|
|
4944
|
-
},
|
|
4945
|
-
dropped
|
|
5091
|
+
stale: out,
|
|
5092
|
+
unchecked
|
|
4946
5093
|
};
|
|
4947
5094
|
}
|
|
4948
5095
|
/**
|
|
4949
|
-
*
|
|
4950
|
-
*
|
|
4951
|
-
*
|
|
4952
|
-
* stand" question never reads.
|
|
5096
|
+
* "Review Initiatives" items whose product has no initiative left in its backlog or todo column (PRO-2800): two reviews found one
|
|
5097
|
+
* waiting 15 days on a product whose initiatives had all moved on, with nothing an agent could act on. Best effort,
|
|
5098
|
+
* a handful of products; returns how many could not be checked.
|
|
4953
5099
|
*/
|
|
4954
|
-
|
|
4955
|
-
"
|
|
4956
|
-
|
|
4957
|
-
"
|
|
4958
|
-
];
|
|
4959
|
-
|
|
4960
|
-
const
|
|
5100
|
+
async function staleBacklogReviews(client, rows, out) {
|
|
5101
|
+
const asks = [...new Map(rows.filter((row) => row["kind"] === "review_backlog" && typeof row["productId"] === "string").map((row) => [String(row["id"]), row])).values()];
|
|
5102
|
+
if (asks.length === 0) return 0;
|
|
5103
|
+
if (!client.hasCapability("backlog:read")) return asks.length;
|
|
5104
|
+
const checked = [...new Set(asks.map((row) => String(row["productId"])))].slice(0, STALE_CHECKS);
|
|
5105
|
+
let unchecked = asks.filter((row) => !checked.includes(String(row["productId"]))).length;
|
|
5106
|
+
const empty = /* @__PURE__ */ new Set();
|
|
5107
|
+
await Promise.all(checked.map(async (productId) => {
|
|
5108
|
+
try {
|
|
5109
|
+
const pages = await Promise.all(["backlog", "in_progress"].map((status) => client.listBacklogItems(productId, {
|
|
5110
|
+
status,
|
|
5111
|
+
limit: 100
|
|
5112
|
+
})));
|
|
5113
|
+
if (pages.some((page) => page.data.some((item) => item.state === "backlog" || item.state === "todo"))) return;
|
|
5114
|
+
if (pages.some((page) => page.nextCursor)) {
|
|
5115
|
+
unchecked += asks.filter((row) => row["productId"] === productId).length;
|
|
5116
|
+
return;
|
|
5117
|
+
}
|
|
5118
|
+
empty.add(productId);
|
|
5119
|
+
} catch (error) {
|
|
5120
|
+
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
5121
|
+
unchecked += asks.filter((row) => row["productId"] === productId).length;
|
|
5122
|
+
}
|
|
5123
|
+
}));
|
|
5124
|
+
for (const row of asks) if (empty.has(String(row["productId"]))) out.set(String(row["id"]), "no initiative is in the backlog or todo now: the ones it asked about have moved on");
|
|
5125
|
+
return unchecked;
|
|
5126
|
+
}
|
|
4961
5127
|
/**
|
|
4962
|
-
*
|
|
4963
|
-
*
|
|
4964
|
-
* open initiatives inside the tool result the document budget is sized for.
|
|
5128
|
+
* One product's Action Required as counts, the way the list's summary counts them: open is what still stands and is
|
|
5129
|
+
* not put off, stale and snoozed are counted apart. The product context reports these, so the two reads agree.
|
|
4965
5130
|
*/
|
|
4966
|
-
|
|
4967
|
-
const
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
}
|
|
4978
|
-
|
|
4979
|
-
function unavailableReason(reason) {
|
|
4980
|
-
if (reason instanceof TaigaApiError) return apiFailureText(reason);
|
|
4981
|
-
return `the API could not be reached: ${networkFailureDetail(reason)}`;
|
|
4982
|
-
}
|
|
4983
|
-
/** Whether a failed part is the API's doing; anything else is ours and propagates. */
|
|
4984
|
-
function isApiFailure$1(reason) {
|
|
4985
|
-
return reason instanceof TaigaApiError || isNetworkFailure(reason);
|
|
5131
|
+
async function attentionCounts(client, rows) {
|
|
5132
|
+
const now = Date.now();
|
|
5133
|
+
const due = rows.filter((row) => isDue(row, now));
|
|
5134
|
+
const { stale, unchecked } = await staleReviews(client, due);
|
|
5135
|
+
const [entry] = summaryOf(due, stale);
|
|
5136
|
+
const snoozed = rows.length - due.length;
|
|
5137
|
+
return {
|
|
5138
|
+
open: entry ? Number(entry["open"]) : 0,
|
|
5139
|
+
byKind: entry ? entry["byKind"] : {},
|
|
5140
|
+
...entry && typeof entry["stale"] === "number" ? { stale: entry["stale"] } : {},
|
|
5141
|
+
...snoozed > 0 ? { snoozed } : {},
|
|
5142
|
+
...unchecked > 0 ? { staleNote: `${unchecked} review${unchecked === 1 ? " was" : "s were"} not checked for staleness and count as open.` } : {}
|
|
5143
|
+
};
|
|
4986
5144
|
}
|
|
4987
|
-
|
|
4988
|
-
|
|
5145
|
+
/** An item the team has not put off: the page hides one until its snooze ends, and so does this. */
|
|
5146
|
+
function isDue(row, now) {
|
|
5147
|
+
return !(row["status"] === "snoozed" && typeof row["snoozedUntil"] === "string" && Date.parse(row["snoozedUntil"]) > now);
|
|
4989
5148
|
}
|
|
4990
|
-
/**
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
};
|
|
5012
|
-
} catch (error) {
|
|
5013
|
-
if (!isNotFound$1(error)) throw error;
|
|
5014
|
-
return {
|
|
5015
|
-
...base,
|
|
5016
|
-
instructions: null,
|
|
5017
|
-
instructionsVersion: null,
|
|
5018
|
-
note: "This factory has not published instructions yet."
|
|
5019
|
-
};
|
|
5020
|
-
}
|
|
5021
|
-
}
|
|
5022
|
-
/**
|
|
5023
|
-
* The specification and architecture from the product's own timeline: their
|
|
5024
|
-
* outline by default, the whole sections up to a budget or in full when
|
|
5025
|
-
* asked. Null, with a note, where unpublished.
|
|
5026
|
-
*/
|
|
5027
|
-
async function documentsPart(client, product, depth) {
|
|
5028
|
-
const productId = product.id;
|
|
5029
|
-
const origin = defaultOriginFor(product);
|
|
5030
|
-
const reads = await Promise.allSettled(CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin)));
|
|
5031
|
-
const out = { origin };
|
|
5032
|
-
const notes = [];
|
|
5033
|
-
CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
|
|
5034
|
-
const read = reads[index];
|
|
5035
|
-
if (read.status === "fulfilled") {
|
|
5036
|
-
const doc = stripProvenance(read.value.data);
|
|
5037
|
-
if (depth === "full") out[type] = doc;
|
|
5038
|
-
else if (depth === "budget") {
|
|
5039
|
-
const lean = withoutCitations(doc);
|
|
5040
|
-
if (lean.dropped) notes.push(`The ${origin} ${type} leaves out each section's citations (its code evidence); taiga_get_product_document carries them.`);
|
|
5041
|
-
const cut = budgetSections(lean.doc, DOCUMENT_BUDGET);
|
|
5042
|
-
if (!cut) {
|
|
5043
|
-
const small = JSON.stringify(lean.doc).length <= DOCUMENT_BUDGET;
|
|
5044
|
-
out[type] = small ? lean.doc : {
|
|
5045
|
-
version: doc.version,
|
|
5046
|
-
sections: null
|
|
5047
|
-
};
|
|
5048
|
-
if (!small) notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
5049
|
-
} else if (cut.omittedSections.length > 0) {
|
|
5050
|
-
out[type] = {
|
|
5051
|
-
...cut.doc,
|
|
5052
|
-
omittedSections: cut.omittedSections
|
|
5053
|
-
};
|
|
5054
|
-
notes.push(`The ${origin} ${type} carries the whole sections that fit; read the ones under omittedSections with taiga_get_product_document, naming them in sections.`);
|
|
5055
|
-
} else out[type] = cut.doc;
|
|
5056
|
-
} else {
|
|
5057
|
-
const sections = sectionsOf(doc.data ?? {});
|
|
5058
|
-
if (sections) {
|
|
5059
|
-
const outline = Object.keys(sections).map((id) => ({
|
|
5060
|
-
id,
|
|
5061
|
-
characters: sectionContent(sections, id)?.length ?? 0
|
|
5062
|
-
}));
|
|
5063
|
-
out[type] = {
|
|
5064
|
-
version: doc.version,
|
|
5065
|
-
sections: outline
|
|
5066
|
-
};
|
|
5067
|
-
} else {
|
|
5068
|
-
out[type] = {
|
|
5069
|
-
version: doc.version,
|
|
5070
|
-
sections: null
|
|
5071
|
-
};
|
|
5072
|
-
notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
5073
|
-
}
|
|
5074
|
-
}
|
|
5075
|
-
} else if (isNotFound$1(read.reason)) {
|
|
5076
|
-
out[type] = null;
|
|
5077
|
-
notes.push(`No published ${origin} ${type}; taiga_list_product_documents shows what the product has.`);
|
|
5078
|
-
} else throw read.reason;
|
|
5079
|
-
});
|
|
5080
|
-
return {
|
|
5081
|
-
...out,
|
|
5082
|
-
notes
|
|
5083
|
-
};
|
|
5084
|
-
}
|
|
5085
|
-
/** Every published policy with its section count, so the model knows which one to read with taiga_get_document. */
|
|
5086
|
-
async function policiesPart(client) {
|
|
5087
|
-
return (await client.listTenantDocuments({ limit: 100 })).data.filter((row) => POLICY_TYPES.includes(String(row["type"])) && row["isDraft"] === false).map((row) => ({
|
|
5088
|
-
type: row["type"],
|
|
5089
|
-
name: row["name"],
|
|
5090
|
-
version: row["version"],
|
|
5091
|
-
sections: Object.keys(sectionsOf(row["data"] ?? {}) ?? {}).length
|
|
5092
|
-
}));
|
|
5149
|
+
/** Pages read for the summary; past this it says it counted only those. */
|
|
5150
|
+
const SUMMARY_PAGE = 100;
|
|
5151
|
+
const SETUP_TASK_FIELDS = [
|
|
5152
|
+
"id",
|
|
5153
|
+
"productId",
|
|
5154
|
+
"productName",
|
|
5155
|
+
"category",
|
|
5156
|
+
"phase",
|
|
5157
|
+
"target",
|
|
5158
|
+
"key",
|
|
5159
|
+
"isSecret",
|
|
5160
|
+
"title",
|
|
5161
|
+
"providerDocUrl",
|
|
5162
|
+
"providerActionUrl",
|
|
5163
|
+
"inboxItemId",
|
|
5164
|
+
"inboxItemStatus",
|
|
5165
|
+
"createdAt"
|
|
5166
|
+
];
|
|
5167
|
+
/** The API's count across every page, on the first page; nothing from an API that does not send one. */
|
|
5168
|
+
function totalOf(result) {
|
|
5169
|
+
return typeof result.totalCount === "number" ? { totalCount: result.totalCount } : {};
|
|
5093
5170
|
}
|
|
5094
|
-
/** The statuses an item is open in; the API's filter takes one at a time. */
|
|
5095
|
-
const OPEN_BACKLOG_STATUSES = ["backlog", "in_progress"];
|
|
5096
5171
|
/**
|
|
5097
|
-
*
|
|
5098
|
-
*
|
|
5099
|
-
*
|
|
5100
|
-
*
|
|
5101
|
-
* retired status (readable for a rolling deploy) match neither filter;
|
|
5102
|
-
* the settled check below keeps the shape honest for whatever comes back.
|
|
5172
|
+
* One row per variable: tasks filed before the API merged a key's categories
|
|
5173
|
+
* (PRO-2782) still list JWT_SECRET once as a secret and once as an env_var.
|
|
5174
|
+
* The oldest row stays, and names the others under alsoFiledAs, so the
|
|
5175
|
+
* duplicates read as one thing to set with several records, not several things.
|
|
5103
5176
|
*/
|
|
5104
|
-
|
|
5105
|
-
const
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
for (const
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
truncated = true;
|
|
5113
|
-
pageCapped = true;
|
|
5114
|
-
break;
|
|
5115
|
-
}
|
|
5116
|
-
const result = await client.listBacklogItems(productId, {
|
|
5117
|
-
status,
|
|
5118
|
-
limit: 100,
|
|
5119
|
-
cursor
|
|
5120
|
-
});
|
|
5121
|
-
for (const row of result.data) if (!isSettledOrArchived({
|
|
5122
|
-
status: String(row.status),
|
|
5123
|
-
archivedAt: row.archivedAt ?? null
|
|
5124
|
-
})) open.set(String(row.id), row);
|
|
5125
|
-
if (!result.nextCursor) break;
|
|
5126
|
-
cursor = result.nextCursor;
|
|
5177
|
+
function sameKeyOnce(rows) {
|
|
5178
|
+
const first = /* @__PURE__ */ new Map();
|
|
5179
|
+
const out = [];
|
|
5180
|
+
const isOpen = (row) => row["inboxItemStatus"] !== "done" && row["inboxItemStatus"] !== "dismissed";
|
|
5181
|
+
for (const row of rows) {
|
|
5182
|
+
if (typeof row["key"] !== "string" || !row["key"]) {
|
|
5183
|
+
out.push(row);
|
|
5184
|
+
continue;
|
|
5127
5185
|
}
|
|
5186
|
+
const key = `${String(row["productId"])}:${row["key"]}`;
|
|
5187
|
+
const kept = first.get(key);
|
|
5188
|
+
if (!kept) {
|
|
5189
|
+
const copy = { ...row };
|
|
5190
|
+
first.set(key, copy);
|
|
5191
|
+
out.push(copy);
|
|
5192
|
+
continue;
|
|
5193
|
+
}
|
|
5194
|
+
const also = kept["alsoFiledAs"] ?? [];
|
|
5195
|
+
const record = (from) => ({
|
|
5196
|
+
id: from["id"],
|
|
5197
|
+
category: from["category"],
|
|
5198
|
+
title: from["title"],
|
|
5199
|
+
inboxItemStatus: from["inboxItemStatus"]
|
|
5200
|
+
});
|
|
5201
|
+
if (!isOpen(kept) && isOpen(row)) {
|
|
5202
|
+
const { alsoFiledAs: _also, ...was } = kept;
|
|
5203
|
+
for (const field of Object.keys(kept)) delete kept[field];
|
|
5204
|
+
Object.assign(kept, row, { alsoFiledAs: [...also, record(was)] });
|
|
5205
|
+
} else kept["alsoFiledAs"] = [...also, record(row)];
|
|
5128
5206
|
}
|
|
5129
|
-
|
|
5130
|
-
if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
|
|
5131
|
-
return {
|
|
5132
|
-
items: situatedRows(rows.slice(-50)),
|
|
5133
|
-
truncated,
|
|
5134
|
-
...pageCapped ? { note: "More than 1000 open items in one status: only the oldest of them were read, so the newest may be missing here; taiga_list_initiatives pages the rest." } : {}
|
|
5135
|
-
};
|
|
5136
|
-
}
|
|
5137
|
-
/**
|
|
5138
|
-
* The linked repositories. The route serves one page of 100 and takes no
|
|
5139
|
-
* cursor, so past that the bundle says the list is cut rather than complete.
|
|
5140
|
-
*/
|
|
5141
|
-
async function repositoriesPart(links) {
|
|
5142
|
-
const result = await links();
|
|
5143
|
-
return {
|
|
5144
|
-
items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
|
|
5145
|
-
truncated: result.nextCursor !== null
|
|
5146
|
-
};
|
|
5147
|
-
}
|
|
5148
|
-
async function buildsPart(client, productId) {
|
|
5149
|
-
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data;
|
|
5150
|
-
const states = await initiativeStates(client, rows);
|
|
5151
|
-
return pickFields(rows, CONTEXT_BUILD_FIELDS).map(readableRow).map((row) => withInitiativeState(row, states));
|
|
5152
|
-
}
|
|
5153
|
-
/**
|
|
5154
|
-
* What waits on a person for this product, counted by kind (PRO-2800): the
|
|
5155
|
-
* reviewing agent called four tools to answer "how does it stand"; the
|
|
5156
|
-
* counts say whether taiga_list_action_required is worth a call.
|
|
5157
|
-
*/
|
|
5158
|
-
async function attentionPart(client, productId) {
|
|
5159
|
-
const result = await client.listInbox({
|
|
5160
|
-
productId,
|
|
5161
|
-
limit: 100,
|
|
5162
|
-
withTotal: true
|
|
5163
|
-
});
|
|
5164
|
-
const now = Date.now();
|
|
5165
|
-
const rows = result.data;
|
|
5166
|
-
const byKind = {};
|
|
5167
|
-
for (const row of rows) byKind[String(row["kind"])] = (byKind[String(row["kind"])] ?? 0) + 1;
|
|
5168
|
-
const snoozed = rows.filter((row) => row["status"] === "snoozed" && typeof row["snoozedUntil"] === "string" && Date.parse(row["snoozedUntil"]) > now).length;
|
|
5169
|
-
return {
|
|
5170
|
-
open: typeof result.totalCount === "number" ? result.totalCount : rows.length,
|
|
5171
|
-
byKind,
|
|
5172
|
-
...snoozed > 0 ? { snoozed } : {},
|
|
5173
|
-
...result.nextCursor ? { note: "byKind and snoozed count the first 100; taiga_list_action_required pages the rest." } : {}
|
|
5174
|
-
};
|
|
5175
|
-
}
|
|
5176
|
-
/** The latest sweep and what it left open, as counts; taiga_list_findings lists them. */
|
|
5177
|
-
async function maintenancePart(client, productId) {
|
|
5178
|
-
const summary = (await client.getMaintenanceSummary(productId)).data;
|
|
5179
|
-
const last = summary["lastRun"];
|
|
5180
|
-
return {
|
|
5181
|
-
openFindings: summary["openFindings"],
|
|
5182
|
-
bySeverity: summary["severityCounts"],
|
|
5183
|
-
lastSweep: last ? {
|
|
5184
|
-
status: last["status"],
|
|
5185
|
-
completedAt: last["completedAt"]
|
|
5186
|
-
} : null,
|
|
5187
|
-
...last ? {} : { note: "Never swept: no findings is not an all-clear." }
|
|
5188
|
-
};
|
|
5207
|
+
return out;
|
|
5189
5208
|
}
|
|
5190
|
-
/**
|
|
5191
|
-
|
|
5192
|
-
const standing = (await client.getPolicyStanding(productId)).data;
|
|
5193
|
-
const summary = standingSummary(standing);
|
|
5194
|
-
const state = standing.assessment?.state;
|
|
5209
|
+
/** A page of folded rows, saying how many records it folded so totalCount (records) and the rows reconcile. */
|
|
5210
|
+
function folded(rows, nextCursor, records) {
|
|
5195
5211
|
return {
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
controlsViolated: summary.controls,
|
|
5199
|
-
...state === "never" || state === "failed" ? { note: `The assessment ${state === "never" ? "has never run" : "failed"}: no violations here is not an all-clear.` } : {}
|
|
5200
|
-
};
|
|
5201
|
-
}
|
|
5202
|
-
async function productContext(client, product, options) {
|
|
5203
|
-
const productId = product.id;
|
|
5204
|
-
const granted = (part) => client.hasCapability(PARTS[part]);
|
|
5205
|
-
const notGranted = Object.keys(PARTS).filter((part) => !granted(part));
|
|
5206
|
-
let linksRead;
|
|
5207
|
-
const links = () => linksRead ??= client.listProductRepositories(productId);
|
|
5208
|
-
const fetchers = {
|
|
5209
|
-
factory: () => factoryPart(client, String(product.factoryId)),
|
|
5210
|
-
documents: () => documentsPart(client, product, options.documents),
|
|
5211
|
-
policies: () => policiesPart(client),
|
|
5212
|
-
initiatives: () => backlogPart(client, productId),
|
|
5213
|
-
repositories: () => repositoriesPart(links),
|
|
5214
|
-
runs: () => buildsPart(client, productId),
|
|
5215
|
-
actionRequired: () => attentionPart(client, productId),
|
|
5216
|
-
maintenance: () => maintenancePart(client, productId),
|
|
5217
|
-
policyStanding: () => standingPart(client, productId)
|
|
5218
|
-
};
|
|
5219
|
-
const parts = Object.keys(fetchers).filter(granted);
|
|
5220
|
-
const [settled, readiness] = await Promise.all([Promise.allSettled(parts.map((part) => fetchers[part]())), setupReadiness(client, product, links)]);
|
|
5221
|
-
const bundle = { product: {
|
|
5222
|
-
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
5223
|
-
projectType: isImportedProduct(product) ? "brownfield" : "greenfield",
|
|
5224
|
-
defaultOrigin: defaultOriginFor(product),
|
|
5225
|
-
...readiness ? { readiness } : {}
|
|
5226
|
-
} };
|
|
5227
|
-
const unavailable = {};
|
|
5228
|
-
parts.forEach((part, index) => {
|
|
5229
|
-
const result = settled[index];
|
|
5230
|
-
if (result.status === "fulfilled") bundle[part] = result.value;
|
|
5231
|
-
else if (isApiFailure$1(result.reason)) unavailable[part] = unavailableReason(result.reason);
|
|
5232
|
-
else throw result.reason;
|
|
5233
|
-
});
|
|
5234
|
-
const result = {
|
|
5235
|
-
...bundle,
|
|
5236
|
-
notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
|
|
5237
|
-
unavailable,
|
|
5238
|
-
readNext: "Read a policy with taiga_get_document (sections: [] for its outline, or a control id such as SEC-5.1), the specification's or architecture's sections or another product document with taiga_get_product_document, an initiative with taiga_get_initiative (taiga_list_initiatives pages a truncated backlog) and a run with taiga_get_run."
|
|
5212
|
+
...page(rows, nextCursor),
|
|
5213
|
+
...rows.length < records ? { recordsOnPage: records } : {}
|
|
5239
5214
|
};
|
|
5240
|
-
let characters = JSON.stringify(inProductWords(result), null, 2).length;
|
|
5241
|
-
for (let measured = -1; measured !== characters;) {
|
|
5242
|
-
measured = characters;
|
|
5243
|
-
characters = JSON.stringify(inProductWords({
|
|
5244
|
-
...result,
|
|
5245
|
-
characters: measured
|
|
5246
|
-
}), null, 2).length;
|
|
5247
|
-
}
|
|
5248
|
-
return toolResult({
|
|
5249
|
-
...result,
|
|
5250
|
-
characters
|
|
5251
|
-
});
|
|
5252
5215
|
}
|
|
5253
|
-
function
|
|
5216
|
+
function registerInboxTools(server, client) {
|
|
5254
5217
|
if (!client.hasCapability("products:read")) return;
|
|
5255
|
-
server.registerTool("
|
|
5256
|
-
title: "
|
|
5257
|
-
description:
|
|
5218
|
+
server.registerTool("taiga_list_action_required", {
|
|
5219
|
+
title: "List what needs a person",
|
|
5220
|
+
description: "List what waits on a person (Action Required), across every product the key can open or one; the list is the team's. Reviews (plans, initiative sets, documents, pull requests, findings, policy violations) and setup only a person can do (secrets, variables, accounts, permissions). The first page opens with summary (open items by product and kind; stale ones apart) and totalCount. Rows carry waitingDays, readWith (the read that opens the subject), summary (its opening paragraph, up to 200 characters; none on a pull request review, whose title says it) and, for a pull request, prUrl; status and blocking appear when not open or when blocking; a review that no longer stands is marked stale. category: gate or setup; resolved: true reads the archive; snoozed items are hidden (snoozedHidden); full: true adds metadata. Unlike taiga_list_setup_tasks, which says exactly what to set and where.",
|
|
5221
|
+
inputSchema: z.object({
|
|
5222
|
+
productId: productId.optional(),
|
|
5223
|
+
category: z.enum(["gate", "setup"]).optional().describe("gate: things to review; setup: things to set up"),
|
|
5224
|
+
resolved: z.boolean().optional().describe("Read the resolved archive (done and dismissed) instead"),
|
|
5225
|
+
full: z.boolean().optional().describe("Carry each item's metadata, such as a document decision's whole text (default false)"),
|
|
5226
|
+
...pagination
|
|
5227
|
+
}),
|
|
5228
|
+
annotations: READ_ONLY
|
|
5229
|
+
}, (params) => runTool(async () => {
|
|
5230
|
+
const { resolved, full, ...rest } = params;
|
|
5231
|
+
const query = {
|
|
5232
|
+
...rest,
|
|
5233
|
+
...resolved ? { resolved: true } : {}
|
|
5234
|
+
};
|
|
5235
|
+
const first = params.cursor === void 0;
|
|
5236
|
+
const result = await client.listInbox({
|
|
5237
|
+
...query,
|
|
5238
|
+
...first ? { withTotal: true } : {}
|
|
5239
|
+
});
|
|
5240
|
+
const overview = !first || resolved ? null : !result.nextCursor ? result : await client.listInbox({
|
|
5241
|
+
...query,
|
|
5242
|
+
limit: SUMMARY_PAGE,
|
|
5243
|
+
cursor: void 0
|
|
5244
|
+
}).catch((error) => {
|
|
5245
|
+
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
5246
|
+
throw error;
|
|
5247
|
+
});
|
|
5248
|
+
const now = Date.now();
|
|
5249
|
+
const due_ = (row) => isDue(row, now);
|
|
5250
|
+
const rows = result.data;
|
|
5251
|
+
const due = rows.filter(due_);
|
|
5252
|
+
const hidden = rows.length - due.length;
|
|
5253
|
+
const overviewDue = overview ? overview.data.filter(due_) : [];
|
|
5254
|
+
const checked = resolved ? {
|
|
5255
|
+
stale: /* @__PURE__ */ new Map(),
|
|
5256
|
+
unchecked: 0
|
|
5257
|
+
} : await staleReviews(client, [...due, ...overviewDue]);
|
|
5258
|
+
const stale = checked.stale;
|
|
5259
|
+
return toolResult({
|
|
5260
|
+
...overview ? {
|
|
5261
|
+
summary: summaryOf(overviewDue, stale),
|
|
5262
|
+
...overview.nextCursor ? { summaryNote: `The summary counts the first ${SUMMARY_PAGE} items.` } : {}
|
|
5263
|
+
} : {},
|
|
5264
|
+
...page(due.map((row) => {
|
|
5265
|
+
const item = itemRow(row, now, full === true, client.hasCapability("backlog:read"));
|
|
5266
|
+
const why = stale.get(String(row["id"]));
|
|
5267
|
+
return why ? {
|
|
5268
|
+
...item,
|
|
5269
|
+
stale: why
|
|
5270
|
+
} : item;
|
|
5271
|
+
}), result.nextCursor),
|
|
5272
|
+
...totalOf(result),
|
|
5273
|
+
...hidden > 0 ? { snoozedHidden: hidden } : {},
|
|
5274
|
+
...checked.unchecked > 0 ? { staleNote: `${checked.unchecked} review${checked.unchecked === 1 ? " was" : "s were"} not checked for staleness (past the cap, or the read was refused).` } : {}
|
|
5275
|
+
});
|
|
5276
|
+
}));
|
|
5277
|
+
server.registerTool("taiga_list_setup_tasks", {
|
|
5278
|
+
title: "List setup tasks",
|
|
5279
|
+
description: "List what a person must set up for a product (secrets, variables, accounts), with exactly what and where: key (the name to create; Taiga never stores values), the provider page, and phase: prereq (the deploy fails without it), wiring (generated values back to providers) or followup (can wait until after launch). Runs do not wait for them. One product or all, oldest first; includeDone: true adds done and dismissed. A variable filed twice lists the others under alsoFiledAs; totalCount counts records, and a page that folded some says recordsOnPage. Unlike taiga_list_action_required, which lists the same work as items to resolve.",
|
|
5258
5280
|
inputSchema: z.object({
|
|
5259
5281
|
productId: productId.optional(),
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
documents: z.enum(DOCUMENT_DEPTHS).optional().describe("The specification and architecture as outline (section ids and sizes, the default), budget (the whole sections that fit) or full")
|
|
5282
|
+
includeDone: z.boolean().optional().describe("Also the tasks already done or dismissed (default false)"),
|
|
5283
|
+
...pagination
|
|
5263
5284
|
}),
|
|
5264
5285
|
annotations: READ_ONLY
|
|
5265
5286
|
}, (params) => runTool(async () => {
|
|
5266
|
-
const
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
return
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
//#region src/tools/autonomy.ts
|
|
5277
|
-
/**
|
|
5278
|
-
* Autonomy (docs.tai.ga, Deliver: Initiatives, Auto-merge): whether a landed
|
|
5279
|
-
* plan is built without a review, and whether a pull request merges on its
|
|
5280
|
-
* own. The organization and its factories are ceilings for merging, set by
|
|
5281
|
-
* an administrator in the web; a product sets defaults; an initiative's own
|
|
5282
|
-
* switch is the final word within the ceilings. The initiative's build gate
|
|
5283
|
-
* is taiga_set_auto_build.
|
|
5284
|
-
*/
|
|
5285
|
-
function registerAutonomyTools(server, client) {
|
|
5286
|
-
if (client.hasCapability("products:write")) server.registerTool("taiga_set_product_autonomy", {
|
|
5287
|
-
title: "Set a product's autonomy defaults",
|
|
5288
|
-
description: "Set the defaults a product's initiatives follow unless they decide for themselves. autoBuild: false makes every plan wait for a person to approve or reject it; true builds it as soon as the product has room. autoMerge: false makes pull requests wait for a person to merge; null follows the organization and factory (autonomous merge needs the organization switch on, a factory can turn it off, and neither is changed here).",
|
|
5289
|
-
inputSchema: z.object({
|
|
5290
|
-
productId,
|
|
5291
|
-
autoBuild: z.boolean().optional().describe("Build landed plans without a review by default"),
|
|
5292
|
-
autoMerge: z.boolean().nullable().optional().describe("false: pull requests wait for a person by default; null: follow the organization and factory")
|
|
5293
|
-
}),
|
|
5294
|
-
annotations: OPERATE_SETTING
|
|
5295
|
-
}, (params) => runTool(async () => {
|
|
5296
|
-
if (params.autoBuild === void 0 && params.autoMerge === void 0) return toolError("VALIDATION_ERROR", "Set autoBuild, autoMerge or both.");
|
|
5297
|
-
const autoMerge = params.autoMerge === true ? null : params.autoMerge;
|
|
5298
|
-
const product = (await client.updateProduct(params.productId, {
|
|
5299
|
-
...params.autoBuild !== void 0 ? { autoBuild: params.autoBuild } : {},
|
|
5300
|
-
...autoMerge !== void 0 ? { autoMerge } : {}
|
|
5301
|
-
})).data;
|
|
5302
|
-
return toolResult(pickFields([product], [
|
|
5303
|
-
"id",
|
|
5304
|
-
"name",
|
|
5305
|
-
"slug",
|
|
5306
|
-
"autoBuild",
|
|
5307
|
-
"autoMerge"
|
|
5308
|
-
])[0]);
|
|
5287
|
+
const { includeDone, ...rest } = params;
|
|
5288
|
+
const result = await client.listSetupTasks({
|
|
5289
|
+
...rest,
|
|
5290
|
+
...includeDone ? {} : { openOnly: true },
|
|
5291
|
+
...params.cursor === void 0 ? { withTotal: true } : {}
|
|
5292
|
+
});
|
|
5293
|
+
return toolResult({
|
|
5294
|
+
...folded(sameKeyOnce(pickFields(result.data, SETUP_TASK_FIELDS)), result.nextCursor, result.data.length),
|
|
5295
|
+
...totalOf(result)
|
|
5296
|
+
});
|
|
5309
5297
|
}));
|
|
5310
|
-
if (client.hasCapability("
|
|
5311
|
-
|
|
5312
|
-
|
|
5298
|
+
if (!client.hasCapability("products:write")) return;
|
|
5299
|
+
server.registerTool("taiga_resolve_action", {
|
|
5300
|
+
title: "Resolve or snooze an Action Required item",
|
|
5301
|
+
description: "Set an Action Required item for everybody: done, dismissed (does not apply), in_progress, or snooze it until a time (null wakes it). Resolving a review approves or merges nothing; answer the plan or pull request where it lives. Marking the product documents review done records, in your name, that every published document was reviewed (in_progress withdraws it): only when the person says they read them. Ask before clearing what the person has not dealt with.",
|
|
5313
5302
|
inputSchema: z.object({
|
|
5314
|
-
|
|
5315
|
-
|
|
5303
|
+
actionId: idInput("Action Required item", "taiga_list_action_required gives it"),
|
|
5304
|
+
status: z.enum([
|
|
5305
|
+
"in_progress",
|
|
5306
|
+
"done",
|
|
5307
|
+
"dismissed"
|
|
5308
|
+
]).optional().describe("What became of it"),
|
|
5309
|
+
snoozedUntil: z.string().datetime().nullable().optional().describe("Hide it until this time (ISO 8601 in UTC, ending in Z); null wakes it now")
|
|
5316
5310
|
}),
|
|
5317
|
-
annotations:
|
|
5311
|
+
annotations: OPERATE
|
|
5318
5312
|
}, (params) => runTool(async () => {
|
|
5319
|
-
|
|
5320
|
-
return toolResult(pickFields([
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
"autoMerge",
|
|
5325
|
-
"autoBuild"
|
|
5326
|
-
])[0]);
|
|
5313
|
+
if (params.status === void 0 && params.snoozedUntil === void 0) return toolError("VALIDATION_ERROR", "Say what became of the item (status) or until when to snooze it.");
|
|
5314
|
+
return toolResult(pickFields([(await client.updateInboxItem(params.actionId, {
|
|
5315
|
+
...params.status !== void 0 ? { status: params.status } : {},
|
|
5316
|
+
...params.snoozedUntil !== void 0 ? { snoozedUntil: params.snoozedUntil } : {}
|
|
5317
|
+
})).data], ITEM_FIELDS)[0]);
|
|
5327
5318
|
}));
|
|
5328
5319
|
}
|
|
5329
5320
|
//#endregion
|
|
5330
|
-
//#region src/tools/
|
|
5321
|
+
//#region src/tools/context.ts
|
|
5331
5322
|
/**
|
|
5332
|
-
*
|
|
5333
|
-
*
|
|
5334
|
-
*
|
|
5323
|
+
* One call at the start of a coding session: everything the model would
|
|
5324
|
+
* otherwise collect in eight, fanned out here. Each part stands on its own
|
|
5325
|
+
* (Promise.allSettled): a part the API refuses or fails is named under
|
|
5326
|
+
* `unavailable` with what the API said, a part the key's capabilities do not
|
|
5327
|
+
* cover is named under `notGranted`, and neither is ever shown as empty. A
|
|
5328
|
+
* failure that is not the API's (a bug in this code) is not downgraded; it
|
|
5329
|
+
* fails the call so it surfaces.
|
|
5335
5330
|
*/
|
|
5336
|
-
|
|
5331
|
+
/** The two documents a coding session starts from; the rest are a read away. */
|
|
5332
|
+
const CONTEXT_DOCUMENT_TYPES = ["specification", "architecture"];
|
|
5333
|
+
const REPOSITORY_SUMMARY_FIELDS = [
|
|
5337
5334
|
"id",
|
|
5338
|
-
"
|
|
5339
|
-
"
|
|
5340
|
-
"
|
|
5341
|
-
"
|
|
5342
|
-
"
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5335
|
+
"provider",
|
|
5336
|
+
"repositoryName",
|
|
5337
|
+
"repositoryUrl",
|
|
5338
|
+
"effectiveBranch",
|
|
5339
|
+
"isManaged"
|
|
5340
|
+
];
|
|
5341
|
+
/** How many of the latest runs to carry; taiga_list_runs pages the rest. */
|
|
5342
|
+
const BUILDS_IN_CONTEXT = 10;
|
|
5343
|
+
/** A run as the bundle carries it: enough to say what was built and where; taiga_get_run has the rest. */
|
|
5344
|
+
const CONTEXT_BUILD_FIELDS = [
|
|
5345
|
+
"id",
|
|
5346
|
+
"runNumber",
|
|
5347
|
+
"backlogItemId",
|
|
5348
|
+
"targetTitle",
|
|
5346
5349
|
"status",
|
|
5347
|
-
"
|
|
5348
|
-
"
|
|
5349
|
-
"
|
|
5350
|
-
"
|
|
5350
|
+
"failedReason",
|
|
5351
|
+
"completedSteps",
|
|
5352
|
+
"totalSteps",
|
|
5353
|
+
"baseBranch",
|
|
5354
|
+
"prNumber",
|
|
5355
|
+
"prUrl",
|
|
5356
|
+
"prState",
|
|
5357
|
+
"prMerged",
|
|
5358
|
+
"prDraft",
|
|
5359
|
+
"reviewEscalation",
|
|
5360
|
+
"error",
|
|
5361
|
+
"createdAt"
|
|
5351
5362
|
];
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5363
|
+
/**
|
|
5364
|
+
* Characters of section text each of the two documents may bring by default.
|
|
5365
|
+
* Two of them plus the rest of the bundle stay well inside the 25k-token
|
|
5366
|
+
* tool result Claude Code accepts (a 67k-character bundle was refused); a
|
|
5367
|
+
* larger product's documents come cut, with the sections left out named.
|
|
5368
|
+
*/
|
|
5369
|
+
const DOCUMENT_BUDGET = 12e3;
|
|
5370
|
+
/**
|
|
5371
|
+
* The document without each section's citations: on a reality document they
|
|
5372
|
+
* are the code evidence behind every claim and as long as the text itself,
|
|
5373
|
+
* which a session's opening read does not need. The full read keeps them.
|
|
5374
|
+
* Says whether any were dropped, so the bundle can tell the reader.
|
|
5375
|
+
*/
|
|
5376
|
+
function withoutCitations(doc) {
|
|
5377
|
+
const sections = isRecord(doc.data) ? sectionsOf(doc.data) : null;
|
|
5378
|
+
if (!sections) return {
|
|
5379
|
+
doc,
|
|
5380
|
+
dropped: false
|
|
5381
|
+
};
|
|
5382
|
+
let dropped = false;
|
|
5383
|
+
const kept = Object.fromEntries(Object.entries(sections).map(([id, section]) => {
|
|
5384
|
+
if (!isRecord(section) || !("citations" in section)) return [id, section];
|
|
5385
|
+
dropped = true;
|
|
5386
|
+
const { citations: _citations, ...rest } = section;
|
|
5387
|
+
return [id, rest];
|
|
5388
|
+
}));
|
|
5364
5389
|
return {
|
|
5365
|
-
|
|
5366
|
-
...
|
|
5367
|
-
|
|
5390
|
+
doc: {
|
|
5391
|
+
...doc,
|
|
5392
|
+
data: {
|
|
5393
|
+
...doc.data,
|
|
5394
|
+
sections: kept
|
|
5395
|
+
}
|
|
5368
5396
|
},
|
|
5369
|
-
|
|
5370
|
-
...resolvedAt ? { resolvedAt } : {},
|
|
5371
|
-
...typeof metadata["prUrl"] === "string" ? { prUrl: metadata["prUrl"] } : {},
|
|
5372
|
-
...Number.isNaN(created) ? {} : { waitingDays: Math.floor((waitedUntil(resolvedAt, now) - created) / DAY_MS) },
|
|
5373
|
-
...full ? { metadata: row["metadata"] } : {}
|
|
5397
|
+
dropped
|
|
5374
5398
|
};
|
|
5375
5399
|
}
|
|
5376
|
-
/**
|
|
5377
|
-
*
|
|
5378
|
-
*
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
+
/**
|
|
5401
|
+
* How much of the two documents the bundle carries. The outline by default
|
|
5402
|
+
* (PRO-2778): the whole sections that fit a budget made the opening read run
|
|
5403
|
+
* to 43k characters for an active product, most of it prose a "where does it
|
|
5404
|
+
* stand" question never reads.
|
|
5405
|
+
*/
|
|
5406
|
+
const DOCUMENT_DEPTHS = [
|
|
5407
|
+
"outline",
|
|
5408
|
+
"budget",
|
|
5409
|
+
"full"
|
|
5410
|
+
];
|
|
5411
|
+
/** Pages of 100 the backlog part follows before it stops and says so. */
|
|
5412
|
+
const BACKLOG_PAGE_LIMIT = 10;
|
|
5413
|
+
/**
|
|
5414
|
+
* Open items the bundle carries, the newest of those read; past this it says truncated and
|
|
5415
|
+
* taiga_list_initiatives pages the rest. Keeps a product with hundreds of
|
|
5416
|
+
* open initiatives inside the tool result the document budget is sized for.
|
|
5417
|
+
*/
|
|
5418
|
+
const BACKLOG_IN_CONTEXT = 50;
|
|
5419
|
+
const PARTS = {
|
|
5420
|
+
factory: "factories:read",
|
|
5421
|
+
documents: "products:read",
|
|
5422
|
+
policies: "tenant:documents:read",
|
|
5423
|
+
initiatives: "backlog:read",
|
|
5424
|
+
repositories: "products:read",
|
|
5425
|
+
runs: "products:read",
|
|
5426
|
+
actionRequired: "products:read",
|
|
5427
|
+
maintenance: "products:read",
|
|
5428
|
+
policyStanding: "products:read"
|
|
5429
|
+
};
|
|
5430
|
+
/** What the API said when a part failed, in the words the bundle reports. */
|
|
5431
|
+
function unavailableReason(reason) {
|
|
5432
|
+
if (reason instanceof TaigaApiError) return apiFailureText(reason);
|
|
5433
|
+
return `the API could not be reached: ${networkFailureDetail(reason)}`;
|
|
5434
|
+
}
|
|
5435
|
+
/** Whether a failed part is the API's doing; anything else is ours and propagates. */
|
|
5436
|
+
function isApiFailure$1(reason) {
|
|
5437
|
+
return reason instanceof TaigaApiError || isNetworkFailure(reason);
|
|
5438
|
+
}
|
|
5439
|
+
function isNotFound$1(reason) {
|
|
5440
|
+
return reason instanceof TaigaApiError && reason.status === 404;
|
|
5441
|
+
}
|
|
5442
|
+
/** The factory's standing guidance in full; null, with a note, when none is published or it is not section-shaped. */
|
|
5443
|
+
async function factoryPart(client, factoryId) {
|
|
5444
|
+
const factory = (await client.getFactory(factoryId)).data;
|
|
5445
|
+
const base = {
|
|
5446
|
+
id: factory.id,
|
|
5447
|
+
name: factory.name,
|
|
5448
|
+
slug: factory.slug
|
|
5449
|
+
};
|
|
5450
|
+
try {
|
|
5451
|
+
const doc = (await client.getFactoryDocument(factoryId, "factory_instructions")).data;
|
|
5452
|
+
const sections = sectionsOf(doc.data);
|
|
5453
|
+
if (!sections) return {
|
|
5454
|
+
...base,
|
|
5455
|
+
instructions: null,
|
|
5456
|
+
instructionsVersion: doc.version,
|
|
5457
|
+
note: "The published instructions are not made of sections; read them with taiga_get_factory_document."
|
|
5458
|
+
};
|
|
5459
|
+
return {
|
|
5460
|
+
...base,
|
|
5461
|
+
instructions: instructionsText(sections),
|
|
5462
|
+
instructionsVersion: doc.version
|
|
5463
|
+
};
|
|
5464
|
+
} catch (error) {
|
|
5465
|
+
if (!isNotFound$1(error)) throw error;
|
|
5466
|
+
return {
|
|
5467
|
+
...base,
|
|
5468
|
+
instructions: null,
|
|
5469
|
+
instructionsVersion: null,
|
|
5470
|
+
note: "This factory has not published instructions yet."
|
|
5471
|
+
};
|
|
5400
5472
|
}
|
|
5401
|
-
return [...byProduct.values()].sort((a, b) => b.open - a.open);
|
|
5402
5473
|
}
|
|
5403
|
-
/** Pull request reviews checked against their run and initiative; past this a row is left as it is. */
|
|
5404
|
-
const STALE_CHECKS = 10;
|
|
5405
5474
|
/**
|
|
5406
|
-
*
|
|
5407
|
-
*
|
|
5408
|
-
*
|
|
5409
|
-
* row still is open in Action Required, so this says what it is now rather
|
|
5410
|
-
* than hiding it. Best effort, a handful of runs: a failed read leaves the
|
|
5411
|
-
* row as it was.
|
|
5475
|
+
* The specification and architecture from the product's own timeline: their
|
|
5476
|
+
* outline by default, the whole sections up to a budget or in full when
|
|
5477
|
+
* asked. Null, with a note, where unpublished.
|
|
5412
5478
|
*/
|
|
5413
|
-
async function
|
|
5414
|
-
const
|
|
5415
|
-
const
|
|
5416
|
-
|
|
5417
|
-
const out =
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
if (
|
|
5424
|
-
if (
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5479
|
+
async function documentsPart(client, product, depth) {
|
|
5480
|
+
const productId = product.id;
|
|
5481
|
+
const origin = defaultOriginFor(product);
|
|
5482
|
+
const reads = await Promise.allSettled(CONTEXT_DOCUMENT_TYPES.map((type) => client.getProductDocument(productId, type, origin)));
|
|
5483
|
+
const out = { origin };
|
|
5484
|
+
const notes = [];
|
|
5485
|
+
CONTEXT_DOCUMENT_TYPES.forEach((type, index) => {
|
|
5486
|
+
const read = reads[index];
|
|
5487
|
+
if (read.status === "fulfilled") {
|
|
5488
|
+
const doc = stripProvenance(read.value.data);
|
|
5489
|
+
if (depth === "full") out[type] = doc;
|
|
5490
|
+
else if (depth === "budget") {
|
|
5491
|
+
const lean = withoutCitations(doc);
|
|
5492
|
+
if (lean.dropped) notes.push(`The ${origin} ${type} leaves out each section's citations (its code evidence); taiga_get_product_document carries them.`);
|
|
5493
|
+
const cut = budgetSections(lean.doc, DOCUMENT_BUDGET);
|
|
5494
|
+
if (!cut) {
|
|
5495
|
+
const small = JSON.stringify(lean.doc).length <= DOCUMENT_BUDGET;
|
|
5496
|
+
out[type] = small ? lean.doc : {
|
|
5497
|
+
version: doc.version,
|
|
5498
|
+
sections: null
|
|
5499
|
+
};
|
|
5500
|
+
if (!small) notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
5501
|
+
} else if (cut.omittedSections.length > 0) {
|
|
5502
|
+
out[type] = {
|
|
5503
|
+
...cut.doc,
|
|
5504
|
+
omittedSections: cut.omittedSections
|
|
5505
|
+
};
|
|
5506
|
+
notes.push(`The ${origin} ${type} carries the whole sections that fit; read the ones under omittedSections with taiga_get_product_document, naming them in sections.`);
|
|
5507
|
+
} else out[type] = cut.doc;
|
|
5508
|
+
} else {
|
|
5509
|
+
const sections = sectionsOf(doc.data ?? {});
|
|
5510
|
+
if (sections) {
|
|
5511
|
+
const outline = Object.keys(sections).map((id) => ({
|
|
5512
|
+
id,
|
|
5513
|
+
characters: sectionContent(sections, id)?.length ?? 0
|
|
5514
|
+
}));
|
|
5515
|
+
out[type] = {
|
|
5516
|
+
version: doc.version,
|
|
5517
|
+
sections: outline
|
|
5518
|
+
};
|
|
5519
|
+
} else {
|
|
5520
|
+
out[type] = {
|
|
5521
|
+
version: doc.version,
|
|
5522
|
+
sections: null
|
|
5523
|
+
};
|
|
5524
|
+
notes.push(`The ${origin} ${type} is not made of sections; read it with taiga_get_product_document.`);
|
|
5525
|
+
}
|
|
5526
|
+
}
|
|
5527
|
+
} else if (isNotFound$1(read.reason)) {
|
|
5528
|
+
out[type] = null;
|
|
5529
|
+
notes.push(`No published ${origin} ${type}; taiga_list_product_documents shows what the product has.`);
|
|
5530
|
+
} else throw read.reason;
|
|
5531
|
+
});
|
|
5434
5532
|
return {
|
|
5435
|
-
|
|
5436
|
-
|
|
5533
|
+
...out,
|
|
5534
|
+
notes
|
|
5437
5535
|
};
|
|
5438
5536
|
}
|
|
5439
|
-
/**
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
if (asks.length === 0) return 0;
|
|
5447
|
-
if (!client.hasCapability("backlog:read")) return asks.length;
|
|
5448
|
-
const checked = [...new Set(asks.map((row) => String(row["productId"])))].slice(0, STALE_CHECKS);
|
|
5449
|
-
let unchecked = asks.filter((row) => !checked.includes(String(row["productId"]))).length;
|
|
5450
|
-
const empty = /* @__PURE__ */ new Set();
|
|
5451
|
-
await Promise.all(checked.map(async (productId) => {
|
|
5452
|
-
try {
|
|
5453
|
-
const [waiting, started] = await Promise.all([client.listBacklogItems(productId, {
|
|
5454
|
-
status: "backlog",
|
|
5455
|
-
limit: 1
|
|
5456
|
-
}), client.listBacklogItems(productId, {
|
|
5457
|
-
status: "in_progress",
|
|
5458
|
-
limit: 1
|
|
5459
|
-
})]);
|
|
5460
|
-
if (waiting.data.length === 0 && started.data.length === 0) empty.add(productId);
|
|
5461
|
-
} catch (error) {
|
|
5462
|
-
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
5463
|
-
unchecked += asks.filter((row) => row["productId"] === productId).length;
|
|
5464
|
-
}
|
|
5537
|
+
/** Every published policy with its section count, so the model knows which one to read with taiga_get_document. */
|
|
5538
|
+
async function policiesPart(client) {
|
|
5539
|
+
return (await client.listTenantDocuments({ limit: 100 })).data.filter((row) => POLICY_TYPES.includes(String(row["type"])) && row["isDraft"] === false).map((row) => ({
|
|
5540
|
+
type: row["type"],
|
|
5541
|
+
name: row["name"],
|
|
5542
|
+
version: row["version"],
|
|
5543
|
+
sections: Object.keys(sectionsOf(row["data"] ?? {}) ?? {}).length
|
|
5465
5544
|
}));
|
|
5466
|
-
for (const row of asks) if (empty.has(String(row["productId"]))) out.set(String(row["id"]), "no initiative is open now: the ones it asked about are all done or canceled");
|
|
5467
|
-
return unchecked;
|
|
5468
|
-
}
|
|
5469
|
-
/** Pages read for the summary; past this it says it counted only those. */
|
|
5470
|
-
const SUMMARY_PAGE = 100;
|
|
5471
|
-
const SETUP_TASK_FIELDS = [
|
|
5472
|
-
"id",
|
|
5473
|
-
"productId",
|
|
5474
|
-
"productName",
|
|
5475
|
-
"category",
|
|
5476
|
-
"phase",
|
|
5477
|
-
"target",
|
|
5478
|
-
"key",
|
|
5479
|
-
"isSecret",
|
|
5480
|
-
"title",
|
|
5481
|
-
"providerDocUrl",
|
|
5482
|
-
"providerActionUrl",
|
|
5483
|
-
"inboxItemId",
|
|
5484
|
-
"inboxItemStatus",
|
|
5485
|
-
"createdAt"
|
|
5486
|
-
];
|
|
5487
|
-
/** The API's count across every page, on the first page; nothing from an API that does not send one. */
|
|
5488
|
-
function totalOf(result) {
|
|
5489
|
-
return typeof result.totalCount === "number" ? { totalCount: result.totalCount } : {};
|
|
5490
5545
|
}
|
|
5546
|
+
/** The statuses an item is open in; the API's filter takes one at a time. */
|
|
5547
|
+
const OPEN_BACKLOG_STATUSES = ["backlog", "in_progress"];
|
|
5491
5548
|
/**
|
|
5492
|
-
*
|
|
5493
|
-
*
|
|
5494
|
-
* The oldest
|
|
5495
|
-
*
|
|
5549
|
+
* The items still open: one status-filtered read per open status (the API
|
|
5550
|
+
* leaves archived items out), each paged in full up to a cap the result
|
|
5551
|
+
* reports. The list is oldest first and the open items are the newest, so a
|
|
5552
|
+
* single page would miss exactly the ones that matter. Rows carrying a
|
|
5553
|
+
* retired status (readable for a rolling deploy) match neither filter;
|
|
5554
|
+
* the settled check below keeps the shape honest for whatever comes back.
|
|
5496
5555
|
*/
|
|
5497
|
-
function
|
|
5498
|
-
const
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
for (const
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
const
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
}
|
|
5521
|
-
if (!isOpen(kept) && isOpen(row)) {
|
|
5522
|
-
const { alsoFiledAs: _also, ...was } = kept;
|
|
5523
|
-
for (const field of Object.keys(kept)) delete kept[field];
|
|
5524
|
-
Object.assign(kept, row, { alsoFiledAs: [...also, record(was)] });
|
|
5525
|
-
} else kept["alsoFiledAs"] = [...also, record(row)];
|
|
5556
|
+
async function backlogPart(client, productId) {
|
|
5557
|
+
const open = /* @__PURE__ */ new Map();
|
|
5558
|
+
let truncated = false;
|
|
5559
|
+
let pageCapped = false;
|
|
5560
|
+
for (const status of OPEN_BACKLOG_STATUSES) {
|
|
5561
|
+
let cursor;
|
|
5562
|
+
for (let pageIndex = 0;; pageIndex += 1) {
|
|
5563
|
+
if (pageIndex === BACKLOG_PAGE_LIMIT) {
|
|
5564
|
+
truncated = true;
|
|
5565
|
+
pageCapped = true;
|
|
5566
|
+
break;
|
|
5567
|
+
}
|
|
5568
|
+
const result = await client.listBacklogItems(productId, {
|
|
5569
|
+
status,
|
|
5570
|
+
limit: 100,
|
|
5571
|
+
cursor
|
|
5572
|
+
});
|
|
5573
|
+
for (const row of result.data) if (!isSettledOrArchived({
|
|
5574
|
+
status: String(row.status),
|
|
5575
|
+
archivedAt: row.archivedAt ?? null
|
|
5576
|
+
})) open.set(String(row.id), row);
|
|
5577
|
+
if (!result.nextCursor) break;
|
|
5578
|
+
cursor = result.nextCursor;
|
|
5579
|
+
}
|
|
5526
5580
|
}
|
|
5527
|
-
|
|
5581
|
+
const rows = [...open.values()].sort((a, b) => String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0);
|
|
5582
|
+
if (rows.length > BACKLOG_IN_CONTEXT) truncated = true;
|
|
5583
|
+
const byColumn = {
|
|
5584
|
+
backlog: 0,
|
|
5585
|
+
todo: 0,
|
|
5586
|
+
queue: 0,
|
|
5587
|
+
plan: 0,
|
|
5588
|
+
build: 0
|
|
5589
|
+
};
|
|
5590
|
+
for (const row of rows) if (row.state) byColumn[row.state] = (byColumn[row.state] ?? 0) + 1;
|
|
5591
|
+
return {
|
|
5592
|
+
byColumn,
|
|
5593
|
+
items: situatedRows(rows.slice(-50)),
|
|
5594
|
+
truncated,
|
|
5595
|
+
...pageCapped ? { note: "More than 1000 open items in one status: only the oldest of them were read, so the newest may be missing here and byColumn counts only those read; taiga_list_initiatives pages the rest." } : {}
|
|
5596
|
+
};
|
|
5528
5597
|
}
|
|
5529
|
-
/**
|
|
5530
|
-
|
|
5598
|
+
/**
|
|
5599
|
+
* The linked repositories. The route serves one page of 100 and takes no
|
|
5600
|
+
* cursor, so past that the bundle says the list is cut rather than complete.
|
|
5601
|
+
*/
|
|
5602
|
+
async function repositoriesPart(links) {
|
|
5603
|
+
const result = await links();
|
|
5531
5604
|
return {
|
|
5532
|
-
|
|
5533
|
-
|
|
5605
|
+
items: pickFields(result.data, REPOSITORY_SUMMARY_FIELDS),
|
|
5606
|
+
truncated: result.nextCursor !== null
|
|
5534
5607
|
};
|
|
5535
5608
|
}
|
|
5536
|
-
function
|
|
5609
|
+
async function buildsPart(client, productId) {
|
|
5610
|
+
const rows = (await client.listBuildRuns(productId, { limit: BUILDS_IN_CONTEXT })).data;
|
|
5611
|
+
const states = await initiativeStates(client, rows);
|
|
5612
|
+
return pickFields(rows, CONTEXT_BUILD_FIELDS).map(readableRow).map((row) => withInitiativeState(row, states));
|
|
5613
|
+
}
|
|
5614
|
+
/**
|
|
5615
|
+
* What waits on a person for this product, counted as taiga_list_action_required's summary counts it (PRO-2800):
|
|
5616
|
+
* the reviewing agent called four tools to answer "how does it stand", and two later reviews found this count and
|
|
5617
|
+
* the list's disagree (18 open here, 16 there), because this one counted stale reviews and put-off items as open.
|
|
5618
|
+
*/
|
|
5619
|
+
async function attentionPart(client, productId) {
|
|
5620
|
+
const result = await client.listInbox({
|
|
5621
|
+
productId,
|
|
5622
|
+
limit: 100
|
|
5623
|
+
});
|
|
5624
|
+
return {
|
|
5625
|
+
...await attentionCounts(client, result.data),
|
|
5626
|
+
...result.nextCursor ? { note: "These count the first 100 items; taiga_list_action_required pages the rest." } : {}
|
|
5627
|
+
};
|
|
5628
|
+
}
|
|
5629
|
+
/** The latest sweep and what it left open, as counts; taiga_list_findings lists them. */
|
|
5630
|
+
async function maintenancePart(client, productId) {
|
|
5631
|
+
const summary = (await client.getMaintenanceSummary(productId)).data;
|
|
5632
|
+
const last = summary["lastRun"];
|
|
5633
|
+
return {
|
|
5634
|
+
openFindings: summary["openFindings"],
|
|
5635
|
+
bySeverity: summary["severityCounts"],
|
|
5636
|
+
lastSweep: last ? {
|
|
5637
|
+
status: last["status"],
|
|
5638
|
+
completedAt: last["completedAt"]
|
|
5639
|
+
} : null,
|
|
5640
|
+
...last ? {} : { note: "Never swept: no findings is not an all-clear." }
|
|
5641
|
+
};
|
|
5642
|
+
}
|
|
5643
|
+
/** Where the product stands against the policies, as counts; taiga_get_policy_standing has each violation. */
|
|
5644
|
+
async function standingPart(client, productId) {
|
|
5645
|
+
const standing = (await client.getPolicyStanding(productId)).data;
|
|
5646
|
+
const summary = standingSummary(standing);
|
|
5647
|
+
const state = standing.assessment?.state;
|
|
5648
|
+
return {
|
|
5649
|
+
openViolations: summary.violations,
|
|
5650
|
+
bySeverity: summary.bySeverity,
|
|
5651
|
+
controlsViolated: summary.controls,
|
|
5652
|
+
...state === "never" || state === "failed" ? { note: `The assessment ${state === "never" ? "has never run" : "failed"}: no violations here is not an all-clear.` } : {}
|
|
5653
|
+
};
|
|
5654
|
+
}
|
|
5655
|
+
async function productContext(client, product, options) {
|
|
5656
|
+
const productId = product.id;
|
|
5657
|
+
const granted = (part) => client.hasCapability(PARTS[part]);
|
|
5658
|
+
const notGranted = Object.keys(PARTS).filter((part) => !granted(part));
|
|
5659
|
+
let linksRead;
|
|
5660
|
+
const links = () => linksRead ??= client.listProductRepositories(productId);
|
|
5661
|
+
const fetchers = {
|
|
5662
|
+
factory: () => factoryPart(client, String(product.factoryId)),
|
|
5663
|
+
documents: () => documentsPart(client, product, options.documents),
|
|
5664
|
+
policies: () => policiesPart(client),
|
|
5665
|
+
initiatives: () => backlogPart(client, productId),
|
|
5666
|
+
repositories: () => repositoriesPart(links),
|
|
5667
|
+
runs: () => buildsPart(client, productId),
|
|
5668
|
+
actionRequired: () => attentionPart(client, productId),
|
|
5669
|
+
maintenance: () => maintenancePart(client, productId),
|
|
5670
|
+
policyStanding: () => standingPart(client, productId)
|
|
5671
|
+
};
|
|
5672
|
+
const parts = Object.keys(fetchers).filter(granted);
|
|
5673
|
+
const [settled, readiness] = await Promise.all([Promise.allSettled(parts.map((part) => fetchers[part]())), setupReadiness(client, product, links)]);
|
|
5674
|
+
const bundle = { product: {
|
|
5675
|
+
...pickFields([product], PRODUCT_SUMMARY_FIELDS)[0],
|
|
5676
|
+
projectType: isImportedProduct(product) ? "brownfield" : "greenfield",
|
|
5677
|
+
defaultOrigin: defaultOriginFor(product),
|
|
5678
|
+
...readiness ? { readiness } : {}
|
|
5679
|
+
} };
|
|
5680
|
+
const unavailable = {};
|
|
5681
|
+
parts.forEach((part, index) => {
|
|
5682
|
+
const result = settled[index];
|
|
5683
|
+
if (result.status === "fulfilled") bundle[part] = result.value;
|
|
5684
|
+
else if (isApiFailure$1(result.reason)) unavailable[part] = unavailableReason(result.reason);
|
|
5685
|
+
else throw result.reason;
|
|
5686
|
+
});
|
|
5687
|
+
const result = {
|
|
5688
|
+
...bundle,
|
|
5689
|
+
notGranted: notGranted.map((part) => `${part} (needs ${PARTS[part]})`),
|
|
5690
|
+
unavailable,
|
|
5691
|
+
readNext: "Read a policy with taiga_get_document (sections: [] for its outline, or a control id such as SEC-5.1), the specification's or architecture's sections or another product document with taiga_get_product_document, an initiative with taiga_get_initiative (taiga_list_initiatives pages a truncated backlog) and a run with taiga_get_run."
|
|
5692
|
+
};
|
|
5693
|
+
let characters = JSON.stringify(inProductWords(result), null, 2).length;
|
|
5694
|
+
for (let measured = -1; measured !== characters;) {
|
|
5695
|
+
measured = characters;
|
|
5696
|
+
characters = JSON.stringify(inProductWords({
|
|
5697
|
+
...result,
|
|
5698
|
+
characters: measured
|
|
5699
|
+
}), null, 2).length;
|
|
5700
|
+
}
|
|
5701
|
+
return toolResult({
|
|
5702
|
+
...result,
|
|
5703
|
+
characters
|
|
5704
|
+
});
|
|
5705
|
+
}
|
|
5706
|
+
function registerContextTools(server, client) {
|
|
5537
5707
|
if (!client.hasCapability("products:read")) return;
|
|
5538
|
-
server.registerTool("
|
|
5539
|
-
title: "
|
|
5540
|
-
description:
|
|
5708
|
+
server.registerTool("taiga_get_product_context", {
|
|
5709
|
+
title: "Get product context",
|
|
5710
|
+
description: `Read where a product stands in one call; the first read for any product question. Name it by ID, slug or git remote URL. Returns product (in setup, readiness and next move), factory with its instructions, specification and architecture outlines (section ids, sizes), policies with section counts, open initiatives with a count per board column (byColumn; taiga_list_initiatives status: 'all' has the done and canceled), repositories, the latest ten runs, and counts for Action Required, open findings and policy violations. A part that failed is under unavailable, one the key cannot read under notGranted; neither shows as empty. documents: budget returns whole sections up to about ${DOCUMENT_BUDGET / 1e3}k characters each, full returns all.`,
|
|
5541
5711
|
inputSchema: z.object({
|
|
5542
5712
|
productId: productId.optional(),
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
...pagination
|
|
5713
|
+
slug: slug.optional(),
|
|
5714
|
+
remoteUrl: remoteUrl.optional(),
|
|
5715
|
+
documents: z.enum(DOCUMENT_DEPTHS).optional().describe("The specification and architecture as outline (section ids and sizes, the default), budget (the whole sections that fit) or full")
|
|
5547
5716
|
}),
|
|
5548
5717
|
annotations: READ_ONLY
|
|
5549
5718
|
}, (params) => runTool(async () => {
|
|
5550
|
-
const
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
...query,
|
|
5558
|
-
...first ? { withTotal: true } : {}
|
|
5559
|
-
});
|
|
5560
|
-
const overview = !first || resolved ? null : !result.nextCursor ? result : await client.listInbox({
|
|
5561
|
-
...query,
|
|
5562
|
-
limit: SUMMARY_PAGE,
|
|
5563
|
-
cursor: void 0
|
|
5564
|
-
}).catch((error) => {
|
|
5565
|
-
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
5566
|
-
throw error;
|
|
5567
|
-
});
|
|
5568
|
-
const now = Date.now();
|
|
5569
|
-
const isDue = (row) => !(row["status"] === "snoozed" && typeof row["snoozedUntil"] === "string" && Date.parse(row["snoozedUntil"]) > now);
|
|
5570
|
-
const rows = result.data;
|
|
5571
|
-
const due = rows.filter(isDue);
|
|
5572
|
-
const hidden = rows.length - due.length;
|
|
5573
|
-
const overviewDue = overview ? overview.data.filter(isDue) : [];
|
|
5574
|
-
const checked = resolved ? {
|
|
5575
|
-
stale: /* @__PURE__ */ new Map(),
|
|
5576
|
-
unchecked: 0
|
|
5577
|
-
} : await staleReviews(client, [...due, ...overviewDue]);
|
|
5578
|
-
const stale = checked.stale;
|
|
5579
|
-
return toolResult({
|
|
5580
|
-
...overview ? {
|
|
5581
|
-
summary: summaryOf(overviewDue, stale),
|
|
5582
|
-
...overview.nextCursor ? { summaryNote: `The summary counts the first ${SUMMARY_PAGE} items.` } : {}
|
|
5583
|
-
} : {},
|
|
5584
|
-
...page(due.map((row) => {
|
|
5585
|
-
const item = itemRow(row, now, full === true);
|
|
5586
|
-
const why = stale.get(String(row["id"]));
|
|
5587
|
-
return why ? {
|
|
5588
|
-
...item,
|
|
5589
|
-
stale: why
|
|
5590
|
-
} : item;
|
|
5591
|
-
}), result.nextCursor),
|
|
5592
|
-
...totalOf(result),
|
|
5593
|
-
...hidden > 0 ? { snoozedHidden: hidden } : {},
|
|
5594
|
-
...checked.unchecked > 0 ? { staleNote: `${checked.unchecked} review${checked.unchecked === 1 ? " was" : "s were"} not checked for staleness (past the cap, or the read was refused).` } : {}
|
|
5595
|
-
});
|
|
5719
|
+
const resolved = await resolveProductRef(client, params, [
|
|
5720
|
+
"productId",
|
|
5721
|
+
"slug",
|
|
5722
|
+
"remoteUrl"
|
|
5723
|
+
]);
|
|
5724
|
+
if ("refusal" in resolved) return toolError(resolved.refusal.code, resolved.refusal.message);
|
|
5725
|
+
return productContext(client, resolved.product, { documents: params.documents ?? "outline" });
|
|
5596
5726
|
}));
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5727
|
+
}
|
|
5728
|
+
//#endregion
|
|
5729
|
+
//#region src/tools/autonomy.ts
|
|
5730
|
+
/**
|
|
5731
|
+
* Autonomy (docs.tai.ga, Deliver: Initiatives, Auto-merge): whether a landed
|
|
5732
|
+
* plan is built without a review, and whether a pull request merges on its
|
|
5733
|
+
* own. The organization and its factories are ceilings for merging, set by
|
|
5734
|
+
* an administrator in the web; a product sets defaults; an initiative's own
|
|
5735
|
+
* switch is the final word within the ceilings. The initiative's build gate
|
|
5736
|
+
* is taiga_set_auto_build.
|
|
5737
|
+
*/
|
|
5738
|
+
function registerAutonomyTools(server, client) {
|
|
5739
|
+
if (client.hasCapability("products:write")) server.registerTool("taiga_set_product_autonomy", {
|
|
5740
|
+
title: "Set a product's autonomy defaults",
|
|
5741
|
+
description: "Set the defaults a product's initiatives follow unless they decide for themselves. autoBuild: false makes every plan wait for a person to approve or reject it; true builds it as soon as the product has room. autoMerge: false makes pull requests wait for a person to merge; null follows the organization and factory (autonomous merge needs the organization switch on, a factory can turn it off, and neither is changed here).",
|
|
5600
5742
|
inputSchema: z.object({
|
|
5601
|
-
productId
|
|
5602
|
-
|
|
5603
|
-
|
|
5743
|
+
productId,
|
|
5744
|
+
autoBuild: z.boolean().optional().describe("Build landed plans without a review by default"),
|
|
5745
|
+
autoMerge: z.boolean().nullable().optional().describe("false: pull requests wait for a person by default; null: follow the organization and factory")
|
|
5604
5746
|
}),
|
|
5605
|
-
annotations:
|
|
5747
|
+
annotations: OPERATE_SETTING
|
|
5606
5748
|
}, (params) => runTool(async () => {
|
|
5607
|
-
|
|
5608
|
-
const
|
|
5609
|
-
|
|
5610
|
-
...
|
|
5611
|
-
...
|
|
5612
|
-
});
|
|
5613
|
-
return toolResult(
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5749
|
+
if (params.autoBuild === void 0 && params.autoMerge === void 0) return toolError("VALIDATION_ERROR", "Set autoBuild, autoMerge or both.");
|
|
5750
|
+
const autoMerge = params.autoMerge === true ? null : params.autoMerge;
|
|
5751
|
+
const product = (await client.updateProduct(params.productId, {
|
|
5752
|
+
...params.autoBuild !== void 0 ? { autoBuild: params.autoBuild } : {},
|
|
5753
|
+
...autoMerge !== void 0 ? { autoMerge } : {}
|
|
5754
|
+
})).data;
|
|
5755
|
+
return toolResult(pickFields([product], [
|
|
5756
|
+
"id",
|
|
5757
|
+
"name",
|
|
5758
|
+
"slug",
|
|
5759
|
+
"autoBuild",
|
|
5760
|
+
"autoMerge"
|
|
5761
|
+
])[0]);
|
|
5617
5762
|
}));
|
|
5618
|
-
if (
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
description: "Set an Action Required item for everybody: done, dismissed (does not apply), in_progress, or snooze it until a time (null wakes it). Resolving a review approves or merges nothing; answer the plan or pull request where it lives. Marking the product documents review done records, in your name, that every published document was reviewed (in_progress withdraws it): only when the person says they read them. Ask before clearing what the person has not dealt with.",
|
|
5763
|
+
if (client.hasCapability("backlog:write")) server.registerTool("taiga_set_auto_merge", {
|
|
5764
|
+
title: "Set whether an initiative's pull request merges on its own",
|
|
5765
|
+
description: "Set whether one initiative's pull request merges itself once the repository's rules pass (required checks, reviews, a code reviewer Taiga waits for): true, false (a person merges), or null (product default). Where the organization or factory has turned autonomous merge off, true changes nothing. Taiga merges through the repository's rules, never around them.",
|
|
5622
5766
|
inputSchema: z.object({
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
"in_progress",
|
|
5626
|
-
"done",
|
|
5627
|
-
"dismissed"
|
|
5628
|
-
]).optional().describe("What became of it"),
|
|
5629
|
-
snoozedUntil: z.string().datetime().nullable().optional().describe("Hide it until this time (ISO 8601 in UTC, ending in Z); null wakes it now")
|
|
5767
|
+
initiativeId: idInput("initiative", "taiga_list_initiatives (query finds one by title) gives it"),
|
|
5768
|
+
autoMerge: z.boolean().nullable().describe("true merges on its own, false waits for a person, null follows the product")
|
|
5630
5769
|
}),
|
|
5631
|
-
annotations:
|
|
5770
|
+
annotations: OPERATE_SETTING
|
|
5632
5771
|
}, (params) => runTool(async () => {
|
|
5633
|
-
|
|
5634
|
-
return toolResult(pickFields([
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5772
|
+
const item = (await client.updateBacklogItem(params.initiativeId, { autoMerge: params.autoMerge })).data;
|
|
5773
|
+
return toolResult(pickFields([item], [
|
|
5774
|
+
"id",
|
|
5775
|
+
"title",
|
|
5776
|
+
"state",
|
|
5777
|
+
"autoMerge",
|
|
5778
|
+
"autoBuild"
|
|
5779
|
+
])[0]);
|
|
5638
5780
|
}));
|
|
5639
5781
|
}
|
|
5640
5782
|
//#endregion
|
|
@@ -20924,7 +21066,7 @@ function registerRepositoryTools(server, client) {
|
|
|
20924
21066
|
}
|
|
20925
21067
|
if (client.hasCapability("vcs:read")) server.registerTool("taiga_list_linkable_repositories", {
|
|
20926
21068
|
title: "Find a repository to link",
|
|
20927
|
-
description: "List the repositories the organization's GitHub connection can reach, to link one with taiga_link_repository (it takes the id). query searches by name; account (login, owner, or installationId) reads one account, else rows come from every account in turn. linked: true means a product already links it (taiga_get_product with its URL says which). No connection means GitHub is not connected yet; someone who can install the app does that once, in the web.",
|
|
21069
|
+
description: "List the repositories the organization's GitHub connection can reach, to link one with taiga_link_repository (it takes the row's id: Taiga's record of the repository, not GitHub's numeric id, and not a link ID). query searches by name; account (login, owner, or installationId) reads one account, else rows come from every account in turn. linked: true means a product already links it (taiga_get_product with its URL says which). No connection means GitHub is not connected yet; someone who can install the app does that once, in the web.",
|
|
20928
21070
|
inputSchema: z.object({
|
|
20929
21071
|
query: z.string().trim().min(1).max(256).optional().describe("Part of the repository name"),
|
|
20930
21072
|
account: z.string().trim().min(1).max(140).optional().describe("The GitHub account login (the owner in owner/repo), case-insensitive"),
|
|
@@ -20951,7 +21093,7 @@ function registerRepositoryTools(server, client) {
|
|
|
20951
21093
|
});
|
|
20952
21094
|
const found = rows(result.data);
|
|
20953
21095
|
return toolResult({
|
|
20954
|
-
accounts,
|
|
21096
|
+
accounts: params.account || params.installationId ? pickFields(chosen, INSTALLATION_FIELDS) : accounts,
|
|
20955
21097
|
repositories: page(found.slice(0, max), result.nextCursor),
|
|
20956
21098
|
...found.length > max ? { more: "More matched than the limit; narrow the query or raise the limit." } : {}
|
|
20957
21099
|
});
|
|
@@ -20974,7 +21116,7 @@ function registerRepositoryTools(server, client) {
|
|
|
20974
21116
|
description: "Link a GitHub repository to a product (taiga_list_linkable_repositories gives its id). replacesRepositoryId swaps out the one linked now in one step (the same one changes nothing); linking while one is linked without it is refused. A managed link (isManaged) cannot be replaced or unlinked. Once linked, planning reads its code, runs open pull requests in it, sweeps scan it, and its deployment history is imported.",
|
|
20975
21117
|
inputSchema: z.object({
|
|
20976
21118
|
productId,
|
|
20977
|
-
githubRepositoryId: idInput("GitHub repository", "taiga_list_linkable_repositories gives it"),
|
|
21119
|
+
githubRepositoryId: idInput("GitHub repository", "taiga_list_linkable_repositories gives it (the row's id, Taiga's record; not GitHub's numeric id)"),
|
|
20978
21120
|
replacesRepositoryId: repositoryId.optional().describe("The link this one replaces")
|
|
20979
21121
|
}),
|
|
20980
21122
|
annotations: OPERATE
|
|
@@ -21948,30 +22090,31 @@ function registerWhoami(server, client, registered) {
|
|
|
21948
22090
|
* (PRO-2800).
|
|
21949
22091
|
*/
|
|
21950
22092
|
const SERVER_INSTRUCTIONS = [
|
|
21951
|
-
"Taiga is a policy-aware software delivery platform. Use these tools (taiga_*)
|
|
21952
|
-
"
|
|
22093
|
+
"Taiga is a policy-aware software delivery platform. Use these tools (taiga_*) when the person asks about a Taiga",
|
|
22094
|
+
"product, its initiatives or runs, policies, findings, deployments, or setting a product up.",
|
|
21953
22095
|
"",
|
|
21954
|
-
"**Which read.** Where a product stands: taiga_get_product_context
|
|
21955
|
-
"people: taiga_list_action_required. A policy: taiga_get_document (sections: []
|
|
21956
|
-
"such as SEC-5.1). Findings: taiga_list_findings. One initiative or run: taiga_get_initiative,
|
|
21957
|
-
"
|
|
22096
|
+
"**Which read.** Where a product stands (counts, not detail): taiga_get_product_context, by git remote URL, slug",
|
|
22097
|
+
"or ID. What waits on people: taiga_list_action_required. A policy: taiga_get_document (sections: [], or a",
|
|
22098
|
+
"control id such as SEC-5.1). Findings: taiga_list_findings. One initiative or run: taiga_get_initiative,",
|
|
22099
|
+
"taiga_get_run. Which tools this key may call: taiga_whoami.",
|
|
21958
22100
|
"",
|
|
21959
|
-
"**Words.** An initiative is a unit of work
|
|
21960
|
-
"line
|
|
21961
|
-
"coding
|
|
21962
|
-
"
|
|
21963
|
-
"
|
|
22101
|
+
"**Words.** An initiative is a unit of work, on a board of columns (backlog, todo, queue, plan, build, done,",
|
|
22102
|
+
"canceled); the plan and build columns are stations. The line works the queue one initiative at a time. A run is",
|
|
22103
|
+
"one coding-agent attempt; hardening is its security pass; handed back means a person must act. A gate is a review",
|
|
22104
|
+
"a person makes; a sweep is a scan for findings; standing is how a product meets the policies. Discovery is the web",
|
|
22105
|
+
"conversation that specifies a new product. A factory owns products. People stamp checks in the web.",
|
|
21964
22106
|
"",
|
|
21965
|
-
"**
|
|
21966
|
-
"
|
|
22107
|
+
"**IDs** are UUIDs; the product, factory and initiative lists take query to find one by name. A NOT_FOUND names the tool that lists the right",
|
|
22108
|
+
"ones. Lists page with nextCursor. outcome, situation and nextStep are one-sentence summaries; setup writes",
|
|
22109
|
+
"return readiness with nextStep, so there is no need to read the product again. A product document has two",
|
|
22110
|
+
"timelines: plan (what was meant) and reality (derived from the code).",
|
|
21967
22111
|
"",
|
|
21968
|
-
"**Writing.** A tool that writes opens with \"Writes.\", \"Ask first (paid agent work).\" or \"Ask first (others
|
|
21969
|
-
"it).\"; before an ask-first tool, tell the person
|
|
21970
|
-
"
|
|
21971
|
-
"the one that exists may not be this person's.",
|
|
22112
|
+
"**Writing.** A tool that writes opens with \"Writes.\", \"Ask first (paid agent work).\" or \"Ask first (others",
|
|
22113
|
+
"see it).\"; before an ask-first tool, tell the person and wait for their yes. Others share the organization: a",
|
|
22114
|
+
"taken name is refused unless allowDuplicateName, and the one that exists may not be this person's.",
|
|
21972
22115
|
"",
|
|
21973
|
-
"**Only in the web:** connecting GitHub, members and roles,
|
|
21974
|
-
"
|
|
22116
|
+
"**Only in the web:** connecting GitHub, members and roles, Discovery, stamping checks, starting a run over, and",
|
|
22117
|
+
"deleting factories and products."
|
|
21975
22118
|
].join("\n");
|
|
21976
22119
|
//#endregion
|
|
21977
22120
|
//#region src/server.ts
|