guito 1.0.0 → 1.0.1
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/CHANGELOG.md +20 -0
- package/bin/guito-server.js +1676 -773
- package/bin/ui/browser/index.html +1 -1
- package/bin/ui/browser/main-VXSKPJDX.js +65 -0
- package/package.json +1 -1
- package/bin/ui/browser/main-MPHQHWT5.js +0 -64
package/bin/guito-server.js
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import { existsSync } from
|
|
2
|
-
import { execFile, spawn } from
|
|
3
|
-
import { readFile, rm, writeFile } from
|
|
4
|
-
import { randomBytes } from
|
|
5
|
-
import { userInfo } from
|
|
6
|
-
import { basename, isAbsolute, join, relative, resolve, sep } from
|
|
7
|
-
import fastify from
|
|
8
|
-
import fastifyStatic from
|
|
9
|
-
import fastifyCors from
|
|
10
|
-
import fastifyCompress from
|
|
11
|
-
import { simpleGit } from
|
|
12
|
-
import { createTwoFilesPatch } from
|
|
13
|
-
import { promisify } from
|
|
14
|
-
import { workingTree } from
|
|
15
|
-
import { avatarCache } from
|
|
16
|
-
import { repositoryState } from
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { userInfo } from "node:os";
|
|
6
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import fastify from "fastify";
|
|
8
|
+
import fastifyStatic from "@fastify/static";
|
|
9
|
+
import fastifyCors from "@fastify/cors";
|
|
10
|
+
import fastifyCompress from "@fastify/compress";
|
|
11
|
+
import { simpleGit } from "simple-git";
|
|
12
|
+
import { createTwoFilesPatch } from "diff";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { workingTree } from "./working-tree.js";
|
|
15
|
+
import { avatarCache } from "./avatars.js";
|
|
16
|
+
import { repositoryState } from "./repository-state.js";
|
|
17
17
|
const execFileAsync = promisify(execFile);
|
|
18
|
-
const AZURE_API_VERSION =
|
|
19
|
-
export const DEFAULT_PR_BRANCH_NAME_TEMPLATE =
|
|
18
|
+
const AZURE_API_VERSION = "5.0-preview";
|
|
19
|
+
export const DEFAULT_PR_BRANCH_NAME_TEMPLATE = "pr/${randomstring}";
|
|
20
20
|
const PR_BRANCH_TEMPLATE_VARIABLES = [
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
"username",
|
|
22
|
+
"randomstring",
|
|
23
|
+
"branch",
|
|
24
|
+
"targetbranch",
|
|
25
|
+
"title",
|
|
26
|
+
"repository",
|
|
27
|
+
"date",
|
|
28
|
+
"time",
|
|
29
|
+
"timestamp",
|
|
30
30
|
];
|
|
31
31
|
/** Azure DevOps comment thread statuses, by their numeric REST value. */
|
|
32
32
|
const AZURE_THREAD_STATUSES = {
|
|
@@ -38,7 +38,7 @@ const AZURE_THREAD_STATUSES = {
|
|
|
38
38
|
byDesign: 6,
|
|
39
39
|
pending: 7,
|
|
40
40
|
};
|
|
41
|
-
const azureThreadStatusName = (value) => Object.entries(AZURE_THREAD_STATUSES).find(([, numeric]) => numeric === value)?.[0] ??
|
|
41
|
+
const azureThreadStatusName = (value) => Object.entries(AZURE_THREAD_STATUSES).find(([, numeric]) => numeric === value)?.[0] ?? "unknown";
|
|
42
42
|
/** Pull request reviewer votes, by their numeric REST value. */
|
|
43
43
|
const AZURE_VOTES = {
|
|
44
44
|
reset: 0,
|
|
@@ -53,7 +53,7 @@ const AZURE_VOTES = {
|
|
|
53
53
|
* forms, a trailing .git, and URL-encoded segments such as %20.
|
|
54
54
|
*/
|
|
55
55
|
export function parseAzureRemoteUrl(remoteUrl) {
|
|
56
|
-
const value = (remoteUrl ??
|
|
56
|
+
const value = (remoteUrl ?? "").trim();
|
|
57
57
|
if (!value) {
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
@@ -70,19 +70,19 @@ export function parseAzureRemoteUrl(remoteUrl) {
|
|
|
70
70
|
}
|
|
71
71
|
path = scpMatch[1];
|
|
72
72
|
}
|
|
73
|
-
path = path.replace(/\.git\/?$/i,
|
|
73
|
+
path = path.replace(/\.git\/?$/i, "");
|
|
74
74
|
try {
|
|
75
75
|
path = decodeURIComponent(path);
|
|
76
76
|
}
|
|
77
77
|
catch {
|
|
78
78
|
// Keep the raw path when it contains invalid percent-escapes.
|
|
79
79
|
}
|
|
80
|
-
const gitIndex = path.toLowerCase().lastIndexOf(
|
|
80
|
+
const gitIndex = path.toLowerCase().lastIndexOf("/_git/");
|
|
81
81
|
if (gitIndex < 0) {
|
|
82
82
|
return null;
|
|
83
83
|
}
|
|
84
|
-
const projectPath = path.slice(0, gitIndex).replace(/^\/+|\/+$/g,
|
|
85
|
-
const repo = path.slice(gitIndex +
|
|
84
|
+
const projectPath = path.slice(0, gitIndex).replace(/^\/+|\/+$/g, "");
|
|
85
|
+
const repo = path.slice(gitIndex + "/_git/".length).replace(/^\/+|\/+$/g, "");
|
|
86
86
|
if (!projectPath || !repo) {
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
@@ -94,7 +94,7 @@ export function parseAzureRemoteUrl(remoteUrl) {
|
|
|
94
94
|
* prefix of the remote path it is not duplicated.
|
|
95
95
|
*/
|
|
96
96
|
export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
|
|
97
|
-
const base = (baseUrl ??
|
|
97
|
+
const base = (baseUrl ?? "").trim().replace(/\/+$/, "");
|
|
98
98
|
if (!base) {
|
|
99
99
|
return null;
|
|
100
100
|
}
|
|
@@ -103,25 +103,25 @@ export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
|
|
|
103
103
|
return null;
|
|
104
104
|
}
|
|
105
105
|
const encodePath = (value) => value
|
|
106
|
-
.split(
|
|
106
|
+
.split("/")
|
|
107
107
|
.filter(Boolean)
|
|
108
108
|
.map((segment) => encodeURIComponent(segment))
|
|
109
|
-
.join(
|
|
109
|
+
.join("/");
|
|
110
110
|
try {
|
|
111
111
|
const parsed = new URL(base);
|
|
112
112
|
const decode = (path) => path
|
|
113
|
-
.split(
|
|
113
|
+
.split("/")
|
|
114
114
|
.filter(Boolean)
|
|
115
115
|
.map((segment) => decodeURIComponent(segment));
|
|
116
116
|
const baseParts = decode(parsed.pathname);
|
|
117
|
-
const remoteParts = info.projectPath.split(
|
|
117
|
+
const remoteParts = info.projectPath.split("/").filter(Boolean);
|
|
118
118
|
let overlap = Math.min(baseParts.length, remoteParts.length);
|
|
119
119
|
while (overlap &&
|
|
120
120
|
!baseParts
|
|
121
121
|
.slice(-overlap)
|
|
122
122
|
.every((part, index) => part.toLowerCase() === remoteParts[index].toLowerCase()))
|
|
123
123
|
overlap--;
|
|
124
|
-
return `${parsed.origin}/${[...baseParts, ...remoteParts.slice(overlap)].map(encodeURIComponent).join(
|
|
124
|
+
return `${parsed.origin}/${[...baseParts, ...remoteParts.slice(overlap)].map(encodeURIComponent).join("/")}`;
|
|
125
125
|
}
|
|
126
126
|
catch {
|
|
127
127
|
return `${base}/${encodePath(info.projectPath)}`;
|
|
@@ -145,60 +145,60 @@ export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
|
|
|
145
145
|
* the current Windows session (SSPI single sign-on). Do not also request
|
|
146
146
|
* --ntlm: some curl builds omit that option while still supporting Negotiate.
|
|
147
147
|
*/
|
|
148
|
-
function defaultAzureRequest(method, url, body) {
|
|
148
|
+
function defaultAzureRequest(method, url, body, contentType = "application/json") {
|
|
149
149
|
return new Promise((resolveRequest, rejectRequest) => {
|
|
150
150
|
const args = [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
151
|
+
"-sS",
|
|
152
|
+
"--negotiate",
|
|
153
|
+
"-u",
|
|
154
|
+
":",
|
|
155
|
+
"--max-time",
|
|
156
|
+
"60",
|
|
157
|
+
"-w",
|
|
158
|
+
"\n%{http_code}",
|
|
159
|
+
"-X",
|
|
160
160
|
method,
|
|
161
161
|
];
|
|
162
|
-
if (method ===
|
|
163
|
-
args.push(
|
|
162
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
163
|
+
args.push("-H", `Content-Type: ${contentType}`, "--data-binary", "@-");
|
|
164
164
|
}
|
|
165
165
|
args.push(url);
|
|
166
|
-
const child = spawn(
|
|
167
|
-
let stdout =
|
|
168
|
-
let stderr =
|
|
169
|
-
child.stdout.on(
|
|
166
|
+
const child = spawn("curl.exe", args, { windowsHide: true });
|
|
167
|
+
let stdout = "";
|
|
168
|
+
let stderr = "";
|
|
169
|
+
child.stdout.on("data", (chunk) => {
|
|
170
170
|
stdout += chunk;
|
|
171
171
|
});
|
|
172
|
-
child.stderr.on(
|
|
172
|
+
child.stderr.on("data", (chunk) => {
|
|
173
173
|
stderr += chunk;
|
|
174
174
|
});
|
|
175
175
|
// EPIPE when curl exits before reading the whole body; the close handler
|
|
176
176
|
// reports the real failure.
|
|
177
|
-
child.stdin.on(
|
|
178
|
-
child.on(
|
|
177
|
+
child.stdin.on("error", () => { });
|
|
178
|
+
child.on("error", (err) => {
|
|
179
179
|
rejectRequest(new Error(`Failed to run curl.exe (Windows integrated auth requires Windows): ${err.message}`));
|
|
180
180
|
});
|
|
181
|
-
child.on(
|
|
181
|
+
child.on("close", (code) => {
|
|
182
182
|
if (code !== 0) {
|
|
183
183
|
rejectRequest(new Error(stderr.trim() || `curl.exe exited with code ${code}.`));
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
186
186
|
// -w appends "\n<http_code>" after the response body.
|
|
187
|
-
const separator = stdout.lastIndexOf(
|
|
187
|
+
const separator = stdout.lastIndexOf("\n");
|
|
188
188
|
const status = Number.parseInt(stdout.slice(separator + 1), 10);
|
|
189
189
|
if (separator < 0 || !Number.isFinite(status)) {
|
|
190
|
-
rejectRequest(new Error(
|
|
190
|
+
rejectRequest(new Error("Unexpected curl.exe output (missing HTTP status)."));
|
|
191
191
|
return;
|
|
192
192
|
}
|
|
193
193
|
resolveRequest({ status, body: stdout.slice(0, separator) });
|
|
194
194
|
});
|
|
195
|
-
child.stdin.end(body,
|
|
195
|
+
child.stdin.end(body, "utf8");
|
|
196
196
|
});
|
|
197
197
|
}
|
|
198
198
|
const randomBranchSuffix = () => {
|
|
199
|
-
const alphabet =
|
|
199
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
200
200
|
const bytes = randomBytes(6);
|
|
201
|
-
let suffix =
|
|
201
|
+
let suffix = "";
|
|
202
202
|
for (const byte of bytes) {
|
|
203
203
|
suffix += alphabet[byte % alphabet.length];
|
|
204
204
|
}
|
|
@@ -206,16 +206,16 @@ const randomBranchSuffix = () => {
|
|
|
206
206
|
};
|
|
207
207
|
const safeBranchTemplateValue = (value) => value
|
|
208
208
|
.trim()
|
|
209
|
-
.replace(/[\x00-\x20\x7f~^:?*\[\\]+/g,
|
|
210
|
-
.replace(/\.{2,}/g,
|
|
211
|
-
.replace(/-+/g,
|
|
212
|
-
.replace(/^[./-]+|[./-]+$/g,
|
|
209
|
+
.replace(/[\x00-\x20\x7f~^:?*\[\\]+/g, "-")
|
|
210
|
+
.replace(/\.{2,}/g, "-")
|
|
211
|
+
.replace(/-+/g, "-")
|
|
212
|
+
.replace(/^[./-]+|[./-]+$/g, "") || "unknown";
|
|
213
213
|
/** Expands a configured automatic PR branch name and rejects unknown variables. */
|
|
214
214
|
export const formatPrBranchName = (template, values) => {
|
|
215
215
|
const source = template.trim() || DEFAULT_PR_BRANCH_NAME_TEMPLATE;
|
|
216
216
|
return source.replace(/\$\{([^}]+)\}/g, (_match, variable) => {
|
|
217
217
|
if (!PR_BRANCH_TEMPLATE_VARIABLES.includes(variable)) {
|
|
218
|
-
throw new Error(`Unknown pull request branch variable \${${variable}}. Supported variables: ${PR_BRANCH_TEMPLATE_VARIABLES.map((name) => `\${${name}}`).join(
|
|
218
|
+
throw new Error(`Unknown pull request branch variable \${${variable}}. Supported variables: ${PR_BRANCH_TEMPLATE_VARIABLES.map((name) => `\${${name}}`).join(", ")}.`);
|
|
219
219
|
}
|
|
220
220
|
return safeBranchTemplateValue(values[variable]);
|
|
221
221
|
});
|
|
@@ -223,23 +223,23 @@ export const formatPrBranchName = (template, values) => {
|
|
|
223
223
|
/** Extracts Azure DevOps' human-readable error message from a REST response. */
|
|
224
224
|
const azureErrorMessage = (result) => {
|
|
225
225
|
try {
|
|
226
|
-
return String(JSON.parse(result.body)?.message ??
|
|
226
|
+
return String(JSON.parse(result.body)?.message ?? "");
|
|
227
227
|
}
|
|
228
228
|
catch {
|
|
229
229
|
return result.body;
|
|
230
230
|
}
|
|
231
231
|
};
|
|
232
|
-
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, prBranchNameTemplate, autoReload, diffViewer, showGraph, showStashes, showTags, showRemoteBranches, issueRegex, issueUrl, fileListView, searchMode, searchCaseSensitive, azureRequestImpl, avatarFetchImpl, onLog, }) {
|
|
232
|
+
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, prBranchNameTemplate, autoReload, diffViewer, showGraph, showStashes, showTags, showRemoteBranches, issueRegex, issueUrl, allowMerge, fileListView, searchMode, searchCaseSensitive, azureRequestImpl, avatarFetchImpl, onLog, }) {
|
|
233
233
|
// Initialize server
|
|
234
234
|
const app = fastify({
|
|
235
235
|
logger: false,
|
|
236
236
|
});
|
|
237
237
|
if (onLog) {
|
|
238
238
|
// Report error responses to the host (e.g. the VS Code output channel).
|
|
239
|
-
app.addHook(
|
|
239
|
+
app.addHook("onSend", async (request, reply, payload) => {
|
|
240
240
|
if (reply.statusCode >= 400) {
|
|
241
|
-
const body = typeof payload ===
|
|
242
|
-
onLog(`${request.method} ${request.url} -> ${reply.statusCode}${body ? ` ${body}` :
|
|
241
|
+
const body = typeof payload === "string" ? payload.slice(0, 500) : "";
|
|
242
|
+
onLog(`${request.method} ${request.url} -> ${reply.statusCode}${body ? ` ${body}` : ""}`);
|
|
243
243
|
}
|
|
244
244
|
return payload;
|
|
245
245
|
});
|
|
@@ -249,44 +249,50 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
249
249
|
// Compress JSON responses (commit history payloads are highly repetitive)
|
|
250
250
|
await app.register(fastifyCompress);
|
|
251
251
|
if (apiToken) {
|
|
252
|
-
app.addHook(
|
|
253
|
-
const pathname = request.url.split(
|
|
254
|
-
if (!pathname.startsWith(
|
|
252
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
253
|
+
const pathname = request.url.split("?", 1)[0];
|
|
254
|
+
if (!pathname.startsWith("/api/")) {
|
|
255
255
|
return;
|
|
256
256
|
}
|
|
257
|
-
const headerToken = request.headers[
|
|
258
|
-
const queryToken = request.query
|
|
259
|
-
|
|
257
|
+
const headerToken = request.headers["x-guito-token"];
|
|
258
|
+
const queryToken = request.query
|
|
259
|
+
?.guitoToken;
|
|
260
|
+
const archiveToken = pathname === "/api/archive" ? queryToken : undefined;
|
|
260
261
|
if (headerToken !== apiToken && archiveToken !== apiToken) {
|
|
261
|
-
await reply
|
|
262
|
+
await reply
|
|
263
|
+
.status(401)
|
|
264
|
+
.type("application/json")
|
|
265
|
+
.send({ error: "unauthorized" });
|
|
262
266
|
}
|
|
263
267
|
});
|
|
264
268
|
}
|
|
265
269
|
// Register static file provider (supports both `ui/` and `ui/browser/` layouts)
|
|
266
|
-
const staticRoot = existsSync(join(uiRoot,
|
|
267
|
-
? join(uiRoot,
|
|
270
|
+
const staticRoot = existsSync(join(uiRoot, "browser", "index.html"))
|
|
271
|
+
? join(uiRoot, "browser")
|
|
268
272
|
: uiRoot;
|
|
269
273
|
if (existsSync(staticRoot)) {
|
|
270
274
|
await app.register(fastifyStatic, { root: staticRoot });
|
|
271
275
|
// Serve UI
|
|
272
|
-
app.get(
|
|
276
|
+
app.get("/", (_req, resp) => resp.sendFile("index.html"));
|
|
273
277
|
}
|
|
274
278
|
else {
|
|
275
|
-
app.get(
|
|
279
|
+
app.get("/", (_req, resp) => resp.send("Guito API is running. Build the UI with `npm run build:ui`."));
|
|
276
280
|
}
|
|
277
281
|
// Initialize git lib
|
|
278
282
|
const git = simpleGit(repositoryPath);
|
|
279
283
|
const avatars = avatarCache(avatarFetchImpl);
|
|
280
|
-
app.get(
|
|
281
|
-
const email = String(req.query?.email ??
|
|
284
|
+
app.get("/api/avatar", async (req, reply) => {
|
|
285
|
+
const email = String(req.query?.email ?? "").trim();
|
|
282
286
|
if (!email || email.length > 320)
|
|
283
|
-
return reply.code(400).send({ error:
|
|
287
|
+
return reply.code(400).send({ error: "Valid email required" });
|
|
284
288
|
const image = await avatars(email);
|
|
285
|
-
reply.header(
|
|
286
|
-
return image
|
|
289
|
+
reply.header("Cache-Control", "private, max-age=3600");
|
|
290
|
+
return image
|
|
291
|
+
? reply.type(image.contentType).send(image.data)
|
|
292
|
+
: reply.code(204).send();
|
|
287
293
|
});
|
|
288
294
|
let stateRequest;
|
|
289
|
-
app.get(
|
|
295
|
+
app.get("/api/repository-state", async (_req, reply) => {
|
|
290
296
|
try {
|
|
291
297
|
stateRequest ?? (stateRequest = repositoryState(git, repositoryPath).finally(() => {
|
|
292
298
|
stateRequest = undefined;
|
|
@@ -300,16 +306,16 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
300
306
|
// ==================== Diff parsing ====================
|
|
301
307
|
function parseUnifiedDiff(rawDiff) {
|
|
302
308
|
const files = [];
|
|
303
|
-
const lines = rawDiff.split(
|
|
309
|
+
const lines = rawDiff.split("\n");
|
|
304
310
|
let current = null;
|
|
305
311
|
let inHunk = false;
|
|
306
312
|
let oldLine = 0;
|
|
307
313
|
let newLine = 0;
|
|
308
314
|
const startFile = (line) => {
|
|
309
315
|
current = {
|
|
310
|
-
path:
|
|
311
|
-
oldPath:
|
|
312
|
-
status:
|
|
316
|
+
path: "",
|
|
317
|
+
oldPath: "",
|
|
318
|
+
status: "modified",
|
|
313
319
|
lines: [],
|
|
314
320
|
additions: 0,
|
|
315
321
|
deletions: 0,
|
|
@@ -322,7 +328,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
322
328
|
inHunk = false;
|
|
323
329
|
};
|
|
324
330
|
for (const line of lines) {
|
|
325
|
-
if (line.startsWith(
|
|
331
|
+
if (line.startsWith("diff --git ")) {
|
|
326
332
|
if (current)
|
|
327
333
|
files.push(current);
|
|
328
334
|
startFile(line);
|
|
@@ -330,45 +336,54 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
330
336
|
}
|
|
331
337
|
if (!current)
|
|
332
338
|
continue;
|
|
333
|
-
if (line.startsWith(
|
|
339
|
+
if (line.startsWith("@@")) {
|
|
334
340
|
inHunk = true;
|
|
335
341
|
const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
336
342
|
if (match) {
|
|
337
343
|
oldLine = parseInt(match[1], 10);
|
|
338
344
|
newLine = parseInt(match[2], 10);
|
|
339
345
|
}
|
|
340
|
-
current.lines.push({ type:
|
|
346
|
+
current.lines.push({ type: "hunk", text: line });
|
|
341
347
|
continue;
|
|
342
348
|
}
|
|
343
349
|
if (!inHunk) {
|
|
344
350
|
// File meta headers (mode, index, rename, binary...)
|
|
345
|
-
if (line.startsWith(
|
|
346
|
-
current.status =
|
|
347
|
-
else if (line.startsWith(
|
|
348
|
-
current.status =
|
|
349
|
-
else if (line.startsWith(
|
|
350
|
-
current.status =
|
|
351
|
-
else if (line.startsWith(
|
|
352
|
-
current.path = line.slice(
|
|
353
|
-
else if (line.startsWith(
|
|
354
|
-
|
|
351
|
+
if (line.startsWith("new file mode"))
|
|
352
|
+
current.status = "added";
|
|
353
|
+
else if (line.startsWith("deleted file mode"))
|
|
354
|
+
current.status = "deleted";
|
|
355
|
+
else if (line.startsWith("rename from "))
|
|
356
|
+
current.status = "renamed";
|
|
357
|
+
else if (line.startsWith("rename to "))
|
|
358
|
+
current.path = line.slice("rename to ".length);
|
|
359
|
+
else if (line.startsWith("Binary files") ||
|
|
360
|
+
line.startsWith("GIT binary patch"))
|
|
361
|
+
current.status = "binary";
|
|
355
362
|
continue;
|
|
356
363
|
}
|
|
357
|
-
if (line.startsWith(
|
|
364
|
+
if (line.startsWith("\\")) {
|
|
358
365
|
// ""
|
|
359
366
|
continue;
|
|
360
367
|
}
|
|
361
|
-
if (line.startsWith(
|
|
362
|
-
current.lines.push({
|
|
368
|
+
if (line.startsWith("+")) {
|
|
369
|
+
current.lines.push({
|
|
370
|
+
type: "add",
|
|
371
|
+
newLine: newLine++,
|
|
372
|
+
text: line.slice(1),
|
|
373
|
+
});
|
|
363
374
|
current.additions++;
|
|
364
375
|
}
|
|
365
|
-
else if (line.startsWith(
|
|
366
|
-
current.lines.push({
|
|
376
|
+
else if (line.startsWith("-")) {
|
|
377
|
+
current.lines.push({
|
|
378
|
+
type: "del",
|
|
379
|
+
oldLine: oldLine++,
|
|
380
|
+
text: line.slice(1),
|
|
381
|
+
});
|
|
367
382
|
current.deletions++;
|
|
368
383
|
}
|
|
369
384
|
else {
|
|
370
385
|
current.lines.push({
|
|
371
|
-
type:
|
|
386
|
+
type: "context",
|
|
372
387
|
oldLine: oldLine++,
|
|
373
388
|
newLine: newLine++,
|
|
374
389
|
text: line.slice(1),
|
|
@@ -387,49 +402,51 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
387
402
|
mutationQueue = result.catch(() => { });
|
|
388
403
|
return result;
|
|
389
404
|
};
|
|
390
|
-
const repoRoot = async () => (await git.revparse([
|
|
405
|
+
const repoRoot = async () => (await git.revparse(["--show-toplevel"])).trim();
|
|
391
406
|
// ==================== Azure DevOps settings ====================
|
|
392
407
|
// Settings live in the repository's git directory so they are per-repo and
|
|
393
408
|
// never committed. The VS Code extension passes its own setting via
|
|
394
409
|
// azureDevOpsUrl, which wins over the file.
|
|
395
|
-
let settingsPath =
|
|
410
|
+
let settingsPath = "";
|
|
396
411
|
try {
|
|
397
|
-
const gitDir = (await git.revparse([
|
|
398
|
-
settingsPath = join(gitDir,
|
|
412
|
+
const gitDir = (await git.revparse(["--absolute-git-dir"])).trim();
|
|
413
|
+
settingsPath = join(gitDir, "guito-settings.json");
|
|
399
414
|
}
|
|
400
415
|
catch {
|
|
401
|
-
settingsPath = join(repositoryPath,
|
|
416
|
+
settingsPath = join(repositoryPath, ".git", "guito-settings.json");
|
|
402
417
|
}
|
|
403
418
|
const readSettings = async () => {
|
|
404
419
|
try {
|
|
405
|
-
return JSON.parse(await readFile(settingsPath,
|
|
420
|
+
return JSON.parse(await readFile(settingsPath, "utf8"));
|
|
406
421
|
}
|
|
407
422
|
catch {
|
|
408
423
|
return {};
|
|
409
424
|
}
|
|
410
425
|
};
|
|
411
426
|
const writeSettings = async (settings) => {
|
|
412
|
-
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`,
|
|
427
|
+
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
413
428
|
};
|
|
414
429
|
const azureRequest = azureRequestImpl ?? defaultAzureRequest;
|
|
415
430
|
const effectiveAzureUrl = async () => {
|
|
416
|
-
const configuredAzureUrl = azureDevOpsUrl?.trim() ??
|
|
431
|
+
const configuredAzureUrl = azureDevOpsUrl?.trim() ?? "";
|
|
417
432
|
if (configuredAzureUrl) {
|
|
418
|
-
return { url: configuredAzureUrl, source:
|
|
433
|
+
return { url: configuredAzureUrl, source: "vscode" };
|
|
419
434
|
}
|
|
420
435
|
const settings = await readSettings();
|
|
421
|
-
const url = typeof settings.azureDevOpsUrl ===
|
|
422
|
-
|
|
436
|
+
const url = typeof settings.azureDevOpsUrl === "string"
|
|
437
|
+
? settings.azureDevOpsUrl.trim()
|
|
438
|
+
: "";
|
|
439
|
+
return { url, source: url ? "file" : "" };
|
|
423
440
|
};
|
|
424
441
|
/** Resolves the Azure DevOps project prefix or throws a user-facing error. */
|
|
425
442
|
const azureProjectContext = async () => {
|
|
426
443
|
const effective = await effectiveAzureUrl();
|
|
427
444
|
if (!effective.url) {
|
|
428
|
-
throw new Error(
|
|
445
|
+
throw new Error("Azure DevOps URL is not configured. Set it in Settings (gear icon) or the guito.azureDevOpsUrl VS Code setting.");
|
|
429
446
|
}
|
|
430
|
-
let remoteUrl =
|
|
447
|
+
let remoteUrl = "";
|
|
431
448
|
try {
|
|
432
|
-
remoteUrl = (await git.remote([
|
|
449
|
+
remoteUrl = (await git.remote(["get-url", "origin"])).trim();
|
|
433
450
|
}
|
|
434
451
|
catch {
|
|
435
452
|
throw new Error('No "origin" remote is configured for this repository.');
|
|
@@ -449,53 +466,72 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
449
466
|
const { prefix } = await azureProjectContext();
|
|
450
467
|
// The prefix is "{collection-or-org}/{project}"; connectionData is
|
|
451
468
|
// collection scoped.
|
|
452
|
-
const collection = prefix.slice(0, prefix.lastIndexOf(
|
|
453
|
-
const result = await azureRequest(
|
|
469
|
+
const collection = prefix.slice(0, prefix.lastIndexOf("/"));
|
|
470
|
+
const result = await azureRequest("GET", `${collection}/_apis/connectionData?q=1&api-version=${AZURE_API_VERSION}`, "");
|
|
454
471
|
if (result.status < 200 || result.status >= 300) {
|
|
455
472
|
throw new Error(azureErrorMessage(result) ||
|
|
456
473
|
`Azure DevOps returned HTTP ${result.status} while resolving the current user.`);
|
|
457
474
|
}
|
|
458
475
|
const user = (JSON.parse(result.body) ?? {}).authenticatedUser;
|
|
459
|
-
const id = String(user?.id ??
|
|
476
|
+
const id = String(user?.id ?? "");
|
|
460
477
|
if (!id) {
|
|
461
|
-
throw new Error(
|
|
478
|
+
throw new Error("Azure DevOps did not report an authenticated user.");
|
|
462
479
|
}
|
|
463
480
|
cachedAzureMe = {
|
|
464
481
|
id,
|
|
465
|
-
name: String(user?.providerDisplayName ??
|
|
466
|
-
email: String(user?.properties?.Mail?.$value ??
|
|
482
|
+
name: String(user?.providerDisplayName ?? ""),
|
|
483
|
+
email: String(user?.properties?.Mail?.$value ??
|
|
484
|
+
user?.properties?.Account?.$value ??
|
|
485
|
+
""),
|
|
467
486
|
};
|
|
468
487
|
return cachedAzureMe;
|
|
469
488
|
};
|
|
470
489
|
/** Web UI URL of a pull request, used by the "Open in Azure DevOps" links. */
|
|
471
490
|
const azurePrWebUrl = (prefix, remoteUrl, id) => {
|
|
472
|
-
const repo = parseAzureRemoteUrl(remoteUrl)?.repo ??
|
|
491
|
+
const repo = parseAzureRemoteUrl(remoteUrl)?.repo ?? "";
|
|
473
492
|
return `${prefix}/_git/${encodeURIComponent(repo)}/pullrequest/${id}`;
|
|
474
493
|
};
|
|
494
|
+
/** Normalizes the Azure pull request merge state for the dialog. */
|
|
495
|
+
const azureMergeStatus = (pr) => {
|
|
496
|
+
const raw = String(pr?.mergeStatus ?? "notSet");
|
|
497
|
+
return [
|
|
498
|
+
"notSet",
|
|
499
|
+
"queued",
|
|
500
|
+
"conflicts",
|
|
501
|
+
"succeeded",
|
|
502
|
+
"rejectedByPolicy",
|
|
503
|
+
"failure",
|
|
504
|
+
].includes(raw)
|
|
505
|
+
? raw
|
|
506
|
+
: "notSet";
|
|
507
|
+
};
|
|
475
508
|
/** Maps one Azure DevOps pull request record to the shape the UI consumes. */
|
|
476
509
|
const mapAzurePullRequest = (pr, prefix, remoteUrl, me) => {
|
|
477
510
|
const reviewers = (Array.isArray(pr?.reviewers) ? pr.reviewers : []).map((entry) => ({
|
|
478
|
-
id: String(entry?.id ??
|
|
479
|
-
name: String(entry?.displayName ??
|
|
480
|
-
email: String(entry?.uniqueName ??
|
|
511
|
+
id: String(entry?.id ?? ""),
|
|
512
|
+
name: String(entry?.displayName ?? ""),
|
|
513
|
+
email: String(entry?.uniqueName ?? ""),
|
|
481
514
|
vote: Number(entry?.vote ?? 0),
|
|
482
515
|
isRequired: entry?.isRequired === true,
|
|
483
516
|
}));
|
|
484
|
-
const mine = me
|
|
517
|
+
const mine = me
|
|
518
|
+
? reviewers.find((reviewer) => reviewer.id === me.id)
|
|
519
|
+
: undefined;
|
|
485
520
|
return {
|
|
486
521
|
id: Number(pr?.pullRequestId ?? 0),
|
|
487
|
-
title: String(pr?.title ??
|
|
522
|
+
title: String(pr?.title ?? ""),
|
|
488
523
|
isDraft: pr?.isDraft === true,
|
|
489
524
|
author: {
|
|
490
|
-
id: String(pr?.createdBy?.id ??
|
|
491
|
-
name: String(pr?.createdBy?.displayName ??
|
|
492
|
-
email: String(pr?.createdBy?.uniqueName ??
|
|
525
|
+
id: String(pr?.createdBy?.id ?? ""),
|
|
526
|
+
name: String(pr?.createdBy?.displayName ?? ""),
|
|
527
|
+
email: String(pr?.createdBy?.uniqueName ?? ""),
|
|
493
528
|
},
|
|
494
|
-
createdAt: String(pr?.creationDate ??
|
|
495
|
-
sourceBranch: String(pr?.sourceRefName ??
|
|
496
|
-
targetBranch: String(pr?.targetRefName ??
|
|
497
|
-
status: String(pr?.status ??
|
|
498
|
-
webUrl: String(pr?._links?.web?.href ||
|
|
529
|
+
createdAt: String(pr?.creationDate ?? ""),
|
|
530
|
+
sourceBranch: String(pr?.sourceRefName ?? "").replace(/^refs\/heads\//, ""),
|
|
531
|
+
targetBranch: String(pr?.targetRefName ?? "").replace(/^refs\/heads\//, ""),
|
|
532
|
+
status: String(pr?.status ?? "active"),
|
|
533
|
+
webUrl: String(pr?._links?.web?.href || "") ||
|
|
534
|
+
azurePrWebUrl(prefix, remoteUrl, Number(pr?.pullRequestId ?? 0)),
|
|
499
535
|
reviewers,
|
|
500
536
|
myVote: mine ? Number(mine.vote ?? 0) : 0,
|
|
501
537
|
requiresMe: mine?.isRequired === true,
|
|
@@ -504,7 +540,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
504
540
|
/** REST base of one pull request (".../pullrequests/{id}") plus repo context. */
|
|
505
541
|
const azurePrApiBase = async (id) => {
|
|
506
542
|
if (!Number.isInteger(id) || id <= 0) {
|
|
507
|
-
throw new Error(
|
|
543
|
+
throw new Error("A pull request id is required.");
|
|
508
544
|
}
|
|
509
545
|
const { prefix, remoteUrl } = await azureProjectContext();
|
|
510
546
|
const repo = parseAzureRemoteUrl(remoteUrl)?.repo;
|
|
@@ -519,42 +555,98 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
519
555
|
};
|
|
520
556
|
};
|
|
521
557
|
/** Runs one Azure request and turns non-2xx replies into user-facing errors. */
|
|
522
|
-
const azureJson = async (method, url, body =
|
|
523
|
-
const result = await azureRequest(method, url, body);
|
|
558
|
+
const azureJson = async (method, url, body = "", contentType = "application/json") => {
|
|
559
|
+
const result = await azureRequest(method, url, body, contentType);
|
|
524
560
|
if (result.status < 200 || result.status >= 300) {
|
|
525
|
-
throw new Error(azureErrorMessage(result) ||
|
|
561
|
+
throw new Error(azureErrorMessage(result) ||
|
|
562
|
+
`Azure DevOps returned HTTP ${result.status}.`);
|
|
526
563
|
}
|
|
527
564
|
return (result.body ? JSON.parse(result.body) : {});
|
|
528
565
|
};
|
|
566
|
+
/** vstfs artifact URI of a pull request; work item ArtifactLink relations point at it. */
|
|
567
|
+
const azurePrArtifactUrl = (projectId, repositoryId, id) => `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${id}`;
|
|
568
|
+
/** Reads one work item's relations from the WIT API. */
|
|
569
|
+
const azureWorkItemRelations = async (prefix, workItemId) => {
|
|
570
|
+
const item = await azureJson("GET", `${prefix}/_apis/wit/workitems/${workItemId}?$expand=relations&api-version=${AZURE_API_VERSION}`);
|
|
571
|
+
return Array.isArray(item?.relations) ? item.relations : [];
|
|
572
|
+
};
|
|
573
|
+
/** Patches a work item's relations (the WIT API wants the patch content type). */
|
|
574
|
+
const azurePatchWorkItemRelations = async (prefix, workItemId, patch) => {
|
|
575
|
+
await azureJson("PATCH", `${prefix}/_apis/wit/workitems/${workItemId}?api-version=${AZURE_API_VERSION}`, JSON.stringify(patch), "application/json-patch+json");
|
|
576
|
+
};
|
|
577
|
+
/** Project prefix plus artifact URI of one pull request (work item links). */
|
|
578
|
+
const azurePrArtifactOf = async (id) => {
|
|
579
|
+
const { base, prefix } = await azurePrApiBase(id);
|
|
580
|
+
const pr = await azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`);
|
|
581
|
+
const projectId = String(pr?.repository?.project?.id ?? "");
|
|
582
|
+
const repositoryId = String(pr?.repository?.id ?? "");
|
|
583
|
+
if (!projectId || !repositoryId) {
|
|
584
|
+
throw new Error("Azure DevOps did not report the pull request's project and repository.");
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
prefix,
|
|
588
|
+
artifactUrl: azurePrArtifactUrl(projectId, repositoryId, id),
|
|
589
|
+
};
|
|
590
|
+
};
|
|
529
591
|
/** Merges the VS Code settings with the server-side settings file. */
|
|
530
592
|
const effectiveSettings = async () => {
|
|
531
|
-
const [file, azure] = await Promise.all([
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
|
|
593
|
+
const [file, azure] = await Promise.all([
|
|
594
|
+
readSettings(),
|
|
595
|
+
effectiveAzureUrl(),
|
|
596
|
+
]);
|
|
597
|
+
const fileAutoReload = typeof file.autoReload === "boolean" ? file.autoReload : undefined;
|
|
598
|
+
const fileShowGraph = typeof file.showGraph === "boolean" ? file.showGraph : undefined;
|
|
599
|
+
const fileShowStashes = typeof file.showStashes === "boolean" ? file.showStashes : undefined;
|
|
600
|
+
const fileShowTags = typeof file.showTags === "boolean" ? file.showTags : undefined;
|
|
601
|
+
const fileShowRemoteBranches = typeof file.showRemoteBranches === "boolean"
|
|
602
|
+
? file.showRemoteBranches
|
|
603
|
+
: undefined;
|
|
604
|
+
const fileFileListView = file.fileListView === "tree" || file.fileListView === "flat"
|
|
605
|
+
? file.fileListView
|
|
606
|
+
: undefined;
|
|
607
|
+
const fileSearchMode = file.searchMode === "filter" || file.searchMode === "navigate"
|
|
608
|
+
? file.searchMode
|
|
609
|
+
: undefined;
|
|
610
|
+
const fileAllowMerge = typeof file.allowMerge === "boolean" ? file.allowMerge : undefined;
|
|
611
|
+
const fileSearchCaseSensitive = typeof file.searchCaseSensitive === "boolean"
|
|
612
|
+
? file.searchCaseSensitive
|
|
613
|
+
: undefined;
|
|
614
|
+
const filePrBranchNameTemplate = typeof file.prBranchNameTemplate === "string" &&
|
|
615
|
+
file.prBranchNameTemplate.trim()
|
|
541
616
|
? file.prBranchNameTemplate.trim()
|
|
542
617
|
: undefined;
|
|
543
618
|
const localIssue = file.issueLinking;
|
|
544
619
|
let issueLinking = null;
|
|
545
620
|
if (issueRegex?.trim() && issueUrl?.trim()) {
|
|
546
|
-
issueLinking = {
|
|
621
|
+
issueLinking = {
|
|
622
|
+
regex: issueRegex.trim(),
|
|
623
|
+
url: issueUrl.trim(),
|
|
624
|
+
useGlobally: true,
|
|
625
|
+
};
|
|
547
626
|
}
|
|
548
|
-
else if (typeof localIssue?.regex ===
|
|
549
|
-
|
|
627
|
+
else if (typeof localIssue?.regex === "string" &&
|
|
628
|
+
typeof localIssue.url === "string") {
|
|
629
|
+
issueLinking = {
|
|
630
|
+
regex: localIssue.regex,
|
|
631
|
+
url: localIssue.url,
|
|
632
|
+
useGlobally: false,
|
|
633
|
+
};
|
|
550
634
|
}
|
|
551
635
|
else {
|
|
552
636
|
const [regex, url] = await Promise.all([
|
|
553
|
-
git
|
|
554
|
-
|
|
637
|
+
git
|
|
638
|
+
.raw(["config", "--global", "--get", "guito.issueRegex"])
|
|
639
|
+
.catch(() => ""),
|
|
640
|
+
git
|
|
641
|
+
.raw(["config", "--global", "--get", "guito.issueUrl"])
|
|
642
|
+
.catch(() => ""),
|
|
555
643
|
]);
|
|
556
644
|
if (regex.trim() && url.trim()) {
|
|
557
|
-
issueLinking = {
|
|
645
|
+
issueLinking = {
|
|
646
|
+
regex: regex.trim(),
|
|
647
|
+
url: url.trim(),
|
|
648
|
+
useGlobally: true,
|
|
649
|
+
};
|
|
558
650
|
}
|
|
559
651
|
}
|
|
560
652
|
return {
|
|
@@ -563,53 +655,63 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
563
655
|
filePrBranchNameTemplate ||
|
|
564
656
|
DEFAULT_PR_BRANCH_NAME_TEMPLATE,
|
|
565
657
|
source: azure.source,
|
|
566
|
-
autoReload: typeof autoReload ===
|
|
567
|
-
diffViewer: diffViewer ===
|
|
568
|
-
showGraph: typeof showGraph ===
|
|
569
|
-
showStashes: typeof showStashes ===
|
|
570
|
-
|
|
571
|
-
|
|
658
|
+
autoReload: typeof autoReload === "boolean" ? autoReload : (fileAutoReload ?? true),
|
|
659
|
+
diffViewer: diffViewer === "vscode" ? "vscode" : "guito",
|
|
660
|
+
showGraph: typeof showGraph === "boolean" ? showGraph : (fileShowGraph ?? true),
|
|
661
|
+
showStashes: typeof showStashes === "boolean"
|
|
662
|
+
? showStashes
|
|
663
|
+
: (fileShowStashes ?? false),
|
|
664
|
+
showTags: typeof showTags === "boolean" ? showTags : (fileShowTags ?? true),
|
|
665
|
+
showRemoteBranches: typeof showRemoteBranches === "boolean"
|
|
572
666
|
? showRemoteBranches
|
|
573
667
|
: (fileShowRemoteBranches ?? true),
|
|
574
|
-
fileListView: fileListView ?? fileFileListView ??
|
|
575
|
-
searchMode: searchMode ?? fileSearchMode ??
|
|
576
|
-
searchCaseSensitive: typeof searchCaseSensitive ===
|
|
668
|
+
fileListView: fileListView ?? fileFileListView ?? "flat",
|
|
669
|
+
searchMode: searchMode ?? fileSearchMode ?? "navigate",
|
|
670
|
+
searchCaseSensitive: typeof searchCaseSensitive === "boolean"
|
|
577
671
|
? searchCaseSensitive
|
|
578
672
|
: (fileSearchCaseSensitive ?? false),
|
|
673
|
+
allowMerge: typeof allowMerge === "boolean" ? allowMerge : (fileAllowMerge ?? true),
|
|
579
674
|
issueLinking,
|
|
580
675
|
};
|
|
581
676
|
};
|
|
582
677
|
const configuredIdentity = async () => {
|
|
583
678
|
const config = await git.listConfig();
|
|
584
679
|
return {
|
|
585
|
-
name: String(config.all[
|
|
586
|
-
email: String(config.all[
|
|
680
|
+
name: String(config.all["user.name"] ?? "").trim(),
|
|
681
|
+
email: String(config.all["user.email"] ?? "").trim(),
|
|
587
682
|
};
|
|
588
683
|
};
|
|
589
684
|
const authorIdentity = (name, email, identity) => ({
|
|
590
|
-
author_name: identity.name &&
|
|
685
|
+
author_name: identity.name &&
|
|
686
|
+
identity.email &&
|
|
687
|
+
email.trim().toLowerCase() === identity.email.toLowerCase()
|
|
591
688
|
? identity.name
|
|
592
689
|
: name,
|
|
593
690
|
author_email: email,
|
|
594
691
|
});
|
|
595
692
|
// ==================== Repository ====================
|
|
596
|
-
app.get(
|
|
693
|
+
app.get("/api/repo", async (_req, resp) => {
|
|
597
694
|
try {
|
|
598
|
-
const root = (await git.revparse([
|
|
599
|
-
return resp
|
|
600
|
-
|
|
601
|
-
|
|
695
|
+
const root = (await git.revparse(["--show-toplevel"])).trim();
|
|
696
|
+
return resp.type("application/json").send({
|
|
697
|
+
root,
|
|
698
|
+
name: basename(root),
|
|
699
|
+
identity: await configuredIdentity(),
|
|
700
|
+
});
|
|
602
701
|
}
|
|
603
702
|
catch (err) {
|
|
604
|
-
return resp
|
|
703
|
+
return resp
|
|
704
|
+
.status(400)
|
|
705
|
+
.type("application/json")
|
|
706
|
+
.send({ error: err.message });
|
|
605
707
|
}
|
|
606
708
|
});
|
|
607
709
|
// ==================== Commits ====================
|
|
608
|
-
app.get(
|
|
710
|
+
app.get("/api/commits", async (req, resp) => {
|
|
609
711
|
try {
|
|
610
712
|
const query = (req.query ?? {});
|
|
611
|
-
const limit = Number.parseInt(query.limit ??
|
|
612
|
-
const skip = Number.parseInt(query.skip ??
|
|
713
|
+
const limit = Number.parseInt(query.limit ?? "", 10);
|
|
714
|
+
const skip = Number.parseInt(query.skip ?? "", 10);
|
|
613
715
|
// Build simple-git options conditionally: an explicit key with an
|
|
614
716
|
// undefined value would still be emitted as a bare command flag.
|
|
615
717
|
// The commit body is intentionally omitted: it roughly doubles the
|
|
@@ -617,273 +719,329 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
617
719
|
// on demand by /api/commit/detail instead.
|
|
618
720
|
const options = {
|
|
619
721
|
format: {
|
|
620
|
-
hash:
|
|
621
|
-
date:
|
|
622
|
-
message:
|
|
623
|
-
refs:
|
|
624
|
-
author_name:
|
|
625
|
-
author_email:
|
|
626
|
-
parents:
|
|
722
|
+
hash: "%H",
|
|
723
|
+
date: "%aI",
|
|
724
|
+
message: "%s",
|
|
725
|
+
refs: "%D",
|
|
726
|
+
author_name: "%aN",
|
|
727
|
+
author_email: "%aE",
|
|
728
|
+
parents: "%P",
|
|
627
729
|
},
|
|
628
|
-
|
|
730
|
+
"--all": null,
|
|
629
731
|
};
|
|
630
732
|
if (Number.isFinite(limit) && limit > 0) {
|
|
631
733
|
options.maxCount = limit;
|
|
632
734
|
}
|
|
633
735
|
if (Number.isFinite(skip) && skip > 0) {
|
|
634
|
-
options[
|
|
736
|
+
options["--skip"] = String(skip);
|
|
635
737
|
}
|
|
636
738
|
const [log, count, identity] = await Promise.all([
|
|
637
739
|
git.log(options),
|
|
638
|
-
git.raw([
|
|
740
|
+
git.raw(["rev-list", "--count", "--all"]),
|
|
639
741
|
configuredIdentity(),
|
|
640
742
|
]);
|
|
641
743
|
const commits = log.all.map((commit) => ({
|
|
642
744
|
...commit,
|
|
643
745
|
...authorIdentity(commit.author_name, commit.author_email, identity),
|
|
644
|
-
parents: String(commit.parents ??
|
|
645
|
-
.split(
|
|
746
|
+
parents: String(commit.parents ?? "")
|
|
747
|
+
.split(" ")
|
|
646
748
|
.filter(Boolean),
|
|
647
749
|
}));
|
|
648
750
|
return resp
|
|
649
|
-
.type(
|
|
751
|
+
.type("application/json")
|
|
650
752
|
.send({ commits, total: Number.parseInt(count, 10) || 0 });
|
|
651
753
|
}
|
|
652
754
|
catch (err) {
|
|
653
755
|
// A repository without any commits has no history to list.
|
|
654
|
-
if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ??
|
|
655
|
-
return resp.type(
|
|
756
|
+
if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? "")) {
|
|
757
|
+
return resp.type("application/json").send({ commits: [], total: 0 });
|
|
656
758
|
}
|
|
657
|
-
return resp
|
|
759
|
+
return resp
|
|
760
|
+
.status(400)
|
|
761
|
+
.type("application/json")
|
|
762
|
+
.send({ error: err.message });
|
|
658
763
|
}
|
|
659
764
|
});
|
|
660
765
|
// Full-text commit search. The list payload omits commit bodies, so body
|
|
661
766
|
// matching runs here (git --grep covers subject and body together) and the
|
|
662
767
|
// client unions the resulting hashes with its local subject/author/hash
|
|
663
768
|
// matches over the loaded history.
|
|
664
|
-
app.get(
|
|
665
|
-
const query = String((req.query ?? {}).query ??
|
|
666
|
-
const caseSensitive = String((req.query ?? {}).caseSensitive ??
|
|
769
|
+
app.get("/api/commits/search", async (req, resp) => {
|
|
770
|
+
const query = String((req.query ?? {}).query ?? "").trim();
|
|
771
|
+
const caseSensitive = String((req.query ?? {}).caseSensitive ?? "") === "1";
|
|
667
772
|
if (!query) {
|
|
668
|
-
return resp.type(
|
|
773
|
+
return resp.type("application/json").send({ hashes: [], indices: {} });
|
|
669
774
|
}
|
|
670
775
|
try {
|
|
671
|
-
const grepArgs = [
|
|
776
|
+
const grepArgs = ["log", "--all"];
|
|
672
777
|
if (!caseSensitive)
|
|
673
|
-
grepArgs.push(
|
|
674
|
-
grepArgs.push(
|
|
778
|
+
grepArgs.push("-i");
|
|
779
|
+
grepArgs.push("--fixed-strings", `--grep=${query}`, "--format=%H");
|
|
675
780
|
const [raw, history] = await Promise.all([
|
|
676
781
|
git.raw(grepArgs),
|
|
677
|
-
git.raw([
|
|
782
|
+
git.raw(["log", "--all", "--format=%H"]),
|
|
678
783
|
]);
|
|
679
784
|
const hashes = raw
|
|
680
|
-
.split(
|
|
785
|
+
.split("\n")
|
|
681
786
|
.map((hash) => hash.trim())
|
|
682
787
|
.filter(Boolean);
|
|
683
788
|
const matches = new Set(hashes);
|
|
684
789
|
// Use the same unfiltered Git log order as the paged history endpoint.
|
|
685
790
|
const indices = {};
|
|
686
791
|
history
|
|
687
|
-
.split(
|
|
792
|
+
.split("\n")
|
|
688
793
|
.map((hash) => hash.trim())
|
|
689
794
|
.filter(Boolean)
|
|
690
795
|
.forEach((hash, index) => {
|
|
691
796
|
if (matches.has(hash))
|
|
692
797
|
indices[hash] = index;
|
|
693
798
|
});
|
|
694
|
-
return resp.type(
|
|
799
|
+
return resp.type("application/json").send({
|
|
695
800
|
hashes,
|
|
696
801
|
indices,
|
|
697
802
|
});
|
|
698
803
|
}
|
|
699
804
|
catch (err) {
|
|
700
|
-
if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ??
|
|
701
|
-
return resp.type(
|
|
805
|
+
if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? "")) {
|
|
806
|
+
return resp.type("application/json").send({ hashes: [], indices: {} });
|
|
702
807
|
}
|
|
703
|
-
return resp
|
|
808
|
+
return resp
|
|
809
|
+
.status(400)
|
|
810
|
+
.type("application/json")
|
|
811
|
+
.send({ error: err.message });
|
|
704
812
|
}
|
|
705
813
|
});
|
|
706
|
-
app.post(
|
|
814
|
+
app.post("/api/commit", async (req, resp) => {
|
|
707
815
|
try {
|
|
708
816
|
const { message, description } = req.body ?? {};
|
|
709
817
|
await mutate(() => working.commit(message, description));
|
|
710
|
-
return resp.type(
|
|
818
|
+
return resp.type("application/json").send({ success: true });
|
|
711
819
|
}
|
|
712
820
|
catch (err) {
|
|
713
|
-
return resp
|
|
821
|
+
return resp
|
|
822
|
+
.status(400)
|
|
823
|
+
.type("application/json")
|
|
824
|
+
.send({ error: err.message });
|
|
714
825
|
}
|
|
715
826
|
});
|
|
716
|
-
app.post(
|
|
827
|
+
app.post("/api/amend", async (req, resp) => {
|
|
717
828
|
try {
|
|
718
829
|
const { message, description } = req.body;
|
|
719
830
|
await git.commit([message, ...(description ? [description] : [])], {
|
|
720
|
-
|
|
721
|
-
|
|
831
|
+
"--amend": null,
|
|
832
|
+
"--no-edit": null,
|
|
722
833
|
});
|
|
723
|
-
return resp.type(
|
|
834
|
+
return resp.type("application/json").send({ success: true });
|
|
724
835
|
}
|
|
725
836
|
catch (err) {
|
|
726
|
-
return resp
|
|
837
|
+
return resp
|
|
838
|
+
.status(400)
|
|
839
|
+
.type("application/json")
|
|
840
|
+
.send({ error: err.message });
|
|
727
841
|
}
|
|
728
842
|
});
|
|
729
|
-
app.post(
|
|
843
|
+
app.post("/api/revert", async (req, resp) => {
|
|
730
844
|
try {
|
|
731
845
|
const { commit } = req.body;
|
|
732
846
|
await git.revert(commit);
|
|
733
|
-
return resp.type(
|
|
847
|
+
return resp.type("application/json").send({ success: true });
|
|
734
848
|
}
|
|
735
849
|
catch (err) {
|
|
736
|
-
return resp
|
|
850
|
+
return resp
|
|
851
|
+
.status(400)
|
|
852
|
+
.type("application/json")
|
|
853
|
+
.send({ error: err.message });
|
|
737
854
|
}
|
|
738
855
|
});
|
|
739
|
-
app.post(
|
|
856
|
+
app.post("/api/cherry-pick", async (req, resp) => {
|
|
740
857
|
try {
|
|
741
|
-
await git.raw([
|
|
742
|
-
return resp.type(
|
|
858
|
+
await git.raw(["cherry-pick", req.body.commit]);
|
|
859
|
+
return resp.type("application/json").send({ success: true });
|
|
743
860
|
}
|
|
744
861
|
catch (err) {
|
|
745
|
-
return resp
|
|
862
|
+
return resp
|
|
863
|
+
.status(400)
|
|
864
|
+
.type("application/json")
|
|
865
|
+
.send({ error: err.message });
|
|
746
866
|
}
|
|
747
867
|
});
|
|
748
|
-
app.post(
|
|
868
|
+
app.post("/api/commit/drop", async (req, resp) => {
|
|
749
869
|
try {
|
|
750
|
-
await git.raw([
|
|
751
|
-
return resp.type(
|
|
870
|
+
await git.raw(["reset", "--hard", `${req.body.commit}^`]);
|
|
871
|
+
return resp.type("application/json").send({ success: true });
|
|
752
872
|
}
|
|
753
873
|
catch (err) {
|
|
754
|
-
return resp
|
|
874
|
+
return resp
|
|
875
|
+
.status(400)
|
|
876
|
+
.type("application/json")
|
|
877
|
+
.send({ error: err.message });
|
|
755
878
|
}
|
|
756
879
|
});
|
|
757
|
-
app.post(
|
|
880
|
+
app.post("/api/reset-commit", async (req, resp) => {
|
|
758
881
|
try {
|
|
759
|
-
const mode = [
|
|
760
|
-
|
|
761
|
-
|
|
882
|
+
const mode = ["soft", "mixed", "hard"].includes(req.body?.mode)
|
|
883
|
+
? req.body.mode
|
|
884
|
+
: "hard";
|
|
885
|
+
await git.raw(["reset", `--${mode}`, req.body.commit]);
|
|
886
|
+
return resp.type("application/json").send({ success: true });
|
|
762
887
|
}
|
|
763
888
|
catch (err) {
|
|
764
|
-
return resp
|
|
889
|
+
return resp
|
|
890
|
+
.status(400)
|
|
891
|
+
.type("application/json")
|
|
892
|
+
.send({ error: err.message });
|
|
765
893
|
}
|
|
766
894
|
});
|
|
767
895
|
// ==================== Staging ====================
|
|
768
|
-
app.get(
|
|
896
|
+
app.get("/api/status", async (_req, resp) => {
|
|
769
897
|
try {
|
|
770
898
|
const status = await git.status();
|
|
771
|
-
return resp.type(
|
|
899
|
+
return resp.type("application/json").send(status);
|
|
772
900
|
}
|
|
773
901
|
catch (err) {
|
|
774
|
-
return resp
|
|
902
|
+
return resp
|
|
903
|
+
.status(400)
|
|
904
|
+
.type("application/json")
|
|
905
|
+
.send({ error: err.message });
|
|
775
906
|
}
|
|
776
907
|
});
|
|
777
|
-
app.post(
|
|
908
|
+
app.post("/api/stage", async (req, resp) => {
|
|
778
909
|
try {
|
|
779
910
|
await mutate(() => working.stage(req.body?.files));
|
|
780
|
-
return resp.type(
|
|
911
|
+
return resp.type("application/json").send({ success: true });
|
|
781
912
|
}
|
|
782
913
|
catch (err) {
|
|
783
|
-
return resp
|
|
914
|
+
return resp
|
|
915
|
+
.status(400)
|
|
916
|
+
.type("application/json")
|
|
917
|
+
.send({ error: err.message });
|
|
784
918
|
}
|
|
785
919
|
});
|
|
786
|
-
app.post(
|
|
920
|
+
app.post("/api/unstage", async (req, resp) => {
|
|
787
921
|
try {
|
|
788
922
|
await mutate(() => working.unstage(req.body?.files));
|
|
789
|
-
return resp.type(
|
|
923
|
+
return resp.type("application/json").send({ success: true });
|
|
790
924
|
}
|
|
791
925
|
catch (err) {
|
|
792
|
-
return resp
|
|
926
|
+
return resp
|
|
927
|
+
.status(400)
|
|
928
|
+
.type("application/json")
|
|
929
|
+
.send({ error: err.message });
|
|
793
930
|
}
|
|
794
931
|
});
|
|
795
|
-
app.post(
|
|
932
|
+
app.post("/api/stage-lines", async (req, resp) => {
|
|
796
933
|
try {
|
|
797
934
|
const { file, patch } = req.body;
|
|
798
935
|
// Stage specific lines using git add -p equivalent (patch mode)
|
|
799
|
-
await git.raw(
|
|
800
|
-
return resp.type(
|
|
936
|
+
await git.raw("add --patch", { input: patch });
|
|
937
|
+
return resp.type("application/json").send({ success: true });
|
|
801
938
|
}
|
|
802
939
|
catch (err) {
|
|
803
|
-
return resp
|
|
940
|
+
return resp
|
|
941
|
+
.status(400)
|
|
942
|
+
.type("application/json")
|
|
943
|
+
.send({ error: err.message });
|
|
804
944
|
}
|
|
805
945
|
});
|
|
806
946
|
// ==================== Branches ====================
|
|
807
|
-
app.get(
|
|
947
|
+
app.get("/api/branches/all", async (_req, resp) => {
|
|
808
948
|
try {
|
|
809
949
|
const raw = await git.raw([
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
950
|
+
"for-each-ref",
|
|
951
|
+
"--format=%(refname)%09%(objectname)%09%(HEAD)",
|
|
952
|
+
"refs/heads",
|
|
953
|
+
"refs/remotes",
|
|
814
954
|
]);
|
|
815
955
|
const branches = raw
|
|
816
|
-
.split(
|
|
956
|
+
.split("\n")
|
|
817
957
|
.map((line) => line.trim())
|
|
818
958
|
.filter(Boolean)
|
|
819
959
|
.map((line) => {
|
|
820
|
-
const [refname, commit, headMarker] = line.split(
|
|
821
|
-
const remote = refname.startsWith(
|
|
822
|
-
const name = refname.replace(/^refs\/(heads|remotes)\//,
|
|
823
|
-
return { name, commit, current: headMarker ===
|
|
960
|
+
const [refname, commit, headMarker] = line.split("\t");
|
|
961
|
+
const remote = refname.startsWith("refs/remotes/");
|
|
962
|
+
const name = refname.replace(/^refs\/(heads|remotes)\//, "");
|
|
963
|
+
return { name, commit, current: headMarker === "*", remote };
|
|
824
964
|
});
|
|
825
|
-
return resp.type(
|
|
965
|
+
return resp.type("application/json").send(branches);
|
|
826
966
|
}
|
|
827
967
|
catch (err) {
|
|
828
|
-
return resp
|
|
968
|
+
return resp
|
|
969
|
+
.status(400)
|
|
970
|
+
.type("application/json")
|
|
971
|
+
.send({ error: err.message });
|
|
829
972
|
}
|
|
830
973
|
});
|
|
831
|
-
app.get(
|
|
974
|
+
app.get("/api/branches", async (_req, resp) => {
|
|
832
975
|
try {
|
|
833
976
|
const branchSummary = await git.branchLocal();
|
|
834
|
-
return resp.type(
|
|
977
|
+
return resp.type("application/json").send(branchSummary);
|
|
835
978
|
}
|
|
836
979
|
catch (err) {
|
|
837
|
-
return resp
|
|
980
|
+
return resp
|
|
981
|
+
.status(400)
|
|
982
|
+
.type("application/json")
|
|
983
|
+
.send({ error: err.message });
|
|
838
984
|
}
|
|
839
985
|
});
|
|
840
|
-
app.post(
|
|
986
|
+
app.post("/api/branch/create", async (req, resp) => {
|
|
841
987
|
try {
|
|
842
988
|
const { name, startPoint } = req.body;
|
|
843
989
|
await git.branch([...(startPoint ? [name, startPoint] : [name])]);
|
|
844
|
-
return resp.type(
|
|
990
|
+
return resp.type("application/json").send({ success: true });
|
|
845
991
|
}
|
|
846
992
|
catch (err) {
|
|
847
|
-
return resp
|
|
993
|
+
return resp
|
|
994
|
+
.status(400)
|
|
995
|
+
.type("application/json")
|
|
996
|
+
.send({ error: err.message });
|
|
848
997
|
}
|
|
849
998
|
});
|
|
850
|
-
app.post(
|
|
999
|
+
app.post("/api/branch/delete", async (req, resp) => {
|
|
851
1000
|
try {
|
|
852
1001
|
const { name, force } = req.body;
|
|
853
1002
|
await git.deleteLocalBranch(name, force);
|
|
854
|
-
return resp.type(
|
|
1003
|
+
return resp.type("application/json").send({ success: true });
|
|
855
1004
|
}
|
|
856
1005
|
catch (err) {
|
|
857
|
-
return resp
|
|
1006
|
+
return resp
|
|
1007
|
+
.status(400)
|
|
1008
|
+
.type("application/json")
|
|
1009
|
+
.send({ error: err.message });
|
|
858
1010
|
}
|
|
859
1011
|
});
|
|
860
|
-
app.post(
|
|
1012
|
+
app.post("/api/branch/delete-remote", async (req, resp) => {
|
|
861
1013
|
try {
|
|
862
1014
|
const { remote, branch } = req.body;
|
|
863
|
-
await git.push([remote,
|
|
864
|
-
return resp.type(
|
|
1015
|
+
await git.push([remote, "--delete", branch]);
|
|
1016
|
+
return resp.type("application/json").send({ success: true });
|
|
865
1017
|
}
|
|
866
1018
|
catch (err) {
|
|
867
|
-
return resp
|
|
1019
|
+
return resp
|
|
1020
|
+
.status(400)
|
|
1021
|
+
.type("application/json")
|
|
1022
|
+
.send({ error: err.message });
|
|
868
1023
|
}
|
|
869
1024
|
});
|
|
870
|
-
app.post(
|
|
1025
|
+
app.post("/api/branch/rename", async (req, resp) => {
|
|
871
1026
|
try {
|
|
872
1027
|
const { oldName, newName } = req.body;
|
|
873
|
-
await git.branch([
|
|
874
|
-
return resp.type(
|
|
1028
|
+
await git.branch(["-m", oldName, newName]);
|
|
1029
|
+
return resp.type("application/json").send({ success: true });
|
|
875
1030
|
}
|
|
876
1031
|
catch (err) {
|
|
877
|
-
return resp
|
|
1032
|
+
return resp
|
|
1033
|
+
.status(400)
|
|
1034
|
+
.type("application/json")
|
|
1035
|
+
.send({ error: err.message });
|
|
878
1036
|
}
|
|
879
1037
|
});
|
|
880
1038
|
// ==================== Worktrees ====================
|
|
881
|
-
app.get(
|
|
1039
|
+
app.get("/api/worktrees", async (_req, resp) => {
|
|
882
1040
|
try {
|
|
883
|
-
const raw = await git.raw([
|
|
1041
|
+
const raw = await git.raw(["worktree", "list", "--porcelain"]);
|
|
884
1042
|
// Path comparison is case-insensitive on Windows so the served worktree
|
|
885
1043
|
// is detected regardless of drive-letter casing.
|
|
886
|
-
const samePath = (a, b) => process.platform ===
|
|
1044
|
+
const samePath = (a, b) => process.platform === "win32"
|
|
887
1045
|
? resolve(a).toLowerCase() === resolve(b).toLowerCase()
|
|
888
1046
|
: resolve(a) === resolve(b);
|
|
889
1047
|
const worktrees = raw
|
|
@@ -892,425 +1050,527 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
892
1050
|
.filter(Boolean)
|
|
893
1051
|
.map((block) => {
|
|
894
1052
|
const entry = {
|
|
895
|
-
path:
|
|
896
|
-
head:
|
|
897
|
-
branch:
|
|
1053
|
+
path: "",
|
|
1054
|
+
head: "",
|
|
1055
|
+
branch: "",
|
|
898
1056
|
bare: false,
|
|
899
1057
|
detached: false,
|
|
900
1058
|
current: false,
|
|
901
1059
|
};
|
|
902
|
-
for (const line of block.split(
|
|
903
|
-
if (line.startsWith(
|
|
904
|
-
entry.path = line.slice(
|
|
905
|
-
else if (line.startsWith(
|
|
906
|
-
entry.head = line.slice(
|
|
907
|
-
else if (line.startsWith(
|
|
908
|
-
entry.branch = line
|
|
909
|
-
|
|
1060
|
+
for (const line of block.split("\n").map((l) => l.trim())) {
|
|
1061
|
+
if (line.startsWith("worktree "))
|
|
1062
|
+
entry.path = line.slice("worktree ".length);
|
|
1063
|
+
else if (line.startsWith("HEAD "))
|
|
1064
|
+
entry.head = line.slice("HEAD ".length);
|
|
1065
|
+
else if (line.startsWith("branch "))
|
|
1066
|
+
entry.branch = line
|
|
1067
|
+
.slice("branch ".length)
|
|
1068
|
+
.replace(/^refs\/heads\//, "");
|
|
1069
|
+
else if (line === "bare")
|
|
910
1070
|
entry.bare = true;
|
|
911
|
-
else if (line ===
|
|
1071
|
+
else if (line === "detached")
|
|
912
1072
|
entry.detached = true;
|
|
913
1073
|
}
|
|
914
1074
|
entry.current = !!entry.path && samePath(entry.path, repositoryPath);
|
|
915
1075
|
return entry;
|
|
916
1076
|
});
|
|
917
|
-
return resp.type(
|
|
1077
|
+
return resp.type("application/json").send(worktrees);
|
|
918
1078
|
}
|
|
919
1079
|
catch (err) {
|
|
920
|
-
return resp
|
|
1080
|
+
return resp
|
|
1081
|
+
.status(400)
|
|
1082
|
+
.type("application/json")
|
|
1083
|
+
.send({ error: err.message });
|
|
921
1084
|
}
|
|
922
1085
|
});
|
|
923
|
-
app.post(
|
|
1086
|
+
app.post("/api/worktrees", async (req, resp) => {
|
|
924
1087
|
try {
|
|
925
|
-
const path = typeof req.body?.path ===
|
|
926
|
-
const branch = typeof req.body?.branch ===
|
|
1088
|
+
const path = typeof req.body?.path === "string" ? req.body.path.trim() : "";
|
|
1089
|
+
const branch = typeof req.body?.branch === "string" ? req.body.branch.trim() : "";
|
|
927
1090
|
if (!path || !isAbsolute(path)) {
|
|
928
|
-
throw new Error(
|
|
1091
|
+
throw new Error("Choose an absolute folder path for the worktree.");
|
|
929
1092
|
}
|
|
930
1093
|
if (!branch) {
|
|
931
|
-
throw new Error(
|
|
1094
|
+
throw new Error("Choose a local branch for the worktree.");
|
|
932
1095
|
}
|
|
933
1096
|
// Resolve the branch as a local ref before passing it to Git. Besides a
|
|
934
1097
|
// clearer error, this prevents a request from treating an option as a ref.
|
|
935
|
-
await git.raw([
|
|
936
|
-
|
|
937
|
-
|
|
1098
|
+
await git.raw([
|
|
1099
|
+
"show-ref",
|
|
1100
|
+
"--verify",
|
|
1101
|
+
"--quiet",
|
|
1102
|
+
`refs/heads/${branch}`,
|
|
1103
|
+
]);
|
|
1104
|
+
await mutate(() => git
|
|
1105
|
+
.raw(["worktree", "add", "--", resolve(path), branch])
|
|
1106
|
+
.then(() => { }));
|
|
1107
|
+
return resp.type("application/json").send({ success: true });
|
|
938
1108
|
}
|
|
939
1109
|
catch (err) {
|
|
940
|
-
return resp
|
|
1110
|
+
return resp
|
|
1111
|
+
.status(400)
|
|
1112
|
+
.type("application/json")
|
|
1113
|
+
.send({ error: err.message });
|
|
941
1114
|
}
|
|
942
1115
|
});
|
|
943
|
-
app.post(
|
|
1116
|
+
app.post("/api/worktrees/remove", async (req, resp) => {
|
|
944
1117
|
try {
|
|
945
|
-
const path = typeof req.body?.path ===
|
|
1118
|
+
const path = typeof req.body?.path === "string" ? req.body.path.trim() : "";
|
|
946
1119
|
if (!path || !isAbsolute(path)) {
|
|
947
|
-
throw new Error(
|
|
1120
|
+
throw new Error("A valid worktree path is required.");
|
|
948
1121
|
}
|
|
949
1122
|
const target = resolve(path);
|
|
950
|
-
const samePath = (a, b) => process.platform ===
|
|
1123
|
+
const samePath = (a, b) => process.platform === "win32"
|
|
951
1124
|
? resolve(a).toLowerCase() === resolve(b).toLowerCase()
|
|
952
1125
|
: resolve(a) === resolve(b);
|
|
953
1126
|
if (samePath(target, repositoryPath)) {
|
|
954
|
-
throw new Error(
|
|
1127
|
+
throw new Error("The worktree currently open in Guito cannot be removed.");
|
|
955
1128
|
}
|
|
956
1129
|
// Only registered worktrees may be removed. Do not use --force: Git must
|
|
957
1130
|
// protect uncommitted or untracked files in the selected worktree.
|
|
958
|
-
const registered = (await git.raw([
|
|
1131
|
+
const registered = (await git.raw(["worktree", "list", "--porcelain"]))
|
|
959
1132
|
.split(/\n\s*\n/)
|
|
960
1133
|
.some((block) => {
|
|
961
|
-
const line = block
|
|
962
|
-
|
|
1134
|
+
const line = block
|
|
1135
|
+
.split("\n")
|
|
1136
|
+
.find((entry) => entry.startsWith("worktree "));
|
|
1137
|
+
return (!!line && samePath(line.slice("worktree ".length).trim(), target));
|
|
963
1138
|
});
|
|
964
1139
|
if (!registered) {
|
|
965
|
-
throw new Error(
|
|
1140
|
+
throw new Error("The selected folder is not a registered worktree.");
|
|
966
1141
|
}
|
|
967
|
-
await mutate(() => git.raw([
|
|
968
|
-
return resp.type(
|
|
1142
|
+
await mutate(() => git.raw(["worktree", "remove", "--", target]).then(() => { }));
|
|
1143
|
+
return resp.type("application/json").send({ success: true });
|
|
969
1144
|
}
|
|
970
1145
|
catch (err) {
|
|
971
|
-
return resp
|
|
1146
|
+
return resp
|
|
1147
|
+
.status(400)
|
|
1148
|
+
.type("application/json")
|
|
1149
|
+
.send({ error: err.message });
|
|
972
1150
|
}
|
|
973
1151
|
});
|
|
974
|
-
app.post(
|
|
1152
|
+
app.post("/api/checkout", async (req, resp) => {
|
|
975
1153
|
try {
|
|
976
1154
|
const { ref } = req.body;
|
|
977
1155
|
await git.checkout(ref);
|
|
978
|
-
return resp.type(
|
|
1156
|
+
return resp.type("application/json").send({ success: true });
|
|
979
1157
|
}
|
|
980
1158
|
catch (err) {
|
|
981
|
-
return resp
|
|
1159
|
+
return resp
|
|
1160
|
+
.status(400)
|
|
1161
|
+
.type("application/json")
|
|
1162
|
+
.send({ error: err.message });
|
|
982
1163
|
}
|
|
983
1164
|
});
|
|
984
1165
|
// ==================== Merge & Rebase ====================
|
|
985
|
-
app.post(
|
|
1166
|
+
app.post("/api/merge", async (req, resp) => {
|
|
986
1167
|
try {
|
|
987
1168
|
const { branch } = req.body;
|
|
988
1169
|
await git.merge([branch]);
|
|
989
|
-
return resp.type(
|
|
1170
|
+
return resp.type("application/json").send({ success: true });
|
|
990
1171
|
}
|
|
991
1172
|
catch (err) {
|
|
992
|
-
return resp
|
|
1173
|
+
return resp
|
|
1174
|
+
.status(400)
|
|
1175
|
+
.type("application/json")
|
|
1176
|
+
.send({ error: err.message });
|
|
993
1177
|
}
|
|
994
1178
|
});
|
|
995
|
-
app.get(
|
|
1179
|
+
app.get("/api/archive", async (req, resp) => {
|
|
996
1180
|
try {
|
|
997
|
-
const ref = String(req.query?.ref ??
|
|
1181
|
+
const ref = String(req.query?.ref ?? "HEAD");
|
|
998
1182
|
if (!/^[0-9a-fA-F]{7,40}$/.test(ref) && !/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
|
999
|
-
return resp
|
|
1183
|
+
return resp
|
|
1184
|
+
.status(400)
|
|
1185
|
+
.type("application/json")
|
|
1186
|
+
.send({ error: "invalid ref" });
|
|
1000
1187
|
}
|
|
1001
|
-
const result = await execFileAsync(
|
|
1188
|
+
const result = await execFileAsync("git", ["archive", "--format=zip", ref], {
|
|
1002
1189
|
cwd: repositoryPath,
|
|
1003
|
-
encoding:
|
|
1190
|
+
encoding: "buffer",
|
|
1004
1191
|
maxBuffer: 100 * 1024 * 1024,
|
|
1005
1192
|
});
|
|
1006
1193
|
return resp
|
|
1007
|
-
.header(
|
|
1008
|
-
.type(
|
|
1194
|
+
.header("Content-Disposition", `attachment; filename="guito-${ref.slice(0, 8)}.zip"`)
|
|
1195
|
+
.type("application/zip")
|
|
1009
1196
|
.send(result.stdout);
|
|
1010
1197
|
}
|
|
1011
1198
|
catch (err) {
|
|
1012
|
-
return resp
|
|
1199
|
+
return resp
|
|
1200
|
+
.status(400)
|
|
1201
|
+
.type("application/json")
|
|
1202
|
+
.send({ error: err.message });
|
|
1013
1203
|
}
|
|
1014
1204
|
});
|
|
1015
|
-
app.post(
|
|
1205
|
+
app.post("/api/rebase", async (req, resp) => {
|
|
1016
1206
|
try {
|
|
1017
1207
|
const { branch } = req.body;
|
|
1018
1208
|
await git.rebase([branch]);
|
|
1019
|
-
return resp.type(
|
|
1209
|
+
return resp.type("application/json").send({ success: true });
|
|
1020
1210
|
}
|
|
1021
1211
|
catch (err) {
|
|
1022
|
-
return resp
|
|
1212
|
+
return resp
|
|
1213
|
+
.status(400)
|
|
1214
|
+
.type("application/json")
|
|
1215
|
+
.send({ error: err.message });
|
|
1023
1216
|
}
|
|
1024
1217
|
});
|
|
1025
|
-
app.post(
|
|
1218
|
+
app.post("/api/squash", async (req, resp) => {
|
|
1026
1219
|
try {
|
|
1027
1220
|
const { commits } = req.body;
|
|
1028
1221
|
// Squash: rebase -i with squash operation
|
|
1029
|
-
await git.rebase([
|
|
1030
|
-
return resp.type(
|
|
1222
|
+
await git.rebase(["-i", "HEAD~" + commits]);
|
|
1223
|
+
return resp.type("application/json").send({ success: true });
|
|
1031
1224
|
}
|
|
1032
1225
|
catch (err) {
|
|
1033
|
-
return resp
|
|
1226
|
+
return resp
|
|
1227
|
+
.status(400)
|
|
1228
|
+
.type("application/json")
|
|
1229
|
+
.send({ error: err.message });
|
|
1034
1230
|
}
|
|
1035
1231
|
});
|
|
1036
1232
|
// ==================== Stash ====================
|
|
1037
|
-
app.get(
|
|
1233
|
+
app.get("/api/stash/list", async (_req, resp) => {
|
|
1038
1234
|
try {
|
|
1039
1235
|
// Extra fields (date, author) let the commit table render stash rows;
|
|
1040
1236
|
// they are NUL-separated because messages cannot contain NUL.
|
|
1041
1237
|
const raw = await git.raw([
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1238
|
+
"stash",
|
|
1239
|
+
"list",
|
|
1240
|
+
"--format=%H%x00%aI%x00%aN%x00%aE%x00%s",
|
|
1045
1241
|
]);
|
|
1046
1242
|
const all = raw
|
|
1047
1243
|
.trim()
|
|
1048
|
-
.split(
|
|
1244
|
+
.split("\n")
|
|
1049
1245
|
.filter(Boolean)
|
|
1050
1246
|
.map((line) => {
|
|
1051
|
-
const [hash, date, authorName, authorEmail, message] = line.split(
|
|
1052
|
-
return {
|
|
1247
|
+
const [hash, date, authorName, authorEmail, message] = line.split("\u0000");
|
|
1248
|
+
return {
|
|
1249
|
+
hash,
|
|
1250
|
+
date,
|
|
1251
|
+
author_name: authorName,
|
|
1252
|
+
author_email: authorEmail,
|
|
1253
|
+
message,
|
|
1254
|
+
};
|
|
1053
1255
|
});
|
|
1054
|
-
return resp.type(
|
|
1256
|
+
return resp.type("application/json").send({
|
|
1055
1257
|
all,
|
|
1056
1258
|
latest: all[0] ?? null,
|
|
1057
1259
|
total: all.length,
|
|
1058
1260
|
});
|
|
1059
1261
|
}
|
|
1060
1262
|
catch (err) {
|
|
1061
|
-
return resp
|
|
1263
|
+
return resp
|
|
1264
|
+
.status(400)
|
|
1265
|
+
.type("application/json")
|
|
1266
|
+
.send({ error: err.message });
|
|
1062
1267
|
}
|
|
1063
1268
|
});
|
|
1064
|
-
app.post(
|
|
1269
|
+
app.post("/api/stash/save", async (req, resp) => {
|
|
1065
1270
|
try {
|
|
1066
1271
|
const { message, scope } = req.body ?? {};
|
|
1067
1272
|
// 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
|
|
1068
1273
|
// (--keep-index leaves staged changes staged); 'all' → index + working tree.
|
|
1069
1274
|
// Untracked files ride along for 'all'/'unstaged' since the panel lists them as unstaged.
|
|
1070
|
-
const args = [
|
|
1071
|
-
if (scope ===
|
|
1072
|
-
args.push(
|
|
1275
|
+
const args = ["push"];
|
|
1276
|
+
if (scope === "staged") {
|
|
1277
|
+
args.push("--staged");
|
|
1073
1278
|
}
|
|
1074
1279
|
else {
|
|
1075
|
-
if (scope ===
|
|
1076
|
-
args.push(
|
|
1077
|
-
args.push(
|
|
1280
|
+
if (scope === "unstaged")
|
|
1281
|
+
args.push("--keep-index");
|
|
1282
|
+
args.push("-u");
|
|
1078
1283
|
}
|
|
1079
1284
|
if (message)
|
|
1080
|
-
args.push(
|
|
1285
|
+
args.push("-m", message);
|
|
1081
1286
|
await git.stash(args);
|
|
1082
|
-
return resp.type(
|
|
1287
|
+
return resp.type("application/json").send({ success: true });
|
|
1083
1288
|
}
|
|
1084
1289
|
catch (err) {
|
|
1085
|
-
return resp
|
|
1290
|
+
return resp
|
|
1291
|
+
.status(400)
|
|
1292
|
+
.type("application/json")
|
|
1293
|
+
.send({ error: err.message });
|
|
1086
1294
|
}
|
|
1087
1295
|
});
|
|
1088
|
-
app.post(
|
|
1296
|
+
app.post("/api/stash/apply", async (req, resp) => {
|
|
1089
1297
|
try {
|
|
1090
1298
|
const { index } = req.body;
|
|
1091
|
-
await git.stash([
|
|
1092
|
-
return resp.type(
|
|
1299
|
+
await git.stash(["apply", `stash@{${index}}`]);
|
|
1300
|
+
return resp.type("application/json").send({ success: true });
|
|
1093
1301
|
}
|
|
1094
1302
|
catch (err) {
|
|
1095
|
-
return resp
|
|
1303
|
+
return resp
|
|
1304
|
+
.status(400)
|
|
1305
|
+
.type("application/json")
|
|
1306
|
+
.send({ error: err.message });
|
|
1096
1307
|
}
|
|
1097
1308
|
});
|
|
1098
|
-
app.post(
|
|
1309
|
+
app.post("/api/stash/pop", async (req, resp) => {
|
|
1099
1310
|
try {
|
|
1100
1311
|
const { index } = req.body;
|
|
1101
|
-
await git.stash([
|
|
1102
|
-
return resp.type(
|
|
1312
|
+
await git.stash(["pop", `stash@{${index}}`]);
|
|
1313
|
+
return resp.type("application/json").send({ success: true });
|
|
1103
1314
|
}
|
|
1104
1315
|
catch (err) {
|
|
1105
|
-
return resp
|
|
1316
|
+
return resp
|
|
1317
|
+
.status(400)
|
|
1318
|
+
.type("application/json")
|
|
1319
|
+
.send({ error: err.message });
|
|
1106
1320
|
}
|
|
1107
1321
|
});
|
|
1108
|
-
app.post(
|
|
1322
|
+
app.post("/api/stash/drop", async (req, resp) => {
|
|
1109
1323
|
try {
|
|
1110
1324
|
const { index } = req.body;
|
|
1111
|
-
await git.stash([
|
|
1112
|
-
return resp.type(
|
|
1325
|
+
await git.stash(["drop", `stash@{${index}}`]);
|
|
1326
|
+
return resp.type("application/json").send({ success: true });
|
|
1113
1327
|
}
|
|
1114
1328
|
catch (err) {
|
|
1115
|
-
return resp
|
|
1329
|
+
return resp
|
|
1330
|
+
.status(400)
|
|
1331
|
+
.type("application/json")
|
|
1332
|
+
.send({ error: err.message });
|
|
1116
1333
|
}
|
|
1117
1334
|
});
|
|
1118
|
-
app.post(
|
|
1335
|
+
app.post("/api/stash/show", async (req, resp) => {
|
|
1119
1336
|
try {
|
|
1120
1337
|
const { index } = req.body;
|
|
1121
|
-
const show = await git.stash([
|
|
1122
|
-
return resp.type(
|
|
1338
|
+
const show = await git.stash(["show", "-p", `stash@{${index}}`]);
|
|
1339
|
+
return resp.type("application/json").send({ preview: show });
|
|
1123
1340
|
}
|
|
1124
1341
|
catch (err) {
|
|
1125
|
-
return resp
|
|
1342
|
+
return resp
|
|
1343
|
+
.status(400)
|
|
1344
|
+
.type("application/json")
|
|
1345
|
+
.send({ error: err.message });
|
|
1126
1346
|
}
|
|
1127
1347
|
});
|
|
1128
1348
|
// ==================== Tags ====================
|
|
1129
1349
|
// All tags with the commit each points at (annotated tags are peeled, so the
|
|
1130
1350
|
// client can always look the tagged commit up by hash). Sorted by name.
|
|
1131
|
-
app.get(
|
|
1351
|
+
app.get("/api/tags", async (_req, resp) => {
|
|
1132
1352
|
try {
|
|
1133
1353
|
const raw = await git.raw([
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1354
|
+
"tag",
|
|
1355
|
+
"--list",
|
|
1356
|
+
"--sort=refname",
|
|
1357
|
+
"--format=%(refname:short)%09%(*objectname)%09%(objectname)",
|
|
1138
1358
|
]);
|
|
1139
1359
|
const tags = raw
|
|
1140
|
-
.split(
|
|
1141
|
-
.map((line) => line.split(
|
|
1360
|
+
.split("\n")
|
|
1361
|
+
.map((line) => line.split("\t"))
|
|
1142
1362
|
.filter(([name]) => Boolean(name.trim()))
|
|
1143
1363
|
.map(([name, peeled, direct]) => ({ name, hash: peeled || direct }));
|
|
1144
|
-
return resp.type(
|
|
1364
|
+
return resp.type("application/json").send(tags);
|
|
1145
1365
|
}
|
|
1146
1366
|
catch (err) {
|
|
1147
|
-
return resp
|
|
1367
|
+
return resp
|
|
1368
|
+
.status(400)
|
|
1369
|
+
.type("application/json")
|
|
1370
|
+
.send({ error: err.message });
|
|
1148
1371
|
}
|
|
1149
1372
|
});
|
|
1150
|
-
app.post(
|
|
1373
|
+
app.post("/api/tag/create", async (req, resp) => {
|
|
1151
1374
|
try {
|
|
1152
1375
|
const { name, message, commit } = req.body;
|
|
1153
1376
|
if (message) {
|
|
1154
|
-
await git.tag([
|
|
1377
|
+
await git.tag(["-a", name, "-m", message, ...(commit ? [commit] : [])]);
|
|
1155
1378
|
}
|
|
1156
1379
|
else {
|
|
1157
1380
|
await git.tag([name, ...(commit ? [commit] : [])]);
|
|
1158
1381
|
}
|
|
1159
|
-
return resp.type(
|
|
1382
|
+
return resp.type("application/json").send({ success: true });
|
|
1160
1383
|
}
|
|
1161
1384
|
catch (err) {
|
|
1162
|
-
return resp
|
|
1385
|
+
return resp
|
|
1386
|
+
.status(400)
|
|
1387
|
+
.type("application/json")
|
|
1388
|
+
.send({ error: err.message });
|
|
1163
1389
|
}
|
|
1164
1390
|
});
|
|
1165
|
-
app.post(
|
|
1391
|
+
app.post("/api/tag/delete", async (req, resp) => {
|
|
1166
1392
|
try {
|
|
1167
1393
|
const { name } = req.body;
|
|
1168
|
-
await git.tag([
|
|
1169
|
-
return resp.type(
|
|
1394
|
+
await git.tag(["-d", name]);
|
|
1395
|
+
return resp.type("application/json").send({ success: true });
|
|
1170
1396
|
}
|
|
1171
1397
|
catch (err) {
|
|
1172
|
-
return resp
|
|
1398
|
+
return resp
|
|
1399
|
+
.status(400)
|
|
1400
|
+
.type("application/json")
|
|
1401
|
+
.send({ error: err.message });
|
|
1173
1402
|
}
|
|
1174
1403
|
});
|
|
1175
|
-
app.post(
|
|
1404
|
+
app.post("/api/tag/push", async (req, resp) => {
|
|
1176
1405
|
try {
|
|
1177
1406
|
const { name, remote } = req.body;
|
|
1178
1407
|
if (!name) {
|
|
1179
|
-
return resp
|
|
1408
|
+
return resp
|
|
1409
|
+
.status(400)
|
|
1410
|
+
.type("application/json")
|
|
1411
|
+
.send({ error: "name required" });
|
|
1180
1412
|
}
|
|
1181
|
-
await git.push(remote ||
|
|
1182
|
-
return resp.type(
|
|
1413
|
+
await git.push(remote || "origin", `refs/tags/${name}`);
|
|
1414
|
+
return resp.type("application/json").send({ success: true });
|
|
1183
1415
|
}
|
|
1184
1416
|
catch (err) {
|
|
1185
|
-
return resp
|
|
1417
|
+
return resp
|
|
1418
|
+
.status(400)
|
|
1419
|
+
.type("application/json")
|
|
1420
|
+
.send({ error: err.message });
|
|
1186
1421
|
}
|
|
1187
1422
|
});
|
|
1188
1423
|
// ==================== Remote Operations ====================
|
|
1189
|
-
app.get(
|
|
1424
|
+
app.get("/api/fetch", async (req, resp) => {
|
|
1190
1425
|
try {
|
|
1191
|
-
const prune = [
|
|
1192
|
-
await git.fetch(prune ? {
|
|
1193
|
-
return resp.type(
|
|
1426
|
+
const prune = ["1", "true", "yes"].includes(String(req.query?.prune ?? "").toLowerCase());
|
|
1427
|
+
await git.fetch(prune ? { "--prune": null } : {});
|
|
1428
|
+
return resp.type("application/json").send({ success: true });
|
|
1194
1429
|
}
|
|
1195
1430
|
catch (err) {
|
|
1196
|
-
return resp
|
|
1431
|
+
return resp
|
|
1432
|
+
.status(400)
|
|
1433
|
+
.type("application/json")
|
|
1434
|
+
.send({ error: err.message });
|
|
1197
1435
|
}
|
|
1198
1436
|
});
|
|
1199
|
-
app.post(
|
|
1437
|
+
app.post("/api/pull", async (req, resp) => {
|
|
1200
1438
|
try {
|
|
1201
1439
|
const { remote, branch, rebase } = req.body;
|
|
1202
|
-
await git.pull(remote ||
|
|
1203
|
-
return resp.type(
|
|
1440
|
+
await git.pull(remote || "origin", branch || undefined, rebase ? { "--rebase": null } : {});
|
|
1441
|
+
return resp.type("application/json").send({ success: true });
|
|
1204
1442
|
}
|
|
1205
1443
|
catch (err) {
|
|
1206
|
-
return resp
|
|
1444
|
+
return resp
|
|
1445
|
+
.status(400)
|
|
1446
|
+
.type("application/json")
|
|
1447
|
+
.send({ error: err.message });
|
|
1207
1448
|
}
|
|
1208
1449
|
});
|
|
1209
|
-
app.post(
|
|
1450
|
+
app.post("/api/push", async (req, resp) => {
|
|
1210
1451
|
try {
|
|
1211
1452
|
const { remote, branch, force } = req.body;
|
|
1212
|
-
await git.push(remote ||
|
|
1213
|
-
return resp.type(
|
|
1453
|
+
await git.push(remote || "origin", branch || undefined, force ? { "-f": null } : {});
|
|
1454
|
+
return resp.type("application/json").send({ success: true });
|
|
1214
1455
|
}
|
|
1215
1456
|
catch (err) {
|
|
1216
|
-
return resp
|
|
1457
|
+
return resp
|
|
1458
|
+
.status(400)
|
|
1459
|
+
.type("application/json")
|
|
1460
|
+
.send({ error: err.message });
|
|
1217
1461
|
}
|
|
1218
1462
|
});
|
|
1219
|
-
app.post(
|
|
1463
|
+
app.post("/api/sync", async (_req, resp) => {
|
|
1220
1464
|
try {
|
|
1221
|
-
await git.pull(
|
|
1222
|
-
await git.push(
|
|
1223
|
-
return resp.type(
|
|
1465
|
+
await git.pull("origin", undefined, {});
|
|
1466
|
+
await git.push("origin", undefined, {});
|
|
1467
|
+
return resp.type("application/json").send({ success: true });
|
|
1224
1468
|
}
|
|
1225
1469
|
catch (err) {
|
|
1226
|
-
return resp
|
|
1470
|
+
return resp
|
|
1471
|
+
.status(400)
|
|
1472
|
+
.type("application/json")
|
|
1473
|
+
.send({ error: err.message });
|
|
1227
1474
|
}
|
|
1228
1475
|
});
|
|
1229
|
-
app.post(
|
|
1476
|
+
app.post("/api/prune", async (req, resp) => {
|
|
1230
1477
|
try {
|
|
1231
1478
|
const { remote } = req.body;
|
|
1232
|
-
await git.remote([
|
|
1233
|
-
return resp.type(
|
|
1479
|
+
await git.remote(["prune", remote || "origin"]);
|
|
1480
|
+
return resp.type("application/json").send({ success: true });
|
|
1234
1481
|
}
|
|
1235
1482
|
catch (err) {
|
|
1236
|
-
return resp
|
|
1483
|
+
return resp
|
|
1484
|
+
.status(400)
|
|
1485
|
+
.type("application/json")
|
|
1486
|
+
.send({ error: err.message });
|
|
1237
1487
|
}
|
|
1238
1488
|
});
|
|
1239
1489
|
const listRemotes = async () => {
|
|
1240
|
-
const names = (await git.raw([
|
|
1490
|
+
const names = (await git.raw(["remote"]))
|
|
1241
1491
|
.split(/\r?\n/)
|
|
1242
1492
|
.map((name) => name.trim())
|
|
1243
1493
|
.filter(Boolean);
|
|
1244
1494
|
return Promise.all(names.map(async (name) => {
|
|
1245
|
-
const fetchUrl = (await git.raw([
|
|
1246
|
-
const pushUrl = (await git
|
|
1495
|
+
const fetchUrl = (await git.raw(["remote", "get-url", name])).trim();
|
|
1496
|
+
const pushUrl = (await git
|
|
1497
|
+
.raw(["remote", "get-url", "--push", name])
|
|
1498
|
+
.catch(() => fetchUrl)).trim();
|
|
1247
1499
|
return { name, fetchUrl, pushUrl };
|
|
1248
1500
|
}));
|
|
1249
1501
|
};
|
|
1250
|
-
app.get(
|
|
1502
|
+
app.get("/api/remotes", async (_req, resp) => {
|
|
1251
1503
|
try {
|
|
1252
|
-
return resp.type(
|
|
1504
|
+
return resp.type("application/json").send(await listRemotes());
|
|
1253
1505
|
}
|
|
1254
1506
|
catch (err) {
|
|
1255
1507
|
return resp.status(400).send({ error: err.message });
|
|
1256
1508
|
}
|
|
1257
1509
|
});
|
|
1258
|
-
app.post(
|
|
1510
|
+
app.post("/api/remotes", async (req, resp) => {
|
|
1259
1511
|
try {
|
|
1260
|
-
const name = String(req.body?.name ??
|
|
1261
|
-
const originalName = String(req.body?.originalName ??
|
|
1262
|
-
const fetchUrl = String(req.body?.fetchUrl ??
|
|
1263
|
-
const pushUrl = String(req.body?.pushUrl ??
|
|
1512
|
+
const name = String(req.body?.name ?? "").trim();
|
|
1513
|
+
const originalName = String(req.body?.originalName ?? "").trim();
|
|
1514
|
+
const fetchUrl = String(req.body?.fetchUrl ?? "").trim();
|
|
1515
|
+
const pushUrl = String(req.body?.pushUrl ?? "").trim() || fetchUrl;
|
|
1264
1516
|
if (!/^[A-Za-z0-9._-]+$/.test(name) || !fetchUrl) {
|
|
1265
|
-
return resp
|
|
1517
|
+
return resp
|
|
1518
|
+
.status(400)
|
|
1519
|
+
.send({ error: "A valid remote name and fetch URL are required." });
|
|
1266
1520
|
}
|
|
1267
1521
|
if (!originalName) {
|
|
1268
|
-
await git.raw([
|
|
1522
|
+
await git.raw(["remote", "add", name, fetchUrl]);
|
|
1269
1523
|
}
|
|
1270
1524
|
else {
|
|
1271
1525
|
if (originalName !== name)
|
|
1272
|
-
await git.raw([
|
|
1273
|
-
await git.raw([
|
|
1526
|
+
await git.raw(["remote", "rename", originalName, name]);
|
|
1527
|
+
await git.raw(["remote", "set-url", name, fetchUrl]);
|
|
1274
1528
|
}
|
|
1275
|
-
await git.raw([
|
|
1529
|
+
await git.raw(["remote", "set-url", "--push", name, pushUrl]);
|
|
1276
1530
|
return resp.send(await listRemotes());
|
|
1277
1531
|
}
|
|
1278
1532
|
catch (err) {
|
|
1279
1533
|
return resp.status(400).send({ error: err.message });
|
|
1280
1534
|
}
|
|
1281
1535
|
});
|
|
1282
|
-
app.delete(
|
|
1536
|
+
app.delete("/api/remotes/:name", async (req, resp) => {
|
|
1283
1537
|
try {
|
|
1284
|
-
const name = String(req.params?.name ??
|
|
1538
|
+
const name = String(req.params?.name ?? "").trim();
|
|
1285
1539
|
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
1286
|
-
return resp.status(400).send({ error:
|
|
1540
|
+
return resp.status(400).send({ error: "Invalid remote name." });
|
|
1287
1541
|
}
|
|
1288
|
-
await git.raw([
|
|
1542
|
+
await git.raw(["remote", "remove", name]);
|
|
1289
1543
|
return resp.send(await listRemotes());
|
|
1290
1544
|
}
|
|
1291
1545
|
catch (err) {
|
|
1292
1546
|
return resp.status(400).send({ error: err.message });
|
|
1293
1547
|
}
|
|
1294
1548
|
});
|
|
1295
|
-
app.post(
|
|
1549
|
+
app.post("/api/identity", async (req, resp) => {
|
|
1296
1550
|
try {
|
|
1297
|
-
const name = String(req.body?.name ??
|
|
1298
|
-
const email = String(req.body?.email ??
|
|
1551
|
+
const name = String(req.body?.name ?? "").trim();
|
|
1552
|
+
const email = String(req.body?.email ?? "").trim();
|
|
1299
1553
|
if (!name || !email) {
|
|
1300
|
-
return resp
|
|
1554
|
+
return resp
|
|
1555
|
+
.status(400)
|
|
1556
|
+
.send({ error: "User name and email are required." });
|
|
1301
1557
|
}
|
|
1302
|
-
await git.raw([
|
|
1303
|
-
await git.raw([
|
|
1558
|
+
await git.raw(["config", "--local", "user.name", name]);
|
|
1559
|
+
await git.raw(["config", "--local", "user.email", email]);
|
|
1304
1560
|
return resp.send(await configuredIdentity());
|
|
1305
1561
|
}
|
|
1306
1562
|
catch (err) {
|
|
1307
1563
|
return resp.status(400).send({ error: err.message });
|
|
1308
1564
|
}
|
|
1309
1565
|
});
|
|
1310
|
-
app.delete(
|
|
1566
|
+
app.delete("/api/identity", async (_req, resp) => {
|
|
1311
1567
|
try {
|
|
1312
|
-
await git
|
|
1313
|
-
|
|
1568
|
+
await git
|
|
1569
|
+
.raw(["config", "--local", "--unset-all", "user.name"])
|
|
1570
|
+
.catch(() => "");
|
|
1571
|
+
await git
|
|
1572
|
+
.raw(["config", "--local", "--unset-all", "user.email"])
|
|
1573
|
+
.catch(() => "");
|
|
1314
1574
|
return resp.send(await configuredIdentity());
|
|
1315
1575
|
}
|
|
1316
1576
|
catch (err) {
|
|
@@ -1318,106 +1578,122 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1318
1578
|
}
|
|
1319
1579
|
});
|
|
1320
1580
|
// ==================== Settings ====================
|
|
1321
|
-
app.get(
|
|
1581
|
+
app.get("/api/settings", async (_req, resp) => {
|
|
1322
1582
|
try {
|
|
1323
1583
|
const settings = await effectiveSettings();
|
|
1324
|
-
return resp.type(
|
|
1584
|
+
return resp.type("application/json").send(settings);
|
|
1325
1585
|
}
|
|
1326
1586
|
catch (err) {
|
|
1327
|
-
return resp
|
|
1587
|
+
return resp
|
|
1588
|
+
.status(400)
|
|
1589
|
+
.type("application/json")
|
|
1590
|
+
.send({ error: err.message });
|
|
1328
1591
|
}
|
|
1329
1592
|
});
|
|
1330
|
-
app.post(
|
|
1593
|
+
app.post("/api/settings", async (req, resp) => {
|
|
1331
1594
|
try {
|
|
1332
1595
|
const body = req.body ?? {};
|
|
1333
1596
|
const settings = await readSettings();
|
|
1334
|
-
if (
|
|
1335
|
-
const url = String(body.azureDevOpsUrl ??
|
|
1597
|
+
if ("azureDevOpsUrl" in body) {
|
|
1598
|
+
const url = String(body.azureDevOpsUrl ?? "").trim();
|
|
1336
1599
|
if (url && !/^https?:\/\//i.test(url)) {
|
|
1337
|
-
return resp
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
.send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
|
|
1600
|
+
return resp.status(400).type("application/json").send({
|
|
1601
|
+
error: "The Azure DevOps URL must start with http:// or https://.",
|
|
1602
|
+
});
|
|
1341
1603
|
}
|
|
1342
1604
|
settings.azureDevOpsUrl = url;
|
|
1343
1605
|
}
|
|
1344
|
-
if (
|
|
1345
|
-
const template = String(body.prBranchNameTemplate ??
|
|
1606
|
+
if ("prBranchNameTemplate" in body) {
|
|
1607
|
+
const template = String(body.prBranchNameTemplate ?? "").trim();
|
|
1346
1608
|
if (template.length > 200) {
|
|
1347
|
-
return resp
|
|
1348
|
-
.
|
|
1349
|
-
|
|
1609
|
+
return resp.status(400).send({
|
|
1610
|
+
error: "The pull request branch template must be 200 characters or fewer.",
|
|
1611
|
+
});
|
|
1350
1612
|
}
|
|
1351
1613
|
try {
|
|
1352
1614
|
formatPrBranchName(template, {
|
|
1353
|
-
username:
|
|
1354
|
-
randomstring:
|
|
1355
|
-
branch:
|
|
1356
|
-
targetbranch:
|
|
1357
|
-
title:
|
|
1358
|
-
repository:
|
|
1359
|
-
date:
|
|
1360
|
-
time:
|
|
1361
|
-
timestamp:
|
|
1615
|
+
username: "username",
|
|
1616
|
+
randomstring: "randomstring",
|
|
1617
|
+
branch: "branch",
|
|
1618
|
+
targetbranch: "targetbranch",
|
|
1619
|
+
title: "title",
|
|
1620
|
+
repository: "repository",
|
|
1621
|
+
date: "2026-09-12",
|
|
1622
|
+
time: "120000",
|
|
1623
|
+
timestamp: "1789214400000",
|
|
1362
1624
|
});
|
|
1363
1625
|
}
|
|
1364
1626
|
catch (error) {
|
|
1365
1627
|
return resp.status(400).send({ error: error.message });
|
|
1366
1628
|
}
|
|
1367
|
-
settings.prBranchNameTemplate =
|
|
1629
|
+
settings.prBranchNameTemplate =
|
|
1630
|
+
template || DEFAULT_PR_BRANCH_NAME_TEMPLATE;
|
|
1368
1631
|
}
|
|
1369
|
-
if (typeof body.showGraph ===
|
|
1632
|
+
if (typeof body.showGraph === "boolean") {
|
|
1370
1633
|
settings.showGraph = body.showGraph;
|
|
1371
1634
|
}
|
|
1372
|
-
if (typeof body.autoReload ===
|
|
1635
|
+
if (typeof body.autoReload === "boolean") {
|
|
1373
1636
|
settings.autoReload = body.autoReload;
|
|
1374
1637
|
}
|
|
1375
|
-
if (typeof body.showStashes ===
|
|
1638
|
+
if (typeof body.showStashes === "boolean") {
|
|
1376
1639
|
settings.showStashes = body.showStashes;
|
|
1377
1640
|
}
|
|
1378
|
-
if (typeof body.showTags ===
|
|
1641
|
+
if (typeof body.showTags === "boolean") {
|
|
1379
1642
|
settings.showTags = body.showTags;
|
|
1380
1643
|
}
|
|
1381
|
-
if (typeof body.showRemoteBranches ===
|
|
1644
|
+
if (typeof body.showRemoteBranches === "boolean") {
|
|
1382
1645
|
settings.showRemoteBranches = body.showRemoteBranches;
|
|
1383
1646
|
}
|
|
1384
|
-
if (body.fileListView ===
|
|
1647
|
+
if (body.fileListView === "tree" || body.fileListView === "flat") {
|
|
1385
1648
|
settings.fileListView = body.fileListView;
|
|
1386
1649
|
}
|
|
1387
|
-
if (body.searchMode ===
|
|
1650
|
+
if (body.searchMode === "filter" || body.searchMode === "navigate") {
|
|
1388
1651
|
settings.searchMode = body.searchMode;
|
|
1389
1652
|
}
|
|
1390
|
-
if (typeof body.searchCaseSensitive ===
|
|
1653
|
+
if (typeof body.searchCaseSensitive === "boolean") {
|
|
1391
1654
|
settings.searchCaseSensitive = body.searchCaseSensitive;
|
|
1392
1655
|
}
|
|
1393
|
-
if (
|
|
1656
|
+
if (typeof body.allowMerge === "boolean") {
|
|
1657
|
+
settings.allowMerge = body.allowMerge;
|
|
1658
|
+
}
|
|
1659
|
+
if ("issueLinking" in body) {
|
|
1394
1660
|
if (body.issueLinking === null) {
|
|
1395
1661
|
if (body.issueLinkingGlobal) {
|
|
1396
|
-
await git
|
|
1397
|
-
|
|
1662
|
+
await git
|
|
1663
|
+
.raw(["config", "--global", "--unset-all", "guito.issueRegex"])
|
|
1664
|
+
.catch(() => "");
|
|
1665
|
+
await git
|
|
1666
|
+
.raw(["config", "--global", "--unset-all", "guito.issueUrl"])
|
|
1667
|
+
.catch(() => "");
|
|
1398
1668
|
}
|
|
1399
1669
|
else {
|
|
1400
1670
|
delete settings.issueLinking;
|
|
1401
1671
|
}
|
|
1402
1672
|
}
|
|
1403
1673
|
else {
|
|
1404
|
-
const regex = String(body.issueLinking?.regex ??
|
|
1405
|
-
const url = String(body.issueLinking?.url ??
|
|
1674
|
+
const regex = String(body.issueLinking?.regex ?? "").trim();
|
|
1675
|
+
const url = String(body.issueLinking?.url ?? "").trim();
|
|
1406
1676
|
if (!regex || !url) {
|
|
1407
|
-
return resp
|
|
1677
|
+
return resp
|
|
1678
|
+
.status(400)
|
|
1679
|
+
.send({ error: "Issue regex and URL are required." });
|
|
1408
1680
|
}
|
|
1409
1681
|
try {
|
|
1410
1682
|
new RegExp(regex);
|
|
1411
1683
|
}
|
|
1412
1684
|
catch {
|
|
1413
|
-
return resp.status(400).send({
|
|
1685
|
+
return resp.status(400).send({
|
|
1686
|
+
error: "Issue regex is not a valid regular expression.",
|
|
1687
|
+
});
|
|
1414
1688
|
}
|
|
1415
1689
|
if (!/^https?:\/\//i.test(url)) {
|
|
1416
|
-
return resp.status(400).send({
|
|
1690
|
+
return resp.status(400).send({
|
|
1691
|
+
error: "Issue URL must start with http:// or https://.",
|
|
1692
|
+
});
|
|
1417
1693
|
}
|
|
1418
1694
|
if (body.issueLinking.useGlobally) {
|
|
1419
|
-
await git.raw([
|
|
1420
|
-
await git.raw([
|
|
1695
|
+
await git.raw(["config", "--global", "guito.issueRegex", regex]);
|
|
1696
|
+
await git.raw(["config", "--global", "guito.issueUrl", url]);
|
|
1421
1697
|
delete settings.issueLinking;
|
|
1422
1698
|
}
|
|
1423
1699
|
else {
|
|
@@ -1427,35 +1703,40 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1427
1703
|
}
|
|
1428
1704
|
await writeSettings(settings);
|
|
1429
1705
|
const effective = await effectiveSettings();
|
|
1430
|
-
return resp.type(
|
|
1706
|
+
return resp.type("application/json").send(effective);
|
|
1431
1707
|
}
|
|
1432
1708
|
catch (err) {
|
|
1433
|
-
return resp
|
|
1709
|
+
return resp
|
|
1710
|
+
.status(400)
|
|
1711
|
+
.type("application/json")
|
|
1712
|
+
.send({ error: err.message });
|
|
1434
1713
|
}
|
|
1435
1714
|
});
|
|
1436
1715
|
// ==================== Azure DevOps Pull Requests ====================
|
|
1437
|
-
app.post(
|
|
1716
|
+
app.post("/api/azure-devops/pullrequest", async (req, resp) => {
|
|
1438
1717
|
try {
|
|
1439
1718
|
const { sourceBranch, targetBranch, title, description, newBranch } = req.body ?? {};
|
|
1440
1719
|
if (!targetBranch) {
|
|
1441
|
-
return resp
|
|
1720
|
+
return resp
|
|
1721
|
+
.status(400)
|
|
1722
|
+
.type("application/json")
|
|
1723
|
+
.send({ error: "targetBranch required" });
|
|
1442
1724
|
}
|
|
1443
1725
|
const { prefix, remoteUrl } = await azureProjectContext();
|
|
1444
1726
|
const info = parseAzureRemoteUrl(remoteUrl);
|
|
1445
1727
|
if (!info) {
|
|
1446
|
-
return resp
|
|
1447
|
-
.
|
|
1448
|
-
|
|
1449
|
-
.send({ error: 'Unable to build the Azure DevOps pull request URL.' });
|
|
1728
|
+
return resp.status(400).type("application/json").send({
|
|
1729
|
+
error: "Unable to build the Azure DevOps pull request URL.",
|
|
1730
|
+
});
|
|
1450
1731
|
}
|
|
1451
1732
|
const prUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
1452
|
-
let source = String(sourceBranch ??
|
|
1733
|
+
let source = String(sourceBranch ?? "").trim();
|
|
1453
1734
|
if (newBranch) {
|
|
1454
1735
|
// Create a remote-only branch from the current HEAD; the local
|
|
1455
1736
|
// repository and the checked-out branch stay untouched.
|
|
1456
1737
|
const now = new Date();
|
|
1457
1738
|
const currentSettings = await effectiveSettings();
|
|
1458
|
-
let computerUsername = process.env.USERNAME || process.env.USER ||
|
|
1739
|
+
let computerUsername = process.env.USERNAME || process.env.USER || "";
|
|
1459
1740
|
try {
|
|
1460
1741
|
computerUsername = userInfo().username || computerUsername;
|
|
1461
1742
|
}
|
|
@@ -1465,18 +1746,18 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1465
1746
|
const name = formatPrBranchName(currentSettings.prBranchNameTemplate, {
|
|
1466
1747
|
username: computerUsername,
|
|
1467
1748
|
randomstring: randomBranchSuffix(),
|
|
1468
|
-
branch: (await git.revparse([
|
|
1749
|
+
branch: (await git.revparse(["--abbrev-ref", "HEAD"])).trim(),
|
|
1469
1750
|
targetbranch: String(targetBranch)
|
|
1470
|
-
.replace(/^refs\/heads\//,
|
|
1471
|
-
.replace(/^origin\//,
|
|
1472
|
-
title: String(title ??
|
|
1751
|
+
.replace(/^refs\/heads\//, "")
|
|
1752
|
+
.replace(/^origin\//, ""),
|
|
1753
|
+
title: String(title ?? "").trim() || "pull-request",
|
|
1473
1754
|
repository: basename(await repoRoot()),
|
|
1474
1755
|
date: now.toISOString().slice(0, 10),
|
|
1475
|
-
time: now.toISOString().slice(11, 19).replace(/:/g,
|
|
1756
|
+
time: now.toISOString().slice(11, 19).replace(/:/g, ""),
|
|
1476
1757
|
timestamp: String(now.getTime()),
|
|
1477
1758
|
});
|
|
1478
1759
|
try {
|
|
1479
|
-
await git.raw([
|
|
1760
|
+
await git.raw(["check-ref-format", "--branch", name]);
|
|
1480
1761
|
}
|
|
1481
1762
|
catch {
|
|
1482
1763
|
return resp.status(400).send({
|
|
@@ -1484,23 +1765,26 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1484
1765
|
});
|
|
1485
1766
|
}
|
|
1486
1767
|
const existingBranch = await git
|
|
1487
|
-
.raw([
|
|
1488
|
-
.catch(() =>
|
|
1768
|
+
.raw(["ls-remote", "--heads", "origin", `refs/heads/${name}`])
|
|
1769
|
+
.catch(() => "");
|
|
1489
1770
|
if (existingBranch.trim()) {
|
|
1490
1771
|
return resp.status(400).send({
|
|
1491
1772
|
error: `The pull request branch already exists on origin: ${name}`,
|
|
1492
1773
|
});
|
|
1493
1774
|
}
|
|
1494
|
-
await git.push([
|
|
1775
|
+
await git.push(["origin", `HEAD:refs/heads/${name}`]);
|
|
1495
1776
|
source = name;
|
|
1496
1777
|
}
|
|
1497
1778
|
else {
|
|
1498
1779
|
if (!source) {
|
|
1499
|
-
return resp
|
|
1780
|
+
return resp
|
|
1781
|
+
.status(400)
|
|
1782
|
+
.type("application/json")
|
|
1783
|
+
.send({ error: "sourceBranch required" });
|
|
1500
1784
|
}
|
|
1501
|
-
if (source.startsWith(
|
|
1785
|
+
if (source.startsWith("origin/")) {
|
|
1502
1786
|
// Remote-tracking branch: the PR source is the branch name on the remote.
|
|
1503
|
-
source = source.slice(
|
|
1787
|
+
source = source.slice("origin/".length);
|
|
1504
1788
|
}
|
|
1505
1789
|
else {
|
|
1506
1790
|
// Publish the branch first when the remote does not have it yet.
|
|
@@ -1509,15 +1793,15 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1509
1793
|
// rev-parse --verify --quiet does not fail reliably through
|
|
1510
1794
|
// simple-git, so list the tracking refs instead.
|
|
1511
1795
|
const trackingRefs = await git.raw([
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1796
|
+
"for-each-ref",
|
|
1797
|
+
"refs/remotes/origin",
|
|
1798
|
+
"--format=%(refname)",
|
|
1515
1799
|
]);
|
|
1516
1800
|
const published = trackingRefs
|
|
1517
|
-
.split(
|
|
1801
|
+
.split("\n")
|
|
1518
1802
|
.some((line) => line.trim() === `refs/remotes/origin/${source}`);
|
|
1519
1803
|
if (!published) {
|
|
1520
|
-
await git.push([
|
|
1804
|
+
await git.push(["origin", source]);
|
|
1521
1805
|
}
|
|
1522
1806
|
}
|
|
1523
1807
|
}
|
|
@@ -1525,44 +1809,54 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1525
1809
|
isDraft: req.body?.isDraft === true,
|
|
1526
1810
|
sourceRefName: `refs/heads/${source}`,
|
|
1527
1811
|
targetRefName: `refs/heads/${String(targetBranch)
|
|
1528
|
-
.replace(/^refs\/heads\//,
|
|
1529
|
-
.replace(/^origin\//,
|
|
1812
|
+
.replace(/^refs\/heads\//, "")
|
|
1813
|
+
.replace(/^origin\//, "")}`,
|
|
1530
1814
|
};
|
|
1531
|
-
const trimmedTitle = String(title ??
|
|
1532
|
-
const trimmedDescription = String(description ??
|
|
1815
|
+
const trimmedTitle = String(title ?? "").trim();
|
|
1816
|
+
const trimmedDescription = String(description ?? "").trim();
|
|
1533
1817
|
if (trimmedTitle) {
|
|
1534
1818
|
payload.title = trimmedTitle;
|
|
1535
1819
|
}
|
|
1536
1820
|
if (trimmedDescription) {
|
|
1537
1821
|
payload.description = trimmedDescription;
|
|
1538
1822
|
}
|
|
1539
|
-
const reviewerList = Array.isArray(req.body?.reviewers)
|
|
1823
|
+
const reviewerList = Array.isArray(req.body?.reviewers)
|
|
1824
|
+
? req.body.reviewers
|
|
1825
|
+
: [];
|
|
1540
1826
|
const payloadReviewers = reviewerList
|
|
1541
|
-
.map((entry) => ({
|
|
1827
|
+
.map((entry) => ({
|
|
1828
|
+
id: String(entry?.id ?? ""),
|
|
1829
|
+
isRequired: !!entry?.required,
|
|
1830
|
+
}))
|
|
1542
1831
|
.filter((entry) => entry.id);
|
|
1543
1832
|
if (payloadReviewers.length) {
|
|
1544
1833
|
payload.reviewers = payloadReviewers;
|
|
1545
1834
|
}
|
|
1546
|
-
const workItemList = Array.isArray(req.body?.workItems)
|
|
1835
|
+
const workItemList = Array.isArray(req.body?.workItems)
|
|
1836
|
+
? req.body.workItems
|
|
1837
|
+
: [];
|
|
1547
1838
|
const payloadWorkItems = workItemList
|
|
1548
1839
|
.map((id) => Number(id))
|
|
1549
1840
|
.filter((id) => Number.isInteger(id) && id > 0);
|
|
1550
1841
|
if (payloadWorkItems.length) {
|
|
1551
|
-
payload.workItemRefs = payloadWorkItems.map((id) => ({
|
|
1842
|
+
payload.workItemRefs = payloadWorkItems.map((id) => ({
|
|
1843
|
+
id: String(id),
|
|
1844
|
+
}));
|
|
1552
1845
|
}
|
|
1553
|
-
const result = await azureRequest(
|
|
1846
|
+
const result = await azureRequest("POST", prUrl, JSON.stringify(payload));
|
|
1554
1847
|
if (result.status < 200 || result.status >= 300) {
|
|
1555
1848
|
return resp
|
|
1556
1849
|
.status(400)
|
|
1557
|
-
.type(
|
|
1850
|
+
.type("application/json")
|
|
1558
1851
|
.send({
|
|
1559
|
-
error: azureErrorMessage(result) ||
|
|
1852
|
+
error: azureErrorMessage(result) ||
|
|
1853
|
+
`Azure DevOps returned HTTP ${result.status}.`,
|
|
1560
1854
|
});
|
|
1561
1855
|
}
|
|
1562
1856
|
const created = JSON.parse(result.body);
|
|
1563
1857
|
// Tags (labels) are a separate resource; add them after the PR exists.
|
|
1564
1858
|
const labelNames = (Array.isArray(req.body?.labels) ? req.body.labels : [])
|
|
1565
|
-
.map((name) => String(name ??
|
|
1859
|
+
.map((name) => String(name ?? "").trim())
|
|
1566
1860
|
.filter(Boolean);
|
|
1567
1861
|
const warnings = [];
|
|
1568
1862
|
if (labelNames.length && created.pullRequestId) {
|
|
@@ -1571,7 +1865,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1571
1865
|
`/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
|
|
1572
1866
|
for (const name of labelNames) {
|
|
1573
1867
|
try {
|
|
1574
|
-
const labelResult = await azureRequest(
|
|
1868
|
+
const labelResult = await azureRequest("POST", labelsUrl, JSON.stringify({ name }));
|
|
1575
1869
|
if (labelResult.status < 200 || labelResult.status >= 300) {
|
|
1576
1870
|
warnings.push(`Could not add tag "${name}" (HTTP ${labelResult.status}).`);
|
|
1577
1871
|
}
|
|
@@ -1581,87 +1875,99 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1581
1875
|
}
|
|
1582
1876
|
}
|
|
1583
1877
|
}
|
|
1584
|
-
return resp.type(
|
|
1878
|
+
return resp.type("application/json").send({
|
|
1585
1879
|
id: created.pullRequestId,
|
|
1586
|
-
url: created._links?.web?.href ??
|
|
1880
|
+
url: created._links?.web?.href ?? "",
|
|
1587
1881
|
branch: source,
|
|
1588
1882
|
...(warnings.length ? { warnings } : {}),
|
|
1589
1883
|
});
|
|
1590
1884
|
}
|
|
1591
1885
|
catch (err) {
|
|
1592
|
-
return resp
|
|
1886
|
+
return resp
|
|
1887
|
+
.status(400)
|
|
1888
|
+
.type("application/json")
|
|
1889
|
+
.send({ error: err.message });
|
|
1593
1890
|
}
|
|
1594
1891
|
});
|
|
1595
1892
|
// ==================== Azure DevOps PR metadata ====================
|
|
1596
1893
|
// Collection-scoped identity IDs can be passed directly as PR reviewers.
|
|
1597
|
-
app.get(
|
|
1894
|
+
app.get("/api/azure-devops/reviewers", async (req, resp) => {
|
|
1598
1895
|
try {
|
|
1599
|
-
const query = String((req.query ?? {}).query ??
|
|
1896
|
+
const query = String((req.query ?? {}).query ?? "").trim();
|
|
1600
1897
|
if (!query) {
|
|
1601
|
-
return resp.type(
|
|
1898
|
+
return resp.type("application/json").send({ reviewers: [] });
|
|
1602
1899
|
}
|
|
1603
1900
|
const { prefix } = await azureProjectContext();
|
|
1604
|
-
const collection = prefix.slice(0, prefix.lastIndexOf(
|
|
1605
|
-
const identityBase = collection.replace(
|
|
1606
|
-
const url = `${identityBase}/_apis/identities?api-version=5.0` +
|
|
1901
|
+
const collection = prefix.slice(0, prefix.lastIndexOf("/"));
|
|
1902
|
+
const identityBase = collection.replace("https://dev.azure.com/", "https://vssps.dev.azure.com/");
|
|
1903
|
+
const url = `${identityBase}/_apis/identities?api-version=5.0-preview` +
|
|
1607
1904
|
`&searchFilter=General&filterValue=${encodeURIComponent(query)}&queryMembership=None`;
|
|
1608
|
-
const result = await azureRequest(
|
|
1905
|
+
const result = await azureRequest("GET", url, "");
|
|
1609
1906
|
if (result.status < 200 || result.status >= 300) {
|
|
1610
1907
|
return resp
|
|
1611
1908
|
.status(400)
|
|
1612
|
-
.type(
|
|
1909
|
+
.type("application/json")
|
|
1613
1910
|
.send({
|
|
1614
|
-
error: azureErrorMessage(result) ||
|
|
1911
|
+
error: azureErrorMessage(result) ||
|
|
1912
|
+
`Azure DevOps returned HTTP ${result.status}.`,
|
|
1615
1913
|
});
|
|
1616
1914
|
}
|
|
1617
1915
|
const data = JSON.parse(result.body);
|
|
1618
|
-
const list = Array.isArray(data)
|
|
1916
|
+
const list = Array.isArray(data)
|
|
1917
|
+
? data
|
|
1918
|
+
: (data.value ?? data.identities ?? []);
|
|
1619
1919
|
const reviewers = list
|
|
1620
1920
|
.map((entry) => ({
|
|
1621
|
-
id: String(entry?.id ?? entry?.identity?.id ??
|
|
1921
|
+
id: String(entry?.id ?? entry?.identity?.id ?? ""),
|
|
1622
1922
|
label: String(entry?.customDisplayName ||
|
|
1623
1923
|
entry?.providerDisplayName ||
|
|
1624
1924
|
entry?.label ||
|
|
1625
1925
|
entry?.displayName ||
|
|
1626
1926
|
entry?.name ||
|
|
1627
|
-
|
|
1927
|
+
""),
|
|
1628
1928
|
description: entry?.properties?.Mail?.$value ??
|
|
1629
1929
|
entry?.properties?.Account?.$value ??
|
|
1630
1930
|
entry?.description ??
|
|
1631
1931
|
undefined,
|
|
1632
1932
|
}))
|
|
1633
1933
|
.filter((entry) => entry.id && entry.label);
|
|
1634
|
-
return resp.type(
|
|
1934
|
+
return resp.type("application/json").send({ reviewers });
|
|
1635
1935
|
}
|
|
1636
1936
|
catch (err) {
|
|
1637
|
-
return resp
|
|
1937
|
+
return resp
|
|
1938
|
+
.status(400)
|
|
1939
|
+
.type("application/json")
|
|
1940
|
+
.send({ error: err.message });
|
|
1638
1941
|
}
|
|
1639
1942
|
});
|
|
1640
1943
|
// Work item search by id or title: a WIQL query returns ids, a batch read
|
|
1641
1944
|
// then fetches title/state for the first matches.
|
|
1642
|
-
app.get(
|
|
1945
|
+
app.get("/api/azure-devops/workitems", async (req, resp) => {
|
|
1643
1946
|
try {
|
|
1644
|
-
const query = String((req.query ?? {}).query ??
|
|
1947
|
+
const query = String((req.query ?? {}).query ?? "")
|
|
1645
1948
|
.trim()
|
|
1646
|
-
.replace(/^#(?=\d+$)/,
|
|
1949
|
+
.replace(/^#(?=\d+$)/, "");
|
|
1647
1950
|
if (!query) {
|
|
1648
|
-
return resp.type(
|
|
1951
|
+
return resp.type("application/json").send({ workItems: [] });
|
|
1649
1952
|
}
|
|
1650
1953
|
const { prefix } = await azureProjectContext();
|
|
1651
|
-
const clauses = [
|
|
1954
|
+
const clauses = [
|
|
1955
|
+
`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`,
|
|
1956
|
+
];
|
|
1652
1957
|
if (/^\d+$/.test(query)) {
|
|
1653
1958
|
clauses.push(`[System.Id] = ${Number(query)}`);
|
|
1654
1959
|
}
|
|
1655
|
-
const wiql =
|
|
1656
|
-
`WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(
|
|
1657
|
-
|
|
1658
|
-
const wiqlResult = await azureRequest(
|
|
1960
|
+
const wiql = "SELECT [System.Id], [System.Title], [System.State] FROM WorkItems " +
|
|
1961
|
+
`WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(" OR ")}) ` +
|
|
1962
|
+
"ORDER BY [System.ChangedDate] DESC";
|
|
1963
|
+
const wiqlResult = await azureRequest("POST", `${prefix}/_apis/wit/wiql?$top=20&api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
|
|
1659
1964
|
if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
|
|
1660
1965
|
return resp
|
|
1661
1966
|
.status(400)
|
|
1662
|
-
.type(
|
|
1967
|
+
.type("application/json")
|
|
1663
1968
|
.send({
|
|
1664
|
-
error: azureErrorMessage(wiqlResult) ||
|
|
1969
|
+
error: azureErrorMessage(wiqlResult) ||
|
|
1970
|
+
`Azure DevOps returned HTTP ${wiqlResult.status}.`,
|
|
1665
1971
|
});
|
|
1666
1972
|
}
|
|
1667
1973
|
const ids = (JSON.parse(wiqlResult.body).workItems ?? [])
|
|
@@ -1669,41 +1975,46 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1669
1975
|
.filter((id) => Number.isInteger(id) && id > 0)
|
|
1670
1976
|
.slice(0, 20);
|
|
1671
1977
|
if (!ids.length) {
|
|
1672
|
-
return resp.type(
|
|
1978
|
+
return resp.type("application/json").send({ workItems: [] });
|
|
1673
1979
|
}
|
|
1674
|
-
const batchResult = await azureRequest(
|
|
1675
|
-
`&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`,
|
|
1980
|
+
const batchResult = await azureRequest("GET", `${prefix}/_apis/wit/workitems?ids=${ids.join(",")}` +
|
|
1981
|
+
`&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`, "");
|
|
1676
1982
|
if (batchResult.status < 200 || batchResult.status >= 300) {
|
|
1677
1983
|
return resp
|
|
1678
1984
|
.status(400)
|
|
1679
|
-
.type(
|
|
1985
|
+
.type("application/json")
|
|
1680
1986
|
.send({
|
|
1681
|
-
error: azureErrorMessage(batchResult) ||
|
|
1987
|
+
error: azureErrorMessage(batchResult) ||
|
|
1988
|
+
`Azure DevOps returned HTTP ${batchResult.status}.`,
|
|
1682
1989
|
});
|
|
1683
1990
|
}
|
|
1684
1991
|
const workItems = (JSON.parse(batchResult.body).value ?? []).map((item) => ({
|
|
1685
1992
|
id: Number(item?.id),
|
|
1686
|
-
title: String(item?.fields?.[
|
|
1687
|
-
state: String(item?.fields?.[
|
|
1993
|
+
title: String(item?.fields?.["System.Title"] ?? ""),
|
|
1994
|
+
state: String(item?.fields?.["System.State"] ?? ""),
|
|
1688
1995
|
}));
|
|
1689
|
-
return resp.type(
|
|
1996
|
+
return resp.type("application/json").send({ workItems });
|
|
1690
1997
|
}
|
|
1691
1998
|
catch (err) {
|
|
1692
|
-
return resp
|
|
1999
|
+
return resp
|
|
2000
|
+
.status(400)
|
|
2001
|
+
.type("application/json")
|
|
2002
|
+
.send({ error: err.message });
|
|
1693
2003
|
}
|
|
1694
2004
|
});
|
|
1695
2005
|
// Existing pull request labels for the tag autocomplete.
|
|
1696
|
-
app.get(
|
|
2006
|
+
app.get("/api/azure-devops/tags", async (_req, resp) => {
|
|
1697
2007
|
try {
|
|
1698
2008
|
const { prefix, remoteUrl } = await azureProjectContext();
|
|
1699
2009
|
const repo = parseAzureRemoteUrl(remoteUrl).repo;
|
|
1700
2010
|
// PR labels are distinct from Azure Boards work item tags.
|
|
1701
2011
|
const names = new Set();
|
|
1702
2012
|
for (let skip = 0;; skip += 100) {
|
|
1703
|
-
const result = await azureRequest(
|
|
1704
|
-
`?searchCriteria.status=all&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`,
|
|
2013
|
+
const result = await azureRequest("GET", `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
|
|
2014
|
+
`?searchCriteria.status=all&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`, "");
|
|
1705
2015
|
if (result.status < 200 || result.status >= 300) {
|
|
1706
|
-
throw new Error(azureErrorMessage(result) ||
|
|
2016
|
+
throw new Error(azureErrorMessage(result) ||
|
|
2017
|
+
`Azure DevOps returned HTTP ${result.status}.`);
|
|
1707
2018
|
}
|
|
1708
2019
|
const data = JSON.parse(result.body);
|
|
1709
2020
|
const requests = Array.isArray(data) ? data : (data.value ?? []);
|
|
@@ -1715,16 +2026,17 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1715
2026
|
return request.labels;
|
|
1716
2027
|
if (!Number.isInteger(request.pullRequestId))
|
|
1717
2028
|
return [];
|
|
1718
|
-
const result = await azureRequest(
|
|
1719
|
-
`/pullrequests/${request.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`,
|
|
2029
|
+
const result = await azureRequest("GET", `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}` +
|
|
2030
|
+
`/pullrequests/${request.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`, "");
|
|
1720
2031
|
if (result.status < 200 || result.status >= 300) {
|
|
1721
|
-
throw new Error(azureErrorMessage(result) ||
|
|
2032
|
+
throw new Error(azureErrorMessage(result) ||
|
|
2033
|
+
`Azure DevOps returned HTTP ${result.status}.`);
|
|
1722
2034
|
}
|
|
1723
2035
|
const data = JSON.parse(result.body);
|
|
1724
2036
|
return Array.isArray(data) ? data : (data.value ?? []);
|
|
1725
2037
|
}));
|
|
1726
2038
|
for (const label of labels.flat()) {
|
|
1727
|
-
if (typeof label.name ===
|
|
2039
|
+
if (typeof label.name === "string" && label.name.trim())
|
|
1728
2040
|
names.add(label.name.trim());
|
|
1729
2041
|
}
|
|
1730
2042
|
}
|
|
@@ -1732,29 +2044,35 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1732
2044
|
break;
|
|
1733
2045
|
}
|
|
1734
2046
|
const tags = [...names].sort((a, b) => a.localeCompare(b));
|
|
1735
|
-
return resp.type(
|
|
2047
|
+
return resp.type("application/json").send({ tags });
|
|
1736
2048
|
}
|
|
1737
2049
|
catch (err) {
|
|
1738
|
-
return resp
|
|
2050
|
+
return resp
|
|
2051
|
+
.status(400)
|
|
2052
|
+
.type("application/json")
|
|
2053
|
+
.send({ error: err.message });
|
|
1739
2054
|
}
|
|
1740
2055
|
});
|
|
1741
2056
|
// ==================== Azure DevOps pull request review ====================
|
|
1742
2057
|
// Everything the PR sidebar list and the PR detail dialog need: identity,
|
|
1743
2058
|
// listing, editing, votes, reviewers, auto-complete, completion, comment
|
|
1744
2059
|
// threads, and per-file diffs.
|
|
1745
|
-
app.get(
|
|
2060
|
+
app.get("/api/azure-devops/me", async (_req, resp) => {
|
|
1746
2061
|
try {
|
|
1747
|
-
return resp.type(
|
|
2062
|
+
return resp.type("application/json").send(await azureMe());
|
|
1748
2063
|
}
|
|
1749
2064
|
catch (err) {
|
|
1750
|
-
return resp
|
|
2065
|
+
return resp
|
|
2066
|
+
.status(400)
|
|
2067
|
+
.type("application/json")
|
|
2068
|
+
.send({ error: err.message });
|
|
1751
2069
|
}
|
|
1752
2070
|
});
|
|
1753
2071
|
// Pull requests the current user created or reviews ("mine"), merged and
|
|
1754
2072
|
// de-duplicated. Status defaults to active.
|
|
1755
|
-
app.get(
|
|
2073
|
+
app.get("/api/azure-devops/pullrequests", async (req, resp) => {
|
|
1756
2074
|
try {
|
|
1757
|
-
const status = String(req.query?.status ??
|
|
2075
|
+
const status = String(req.query?.status ?? "active").trim() || "active";
|
|
1758
2076
|
const { prefix, remoteUrl } = await azureProjectContext();
|
|
1759
2077
|
const repo = parseAzureRemoteUrl(remoteUrl).repo;
|
|
1760
2078
|
const me = await azureMe();
|
|
@@ -1764,7 +2082,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1764
2082
|
const url = `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
|
|
1765
2083
|
`?searchCriteria.status=${encodeURIComponent(status)}&searchCriteria.${criteria}` +
|
|
1766
2084
|
`&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`;
|
|
1767
|
-
const data = await azureJson(
|
|
2085
|
+
const data = await azureJson("GET", url);
|
|
1768
2086
|
const requests = Array.isArray(data?.value) ? data.value : [];
|
|
1769
2087
|
for (const request of requests) {
|
|
1770
2088
|
const id = Number(request?.pullRequestId);
|
|
@@ -1785,43 +2103,65 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1785
2103
|
const pullRequests = [...merged.values()]
|
|
1786
2104
|
.map((request) => mapAzurePullRequest(request, prefix, remoteUrl, me))
|
|
1787
2105
|
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
1788
|
-
return resp.type(
|
|
2106
|
+
return resp.type("application/json").send({ pullRequests });
|
|
1789
2107
|
}
|
|
1790
2108
|
catch (err) {
|
|
1791
|
-
return resp
|
|
2109
|
+
return resp
|
|
2110
|
+
.status(400)
|
|
2111
|
+
.type("application/json")
|
|
2112
|
+
.send({ error: err.message });
|
|
1792
2113
|
}
|
|
1793
2114
|
});
|
|
1794
|
-
app.get(
|
|
2115
|
+
app.get("/api/azure-devops/pullrequests/:id", async (req, resp) => {
|
|
1795
2116
|
try {
|
|
1796
2117
|
const { base, prefix, remoteUrl } = await azurePrApiBase(Number(req.params?.id));
|
|
1797
2118
|
const [pr, me] = await Promise.all([
|
|
1798
|
-
azureJson(
|
|
2119
|
+
azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`),
|
|
1799
2120
|
azureMe(),
|
|
1800
2121
|
]);
|
|
1801
2122
|
const summary = mapAzurePullRequest(pr, prefix, remoteUrl, me);
|
|
1802
|
-
|
|
2123
|
+
// Some Server releases omit labels from the PR detail response; read
|
|
2124
|
+
// the labels resource explicitly in that case instead of reporting a
|
|
2125
|
+
// false empty tag list.
|
|
2126
|
+
let labels = (Array.isArray(pr?.labels) ? pr.labels : [])
|
|
2127
|
+
.map((label) => String(label?.name ?? ""))
|
|
2128
|
+
.filter(Boolean);
|
|
2129
|
+
if (!Array.isArray(pr?.labels)) {
|
|
2130
|
+
const labelsResult = await azureRequest("GET", `${base}/labels?api-version=${AZURE_API_VERSION}`, "");
|
|
2131
|
+
if (labelsResult.status < 200 || labelsResult.status >= 300) {
|
|
2132
|
+
throw new Error(azureErrorMessage(labelsResult) ||
|
|
2133
|
+
`Azure DevOps returned HTTP ${labelsResult.status} while reading pull request tags.`);
|
|
2134
|
+
}
|
|
2135
|
+
const labelData = JSON.parse(labelsResult.body);
|
|
2136
|
+
labels = (Array.isArray(labelData) ? labelData : (labelData.value ?? []))
|
|
2137
|
+
.map((label) => String(label?.name ?? ""))
|
|
2138
|
+
.filter(Boolean);
|
|
2139
|
+
}
|
|
2140
|
+
return resp.type("application/json").send({
|
|
1803
2141
|
...summary,
|
|
1804
|
-
description: String(pr?.description ??
|
|
2142
|
+
description: String(pr?.description ?? ""),
|
|
1805
2143
|
autoCompleteSetBy: pr?.autoCompleteSetBy
|
|
1806
2144
|
? {
|
|
1807
|
-
id: String(pr.autoCompleteSetBy.id ??
|
|
1808
|
-
name: String(pr.autoCompleteSetBy.displayName ??
|
|
2145
|
+
id: String(pr.autoCompleteSetBy.id ?? ""),
|
|
2146
|
+
name: String(pr.autoCompleteSetBy.displayName ?? ""),
|
|
1809
2147
|
}
|
|
1810
2148
|
: null,
|
|
1811
2149
|
completionOptions: pr?.completionOptions ?? null,
|
|
1812
|
-
lastMergeSourceCommit: String(pr?.lastMergeSourceCommit?.commitId ??
|
|
1813
|
-
lastMergeTargetCommit: String(pr?.lastMergeTargetCommit?.commitId ??
|
|
1814
|
-
labels
|
|
1815
|
-
|
|
1816
|
-
.filter(Boolean),
|
|
2150
|
+
lastMergeSourceCommit: String(pr?.lastMergeSourceCommit?.commitId ?? ""),
|
|
2151
|
+
lastMergeTargetCommit: String(pr?.lastMergeTargetCommit?.commitId ?? ""),
|
|
2152
|
+
labels,
|
|
2153
|
+
mergeStatus: azureMergeStatus(pr),
|
|
1817
2154
|
});
|
|
1818
2155
|
}
|
|
1819
2156
|
catch (err) {
|
|
1820
|
-
return resp
|
|
2157
|
+
return resp
|
|
2158
|
+
.status(400)
|
|
2159
|
+
.type("application/json")
|
|
2160
|
+
.send({ error: err.message });
|
|
1821
2161
|
}
|
|
1822
2162
|
});
|
|
1823
2163
|
// Edits title/description or flips the draft flag ("publish"/"convert to draft").
|
|
1824
|
-
app.patch(
|
|
2164
|
+
app.patch("/api/azure-devops/pullrequests/:id", async (req, resp) => {
|
|
1825
2165
|
try {
|
|
1826
2166
|
const { title, description, isDraft } = req.body ?? {};
|
|
1827
2167
|
const payload = {};
|
|
@@ -1832,65 +2172,83 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1832
2172
|
if (isDraft !== undefined)
|
|
1833
2173
|
payload.isDraft = isDraft === true;
|
|
1834
2174
|
if (!Object.keys(payload).length) {
|
|
1835
|
-
return resp
|
|
2175
|
+
return resp
|
|
2176
|
+
.status(400)
|
|
2177
|
+
.type("application/json")
|
|
2178
|
+
.send({ error: "Nothing to update." });
|
|
1836
2179
|
}
|
|
1837
2180
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1838
|
-
await azureJson(
|
|
1839
|
-
return resp.type(
|
|
2181
|
+
await azureJson("PATCH", `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
|
|
2182
|
+
return resp.type("application/json").send({ ok: true });
|
|
1840
2183
|
}
|
|
1841
2184
|
catch (err) {
|
|
1842
|
-
return resp
|
|
2185
|
+
return resp
|
|
2186
|
+
.status(400)
|
|
2187
|
+
.type("application/json")
|
|
2188
|
+
.send({ error: err.message });
|
|
1843
2189
|
}
|
|
1844
2190
|
});
|
|
1845
2191
|
// Records the current user's vote on the pull request.
|
|
1846
|
-
app.post(
|
|
2192
|
+
app.post("/api/azure-devops/pullrequests/:id/vote", async (req, resp) => {
|
|
1847
2193
|
try {
|
|
1848
|
-
const vote = String(req.body?.vote ??
|
|
2194
|
+
const vote = String(req.body?.vote ?? "");
|
|
1849
2195
|
if (!(vote in AZURE_VOTES)) {
|
|
1850
2196
|
return resp
|
|
1851
2197
|
.status(400)
|
|
1852
|
-
.type(
|
|
2198
|
+
.type("application/json")
|
|
1853
2199
|
.send({ error: `Unknown vote "${vote}".` });
|
|
1854
2200
|
}
|
|
1855
2201
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1856
2202
|
const me = await azureMe();
|
|
1857
|
-
await azureJson(
|
|
1858
|
-
return resp.type(
|
|
2203
|
+
await azureJson("PUT", `${base}/reviewers/${encodeURIComponent(me.id)}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ vote: AZURE_VOTES[vote] }));
|
|
2204
|
+
return resp.type("application/json").send({ ok: true });
|
|
1859
2205
|
}
|
|
1860
2206
|
catch (err) {
|
|
1861
|
-
return resp
|
|
2207
|
+
return resp
|
|
2208
|
+
.status(400)
|
|
2209
|
+
.type("application/json")
|
|
2210
|
+
.send({ error: err.message });
|
|
1862
2211
|
}
|
|
1863
2212
|
});
|
|
1864
2213
|
// Adds or updates a reviewer (optionally required), or removes one.
|
|
1865
|
-
app.post(
|
|
2214
|
+
app.post("/api/azure-devops/pullrequests/:id/reviewers", async (req, resp) => {
|
|
1866
2215
|
try {
|
|
1867
|
-
const id = String(req.body?.id ??
|
|
2216
|
+
const id = String(req.body?.id ?? "");
|
|
1868
2217
|
if (!id) {
|
|
1869
|
-
return resp
|
|
2218
|
+
return resp
|
|
2219
|
+
.status(400)
|
|
2220
|
+
.type("application/json")
|
|
2221
|
+
.send({ error: "Reviewer id required." });
|
|
1870
2222
|
}
|
|
1871
2223
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1872
2224
|
const reviewerUrl = `${base}/reviewers/${encodeURIComponent(id)}?api-version=${AZURE_API_VERSION}`;
|
|
1873
2225
|
if (req.body?.remove === true) {
|
|
1874
|
-
await azureJson(
|
|
1875
|
-
return resp.type(
|
|
2226
|
+
await azureJson("DELETE", reviewerUrl);
|
|
2227
|
+
return resp.type("application/json").send({ ok: true });
|
|
1876
2228
|
}
|
|
1877
2229
|
// The PUT replaces the reviewer record, so an existing vote is passed
|
|
1878
2230
|
// through to survive an isRequired toggle.
|
|
1879
|
-
const payload = {
|
|
1880
|
-
|
|
2231
|
+
const payload = {
|
|
2232
|
+
isRequired: req.body?.required === true,
|
|
2233
|
+
};
|
|
2234
|
+
if (req.body?.vote !== undefined &&
|
|
2235
|
+
Number.isFinite(Number(req.body.vote))) {
|
|
1881
2236
|
payload.vote = Number(req.body.vote);
|
|
1882
2237
|
}
|
|
1883
|
-
await azureJson(
|
|
1884
|
-
return resp.type(
|
|
2238
|
+
await azureJson("PUT", reviewerUrl, JSON.stringify(payload));
|
|
2239
|
+
return resp.type("application/json").send({ ok: true });
|
|
1885
2240
|
}
|
|
1886
2241
|
catch (err) {
|
|
1887
|
-
return resp
|
|
2242
|
+
return resp
|
|
2243
|
+
.status(400)
|
|
2244
|
+
.type("application/json")
|
|
2245
|
+
.send({ error: err.message });
|
|
1888
2246
|
}
|
|
1889
2247
|
});
|
|
1890
2248
|
/** Maps dialog completion options onto the Azure DevOps payload shape. */
|
|
1891
2249
|
const completionOptionsOf = (body) => {
|
|
1892
|
-
const strategies = [
|
|
1893
|
-
const mergeStrategy = String(body?.mergeStrategy ??
|
|
2250
|
+
const strategies = ["noFastForward", "squash", "rebase", "rebaseMerge"];
|
|
2251
|
+
const mergeStrategy = String(body?.mergeStrategy ?? "");
|
|
1894
2252
|
const options = {};
|
|
1895
2253
|
if (strategies.includes(mergeStrategy))
|
|
1896
2254
|
options.mergeStrategy = mergeStrategy;
|
|
@@ -1907,12 +2265,14 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1907
2265
|
return Object.keys(options).length ? options : null;
|
|
1908
2266
|
};
|
|
1909
2267
|
// Sets or clears auto-complete with optional completion options.
|
|
1910
|
-
app.post(
|
|
2268
|
+
app.post("/api/azure-devops/pullrequests/:id/autocomplete", async (req, resp) => {
|
|
1911
2269
|
try {
|
|
1912
2270
|
const enabled = req.body?.enabled === true;
|
|
1913
2271
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1914
2272
|
const buildPayload = (identity) => {
|
|
1915
|
-
const payload = {
|
|
2273
|
+
const payload = {
|
|
2274
|
+
autoCompleteSetBy: identity,
|
|
2275
|
+
};
|
|
1916
2276
|
if (enabled) {
|
|
1917
2277
|
const options = completionOptionsOf(req.body);
|
|
1918
2278
|
if (options)
|
|
@@ -1925,209 +2285,694 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1925
2285
|
// Azure DevOps clears auto-complete for an empty identity; some Server
|
|
1926
2286
|
// releases only accept an explicit null, so retry that shape on failure.
|
|
1927
2287
|
try {
|
|
1928
|
-
await azureJson(
|
|
2288
|
+
await azureJson("PATCH", url, buildPayload(enabled && me ? { id: me.id } : { id: "" }));
|
|
1929
2289
|
}
|
|
1930
2290
|
catch (firstError) {
|
|
1931
2291
|
if (enabled)
|
|
1932
2292
|
throw firstError;
|
|
1933
|
-
await azureJson(
|
|
2293
|
+
await azureJson("PATCH", url, buildPayload(null));
|
|
1934
2294
|
}
|
|
1935
|
-
return resp.type(
|
|
2295
|
+
return resp.type("application/json").send({ ok: true });
|
|
1936
2296
|
}
|
|
1937
2297
|
catch (err) {
|
|
1938
|
-
return resp
|
|
2298
|
+
return resp
|
|
2299
|
+
.status(400)
|
|
2300
|
+
.type("application/json")
|
|
2301
|
+
.send({ error: err.message });
|
|
1939
2302
|
}
|
|
1940
2303
|
});
|
|
1941
2304
|
// Completes the pull request (merge). Requires the merge source commit for
|
|
1942
2305
|
// optimistic concurrency, exactly like the Azure DevOps web dialog.
|
|
1943
|
-
app.post(
|
|
2306
|
+
app.post("/api/azure-devops/pullrequests/:id/complete", async (req, resp) => {
|
|
1944
2307
|
try {
|
|
1945
2308
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1946
|
-
const pr = await azureJson(
|
|
1947
|
-
const commitId = String(pr?.lastMergeSourceCommit?.commitId ??
|
|
2309
|
+
const pr = await azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`);
|
|
2310
|
+
const commitId = String(pr?.lastMergeSourceCommit?.commitId ?? "");
|
|
1948
2311
|
if (!commitId) {
|
|
1949
|
-
throw new Error(
|
|
2312
|
+
throw new Error("The pull request has no merge commit to complete yet.");
|
|
1950
2313
|
}
|
|
1951
2314
|
const payload = {
|
|
1952
|
-
status:
|
|
2315
|
+
status: "completed",
|
|
1953
2316
|
lastMergeSourceCommit: { commitId },
|
|
1954
2317
|
};
|
|
1955
2318
|
const options = completionOptionsOf(req.body);
|
|
1956
2319
|
if (options)
|
|
1957
2320
|
payload.completionOptions = options;
|
|
1958
|
-
await azureJson(
|
|
1959
|
-
return resp.type(
|
|
2321
|
+
await azureJson("PATCH", `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
|
|
2322
|
+
return resp.type("application/json").send({ ok: true });
|
|
1960
2323
|
}
|
|
1961
2324
|
catch (err) {
|
|
1962
|
-
return resp
|
|
2325
|
+
return resp
|
|
2326
|
+
.status(400)
|
|
2327
|
+
.type("application/json")
|
|
2328
|
+
.send({ error: err.message });
|
|
1963
2329
|
}
|
|
1964
2330
|
});
|
|
1965
|
-
app.get(
|
|
2331
|
+
app.get("/api/azure-devops/pullrequests/:id/threads", async (req, resp) => {
|
|
1966
2332
|
try {
|
|
1967
2333
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1968
|
-
const data = await azureJson(
|
|
2334
|
+
const data = await azureJson("GET", `${base}/threads?api-version=${AZURE_API_VERSION}`);
|
|
1969
2335
|
const threads = (Array.isArray(data?.value) ? data.value : [])
|
|
1970
2336
|
.filter((thread) => thread?.isDeleted !== true)
|
|
1971
2337
|
.map((thread) => {
|
|
1972
2338
|
const context = thread?.threadContext ?? {};
|
|
1973
|
-
const line = context.rightFileStart?.line ??
|
|
2339
|
+
const line = context.rightFileStart?.line ??
|
|
2340
|
+
context.leftFileStart?.line ??
|
|
2341
|
+
null;
|
|
1974
2342
|
return {
|
|
1975
2343
|
id: Number(thread?.id ?? 0),
|
|
1976
2344
|
status: azureThreadStatusName(Number(thread?.status ?? 0)),
|
|
1977
2345
|
isDeleted: false,
|
|
1978
|
-
filePath: typeof context.filePath ===
|
|
2346
|
+
filePath: typeof context.filePath === "string"
|
|
2347
|
+
? context.filePath.replace(/^\//, "")
|
|
2348
|
+
: null,
|
|
1979
2349
|
line: Number.isInteger(line) ? line : null,
|
|
1980
|
-
side: context.rightFileStart
|
|
2350
|
+
side: context.rightFileStart
|
|
2351
|
+
? "right"
|
|
2352
|
+
: context.leftFileStart
|
|
2353
|
+
? "left"
|
|
2354
|
+
: null,
|
|
1981
2355
|
comments: (Array.isArray(thread?.comments) ? thread.comments : [])
|
|
1982
2356
|
.filter((comment) => comment?.isDeleted !== true)
|
|
1983
2357
|
.map((comment) => ({
|
|
1984
2358
|
id: Number(comment?.id ?? 0),
|
|
1985
2359
|
author: {
|
|
1986
|
-
name: String(comment?.author?.displayName ??
|
|
1987
|
-
email: String(comment?.author?.uniqueName ??
|
|
2360
|
+
name: String(comment?.author?.displayName ?? ""),
|
|
2361
|
+
email: String(comment?.author?.uniqueName ?? ""),
|
|
1988
2362
|
},
|
|
1989
|
-
content: String(comment?.content ??
|
|
1990
|
-
createdAt: String(comment?.publishedDate ??
|
|
2363
|
+
content: String(comment?.content ?? ""),
|
|
2364
|
+
createdAt: String(comment?.publishedDate ?? ""),
|
|
1991
2365
|
isDeleted: false,
|
|
1992
2366
|
})),
|
|
1993
2367
|
};
|
|
1994
2368
|
});
|
|
1995
|
-
return resp.type(
|
|
2369
|
+
return resp.type("application/json").send({ threads });
|
|
1996
2370
|
}
|
|
1997
2371
|
catch (err) {
|
|
1998
|
-
return resp
|
|
2372
|
+
return resp
|
|
2373
|
+
.status(400)
|
|
2374
|
+
.type("application/json")
|
|
2375
|
+
.send({ error: err.message });
|
|
1999
2376
|
}
|
|
2000
2377
|
});
|
|
2001
2378
|
// Adds a comment: a reply to a thread, a general PR comment, or an inline
|
|
2002
2379
|
// comment anchored to a file line (thread context).
|
|
2003
|
-
app.post(
|
|
2380
|
+
app.post("/api/azure-devops/pullrequests/:id/threads", async (req, resp) => {
|
|
2004
2381
|
try {
|
|
2005
|
-
const content = String(req.body?.content ??
|
|
2382
|
+
const content = String(req.body?.content ?? "").trim();
|
|
2006
2383
|
if (!content) {
|
|
2007
|
-
return resp
|
|
2384
|
+
return resp
|
|
2385
|
+
.status(400)
|
|
2386
|
+
.type("application/json")
|
|
2387
|
+
.send({ error: "Comment text required." });
|
|
2008
2388
|
}
|
|
2009
2389
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2010
2390
|
const threadId = Number(req.body?.threadId ?? 0);
|
|
2011
2391
|
if (Number.isInteger(threadId) && threadId > 0) {
|
|
2012
|
-
await azureJson(
|
|
2013
|
-
return resp.type(
|
|
2392
|
+
await azureJson("POST", `${base}/threads/${threadId}/comments?api-version=${AZURE_API_VERSION}`, JSON.stringify({ content }));
|
|
2393
|
+
return resp.type("application/json").send({ ok: true });
|
|
2014
2394
|
}
|
|
2015
2395
|
const payload = {
|
|
2016
2396
|
status: AZURE_THREAD_STATUSES.active,
|
|
2017
2397
|
comments: [{ content, parentCommentId: 0 }],
|
|
2018
2398
|
};
|
|
2019
|
-
const filePath = String(req.body?.filePath ??
|
|
2399
|
+
const filePath = String(req.body?.filePath ?? "").trim();
|
|
2020
2400
|
const line = Number(req.body?.line ?? 0);
|
|
2021
|
-
const
|
|
2401
|
+
const requestedEndLine = Number(req.body?.endLine ?? line);
|
|
2402
|
+
const endLine = Number.isInteger(requestedEndLine) && requestedEndLine >= line
|
|
2403
|
+
? requestedEndLine
|
|
2404
|
+
: line;
|
|
2405
|
+
const left = req.body?.side === "left";
|
|
2022
2406
|
if (filePath && Number.isInteger(line) && line > 0) {
|
|
2023
2407
|
// Azure anchors a whole-line comment from offset 1 to "end of line".
|
|
2024
2408
|
payload.threadContext = {
|
|
2025
|
-
filePath: `/${filePath.replace(/^\//,
|
|
2026
|
-
[left ?
|
|
2027
|
-
[left ?
|
|
2409
|
+
filePath: `/${filePath.replace(/^\//, "")}`,
|
|
2410
|
+
[left ? "leftFileStart" : "rightFileStart"]: { line, offset: 1 },
|
|
2411
|
+
[left ? "leftFileEnd" : "rightFileEnd"]: {
|
|
2412
|
+
line: endLine,
|
|
2413
|
+
offset: 2147483647,
|
|
2414
|
+
},
|
|
2028
2415
|
};
|
|
2029
2416
|
}
|
|
2030
|
-
await azureJson(
|
|
2031
|
-
return resp.type(
|
|
2417
|
+
await azureJson("POST", `${base}/threads?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
|
|
2418
|
+
return resp.type("application/json").send({ ok: true });
|
|
2032
2419
|
}
|
|
2033
2420
|
catch (err) {
|
|
2034
|
-
return resp
|
|
2421
|
+
return resp
|
|
2422
|
+
.status(400)
|
|
2423
|
+
.type("application/json")
|
|
2424
|
+
.send({ error: err.message });
|
|
2035
2425
|
}
|
|
2036
2426
|
});
|
|
2037
2427
|
// Resolves, reactivates or closes a comment thread.
|
|
2038
|
-
app.post(
|
|
2428
|
+
app.post("/api/azure-devops/pullrequests/:id/threads/:threadId/status", async (req, resp) => {
|
|
2039
2429
|
try {
|
|
2040
|
-
const status = String(req.body?.status ??
|
|
2041
|
-
if (!(status in AZURE_THREAD_STATUSES) || status ===
|
|
2430
|
+
const status = String(req.body?.status ?? "");
|
|
2431
|
+
if (!(status in AZURE_THREAD_STATUSES) || status === "unknown") {
|
|
2042
2432
|
return resp
|
|
2043
2433
|
.status(400)
|
|
2044
|
-
.type(
|
|
2434
|
+
.type("application/json")
|
|
2045
2435
|
.send({ error: `Unknown thread status "${status}".` });
|
|
2046
2436
|
}
|
|
2047
2437
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2048
|
-
await azureJson(
|
|
2049
|
-
return resp.type(
|
|
2438
|
+
await azureJson("PATCH", `${base}/threads/${Number(req.params?.threadId)}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ status: AZURE_THREAD_STATUSES[status] }));
|
|
2439
|
+
return resp.type("application/json").send({ ok: true });
|
|
2050
2440
|
}
|
|
2051
2441
|
catch (err) {
|
|
2052
|
-
return resp
|
|
2442
|
+
return resp
|
|
2443
|
+
.status(400)
|
|
2444
|
+
.type("application/json")
|
|
2445
|
+
.send({ error: err.message });
|
|
2053
2446
|
}
|
|
2054
2447
|
});
|
|
2055
2448
|
// Changed files of the latest PR iteration (names and change types only;
|
|
2056
2449
|
// content is fetched per file on demand by /file-diff).
|
|
2057
|
-
app.get(
|
|
2450
|
+
app.get("/api/azure-devops/pullrequests/:id/changes", async (req, resp) => {
|
|
2058
2451
|
try {
|
|
2059
2452
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2060
|
-
const iterations = await azureJson(
|
|
2453
|
+
const iterations = await azureJson("GET", `${base}/iterations?api-version=${AZURE_API_VERSION}`);
|
|
2061
2454
|
const list = Array.isArray(iterations?.value) ? iterations.value : [];
|
|
2062
2455
|
const latest = list.length
|
|
2063
|
-
? list.reduce((newest, entry) => String(entry?.createdDate ??
|
|
2456
|
+
? list.reduce((newest, entry) => String(entry?.createdDate ?? "") >
|
|
2457
|
+
String(newest?.createdDate ?? "")
|
|
2458
|
+
? entry
|
|
2459
|
+
: newest)
|
|
2064
2460
|
: null;
|
|
2065
2461
|
if (!latest) {
|
|
2066
|
-
return resp.type(
|
|
2462
|
+
return resp.type("application/json").send({ files: [] });
|
|
2067
2463
|
}
|
|
2068
|
-
const data = await azureJson('GET', `${base}/iterations/${latest.id}/changes?api-version=${AZURE_API_VERSION}`);
|
|
2069
2464
|
const changeStatus = {
|
|
2070
|
-
add:
|
|
2071
|
-
edit:
|
|
2072
|
-
rename:
|
|
2073
|
-
delete:
|
|
2074
|
-
undelete:
|
|
2465
|
+
add: "added",
|
|
2466
|
+
edit: "modified",
|
|
2467
|
+
rename: "renamed",
|
|
2468
|
+
delete: "deleted",
|
|
2469
|
+
undelete: "added",
|
|
2075
2470
|
};
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2471
|
+
// Azure returns the changed files under "changeEntries" and pages
|
|
2472
|
+
// them (nextSkip); some releases use "value" instead. Follow the
|
|
2473
|
+
// paging until every page has been read, otherwise a PR with more
|
|
2474
|
+
// changes than one page would show none at all.
|
|
2475
|
+
const entries = [];
|
|
2476
|
+
let skip = 0;
|
|
2477
|
+
for (;;) {
|
|
2478
|
+
const data = await azureJson("GET", `${base}/iterations/${latest.id}/changes?$top=1000&$skip=${skip}&api-version=${AZURE_API_VERSION}`);
|
|
2479
|
+
const page = Array.isArray(data?.changeEntries)
|
|
2480
|
+
? data.changeEntries
|
|
2481
|
+
: Array.isArray(data?.value)
|
|
2482
|
+
? data.value
|
|
2483
|
+
: [];
|
|
2484
|
+
entries.push(...page);
|
|
2485
|
+
const nextSkip = Number(data?.nextSkip);
|
|
2486
|
+
if (Number.isInteger(nextSkip) && nextSkip > skip && page.length) {
|
|
2487
|
+
skip = nextSkip;
|
|
2488
|
+
continue;
|
|
2489
|
+
}
|
|
2490
|
+
if (page.length >= 1000) {
|
|
2491
|
+
skip += 1000;
|
|
2492
|
+
continue;
|
|
2493
|
+
}
|
|
2494
|
+
break;
|
|
2495
|
+
}
|
|
2496
|
+
const files = entries
|
|
2497
|
+
.filter((entry) => entry?.item?.isFolder !== true)
|
|
2498
|
+
.map((entry) => {
|
|
2499
|
+
const path = String(entry?.item?.path ?? "").replace(/^\//, "");
|
|
2500
|
+
const changeType = changeStatus[String(entry?.changeType ?? "")] ?? "modified";
|
|
2501
|
+
const oldPath = changeType === "renamed"
|
|
2502
|
+
? String(entry?.sourceServerItem ?? "").replace(/^\//, "")
|
|
2503
|
+
: "";
|
|
2082
2504
|
return { path, oldPath, changeType };
|
|
2083
|
-
})
|
|
2084
|
-
|
|
2505
|
+
})
|
|
2506
|
+
.filter((file) => Boolean(file.path));
|
|
2507
|
+
return resp.type("application/json").send({ files });
|
|
2085
2508
|
}
|
|
2086
2509
|
catch (err) {
|
|
2087
|
-
return resp
|
|
2510
|
+
return resp
|
|
2511
|
+
.status(400)
|
|
2512
|
+
.type("application/json")
|
|
2513
|
+
.send({ error: err.message });
|
|
2514
|
+
}
|
|
2515
|
+
});
|
|
2516
|
+
/** Maps a policy evaluation status state onto the four UI states. */
|
|
2517
|
+
const azurePolicyState = (state) => {
|
|
2518
|
+
switch (String(state ?? "").toLowerCase()) {
|
|
2519
|
+
case "approved":
|
|
2520
|
+
return "succeeded";
|
|
2521
|
+
case "rejected":
|
|
2522
|
+
case "broken":
|
|
2523
|
+
return "failed";
|
|
2524
|
+
case "notapplicable":
|
|
2525
|
+
return "notApplicable";
|
|
2526
|
+
default:
|
|
2527
|
+
// pending, deferred, queued and unknown states all still gate merges.
|
|
2528
|
+
return "pending";
|
|
2529
|
+
}
|
|
2530
|
+
};
|
|
2531
|
+
/** Maps a native PR status state onto the four UI states. */
|
|
2532
|
+
const azureStatusState = (state) => {
|
|
2533
|
+
switch (String(state ?? "").toLowerCase()) {
|
|
2534
|
+
case "succeeded":
|
|
2535
|
+
return "succeeded";
|
|
2536
|
+
case "failed":
|
|
2537
|
+
case "error":
|
|
2538
|
+
return "failed";
|
|
2539
|
+
default:
|
|
2540
|
+
// notSet and pending: posted but undecided.
|
|
2541
|
+
return "pending";
|
|
2542
|
+
}
|
|
2543
|
+
};
|
|
2544
|
+
/** Reads every policy evaluation of one pull request (paged). */
|
|
2545
|
+
const fetchPolicyEvaluations = async (prefix, projectId, prId) => {
|
|
2546
|
+
if (!projectId || !prId) {
|
|
2547
|
+
throw new Error("The pull request does not expose its project identity.");
|
|
2548
|
+
}
|
|
2549
|
+
// The artifact id Azure keys policy evaluations by. The documented
|
|
2550
|
+
// template is "CodeReviewId"; the "CodeReviewIdentity" spelling is
|
|
2551
|
+
// rejected with an artifact-not-found 404 (verified against TFS).
|
|
2552
|
+
const artifactId = `vstfs:///CodeReview/CodeReviewId/${projectId}/${prId}`;
|
|
2553
|
+
const evaluations = [];
|
|
2554
|
+
let skip = 0;
|
|
2555
|
+
for (;;) {
|
|
2556
|
+
const data = await azureJson("GET", `${prefix}/_apis/policy/evaluations?artifactId=${encodeURIComponent(artifactId)}` +
|
|
2557
|
+
`&$skip=${skip}&$top=1000&api-version=5.0-preview.1`);
|
|
2558
|
+
// Versions disagree on the envelope: "evaluations" (preview shape), the
|
|
2559
|
+
// generic "count"/"value" list (seen live on TFS 5.0-preview.1) or a
|
|
2560
|
+
// bare array.
|
|
2561
|
+
let page = [];
|
|
2562
|
+
if (Array.isArray(data)) {
|
|
2563
|
+
page = data;
|
|
2564
|
+
}
|
|
2565
|
+
else if (Array.isArray(data?.evaluations)) {
|
|
2566
|
+
page = data.evaluations;
|
|
2567
|
+
}
|
|
2568
|
+
else if (Array.isArray(data?.value)) {
|
|
2569
|
+
page = data.value;
|
|
2570
|
+
}
|
|
2571
|
+
const before = evaluations.length;
|
|
2572
|
+
evaluations.push(...page);
|
|
2573
|
+
const total = Number(data?.count);
|
|
2574
|
+
const nextSkip = Number(data?.nextSkip);
|
|
2575
|
+
const moreByCount = Number.isInteger(total) && total > evaluations.length;
|
|
2576
|
+
const moreBySkip = Number.isInteger(nextSkip) && nextSkip > skip && page.length > 0;
|
|
2577
|
+
// Stop when complete, when a page added nothing new (the server ignored
|
|
2578
|
+
// our skip) or after a safety-capped number of pages.
|
|
2579
|
+
if (!(moreByCount || moreBySkip) || evaluations.length === before) {
|
|
2580
|
+
break;
|
|
2581
|
+
}
|
|
2582
|
+
skip =
|
|
2583
|
+
Number.isInteger(nextSkip) && nextSkip > skip
|
|
2584
|
+
? nextSkip
|
|
2585
|
+
: evaluations.length;
|
|
2586
|
+
if (skip > 20000) {
|
|
2587
|
+
break;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
return evaluations;
|
|
2591
|
+
};
|
|
2592
|
+
/** Reads the native statuses posted directly on the pull request. */
|
|
2593
|
+
const fetchPullRequestStatuses = async (base) => {
|
|
2594
|
+
const data = await azureJson("GET", `${base}/statuses?api-version=5.0-preview.1`);
|
|
2595
|
+
return Array.isArray(data?.value) ? data.value : [];
|
|
2596
|
+
};
|
|
2597
|
+
/** Turns one policy evaluation into a check row (null when unnameable). */
|
|
2598
|
+
const policyCheckOf = (prefix, evaluation, index) => {
|
|
2599
|
+
const configuration = evaluation?.configuration ?? {};
|
|
2600
|
+
const type = configuration.type ?? {};
|
|
2601
|
+
const settings = configuration.settings ?? {};
|
|
2602
|
+
const context = evaluation?.context ?? {};
|
|
2603
|
+
const status = evaluation?.status;
|
|
2604
|
+
// 5.0-preview answers status as a plain state string; newer versions may
|
|
2605
|
+
// answer a { state, message } object. Support both.
|
|
2606
|
+
const state = typeof status === "string" ? status : status?.state;
|
|
2607
|
+
const message = typeof status === "string" ? "" : status?.message;
|
|
2608
|
+
const typeName = String(type?.displayName ?? "").trim();
|
|
2609
|
+
const isBuild = /build/i.test(typeName);
|
|
2610
|
+
const name = String(settings?.displayName ?? "").trim() ||
|
|
2611
|
+
(isBuild ? String(context?.buildDefinitionName ?? "").trim() : "") ||
|
|
2612
|
+
typeName ||
|
|
2613
|
+
"Policy";
|
|
2614
|
+
if (!name) {
|
|
2615
|
+
return null;
|
|
2616
|
+
}
|
|
2617
|
+
const gate = `${typeName} ${name}`;
|
|
2618
|
+
const buildId = Number(context?.buildId ?? settings?.buildId);
|
|
2619
|
+
const evaluationId = String(evaluation?.evaluationId ?? status?.evaluationId ?? "").trim();
|
|
2620
|
+
const check = {
|
|
2621
|
+
id: `policy:${evaluationId || index}`,
|
|
2622
|
+
name,
|
|
2623
|
+
kind: /build/i.test(gate)
|
|
2624
|
+
? "build"
|
|
2625
|
+
: /reviewer/i.test(gate)
|
|
2626
|
+
? "reviewer"
|
|
2627
|
+
: "policy",
|
|
2628
|
+
state: azurePolicyState(state),
|
|
2629
|
+
required: configuration?.isBlocking === true ||
|
|
2630
|
+
configuration?.isRequired === true,
|
|
2631
|
+
};
|
|
2632
|
+
const detail = String(message ?? "").trim();
|
|
2633
|
+
if (detail) {
|
|
2634
|
+
check.detail = detail;
|
|
2635
|
+
}
|
|
2636
|
+
if (context?.isExpired === true) {
|
|
2637
|
+
check.detail = detail ? `${detail} (build expired)` : "Build expired";
|
|
2638
|
+
}
|
|
2639
|
+
if (Number.isInteger(buildId) && buildId > 0) {
|
|
2640
|
+
check.url = `${prefix}/_build/results?buildId=${buildId}`;
|
|
2641
|
+
}
|
|
2642
|
+
// Only build gates can be re-run, and only through their evaluation id.
|
|
2643
|
+
if (check.kind === "build" && evaluationId) {
|
|
2644
|
+
check.evaluationId = evaluationId;
|
|
2645
|
+
}
|
|
2646
|
+
return check;
|
|
2647
|
+
};
|
|
2648
|
+
// Related work items of the pull request, resolved to title/state through
|
|
2649
|
+
// the work-item batch API. Unresolvable refs degrade to an id-only row.
|
|
2650
|
+
app.get("/api/azure-devops/pullrequests/:id/workitems", async (req, resp) => {
|
|
2651
|
+
try {
|
|
2652
|
+
const { base, prefix } = await azurePrApiBase(Number(req.params?.id));
|
|
2653
|
+
const refs = await azureJson("GET", `${base}/workitems?api-version=${AZURE_API_VERSION}`);
|
|
2654
|
+
const ids = [
|
|
2655
|
+
...new Set((Array.isArray(refs?.value) ? refs.value : [])
|
|
2656
|
+
.map((entry) => Number(entry?.id))
|
|
2657
|
+
.filter((id) => Number.isInteger(id) && id > 0)),
|
|
2658
|
+
];
|
|
2659
|
+
if (!ids.length) {
|
|
2660
|
+
return resp.type("application/json").send({ workItems: [] });
|
|
2661
|
+
}
|
|
2662
|
+
const batch = await azureRequest("GET", `${prefix}/_apis/wit/workitems?ids=${ids.join(",")}` +
|
|
2663
|
+
`&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`, "");
|
|
2664
|
+
const details = new Map();
|
|
2665
|
+
if (batch.status >= 200 && batch.status < 300) {
|
|
2666
|
+
const data = JSON.parse(batch.body);
|
|
2667
|
+
for (const item of Array.isArray(data?.value) ? data.value : []) {
|
|
2668
|
+
details.set(String(item?.id ?? ""), {
|
|
2669
|
+
title: String(item?.fields?.["System.Title"] ?? ""),
|
|
2670
|
+
state: String(item?.fields?.["System.State"] ?? ""),
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
const workItems = ids.map((id) => ({
|
|
2675
|
+
id,
|
|
2676
|
+
title: details.get(String(id))?.title || `Work item ${id}`,
|
|
2677
|
+
state: details.get(String(id))?.state ?? "",
|
|
2678
|
+
url: `${prefix}/_workitems/edit/${id}`,
|
|
2679
|
+
}));
|
|
2680
|
+
return resp.type("application/json").send({ workItems });
|
|
2681
|
+
}
|
|
2682
|
+
catch (err) {
|
|
2683
|
+
return resp
|
|
2684
|
+
.status(400)
|
|
2685
|
+
.type("application/json")
|
|
2686
|
+
.send({ error: err.message });
|
|
2687
|
+
}
|
|
2688
|
+
});
|
|
2689
|
+
// Tags are the PR labels resource; the side panel adds and removes them
|
|
2690
|
+
// directly, so both directions are served here.
|
|
2691
|
+
app.post("/api/azure-devops/pullrequests/:id/labels", async (req, resp) => {
|
|
2692
|
+
try {
|
|
2693
|
+
const name = String(req.body?.name ?? "").trim();
|
|
2694
|
+
if (!name) {
|
|
2695
|
+
return resp
|
|
2696
|
+
.status(400)
|
|
2697
|
+
.type("application/json")
|
|
2698
|
+
.send({ error: "A tag name is required." });
|
|
2699
|
+
}
|
|
2700
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2701
|
+
await azureJson("POST", `${base}/labels?api-version=${AZURE_API_VERSION}`, JSON.stringify({ name }));
|
|
2702
|
+
return resp.type("application/json").send({ success: true });
|
|
2703
|
+
}
|
|
2704
|
+
catch (err) {
|
|
2705
|
+
return resp
|
|
2706
|
+
.status(400)
|
|
2707
|
+
.type("application/json")
|
|
2708
|
+
.send({ error: err.message });
|
|
2709
|
+
}
|
|
2710
|
+
});
|
|
2711
|
+
app.delete("/api/azure-devops/pullrequests/:id/labels/:name", async (req, resp) => {
|
|
2712
|
+
try {
|
|
2713
|
+
const name = String(req.params?.name ?? "");
|
|
2714
|
+
if (!name) {
|
|
2715
|
+
return resp
|
|
2716
|
+
.status(400)
|
|
2717
|
+
.type("application/json")
|
|
2718
|
+
.send({ error: "A tag name is required." });
|
|
2719
|
+
}
|
|
2720
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2721
|
+
await azureJson("DELETE", `${base}/labels/${encodeURIComponent(name)}?api-version=${AZURE_API_VERSION}`, "");
|
|
2722
|
+
return resp.type("application/json").send({ success: true });
|
|
2723
|
+
}
|
|
2724
|
+
catch (err) {
|
|
2725
|
+
return resp
|
|
2726
|
+
.status(400)
|
|
2727
|
+
.type("application/json")
|
|
2728
|
+
.send({ error: err.message });
|
|
2729
|
+
}
|
|
2730
|
+
});
|
|
2731
|
+
// Work items are linked from the work item side: the link lives on the work
|
|
2732
|
+
// item as an ArtifactLink relation pointing at the PR's artifact URI, so
|
|
2733
|
+
// linking and unlinking both patch the work item rather than the PR.
|
|
2734
|
+
app.post("/api/azure-devops/pullrequests/:id/workitems", async (req, resp) => {
|
|
2735
|
+
try {
|
|
2736
|
+
const workItemId = Number(req.body?.id);
|
|
2737
|
+
if (!Number.isInteger(workItemId) || workItemId <= 0) {
|
|
2738
|
+
return resp
|
|
2739
|
+
.status(400)
|
|
2740
|
+
.type("application/json")
|
|
2741
|
+
.send({ error: "A work item id is required." });
|
|
2742
|
+
}
|
|
2743
|
+
const { prefix, artifactUrl } = await azurePrArtifactOf(Number(req.params?.id));
|
|
2744
|
+
const alreadyLinked = (await azureWorkItemRelations(prefix, workItemId)).some((relation) => String(relation?.url ?? "").toLowerCase() ===
|
|
2745
|
+
artifactUrl.toLowerCase());
|
|
2746
|
+
if (!alreadyLinked) {
|
|
2747
|
+
await azurePatchWorkItemRelations(prefix, workItemId, [
|
|
2748
|
+
{
|
|
2749
|
+
op: "add",
|
|
2750
|
+
path: "/relations/-",
|
|
2751
|
+
value: {
|
|
2752
|
+
rel: "ArtifactLink",
|
|
2753
|
+
url: artifactUrl,
|
|
2754
|
+
attributes: { name: "Pull Request" },
|
|
2755
|
+
},
|
|
2756
|
+
},
|
|
2757
|
+
]);
|
|
2758
|
+
}
|
|
2759
|
+
return resp.type("application/json").send({ success: true });
|
|
2760
|
+
}
|
|
2761
|
+
catch (err) {
|
|
2762
|
+
return resp
|
|
2763
|
+
.status(400)
|
|
2764
|
+
.type("application/json")
|
|
2765
|
+
.send({ error: err.message });
|
|
2766
|
+
}
|
|
2767
|
+
});
|
|
2768
|
+
app.delete("/api/azure-devops/pullrequests/:id/workitems/:workItemId", async (req, resp) => {
|
|
2769
|
+
try {
|
|
2770
|
+
const workItemId = Number(req.params?.workItemId);
|
|
2771
|
+
if (!Number.isInteger(workItemId) || workItemId <= 0) {
|
|
2772
|
+
return resp
|
|
2773
|
+
.status(400)
|
|
2774
|
+
.type("application/json")
|
|
2775
|
+
.send({ error: "A work item id is required." });
|
|
2776
|
+
}
|
|
2777
|
+
const { prefix, artifactUrl } = await azurePrArtifactOf(Number(req.params?.id));
|
|
2778
|
+
// Relations are removed by index, so confirm the link and its index in
|
|
2779
|
+
// the same read, then assert that url at the index before removing:
|
|
2780
|
+
// Azure applies the patch atomically, so a relation list that moved
|
|
2781
|
+
// underneath this call is rejected instead of removing a neighbour.
|
|
2782
|
+
const relations = await azureWorkItemRelations(prefix, workItemId);
|
|
2783
|
+
const index = relations.findIndex((relation) => String(relation?.url ?? "").toLowerCase() ===
|
|
2784
|
+
artifactUrl.toLowerCase());
|
|
2785
|
+
if (index >= 0) {
|
|
2786
|
+
await azurePatchWorkItemRelations(prefix, workItemId, [
|
|
2787
|
+
{
|
|
2788
|
+
op: "test",
|
|
2789
|
+
path: `/relations/${index}/url`,
|
|
2790
|
+
value: String(relations[index]?.url ?? ""),
|
|
2791
|
+
},
|
|
2792
|
+
{ op: "remove", path: `/relations/${index}` },
|
|
2793
|
+
]);
|
|
2794
|
+
}
|
|
2795
|
+
return resp.type("application/json").send({ success: true });
|
|
2796
|
+
}
|
|
2797
|
+
catch (err) {
|
|
2798
|
+
return resp
|
|
2799
|
+
.status(400)
|
|
2800
|
+
.type("application/json")
|
|
2801
|
+
.send({ error: err.message });
|
|
2802
|
+
}
|
|
2803
|
+
});
|
|
2804
|
+
// Merge gates of the pull request: Azure policy evaluations (builds,
|
|
2805
|
+
// required reviewers, comments, work items, ...) plus the native statuses
|
|
2806
|
+
// posted on the PR. One failing source degrades to a warning so the other
|
|
2807
|
+
// still reaches the dialog; only when both fail is the request an error.
|
|
2808
|
+
app.get("/api/azure-devops/pullrequests/:id/checks", async (req, resp) => {
|
|
2809
|
+
const describe = (error) => error instanceof Error ? error.message : String(error);
|
|
2810
|
+
try {
|
|
2811
|
+
const { base, prefix } = await azurePrApiBase(Number(req.params?.id));
|
|
2812
|
+
const pr = await azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`);
|
|
2813
|
+
const warnings = [];
|
|
2814
|
+
const checks = new Map();
|
|
2815
|
+
const [evaluations, statuses] = await Promise.all([
|
|
2816
|
+
fetchPolicyEvaluations(prefix, String(pr?.repository?.project?.id ?? ""), Number(pr?.pullRequestId ?? 0)).catch((error) => ({ error })),
|
|
2817
|
+
fetchPullRequestStatuses(base).catch((error) => ({ error })),
|
|
2818
|
+
]);
|
|
2819
|
+
const policyError = "error" in evaluations ? evaluations.error : null;
|
|
2820
|
+
const statusError = "error" in statuses ? statuses.error : null;
|
|
2821
|
+
if (policyError && statusError) {
|
|
2822
|
+
throw new Error(`Policy evaluations failed (${describe(policyError)}); pull request statuses failed (${describe(statusError)}).`);
|
|
2823
|
+
}
|
|
2824
|
+
if (policyError) {
|
|
2825
|
+
warnings.push(`Policy evaluations are unavailable (${describe(policyError)}). Showing pull request statuses only.`);
|
|
2826
|
+
}
|
|
2827
|
+
else {
|
|
2828
|
+
evaluations.forEach((evaluation, index) => {
|
|
2829
|
+
const check = policyCheckOf(prefix, evaluation, index);
|
|
2830
|
+
if (check) {
|
|
2831
|
+
checks.set(check.id, check);
|
|
2832
|
+
}
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
if (statusError) {
|
|
2836
|
+
warnings.push(`Pull request statuses are unavailable (${describe(statusError)}). Showing policy evaluations only.`);
|
|
2837
|
+
}
|
|
2838
|
+
else {
|
|
2839
|
+
// Statuses repeat on re-post; keep only the latest per genre/name.
|
|
2840
|
+
const latestStatuses = new Map();
|
|
2841
|
+
for (const status of statuses) {
|
|
2842
|
+
const key = `${String(status?.context?.genre ?? "")}/${String(status?.context?.name ?? "")}`;
|
|
2843
|
+
const existing = latestStatuses.get(key);
|
|
2844
|
+
if (!existing ||
|
|
2845
|
+
String(status?.creationDate ?? "") >
|
|
2846
|
+
String(existing?.creationDate ?? "")) {
|
|
2847
|
+
latestStatuses.set(key, status);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
for (const [key, status] of latestStatuses) {
|
|
2851
|
+
const name = String(status?.context?.name ?? "Status");
|
|
2852
|
+
const row = {
|
|
2853
|
+
id: `status:${key}`,
|
|
2854
|
+
name,
|
|
2855
|
+
kind: "status",
|
|
2856
|
+
state: azureStatusState(status?.state),
|
|
2857
|
+
required: false,
|
|
2858
|
+
};
|
|
2859
|
+
const description = String(status?.description ?? "").trim();
|
|
2860
|
+
if (description) {
|
|
2861
|
+
row.detail = description;
|
|
2862
|
+
}
|
|
2863
|
+
const target = String(status?.targetUrl ?? "");
|
|
2864
|
+
if (target) {
|
|
2865
|
+
row.url = target;
|
|
2866
|
+
}
|
|
2867
|
+
checks.set(row.id, row);
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
return resp
|
|
2871
|
+
.type("application/json")
|
|
2872
|
+
.send({ checks: [...checks.values()], warnings });
|
|
2873
|
+
}
|
|
2874
|
+
catch (err) {
|
|
2875
|
+
return resp
|
|
2876
|
+
.status(400)
|
|
2877
|
+
.type("application/json")
|
|
2878
|
+
.send({ error: err.message });
|
|
2879
|
+
}
|
|
2880
|
+
});
|
|
2881
|
+
// Re-queues the build behind one build policy evaluation, the same action
|
|
2882
|
+
// the Azure DevOps checks list offers. The evaluation id comes from the
|
|
2883
|
+
// checks endpoint above; Azure answers the refreshed evaluation.
|
|
2884
|
+
app.post("/api/azure-devops/pullrequests/:id/checks/:evaluationId/requeue", async (req, resp) => {
|
|
2885
|
+
try {
|
|
2886
|
+
const evaluationId = String(req.params?.evaluationId ?? "").trim();
|
|
2887
|
+
if (!evaluationId) {
|
|
2888
|
+
throw new Error("A policy evaluation id is required.");
|
|
2889
|
+
}
|
|
2890
|
+
const { prefix } = await azurePrApiBase(Number(req.params?.id));
|
|
2891
|
+
await azureJson("POST", `${prefix}/_apis/policy/evaluations/${encodeURIComponent(evaluationId)}` +
|
|
2892
|
+
`?api-version=5.0-preview.1`);
|
|
2893
|
+
return resp.type("application/json").send({ ok: true });
|
|
2894
|
+
}
|
|
2895
|
+
catch (err) {
|
|
2896
|
+
return resp
|
|
2897
|
+
.status(400)
|
|
2898
|
+
.type("application/json")
|
|
2899
|
+
.send({ error: err.message });
|
|
2900
|
+
}
|
|
2901
|
+
});
|
|
2902
|
+
// Abandons (closes) the pull request without merging.
|
|
2903
|
+
app.post("/api/azure-devops/pullrequests/:id/abandon", async (req, resp) => {
|
|
2904
|
+
try {
|
|
2905
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2906
|
+
await azureJson("PATCH", `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ status: "abandoned" }));
|
|
2907
|
+
return resp.type("application/json").send({ ok: true });
|
|
2908
|
+
}
|
|
2909
|
+
catch (err) {
|
|
2910
|
+
return resp
|
|
2911
|
+
.status(400)
|
|
2912
|
+
.type("application/json")
|
|
2913
|
+
.send({ error: err.message });
|
|
2088
2914
|
}
|
|
2089
2915
|
});
|
|
2090
2916
|
// Unified diff of one PR file, computed from the Azure item contents at the
|
|
2091
2917
|
// PR merge commits. source/target are optional overrides; default comes from
|
|
2092
2918
|
// the pull request detail.
|
|
2093
|
-
app.get(
|
|
2919
|
+
app.get("/api/azure-devops/pullrequests/:id/file-diff", async (req, resp) => {
|
|
2094
2920
|
try {
|
|
2095
|
-
const path = String(req.query?.path ??
|
|
2921
|
+
const path = String(req.query?.path ?? "").replace(/^\//, "");
|
|
2096
2922
|
if (!path) {
|
|
2097
|
-
return resp
|
|
2923
|
+
return resp
|
|
2924
|
+
.status(400)
|
|
2925
|
+
.type("application/json")
|
|
2926
|
+
.send({ error: "path required" });
|
|
2098
2927
|
}
|
|
2099
|
-
const oldPath = String(req.query?.oldPath ??
|
|
2928
|
+
const oldPath = String(req.query?.oldPath ?? "").replace(/^\//, "");
|
|
2100
2929
|
const { base, prefix, repo } = await azurePrApiBase(Number(req.params?.id));
|
|
2101
|
-
let source = String(req.query?.source ??
|
|
2102
|
-
let target = String(req.query?.target ??
|
|
2930
|
+
let source = String(req.query?.source ?? "");
|
|
2931
|
+
let target = String(req.query?.target ?? "");
|
|
2103
2932
|
if (!source || !target) {
|
|
2104
|
-
const pr = await azureJson(
|
|
2105
|
-
source = source || String(pr?.lastMergeSourceCommit?.commitId ??
|
|
2106
|
-
target = target || String(pr?.lastMergeTargetCommit?.commitId ??
|
|
2933
|
+
const pr = await azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`);
|
|
2934
|
+
source = source || String(pr?.lastMergeSourceCommit?.commitId ?? "");
|
|
2935
|
+
target = target || String(pr?.lastMergeTargetCommit?.commitId ?? "");
|
|
2107
2936
|
}
|
|
2108
2937
|
if (!source || !target) {
|
|
2109
|
-
throw new Error(
|
|
2938
|
+
throw new Error("The pull request does not have merge commits to diff yet.");
|
|
2110
2939
|
}
|
|
2111
2940
|
const fetchContent = async (itemPath, commit) => {
|
|
2112
2941
|
if (!itemPath)
|
|
2113
|
-
return { content:
|
|
2942
|
+
return { content: "", binary: false };
|
|
2114
2943
|
const url = `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/items` +
|
|
2115
2944
|
`?path=${encodeURIComponent(`/${itemPath}`)}` +
|
|
2116
2945
|
`&versionDescriptor.version=${encodeURIComponent(commit)}` +
|
|
2117
2946
|
`&versionDescriptor.versionType=commit&includeContent=true` +
|
|
2118
2947
|
`&api-version=${AZURE_API_VERSION}`;
|
|
2119
|
-
const result = await azureRequest(
|
|
2948
|
+
const result = await azureRequest("GET", url, "");
|
|
2120
2949
|
// 404/203: the file does not exist at that commit (added/deleted file).
|
|
2121
2950
|
if (result.status === 404 || result.status === 203) {
|
|
2122
|
-
return { content:
|
|
2951
|
+
return { content: "", binary: false };
|
|
2123
2952
|
}
|
|
2124
2953
|
if (result.status < 200 || result.status >= 300) {
|
|
2125
|
-
throw new Error(azureErrorMessage(result) ||
|
|
2954
|
+
throw new Error(azureErrorMessage(result) ||
|
|
2955
|
+
`Azure DevOps returned HTTP ${result.status}.`);
|
|
2126
2956
|
}
|
|
2127
|
-
|
|
2957
|
+
let data = null;
|
|
2958
|
+
try {
|
|
2959
|
+
data = JSON.parse(result.body);
|
|
2960
|
+
}
|
|
2961
|
+
catch {
|
|
2962
|
+
// Depending on the Azure deployment and file type, the Items API
|
|
2963
|
+
// may return the file body directly instead of a JSON envelope.
|
|
2964
|
+
}
|
|
2965
|
+
const hasContentEnvelope = data !== null &&
|
|
2966
|
+
typeof data === "object" &&
|
|
2967
|
+
(Object.prototype.hasOwnProperty.call(data, "content") ||
|
|
2968
|
+
Object.prototype.hasOwnProperty.call(data, "isBinary"));
|
|
2128
2969
|
return {
|
|
2129
|
-
content:
|
|
2130
|
-
|
|
2970
|
+
content: hasContentEnvelope
|
|
2971
|
+
? typeof data.content === "string"
|
|
2972
|
+
? data.content
|
|
2973
|
+
: ""
|
|
2974
|
+
: result.body,
|
|
2975
|
+
binary: hasContentEnvelope && data.isBinary === true,
|
|
2131
2976
|
};
|
|
2132
2977
|
};
|
|
2133
2978
|
const [oldFile, newFile] = await Promise.all([
|
|
@@ -2135,161 +2980,209 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
2135
2980
|
fetchContent(path, source),
|
|
2136
2981
|
]);
|
|
2137
2982
|
if (oldFile.binary || newFile.binary) {
|
|
2138
|
-
return resp.type(
|
|
2983
|
+
return resp.type("application/json").send({
|
|
2139
2984
|
path,
|
|
2140
2985
|
oldPath,
|
|
2141
|
-
status:
|
|
2986
|
+
status: "binary",
|
|
2142
2987
|
binary: true,
|
|
2143
2988
|
additions: 0,
|
|
2144
2989
|
deletions: 0,
|
|
2145
2990
|
lines: [],
|
|
2146
2991
|
});
|
|
2147
2992
|
}
|
|
2148
|
-
const status = String(req.query?.changeType ??
|
|
2149
|
-
(!oldFile.content
|
|
2993
|
+
const status = String(req.query?.changeType ?? "") ||
|
|
2994
|
+
(!oldFile.content
|
|
2995
|
+
? "added"
|
|
2996
|
+
: !newFile.content
|
|
2997
|
+
? "deleted"
|
|
2998
|
+
: "modified");
|
|
2150
2999
|
// jsdiff omits the "diff --git" header parseUnifiedDiff expects, and
|
|
2151
3000
|
// prefixing it lets the same parser serve both local and PR diffs.
|
|
2152
3001
|
const patch = createTwoFilesPatch(`a/${oldPath || path}`, `b/${path}`, oldFile.content, newFile.content);
|
|
2153
|
-
const raw = `diff --git a/${oldPath || path} b/${path}\n${patch.replace(/^=+\n/,
|
|
3002
|
+
const raw = `diff --git a/${oldPath || path} b/${path}\n${patch.replace(/^=+\n/, "")}`;
|
|
2154
3003
|
// The patch's trailing newline would surface as a phantom context row.
|
|
2155
|
-
const [file] = parseUnifiedDiff(raw.replace(/\n+$/,
|
|
3004
|
+
const [file] = parseUnifiedDiff(raw.replace(/\n+$/, ""));
|
|
2156
3005
|
const maxLines = 10000;
|
|
2157
3006
|
const lines = (file?.lines ?? []).slice(0, maxLines);
|
|
2158
3007
|
if ((file?.lines ?? []).length > maxLines) {
|
|
2159
|
-
lines.push({
|
|
3008
|
+
lines.push({
|
|
3009
|
+
type: "hunk",
|
|
3010
|
+
text: "Diff truncated (file too large).",
|
|
3011
|
+
});
|
|
2160
3012
|
}
|
|
2161
|
-
return resp.type(
|
|
3013
|
+
return resp.type("application/json").send({
|
|
2162
3014
|
path,
|
|
2163
3015
|
oldPath,
|
|
2164
3016
|
status,
|
|
2165
3017
|
binary: false,
|
|
2166
3018
|
additions: file?.additions ?? 0,
|
|
2167
3019
|
deletions: file?.deletions ?? 0,
|
|
3020
|
+
originalContent: oldFile.content,
|
|
3021
|
+
modifiedContent: newFile.content,
|
|
2168
3022
|
lines,
|
|
2169
3023
|
});
|
|
2170
3024
|
}
|
|
2171
3025
|
catch (err) {
|
|
2172
|
-
return resp
|
|
3026
|
+
return resp
|
|
3027
|
+
.status(400)
|
|
3028
|
+
.type("application/json")
|
|
3029
|
+
.send({ error: err.message });
|
|
2173
3030
|
}
|
|
2174
3031
|
});
|
|
2175
3032
|
// ==================== Commit Detail ====================
|
|
2176
|
-
app.post(
|
|
3033
|
+
app.post("/api/commit/detail", async (req, resp) => {
|
|
2177
3034
|
try {
|
|
2178
3035
|
const { hash } = req.body;
|
|
2179
3036
|
if (!hash) {
|
|
2180
|
-
return resp
|
|
3037
|
+
return resp
|
|
3038
|
+
.status(400)
|
|
3039
|
+
.type("application/json")
|
|
3040
|
+
.send({ error: "hash required" });
|
|
2181
3041
|
}
|
|
2182
3042
|
// Fields are NUL-separated; %b may span lines and contain anything
|
|
2183
3043
|
// except NUL, so it cannot be split naively by newline.
|
|
2184
3044
|
const raw = await git.raw([
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
3045
|
+
"show",
|
|
3046
|
+
"-s",
|
|
3047
|
+
"--format=%H%x00%aI%x00%s%x00%b%x00%aN%x00%aE%x00%P",
|
|
2188
3048
|
hash,
|
|
2189
3049
|
]);
|
|
2190
|
-
const [fullHash, date, message, body, authorName, authorEmail, parents] = raw
|
|
2191
|
-
|
|
2192
|
-
.split('\u0000');
|
|
2193
|
-
return resp.type('application/json').send({
|
|
3050
|
+
const [fullHash, date, message, body, authorName, authorEmail, parents] = raw.trimEnd().split("\u0000");
|
|
3051
|
+
return resp.type("application/json").send({
|
|
2194
3052
|
hash: fullHash,
|
|
2195
3053
|
date,
|
|
2196
3054
|
message,
|
|
2197
|
-
refs:
|
|
2198
|
-
body: body ??
|
|
3055
|
+
refs: "",
|
|
3056
|
+
body: body ?? "",
|
|
2199
3057
|
...authorIdentity(authorName, authorEmail, await configuredIdentity()),
|
|
2200
|
-
parents: String(parents ??
|
|
2201
|
-
.split(
|
|
3058
|
+
parents: String(parents ?? "")
|
|
3059
|
+
.split(" ")
|
|
2202
3060
|
.filter(Boolean),
|
|
2203
3061
|
});
|
|
2204
3062
|
}
|
|
2205
3063
|
catch (err) {
|
|
2206
|
-
return resp
|
|
3064
|
+
return resp
|
|
3065
|
+
.status(400)
|
|
3066
|
+
.type("application/json")
|
|
3067
|
+
.send({ error: err.message });
|
|
2207
3068
|
}
|
|
2208
3069
|
});
|
|
2209
3070
|
// ==================== Diff Viewer ====================
|
|
2210
|
-
app.post(
|
|
3071
|
+
app.post("/api/commit/diff", async (req, resp) => {
|
|
2211
3072
|
try {
|
|
2212
3073
|
const { hash } = req.body;
|
|
2213
3074
|
if (!hash) {
|
|
2214
|
-
return resp
|
|
3075
|
+
return resp
|
|
3076
|
+
.status(400)
|
|
3077
|
+
.type("application/json")
|
|
3078
|
+
.send({ error: "hash required" });
|
|
2215
3079
|
}
|
|
2216
3080
|
// Get the raw unified diff (diff against the first parent for merges)
|
|
2217
3081
|
const rawDiff = await git.raw([
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
3082
|
+
"show",
|
|
3083
|
+
"--no-color",
|
|
3084
|
+
"--pretty=format:",
|
|
3085
|
+
"--find-renames",
|
|
3086
|
+
"--first-parent",
|
|
3087
|
+
"-m",
|
|
2224
3088
|
hash,
|
|
2225
3089
|
]);
|
|
2226
|
-
return resp
|
|
3090
|
+
return resp
|
|
3091
|
+
.type("application/json")
|
|
3092
|
+
.send({ hash, files: parseUnifiedDiff(rawDiff) });
|
|
2227
3093
|
}
|
|
2228
3094
|
catch (err) {
|
|
2229
|
-
return resp
|
|
3095
|
+
return resp
|
|
3096
|
+
.status(400)
|
|
3097
|
+
.type("application/json")
|
|
3098
|
+
.send({ error: err.message });
|
|
2230
3099
|
}
|
|
2231
3100
|
});
|
|
2232
3101
|
// ==================== Working Changes ====================
|
|
2233
|
-
app.get(
|
|
3102
|
+
app.get("/api/working-changes", async (_req, resp) => {
|
|
2234
3103
|
try {
|
|
2235
3104
|
await mutationQueue;
|
|
2236
|
-
return resp.type(
|
|
3105
|
+
return resp.type("application/json").send(await working.snapshot());
|
|
2237
3106
|
}
|
|
2238
3107
|
catch (err) {
|
|
2239
|
-
return resp
|
|
3108
|
+
return resp
|
|
3109
|
+
.status(400)
|
|
3110
|
+
.type("application/json")
|
|
3111
|
+
.send({ error: err.message });
|
|
2240
3112
|
}
|
|
2241
3113
|
});
|
|
2242
3114
|
// ==================== File Content ====================
|
|
2243
|
-
app.post(
|
|
3115
|
+
app.post("/api/file-content", async (req, resp) => {
|
|
2244
3116
|
try {
|
|
2245
3117
|
const { path, ref } = req.body;
|
|
2246
3118
|
if (!path || !ref) {
|
|
2247
|
-
return resp
|
|
3119
|
+
return resp
|
|
3120
|
+
.status(400)
|
|
3121
|
+
.type("application/json")
|
|
3122
|
+
.send({ error: "path and ref required" });
|
|
2248
3123
|
}
|
|
2249
|
-
if (ref ===
|
|
3124
|
+
if (ref === "WORKING") {
|
|
2250
3125
|
const root = await repoRoot();
|
|
2251
3126
|
const abs = resolve(root, path);
|
|
2252
3127
|
const rel = relative(root, abs);
|
|
2253
|
-
if (rel ===
|
|
2254
|
-
return resp
|
|
3128
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
3129
|
+
return resp
|
|
3130
|
+
.status(400)
|
|
3131
|
+
.type("application/json")
|
|
3132
|
+
.send({ error: "invalid path" });
|
|
2255
3133
|
}
|
|
2256
3134
|
try {
|
|
2257
3135
|
const buffer = await readFile(abs);
|
|
2258
3136
|
if (buffer.includes(0)) {
|
|
2259
|
-
return resp
|
|
3137
|
+
return resp
|
|
3138
|
+
.type("application/json")
|
|
3139
|
+
.send({ binary: true, content: "" });
|
|
2260
3140
|
}
|
|
2261
|
-
return resp
|
|
3141
|
+
return resp
|
|
3142
|
+
.type("application/json")
|
|
3143
|
+
.send({ content: buffer.toString("utf8") });
|
|
2262
3144
|
}
|
|
2263
3145
|
catch {
|
|
2264
3146
|
// File no longer exists on disk (deleted).
|
|
2265
|
-
return resp.type(
|
|
3147
|
+
return resp.type("application/json").send({ content: "" });
|
|
2266
3148
|
}
|
|
2267
3149
|
}
|
|
2268
|
-
if (ref ===
|
|
2269
|
-
return resp.type(
|
|
3150
|
+
if (ref === "EMPTY") {
|
|
3151
|
+
return resp.type("application/json").send({ content: "" });
|
|
2270
3152
|
}
|
|
2271
3153
|
try {
|
|
2272
|
-
const content = await git.raw([
|
|
2273
|
-
|
|
2274
|
-
|
|
3154
|
+
const content = await git.raw([
|
|
3155
|
+
"show",
|
|
3156
|
+
ref === "INDEX" ? `:${path}` : `${ref}:${path}`,
|
|
3157
|
+
]);
|
|
3158
|
+
if (content.includes("\u0000")) {
|
|
3159
|
+
return resp
|
|
3160
|
+
.type("application/json")
|
|
3161
|
+
.send({ binary: true, content: "" });
|
|
2275
3162
|
}
|
|
2276
|
-
return resp.type(
|
|
3163
|
+
return resp.type("application/json").send({ content });
|
|
2277
3164
|
}
|
|
2278
3165
|
catch {
|
|
2279
3166
|
// Path did not exist at that revision (added file).
|
|
2280
|
-
return resp.type(
|
|
3167
|
+
return resp.type("application/json").send({ content: "" });
|
|
2281
3168
|
}
|
|
2282
3169
|
}
|
|
2283
3170
|
catch (err) {
|
|
2284
|
-
return resp
|
|
3171
|
+
return resp
|
|
3172
|
+
.status(400)
|
|
3173
|
+
.type("application/json")
|
|
3174
|
+
.send({ error: err.message });
|
|
2285
3175
|
}
|
|
2286
3176
|
});
|
|
2287
3177
|
// ==================== Discard ====================
|
|
2288
|
-
app.post(
|
|
3178
|
+
app.post("/api/discard", async (req, resp) => {
|
|
2289
3179
|
try {
|
|
2290
3180
|
const { files, mode } = req.body;
|
|
2291
3181
|
if (!Array.isArray(files) || files.length === 0) {
|
|
2292
|
-
return resp
|
|
3182
|
+
return resp
|
|
3183
|
+
.status(400)
|
|
3184
|
+
.type("application/json")
|
|
3185
|
+
.send({ error: "files required" });
|
|
2293
3186
|
}
|
|
2294
3187
|
const root = await repoRoot();
|
|
2295
3188
|
const status = await git.status();
|
|
@@ -2297,47 +3190,56 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
2297
3190
|
const tracked = files.filter((file) => !untracked.has(file));
|
|
2298
3191
|
const removed = files.filter((file) => untracked.has(file));
|
|
2299
3192
|
if (tracked.length > 0) {
|
|
2300
|
-
if (mode ===
|
|
3193
|
+
if (mode === "unstaged") {
|
|
2301
3194
|
// Restore from the index so staged changes survive; unmerged (conflicted)
|
|
2302
3195
|
// paths cannot be restored from the index and fall back to HEAD.
|
|
2303
3196
|
const conflicted = new Set(status.conflicted ?? []);
|
|
2304
3197
|
const restorable = tracked.filter((file) => !conflicted.has(file));
|
|
2305
3198
|
const unmerged = tracked.filter((file) => conflicted.has(file));
|
|
2306
3199
|
if (restorable.length > 0)
|
|
2307
|
-
await git.raw([
|
|
3200
|
+
await git.raw(["checkout", "--", ...restorable]);
|
|
2308
3201
|
if (unmerged.length > 0)
|
|
2309
|
-
await git.raw([
|
|
3202
|
+
await git.raw(["checkout", "HEAD", "--", ...unmerged]);
|
|
2310
3203
|
}
|
|
2311
3204
|
else {
|
|
2312
|
-
await git.raw([
|
|
3205
|
+
await git.raw(["checkout", "HEAD", "--", ...tracked]);
|
|
2313
3206
|
}
|
|
2314
3207
|
}
|
|
2315
3208
|
for (const file of removed) {
|
|
2316
3209
|
await rm(join(root, file), { force: true, recursive: true });
|
|
2317
3210
|
}
|
|
2318
|
-
return resp.type(
|
|
3211
|
+
return resp.type("application/json").send({ success: true });
|
|
2319
3212
|
}
|
|
2320
3213
|
catch (err) {
|
|
2321
|
-
return resp
|
|
3214
|
+
return resp
|
|
3215
|
+
.status(400)
|
|
3216
|
+
.type("application/json")
|
|
3217
|
+
.send({ error: err.message });
|
|
2322
3218
|
}
|
|
2323
3219
|
});
|
|
2324
3220
|
// ==================== Working Tree Actions ====================
|
|
2325
|
-
app.post(
|
|
3221
|
+
app.post("/api/reset", async (_req, resp) => {
|
|
2326
3222
|
try {
|
|
2327
|
-
await git.raw([
|
|
2328
|
-
return resp.type(
|
|
3223
|
+
await git.raw(["reset", "--hard", "HEAD"]);
|
|
3224
|
+
return resp.type("application/json").send({ ok: true });
|
|
2329
3225
|
}
|
|
2330
3226
|
catch (err) {
|
|
2331
|
-
return resp
|
|
3227
|
+
return resp
|
|
3228
|
+
.status(400)
|
|
3229
|
+
.type("application/json")
|
|
3230
|
+
.send({ error: err.message });
|
|
2332
3231
|
}
|
|
2333
3232
|
});
|
|
2334
|
-
app.post(
|
|
3233
|
+
app.post("/api/clean", async (_req, resp) => {
|
|
2335
3234
|
try {
|
|
2336
|
-
await git.raw([
|
|
2337
|
-
return resp.type(
|
|
3235
|
+
await git.raw(["clean", "-fd"]);
|
|
3236
|
+
return resp.type("application/json").send({ ok: true });
|
|
2338
3237
|
}
|
|
2339
3238
|
catch (err) {
|
|
2340
|
-
return resp
|
|
3239
|
+
return resp
|
|
3240
|
+
.status(400)
|
|
3241
|
+
.type("application/json")
|
|
3242
|
+
.send({ error: err.message });
|
|
2341
3243
|
}
|
|
2342
3244
|
});
|
|
2343
3245
|
const address = await app.listen({ port, ...(host ? { host } : {}) });
|
|
@@ -2358,6 +3260,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
2358
3260
|
showRemoteBranches = settings.showRemoteBranches;
|
|
2359
3261
|
issueRegex = settings.issueRegex;
|
|
2360
3262
|
issueUrl = settings.issueUrl;
|
|
3263
|
+
allowMerge = settings.allowMerge;
|
|
2361
3264
|
},
|
|
2362
3265
|
};
|
|
2363
3266
|
}
|