@cursor/july 0.1.54 → 0.1.55
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/docs/404.html +2 -2
- package/dist/docs/ab.html +3 -3
- package/dist/docs/assets/{app.DAKcDclS.js → app.CVQxhV85.js} +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.BFpWSo6D.js +1 -0
- package/dist/docs/assets/chunks/{VPLocalSearchBox.BNIaCSfG.js → VPLocalSearchBox.QAPzJjql.js} +1 -1
- package/dist/docs/assets/chunks/{theme.B47GzJjc.js → theme.CSxTCOFu.js} +2 -2
- package/dist/docs/assets/{reference_cli.md.-V6J7Cl8.js → reference_cli.md.D_CCfG8R.js} +2 -0
- package/dist/docs/assets/templates_triage.md.DWuQ1bZz.js +9 -0
- package/dist/docs/assets/templates_triage.md.DWuQ1bZz.lean.js +1 -0
- package/dist/docs/building-with-agents.html +3 -3
- package/dist/docs/concepts.html +3 -3
- package/dist/docs/deployment.html +3 -3
- package/dist/docs/evals.html +3 -3
- package/dist/docs/example-agents/approval-buddy.html +3 -3
- package/dist/docs/example-agents/benny.html +3 -3
- package/dist/docs/example-agents/bugbot.html +3 -3
- package/dist/docs/example-agents/codebase-wiki.html +3 -3
- package/dist/docs/example-agents/codeowners-review.html +3 -3
- package/dist/docs/example-agents/concierge.html +3 -3
- package/dist/docs/example-agents/fsd.html +3 -3
- package/dist/docs/example-agents/index.html +3 -3
- package/dist/docs/example-agents/knowledge-base.html +3 -3
- package/dist/docs/example-agents/oncall.html +3 -3
- package/dist/docs/example-agents/security-reviewer.html +3 -3
- package/dist/docs/example-agents/slack-agent.html +3 -3
- package/dist/docs/example-agents/weather-agent.html +3 -3
- package/dist/docs/guides/agent-to-agent.html +3 -3
- package/dist/docs/guides/cloud-runtime.html +3 -3
- package/dist/docs/guides/github.html +3 -3
- package/dist/docs/guides/human-in-the-loop.html +3 -3
- package/dist/docs/guides/mcp-oauth.html +3 -3
- package/dist/docs/guides/opentelemetry.html +3 -3
- package/dist/docs/guides/slack.html +3 -3
- package/dist/docs/guides/webhooks.html +3 -3
- package/dist/docs/hashmap.json +1 -1
- package/dist/docs/hillclimbing.html +3 -3
- package/dist/docs/index.html +3 -3
- package/dist/docs/quickstart.html +3 -3
- package/dist/docs/reference/agent-config.html +3 -3
- package/dist/docs/reference/artifacts.html +3 -3
- package/dist/docs/reference/channels.html +3 -3
- package/dist/docs/reference/cli.html +6 -4
- package/dist/docs/reference/connections.html +3 -3
- package/dist/docs/reference/hooks.html +3 -3
- package/dist/docs/reference/http-api.html +3 -3
- package/dist/docs/reference/instructions.html +3 -3
- package/dist/docs/reference/playground.html +3 -3
- package/dist/docs/reference/project-layout.html +3 -3
- package/dist/docs/reference/prompt.html +3 -3
- package/dist/docs/reference/schedules.html +3 -3
- package/dist/docs/reference/sessions.html +3 -3
- package/dist/docs/reference/skills.html +3 -3
- package/dist/docs/reference/subagents.html +3 -3
- package/dist/docs/reference/tools.html +3 -3
- package/dist/docs/scaffolding-agents.html +3 -3
- package/dist/docs/storage.html +3 -3
- package/dist/docs/templates/triage.html +14 -16
- package/dist/docs/troubleshooting.html +3 -3
- package/dist/internal/cli-ax.d.ts.map +1 -1
- package/dist/internal/cli-ax.js +10 -1
- package/dist/internal/cli-deploy.js +1 -1
- package/dist/internal/cli-slack.d.ts +2 -0
- package/dist/internal/cli-slack.d.ts.map +1 -1
- package/dist/internal/cli-slack.js +35 -18
- package/dist/internal/init-project.d.ts +3 -0
- package/dist/internal/init-project.d.ts.map +1 -1
- package/dist/internal/init-project.js +77 -45
- package/dist/internal/slack-provision-client.js +1 -1
- package/dist/playground/assets/{index-BHAPd9KT.js → index-0oqXUkrR.js} +44 -44
- package/dist/playground/assets/index-tHCjJxv4.css +1 -0
- package/dist/playground/index.html +2 -2
- package/docs/reference/cli.md +2 -0
- package/docs/templates/triage.md +59 -101
- package/package.json +1 -1
- package/src/internal/cli-ax.ts +10 -1
- package/src/internal/cli-deploy.ts +2 -2
- package/src/internal/cli-slack.ts +57 -18
- package/src/internal/init-project.ts +93 -48
- package/src/internal/slack-provision-client.ts +1 -1
- package/templates/triage/README.md +27 -29
- package/templates/triage/agent/agent.ts +1 -0
- package/templates/triage/agent/channels/queue.ts +47 -0
- package/templates/triage/agent/channels/webhook.ts +50 -0
- package/templates/triage/agent/instructions.md +24 -13
- package/templates/triage/agent/lib/actuate.ts +759 -0
- package/templates/triage/agent/lib/parse-queue-event.ts +168 -0
- package/templates/triage/agent/lib/tracker.ts +5 -0
- package/templates/triage/agent/lib/types.ts +52 -0
- package/templates/triage/agent/lib/verify-webhook.ts +81 -0
- package/templates/triage/agent/skills/triage-rubric.md +6 -5
- package/templates/triage/agent/tools/record_triage.ts +24 -5
- package/templates/triage/evals/triage.eval.ts +25 -3
- package/templates/triage/overlays/jira/README.md +21 -0
- package/templates/triage/overlays/jira/agent/lib/tracker.ts +8 -0
- package/templates/triage/overlays/jira/agent/mcp-connections/tracker.ts +7 -0
- package/templates/triage/overlays/linear/README.md +21 -0
- package/templates/triage/overlays/linear/agent/lib/tracker.ts +8 -0
- package/templates/triage/overlays/linear/agent/mcp-connections/tracker.ts +7 -0
- package/templates/triage/package.json +1 -1
- package/dist/docs/assets/chunks/@localSearchIndexroot.C4uxzYEU.js +0 -1
- package/dist/docs/assets/templates_triage.md.CConZt1z.js +0 -11
- package/dist/docs/assets/templates_triage.md.CConZt1z.lean.js +0 -1
- package/dist/playground/assets/index-DpKH0HcT.css +0 -1
- package/templates/triage/agent/channels/intake.ts +0 -20
- package/templates/triage/agent/channels/slack.ts +0 -3
- /package/dist/docs/assets/{reference_cli.md.-V6J7Cl8.lean.js → reference_cli.md.D_CCfG8R.lean.js} +0 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HostMcpCallResult,
|
|
3
|
+
HostMcpRegistry,
|
|
4
|
+
HostMcpToolInfo,
|
|
5
|
+
} from "@cursor/july";
|
|
6
|
+
import {
|
|
7
|
+
ISSUE_ID,
|
|
8
|
+
JIRA_URL,
|
|
9
|
+
LINEAR_URL,
|
|
10
|
+
pinIssueIdentifier,
|
|
11
|
+
richTextValue,
|
|
12
|
+
} from "./parse-queue-event.js";
|
|
13
|
+
import type {
|
|
14
|
+
QueueAction,
|
|
15
|
+
RecordTriageResult,
|
|
16
|
+
TrackerConfig,
|
|
17
|
+
TrackerIssue,
|
|
18
|
+
TrackerResult,
|
|
19
|
+
TriageSeverity,
|
|
20
|
+
TriageVerdict,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
const CONNECTION_NAME = "tracker";
|
|
24
|
+
|
|
25
|
+
const SEARCH_ISSUE_TOOL = /(?:search|list|find)[_-]?issues?$/i;
|
|
26
|
+
const COMMENT_TOOL = /(?:add|create|save)[_-]?comment$/i;
|
|
27
|
+
const LIST_COMMENT_TOOL = /(?:list|get|read)[_-]?comments?$/i;
|
|
28
|
+
const SAVE_OR_UPDATE_ISSUE_TOOL = /(?:save|update)[_-]?issue$/i;
|
|
29
|
+
const UPDATE_ISSUE_TOOL = /update[_-]?issue$/i;
|
|
30
|
+
|
|
31
|
+
const FIELD_ALIASES = {
|
|
32
|
+
title: ["title", "name", "issueTitle", "summary"],
|
|
33
|
+
query: ["query", "q", "search", "term", "jql", "text"],
|
|
34
|
+
comment: ["comment", "body", "text", "message"],
|
|
35
|
+
issue: ["id", "issueId", "issue_id", "issueKey", "issue_key", "identifier"],
|
|
36
|
+
commentIssue: [
|
|
37
|
+
"issueId",
|
|
38
|
+
"issue_id",
|
|
39
|
+
"issueKey",
|
|
40
|
+
"issue_key",
|
|
41
|
+
"identifier",
|
|
42
|
+
],
|
|
43
|
+
priority: ["priority", "priorityId", "priority_id"],
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
const PRIORITY_BY_SEVERITY: Record<TriageSeverity, number> = {
|
|
47
|
+
urgent: 1,
|
|
48
|
+
high: 2,
|
|
49
|
+
medium: 3,
|
|
50
|
+
low: 4,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface ActuateInput {
|
|
54
|
+
verdict: TriageVerdict;
|
|
55
|
+
mcp: HostMcpRegistry;
|
|
56
|
+
tracker: TrackerConfig;
|
|
57
|
+
purpose?: "live" | "eval";
|
|
58
|
+
now?: () => string;
|
|
59
|
+
issueKey?: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Host-trusted inbound title. Live `record_triage` omits this and looks
|
|
62
|
+
* up the pinned issue. `session.title` is the harness display title and
|
|
63
|
+
* is overwritten by the turn summary.
|
|
64
|
+
*/
|
|
65
|
+
issueTitle?: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function actuate(input: ActuateInput): Promise<RecordTriageResult> {
|
|
69
|
+
const evaluatedAt = (input.now ?? (() => new Date().toISOString()))();
|
|
70
|
+
const verdict: TriageVerdict = {
|
|
71
|
+
...input.verdict,
|
|
72
|
+
identifier: pinIssueIdentifier(input.issueKey, input.verdict.identifier),
|
|
73
|
+
};
|
|
74
|
+
const tracker = await resolveTracker({ ...input, verdict });
|
|
75
|
+
return { verdict, evaluatedAt, tracker };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function resolveTracker(input: ActuateInput): Promise<TrackerResult> {
|
|
79
|
+
if (input.purpose === "eval") {
|
|
80
|
+
return { status: "skipped", reason: "eval" };
|
|
81
|
+
}
|
|
82
|
+
if (input.tracker.kind === "none") {
|
|
83
|
+
return { status: "not_connected" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const connectionName = input.tracker.connectionName || CONNECTION_NAME;
|
|
87
|
+
if (!input.mcp.names().includes(connectionName)) {
|
|
88
|
+
return { status: "not_connected" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let tools: HostMcpToolInfo[];
|
|
92
|
+
try {
|
|
93
|
+
tools = await input.mcp.listTools(connectionName);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { status: "failed", message: errorText(error) };
|
|
96
|
+
}
|
|
97
|
+
if (tools.length === 0) {
|
|
98
|
+
return { status: "not_connected" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const issue: TrackerIssue = {
|
|
102
|
+
identifier: input.verdict.identifier,
|
|
103
|
+
url: "",
|
|
104
|
+
};
|
|
105
|
+
const searchTool = tools.find((tool) => SEARCH_ISSUE_TOOL.test(tool.name));
|
|
106
|
+
let search: SearchResult = {
|
|
107
|
+
candidates: [],
|
|
108
|
+
};
|
|
109
|
+
if (searchTool !== undefined) {
|
|
110
|
+
const hostTitle = await resolveHostTitle({
|
|
111
|
+
mcp: input.mcp,
|
|
112
|
+
connectionName,
|
|
113
|
+
tool: searchTool,
|
|
114
|
+
identifier: issue.identifier,
|
|
115
|
+
issueTitle: input.issueTitle,
|
|
116
|
+
});
|
|
117
|
+
if (hostTitle !== "") {
|
|
118
|
+
search = await searchExisting({
|
|
119
|
+
mcp: input.mcp,
|
|
120
|
+
connectionName,
|
|
121
|
+
tool: searchTool,
|
|
122
|
+
query: hostTitle,
|
|
123
|
+
matchTitle: hostTitle,
|
|
124
|
+
excludeIdentifier: issue.identifier,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const duplicate = search.duplicate;
|
|
130
|
+
const commentTool = tools.find((tool) => COMMENT_TOOL.test(tool.name));
|
|
131
|
+
if (commentTool === undefined) {
|
|
132
|
+
return {
|
|
133
|
+
status: "failed",
|
|
134
|
+
message: "the tracker connector exposes no comment tool",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const listed = await loadComments({
|
|
139
|
+
mcp: input.mcp,
|
|
140
|
+
connectionName,
|
|
141
|
+
tools,
|
|
142
|
+
identifier: issue.identifier,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const actions: QueueAction[] = [];
|
|
146
|
+
if (listed.status !== "failed") {
|
|
147
|
+
if (
|
|
148
|
+
!alreadyCommented(
|
|
149
|
+
listed,
|
|
150
|
+
search.candidates,
|
|
151
|
+
issue.identifier,
|
|
152
|
+
"Triage:"
|
|
153
|
+
)
|
|
154
|
+
) {
|
|
155
|
+
const comment = await commentOn({
|
|
156
|
+
mcp: input.mcp,
|
|
157
|
+
connectionName,
|
|
158
|
+
tool: commentTool,
|
|
159
|
+
issue,
|
|
160
|
+
body: triageComment(input.verdict, duplicate),
|
|
161
|
+
});
|
|
162
|
+
if (!comment.ok) {
|
|
163
|
+
return { status: "failed", message: comment.message };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
actions.push({ op: "commented" });
|
|
167
|
+
}
|
|
168
|
+
if (input.verdict.needsInfo === true) {
|
|
169
|
+
actions.push({ op: "needs_info" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (duplicate !== undefined) {
|
|
173
|
+
const duplicateComment = `Triage: duplicate inbound ${issue.identifier}`;
|
|
174
|
+
const duplicateListed = await loadComments({
|
|
175
|
+
mcp: input.mcp,
|
|
176
|
+
connectionName,
|
|
177
|
+
tools,
|
|
178
|
+
identifier: duplicate.identifier,
|
|
179
|
+
});
|
|
180
|
+
if (duplicateListed.status !== "failed") {
|
|
181
|
+
if (
|
|
182
|
+
!alreadyCommented(
|
|
183
|
+
duplicateListed,
|
|
184
|
+
search.candidates,
|
|
185
|
+
duplicate.identifier,
|
|
186
|
+
duplicateComment
|
|
187
|
+
)
|
|
188
|
+
) {
|
|
189
|
+
const comment = await commentOn({
|
|
190
|
+
mcp: input.mcp,
|
|
191
|
+
connectionName,
|
|
192
|
+
tool: commentTool,
|
|
193
|
+
issue: duplicate,
|
|
194
|
+
body: duplicateComment,
|
|
195
|
+
});
|
|
196
|
+
if (!comment.ok) {
|
|
197
|
+
return { status: "failed", message: comment.message };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
actions.push({ op: "duplicate_of", of: duplicate.identifier });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (input.verdict.kind === "bug") {
|
|
205
|
+
let updateTool: HostMcpToolInfo | undefined;
|
|
206
|
+
let updateArgs: Record<string, unknown> | undefined;
|
|
207
|
+
for (const tool of tools) {
|
|
208
|
+
if (!SAVE_OR_UPDATE_ISSUE_TOOL.test(tool.name)) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const args = buildPriorityArgs(
|
|
212
|
+
input.verdict,
|
|
213
|
+
tool,
|
|
214
|
+
input.tracker.kind
|
|
215
|
+
);
|
|
216
|
+
if (args !== undefined) {
|
|
217
|
+
updateTool = tool;
|
|
218
|
+
updateArgs = args;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (updateTool !== undefined && updateArgs !== undefined) {
|
|
223
|
+
const update = await callTool({
|
|
224
|
+
mcp: input.mcp,
|
|
225
|
+
connectionName,
|
|
226
|
+
tool: updateTool.name,
|
|
227
|
+
args: updateArgs,
|
|
228
|
+
});
|
|
229
|
+
if (!update.ok) {
|
|
230
|
+
return { status: "failed", message: update.message };
|
|
231
|
+
}
|
|
232
|
+
actions.push({
|
|
233
|
+
op: "prioritized",
|
|
234
|
+
severity: input.verdict.severity,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { status: "acted", issue, actions };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
type SearchResult = {
|
|
243
|
+
candidates: CandidateIssue[];
|
|
244
|
+
duplicate?: TrackerIssue;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
async function resolveHostTitle(input: {
|
|
248
|
+
mcp: HostMcpRegistry;
|
|
249
|
+
connectionName: string;
|
|
250
|
+
tool: HostMcpToolInfo;
|
|
251
|
+
identifier: string;
|
|
252
|
+
issueTitle?: string | null;
|
|
253
|
+
}): Promise<string> {
|
|
254
|
+
if (typeof input.issueTitle === "string") {
|
|
255
|
+
const title = input.issueTitle.trim();
|
|
256
|
+
if (title !== "") {
|
|
257
|
+
return title;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const listed = await searchIssues({
|
|
261
|
+
mcp: input.mcp,
|
|
262
|
+
connectionName: input.connectionName,
|
|
263
|
+
tool: input.tool,
|
|
264
|
+
query: input.identifier,
|
|
265
|
+
});
|
|
266
|
+
const self = listed.find(
|
|
267
|
+
(candidate) => issueIdentifier(candidate) === input.identifier
|
|
268
|
+
);
|
|
269
|
+
return self?.title.trim() ?? "";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function searchExisting(input: {
|
|
273
|
+
mcp: HostMcpRegistry;
|
|
274
|
+
connectionName: string;
|
|
275
|
+
tool: HostMcpToolInfo;
|
|
276
|
+
query: string;
|
|
277
|
+
matchTitle: string;
|
|
278
|
+
excludeIdentifier: string;
|
|
279
|
+
}): Promise<SearchResult> {
|
|
280
|
+
const candidates = await searchIssues({
|
|
281
|
+
mcp: input.mcp,
|
|
282
|
+
connectionName: input.connectionName,
|
|
283
|
+
tool: input.tool,
|
|
284
|
+
query: input.query,
|
|
285
|
+
});
|
|
286
|
+
return {
|
|
287
|
+
candidates,
|
|
288
|
+
duplicate: matchingIssue(
|
|
289
|
+
candidates,
|
|
290
|
+
input.matchTitle,
|
|
291
|
+
input.excludeIdentifier
|
|
292
|
+
),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function searchIssues(input: {
|
|
297
|
+
mcp: HostMcpRegistry;
|
|
298
|
+
connectionName: string;
|
|
299
|
+
tool: HostMcpToolInfo;
|
|
300
|
+
query: string;
|
|
301
|
+
}): Promise<CandidateIssue[]> {
|
|
302
|
+
try {
|
|
303
|
+
const result = await input.mcp.callTool(
|
|
304
|
+
input.connectionName,
|
|
305
|
+
input.tool.name,
|
|
306
|
+
buildSearchArgs(input.query, input.tool)
|
|
307
|
+
);
|
|
308
|
+
if (result.isError === true) {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
return issuesFromSearch(result);
|
|
312
|
+
} catch {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
type ListedComments =
|
|
318
|
+
| { status: "listed"; bodies: string[] }
|
|
319
|
+
| { status: "unavailable" }
|
|
320
|
+
| { status: "failed" };
|
|
321
|
+
|
|
322
|
+
async function loadComments(input: {
|
|
323
|
+
mcp: HostMcpRegistry;
|
|
324
|
+
connectionName: string;
|
|
325
|
+
tools: HostMcpToolInfo[];
|
|
326
|
+
identifier: string;
|
|
327
|
+
}): Promise<ListedComments> {
|
|
328
|
+
const tool = input.tools.find((candidate) =>
|
|
329
|
+
LIST_COMMENT_TOOL.test(candidate.name)
|
|
330
|
+
);
|
|
331
|
+
if (tool === undefined) {
|
|
332
|
+
return { status: "unavailable" };
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const result = await input.mcp.callTool(
|
|
336
|
+
input.connectionName,
|
|
337
|
+
tool.name,
|
|
338
|
+
{
|
|
339
|
+
[pickProperty(tool, FIELD_ALIASES.commentIssue)]: input.identifier,
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
if (result.isError === true) {
|
|
343
|
+
return { status: "failed" };
|
|
344
|
+
}
|
|
345
|
+
return { status: "listed", bodies: commentBodiesFrom(result) };
|
|
346
|
+
} catch {
|
|
347
|
+
return { status: "failed" };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function commentBodiesFrom(result: HostMcpCallResult): string[] {
|
|
352
|
+
const fromStructured = collectCommentBodies(result.structuredContent);
|
|
353
|
+
if (fromStructured.length > 0) {
|
|
354
|
+
return fromStructured;
|
|
355
|
+
}
|
|
356
|
+
const text = resultText(result);
|
|
357
|
+
const fromJson = collectCommentBodies(tryParseJson(text));
|
|
358
|
+
if (fromJson.length > 0) {
|
|
359
|
+
return fromJson;
|
|
360
|
+
}
|
|
361
|
+
return text === "" ? [] : [text];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function alreadyCommented(
|
|
365
|
+
listed: ListedComments,
|
|
366
|
+
candidates: CandidateIssue[],
|
|
367
|
+
identifier: string,
|
|
368
|
+
prefix: string
|
|
369
|
+
): boolean {
|
|
370
|
+
if (listed.status === "listed") {
|
|
371
|
+
return listed.bodies.some((comment) => hasSentinel(comment, prefix));
|
|
372
|
+
}
|
|
373
|
+
return hasCommentStarting(candidates, identifier, prefix);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function commentOn(input: {
|
|
377
|
+
mcp: HostMcpRegistry;
|
|
378
|
+
connectionName: string;
|
|
379
|
+
tool: HostMcpToolInfo;
|
|
380
|
+
issue: TrackerIssue;
|
|
381
|
+
body: string;
|
|
382
|
+
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
|
383
|
+
const result = await callTool({
|
|
384
|
+
mcp: input.mcp,
|
|
385
|
+
connectionName: input.connectionName,
|
|
386
|
+
tool: input.tool.name,
|
|
387
|
+
args: buildCommentArgs(input.issue, input.body, input.tool),
|
|
388
|
+
});
|
|
389
|
+
if (!result.ok) {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
message: `could not comment on ${input.issue.identifier}: ${result.message}`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function callTool(input: {
|
|
399
|
+
mcp: HostMcpRegistry;
|
|
400
|
+
connectionName: string;
|
|
401
|
+
tool: string;
|
|
402
|
+
args: Record<string, unknown>;
|
|
403
|
+
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
|
404
|
+
try {
|
|
405
|
+
const result = await input.mcp.callTool(
|
|
406
|
+
input.connectionName,
|
|
407
|
+
input.tool,
|
|
408
|
+
input.args
|
|
409
|
+
);
|
|
410
|
+
if (result.isError === true) {
|
|
411
|
+
return { ok: false, message: resultText(result) };
|
|
412
|
+
}
|
|
413
|
+
return { ok: true };
|
|
414
|
+
} catch (error) {
|
|
415
|
+
return { ok: false, message: errorText(error) };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function buildSearchArgs(
|
|
420
|
+
query: string,
|
|
421
|
+
tool: HostMcpToolInfo
|
|
422
|
+
): Record<string, unknown> {
|
|
423
|
+
return {
|
|
424
|
+
[pickProperty(tool, FIELD_ALIASES.query)]: query,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function buildCommentArgs(
|
|
429
|
+
issue: TrackerIssue,
|
|
430
|
+
body: string,
|
|
431
|
+
tool: HostMcpToolInfo
|
|
432
|
+
): Record<string, unknown> {
|
|
433
|
+
return {
|
|
434
|
+
[pickProperty(tool, FIELD_ALIASES.commentIssue)]: issue.identifier,
|
|
435
|
+
[pickProperty(tool, FIELD_ALIASES.comment)]: body,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function buildPriorityArgs(
|
|
440
|
+
verdict: TriageVerdict,
|
|
441
|
+
tool: HostMcpToolInfo,
|
|
442
|
+
kind: TrackerConfig["kind"]
|
|
443
|
+
): Record<string, unknown> | undefined {
|
|
444
|
+
if (kind !== "linear") {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
const known = propertyNames(tool);
|
|
448
|
+
const issueProperty =
|
|
449
|
+
FIELD_ALIASES.issue.find((alias) => known.has(alias)) ??
|
|
450
|
+
(UPDATE_ISSUE_TOOL.test(tool.name) ? "id" : undefined);
|
|
451
|
+
if (issueProperty === undefined) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
const priorityProperty = FIELD_ALIASES.priority.find((alias) =>
|
|
455
|
+
known.has(alias)
|
|
456
|
+
);
|
|
457
|
+
if (priorityProperty === undefined) {
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
[issueProperty]: verdict.identifier,
|
|
462
|
+
[priorityProperty]: PRIORITY_BY_SEVERITY[verdict.severity],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function pickProperty(
|
|
467
|
+
tool: HostMcpToolInfo,
|
|
468
|
+
aliases: readonly string[]
|
|
469
|
+
): string {
|
|
470
|
+
const known = propertyNames(tool);
|
|
471
|
+
return aliases.find((alias) => known.has(alias)) ?? aliases[0]!;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function propertyNames(tool: HostMcpToolInfo): Set<string> {
|
|
475
|
+
const properties = tool.inputSchema?.["properties"];
|
|
476
|
+
if (!isRecord(properties)) {
|
|
477
|
+
return new Set();
|
|
478
|
+
}
|
|
479
|
+
return new Set(Object.keys(properties));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function triageComment(
|
|
483
|
+
verdict: TriageVerdict,
|
|
484
|
+
duplicate: TrackerIssue | undefined
|
|
485
|
+
): string {
|
|
486
|
+
const lines = [
|
|
487
|
+
`Triage: ${verdict.kind}`,
|
|
488
|
+
`Severity: ${verdict.severity}`,
|
|
489
|
+
`Summary: ${verdict.summary}`,
|
|
490
|
+
`Next step: ${verdict.nextStep}`,
|
|
491
|
+
];
|
|
492
|
+
if (duplicate !== undefined) {
|
|
493
|
+
lines.push(`Duplicate of: ${duplicate.identifier}`);
|
|
494
|
+
}
|
|
495
|
+
if (verdict.needsInfo === true) {
|
|
496
|
+
lines.push(`Needs info: ${verdict.nextStep}`);
|
|
497
|
+
}
|
|
498
|
+
if (verdict.reporter !== undefined && verdict.reporter !== "") {
|
|
499
|
+
lines.push(`Reported by: ${verdict.reporter}`);
|
|
500
|
+
}
|
|
501
|
+
return lines.join("\n");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
interface CandidateIssue {
|
|
505
|
+
identifier: string;
|
|
506
|
+
url: string;
|
|
507
|
+
title: string;
|
|
508
|
+
comments: string[];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function matchingIssue(
|
|
512
|
+
candidates: CandidateIssue[],
|
|
513
|
+
title: string,
|
|
514
|
+
excludeIdentifier: string
|
|
515
|
+
): TrackerIssue | undefined {
|
|
516
|
+
const match = candidates.find(
|
|
517
|
+
(candidate) =>
|
|
518
|
+
issueIdentifier(candidate) !== excludeIdentifier &&
|
|
519
|
+
titlesMatch(candidate.title, title)
|
|
520
|
+
);
|
|
521
|
+
if (match === undefined) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
const identifier = issueIdentifier(match);
|
|
525
|
+
if (identifier === "") {
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
return { identifier, url: match.url };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function issuesFromSearch(result: HostMcpCallResult): CandidateIssue[] {
|
|
532
|
+
const fromStructured = collectIssues(result.structuredContent);
|
|
533
|
+
if (fromStructured.length > 0) {
|
|
534
|
+
return fromStructured;
|
|
535
|
+
}
|
|
536
|
+
const text = resultText(result);
|
|
537
|
+
const fromJson = collectIssues(tryParseJson(text));
|
|
538
|
+
return fromJson.length > 0 ? fromJson : issuesFromLines(text);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function collectIssues(
|
|
542
|
+
value: unknown,
|
|
543
|
+
found: CandidateIssue[] = []
|
|
544
|
+
): CandidateIssue[] {
|
|
545
|
+
if (Array.isArray(value)) {
|
|
546
|
+
for (const entry of value) {
|
|
547
|
+
collectIssues(entry, found);
|
|
548
|
+
}
|
|
549
|
+
return found;
|
|
550
|
+
}
|
|
551
|
+
if (!isRecord(value)) {
|
|
552
|
+
return found;
|
|
553
|
+
}
|
|
554
|
+
const url = firstIssueUrl(value);
|
|
555
|
+
const identifier = firstIssueId(value) || issueKeyIn(url);
|
|
556
|
+
const title = firstString(value, FIELD_ALIASES.title);
|
|
557
|
+
if (identifier !== "" || url !== "") {
|
|
558
|
+
found.push({
|
|
559
|
+
identifier,
|
|
560
|
+
url,
|
|
561
|
+
title,
|
|
562
|
+
comments: [
|
|
563
|
+
...collectCommentBodies(value["comments"]),
|
|
564
|
+
...collectCommentBodies(value["comment"]),
|
|
565
|
+
],
|
|
566
|
+
});
|
|
567
|
+
return found;
|
|
568
|
+
}
|
|
569
|
+
for (const nested of Object.values(value)) {
|
|
570
|
+
if (typeof nested === "object" && nested !== null) {
|
|
571
|
+
collectIssues(nested, found);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return found;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function issuesFromLines(text: string): CandidateIssue[] {
|
|
578
|
+
return text.split(/\n/).flatMap((line) => {
|
|
579
|
+
const identifier = line.match(ISSUE_ID)?.[0] ?? "";
|
|
580
|
+
const url = line.match(LINEAR_URL)?.[0] ?? line.match(JIRA_URL)?.[0] ?? "";
|
|
581
|
+
if (identifier === "" && url === "") {
|
|
582
|
+
return [];
|
|
583
|
+
}
|
|
584
|
+
const title = line
|
|
585
|
+
.replace(LINEAR_URL, "")
|
|
586
|
+
.replace(JIRA_URL, "")
|
|
587
|
+
.replace(ISSUE_ID, "")
|
|
588
|
+
.replace(/\s+/g, " ")
|
|
589
|
+
.trim();
|
|
590
|
+
const triage = line.indexOf("Triage:");
|
|
591
|
+
return [
|
|
592
|
+
{
|
|
593
|
+
identifier,
|
|
594
|
+
url,
|
|
595
|
+
title,
|
|
596
|
+
comments: triage === -1 ? [] : [line.slice(triage)],
|
|
597
|
+
},
|
|
598
|
+
];
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function hasCommentStarting(
|
|
603
|
+
candidates: CandidateIssue[],
|
|
604
|
+
identifier: string,
|
|
605
|
+
prefix: string
|
|
606
|
+
): boolean {
|
|
607
|
+
return candidates.some(
|
|
608
|
+
(candidate) =>
|
|
609
|
+
issueIdentifier(candidate) === identifier &&
|
|
610
|
+
candidate.comments.some((comment) => hasSentinel(comment, prefix))
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function hasSentinel(text: string, prefix: string): boolean {
|
|
615
|
+
return text.split(/\n/).some((line) => {
|
|
616
|
+
const trimmed = line
|
|
617
|
+
.trimStart()
|
|
618
|
+
.replace(/^[-*]\s+/, "")
|
|
619
|
+
.replace(/^\d+\.\s+/, "");
|
|
620
|
+
return trimmed.startsWith(prefix);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const COMMENT_WRAP_KEYS = [
|
|
625
|
+
"comments",
|
|
626
|
+
"comment",
|
|
627
|
+
"nodes",
|
|
628
|
+
"items",
|
|
629
|
+
"results",
|
|
630
|
+
"data",
|
|
631
|
+
"content",
|
|
632
|
+
"replies",
|
|
633
|
+
] as const;
|
|
634
|
+
|
|
635
|
+
function collectCommentBodies(value: unknown, found: string[] = []): string[] {
|
|
636
|
+
if (typeof value === "string") {
|
|
637
|
+
if (value.trim() !== "") {
|
|
638
|
+
found.push(value);
|
|
639
|
+
}
|
|
640
|
+
return found;
|
|
641
|
+
}
|
|
642
|
+
if (Array.isArray(value)) {
|
|
643
|
+
for (const entry of value) {
|
|
644
|
+
collectCommentBodies(entry, found);
|
|
645
|
+
}
|
|
646
|
+
return found;
|
|
647
|
+
}
|
|
648
|
+
if (!isRecord(value)) {
|
|
649
|
+
return found;
|
|
650
|
+
}
|
|
651
|
+
const body = firstString(value, FIELD_ALIASES.comment);
|
|
652
|
+
if (body !== "") {
|
|
653
|
+
found.push(body);
|
|
654
|
+
collectCommentBodies(value["comments"], found);
|
|
655
|
+
collectCommentBodies(value["replies"], found);
|
|
656
|
+
return found;
|
|
657
|
+
}
|
|
658
|
+
for (const key of FIELD_ALIASES.comment) {
|
|
659
|
+
const nested = value[key];
|
|
660
|
+
if (nested === undefined || typeof nested === "string") {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const flattened = richTextValue(nested);
|
|
664
|
+
if (flattened !== "") {
|
|
665
|
+
found.push(flattened);
|
|
666
|
+
collectCommentBodies(value["comments"], found);
|
|
667
|
+
collectCommentBodies(value["replies"], found);
|
|
668
|
+
return found;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
for (const key of COMMENT_WRAP_KEYS) {
|
|
672
|
+
if (value[key] !== undefined) {
|
|
673
|
+
collectCommentBodies(value[key], found);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return found;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function issueIdentifier(issue: CandidateIssue): string {
|
|
680
|
+
return issue.identifier || issueKeyIn(issue.url);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function issueKeyIn(value: string): string {
|
|
684
|
+
return value.match(ISSUE_ID)?.[0] ?? "";
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function firstIssueId(record: Record<string, unknown>): string {
|
|
688
|
+
for (const key of ["identifier", "issueKey", "issue_key", "key", "id"]) {
|
|
689
|
+
const value = record[key];
|
|
690
|
+
if (typeof value === "string") {
|
|
691
|
+
const match = value.match(ISSUE_ID);
|
|
692
|
+
if (match !== null) {
|
|
693
|
+
return match[0];
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return "";
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function firstIssueUrl(record: Record<string, unknown>): string {
|
|
701
|
+
for (const key of ["url", "htmlUrl", "issueUrl", "link"]) {
|
|
702
|
+
const value = record[key];
|
|
703
|
+
if (typeof value === "string") {
|
|
704
|
+
const url =
|
|
705
|
+
value.match(LINEAR_URL)?.[0] ?? value.match(JIRA_URL)?.[0] ?? "";
|
|
706
|
+
if (url !== "") {
|
|
707
|
+
return url;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return "";
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function firstString(
|
|
715
|
+
record: Record<string, unknown>,
|
|
716
|
+
keys: readonly string[]
|
|
717
|
+
): string {
|
|
718
|
+
for (const key of keys) {
|
|
719
|
+
const value = record[key];
|
|
720
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
721
|
+
return value;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return "";
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function titlesMatch(candidate: string, title: string): boolean {
|
|
728
|
+
const hay = normalizeTitle(candidate);
|
|
729
|
+
const needle = normalizeTitle(title);
|
|
730
|
+
return hay !== "" && hay === needle;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function normalizeTitle(value: string): string {
|
|
734
|
+
return value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function tryParseJson(text: string): unknown {
|
|
738
|
+
try {
|
|
739
|
+
return JSON.parse(text);
|
|
740
|
+
} catch {
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function resultText(result: HostMcpCallResult): string {
|
|
746
|
+
return result.content
|
|
747
|
+
.map((part) => ("text" in part && typeof part.text === "string" ? part.text : ""))
|
|
748
|
+
.filter((part) => part !== "")
|
|
749
|
+
.join("\n")
|
|
750
|
+
.trim();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function errorText(error: unknown): string {
|
|
754
|
+
return error instanceof Error ? error.message : String(error);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
758
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
759
|
+
}
|