dsh-github-flow 0.5.0 → 0.6.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 +16 -4
- package/README.md +16 -4
- package/docs/UPGRADE-NOTES.md +8 -8
- package/docs/architecture.md +33 -17
- package/docs/images/1.png +0 -0
- package/lib/api/routes-actions.js +139 -8
- package/lib/api/routes-overview.js +147 -29
- package/lib/cache.d.ts +77 -4
- package/lib/cache.js +114 -7
- package/lib/client.d.ts +7 -3
- package/lib/client.js +3309 -314
- package/lib/executor/error-classifier.d.ts +55 -0
- package/lib/executor/error-classifier.js +199 -0
- package/lib/executor/git-parser.d.ts +124 -0
- package/lib/executor/git-parser.js +397 -0
- package/lib/executor.d.ts +122 -4
- package/lib/executor.js +685 -80
- package/lib/tools/help.js +22 -7
- package/lib/tools/image-utils.d.ts +27 -0
- package/lib/tools/image-utils.js +73 -3
- package/lib/types.d.ts +331 -0
- package/lib/ui/help-modal.d.ts +6 -0
- package/lib/ui/help-modal.js +113 -15
- package/lib/ui/issue-modal.d.ts +14 -0
- package/lib/ui/issue-modal.js +366 -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 +516 -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/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 +960 -0
- package/lib/ui/utils.js +13 -1
- package/lib/ui/workspace-panel.js +169 -27
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
package/lib/executor.js
CHANGED
|
@@ -1,16 +1,91 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { findMostRecentWorkspacePath, findWorkspacePathBySession, hasGitDir } from './workspace.js';
|
|
8
|
+
// 图片 URL 提取复用 Agent 工具链的同一份实现:弹窗只展示计数与外链,
|
|
9
|
+
// 但「哪些 URL 算图片」必须与 github_issue(view_images) 完全同源,否则
|
|
10
|
+
// 用户会看到「附件 2 张」却下载不到对应图片。
|
|
11
|
+
import { extractImageUrls } from './tools/image-utils.js';
|
|
7
12
|
// 子模块纯函数解耦导入并对外部 Re-export 保证 100% 向后兼容
|
|
8
13
|
import { parseGhVersion, compareGhVersions } from './executor/version-utils.js';
|
|
9
14
|
import { spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput } from './executor/process-utils.js';
|
|
10
|
-
import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText } from './executor/git-parser.js';
|
|
15
|
+
import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff, } from './executor/git-parser.js';
|
|
11
16
|
import { getPlatformInstallGuide, getGhUpgradeHint } from './executor/platform-guide.js';
|
|
12
|
-
|
|
17
|
+
import { classifyGhError, describeDeterministicError, isRetryableError, permissionRemedy, isPermissionError } from './executor/error-classifier.js';
|
|
18
|
+
export { parseGhVersion, compareGhVersions, spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput, parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff, getPlatformInstallGuide, getGhUpgradeHint, classifyGhError, describeDeterministicError, isRetryableError, permissionRemedy, isPermissionError, };
|
|
13
19
|
const execFileAsync = promisify(execFile);
|
|
20
|
+
/**
|
|
21
|
+
* `gh repo view` 的字段集。
|
|
22
|
+
*
|
|
23
|
+
* 为什么必须集中一处:同一份字段列表此前在 getRepoMetadata 里重复了两遍,
|
|
24
|
+
* 补字段时漏改一处就会出现「在线路径有 isFork、兜底路径没有」的不一致。
|
|
25
|
+
*
|
|
26
|
+
* 字段取舍:
|
|
27
|
+
* - parent 只返回骨架(id/name/owner.login),不含 stargazerCount
|
|
28
|
+
* ——`--json` 不支持嵌套子字段(实测 `Unknown JSON field: "parent.stargazerCount"`)
|
|
29
|
+
* - viewerPermission 是只读仓库的写操作前置拦截依据
|
|
30
|
+
*/
|
|
31
|
+
const REPO_VIEW_FIELDS = 'nameWithOwner,name,owner,defaultBranchRef,description,isPrivate,url,stargazerCount,forkCount,isFork,parent,viewerPermission';
|
|
32
|
+
/**
|
|
33
|
+
* `gh issue view --json` 的完整字段集(gh 2.96.0 实测的合法全集子集)。
|
|
34
|
+
*
|
|
35
|
+
* 为什么集中一处:同一份字段列表要被「常规调用」与「降级重试」两处消费,
|
|
36
|
+
* 散落成字面量必然漂移。字段取舍全部基于实测响应形态:
|
|
37
|
+
* - `closedByPullRequestsReferences` 节点只有 id/number/repository/url,
|
|
38
|
+
* 不含 title/state,UI 因此只渲染编号链接;
|
|
39
|
+
* - `milestone` 形态为 {number,title,description,dueOn},没有 state;
|
|
40
|
+
* - `subIssuesSummary` 为 {completed,percentCompleted,total}。
|
|
41
|
+
*/
|
|
42
|
+
const ISSUE_DETAIL_FULL_FIELDS = 'number,title,state,stateReason,author,createdAt,updatedAt,closedAt,url,body,labels,assignees,milestone,isPinned,issueType,comments,closedByPullRequestsReferences,subIssuesSummary';
|
|
43
|
+
/**
|
|
44
|
+
* Issue 详情的降级字段集。
|
|
45
|
+
*
|
|
46
|
+
* 为什么需要:老版本 gh 遇到未知 JSON 字段会**整体失败**(实测
|
|
47
|
+
* `gh issue view --json bogusField` → `Unknown JSON field: "bogusField"`)。
|
|
48
|
+
* 这里只保留长期稳定、几乎不可能被移除的字段,保证低版本宿主仍能看到
|
|
49
|
+
* 正文与评论时间线——缺失的里程碑/关联 PR 只是少几个装饰位,不致命。
|
|
50
|
+
*/
|
|
51
|
+
const ISSUE_DETAIL_BASE_FIELDS = 'number,title,state,author,createdAt,updatedAt,url,body,labels,assignees,milestone,comments';
|
|
52
|
+
/**
|
|
53
|
+
* 把 `gh repo view --json` 的原始结果映射为 RepoMetadata。
|
|
54
|
+
*
|
|
55
|
+
* 抽出的理由:同一段映射原先在 getRepoMetadata 里写了两遍(上下文探测 + 精确探测),
|
|
56
|
+
* 逐字符相同。补 3 个字段要改两处,必然漂移。
|
|
57
|
+
*
|
|
58
|
+
* @param fallbackNameWithOwner d.nameWithOwner 缺失时的兜底(离线解析结果)
|
|
59
|
+
*/
|
|
60
|
+
function mapRepoMetadata(d, fallbackNameWithOwner) {
|
|
61
|
+
const nameWithOwner = d?.nameWithOwner || fallbackNameWithOwner || '';
|
|
62
|
+
const meta = {
|
|
63
|
+
nameWithOwner,
|
|
64
|
+
name: d?.name || '',
|
|
65
|
+
owner: d?.owner?.login || '',
|
|
66
|
+
defaultBranch: d?.defaultBranchRef?.name || 'main',
|
|
67
|
+
description: d?.description || '',
|
|
68
|
+
isPrivate: Boolean(d?.isPrivate),
|
|
69
|
+
url: d?.url || `https://github.com/${nameWithOwner}`,
|
|
70
|
+
stargazerCount: typeof d?.stargazerCount === 'number' ? d.stargazerCount : 0,
|
|
71
|
+
forkCount: typeof d?.forkCount === 'number' ? d.forkCount : 0,
|
|
72
|
+
};
|
|
73
|
+
if (typeof d?.isFork === 'boolean')
|
|
74
|
+
meta.isFork = d.isFork;
|
|
75
|
+
// parent 的 nameWithOwner 需自行拼接:gh 只给 {name, owner:{login}}
|
|
76
|
+
const parentOwner = d?.parent?.owner?.login;
|
|
77
|
+
const parentName = d?.parent?.name;
|
|
78
|
+
if (typeof d?.isFork === 'boolean' && d.isFork && typeof parentOwner === 'string' && typeof parentName === 'string') {
|
|
79
|
+
const parentFull = `${parentOwner}/${parentName}`;
|
|
80
|
+
meta.parent = { nameWithOwner: parentFull, url: `https://github.com/${parentFull}` };
|
|
81
|
+
}
|
|
82
|
+
// 只接受已知档位,未知值一律不带——避免把陌生的 gh 新档位当成可写权限
|
|
83
|
+
const perm = typeof d?.viewerPermission === 'string' ? d.viewerPermission.toUpperCase() : '';
|
|
84
|
+
if (perm === 'ADMIN' || perm === 'MAINTAIN' || perm === 'WRITE' || perm === 'TRIAGE' || perm === 'READ') {
|
|
85
|
+
meta.viewerPermission = perm;
|
|
86
|
+
}
|
|
87
|
+
return meta;
|
|
88
|
+
}
|
|
14
89
|
/** 认证状态缓存时长。用户手动 `gh auth login/logout` 后最多 30 秒内面板才会反映,属可接受代价 */
|
|
15
90
|
const AUTH_CACHE_TTL_MS = 30_000;
|
|
16
91
|
/** gh 版本缓存时长。与 authCache 同哲学:gh 升级是分钟级低频事件,30 秒陈旧窗口无实际影响 */
|
|
@@ -162,7 +237,8 @@ export class GhExecutor {
|
|
|
162
237
|
const errBuf = Buffer.isBuffer(stderr) ? stderr : (stderr ? Buffer.from(String(stderr)) : null);
|
|
163
238
|
if (!buf.length) {
|
|
164
239
|
const errMsg = errBuf ? errBuf.toString('utf8').trim() : '';
|
|
165
|
-
|
|
240
|
+
const msg = errMsg || '二进制输出为空';
|
|
241
|
+
return { ok: false, error: msg, errorKind: classifyGhError(msg) };
|
|
166
242
|
}
|
|
167
243
|
return { ok: true, data: buf, binaryBytes: buf.length, truncated: false };
|
|
168
244
|
}
|
|
@@ -172,7 +248,7 @@ export class GhExecutor {
|
|
|
172
248
|
if (!rawOutput && errText) {
|
|
173
249
|
rawOutput = errText.trim();
|
|
174
250
|
}
|
|
175
|
-
const outcome = truncateOutput(rawOutput, this.maxOutputChars, this.maxOutputBytes);
|
|
251
|
+
const outcome = truncateOutput(rawOutput, options.maxOutputChars ?? this.maxOutputChars, options.maxOutputBytes ?? this.maxOutputBytes);
|
|
176
252
|
rawOutput = outcome.text;
|
|
177
253
|
const truncated = outcome.truncated;
|
|
178
254
|
// 如果未被截断且命令带有 --json 或包含 api,尝试解析为 JSON
|
|
@@ -214,10 +290,24 @@ export class GhExecutor {
|
|
|
214
290
|
rawOutput = truncateOutput(outText.trim(), this.maxOutputChars, this.maxOutputBytes).text;
|
|
215
291
|
}
|
|
216
292
|
}
|
|
293
|
+
// 权限不足时的处置指引。
|
|
294
|
+
//
|
|
295
|
+
// 为什么在 executor 层拼进 error,而不是让 97 个工具调用点各自 `throw new Error(res.error + hint)`:
|
|
296
|
+
// 全仓有 97 处 `throw new Error(res.error)`,逐一改必然遗漏;而模型只能看到 error
|
|
297
|
+
// 字符串,不拼进去等于永远看不到指引。在唯一的事实源处拼一次,所有调用点自动获益。
|
|
298
|
+
//
|
|
299
|
+
// permissionRemedy 字段仍然独立保留:程序化消费方(未来的 UI、诊断面板)
|
|
300
|
+
// 可以只取指引而不用从 error 里再解析一遍。
|
|
301
|
+
const remedy = permissionRemedy(friendlyError, args.join(' '));
|
|
302
|
+
const finalError = remedy ? `${friendlyError}\n\n${remedy}` : friendlyError;
|
|
217
303
|
return {
|
|
218
304
|
ok: false,
|
|
219
|
-
error:
|
|
305
|
+
error: finalError,
|
|
220
306
|
rawOutput,
|
|
307
|
+
// 失败语义分类:上层(概览路由 / UI 重试器)据此决定是重试、回退快照
|
|
308
|
+
// 还是直接把「仓库关了 Issues」降级成空列表。分类只有这一处事实源。
|
|
309
|
+
errorKind: classifyGhError(friendlyError),
|
|
310
|
+
...(remedy ? { permissionRemedy: remedy } : {}),
|
|
221
311
|
};
|
|
222
312
|
}
|
|
223
313
|
}
|
|
@@ -699,6 +789,383 @@ export class GhExecutor {
|
|
|
699
789
|
return { ok: false, error: msg };
|
|
700
790
|
}
|
|
701
791
|
}
|
|
792
|
+
/**
|
|
793
|
+
* 推导当前工作区可供用户切换的全部关联 GitHub 仓库列表。
|
|
794
|
+
*
|
|
795
|
+
* 数据来源(纯本地+拓扑合成,零新增网络开销):
|
|
796
|
+
* 1. 扫描本地 .git/config 的所有 GitHub remote(如 origin, upstream 等);
|
|
797
|
+
* 2. 若当前仓库为 Fork 且存在 parent(上游),合并上游仓库(支持本地未配 upstream 的情形);
|
|
798
|
+
* 3. 标识当前基准 (isCurrent) 及角色 (fork, upstream, origin, parent)。
|
|
799
|
+
*/
|
|
800
|
+
getAssociatedRepos(cwd, currentRepo, currentUser) {
|
|
801
|
+
const items = [];
|
|
802
|
+
const seen = new Set();
|
|
803
|
+
const cfg = this.readParsedGitConfig(cwd);
|
|
804
|
+
if (cfg && Array.isArray(cfg.remotes)) {
|
|
805
|
+
for (const r of cfg.remotes) {
|
|
806
|
+
const gh = parseGithubUrl(r.url);
|
|
807
|
+
if (!gh)
|
|
808
|
+
continue;
|
|
809
|
+
const full = `${gh.owner}/${gh.repo}`;
|
|
810
|
+
const fullKey = full.toLowerCase();
|
|
811
|
+
if (seen.has(fullKey))
|
|
812
|
+
continue;
|
|
813
|
+
seen.add(fullKey);
|
|
814
|
+
const isCurrent = Boolean(currentRepo && currentRepo.nameWithOwner && currentRepo.nameWithOwner.toLowerCase() === fullKey);
|
|
815
|
+
let role = 'remote';
|
|
816
|
+
if (r.name === 'upstream') {
|
|
817
|
+
role = 'upstream';
|
|
818
|
+
}
|
|
819
|
+
else if (currentUser && gh.owner.toLowerCase() === currentUser.toLowerCase()) {
|
|
820
|
+
role = 'fork';
|
|
821
|
+
}
|
|
822
|
+
else if (r.name === 'origin') {
|
|
823
|
+
role = 'origin';
|
|
824
|
+
}
|
|
825
|
+
items.push({
|
|
826
|
+
nameWithOwner: full,
|
|
827
|
+
remoteName: r.name,
|
|
828
|
+
url: `https://github.com/${full}`,
|
|
829
|
+
isCurrent,
|
|
830
|
+
role,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// 若当前是在线识别出的 Fork 仓库且存在 parent,但本地尚未配置 upstream remote
|
|
835
|
+
if (currentRepo?.isFork && currentRepo.parent?.nameWithOwner) {
|
|
836
|
+
const parentFull = currentRepo.parent.nameWithOwner;
|
|
837
|
+
const parentKey = parentFull.toLowerCase();
|
|
838
|
+
if (!seen.has(parentKey)) {
|
|
839
|
+
seen.add(parentKey);
|
|
840
|
+
const isCurrent = Boolean(currentRepo.nameWithOwner && currentRepo.nameWithOwner.toLowerCase() === parentKey);
|
|
841
|
+
items.push({
|
|
842
|
+
nameWithOwner: parentFull,
|
|
843
|
+
url: currentRepo.parent.url || `https://github.com/${parentFull}`,
|
|
844
|
+
isCurrent,
|
|
845
|
+
role: 'parent',
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return items;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* 设置当前工作区的默认基准 GitHub 仓库(调用官方 `gh repo set-default` 并在本地 git config 持久化)。
|
|
853
|
+
*
|
|
854
|
+
* 为什么调用 gh repo set-default:
|
|
855
|
+
* 1. 它会在 .git/config 写入 `remote.<name>.gh-resolved = base`,这是官方的持久化事实源;
|
|
856
|
+
* 2. 执行后,不仅本插件的 Web 驾驶舱,外部系统终端中运行的 gh pr, gh issue, gh run 也将 100% 对齐;
|
|
857
|
+
* 3. 严格做参数格式校验,仅允许合法的 owner/repo 或合法 remote 别名,杜绝命令注入。
|
|
858
|
+
*/
|
|
859
|
+
async setDefaultRepo(cwd, targetRepoOrRemote) {
|
|
860
|
+
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
861
|
+
if (!hasGitDir(targetCwd)) {
|
|
862
|
+
return { ok: false, error: '当前工作区不是有效的 Git 仓库' };
|
|
863
|
+
}
|
|
864
|
+
const clean = (targetRepoOrRemote || '').trim();
|
|
865
|
+
// 安全白名单校验:只允许字母、数字、点号、短横线、下划线及至多一个斜杠(owner/repo 或 remoteName)
|
|
866
|
+
if (!clean || clean.startsWith('-') || clean.includes('..') || clean.includes('\\') || !/^[a-zA-Z0-9_.-]+(\/[a-zA-Z0-9_.-]+)?$/.test(clean)) {
|
|
867
|
+
return { ok: false, error: '非法目标仓库参数,仅允许 owner/repo 或有效 remote 别名' };
|
|
868
|
+
}
|
|
869
|
+
const r = await this.run(['repo', 'set-default', clean], {
|
|
870
|
+
cwd: targetCwd,
|
|
871
|
+
timeoutMs: 10_000,
|
|
872
|
+
});
|
|
873
|
+
if (!r.ok) {
|
|
874
|
+
return { ok: false, error: r.error || '设置默认基准仓库失败' };
|
|
875
|
+
}
|
|
876
|
+
return { ok: true, target: clean };
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* 获取 Pull Request 完整详情与变更 Diff 数据(用于 PR 在线审阅与代码比对)。
|
|
880
|
+
*
|
|
881
|
+
* 架构设计:
|
|
882
|
+
* 1. 严格参数白名单校验(PR 编号必须为正整数,repo 必须为合法 owner/repo 格式);
|
|
883
|
+
* 2. 并行拉取元数据 (gh pr view) 与 Unified Diff (gh pr diff);
|
|
884
|
+
* 3. 在内存通过 splitUnifiedDiff 对 Diff 原文按修改文件切片索引,
|
|
885
|
+
* 前端点击任一文件即可 0ms 呈现代码行级增删对比,绝无二次网络往返。
|
|
886
|
+
*/
|
|
887
|
+
async getPrDetail(cwd, prNumber, repo) {
|
|
888
|
+
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
889
|
+
const num = Number(prNumber);
|
|
890
|
+
if (!num || isNaN(num) || num <= 0) {
|
|
891
|
+
return { ok: false, error: '缺少有效 PR 编号(必须为正整数)' };
|
|
892
|
+
}
|
|
893
|
+
const cleanRepo = typeof repo === 'string' ? repo.trim() : '';
|
|
894
|
+
if (cleanRepo && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(cleanRepo)) {
|
|
895
|
+
return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
|
|
896
|
+
}
|
|
897
|
+
const viewArgs = [
|
|
898
|
+
'pr',
|
|
899
|
+
'view',
|
|
900
|
+
String(num),
|
|
901
|
+
'--json',
|
|
902
|
+
'number,title,state,isDraft,url,createdAt,updatedAt,author,baseRefName,headRefName,body,reviewDecision,statusCheckRollup,comments,reviews,files,additions,deletions',
|
|
903
|
+
];
|
|
904
|
+
if (cleanRepo)
|
|
905
|
+
viewArgs.push('-R', cleanRepo);
|
|
906
|
+
const diffArgs = ['pr', 'diff', String(num)];
|
|
907
|
+
if (cleanRepo)
|
|
908
|
+
diffArgs.push('-R', cleanRepo);
|
|
909
|
+
/**
|
|
910
|
+
* 行级审查评论的抓取通道。
|
|
911
|
+
*
|
|
912
|
+
* 为什么单独走 API:`gh pr view --json comments` 只返回对话区(issue-level),
|
|
913
|
+
* 挂在代码行上的 review comment 一条都拿不到。实测 cli/cli#14752 有 10 条
|
|
914
|
+
* 行级评论而 `--json comments` 为零。
|
|
915
|
+
*/
|
|
916
|
+
const fetchLineComments = async (fullName) => {
|
|
917
|
+
if (!fullName)
|
|
918
|
+
return [];
|
|
919
|
+
const r = await this.run([`api`, `repos/${fullName}/pulls/${num}/comments?per_page=100`], { cwd: targetCwd, timeoutMs: 12_000 });
|
|
920
|
+
if (!r.ok || !Array.isArray(r.data))
|
|
921
|
+
return [];
|
|
922
|
+
return r.data
|
|
923
|
+
.filter((c) => c && typeof c.path === 'string')
|
|
924
|
+
.map((c) => ({
|
|
925
|
+
id: String(c.id ?? ''),
|
|
926
|
+
path: String(c.path),
|
|
927
|
+
line: typeof c.line === 'number' ? c.line : (typeof c.original_line === 'number' ? c.original_line : null),
|
|
928
|
+
side: c.side ? String(c.side) : undefined,
|
|
929
|
+
author: { login: String(c.user?.login || 'unknown') },
|
|
930
|
+
body: String(c.body || ''),
|
|
931
|
+
createdAt: String(c.created_at || ''),
|
|
932
|
+
}));
|
|
933
|
+
};
|
|
934
|
+
// 本地可推导出仓库全名时就三路并发(UI 通常已传入 repo),
|
|
935
|
+
// 推导不出则先用空结果占位,待 view 返回后从权威 url 补拉。
|
|
936
|
+
const repoGuess = cleanRepo || (this.getRepoIdentityFromLocal(targetCwd)?.nameWithOwner || '');
|
|
937
|
+
const [rView, rDiff, guessedLineComments] = await Promise.all([
|
|
938
|
+
this.run(viewArgs, { cwd: targetCwd, timeoutMs: 15_000 }),
|
|
939
|
+
// PR diff 走程序化消费通道(按文件切片,一次只渲染一个文件),
|
|
940
|
+
// 不进入模型上下文,因此显式放宽 24KB 默认阈值——实测普通 PR 的
|
|
941
|
+
// diff 已达 39KB,沿用默认会被静默截断,导致后半数文件点开皆空。
|
|
942
|
+
this.run(diffArgs, {
|
|
943
|
+
cwd: targetCwd,
|
|
944
|
+
timeoutMs: 25_000,
|
|
945
|
+
rawText: true,
|
|
946
|
+
maxOutputChars: 2_000_000,
|
|
947
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
948
|
+
}),
|
|
949
|
+
// 第三路:行级审查评论。本地能推导仓库名时与上面两路并发,不增加串行等待。
|
|
950
|
+
repoGuess ? fetchLineComments(repoGuess) : Promise.resolve([]),
|
|
951
|
+
]);
|
|
952
|
+
if (!rView.ok || !rView.data) {
|
|
953
|
+
return { ok: false, error: rView.error || `获取 PR #${num} 详情失败` };
|
|
954
|
+
}
|
|
955
|
+
const raw = rView.data;
|
|
956
|
+
// 本地推不出仓库名(无 repo 参数且非 Git 目录)时,用 view 返回的权威 URL
|
|
957
|
+
// 补拉一次行级评论。这是少见路径,多一次往返换来评论不丢,值得。
|
|
958
|
+
let lineComments = guessedLineComments;
|
|
959
|
+
if (!repoGuess) {
|
|
960
|
+
const m = /github\.com\/([^/]+)\/([^/]+)\/pull\//i.exec(String(raw.url || ''));
|
|
961
|
+
if (m)
|
|
962
|
+
lineComments = await fetchLineComments(`${m[1]}/${m[2]}`);
|
|
963
|
+
}
|
|
964
|
+
const fullDiff = rDiff.ok ? (typeof rDiff.data === 'string' ? rDiff.data : rDiff.rawOutput || '') : '';
|
|
965
|
+
const fileDiffs = splitUnifiedDiff(fullDiff);
|
|
966
|
+
const prDetail = {
|
|
967
|
+
number: Number(raw.number) || num,
|
|
968
|
+
title: String(raw.title || ''),
|
|
969
|
+
state: (raw.state || 'OPEN').toUpperCase(),
|
|
970
|
+
isDraft: Boolean(raw.isDraft),
|
|
971
|
+
url: String(raw.url || ''),
|
|
972
|
+
createdAt: String(raw.createdAt || ''),
|
|
973
|
+
updatedAt: String(raw.updatedAt || ''),
|
|
974
|
+
author: {
|
|
975
|
+
login: String(raw.author?.login || 'ghost'),
|
|
976
|
+
avatarUrl: raw.author?.avatarUrl ? String(raw.author.avatarUrl) : undefined,
|
|
977
|
+
},
|
|
978
|
+
headRefName: String(raw.headRefName || ''),
|
|
979
|
+
baseRefName: String(raw.baseRefName || ''),
|
|
980
|
+
headRepoOwner: raw.headRepositoryOwner?.login ? String(raw.headRepositoryOwner.login) : undefined,
|
|
981
|
+
body: String(raw.body || ''),
|
|
982
|
+
reviewDecision: raw.reviewDecision ? String(raw.reviewDecision) : undefined,
|
|
983
|
+
statusCheckRollup: raw.statusCheckRollup,
|
|
984
|
+
additions: Number(raw.additions) || 0,
|
|
985
|
+
deletions: Number(raw.deletions) || 0,
|
|
986
|
+
files: Array.isArray(raw.files)
|
|
987
|
+
? raw.files.map((f) => ({
|
|
988
|
+
path: String(f.path || ''),
|
|
989
|
+
additions: Number(f.additions) || 0,
|
|
990
|
+
deletions: Number(f.deletions) || 0,
|
|
991
|
+
changeType: String(f.changeType || 'MODIFIED'),
|
|
992
|
+
}))
|
|
993
|
+
: [],
|
|
994
|
+
comments: Array.isArray(raw.comments)
|
|
995
|
+
? raw.comments.map((c) => ({
|
|
996
|
+
id: String(c.id || ''),
|
|
997
|
+
author: {
|
|
998
|
+
login: String(c.author?.login || 'unknown'),
|
|
999
|
+
avatarUrl: c.author?.avatarUrl ? String(c.author.avatarUrl) : undefined,
|
|
1000
|
+
},
|
|
1001
|
+
authorAssociation: c.authorAssociation ? String(c.authorAssociation) : undefined,
|
|
1002
|
+
body: String(c.body || ''),
|
|
1003
|
+
createdAt: String(c.createdAt || ''),
|
|
1004
|
+
url: String(c.url || ''),
|
|
1005
|
+
}))
|
|
1006
|
+
: [],
|
|
1007
|
+
reviews: Array.isArray(raw.reviews)
|
|
1008
|
+
? raw.reviews.map((rv) => ({
|
|
1009
|
+
id: String(rv.id || ''),
|
|
1010
|
+
author: { login: String(rv.author?.login || 'unknown') },
|
|
1011
|
+
state: String(rv.state || ''),
|
|
1012
|
+
body: String(rv.body || ''),
|
|
1013
|
+
submittedAt: String(rv.submittedAt || ''),
|
|
1014
|
+
}))
|
|
1015
|
+
: [],
|
|
1016
|
+
fileDiffs,
|
|
1017
|
+
lineComments,
|
|
1018
|
+
...(rDiff.truncated ? { diffTruncated: true } : {}),
|
|
1019
|
+
};
|
|
1020
|
+
return { ok: true, data: prDetail };
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* 获取 Issue 完整详情(供 IssueDetailModal 在线查看消费)。
|
|
1024
|
+
*
|
|
1025
|
+
* 架构设计:
|
|
1026
|
+
* 1. 严格参数白名单(编号必须为正整数,repo 必须为 owner/repo 形态)——与
|
|
1027
|
+
* getPrDetail 同一套规则,杜绝任何形式的命令/路径注入;
|
|
1028
|
+
* 2. 单次 `gh issue view --json` 往返即可拿到正文、评论时间线、里程碑与关联 PR,
|
|
1029
|
+
* 不拆成多路并发(Issue 没有 diff 这类重载荷,多开进程反而增加失败面);
|
|
1030
|
+
* 3. 老版本 gh 遇未知字段会整体失败,因此保留 BASE 字段集降级链。
|
|
1031
|
+
*/
|
|
1032
|
+
async getIssueDetail(cwd, issueNumber, repo) {
|
|
1033
|
+
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
1034
|
+
const num = Number(issueNumber);
|
|
1035
|
+
if (!num || isNaN(num) || num <= 0 || !Number.isInteger(num)) {
|
|
1036
|
+
return { ok: false, error: '缺少有效 Issue 编号(必须为正整数)' };
|
|
1037
|
+
}
|
|
1038
|
+
const cleanRepo = typeof repo === 'string' ? repo.trim() : '';
|
|
1039
|
+
if (cleanRepo && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(cleanRepo)) {
|
|
1040
|
+
return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
|
|
1041
|
+
}
|
|
1042
|
+
const buildArgs = (fields) => {
|
|
1043
|
+
const args = ['issue', 'view', String(num), '--json', fields];
|
|
1044
|
+
if (cleanRepo)
|
|
1045
|
+
args.push('-R', cleanRepo);
|
|
1046
|
+
return args;
|
|
1047
|
+
};
|
|
1048
|
+
/**
|
|
1049
|
+
* 输出阈值必须显式放宽。
|
|
1050
|
+
*
|
|
1051
|
+
* 为什么:executor 默认 maxOutputChars=24000,实测含 149 条评论的 Issue
|
|
1052
|
+
* JSON 达 117KB;一旦被截断,run() 会跳过 JSON.parse,data 恒为 undefined,
|
|
1053
|
+
* 于是弹窗只能显示「元数据全空 + 零评论」的假详情——比报错更糟。
|
|
1054
|
+
* 放宽到 2M/8MB 与 getPrDetail 的 diff 通道同口径。
|
|
1055
|
+
*/
|
|
1056
|
+
const execOpts = {
|
|
1057
|
+
cwd: targetCwd,
|
|
1058
|
+
timeoutMs: 20_000,
|
|
1059
|
+
maxOutputChars: 2_000_000,
|
|
1060
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
1061
|
+
};
|
|
1062
|
+
let fieldsDegraded = false;
|
|
1063
|
+
let r = await this.run(buildArgs(ISSUE_DETAIL_FULL_FIELDS), execOpts);
|
|
1064
|
+
// 老版本 gh 会以 `Unknown JSON field: "closedAt"` 整体拒绝:
|
|
1065
|
+
// 这不是网络问题,也不该让整个功能不可用——退回长期稳定字段集重试一次。
|
|
1066
|
+
if (!r.ok && /Unknown JSON field/i.test(String(r.error || ''))) {
|
|
1067
|
+
fieldsDegraded = true;
|
|
1068
|
+
r = await this.run(buildArgs(ISSUE_DETAIL_BASE_FIELDS), execOpts);
|
|
1069
|
+
}
|
|
1070
|
+
if (!r.ok) {
|
|
1071
|
+
return { ok: false, error: r.error || `获取 Issue #${num} 详情失败` };
|
|
1072
|
+
}
|
|
1073
|
+
// 截断 / 解析失败都必须显式失败:静默返回空评论会被误读成「没有讨论」。
|
|
1074
|
+
if (r.truncated || !r.data || typeof r.data !== 'object') {
|
|
1075
|
+
return {
|
|
1076
|
+
ok: false,
|
|
1077
|
+
error: `Issue #${num} 的讨论内容过长,已超出插件单次读取上限。请在浏览器中查看完整讨论(${cleanRepo ? `https://github.com/${cleanRepo}/issues/${num}` : '仓库 Issues 页面'})。`,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
const raw = r.data;
|
|
1081
|
+
const comments = Array.isArray(raw.comments)
|
|
1082
|
+
? raw.comments.map((c) => ({
|
|
1083
|
+
id: String(c.id || ''),
|
|
1084
|
+
author: {
|
|
1085
|
+
login: String(c.author?.login || 'ghost'),
|
|
1086
|
+
avatarUrl: c.author?.avatarUrl ? String(c.author.avatarUrl) : undefined,
|
|
1087
|
+
},
|
|
1088
|
+
authorAssociation: c.authorAssociation ? String(c.authorAssociation) : undefined,
|
|
1089
|
+
body: String(c.body || ''),
|
|
1090
|
+
createdAt: String(c.createdAt || ''),
|
|
1091
|
+
url: String(c.url || ''),
|
|
1092
|
+
...(c.isMinimized === true ? { isMinimized: true } : {}),
|
|
1093
|
+
...(c.minimizedReason ? { minimizedReason: String(c.minimizedReason) } : {}),
|
|
1094
|
+
...(c.includesCreatedEdit === true ? { includesCreatedEdit: true } : {}),
|
|
1095
|
+
}))
|
|
1096
|
+
: [];
|
|
1097
|
+
const body = String(raw.body || '');
|
|
1098
|
+
// 图片清单:正文 + 全部评论合并去重,与 github_issue(view_images) 同源
|
|
1099
|
+
const imageSources = [body];
|
|
1100
|
+
for (const c of comments)
|
|
1101
|
+
imageSources.push(c.body);
|
|
1102
|
+
const images = Array.from(new Set(imageSources.flatMap((b) => extractImageUrls(b))));
|
|
1103
|
+
const linkedPrs = Array.isArray(raw.closedByPullRequestsReferences)
|
|
1104
|
+
? raw.closedByPullRequestsReferences
|
|
1105
|
+
.filter((p) => p && Number(p.number) > 0)
|
|
1106
|
+
.map((p) => {
|
|
1107
|
+
const repoName = p.repository?.name && p.repository?.owner?.login
|
|
1108
|
+
? `${p.repository.owner.login}/${p.repository.name}`
|
|
1109
|
+
: undefined;
|
|
1110
|
+
return {
|
|
1111
|
+
number: Number(p.number),
|
|
1112
|
+
url: String(p.url || ''),
|
|
1113
|
+
...(repoName ? { repository: repoName } : {}),
|
|
1114
|
+
};
|
|
1115
|
+
})
|
|
1116
|
+
: [];
|
|
1117
|
+
const detail = {
|
|
1118
|
+
number: Number(raw.number) || num,
|
|
1119
|
+
title: String(raw.title || ''),
|
|
1120
|
+
state: String(raw.state || 'OPEN').toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN',
|
|
1121
|
+
stateReason: raw.stateReason ? String(raw.stateReason) : undefined,
|
|
1122
|
+
url: String(raw.url || ''),
|
|
1123
|
+
createdAt: String(raw.createdAt || ''),
|
|
1124
|
+
updatedAt: String(raw.updatedAt || ''),
|
|
1125
|
+
closedAt: raw.closedAt ? String(raw.closedAt) : undefined,
|
|
1126
|
+
author: {
|
|
1127
|
+
login: String(raw.author?.login || 'ghost'),
|
|
1128
|
+
avatarUrl: raw.author?.avatarUrl ? String(raw.author.avatarUrl) : undefined,
|
|
1129
|
+
},
|
|
1130
|
+
body,
|
|
1131
|
+
labels: Array.isArray(raw.labels)
|
|
1132
|
+
? raw.labels.map((l) => ({
|
|
1133
|
+
name: String(l.name || ''),
|
|
1134
|
+
...(l.color ? { color: String(l.color) } : {}),
|
|
1135
|
+
...(l.description ? { description: String(l.description) } : {}),
|
|
1136
|
+
}))
|
|
1137
|
+
: [],
|
|
1138
|
+
assignees: Array.isArray(raw.assignees)
|
|
1139
|
+
? raw.assignees
|
|
1140
|
+
.map((a) => ({ login: String(a.login || '') }))
|
|
1141
|
+
.filter((a) => a.login)
|
|
1142
|
+
: [],
|
|
1143
|
+
...(raw.milestone && raw.milestone.title
|
|
1144
|
+
? {
|
|
1145
|
+
milestone: {
|
|
1146
|
+
...(typeof raw.milestone.number === 'number' ? { number: raw.milestone.number } : {}),
|
|
1147
|
+
title: String(raw.milestone.title),
|
|
1148
|
+
...(raw.milestone.dueOn ? { dueOn: String(raw.milestone.dueOn) } : {}),
|
|
1149
|
+
},
|
|
1150
|
+
}
|
|
1151
|
+
: {}),
|
|
1152
|
+
...(raw.isPinned === true ? { isPinned: true } : {}),
|
|
1153
|
+
...(raw.issueType?.name ? { issueType: String(raw.issueType.name) } : {}),
|
|
1154
|
+
comments,
|
|
1155
|
+
linkedPrs,
|
|
1156
|
+
...(raw.subIssuesSummary && typeof raw.subIssuesSummary.total === 'number'
|
|
1157
|
+
? {
|
|
1158
|
+
subIssues: {
|
|
1159
|
+
completed: Number(raw.subIssuesSummary.completed) || 0,
|
|
1160
|
+
total: Number(raw.subIssuesSummary.total) || 0,
|
|
1161
|
+
},
|
|
1162
|
+
}
|
|
1163
|
+
: {}),
|
|
1164
|
+
images,
|
|
1165
|
+
...(fieldsDegraded ? { fieldsDegraded: true } : {}),
|
|
1166
|
+
};
|
|
1167
|
+
return { ok: true, data: detail };
|
|
1168
|
+
}
|
|
702
1169
|
/** 生成当前操作系统平台的推荐安装引导与命令 */
|
|
703
1170
|
getPlatformInstallGuide() {
|
|
704
1171
|
return getPlatformInstallGuide();
|
|
@@ -828,41 +1295,208 @@ export class GhExecutor {
|
|
|
828
1295
|
};
|
|
829
1296
|
}
|
|
830
1297
|
/**
|
|
831
|
-
*
|
|
1298
|
+
* 从 `refs/remotes/<remote>/HEAD` 符号引用中读取仓库默认分支。
|
|
832
1299
|
*
|
|
833
|
-
*
|
|
834
|
-
* `
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
1300
|
+
* 为什么只认这一个来源(其余全部实测排除):
|
|
1301
|
+
* - `packed-refs` 只存具体 sha,不含 symbolic ref —— 读不到;
|
|
1302
|
+
* - 当前 HEAD 指向的是**用户 checkout 的分支**,不是仓库默认分支,语义错误;
|
|
1303
|
+
* - 猜 main/master —— 无依据,宁可回落固定值也不猜。
|
|
1304
|
+
*
|
|
1305
|
+
* @returns 分支名;文件不存在(未 fetch 过)或内容不是符号引用时返回 null,
|
|
1306
|
+
* 由调用方回落到既有默认值(保持原有行为,不引入新错误)
|
|
838
1307
|
*/
|
|
839
|
-
|
|
1308
|
+
readDefaultBranchFromRef(remoteName, cwd) {
|
|
1309
|
+
try {
|
|
1310
|
+
const info = this.resolveGitDirInfo(cwd);
|
|
1311
|
+
if (!info)
|
|
1312
|
+
return null;
|
|
1313
|
+
// refs 存放在共享目录(worktree 与主仓库共用),已实测
|
|
1314
|
+
const headRefPath = path.join(info.commonDir, 'refs', 'remotes', remoteName, 'HEAD');
|
|
1315
|
+
if (!fs.existsSync(headRefPath))
|
|
1316
|
+
return null;
|
|
1317
|
+
const raw = fs.readFileSync(headRefPath, 'utf8');
|
|
1318
|
+
// 内容形如 `ref: refs/remotes/origin/main`
|
|
1319
|
+
const m = /^\s*ref:\s*refs\/remotes\/[^/]+\/(.+?)\s*$/m.exec(raw);
|
|
1320
|
+
return m ? m[1] : null;
|
|
1321
|
+
}
|
|
1322
|
+
catch {
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* 解析工作区真实的 git 目录的**两个**路径:局部目录与共享仓库目录。
|
|
1328
|
+
*
|
|
1329
|
+
* 为什么必须分开:worktree 的 `HEAD` 在局部目录(`.git/worktrees/<name>/HEAD`),
|
|
1330
|
+
* 而 `config` 在共享目录(`commondir` 指向处)。只解析其中一个必然读错:
|
|
1331
|
+
* - 只读局部 → 找不到 config(remote 配置在共享目录)
|
|
1332
|
+
* - 只读共享 → 拿到**主工作树**的分支名,进而按错误分支查 `branch.*.remote`
|
|
1333
|
+
*
|
|
1334
|
+
* 实测:主工作树 HEAD 是 `ref: refs/heads/master`,worktree 的是 `ref: refs/heads/feat`。
|
|
1335
|
+
*
|
|
1336
|
+
* 常规仓库两者指向同一目录。
|
|
1337
|
+
*
|
|
1338
|
+
* @returns 两个绝对路径;非 Git 目录/指针不可解析时返回 null(绝不抛错)
|
|
1339
|
+
*/
|
|
1340
|
+
resolveGitDirInfo(cwd) {
|
|
840
1341
|
const targetCwd = cwd || this.resolveWorkspaceCwd();
|
|
841
1342
|
try {
|
|
842
|
-
const
|
|
843
|
-
if (!fs.existsSync(
|
|
1343
|
+
const dotGit = path.join(targetCwd, '.git');
|
|
1344
|
+
if (!fs.existsSync(dotGit))
|
|
844
1345
|
return null;
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
|
|
1346
|
+
const st = fs.statSync(dotGit);
|
|
1347
|
+
if (st.isDirectory())
|
|
1348
|
+
return { localDir: dotGit, commonDir: dotGit };
|
|
1349
|
+
if (!st.isFile())
|
|
848
1350
|
return null;
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1351
|
+
// worktree/submodule:.git 是文本文件,内容形如 `gitdir: C:/repo/.git/worktrees/wt`
|
|
1352
|
+
const pointer = fs.readFileSync(dotGit, 'utf8');
|
|
1353
|
+
const m = /^\s*gitdir:\s*(.+?)\s*$/m.exec(pointer);
|
|
1354
|
+
if (!m)
|
|
1355
|
+
return null;
|
|
1356
|
+
const gitDirRaw = m[1].replace(/[\\/]+$/, '');
|
|
1357
|
+
const localDir = path.isAbsolute(gitDirRaw) ? path.normalize(gitDirRaw) : path.resolve(targetCwd, gitDirRaw);
|
|
1358
|
+
if (!fs.existsSync(localDir))
|
|
1359
|
+
return null;
|
|
1360
|
+
// worktree:<localDir>/commondir 记录共享仓库目录(相对 localDir)
|
|
1361
|
+
const commonDirFile = path.join(localDir, 'commondir');
|
|
1362
|
+
if (fs.existsSync(commonDirFile)) {
|
|
1363
|
+
try {
|
|
1364
|
+
const rel = fs.readFileSync(commonDirFile, 'utf8').trim();
|
|
1365
|
+
if (rel) {
|
|
1366
|
+
const common = path.resolve(localDir, rel);
|
|
1367
|
+
if (fs.existsSync(path.join(common, 'config')))
|
|
1368
|
+
return { localDir, commonDir: common };
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
catch {
|
|
1372
|
+
// commondir 读失败 → 退回 localDir(至少还能读到局部配置)
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return { localDir, commonDir: localDir };
|
|
1376
|
+
}
|
|
1377
|
+
catch {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* 解析工作区真实的 git 目录(共享仓库目录)。
|
|
1383
|
+
* 薄封装自 resolveGitDirInfo:读 `config` 的场景(远程指纹)只需要共享目录。
|
|
1384
|
+
*/
|
|
1385
|
+
resolveGitDirPath(cwd) {
|
|
1386
|
+
return this.resolveGitDirInfo(cwd)?.commonDir ?? null;
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* remote 配置指纹:全部 remote url 去重、排序、拼接后取 SHA-256 前 12 位。
|
|
1390
|
+
*
|
|
1391
|
+
* 为什么不用身份解析做缓存失效判定:身份解析要回答「变成了什么」,
|
|
1392
|
+
* 一旦解析器对某种 config 布局有偏差(如双 remote 时取错 remote 段),
|
|
1393
|
+
* 比对就永远失配——缓存再也不命中,每次请求都重拉全部 gh 子进程。
|
|
1394
|
+
* 指纹只回答「变了没有」,不依赖任何语义理解,因此对该类偏差免疫。
|
|
1395
|
+
*
|
|
1396
|
+
* 反例(实测):`git remote rename origin upstream` 后 `git remote add origin <fork>`,
|
|
1397
|
+
* config 里 upstream 段排在 origin 段前,取「第一个 github.com 匹配」会拿到上游,
|
|
1398
|
+
* 而 git 权威解析(branch.<name>.remote → origin)拿到的是 fork。
|
|
1399
|
+
*
|
|
1400
|
+
* @returns 12 位十六进制指纹;仅当「非 Git 目录 / config 不可读」时返回 null
|
|
1401
|
+
* (调用方据此保守判定为「未变更」,绝不因探测失败误清空缓存)。
|
|
1402
|
+
* 注意:**config 可读但没有任何 remote 时返回的是空集合的哈希,不是 null**——
|
|
1403
|
+
* 「仓库没有远程」是一个确定事实,它与「读不到配置」必须区分开:
|
|
1404
|
+
* 否则用户删光 remote 后指纹恒为 null,旧快照再也无法失效。
|
|
1405
|
+
*/
|
|
1406
|
+
getRemoteFingerprint(cwd) {
|
|
1407
|
+
try {
|
|
1408
|
+
const gitDir = this.resolveGitDirPath(cwd);
|
|
1409
|
+
if (!gitDir)
|
|
1410
|
+
return null;
|
|
1411
|
+
const configPath = path.join(gitDir, 'config');
|
|
1412
|
+
if (!fs.existsSync(configPath))
|
|
1413
|
+
return null;
|
|
1414
|
+
const urls = parseRemoteUrls(fs.readFileSync(configPath, 'utf8'));
|
|
1415
|
+
// 排序后拼接:remote 数量、书写顺序、命名方式都不影响指纹——
|
|
1416
|
+
// 只有「url 集合本身」变化才会改变指纹,这正是「配置变没变」的定义。
|
|
1417
|
+
// 空集合同样参与哈希(得到空串的 SHA-256 前缀),语义是「已读、无 remote」,
|
|
1418
|
+
// 与读取失败的 null 是两回事。
|
|
1419
|
+
const normalized = [...urls].sort().join('\n');
|
|
1420
|
+
return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 12);
|
|
1421
|
+
}
|
|
1422
|
+
catch {
|
|
1423
|
+
return null;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* 读取并解析工作区的 git 远程视图(`.git/config` + `HEAD`)。
|
|
1428
|
+
* 内部方法:把「两个路径各读哪个文件」的细节收敛在一处,
|
|
1429
|
+
* 避免各调用点自己拼路径读错 worktree 的文件。
|
|
1430
|
+
*/
|
|
1431
|
+
readParsedGitConfig(cwd) {
|
|
1432
|
+
const info = this.resolveGitDirInfo(cwd);
|
|
1433
|
+
if (!info)
|
|
1434
|
+
return null;
|
|
1435
|
+
try {
|
|
1436
|
+
const configPath = path.join(info.commonDir, 'config');
|
|
1437
|
+
if (!fs.existsSync(configPath))
|
|
1438
|
+
return null;
|
|
1439
|
+
const configText = fs.readFileSync(configPath, 'utf8');
|
|
1440
|
+
// HEAD 必须从 localDir 读:worktree 下 commonDir/HEAD 是主工作树的分支名
|
|
1441
|
+
const headPath = path.join(info.localDir, 'HEAD');
|
|
1442
|
+
const headText = fs.existsSync(headPath) ? fs.readFileSync(headPath, 'utf8') : null;
|
|
1443
|
+
return parseGitConfig(configText, headText);
|
|
861
1444
|
}
|
|
862
1445
|
catch {
|
|
863
1446
|
return null;
|
|
864
1447
|
}
|
|
865
1448
|
}
|
|
1449
|
+
/**
|
|
1450
|
+
* 仅从本地 `.git/config` 解析仓库身份,绝不发起网络请求。
|
|
1451
|
+
*
|
|
1452
|
+
* 为什么需要它:全局驾驶舱要为每个工作区认识别仓库,而 getRepoMetadata 会调用
|
|
1453
|
+
* `gh repo view` 走网络。实测 5 个工作区时该端点冷启动 20.5 秒,其中绝大部分耗在这 5 次
|
|
1454
|
+
* 网络往返上——而有界并发根本救不了它,因为瓶颈是网络延迟而不是进程排队。
|
|
1455
|
+
* 工作区矩阵只需要「这个目录属于哪个仓库」这一身份信息,本地 .git/config 已经完整具备,
|
|
1456
|
+
* 描述/star 数等富字段是当前仓库详情才需要的。这一步把每个工作区从 1 次网络往返降为 0。
|
|
1457
|
+
*
|
|
1458
|
+
* 解析口径与 git 一致(三级回落),不再「取 config 里第一个 github.com 匹配」——
|
|
1459
|
+
* 后者在 `git remote rename origin upstream` + `add origin <fork>` 的布局下会取到上游,
|
|
1460
|
+
* 而 git 认的是 origin(实测)。
|
|
1461
|
+
*/
|
|
1462
|
+
getRepoIdentityFromLocal(cwd) {
|
|
1463
|
+
const cfg = this.readParsedGitConfig(cwd);
|
|
1464
|
+
if (!cfg)
|
|
1465
|
+
return null;
|
|
1466
|
+
const layout = classifyRepoLayout(cfg);
|
|
1467
|
+
/**
|
|
1468
|
+
* 按布局分类选目标 remote —— 与 `.git/config` 书写顺序无关。
|
|
1469
|
+
*
|
|
1470
|
+
* github-only :完全按 git 规则(branch.<当前分支>.remote → origin → 首个)
|
|
1471
|
+
* github-mixed :git 选中的不是 GitHub → 在 GitHub remote 中按
|
|
1472
|
+
* origin 优先 → 名字字典序(pickGithubRemote)
|
|
1473
|
+
* 其余 :无 GitHub remote,返回 null,不猜测
|
|
1474
|
+
*/
|
|
1475
|
+
let target = null;
|
|
1476
|
+
if (layout === 'github-only') {
|
|
1477
|
+
target = resolveRemoteTarget(cfg);
|
|
1478
|
+
}
|
|
1479
|
+
else if (layout === 'github-mixed') {
|
|
1480
|
+
target = pickGithubRemote(cfg);
|
|
1481
|
+
}
|
|
1482
|
+
if (!target)
|
|
1483
|
+
return null;
|
|
1484
|
+
const gh = parseGithubUrl(target.url);
|
|
1485
|
+
if (!gh)
|
|
1486
|
+
return null;
|
|
1487
|
+
const nameWithOwner = `${gh.owner}/${gh.repo}`;
|
|
1488
|
+
return {
|
|
1489
|
+
nameWithOwner,
|
|
1490
|
+
name: gh.repo,
|
|
1491
|
+
owner: gh.owner,
|
|
1492
|
+
// 默认分支:从该 remote 的 HEAD 符号引用推断,读不到才回落 'main'
|
|
1493
|
+
defaultBranch: this.readDefaultBranchFromRef(target.name, cwd) || 'main',
|
|
1494
|
+
description: '',
|
|
1495
|
+
isPrivate: false,
|
|
1496
|
+
url: `https://github.com/${nameWithOwner}`,
|
|
1497
|
+
layout,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
866
1500
|
/**
|
|
867
1501
|
* 获取当前目录(工作区)关联的远程仓库元数据
|
|
868
1502
|
* 采用 gh repo view + 本地 git remote 双保险机制,彻底杜绝已配置远程却误判未关联的问题
|
|
@@ -875,61 +1509,32 @@ export class GhExecutor {
|
|
|
875
1509
|
return null;
|
|
876
1510
|
}
|
|
877
1511
|
// 1. 优先尝试由 gh 直接根据当前上下文识别
|
|
878
|
-
const res = await this.run(['repo', 'view', '--json',
|
|
1512
|
+
const res = await this.run(['repo', 'view', '--json', REPO_VIEW_FIELDS], { cwd: targetCwd, timeoutMs: 10_000 });
|
|
879
1513
|
if (res.ok && res.data && res.data.nameWithOwner) {
|
|
880
|
-
|
|
881
|
-
return {
|
|
882
|
-
nameWithOwner: d.nameWithOwner || '',
|
|
883
|
-
name: d.name || '',
|
|
884
|
-
owner: d.owner?.login || '',
|
|
885
|
-
defaultBranch: d.defaultBranchRef?.name || 'main',
|
|
886
|
-
description: d.description || '',
|
|
887
|
-
isPrivate: Boolean(d.isPrivate),
|
|
888
|
-
url: d.url || `https://github.com/${d.nameWithOwner}`,
|
|
889
|
-
stargazerCount: typeof d.stargazerCount === 'number' ? d.stargazerCount : 0,
|
|
890
|
-
forkCount: typeof d.forkCount === 'number' ? d.forkCount : 0,
|
|
891
|
-
};
|
|
1514
|
+
return mapRepoMetadata(res.data);
|
|
892
1515
|
}
|
|
893
|
-
// 2.
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
return {
|
|
908
|
-
nameWithOwner: d.nameWithOwner || nameWithOwner,
|
|
909
|
-
name: d.name || repoName,
|
|
910
|
-
owner: d.owner?.login || owner,
|
|
911
|
-
defaultBranch: d.defaultBranchRef?.name || 'main',
|
|
912
|
-
description: d.description || '',
|
|
913
|
-
isPrivate: Boolean(d.isPrivate),
|
|
914
|
-
url: d.url || `https://github.com/${nameWithOwner}`,
|
|
915
|
-
stargazerCount: typeof d.stargazerCount === 'number' ? d.stargazerCount : 0,
|
|
916
|
-
forkCount: typeof d.forkCount === 'number' ? d.forkCount : 0,
|
|
917
|
-
};
|
|
918
|
-
}
|
|
919
|
-
// 若离线或网络受限,由本地 git remote 配置兜底返回
|
|
920
|
-
return {
|
|
921
|
-
nameWithOwner,
|
|
922
|
-
name: repoName,
|
|
923
|
-
owner,
|
|
924
|
-
defaultBranch: 'main',
|
|
925
|
-
description: '已成功关联本地 Git 远程仓库',
|
|
926
|
-
isPrivate: false,
|
|
927
|
-
url: `https://github.com/${nameWithOwner}`,
|
|
928
|
-
};
|
|
929
|
-
}
|
|
1516
|
+
// 2. 原生文件解析:走与 getRepoIdentityFromLocal 完全相同的解析器
|
|
1517
|
+
// (0 子进程开销、免疫沙箱 EPERM)。
|
|
1518
|
+
// 为什么不再自己写一份正则:两处各维护一份必然漂移,且旧正则
|
|
1519
|
+
// 「取第一个 github.com 匹配」在多 remote 布局下会取到上游仓库。
|
|
1520
|
+
const local = this.getRepoIdentityFromLocal(targetCwd);
|
|
1521
|
+
if (local) {
|
|
1522
|
+
const { nameWithOwner } = local;
|
|
1523
|
+
// 使用精确的 owner/repo 向 gh 获取元数据(显式传参,不依赖无上下文的探测)
|
|
1524
|
+
const directRes = await this.run(['repo', 'view', nameWithOwner, '--json', REPO_VIEW_FIELDS], {
|
|
1525
|
+
cwd: targetCwd,
|
|
1526
|
+
timeoutMs: 10_000,
|
|
1527
|
+
});
|
|
1528
|
+
if (directRes.ok && directRes.data && directRes.data.nameWithOwner) {
|
|
1529
|
+
return mapRepoMetadata(directRes.data, nameWithOwner);
|
|
930
1530
|
}
|
|
1531
|
+
// 若离线或网络受限,由本地 git remote 配置兜底返回。
|
|
1532
|
+
// 注意:此处不带 isFork / parent / viewerPermission —— 离线无从得知,绝不猜测。
|
|
1533
|
+
return {
|
|
1534
|
+
...local,
|
|
1535
|
+
description: '已成功关联本地 Git 远程仓库',
|
|
1536
|
+
};
|
|
931
1537
|
}
|
|
932
|
-
catch { }
|
|
933
1538
|
return null;
|
|
934
1539
|
}
|
|
935
1540
|
}
|