@orboto/mcp 0.195.0 → 0.197.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 +4 -2
- package/dist/orboto-client.js +1 -1
- package/dist/server.js +10 -0
- package/dist/tool-docs.js +1 -1
- package/dist/tools/agent-coordination.js +17 -23
- package/dist/tools/agent-coordination.test.js +40 -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 +18 -8
- package/dist/tools/agent-presence-schema.js +35 -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/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
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) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2123 - `orboto_sentry`: the Sentry connector in one tool. Mapping
|
|
3
|
+
* CRUD, health, secret rotation, the triage-lane binding and the lane's
|
|
4
|
+
* verdict write share an `action` so the full manifest stays under its
|
|
5
|
+
* shrink-only ceiling.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { mcpInstanceToken, resolveProjectByKey, resolveTicketByKey } from './shared.js';
|
|
9
|
+
const ACTIONS = ['list', 'health', 'connect', 'update', 'rotate_secret', 'disconnect', 'triage_lane', 'verdict'];
|
|
10
|
+
export const sentryToolConfig = {
|
|
11
|
+
title: 'Sentry connector',
|
|
12
|
+
description: 'Sentry connector (ORB-2123): list, health, connect (orgSlug, projectSlug, clientSecret), update (triageMode comment|fix_branch), rotate_secret, disconnect, triage_lane (laneId, empty unbinds), verdict (ticketKey, verdict known_fixed|regression|new, summary). Secrets never come back; docs/sentry-intake.md.',
|
|
13
|
+
inputSchema: z.object({
|
|
14
|
+
action: z.enum(ACTIONS),
|
|
15
|
+
projectKey: z.string().min(1),
|
|
16
|
+
connectionId: z.string().uuid().optional(),
|
|
17
|
+
orgSlug: z.string().optional(),
|
|
18
|
+
projectSlug: z.string().optional(),
|
|
19
|
+
clientSecret: z.string().optional(),
|
|
20
|
+
triageMode: z.enum(['comment', 'fix_branch']).optional(),
|
|
21
|
+
laneId: z.string().optional(),
|
|
22
|
+
ticketKey: z.string().optional(),
|
|
23
|
+
verdict: z.enum(['known_fixed', 'regression', 'new']).optional(),
|
|
24
|
+
summary: z.string().optional(),
|
|
25
|
+
}).shape,
|
|
26
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
27
|
+
};
|
|
28
|
+
function line(c) {
|
|
29
|
+
return `- ${c.id} ${c.orgSlug}/${c.projectSlug} mode=${c.triageMode} ${c.enabled ? 'enabled' : 'paused'} token=${c.hasAuthToken ? 'yes' : 'no'} deliveries=${c.webhookCount} last=${c.lastWebhookAt ?? 'never'}${c.lastError ? ` error=${c.lastError}` : ''}\n webhook ${c.webhookUrl}`;
|
|
30
|
+
}
|
|
31
|
+
function text(result, summary) {
|
|
32
|
+
return { content: [{ type: 'text', text: summary }], structuredContent: result };
|
|
33
|
+
}
|
|
34
|
+
export function makeSentryHandler(client) {
|
|
35
|
+
return async (args) => {
|
|
36
|
+
const project = await resolveProjectByKey(client, args.projectKey);
|
|
37
|
+
const need = (field) => { if (args[field] === undefined || args[field] === null || args[field] === '')
|
|
38
|
+
throw new Error(`${args.action} needs ${String(field)}.`); };
|
|
39
|
+
if (args.action === 'list') {
|
|
40
|
+
const rows = await client.get(`/projects/${project.id}/sentry-connections`);
|
|
41
|
+
return text({ connections: rows }, rows.length ? rows.map(line).join('\n') : `No Sentry connection on ${project.key}.`);
|
|
42
|
+
}
|
|
43
|
+
if (args.action === 'connect') {
|
|
44
|
+
need('orgSlug');
|
|
45
|
+
need('projectSlug');
|
|
46
|
+
need('clientSecret');
|
|
47
|
+
const created = await client.post(`/projects/${project.id}/sentry-connections`, {
|
|
48
|
+
orgSlug: args.orgSlug, projectSlug: args.projectSlug, clientSecret: args.clientSecret,
|
|
49
|
+
...(args.triageMode ? { triageMode: args.triageMode } : {}),
|
|
50
|
+
});
|
|
51
|
+
const { webhookUrlWarning, ...connection } = created;
|
|
52
|
+
return text({ connection }, `Connected ${created.orgSlug}/${created.projectSlug} to ${project.key} (${created.id}).\n${line(created)}${webhookUrlWarning ? `\nWarning: ${webhookUrlWarning.message}` : ''}`);
|
|
53
|
+
}
|
|
54
|
+
need('connectionId');
|
|
55
|
+
if (args.action === 'health') {
|
|
56
|
+
const health = await client.get(`/projects/${project.id}/sentry-connections/${args.connectionId}/health`);
|
|
57
|
+
return text(health, `${health.healthy ? 'healthy' : 'needs attention'} - deliveries=${health.webhookCount} last=${health.lastWebhookAt ?? 'never'} linked=${health.linkedIssues} open=${health.openLinkedIssues}${health.lastError ? ` error=${health.lastError}` : ''}`);
|
|
58
|
+
}
|
|
59
|
+
if (args.action === 'update') {
|
|
60
|
+
need('triageMode');
|
|
61
|
+
const row = await client.patch(`/projects/${project.id}/sentry-connections/${args.connectionId}`, { triageMode: args.triageMode });
|
|
62
|
+
return text({ connection: row }, `Triage mode is now ${row.triageMode}.\n${line(row)}`);
|
|
63
|
+
}
|
|
64
|
+
if (args.action === 'rotate_secret') {
|
|
65
|
+
need('clientSecret');
|
|
66
|
+
const row = await client.post(`/projects/${project.id}/sentry-connections/${args.connectionId}/rotate-secret`, { clientSecret: args.clientSecret });
|
|
67
|
+
return text({ connection: row }, `Client secret replaced on ${row.id}; deliveries signed with the old secret are rejected from now on.`);
|
|
68
|
+
}
|
|
69
|
+
if (args.action === 'disconnect') {
|
|
70
|
+
await client.delete(`/projects/${project.id}/sentry-connections/${args.connectionId}`);
|
|
71
|
+
return text({ deleted: true, connectionId: args.connectionId }, `Disconnected ${args.connectionId} from ${project.key}.`);
|
|
72
|
+
}
|
|
73
|
+
if (args.action === 'triage_lane') {
|
|
74
|
+
const out = await client.put(`/projects/${project.id}/sentry-connections/${args.connectionId}/triage-lane`, { laneId: args.laneId || null });
|
|
75
|
+
return text(out, out.lane ? `Lane ${out.lane.name} bound (assignment v${out.lane.assignmentVersion}); mandate follows mode ${out.connection.triageMode}.` : 'Triage lane unbound; the mandate was withdrawn.');
|
|
76
|
+
}
|
|
77
|
+
need('ticketKey');
|
|
78
|
+
need('verdict');
|
|
79
|
+
need('summary');
|
|
80
|
+
const ticket = await resolveTicketByKey(client, args.ticketKey);
|
|
81
|
+
const body = { verdict: args.verdict, summary: args.summary, agentSessionToken: mcpInstanceToken() };
|
|
82
|
+
const result = await client.post(`/projects/${project.id}/sentry-connections/${args.connectionId}/triage/${ticket.id}/verdict`, body);
|
|
83
|
+
return text(result, `${result.ticketKey ?? ticket.id}: ${result.verdict} (${result.mode}) priority=${result.priority ?? '-'}${result.closed ? ' closed' : ''}${result.escalated ? ' escalated' : ''}${result.branchName ? ` branch=${result.branchName} session=${result.workSessionId}` : ''} next=${result.nextStep}`);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-2123 - `orboto_sentry` handler tests: every action hits the right
|
|
3
|
+
* route, secrets never come back in the text, and a missing field fails
|
|
4
|
+
* before any write.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
8
|
+
import { makeSentryHandler, sentryToolConfig } from './sentry.js';
|
|
9
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
10
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
11
|
+
function stub(responses) {
|
|
12
|
+
const calls = [];
|
|
13
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
14
|
+
const u = url.toString();
|
|
15
|
+
const m = init?.method ?? 'GET';
|
|
16
|
+
const b = init?.body ? JSON.parse(init.body) : undefined;
|
|
17
|
+
calls.push({ url: u, method: m, body: b });
|
|
18
|
+
const r = responses.shift();
|
|
19
|
+
if (!r)
|
|
20
|
+
throw new Error(`unexpected extra fetch ${m} ${u}`);
|
|
21
|
+
return {
|
|
22
|
+
ok: r.ok ?? true,
|
|
23
|
+
status: r.status ?? 200,
|
|
24
|
+
statusText: r.ok === false ? 'Error' : 'OK',
|
|
25
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
26
|
+
text: async () => JSON.stringify('json' in r ? r.json : {}),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
return calls;
|
|
30
|
+
}
|
|
31
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
32
|
+
const project = { id: '11111111-1111-4111-8111-111111111111', key: 'TEN', name: '10monkeys' };
|
|
33
|
+
const connectionId = '22222222-2222-4222-8222-222222222222';
|
|
34
|
+
const connection = {
|
|
35
|
+
id: connectionId, orgSlug: 'edyoutec', projectSlug: '10monkeys-next', enabled: true, triageMode: 'comment', hasAuthToken: false,
|
|
36
|
+
webhookUrl: 'https://orboto.example.com/inbound/sentry/' + connectionId, lastWebhookAt: null, lastSignatureOk: null, lastError: null, webhookCount: 0,
|
|
37
|
+
triageLaneId: null, dailyTriageLimit: null, escalationUserId: null,
|
|
38
|
+
};
|
|
39
|
+
describe('orboto_sentry', () => {
|
|
40
|
+
it('declares a short title and explicit annotations', () => {
|
|
41
|
+
expect(sentryToolConfig.title.length).toBeLessThanOrEqual(64);
|
|
42
|
+
expect(sentryToolConfig.annotations).toEqual({ readOnlyHint: false, destructiveHint: true, idempotentHint: false });
|
|
43
|
+
});
|
|
44
|
+
it('lists connections with their webhook URL and health', async () => {
|
|
45
|
+
stub([{ json: project }, { json: [connection] }]);
|
|
46
|
+
const out = await makeSentryHandler(client)({ action: 'list', projectKey: 'TEN' });
|
|
47
|
+
const text = out.content[0].text;
|
|
48
|
+
expect(text).toContain('edyoutec/10monkeys-next mode=comment enabled token=no');
|
|
49
|
+
expect(text).toContain(connection.webhookUrl);
|
|
50
|
+
expect(out.structuredContent).toEqual({ connections: [connection] });
|
|
51
|
+
});
|
|
52
|
+
it('connects with the client secret in the body and never in the text', async () => {
|
|
53
|
+
const calls = stub([{ json: project }, { json: { ...connection, triageMode: 'fix_branch', webhookUrlWarning: { code: 'webhook_url_from_request', message: 'set API_PUBLIC_URL' } } }]);
|
|
54
|
+
const out = await makeSentryHandler(client)({ action: 'connect', projectKey: 'TEN', orgSlug: 'edyoutec', projectSlug: '10monkeys-next', clientSecret: 'sec_value_123', triageMode: 'fix_branch' });
|
|
55
|
+
expect(calls[1]).toMatchObject({ method: 'POST', url: expect.stringContaining(`/projects/${project.id}/sentry-connections`), body: { orgSlug: 'edyoutec', projectSlug: '10monkeys-next', clientSecret: 'sec_value_123', triageMode: 'fix_branch' } });
|
|
56
|
+
const text = out.content[0].text;
|
|
57
|
+
expect(text).toContain('Connected edyoutec/10monkeys-next to TEN');
|
|
58
|
+
expect(text).toContain('Warning: set API_PUBLIC_URL');
|
|
59
|
+
expect(text).not.toContain('sec_value_123');
|
|
60
|
+
expect(JSON.stringify(out.structuredContent)).not.toContain('sec_value_123');
|
|
61
|
+
});
|
|
62
|
+
it('refuses a connect without the secret before any write', async () => {
|
|
63
|
+
const calls = stub([{ json: project }]);
|
|
64
|
+
await expect(makeSentryHandler(client)({ action: 'connect', projectKey: 'TEN', orgSlug: 'edyoutec', projectSlug: 'p' })).rejects.toThrow('connect needs clientSecret.');
|
|
65
|
+
expect(calls).toHaveLength(1);
|
|
66
|
+
});
|
|
67
|
+
it('routes health, update, rotate_secret, disconnect and triage_lane to their endpoints', async () => {
|
|
68
|
+
const calls = stub([
|
|
69
|
+
{ json: project }, { json: { healthy: true, lastWebhookAt: '2026-09-14T08:00:00Z', lastError: null, linkedIssues: 3, openLinkedIssues: 1, webhookCount: 9 } },
|
|
70
|
+
{ json: project }, { json: { ...connection, triageMode: 'fix_branch' } },
|
|
71
|
+
{ json: project }, { json: connection },
|
|
72
|
+
{ json: project }, { json: { deleted: true } },
|
|
73
|
+
{ json: project }, { json: { connection, lane: { id: 'l1', name: 'sentry-triage-1', assignmentVersion: 3 } } },
|
|
74
|
+
{ json: project }, { json: { connection, lane: null } },
|
|
75
|
+
]);
|
|
76
|
+
const h = makeSentryHandler(client);
|
|
77
|
+
expect((await h({ action: 'health', projectKey: 'TEN', connectionId })).content[0]).toMatchObject({ text: expect.stringContaining('healthy - deliveries=9') });
|
|
78
|
+
expect((await h({ action: 'update', projectKey: 'TEN', connectionId, triageMode: 'fix_branch' })).content[0]).toMatchObject({ text: expect.stringContaining('Triage mode is now fix_branch') });
|
|
79
|
+
expect((await h({ action: 'rotate_secret', projectKey: 'TEN', connectionId, clientSecret: 'new_secret_value' })).content[0]).toMatchObject({ text: expect.not.stringContaining('new_secret_value') });
|
|
80
|
+
expect((await h({ action: 'disconnect', projectKey: 'TEN', connectionId })).structuredContent).toEqual({ deleted: true, connectionId });
|
|
81
|
+
expect((await h({ action: 'triage_lane', projectKey: 'TEN', connectionId, laneId: 'l1' })).content[0]).toMatchObject({ text: expect.stringContaining('Lane sentry-triage-1 bound (assignment v3)') });
|
|
82
|
+
expect((await h({ action: 'triage_lane', projectKey: 'TEN', connectionId })).content[0]).toMatchObject({ text: expect.stringContaining('unbound') });
|
|
83
|
+
expect(calls.map((c) => `${c.method} ${c.url.replace('https://orboto.example.com', '')}`)).toEqual([
|
|
84
|
+
'GET /projects/by-key/TEN', `GET /projects/${project.id}/sentry-connections/${connectionId}/health`,
|
|
85
|
+
'GET /projects/by-key/TEN', `PATCH /projects/${project.id}/sentry-connections/${connectionId}`,
|
|
86
|
+
'GET /projects/by-key/TEN', `POST /projects/${project.id}/sentry-connections/${connectionId}/rotate-secret`,
|
|
87
|
+
'GET /projects/by-key/TEN', `DELETE /projects/${project.id}/sentry-connections/${connectionId}`,
|
|
88
|
+
'GET /projects/by-key/TEN', `PUT /projects/${project.id}/sentry-connections/${connectionId}/triage-lane`,
|
|
89
|
+
'GET /projects/by-key/TEN', `PUT /projects/${project.id}/sentry-connections/${connectionId}/triage-lane`,
|
|
90
|
+
]);
|
|
91
|
+
expect(calls[9].body).toEqual({ laneId: 'l1' });
|
|
92
|
+
expect(calls[11].body).toEqual({ laneId: null });
|
|
93
|
+
});
|
|
94
|
+
it('records a verdict on the ticket resolved by key and reports the hand-off', async () => {
|
|
95
|
+
const calls = stub([
|
|
96
|
+
{ json: project },
|
|
97
|
+
{ json: project },
|
|
98
|
+
{ json: { id: '33333333-3333-4333-8333-333333333333', ticketKey: 'TEN-7', title: 'x' } },
|
|
99
|
+
{ json: { ticketKey: 'TEN-7', verdict: 'new', mode: 'fix_branch', closed: false, escalated: false, branchName: 'sentry/ten-7-fix', workSessionId: 'ws1', nextStep: 'implement_then_finish', priority: 'high' } },
|
|
100
|
+
]);
|
|
101
|
+
const out = await makeSentryHandler(client)({ action: 'verdict', projectKey: 'TEN', connectionId, ticketKey: 'TEN-7', verdict: 'new', summary: 'Unguarded read of session.cart.' });
|
|
102
|
+
expect(calls[3]).toMatchObject({ method: 'POST', url: expect.stringContaining(`/sentry-connections/${connectionId}/triage/33333333-3333-4333-8333-333333333333/verdict`) });
|
|
103
|
+
expect(calls[3].body).toMatchObject({ verdict: 'new', summary: 'Unguarded read of session.cart.' });
|
|
104
|
+
expect(typeof calls[3].body.agentSessionToken).toBe('string');
|
|
105
|
+
expect(out.content[0].text).toBe('TEN-7: new (fix_branch) priority=high branch=sentry/ten-7-fix session=ws1 next=implement_then_finish');
|
|
106
|
+
});
|
|
107
|
+
});
|
package/dist/tools/shared.js
CHANGED
|
@@ -27,6 +27,8 @@ export function agentTicketListRow(t, verbose = false) {
|
|
|
27
27
|
title: t.title,
|
|
28
28
|
status: t.statusName ?? t.status,
|
|
29
29
|
statusCategory: t.statusCategory ?? null,
|
|
30
|
+
specState: t.specState ?? 'none',
|
|
31
|
+
...specReleaseInfo(t),
|
|
30
32
|
priority: t.priority,
|
|
31
33
|
type: t.type,
|
|
32
34
|
dueDate: t.dueDate ?? null,
|
|
@@ -43,8 +45,10 @@ export function agentTicketListRow(t, verbose = false) {
|
|
|
43
45
|
const assigneeNames = (t.assignees ?? []).map((a) => a.fullName || a.email);
|
|
44
46
|
return {
|
|
45
47
|
key: t.ticketKey,
|
|
48
|
+
...specReleaseInfo(t),
|
|
46
49
|
title: t.title,
|
|
47
50
|
statusCategory: t.statusCategory ?? null,
|
|
51
|
+
...(t.specState && t.specState !== 'none' ? { specState: t.specState } : {}),
|
|
48
52
|
...(t.priority && t.priority !== 'normal' ? { priority: t.priority } : {}),
|
|
49
53
|
...(t.type && t.type !== 'task' ? { type: t.type } : {}),
|
|
50
54
|
...(t.dueDate ? { dueDate: t.dueDate } : {}),
|
|
@@ -183,3 +187,8 @@ export function resolveByName(candidates, ref, getName) {
|
|
|
183
187
|
return { match: null, ambiguous: normMatches };
|
|
184
188
|
return { match: null, ambiguous: null };
|
|
185
189
|
}
|
|
190
|
+
export function specReleaseInfo(t) {
|
|
191
|
+
if (t.specReleasedBy === undefined)
|
|
192
|
+
return {};
|
|
193
|
+
return { specReleasedBy: t.specReleasedBy, specReleasedByFullName: t.specReleasedByFullName ?? null, specReleasedAt: t.specReleasedAt ?? null, specReleasePolicy: t.specReleasePolicy ?? null, specReleaseRole: t.specReleaseRole ?? null };
|
|
194
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { resolveTicketByKey } from './shared.js';
|
|
3
|
+
export const specCheckToolConfig = {
|
|
4
|
+
title: 'Check build order',
|
|
5
|
+
description: 'Validate a build order. Returns issues, template, human authorId and releaseBy policy. Release permission is checked separately.',
|
|
6
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
7
|
+
inputSchema: z.object({ ticketKey: z.string().min(1) }),
|
|
8
|
+
};
|
|
9
|
+
export function makeSpecCheckHandler(client) {
|
|
10
|
+
return async (input) => {
|
|
11
|
+
const ticket = await resolveTicketByKey(client, input.ticketKey);
|
|
12
|
+
const result = await client.get(`/projects/${ticket.projectId}/tickets/${ticket.id}/spec-check`);
|
|
13
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OrbotoClient, OrbotoApiError } from '../orboto-client.js';
|
|
3
|
+
import { makeSpecCheckHandler, specCheckToolConfig } from './spec-check.js';
|
|
4
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
5
|
+
describe('spec check', () => {
|
|
6
|
+
it('returns the authoritative diagnostics and template without writes', async () => {
|
|
7
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.test', apiKey: 'test' });
|
|
8
|
+
const report = { authorId: 'human-author', releaseBy: 'author', check: { valid: false, criteriaCount: 0, openQuestionCount: 2, missingHeadings: ['Goal'] }, template: 'template' };
|
|
9
|
+
const get = vi.spyOn(client, 'get').mockResolvedValueOnce({ id: 'p', key: 'ORB' }).mockResolvedValueOnce({ id: 't', projectId: 'p' }).mockResolvedValueOnce(report);
|
|
10
|
+
const post = vi.spyOn(client, 'post');
|
|
11
|
+
const patch = vi.spyOn(client, 'patch');
|
|
12
|
+
expect(await makeSpecCheckHandler(client)({ ticketKey: 'ORB-2099' })).toMatchObject({ structuredContent: report });
|
|
13
|
+
expect(get).toHaveBeenLastCalledWith('/projects/p/tickets/t/spec-check');
|
|
14
|
+
expect(post).not.toHaveBeenCalled();
|
|
15
|
+
expect(patch).not.toHaveBeenCalled();
|
|
16
|
+
expect(specCheckToolConfig.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
|
|
17
|
+
});
|
|
18
|
+
it('propagates read authorization errors', async () => {
|
|
19
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.test', apiKey: 'test' });
|
|
20
|
+
vi.spyOn(client, 'get').mockResolvedValueOnce({ id: 'p', key: 'ORB' }).mockResolvedValueOnce({ id: 't', projectId: 'p' }).mockRejectedValueOnce(new OrbotoApiError(404, '{}', 'test'));
|
|
21
|
+
await expect(makeSpecCheckHandler(client)({ ticketKey: 'ORB-2099' })).rejects.toMatchObject({ status: 404 });
|
|
22
|
+
});
|
|
23
|
+
});
|