@orboto/mcp 0.195.1 → 0.198.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 +5 -0
- package/dist/event-bridge.test.js +34 -0
- package/dist/http-transport.js +6 -3
- package/dist/manifest-size.test.js +11 -5
- package/dist/orboto-client.js +4 -4
- package/dist/server.js +10 -0
- package/dist/tool-docs.js +1 -1
- package/dist/tools/agent-coordination.js +33 -32
- package/dist/tools/agent-coordination.test.js +87 -6
- package/dist/tools/agent-message-work.js +22 -0
- package/dist/tools/agent-message-work.test.js +33 -0
- package/dist/tools/agent-messages.js +32 -15
- package/dist/tools/agent-presence-schema.js +40 -0
- package/dist/tools/agent-session-scope.js +8 -0
- package/dist/tools/claim.js +22 -19
- package/dist/tools/claim.test.js +7 -5
- package/dist/tools/get-ticket.js +3 -0
- package/dist/tools/git-rotate-token.js +61 -0
- package/dist/tools/git-rotate-token.test.js +87 -0
- package/dist/tools/list-git-app-installations.js +1 -10
- package/dist/tools/list-tickets.js +4 -0
- package/dist/tools/project-readiness-schema.js +16 -0
- package/dist/tools/project-readiness.js +16 -0
- package/dist/tools/project-readiness.test.js +19 -0
- package/dist/tools/raci.js +4 -2
- package/dist/tools/sentry.js +85 -0
- package/dist/tools/sentry.test.js +107 -0
- package/dist/tools/session-start.js +20 -8
- package/dist/tools/session-start.test.js +44 -0
- package/dist/tools/shared.js +9 -0
- package/dist/tools/spec-check.js +15 -0
- package/dist/tools/spec-check.test.js +23 -0
- package/dist/tools/spec-readiness.test.js +85 -0
- package/dist/tools/spec-schemas.js +34 -0
- package/dist/tools/ticket-writes.js +27 -22
- package/dist/tools/ticket-writes.test.js +3 -4
- package/dist/tools/update-project.js +19 -12
- package/dist/tools/update-project.test.js +13 -4
- package/dist/tools/work-next.js +1 -1
- package/dist/tools/work-sessions-next.test.js +23 -0
- package/dist/tools/work-sessions.js +30 -9
- package/dist/tools/work-sessions.test.js +14 -0
- package/package.json +1 -1
|
@@ -8,21 +8,29 @@
|
|
|
8
8
|
* api's `x-orboto-agent-mail` response header - no polling anywhere).
|
|
9
9
|
*/
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
+
import { makeAgentMessageWorkHandler } from './agent-message-work.js';
|
|
11
12
|
import { mcpInstanceToken } from './shared.js';
|
|
13
|
+
const PartySchema = z.object({ userId: z.string(), email: z.string(), sessionId: z.string().nullable(), role: z.string().nullable(), label: z.string() });
|
|
12
14
|
export const agentMessagesToolConfig = {
|
|
13
|
-
title: '
|
|
14
|
-
description: 'Your
|
|
15
|
+
title: 'Agent inbox and message work',
|
|
16
|
+
description: 'Your inbox as THIS session sees it: mail to this session, account mail inside your declared scope, broadcasts; sibling-session mail and own sends stay out, all:true shows the whole account (ORB-2136). messageWork carries durable work ownership - claim before executing. ackIds is a plain read receipt: no claim needed, finishes nothing. Reply via orboto_agent_notify with threadId (toSessionRef = from.sessionId reaches that instance).',
|
|
15
17
|
inputSchema: z.object({
|
|
16
|
-
|
|
18
|
+
messageWork: z.record(z.unknown()).optional()
|
|
19
|
+
.describe('Work envelope {messageId, mutation, cursor, limit, openOnly}; schema: orboto_api_search.'),
|
|
20
|
+
all: z.boolean().default(false),
|
|
17
21
|
limit: z.number().int().min(1).max(200).default(50),
|
|
18
|
-
project: z.string().min(1).max(64).optional().describe('Project key
|
|
19
|
-
includeOwnSends: z.boolean().default(false)
|
|
20
|
-
ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark
|
|
22
|
+
project: z.string().min(1).max(64).optional().describe('Project key/UUID; includes unscoped mail.'),
|
|
23
|
+
includeOwnSends: z.boolean().default(false),
|
|
24
|
+
ackIds: z.array(z.string().uuid()).max(200).optional().describe('Message ids to mark read.'),
|
|
21
25
|
}).shape,
|
|
22
26
|
outputSchema: z.object({
|
|
27
|
+
work: z.record(z.unknown()).optional(),
|
|
28
|
+
sessionId: z.string().nullable().optional(),
|
|
23
29
|
messages: z.array(z.object({
|
|
24
30
|
id: z.string(),
|
|
25
31
|
fromUserId: z.string(),
|
|
32
|
+
from: PartySchema,
|
|
33
|
+
to: PartySchema,
|
|
26
34
|
kind: z.string(),
|
|
27
35
|
subject: z.string(),
|
|
28
36
|
payload: z.record(z.string(), z.unknown()).nullable(),
|
|
@@ -33,13 +41,19 @@ export const agentMessagesToolConfig = {
|
|
|
33
41
|
})),
|
|
34
42
|
acked: z.number().int(),
|
|
35
43
|
}).shape,
|
|
36
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
44
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
37
45
|
};
|
|
38
46
|
export function makeAgentMessagesHandler(client) {
|
|
39
47
|
return async (args, extra) => {
|
|
48
|
+
if (args.messageWork) {
|
|
49
|
+
if (args.ackIds?.length)
|
|
50
|
+
throw new Error('Use a separate explicit ACK call.');
|
|
51
|
+
const result = await makeAgentMessageWorkHandler(client)(args.messageWork, extra);
|
|
52
|
+
return { content: result.content, structuredContent: { messages: [], acked: 0, work: result.structuredContent } };
|
|
53
|
+
}
|
|
40
54
|
let acked = 0;
|
|
41
55
|
if (args.ackIds && args.ackIds.length > 0) {
|
|
42
|
-
const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds });
|
|
56
|
+
const res = await client.post('/v1/agent/messages/ack', { ids: args.ackIds, instanceToken: mcpInstanceToken(undefined, extra) });
|
|
43
57
|
acked = res.acked;
|
|
44
58
|
}
|
|
45
59
|
const q = new URLSearchParams();
|
|
@@ -49,21 +63,24 @@ export function makeAgentMessagesHandler(client) {
|
|
|
49
63
|
q.set('limit', String(args.limit));
|
|
50
64
|
if (args.project)
|
|
51
65
|
q.set('project', args.project);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const { messages } = await client.get(`/v1/agent/messages${q.toString() ? `?${q.toString()}` : ''}
|
|
66
|
+
const instanceToken = mcpInstanceToken(undefined, extra);
|
|
67
|
+
if (!args.includeOwnSends)
|
|
68
|
+
q.set('excludeRef', instanceToken);
|
|
69
|
+
const { messages, sessionId } = await client.get(`/v1/agent/messages${q.toString() ? `?${q.toString()}` : ''}`, { instanceToken });
|
|
56
70
|
const lines = messages.length === 0
|
|
57
71
|
? [acked > 0 ? `Acknowledged ${acked} message(s). Inbox empty.` : 'Inbox empty.']
|
|
58
|
-
: messages.map((m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.fromUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`);
|
|
72
|
+
: messages.map((m) => `[${m.kind}]${m.projectKey ? ` [${m.projectKey}]` : ''} ${m.subject} (from ${m.from?.label ?? m.fromUserId} to ${m.to?.label ?? m.toUserId}, ${m.createdAt}, id ${m.id}${m.threadId ? `, thread ${m.threadId}` : ''})${m.payload ? ` payload: ${JSON.stringify(m.payload)}` : ''}`);
|
|
59
73
|
if (messages.length > 0) {
|
|
60
|
-
lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId.`);
|
|
74
|
+
lines.push(`Acknowledge with ackIds once handled; reply via orboto_agent_notify with threadId (toSessionRef = the sender's instance short id reaches that session only).`);
|
|
61
75
|
}
|
|
76
|
+
if (sessionId)
|
|
77
|
+
lines.push(`This session: ${sessionId.slice(0, 8)} (ref ${instanceToken}).`);
|
|
62
78
|
return {
|
|
63
79
|
content: [{ type: 'text', text: lines.join('\n') }],
|
|
64
80
|
structuredContent: {
|
|
81
|
+
sessionId: sessionId ?? null,
|
|
65
82
|
messages: messages.map((m) => ({
|
|
66
|
-
id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject,
|
|
83
|
+
id: m.id, fromUserId: m.fromUserId, from: m.from, to: m.to, kind: m.kind, subject: m.subject,
|
|
67
84
|
payload: m.payload, threadId: m.threadId, projectKey: m.projectKey ?? null,
|
|
68
85
|
createdAt: m.createdAt, readAt: m.readAt,
|
|
69
86
|
})),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AgentSessionScopeSchema } from './agent-session-scope.js';
|
|
3
|
+
const WorkSessionResourceClaimSchema = z.object({
|
|
4
|
+
kind: z.enum(['path', 'named']), value: z.string().min(1).max(500), mode: z.enum(['read', 'write']),
|
|
5
|
+
state: z.enum(['granted', 'waiting']).optional(), requestedAt: z.string().optional(),
|
|
6
|
+
});
|
|
7
|
+
export const AgentInventoryKindSchema = z.enum(['agent', 'human']);
|
|
8
|
+
export const AgentConnectionTypeSchema = z.enum(['api_key', 'oauth', 'lane', 'external_token', 'live_events', 'web']);
|
|
9
|
+
export const AgentInventoryConnectionSchema = z.object({ type: AgentConnectionTypeSchema, label: z.string() });
|
|
10
|
+
export const AgentInventoryProjectSchema = z.object({ id: z.string().uuid(), key: z.string(), name: z.string() });
|
|
11
|
+
export const AgentInventoryEntrySchema = z.object({
|
|
12
|
+
userId: z.string().uuid(),
|
|
13
|
+
userEmail: z.string(),
|
|
14
|
+
userFullName: z.string().nullable(),
|
|
15
|
+
kind: AgentInventoryKindSchema,
|
|
16
|
+
/** Alias of `kind === 'agent'`. */
|
|
17
|
+
isBot: z.boolean(),
|
|
18
|
+
actsAs: z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() }).nullable(),
|
|
19
|
+
owner: z.object({ id: z.string().uuid(), name: z.string().nullable(), email: z.string() }).nullable(),
|
|
20
|
+
autonomyPaused: z.boolean(),
|
|
21
|
+
sessionId: z.string().uuid(),
|
|
22
|
+
status: z.string(),
|
|
23
|
+
lane: z.object({ id: z.string().uuid(), name: z.string(), role: z.string(), paused: z.boolean() }).nullable(),
|
|
24
|
+
projects: z.array(AgentInventoryProjectSchema),
|
|
25
|
+
connections: z.array(AgentInventoryConnectionSchema),
|
|
26
|
+
workingOnTicket: z.object({ id: z.string().uuid(), key: z.string().nullable(), title: z.string(), projectKey: z.string().nullable() }).nullable(),
|
|
27
|
+
workSessions: z.array(z.object({
|
|
28
|
+
ticketId: z.string().uuid(), ticketKey: z.string().nullable(), role: z.string(), leaseUntil: z.string(),
|
|
29
|
+
resourceClaims: z.array(WorkSessionResourceClaimSchema),
|
|
30
|
+
})),
|
|
31
|
+
capabilities: z.array(z.string()),
|
|
32
|
+
clientInfo: z.record(z.string(), z.string()),
|
|
33
|
+
/** ORB-2136 - the scope the session declared; null when it declared none. */
|
|
34
|
+
scope: AgentSessionScopeSchema.nullable(),
|
|
35
|
+
/** ORB-2136 - unread inbox messages this session would list. */
|
|
36
|
+
unreadMessages: z.number().int(),
|
|
37
|
+
lastSeenAt: z.string(),
|
|
38
|
+
createdAt: z.string(),
|
|
39
|
+
});
|
|
40
|
+
export const AgentInventorySchema = z.array(AgentInventoryEntrySchema);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** ORB-2136 - standalone mirror of `@orboto/shared-schema` agent-session-scope (the MCP package does not depend on it). */
|
|
3
|
+
export const AgentSessionRoleSchema = z.enum(['integrator', 'lead', 'worker', 'spec', 'review', 'coordinator']);
|
|
4
|
+
export const AgentSessionScopeSchema = z.object({
|
|
5
|
+
projectKeys: z.array(z.string()).optional(),
|
|
6
|
+
ticketKeys: z.array(z.string()).optional(),
|
|
7
|
+
role: AgentSessionRoleSchema.optional(),
|
|
8
|
+
});
|
package/dist/tools/claim.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @see ORB-179, ORB-181
|
|
5
5
|
*/
|
|
6
|
+
import { specGateFailure, isAlreadyAssigned } from './spec-schemas.js';
|
|
6
7
|
import { z } from 'zod';
|
|
7
8
|
import { OrbotoApiError } from '../orboto-client.js';
|
|
8
9
|
import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
|
|
@@ -15,33 +16,44 @@ const CATEGORY_TO_LEGACY = {
|
|
|
15
16
|
};
|
|
16
17
|
export const claimToolConfig = {
|
|
17
18
|
title: 'Claim a ticket (assign self + in_progress + timer)',
|
|
18
|
-
description: '
|
|
19
|
-
+ '`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.',
|
|
19
|
+
description: 'Assign yourself, move to in_progress and start a timer. Repeated claims recheck the specification gate. Worker bots need ready, or override=true with a reason; enabled epics are blocked. sole removes other assignees after the gate passes. force permits reopening done tickets. noTimer skips time tracking. On human accounts a different active timer is stopped first and its elapsed time booked to that ticket. Bot timers are per instance and never auto-stop each other: agentSessionToken defaults to this MCP connection; own both start and stop.',
|
|
20
20
|
inputSchema: z.object({
|
|
21
21
|
ticketKey: z.string().min(3),
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
override: z.boolean().optional().describe("Spec override; reason required."),
|
|
23
|
+
reason: z.string().trim().min(1).max(2000).optional(),
|
|
24
|
+
sole: z.boolean().optional().describe('Remove other assignees after gate.'),
|
|
25
|
+
force: z.boolean().optional().describe('Reopen done.'),
|
|
26
|
+
noTimer: z.boolean().optional().describe('No timer.'),
|
|
27
|
+
agentSessionToken: z.string().optional().describe('Instance token for bot timers.'),
|
|
26
28
|
}).shape,
|
|
27
29
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
28
30
|
};
|
|
29
31
|
export function makeClaimHandler(client) {
|
|
30
|
-
return async ({ ticketKey, sole, force, noTimer, agentSessionToken }, extra) => {
|
|
32
|
+
return async ({ ticketKey, sole, force, override, reason, noTimer, agentSessionToken }, extra) => {
|
|
31
33
|
const me = await client.get('/users/me');
|
|
32
34
|
const current = await resolveTicketByKey(client, ticketKey);
|
|
33
35
|
const currentAssignees = current.assignees ?? [];
|
|
34
|
-
const alreadyAssigned = currentAssignees.some((a) => a.id === me.id);
|
|
36
|
+
const alreadyAssigned = currentAssignees.some((a) => (a.userId ?? a.id) === me.id);
|
|
35
37
|
const currentCategory = current.statusCategory;
|
|
36
38
|
if (currentCategory === 'done' && !force) {
|
|
37
39
|
throw new Error(`Refusing to claim [${current.ticketKey}]: status is "done". Pass force=true to reopen + claim.`);
|
|
38
40
|
}
|
|
41
|
+
try {
|
|
42
|
+
await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, { override, reason });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const gate = error instanceof OrbotoApiError && error.status === 409 ? specGateFailure(error.body) : undefined;
|
|
46
|
+
if (gate)
|
|
47
|
+
return gate;
|
|
48
|
+
if (!(error instanceof OrbotoApiError) || error.status !== 409 || !isAlreadyAssigned(error.body))
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
39
51
|
if (sole) {
|
|
40
52
|
for (const a of currentAssignees) {
|
|
41
|
-
if (a.id === me.id)
|
|
53
|
+
if ((a.userId ?? a.id) === me.id)
|
|
42
54
|
continue;
|
|
43
55
|
try {
|
|
44
|
-
await client.delete(`/projects/${current.projectId}/tickets/${current.id}/assignees/${a.id}`);
|
|
56
|
+
await client.delete(`/projects/${current.projectId}/tickets/${current.id}/assignees/${a.userId ?? a.id}`);
|
|
45
57
|
}
|
|
46
58
|
catch (err) {
|
|
47
59
|
if (!(err instanceof OrbotoApiError) || err.status !== 404)
|
|
@@ -49,15 +61,6 @@ export function makeClaimHandler(client) {
|
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
63
|
}
|
|
52
|
-
if (!alreadyAssigned) {
|
|
53
|
-
try {
|
|
54
|
-
await client.post(`/projects/${current.projectId}/tickets/${current.id}/assignees/${me.id}`, {});
|
|
55
|
-
}
|
|
56
|
-
catch (err) {
|
|
57
|
-
if (!(err instanceof OrbotoApiError) || err.status !== 409)
|
|
58
|
-
throw err;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
64
|
let finalTicket = current;
|
|
62
65
|
if (currentCategory !== 'in_progress') {
|
|
63
66
|
finalTicket = await client.patch(`/projects/${current.projectId}/tickets/${current.id}`, { status: CATEGORY_TO_LEGACY.in_progress });
|
package/dist/tools/claim.test.js
CHANGED
|
@@ -75,7 +75,7 @@ describe('orboto_claim', () => {
|
|
|
75
75
|
noop: false,
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
|
-
it('idempotent
|
|
78
|
+
it('idempotent re-claim rechecks assignment readiness without changing status', async () => {
|
|
79
79
|
const calls = stub([
|
|
80
80
|
{ json: ME },
|
|
81
81
|
{ json: PROJ },
|
|
@@ -83,10 +83,11 @@ describe('orboto_claim', () => {
|
|
|
83
83
|
...TICKET_TODO, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress',
|
|
84
84
|
assignees: [{ id: 'me1', email: 'agent-e@orboto.io', fullName: 'Agent E' }],
|
|
85
85
|
} },
|
|
86
|
+
{ json: {} },
|
|
86
87
|
{ json: { ticketId: 't1' } }, // GET /time/timer - already on same ticket
|
|
87
88
|
]);
|
|
88
89
|
const res = await makeClaimHandler(client)({ ticketKey: 'ACME-1' });
|
|
89
|
-
expect(calls).toHaveLength(
|
|
90
|
+
expect(calls).toHaveLength(5);
|
|
90
91
|
expect(calls.some((c) => c.method === 'PATCH')).toBe(false);
|
|
91
92
|
expect(calls.some((c) => c.url.includes('/timer/start'))).toBe(false);
|
|
92
93
|
expect(res.structuredContent).toMatchObject({
|
|
@@ -94,7 +95,7 @@ describe('orboto_claim', () => {
|
|
|
94
95
|
noop: true,
|
|
95
96
|
});
|
|
96
97
|
});
|
|
97
|
-
it('sole=true
|
|
98
|
+
it('sole=true verifies self assignment before removing other assignees', async () => {
|
|
98
99
|
const calls = stub([
|
|
99
100
|
{ json: ME },
|
|
100
101
|
{ json: PROJ },
|
|
@@ -111,8 +112,9 @@ describe('orboto_claim', () => {
|
|
|
111
112
|
]);
|
|
112
113
|
await makeClaimHandler(client)({ ticketKey: 'ACME-1', sole: true });
|
|
113
114
|
expect(calls.filter((c) => c.method === 'DELETE')).toHaveLength(2);
|
|
114
|
-
expect(calls[3].url).toContain('/assignees/
|
|
115
|
-
expect(calls[4].url).toContain('/assignees/
|
|
115
|
+
expect(calls[3].url).toContain('/assignees/me1');
|
|
116
|
+
expect(calls[4].url).toContain('/assignees/other1');
|
|
117
|
+
expect(calls[5].url).toContain('/assignees/other2');
|
|
116
118
|
});
|
|
117
119
|
it('refuses to claim a done ticket without force=true', async () => {
|
|
118
120
|
stub([
|
package/dist/tools/get-ticket.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { specReleaseInfo } from './shared.js';
|
|
1
2
|
/**
|
|
2
3
|
* ORB-244 Phase B - `orboto_get_ticket`.
|
|
3
4
|
*
|
|
@@ -73,6 +74,8 @@ export function makeGetTicketHandler(client) {
|
|
|
73
74
|
: null,
|
|
74
75
|
priority: full.priority,
|
|
75
76
|
type: full.type,
|
|
77
|
+
specState: full.specState ?? 'none',
|
|
78
|
+
...specReleaseInfo(full),
|
|
76
79
|
deliveryMode: full.deliveryMode ?? 'implementation',
|
|
77
80
|
dueDate: full.dueDate,
|
|
78
81
|
startDate: full.startDate,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2112 - `orboto_git_rotate_token`.
|
|
3
|
+
*
|
|
4
|
+
* Replace the credential a project's git connection uses. orboto probes
|
|
5
|
+
* the provider with the new one first, so a bad paste leaves the working
|
|
6
|
+
* credential in place and the connection id, its activities and issue
|
|
7
|
+
* links untouched.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { resolveProjectByKey } from './shared.js';
|
|
11
|
+
export const gitRotateTokenToolConfig = {
|
|
12
|
+
title: 'Rotate a git connection\'s access token',
|
|
13
|
+
description: 'Replace a git connection\'s access token (ssh: private key). The provider is probed with the new credential first; on success it is re-sealed under the same connection id, on failure 422 and the old one stays. reinstallWebhook also reinstalls the hook. App / OAuth connections answer 422.',
|
|
14
|
+
inputSchema: z.object({
|
|
15
|
+
projectKey: z.string().min(1).describe('Project key, e.g. ORB.'),
|
|
16
|
+
connectionId: z.string().uuid().describe('Git connection id.'),
|
|
17
|
+
accessToken: z.string().min(1).optional().describe('New access token (every provider but ssh).'),
|
|
18
|
+
privateKey: z.string().min(1).optional().describe('New SSH private key (ssh only).'),
|
|
19
|
+
reinstallWebhook: z.boolean().optional().describe('Also reinstall the provider hook.'),
|
|
20
|
+
}).shape,
|
|
21
|
+
outputSchema: z.object({
|
|
22
|
+
connectionId: z.string(),
|
|
23
|
+
name: z.string(),
|
|
24
|
+
healthy: z.boolean(),
|
|
25
|
+
tokenState: z.enum(['ok', 'unauthorized', 'unknown']),
|
|
26
|
+
reason: z.string().nullable(),
|
|
27
|
+
webhookUrl: z.string().optional(),
|
|
28
|
+
}).shape,
|
|
29
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
30
|
+
};
|
|
31
|
+
export function makeGitRotateTokenHandler(client) {
|
|
32
|
+
return async (args) => {
|
|
33
|
+
if ((args.accessToken ? 1 : 0) + (args.privateKey ? 1 : 0) !== 1) {
|
|
34
|
+
throw new Error('Pass exactly one of accessToken or privateKey.');
|
|
35
|
+
}
|
|
36
|
+
const project = await resolveProjectByKey(client, args.projectKey);
|
|
37
|
+
const res = await client.post(`/projects/${project.id}/git-connections/${args.connectionId}/rotate-token`, {
|
|
38
|
+
...(args.accessToken ? { accessToken: args.accessToken } : {}),
|
|
39
|
+
...(args.privateKey ? { privateKey: args.privateKey } : {}),
|
|
40
|
+
...(args.reinstallWebhook ? { reinstallWebhook: true } : {}),
|
|
41
|
+
});
|
|
42
|
+
const lines = [
|
|
43
|
+
`Rotated the credential on "${res.health.name}" (${res.health.connectionId}).`,
|
|
44
|
+
`Token state: ${res.health.tokenState}; healthy: ${res.health.healthy}${res.health.reason ? ` (${res.health.reason})` : ''}.`,
|
|
45
|
+
];
|
|
46
|
+
if (res.webhookUrl) {
|
|
47
|
+
lines.push(`Webhook ${res.webhookAutoInstalled ? 'reinstalled on' : 'install failed for'} ${res.webhookUrl}${res.webhookInstallError ? ` - ${res.webhookInstallError}` : ''}.`);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
51
|
+
structuredContent: {
|
|
52
|
+
connectionId: res.health.connectionId,
|
|
53
|
+
name: res.health.name,
|
|
54
|
+
healthy: res.health.healthy,
|
|
55
|
+
tokenState: res.health.tokenState,
|
|
56
|
+
reason: res.health.reason,
|
|
57
|
+
...(res.webhookUrl ? { webhookUrl: res.webhookUrl } : {}),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2114 - `orboto_git_rotate_token` handler tests.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { gitRotateTokenToolConfig, makeGitRotateTokenHandler } from './git-rotate-token.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: r.ok === false ? 'Error' : 'OK',
|
|
23
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
24
|
+
text: async () => JSON.stringify('json' in r ? r.json : {}),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
return calls;
|
|
28
|
+
}
|
|
29
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
30
|
+
const project = { id: '11111111-1111-4111-8111-111111111111', key: 'ORB', name: 'orboto' };
|
|
31
|
+
const connectionId = '22222222-2222-4222-8222-222222222222';
|
|
32
|
+
const health = { connectionId, name: 'Forgejo - orboto/orboto', healthy: true, tokenState: 'ok', unhealthySince: null, reason: null };
|
|
33
|
+
describe('orboto_git_rotate_token', () => {
|
|
34
|
+
it('posts the new access token to the rotate route and returns the health row', async () => {
|
|
35
|
+
const calls = stub([{ json: project }, { json: { rotated: true, health } }]);
|
|
36
|
+
const out = await makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, accessToken: 'tok_new' });
|
|
37
|
+
expect(calls[0]).toMatchObject({ method: 'GET', url: expect.stringContaining('/projects/by-key/ORB') });
|
|
38
|
+
expect(calls[1]).toMatchObject({
|
|
39
|
+
method: 'POST',
|
|
40
|
+
url: expect.stringContaining(`/projects/${project.id}/git-connections/${connectionId}/rotate-token`),
|
|
41
|
+
body: { accessToken: 'tok_new' },
|
|
42
|
+
});
|
|
43
|
+
expect(out.structuredContent).toEqual({ connectionId, name: health.name, healthy: true, tokenState: 'ok', reason: null });
|
|
44
|
+
const text = out.content[0].text;
|
|
45
|
+
expect(text).toContain('Rotated the credential');
|
|
46
|
+
expect(text).not.toContain('tok_new');
|
|
47
|
+
});
|
|
48
|
+
it('passes privateKey and reinstallWebhook through and reports the reinstalled hook', async () => {
|
|
49
|
+
const calls = stub([
|
|
50
|
+
{ json: project },
|
|
51
|
+
{ json: { rotated: true, health, webhookUrl: 'https://orboto.example.com/git/webhook/x', webhookAutoInstalled: true, webhookInstallError: null } },
|
|
52
|
+
]);
|
|
53
|
+
const out = await makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, privateKey: '-----BEGIN KEY-----', reinstallWebhook: true });
|
|
54
|
+
expect(calls[1].body).toEqual({ privateKey: '-----BEGIN KEY-----', reinstallWebhook: true });
|
|
55
|
+
expect(out.content[0].text).toContain('Webhook reinstalled on https://orboto.example.com/git/webhook/x');
|
|
56
|
+
expect(out.structuredContent).toMatchObject({ webhookUrl: 'https://orboto.example.com/git/webhook/x' });
|
|
57
|
+
});
|
|
58
|
+
it('reports a failed webhook install with its error', async () => {
|
|
59
|
+
stub([
|
|
60
|
+
{ json: project },
|
|
61
|
+
{ json: { rotated: true, health: { ...health, healthy: false, tokenState: 'unknown', reason: 'outbound_unreachable' }, webhookUrl: 'https://orboto.example.com/git/webhook/x', webhookAutoInstalled: false, webhookInstallError: 'Gitea: 403' } },
|
|
62
|
+
]);
|
|
63
|
+
const out = await makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, accessToken: 'tok' });
|
|
64
|
+
const text = out.content[0].text;
|
|
65
|
+
expect(text).toContain('Webhook install failed for https://orboto.example.com/git/webhook/x - Gitea: 403');
|
|
66
|
+
expect(text).toContain('(outbound_unreachable)');
|
|
67
|
+
});
|
|
68
|
+
it('refuses when neither or both credentials are given, before any request', async () => {
|
|
69
|
+
const calls = stub([]);
|
|
70
|
+
await expect(makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId })).rejects.toThrow('exactly one');
|
|
71
|
+
await expect(makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, accessToken: 'a', privateKey: 'b' })).rejects.toThrow('exactly one');
|
|
72
|
+
expect(calls).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
it('surfaces a rejected token as the API error', async () => {
|
|
75
|
+
stub([{ json: project }, { ok: false, status: 422, json: { error: 'errors.git.token_rejected' } }]);
|
|
76
|
+
await expect(makeGitRotateTokenHandler(client)({ projectKey: 'ORB', connectionId, accessToken: 'bad' })).rejects.toThrow();
|
|
77
|
+
});
|
|
78
|
+
it('names an unknown project key', async () => {
|
|
79
|
+
stub([{ ok: false, status: 404, json: { error: 'errors.projects.not_found' } }]);
|
|
80
|
+
await expect(makeGitRotateTokenHandler(client)({ projectKey: 'NOPE', connectionId, accessToken: 'tok' })).rejects.toThrow('Project "NOPE" not found');
|
|
81
|
+
});
|
|
82
|
+
it('declares a write tool with a title and an output schema', () => {
|
|
83
|
+
expect(gitRotateTokenToolConfig.title).toMatch(/rotate/i);
|
|
84
|
+
expect(gitRotateTokenToolConfig.annotations.readOnlyHint).toBe(false);
|
|
85
|
+
expect(Object.keys(gitRotateTokenToolConfig.outputSchema)).toEqual(expect.arrayContaining(['connectionId', 'tokenState']));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -11,17 +11,8 @@
|
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
export const listGitAppInstallationsToolConfig = {
|
|
13
13
|
title: 'List GitHub App installations',
|
|
14
|
-
description: '
|
|
14
|
+
description: 'Every GitHub App installation orboto knows about, across projects (super-admin): the org/user, install time and whether it is suspended.',
|
|
15
15
|
inputSchema: z.object({}).shape,
|
|
16
|
-
outputSchema: z.object({
|
|
17
|
-
installations: z.array(z.object({
|
|
18
|
-
provider: z.string(),
|
|
19
|
-
accountLogin: z.string(),
|
|
20
|
-
accountType: z.string(),
|
|
21
|
-
installedAt: z.string(),
|
|
22
|
-
suspended: z.boolean(),
|
|
23
|
-
})),
|
|
24
|
-
}).shape,
|
|
25
16
|
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
26
17
|
};
|
|
27
18
|
export function makeListGitAppInstallationsHandler(client) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TicketSpecStateSchema } from './spec-schemas.js';
|
|
1
2
|
/**
|
|
2
3
|
* ORB-244 Phase B - `orboto_list_tickets`.
|
|
3
4
|
*
|
|
@@ -15,6 +16,7 @@ export const listTicketsToolConfig = {
|
|
|
15
16
|
description: 'List tickets in a project, optionally filtered by status category, milestone name, or assignee email. Set unscheduled=true for the product backlog (everything not planned into a milestone yet). Returns up to 50 tickets per call.',
|
|
16
17
|
inputSchema: z.object({
|
|
17
18
|
projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
|
|
19
|
+
specState: TicketSpecStateSchema.optional(),
|
|
18
20
|
statusCategory: z
|
|
19
21
|
.enum(['todo', 'in_progress', 'in_review', 'done', 'wont_fix'])
|
|
20
22
|
.optional()
|
|
@@ -45,6 +47,8 @@ export function makeListTicketsHandler(client) {
|
|
|
45
47
|
const project = await resolveProjectByKey(client, input.projectKey);
|
|
46
48
|
const qs = new URLSearchParams();
|
|
47
49
|
qs.set('limit', String(input.limit ?? 25));
|
|
50
|
+
if (input.specState)
|
|
51
|
+
qs.set('specState', input.specState);
|
|
48
52
|
if (input.statusCategory)
|
|
49
53
|
qs.set('statusCategory', input.statusCategory);
|
|
50
54
|
if (input.unscheduled && input.milestone) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const ProjectReadinessStatusSchema = z.enum(['ok', 'missing', 'n/a']);
|
|
3
|
+
export const ProjectReadinessItemIdSchema = z.enum(['project_record', 'human_manager', 'milestones', 'git_connection', 'doc_primer', 'instructions', 'tech_stack', 'commands', 'lane_assignment', 'acceptance', 'spec_settings', 'sentry_intake']);
|
|
4
|
+
export const ProjectReadinessItemSchema = z.object({
|
|
5
|
+
id: ProjectReadinessItemIdSchema,
|
|
6
|
+
status: ProjectReadinessStatusSchema,
|
|
7
|
+
reason: z.string(),
|
|
8
|
+
checked: z.number().int().nonnegative(),
|
|
9
|
+
missing: z.number().int().nonnegative(),
|
|
10
|
+
fix: z.object({ method: z.string(), route: z.string(), mcpTool: z.string(), cliCommand: z.string(), settingsPath: z.string() }),
|
|
11
|
+
});
|
|
12
|
+
export const ProjectReadinessSchema = z.object({
|
|
13
|
+
projectId: z.string().uuid(), projectKey: z.string(), ready: z.boolean(), language: z.string(),
|
|
14
|
+
ticketScope: z.literal('visible'),
|
|
15
|
+
items: z.array(ProjectReadinessItemSchema),
|
|
16
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ProjectReadinessSchema } from './project-readiness-schema.js';
|
|
3
|
+
import { resolveProjectByKey } from './shared.js';
|
|
4
|
+
export const projectReadinessToolConfig = {
|
|
5
|
+
title: 'Check project readiness',
|
|
6
|
+
description: 'Check setup before autonomous work: measured ok/missing/n/a items with reasons and fix actions. Visible tickets only; no external probes.',
|
|
7
|
+
inputSchema: z.object({ projectKey: z.string().min(1).describe('Project key, case-insensitive.') }).shape,
|
|
8
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
9
|
+
};
|
|
10
|
+
export function makeProjectReadinessHandler(client) {
|
|
11
|
+
return async ({ projectKey }) => {
|
|
12
|
+
const project = await resolveProjectByKey(client, projectKey);
|
|
13
|
+
const report = ProjectReadinessSchema.parse(await client.get(`/projects/${project.id}/readiness`));
|
|
14
|
+
return { content: [{ type: 'text', text: `${report.projectKey}: ${report.ready ? 'ready' : 'setup incomplete'}\n${report.items.map(i => `${i.status}: ${i.id} (${i.reason})`).join('\n')}` }], structuredContent: report };
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, it, vi } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { makeProjectReadinessHandler } from './project-readiness.js';
|
|
5
|
+
it('returns measured readiness after project key resolution and preserves fix actions', async () => {
|
|
6
|
+
const report = { projectId: '11111111-2222-4333-8444-555555555555', projectKey: 'TEST', language: 'en', ready: false, ticketScope: 'visible', items: [{ id: 'commands', status: 'missing', reason: 'commands_missing', checked: 1, missing: 1, fix: { method: 'POST', route: '/projects/p/primer-facts', mcpTool: 'orboto_primer_fact_add', cliCommand: 'orboto post /projects/p/primer-facts', settingsPath: '/projects/test/settings?tab=aiPrimer' } }] };
|
|
7
|
+
const get = vi.fn().mockResolvedValueOnce({ id: report.projectId, key: 'TEST', name: 'Test' }).mockResolvedValueOnce(report);
|
|
8
|
+
const result = await makeProjectReadinessHandler({ get })({ projectKey: 'test' });
|
|
9
|
+
expect(get).toHaveBeenLastCalledWith(`/projects/${report.projectId}/readiness`);
|
|
10
|
+
expect(result.structuredContent).toEqual(report);
|
|
11
|
+
});
|
|
12
|
+
it('pins the standalone schema mirror to shared-schema', () => {
|
|
13
|
+
const root = resolve(process.cwd(), '../..');
|
|
14
|
+
expect(readFileSync(resolve(root, 'apps/mcp/src/tools/project-readiness-schema.ts'), 'utf8')).toBe(readFileSync(resolve(root, 'packages/shared-schema/src/project-readiness.ts'), 'utf8').replace("import { ProjectSchema } from './projects.ts';\n", '').replace('\nexport const CreatedProjectSchema = ProjectSchema.extend({ readiness: ProjectReadinessSchema });\n', ''));
|
|
15
|
+
});
|
|
16
|
+
it('propagates denied access instead of reporting readiness', async () => {
|
|
17
|
+
const get = vi.fn().mockRejectedValue(new Error('404'));
|
|
18
|
+
await expect(makeProjectReadinessHandler({ get })({ projectKey: 'private' })).rejects.toThrow();
|
|
19
|
+
});
|
package/dist/tools/raci.js
CHANGED
|
@@ -65,15 +65,17 @@ export const setRaciToolConfig = {
|
|
|
65
65
|
ticketKey: z.string().min(3).describe('Ticket key (e.g. "ORB-42").'),
|
|
66
66
|
userEmail: z.string().email().describe('Email of a project member.'),
|
|
67
67
|
role: z.enum(ROLES).describe('R, A, C, or I.'),
|
|
68
|
+
override: z.boolean().optional(),
|
|
69
|
+
reason: z.string().trim().min(1).max(2000).optional(),
|
|
68
70
|
}).shape,
|
|
69
71
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
70
72
|
};
|
|
71
73
|
export function makeSetRaciHandler(client) {
|
|
72
|
-
return async ({ ticketKey, userEmail, role }) => {
|
|
74
|
+
return async ({ ticketKey, userEmail, role, override, reason }) => {
|
|
73
75
|
const ticket = await resolveTicketByKey(client, ticketKey);
|
|
74
76
|
const userId = await resolveMemberId(client, ticket.projectId, userEmail);
|
|
75
77
|
try {
|
|
76
|
-
await client.put(`/projects/${ticket.projectId}/tickets/${ticket.id}/raci/${userId}`, { role });
|
|
78
|
+
await client.put(`/projects/${ticket.projectId}/tickets/${ticket.id}/raci/${userId}`, { role, override, reason });
|
|
77
79
|
}
|
|
78
80
|
catch (err) {
|
|
79
81
|
if (err instanceof OrbotoApiError && err.status === 409) {
|