@taiga-ai/mcp-server 0.25.0-rc.472 → 0.26.0-dev.479
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/README.md +111 -0
- package/dist/index.js +322 -122
- package/package.json +15 -2
- package/scripts/build.mjs +0 -37
- package/scripts/prepare-publish.cjs +0 -49
- package/src/client.test.ts +0 -424
- package/src/client.ts +0 -976
- package/src/config.test.ts +0 -24
- package/src/config.ts +0 -28
- package/src/index.ts +0 -45
- package/src/modern.integration.test.ts +0 -153
- package/src/server.integration.test.ts +0 -9075
- package/src/server.ts +0 -18
- package/src/tools/annotations.ts +0 -38
- package/src/tools/autonomy.ts +0 -86
- package/src/tools/backlogs.ts +0 -766
- package/src/tools/builds.ts +0 -668
- package/src/tools/catalogue.test.ts +0 -170
- package/src/tools/catalogue.ts +0 -223
- package/src/tools/context.ts +0 -513
- package/src/tools/documents.ts +0 -375
- package/src/tools/errors.ts +0 -172
- package/src/tools/hints.ts +0 -99
- package/src/tools/inbox.ts +0 -546
- package/src/tools/index.ts +0 -239
- package/src/tools/inputs.ts +0 -47
- package/src/tools/instructions.ts +0 -41
- package/src/tools/jobs.ts +0 -90
- package/src/tools/knowledge.ts +0 -249
- package/src/tools/maintenance.ts +0 -540
- package/src/tools/markdown.test.ts +0 -249
- package/src/tools/markdown.ts +0 -151
- package/src/tools/observe.ts +0 -311
- package/src/tools/product-documents.ts +0 -184
- package/src/tools/products.ts +0 -529
- package/src/tools/prompts.ts +0 -265
- package/src/tools/queue.ts +0 -250
- package/src/tools/readiness.ts +0 -177
- package/src/tools/remote.test.ts +0 -44
- package/src/tools/remote.ts +0 -107
- package/src/tools/repositories.ts +0 -360
- package/src/tools/request-prompt.test.ts +0 -32
- package/src/tools/request-prompt.ts +0 -28
- package/src/tools/resources.ts +0 -217
- package/src/tools/sections.test.ts +0 -143
- package/src/tools/sections.ts +0 -287
- package/src/tools/setup.ts +0 -455
- package/src/tools/summarize.test.ts +0 -20
- package/src/tools/summarize.ts +0 -96
- package/src/tools/wait.ts +0 -490
- package/src/version.test.ts +0 -10
- package/src/version.ts +0 -14
- package/taiga-ai-mcp-server-0.25.0-rc.472.tgz +0 -0
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -9
package/src/tools/builds.ts
DELETED
|
@@ -1,668 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import {
|
|
4
|
-
type BuildRunStep,
|
|
5
|
-
type BuildRunWithContext,
|
|
6
|
-
BUILD_RUN_STATUSES,
|
|
7
|
-
dispositionOf,
|
|
8
|
-
type BuildRunStatus,
|
|
9
|
-
} from '@taiga/types';
|
|
10
|
-
import type { TaigaClient, WithoutTenant } from '../client.js';
|
|
11
|
-
import { OPERATE, READ_ONLY } from './annotations.js';
|
|
12
|
-
import { idInput, pagination, productId } from './inputs.js';
|
|
13
|
-
import { isNetworkFailure, runTool, toolError, toolResult } from './errors.js';
|
|
14
|
-
import { TaigaApiError } from '../client.js';
|
|
15
|
-
import { page, pickFields } from './summarize.js';
|
|
16
|
-
|
|
17
|
-
/** What a list row needs to say for the model to pick a run to read in full. */
|
|
18
|
-
export const BUILD_SUMMARY_FIELDS = [
|
|
19
|
-
'id',
|
|
20
|
-
'runNumber',
|
|
21
|
-
'productId',
|
|
22
|
-
'backlogItemId',
|
|
23
|
-
'targetTitle',
|
|
24
|
-
'planId',
|
|
25
|
-
'status',
|
|
26
|
-
'failedReason',
|
|
27
|
-
'repositoryFullName',
|
|
28
|
-
'branch',
|
|
29
|
-
'baseBranch',
|
|
30
|
-
'completedSteps',
|
|
31
|
-
'totalSteps',
|
|
32
|
-
'prNumber',
|
|
33
|
-
'prUrl',
|
|
34
|
-
'prState',
|
|
35
|
-
'prDraft',
|
|
36
|
-
'prMerged',
|
|
37
|
-
'reviewEscalation',
|
|
38
|
-
'error',
|
|
39
|
-
'actions',
|
|
40
|
-
'createdAt',
|
|
41
|
-
'startedAt',
|
|
42
|
-
'completedAt',
|
|
43
|
-
] as const;
|
|
44
|
-
|
|
45
|
-
/** What a run says about itself after a stop or a resume: its state and what can be done next. */
|
|
46
|
-
const RUN_OUTCOME_FIELDS = [
|
|
47
|
-
'id',
|
|
48
|
-
'runNumber',
|
|
49
|
-
'backlogItemId',
|
|
50
|
-
'status',
|
|
51
|
-
'failedReason',
|
|
52
|
-
'completedSteps',
|
|
53
|
-
'totalSteps',
|
|
54
|
-
'prNumber',
|
|
55
|
-
'prUrl',
|
|
56
|
-
'prDraft',
|
|
57
|
-
'prMerged',
|
|
58
|
-
'reviewEscalation',
|
|
59
|
-
'error',
|
|
60
|
-
'actions',
|
|
61
|
-
] as const;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Where a run that ended without finishing stopped, from its steps and its
|
|
65
|
-
* hardening result: a failed run with every step completed reads as nothing
|
|
66
|
-
* failed, and retry_from_failed_step then reads as a mistake, when the agent
|
|
67
|
-
* stalled in the hardening pass after the steps and the retry continues it.
|
|
68
|
-
* Null for a run still going or that finished.
|
|
69
|
-
*/
|
|
70
|
-
export function stoppedIn(
|
|
71
|
-
run: Pick<WithoutTenant<BuildRunWithContext>, 'prMerged' | 'status' | 'hardeningResult'>,
|
|
72
|
-
steps: readonly Pick<WithoutTenant<BuildRunStep>, 'status' | 'stepOrder' | 'title'>[]
|
|
73
|
-
): string | null {
|
|
74
|
-
// A merged run is finished, whatever its status still says: the merge is
|
|
75
|
-
// latched apart from the status, which can stay at a stale failed or stopped.
|
|
76
|
-
if (run.prMerged === true || (run.status !== 'failed' && run.status !== 'stopped')) return null;
|
|
77
|
-
// No step started yet: the run never got past setting up (cloning, the
|
|
78
|
-
// workspace), whatever the first step is called.
|
|
79
|
-
const started = steps.some((step) => step.status !== 'planned');
|
|
80
|
-
if (!started) return 'before the first step started, while setting up the run (cloning, preparing the workspace)';
|
|
81
|
-
const open = steps.find((step) => step.status !== 'completed' && step.status !== 'skipped');
|
|
82
|
-
if (open) {
|
|
83
|
-
return open.status === 'planned'
|
|
84
|
-
? `before step ${String(open.stepOrder)}, ${String(open.title)}, started (the ones before it completed)`
|
|
85
|
-
: `step ${String(open.stepOrder)}, ${String(open.title)} (${String(open.status)})`;
|
|
86
|
-
}
|
|
87
|
-
if (run.hardeningResult == null) {
|
|
88
|
-
return (
|
|
89
|
-
'after the last step, in hardening: the security scanners and their fixes the agent runs before the ' +
|
|
90
|
-
'pull request is ready. A retry continues from there; the steps stay.'
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
return 'after the steps and hardening, at the pull request';
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The disposition with the merge latch authoritative: a run whose pull
|
|
98
|
-
* request merged landed, whatever settled status it kept when the completion
|
|
99
|
-
* write after the merge failed (build.ts allows a merged run to stay failed
|
|
100
|
-
* or stopped). A run still executing keeps its in-flight disposition.
|
|
101
|
-
*/
|
|
102
|
-
export function landedDisposition(status: BuildRunStatus, prMerged: boolean): string {
|
|
103
|
-
const disposition = dispositionOf(status, prMerged);
|
|
104
|
-
return prMerged && disposition !== 'in_flight' ? 'succeeded' : disposition;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** What each executing status is doing, in a phrase. */
|
|
108
|
-
const RUNNING: Partial<Record<BuildRunStatus, string>> = {
|
|
109
|
-
cloning: 'setting up the workspace',
|
|
110
|
-
implementing: 'implementing the steps',
|
|
111
|
-
hardening: 'hardening: running the security scanners and fixing what they find',
|
|
112
|
-
pushing: 'pushing the branch',
|
|
113
|
-
ci_checking: "waiting for the pull request's CI",
|
|
114
|
-
ci_fixing: 'fixing what CI found',
|
|
115
|
-
reviewing: 'answering review comments on the pull request',
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
/** Why the agent handed a run back, in the web's words (apps/web handback), with the checks it names. */
|
|
119
|
-
export function handbackSaid(escalation: unknown): string | null {
|
|
120
|
-
if (!escalation || typeof escalation !== 'object') return null;
|
|
121
|
-
const e = escalation as {
|
|
122
|
-
reason?: unknown;
|
|
123
|
-
rounds?: unknown;
|
|
124
|
-
failingChecks?: unknown;
|
|
125
|
-
file?: unknown;
|
|
126
|
-
blockers?: unknown;
|
|
127
|
-
};
|
|
128
|
-
const checks = Array.isArray(e.failingChecks)
|
|
129
|
-
? (e.failingChecks as { name?: unknown }[]).map((check) => String(check.name)).join(', ')
|
|
130
|
-
: '';
|
|
131
|
-
const on = checks ? ` on ${checks}` : '';
|
|
132
|
-
const rounds =
|
|
133
|
-
typeof e.rounds === 'number' && e.rounds > 0
|
|
134
|
-
? ` after ${e.rounds} automatic round${e.rounds === 1 ? '' : 's'}`
|
|
135
|
-
: '';
|
|
136
|
-
const blockers = Array.isArray(e.blockers)
|
|
137
|
-
? (e.blockers as unknown[]).map((b) => READY_BLOCKER_SAID[String(b)] ?? String(b))
|
|
138
|
-
: [];
|
|
139
|
-
switch (e.reason) {
|
|
140
|
-
case 'ci_fix_cap':
|
|
141
|
-
return `CI is still failing${on}${rounds}`;
|
|
142
|
-
case 'ci_fix_stalled':
|
|
143
|
-
return `CI is still failing${on}, and the last fix round changed nothing`;
|
|
144
|
-
case 'conflict_cap':
|
|
145
|
-
return `the branch still conflicts with its base${rounds}`;
|
|
146
|
-
case 'review_bot_cap':
|
|
147
|
-
return `review bots still have open comments${rounds}`;
|
|
148
|
-
case 'ci_never_settled':
|
|
149
|
-
return 'CI did not finish in time';
|
|
150
|
-
case 'ready_blocked':
|
|
151
|
-
return `CI is green, but Taiga kept the pull request a draft${blockers.length ? `: ${blockers.join('; ')}` : ''}`;
|
|
152
|
-
case 'check_weakened':
|
|
153
|
-
return `CI went green only after a check was loosened${typeof e.file === 'string' ? ` in ${e.file}` : ''}`;
|
|
154
|
-
default:
|
|
155
|
-
return typeof e.reason === 'string' ? e.reason : null;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** What a person does about a handback, by its reason, as the web's handback detail says it. */
|
|
160
|
-
export function handbackNextStep(escalation: unknown): string {
|
|
161
|
-
const reason = escalation && typeof escalation === 'object' ? (escalation as { reason?: unknown }).reason : null;
|
|
162
|
-
if (reason === 'check_weakened') return 'review the loosened check on GitHub before merging';
|
|
163
|
-
if (reason === 'ci_never_settled')
|
|
164
|
-
return 'look at what is still running or queued in CI on GitHub, then review the pull request';
|
|
165
|
-
return 'fix it by hand, or review the pull request on GitHub, which hands it back to the agent';
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** The web's words for why Taiga keeps a pull request a draft. */
|
|
169
|
-
const READY_BLOCKER_SAID: Record<string, string> = {
|
|
170
|
-
plan_incomplete: 'not every planned step shipped',
|
|
171
|
-
tests_failed: 'the tests Taiga ran after the build failed',
|
|
172
|
-
bar_lowering: 'a fix lowered a check instead of fixing the code',
|
|
173
|
-
bar_lowering_unchecked: 'Taiga could not check whether a fix lowered a check',
|
|
174
|
-
criticals_unresolved: "Taiga's own review left a critical issue unresolved",
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
/** The first sentence of a run's error, short enough for a row. */
|
|
178
|
-
function errorSaid(error: unknown): string | null {
|
|
179
|
-
if (typeof error !== 'string' || !error.trim()) return null;
|
|
180
|
-
const first = error
|
|
181
|
-
.trim()
|
|
182
|
-
.split(/\n|(?<=[.!?])\s/)[0]!
|
|
183
|
-
.replace(/[.!?]$/, '');
|
|
184
|
-
return first.length > 240 ? `${first.slice(0, 240).trimEnd()}...` : first;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* How a run stands, in one sentence, with its disposition: the meaning the
|
|
189
|
-
* status and the pull request facts carry between them
|
|
190
|
-
* (PRO-2800). An agent read `closed` with failedReason null, every step
|
|
191
|
-
* completed and hardening passed, and could not tell what had happened; the
|
|
192
|
-
* status alone cannot say whether a completed run merged (dispositionOf).
|
|
193
|
-
*/
|
|
194
|
-
export function runOutcome(run: Record<string, unknown>): { disposition: string; outcome: string } | null {
|
|
195
|
-
const status = run['status'] as BuildRunStatus;
|
|
196
|
-
if (!(BUILD_RUN_STATUSES as readonly string[]).includes(status)) return null;
|
|
197
|
-
const disposition = landedDisposition(status, run['prMerged'] === true);
|
|
198
|
-
const pr = typeof run['prNumber'] === 'number' ? `PR #${run['prNumber']}` : 'its pull request';
|
|
199
|
-
const progress =
|
|
200
|
-
typeof run['completedSteps'] === 'number' && typeof run['totalSteps'] === 'number'
|
|
201
|
-
? ` (${run['completedSteps']} of ${run['totalSteps']} steps done)`
|
|
202
|
-
: '';
|
|
203
|
-
// The error says what happened; the failedReason is only its code (PRO-2800).
|
|
204
|
-
const why = errorSaid(run['error']) ?? (typeof run['failedReason'] === 'string' ? run['failedReason'] : null);
|
|
205
|
-
const handback = handbackSaid(run['reviewEscalation']);
|
|
206
|
-
const draft = run['prDraft'] === true ? `, still a draft` : '';
|
|
207
|
-
const sentence: Record<string, string> = {
|
|
208
|
-
in_flight: status === 'pending' ? 'Queued: waiting to start.' : `Running: ${RUNNING[status] ?? status}${progress}.`,
|
|
209
|
-
// A handback is a person's turn too, but not a plain review: say what the agent could not get past.
|
|
210
|
-
gated: handback
|
|
211
|
-
? `Handed back to a person: ${handback}. ${pr}${draft ? `${draft},` : ''} waits for them to ${handbackNextStep(run['reviewEscalation'])}.`
|
|
212
|
-
: `Waiting for a person to review and merge ${pr}${draft}.`,
|
|
213
|
-
succeeded: `Merged: ${pr} is in ${typeof run['baseBranch'] === 'string' ? run['baseBranch'] : 'the base branch'}.`,
|
|
214
|
-
unmerged:
|
|
215
|
-
typeof run['prNumber'] === 'number' || run['prUrl']
|
|
216
|
-
? `Settled without merging: ${pr} was left unmerged.`
|
|
217
|
-
: 'Settled without a merged pull request.',
|
|
218
|
-
failed: `Failed${why ? `: ${why}` : ''}${progress}.`,
|
|
219
|
-
stopped: `Stopped before it finished${progress}.`,
|
|
220
|
-
rejected: `${pr} was closed without merging, so the work did not land.${handback ? ` It had been handed back: ${handback}.` : ''}`,
|
|
221
|
-
};
|
|
222
|
-
return { disposition, outcome: sentence[disposition] ?? status };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* What hardening left blocking, in a sentence: the findings the run names (PRO-2817), or, for a run reported before
|
|
227
|
-
* it recorded them, how many.
|
|
228
|
-
*/
|
|
229
|
-
function hardeningSaid(blocking: number, named: Record<string, unknown>[]): string {
|
|
230
|
-
const count = `Hardening ended with ${blocking} blocking finding${blocking === 1 ? '' : 's'} it could not fix`;
|
|
231
|
-
if (named.length === 0) return `${count}; the run records how many, not which.`;
|
|
232
|
-
const shown = named.slice(0, 3).map((finding) => {
|
|
233
|
-
const message = String(finding['message']);
|
|
234
|
-
const where = finding['line'] ? `${String(finding['path'])}:${String(finding['line'])}` : String(finding['path']);
|
|
235
|
-
// Always where it is, though a scanner's title may repeat the path: a substring test would drop a real one.
|
|
236
|
-
return `${message} (${String(finding['tool'])}, ${where})`;
|
|
237
|
-
});
|
|
238
|
-
const rest = blocking - shown.length;
|
|
239
|
-
const recorded = named.length > shown.length ? `, ${named.length} recorded in taiga_get_run` : '';
|
|
240
|
-
return `${count}: ${shown.join('; ')}${rest > 0 ? `; and ${rest} more${recorded}` : ''}.`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* What each failedReason means for the next move (PRO-2800): reviewers met dispatch_rejected and agent_reported as
|
|
245
|
-
* bare enums. As @taiga/types documents them.
|
|
246
|
-
*/
|
|
247
|
-
const FAILED_REASON_MEANS: Record<string, string> = {
|
|
248
|
-
agent_reported: 'the agent stopped and said why (error); resuming would likely hit the same wall',
|
|
249
|
-
server_orphan: 'the run stopped reporting (its machine died or ran out of memory); resuming can recover',
|
|
250
|
-
agent_stalled: 'the agent kept running but stopped making progress; resuming can recover',
|
|
251
|
-
user_stopped: 'it was stopped, by a person or because its initiative was settled',
|
|
252
|
-
timeout: 'it ran past its time limit; resuming continues from the unfinished step',
|
|
253
|
-
dispatch_rejected: 'the agent never started (its environment or credentials failed); resuming dispatches it again',
|
|
254
|
-
context_exhaustion: 'the agent ran out of context on a step; resuming starts that step fresh',
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* A run row as an agent reads it: its outcome added, and prDraft only where
|
|
259
|
-
* there is a pull request. The column defaults to true, so a run that never
|
|
260
|
-
* opened one said it had a draft (PRO-2800). A list row leaves the full error
|
|
261
|
-
* text out: its first sentence is in the outcome, and taiga_get_run has it all.
|
|
262
|
-
*/
|
|
263
|
-
export function readableRun(run: Record<string, unknown>, opts: { row?: boolean } = {}): Record<string, unknown> {
|
|
264
|
-
// runtime names the machinery a run used (legacy or engineer): nothing an agent acts on.
|
|
265
|
-
const { prDraft, error, reviewEscalation, runtime: _runtime, ...rest } = run;
|
|
266
|
-
const said = runOutcome(run);
|
|
267
|
-
const means = FAILED_REASON_MEANS[String(run['failedReason'])];
|
|
268
|
-
const hardening = run['hardeningResult'] as { blockingFindings?: unknown; blocking?: unknown } | null | undefined;
|
|
269
|
-
const blocking = typeof hardening?.blockingFindings === 'number' ? hardening.blockingFindings : 0;
|
|
270
|
-
const named = Array.isArray(hardening?.blocking) ? (hardening.blocking as Record<string, unknown>[]) : [];
|
|
271
|
-
return {
|
|
272
|
-
...rest,
|
|
273
|
-
...(means ? { failedReasonMeans: means } : {}),
|
|
274
|
-
...(blocking > 0
|
|
275
|
-
? {
|
|
276
|
-
hardeningNote: hardeningSaid(blocking, named),
|
|
277
|
-
}
|
|
278
|
-
: {}),
|
|
279
|
-
// A row's outcome says the error and the handback; the full read keeps them whole.
|
|
280
|
-
...(opts.row || error === undefined ? {} : { error }),
|
|
281
|
-
...(opts.row || reviewEscalation === undefined ? {} : { reviewEscalation }),
|
|
282
|
-
// A PR number proves a PR exists (isSettledWithoutPr); the URL is nullable apart from it.
|
|
283
|
-
...(typeof rest['prNumber'] === 'number' || rest['prUrl'] ? { prDraft } : {}),
|
|
284
|
-
...(said ?? {}),
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** Distinct initiatives a run list reads to say which moved on; past this a waiting row says it was not checked. */
|
|
289
|
-
const INITIATIVE_CHECKS = 10;
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* The status of the initiative behind each run still waiting on its pull
|
|
293
|
-
* request (PRO-2800). A reviewing agent read a run of a canceled initiative as
|
|
294
|
-
* "waiting for a person to review and merge" while Action Required called the
|
|
295
|
-
* same review stale.
|
|
296
|
-
*
|
|
297
|
-
* The run says it itself since PRO-2808 (initiativeClosed: done, canceled,
|
|
298
|
-
* archived, deleted, or null while live), for every key that can read it.
|
|
299
|
-
* Against an API from before that, each initiative is read: a 404 is
|
|
300
|
-
* 'deleted', and an initiative past the cap, one whose read failed, and every
|
|
301
|
-
* one without backlog:read is 'unchecked', which withInitiativeState adds to
|
|
302
|
-
* the row's outcome.
|
|
303
|
-
*/
|
|
304
|
-
export async function initiativeStates(
|
|
305
|
-
client: TaigaClient,
|
|
306
|
-
rows: Record<string, unknown>[]
|
|
307
|
-
): Promise<Map<string, string>> {
|
|
308
|
-
const states = new Map<string, string>();
|
|
309
|
-
// Only the runs that could still be waiting: a settled run's initiative changes nothing about it.
|
|
310
|
-
const waiting = rows.filter(
|
|
311
|
-
(row) => ['needs_review', 'completed'].includes(String(row['status'])) && row['prMerged'] !== true
|
|
312
|
-
);
|
|
313
|
-
const told = waiting.filter((row) => 'initiativeClosed' in row);
|
|
314
|
-
for (const row of told) {
|
|
315
|
-
if (typeof row['backlogItemId'] === 'string') {
|
|
316
|
-
states.set(row['backlogItemId'], typeof row['initiativeClosed'] === 'string' ? row['initiativeClosed'] : 'live');
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const all = [
|
|
320
|
-
...new Set(
|
|
321
|
-
waiting
|
|
322
|
-
.filter((row) => !('initiativeClosed' in row))
|
|
323
|
-
.map((row) => row['backlogItemId'])
|
|
324
|
-
.filter((id): id is string => typeof id === 'string' && !states.has(id))
|
|
325
|
-
),
|
|
326
|
-
];
|
|
327
|
-
// Without backlog:read, or past the cap, a waiting run's initiative is not read: it is marked unchecked so
|
|
328
|
-
// the row does not claim a person is waiting when nobody may be.
|
|
329
|
-
const ids = client.hasCapability('backlog:read') ? all.slice(0, INITIATIVE_CHECKS) : [];
|
|
330
|
-
for (const id of all) if (!ids.includes(id)) states.set(id, 'unchecked');
|
|
331
|
-
await Promise.all(
|
|
332
|
-
ids.map(async (id) => {
|
|
333
|
-
try {
|
|
334
|
-
const item = (await client.getBacklogItem(id)).data as unknown as Record<string, unknown>;
|
|
335
|
-
// Archiving keeps the status it had, so it is read apart, as the API reads it (initiativeClosed).
|
|
336
|
-
states.set(id, item['archivedAt'] ? 'archived' : String(item['status'] ?? item['state']));
|
|
337
|
-
} catch (error) {
|
|
338
|
-
if (error instanceof TaigaApiError && error.status === 404) return void states.set(id, 'deleted');
|
|
339
|
-
if (!(error instanceof TaigaApiError || isNetworkFailure(error))) throw error;
|
|
340
|
-
states.set(id, 'unchecked');
|
|
341
|
-
}
|
|
342
|
-
})
|
|
343
|
-
);
|
|
344
|
-
return states;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* How each way an initiative ends reads on a run it left waiting: the same set
|
|
349
|
-
* Action Required closes a run's review for (PRO-2806). A done one's waiting
|
|
350
|
-
* run is not the one that landed it: that run is merged and never waiting.
|
|
351
|
-
*/
|
|
352
|
-
const INITIATIVE_ENDED: Record<string, string> = {
|
|
353
|
-
done: 'was marked done',
|
|
354
|
-
canceled: 'was canceled',
|
|
355
|
-
archived: 'was archived',
|
|
356
|
-
deleted: 'was deleted',
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
/** A readable run row that also says when its initiative ended while its pull request waited. */
|
|
360
|
-
export function withInitiativeState(
|
|
361
|
-
row: Record<string, unknown>,
|
|
362
|
-
states: Map<string, string>
|
|
363
|
-
): Record<string, unknown> {
|
|
364
|
-
const state = states.get(String(row['backlogItemId']));
|
|
365
|
-
// A run still executing is left as it is: its initiative's cancel stops it, and one still going after that is
|
|
366
|
-
// what taiga_stop_run is for.
|
|
367
|
-
if (!['gated', 'unmerged'].includes(String(row['disposition']))) return row;
|
|
368
|
-
if (state === 'unchecked') {
|
|
369
|
-
return { ...row, outcome: `${String(row['outcome'])} Whether its initiative still wants it was not checked.` };
|
|
370
|
-
}
|
|
371
|
-
const ended = state ? INITIATIVE_ENDED[state] : undefined;
|
|
372
|
-
if (!ended) return row;
|
|
373
|
-
const pr = typeof row['prNumber'] === 'number' ? `PR #${row['prNumber']}` : 'its pull request';
|
|
374
|
-
const open = row['prState'] === 'open' ? ` ${pr} is still open on the forge.` : '';
|
|
375
|
-
return {
|
|
376
|
-
...row,
|
|
377
|
-
disposition: 'abandoned',
|
|
378
|
-
// Retry or start over cannot succeed once the initiative is gone.
|
|
379
|
-
actions: [],
|
|
380
|
-
outcome: `Its initiative ${ended}, so nobody is waiting on this run.${open}`,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/** A step that changed only prose: no code of its own could have broken a test. */
|
|
385
|
-
const PROSE_ONLY = /(^|\/)(README|CHANGELOG|LICENSE|NOTICE|AUTHORS)$|\.(md|mdx|txt|rst|adoc)$/i;
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* What a check that failed after every step means (PRO-2800). Two reviews read
|
|
389
|
-
* seven steps of "tests: failed" as seven breakages, while the headlines said
|
|
390
|
-
* each step's own tests passed: a check that fails from the first step on,
|
|
391
|
-
* docs-only steps included, is one the repository or the check environment
|
|
392
|
-
* carries throughout. The check records no reason, so this says what the
|
|
393
|
-
* pattern shows and no more.
|
|
394
|
-
*/
|
|
395
|
-
export function verificationNote(steps: Record<string, unknown>[]): string | null {
|
|
396
|
-
const checked = steps.filter((step) => Array.isArray(step['verification']) && step['verification'].length > 0);
|
|
397
|
-
if (checked.length < 2) return null;
|
|
398
|
-
type Check = { check?: unknown; status?: unknown; reason?: unknown };
|
|
399
|
-
const failedOn = (step: Record<string, unknown>) =>
|
|
400
|
-
(step['verification'] as Check[]).filter((check) => check.status === 'failed').map((check) => String(check.check));
|
|
401
|
-
const always = failedOn(checked[0]!).filter((check) => checked.every((step) => failedOn(step).includes(check)));
|
|
402
|
-
if (always.length === 0) return null;
|
|
403
|
-
const prose = checked.find((step) => {
|
|
404
|
-
const files = step['filesChanged'];
|
|
405
|
-
return Array.isArray(files) && files.length > 0 && files.every((file) => PROSE_ONLY.test(String(file)));
|
|
406
|
-
});
|
|
407
|
-
// Two code steps both failing may be two real breakages; the pattern needs three, or a step with no code.
|
|
408
|
-
if (!prose && checked.length < 3) return null;
|
|
409
|
-
const reasons = [
|
|
410
|
-
...new Set(
|
|
411
|
-
checked.flatMap((step) =>
|
|
412
|
-
(step['verification'] as Check[])
|
|
413
|
-
.filter((check) => check.status === 'failed' && always.includes(String(check.check)))
|
|
414
|
-
.map((check) => check.reason)
|
|
415
|
-
.filter((reason): reason is string => typeof reason === 'string' && reason.length > 0)
|
|
416
|
-
)
|
|
417
|
-
),
|
|
418
|
-
];
|
|
419
|
-
const first = checked[0]!['stepOrder'];
|
|
420
|
-
const last = checked[checked.length - 1]!['stepOrder'];
|
|
421
|
-
const proseSaid = prose ? `, including step ${String(prose['stepOrder'])}, which changed only documentation` : '';
|
|
422
|
-
return (
|
|
423
|
-
`Taiga's ${always.join(' and ')} check failed after every checked step (${String(first)} to ${String(last)})` +
|
|
424
|
-
`${proseSaid}. A check failing from the first step on is usually one the repository or the check ` +
|
|
425
|
-
'environment fails throughout, not one each step broke. ' +
|
|
426
|
-
(reasons.length > 0
|
|
427
|
-
? `Its reason${reasons.length === 1 ? '' : 's'}: ${reasons.slice(0, 3).join('; ')}.`
|
|
428
|
-
: "It records no reason; each step's headline says what its own run found.")
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** readableRun for a list row. */
|
|
433
|
-
export const readableRow = (run: Record<string, unknown>) => readableRun(run, { row: true });
|
|
434
|
-
|
|
435
|
-
/** How many products the org-wide list names; past this a row keeps its productId only. */
|
|
436
|
-
const NAMED_PRODUCTS = 100;
|
|
437
|
-
|
|
438
|
-
const runId = idInput('run', 'taiga_list_runs gives it');
|
|
439
|
-
|
|
440
|
-
/** A step without its instructions: what it is, how it stands and what it changed. */
|
|
441
|
-
const STEP_SUMMARY_FIELDS = [
|
|
442
|
-
'stepOrder',
|
|
443
|
-
'title',
|
|
444
|
-
'status',
|
|
445
|
-
'currentPhase',
|
|
446
|
-
'commitSha',
|
|
447
|
-
'filesChanged',
|
|
448
|
-
'verification',
|
|
449
|
-
'error',
|
|
450
|
-
'errorClassification',
|
|
451
|
-
'startedAt',
|
|
452
|
-
'completedAt',
|
|
453
|
-
] as const;
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* A step a failed run never reached reads as planned, as it was (PRO-2800): a run rejected before it began showed
|
|
457
|
-
* each step failed with "still in flight", though none had started. The API reaches failed only through in_progress,
|
|
458
|
-
* which sets startedAt, so a failed step without one is a planned step the run's failure swept up.
|
|
459
|
-
*/
|
|
460
|
-
function neverStarted(step: Record<string, unknown>): Record<string, unknown> {
|
|
461
|
-
if (step['status'] !== 'failed' || step['startedAt']) return step;
|
|
462
|
-
// The sweep also stamped completedAt and an error; a planned step has neither.
|
|
463
|
-
const { error: _error, ...rest } = step;
|
|
464
|
-
return { ...rest, status: 'planned', completedAt: null };
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/** Past this many characters a step's headline is cut; the full read has the rest. */
|
|
468
|
-
const HEADLINE_LIMIT = 600;
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* The first paragraph of a step's summary: the agent writes its result
|
|
472
|
-
* there (what changed, which checks passed), and the detail after it.
|
|
473
|
-
*/
|
|
474
|
-
export function summaryHeadline(summary: unknown): string | null {
|
|
475
|
-
if (typeof summary !== 'string' || !summary.trim()) return null;
|
|
476
|
-
const first = summary
|
|
477
|
-
.trim()
|
|
478
|
-
.split(/\n\s*\n/, 1)[0]!
|
|
479
|
-
.trim();
|
|
480
|
-
return first.length > HEADLINE_LIMIT ? `${first.slice(0, HEADLINE_LIMIT).trimEnd()}...` : first;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* The runs of every product the key can see, each row naming its product.
|
|
485
|
-
* The route carries product ids only; one products read names them, and a
|
|
486
|
-
* failure of that read leaves the rows unnamed rather than failing the list.
|
|
487
|
-
*/
|
|
488
|
-
async function activityRows(
|
|
489
|
-
client: TaigaClient,
|
|
490
|
-
opts: { active?: boolean | undefined; limit?: number | undefined; cursor?: string | undefined }
|
|
491
|
-
) {
|
|
492
|
-
const [runs, products] = await Promise.all([
|
|
493
|
-
client.listBuildRunActivity(opts),
|
|
494
|
-
// Only the API failing or being unreachable leaves rows unnamed; a bug
|
|
495
|
-
// in this code surfaces.
|
|
496
|
-
client.listProducts({ limit: NAMED_PRODUCTS }).catch((error: unknown) => {
|
|
497
|
-
if (error instanceof TaigaApiError || isNetworkFailure(error)) return null;
|
|
498
|
-
throw error;
|
|
499
|
-
}),
|
|
500
|
-
]);
|
|
501
|
-
const nameById = new Map<string, { name: string; slug: string }>();
|
|
502
|
-
for (const product of products?.data ?? []) {
|
|
503
|
-
nameById.set(product.id, { name: product.name, slug: product.slug });
|
|
504
|
-
}
|
|
505
|
-
const states = await initiativeStates(client, runs.data as Record<string, unknown>[]);
|
|
506
|
-
const rows = pickFields(runs.data as Record<string, unknown>[], BUILD_SUMMARY_FIELDS)
|
|
507
|
-
.map(readableRow)
|
|
508
|
-
.map((row) => withInitiativeState(row, states))
|
|
509
|
-
.map((row) => {
|
|
510
|
-
const product = nameById.get(String(row['productId']));
|
|
511
|
-
return product ? { ...row, product } : row;
|
|
512
|
-
});
|
|
513
|
-
const unnamed = rows.some((row) => !('product' in row));
|
|
514
|
-
// Why a row stayed unnamed, by what the products read said: it failed, it
|
|
515
|
-
// was cut before that product, or it listed everything the key can see and
|
|
516
|
-
// the run's product is not among them (deleted since, or not visible).
|
|
517
|
-
const note = !unnamed
|
|
518
|
-
? null
|
|
519
|
-
: !products
|
|
520
|
-
? 'The products could not be read, so rows carry productId only; taiga_get_product names one.'
|
|
521
|
-
: products.nextCursor !== null
|
|
522
|
-
? `Only the first ${NAMED_PRODUCTS} products could be named; a row without product carries productId only.`
|
|
523
|
-
: 'A row without product belongs to a product the key cannot list (deleted since, or not visible to it); it carries productId only.';
|
|
524
|
-
return { ...page(rows, runs.nextCursor), ...(note ? { note } : {}) };
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
export function registerBuildTools(server: McpServer, client: TaigaClient) {
|
|
528
|
-
if (!client.hasCapability('products:read')) return;
|
|
529
|
-
|
|
530
|
-
server.registerTool(
|
|
531
|
-
'taiga_list_runs',
|
|
532
|
-
{
|
|
533
|
-
title: 'List runs',
|
|
534
|
-
description:
|
|
535
|
-
'List coding agent runs, newest first: branch, pull request, step progress, outcome (one sentence), ' +
|
|
536
|
-
'actions and disposition: in_flight, gated (a person owes a review and merge), succeeded (merged), ' +
|
|
537
|
-
'failed, stopped, rejected (pull request closed unmerged), unmerged, or abandoned (its initiative ended: ' +
|
|
538
|
-
"done, canceled, archived or deleted). Without productId, every product's runs; active: true keeps " +
|
|
539
|
-
'running ones. taiga_get_run ' +
|
|
540
|
-
'reads one; carry work on from the initiative (taiga_resume_initiative).',
|
|
541
|
-
inputSchema: z.object({
|
|
542
|
-
productId: productId.optional(),
|
|
543
|
-
initiativeId: idInput('initiative', 'taiga_list_initiatives (query finds one by title) gives it')
|
|
544
|
-
.optional()
|
|
545
|
-
.describe("Only this initiative's runs -- get from taiga_list_initiatives; needs productId"),
|
|
546
|
-
active: z
|
|
547
|
-
.boolean()
|
|
548
|
-
.optional()
|
|
549
|
-
.describe(
|
|
550
|
-
'Only runs the agent is executing now (cloning through reviewing; a queued pending run is not yet ' +
|
|
551
|
-
'executing and is left out); for the org-wide list'
|
|
552
|
-
),
|
|
553
|
-
...pagination,
|
|
554
|
-
}),
|
|
555
|
-
annotations: READ_ONLY,
|
|
556
|
-
},
|
|
557
|
-
(params) =>
|
|
558
|
-
runTool(async () => {
|
|
559
|
-
if (!params.productId && params.initiativeId) {
|
|
560
|
-
return toolError(
|
|
561
|
-
'VALIDATION_ERROR',
|
|
562
|
-
'initiativeId narrows the runs of one product: pass its productId as well (the initiative carries it).'
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
// The product route has no such filter; a silently ignored one would
|
|
566
|
-
// hand the model finished runs as running.
|
|
567
|
-
if (params.productId && params.active) {
|
|
568
|
-
return toolError(
|
|
569
|
-
'VALIDATION_ERROR',
|
|
570
|
-
"active narrows the org-wide list only: omit productId, or read the product's runs and judge each by its status."
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
if (params.productId) {
|
|
574
|
-
const result = await client.listBuildRuns(params.productId, {
|
|
575
|
-
backlogItemId: params.initiativeId,
|
|
576
|
-
limit: params.limit,
|
|
577
|
-
cursor: params.cursor,
|
|
578
|
-
});
|
|
579
|
-
const raw = result.data as Record<string, unknown>[];
|
|
580
|
-
const states = await initiativeStates(client, raw);
|
|
581
|
-
return toolResult(
|
|
582
|
-
page(
|
|
583
|
-
pickFields(raw, BUILD_SUMMARY_FIELDS)
|
|
584
|
-
.map(readableRow)
|
|
585
|
-
.map((row) => withInitiativeState(row, states)),
|
|
586
|
-
result.nextCursor
|
|
587
|
-
)
|
|
588
|
-
);
|
|
589
|
-
}
|
|
590
|
-
return toolResult(await activityRows(client, params));
|
|
591
|
-
})
|
|
592
|
-
);
|
|
593
|
-
|
|
594
|
-
server.registerTool(
|
|
595
|
-
'taiga_get_run',
|
|
596
|
-
{
|
|
597
|
-
title: 'Get run',
|
|
598
|
-
description:
|
|
599
|
-
'Read one run with its steps: outcome, disposition, pull request, hardening, and per step its title, ' +
|
|
600
|
-
"status, commit, files changed, verification and headline (first paragraph of the agent's report). " +
|
|
601
|
-
"verification is Taiga's own check after the step (tests, lint, build), so a docs-only step can fail on " +
|
|
602
|
-
"the repository's failing tests (verificationNote says when one failed throughout). A failed or stopped " +
|
|
603
|
-
'run has stoppedIn, lastProgressAt and failedReasonMeans (what its failedReason means). full: true ' +
|
|
604
|
-
'adds complete instructions and reports. actions: resume and retry_from_failed_step are ' +
|
|
605
|
-
'taiga_resume_initiative; webOnlyActions (start_over) are in the web; to stop it, move the initiative out of Build. A ' +
|
|
606
|
-
'maintenance run has no initiative and is retried from its finding.',
|
|
607
|
-
inputSchema: z.object({
|
|
608
|
-
runId,
|
|
609
|
-
full: z
|
|
610
|
-
.boolean()
|
|
611
|
-
.optional()
|
|
612
|
-
.describe("Carry each step's complete instructions and report (default false: a summary)"),
|
|
613
|
-
}),
|
|
614
|
-
annotations: READ_ONLY,
|
|
615
|
-
},
|
|
616
|
-
(params) =>
|
|
617
|
-
runTool(async () => {
|
|
618
|
-
// Two routes, one answer: fanned out here rather than in the model's
|
|
619
|
-
// context, like the six-tool pattern asks.
|
|
620
|
-
const [run, steps] = await Promise.all([
|
|
621
|
-
client.getBuildRun(params.runId),
|
|
622
|
-
client.listBuildRunSteps(params.runId),
|
|
623
|
-
]);
|
|
624
|
-
const runRow = run.data;
|
|
625
|
-
// Steps a failed run never reached read as planned before anything else reads them, stoppedIn included.
|
|
626
|
-
const rows = steps.data.map((step) =>
|
|
627
|
-
neverStarted(step as unknown as Record<string, unknown>)
|
|
628
|
-
) as unknown as typeof steps.data;
|
|
629
|
-
const where = stoppedIn(runRow, rows);
|
|
630
|
-
const stop = where ? { stoppedIn: where } : {};
|
|
631
|
-
const states = await initiativeStates(client, [runRow as unknown as Record<string, unknown>]);
|
|
632
|
-
const readable = withInitiativeState(readableRun(runRow), states);
|
|
633
|
-
const note = verificationNote(rows as unknown as Record<string, unknown>[]);
|
|
634
|
-
const verification = note ? { verificationNote: note } : {};
|
|
635
|
-
if (params.full) return toolResult({ ...readable, ...stop, ...verification, steps: rows });
|
|
636
|
-
return toolResult({
|
|
637
|
-
...readable,
|
|
638
|
-
...stop,
|
|
639
|
-
...verification,
|
|
640
|
-
steps: pickFields(rows, STEP_SUMMARY_FIELDS).map((step, index) => ({
|
|
641
|
-
...step,
|
|
642
|
-
headline: summaryHeadline(rows[index]!.summary),
|
|
643
|
-
})),
|
|
644
|
-
});
|
|
645
|
-
})
|
|
646
|
-
);
|
|
647
|
-
|
|
648
|
-
if (!client.hasCapability('products:write')) return;
|
|
649
|
-
|
|
650
|
-
server.registerTool(
|
|
651
|
-
'taiga_stop_run',
|
|
652
|
-
{
|
|
653
|
-
title: 'Stop a run',
|
|
654
|
-
description:
|
|
655
|
-
'Emergency stop for a run whose initiative cannot be moved out of Build (finished, canceled or gone) but ' +
|
|
656
|
-
'is still going. Normally stop work by moving the initiative out of Build with taiga_move_initiative. ' +
|
|
657
|
-
'The agent halts; its branch and any draft pull request stay. Only a run whose actions include stop; ' +
|
|
658
|
-
'others are refused (CONFLICT).',
|
|
659
|
-
inputSchema: z.object({ runId }),
|
|
660
|
-
annotations: OPERATE,
|
|
661
|
-
},
|
|
662
|
-
(params) =>
|
|
663
|
-
runTool(async () => {
|
|
664
|
-
const result = await client.stopBuildRun(params.runId);
|
|
665
|
-
return toolResult(readableRow(pickFields([result.data as Record<string, unknown>], RUN_OUTCOME_FIELDS)[0]!));
|
|
666
|
-
})
|
|
667
|
-
);
|
|
668
|
-
}
|