dsh-github-flow 0.5.0 → 0.7.0
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/README.en.md +112 -17
- package/README.md +115 -17
- package/docs/UPGRADE-NOTES.md +12 -11
- package/docs/architecture.md +81 -22
- package/docs/images/1.png +0 -0
- package/lib/api/route-helpers.d.ts +19 -0
- package/lib/api/route-helpers.js +54 -0
- package/lib/api/routes-actions.js +214 -9
- package/lib/api/routes-lifecycle.js +8 -1
- package/lib/api/routes-overview.d.ts +26 -0
- package/lib/api/routes-overview.js +396 -36
- package/lib/api/routes-proxy.js +8 -1
- package/lib/api/routes.d.ts +11 -1
- package/lib/api/routes.js +15 -2
- package/lib/cache.d.ts +56 -4
- package/lib/cache.js +77 -7
- package/lib/client.d.ts +8 -3
- package/lib/client.js +5542 -1391
- package/lib/destructive.d.ts +59 -0
- package/lib/destructive.js +285 -0
- package/lib/executor/args-guard.d.ts +17 -0
- package/lib/executor/args-guard.js +23 -0
- package/lib/executor/error-classifier.d.ts +55 -0
- package/lib/executor/error-classifier.js +199 -0
- package/lib/executor/failure-anchors.d.ts +52 -0
- package/lib/executor/failure-anchors.js +116 -0
- package/lib/executor/git-parser.d.ts +124 -0
- package/lib/executor/git-parser.js +397 -0
- package/lib/executor/pr-review.d.ts +95 -0
- package/lib/executor/pr-review.js +175 -0
- package/lib/executor/run-log.d.ts +36 -0
- package/lib/executor/run-log.js +78 -0
- package/lib/executor.d.ts +277 -6
- package/lib/executor.js +1265 -94
- package/lib/github-service.d.ts +1 -1
- package/lib/main.d.ts +92 -18
- package/lib/main.js +186 -95
- package/lib/renderers/common.d.ts +0 -3
- package/lib/renderers/common.js +8 -44
- package/lib/renderers/markdown.d.ts +1 -0
- package/lib/renderers/markdown.js +1 -0
- package/lib/renderers/pr.js +26 -1
- package/lib/renderers/task.d.ts +9 -0
- package/lib/renderers/task.js +120 -0
- package/lib/review-rules.d.ts +60 -0
- package/lib/review-rules.js +204 -0
- package/lib/shared/state-badges.d.ts +42 -0
- package/lib/shared/state-badges.js +102 -0
- package/lib/task-primitives.d.ts +109 -0
- package/lib/task-primitives.js +194 -0
- package/lib/tool-tiers.d.ts +57 -0
- package/lib/tool-tiers.js +168 -0
- package/lib/tools/agent-task.js +4 -4
- package/lib/tools/api.js +18 -6
- package/lib/tools/cache.js +42 -40
- package/lib/tools/codespace.js +4 -12
- package/lib/tools/discussion.js +4 -4
- package/lib/tools/gist.d.ts +1 -39
- package/lib/tools/gist.js +4 -14
- package/lib/tools/help.js +72 -18
- package/lib/tools/image-utils.d.ts +27 -0
- package/lib/tools/image-utils.js +73 -3
- package/lib/tools/issue.js +16 -28
- package/lib/tools/label.js +4 -15
- package/lib/tools/pr.d.ts +47 -0
- package/lib/tools/pr.js +170 -33
- package/lib/tools/project.js +4 -13
- package/lib/tools/release.js +11 -22
- package/lib/tools/repo.js +4 -4
- package/lib/tools/ruleset.js +9 -6
- package/lib/tools/run.js +76 -24
- package/lib/tools/secret.js +11 -17
- package/lib/tools/skill.js +18 -22
- package/lib/tools/status.js +4 -4
- package/lib/tools/task.d.ts +219 -0
- package/lib/tools/task.js +457 -0
- package/lib/types.d.ts +514 -1
- package/lib/ui/help-modal.d.ts +6 -0
- package/lib/ui/help-modal.js +124 -16
- package/lib/ui/issue-modal.d.ts +14 -0
- package/lib/ui/issue-modal.js +370 -0
- package/lib/ui/markdown-lite.d.ts +66 -0
- package/lib/ui/markdown-lite.js +312 -0
- package/lib/ui/network-banner.d.ts +12 -0
- package/lib/ui/network-banner.js +35 -0
- package/lib/ui/pr-modal.d.ts +17 -0
- package/lib/ui/pr-modal.js +555 -0
- package/lib/ui/repo-badges.d.ts +43 -0
- package/lib/ui/repo-badges.js +82 -0
- package/lib/ui/repo-selector.d.ts +14 -0
- package/lib/ui/repo-selector.js +112 -0
- package/lib/ui/run-modal.d.ts +26 -0
- package/lib/ui/run-modal.js +486 -0
- package/lib/ui/storage.d.ts +47 -0
- package/lib/ui/storage.js +139 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +1330 -44
- package/lib/ui/sync-settings-modal.js +1 -1
- package/lib/ui/utils.js +13 -1
- package/lib/ui/workspace-panel.js +226 -29
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +9 -2
|
@@ -253,3 +253,400 @@ export function parseGitStatText(statText) {
|
|
|
253
253
|
return null;
|
|
254
254
|
return { files, summary };
|
|
255
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* 提取 `.git/config` 中所有 `[remote "..."]` 段内的 url。
|
|
258
|
+
*
|
|
259
|
+
* 为什么必须按段扫描而不是全文抓 `url = `:`[submodule "..."]` 段同样带 url
|
|
260
|
+
* (实测 `[submodule "lib"] url = https://github.com/x/y.git`)。全文正则会把
|
|
261
|
+
* 子模块的远程算进指纹,导致一次 `git submodule add` 就触发整个面板缓存的
|
|
262
|
+
* 无谓作废。子模块的 url 与本工作区关联哪个 GitHub 仓库毫无关系。
|
|
263
|
+
*
|
|
264
|
+
* 输出语义:保持文件中的出现顺序、已去重、不含空值。
|
|
265
|
+
* 排序交给调用方(指纹需要稳定顺序,但那是调用方的语义,纯函数不该替它决定)。
|
|
266
|
+
*/
|
|
267
|
+
export function parseRemoteUrls(configText) {
|
|
268
|
+
return parseGitConfig(configText).allUrls;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* 规范化 git 配置值:处理引号包裹与行尾注释。
|
|
272
|
+
*
|
|
273
|
+
* git 的实际规则(全部实测):
|
|
274
|
+
* - 未加引号:`;` 或 `#` 之后一律视为注释,**即使前面没有空格**
|
|
275
|
+
* `url = https://github.com/a/b.git ; note` → https://github.com/a/b.git
|
|
276
|
+
* `url = https://github.com/a/b.git;note` → https://github.com/a/b.git
|
|
277
|
+
* - 加引号:引号内的 `;` / `#` 是字面值,注释只能出现在闭合引号之后
|
|
278
|
+
* `url = "https://user:p#ss@github.com/a/b.git"` → 保留 #
|
|
279
|
+
* `url = "https://github.com/a/b.git" ; note` → 剥掉引号与注释
|
|
280
|
+
* - 引号内支持反斜杠转义
|
|
281
|
+
*
|
|
282
|
+
* 为什么必须处理:不剥注释会让 `url = https://github.com/a/b.git ; note`
|
|
283
|
+
* 解析失败(URL 尾部带注释文本),用户手写的 config 里这很常见。
|
|
284
|
+
*/
|
|
285
|
+
function unquoteValue(raw) {
|
|
286
|
+
const v = raw.trim();
|
|
287
|
+
if (!v)
|
|
288
|
+
return '';
|
|
289
|
+
// 引号形态:取引号内内容(支持 \" 转义),忽略闭合引号之后的一切
|
|
290
|
+
const q = v[0];
|
|
291
|
+
if (q === '"' || q === "'") {
|
|
292
|
+
let out = '';
|
|
293
|
+
for (let i = 1; i < v.length; i++) {
|
|
294
|
+
const ch = v[i];
|
|
295
|
+
if (ch === '\\' && i + 1 < v.length) {
|
|
296
|
+
out += v[i + 1];
|
|
297
|
+
i++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (ch === q)
|
|
301
|
+
return out.trim();
|
|
302
|
+
out += ch;
|
|
303
|
+
}
|
|
304
|
+
// 引号未闭合:按未加引号处理(畸形配置,尽力而非报错)
|
|
305
|
+
return out.trim();
|
|
306
|
+
}
|
|
307
|
+
// 未加引号:首个 ; 或 # 起为注释
|
|
308
|
+
const cut = v.search(/[;#]/);
|
|
309
|
+
return (cut >= 0 ? v.slice(0, cut) : v).trim();
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 解析 `.git/config` 与 `HEAD`,产出与 git 语义一致的远程视图。
|
|
313
|
+
*
|
|
314
|
+
* 为什么把 HEAD 一起解析:`branch.<name>.remote` 的 `<name>` 是**当前分支**,
|
|
315
|
+
* 不知道当前分支就无法按 git 的规则选出 remote,只能退化成「取第一个」——
|
|
316
|
+
* 那正是本次要修的错误。
|
|
317
|
+
*
|
|
318
|
+
* @param configText `.git/config` 全文
|
|
319
|
+
* @param headText HEAD 文件全文。worktree 场景必须传**局部 gitdir** 下的那个,
|
|
320
|
+
* 传共享目录的会拿到主工作树分支名(实测两者不同)。
|
|
321
|
+
*/
|
|
322
|
+
export function parseGitConfig(configText, headText) {
|
|
323
|
+
const remotes = [];
|
|
324
|
+
const allUrls = [];
|
|
325
|
+
const branchRemoteByName = new Map();
|
|
326
|
+
if (typeof configText === 'string' && configText) {
|
|
327
|
+
// 当前所处 section:'remote' / 'branch' / null(其他段)
|
|
328
|
+
let section = null;
|
|
329
|
+
let sectionName = '';
|
|
330
|
+
for (const rawLine of configText.split(/\r?\n/)) {
|
|
331
|
+
const line = rawLine.trim();
|
|
332
|
+
if (!line || line.startsWith('#') || line.startsWith(';'))
|
|
333
|
+
continue;
|
|
334
|
+
if (line.startsWith('[')) {
|
|
335
|
+
// `[remote "origin"]` / `[remote.origin]` 两种段头都认。
|
|
336
|
+
// 前瞻断言排除 `[remote-tracking]` 这类同前缀但语义无关的段
|
|
337
|
+
// (`\b` 做不到——`-` 是非单词字符,会被判为词边界)。
|
|
338
|
+
const quoted = /^\[\s*(remote|branch)\s+"([^"]*)"\s*\]/i.exec(line);
|
|
339
|
+
const dotted = /^\[\s*(remote|branch)\.([^\s\]]+)\s*\]/i.exec(line);
|
|
340
|
+
const m = quoted || dotted;
|
|
341
|
+
if (m) {
|
|
342
|
+
section = m[1].toLowerCase();
|
|
343
|
+
sectionName = m[2];
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
section = null;
|
|
347
|
+
sectionName = '';
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (!section)
|
|
352
|
+
continue;
|
|
353
|
+
if (section === 'remote') {
|
|
354
|
+
const mu = /^url\s*=\s*(.+)$/i.exec(line);
|
|
355
|
+
if (mu) {
|
|
356
|
+
const url = unquoteValue(mu[1]);
|
|
357
|
+
if (url) {
|
|
358
|
+
// 指纹收集全部 url 行(含同 remote 的第二行)
|
|
359
|
+
if (!allUrls.includes(url))
|
|
360
|
+
allUrls.push(url);
|
|
361
|
+
// 身份映射只保留首个:git 对同名 remote 的多 url 也只认第一个
|
|
362
|
+
if (!remotes.some((r) => r.name === sectionName)) {
|
|
363
|
+
remotes.push({ name: sectionName, url });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
// upstream 段写在前面时也要能读到——这里不做任何顺序假设
|
|
369
|
+
}
|
|
370
|
+
else if (section === 'branch') {
|
|
371
|
+
const mr = /^remote\s*=\s*(.+)$/i.exec(line);
|
|
372
|
+
if (mr) {
|
|
373
|
+
const value = unquoteValue(mr[1]);
|
|
374
|
+
if (value)
|
|
375
|
+
branchRemoteByName.set(sectionName, value);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// HEAD 形态:`ref: refs/heads/main` → main;40 位 sha(detached)→ null
|
|
381
|
+
let currentBranch = null;
|
|
382
|
+
if (typeof headText === 'string' && headText) {
|
|
383
|
+
const mh = /^\s*ref:\s*refs\/heads\/(.+?)\s*$/m.exec(headText);
|
|
384
|
+
if (mh)
|
|
385
|
+
currentBranch = mh[1];
|
|
386
|
+
}
|
|
387
|
+
const branchRemote = currentBranch ? branchRemoteByName.get(currentBranch) ?? null : null;
|
|
388
|
+
return { remotes, allUrls, currentBranch, branchRemote };
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* 按 git 的规则选出「这个工作区关联的 remote」。
|
|
392
|
+
*
|
|
393
|
+
* 优先级与 git 一致:
|
|
394
|
+
* 1. `branch.<当前分支>.remote` —— 当前分支显式跟踪的 remote
|
|
395
|
+
* 2. `origin` —— git 的默认远程
|
|
396
|
+
* 3. 第一个 remote —— 兜底(保持既有行为)
|
|
397
|
+
*
|
|
398
|
+
* 特殊值 `.` 表示跟踪**本地分支**而非远程(实测可设置),跳过第 1 级——
|
|
399
|
+
* 拿 "." 去查 `[remote "."]` 必然查不到,且语义上它根本不是远程。
|
|
400
|
+
*/
|
|
401
|
+
export function resolveRemoteTarget(cfg) {
|
|
402
|
+
const remotes = Array.isArray(cfg?.remotes) ? cfg.remotes : [];
|
|
403
|
+
if (remotes.length === 0)
|
|
404
|
+
return null;
|
|
405
|
+
const branchRemote = cfg?.branchRemote;
|
|
406
|
+
if (branchRemote && branchRemote !== '.') {
|
|
407
|
+
const hit = remotes.find((r) => r.name === branchRemote);
|
|
408
|
+
if (hit)
|
|
409
|
+
return hit;
|
|
410
|
+
}
|
|
411
|
+
const origin = remotes.find((r) => r.name === 'origin');
|
|
412
|
+
if (origin)
|
|
413
|
+
return origin;
|
|
414
|
+
return remotes[0];
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* 从 remote url 中提取 GitHub 的 owner/repo。
|
|
418
|
+
*
|
|
419
|
+
* 覆盖形态(均实测于真实 clone / hand-written config):
|
|
420
|
+
* https://github.com/o/r.git
|
|
421
|
+
* git@github.com:o/r.git
|
|
422
|
+
* ssh://git@github.com/o/r.git
|
|
423
|
+
* https://user:token@github.com/o/r.git
|
|
424
|
+
* 带尾斜杠 / 不带 .git
|
|
425
|
+
*
|
|
426
|
+
* 非 github.com 的 host 一律返回 null(GHE 不在支持范围)。
|
|
427
|
+
* 为什么绝不"猜一个":错误身份比没有身份更有害——面板会显示一个
|
|
428
|
+
* 用户根本不认识的仓库,且所有后续判断都建立在错误前提上。
|
|
429
|
+
* 正则两端锚定,杜绝 `github.com.evil.com` 这类子串误匹配。
|
|
430
|
+
*/
|
|
431
|
+
export function parseGithubUrl(url) {
|
|
432
|
+
if (typeof url !== 'string' || !url)
|
|
433
|
+
return null;
|
|
434
|
+
const m = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/\s]+@)?github\.com[:/]+([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
435
|
+
if (!m)
|
|
436
|
+
return null;
|
|
437
|
+
const owner = m[1];
|
|
438
|
+
const repo = m[2];
|
|
439
|
+
// 排除 `.` / `..` 这类目录穿越形态被当成仓库名
|
|
440
|
+
if (!owner || !repo || repo === '.' || repo === '..')
|
|
441
|
+
return null;
|
|
442
|
+
return { owner, repo };
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* 从 remote url 中提取 host(小写)。
|
|
446
|
+
*
|
|
447
|
+
* 覆盖:https://host/o/r、git@host:o/r、ssh://git@host:2222/o/r、带 token 的 https。
|
|
448
|
+
* 无法解析返回 null。
|
|
449
|
+
*
|
|
450
|
+
* 为什么不直接复用 parseGithubUrl:本函数要处理**任意 host**(GHE / GitLab),
|
|
451
|
+
* 用于判断「这个 remote 是不是 GitHub」,而不是「这个 remote 属于哪个 GitHub 仓库」。
|
|
452
|
+
*/
|
|
453
|
+
export function parseRemoteHost(url) {
|
|
454
|
+
if (typeof url !== 'string' || !url)
|
|
455
|
+
return null;
|
|
456
|
+
const raw = url.trim();
|
|
457
|
+
// 本地路径形态不是"远程 host",必须显式排除:
|
|
458
|
+
// file:///c/Users/me/repo → 否则会解析出 host "file"
|
|
459
|
+
// C:\local\repo → 否则会解析出 host "c"(盘符)
|
|
460
|
+
// 这两类的误判虽不影响 classifyRepoLayout(它只比对 'github.com'),
|
|
461
|
+
// 但违反本函数「无法解析返回 null」的契约,会误导后续调用方。
|
|
462
|
+
if (/^file:/i.test(raw))
|
|
463
|
+
return null;
|
|
464
|
+
if (/^[a-zA-Z]:[\\/]/.test(raw))
|
|
465
|
+
return null;
|
|
466
|
+
const m = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/\s]+@)?([^/:\s]+)[:/]/i.exec(raw);
|
|
467
|
+
if (!m)
|
|
468
|
+
return null;
|
|
469
|
+
const host = m[1].toLowerCase();
|
|
470
|
+
// 单字母多半是盘符残留;含空格不可能出现在 host 位置
|
|
471
|
+
if (host.length < 2 || host === '.' || host.includes(' '))
|
|
472
|
+
return null;
|
|
473
|
+
return host;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* 判断工作区处于哪种远程布局。
|
|
477
|
+
*
|
|
478
|
+
* 为什么需要分类而不是顺序盲扫:S2 的兜底逻辑按 `.git/config` 的书写顺序
|
|
479
|
+
* 挑第一个能解析出 GitHub 身份的 remote,而用户执行 `git remote rename` /
|
|
480
|
+
* `git remote add` 的先后会改变这个顺序——同一个工作区在两次配置操作后
|
|
481
|
+
* 可能解析出不同仓库。分类后每种布局的处置规则固定,与顺序无关。
|
|
482
|
+
*
|
|
483
|
+
* 分类只看 host 与 git 选中的 remote,不看 URL 的仓库名——后者是身份层的事。
|
|
484
|
+
*/
|
|
485
|
+
export function classifyRepoLayout(cfg) {
|
|
486
|
+
const remotes = Array.isArray(cfg?.remotes) ? cfg.remotes : [];
|
|
487
|
+
if (remotes.length === 0)
|
|
488
|
+
return 'no-remote';
|
|
489
|
+
const isGithub = (u) => parseRemoteHost(u) === 'github.com';
|
|
490
|
+
const githubRemotes = remotes.filter((r) => isGithub(r.url));
|
|
491
|
+
if (githubRemotes.length === 0)
|
|
492
|
+
return 'no-github';
|
|
493
|
+
if (githubRemotes.length === remotes.length)
|
|
494
|
+
return 'github-only';
|
|
495
|
+
return 'github-mixed';
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* 在混合布局中确定性地选出一个 GitHub remote。
|
|
499
|
+
*
|
|
500
|
+
* 规则(与书写顺序无关):
|
|
501
|
+
* 1. 名字为 `origin` 的优先
|
|
502
|
+
* 2. 否则按 remote 名字字典序最小的
|
|
503
|
+
*
|
|
504
|
+
* 为什么用字典序而不是「第一个出现的」:后者让同一个工作区的解析结果
|
|
505
|
+
* 依赖用户执行 git remote 命令的先后,属于不可复现的行为。
|
|
506
|
+
* 字典序是可复现、可断言的确定性规则。
|
|
507
|
+
*/
|
|
508
|
+
export function pickGithubRemote(cfg) {
|
|
509
|
+
const remotes = Array.isArray(cfg?.remotes) ? cfg.remotes : [];
|
|
510
|
+
const github = remotes.filter((r) => parseRemoteHost(r.url) === 'github.com');
|
|
511
|
+
if (github.length === 0)
|
|
512
|
+
return null;
|
|
513
|
+
const origin = github.find((r) => r.name === 'origin');
|
|
514
|
+
if (origin)
|
|
515
|
+
return origin;
|
|
516
|
+
return [...github].sort((a, b) => a.name.localeCompare(b.name))[0];
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* 解码 git 的 C 风格路径转义(`core.quotePath=true` 时的输出形态)。
|
|
520
|
+
*
|
|
521
|
+
* 为什么必须解码:git 的 `core.quotePath` **默认就是 true**,此时非 ASCII 路径
|
|
522
|
+
* (中文/日文/emoji)会被输出成 `"\344\270\255\346\226\207.txt"` 这种八进制转义。
|
|
523
|
+
* 而 `gh pr view --json files` 返回的是**真实 UTF-8 路径**——两者不一致会导致
|
|
524
|
+
* 点击文件时按真实路径查不到 diff,界面显示空白,看起来像"这个文件没改动"。
|
|
525
|
+
*
|
|
526
|
+
* 只在后端运行(git-parser 仅被 executor 引用),可安全使用 Buffer 做字节重组。
|
|
527
|
+
*/
|
|
528
|
+
function unquoteGitPath(raw) {
|
|
529
|
+
const quoted = raw.trim();
|
|
530
|
+
if (!(quoted.startsWith('"') && quoted.endsWith('"')) || quoted.length < 2)
|
|
531
|
+
return quoted;
|
|
532
|
+
const inner = quoted.slice(1, -1);
|
|
533
|
+
const bytes = [];
|
|
534
|
+
const simpleEscapes = {
|
|
535
|
+
n: 10, t: 9, r: 13, '"': 34, '\\': 92, a: 7, b: 8, f: 12, v: 11,
|
|
536
|
+
};
|
|
537
|
+
for (let i = 0; i < inner.length; i++) {
|
|
538
|
+
const ch = inner[i];
|
|
539
|
+
if (ch === '\\' && i + 1 < inner.length) {
|
|
540
|
+
const next = inner[i + 1];
|
|
541
|
+
// 八进制转义:\NNN(git 对非 ASCII 字节统一用 3 位八进制)
|
|
542
|
+
if (next >= '0' && next <= '7') {
|
|
543
|
+
const oct = inner.slice(i + 1, i + 4);
|
|
544
|
+
if (/^[0-7]{3}$/.test(oct)) {
|
|
545
|
+
bytes.push(parseInt(oct, 8));
|
|
546
|
+
i += 3;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (next in simpleEscapes) {
|
|
551
|
+
bytes.push(simpleEscapes[next]);
|
|
552
|
+
i += 1;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
bytes.push(ch.charCodeAt(0));
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
// 普通字符:按其 UTF-8 字节序列入栈(保证与八进制字节混排后可整体还原)
|
|
559
|
+
for (const b of Buffer.from(ch, 'utf8'))
|
|
560
|
+
bytes.push(b);
|
|
561
|
+
}
|
|
562
|
+
return Buffer.from(bytes).toString('utf8');
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* 从 `diff --git <src> <dst>` 行中解析出源路径与目标路径。
|
|
566
|
+
*
|
|
567
|
+
* 两种形态都要支持:
|
|
568
|
+
* 1. 未加引号:`diff --git a/src/app.ts b/src/app.ts`
|
|
569
|
+
* 2. 加引号:`diff --git "a/\344\270\255.txt" "b/\344\270\255.txt"`
|
|
570
|
+
* —— core.quotePath=true(git 默认值)下的非 ASCII 路径形态,需转义还原。
|
|
571
|
+
*
|
|
572
|
+
* 未加引号形态的分割点判定为什么不能用「第一个」或「最后一个 ` b/`」:
|
|
573
|
+
* 路径本身就可能含 ` b/` 子串(如 `diff --git a/x b/y.ts b/x b/y.ts`)。
|
|
574
|
+
* 非贪婪 → 在第一个 ` b/` 截断,得到 src=`x` / dst=`y.ts b/x b/y.ts`(错)
|
|
575
|
+
* 贪婪 → 在最后一个 ` b/` 截断,得到 src=`x b/y.ts b/x` / dst=`y.ts`(也错)
|
|
576
|
+
* 正确做法:**枚举所有 ` b/` 分割点,优先选让两侧路径相等的那个**——
|
|
577
|
+
* 因为除重命名外 git 的 src 与 dst 本就同路径。重命名场景退化为取首个分割点。
|
|
578
|
+
*/
|
|
579
|
+
function parseDiffGitHeader(line) {
|
|
580
|
+
const body = line.replace(/^diff --git\s+/, '').trim();
|
|
581
|
+
if (!body)
|
|
582
|
+
return null;
|
|
583
|
+
// 形态 2:两侧均为带引号字符串
|
|
584
|
+
if (body.startsWith('"')) {
|
|
585
|
+
const m = /^("(?:[^"\\]|\\.)*")\s+("(?:[^"\\]|\\.)*")$/.exec(body);
|
|
586
|
+
if (!m)
|
|
587
|
+
return null;
|
|
588
|
+
return { src: unquoteGitPath(m[1]), dst: unquoteGitPath(m[2]) };
|
|
589
|
+
}
|
|
590
|
+
// 形态 1:逐分割点尝试,优先两侧等值
|
|
591
|
+
if (!body.startsWith('a/'))
|
|
592
|
+
return null;
|
|
593
|
+
const rest = body.slice(2);
|
|
594
|
+
const positions = [];
|
|
595
|
+
let idx = rest.indexOf(' b/');
|
|
596
|
+
while (idx >= 0) {
|
|
597
|
+
positions.push(idx);
|
|
598
|
+
idx = rest.indexOf(' b/', idx + 1);
|
|
599
|
+
}
|
|
600
|
+
if (positions.length === 0)
|
|
601
|
+
return null;
|
|
602
|
+
for (const p of positions) {
|
|
603
|
+
const srcPart = rest.slice(0, p);
|
|
604
|
+
const dstPart = rest.slice(p + 1); // 跳过空格,保留 "b/..." 前缀
|
|
605
|
+
if (dstPart.startsWith('b/') && dstPart.slice(2) === srcPart) {
|
|
606
|
+
return { src: `a/${srcPart}`, dst: dstPart };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// 重命名等两侧路径确实不同的场景:取首个分割点
|
|
610
|
+
const p = positions[0];
|
|
611
|
+
return { src: `a/${rest.slice(0, p)}`, dst: rest.slice(p + 1) };
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* 将整段 Git Unified Diff 纯文本按变动文件切分为字典映射。
|
|
615
|
+
*
|
|
616
|
+
* 为什么需要按文件切片:PR 可能会修改数十个文件,前端需要支持「点击某个文件即刻查看该文件改动」。
|
|
617
|
+
* 在内存做切片索引后,切换文件无需发起网络请求(0ms 响应),体验极致流畅。
|
|
618
|
+
*
|
|
619
|
+
* 键必须与 `gh pr view --json files` 返回的 `files[].path` **逐字一致**,
|
|
620
|
+
* 否则点击文件查不到 diff。因此需要处理 core.quotePath 的八进制转义。
|
|
621
|
+
*
|
|
622
|
+
* @param rawDiff gh pr diff 或 git diff 输出的原始文本
|
|
623
|
+
* @returns 映射字典:{ [filePath: string]: string (该文件的完整 diff 文本段) }
|
|
624
|
+
*/
|
|
625
|
+
export function splitUnifiedDiff(rawDiff) {
|
|
626
|
+
const fileDiffs = {};
|
|
627
|
+
if (typeof rawDiff !== 'string' || !rawDiff)
|
|
628
|
+
return fileDiffs;
|
|
629
|
+
// 使用正向前瞻匹配每一个 `diff --git ` 开头的分界线
|
|
630
|
+
const chunks = rawDiff.split(/(?=^diff --git )/m);
|
|
631
|
+
for (const chunk of chunks) {
|
|
632
|
+
const trimmed = chunk.trim();
|
|
633
|
+
if (!trimmed.startsWith('diff --git'))
|
|
634
|
+
continue;
|
|
635
|
+
const firstLine = trimmed.split(/\r?\n/, 1)[0] || '';
|
|
636
|
+
const parsed = parseDiffGitHeader(firstLine);
|
|
637
|
+
if (!parsed)
|
|
638
|
+
continue;
|
|
639
|
+
const { src, dst } = parsed;
|
|
640
|
+
// 统一剥掉 a/ 与 b/ 前缀,得到仓库内的相对路径
|
|
641
|
+
const srcPath = src.replace(/^[ab]\//, '');
|
|
642
|
+
const dstPath = dst.replace(/^[ab]\//, '');
|
|
643
|
+
// 删除文件时目标侧是 /dev/null,必须回退用源路径作为键,
|
|
644
|
+
// 否则该文件的 diff 会被挂到一个不存在的 "/dev/null" 键上,永远查不到。
|
|
645
|
+
const isDeletion = dstPath === 'dev/null' || dstPath === '/dev/null';
|
|
646
|
+
const targetPath = isDeletion ? srcPath : dstPath;
|
|
647
|
+
if (targetPath) {
|
|
648
|
+
fileDiffs[targetPath] = trimmed;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return fileDiffs;
|
|
652
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR 行级评注写入:payload 构造、校验与 422 降级(纯函数)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:
|
|
5
|
+
* 1. `gh pr review` 没有行级参数(`gh pr review --help` 里 line/side/path 零命中),
|
|
6
|
+
* 只能走 `POST /repos/{owner}/{repo}/pulls/{n}/reviews` 的 `comments[]`;
|
|
7
|
+
* 2. 「哪些错误可以在发请求前拦住」「422 怎么降级」是纯逻辑,必须能脱离网络单测;
|
|
8
|
+
* 3. executor 只负责发请求,校验与降级策略放在这里,避免 executor 继续膨胀。
|
|
9
|
+
*
|
|
10
|
+
* 严格按 REST 文档字段构造请求体,未自造任何键。
|
|
11
|
+
*/
|
|
12
|
+
/** 单次 review 的行级评注上限。超过就拆多次,一次一个请求 = 通知只发一轮 */
|
|
13
|
+
export declare const MAX_REVIEW_COMMENTS = 50;
|
|
14
|
+
export type PrReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
|
|
15
|
+
/** 调用方传入的单条行级评注(宽松类型,校验后才是 REST 形状) */
|
|
16
|
+
export interface PrReviewCommentInput {
|
|
17
|
+
path?: unknown;
|
|
18
|
+
line?: unknown;
|
|
19
|
+
side?: unknown;
|
|
20
|
+
start_line?: unknown;
|
|
21
|
+
start_side?: unknown;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
}
|
|
24
|
+
/** REST 形状的单条评注(全部字段已归一化) */
|
|
25
|
+
export interface PrReviewCommentPayload {
|
|
26
|
+
path: string;
|
|
27
|
+
line?: number;
|
|
28
|
+
side?: 'LEFT' | 'RIGHT';
|
|
29
|
+
start_line?: number;
|
|
30
|
+
start_side?: 'LEFT' | 'RIGHT';
|
|
31
|
+
/**
|
|
32
|
+
* `file` = 文件级评论(不挂行),`line` = 行级评论。
|
|
33
|
+
*
|
|
34
|
+
* 为什么需要它:REST 的 `comments[]` 里 `line` 是**实际必填**的(缺了就 422),
|
|
35
|
+
* 想只评论某个文件必须显式声明 `subject_type: 'file'`。不写这个字段时,
|
|
36
|
+
* 「只给 path 不给 line」会被 GitHub 判为非法行级评论——那正是「明明参数合法却 422」的成因。
|
|
37
|
+
*/
|
|
38
|
+
subject_type?: 'file' | 'line';
|
|
39
|
+
body: string;
|
|
40
|
+
}
|
|
41
|
+
/** REST 形状的 review 请求体 */
|
|
42
|
+
export interface PrReviewPayload {
|
|
43
|
+
commit_id?: string;
|
|
44
|
+
event: PrReviewEvent;
|
|
45
|
+
body?: string;
|
|
46
|
+
comments?: PrReviewCommentPayload[];
|
|
47
|
+
}
|
|
48
|
+
export interface BuildResult {
|
|
49
|
+
ok: boolean;
|
|
50
|
+
payload?: PrReviewPayload;
|
|
51
|
+
/** 校验失败时的**全部**问题(一次说完,不要让调用方改一条试一次) */
|
|
52
|
+
errors?: string[];
|
|
53
|
+
}
|
|
54
|
+
/** `createPrReview` 的返回形状 */
|
|
55
|
+
export interface PrReviewResult {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
error?: string;
|
|
58
|
+
/** 是否走了 422 降级(行级评注被折叠进正文) */
|
|
59
|
+
degraded?: boolean;
|
|
60
|
+
/** 降级原因,供渲染层用 appendDegradedNote 显式呈现 */
|
|
61
|
+
degradedReason?: string;
|
|
62
|
+
data?: any;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 构造 REST review 请求体。
|
|
66
|
+
*
|
|
67
|
+
* @param input.event review 判定,缺省 COMMENT
|
|
68
|
+
* @param input.body 整体说明
|
|
69
|
+
* @param input.comments 行级评注(空数组等价于不传 → 调用方应走原 gh pr review 路径)
|
|
70
|
+
* @param input.commit_id 可选锚点 SHA
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildPrReviewPayload(input: {
|
|
73
|
+
event?: unknown;
|
|
74
|
+
body?: unknown;
|
|
75
|
+
comments?: unknown;
|
|
76
|
+
commit_id?: unknown;
|
|
77
|
+
}): BuildResult;
|
|
78
|
+
/**
|
|
79
|
+
* 422 降级:把行级评注折叠进 review 正文。
|
|
80
|
+
*
|
|
81
|
+
* 为什么必须降级而不是直接失败:422 的典型原因是「该行不在 diff 的 RIGHT 侧」
|
|
82
|
+
* (纯删除行、未变更的上下文行)。内容本身是对的,只是挂不到那一行上——
|
|
83
|
+
* 丢掉它等于让 Agent 白干一轮,而把内容塞进正文至少保证意见送达。
|
|
84
|
+
*
|
|
85
|
+
* 每条降级项都带 `path:line` 前缀,人能在正文里定位;调用方另需回传
|
|
86
|
+
* `degraded: true` + `degradedReason`,让「这是降级结果」显式可见。
|
|
87
|
+
*/
|
|
88
|
+
export declare function foldCommentsIntoBody(comments: PrReviewCommentPayload[], baseBody?: string): string;
|
|
89
|
+
/**
|
|
90
|
+
* 判定失败是否属于「行级评注被拒绝」这一类(值得降级重试)。
|
|
91
|
+
*
|
|
92
|
+
* 判据来自 gh 实测 stderr:REST 422 会以 `HTTP 422` 或 `Validation Failed` 出现;
|
|
93
|
+
* 403/404/网络错误**不在**此列——那些降级也没用,必须原样抛给调用方。
|
|
94
|
+
*/
|
|
95
|
+
export declare function isLineCommentRejected(errorText: string): boolean;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR 行级评注写入:payload 构造、校验与 422 降级(纯函数)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:
|
|
5
|
+
* 1. `gh pr review` 没有行级参数(`gh pr review --help` 里 line/side/path 零命中),
|
|
6
|
+
* 只能走 `POST /repos/{owner}/{repo}/pulls/{n}/reviews` 的 `comments[]`;
|
|
7
|
+
* 2. 「哪些错误可以在发请求前拦住」「422 怎么降级」是纯逻辑,必须能脱离网络单测;
|
|
8
|
+
* 3. executor 只负责发请求,校验与降级策略放在这里,避免 executor 继续膨胀。
|
|
9
|
+
*
|
|
10
|
+
* 严格按 REST 文档字段构造请求体,未自造任何键。
|
|
11
|
+
*/
|
|
12
|
+
import { normalizeSeverity } from '../review-rules.js';
|
|
13
|
+
/** 单次 review 的行级评注上限。超过就拆多次,一次一个请求 = 通知只发一轮 */
|
|
14
|
+
export const MAX_REVIEW_COMMENTS = 50;
|
|
15
|
+
function toPositiveInt(v) {
|
|
16
|
+
const n = typeof v === 'number' ? v : Number(v);
|
|
17
|
+
return Number.isFinite(n) && Number.isInteger(n) && n > 0 ? n : null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 归一化单条评注。
|
|
21
|
+
*
|
|
22
|
+
* 归一化而非报错的项:`side` 非法 → 回落到 `RIGHT`(新代码侧是最常见意图,
|
|
23
|
+
* 且报错会让一次批量 review 因为一个拼写错误整体失败)。
|
|
24
|
+
* 必须报错的项:缺 `path` / 缺 `body` / `line` 给了但不是正整数——
|
|
25
|
+
* 这三类在 REST 侧必然 422,提前拦住比事后降级更诚实。
|
|
26
|
+
*/
|
|
27
|
+
function normalizeComment(raw, index) {
|
|
28
|
+
const errors = [];
|
|
29
|
+
const at = `comments[${index}]`;
|
|
30
|
+
const p = typeof raw?.path === 'string' ? raw.path.trim() : '';
|
|
31
|
+
if (!p)
|
|
32
|
+
errors.push(`${at}.path 必填(文件相对路径)`);
|
|
33
|
+
const body = typeof raw?.body === 'string' ? raw.body.trim() : '';
|
|
34
|
+
if (!body)
|
|
35
|
+
errors.push(`${at}.body 必填(评注正文不能为空)`);
|
|
36
|
+
let line;
|
|
37
|
+
if (raw?.line !== undefined && raw?.line !== null) {
|
|
38
|
+
const n = toPositiveInt(raw.line);
|
|
39
|
+
if (n === null)
|
|
40
|
+
errors.push(`${at}.line 必须是正整数(当前 ${JSON.stringify(raw.line)})`);
|
|
41
|
+
else
|
|
42
|
+
line = n;
|
|
43
|
+
}
|
|
44
|
+
let startLine;
|
|
45
|
+
if (raw?.start_line !== undefined && raw?.start_line !== null) {
|
|
46
|
+
const n = toPositiveInt(raw.start_line);
|
|
47
|
+
if (n === null)
|
|
48
|
+
errors.push(`${at}.start_line 必须是正整数(当前 ${JSON.stringify(raw.start_line)})`);
|
|
49
|
+
else
|
|
50
|
+
startLine = n;
|
|
51
|
+
}
|
|
52
|
+
if (line !== undefined && startLine !== undefined && startLine > line) {
|
|
53
|
+
errors.push(`${at}.start_line(${startLine}) 不能大于 line(${line})`);
|
|
54
|
+
}
|
|
55
|
+
const sideRaw = typeof raw?.side === 'string' ? raw.side.toUpperCase() : '';
|
|
56
|
+
const side = sideRaw === 'LEFT' ? 'LEFT' : 'RIGHT';
|
|
57
|
+
const startSideRaw = typeof raw?.start_side === 'string' ? raw.start_side.toUpperCase() : '';
|
|
58
|
+
const startSide = startLine === undefined ? undefined : startSideRaw === 'LEFT' ? 'LEFT' : 'RIGHT';
|
|
59
|
+
if (errors.length > 0)
|
|
60
|
+
return { ok: false, errors };
|
|
61
|
+
/*
|
|
62
|
+
* 没有 line → 文件级评论(`subject_type: 'file'`)。
|
|
63
|
+
*
|
|
64
|
+
* 为什么必须显式声明而不是留空:REST 的 `comments[]` 里 `line` 实际是必填的,
|
|
65
|
+
* 只给 `path` 会被判为「非法行级评论」直接 422。声明成 file 级之后,
|
|
66
|
+
* 「只想对某个文件留一句」成了合法输入,也不必再走一次注定失败的请求。
|
|
67
|
+
*/
|
|
68
|
+
if (line === undefined) {
|
|
69
|
+
return { ok: true, value: { path: p, subject_type: 'file', body: normalizeSeverity(body) } };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
value: {
|
|
74
|
+
path: p,
|
|
75
|
+
line,
|
|
76
|
+
side,
|
|
77
|
+
...(startLine !== undefined ? { start_line: startLine, start_side: startSide } : {}),
|
|
78
|
+
// 严重度前缀在此统一补齐:只补不删,[P0]/[P1] 原样保留
|
|
79
|
+
body: normalizeSeverity(body),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 构造 REST review 请求体。
|
|
85
|
+
*
|
|
86
|
+
* @param input.event review 判定,缺省 COMMENT
|
|
87
|
+
* @param input.body 整体说明
|
|
88
|
+
* @param input.comments 行级评注(空数组等价于不传 → 调用方应走原 gh pr review 路径)
|
|
89
|
+
* @param input.commit_id 可选锚点 SHA
|
|
90
|
+
*/
|
|
91
|
+
export function buildPrReviewPayload(input) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
const rawEvent = typeof input?.event === 'string' ? input.event.toUpperCase() : '';
|
|
94
|
+
const event = rawEvent === 'APPROVE' || rawEvent === 'REQUEST_CHANGES' || rawEvent === 'COMMENT' ? rawEvent : 'COMMENT';
|
|
95
|
+
if (rawEvent && rawEvent !== event) {
|
|
96
|
+
errors.push(`event 只能是 COMMENT / APPROVE / REQUEST_CHANGES(当前 ${JSON.stringify(input.event)})`);
|
|
97
|
+
}
|
|
98
|
+
const list = Array.isArray(input?.comments) ? input.comments : [];
|
|
99
|
+
if (list.length > MAX_REVIEW_COMMENTS) {
|
|
100
|
+
errors.push(`单次最多 ${MAX_REVIEW_COMMENTS} 条行级评注(当前 ${list.length} 条),请拆分多次提交`);
|
|
101
|
+
}
|
|
102
|
+
const comments = [];
|
|
103
|
+
for (let i = 0; i < list.length; i++) {
|
|
104
|
+
const r = normalizeComment(list[i], i);
|
|
105
|
+
if (r.ok)
|
|
106
|
+
comments.push(r.value);
|
|
107
|
+
else
|
|
108
|
+
errors.push(...r.errors);
|
|
109
|
+
}
|
|
110
|
+
const summary = typeof input?.body === 'string' ? input.body.trim() : '';
|
|
111
|
+
/*
|
|
112
|
+
* 整体说明只在「提意见」时补严重度前缀,APPROVE 不补。
|
|
113
|
+
*
|
|
114
|
+
* 为什么:`[P2] LGTM` 是把优先级标签套在一句同意上,语义上说不通——严重度
|
|
115
|
+
* 是给**问题**定级的,不是给结论定级的。行级评注则一律补(每条都是一条意见)。
|
|
116
|
+
*/
|
|
117
|
+
const bodyText = summary
|
|
118
|
+
? event === 'APPROVE'
|
|
119
|
+
? summary
|
|
120
|
+
: normalizeSeverity(summary)
|
|
121
|
+
: comments.length > 0
|
|
122
|
+
? `[P2] 共 ${comments.length} 条行级评注`
|
|
123
|
+
: '';
|
|
124
|
+
if (!bodyText && event !== 'APPROVE') {
|
|
125
|
+
errors.push('body 必填:REQUEST_CHANGES / COMMENT 需要一段整体说明');
|
|
126
|
+
}
|
|
127
|
+
const commitId = typeof input?.commit_id === 'string' ? input.commit_id.trim() : '';
|
|
128
|
+
if (commitId && !/^[0-9a-f]{7,40}$/i.test(commitId)) {
|
|
129
|
+
errors.push(`commit_id 必须是 7–40 位十六进制 SHA(当前 ${JSON.stringify(input.commit_id)})`);
|
|
130
|
+
}
|
|
131
|
+
if (errors.length > 0)
|
|
132
|
+
return { ok: false, errors };
|
|
133
|
+
const payload = {
|
|
134
|
+
event,
|
|
135
|
+
...(commitId ? { commit_id: commitId } : {}),
|
|
136
|
+
...(bodyText ? { body: bodyText } : {}),
|
|
137
|
+
...(comments.length > 0 ? { comments } : {}),
|
|
138
|
+
};
|
|
139
|
+
return { ok: true, payload };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 422 降级:把行级评注折叠进 review 正文。
|
|
143
|
+
*
|
|
144
|
+
* 为什么必须降级而不是直接失败:422 的典型原因是「该行不在 diff 的 RIGHT 侧」
|
|
145
|
+
* (纯删除行、未变更的上下文行)。内容本身是对的,只是挂不到那一行上——
|
|
146
|
+
* 丢掉它等于让 Agent 白干一轮,而把内容塞进正文至少保证意见送达。
|
|
147
|
+
*
|
|
148
|
+
* 每条降级项都带 `path:line` 前缀,人能在正文里定位;调用方另需回传
|
|
149
|
+
* `degraded: true` + `degradedReason`,让「这是降级结果」显式可见。
|
|
150
|
+
*/
|
|
151
|
+
export function foldCommentsIntoBody(comments, baseBody) {
|
|
152
|
+
const head = typeof baseBody === 'string' ? baseBody.trim() : '';
|
|
153
|
+
const parts = [];
|
|
154
|
+
if (head)
|
|
155
|
+
parts.push(head);
|
|
156
|
+
parts.push('---');
|
|
157
|
+
parts.push('> ⚠️ 以下评注无法挂到具体代码行(该行不在 diff 的可评论侧),已内联到正文。');
|
|
158
|
+
for (const c of comments) {
|
|
159
|
+
const where = c.line ? `${c.path}:${c.line}` : c.path;
|
|
160
|
+
parts.push(`#### \`${where}\`\n\n${c.body}`);
|
|
161
|
+
}
|
|
162
|
+
return parts.join('\n\n');
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* 判定失败是否属于「行级评注被拒绝」这一类(值得降级重试)。
|
|
166
|
+
*
|
|
167
|
+
* 判据来自 gh 实测 stderr:REST 422 会以 `HTTP 422` 或 `Validation Failed` 出现;
|
|
168
|
+
* 403/404/网络错误**不在**此列——那些降级也没用,必须原样抛给调用方。
|
|
169
|
+
*/
|
|
170
|
+
export function isLineCommentRejected(errorText) {
|
|
171
|
+
const t = typeof errorText === 'string' ? errorText : '';
|
|
172
|
+
if (!t)
|
|
173
|
+
return false;
|
|
174
|
+
return /HTTP 422|Validation Failed|Unprocessable Entity/i.test(t);
|
|
175
|
+
}
|