@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,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase C Group 2 - time-tracking tools.
|
|
3
|
+
*
|
|
4
|
+
* Three tools that wrap the existing /time/timer/* + /tickets/:id/time-entries
|
|
5
|
+
* endpoints:
|
|
6
|
+
* - orboto_timer_start (ticketKey, description?, replace?)
|
|
7
|
+
* - orboto_timer_stop - closes the running timer; the API converts
|
|
8
|
+
* elapsed time into a time_entries row using the
|
|
9
|
+
* description set at start.
|
|
10
|
+
* - orboto_log_time - direct time-entry POST for after-the-fact
|
|
11
|
+
* logging (no running timer involved).
|
|
12
|
+
*
|
|
13
|
+
* Note: the wrapper's `timer-stop "note"` syntax sends a `note` field
|
|
14
|
+
* the API silently ignores - the time entry's description is whatever
|
|
15
|
+
* was set on `start`. To attach a note to a stopped session, post a
|
|
16
|
+
* comment afterwards via `orboto_comment`. The MCP tool only accepts
|
|
17
|
+
* what the API actually accepts.
|
|
18
|
+
*/
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import { OrbotoApiError } from '../orboto-client.js';
|
|
21
|
+
import { resolveTicketByKey } from './shared.js';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// orboto_timer_start
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export const timerStartToolConfig = {
|
|
26
|
+
title: 'Start a timer on a ticket',
|
|
27
|
+
description: 'Start the user\'s stopwatch on a ticket. If a timer is already running on a different ticket, pass replace=true to auto-stop the previous one (its elapsed time is committed to time_entries before the new timer begins). Description becomes the time entry\'s note when this timer eventually stops.',
|
|
28
|
+
inputSchema: z.object({
|
|
29
|
+
ticketKey: z.string().min(3),
|
|
30
|
+
description: z.string().optional().describe('Note for the time entry; set at start, not at stop.'),
|
|
31
|
+
replace: z.boolean().optional().describe('Stop+commit a running timer first (default: 409).'),
|
|
32
|
+
}).shape,
|
|
33
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
34
|
+
};
|
|
35
|
+
export function makeTimerStartHandler(client) {
|
|
36
|
+
return async ({ ticketKey, description, replace }) => {
|
|
37
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
38
|
+
const body = { ticketId: ticket.id };
|
|
39
|
+
if (description)
|
|
40
|
+
body.description = description;
|
|
41
|
+
if (replace)
|
|
42
|
+
body.replace = true;
|
|
43
|
+
try {
|
|
44
|
+
const timer = await client.post('/time/timer/start', body);
|
|
45
|
+
return {
|
|
46
|
+
content: [{
|
|
47
|
+
type: 'text',
|
|
48
|
+
text: `Timer started on [${ticket.ticketKey}] ${ticket.title}${description ? ` - note: ${description}` : ''}`,
|
|
49
|
+
}],
|
|
50
|
+
structuredContent: {
|
|
51
|
+
ticketKey: ticket.ticketKey,
|
|
52
|
+
startedAt: timer.startedAt,
|
|
53
|
+
description: timer.description,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// 409 = a timer is already running on a different ticket.
|
|
59
|
+
// Surface the API's hint about `replace` rather than letting
|
|
60
|
+
// the model guess.
|
|
61
|
+
if (err instanceof OrbotoApiError && err.status === 409) {
|
|
62
|
+
throw new Error('A timer is already running on a different ticket. Pass replace=true to commit its elapsed time and start fresh on this one.');
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// orboto_timer_stop
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
export const timerStopToolConfig = {
|
|
72
|
+
title: 'Stop the running timer',
|
|
73
|
+
description: 'Stop the user\'s stopwatch. The API commits a time_entries row using the description set at start (or pause/resume), then deletes the active-timer record. Returns the duration in minutes.',
|
|
74
|
+
inputSchema: z.object({}).shape,
|
|
75
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
76
|
+
};
|
|
77
|
+
export function makeTimerStopHandler(client) {
|
|
78
|
+
return async () => {
|
|
79
|
+
try {
|
|
80
|
+
// ORB-1603 - the API stop is idempotent now: nothing-to-stop returns
|
|
81
|
+
// 200 { stopped:false } instead of a 404 (the 404 branch below stays
|
|
82
|
+
// for older servers), and laneFallback flags that the timer was found
|
|
83
|
+
// via the single-active-timer fallback after an instance-token mismatch.
|
|
84
|
+
const res = await client.post('/time/timer/stop', {});
|
|
85
|
+
if (res.stopped === false) {
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: 'text', text: 'No active timer to stop.' }],
|
|
88
|
+
structuredContent: { durationMinutes: 0, alreadyStopped: true },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
content: [{
|
|
93
|
+
type: 'text',
|
|
94
|
+
text: `Timer stopped - logged ${res.durationMinutes} min.${res.laneFallback ? ' (matched via single-active-timer fallback - instance token differed from start)' : ''}`,
|
|
95
|
+
}],
|
|
96
|
+
structuredContent: { durationMinutes: res.durationMinutes, ...(res.laneFallback ? { laneFallback: true } : {}) },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof OrbotoApiError && err.status === 404) {
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: 'text', text: 'No active timer to stop.' }],
|
|
103
|
+
structuredContent: { durationMinutes: 0, alreadyStopped: true },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// orboto_log_time
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
export const logTimeToolConfig = {
|
|
114
|
+
title: 'Log a time entry on a ticket',
|
|
115
|
+
description: 'Direct time-entry POST - for "I just spent 90 minutes on this last Tuesday but forgot to start a timer". `loggedAt` defaults to now; pass an ISO datetime to back-date.',
|
|
116
|
+
inputSchema: z.object({
|
|
117
|
+
ticketKey: z.string().min(3),
|
|
118
|
+
durationMinutes: z.number().int().positive().describe('Minutes, > 0.'),
|
|
119
|
+
description: z.string().optional(),
|
|
120
|
+
loggedAt: z.string().datetime().optional().describe('ISO 8601. Default: now.'),
|
|
121
|
+
}).shape,
|
|
122
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
123
|
+
};
|
|
124
|
+
export function makeLogTimeHandler(client) {
|
|
125
|
+
return async ({ ticketKey, durationMinutes, description, loggedAt }) => {
|
|
126
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
127
|
+
const body = { durationMinutes };
|
|
128
|
+
if (description !== undefined)
|
|
129
|
+
body.description = description;
|
|
130
|
+
if (loggedAt)
|
|
131
|
+
body.loggedAt = loggedAt;
|
|
132
|
+
const entry = await client.post(`/tickets/${ticket.id}/time-entries`, body);
|
|
133
|
+
return {
|
|
134
|
+
content: [{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: `Logged ${entry.durationMinutes} min on [${ticket.ticketKey}]${entry.description ? ` - ${entry.description}` : ''}`,
|
|
137
|
+
}],
|
|
138
|
+
structuredContent: {
|
|
139
|
+
ticketKey: ticket.ticketKey,
|
|
140
|
+
entryId: entry.id,
|
|
141
|
+
durationMinutes: entry.durationMinutes,
|
|
142
|
+
description: entry.description,
|
|
143
|
+
loggedAt: entry.loggedAt,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// orboto_list_time_entries (ORB-1292)
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
export const listTimeEntriesToolConfig = {
|
|
152
|
+
title: 'List a ticket\'s time entries',
|
|
153
|
+
description: 'List the time entries on a ticket (most recent first) with each entry\'s id, duration, date and agent label. Use this to FIND an over-tracked entry before fixing it with orboto_edit_time_entry / orboto_delete_time_entry.',
|
|
154
|
+
inputSchema: z.object({
|
|
155
|
+
ticketKey: z.string().min(3),
|
|
156
|
+
limit: z.number().int().positive().max(100).optional().describe('Default 25.'),
|
|
157
|
+
}).shape,
|
|
158
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
159
|
+
};
|
|
160
|
+
export function makeListTimeEntriesHandler(client) {
|
|
161
|
+
return async ({ ticketKey, limit }) => {
|
|
162
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
163
|
+
const page = await client.get(`/tickets/${ticket.id}/time-entries?limit=${limit ?? 25}`);
|
|
164
|
+
const lines = page.items.map((e) => {
|
|
165
|
+
// ORB-1368 - agentLabel already flags a known agent instance; add a
|
|
166
|
+
// bare [agent] marker for agent work with no instance label (a bot's
|
|
167
|
+
// NULL-lane entry).
|
|
168
|
+
const agentMark = e.agentLabel ? ` [${e.agentLabel}]` : (e.isAgentWork ? ' [agent]' : '');
|
|
169
|
+
return `- ${e.durationMinutes} min - ${e.loggedAt}${agentMark}${e.description ? ` - ${e.description}` : ''} (id ${e.id})`;
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
content: [{
|
|
173
|
+
type: 'text',
|
|
174
|
+
text: page.items.length ? `Time entries on [${ticket.ticketKey}]:\n${lines.join('\n')}` : `No time entries on [${ticket.ticketKey}].`,
|
|
175
|
+
}],
|
|
176
|
+
structuredContent: { ticketKey: ticket.ticketKey, entries: page.items, nextCursor: page.nextCursor },
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// orboto_edit_time_entry (ORB-1292)
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
export const editTimeEntryToolConfig = {
|
|
184
|
+
title: 'Edit / correct a time entry',
|
|
185
|
+
description: 'Correct a time entry - fix an over-tracked duration, edit its note, change its date, or re-target it to another ticket. Use THIS (not orboto_log_time) to fix a wrong entry: log_time is additive and only makes the total worse. Get the entryId from orboto_list_time_entries (or an orboto_log_time response). Blocked if the entry is locked by an approved timesheet.',
|
|
186
|
+
inputSchema: z.object({
|
|
187
|
+
ticketKey: z.string().min(3).describe('The ticket the entry currently belongs to.'),
|
|
188
|
+
entryId: z.string().uuid(),
|
|
189
|
+
durationMinutes: z.number().int().positive().optional(),
|
|
190
|
+
description: z.string().nullish(),
|
|
191
|
+
loggedAt: z.string().datetime().optional(),
|
|
192
|
+
moveToTicketKey: z.string().min(3).optional().describe('Re-target the entry to a different ticket.'),
|
|
193
|
+
}).shape,
|
|
194
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
195
|
+
};
|
|
196
|
+
export function makeEditTimeEntryHandler(client) {
|
|
197
|
+
return async ({ ticketKey, entryId, durationMinutes, description, loggedAt, moveToTicketKey }) => {
|
|
198
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
199
|
+
const body = {};
|
|
200
|
+
if (durationMinutes !== undefined)
|
|
201
|
+
body.durationMinutes = durationMinutes;
|
|
202
|
+
if (description !== undefined)
|
|
203
|
+
body.description = description;
|
|
204
|
+
if (loggedAt)
|
|
205
|
+
body.loggedAt = loggedAt;
|
|
206
|
+
if (moveToTicketKey) {
|
|
207
|
+
const dest = await resolveTicketByKey(client, moveToTicketKey);
|
|
208
|
+
body.ticketId = dest.id;
|
|
209
|
+
}
|
|
210
|
+
const updated = await client.patch(`/tickets/${ticket.id}/time-entries/${entryId}`, body);
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: 'text', text: `Updated time entry ${entryId.slice(0, 8)} on [${ticket.ticketKey}] → ${updated.durationMinutes} min.` }],
|
|
213
|
+
structuredContent: { ticketKey: ticket.ticketKey, entryId: updated.id, durationMinutes: updated.durationMinutes, ticketId: updated.ticketId },
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// orboto_delete_time_entry (ORB-1292)
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
export const deleteTimeEntryToolConfig = {
|
|
221
|
+
title: 'Delete a time entry',
|
|
222
|
+
description: 'Delete a time entry - e.g. a bogus over-tracked entry that should not exist at all (prefer orboto_edit_time_entry when it should just have a different duration). You can delete your own; deleting another user\'s needs the `time:delete_others` permission. Blocked if locked by an approved timesheet. Get the entryId from orboto_list_time_entries.',
|
|
223
|
+
inputSchema: z.object({
|
|
224
|
+
ticketKey: z.string().min(3),
|
|
225
|
+
entryId: z.string().uuid(),
|
|
226
|
+
}).shape,
|
|
227
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
228
|
+
};
|
|
229
|
+
export function makeDeleteTimeEntryHandler(client) {
|
|
230
|
+
return async ({ ticketKey, entryId }) => {
|
|
231
|
+
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
232
|
+
await client.delete(`/tickets/${ticket.id}/time-entries/${entryId}`);
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: 'text', text: `Deleted time entry ${entryId.slice(0, 8)} from [${ticket.ticketKey}].` }],
|
|
235
|
+
structuredContent: { ticketKey: ticket.ticketKey, entryId, deleted: true },
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase C Group 2 - time-tracking tool tests.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { makeTimerStartHandler, makeTimerStopHandler, makeLogTimeHandler, makeListTimeEntriesHandler, makeEditTimeEntryHandler, makeDeleteTimeEntryHandler, } from './time-writes.js';
|
|
7
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
function stub(responses) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
12
|
+
const u = url.toString();
|
|
13
|
+
const m = init?.method ?? 'GET';
|
|
14
|
+
const b = init?.body ? JSON.parse(init.body) : undefined;
|
|
15
|
+
calls.push({ url: u, method: m, body: b });
|
|
16
|
+
const r = responses.shift();
|
|
17
|
+
if (!r)
|
|
18
|
+
throw new Error(`unexpected extra fetch ${m} ${u}`);
|
|
19
|
+
return {
|
|
20
|
+
ok: r.ok ?? true,
|
|
21
|
+
status: r.status ?? 200,
|
|
22
|
+
statusText: 'OK',
|
|
23
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
24
|
+
text: async () => '',
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
return calls;
|
|
28
|
+
}
|
|
29
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
30
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
31
|
+
const TICKET = { id: 't1', projectId: 'p1', ticketKey: 'ACME-1', title: 'Bug' };
|
|
32
|
+
describe('orboto_timer_start', () => {
|
|
33
|
+
it('starts with description + replace=false default', async () => {
|
|
34
|
+
const calls = stub([
|
|
35
|
+
{ json: PROJ },
|
|
36
|
+
{ json: TICKET },
|
|
37
|
+
{ json: { id: 'tm1', ticketId: 't1', startedAt: 'now', accumulatedSeconds: 0, description: 'debug', pausedAt: null } },
|
|
38
|
+
]);
|
|
39
|
+
await makeTimerStartHandler(client)({ ticketKey: 'ACME-1', description: 'debug' });
|
|
40
|
+
expect(calls[2].method).toBe('POST');
|
|
41
|
+
expect(calls[2].url).toContain('/time/timer/start');
|
|
42
|
+
expect(calls[2].body).toEqual({ ticketId: 't1', description: 'debug' });
|
|
43
|
+
});
|
|
44
|
+
it('passes replace=true when supplied', async () => {
|
|
45
|
+
const calls = stub([
|
|
46
|
+
{ json: PROJ },
|
|
47
|
+
{ json: TICKET },
|
|
48
|
+
{ json: { id: 'tm1', ticketId: 't1', startedAt: 'now', accumulatedSeconds: 0, description: null, pausedAt: null } },
|
|
49
|
+
]);
|
|
50
|
+
await makeTimerStartHandler(client)({ ticketKey: 'ACME-1', replace: true });
|
|
51
|
+
expect(calls[2].body).toEqual({ ticketId: 't1', replace: true });
|
|
52
|
+
});
|
|
53
|
+
it('rewrites a 409 into a clear message about replace=true', async () => {
|
|
54
|
+
stub([
|
|
55
|
+
{ json: PROJ },
|
|
56
|
+
{ json: TICKET },
|
|
57
|
+
{ ok: false, status: 409, json: { error: 'Timer already running' } },
|
|
58
|
+
]);
|
|
59
|
+
await expect(makeTimerStartHandler(client)({ ticketKey: 'ACME-1' })).rejects.toThrow(/replace=true/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('orboto_timer_stop', () => {
|
|
63
|
+
it('returns durationMinutes from the API response', async () => {
|
|
64
|
+
const calls = stub([{ json: { durationMinutes: 47 } }]);
|
|
65
|
+
const res = await makeTimerStopHandler(client)();
|
|
66
|
+
expect(calls[0].method).toBe('POST');
|
|
67
|
+
expect(calls[0].url).toContain('/time/timer/stop');
|
|
68
|
+
expect(res.structuredContent).toEqual({ durationMinutes: 47 });
|
|
69
|
+
expect(res.content[0].text).toContain('47 min');
|
|
70
|
+
});
|
|
71
|
+
it('treats 404 as idempotent (no active timer)', async () => {
|
|
72
|
+
stub([{ ok: false, status: 404, json: { error: 'No active timer' } }]);
|
|
73
|
+
const res = await makeTimerStopHandler(client)();
|
|
74
|
+
const sc = res.structuredContent;
|
|
75
|
+
expect(sc.alreadyStopped).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('orboto_log_time', () => {
|
|
79
|
+
it('POSTs durationMinutes + optional description', async () => {
|
|
80
|
+
const calls = stub([
|
|
81
|
+
{ json: PROJ },
|
|
82
|
+
{ json: TICKET },
|
|
83
|
+
{ json: { id: 'e1', ticketId: 't1', userId: 'u1', durationMinutes: 60, description: 'pair-debug', loggedAt: 'now' } },
|
|
84
|
+
]);
|
|
85
|
+
await makeLogTimeHandler(client)({
|
|
86
|
+
ticketKey: 'ACME-1', durationMinutes: 60, description: 'pair-debug',
|
|
87
|
+
});
|
|
88
|
+
expect(calls[2].method).toBe('POST');
|
|
89
|
+
expect(calls[2].url).toContain('/tickets/t1/time-entries');
|
|
90
|
+
expect(calls[2].body).toEqual({ durationMinutes: 60, description: 'pair-debug' });
|
|
91
|
+
});
|
|
92
|
+
it('passes loggedAt for backdated entries', async () => {
|
|
93
|
+
const calls = stub([
|
|
94
|
+
{ json: PROJ },
|
|
95
|
+
{ json: TICKET },
|
|
96
|
+
{ json: { id: 'e1', ticketId: 't1', userId: 'u1', durationMinutes: 30, description: null, loggedAt: '2026-04-20T10:00:00Z' } },
|
|
97
|
+
]);
|
|
98
|
+
await makeLogTimeHandler(client)({
|
|
99
|
+
ticketKey: 'ACME-1', durationMinutes: 30, loggedAt: '2026-04-20T10:00:00Z',
|
|
100
|
+
});
|
|
101
|
+
expect(calls[2].body).toEqual({ durationMinutes: 30, loggedAt: '2026-04-20T10:00:00Z' });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('orboto_list_time_entries / edit / delete (ORB-1292)', () => {
|
|
105
|
+
it('list GETs the ticket time entries and returns them structured', async () => {
|
|
106
|
+
const calls = stub([
|
|
107
|
+
{ json: PROJ },
|
|
108
|
+
{ json: TICKET },
|
|
109
|
+
{ json: { items: [{ id: 'e1', ticketId: 't1', userId: 'u1', durationMinutes: 476, loggedAt: 'now' }], nextCursor: null } },
|
|
110
|
+
]);
|
|
111
|
+
const res = await makeListTimeEntriesHandler(client)({ ticketKey: 'ACME-1' });
|
|
112
|
+
expect(calls[2].method).toBe('GET');
|
|
113
|
+
expect(calls[2].url).toContain('/tickets/t1/time-entries');
|
|
114
|
+
expect(res.structuredContent.entries).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
it('edit PATCHes the entry by id with the new duration', async () => {
|
|
117
|
+
const calls = stub([
|
|
118
|
+
{ json: PROJ },
|
|
119
|
+
{ json: TICKET },
|
|
120
|
+
{ json: { id: 'e1', ticketId: 't1', userId: 'u1', durationMinutes: 45, loggedAt: 'now' } },
|
|
121
|
+
]);
|
|
122
|
+
await makeEditTimeEntryHandler(client)({ ticketKey: 'ACME-1', entryId: 'e1', durationMinutes: 45 });
|
|
123
|
+
expect(calls[2].method).toBe('PATCH');
|
|
124
|
+
expect(calls[2].url).toContain('/tickets/t1/time-entries/e1');
|
|
125
|
+
expect(calls[2].body).toEqual({ durationMinutes: 45 });
|
|
126
|
+
});
|
|
127
|
+
it('delete DELETEs the entry by id', async () => {
|
|
128
|
+
const calls = stub([
|
|
129
|
+
{ json: PROJ },
|
|
130
|
+
{ json: TICKET },
|
|
131
|
+
{ json: {} },
|
|
132
|
+
]);
|
|
133
|
+
const res = await makeDeleteTimeEntryHandler(client)({ ticketKey: 'ACME-1', entryId: 'e1' });
|
|
134
|
+
expect(calls[2].method).toBe('DELETE');
|
|
135
|
+
expect(calls[2].url).toContain('/tickets/t1/time-entries/e1');
|
|
136
|
+
expect(res.structuredContent.deleted).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-885 - `orboto_update_project`.
|
|
3
|
+
* ORB-830 - `orboto_create_project` + `orboto_archive_project`.
|
|
4
|
+
*
|
|
5
|
+
* Project-CRUD write surface for the MCP. The read half
|
|
6
|
+
* (`orboto_list_projects`, `orboto_get_project`) shipped in ORB-244
|
|
7
|
+
* Phase B; ORB-885 added `update_project`, ORB-830 closes the gap
|
|
8
|
+
* with create + archive so an agent never has to drop to a raw
|
|
9
|
+
* `POST /projects` to spin up a new project.
|
|
10
|
+
*
|
|
11
|
+
* Status mutation is allowed in `update_project` (unlike
|
|
12
|
+
* `orboto_update_milestone`, which delegates closing to a dedicated
|
|
13
|
+
* tool) because project status is a four-state lifecycle (`draft` →
|
|
14
|
+
* `active` → `archived` / `closed`) with no extra semantics - a
|
|
15
|
+
* single patch field covers it cleanly. `archive_project` is a
|
|
16
|
+
* thin convenience over `update_project({ status: 'archived' })`
|
|
17
|
+
* because "archive this project" is a workflow agents reach for
|
|
18
|
+
* often enough that an explicit verb beats teaching the model the
|
|
19
|
+
* patch shape.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
import { resolveProjectByKey } from './shared.js';
|
|
23
|
+
// Mirrors the Zod schema on `PATCH /projects/:id` (see
|
|
24
|
+
// apps/api/src/routes/projects.ts). Kept in sync manually - there's no
|
|
25
|
+
// generator from the API's runtime schema into the MCP package yet.
|
|
26
|
+
const PROJECT_KEY_RE = /^[A-Z0-9]+$/;
|
|
27
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
// ORB-993/ORB-994 - the 18 detector-supported workspace/project locales.
|
|
29
|
+
// Mirrors WORKSPACE_LOCALE_CODES in @orboto/shared-schema (kept in sync
|
|
30
|
+
// manually, same as the rest of this tool's schema).
|
|
31
|
+
const LOCALE_CODES = ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'ru', 'pl', 'tr', 'cs', 'da', 'sv', 'no', 'fi', 'ja', 'zh', 'ko'];
|
|
32
|
+
export const updateProjectToolConfig = {
|
|
33
|
+
title: 'Update a project\'s metadata',
|
|
34
|
+
description: 'Patch a project (`name`, `description`, `key`, `status`, `branchTemplate`, `customerId`, `language`). At least one field must be set. Use `status: "archived"` or `"closed"` to take a project out of active rotation. Renaming the `key` also rewrites every ticket\'s `PROJ-N` reference - handle with care. `language` sets the project\'s expected ticket language (one of the 18 supported locales); pass null to inherit the workspace language.',
|
|
35
|
+
inputSchema: z.object({
|
|
36
|
+
projectKey: z.string().min(1).describe('Current project key (e.g. "ACME"). Case-insensitive.'),
|
|
37
|
+
patch: z.object({
|
|
38
|
+
name: z.string().min(1).max(255).optional(),
|
|
39
|
+
description: z.string().nullable().optional().describe('Pass null to clear the description.'),
|
|
40
|
+
key: z.string().min(2).max(10).regex(PROJECT_KEY_RE).optional()
|
|
41
|
+
.describe('New project key. A-Z0-9 only, 2-10 chars. Rewrites every ticket reference.'),
|
|
42
|
+
status: z.enum(['draft', 'active', 'archived', 'closed']).optional(),
|
|
43
|
+
branchTemplate: z.string().max(100).nullable().optional(),
|
|
44
|
+
customerId: z.string().regex(UUID_RE).nullable().optional()
|
|
45
|
+
.describe('UUID of a customer record, or null to detach.'),
|
|
46
|
+
language: z.enum(LOCALE_CODES).nullable().optional()
|
|
47
|
+
.describe('Project content language (ORB-994). Wins over the workspace language for this project. null = inherit workspace.'),
|
|
48
|
+
}).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' }),
|
|
49
|
+
}).shape,
|
|
50
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
51
|
+
};
|
|
52
|
+
export function makeUpdateProjectHandler(client) {
|
|
53
|
+
return async ({ projectKey, patch }) => {
|
|
54
|
+
const project = await resolveProjectByKey(client, projectKey);
|
|
55
|
+
const updated = await client.patch(`/projects/${project.id}`, patch);
|
|
56
|
+
const fields = Object.keys(patch).join(', ');
|
|
57
|
+
return {
|
|
58
|
+
content: [{
|
|
59
|
+
type: 'text',
|
|
60
|
+
text: `Updated project ${updated.key} - ${updated.name} (${fields}).`,
|
|
61
|
+
}],
|
|
62
|
+
structuredContent: {
|
|
63
|
+
id: updated.id,
|
|
64
|
+
key: updated.key,
|
|
65
|
+
name: updated.name,
|
|
66
|
+
description: updated.description,
|
|
67
|
+
status: updated.status,
|
|
68
|
+
language: updated.language ?? null,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// orboto_create_project - ORB-830
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
export const createProjectToolConfig = {
|
|
77
|
+
title: 'Create a new project',
|
|
78
|
+
description: 'Create a new project in the workspace. `name` is required; `key` is optional and the API auto-derives one from the name when omitted (uppercase initials). Returns the new project\'s id + key + status (`active` by default). Caller must have `admin:project:create` or be a super-admin. Note: a new project is auto-provisioned with a general doc space (slug `<key>-general`, system-generated, for the AI primer + notes) - don\'t create a separate doc space for its docs, reuse that one (orboto_list_doc_spaces).',
|
|
79
|
+
inputSchema: z.object({
|
|
80
|
+
name: z.string().min(1).max(255).describe('Display name for the project.'),
|
|
81
|
+
key: z.string().min(2).max(10).regex(PROJECT_KEY_RE).optional()
|
|
82
|
+
.describe('Optional explicit key (e.g. "ACME"). A-Z0-9 only, 2-10 chars. Auto-derived from name when omitted.'),
|
|
83
|
+
description: z.string().nullable().optional().describe('Optional free-text description.'),
|
|
84
|
+
customerId: z.string().regex(UUID_RE).nullable().optional()
|
|
85
|
+
.describe('Optional UUID of a customer record to attach the project to.'),
|
|
86
|
+
language: z.enum(LOCALE_CODES).nullable().optional()
|
|
87
|
+
.describe('Optional project content language (ORB-994), one of the 18 supported locales. Omit/null = inherit the workspace language.'),
|
|
88
|
+
}).shape,
|
|
89
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
90
|
+
};
|
|
91
|
+
export function makeCreateProjectHandler(client) {
|
|
92
|
+
return async ({ name, key, description, customerId, language }) => {
|
|
93
|
+
const body = { name };
|
|
94
|
+
if (key !== undefined)
|
|
95
|
+
body.key = key;
|
|
96
|
+
if (description !== undefined)
|
|
97
|
+
body.description = description;
|
|
98
|
+
if (customerId !== undefined)
|
|
99
|
+
body.customerId = customerId;
|
|
100
|
+
if (language !== undefined)
|
|
101
|
+
body.language = language;
|
|
102
|
+
const created = await client.post('/projects', body);
|
|
103
|
+
return {
|
|
104
|
+
content: [{
|
|
105
|
+
type: 'text',
|
|
106
|
+
text: `Created project ${created.key} - ${created.name} (status: ${created.status}).`,
|
|
107
|
+
}],
|
|
108
|
+
structuredContent: {
|
|
109
|
+
id: created.id,
|
|
110
|
+
key: created.key,
|
|
111
|
+
name: created.name,
|
|
112
|
+
description: created.description,
|
|
113
|
+
status: created.status,
|
|
114
|
+
language: created.language ?? null,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// orboto_archive_project - ORB-830
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
export const archiveProjectToolConfig = {
|
|
123
|
+
title: 'Archive a project',
|
|
124
|
+
description: 'Archive a project - moves it out of the active rotation without deleting any data. Convenience wrapper around `orboto_update_project({ status: "archived" })` because "archive this project" is a workflow operators hit often. Archiving is reversible: pass the same project back through `orboto_update_project({ status: "active" })` to restore. Idempotent - re-archiving an already-archived project is a no-op success.',
|
|
125
|
+
inputSchema: z.object({
|
|
126
|
+
projectKey: z.string().min(1).describe('Project key (e.g. "ACME") of the project to archive.'),
|
|
127
|
+
}).shape,
|
|
128
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
129
|
+
};
|
|
130
|
+
export function makeArchiveProjectHandler(client) {
|
|
131
|
+
return async ({ projectKey }) => {
|
|
132
|
+
const project = await resolveProjectByKey(client, projectKey);
|
|
133
|
+
if (project.status === 'archived') {
|
|
134
|
+
return {
|
|
135
|
+
content: [{
|
|
136
|
+
type: 'text',
|
|
137
|
+
text: `Project ${project.key} is already archived (no-op).`,
|
|
138
|
+
}],
|
|
139
|
+
structuredContent: {
|
|
140
|
+
id: project.id,
|
|
141
|
+
key: project.key,
|
|
142
|
+
name: project.name,
|
|
143
|
+
description: project.description,
|
|
144
|
+
status: project.status,
|
|
145
|
+
alreadyArchived: true,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const updated = await client.patch(`/projects/${project.id}`, { status: 'archived' });
|
|
150
|
+
return {
|
|
151
|
+
content: [{
|
|
152
|
+
type: 'text',
|
|
153
|
+
text: `Archived project ${updated.key} - ${updated.name}.`,
|
|
154
|
+
}],
|
|
155
|
+
structuredContent: {
|
|
156
|
+
id: updated.id,
|
|
157
|
+
key: updated.key,
|
|
158
|
+
name: updated.name,
|
|
159
|
+
description: updated.description,
|
|
160
|
+
status: updated.status,
|
|
161
|
+
alreadyArchived: false,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
}
|