@orboto/mcp 0.178.0
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/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- package/package.json +66 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - composite ticket-lifecycle tools.
|
|
3
|
+
*
|
|
4
|
+
* Two tools that bundle the most-used wrapper composites:
|
|
5
|
+
*
|
|
6
|
+
* - orboto_claim ≈ assign_self + move(in_progress) + timer_start
|
|
7
|
+
* - orboto_unclaim ≈ unassign_self + move(todo)
|
|
8
|
+
*
|
|
9
|
+
* The composite-atomic behaviour is the real value here - these are
|
|
10
|
+
* the most-typed wrapper commands by agents during ticket pickup. The
|
|
11
|
+
* 1:1 mirror of `orboto.mjs claim` semantics matters:
|
|
12
|
+
*
|
|
13
|
+
* - `--sole` (`sole=true`) - destructive take-over: remove every
|
|
14
|
+
* other assignee before adding self. Use sparingly; the API has
|
|
15
|
+
* no atomic swap so this is a delete-loop + add.
|
|
16
|
+
* - `--force` (`force=true`) - allow re-claiming a `done` ticket
|
|
17
|
+
* (the wrapper refuses without it to prevent accidental reopen).
|
|
18
|
+
* - `--no-timer` (`noTimer=true`) - skip the timer_start side
|
|
19
|
+
* effect. Useful when the agent only wants ownership-by-assignee
|
|
20
|
+
* without committing time.
|
|
21
|
+
*
|
|
22
|
+
* Both tools are **idempotent** on the happy paths:
|
|
23
|
+
* - claim of a ticket where the caller is already an assignee and
|
|
24
|
+
* statusCategory is already `in_progress` → no PATCH, no extra
|
|
25
|
+
* assignee POST (the wrapper still POSTs once and 409s; we skip).
|
|
26
|
+
* - unclaim of a ticket where the caller isn't an assignee → 404 on
|
|
27
|
+
* DELETE is swallowed, status move still happens.
|
|
28
|
+
*
|
|
29
|
+
* Timer side-effects mirror ORB-179 (`claim`) + ORB-181 (`close`):
|
|
30
|
+
* - If a different ticket has an active timer, stop it first
|
|
31
|
+
* (commits the elapsed time-entry under the previous ticket's
|
|
32
|
+
* description), then start a fresh timer on this one.
|
|
33
|
+
* - Timer failures never roll back the assign/status work - we
|
|
34
|
+
* surface them as a `timerWarning` field on the structured
|
|
35
|
+
* response, same shape as the wrapper.
|
|
36
|
+
*/
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
39
|
+
import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
|
|
40
|
+
// `statusCategory` is the discriminator the API exposes on every
|
|
41
|
+
// ticket-read response; we map it (rather than `status` legacy enum)
|
|
42
|
+
// because that's what the wrapper inspects too.
|
|
43
|
+
const CATEGORY_TO_LEGACY = {
|
|
44
|
+
todo: 'TODO',
|
|
45
|
+
in_progress: 'IN_PROGRESS',
|
|
46
|
+
in_review: 'IN_REVIEW',
|
|
47
|
+
done: 'DONE',
|
|
48
|
+
wont_fix: 'WONT_FIX',
|
|
49
|
+
};
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// orboto_claim
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
export const claimToolConfig = {
|
|
54
|
+
title: 'Claim a ticket (assign self + in_progress + timer)',
|
|
55
|
+
description: 'Composite of `assign self → move to in_progress → start timer` - the canonical "I am picking this up now" move. Idempotent: re-claiming an already-claimed in_progress ticket is a no-op. Set `sole=true` to remove every other assignee first (destructive take-over). Set `force=true` to allow re-claiming a `done` ticket (otherwise refuses, to prevent accidental reopens). Set `noTimer=true` to skip the timer start (e.g. when you only want ownership, not time tracking). If a different ticket has an active timer, it is stopped first (its elapsed time commits a time entry under the previous ticket). '
|
|
56
|
+
// ORB-1805 - moved out of the input schema, which every client pays
|
|
57
|
+
// for at connect; orboto_help serves this text in full.
|
|
58
|
+
+ '`agentSessionToken` is a stable per-agent-instance token: on a bot/service account it scopes the timer to YOUR instance - concurrent per-instance timers and NO auto-stop, so you own both start and stop. Omit it on human accounts, which keep the single-timer behaviour.',
|
|
59
|
+
inputSchema: z.object({
|
|
60
|
+
ticketKey: z.string().min(3),
|
|
61
|
+
sole: z.boolean().optional().describe('Take-over: remove every other assignee first.'),
|
|
62
|
+
force: z.boolean().optional().describe('Allow re-claiming a `done` ticket.'),
|
|
63
|
+
noTimer: z.boolean().optional().describe('Skip the timer start.'),
|
|
64
|
+
agentSessionToken: z.string().optional().describe('Per-agent-instance token; scopes the timer on bot accounts.'),
|
|
65
|
+
}).shape,
|
|
66
|
+
// ORB-1669 - destructive because of `sole=true`, which strips every
|
|
67
|
+
// other assignee. Annotations are per-tool and cannot be conditioned on
|
|
68
|
+
// an argument, so the hint has to cover the worst call the tool allows.
|
|
69
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
70
|
+
};
|
|
71
|
+
export function makeClaimHandler(client) {
|
|
72
|
+
return async ({ ticketKey, sole, force, noTimer, agentSessionToken }, extra) => {
|
|
73
|
+
const me = await client.get('/users/me');
|
|
74
|
+
const current = await resolveTicketByKey(client, ticketKey);
|
|
75
|
+
const currentAssignees = current.assignees ?? [];
|
|
76
|
+
const alreadyAssigned = currentAssignees.some((a) => a.id === me.id);
|
|
77
|
+
const currentCategory = current.statusCategory;
|
|
78
|
+
if (currentCategory === 'done' && !force) {
|
|
79
|
+
throw new Error(`Refusing to claim [${current.ticketKey}]: status is "done". Pass force=true to reopen + claim.`);
|
|
80
|
+
}
|
|
81
|
+
// --sole: destructive take-over. Remove every other assignee
|
|
82
|
+
// first. Each DELETE tolerates a 404 (race condition where another
|
|
83
|
+
// process already unassigned them).
|
|
84
|
+
if (sole) {
|
|
85
|
+
for (const a of currentAssignees) {
|
|
86
|
+
if (a.id === me.id)
|
|
87
|
+
continue;
|
|
88
|
+
try {
|
|
89
|
+
await client.delete(`/projects/${current.projectId}/tickets/${current.id}/assignees/${a.id}`);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (!(err instanceof OrbotoApiError) || err.status !== 404)
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Additive self-add. Skip if we're already on the ticket - POSTing
|
|
98
|
+
// again would 409; idempotency here matters because agents call
|
|
99
|
+
// claim defensively at the start of every operation.
|
|
100
|
+
if (!alreadyAssigned) {
|
|
101
|
+
try {
|
|
102
|
+
await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, {});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
// 409 = race-added by another process while we were checking.
|
|
106
|
+
// Treat as already-assigned success.
|
|
107
|
+
if (!(err instanceof OrbotoApiError) || err.status !== 409)
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Only PATCH if we're not already in_progress. Saves an audit row
|
|
112
|
+
// + websocket broadcast on the common re-claim path.
|
|
113
|
+
let finalTicket = current;
|
|
114
|
+
if (currentCategory !== 'in_progress') {
|
|
115
|
+
finalTicket = await client.patch(`/projects/${current.projectId}/tickets/${current.id}`, { status: CATEGORY_TO_LEGACY.in_progress });
|
|
116
|
+
}
|
|
117
|
+
// Timer side-effects. Mirrors the wrapper's ORB-179 path: stop a
|
|
118
|
+
// timer running on a different ticket, then start fresh here. A
|
|
119
|
+
// 409 on the start side is captured as a warning rather than
|
|
120
|
+
// raised, so the assign/status work still counts as success.
|
|
121
|
+
let timerStarted = false;
|
|
122
|
+
let timerWarning = null;
|
|
123
|
+
// Bot/service accounts own their timer (per-instance, no auto-stop). Instance
|
|
124
|
+
// id: explicit arg > per-connection MCP session id (distinct per client even
|
|
125
|
+
// on a shared HTTP server) > per-process id (stdio). Human accounts fall
|
|
126
|
+
// through to the legacy auto-stop path.
|
|
127
|
+
const effectiveToken = agentSessionToken ?? (me.isBot ? mcpInstanceToken(undefined, extra) : undefined);
|
|
128
|
+
if (!noTimer) {
|
|
129
|
+
try {
|
|
130
|
+
if (effectiveToken) {
|
|
131
|
+
// ORB-1252 - agent instance owns its timer: per-session, no auto-stop.
|
|
132
|
+
// Idempotent on the same ticket; 409 if this session already runs a
|
|
133
|
+
// different ticket (surfaced as a warning below).
|
|
134
|
+
await client.post('/time/timer/start', { ticketId: current.id, agentSessionToken: effectiveToken });
|
|
135
|
+
timerStarted = true;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const active = await client.get('/time/timer').catch(() => null);
|
|
139
|
+
if (active && active.ticketId && active.ticketId !== current.id) {
|
|
140
|
+
const other = active.ticketTitle ? `"${active.ticketTitle}"` : active.ticketId;
|
|
141
|
+
await client.post('/time/timer/stop', {
|
|
142
|
+
note: `Auto-stopped by claim of ${finalTicket.ticketKey ?? current.id}`,
|
|
143
|
+
}).catch(() => {
|
|
144
|
+
// Swallow - we'll try start below; if start 409s we surface
|
|
145
|
+
// the warning then.
|
|
146
|
+
timerWarning = `Failed to stop timer on ${other}; new timer not started.`;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
else if (active && active.ticketId === current.id) {
|
|
150
|
+
// Same ticket - effectively already started.
|
|
151
|
+
timerStarted = true;
|
|
152
|
+
}
|
|
153
|
+
if (!timerStarted && !timerWarning) {
|
|
154
|
+
await client.post('/time/timer/start', { ticketId: current.id });
|
|
155
|
+
timerStarted = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (err instanceof OrbotoApiError && err.status === 409) {
|
|
161
|
+
timerWarning = 'A timer is already running on another ticket; could not start one here. Run `orboto_timer_stop` first.';
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// Any other timer error → keep the claim, surface the issue.
|
|
165
|
+
timerWarning = err instanceof Error ? err.message : String(err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const noop = alreadyAssigned &&
|
|
170
|
+
currentCategory === 'in_progress' &&
|
|
171
|
+
!sole &&
|
|
172
|
+
(noTimer || timerStarted);
|
|
173
|
+
const lines = [
|
|
174
|
+
`Claimed [${finalTicket.ticketKey}] ${finalTicket.title}`,
|
|
175
|
+
` status: ${finalTicket.statusName ?? finalTicket.status}`,
|
|
176
|
+
noTimer ? ' timer: skipped (noTimer=true)' : ` timer: ${timerStarted ? 'started' : 'not started'}`,
|
|
177
|
+
timerWarning ? ` warning: ${timerWarning}` : null,
|
|
178
|
+
noop ? ' (no-op - already claimed + in_progress)' : null,
|
|
179
|
+
].filter((l) => l !== null);
|
|
180
|
+
return {
|
|
181
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
182
|
+
structuredContent: {
|
|
183
|
+
ticketKey: finalTicket.ticketKey,
|
|
184
|
+
status: finalTicket.statusName ?? finalTicket.status,
|
|
185
|
+
statusCategory: finalTicket.statusCategory,
|
|
186
|
+
assignedSelf: true,
|
|
187
|
+
soleTakeover: sole === true,
|
|
188
|
+
timerStarted,
|
|
189
|
+
timerWarning,
|
|
190
|
+
noop,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// orboto_unclaim
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
export const unclaimToolConfig = {
|
|
199
|
+
title: 'Unclaim a ticket (composite: unassign self + move to todo)',
|
|
200
|
+
description: 'Inverse of `orboto_claim`: remove the calling user as an assignee and move the ticket back to `todo`. Idempotent - if the caller wasn\'t an assignee, the unassign step is a no-op and the status move still happens. Does not stop a running timer (use `orboto_timer_stop` if you want that side-effect; staying separate avoids surprising the next claimant).',
|
|
201
|
+
inputSchema: z.object({
|
|
202
|
+
ticketKey: z.string().min(3),
|
|
203
|
+
}).shape,
|
|
204
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
205
|
+
};
|
|
206
|
+
export function makeUnclaimHandler(client) {
|
|
207
|
+
return async ({ ticketKey }) => {
|
|
208
|
+
const me = await client.get('/users/me');
|
|
209
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
210
|
+
let alreadyUnassigned = false;
|
|
211
|
+
try {
|
|
212
|
+
await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}/assignees/${me.id}`);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (err instanceof OrbotoApiError && err.status === 404) {
|
|
216
|
+
alreadyUnassigned = true;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { status: CATEGORY_TO_LEGACY.todo });
|
|
223
|
+
return {
|
|
224
|
+
content: [{
|
|
225
|
+
type: 'text',
|
|
226
|
+
text: `Unclaimed [${updated.ticketKey}] - moved to ${updated.statusName ?? updated.status}${alreadyUnassigned ? ' (was not assigned)' : ''}.`,
|
|
227
|
+
}],
|
|
228
|
+
structuredContent: {
|
|
229
|
+
ticketKey: updated.ticketKey,
|
|
230
|
+
status: updated.statusName ?? updated.status,
|
|
231
|
+
statusCategory: updated.statusCategory,
|
|
232
|
+
alreadyUnassigned,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - `orboto_claim` / `orboto_unclaim` unit tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers the composite happy paths, the idempotent re-claim shortcut,
|
|
5
|
+
* the `sole=true` destructive take-over, the `force=true` reopen-done
|
|
6
|
+
* guard, and the `noTimer=true` skip. The timer-warning branch (where
|
|
7
|
+
* a 409 surfaces from `/time/timer/start`) is exercised in the
|
|
8
|
+
* dedicated test below.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
12
|
+
import { makeClaimHandler, makeUnclaimHandler } from './claim.js';
|
|
13
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
14
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
15
|
+
function stub(responses) {
|
|
16
|
+
const calls = [];
|
|
17
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
18
|
+
calls.push({
|
|
19
|
+
url: url.toString(),
|
|
20
|
+
method: init?.method ?? 'GET',
|
|
21
|
+
body: init?.body ? JSON.parse(init.body) : undefined,
|
|
22
|
+
});
|
|
23
|
+
const r = responses.shift();
|
|
24
|
+
if (!r)
|
|
25
|
+
throw new Error(`unexpected extra fetch ${init?.method ?? 'GET'} ${url}`);
|
|
26
|
+
return {
|
|
27
|
+
ok: r.ok ?? true,
|
|
28
|
+
status: r.status ?? 200,
|
|
29
|
+
statusText: 'OK',
|
|
30
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
31
|
+
text: async () => '',
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
return calls;
|
|
35
|
+
}
|
|
36
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
37
|
+
const ME = { id: 'me1', email: 'agent-e@orboto.io', fullName: 'Agent E' };
|
|
38
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
39
|
+
const TICKET_TODO = {
|
|
40
|
+
id: 't1', projectId: 'p1', ticketKey: 'ACME-1', ticketNumber: 1,
|
|
41
|
+
title: 'Bug', description: null, status: 'TODO', statusName: 'To Do',
|
|
42
|
+
statusCategory: 'todo', type: 'bug', priority: 'normal',
|
|
43
|
+
estimatedTimeMinutes: 0, dueDate: null, isPrivate: false, assignees: [],
|
|
44
|
+
};
|
|
45
|
+
describe('orboto_claim', () => {
|
|
46
|
+
it('happy path: assigns self + moves to in_progress + starts timer (no active timer)', async () => {
|
|
47
|
+
const calls = stub([
|
|
48
|
+
{ json: ME }, // /users/me
|
|
49
|
+
{ json: PROJ }, // resolveTicketByKey: by-key project
|
|
50
|
+
{ json: TICKET_TODO }, // resolveTicketByKey: ticket
|
|
51
|
+
{ json: {} }, // POST assignees/me
|
|
52
|
+
{ json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } }, // PATCH status
|
|
53
|
+
{ json: null }, // GET /time/timer (no active)
|
|
54
|
+
{ json: { id: 'tm1', ticketId: 't1', startedAt: '2026-05-14T16:00:00Z' } }, // POST timer/start
|
|
55
|
+
]);
|
|
56
|
+
const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1' });
|
|
57
|
+
expect(calls[3]).toMatchObject({
|
|
58
|
+
method: 'POST',
|
|
59
|
+
url: 'https://orboto.example.com/projects/p1/tickets/t1/assignees/me1',
|
|
60
|
+
});
|
|
61
|
+
expect(calls[4]).toMatchObject({
|
|
62
|
+
method: 'PATCH',
|
|
63
|
+
url: 'https://orboto.example.com/projects/p1/tickets/t1',
|
|
64
|
+
body: { status: 'IN_PROGRESS' },
|
|
65
|
+
});
|
|
66
|
+
expect(calls[6]).toMatchObject({
|
|
67
|
+
method: 'POST',
|
|
68
|
+
url: 'https://orboto.example.com/time/timer/start',
|
|
69
|
+
body: { ticketId: 't1' },
|
|
70
|
+
});
|
|
71
|
+
expect(res.structuredContent).toMatchObject({
|
|
72
|
+
ticketKey: 'ACME-1',
|
|
73
|
+
timerStarted: true,
|
|
74
|
+
timerWarning: null,
|
|
75
|
+
noop: false,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
it('idempotent: skip assign POST + status PATCH when already-in-progress + already-assigned', async () => {
|
|
79
|
+
const calls = stub([
|
|
80
|
+
{ json: ME },
|
|
81
|
+
{ json: PROJ },
|
|
82
|
+
{ json: {
|
|
83
|
+
...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress',
|
|
84
|
+
assignees: [{ id: 'me1', email: 'agent-e@orboto.io', fullName: 'Agent E' }],
|
|
85
|
+
} },
|
|
86
|
+
{ json: { ticketId: 't1' } }, // GET /time/timer - already on same ticket
|
|
87
|
+
]);
|
|
88
|
+
const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1' });
|
|
89
|
+
// 4 calls total: /users/me, by-key project, by-key ticket, GET /time/timer.
|
|
90
|
+
// No POST /assignees, no PATCH status, no POST /timer/start.
|
|
91
|
+
expect(calls).toHaveLength(4);
|
|
92
|
+
expect(calls.some((c) => c.method === 'PATCH')).toBe(false);
|
|
93
|
+
expect(calls.some((c) => c.url.includes('/timer/start'))).toBe(false);
|
|
94
|
+
expect(res.structuredContent).toMatchObject({
|
|
95
|
+
ticketKey: 'ACME-1',
|
|
96
|
+
noop: true,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
it('sole=true removes every other assignee before adding self', async () => {
|
|
100
|
+
const calls = stub([
|
|
101
|
+
{ json: ME },
|
|
102
|
+
{ json: PROJ },
|
|
103
|
+
{ json: { ...TICKET_TODO, assignees: [
|
|
104
|
+
{ id: 'other1', email: 'a@x', fullName: 'A' },
|
|
105
|
+
{ id: 'other2', email: 'b@x', fullName: 'B' },
|
|
106
|
+
] } },
|
|
107
|
+
{ ok: true, status: 204 }, // DELETE other1
|
|
108
|
+
{ ok: true, status: 204 }, // DELETE other2
|
|
109
|
+
{ json: {} }, // POST assignees/me
|
|
110
|
+
{ json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
|
|
111
|
+
{ json: null }, // GET timer
|
|
112
|
+
{ json: {} }, // POST timer/start
|
|
113
|
+
]);
|
|
114
|
+
await makeClaimHandler(client)({ ticketKey: 'ACME-1', sole: true });
|
|
115
|
+
expect(calls.filter((c) => c.method === 'DELETE')).toHaveLength(2);
|
|
116
|
+
expect(calls[3].url).toContain('/assignees/other1');
|
|
117
|
+
expect(calls[4].url).toContain('/assignees/other2');
|
|
118
|
+
});
|
|
119
|
+
it('refuses to claim a done ticket without force=true', async () => {
|
|
120
|
+
stub([
|
|
121
|
+
{ json: ME },
|
|
122
|
+
{ json: PROJ },
|
|
123
|
+
{ json: { ...TICKET_TODO, status: 'DONE', statusName: 'Done', statusCategory: 'done' } },
|
|
124
|
+
]);
|
|
125
|
+
await expect(makeClaimHandler(client)({ ticketKey: 'ACME-1' })).rejects.toThrow(/Refusing to claim/);
|
|
126
|
+
});
|
|
127
|
+
it('force=true claims a done ticket and re-opens to in_progress', async () => {
|
|
128
|
+
const calls = stub([
|
|
129
|
+
{ json: ME },
|
|
130
|
+
{ json: PROJ },
|
|
131
|
+
{ json: { ...TICKET_TODO, status: 'DONE', statusName: 'Done', statusCategory: 'done' } },
|
|
132
|
+
{ json: {} }, // POST assignees/me
|
|
133
|
+
{ json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
|
|
134
|
+
{ json: null },
|
|
135
|
+
{ json: {} },
|
|
136
|
+
]);
|
|
137
|
+
await makeClaimHandler(client)({ ticketKey: 'ACME-1', force: true });
|
|
138
|
+
expect(calls.find((c) => c.method === 'PATCH')?.body).toEqual({ status: 'IN_PROGRESS' });
|
|
139
|
+
});
|
|
140
|
+
it('noTimer=true skips the timer interaction', async () => {
|
|
141
|
+
const calls = stub([
|
|
142
|
+
{ json: ME },
|
|
143
|
+
{ json: PROJ },
|
|
144
|
+
{ json: TICKET_TODO },
|
|
145
|
+
{ json: {} },
|
|
146
|
+
{ json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
|
|
147
|
+
]);
|
|
148
|
+
const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1', noTimer: true });
|
|
149
|
+
expect(calls.some((c) => c.url.includes('/timer'))).toBe(false);
|
|
150
|
+
expect(res.structuredContent).toMatchObject({ timerStarted: false });
|
|
151
|
+
});
|
|
152
|
+
it('timer 409 → assign/status succeed, timerWarning surfaces', async () => {
|
|
153
|
+
stub([
|
|
154
|
+
{ json: ME },
|
|
155
|
+
{ json: PROJ },
|
|
156
|
+
{ json: TICKET_TODO },
|
|
157
|
+
{ json: {} },
|
|
158
|
+
{ json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
|
|
159
|
+
{ json: null }, // GET /time/timer - empty
|
|
160
|
+
{ ok: false, status: 409, json: { error: 'timer already running' } }, // POST /timer/start fails
|
|
161
|
+
]);
|
|
162
|
+
const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1' });
|
|
163
|
+
expect(res.structuredContent).toMatchObject({
|
|
164
|
+
ticketKey: 'ACME-1',
|
|
165
|
+
timerStarted: false,
|
|
166
|
+
});
|
|
167
|
+
expect(res.structuredContent.timerWarning).toMatch(/timer is already running/i);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('orboto_unclaim', () => {
|
|
171
|
+
it('unassigns self + moves to todo', async () => {
|
|
172
|
+
const calls = stub([
|
|
173
|
+
{ json: ME },
|
|
174
|
+
{ json: PROJ },
|
|
175
|
+
{ json: { ...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress' } },
|
|
176
|
+
{ ok: true, status: 204 }, // DELETE assignees/me
|
|
177
|
+
{ json: { ...TICKET_TODO, status: 'TODO', statusName: 'To Do', statusCategory: 'todo' } },
|
|
178
|
+
]);
|
|
179
|
+
const res = await makeUnclaimHandler(client)({ ticketKey: 'ACME-1' });
|
|
180
|
+
expect(calls[3]).toMatchObject({ method: 'DELETE', url: 'https://orboto.example.com/projects/p1/tickets/t1/assignees/me1' });
|
|
181
|
+
expect(calls[4]).toMatchObject({ method: 'PATCH', body: { status: 'TODO' } });
|
|
182
|
+
expect(res.structuredContent).toMatchObject({
|
|
183
|
+
ticketKey: 'ACME-1',
|
|
184
|
+
alreadyUnassigned: false,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
it('idempotent: 404 on unassign is treated as alreadyUnassigned', async () => {
|
|
188
|
+
stub([
|
|
189
|
+
{ json: ME },
|
|
190
|
+
{ json: PROJ },
|
|
191
|
+
{ json: TICKET_TODO },
|
|
192
|
+
{ ok: false, status: 404, json: { error: 'not assigned' } },
|
|
193
|
+
{ json: { ...TICKET_TODO, statusCategory: 'todo' } },
|
|
194
|
+
]);
|
|
195
|
+
const res = await makeUnclaimHandler(client)({ ticketKey: 'ACME-1' });
|
|
196
|
+
expect(res.structuredContent).toMatchObject({ alreadyUnassigned: true });
|
|
197
|
+
});
|
|
198
|
+
it('surfaces non-404 errors from the unassign delete', async () => {
|
|
199
|
+
stub([
|
|
200
|
+
{ json: ME },
|
|
201
|
+
{ json: PROJ },
|
|
202
|
+
{ json: TICKET_TODO },
|
|
203
|
+
{ ok: false, status: 500, json: { error: 'boom' } },
|
|
204
|
+
]);
|
|
205
|
+
await expect(makeUnclaimHandler(client)({ ticketKey: 'ACME-1' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1028 - `orboto_critical_path`. Phase 4 agent surface for the CPM
|
|
3
|
+
* endpoint shipped in Phase 1. Wraps GET /projects/:id/critical-path,
|
|
4
|
+
* resolving the project key (and optional milestone name) for the caller.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { resolveProjectByKey } from './shared.js';
|
|
8
|
+
import { resolveMilestoneByNameOrId } from './milestones.js';
|
|
9
|
+
export const criticalPathToolConfig = {
|
|
10
|
+
title: 'Critical path (CPM)',
|
|
11
|
+
description: "Compute a project's (or one milestone's) critical path via the Critical Path Method: the longest finish-to-start dependency chain that sets the delivery date, plus each ticket's slack (total float, in working days). Durations come from `estimatedTimeMinutes` (8h/day, floored at 1 day). Deadline-aware (ORB-1459): a ticket's due date (or its milestone's, when tighter) seeds the backward pass, so total float can go NEGATIVE when a chain cannot meet its deadline - those tickets are reported under `deadlineRisks` with the shortfall in working days. ORB-1614: a dependency edge to a ticket in ANOTHER project is followed one hop (that ticket's own further cross-project blockers are not) when you can read it, and marked `external: true` in the result - an edge to a foreign ticket you cannot read is silently absent from the graph, same as any other out-of-window ticket. Returns the critical path (ticket keys in order), the total working-day duration, per-ticket float, and the deadline risks. A dependency cycle (including one spanning projects) returns the tangled ticket keys instead of a path. By default tickets in completed/archived milestones are excluded (matching the board); set includeClosedMilestones to include them.",
|
|
12
|
+
inputSchema: z.object({
|
|
13
|
+
projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
|
|
14
|
+
milestone: z.string().optional().describe('Milestone name to scope to. Omit for the whole project.'),
|
|
15
|
+
includeClosedMilestones: z.boolean().optional().describe('Include tickets from completed/archived milestones. Default false (hidden, matching the board).'),
|
|
16
|
+
}).shape,
|
|
17
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
18
|
+
};
|
|
19
|
+
export function makeCriticalPathHandler(client) {
|
|
20
|
+
return async (input) => {
|
|
21
|
+
const project = await resolveProjectByKey(client, input.projectKey);
|
|
22
|
+
const params = new URLSearchParams();
|
|
23
|
+
if (input.milestone) {
|
|
24
|
+
// ORB-1696 - shared resolver: key (ORB-M3), name or UUID, ambiguous
|
|
25
|
+
// name -> explicit error. Matches create_ticket/set_milestone/OQL.
|
|
26
|
+
const m = await resolveMilestoneByNameOrId(client, project.id, input.milestone);
|
|
27
|
+
params.set('milestoneId', m.id);
|
|
28
|
+
}
|
|
29
|
+
if (input.includeClosedMilestones)
|
|
30
|
+
params.set('includeClosedMilestones', 'true');
|
|
31
|
+
const qs = params.toString();
|
|
32
|
+
const url = `/projects/${project.id}/critical-path${qs ? `?${qs}` : ''}`;
|
|
33
|
+
const res = await client.get(url);
|
|
34
|
+
if (res.cycle) {
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: 'text', text: `Dependency cycle - the critical path is undefined until it's broken. Tickets involved: ${res.cycle.ticketKeys.join(', ')}` }],
|
|
37
|
+
structuredContent: { cycle: res.cycle, criticalPath: [], projectDurationDays: 0, tickets: [] },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// ORB-1614 - flag cross-project neighbours pulled into the graph so the
|
|
41
|
+
// reader knows a key like "OVB-55" is not a typo for this project.
|
|
42
|
+
const externalKeys = new Set(res.tickets.filter((t) => t.external).map((t) => t.ticketKey));
|
|
43
|
+
const slack = res.tickets
|
|
44
|
+
.filter((t) => !t.isCritical && t.totalFloat > 0)
|
|
45
|
+
.sort((a, b) => a.totalFloat - b.totalFloat)
|
|
46
|
+
.map((t) => ` ${t.ticketKey}${t.external ? ' [external]' : ''}: ${t.totalFloat}d slack`)
|
|
47
|
+
.join('\n');
|
|
48
|
+
// ORB-1459 - surface tickets whose deadline can't be met (negative float).
|
|
49
|
+
const risks = (res.deadlineRisks ?? [])
|
|
50
|
+
.map((r) => {
|
|
51
|
+
const via = r.constrainingTicketKey && r.constrainingTicketKey !== r.ticketKey
|
|
52
|
+
? ` (via ${r.constrainingTicketKey}${r.dueDate ? ` due ${r.dueDate}` : ''})`
|
|
53
|
+
: r.dueDate ? ` (due ${r.dueDate})` : '';
|
|
54
|
+
return ` ${r.ticketKey}: ${r.shortfallDays}d short${via}`;
|
|
55
|
+
})
|
|
56
|
+
.join('\n');
|
|
57
|
+
const pathDisplay = res.criticalPath.length
|
|
58
|
+
? res.criticalPath.map((k) => (externalKeys.has(k) ? `${k} [external]` : k)).join(' -> ')
|
|
59
|
+
: '(none)';
|
|
60
|
+
const text = `Critical path (${res.projectDurationDays} working day${res.projectDurationDays === 1 ? '' : 's'}): ${pathDisplay}` +
|
|
61
|
+
(slack ? `\nSlack on non-critical tickets:\n${slack}` : '\nAll in-scope tickets are on the critical path.') +
|
|
62
|
+
(risks ? `\nDEADLINE RISKS (negative float - cannot meet the deadline):\n${risks}` : '');
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: 'text', text }],
|
|
65
|
+
structuredContent: {
|
|
66
|
+
criticalPath: res.criticalPath,
|
|
67
|
+
projectDurationDays: res.projectDurationDays,
|
|
68
|
+
tickets: res.tickets.map((t) => ({
|
|
69
|
+
ticketKey: t.ticketKey,
|
|
70
|
+
isCritical: t.isCritical,
|
|
71
|
+
deadlineCritical: t.deadlineCritical ?? false,
|
|
72
|
+
bindingConstraint: t.bindingConstraint ?? 'project_end',
|
|
73
|
+
totalFloat: t.totalFloat,
|
|
74
|
+
durationDays: t.durationDays,
|
|
75
|
+
external: t.external ?? false,
|
|
76
|
+
externalProjectId: t.externalProjectId ?? null,
|
|
77
|
+
})),
|
|
78
|
+
deadlineRisks: res.deadlineRisks ?? [],
|
|
79
|
+
cycle: null,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
3
|
+
import { makeCriticalPathHandler } from './critical-path.js';
|
|
4
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
5
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
6
|
+
function stub(responses) {
|
|
7
|
+
const calls = [];
|
|
8
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
|
|
9
|
+
calls.push(url.toString());
|
|
10
|
+
const r = responses.shift();
|
|
11
|
+
if (!r)
|
|
12
|
+
throw new Error(`unexpected extra fetch to ${url}`);
|
|
13
|
+
return { ok: true, status: 200, statusText: 'OK', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
|
|
14
|
+
});
|
|
15
|
+
return calls;
|
|
16
|
+
}
|
|
17
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
18
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: '', status: 'active' };
|
|
19
|
+
describe('orboto_critical_path (ORB-1028)', () => {
|
|
20
|
+
it('resolves the project and returns the path + per-ticket float', async () => {
|
|
21
|
+
const calls = stub([
|
|
22
|
+
{ json: PROJ },
|
|
23
|
+
{ json: {
|
|
24
|
+
tickets: [
|
|
25
|
+
{ ticketKey: 'ACME-1', title: 'A', durationDays: 1, totalFloat: 0, isCritical: true },
|
|
26
|
+
{ ticketKey: 'ACME-2', title: 'B', durationDays: 1, totalFloat: 2, isCritical: false },
|
|
27
|
+
],
|
|
28
|
+
criticalPath: ['ACME-1'],
|
|
29
|
+
dependencies: [],
|
|
30
|
+
projectDurationDays: 1,
|
|
31
|
+
cycle: null,
|
|
32
|
+
} },
|
|
33
|
+
]);
|
|
34
|
+
const res = await makeCriticalPathHandler(client)({ projectKey: 'ACME' });
|
|
35
|
+
expect(calls[1]).toContain('/projects/p1/critical-path');
|
|
36
|
+
expect(calls[1]).not.toContain('milestoneId');
|
|
37
|
+
const sc = res.structuredContent;
|
|
38
|
+
expect(sc.criticalPath).toEqual(['ACME-1']);
|
|
39
|
+
expect(sc.tickets.find((t) => t.ticketKey === 'ACME-2')).toMatchObject({ isCritical: false, totalFloat: 2 });
|
|
40
|
+
expect(res.content[0].text).toContain('ACME-1');
|
|
41
|
+
});
|
|
42
|
+
it('resolves a milestone name to its id query param', async () => {
|
|
43
|
+
const calls = stub([
|
|
44
|
+
{ json: PROJ },
|
|
45
|
+
{ json: [{ id: 'm2', name: 'Sprint 7' }] },
|
|
46
|
+
{ json: { tickets: [], criticalPath: [], dependencies: [], projectDurationDays: 0, cycle: null } },
|
|
47
|
+
]);
|
|
48
|
+
await makeCriticalPathHandler(client)({ projectKey: 'ACME', milestone: 'Sprint 7' });
|
|
49
|
+
expect(calls[2]).toContain('milestoneId=m2');
|
|
50
|
+
});
|
|
51
|
+
it('surfaces deadline risks (negative float) in text + structured output', async () => {
|
|
52
|
+
const calls = stub([
|
|
53
|
+
{ json: PROJ },
|
|
54
|
+
{ json: {
|
|
55
|
+
tickets: [
|
|
56
|
+
{ ticketKey: 'ACME-1', title: 'A', durationDays: 3, totalFloat: -2, isCritical: true, deadlineCritical: true, bindingConstraint: 'successors' },
|
|
57
|
+
{ ticketKey: 'ACME-2', title: 'B', durationDays: 2, totalFloat: -2, isCritical: true, deadlineCritical: true, bindingConstraint: 'due_date' },
|
|
58
|
+
],
|
|
59
|
+
criticalPath: ['ACME-1', 'ACME-2'],
|
|
60
|
+
deadlineRisks: [
|
|
61
|
+
{ ticketKey: 'ACME-1', constrainingTicketKey: 'ACME-2', dueDate: '2026-07-18', shortfallDays: 2 },
|
|
62
|
+
{ ticketKey: 'ACME-2', constrainingTicketKey: 'ACME-2', dueDate: '2026-07-18', shortfallDays: 2 },
|
|
63
|
+
],
|
|
64
|
+
dependencies: [],
|
|
65
|
+
projectDurationDays: 5,
|
|
66
|
+
cycle: null,
|
|
67
|
+
} },
|
|
68
|
+
]);
|
|
69
|
+
const res = await makeCriticalPathHandler(client)({ projectKey: 'ACME' });
|
|
70
|
+
expect(calls[1]).toContain('/projects/p1/critical-path');
|
|
71
|
+
const text = res.content[0].text;
|
|
72
|
+
expect(text).toMatch(/DEADLINE RISKS/);
|
|
73
|
+
expect(text).toContain('ACME-2: 2d short');
|
|
74
|
+
const sc = res.structuredContent;
|
|
75
|
+
expect(sc.deadlineRisks).toHaveLength(2);
|
|
76
|
+
expect(sc.deadlineRisks[0]).toMatchObject({ ticketKey: 'ACME-1', shortfallDays: 2 });
|
|
77
|
+
});
|
|
78
|
+
// ORB-1614 - a 1-hop cross-project neighbour is marked external:true by
|
|
79
|
+
// the API; the tool should flag it in both the text (so "OVB-55" isn't
|
|
80
|
+
// mistaken for a typo of this project's key) and the structured output.
|
|
81
|
+
it('flags a cross-project neighbour with [external] in text and structured output', async () => {
|
|
82
|
+
const calls = stub([
|
|
83
|
+
{ json: PROJ },
|
|
84
|
+
{ json: {
|
|
85
|
+
tickets: [
|
|
86
|
+
{ ticketKey: 'ACME-1', title: 'A', durationDays: 1, totalFloat: 0, isCritical: true, external: false },
|
|
87
|
+
{ ticketKey: 'OVB-6', title: 'Foreign blocker', durationDays: 2, totalFloat: 0, isCritical: true, external: true, externalProjectId: 'p2' },
|
|
88
|
+
],
|
|
89
|
+
criticalPath: ['OVB-6', 'ACME-1'],
|
|
90
|
+
dependencies: [{ ticketId: 'ACME-1', dependsOnId: 'OVB-6' }],
|
|
91
|
+
projectDurationDays: 3,
|
|
92
|
+
cycle: null,
|
|
93
|
+
} },
|
|
94
|
+
]);
|
|
95
|
+
const res = await makeCriticalPathHandler(client)({ projectKey: 'ACME' });
|
|
96
|
+
expect(calls[1]).toContain('/projects/p1/critical-path');
|
|
97
|
+
const text = res.content[0].text;
|
|
98
|
+
expect(text).toContain('OVB-6 [external]');
|
|
99
|
+
const sc = res.structuredContent;
|
|
100
|
+
const foreign = sc.tickets.find((t) => t.ticketKey === 'OVB-6');
|
|
101
|
+
expect(foreign).toMatchObject({ external: true, externalProjectId: 'p2' });
|
|
102
|
+
const local = sc.tickets.find((t) => t.ticketKey === 'ACME-1');
|
|
103
|
+
expect(local).toMatchObject({ external: false, externalProjectId: null });
|
|
104
|
+
});
|
|
105
|
+
it('surfaces a dependency cycle', async () => {
|
|
106
|
+
stub([
|
|
107
|
+
{ json: PROJ },
|
|
108
|
+
{ json: { tickets: [], criticalPath: [], dependencies: [], projectDurationDays: 0, cycle: { ticketKeys: ['ACME-1', 'ACME-2'] } } },
|
|
109
|
+
]);
|
|
110
|
+
const res = await makeCriticalPathHandler(client)({ projectKey: 'ACME' });
|
|
111
|
+
expect(res.content[0].text).toMatch(/cycle/i);
|
|
112
|
+
expect(res.structuredContent.cycle.ticketKeys).toEqual(['ACME-1', 'ACME-2']);
|
|
113
|
+
});
|
|
114
|
+
});
|