@opsee/mcp-server 0.11.6 → 0.11.9
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/bin/opsee-mcp.js +0 -0
- package/gen/api/v1/initiative_pb.d.ts +822 -1
- package/gen/api/v1/initiative_pb.js +142 -2
- package/gen/api/v1/readiness_pb.d.ts +65 -0
- package/gen/api/v1/readiness_pb.js +37 -29
- package/package.json +1 -1
- package/src/auth/renew.ts +14 -8
- package/src/client/api.ts +49 -42
- package/src/client/marker.ts +17 -0
- package/src/server.ts +2 -0
- package/src/tools/foreman.ts +371 -0
- package/src/utils/defect.ts +264 -0
- package/src/utils/format/index.ts +1 -0
- package/src/utils/format/initiative.ts +16 -1
- package/src/utils/format/run-record.ts +302 -0
- package/src/utils/text.ts +25 -0
package/src/client/api.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { create } from "@bufbuild/protobuf";
|
|
|
4
4
|
import type { GenService } from "@bufbuild/protobuf/codegenv1";
|
|
5
5
|
import { authManager } from "../auth/manager.js";
|
|
6
6
|
import { getCurrentToken } from "../auth/token-context.js";
|
|
7
|
+
import { clientInterceptor, MCP_CLIENT_MARKER } from "./marker.js";
|
|
7
8
|
import {
|
|
8
9
|
PaginationSchema,
|
|
9
10
|
type Pagination,
|
|
@@ -40,9 +41,6 @@ const authInterceptor: Interceptor = (next) => async (req) => {
|
|
|
40
41
|
if (token) {
|
|
41
42
|
req.header.set("Authorization", `Bearer ${token}`);
|
|
42
43
|
}
|
|
43
|
-
// Marks an automation client so board events carry the system actor: an agent using a user's token
|
|
44
|
-
// must not be self-skipped on that user's board (ADR-0002 §5).
|
|
45
|
-
req.header.set("X-Opsee-Client", "mcp");
|
|
46
44
|
return await next(req);
|
|
47
45
|
};
|
|
48
46
|
|
|
@@ -77,23 +75,21 @@ export function defaultPagination(
|
|
|
77
75
|
});
|
|
78
76
|
}
|
|
79
77
|
|
|
80
|
-
let transport: ReturnType<typeof createConnectTransport> | null = null;
|
|
81
78
|
let cachedClients: ApiClients | null = null;
|
|
82
79
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
baseUrl: authManager.getServerUrl(),
|
|
87
|
-
httpVersion: "1.1",
|
|
88
|
-
interceptors: [authInterceptor, paginationInterceptor],
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
return transport;
|
|
80
|
+
export interface ClientOptions {
|
|
81
|
+
/** Value sent as X-Opsee-Client; the MCP server's own clients send `mcp` (the default). */
|
|
82
|
+
marker?: string;
|
|
92
83
|
}
|
|
93
84
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
85
|
+
function makeTransport({ marker = MCP_CLIENT_MARKER }: ClientOptions) {
|
|
86
|
+
return createConnectTransport({
|
|
87
|
+
baseUrl: authManager.getServerUrl(),
|
|
88
|
+
httpVersion: "1.1",
|
|
89
|
+
// The marker tells the backend this is an automation client acting with a user's token, so board
|
|
90
|
+
// events carry the system actor and are not self-skipped on that user's board (ADR-0002 §5).
|
|
91
|
+
interceptors: [authInterceptor, clientInterceptor(marker), paginationInterceptor],
|
|
92
|
+
});
|
|
97
93
|
}
|
|
98
94
|
|
|
99
95
|
export type ApiClients = {
|
|
@@ -123,34 +119,45 @@ export type ApiClients = {
|
|
|
123
119
|
milestoneDocPages: Client<typeof MilestoneDocPageService>;
|
|
124
120
|
};
|
|
125
121
|
|
|
122
|
+
/** Builds a fresh client set over its own transport. Another client (the `opsee` CLI) passes its
|
|
123
|
+
* own marker; the MCP server itself uses the cached `getClients()`. */
|
|
124
|
+
export function createClients(options: ClientOptions = {}): ApiClients {
|
|
125
|
+
const transport = makeTransport(options);
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
const makeClient = <T extends GenService<any>>(service: T): Client<T> =>
|
|
128
|
+
createClient(service, transport);
|
|
129
|
+
return {
|
|
130
|
+
projects: makeClient(ProjectService),
|
|
131
|
+
tasks: makeClient(TaskService),
|
|
132
|
+
boards: makeClient(BoardService),
|
|
133
|
+
boardColumns: makeClient(BoardColumnService),
|
|
134
|
+
cycles: makeClient(CycleService),
|
|
135
|
+
docSpaces: makeClient(DocSpaceService),
|
|
136
|
+
docPages: makeClient(DocPageService),
|
|
137
|
+
docPageVersions: makeClient(DocPageVersionService),
|
|
138
|
+
taskTypes: makeClient(TaskTypeService),
|
|
139
|
+
taskPriorities: makeClient(TaskPriorityService),
|
|
140
|
+
users: makeClient(UserService),
|
|
141
|
+
vcsIntegrations: makeClient(VCSIntegrationService),
|
|
142
|
+
milestones: makeClient(MilestoneService),
|
|
143
|
+
milestoneTasks: makeClient(MilestoneTaskService),
|
|
144
|
+
labels: makeClient(LabelService),
|
|
145
|
+
taskLabels: makeClient(TaskLabelService),
|
|
146
|
+
comments: makeClient(CommentService),
|
|
147
|
+
taskDependencies: makeClient(TaskDependencyService),
|
|
148
|
+
notifications: makeClient(NotificationService),
|
|
149
|
+
acceptanceCriteria: makeClient(AcceptanceCriterionService),
|
|
150
|
+
initiatives: makeClient(InitiativeService),
|
|
151
|
+
docTemplates: makeClient(DocTemplateService),
|
|
152
|
+
taskDocPages: makeClient(TaskDocPageService),
|
|
153
|
+
milestoneDocPages: makeClient(MilestoneDocPageService),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The MCP server's clients, stamped `mcp`. */
|
|
126
158
|
export function getClients(): ApiClients {
|
|
127
159
|
if (!cachedClients) {
|
|
128
|
-
cachedClients =
|
|
129
|
-
projects: makeClient(ProjectService),
|
|
130
|
-
tasks: makeClient(TaskService),
|
|
131
|
-
boards: makeClient(BoardService),
|
|
132
|
-
boardColumns: makeClient(BoardColumnService),
|
|
133
|
-
cycles: makeClient(CycleService),
|
|
134
|
-
docSpaces: makeClient(DocSpaceService),
|
|
135
|
-
docPages: makeClient(DocPageService),
|
|
136
|
-
docPageVersions: makeClient(DocPageVersionService),
|
|
137
|
-
taskTypes: makeClient(TaskTypeService),
|
|
138
|
-
taskPriorities: makeClient(TaskPriorityService),
|
|
139
|
-
users: makeClient(UserService),
|
|
140
|
-
vcsIntegrations: makeClient(VCSIntegrationService),
|
|
141
|
-
milestones: makeClient(MilestoneService),
|
|
142
|
-
milestoneTasks: makeClient(MilestoneTaskService),
|
|
143
|
-
labels: makeClient(LabelService),
|
|
144
|
-
taskLabels: makeClient(TaskLabelService),
|
|
145
|
-
comments: makeClient(CommentService),
|
|
146
|
-
taskDependencies: makeClient(TaskDependencyService),
|
|
147
|
-
notifications: makeClient(NotificationService),
|
|
148
|
-
acceptanceCriteria: makeClient(AcceptanceCriterionService),
|
|
149
|
-
initiatives: makeClient(InitiativeService),
|
|
150
|
-
docTemplates: makeClient(DocTemplateService),
|
|
151
|
-
taskDocPages: makeClient(TaskDocPageService),
|
|
152
|
-
milestoneDocPages: makeClient(MilestoneDocPageService),
|
|
153
|
-
};
|
|
160
|
+
cachedClients = createClients();
|
|
154
161
|
}
|
|
155
162
|
return cachedClients;
|
|
156
163
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Interceptor } from "@connectrpc/connect";
|
|
2
|
+
|
|
3
|
+
/** Request header that names the calling client. The backend reads it as the client origin: an
|
|
4
|
+
* automation client (the MCP server, the `opsee` CLI) using a user's token must not be
|
|
5
|
+
* self-skipped on that user's board, and Run Record events it appends are marked agent-authored. */
|
|
6
|
+
export const CLIENT_HEADER = "X-Opsee-Client";
|
|
7
|
+
|
|
8
|
+
/** What the MCP server sends; the CLI sends `cli`. */
|
|
9
|
+
export const MCP_CLIENT_MARKER = "mcp";
|
|
10
|
+
|
|
11
|
+
/** Stamps every request with the given client marker. */
|
|
12
|
+
export function clientInterceptor(marker: string): Interceptor {
|
|
13
|
+
return (next) => async (req) => {
|
|
14
|
+
req.header.set(CLIENT_HEADER, marker);
|
|
15
|
+
return await next(req);
|
|
16
|
+
};
|
|
17
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { registerInitiativeTools } from "./tools/initiatives.js";
|
|
|
18
18
|
import { registerDocSpaceWriteTools } from "./tools/doc-spaces.js";
|
|
19
19
|
import { registerDocTemplateTools } from "./tools/doc-templates.js";
|
|
20
20
|
import { registerDocLinkTools } from "./tools/doc-links.js";
|
|
21
|
+
import { registerForemanTools } from "./tools/foreman.js";
|
|
21
22
|
import { compactToolDefinitions } from "./utils/tool-schema.js";
|
|
22
23
|
|
|
23
24
|
export function createServer(clientFactory?: () => ApiClients): McpServer {
|
|
@@ -46,6 +47,7 @@ export function createServer(clientFactory?: () => ApiClients): McpServer {
|
|
|
46
47
|
registerDocSpaceWriteTools(server, factory);
|
|
47
48
|
registerDocTemplateTools(server, factory);
|
|
48
49
|
registerDocLinkTools(server, factory);
|
|
50
|
+
registerForemanTools(server, factory);
|
|
49
51
|
|
|
50
52
|
compactToolDefinitions(server);
|
|
51
53
|
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two tools that join a planning session to the Foreman (spec stories 70 and 71).
|
|
3
|
+
*
|
|
4
|
+
* `opsee_read_run_record` reads what the Foreman wrote overnight, so a morning session can ask what
|
|
5
|
+
* happened before it re-plans. `opsee_file_defect` writes the other way: an attended session that
|
|
6
|
+
* verified something by hand files its finding as the same Task the unattended Verifier would have
|
|
7
|
+
* filed, so the ordinary dispatch loop schedules the fix.
|
|
8
|
+
*
|
|
9
|
+
* The Defect Task's shape is not defined here. It comes from ../utils/defect.js, which the CLI's
|
|
10
|
+
* own filer imports too: one implementation, so the two are indistinguishable to the Worker that
|
|
11
|
+
* picks the Task up and to the Foreman's own duplicate check, which reads the `[defect:<key>]`
|
|
12
|
+
* token this tool writes.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import type { ApiClients } from "../client/api.js";
|
|
17
|
+
import { defaultPagination, overridePagination } from "../client/api.js";
|
|
18
|
+
import { TaskDependencyType, type Task } from "../../gen/api/v1/models_pb.js";
|
|
19
|
+
import type { RunEvent } from "../../gen/api/v1/initiative_pb.js";
|
|
20
|
+
import { formatError } from "../utils/format.js";
|
|
21
|
+
import { EVENT_BUDGET, eventTime, formatRunEvent, formatRunRecord, RUN_EVENT_KINDS, type RunRecordPage } from "../utils/format/run-record.js";
|
|
22
|
+
import {
|
|
23
|
+
ATTENDED_AUTHOR,
|
|
24
|
+
DEFECT_COMMENT_MARKER,
|
|
25
|
+
DEFECT_LABEL,
|
|
26
|
+
DEFECT_TASK_TYPE,
|
|
27
|
+
DISPATCH_LABEL,
|
|
28
|
+
defectKey,
|
|
29
|
+
defectKeysInComments,
|
|
30
|
+
defectTaskDescription,
|
|
31
|
+
defectTaskTitle,
|
|
32
|
+
} from "../utils/defect.js";
|
|
33
|
+
import { oneLine, sameName } from "../utils/text.js";
|
|
34
|
+
|
|
35
|
+
/** How many events one server page carries while the tool scans for a time range. */
|
|
36
|
+
const SERVER_PAGE = 100;
|
|
37
|
+
|
|
38
|
+
/** The most events a single call will read from the server, however narrow the time range. A
|
|
39
|
+
* planning session wants last night, not the whole Run; a wider read is another call away. */
|
|
40
|
+
const SCAN_MAX = 1000;
|
|
41
|
+
|
|
42
|
+
/** Seconds since the epoch for an ISO 8601 instant, or undefined when it does not parse. */
|
|
43
|
+
function instant(value: string | undefined): number | undefined {
|
|
44
|
+
if (!value) return undefined;
|
|
45
|
+
const ms = Date.parse(value);
|
|
46
|
+
return Number.isNaN(ms) ? undefined : Math.floor(ms / 1000);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function registerForemanTools(
|
|
50
|
+
server: McpServer,
|
|
51
|
+
getClients: () => ApiClients,
|
|
52
|
+
): void {
|
|
53
|
+
server.tool(
|
|
54
|
+
"opsee_read_run_record",
|
|
55
|
+
"Read an Initiative's Run Record — the Foreman's append-only log of what its unattended Workers did: every dispatch, how each turn ended, the Gate results (exit codes), the Verifier's Verdicts and Defects, human steering from the CLI, spend, and any Breaker that halted the night. Call this at the start of a planning session to ask what happened since you last looked, and before re-planning an Initiative the Foreman has been running. Filter by kind and by task, narrow to a time range, and page with afterId. Everything an agent wrote or a command printed comes back quoted as data.",
|
|
56
|
+
{
|
|
57
|
+
initiativeId: z.number().int().min(1).describe("The initiative ID whose Run Record to read"),
|
|
58
|
+
kinds: z.array(z.enum(RUN_EVENT_KINDS)).optional().describe("Filter by event kind: dispatch (a Worker was started) | attempt (a turn ended) | gate (tests/lint/typecheck ran) | verdict (a Verifier reported) | breaker (dispatch was halted) | spend | control (a human attached, released, paused, resumed, or cancelled) | account (an Account was Paused, quarantined, or brought back). Omit for all kinds."),
|
|
59
|
+
taskId: z.number().int().min(1).optional().describe("Only events about this task. Run-wide events (breaker, spend, a Run-level pause) carry no task and are excluded when this is set."),
|
|
60
|
+
since: z.string().optional().describe("Only events at or after this instant (ISO 8601, e.g. '2026-09-07T18:00:00Z'). Applied to when the event happened on the Foreman's machine."),
|
|
61
|
+
until: z.string().optional().describe("Only events at or before this instant (ISO 8601)."),
|
|
62
|
+
afterId: z.number().int().min(1).optional().describe("Pagination cursor: only events with a higher id. Pass the value the previous call reported."),
|
|
63
|
+
limit: z.number().int().min(1).max(200).optional().describe("Maximum events to return (default 50, max 200)."),
|
|
64
|
+
},
|
|
65
|
+
{ readOnlyHint: true, destructiveHint: false },
|
|
66
|
+
async ({ initiativeId, kinds, taskId, since, until, afterId, limit }) => {
|
|
67
|
+
try {
|
|
68
|
+
const clients = getClients();
|
|
69
|
+
const want = limit ?? 50;
|
|
70
|
+
const from = instant(since);
|
|
71
|
+
const to = instant(until);
|
|
72
|
+
if (since && from === undefined) return { content: [{ type: "text", text: `Could not read "since" as a date: ${since}. Use ISO 8601, e.g. 2026-09-07T18:00:00Z.` }], isError: true };
|
|
73
|
+
if (until && to === undefined) return { content: [{ type: "text", text: `Could not read "until" as a date: ${until}. Use ISO 8601, e.g. 2026-09-08T09:00:00Z.` }], isError: true };
|
|
74
|
+
|
|
75
|
+
const page: RunRecordPage = { events: [], filters: [], scanned: 0 };
|
|
76
|
+
// `kinds` is a zod enum and the ids are numbers, so only the two instants are free text —
|
|
77
|
+
// echoed on one line, as everything outside a quoted block in this output is.
|
|
78
|
+
if (kinds?.length) page.filters.push(`kinds=${kinds.join(",")}`);
|
|
79
|
+
if (taskId) page.filters.push(`task=#${taskId}`);
|
|
80
|
+
if (since) page.filters.push(`since=${oneLine(since)}`);
|
|
81
|
+
if (until) page.filters.push(`until=${oneLine(until)}`);
|
|
82
|
+
if (afterId) page.filters.push(`afterId=${afterId}`);
|
|
83
|
+
|
|
84
|
+
// The backend filters by kind, task and cursor; it has no time filter, so a time range is
|
|
85
|
+
// applied here while paging. Bounded by SCAN_MAX so a narrow range over a long Run cannot
|
|
86
|
+
// walk the whole Record in one call, and by EVENT_BUDGET so one call cannot return
|
|
87
|
+
// megabytes into a planning session's context.
|
|
88
|
+
let cursor = afterId;
|
|
89
|
+
let atEnd = false;
|
|
90
|
+
let used = 0;
|
|
91
|
+
scan: while (page.events.length < want && page.scanned < SCAN_MAX) {
|
|
92
|
+
const res = await clients.initiatives.getRunEvents({
|
|
93
|
+
initiativeId,
|
|
94
|
+
pagination: defaultPagination(1, SERVER_PAGE),
|
|
95
|
+
kinds: kinds ?? [],
|
|
96
|
+
taskId,
|
|
97
|
+
afterId: cursor,
|
|
98
|
+
});
|
|
99
|
+
page.run ??= res.run;
|
|
100
|
+
if (res.events.length === 0) {
|
|
101
|
+
atEnd = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
for (const event of res.events) {
|
|
105
|
+
// Every exit below leaves `scanned` and `cursor` covering exactly what this page
|
|
106
|
+
// consumed, so the next call resumes at the first event the caller has not been shown.
|
|
107
|
+
if (page.events.length >= want) break scan;
|
|
108
|
+
const at = eventTime(event);
|
|
109
|
+
// The Record is appended in id order, so the first event past `until` is the end of the
|
|
110
|
+
// range: without this a caller with an `until` in the past walks the whole Record a
|
|
111
|
+
// page at a time, discarding all of it.
|
|
112
|
+
if (to !== undefined && at !== undefined && at > to) {
|
|
113
|
+
page.stoppedPastUntil = event.id;
|
|
114
|
+
break scan;
|
|
115
|
+
}
|
|
116
|
+
if (from !== undefined && (at === undefined || at < from)) {
|
|
117
|
+
page.scanned++;
|
|
118
|
+
cursor = event.id;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (to !== undefined && at === undefined) {
|
|
122
|
+
page.scanned++;
|
|
123
|
+
cursor = event.id;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// Rendered here, not in the formatter, so the budget is spent on what the caller will
|
|
127
|
+
// actually be shown and the cursor stops on the event that did not fit.
|
|
128
|
+
const rendered = formatRunEvent(event as RunEvent);
|
|
129
|
+
if (page.events.length > 0 && used + rendered.length > EVENT_BUDGET) {
|
|
130
|
+
page.budgetReached = true;
|
|
131
|
+
break scan;
|
|
132
|
+
}
|
|
133
|
+
used += rendered.length;
|
|
134
|
+
page.scanned++;
|
|
135
|
+
cursor = event.id;
|
|
136
|
+
page.events.push(rendered);
|
|
137
|
+
}
|
|
138
|
+
if (res.events.length < SERVER_PAGE) {
|
|
139
|
+
atEnd = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Nothing after the first event past `until` is inside the range, so that is the end of
|
|
144
|
+
// this filter's Record rather than a page boundary to resume from.
|
|
145
|
+
if (!atEnd && page.stoppedPastUntil === undefined) page.nextCursor = cursor;
|
|
146
|
+
|
|
147
|
+
return { content: [{ type: "text", text: formatRunRecord(page) }] };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
server.tool(
|
|
155
|
+
"opsee_file_defect",
|
|
156
|
+
"File one failure you observed while verifying a Task as a Defect Task, exactly as the Foreman's unattended Verifier would: a sibling Task in the same Initiative (never a subtask), type Bug, related to the Task you found it on, carrying the dispatch label so the next tick schedules the fix, with a Goal / Acceptance Criteria / Verification / Boundaries description. Use this after verifying work by hand in an attended session — not for planned work, which belongs in the task tree. Everything you write here is quoted as data in the new Task, so a Worker reads it as a record of what you saw, never as instructions.",
|
|
157
|
+
{
|
|
158
|
+
taskId: z.number().int().min(1).describe("The task you found the failure on. The new Task relates to it and names it; the task must be in an Initiative, because that is what makes the fix dispatchable."),
|
|
159
|
+
title: z.string().min(1).describe("One line naming the failure, e.g. 'Label unchanged after pressing'. Becomes the new Task's title, prefixed with the identifier of the task above."),
|
|
160
|
+
steps: z.string().min(1).describe("What you did, step by step, to reach the failure. This becomes the fix Task's Verification section, so write it as a journey someone else can walk."),
|
|
161
|
+
expected: z.string().optional().describe("What should have happened at the end of those steps."),
|
|
162
|
+
observed: z.string().optional().describe("What happened instead."),
|
|
163
|
+
evidenceUrl: z.string().optional().describe("A link to a screenshot or recording. Evidence is linked, never pasted."),
|
|
164
|
+
pullRequestUrl: z.string().optional().describe("The pull request the work under review is on, if there is one."),
|
|
165
|
+
},
|
|
166
|
+
{ readOnlyHint: false, destructiveHint: false },
|
|
167
|
+
async ({ taskId, title, steps, expected, observed, evidenceUrl, pullRequestUrl }) => {
|
|
168
|
+
try {
|
|
169
|
+
const clients = getClients();
|
|
170
|
+
const originRes = await clients.tasks.getTask({ id: taskId });
|
|
171
|
+
const origin = originRes.task;
|
|
172
|
+
if (!origin) return { content: [{ type: "text", text: `Task ${taskId} not found. Use opsee_list_tasks to find the task you verified.` }], isError: true };
|
|
173
|
+
const initiativeId = origin.initiativeId;
|
|
174
|
+
if (!initiativeId) {
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text", text: `Task ${origin.identifier} is not in an Initiative, so a Defect filed against it could never be dispatched. Link it with opsee_link_task_to_initiative first.` }],
|
|
177
|
+
isError: true,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const projectId = origin.project?.id ?? 0;
|
|
181
|
+
if (!projectId) return { content: [{ type: "text", text: `Task ${origin.identifier} carries no project, so its labels and type cannot be resolved.` }], isError: true };
|
|
182
|
+
|
|
183
|
+
// Idempotent, exactly as the Foreman's own filer is (cli/src/foreman/core/defects.ts
|
|
184
|
+
// `alreadyFiled`): the key is the title alone, and a Defect already named in a filing
|
|
185
|
+
// comment on this Task is not filed twice. Without this, two calls with the same Defect —
|
|
186
|
+
// a retry, a session that repeated itself — create two dispatchable Bug Tasks and the
|
|
187
|
+
// Foreman runs both. The key is computed before anything is created, so nothing is left
|
|
188
|
+
// half-written when the answer is "already filed".
|
|
189
|
+
const key = defectKey(title);
|
|
190
|
+
const filed = await clients.comments.getComments({ taskId: origin.id, pagination: overridePagination() });
|
|
191
|
+
const already = defectKeysInComments((filed.comments ?? []).map((c) => c.content ?? ""));
|
|
192
|
+
if (already.has(key)) {
|
|
193
|
+
return {
|
|
194
|
+
content: [{
|
|
195
|
+
type: "text",
|
|
196
|
+
text: [
|
|
197
|
+
`Already filed: a Defect with this title is recorded on ${origin.identifier} as [defect:${key}], so nothing was created.`,
|
|
198
|
+
"",
|
|
199
|
+
`The key is the title alone, so the same failure seen twice files one fix Task. Look for the Bug Task related to ${origin.identifier} — opsee_list_tasks with the "${DEFECT_LABEL}" label finds it — or file this under a different title if it really is a different failure.`,
|
|
200
|
+
].join("\n"),
|
|
201
|
+
}],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const defect = { title, steps, expected: expected ?? "", observed: observed ?? "" };
|
|
206
|
+
const description = defectTaskDescription({
|
|
207
|
+
origin: { identifier: origin.identifier, title: origin.title },
|
|
208
|
+
defect,
|
|
209
|
+
evidence: evidenceUrl ? { url: evidenceUrl } : undefined,
|
|
210
|
+
pullRequestUrl,
|
|
211
|
+
author: ATTENDED_AUTHOR,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Both labels must exist in the project before a Task can carry them.
|
|
215
|
+
const labelIds = await Promise.all([DISPATCH_LABEL, DEFECT_LABEL].map((name) => ensureLabel(clients, projectId, name)));
|
|
216
|
+
|
|
217
|
+
// Every field below is picked exactly as OpseeTrackerAdapter.createTask picks it for the
|
|
218
|
+
// Foreman's own filer (cli/src/foreman/opsee-tracker-adapter.ts `projectDefaults` and
|
|
219
|
+
// `pickNamed`), because a Defect filed here and a Defect filed by the unattended Verifier
|
|
220
|
+
// must be indistinguishable on the board: the active board else the first; the active
|
|
221
|
+
// columns in display order, the `todo` one else the first; the "Bug" type else the first;
|
|
222
|
+
// and the project's first priority — the adapter asks for no priority by name, and
|
|
223
|
+
// `pickNamed` with no name is `options[0]`. Not the origin's priority: that would make a
|
|
224
|
+
// Defect filed from a session sort differently from the same Defect filed by a Verdict.
|
|
225
|
+
const [boards, types, priorities] = await Promise.all([
|
|
226
|
+
clients.boards.getBoards({ projectId, pagination: overridePagination() }),
|
|
227
|
+
clients.taskTypes.getTaskTypes({ projectId, pagination: overridePagination() }),
|
|
228
|
+
clients.taskPriorities.getTaskPriorities({ projectId, pagination: overridePagination() }),
|
|
229
|
+
]);
|
|
230
|
+
const board = boards.boards.find((b) => b.isActive) ?? boards.boards[0];
|
|
231
|
+
if (!board) return { content: [{ type: "text", text: `Project ${projectId} has no board, so a Task cannot be created in it.` }], isError: true };
|
|
232
|
+
const columnsRes = await clients.boardColumns.getBoardColumns({ boardId: board.id, pagination: overridePagination() });
|
|
233
|
+
const columns = (columnsRes.boardColumns ?? []).filter((c) => c.isActive).sort((a, b) => a.displayOrder - b.displayOrder);
|
|
234
|
+
const landing = columns.find((c) => c.lifecycleState === "todo") ?? columns[0];
|
|
235
|
+
if (!landing) return { content: [{ type: "text", text: `Board ${board.id} has no active columns, so a Task cannot be created in it.` }], isError: true };
|
|
236
|
+
const bug = types.taskTypes.find((t) => sameName(t.name, DEFECT_TASK_TYPE)) ?? types.taskTypes[0];
|
|
237
|
+
if (!bug) return { content: [{ type: "text", text: `Project ${projectId} has no task types, so a Task cannot be created in it.` }], isError: true };
|
|
238
|
+
const priority = priorities.taskPriorities[0];
|
|
239
|
+
if (!priority) return { content: [{ type: "text", text: `Project ${projectId} has no priorities, so a Task cannot be created in it.` }], isError: true };
|
|
240
|
+
|
|
241
|
+
// The Foreman's filer reports the Task as created by whoever's token it runs under
|
|
242
|
+
// (`OpseeTrackerAdapter.me`), never by the origin's reporter; so does this.
|
|
243
|
+
const meRes = await clients.users.getMe({});
|
|
244
|
+
if (!meRes.user) return { content: [{ type: "text", text: "The backend returned no user for this token, so the Defect Task would have no reporter. Check the MCP server's credentials." }], isError: true };
|
|
245
|
+
|
|
246
|
+
const created = await clients.tasks.addTask({
|
|
247
|
+
projectId,
|
|
248
|
+
title: defectTaskTitle(origin.identifier, defect),
|
|
249
|
+
description,
|
|
250
|
+
boardId: board.id,
|
|
251
|
+
boardColumnId: landing.id,
|
|
252
|
+
taskTypeId: bug.id,
|
|
253
|
+
taskPriorityId: priority.id,
|
|
254
|
+
reporterUserId: meRes.user.id,
|
|
255
|
+
displayOrder: "",
|
|
256
|
+
aiModeEnabled: false,
|
|
257
|
+
repositoryIds: [],
|
|
258
|
+
labelIds,
|
|
259
|
+
});
|
|
260
|
+
const task = created.task;
|
|
261
|
+
if (!task) return { content: [{ type: "text", text: "Failed to create the Defect task. The backend returned an empty response (no error). Retry; if it persists, surface the call to the Opsee team." }], isError: true };
|
|
262
|
+
|
|
263
|
+
// AddTask cannot place a Task in an Initiative and an edge needs the new id, so this is two
|
|
264
|
+
// more calls. A Defect left outside its Initiative is not dispatchable and a Defect with no
|
|
265
|
+
// edge is an orphan, so a failure here is reported with the id rather than swallowed.
|
|
266
|
+
const wired: string[] = [];
|
|
267
|
+
try {
|
|
268
|
+
await linkToInitiative(clients, task, initiativeId);
|
|
269
|
+
wired.push(`placed in initiative ${initiativeId}`);
|
|
270
|
+
await clients.taskDependencies.addTaskDependency({ fromTaskId: task.id, toTaskId: origin.id, type: TaskDependencyType.RELATES_TO });
|
|
271
|
+
wired.push(`related to ${origin.identifier}`);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return {
|
|
274
|
+
content: [{ type: "text", text: `Created ${task.identifier} (id ${task.id}) but could not finish wiring it (${wired.join("; ") || "nothing wired"}): ${formatError(error)}\n\nFinish it by hand with opsee_link_task_to_initiative and opsee_create_task_dependency, or delete it with opsee_delete_task.` }],
|
|
275
|
+
isError: true,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// The same marker and token the Foreman's filer writes, so its duplicate check reads this
|
|
280
|
+
// Defect back and does not file it a second time from a Verdict that saw the same thing.
|
|
281
|
+
const comment = [
|
|
282
|
+
DEFECT_COMMENT_MARKER,
|
|
283
|
+
"",
|
|
284
|
+
`1 Defect from an attended verification pass on ${origin.identifier} is now a Task of this Initiative, related to this one and labelled "${DISPATCH_LABEL}", so the next tick dispatches it.`,
|
|
285
|
+
"",
|
|
286
|
+
`- [defect:${key}] ${task.identifier}: ${oneLine(title)}`,
|
|
287
|
+
].join("\n");
|
|
288
|
+
let commented = true;
|
|
289
|
+
try {
|
|
290
|
+
await clients.comments.addComment({ taskId: origin.id, content: comment, isInternal: false });
|
|
291
|
+
} catch {
|
|
292
|
+
commented = false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
content: [{
|
|
297
|
+
type: "text",
|
|
298
|
+
text: [
|
|
299
|
+
`Defect filed as ${task.identifier} (id ${task.id}).`,
|
|
300
|
+
` Title: ${task.title}`,
|
|
301
|
+
` Type: ${bug.name} · Column: ${landing.name} · Labels: ${DISPATCH_LABEL}, ${DEFECT_LABEL}`,
|
|
302
|
+
` ${wired.join(" · ")} · key [defect:${key}]`,
|
|
303
|
+
commented
|
|
304
|
+
? ` A comment naming it was left on ${origin.identifier}.`
|
|
305
|
+
: ` The comment on ${origin.identifier} could not be posted; the Task itself is the record. Consider adding one with opsee_add_comment.`,
|
|
306
|
+
"",
|
|
307
|
+
`${origin.identifier} was not moved or edited: it keeps its column, its labels and its pull request.`,
|
|
308
|
+
].join("\n"),
|
|
309
|
+
}],
|
|
310
|
+
};
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return { content: [{ type: "text", text: formatError(error) }], isError: true };
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The id of a project label by name, creating it when the project has no active one. Matched the
|
|
319
|
+
* way the backend matches the dispatch label when it decides what is Ready (`LOWER(name) =
|
|
320
|
+
* LOWER(?) AND is_active`, backend/repository/run.go) and the way the Foreman's own filer matches
|
|
321
|
+
* it (`OpseeTrackerAdapter.ensureLabel`).
|
|
322
|
+
*
|
|
323
|
+
* `isActive` is not a detail: a deactivated "ready-for-agent" is a label `GetReadyTasks` will never
|
|
324
|
+
* match, so returning its id would file a Defect that is never dispatched while the tool reports
|
|
325
|
+
* that the next tick will pick it up. Whitespace is significant for the same reason — the backend
|
|
326
|
+
* does not trim, so `"ready-for-agent "` is a different label there and must be here too. */
|
|
327
|
+
async function ensureLabel(clients: ApiClients, projectId: number, name: string): Promise<number> {
|
|
328
|
+
const res = await clients.labels.getLabels({ projectId, pagination: overridePagination() });
|
|
329
|
+
const found = res.labels.find((l) => l.isActive && sameName(l.name, name));
|
|
330
|
+
if (found) return found.id;
|
|
331
|
+
const created = await clients.labels.addLabel({ projectId, name, isActive: true });
|
|
332
|
+
if (!created.label) throw new Error(`Creating the "${name}" label in project ${projectId} returned no label`);
|
|
333
|
+
return created.label.id;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** EditTask validates the whole Task, so placing one in an Initiative resends what the backend
|
|
337
|
+
* already has. Same shape as opsee_link_task_to_initiative.
|
|
338
|
+
*
|
|
339
|
+
* Every id falls back to the Task's own flat field, exactly as `OpseeTrackerAdapter.editTask` does:
|
|
340
|
+
* `TaskRepository.Get` preloads Project, TaskType, TaskPriority, ReporterUser and BoardColumn but
|
|
341
|
+
* not Board, and AddTask builds its response from that Get, so `task.board` is undefined on every
|
|
342
|
+
* real call and `task.board?.id ?? 0` would send boardId 0. It is the generated `Task` rather than
|
|
343
|
+
* a structural type so the compiler can see the flat fields — the hand-rolled type is what let
|
|
344
|
+
* that through. */
|
|
345
|
+
async function linkToInitiative(clients: ApiClients, task: Task, initiativeId: number): Promise<void> {
|
|
346
|
+
await clients.tasks.editTask({
|
|
347
|
+
id: task.id,
|
|
348
|
+
identifier: task.identifier,
|
|
349
|
+
title: task.title,
|
|
350
|
+
description: task.description,
|
|
351
|
+
displayOrder: task.displayOrder,
|
|
352
|
+
storyPoints: task.storyPoints,
|
|
353
|
+
estimatedHours: task.estimatedHours,
|
|
354
|
+
actualHours: task.actualHours,
|
|
355
|
+
dueDate: task.dueDate,
|
|
356
|
+
aiModeEnabled: task.aiModeEnabled,
|
|
357
|
+
metadataJson: task.metadataJson,
|
|
358
|
+
boardId: task.board?.id ?? task.boardId,
|
|
359
|
+
boardColumnId: task.boardColumn?.id ?? task.boardColumnId,
|
|
360
|
+
projectId: task.project?.id ?? task.projectId,
|
|
361
|
+
taskTypeId: task.taskType?.id ?? task.taskTypeId,
|
|
362
|
+
taskPriorityId: task.taskPriority?.id ?? task.taskPriorityId,
|
|
363
|
+
reporterUserId: task.reporterUser?.id ?? task.reporterUserId,
|
|
364
|
+
assignedUserId: task.assignedUser?.id ?? task.assignedUserId,
|
|
365
|
+
cycleId: task.cycle?.id ?? task.cycleId,
|
|
366
|
+
milestoneId: task.milestoneId,
|
|
367
|
+
parentTaskId: task.parentTaskId,
|
|
368
|
+
repositoryIds: task.taskRepositories?.map((r) => r.id) ?? [],
|
|
369
|
+
initiativeId,
|
|
370
|
+
});
|
|
371
|
+
}
|