guito 1.0.6 → 1.0.7
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 +17 -0
- package/README.md +39 -2
- package/bin/ai-review.js +77 -37
- package/bin/commit-message.js +146 -0
- package/bin/guito-server.js +182 -37
- package/bin/ui/browser/index.html +1 -1
- package/bin/ui/browser/main-2QNMM6BC.js +67 -0
- package/{guito-1.0.6.vsix → guito-1.0.7.vsix} +0 -0
- package/package.json +1 -1
- package/bin/ui/browser/main-TJBW4QCI.js +0 -67
package/bin/guito-server.js
CHANGED
|
@@ -15,8 +15,22 @@ import { workingTree } from './working-tree.js';
|
|
|
15
15
|
import { avatarCache } from './avatars.js';
|
|
16
16
|
import { repositoryState } from './repository-state.js';
|
|
17
17
|
import { createAiReviewer, resolveAiReviewConfig, } from './ai-review.js';
|
|
18
|
+
import { generateCommitMessage } from './commit-message.js';
|
|
18
19
|
const execFileAsync = promisify(execFile);
|
|
19
20
|
const AZURE_API_VERSION = '5.0-preview';
|
|
21
|
+
// The Policy API has been GA since 4.1 and does not answer to the
|
|
22
|
+
// 5.0-preview version the git endpoints use.
|
|
23
|
+
const AZURE_POLICY_API_VERSION = '5.0';
|
|
24
|
+
// Policy type id of the branch "Merge Types" policy. It is nearly identical
|
|
25
|
+
// to the minimum-approver-count id (...4906e5d171dd), so keep them apart.
|
|
26
|
+
const AZURE_MERGE_TYPES_POLICY_ID = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab';
|
|
27
|
+
/** Merge-types policy setting flag per Guito merge strategy. */
|
|
28
|
+
const AZURE_MERGE_POLICY_FLAGS = {
|
|
29
|
+
noFastForward: 'allowNoFastForward',
|
|
30
|
+
squash: 'allowSquash',
|
|
31
|
+
rebase: 'allowRebase',
|
|
32
|
+
rebaseMerge: 'allowRebaseMerge',
|
|
33
|
+
};
|
|
20
34
|
export const DEFAULT_PR_BRANCH_NAME_TEMPLATE = 'pr/${randomstring}';
|
|
21
35
|
/** Matches the UI's insensitive-search behavior for casing and accents. */
|
|
22
36
|
function normalizeSearchText(value, sensitive) {
|
|
@@ -1284,13 +1298,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1284
1298
|
if (newBranch) {
|
|
1285
1299
|
await verifyBranchName(newBranch);
|
|
1286
1300
|
// --track sets the upstream when checking out a remote-tracking ref.
|
|
1287
|
-
await git.raw([
|
|
1288
|
-
'checkout',
|
|
1289
|
-
'-b',
|
|
1290
|
-
newBranch,
|
|
1291
|
-
...(track ? ['--track'] : []),
|
|
1292
|
-
ref,
|
|
1293
|
-
]);
|
|
1301
|
+
await git.raw(['checkout', '-b', newBranch, ...(track ? ['--track'] : []), ref]);
|
|
1294
1302
|
}
|
|
1295
1303
|
else {
|
|
1296
1304
|
await git.raw(['checkout', ...(detach ? ['--detach'] : []), ref]);
|
|
@@ -1424,12 +1432,16 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1424
1432
|
app.post('/api/stash/save', async (req, resp) => {
|
|
1425
1433
|
try {
|
|
1426
1434
|
const message = strField(req.body?.message);
|
|
1427
|
-
const scope = ['staged', 'unstaged', 'all'].includes(req.body?.scope)
|
|
1435
|
+
const scope = ['staged', 'unstaged', 'all'].includes(req.body?.scope)
|
|
1436
|
+
? req.body.scope
|
|
1437
|
+
: 'all';
|
|
1428
1438
|
// 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
|
|
1429
1439
|
// (--keep-index leaves staged changes staged); 'all' → index + working tree.
|
|
1430
1440
|
// Untracked files ride along unless includeUntracked opts out (the legacy
|
|
1431
1441
|
// callers kept them for 'all'/'unstaged', which stay the defaults).
|
|
1432
|
-
const includeUntracked = typeof req.body?.includeUntracked === 'boolean'
|
|
1442
|
+
const includeUntracked = typeof req.body?.includeUntracked === 'boolean'
|
|
1443
|
+
? req.body.includeUntracked
|
|
1444
|
+
: scope !== 'staged';
|
|
1433
1445
|
const args = ['push'];
|
|
1434
1446
|
if (scope === 'staged')
|
|
1435
1447
|
args.push('--staged');
|
|
@@ -1452,7 +1464,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1452
1464
|
if (!Number.isInteger(index) || index < 0)
|
|
1453
1465
|
throw new Error('A stash index is required.');
|
|
1454
1466
|
// --index restores the staged/unstaged split recorded by the stash.
|
|
1455
|
-
const args = [
|
|
1467
|
+
const args = [
|
|
1468
|
+
'apply',
|
|
1469
|
+
...(boolField(req.body?.restoreIndex) ? ['--index'] : []),
|
|
1470
|
+
`stash@{${index}}`,
|
|
1471
|
+
];
|
|
1456
1472
|
await mutate(() => git.stash(args));
|
|
1457
1473
|
return mutationResult(resp, []);
|
|
1458
1474
|
}
|
|
@@ -1465,7 +1481,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1465
1481
|
const index = Number(req.body?.index);
|
|
1466
1482
|
if (!Number.isInteger(index) || index < 0)
|
|
1467
1483
|
throw new Error('A stash index is required.');
|
|
1468
|
-
const args = [
|
|
1484
|
+
const args = [
|
|
1485
|
+
'pop',
|
|
1486
|
+
...(boolField(req.body?.restoreIndex) ? ['--index'] : []),
|
|
1487
|
+
`stash@{${index}}`,
|
|
1488
|
+
];
|
|
1469
1489
|
await mutate(() => git.stash(args));
|
|
1470
1490
|
return mutationResult(resp, []);
|
|
1471
1491
|
}
|
|
@@ -1615,7 +1635,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1615
1635
|
const remote = strField(req.body?.remote);
|
|
1616
1636
|
const branch = strField(req.body?.branch);
|
|
1617
1637
|
// 'mode' supersedes the legacy 'rebase' boolean (kept for older callers).
|
|
1618
|
-
const mode = ['merge', 'rebase', 'ff-only'].includes(req.body?.mode)
|
|
1638
|
+
const mode = ['merge', 'rebase', 'ff-only'].includes(req.body?.mode)
|
|
1639
|
+
? req.body.mode
|
|
1640
|
+
: 'merge';
|
|
1619
1641
|
const rebase = boolField(req.body?.rebase) || mode === 'rebase';
|
|
1620
1642
|
const ffOnly = mode === 'ff-only';
|
|
1621
1643
|
// An explicit remote must be configured; without one, keep the legacy
|
|
@@ -2253,13 +2275,76 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
2253
2275
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
2254
2276
|
}
|
|
2255
2277
|
});
|
|
2256
|
-
/**
|
|
2257
|
-
const
|
|
2278
|
+
/** The project's branch policy configurations; null when they cannot be read. */
|
|
2279
|
+
const azurePolicyConfigurations = async (prefix) => {
|
|
2280
|
+
try {
|
|
2281
|
+
const data = await azureJson('GET', `${prefix}/_apis/policy/configurations?api-version=${AZURE_POLICY_API_VERSION}`);
|
|
2282
|
+
return Array.isArray(data?.value) ? data.value : [];
|
|
2283
|
+
}
|
|
2284
|
+
catch {
|
|
2285
|
+
// A denied or unavailable policy read must not take the whole dialog
|
|
2286
|
+
// down; the client then falls back to the local allowMerge setting.
|
|
2287
|
+
return null;
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
/**
|
|
2291
|
+
* Merge strategies the pull request's target branch allows, from the branch
|
|
2292
|
+
* "Merge Types" policies covering it: a strategy survives only when every
|
|
2293
|
+
* applicable policy allows it. null means nothing restricts the branch (or
|
|
2294
|
+
* the policies could not be read), and a legacy squash-only policy
|
|
2295
|
+
* ("useSquashMerge") allows squash alone.
|
|
2296
|
+
*/
|
|
2297
|
+
const azureMergePolicyOf = (policies, pr) => {
|
|
2298
|
+
if (!policies)
|
|
2299
|
+
return null;
|
|
2300
|
+
const repositoryId = String(pr?.repository?.id ?? '');
|
|
2301
|
+
const targetRefName = String(pr?.targetRefName ?? '');
|
|
2302
|
+
if (!repositoryId || !targetRefName)
|
|
2303
|
+
return null;
|
|
2304
|
+
const coversPullRequest = (scope) => {
|
|
2305
|
+
const scopeRepository = String(scope?.repositoryId ?? '');
|
|
2306
|
+
if (scopeRepository && scopeRepository !== repositoryId)
|
|
2307
|
+
return false;
|
|
2308
|
+
// Without a refName the scope covers every branch of the repository.
|
|
2309
|
+
const refName = String(scope?.refName ?? '');
|
|
2310
|
+
if (!refName)
|
|
2311
|
+
return true;
|
|
2312
|
+
return String(scope?.matchKind ?? 'Exact').toLowerCase() === 'prefix'
|
|
2313
|
+
? targetRefName.startsWith(refName)
|
|
2314
|
+
: targetRefName === refName;
|
|
2315
|
+
};
|
|
2316
|
+
const applicable = policies.filter((policy) => policy?.isEnabled === true &&
|
|
2317
|
+
policy?.isDeleted !== true &&
|
|
2318
|
+
String(policy?.type?.id ?? '') === AZURE_MERGE_TYPES_POLICY_ID &&
|
|
2319
|
+
(Array.isArray(policy?.settings?.scope) ? policy.settings.scope : []).some(coversPullRequest));
|
|
2320
|
+
if (!applicable.length)
|
|
2321
|
+
return null;
|
|
2322
|
+
const all = Object.keys(AZURE_MERGE_POLICY_FLAGS);
|
|
2323
|
+
let allowed = new Set(all);
|
|
2324
|
+
for (const policy of applicable) {
|
|
2325
|
+
const settings = policy.settings ?? {};
|
|
2326
|
+
const strategies = settings.useSquashMerge === true
|
|
2327
|
+
? ['squash']
|
|
2328
|
+
: all.filter((strategy) => settings[AZURE_MERGE_POLICY_FLAGS[strategy]] === true);
|
|
2329
|
+
allowed = new Set(strategies.filter((strategy) => allowed.has(strategy)));
|
|
2330
|
+
}
|
|
2331
|
+
// Overlapping policies that share no strategy are a pathological
|
|
2332
|
+
// configuration; treat the branch as unrestricted rather than offering
|
|
2333
|
+
// no completion strategy at all.
|
|
2334
|
+
return allowed.size ? all.filter((strategy) => allowed.has(strategy)) : null;
|
|
2335
|
+
};
|
|
2336
|
+
/**
|
|
2337
|
+
* Full detail of one pull request, as the dialog and the reviewer see it.
|
|
2338
|
+
* The merge policy is skipped for the review poller, which reloads detail
|
|
2339
|
+
* repeatedly and does not complete pull requests.
|
|
2340
|
+
*/
|
|
2341
|
+
const azurePrDetail = async (id, withMergePolicy = true) => {
|
|
2258
2342
|
{
|
|
2259
2343
|
const { base, prefix, remoteUrl } = await azurePrApiBase(id);
|
|
2260
|
-
const [pr, me] = await Promise.all([
|
|
2344
|
+
const [pr, me, policies] = await Promise.all([
|
|
2261
2345
|
azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`),
|
|
2262
2346
|
azureMe(),
|
|
2347
|
+
withMergePolicy ? azurePolicyConfigurations(prefix) : Promise.resolve(null),
|
|
2263
2348
|
]);
|
|
2264
2349
|
const summary = mapAzurePullRequest(pr, prefix, remoteUrl, me);
|
|
2265
2350
|
// Some Server releases omit labels from the PR detail response; read
|
|
@@ -2295,6 +2380,10 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
2295
2380
|
lastMergeTargetCommit: String(pr?.lastMergeTargetCommit?.commitId ?? ''),
|
|
2296
2381
|
labels,
|
|
2297
2382
|
mergeStatus: azureMergeStatus(pr),
|
|
2383
|
+
// Strategies the target branch's policies allow; null when
|
|
2384
|
+
// unrestricted (or unreadable), so the dialog falls back to the
|
|
2385
|
+
// local allowMerge setting alone.
|
|
2386
|
+
mergePolicy: azureMergePolicyOf(policies, pr),
|
|
2298
2387
|
};
|
|
2299
2388
|
}
|
|
2300
2389
|
};
|
|
@@ -2399,30 +2488,37 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
2399
2488
|
try {
|
|
2400
2489
|
const enabled = req.body?.enabled === true;
|
|
2401
2490
|
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2402
|
-
const
|
|
2491
|
+
const url = `${base}?api-version=${AZURE_API_VERSION}`;
|
|
2492
|
+
if (enabled) {
|
|
2493
|
+
const me = await azureMe();
|
|
2403
2494
|
const payload = {
|
|
2404
|
-
autoCompleteSetBy:
|
|
2495
|
+
autoCompleteSetBy: { id: me.id },
|
|
2405
2496
|
};
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
}
|
|
2411
|
-
return JSON.stringify(payload);
|
|
2412
|
-
};
|
|
2413
|
-
const url = `${base}?api-version=${AZURE_API_VERSION}`;
|
|
2414
|
-
const me = enabled ? await azureMe() : null;
|
|
2415
|
-
// Azure DevOps clears auto-complete for an empty identity; some Server
|
|
2416
|
-
// releases only accept an explicit null, so retry that shape on failure.
|
|
2417
|
-
try {
|
|
2418
|
-
await azureJson('PATCH', url, buildPayload(enabled && me ? { id: me.id } : { id: '' }));
|
|
2497
|
+
const options = completionOptionsOf(req.body);
|
|
2498
|
+
if (options)
|
|
2499
|
+
payload.completionOptions = options;
|
|
2500
|
+
await azureJson('PATCH', url, JSON.stringify(payload));
|
|
2501
|
+
return resp.type('application/json').send({ ok: true });
|
|
2419
2502
|
}
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2503
|
+
// Clearing auto-complete accepts two payload shapes: an empty identity
|
|
2504
|
+
// id (Azure DevOps cloud) and an explicit null (some Server releases).
|
|
2505
|
+
// A PATCH may also return 200 while silently ignoring the field, so
|
|
2506
|
+
// every attempt is verified against the returned pull request record
|
|
2507
|
+
// before the next shape is tried.
|
|
2508
|
+
let lastError = null;
|
|
2509
|
+
for (const identity of [{ id: '' }, null]) {
|
|
2510
|
+
try {
|
|
2511
|
+
const pr = await azureJson('PATCH', url, JSON.stringify({ autoCompleteSetBy: identity }));
|
|
2512
|
+
if (!pr?.autoCompleteSetBy) {
|
|
2513
|
+
return resp.type('application/json').send({ ok: true });
|
|
2514
|
+
}
|
|
2515
|
+
lastError = new Error('Azure DevOps acknowledged the request but did not clear auto-complete.');
|
|
2516
|
+
}
|
|
2517
|
+
catch (err) {
|
|
2518
|
+
lastError = err;
|
|
2519
|
+
}
|
|
2424
2520
|
}
|
|
2425
|
-
|
|
2521
|
+
throw lastError;
|
|
2426
2522
|
}
|
|
2427
2523
|
catch (err) {
|
|
2428
2524
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -3142,7 +3238,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
3142
3238
|
targetCommit: pr.targetCommit,
|
|
3143
3239
|
})),
|
|
3144
3240
|
loadPullRequest: async (id) => {
|
|
3145
|
-
const pr = await azurePrDetail(id);
|
|
3241
|
+
const pr = await azurePrDetail(id, false);
|
|
3146
3242
|
return {
|
|
3147
3243
|
id: pr.id,
|
|
3148
3244
|
title: pr.title,
|
|
@@ -3223,11 +3319,13 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
3223
3319
|
}
|
|
3224
3320
|
});
|
|
3225
3321
|
// Reviews the pull request now; "force" re-reviews the whole diff even when
|
|
3226
|
-
// the merge source commit has not moved
|
|
3322
|
+
// the merge source commit has not moved, and "model" overrides the
|
|
3323
|
+
// configured Claude model for this one run.
|
|
3227
3324
|
app.post('/api/azure-devops/pullrequests/:id/ai-review', async (req, resp) => {
|
|
3228
3325
|
try {
|
|
3229
3326
|
const state = await aiReviewer.review(Number(req.params?.id), {
|
|
3230
3327
|
force: req.body?.force === true,
|
|
3328
|
+
model: typeof req.body?.model === 'string' ? req.body.model : '',
|
|
3231
3329
|
});
|
|
3232
3330
|
if (state.status === 'error') {
|
|
3233
3331
|
return resp.status(400).type('application/json').send({ error: state.error, state });
|
|
@@ -3270,6 +3368,53 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
3270
3368
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
3271
3369
|
}
|
|
3272
3370
|
});
|
|
3371
|
+
// ==================== AI commit message ====================
|
|
3372
|
+
// One Claude Code run over the pending changes; the reply fills the working
|
|
3373
|
+
// panel's message box. Nothing is committed and no Azure DevOps call is made.
|
|
3374
|
+
app.post('/api/commit-message', async (req, resp) => {
|
|
3375
|
+
try {
|
|
3376
|
+
await mutationQueue;
|
|
3377
|
+
const snapshot = await working.snapshot();
|
|
3378
|
+
const staged = snapshot.stagedFiles;
|
|
3379
|
+
const scope = staged.length ? 'staged' : 'working';
|
|
3380
|
+
const files = scope === 'staged' ? staged : [...staged, ...snapshot.unstagedFiles];
|
|
3381
|
+
if (!files.length) {
|
|
3382
|
+
throw new Error('There are no changes to write a commit message about.');
|
|
3383
|
+
}
|
|
3384
|
+
let branch = '';
|
|
3385
|
+
try {
|
|
3386
|
+
branch = await currentBranch();
|
|
3387
|
+
}
|
|
3388
|
+
catch {
|
|
3389
|
+
branch = '';
|
|
3390
|
+
}
|
|
3391
|
+
let recentSubjects = [];
|
|
3392
|
+
try {
|
|
3393
|
+
recentSubjects = (await git.raw(['log', '-n', '12', '--pretty=format:%s']))
|
|
3394
|
+
.split(/\r?\n/)
|
|
3395
|
+
.map((subject) => subject.trim())
|
|
3396
|
+
.filter(Boolean);
|
|
3397
|
+
}
|
|
3398
|
+
catch {
|
|
3399
|
+
// No commits yet (fresh repository); the model manages without style.
|
|
3400
|
+
}
|
|
3401
|
+
const message = await generateCommitMessage({
|
|
3402
|
+
branch,
|
|
3403
|
+
scope,
|
|
3404
|
+
files,
|
|
3405
|
+
recentSubjects,
|
|
3406
|
+
config: await refreshAiReviewConfig(),
|
|
3407
|
+
model: strField(req.body?.model),
|
|
3408
|
+
cwd: repositoryPath,
|
|
3409
|
+
runModel: aiReviewModelImpl,
|
|
3410
|
+
log: (line) => onLog?.(line),
|
|
3411
|
+
});
|
|
3412
|
+
return resp.type('application/json').send(message);
|
|
3413
|
+
}
|
|
3414
|
+
catch (err) {
|
|
3415
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
3416
|
+
}
|
|
3417
|
+
});
|
|
3273
3418
|
// ==================== Commit Detail ====================
|
|
3274
3419
|
app.post('/api/commit/detail', async (req, resp) => {
|
|
3275
3420
|
try {
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
<style>:root{--bg:#1e1e1e;--bg-alt:#232323;--bg-hover:#2a2d2e;--border:#3c3c3c;--text:#cccccc;--text-bright:#e8e8e8;--text-dim:#9d9d9d;--accent:#3794ff;--red:#f14c4c;--mono:ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace}*{box-sizing:border-box}html,body{height:100%;margin:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:13px;color:var(--text);background:var(--bg);overflow:hidden}</style><link rel="stylesheet" href="styles-I7K2YZTW.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-I7K2YZTW.css"></noscript></head>
|
|
10
10
|
<body>
|
|
11
11
|
<app-root></app-root>
|
|
12
|
-
<script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-
|
|
12
|
+
<script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-2QNMM6BC.js" type="module"></script></body>
|
|
13
13
|
</html>
|