@orboto/mcp 0.192.0 → 0.194.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/dist/event-bridge.js +1 -47
- package/dist/event-bridge.test.js +0 -19
- package/dist/http-transport-binding.test.js +1 -4
- package/dist/http-transport.js +1 -213
- package/dist/http-transport.test.js +0 -56
- package/dist/index.js +1 -48
- package/dist/input-schema.js +2 -76
- package/dist/input-schema.test.js +1 -15
- package/dist/instructions-budget.test.js +0 -2
- package/dist/manifest-size.test.js +3 -72
- package/dist/oauth-bootstrap.js +0 -47
- package/dist/oauth-bootstrap.test.js +4 -16
- package/dist/orboto-client.js +0 -30
- package/dist/orboto-client.test.js +0 -12
- package/dist/prompts.js +0 -39
- package/dist/protect-text-usage.test.js +1 -9
- package/dist/required-rules-wrapper.test.js +0 -5
- package/dist/required-rules.js +0 -2
- package/dist/resources.js +0 -62
- package/dist/resources.test.js +0 -16
- package/dist/response-budget.js +2 -150
- package/dist/response-budget.test.js +1 -32
- package/dist/server.js +4 -180
- package/dist/session-nudge.js +0 -9
- package/dist/session-nudge.test.js +2 -10
- package/dist/session-nudge.transport.test.js +0 -23
- package/dist/tool-annotations.test.js +0 -44
- package/dist/tool-docs.js +1 -12
- package/dist/tool-docs.test.js +2 -8
- package/dist/tool-parameters.test.js +0 -19
- package/dist/tools/absence-writes.js +1 -17
- package/dist/tools/admin-translations.js +0 -11
- package/dist/tools/admin-writes.js +0 -28
- package/dist/tools/admin-writes.test.js +0 -1
- package/dist/tools/agent-coordination.js +2 -24
- package/dist/tools/agent-coordination.test.js +0 -2
- package/dist/tools/agent-instructions.js +0 -10
- package/dist/tools/agent-instructions.test.js +0 -2
- package/dist/tools/agent-messages.js +0 -1
- package/dist/tools/ai-status.js +0 -14
- package/dist/tools/ai-usage.js +0 -10
- package/dist/tools/analytics.js +0 -2
- package/dist/tools/api-call.js +0 -12
- package/dist/tools/api-call.test.js +1 -2
- package/dist/tools/api-search.js +1 -6
- package/dist/tools/approvals.js +0 -6
- package/dist/tools/attach.js +0 -25
- package/dist/tools/backup.js +0 -13
- package/dist/tools/backup.test.js +1 -2
- package/dist/tools/bulk-create.js +1 -25
- package/dist/tools/bulk-create.test.js +0 -4
- package/dist/tools/bulk-writes.js +69 -61
- package/dist/tools/bulk-writes.test.js +34 -4
- package/dist/tools/check-similar.js +0 -17
- package/dist/tools/checklist-writes.js +82 -27
- package/dist/tools/checklist-writes.test.js +69 -1
- package/dist/tools/claim.js +1 -71
- package/dist/tools/claim.test.js +0 -2
- package/dist/tools/critical-path.js +0 -5
- package/dist/tools/critical-path.test.js +0 -3
- package/dist/tools/cross-project-links.ee.js +1 -24
- package/dist/tools/doc-attachments.js +0 -28
- package/dist/tools/doc-attachments.test.js +1 -4
- package/dist/tools/doc-comments.js +1 -37
- package/dist/tools/doc-comments.test.js +1 -2
- package/dist/tools/doc-edits.js +1 -31
- package/dist/tools/doc-edits.test.js +0 -9
- package/dist/tools/doc-export.js +0 -24
- package/dist/tools/doc-export.test.js +0 -12
- package/dist/tools/doc-revisions.js +0 -23
- package/dist/tools/docs-ai.js +0 -34
- package/dist/tools/docs-ai.test.js +0 -11
- package/dist/tools/docs.js +1 -75
- package/dist/tools/docs.test.js +1 -3
- package/dist/tools/embedding-status.js +0 -13
- package/dist/tools/embedding-status.test.js +0 -1
- package/dist/tools/feedback.js +1 -15
- package/dist/tools/feedback.test.js +0 -1
- package/dist/tools/get-checklists.js +6 -14
- package/dist/tools/get-project-primer.js +1 -25
- package/dist/tools/get-project.js +0 -17
- package/dist/tools/get-ticket.js +1 -61
- package/dist/tools/get-timer.js +0 -11
- package/dist/tools/git-health-reasons.js +0 -1
- package/dist/tools/identity.js +0 -13
- package/dist/tools/list-projects.js +0 -20
- package/dist/tools/list-projects.test.js +0 -1
- package/dist/tools/list-tickets.js +10 -9
- package/dist/tools/milestones-crud.test.js +0 -4
- package/dist/tools/milestones.js +0 -43
- package/dist/tools/my-tickets.js +0 -7
- package/dist/tools/phase-b-tools.test.js +14 -21
- package/dist/tools/primer-facts.js +1 -49
- package/dist/tools/primer-facts.test.js +0 -20
- package/dist/tools/project-listings.js +0 -25
- package/dist/tools/query.js +1 -12
- package/dist/tools/query.test.js +0 -8
- package/dist/tools/raci.js +0 -11
- package/dist/tools/requirements-spec.js +1 -12
- package/dist/tools/response-expand.js +0 -19
- package/dist/tools/review-policy.js +0 -24
- package/dist/tools/review-policy.test.js +0 -2
- package/dist/tools/search.js +1 -13
- package/dist/tools/session-start.js +1 -136
- package/dist/tools/session-start.test.js +1 -44
- package/dist/tools/set-parent.js +1 -19
- package/dist/tools/shared.js +0 -41
- package/dist/tools/shared.test.js +0 -4
- package/dist/tools/similar-projection.js +0 -15
- package/dist/tools/ticket-attachments.js +1 -25
- package/dist/tools/ticket-schedules.js +0 -9
- package/dist/tools/ticket-schedules.test.js +0 -1
- package/dist/tools/ticket-writes.js +1 -110
- package/dist/tools/ticket-writes.test.js +1 -15
- package/dist/tools/time-writes.js +26 -46
- package/dist/tools/time-writes.test.js +18 -0
- package/dist/tools/update-project.js +1 -27
- package/dist/tools/update-project.test.js +1 -1
- package/dist/tools/wiki.js +0 -8
- package/dist/tools/work-next.js +0 -5
- package/dist/tools/work-sessions-start.test.js +0 -2
- package/dist/tools/work-sessions.js +0 -48
- package/dist/tools/work-sessions.test.js +0 -5
- package/dist/toolset.js +2 -81
- package/dist/toolset.test.js +0 -20
- package/dist/version-guard.test.js +0 -4
- package/dist/version.js +0 -12
- package/dist/with-metrics-output-schema.test.js +1 -14
- package/dist/with-metrics.js +2 -74
- package/dist/with-metrics.test.js +7 -29
- package/package.json +1 -1
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-1455 - read + view ticket attachments from MCP.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* none could READ them: an agent working a ticket could not tell the ticket
|
|
6
|
-
* HAD files, let alone SEE an attached screenshot. These two tools close that
|
|
7
|
-
* hole (orboto_get_ticket also gains an `attachments` array):
|
|
8
|
-
*
|
|
9
|
-
* - orboto_list_ticket_attachments - thin wrapper on the ticket-attachment
|
|
10
|
-
* list route; mirrors orboto_list_doc_attachments (ORB-914).
|
|
11
|
-
* - orboto_get_attachment - fetch an attachment's bytes via the new
|
|
12
|
-
* authenticated base64 route and return them to the model:
|
|
13
|
-
* * images (image/*) -> an MCP image content block so the model
|
|
14
|
-
* actually VIEWS the screenshot, plus a text
|
|
15
|
-
* line with filename/size.
|
|
16
|
-
* * everything else -> a text block with metadata + the base64
|
|
17
|
-
* (small files) or a pointer to the skill
|
|
18
|
-
* download shortcut (large files).
|
|
19
|
-
* Works for ticket/doc/comment attachments - the id is global.
|
|
4
|
+
* @see ORB-914
|
|
20
5
|
*/
|
|
21
6
|
import { z } from 'zod';
|
|
22
7
|
import { resolveTicketByKey } from './shared.js';
|
|
@@ -27,9 +12,6 @@ const MAX_INLINE_BYTES = 5 * 1024 * 1024;
|
|
|
27
12
|
function isImage(mimetype) {
|
|
28
13
|
return mimetype.startsWith('image/');
|
|
29
14
|
}
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
// orboto_list_ticket_attachments
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
15
|
export const listTicketAttachmentsToolConfig = {
|
|
34
16
|
title: 'List attachments on a ticket',
|
|
35
17
|
description: 'Return a ticket\'s attachments newest-first, with each attachment\'s id, filename, content type, size, and stable download URL. Use the returned id with orboto_get_attachment to actually view an image or fetch the bytes. Empty list = no attachments. Input is the ticket key like "ACME-42".',
|
|
@@ -73,9 +55,6 @@ export function makeListTicketAttachmentsHandler(client) {
|
|
|
73
55
|
};
|
|
74
56
|
};
|
|
75
57
|
}
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// orboto_get_attachment
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
58
|
export const getAttachmentToolConfig = {
|
|
80
59
|
title: 'View or fetch an attachment\'s bytes',
|
|
81
60
|
description: 'Fetch a single attachment by its id (from orboto_list_ticket_attachments, orboto_get_ticket, or orboto_list_doc_attachments) and return its content. For an image, the model receives an image content block so it actually SEES the screenshot, plus a text line with filename and size. For a non-image, the model gets a text block with metadata and, for small files, the base64 content; large binaries point at the skill\'s download-attachment shortcut. Works for ticket, doc, and comment attachments (the id is global). Enforces the same project/space access check as listing the attachment.',
|
|
@@ -88,8 +67,6 @@ export function makeGetAttachmentHandler(client) {
|
|
|
88
67
|
return async ({ attachmentId }) => {
|
|
89
68
|
const att = await client.get(`/attachments/${attachmentId}/base64`);
|
|
90
69
|
const kb = Math.round(att.sizeBytes / 1024);
|
|
91
|
-
// Over the inline cap: refuse to spill a multi-MB blob into the model's
|
|
92
|
-
// context. Point at the skill download shortcut, which streams to disk.
|
|
93
70
|
if (att.sizeBytes > MAX_INLINE_BYTES) {
|
|
94
71
|
return {
|
|
95
72
|
content: [{
|
|
@@ -112,7 +89,6 @@ export function makeGetAttachmentHandler(client) {
|
|
|
112
89
|
},
|
|
113
90
|
};
|
|
114
91
|
}
|
|
115
|
-
// Non-image: return metadata + the base64 so an agent can decode / save it.
|
|
116
92
|
return {
|
|
117
93
|
content: [{
|
|
118
94
|
type: 'text',
|
|
@@ -16,9 +16,6 @@ function scheduleLine(s) {
|
|
|
16
16
|
const state = s.cancelledAt ? ' [CANCELLED]' : '';
|
|
17
17
|
return `- ${s.title}${state} - ${when} - ${people} attendee(s) (id ${s.id})`;
|
|
18
18
|
}
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
// orboto_list_ticket_schedules
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
19
|
export const listTicketSchedulesToolConfig = {
|
|
23
20
|
title: 'List a ticket\'s scheduled working sessions',
|
|
24
21
|
description: 'List the meeting / working sessions scheduled on a ticket (by key). Active sessions only by default; pass includeCancelled=true for history. Returns each session\'s id, title, window and attendee count - get the id here before cancelling one with orboto_cancel_ticket_session.',
|
|
@@ -44,9 +41,6 @@ export function makeListTicketSchedulesHandler(client) {
|
|
|
44
41
|
};
|
|
45
42
|
};
|
|
46
43
|
}
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
// orboto_schedule_ticket_session
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
44
|
export const scheduleTicketSessionToolConfig = {
|
|
51
45
|
title: 'Schedule a working session on a ticket',
|
|
52
46
|
description: 'Schedule a working session ("scope review", "design crit") on a ticket. Drops a plan-block on every orboto attendee and emails each attendee an iCal invite. startsAt/endsAt are ISO 8601 datetimes. attendeeUserIds are orboto user UUIDs (from orboto_list_users); externalEmails are non-orboto guests who get the invite but no plan-block. Title defaults to "Working session: <TICKET-KEY>".',
|
|
@@ -88,9 +82,6 @@ export function makeScheduleTicketSessionHandler(client) {
|
|
|
88
82
|
};
|
|
89
83
|
};
|
|
90
84
|
}
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// orboto_cancel_ticket_session
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
85
|
export const cancelTicketSessionToolConfig = {
|
|
95
86
|
title: 'Cancel a scheduled working session',
|
|
96
87
|
description: 'Cancel a working session on a ticket. Removes every attendee\'s plan-block and emails an iCal cancellation to all attendees. Get the scheduleId from orboto_list_ticket_schedules.',
|
|
@@ -1,39 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ORB-244 Phase C Group 1 - ticket mutation tools.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* write is gated on the caller's project-level permissions; a 403
|
|
6
|
-
* from the API surfaces as `isError: true` on the MCP response with
|
|
7
|
-
* the API's error message intact, so the model can either retry
|
|
8
|
-
* elsewhere or explain the lock to the user.
|
|
9
|
-
*
|
|
10
|
-
* Resolution patterns mirror Phase B:
|
|
11
|
-
* - `projectKey` (`ACME`) → UUID via `/projects/by-key/:key`
|
|
12
|
-
* - `ticketKey` (`ACME-42`) → UUID via `/projects/:id/tickets/by-key/:n`
|
|
13
|
-
* - `assigneeEmail` → userId via the project members list
|
|
14
|
-
* - `milestone` (name) → milestoneId via the project milestones list
|
|
15
|
-
*
|
|
16
|
-
* Tools in this file:
|
|
17
|
-
* - orboto_create_ticket
|
|
18
|
-
* - orboto_update_ticket
|
|
19
|
-
* - orboto_move_ticket
|
|
20
|
-
* - orboto_close_ticket
|
|
21
|
-
* - orboto_comment
|
|
22
|
-
* - orboto_assign
|
|
23
|
-
* - orboto_unassign
|
|
24
|
-
* - orboto_set_milestone
|
|
25
|
-
* - orboto_add_ticket_dependency (ORB-453)
|
|
26
|
-
* - orboto_remove_ticket_dependency (ORB-453)
|
|
27
|
-
* - orboto_list_ticket_dependencies (ORB-453)
|
|
4
|
+
* @see ORB-453
|
|
28
5
|
*/
|
|
29
6
|
import { z } from 'zod';
|
|
30
7
|
import { trimSimilarEntries } from './similar-projection.js';
|
|
31
8
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
32
9
|
import { resolveProjectByKey, resolveTicketByKey, resolveByName } from './shared.js';
|
|
33
10
|
import { resolveMilestoneByNameOrId } from './milestones.js';
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Local helpers
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
11
|
const STATUS_CATEGORIES = ['todo', 'in_progress', 'in_review', 'done', 'wont_fix'];
|
|
38
12
|
const CATEGORY_TO_LEGACY = {
|
|
39
13
|
todo: 'TODO',
|
|
@@ -68,24 +42,14 @@ function ticketStructured(t) {
|
|
|
68
42
|
statusCategory: t.statusCategory ?? null,
|
|
69
43
|
type: t.type,
|
|
70
44
|
priority: t.priority,
|
|
71
|
-
// ORB-1608 - role-aware commit policy. Absent on responses the API's
|
|
72
|
-
// enrich pipeline didn't touch; the API itself defaults unset rows to
|
|
73
|
-
// 'implementation'.
|
|
74
45
|
deliveryMode: t.deliveryMode ?? 'implementation',
|
|
75
46
|
dueDate: t.dueDate,
|
|
76
47
|
isPrivate: t.isPrivate,
|
|
77
48
|
};
|
|
78
49
|
}
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
// orboto_create_ticket
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
50
|
export const createTicketToolConfig = {
|
|
83
51
|
title: 'Create a ticket',
|
|
84
52
|
description: 'Create a new ticket in the given project. Creating more than ~3 tickets? Use `orboto_bulk_create_tickets` instead - one call, one compact response. Returns the new ticket\'s key (e.g. "ACME-42") so callers can chain follow-ups. **Read the new key from `structuredContent.createdTicketKey` (ORB-1176) - never from `similarWarnings[].ticketKey`, which are OTHER, possibly-duplicate tickets.** The caller must have `ticket:create` on the project. **Duplicate-detection safety-net (ORB-831):** if `similarWarnings` appears in the response with one or more entries, the ticket WAS created but you should review whether to close it as a duplicate of the listed ticket(s) instead. The warnings are advisory - never blocking - but each entry is a ticket the system thinks the new one overlaps with. Prefer `orboto_check_similar` BEFORE creating when you want a dry-run. **Deferred check under load (ORB-1437):** if `duplicateCheckDeferred: true` appears, the project was under a create burst so the duplicate-check was run in the background instead of inline - `similarWarnings` is then empty because it did NOT run synchronously, which is NOT the same as "no duplicates found". A strong match, if any, is posted as an advisory comment on the new ticket a moment later; check the ticket comments before treating it as new work. **Duplicate-check recall (ORB-1121):** when you search/check-similar first, results rank by term co-occurrence - a long, solution-framed title with rare terms can return 0 hits even when a short, symptom-framed dup sharing one distinctive token exists. Probe with a single distinctive STABLE token (file/component/error-string fragment), keep queries SHORT, and search the SYMPTOM not your fix; a 0-result long query is not "no dup". **Language-mismatch warning (ORB-890):** if `languageWarning` appears, the ticket was written in a language different from the workspace default. Consider rewriting in the expected language so search + duplicate-detection stay consistent. Non-blocking. **Before a mass-create (ORB-989):** call `orboto_whoami` first - its `workspaceLocale` field is the language you should write every ticket in. If the same `languageWarning` repeats, stop and clarify the intended language rather than pushing through the whole batch. **Strict mode (ORB-990):** if the workspace enforces ticket language, a mismatch is rejected (the tool returns a `blocked` result, not a created ticket) - rewrite in the workspace language, or set `allowLanguageMismatch: true` only when the language is genuinely intentional. **Hard duplicate-block (ORB-1471):** some workspaces REFUSE a create whose top similarity match is at/above a configured threshold - the tool returns a `duplicateBlocked` result (NOT a created ticket) listing the matching tickets. Extend or comment on one of those instead. If you have confirmed none of them cover this work, retry with `allowDuplicate: true` AND a `duplicateJustification` explaining why - the justification is persisted as a comment on the new ticket. '
|
|
85
|
-
// ORB-1805 - parameter prose moved out of the input schema (which
|
|
86
|
-
// every client pays for on connect) into this text, which the
|
|
87
|
-
// manifest summarises to one sentence and orboto_help serves in
|
|
88
|
-
// full. Same rows also live in the skill's REFERENCE.md.
|
|
89
53
|
+ '**Parameter notes.** `deliveryMode` (ORB-1608) is the role-aware commit policy that replaced the blanket one-commit-per-ticket rule: implementation/docs expect exactly one linked commit (closing without one is a non-blocking warning); review/admin/epic never expect one - reviews are read-only, admin work carries external evidence, epics derive completion from their children; unset defaults to "epic" when type=epic, else "implementation". `milestone` takes a key ("ORB-M3"), a name, or a UUID and is looked up in the project including closed milestones - unknown or ambiguous is an error, so pass the key/UUID when a name repeats. `labels` and `assigneeEmails` attach ATOMICALLY inside the create (ORB-1416): an unknown label or non-member email rolls the whole create back with a 400, leaving no orphan ticket - there is no separate attach call to retry.',
|
|
90
54
|
inputSchema: z.object({
|
|
91
55
|
projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
|
|
@@ -115,12 +79,8 @@ export function makeCreateTicketHandler(client) {
|
|
|
115
79
|
type: input.type ?? 'task',
|
|
116
80
|
priority: input.priority ?? 'normal',
|
|
117
81
|
isPrivate: input.isPrivate ?? false,
|
|
118
|
-
// ORB-1608 - leave unset when the caller didn't pass one; the API
|
|
119
|
-
// defaults it (epic for type=epic, else implementation).
|
|
120
82
|
...(input.deliveryMode ? { deliveryMode: input.deliveryMode } : {}),
|
|
121
83
|
};
|
|
122
|
-
// ORB-1471 - the override justification rides in the body; the override
|
|
123
|
-
// flag itself is a querystring param (see createPath below).
|
|
124
84
|
if (input.allowDuplicate && input.duplicateJustification)
|
|
125
85
|
body.duplicateJustification = input.duplicateJustification;
|
|
126
86
|
if (input.dueDate)
|
|
@@ -131,18 +91,10 @@ export function makeCreateTicketHandler(client) {
|
|
|
131
91
|
const parent = await resolveTicketByKey(client, input.parentTicketKey);
|
|
132
92
|
body.parentTicketId = parent.id;
|
|
133
93
|
}
|
|
134
|
-
// ORB-1416 - labels + assignees attach INLINE in the create
|
|
135
|
-
// transaction so the whole thing is atomic (one round-trip). No more
|
|
136
|
-
// post-create attach loops: a bad label/assignee reference now rolls
|
|
137
|
-
// the create back on the API side (clean 400, no orphan ticket) rather
|
|
138
|
-
// than leaving a half-created ticket the agent retries into a dup.
|
|
139
94
|
if (input.labels && input.labels.length > 0)
|
|
140
95
|
body.labelNames = input.labels;
|
|
141
96
|
if (input.assigneeEmails && input.assigneeEmails.length > 0)
|
|
142
97
|
body.assigneeEmails = input.assigneeEmails;
|
|
143
|
-
// ORB-990 - strict ticket-language enforcement may reject this with a
|
|
144
|
-
// 422; ORB-1471 - the hard duplicate-block may reject it with a 409.
|
|
145
|
-
// Surface each as a clear block result instead of a raw error.
|
|
146
98
|
const qs = [];
|
|
147
99
|
if (input.allowLanguageMismatch)
|
|
148
100
|
qs.push('allowLanguageMismatch=true');
|
|
@@ -162,21 +114,11 @@ export function makeCreateTicketHandler(client) {
|
|
|
162
114
|
return blocked;
|
|
163
115
|
throw err;
|
|
164
116
|
}
|
|
165
|
-
// ORB-831 / ORB-887 - surface the backend's `similarWarnings` to the
|
|
166
|
-
// calling agent. The text block prepends a clearly-visible warning
|
|
167
|
-
// when matches exist so a model scanning the result for "warning"
|
|
168
|
-
// / "duplicate" notices and self-corrects.
|
|
169
|
-
// ORB-890 / ORB-891 - same surface for `languageWarning` when the
|
|
170
|
-
// detected language doesn't match the workspace default.
|
|
171
117
|
const warnings = created.similarWarnings ?? [];
|
|
172
118
|
const langWarning = created.languageWarning;
|
|
173
119
|
const deferred = created.duplicateCheckDeferred === true;
|
|
174
120
|
const baseText = ticketSummaryText('Created', created);
|
|
175
121
|
const parts = [baseText];
|
|
176
|
-
// ORB-1437 - under a create burst the backend defers the duplicate-check
|
|
177
|
-
// to a background job (empty similarWarnings, duplicateCheckDeferred:true).
|
|
178
|
-
// Tell the agent so an empty warnings list isn't read as "no duplicates":
|
|
179
|
-
// a strong match will land as an advisory comment on the new ticket shortly.
|
|
180
122
|
if (deferred) {
|
|
181
123
|
parts.push(`\nℹ Duplicate-check deferred - the project is under high create load, so the check runs in the background. `
|
|
182
124
|
+ `An empty duplicate list here does NOT mean "no duplicates". If a strong match is found, an advisory comment `
|
|
@@ -193,14 +135,7 @@ export function makeCreateTicketHandler(client) {
|
|
|
193
135
|
content: [{ type: 'text', text }],
|
|
194
136
|
structuredContent: {
|
|
195
137
|
...ticketStructured(created),
|
|
196
|
-
// ORB-1176 - the one unambiguous field for the NEW ticket's key.
|
|
197
|
-
// `similarWarnings[].ticketKey` are OTHER tickets; read
|
|
198
|
-
// `createdTicketKey` (never a warning's key) to avoid grabbing the
|
|
199
|
-
// wrong one.
|
|
200
138
|
createdTicketKey: created.ticketKey,
|
|
201
|
-
// ORB-1693 - agent projection: key/title/category/2dp-similarity/
|
|
202
|
-
// relation only. The rich shape (UUID, colours, statusName, 15dp)
|
|
203
|
-
// stays on the REST response for the web UI.
|
|
204
139
|
similarWarnings: trimSimilarEntries(warnings),
|
|
205
140
|
...(deferred ? { duplicateCheckDeferred: true } : {}),
|
|
206
141
|
...(langWarning ? { languageWarning: langWarning } : {}),
|
|
@@ -277,8 +212,6 @@ function duplicateBlockResult(err) {
|
|
|
277
212
|
parsed = JSON.parse(err.body);
|
|
278
213
|
}
|
|
279
214
|
catch { /* non-JSON body */ }
|
|
280
|
-
// Only OUR duplicate-block 409 carries similarWarnings; a different 409
|
|
281
|
-
// (none currently on this route) should bubble as a normal error.
|
|
282
215
|
if (!parsed.similarWarnings)
|
|
283
216
|
return null;
|
|
284
217
|
const candidates = parsed.similarWarnings;
|
|
@@ -301,9 +234,6 @@ function formatSimilarity(w) {
|
|
|
301
234
|
const pct = `${Math.round(w.similarity * 100)}% ${w.matchMode === 'embedding' ? 'AI match' : 'text match'}`;
|
|
302
235
|
return w.statusName ? `${w.statusName}, ${pct}` : pct;
|
|
303
236
|
}
|
|
304
|
-
// ---------------------------------------------------------------------------
|
|
305
|
-
// orboto_update_ticket
|
|
306
|
-
// ---------------------------------------------------------------------------
|
|
307
237
|
export const updateTicketToolConfig = {
|
|
308
238
|
title: 'Update a ticket',
|
|
309
239
|
description: 'Patch one or more fields on a ticket. Patchable: title, description, customerSummary, type, priority, deliveryMode, dueDate, startDate, isPrivate, estimatedTimeMinutes. `customerSummary` is the customer-facing text shown in the customer project report instead of the internal description; null or empty falls back to an AI distillation or the title. `deliveryMode` is the role-aware commit policy (ORB-1608) - see `orboto_create_ticket` for the mode semantics. Use `orboto_move_ticket` for status, `orboto_set_milestone` for milestone, and `orboto_assign` / `orboto_unassign` for members.',
|
|
@@ -328,8 +258,6 @@ export const updateTicketToolConfig = {
|
|
|
328
258
|
export function makeUpdateTicketHandler(client) {
|
|
329
259
|
return async ({ ticketKey, patch, allowLanguageMismatch }) => {
|
|
330
260
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
331
|
-
// ORB-990 - strict ticket-language enforcement may reject a
|
|
332
|
-
// title/description patch with a 422; surface a clear block result.
|
|
333
261
|
const patchPath = `/projects/${ticket.projectId}/tickets/${ticket.id}${allowLanguageMismatch ? '?allowLanguageMismatch=true' : ''}`;
|
|
334
262
|
let updated;
|
|
335
263
|
try {
|
|
@@ -355,9 +283,6 @@ export function makeUpdateTicketHandler(client) {
|
|
|
355
283
|
};
|
|
356
284
|
};
|
|
357
285
|
}
|
|
358
|
-
// ---------------------------------------------------------------------------
|
|
359
|
-
// orboto_move_ticket
|
|
360
|
-
// ---------------------------------------------------------------------------
|
|
361
286
|
export const moveTicketToolConfig = {
|
|
362
287
|
title: 'Move a ticket between status categories',
|
|
363
288
|
description: 'Move a ticket to a new status category - todo / in_progress / in_review / done / wont_fix. The API picks the project\'s first status with that category. Caller must have `ticket:change_status`. **Summary warning (ORB-1332):** moving to `in_review` or `done` without having just posted a summary comment returns a non-blocking `summaryWarning` - the move still succeeds, but you should post what changed, the commit SHA, and how to verify. Use `orboto_close_ticket` with a `comment` (or comment first, then move) to avoid it. **Delivery-mode warning (ORB-1608/1642):** moving to `done` on an `implementation`/`docs` ticket with zero linked commits returns a non-blocking `deliveryModeWarning` - UNLESS the project has no git connection at all, in which case it\'s omitted (a link isn\'t possible there). Code `no_commit_linked` means the connection looks healthy - link a commit, or change deliveryMode via `orboto_update_ticket` if the work genuinely is not commit-shaped. Code `git_delivery_failing` means the connection itself looks unhealthy - a commit may already exist and just hasn\'t arrived; check the connection before assuming none was made.',
|
|
@@ -377,9 +302,6 @@ export function makeMoveTicketHandler(client) {
|
|
|
377
302
|
}, updated.summaryWarning), updated.deliveryModeWarning);
|
|
378
303
|
};
|
|
379
304
|
}
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
// orboto_close_ticket
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
305
|
export const closeTicketToolConfig = {
|
|
384
306
|
title: 'Close a ticket',
|
|
385
307
|
description: 'Move a ticket to `done` and optionally post a closing comment in one call. Closing more than ~3 tickets? Use `orboto_bulk_close_tickets` instead. Convenience wrapper around `orboto_move_ticket` + `orboto_comment` so the model doesn\'t need to chain two writes. Passing a `comment` is the recommended way to close - it doubles as the transition summary and suppresses the ORB-1332 `summaryWarning`. Closing with no comment (and none posted in the last few minutes) returns a non-blocking `summaryWarning`; the close still succeeds. **Delivery-mode warning (ORB-1608/1642):** on an `implementation`/`docs` ticket, closing with zero linked commits returns a non-blocking `deliveryModeWarning` too - UNLESS the project has no git connection at all, in which case it\'s omitted. `no_commit_linked` = healthy connection, link a commit or change deliveryMode via `orboto_update_ticket`. `git_delivery_failing` = the connection itself looks unhealthy - check it before assuming no commit was made.',
|
|
@@ -393,9 +315,6 @@ export function makeCloseTicketHandler(client) {
|
|
|
393
315
|
return async ({ ticketKey, comment }) => {
|
|
394
316
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
395
317
|
if (comment) {
|
|
396
|
-
// Comment first so the close-comment lands in the audit trail
|
|
397
|
-
// even if the status move 403s. Mirrors the wrapper's `close`
|
|
398
|
-
// behaviour.
|
|
399
318
|
await client.post(`/tickets/${ticket.id}/comments`, { content: comment });
|
|
400
319
|
}
|
|
401
320
|
const updated = await client.patch(`/projects/${ticket.projectId}/tickets/${ticket.id}`, { status: 'DONE' });
|
|
@@ -405,9 +324,6 @@ export function makeCloseTicketHandler(client) {
|
|
|
405
324
|
}, updated.summaryWarning), updated.deliveryModeWarning);
|
|
406
325
|
};
|
|
407
326
|
}
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
// orboto_delete_ticket
|
|
410
|
-
// ---------------------------------------------------------------------------
|
|
411
327
|
export const deleteTicketToolConfig = {
|
|
412
328
|
title: 'Permanently delete a ticket',
|
|
413
329
|
description: 'DESTRUCTIVE, IRREVERSIBLE hard-delete of a ticket (by key): the row and its history are gone, and a `ticket.deleted` event + webhook fire. Strongly prefer moving the ticket to `wont_fix` (orboto_move_ticket) or closing it instead - wont_fix keeps the history and analytics intact. Only hard-delete a ticket that should truly never have existed (accidental duplicate, spam). Caller must have `ticket:delete`.',
|
|
@@ -456,9 +372,6 @@ export function makeCommentHandler(client) {
|
|
|
456
372
|
};
|
|
457
373
|
};
|
|
458
374
|
}
|
|
459
|
-
// ---------------------------------------------------------------------------
|
|
460
|
-
// orboto_update_comment (ORB-1285)
|
|
461
|
-
// ---------------------------------------------------------------------------
|
|
462
375
|
export const updateCommentToolConfig = {
|
|
463
376
|
title: 'Edit a ticket comment',
|
|
464
377
|
description: 'Edit the text of a comment you authored (super-admins can edit any). Markdown. The prior text is snapshotted into the comment\'s revision history. Pass the commentId - from orboto_comment\'s response or orboto_get_ticket.',
|
|
@@ -479,9 +392,6 @@ export function makeUpdateCommentHandler(client) {
|
|
|
479
392
|
};
|
|
480
393
|
};
|
|
481
394
|
}
|
|
482
|
-
// ---------------------------------------------------------------------------
|
|
483
|
-
// orboto_delete_comment (ORB-1285)
|
|
484
|
-
// ---------------------------------------------------------------------------
|
|
485
395
|
export const deleteCommentToolConfig = {
|
|
486
396
|
title: 'Delete a ticket comment',
|
|
487
397
|
description: 'Delete a comment. You can always delete your own; deleting another user\'s comment needs the `comment:delete_others` permission in the project (super-admins always can). Pass the commentId - from orboto_comment\'s response or orboto_get_ticket.',
|
|
@@ -501,9 +411,6 @@ export function makeDeleteCommentHandler(client) {
|
|
|
501
411
|
};
|
|
502
412
|
};
|
|
503
413
|
}
|
|
504
|
-
// ---------------------------------------------------------------------------
|
|
505
|
-
// orboto_assign / orboto_unassign
|
|
506
|
-
// ---------------------------------------------------------------------------
|
|
507
414
|
export const assignToolConfig = {
|
|
508
415
|
title: 'Assign a user to a ticket',
|
|
509
416
|
description: 'Add a project member as an assignee on a ticket. Multi-assignee is supported - this adds, it does not replace. Use `orboto_unassign` to remove.',
|
|
@@ -522,7 +429,6 @@ export function makeAssignHandler(client) {
|
|
|
522
429
|
}
|
|
523
430
|
catch (err) {
|
|
524
431
|
if (err instanceof OrbotoApiError && err.status === 409) {
|
|
525
|
-
// Already assigned - idempotent success.
|
|
526
432
|
return {
|
|
527
433
|
content: [{ type: 'text', text: `[${ticket.ticketKey}] already assigned to ${assigneeEmail}.` }],
|
|
528
434
|
structuredContent: { ticketKey: ticket.ticketKey, alreadyAssigned: true },
|
|
@@ -553,7 +459,6 @@ export function makeUnassignHandler(client) {
|
|
|
553
459
|
await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}/assignees/${userId}`);
|
|
554
460
|
}
|
|
555
461
|
catch (err) {
|
|
556
|
-
// 404 = wasn't assigned. Idempotent success.
|
|
557
462
|
if (err instanceof OrbotoApiError && err.status === 404) {
|
|
558
463
|
return {
|
|
559
464
|
content: [{ type: 'text', text: `${assigneeEmail} wasn't assigned to [${ticket.ticketKey}].` }],
|
|
@@ -568,17 +473,8 @@ export function makeUnassignHandler(client) {
|
|
|
568
473
|
};
|
|
569
474
|
};
|
|
570
475
|
}
|
|
571
|
-
// ---------------------------------------------------------------------------
|
|
572
|
-
// orboto_label_ticket / orboto_unlabel_ticket - ORB-1043
|
|
573
|
-
// ---------------------------------------------------------------------------
|
|
574
476
|
async function resolveLabelId(client, projectId, name) {
|
|
575
477
|
const labels = await client.get(`/projects/${projectId}/labels`);
|
|
576
|
-
// ORB-1826 - exact match wins first, then a unique normalised match
|
|
577
|
-
// (HTML-entity-decoded, trimmed, whitespace-collapsed, casefolded) so
|
|
578
|
-
// case/whitespace/entity variants of an existing label name resolve
|
|
579
|
-
// instead of rejecting. Labels have no unique-name constraint, so an
|
|
580
|
-
// ambiguous normalised match (two labels differing only by case) still
|
|
581
|
-
// errors rather than silently picking one.
|
|
582
478
|
const { match, ambiguous } = resolveByName(labels, name, (l) => l.name);
|
|
583
479
|
if (ambiguous) {
|
|
584
480
|
const list = ambiguous.map((l) => `"${l.name}" (${l.id})`).join(', ');
|
|
@@ -629,9 +525,6 @@ export function makeUnlabelTicketHandler(client) {
|
|
|
629
525
|
};
|
|
630
526
|
};
|
|
631
527
|
}
|
|
632
|
-
// ---------------------------------------------------------------------------
|
|
633
|
-
// orboto_set_milestone
|
|
634
|
-
// ---------------------------------------------------------------------------
|
|
635
528
|
export const setMilestoneToolConfig = {
|
|
636
529
|
title: 'Set a ticket\'s milestone',
|
|
637
530
|
description: 'Move a ticket onto a different milestone (or off all milestones with milestone=null/undefined). Resolves the milestone by key (e.g. "ORB-M3"), name, OR UUID within the ticket\'s project (including closed/archived). If two milestones share a name, pass the key or UUID - an ambiguous name is rejected, not silently guessed.',
|
|
@@ -677,7 +570,6 @@ export function makeAddTicketDependencyHandler(client) {
|
|
|
677
570
|
await client.post(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies`, { dependsOnId: dependsOn.id });
|
|
678
571
|
}
|
|
679
572
|
catch (err) {
|
|
680
|
-
// 409 = edge already exists - idempotent success.
|
|
681
573
|
if (err instanceof OrbotoApiError && err.status === 409) {
|
|
682
574
|
return {
|
|
683
575
|
content: [{ type: 'text', text: `[${ticket.ticketKey}] already depends on [${dependsOn.ticketKey}].` }],
|
|
@@ -715,7 +607,6 @@ export function makeRemoveTicketDependencyHandler(client) {
|
|
|
715
607
|
await client.delete(`/projects/${ticket.projectId}/tickets/${ticket.id}/dependencies/${dependsOn.id}`);
|
|
716
608
|
}
|
|
717
609
|
catch (err) {
|
|
718
|
-
// 404 = edge wasn't there. Idempotent success.
|
|
719
610
|
if (err instanceof OrbotoApiError && err.status === 404) {
|
|
720
611
|
return {
|
|
721
612
|
content: [{ type: 'text', text: `[${ticket.ticketKey}] didn't depend on [${dependsOn.ticketKey}].` }],
|
|
@@ -63,9 +63,6 @@ describe('orboto_create_ticket', () => {
|
|
|
63
63
|
projectKey: 'ACME', title: 'Labelled', labels: ['bug', 'ui'],
|
|
64
64
|
assigneeEmails: ['dev@example.com'],
|
|
65
65
|
});
|
|
66
|
-
// Exactly one POST - the atomic create - carrying labels + assignees
|
|
67
|
-
// in its body. No follow-up /labels/ or /assignees/ round-trips, so a
|
|
68
|
-
// bad reference can't leave an orphan ticket the agent retries into a dup.
|
|
69
66
|
const posts = calls.filter((c) => c.method === 'POST');
|
|
70
67
|
expect(posts).toHaveLength(1);
|
|
71
68
|
expect(posts[0].url).toBe('https://orboto.example.com/projects/p1/tickets');
|
|
@@ -121,12 +118,8 @@ describe('orboto_create_ticket', () => {
|
|
|
121
118
|
expect(text).toContain('ACME-13');
|
|
122
119
|
const sc = res.structuredContent;
|
|
123
120
|
expect(sc.similarWarnings).toHaveLength(2);
|
|
124
|
-
// ORB-1176 - createdTicketKey is the NEW key, never a warning's key.
|
|
125
121
|
expect(sc.createdTicketKey).toBe('ACME-99');
|
|
126
122
|
expect(sc.similarWarnings.map((w) => w.ticketKey)).not.toContain(sc.createdTicketKey);
|
|
127
|
-
// ORB-1693 - the agent projection is pinned: exactly these fields, no
|
|
128
|
-
// UUID, no colours, no statusName, similarity at 2dp. A re-grow of the
|
|
129
|
-
// rich shape is a regression, not an enhancement.
|
|
130
123
|
for (const w of sc.similarWarnings) {
|
|
131
124
|
expect(Object.keys(w).sort()).toEqual(['relation', 'similarity', 'statusCategory', 'ticketKey', 'title']);
|
|
132
125
|
}
|
|
@@ -204,13 +197,12 @@ describe('orboto_create_ticket', () => {
|
|
|
204
197
|
if (m === 'GET') {
|
|
205
198
|
return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
|
|
206
199
|
}
|
|
207
|
-
createUrl = u;
|
|
200
|
+
createUrl = u;
|
|
208
201
|
return { ok: false, status: 422, statusText: 'Unprocessable', json: async () => ({}), text: async () => blockBody };
|
|
209
202
|
});
|
|
210
203
|
const res = await makeCreateTicketHandler(client)({
|
|
211
204
|
projectKey: 'ACME', title: 'Authentifizierung schlägt fehl bei externen Nutzern',
|
|
212
205
|
});
|
|
213
|
-
// No override flag → no query param on the create call.
|
|
214
206
|
expect(createUrl).not.toContain('allowLanguageMismatch');
|
|
215
207
|
expect(res.isError).toBe(true);
|
|
216
208
|
const text = res.content[0].text;
|
|
@@ -236,8 +228,6 @@ describe('orboto_create_ticket', () => {
|
|
|
236
228
|
});
|
|
237
229
|
expect(createUrl).toContain('allowLanguageMismatch=true');
|
|
238
230
|
});
|
|
239
|
-
// ORB-1471 - hard duplicate-block 409 becomes a clear duplicateBlocked
|
|
240
|
-
// result that surfaces the candidate list verbatim + the override recipe.
|
|
241
231
|
it('turns a hard duplicate-block 409 into a duplicateBlocked result listing the candidates (ORB-1471)', async () => {
|
|
242
232
|
const blockBody = JSON.stringify({
|
|
243
233
|
error: 'This ticket looks like a duplicate (98% match to an existing ticket).',
|
|
@@ -595,10 +585,6 @@ describe('orboto_add_ticket_dependency / remove / list - ORB-453', () => {
|
|
|
595
585
|
expect(text).toContain('Blocks');
|
|
596
586
|
expect(text).toContain('_(none)_');
|
|
597
587
|
});
|
|
598
|
-
// ORB-1614 - a cross-project blocker the caller cannot read comes back
|
|
599
|
-
// as an opaque stub (title/ticketKey/projectId/status all null). The
|
|
600
|
-
// rendered text must never print the literal "null" and must not need
|
|
601
|
-
// any of those fields to say something useful.
|
|
602
588
|
it('list renders an opaque placeholder for an unreadable cross-project stub, never "null"', async () => {
|
|
603
589
|
stub([
|
|
604
590
|
{ json: PROJ }, { json: TICKET },
|
|
@@ -1,27 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
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
3
|
*/
|
|
19
4
|
import { z } from 'zod';
|
|
20
5
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
21
6
|
import { resolveTicketByKey } from './shared.js';
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// orboto_timer_start
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
7
|
export const timerStartToolConfig = {
|
|
26
8
|
title: 'Start a timer on a ticket',
|
|
27
9
|
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.',
|
|
@@ -55,9 +37,6 @@ export function makeTimerStartHandler(client) {
|
|
|
55
37
|
};
|
|
56
38
|
}
|
|
57
39
|
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
40
|
if (err instanceof OrbotoApiError && err.status === 409) {
|
|
62
41
|
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
42
|
}
|
|
@@ -65,9 +44,6 @@ export function makeTimerStartHandler(client) {
|
|
|
65
44
|
}
|
|
66
45
|
};
|
|
67
46
|
}
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// orboto_timer_stop
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
47
|
export const timerStopToolConfig = {
|
|
72
48
|
title: 'Stop the running timer',
|
|
73
49
|
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.',
|
|
@@ -77,10 +53,6 @@ export const timerStopToolConfig = {
|
|
|
77
53
|
export function makeTimerStopHandler(client) {
|
|
78
54
|
return async () => {
|
|
79
55
|
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
56
|
const res = await client.post('/time/timer/stop', {});
|
|
85
57
|
if (res.stopped === false) {
|
|
86
58
|
return {
|
|
@@ -107,23 +79,43 @@ export function makeTimerStopHandler(client) {
|
|
|
107
79
|
}
|
|
108
80
|
};
|
|
109
81
|
}
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
// orboto_log_time
|
|
112
|
-
// ---------------------------------------------------------------------------
|
|
113
82
|
export const logTimeToolConfig = {
|
|
114
83
|
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.',
|
|
84
|
+
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. Pass `dates` (YYYY-MM-DD list) to book the same entry on several days in one call (ORB-2056): all-or-nothing, a day inside an approved timesheet refuses the set and names the day, capacity warnings come back advisory.',
|
|
116
85
|
inputSchema: z.object({
|
|
117
86
|
ticketKey: z.string().min(3),
|
|
118
87
|
durationMinutes: z.number().int().positive().describe('Minutes, > 0.'),
|
|
119
88
|
description: z.string().optional(),
|
|
120
|
-
loggedAt: z.string().datetime().optional().describe('ISO 8601. Default: now.'),
|
|
89
|
+
loggedAt: z.string().datetime().optional().describe('ISO 8601. Default: now. Ignored when `dates` is set.'),
|
|
90
|
+
dates: z.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)).min(1).max(200).optional().describe('Book this entry on each of these days (YYYY-MM-DD).'),
|
|
91
|
+
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).optional().describe('Wall-clock start for `dates`, workspace timezone. Default 09:00.'),
|
|
121
92
|
}).shape,
|
|
122
93
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
123
94
|
};
|
|
124
95
|
export function makeLogTimeHandler(client) {
|
|
125
|
-
return async ({ ticketKey, durationMinutes, description, loggedAt }) => {
|
|
96
|
+
return async ({ ticketKey, durationMinutes, description, loggedAt, dates, time }) => {
|
|
126
97
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
98
|
+
if (dates && dates.length > 0) {
|
|
99
|
+
const template = { ticketId: ticket.id, durationMinutes };
|
|
100
|
+
if (description !== undefined)
|
|
101
|
+
template.description = description;
|
|
102
|
+
if (time)
|
|
103
|
+
template.time = time;
|
|
104
|
+
const booked = await client.post('/time-entries/bulk', { template, dates });
|
|
105
|
+
const warn = booked.warnings.length ? ` - ${booked.warnings.length} capacity warning(s): ${booked.warnings.map((w) => `${w.date} ${w.kind}`).join(', ')}` : '';
|
|
106
|
+
return {
|
|
107
|
+
content: [{
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: `Logged ${durationMinutes} min on [${ticket.ticketKey}] on ${booked.entries.length} day(s) (${booked.totalMinutes} min total)${warn}`,
|
|
110
|
+
}],
|
|
111
|
+
structuredContent: {
|
|
112
|
+
ticketKey: ticket.ticketKey,
|
|
113
|
+
entries: booked.entries.map((e) => ({ entryId: e.id, loggedAt: e.loggedAt, durationMinutes: e.durationMinutes })),
|
|
114
|
+
totalMinutes: booked.totalMinutes,
|
|
115
|
+
warnings: booked.warnings,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
127
119
|
const body = { durationMinutes };
|
|
128
120
|
if (description !== undefined)
|
|
129
121
|
body.description = description;
|
|
@@ -145,9 +137,6 @@ export function makeLogTimeHandler(client) {
|
|
|
145
137
|
};
|
|
146
138
|
};
|
|
147
139
|
}
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
// orboto_list_time_entries (ORB-1292)
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
140
|
export const listTimeEntriesToolConfig = {
|
|
152
141
|
title: 'List a ticket\'s time entries',
|
|
153
142
|
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.',
|
|
@@ -162,9 +151,6 @@ export function makeListTimeEntriesHandler(client) {
|
|
|
162
151
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
163
152
|
const page = await client.get(`/tickets/${ticket.id}/time-entries?limit=${limit ?? 25}`);
|
|
164
153
|
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
154
|
const agentMark = e.agentLabel ? ` [${e.agentLabel}]` : (e.isAgentWork ? ' [agent]' : '');
|
|
169
155
|
return `- ${e.durationMinutes} min - ${e.loggedAt}${agentMark}${e.description ? ` - ${e.description}` : ''} (id ${e.id})`;
|
|
170
156
|
});
|
|
@@ -177,9 +163,6 @@ export function makeListTimeEntriesHandler(client) {
|
|
|
177
163
|
};
|
|
178
164
|
};
|
|
179
165
|
}
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
// orboto_edit_time_entry (ORB-1292)
|
|
182
|
-
// ---------------------------------------------------------------------------
|
|
183
166
|
export const editTimeEntryToolConfig = {
|
|
184
167
|
title: 'Edit / correct a time entry',
|
|
185
168
|
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.',
|
|
@@ -214,9 +197,6 @@ export function makeEditTimeEntryHandler(client) {
|
|
|
214
197
|
};
|
|
215
198
|
};
|
|
216
199
|
}
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
// orboto_delete_time_entry (ORB-1292)
|
|
219
|
-
// ---------------------------------------------------------------------------
|
|
220
200
|
export const deleteTimeEntryToolConfig = {
|
|
221
201
|
title: 'Delete a time entry',
|
|
222
202
|
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.',
|
|
@@ -101,6 +101,24 @@ describe('orboto_log_time', () => {
|
|
|
101
101
|
expect(calls[2].body).toEqual({ durationMinutes: 30, loggedAt: '2026-04-20T10:00:00Z' });
|
|
102
102
|
});
|
|
103
103
|
});
|
|
104
|
+
describe('orboto_log_time with dates (ORB-2056)', () => {
|
|
105
|
+
it('books through POST /time-entries/bulk with a template and reports the total', async () => {
|
|
106
|
+
const calls = stub([
|
|
107
|
+
{ json: PROJ },
|
|
108
|
+
{ json: TICKET },
|
|
109
|
+
{ json: { entries: [{ id: 'e1', ticketId: 't1', userId: 'u1', durationMinutes: 120, description: 'sprint', loggedAt: '2026-06-01T07:00:00.000Z' }, { id: 'e2', ticketId: 't1', userId: 'u1', durationMinutes: 120, description: 'sprint', loggedAt: '2026-06-02T07:00:00.000Z' }], warnings: [{ kind: 'non_working_day', date: '2026-06-06', message: 'x' }], lockedDates: [], dryRun: false, totalMinutes: 240 } },
|
|
110
|
+
]);
|
|
111
|
+
const out = await makeLogTimeHandler(client)({
|
|
112
|
+
ticketKey: 'ACME-1', durationMinutes: 120, description: 'sprint', dates: ['2026-06-01', '2026-06-02'], time: '09:00', loggedAt: '2026-01-01T00:00:00Z',
|
|
113
|
+
});
|
|
114
|
+
expect(calls[2].method).toBe('POST');
|
|
115
|
+
expect(calls[2].url).toContain('/time-entries/bulk');
|
|
116
|
+
expect(calls[2].body).toEqual({ template: { ticketId: 't1', durationMinutes: 120, description: 'sprint', time: '09:00' }, dates: ['2026-06-01', '2026-06-02'] });
|
|
117
|
+
expect(out.structuredContent.totalMinutes).toBe(240);
|
|
118
|
+
expect(out.content[0].text).toContain('2 day(s)');
|
|
119
|
+
expect(out.content[0].text).toContain('capacity warning');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
104
122
|
describe('orboto_list_time_entries / edit / delete (ORB-1292)', () => {
|
|
105
123
|
it('list GETs the ticket time entries and returns them structured', async () => {
|
|
106
124
|
const calls = stub([
|