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