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.
@@ -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) ? req.body.scope : 'all';
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' ? req.body.includeUntracked : scope !== 'staged';
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 = ['apply', ...(boolField(req.body?.restoreIndex) ? ['--index'] : []), `stash@{${index}}`];
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 = ['pop', ...(boolField(req.body?.restoreIndex) ? ['--index'] : []), `stash@{${index}}`];
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) ? req.body.mode : 'merge';
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
- /** Full detail of one pull request, as the dialog and the reviewer see it. */
2257
- const azurePrDetail = async (id) => {
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 buildPayload = (identity) => {
2491
+ const url = `${base}?api-version=${AZURE_API_VERSION}`;
2492
+ if (enabled) {
2493
+ const me = await azureMe();
2403
2494
  const payload = {
2404
- autoCompleteSetBy: identity,
2495
+ autoCompleteSetBy: { id: me.id },
2405
2496
  };
2406
- if (enabled) {
2407
- const options = completionOptionsOf(req.body);
2408
- if (options)
2409
- payload.completionOptions = options;
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
- catch (firstError) {
2421
- if (enabled)
2422
- throw firstError;
2423
- await azureJson('PATCH', url, buildPayload(null));
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
- return resp.type('application/json').send({ ok: true });
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-TJBW4QCI.js" type="module"></script></body>
12
+ <script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-2QNMM6BC.js" type="module"></script></body>
13
13
  </html>