evrex-mcp 0.4.0 → 0.6.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/README.md +1 -1
- package/dist/capture.js +1012 -0
- package/dist/hook.js +137 -0
- package/dist/import.js +1667 -0
- package/dist/index.js +736 -109
- package/dist/tickets.js +636 -0
- package/package.json +11 -2
package/dist/tickets.js
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/client.ts
|
|
13
|
+
var client_exports = {};
|
|
14
|
+
__export(client_exports, {
|
|
15
|
+
evrexApi: () => evrexApi
|
|
16
|
+
});
|
|
17
|
+
function headers() {
|
|
18
|
+
const base = { "Content-Type": "application/json" };
|
|
19
|
+
if (EVREX_TOKEN) base.Authorization = `Bearer ${EVREX_TOKEN}`;
|
|
20
|
+
return base;
|
|
21
|
+
}
|
|
22
|
+
function describeFailure(method, path, status, statusText) {
|
|
23
|
+
if (status === 401 || status === 403) {
|
|
24
|
+
return EVREX_TOKEN ? `${method} ${path} -> ${status}: the EVREX_TOKEN this server was started with was rejected. It may be revoked or expired.` : `${method} ${path} -> ${status}: this evrex backend requires a credential, but no EVREX_TOKEN is set for this MCP server.`;
|
|
25
|
+
}
|
|
26
|
+
return `${method} ${path} -> ${status} ${statusText}`;
|
|
27
|
+
}
|
|
28
|
+
async function request(method, path, { body, absentIsAnswer } = {}) {
|
|
29
|
+
const res = await fetch(`${API_BASE_URL}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: headers(),
|
|
32
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
33
|
+
});
|
|
34
|
+
if (res.status === 404 && absentIsAnswer) return null;
|
|
35
|
+
if (!res.ok) throw new Error(describeFailure(method, path, res.status, res.statusText));
|
|
36
|
+
return await res.json();
|
|
37
|
+
}
|
|
38
|
+
var DEFAULT_API_BASE_URL, API_BASE_URL, EVREX_TOKEN, get, getOrNull, post, evrexApi;
|
|
39
|
+
var init_client = __esm({
|
|
40
|
+
"src/client.ts"() {
|
|
41
|
+
"use strict";
|
|
42
|
+
DEFAULT_API_BASE_URL = "https://api.evrex.ai";
|
|
43
|
+
API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
|
44
|
+
EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
|
|
45
|
+
get = (path) => request("GET", path);
|
|
46
|
+
getOrNull = (path) => request("GET", path, { absentIsAnswer: true });
|
|
47
|
+
post = (path, body) => request("POST", path, { body });
|
|
48
|
+
evrexApi = {
|
|
49
|
+
baseUrl: API_BASE_URL,
|
|
50
|
+
repos: () => get("/repos"),
|
|
51
|
+
commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
|
|
52
|
+
// Abbreviated shas resolve server-side, so a value pasted from `git log`
|
|
53
|
+
// works here (apps/backend/src/reads/reads.service.ts#resolveSha).
|
|
54
|
+
commit: (sha) => getOrNull(`/commits/${encodeURIComponent(sha)}`),
|
|
55
|
+
sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
|
|
56
|
+
session: (id) => getOrNull(`/sessions/${encodeURIComponent(id)}`),
|
|
57
|
+
ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
|
|
58
|
+
// Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
|
|
59
|
+
// apps/backend/src/query/query.service.ts#search. Used by evrex_search,
|
|
60
|
+
// which wants ranked hits fast, not a synthesized paragraph.
|
|
61
|
+
search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// src/credential-store.ts
|
|
67
|
+
var credential_store_exports = {};
|
|
68
|
+
__export(credential_store_exports, {
|
|
69
|
+
CredentialStore: () => CredentialStore,
|
|
70
|
+
NoKeychainError: () => NoKeychainError,
|
|
71
|
+
systemRunner: () => systemRunner
|
|
72
|
+
});
|
|
73
|
+
import { spawn } from "node:child_process";
|
|
74
|
+
var SERVICE, ACCOUNT, systemRunner, NoKeychainError, CredentialStore, WINDOWS_PATH, WINDOWS_STORE, WINDOWS_RETRIEVE, WINDOWS_REMOVE;
|
|
75
|
+
var init_credential_store = __esm({
|
|
76
|
+
"src/credential-store.ts"() {
|
|
77
|
+
"use strict";
|
|
78
|
+
SERVICE = "evrex-capture";
|
|
79
|
+
ACCOUNT = "evrex";
|
|
80
|
+
systemRunner = {
|
|
81
|
+
platform: process.platform,
|
|
82
|
+
run(command, args, stdin) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
85
|
+
let stdout = "";
|
|
86
|
+
let stderr = "";
|
|
87
|
+
child.stdout.on("data", (d) => stdout += d.toString());
|
|
88
|
+
child.stderr.on("data", (d) => stderr += d.toString());
|
|
89
|
+
child.on("error", () => resolve({ code: 127, stdout: "", stderr: "" }));
|
|
90
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
91
|
+
if (stdin !== void 0) child.stdin.write(stdin);
|
|
92
|
+
child.stdin.end();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
NoKeychainError = class extends Error {
|
|
97
|
+
constructor(platform) {
|
|
98
|
+
super(
|
|
99
|
+
`evrex could not find a credential store on this machine (${platform}).
|
|
100
|
+
macOS needs \`security\`, which ships with the system.
|
|
101
|
+
Linux needs \`secret-tool\` \u2014 install libsecret-tools (Debian/Ubuntu)
|
|
102
|
+
or libsecret (Fedora/Arch), and make sure a keyring daemon is running.
|
|
103
|
+
Windows needs PowerShell.
|
|
104
|
+
evrex will not fall back to writing the credential in a plain file.`
|
|
105
|
+
);
|
|
106
|
+
this.name = "NoKeychainError";
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
CredentialStore = class {
|
|
110
|
+
constructor(runner = systemRunner) {
|
|
111
|
+
this.runner = runner;
|
|
112
|
+
}
|
|
113
|
+
async available() {
|
|
114
|
+
switch (this.runner.platform) {
|
|
115
|
+
case "darwin":
|
|
116
|
+
return (await this.runner.run("security", ["help"])).code !== 127;
|
|
117
|
+
case "win32":
|
|
118
|
+
return (await this.runner.run("powershell", ["-Command", "$PSVersionTable.PSVersion.Major"])).code !== 127;
|
|
119
|
+
default:
|
|
120
|
+
return (await this.runner.run("secret-tool", ["--version"])).code !== 127;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Replaces any existing credential rather than adding a second one. A
|
|
125
|
+
* machine that re-enrols after expiry must end up with exactly one entry, or
|
|
126
|
+
* the next read is a coin flip between the live credential and a dead one.
|
|
127
|
+
*/
|
|
128
|
+
async store(secret) {
|
|
129
|
+
if (!await this.available()) throw new NoKeychainError(this.runner.platform);
|
|
130
|
+
switch (this.runner.platform) {
|
|
131
|
+
case "darwin": {
|
|
132
|
+
const result = await this.runner.run(
|
|
133
|
+
"security",
|
|
134
|
+
["add-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-U", "-w"],
|
|
135
|
+
`${secret}
|
|
136
|
+
${secret}
|
|
137
|
+
`
|
|
138
|
+
);
|
|
139
|
+
if (result.code !== 0) throw new Error(`Keychain write failed: ${result.stderr.trim()}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
case "win32": {
|
|
143
|
+
const result = await this.runner.run(
|
|
144
|
+
"powershell",
|
|
145
|
+
["-NoProfile", "-Command", WINDOWS_STORE],
|
|
146
|
+
secret
|
|
147
|
+
);
|
|
148
|
+
if (result.code !== 0) throw new Error(`DPAPI write failed: ${result.stderr.trim()}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
default: {
|
|
152
|
+
const result = await this.runner.run(
|
|
153
|
+
"secret-tool",
|
|
154
|
+
["store", "--label=evrex capture credential", "service", SERVICE, "account", ACCOUNT],
|
|
155
|
+
secret
|
|
156
|
+
);
|
|
157
|
+
if (result.code !== 0) throw new Error(`secret-tool write failed: ${result.stderr.trim()}`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Null when there is nothing stored, which is the normal pre-enrolment state. */
|
|
163
|
+
async retrieve() {
|
|
164
|
+
if (!await this.available()) throw new NoKeychainError(this.runner.platform);
|
|
165
|
+
switch (this.runner.platform) {
|
|
166
|
+
case "darwin": {
|
|
167
|
+
const r = await this.runner.run("security", [
|
|
168
|
+
"find-generic-password",
|
|
169
|
+
"-a",
|
|
170
|
+
ACCOUNT,
|
|
171
|
+
"-s",
|
|
172
|
+
SERVICE,
|
|
173
|
+
"-w"
|
|
174
|
+
]);
|
|
175
|
+
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
176
|
+
}
|
|
177
|
+
case "win32": {
|
|
178
|
+
const r = await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_RETRIEVE]);
|
|
179
|
+
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
180
|
+
}
|
|
181
|
+
default: {
|
|
182
|
+
const r = await this.runner.run("secret-tool", [
|
|
183
|
+
"lookup",
|
|
184
|
+
"service",
|
|
185
|
+
SERVICE,
|
|
186
|
+
"account",
|
|
187
|
+
ACCOUNT
|
|
188
|
+
]);
|
|
189
|
+
return r.code === 0 ? r.stdout.trim() || null : null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Idempotent: removing a credential that is not there is not an error. */
|
|
194
|
+
async remove() {
|
|
195
|
+
if (!await this.available()) return;
|
|
196
|
+
switch (this.runner.platform) {
|
|
197
|
+
case "darwin":
|
|
198
|
+
await this.runner.run("security", [
|
|
199
|
+
"delete-generic-password",
|
|
200
|
+
"-a",
|
|
201
|
+
ACCOUNT,
|
|
202
|
+
"-s",
|
|
203
|
+
SERVICE
|
|
204
|
+
]);
|
|
205
|
+
return;
|
|
206
|
+
case "win32":
|
|
207
|
+
await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_REMOVE]);
|
|
208
|
+
return;
|
|
209
|
+
default:
|
|
210
|
+
await this.runner.run("secret-tool", [
|
|
211
|
+
"clear",
|
|
212
|
+
"service",
|
|
213
|
+
SERVICE,
|
|
214
|
+
"account",
|
|
215
|
+
ACCOUNT
|
|
216
|
+
]);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
WINDOWS_PATH = "$env:APPDATA\\evrex\\capture.cred";
|
|
222
|
+
WINDOWS_STORE = `
|
|
223
|
+
$ErrorActionPreference = 'Stop'
|
|
224
|
+
$p = "${WINDOWS_PATH}"
|
|
225
|
+
New-Item -ItemType Directory -Force -Path (Split-Path $p) | Out-Null
|
|
226
|
+
$secret = [Console]::In.ReadToEnd().Trim()
|
|
227
|
+
ConvertTo-SecureString $secret -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -Path $p
|
|
228
|
+
`.trim();
|
|
229
|
+
WINDOWS_RETRIEVE = `
|
|
230
|
+
$ErrorActionPreference = 'Stop'
|
|
231
|
+
$p = "${WINDOWS_PATH}"
|
|
232
|
+
if (-not (Test-Path $p)) { exit 1 }
|
|
233
|
+
$sec = Get-Content $p | ConvertTo-SecureString
|
|
234
|
+
[Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
|
235
|
+
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
|
|
236
|
+
`.trim();
|
|
237
|
+
WINDOWS_REMOVE = `
|
|
238
|
+
$p = "${WINDOWS_PATH}"
|
|
239
|
+
if (Test-Path $p) { Remove-Item $p -Force }
|
|
240
|
+
`.trim();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// src/tickets.ts
|
|
245
|
+
import { realpathSync } from "node:fs";
|
|
246
|
+
import { fileURLToPath } from "node:url";
|
|
247
|
+
|
|
248
|
+
// ../../packages/ingest-core/src/types.ts
|
|
249
|
+
var CONVERSATION_KINDS = [
|
|
250
|
+
"claude-code",
|
|
251
|
+
"cursor",
|
|
252
|
+
"codex",
|
|
253
|
+
"gemini",
|
|
254
|
+
"slack"
|
|
255
|
+
];
|
|
256
|
+
var REFERENCE_KINDS = ["linear", "jira", "confluence"];
|
|
257
|
+
var SOURCE_KINDS = [
|
|
258
|
+
...CONVERSATION_KINDS,
|
|
259
|
+
...REFERENCE_KINDS
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
// ../../packages/ingest-core/src/sanitize.ts
|
|
263
|
+
var NUL = String.fromCharCode(0);
|
|
264
|
+
|
|
265
|
+
// ../../packages/ingest-core/src/linear.ts
|
|
266
|
+
var LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
267
|
+
var PRIORITY = {
|
|
268
|
+
0: "No priority",
|
|
269
|
+
1: "Urgent",
|
|
270
|
+
2: "High",
|
|
271
|
+
3: "Medium",
|
|
272
|
+
4: "Low"
|
|
273
|
+
};
|
|
274
|
+
var STATE_TYPE = {
|
|
275
|
+
triage: "triage",
|
|
276
|
+
backlog: "backlog",
|
|
277
|
+
unstarted: "todo",
|
|
278
|
+
started: "in-progress",
|
|
279
|
+
completed: "done",
|
|
280
|
+
canceled: "cancelled"
|
|
281
|
+
};
|
|
282
|
+
var LINEAR_ISSUES_QUERY = `
|
|
283
|
+
query EvrexIssues($first: Int!, $after: String) {
|
|
284
|
+
issues(first: $first, after: $after, orderBy: updatedAt) {
|
|
285
|
+
nodes {
|
|
286
|
+
identifier
|
|
287
|
+
title
|
|
288
|
+
url
|
|
289
|
+
priority
|
|
290
|
+
createdAt
|
|
291
|
+
updatedAt
|
|
292
|
+
state { name type }
|
|
293
|
+
assignee { id displayName }
|
|
294
|
+
team { key name }
|
|
295
|
+
project { name }
|
|
296
|
+
}
|
|
297
|
+
pageInfo { hasNextPage endCursor }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
`;
|
|
301
|
+
function linearIssueToTicket(issue) {
|
|
302
|
+
if (!issue.identifier) return null;
|
|
303
|
+
const attributes = {
|
|
304
|
+
title: issue.title ?? issue.identifier,
|
|
305
|
+
status: issue.state?.name ?? null,
|
|
306
|
+
statusCategory: issue.state?.type ? STATE_TYPE[issue.state.type] ?? null : null,
|
|
307
|
+
assignee: issue.assignee?.displayName ?? null,
|
|
308
|
+
assigneeId: issue.assignee?.id ?? null,
|
|
309
|
+
team: issue.team?.key ?? null,
|
|
310
|
+
project: issue.project?.name ?? null,
|
|
311
|
+
// Linear has one kind of issue; the field exists so a Jira row and a
|
|
312
|
+
// Linear row are the same shape, which is what U5 exists to prove.
|
|
313
|
+
issueType: null,
|
|
314
|
+
// Null rather than "No priority" when the field is absent: unset and
|
|
315
|
+
// explicitly-not-prioritised are different claims.
|
|
316
|
+
priority: typeof issue.priority === "number" ? PRIORITY[issue.priority] ?? null : null,
|
|
317
|
+
// Linear records completion as a state, not as a separate resolution.
|
|
318
|
+
resolution: null
|
|
319
|
+
};
|
|
320
|
+
return {
|
|
321
|
+
externalId: issue.identifier,
|
|
322
|
+
kind: "linear",
|
|
323
|
+
url: issue.url ?? `https://linear.app/issue/${issue.identifier}`,
|
|
324
|
+
createdAt: issue.createdAt ?? null,
|
|
325
|
+
updatedAt: issue.updatedAt ?? null,
|
|
326
|
+
attributes
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function linearAuthHeader(auth) {
|
|
330
|
+
if (auth.accessToken) return `Bearer ${auth.accessToken}`;
|
|
331
|
+
if (auth.apiKey) return auth.apiKey;
|
|
332
|
+
throw new Error(
|
|
333
|
+
"Linear needs either an API key or an OAuth access token; got neither."
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
async function fetchLinearIssues(options) {
|
|
337
|
+
const call = options.fetch ?? globalThis.fetch;
|
|
338
|
+
const pageSize = options.pageSize ?? 50;
|
|
339
|
+
const limit = options.limit ?? Infinity;
|
|
340
|
+
const tickets = [];
|
|
341
|
+
let after = null;
|
|
342
|
+
while (tickets.length < limit) {
|
|
343
|
+
const response = await call(LINEAR_GRAPHQL_URL, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: {
|
|
346
|
+
Authorization: linearAuthHeader(options.auth),
|
|
347
|
+
"Content-Type": "application/json"
|
|
348
|
+
},
|
|
349
|
+
body: JSON.stringify({
|
|
350
|
+
query: LINEAR_ISSUES_QUERY,
|
|
351
|
+
variables: {
|
|
352
|
+
first: Math.min(pageSize, limit - tickets.length),
|
|
353
|
+
after
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
});
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`Linear returned ${response.status} ${response.statusText}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const body = await response.json();
|
|
363
|
+
if (body.errors?.length) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Linear rejected the query: ${body.errors.map((e) => e.message).join("; ")}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const page = body.data?.issues;
|
|
369
|
+
for (const node of page?.nodes ?? []) {
|
|
370
|
+
const ticket = linearIssueToTicket(node);
|
|
371
|
+
if (ticket) tickets.push(ticket);
|
|
372
|
+
}
|
|
373
|
+
if (!page?.pageInfo?.hasNextPage || !page.pageInfo.endCursor) break;
|
|
374
|
+
after = page.pageInfo.endCursor;
|
|
375
|
+
}
|
|
376
|
+
return tickets;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ../../packages/ingest-core/src/jira.ts
|
|
380
|
+
var JIRA_SEARCH_PATH = "/rest/api/3/search/jql";
|
|
381
|
+
var JIRA_FIELDS = [
|
|
382
|
+
"summary",
|
|
383
|
+
"status",
|
|
384
|
+
"assignee",
|
|
385
|
+
"resolution",
|
|
386
|
+
"created",
|
|
387
|
+
"updated",
|
|
388
|
+
"project",
|
|
389
|
+
"issuetype",
|
|
390
|
+
"priority"
|
|
391
|
+
];
|
|
392
|
+
var STATUS_CATEGORY = {
|
|
393
|
+
new: "todo",
|
|
394
|
+
indeterminate: "in-progress",
|
|
395
|
+
done: "done"
|
|
396
|
+
};
|
|
397
|
+
function jiraIssueToTicket(issue, siteUrl) {
|
|
398
|
+
if (!issue.key) return null;
|
|
399
|
+
const f = issue.fields ?? {};
|
|
400
|
+
const attributes = {
|
|
401
|
+
title: f.summary ?? issue.key,
|
|
402
|
+
status: f.status?.name ?? null,
|
|
403
|
+
statusCategory: f.status?.statusCategory?.key ? STATUS_CATEGORY[f.status.statusCategory.key] ?? null : null,
|
|
404
|
+
// Null when the assignee's privacy settings withhold it, which is not the
|
|
405
|
+
// same claim as an unassigned ticket — `assigneeId` distinguishes them.
|
|
406
|
+
assignee: f.assignee?.displayName ?? null,
|
|
407
|
+
assigneeId: f.assignee?.accountId ?? null,
|
|
408
|
+
// Jira has no team on an issue; the project is the closest equivalent and
|
|
409
|
+
// is reported as itself rather than smuggled into `team`.
|
|
410
|
+
team: null,
|
|
411
|
+
project: f.project?.key ?? null,
|
|
412
|
+
issueType: f.issuetype?.name ?? null,
|
|
413
|
+
priority: f.priority?.name ?? null,
|
|
414
|
+
resolution: f.resolution?.name ?? null
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
externalId: issue.key,
|
|
418
|
+
kind: "jira",
|
|
419
|
+
url: `${siteUrl.replace(/\/+$/, "")}/browse/${issue.key}`,
|
|
420
|
+
createdAt: f.created ?? null,
|
|
421
|
+
updatedAt: f.updated ?? null,
|
|
422
|
+
attributes
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function jiraBasicAuth(email, apiToken) {
|
|
426
|
+
return `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
|
|
427
|
+
}
|
|
428
|
+
async function fetchJiraIssues(options) {
|
|
429
|
+
const call = options.fetch ?? globalThis.fetch;
|
|
430
|
+
const site = options.siteUrl.replace(/\/+$/, "");
|
|
431
|
+
const pageSize = options.pageSize ?? 100;
|
|
432
|
+
const limit = options.limit ?? Infinity;
|
|
433
|
+
const jql = options.jql ?? "ORDER BY updated DESC";
|
|
434
|
+
const tickets = [];
|
|
435
|
+
let nextPageToken = null;
|
|
436
|
+
while (tickets.length < limit) {
|
|
437
|
+
const response = await call(`${site}${JIRA_SEARCH_PATH}`, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: {
|
|
440
|
+
Authorization: jiraBasicAuth(options.email, options.apiToken),
|
|
441
|
+
"Content-Type": "application/json",
|
|
442
|
+
Accept: "application/json"
|
|
443
|
+
},
|
|
444
|
+
body: JSON.stringify({
|
|
445
|
+
jql,
|
|
446
|
+
fields: [...JIRA_FIELDS],
|
|
447
|
+
maxResults: Math.min(pageSize, limit - tickets.length),
|
|
448
|
+
...nextPageToken ? { nextPageToken } : {}
|
|
449
|
+
})
|
|
450
|
+
});
|
|
451
|
+
if (!response.ok) {
|
|
452
|
+
const hint = response.status === 410 ? ` \u2014 that is what the removed /rest/api/3/search returns; this client uses ${JIRA_SEARCH_PATH}` : "";
|
|
453
|
+
throw new Error(
|
|
454
|
+
`Jira returned ${response.status} ${response.statusText}${hint}`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
const body = await response.json();
|
|
458
|
+
for (const issue of body.issues ?? []) {
|
|
459
|
+
const ticket = jiraIssueToTicket(issue, site);
|
|
460
|
+
if (ticket) tickets.push(ticket);
|
|
461
|
+
}
|
|
462
|
+
if (!body.nextPageToken) break;
|
|
463
|
+
nextPageToken = body.nextPageToken;
|
|
464
|
+
}
|
|
465
|
+
return tickets;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/batch.ts
|
|
469
|
+
var MAX_BATCH_BYTES = 24 * 1024 * 1024;
|
|
470
|
+
function measure(items) {
|
|
471
|
+
return items.map((item) => ({
|
|
472
|
+
item,
|
|
473
|
+
bytes: Buffer.byteLength(JSON.stringify(item), "utf8")
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
function batchByBytes(items, maxBytes = MAX_BATCH_BYTES) {
|
|
477
|
+
const batches = [];
|
|
478
|
+
let current = [];
|
|
479
|
+
let size = 0;
|
|
480
|
+
for (const { item, bytes } of measure(items)) {
|
|
481
|
+
if (current.length > 0 && size + bytes > maxBytes) {
|
|
482
|
+
batches.push(current);
|
|
483
|
+
current = [];
|
|
484
|
+
size = 0;
|
|
485
|
+
}
|
|
486
|
+
current.push(item);
|
|
487
|
+
size += bytes;
|
|
488
|
+
}
|
|
489
|
+
if (current.length > 0) batches.push(current);
|
|
490
|
+
return batches;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// src/tickets.ts
|
|
494
|
+
var CONNECTORS = [
|
|
495
|
+
{
|
|
496
|
+
kind: "linear",
|
|
497
|
+
requires: ["EVREX_LINEAR_API_KEY", "EVREX_LINEAR_WORKSPACE"],
|
|
498
|
+
workspace: (env) => env.EVREX_LINEAR_WORKSPACE,
|
|
499
|
+
hint: "Set EVREX_LINEAR_API_KEY to a Linear API key (Settings -> Security & access -> Personal API keys) and EVREX_LINEAR_WORKSPACE to your workspace's URL key.",
|
|
500
|
+
collect: (env, deps) => (deps.fetchLinear ?? fetchLinearIssues)({
|
|
501
|
+
auth: { apiKey: env.EVREX_LINEAR_API_KEY },
|
|
502
|
+
limit: numeric(env.EVREX_TICKET_LIMIT)
|
|
503
|
+
})
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
kind: "jira",
|
|
507
|
+
requires: ["EVREX_JIRA_SITE", "EVREX_JIRA_EMAIL", "EVREX_JIRA_API_TOKEN"],
|
|
508
|
+
// The site host, which is what makes PROJ-1 on two Jira sites two tickets.
|
|
509
|
+
workspace: (env) => env.EVREX_JIRA_SITE.replace(/^https?:\/\//, "").replace(/\/+$/, ""),
|
|
510
|
+
hint: "Set EVREX_JIRA_SITE (https://acme.atlassian.net), EVREX_JIRA_EMAIL to your Atlassian account email, and EVREX_JIRA_API_TOKEN to a token from id.atlassian.com/manage-profile/security/api-tokens.",
|
|
511
|
+
collect: (env, deps) => (deps.fetchJira ?? fetchJiraIssues)({
|
|
512
|
+
siteUrl: env.EVREX_JIRA_SITE,
|
|
513
|
+
email: env.EVREX_JIRA_EMAIL,
|
|
514
|
+
apiToken: env.EVREX_JIRA_API_TOKEN,
|
|
515
|
+
jql: env.EVREX_JIRA_JQL,
|
|
516
|
+
limit: numeric(env.EVREX_TICKET_LIMIT)
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
];
|
|
520
|
+
function numeric(value) {
|
|
521
|
+
const n = Number(value);
|
|
522
|
+
return Number.isFinite(n) && n > 0 ? n : void 0;
|
|
523
|
+
}
|
|
524
|
+
async function syncTickets(deps) {
|
|
525
|
+
const result = {
|
|
526
|
+
delivered: {},
|
|
527
|
+
configured: [],
|
|
528
|
+
failed: false
|
|
529
|
+
};
|
|
530
|
+
for (const connector of CONNECTORS) {
|
|
531
|
+
const present = connector.requires.filter((key) => deps.env[key]);
|
|
532
|
+
if (present.length === 0) continue;
|
|
533
|
+
if (present.length < connector.requires.length) {
|
|
534
|
+
const missing = connector.requires.filter((key) => !deps.env[key]);
|
|
535
|
+
deps.log(
|
|
536
|
+
` ${connector.kind}: half-configured, missing ${missing.join(", ")}.
|
|
537
|
+
${connector.hint}`
|
|
538
|
+
);
|
|
539
|
+
result.failed = true;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
result.configured.push(connector.kind);
|
|
543
|
+
const workspace = connector.workspace(deps.env);
|
|
544
|
+
let tickets;
|
|
545
|
+
try {
|
|
546
|
+
tickets = await connector.collect(deps.env, deps);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
deps.log(` ${connector.kind}: ${error.message}`);
|
|
549
|
+
result.failed = true;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
let sent = 0;
|
|
553
|
+
for (const batch of batchByBytes(tickets)) {
|
|
554
|
+
const ok = await deps.post("/ingest/tickets", {
|
|
555
|
+
kind: connector.kind,
|
|
556
|
+
workspace,
|
|
557
|
+
tickets: batch.map((t) => ({
|
|
558
|
+
externalId: t.externalId,
|
|
559
|
+
url: t.url,
|
|
560
|
+
createdAt: t.createdAt,
|
|
561
|
+
updatedAt: t.updatedAt,
|
|
562
|
+
attributes: t.attributes
|
|
563
|
+
}))
|
|
564
|
+
});
|
|
565
|
+
if (!ok) {
|
|
566
|
+
result.failed = true;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
sent += batch.length;
|
|
570
|
+
deps.log(` ${connector.kind} ${sent}/${tickets.length}`);
|
|
571
|
+
}
|
|
572
|
+
result.delivered[connector.kind] = sent;
|
|
573
|
+
}
|
|
574
|
+
if (result.configured.length === 0 && !result.failed) {
|
|
575
|
+
deps.log(
|
|
576
|
+
"No ticket connector is configured. Set one of:\n" + CONNECTORS.map((c) => ` ${c.kind}: ${c.requires.join(", ")}`).join("\n")
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
return result;
|
|
580
|
+
}
|
|
581
|
+
async function main() {
|
|
582
|
+
const { evrexApi: evrexApi2 } = await Promise.resolve().then(() => (init_client(), client_exports));
|
|
583
|
+
const { CredentialStore: CredentialStore2 } = await Promise.resolve().then(() => (init_credential_store(), credential_store_exports));
|
|
584
|
+
const token = process.env.EVREX_TOKEN ?? await new CredentialStore2().retrieve().catch(() => null);
|
|
585
|
+
if (!token) {
|
|
586
|
+
console.error(
|
|
587
|
+
"evrex: this machine is not enrolled.\n Run `npx -y evrex-mcp enrol` first, or set EVREX_TOKEN."
|
|
588
|
+
);
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
const result = await syncTickets({
|
|
592
|
+
env: process.env,
|
|
593
|
+
log: (line) => console.error(line),
|
|
594
|
+
post: async (path, body) => {
|
|
595
|
+
try {
|
|
596
|
+
const res = await fetch(`${evrexApi2.baseUrl}${path}`, {
|
|
597
|
+
method: "POST",
|
|
598
|
+
headers: {
|
|
599
|
+
"content-type": "application/json",
|
|
600
|
+
authorization: `Bearer ${token}`
|
|
601
|
+
},
|
|
602
|
+
body: JSON.stringify(body)
|
|
603
|
+
});
|
|
604
|
+
if (!res.ok) console.error(` ${path} -> ${res.status}`);
|
|
605
|
+
return res.ok;
|
|
606
|
+
} catch (error) {
|
|
607
|
+
console.error(` ${path} -> ${error.message}`);
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
const total = Object.values(result.delivered).reduce((n, d) => n + d, 0);
|
|
613
|
+
if (result.configured.length > 0) {
|
|
614
|
+
console.error(`
|
|
615
|
+
${total} tickets from ${result.configured.join(", ")}`);
|
|
616
|
+
}
|
|
617
|
+
process.exit(result.failed ? 1 : 0);
|
|
618
|
+
}
|
|
619
|
+
function isMain() {
|
|
620
|
+
const invoked = process.argv[1];
|
|
621
|
+
if (!invoked) return false;
|
|
622
|
+
try {
|
|
623
|
+
return realpathSync(invoked) === fileURLToPath(import.meta.url);
|
|
624
|
+
} catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (isMain()) {
|
|
629
|
+
main().catch((error) => {
|
|
630
|
+
console.error("evrex tickets failed:", error);
|
|
631
|
+
process.exit(1);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
export {
|
|
635
|
+
syncTickets
|
|
636
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evrex-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "MCP server that gives coding agents the recorded reasoning behind a repo: prior decisions, hard constraints, and approaches already rejected.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
"type": "module",
|
|
14
14
|
"main": "./dist/index.js",
|
|
15
15
|
"bin": {
|
|
16
|
-
"evrex-mcp": "dist/index.js"
|
|
16
|
+
"evrex-mcp": "dist/index.js",
|
|
17
|
+
"evrex-hook": "dist/hook.js",
|
|
18
|
+
"evrex-capture": "dist/capture.js",
|
|
19
|
+
"evrex-import": "dist/import.js",
|
|
20
|
+
"evrex-tickets": "dist/tickets.js"
|
|
17
21
|
},
|
|
18
22
|
"files": [
|
|
19
23
|
"dist/index.js",
|
|
24
|
+
"dist/hook.js",
|
|
25
|
+
"dist/capture.js",
|
|
26
|
+
"dist/import.js",
|
|
27
|
+
"dist/tickets.js",
|
|
20
28
|
"README.md"
|
|
21
29
|
],
|
|
22
30
|
"engines": {
|
|
@@ -38,6 +46,7 @@
|
|
|
38
46
|
},
|
|
39
47
|
"devDependencies": {
|
|
40
48
|
"@repo/eslint-config": "workspace:*",
|
|
49
|
+
"@repo/ingest-core": "workspace:*",
|
|
41
50
|
"@repo/llm-core": "workspace:*",
|
|
42
51
|
"@repo/typescript-config": "workspace:*",
|
|
43
52
|
"@types/node": "^24.0.0",
|