@loupekit/mcp 0.10.20 → 0.10.21-next.67
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/index.js +553 -8
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -994,6 +994,455 @@ The element's text, id, aria-label and non-utility classes were all searched. If
|
|
|
994
994
|
};
|
|
995
995
|
}
|
|
996
996
|
|
|
997
|
+
// src/tools/handoff.ts
|
|
998
|
+
var NO_THREAD = (id) => `No thread found for \`${id}\`.
|
|
999
|
+
|
|
1000
|
+
Use \`list_comments\` to see the ids.`;
|
|
1001
|
+
function createHandoffTools(deps) {
|
|
1002
|
+
return {
|
|
1003
|
+
/**
|
|
1004
|
+
* Mark a thread addressed: it moves to In Review and the human is notified.
|
|
1005
|
+
*
|
|
1006
|
+
* There is no way to resolve a thread through this tool, by design.
|
|
1007
|
+
*/
|
|
1008
|
+
async markThreadAddressed({ thread_id, message }) {
|
|
1009
|
+
const thread = await deps.fetchThread(thread_id);
|
|
1010
|
+
if (!thread) return { text: NO_THREAD(thread_id), unavailable: true };
|
|
1011
|
+
await deps.patchThread(thread_id, { status: "in_review" });
|
|
1012
|
+
let posted = "";
|
|
1013
|
+
if (message && message.trim()) {
|
|
1014
|
+
await deps.postMessage(thread_id, {
|
|
1015
|
+
author: { ...deps.agent, type: "agent" },
|
|
1016
|
+
body: message.trim()
|
|
1017
|
+
});
|
|
1018
|
+
posted = " Your note is on the thread.";
|
|
1019
|
+
}
|
|
1020
|
+
return {
|
|
1021
|
+
text: `#${thread_id} \u2192 In Review.${posted}
|
|
1022
|
+
|
|
1023
|
+
A person has to look at it now \u2014 that is the only way a thread closes. If there is a preview, put the URL in your note so they can check it in place.`
|
|
1024
|
+
};
|
|
1025
|
+
},
|
|
1026
|
+
/**
|
|
1027
|
+
* Post a reply without changing the status. For progress notes, questions, and
|
|
1028
|
+
* anything else that is not a handoff.
|
|
1029
|
+
*/
|
|
1030
|
+
async addThreadMessage({ thread_id, message }) {
|
|
1031
|
+
if (!message || !message.trim()) {
|
|
1032
|
+
return { text: "A message body is required \u2014 there is nothing to post.", unavailable: true };
|
|
1033
|
+
}
|
|
1034
|
+
const thread = await deps.fetchThread(thread_id);
|
|
1035
|
+
if (!thread) return { text: NO_THREAD(thread_id), unavailable: true };
|
|
1036
|
+
await deps.postMessage(thread_id, { author: { ...deps.agent, type: "agent" }, body: message.trim() });
|
|
1037
|
+
return {
|
|
1038
|
+
text: `Posted to #${thread_id} as ${deps.agent.name}. The status is unchanged (${thread.status}).`
|
|
1039
|
+
};
|
|
1040
|
+
},
|
|
1041
|
+
/** The full conversation: the original request as message #1, then the replies. */
|
|
1042
|
+
async getThreadConversation({ thread_id }) {
|
|
1043
|
+
const thread = await deps.fetchThread(thread_id);
|
|
1044
|
+
if (!thread) return { text: NO_THREAD(thread_id), unavailable: true };
|
|
1045
|
+
const replies = await deps.listMessages(thread_id);
|
|
1046
|
+
const lines = [
|
|
1047
|
+
`# #${thread_id} \u2014 ${thread.title ?? thread.body.split("\n")[0] ?? ""}`,
|
|
1048
|
+
"",
|
|
1049
|
+
`Status: ${thread.status} \xB7 ${replies.length} repl${replies.length === 1 ? "y" : "ies"}`,
|
|
1050
|
+
"",
|
|
1051
|
+
`**${thread.author.name}** (original request):`,
|
|
1052
|
+
"",
|
|
1053
|
+
thread.body
|
|
1054
|
+
];
|
|
1055
|
+
for (const r of replies) {
|
|
1056
|
+
lines.push("", `**${r.author.name}** (${r.author.type}) \u2014 ${r.createdAt}:`, "", r.body);
|
|
1057
|
+
}
|
|
1058
|
+
if (!replies.length) lines.push("", "_No replies yet._");
|
|
1059
|
+
return { text: lines.join("\n") };
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/tools/create-pr.ts
|
|
1065
|
+
var FIXES_SENTINEL = "<!-- loupe:fixes -->";
|
|
1066
|
+
var PR_TITLE = "Feedback Fixes";
|
|
1067
|
+
function cell(text) {
|
|
1068
|
+
return text.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
|
|
1069
|
+
}
|
|
1070
|
+
function fixRow(row) {
|
|
1071
|
+
const commit = row.commit ? `\`${row.commit.slice(0, 7)}\`` : "\u2014";
|
|
1072
|
+
return `| \`${cell(row.threadId)}\` | ${cell(row.description)} | ${commit} |`;
|
|
1073
|
+
}
|
|
1074
|
+
var TABLE_HEADER = ["| Thread | Fix | Commit |", "| --- | --- | --- |"].join("\n");
|
|
1075
|
+
function initialPrBody(opts = {}) {
|
|
1076
|
+
const rows = opts.rows ?? [];
|
|
1077
|
+
return [
|
|
1078
|
+
opts.intro ?? "Fixes from your team's feedback. Each row is one thread.",
|
|
1079
|
+
"",
|
|
1080
|
+
"## Fixes",
|
|
1081
|
+
"",
|
|
1082
|
+
TABLE_HEADER,
|
|
1083
|
+
...rows.map(fixRow),
|
|
1084
|
+
"",
|
|
1085
|
+
FIXES_SENTINEL,
|
|
1086
|
+
""
|
|
1087
|
+
].join("\n");
|
|
1088
|
+
}
|
|
1089
|
+
function appendFixRow(body, row, opts = {}) {
|
|
1090
|
+
const line = fixRow(row);
|
|
1091
|
+
const at = body.indexOf(FIXES_SENTINEL);
|
|
1092
|
+
const withNote = opts.revisionNote ? `${line}
|
|
1093
|
+
|
|
1094
|
+
> ${opts.revisionNote}` : line;
|
|
1095
|
+
if (at < 0) {
|
|
1096
|
+
return `${body.replace(/\s*$/, "")}
|
|
1097
|
+
|
|
1098
|
+
${withNote}
|
|
1099
|
+
|
|
1100
|
+
${FIXES_SENTINEL}
|
|
1101
|
+
`;
|
|
1102
|
+
}
|
|
1103
|
+
const before = body.slice(0, at);
|
|
1104
|
+
const after = body.slice(at);
|
|
1105
|
+
return `${before.replace(/\s*$/, "")}
|
|
1106
|
+
${withNote}
|
|
1107
|
+
|
|
1108
|
+
${after}`;
|
|
1109
|
+
}
|
|
1110
|
+
function countFixRows(body) {
|
|
1111
|
+
const at = body.indexOf(FIXES_SENTINEL);
|
|
1112
|
+
const scope = at >= 0 ? body.slice(0, at) : body;
|
|
1113
|
+
return scope.split("\n").filter((l) => /^\|\s*`[^`]+`\s*\|/.test(l)).length;
|
|
1114
|
+
}
|
|
1115
|
+
function branchNameFor(args) {
|
|
1116
|
+
if (args.branchName) return args.branchName;
|
|
1117
|
+
if (args.revisionOf) return `revision-${args.revisionOf}`;
|
|
1118
|
+
return "loupe/fixes";
|
|
1119
|
+
}
|
|
1120
|
+
var CreatePrError = class extends Error {
|
|
1121
|
+
};
|
|
1122
|
+
async function createPrForThread(deps, args) {
|
|
1123
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
1124
|
+
if (!args.files.length) throw new CreatePrError("no file changes were given, so there is nothing to open a PR for");
|
|
1125
|
+
const base = args.baseBranch ?? "main";
|
|
1126
|
+
const branch = branchNameFor(args);
|
|
1127
|
+
const api2 = async (path, init) => {
|
|
1128
|
+
const res = await doFetch(`${deps.apiBase.replace(/\/$/, "")}${path}`, {
|
|
1129
|
+
...init,
|
|
1130
|
+
headers: { "Content-Type": "application/json", "X-Loupe-Admin": deps.adminKey, ...init?.headers ?? {} }
|
|
1131
|
+
});
|
|
1132
|
+
if (!res.ok) throw new CreatePrError(`${init?.method ?? "GET"} ${path} \u2192 ${res.status}`);
|
|
1133
|
+
return res.status === 204 ? null : res.json();
|
|
1134
|
+
};
|
|
1135
|
+
const existing = await api2(
|
|
1136
|
+
`/v1/working-branches?projectKey=${encodeURIComponent(deps.projectKey)}&repo=${encodeURIComponent(args.repo)}`
|
|
1137
|
+
);
|
|
1138
|
+
const tracked = existing.find((b) => b.branch === branch) ?? null;
|
|
1139
|
+
let startFresh = tracked === null || !!args.revisionOf;
|
|
1140
|
+
let outcome = "created";
|
|
1141
|
+
if (tracked && !args.revisionOf) {
|
|
1142
|
+
const stillOpen = tracked.prNumber ? await deps.github.isPullRequestOpen(args.repo, tracked.prNumber) : false;
|
|
1143
|
+
if (stillOpen) {
|
|
1144
|
+
startFresh = false;
|
|
1145
|
+
outcome = "appended";
|
|
1146
|
+
} else {
|
|
1147
|
+
await api2("/v1/working-branches", {
|
|
1148
|
+
method: "POST",
|
|
1149
|
+
body: JSON.stringify({
|
|
1150
|
+
projectKey: deps.projectKey,
|
|
1151
|
+
repo: args.repo,
|
|
1152
|
+
branch,
|
|
1153
|
+
status: "merged",
|
|
1154
|
+
prNumber: tracked.prNumber
|
|
1155
|
+
})
|
|
1156
|
+
});
|
|
1157
|
+
startFresh = true;
|
|
1158
|
+
outcome = "restarted";
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const row = { threadId: args.threadId, description: args.description };
|
|
1162
|
+
if (startFresh) {
|
|
1163
|
+
const commit2 = await deps.github.commitChanges(args.repo, branch, commitMessage(args, false), args.files, { base });
|
|
1164
|
+
row.commit = commit2.sha;
|
|
1165
|
+
const intro = args.revisionOf ? `Revision of thread \`${args.revisionOf}\`. The conversation and the original element context carry over.` : void 0;
|
|
1166
|
+
const pr2 = await deps.github.createPullRequest(args.repo, {
|
|
1167
|
+
title: PR_TITLE,
|
|
1168
|
+
head: branch,
|
|
1169
|
+
base,
|
|
1170
|
+
body: initialPrBody({ rows: [row], intro })
|
|
1171
|
+
});
|
|
1172
|
+
await api2("/v1/working-branches", {
|
|
1173
|
+
method: "POST",
|
|
1174
|
+
body: JSON.stringify({
|
|
1175
|
+
projectKey: deps.projectKey,
|
|
1176
|
+
repo: args.repo,
|
|
1177
|
+
branch,
|
|
1178
|
+
baseBranch: base,
|
|
1179
|
+
status: "open",
|
|
1180
|
+
headSha: commit2.sha,
|
|
1181
|
+
prNumber: pr2.number,
|
|
1182
|
+
prUrl: pr2.htmlUrl,
|
|
1183
|
+
addFix: true,
|
|
1184
|
+
description: args.description
|
|
1185
|
+
})
|
|
1186
|
+
});
|
|
1187
|
+
return {
|
|
1188
|
+
outcome,
|
|
1189
|
+
repo: args.repo,
|
|
1190
|
+
branch,
|
|
1191
|
+
commit: commit2.sha,
|
|
1192
|
+
prNumber: pr2.number,
|
|
1193
|
+
prUrl: pr2.htmlUrl,
|
|
1194
|
+
fixCount: 1,
|
|
1195
|
+
note: `Opened ${pr2.htmlUrl} on a new branch \`${branch}\` with this fix as its first commit.`
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
const commit = await deps.github.commitChanges(args.repo, branch, commitMessage(args, true), args.files);
|
|
1199
|
+
row.commit = commit.sha;
|
|
1200
|
+
const pr = await deps.github.getPullRequest(args.repo, tracked.prNumber);
|
|
1201
|
+
const body = appendFixRow(
|
|
1202
|
+
pr.body,
|
|
1203
|
+
row,
|
|
1204
|
+
args.revisionOf ? { revisionNote: `Revision of thread \`${args.revisionOf}\`.` } : {}
|
|
1205
|
+
);
|
|
1206
|
+
await deps.github.updatePullRequestBody(args.repo, pr.number, body);
|
|
1207
|
+
const record = await api2("/v1/working-branches", {
|
|
1208
|
+
method: "POST",
|
|
1209
|
+
body: JSON.stringify({
|
|
1210
|
+
projectKey: deps.projectKey,
|
|
1211
|
+
repo: args.repo,
|
|
1212
|
+
branch,
|
|
1213
|
+
status: "open",
|
|
1214
|
+
headSha: commit.sha,
|
|
1215
|
+
addFix: true,
|
|
1216
|
+
description: args.description
|
|
1217
|
+
})
|
|
1218
|
+
});
|
|
1219
|
+
return {
|
|
1220
|
+
outcome,
|
|
1221
|
+
repo: args.repo,
|
|
1222
|
+
branch,
|
|
1223
|
+
commit: commit.sha,
|
|
1224
|
+
prNumber: pr.number,
|
|
1225
|
+
prUrl: pr.htmlUrl,
|
|
1226
|
+
fixCount: countFixRows(body) || record.fixCount,
|
|
1227
|
+
note: `Appended this fix to ${pr.htmlUrl} as commit \`${commit.sha.slice(0, 7)}\` \u2014 one PR, now ${countFixRows(body) || record.fixCount} fix${(countFixRows(body) || record.fixCount) === 1 ? "" : "es"}.`
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function commitMessage(args, appending) {
|
|
1231
|
+
const verb = appending ? "fix" : "feat";
|
|
1232
|
+
const scope = args.repo.split("/")[1] ?? "app";
|
|
1233
|
+
return `${verb}(${scope}): ${args.description}
|
|
1234
|
+
|
|
1235
|
+
Addresses feedback thread ${args.threadId}.`;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/github/github-client.ts
|
|
1239
|
+
import { execFileSync } from "child_process";
|
|
1240
|
+
var DEFAULT_UA = "loupe-mcp (+https://github.com/mohamed-ashraf-elsaed/loupe)";
|
|
1241
|
+
var PLACEHOLDER_PATTERNS = [
|
|
1242
|
+
/^<.*>$/,
|
|
1243
|
+
// <your token>
|
|
1244
|
+
/(your|my|the)[\s._-]*(gh|github)?[\s._-]*(pat|token|key|secret)/,
|
|
1245
|
+
/^(?:x{4,}|y{4,}|z{4,}|0{4,}|\.{3,}|-{4,}|\*{4,})$/,
|
|
1246
|
+
/(change[\s._-]?me|placeholder|replace[\s._-]?me|todo|tbd)/,
|
|
1247
|
+
/\.\.\./
|
|
1248
|
+
];
|
|
1249
|
+
function isPlaceholderToken(token) {
|
|
1250
|
+
if (token == null) return true;
|
|
1251
|
+
const t = token.trim();
|
|
1252
|
+
if (!t) return true;
|
|
1253
|
+
if (t.length < 20) return true;
|
|
1254
|
+
const lower = t.toLowerCase();
|
|
1255
|
+
return PLACEHOLDER_PATTERNS.some((re) => re.test(lower));
|
|
1256
|
+
}
|
|
1257
|
+
var NO_TOKEN_MESSAGE = "No GitHub token is configured, so I cannot open a pull request.\n\nSet `GITHUB_TOKEN` in the MCP server's environment (a fine-grained PAT with Contents and Pull requests: read and write on the repository), or run `gh auth login`. Everything else works without it \u2014 the fix is still recorded on the thread.";
|
|
1258
|
+
function resolveGitHubToken(opts = {}) {
|
|
1259
|
+
const env = opts.env ?? process.env;
|
|
1260
|
+
const explicit = env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
1261
|
+
if (explicit && !isPlaceholderToken(explicit)) {
|
|
1262
|
+
return { token: explicit.trim(), source: "env", message: "Using GITHUB_TOKEN from the environment." };
|
|
1263
|
+
}
|
|
1264
|
+
const read = opts.readGhToken ?? readGhCliToken;
|
|
1265
|
+
const fromCli = read();
|
|
1266
|
+
if (fromCli && !isPlaceholderToken(fromCli)) {
|
|
1267
|
+
return { token: fromCli.trim(), source: "gh-cli", message: "Using the token from `gh auth token`." };
|
|
1268
|
+
}
|
|
1269
|
+
if (explicit) {
|
|
1270
|
+
return {
|
|
1271
|
+
token: null,
|
|
1272
|
+
source: "none",
|
|
1273
|
+
message: `GITHUB_TOKEN looks like a placeholder, so it was ignored. ${NO_TOKEN_MESSAGE}`
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
return { token: null, source: "none", message: NO_TOKEN_MESSAGE };
|
|
1277
|
+
}
|
|
1278
|
+
function readGhCliToken() {
|
|
1279
|
+
try {
|
|
1280
|
+
const out = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
1281
|
+
return out.trim() || null;
|
|
1282
|
+
} catch {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
var GitHubError = class extends Error {
|
|
1287
|
+
status;
|
|
1288
|
+
path;
|
|
1289
|
+
// Explicit fields, not constructor parameter properties: Node runs these files with
|
|
1290
|
+
// type-stripping only, and `constructor(private x)` is rejected there. There is a
|
|
1291
|
+
// test that scans for this pattern, because it has bitten twice.
|
|
1292
|
+
constructor(message, status, path) {
|
|
1293
|
+
super(message);
|
|
1294
|
+
this.name = "GitHubError";
|
|
1295
|
+
this.status = status;
|
|
1296
|
+
this.path = path;
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
var GitHubClient = class {
|
|
1300
|
+
baseUrl;
|
|
1301
|
+
fetchImpl;
|
|
1302
|
+
userAgent;
|
|
1303
|
+
token;
|
|
1304
|
+
constructor(token, opts = {}) {
|
|
1305
|
+
if (isPlaceholderToken(token)) throw new GitHubError("a real GitHub token is required", 0, "constructor");
|
|
1306
|
+
this.token = token.trim();
|
|
1307
|
+
this.baseUrl = (opts.baseUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
1308
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
1309
|
+
this.userAgent = opts.userAgent ?? DEFAULT_UA;
|
|
1310
|
+
}
|
|
1311
|
+
async request(method, path, body) {
|
|
1312
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
1313
|
+
method,
|
|
1314
|
+
headers: {
|
|
1315
|
+
Accept: "application/vnd.github+json",
|
|
1316
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
1317
|
+
"User-Agent": this.userAgent,
|
|
1318
|
+
Authorization: `Bearer ${this.token}`,
|
|
1319
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
1320
|
+
},
|
|
1321
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
1322
|
+
});
|
|
1323
|
+
const text = await res.text();
|
|
1324
|
+
if (!res.ok) {
|
|
1325
|
+
let detail = text;
|
|
1326
|
+
try {
|
|
1327
|
+
detail = JSON.parse(text).message ?? text;
|
|
1328
|
+
} catch {
|
|
1329
|
+
}
|
|
1330
|
+
throw new GitHubError(this.redact(`${method} ${path} \u2192 ${res.status}: ${detail}`), res.status, path);
|
|
1331
|
+
}
|
|
1332
|
+
return text ? JSON.parse(text) : null;
|
|
1333
|
+
}
|
|
1334
|
+
/** Belt and braces: the token must never reach a log or a tool result. */
|
|
1335
|
+
redact(text) {
|
|
1336
|
+
return this.token ? text.split(this.token).join("[redacted]") : text;
|
|
1337
|
+
}
|
|
1338
|
+
// ---- branches -------------------------------------------------------------
|
|
1339
|
+
async branchExists(repo, branch) {
|
|
1340
|
+
try {
|
|
1341
|
+
await this.request("GET", `/repos/${repo}/git/ref/heads/${encodeURIComponent(branch)}`);
|
|
1342
|
+
return true;
|
|
1343
|
+
} catch (e) {
|
|
1344
|
+
if (e instanceof GitHubError && e.status === 404) return false;
|
|
1345
|
+
throw e;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/** The commit sha a branch points at. */
|
|
1349
|
+
async branchHead(repo, branch) {
|
|
1350
|
+
const ref = await this.request(
|
|
1351
|
+
"GET",
|
|
1352
|
+
`/repos/${repo}/git/ref/heads/${encodeURIComponent(branch)}`
|
|
1353
|
+
);
|
|
1354
|
+
return ref.object.sha;
|
|
1355
|
+
}
|
|
1356
|
+
/** Create a branch from another, resolving the base to its current commit. */
|
|
1357
|
+
async createBranchFromBase(repo, branch, base) {
|
|
1358
|
+
const sha = await this.branchHead(repo, base);
|
|
1359
|
+
await this.request("POST", `/repos/${repo}/git/refs`, { ref: `refs/heads/${branch}`, sha });
|
|
1360
|
+
return sha;
|
|
1361
|
+
}
|
|
1362
|
+
// ---- commits --------------------------------------------------------------
|
|
1363
|
+
/**
|
|
1364
|
+
* Land every file change as exactly **one** commit on `branch`, creating the branch
|
|
1365
|
+
* from `base` first when it does not exist.
|
|
1366
|
+
*
|
|
1367
|
+
* blob(s) → tree → commit → ref, in that order, so a partial failure leaves the
|
|
1368
|
+
* repository untouched rather than half-updated.
|
|
1369
|
+
*/
|
|
1370
|
+
async commitChanges(repo, branch, message, changes, opts = {}) {
|
|
1371
|
+
if (!changes.length) throw new GitHubError("no file changes to commit", 0, "commitChanges");
|
|
1372
|
+
const created = !await this.branchExists(repo, branch);
|
|
1373
|
+
let parentSha;
|
|
1374
|
+
if (created) {
|
|
1375
|
+
const base = opts.base;
|
|
1376
|
+
if (!base) throw new GitHubError(`branch ${branch} does not exist and no base was given`, 0, "commitChanges");
|
|
1377
|
+
parentSha = await this.createBranchFromBase(repo, branch, base);
|
|
1378
|
+
} else {
|
|
1379
|
+
parentSha = await this.branchHead(repo, branch);
|
|
1380
|
+
}
|
|
1381
|
+
const parent = await this.request("GET", `/repos/${repo}/git/commits/${parentSha}`);
|
|
1382
|
+
const blobs = await Promise.all(changes.map(async (change) => {
|
|
1383
|
+
const blob = await this.request("POST", `/repos/${repo}/git/blobs`, {
|
|
1384
|
+
content: change.content,
|
|
1385
|
+
encoding: "utf-8"
|
|
1386
|
+
});
|
|
1387
|
+
return { path: change.path, mode: "100644", type: "blob", sha: blob.sha };
|
|
1388
|
+
}));
|
|
1389
|
+
const tree = await this.request("POST", `/repos/${repo}/git/trees`, {
|
|
1390
|
+
base_tree: parent.tree.sha,
|
|
1391
|
+
tree: blobs
|
|
1392
|
+
});
|
|
1393
|
+
const commit = await this.request("POST", `/repos/${repo}/git/commits`, {
|
|
1394
|
+
message,
|
|
1395
|
+
tree: tree.sha,
|
|
1396
|
+
parents: [parentSha]
|
|
1397
|
+
});
|
|
1398
|
+
await this.request("PATCH", `/repos/${repo}/git/refs/heads/${encodeURIComponent(branch)}`, { sha: commit.sha });
|
|
1399
|
+
return { sha: commit.sha, created, files: changes.length };
|
|
1400
|
+
}
|
|
1401
|
+
// ---- pull requests --------------------------------------------------------
|
|
1402
|
+
async createPullRequest(repo, opts) {
|
|
1403
|
+
const pr = await this.request("POST", `/repos/${repo}/pulls`, {
|
|
1404
|
+
title: opts.title,
|
|
1405
|
+
head: opts.head,
|
|
1406
|
+
base: opts.base,
|
|
1407
|
+
body: opts.body ?? "",
|
|
1408
|
+
draft: opts.draft ?? false
|
|
1409
|
+
});
|
|
1410
|
+
return toPullRequest(pr);
|
|
1411
|
+
}
|
|
1412
|
+
async getPullRequest(repo, number) {
|
|
1413
|
+
return toPullRequest(await this.request("GET", `/repos/${repo}/pulls/${number}`));
|
|
1414
|
+
}
|
|
1415
|
+
/** The PR body, for appending a fix to an accumulating PR. */
|
|
1416
|
+
async getPullRequestBody(repo, number) {
|
|
1417
|
+
return (await this.getPullRequest(repo, number)).body;
|
|
1418
|
+
}
|
|
1419
|
+
async updatePullRequestBody(repo, number, body) {
|
|
1420
|
+
await this.request("PATCH", `/repos/${repo}/pulls/${number}`, { body });
|
|
1421
|
+
}
|
|
1422
|
+
async isPullRequestOpen(repo, number) {
|
|
1423
|
+
return (await this.getPullRequest(repo, number)).state === "open";
|
|
1424
|
+
}
|
|
1425
|
+
/** Find an open PR for a branch, if there is one. */
|
|
1426
|
+
async findOpenPullRequest(repo, head, base) {
|
|
1427
|
+
const q = new URLSearchParams({ head: `${repo.split("/")[0]}:${head}`, state: "open" });
|
|
1428
|
+
if (base) q.set("base", base);
|
|
1429
|
+
const list = await this.request("GET", `/repos/${repo}/pulls?${q}`);
|
|
1430
|
+
return list.length ? toPullRequest(list[0]) : null;
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
function toPullRequest(raw) {
|
|
1434
|
+
return {
|
|
1435
|
+
number: raw.number,
|
|
1436
|
+
url: raw.url,
|
|
1437
|
+
htmlUrl: raw.html_url,
|
|
1438
|
+
state: raw.state,
|
|
1439
|
+
head: raw.head?.ref ?? "",
|
|
1440
|
+
base: raw.base?.ref ?? "",
|
|
1441
|
+
title: raw.title ?? "",
|
|
1442
|
+
body: raw.body ?? ""
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
997
1446
|
// index.ts
|
|
998
1447
|
import {
|
|
999
1448
|
CHANGE_TYPE_LABELS,
|
|
@@ -1163,7 +1612,7 @@ async function proposeChange({ id, html, css, notes }) {
|
|
|
1163
1612
|
bus.publishThread(id, "preview_live", { author: proposal.author });
|
|
1164
1613
|
return wrap(`Proposal saved for #${id}. The dev team can now review your modified HTML/CSS in the dashboard.`);
|
|
1165
1614
|
}
|
|
1166
|
-
var server = new McpServer({ name: "loupe", version: "0.10.
|
|
1615
|
+
var server = new McpServer({ name: "loupe", version: "0.10.21" });
|
|
1167
1616
|
server.tool(
|
|
1168
1617
|
"list_comments",
|
|
1169
1618
|
"List Loupe product-feedback comments for the project as a task backlog. Each item carries its board stage, priority and change type, so you can start with the most urgent. Use this to see what a PM has flagged, then work through the items.",
|
|
@@ -1201,17 +1650,18 @@ server.tool(
|
|
|
1201
1650
|
proposeChange
|
|
1202
1651
|
);
|
|
1203
1652
|
var WS = process.env.LOUPE_WORKSPACE || process.cwd();
|
|
1653
|
+
async function fetchThreadById(id) {
|
|
1654
|
+
try {
|
|
1655
|
+
return await api(`/v1/comments/${encodeURIComponent(id)}`);
|
|
1656
|
+
} catch {
|
|
1657
|
+
return null;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1204
1660
|
var elementTools = createElementContextTools({
|
|
1205
1661
|
store,
|
|
1206
1662
|
workspaceRoot: WS,
|
|
1207
1663
|
mapperOptions: { maxFiles: Number(process.env.LOUPE_MAP_MAX_FILES ?? 4e3) },
|
|
1208
|
-
fetchThread:
|
|
1209
|
-
try {
|
|
1210
|
-
return await api(`/v1/comments/${encodeURIComponent(id)}`);
|
|
1211
|
-
} catch {
|
|
1212
|
-
return null;
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1664
|
+
fetchThread: fetchThreadById
|
|
1215
1665
|
});
|
|
1216
1666
|
var SELECTION_ARGS = {
|
|
1217
1667
|
thread_id: z.string().optional().describe("Work from a stored comment instead of a live selection."),
|
|
@@ -1246,6 +1696,101 @@ server.tool(
|
|
|
1246
1696
|
SELECTION_ARGS,
|
|
1247
1697
|
async ({ thread_id, selection_id }) => wrap((await elementTools.findSourceForSelection({ thread_id, selection_id })).text)
|
|
1248
1698
|
);
|
|
1699
|
+
var AGENT = {
|
|
1700
|
+
id: process.env.LOUPE_AGENT_ID || "loupe-agent",
|
|
1701
|
+
name: process.env.LOUPE_AGENT_NAME || "Claude Code"
|
|
1702
|
+
};
|
|
1703
|
+
var handoff = createHandoffTools({
|
|
1704
|
+
agent: AGENT,
|
|
1705
|
+
fetchThread: fetchThreadById,
|
|
1706
|
+
patchThread: async (id, patch) => {
|
|
1707
|
+
await api(`/v1/comments/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) });
|
|
1708
|
+
},
|
|
1709
|
+
postMessage: async (threadId, message) => await api(`/v1/comments/${encodeURIComponent(threadId)}/messages`, {
|
|
1710
|
+
method: "POST",
|
|
1711
|
+
body: JSON.stringify(message)
|
|
1712
|
+
}),
|
|
1713
|
+
listMessages: async (threadId) => await api(`/v1/comments/${encodeURIComponent(threadId)}/messages`)
|
|
1714
|
+
});
|
|
1715
|
+
server.tool(
|
|
1716
|
+
"mark_thread_addressed",
|
|
1717
|
+
"Hand a thread back to a human: it moves to In Review and, optionally, posts your closing note. Use this when the change is ready. It CANNOT resolve a thread \u2014 only a person does that \u2014 which is why there is no status argument.",
|
|
1718
|
+
{
|
|
1719
|
+
thread_id: z.string().describe("The thread you have addressed."),
|
|
1720
|
+
message: z.string().optional().describe("A short note for the reviewer \u2014 what changed and where to look. Include a preview URL if there is one.")
|
|
1721
|
+
},
|
|
1722
|
+
async ({ thread_id, message }) => wrap((await handoff.markThreadAddressed({ thread_id, message })).text)
|
|
1723
|
+
);
|
|
1724
|
+
server.tool(
|
|
1725
|
+
"add_thread_message",
|
|
1726
|
+
"Reply on a thread without changing its status \u2014 progress notes, questions, or the preview URL when it goes live. The status is left exactly as it was.",
|
|
1727
|
+
{
|
|
1728
|
+
thread_id: z.string(),
|
|
1729
|
+
message: z.string().describe("Markdown. Say something useful; a person reads this.")
|
|
1730
|
+
},
|
|
1731
|
+
async ({ thread_id, message }) => wrap((await handoff.addThreadMessage({ thread_id, message })).text)
|
|
1732
|
+
);
|
|
1733
|
+
server.tool(
|
|
1734
|
+
"get_thread_conversation",
|
|
1735
|
+
"The whole conversation on a thread: the original request, then every reply with its author. Read it before answering so you are not repeating something already said.",
|
|
1736
|
+
{ thread_id: z.string() },
|
|
1737
|
+
async ({ thread_id }) => wrap((await handoff.getThreadConversation({ thread_id })).text)
|
|
1738
|
+
);
|
|
1739
|
+
server.tool(
|
|
1740
|
+
"create_pr_for_thread",
|
|
1741
|
+
"Open a pull request for a fix \u2014 or, more usually, add it to the one the repo already has. One working branch per repo accumulates every fix as its own commit, and the PR body keeps a table of them. If that PR was merged or closed, a fresh branch is started automatically. Use `get_element_context` first to find the file, then pass the full new contents of each file you changed.",
|
|
1742
|
+
{
|
|
1743
|
+
repo: z.string().describe('Owner/name, e.g. "acme/web".'),
|
|
1744
|
+
thread_id: z.string().describe("The thread this fix answers."),
|
|
1745
|
+
description: z.string().describe('One line for the PR table, e.g. "larger checkout button".'),
|
|
1746
|
+
files: z.array(z.object({
|
|
1747
|
+
path: z.string().describe("Path within the repo."),
|
|
1748
|
+
content: z.string().describe("The file's FULL new contents, not a diff.")
|
|
1749
|
+
})).min(1).describe("Every file you changed. They land as one atomic commit."),
|
|
1750
|
+
base_branch: z.string().optional().describe('What to branch from. Defaults to "main".'),
|
|
1751
|
+
branch_name: z.string().optional().describe("Override the branch name. Rarely needed."),
|
|
1752
|
+
revision_of: z.string().optional().describe("Set to a thread id when this is a revision \u2014 it gets its own revision-* branch rather than joining the accumulating one.")
|
|
1753
|
+
},
|
|
1754
|
+
async (args) => {
|
|
1755
|
+
const resolved = resolveGitHubToken();
|
|
1756
|
+
if (!resolved.token) return wrap(resolved.message);
|
|
1757
|
+
try {
|
|
1758
|
+
const result = await createPrForThread(
|
|
1759
|
+
{ github: new GitHubClient(resolved.token), apiBase: API, adminKey: ADMIN, projectKey: PROJECT_KEY },
|
|
1760
|
+
{
|
|
1761
|
+
repo: args.repo,
|
|
1762
|
+
threadId: args.thread_id,
|
|
1763
|
+
description: args.description,
|
|
1764
|
+
files: args.files,
|
|
1765
|
+
baseBranch: args.base_branch,
|
|
1766
|
+
branchName: args.branch_name,
|
|
1767
|
+
revisionOf: args.revision_of
|
|
1768
|
+
}
|
|
1769
|
+
);
|
|
1770
|
+
await api(`/v1/comments/${encodeURIComponent(args.thread_id)}`, {
|
|
1771
|
+
method: "PATCH",
|
|
1772
|
+
body: JSON.stringify({ pr: { number: result.prNumber, url: result.prUrl, state: "open" } })
|
|
1773
|
+
});
|
|
1774
|
+
bus.publishThread(args.thread_id, "pr_created", {
|
|
1775
|
+
number: result.prNumber,
|
|
1776
|
+
url: result.prUrl,
|
|
1777
|
+
branch: result.branch,
|
|
1778
|
+
commit: result.commit
|
|
1779
|
+
});
|
|
1780
|
+
return wrap(
|
|
1781
|
+
`#${args.thread_id} \u2192 ${result.prUrl}
|
|
1782
|
+
|
|
1783
|
+
${result.note}
|
|
1784
|
+
|
|
1785
|
+
Branch \`${result.branch}\` \xB7 commit \`${result.commit.slice(0, 7)}\` \xB7 outcome: ${result.outcome}.
|
|
1786
|
+
The thread's status is unchanged \u2014 call \`mark_thread_addressed\` when the change is ready for review.`
|
|
1787
|
+
);
|
|
1788
|
+
} catch (e) {
|
|
1789
|
+
const text = e instanceof GitHubError || e instanceof CreatePrError ? e.message : String(e);
|
|
1790
|
+
return wrap(`Could not open the pull request: ${text}`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
);
|
|
1249
1794
|
function isEntrypoint() {
|
|
1250
1795
|
try {
|
|
1251
1796
|
return import.meta.url === pathToFileURL(realpathSync(argv[1] ?? "")).href;
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"email": "m.ashraf.saed@gmail.com",
|
|
6
6
|
"url": "https://www.linkedin.com/in/mohamedashrafelsaed/"
|
|
7
7
|
},
|
|
8
|
-
"version": "0.10.
|
|
8
|
+
"version": "0.10.21-next.67",
|
|
9
9
|
"description": "MCP server that exposes Loupe comments to Claude Code as an actionable, fully-contextualized backlog.",
|
|
10
10
|
"keywords": [
|
|
11
11
|
"loupe",
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
"zod": "^3.24.1"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@loupekit/shared": "0.10.
|
|
49
|
+
"@loupekit/shared": "0.10.21-next.67"
|
|
50
50
|
}
|
|
51
51
|
}
|