dsh-github-flow 0.6.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 +98 -15
- package/README.md +101 -15
- package/docs/UPGRADE-NOTES.md +12 -11
- package/docs/architecture.md +56 -13
- package/lib/api/route-helpers.d.ts +19 -0
- package/lib/api/route-helpers.js +54 -0
- package/lib/api/routes-actions.js +75 -1
- package/lib/api/routes-lifecycle.js +8 -1
- package/lib/api/routes-overview.d.ts +26 -0
- package/lib/api/routes-overview.js +276 -34
- 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 +0 -21
- package/lib/cache.js +0 -37
- package/lib/client.d.ts +2 -1
- package/lib/client.js +1585 -429
- 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/failure-anchors.d.ts +52 -0
- package/lib/executor/failure-anchors.js +116 -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 +156 -3
- package/lib/executor.js +594 -28
- 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 +52 -13
- 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 +183 -1
- package/lib/ui/help-modal.d.ts +1 -1
- package/lib/ui/help-modal.js +20 -10
- package/lib/ui/issue-modal.js +7 -3
- package/lib/ui/pr-modal.js +50 -11
- package/lib/ui/run-modal.d.ts +26 -0
- package/lib/ui/run-modal.js +486 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +370 -44
- package/lib/ui/sync-settings-modal.js +1 -1
- package/lib/ui/workspace-panel.js +78 -23
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +9 -2
package/lib/executor.js
CHANGED
|
@@ -12,8 +12,10 @@ import { extractImageUrls } from './tools/image-utils.js';
|
|
|
12
12
|
// 子模块纯函数解耦导入并对外部 Re-export 保证 100% 向后兼容
|
|
13
13
|
import { parseGhVersion, compareGhVersions } from './executor/version-utils.js';
|
|
14
14
|
import { spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput } from './executor/process-utils.js';
|
|
15
|
+
import { buildPrReviewPayload, foldCommentsIntoBody, isLineCommentRejected } from './executor/pr-review.js';
|
|
15
16
|
import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff, } from './executor/git-parser.js';
|
|
16
17
|
import { getPlatformInstallGuide, getGhUpgradeHint } from './executor/platform-guide.js';
|
|
18
|
+
import { buildPlainFailureExcerpt, interpretRunLogError } from './executor/run-log.js';
|
|
17
19
|
import { classifyGhError, describeDeterministicError, isRetryableError, permissionRemedy, isPermissionError } from './executor/error-classifier.js';
|
|
18
20
|
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, };
|
|
19
21
|
const execFileAsync = promisify(execFile);
|
|
@@ -49,6 +51,35 @@ const ISSUE_DETAIL_FULL_FIELDS = 'number,title,state,stateReason,author,createdA
|
|
|
49
51
|
* 正文与评论时间线——缺失的里程碑/关联 PR 只是少几个装饰位,不致命。
|
|
50
52
|
*/
|
|
51
53
|
const ISSUE_DETAIL_BASE_FIELDS = 'number,title,state,author,createdAt,updatedAt,url,body,labels,assignees,milestone,comments';
|
|
54
|
+
/**
|
|
55
|
+
* `gh run view --json` 的完整字段集(gh 2.96.0 实测合法全集)。
|
|
56
|
+
*
|
|
57
|
+
* `jobs` 的值是一个树:job{ databaseId,name,status,conclusion,startedAt,
|
|
58
|
+
* completedAt,url,steps[] },step{ number,name,status,conclusion,startedAt,
|
|
59
|
+
* completedAt }。**step 没有 url**,所以 UI 只能给到 job 级外链。
|
|
60
|
+
* `attempt` 供「第 N 次重跑」显示;`displayTitle` 在 workflow 名称之外给出
|
|
61
|
+
* 这次运行的业务描述(PR 标题/提交信息)。
|
|
62
|
+
*/
|
|
63
|
+
const RUN_DETAIL_FULL_FIELDS = 'attempt,conclusion,createdAt,databaseId,displayTitle,event,headBranch,headSha,jobs,name,number,startedAt,status,updatedAt,url,workflowDatabaseId,workflowName';
|
|
64
|
+
/**
|
|
65
|
+
* Run 详情的降级字段集(老 gh 遇未知字段会整体失败,与 issue 详情同一处置)。
|
|
66
|
+
*
|
|
67
|
+
* 只保留 jobs 这一棵不可替代的树 + 少量基础元数据:缺了 attempt/displayTitle
|
|
68
|
+
* 只是少两个装饰位,缺了 jobs 这个功能就不存在了。
|
|
69
|
+
*/
|
|
70
|
+
const RUN_DETAIL_BASE_FIELDS = 'databaseId,name,status,conclusion,jobs,url,createdAt';
|
|
71
|
+
/** 单次按需拉日志的 job 上限:矩阵构建下几十个失败 job 不再是「一次点击」能承受的量 */
|
|
72
|
+
const RUN_LOG_MAX_JOBS = 3;
|
|
73
|
+
/** 日志摘要的字符预算:不进程模型上下文,只进浏览器 DOM,但仍要防几万字拖卡渲染 */
|
|
74
|
+
const RUN_LOG_BUDGET_CHARS = 120_000;
|
|
75
|
+
/**
|
|
76
|
+
* 单次日志请求的总时间预算(毫秒)。
|
|
77
|
+
*
|
|
78
|
+
* 为什么不是「每个 job 60s,有 3 个就 180s」:这条链路挂在 HTTP 路由上,宿主
|
|
79
|
+
* WebServer 是整个 GUI 共享的。一次点击把连接占住三分钟,等于把面板卡死。
|
|
80
|
+
* 超预算的 job 会进 errors[](可单独重试),用户能在 90s 内拿到已拉到的部分。
|
|
81
|
+
*/
|
|
82
|
+
const RUN_LOG_TOTAL_BUDGET_MS = 90_000;
|
|
52
83
|
/**
|
|
53
84
|
* 把 `gh repo view --json` 的原始结果映射为 RepoMetadata。
|
|
54
85
|
*
|
|
@@ -96,6 +127,14 @@ export class GhExecutor {
|
|
|
96
127
|
maxOutputChars;
|
|
97
128
|
/** UTF-8 字节口径的硬上限;未配置时按 maxOutputChars × 4 推导(覆盖 CJK 3 字节与 emoji 4 字节) */
|
|
98
129
|
maxOutputBytes;
|
|
130
|
+
/**
|
|
131
|
+
* 累计 gh 子进程调用次数(只增不清零,`run()` 每次 +1)。
|
|
132
|
+
*
|
|
133
|
+
* 为什么需要:任务原语(github_task)要如实汇报 `cost.ghCalls`,
|
|
134
|
+
* 而「原语是否真的比手动串步骤省钱」是这个能力存在的唯一判据。
|
|
135
|
+
* 消费方用「调用前快照 → 调用后求差」的方式取区间计数。
|
|
136
|
+
*/
|
|
137
|
+
ghCalls = 0;
|
|
99
138
|
/**
|
|
100
139
|
* 列表类查询的默认条数上限。
|
|
101
140
|
* 为什么挂在 executor 而不是给每个工具工厂加 config 参数:executor 是全部
|
|
@@ -180,11 +219,51 @@ export class GhExecutor {
|
|
|
180
219
|
return recent;
|
|
181
220
|
return explicitCwd || process.cwd();
|
|
182
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* 18 个工具的唯一工作区入口:把 execContext 归一成 `{ cwd, sessionId }`。
|
|
224
|
+
*
|
|
225
|
+
* 为什么必须走这里而不是各工具写 `execContext?.cwd || process.cwd()`:
|
|
226
|
+
* 三级解析里第 1 级是「显式 cwd 且磁盘存在即原样返回」,而 `process.cwd()`
|
|
227
|
+
* 恒满足该条件——于是第 2 级「按 sessionId 反查真实工作区」**永远不会触发**。
|
|
228
|
+
* 工具层把 `process.cwd()` 当兜底传下去,等于把整个会话亲和机制架空。
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ 刻意**不复用** `resolveWorkspaceCwd` 的第 3 级(`findMostRecentWorkspacePath`):
|
|
231
|
+
* 那一级是给「完全无上下文的 CLI 场景」用的,会把目录定到**最近活跃的任意仓库**。
|
|
232
|
+
* 工具调用的默认目录必须可预测——落到一个与本会话无关的仓库意味着读错库、
|
|
233
|
+
* 更糟的是写到错库(merge / release create / label delete 都会打错地方)。
|
|
234
|
+
* 实测:抹掉 DSH_SESSION_ID 后 `resolveWorkspaceCwd(undefined, undefined)` 返回的是
|
|
235
|
+
* 一个与本会话无关的仓库路径,而 `process.cwd()` 是当前进程目录。
|
|
236
|
+
*
|
|
237
|
+
* 解析顺序:显式 cwd(原样透传,含不存在的情况,与旧行为一致)→ session 反查 →
|
|
238
|
+
* process.cwd()。三级都拿不到时才回到进程目录,与 s5 之前的兜底完全一致。
|
|
239
|
+
*/
|
|
240
|
+
resolveExecScope(execContext) {
|
|
241
|
+
const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
|
|
242
|
+
// 1) 显式 cwd 原样透传:这是唯一事实源,不做存在性替换,也不许被会话反查覆盖
|
|
243
|
+
if (execContext?.cwd)
|
|
244
|
+
return { cwd: execContext.cwd, sessionId };
|
|
245
|
+
// 2) 按会话反查真实工作区(旧实现被 process.cwd() 短路掉的那一级)
|
|
246
|
+
if (sessionId) {
|
|
247
|
+
const fromSession = findWorkspacePathBySession(sessionId);
|
|
248
|
+
if (fromSession)
|
|
249
|
+
return { cwd: fromSession, sessionId };
|
|
250
|
+
}
|
|
251
|
+
// 3) 兜底到进程目录。**不**调用 findMostRecentWorkspacePath()
|
|
252
|
+
return { cwd: process.cwd(), sessionId };
|
|
253
|
+
}
|
|
183
254
|
/**
|
|
184
255
|
* 安全执行 gh 命令
|
|
185
256
|
* 采用 execFile 参数数组隔离,彻底杜绝 Shell 字符串拼接注入漏洞
|
|
186
257
|
*/
|
|
187
258
|
async run(args, options = {}) {
|
|
259
|
+
/*
|
|
260
|
+
* gh 子进程调用计数(只增不清零)。
|
|
261
|
+
*
|
|
262
|
+
* 为什么放在 executor 而不是各工具里手数:任务原语(github_task)必须能如实
|
|
263
|
+
* 汇报 `cost.ghCalls`,而「原语是否真的省钱」是它存在的唯一判据。
|
|
264
|
+
* 手写计数必然与实际调用漂移——真实调用点只有这里一个。
|
|
265
|
+
*/
|
|
266
|
+
this.ghCalls++;
|
|
188
267
|
const cwd = this.resolveWorkspaceCwd(options.cwd, options.sessionId);
|
|
189
268
|
const timeout = options.timeoutMs || this.defaultTimeoutMs;
|
|
190
269
|
// 二进制模式(如 gh api 下载图片字节流):stdout 保持 Buffer,绕过 UTF-8
|
|
@@ -248,7 +327,15 @@ export class GhExecutor {
|
|
|
248
327
|
if (!rawOutput && errText) {
|
|
249
328
|
rawOutput = errText.trim();
|
|
250
329
|
}
|
|
251
|
-
const
|
|
330
|
+
const maxChars = options.maxOutputChars ?? this.maxOutputChars;
|
|
331
|
+
// 字节上限联动推导:若调用方显式放宽了字符上限(如放宽到 1MB),未指定 maxOutputBytes 时
|
|
332
|
+
// 必须按 4 倍自动放宽,绝不能死板回落到全局实例的 96KB(否则 >96KB 的输出会被静默判定截断)。
|
|
333
|
+
// 取 Math.max 是为了不压低用户在 Config 里配置的更大 maxOutputBytes。
|
|
334
|
+
const maxBytes = options.maxOutputBytes ??
|
|
335
|
+
(options.maxOutputChars
|
|
336
|
+
? Math.max(options.maxOutputChars * 4, this.maxOutputBytes)
|
|
337
|
+
: this.maxOutputBytes);
|
|
338
|
+
const outcome = truncateOutput(rawOutput, maxChars, maxBytes);
|
|
252
339
|
rawOutput = outcome.text;
|
|
253
340
|
const truncated = outcome.truncated;
|
|
254
341
|
// 如果未被截断且命令带有 --json 或包含 api,尝试解析为 JSON
|
|
@@ -425,18 +512,20 @@ export class GhExecutor {
|
|
|
425
512
|
* 零网络:status 基于 remote-tracking ref(上次 fetch 的快照),毫秒级本地读取,
|
|
426
513
|
* 符合 global-overview 端点「工作区探测不发网络」的性能红线。
|
|
427
514
|
*/
|
|
428
|
-
async getLocalGitStatus(cwd) {
|
|
515
|
+
async getLocalGitStatus(cwd, timeoutMs = 3_000) {
|
|
429
516
|
try {
|
|
430
|
-
//
|
|
517
|
+
// 默认 3s:概览面板只是"读不到就不渲染这一行"的兜底语义。
|
|
518
|
+
// 但安全闸门(ensureCleanWorkspace)必须显式传更长超时——大型仓库 / 网络盘上
|
|
519
|
+
// `git status` 冷启动超 3s 很常见,用 3s 会把"读不到"误判成"有风险"。
|
|
431
520
|
const { stdout } = await execFileAsync('git', ['status', '-sb', '--porcelain'], {
|
|
432
521
|
cwd: cwd || undefined,
|
|
433
|
-
timeout:
|
|
522
|
+
timeout: timeoutMs,
|
|
434
523
|
windowsHide: true,
|
|
435
524
|
});
|
|
436
525
|
return parseGitStatusOutput(String(stdout), 2000);
|
|
437
526
|
}
|
|
438
527
|
catch {
|
|
439
|
-
// 非 Git 目录 / git 未安装 / 超时:静默降级为 null
|
|
528
|
+
// 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方自行决定语义)
|
|
440
529
|
return null;
|
|
441
530
|
}
|
|
442
531
|
}
|
|
@@ -486,7 +575,9 @@ export class GhExecutor {
|
|
|
486
575
|
}
|
|
487
576
|
try {
|
|
488
577
|
// 1. 先用 porcelain status 获取该文件的状态码
|
|
489
|
-
|
|
578
|
+
// 默认值必须为空:旧实现默认 'M',导致工作区干净的文件被报成「已修改」
|
|
579
|
+
// (UI 表现为"文件被改了,但 diff 是空的")。状态只从 porcelain 结果推导。
|
|
580
|
+
let fileStatus = '';
|
|
490
581
|
try {
|
|
491
582
|
const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', cleanRel], {
|
|
492
583
|
cwd: targetCwd || undefined,
|
|
@@ -582,7 +673,7 @@ export class GhExecutor {
|
|
|
582
673
|
return {
|
|
583
674
|
ok: true,
|
|
584
675
|
filePath: cleanRel,
|
|
585
|
-
status: fileStatus,
|
|
676
|
+
status: fileStatus || 'M',
|
|
586
677
|
isBinary: true,
|
|
587
678
|
diff: 'Binary files differ',
|
|
588
679
|
additions: 0,
|
|
@@ -599,7 +690,8 @@ export class GhExecutor {
|
|
|
599
690
|
return {
|
|
600
691
|
ok: true,
|
|
601
692
|
filePath: cleanRel,
|
|
602
|
-
|
|
693
|
+
// porcelain 没给状态且没有 diff → 该文件其实是干净的,显式标 'clean' 而不是伪造成 'M'
|
|
694
|
+
status: fileStatus || (finalDiff ? 'M' : 'clean'),
|
|
603
695
|
diff: finalDiff || '没有检测到文本差异',
|
|
604
696
|
additions: diffStats.additions,
|
|
605
697
|
deletions: diffStats.deletions,
|
|
@@ -741,12 +833,141 @@ export class GhExecutor {
|
|
|
741
833
|
return null;
|
|
742
834
|
}
|
|
743
835
|
}
|
|
836
|
+
/**
|
|
837
|
+
* 发版输入:最近 tag + 该 tag 以来的提交(供 `github_task(prepare_release)` 使用)。
|
|
838
|
+
*
|
|
839
|
+
* 为什么要在 executor 里开这个口子,而不是让工具自己拼 git 命令:
|
|
840
|
+
* 命令形态必须集中(本文件顶部就是"git 白名单"纪律),否则每个新工具都会
|
|
841
|
+
* 自己发明一套 argv,`phase34` 的契约网也就管不到它们了。
|
|
842
|
+
*
|
|
843
|
+
* 安全性:两条命令的形态**全部固定**——`describe --tags --abbrev=0` 无参数注入;
|
|
844
|
+
* log 的修订范围段只拼 `git describe` 自己的输出,且先过 `/^[A-Za-z0-9._\/-]+$/`
|
|
845
|
+
* 白名单校验,杜绝把任意字符串当 revision 传进 git。
|
|
846
|
+
*/
|
|
847
|
+
async getReleaseInputs(cwd, fromTagOverride) {
|
|
848
|
+
const empty = {
|
|
849
|
+
ok: false,
|
|
850
|
+
fromTag: null,
|
|
851
|
+
commits: [],
|
|
852
|
+
totalCommits: 0,
|
|
853
|
+
};
|
|
854
|
+
const runOpts = { cwd: cwd || undefined, timeout: 5_000, windowsHide: true };
|
|
855
|
+
let fromTag = null;
|
|
856
|
+
/*
|
|
857
|
+
* 显式指定的起始 tag 优先,且**必须真的作为 range 起点**。
|
|
858
|
+
*
|
|
859
|
+
* 为什么强调:只拿它当「版本号基数」而不改 range,会出现「按 v1.0.0 算版本、
|
|
860
|
+
* 但统计的是 v1.2.0 以来的提交」——数字与标签对不上,比直接报错更坏。
|
|
861
|
+
*/
|
|
862
|
+
const override = typeof fromTagOverride === 'string' ? fromTagOverride.trim() : '';
|
|
863
|
+
if (override) {
|
|
864
|
+
/*
|
|
865
|
+
* 两条校验缺一不可:
|
|
866
|
+
* - 字符白名单挡住 `;` `$()` 这类命令注入;
|
|
867
|
+
* - **禁止以 `-` 开头**:它会被拼进 `git log <tag>..HEAD` 的第一个位置,
|
|
868
|
+
* git 会把 `-p..HEAD` 当成未知选项(实测报错)。虽然成不了真正的注入,
|
|
869
|
+
* 但「用户输入永远不许以 - 开头」是这条通道的基本纪律。
|
|
870
|
+
*/
|
|
871
|
+
if (override.startsWith('-') || !/^[A-Za-z0-9._/-]+$/.test(override)) {
|
|
872
|
+
return { ...empty, error: `非法的 from_tag:${override}(只允许字母/数字/./_/-/,且不能以 - 开头)` };
|
|
873
|
+
}
|
|
874
|
+
fromTag = override;
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
try {
|
|
878
|
+
const tagRes = await execFileAsync('git', ['describe', '--tags', '--abbrev=0'], runOpts);
|
|
879
|
+
const raw = String(tagRes.stdout || '').trim();
|
|
880
|
+
// 白名单:describe 的输出来自仓库自身,但仍要挡住 `..`、空白与 shell 元字符
|
|
881
|
+
if (raw && /^[A-Za-z0-9._/-]+$/.test(raw))
|
|
882
|
+
fromTag = raw;
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
// 没有任何 tag 是正常状态(新仓库),不是错误
|
|
886
|
+
fromTag = null;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const range = fromTag ? `${fromTag}..HEAD` : 'HEAD';
|
|
891
|
+
const logRes = await execFileAsync('git',
|
|
892
|
+
// %b 带出提交正文:BREAKING CHANGE 常常只写在正文里,只看标题会漏判 major
|
|
893
|
+
['log', range, '-n', '300', '--pretty=format:%x1e%h%x1f%s%x1f%an%x1f%ad%x1f%b'], runOpts);
|
|
894
|
+
const text = String(logRes.stdout || '');
|
|
895
|
+
const commits = text
|
|
896
|
+
.split('\x1e')
|
|
897
|
+
.map((chunk) => chunk.trim())
|
|
898
|
+
.filter(Boolean)
|
|
899
|
+
.map((chunk) => {
|
|
900
|
+
const parts = chunk.split('\x1f');
|
|
901
|
+
return {
|
|
902
|
+
sha: String(parts[0] || ''),
|
|
903
|
+
subject: String(parts[1] || ''),
|
|
904
|
+
author: String(parts[2] || ''),
|
|
905
|
+
date: String(parts[3] || ''),
|
|
906
|
+
// 正文只取前 500 字符:够判 BREAKING CHANGE,又不至于把整包撑大
|
|
907
|
+
body: String(parts.slice(4).join('\x1f') || '').slice(0, 500),
|
|
908
|
+
};
|
|
909
|
+
});
|
|
910
|
+
return { ok: true, fromTag, commits, totalCommits: commits.length };
|
|
911
|
+
}
|
|
912
|
+
catch (err) {
|
|
913
|
+
return { ...empty, fromTag, error: err?.message || '读取提交记录失败' };
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* 工作区干净度闸门(唯一实现)。
|
|
918
|
+
*
|
|
919
|
+
* 为什么抽成公共方法:会改变工作区分支/内容的入口有三条——界面切分支(switchGitBranch)、
|
|
920
|
+
* `github_pr(checkout)`、`github_issue(develop)`。此前只有第一条有脏检查,后两条直穿,
|
|
921
|
+
* 于是「强阻断」的成文承诺只对界面成立。安全不变量必须只有一处实现。
|
|
922
|
+
*
|
|
923
|
+
* 三态语义(fail-closed):
|
|
924
|
+
* - `null`(git 读不到)→ 拒绝。读不到状态≠干净,放行等于闸门失效。
|
|
925
|
+
* - `dirtyCount > 0` → 拒绝,并带前 5 条样例便于用户判断。
|
|
926
|
+
* - 其余 → 放行。
|
|
927
|
+
*
|
|
928
|
+
* 超时为什么单独给 20s:`getLocalGitStatus` 默认 3s 是给概览面板用的(读不到就不渲染那一行),
|
|
929
|
+
* 但这里是一次安全判决——大型仓库 / 网络盘 / OneDrive 同步目录下 `git status` 冷启动超 3s
|
|
930
|
+
* 很常见,用 3s 会把"慢"误判成"脏/不可用",直接把 checkout 挡死。
|
|
931
|
+
*/
|
|
932
|
+
async ensureCleanWorkspace(cwd) {
|
|
933
|
+
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
934
|
+
// 先区分"根本不是 Git 仓库"与"是仓库但读不到状态"——两者对用户的可操作性完全不同。
|
|
935
|
+
if (!hasGitDir(targetCwd)) {
|
|
936
|
+
return {
|
|
937
|
+
ok: false,
|
|
938
|
+
reason: 'unknown',
|
|
939
|
+
dirtyCount: 0,
|
|
940
|
+
samples: [],
|
|
941
|
+
error: `当前目录不是 Git 仓库(${targetCwd}),无法执行需要切换分支的操作。`,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
const gitSt = await this.getLocalGitStatus(targetCwd, 20_000);
|
|
945
|
+
if (gitSt === null) {
|
|
946
|
+
return {
|
|
947
|
+
ok: false,
|
|
948
|
+
reason: 'unknown',
|
|
949
|
+
dirtyCount: 0,
|
|
950
|
+
samples: [],
|
|
951
|
+
error: '读取工作区 Git 状态失败(git 不可用、超时或权限不足)。为避免覆盖本地改动,已拒绝执行。',
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
if (gitSt.dirtyCount > 0) {
|
|
955
|
+
return {
|
|
956
|
+
ok: false,
|
|
957
|
+
reason: 'dirty',
|
|
958
|
+
dirtyCount: gitSt.dirtyCount,
|
|
959
|
+
samples: (gitSt.dirtyFiles || []).slice(0, 5).map((f) => `${f.status} ${f.path}`),
|
|
960
|
+
error: `工作区有 ${gitSt.dirtyCount} 个未提交的文件变更。为防止代码被覆盖,请先提交或保存后再执行。`,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
return { ok: true, dirtyCount: 0, samples: [] };
|
|
964
|
+
}
|
|
744
965
|
/**
|
|
745
966
|
* 安全切换当前工作区的 Git 分支。
|
|
746
967
|
* 三重安全防御纪律:
|
|
747
968
|
* 1. 目标工作区必须存在有效的 .git 目录;
|
|
748
969
|
* 2. 分支名称严格执行字符白名单校验,防命令行参数注入;
|
|
749
|
-
* 3.
|
|
970
|
+
* 3. 工作区干净度交给 ensureCleanWorkspace(唯一实现,与 checkout / develop 共用)。
|
|
750
971
|
*/
|
|
751
972
|
async switchGitBranch(cwd, branchName) {
|
|
752
973
|
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
@@ -759,12 +980,9 @@ export class GhExecutor {
|
|
|
759
980
|
return { ok: false, error: '非法分支名称,仅允许字母、数字、短横线、下划线与斜杠组合' };
|
|
760
981
|
}
|
|
761
982
|
// 防线二:工作区未提交变更阻断(Dirty File Guard)
|
|
762
|
-
const
|
|
763
|
-
if (
|
|
764
|
-
return {
|
|
765
|
-
ok: false,
|
|
766
|
-
error: `工作区有 ${gitSt.dirtyCount} 个未提交的文件变更。为防止代码被覆盖,请先提交或保存后再切换分支。`,
|
|
767
|
-
};
|
|
983
|
+
const guard = await this.ensureCleanWorkspace(targetCwd);
|
|
984
|
+
if (!guard.ok) {
|
|
985
|
+
return { ok: false, error: guard.error };
|
|
768
986
|
}
|
|
769
987
|
try {
|
|
770
988
|
let args = ['checkout', cleanBranch];
|
|
@@ -890,8 +1108,8 @@ export class GhExecutor {
|
|
|
890
1108
|
if (!num || isNaN(num) || num <= 0) {
|
|
891
1109
|
return { ok: false, error: '缺少有效 PR 编号(必须为正整数)' };
|
|
892
1110
|
}
|
|
893
|
-
const cleanRepo =
|
|
894
|
-
if (cleanRepo
|
|
1111
|
+
const cleanRepo = this.normalizeRepoFullName(repo);
|
|
1112
|
+
if (cleanRepo === null) {
|
|
895
1113
|
return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
|
|
896
1114
|
}
|
|
897
1115
|
const viewArgs = [
|
|
@@ -899,7 +1117,10 @@ export class GhExecutor {
|
|
|
899
1117
|
'view',
|
|
900
1118
|
String(num),
|
|
901
1119
|
'--json',
|
|
902
|
-
|
|
1120
|
+
// headRefOid/baseRefOid:分支会移动,SHA 才是可钉住的审查锚点(s7 §2.1)
|
|
1121
|
+
// changedFiles:`--json files` 在 100 条处静默截断,只有这个数字能判定「清单被截了」
|
|
1122
|
+
// headRepositoryOwner:`prDetail.headRepoOwner` 的取值来源(此前恒 undefined)
|
|
1123
|
+
'number,title,state,isDraft,url,createdAt,updatedAt,author,baseRefName,headRefName,headRefOid,baseRefOid,changedFiles,headRepositoryOwner,body,reviewDecision,statusCheckRollup,comments,reviews,files,additions,deletions',
|
|
903
1124
|
];
|
|
904
1125
|
if (cleanRepo)
|
|
905
1126
|
viewArgs.push('-R', cleanRepo);
|
|
@@ -963,6 +1184,24 @@ export class GhExecutor {
|
|
|
963
1184
|
}
|
|
964
1185
|
const fullDiff = rDiff.ok ? (typeof rDiff.data === 'string' ? rDiff.data : rDiff.rawOutput || '') : '';
|
|
965
1186
|
const fileDiffs = splitUnifiedDiff(fullDiff);
|
|
1187
|
+
const files = Array.isArray(raw.files)
|
|
1188
|
+
? raw.files.map((f) => ({
|
|
1189
|
+
path: String(f.path || ''),
|
|
1190
|
+
additions: Number(f.additions) || 0,
|
|
1191
|
+
deletions: Number(f.deletions) || 0,
|
|
1192
|
+
changeType: String(f.changeType || 'MODIFIED'),
|
|
1193
|
+
}))
|
|
1194
|
+
: [];
|
|
1195
|
+
/*
|
|
1196
|
+
* 三个截断字段各自回答一个不同问题,缺一不可(s7 §2.5):
|
|
1197
|
+
* - changedFiles :仓库里这个 PR 到底改了多少个文件(权威总数)
|
|
1198
|
+
* - filesTruncated :清单被 gh 的 100 条上限裁掉了吗(cli/cli#12522 静默截断)
|
|
1199
|
+
* - diffIncomplete :清单里有、但切片里没有对应 diff 的文件(数据缺失,不是「没改动」)
|
|
1200
|
+
* 把三者混成一个布尔值,界面就只能说「可能不全」,用户无法判断该去做什么。
|
|
1201
|
+
*/
|
|
1202
|
+
const changedFiles = Number(raw.changedFiles) || files.length;
|
|
1203
|
+
const filesTruncated = files.length < changedFiles;
|
|
1204
|
+
const diffIncomplete = files.some((f) => !fileDiffs[f.path]);
|
|
966
1205
|
const prDetail = {
|
|
967
1206
|
number: Number(raw.number) || num,
|
|
968
1207
|
title: String(raw.title || ''),
|
|
@@ -977,20 +1216,18 @@ export class GhExecutor {
|
|
|
977
1216
|
},
|
|
978
1217
|
headRefName: String(raw.headRefName || ''),
|
|
979
1218
|
baseRefName: String(raw.baseRefName || ''),
|
|
1219
|
+
headRefOid: raw.headRefOid ? String(raw.headRefOid) : undefined,
|
|
1220
|
+
baseRefOid: raw.baseRefOid ? String(raw.baseRefOid) : undefined,
|
|
980
1221
|
headRepoOwner: raw.headRepositoryOwner?.login ? String(raw.headRepositoryOwner.login) : undefined,
|
|
981
1222
|
body: String(raw.body || ''),
|
|
982
1223
|
reviewDecision: raw.reviewDecision ? String(raw.reviewDecision) : undefined,
|
|
983
1224
|
statusCheckRollup: raw.statusCheckRollup,
|
|
984
1225
|
additions: Number(raw.additions) || 0,
|
|
985
1226
|
deletions: Number(raw.deletions) || 0,
|
|
986
|
-
files
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
deletions: Number(f.deletions) || 0,
|
|
991
|
-
changeType: String(f.changeType || 'MODIFIED'),
|
|
992
|
-
}))
|
|
993
|
-
: [],
|
|
1227
|
+
files,
|
|
1228
|
+
changedFiles,
|
|
1229
|
+
...(filesTruncated ? { filesTruncated: true } : {}),
|
|
1230
|
+
...(diffIncomplete ? { diffIncomplete: true } : {}),
|
|
994
1231
|
comments: Array.isArray(raw.comments)
|
|
995
1232
|
? raw.comments.map((c) => ({
|
|
996
1233
|
id: String(c.id || ''),
|
|
@@ -1019,6 +1256,92 @@ export class GhExecutor {
|
|
|
1019
1256
|
};
|
|
1020
1257
|
return { ok: true, data: prDetail };
|
|
1021
1258
|
}
|
|
1259
|
+
/**
|
|
1260
|
+
* 提交带行级评注的 PR review(REST 通道)。
|
|
1261
|
+
*
|
|
1262
|
+
* 为什么必须走 REST:`gh pr review` 只有 `--approve/--request-changes/--comment`,
|
|
1263
|
+
* 没有任何行级参数(`gh pr review --help` 里 line/side/path 零命中)。行级评注
|
|
1264
|
+
* 的唯一入口是 `POST /repos/{owner}/{repo}/pulls/{n}/reviews` 的 `comments[]`。
|
|
1265
|
+
*
|
|
1266
|
+
* 为什么一次 review 一个请求(而不是逐条 `POST /pulls/{n}/comments`):
|
|
1267
|
+
* 通知只发一轮,也避开 secondary rate limit 的「创建内容过快」判定。
|
|
1268
|
+
*
|
|
1269
|
+
* 422 降级:行级评注被拒(典型原因:该行不在 diff 的可评论侧,如纯删除行)
|
|
1270
|
+
* 时,把评注折叠进 review 正文重试一次。内容不能丢,但降级必须显式可见。
|
|
1271
|
+
*/
|
|
1272
|
+
async createPrReview(cwd, repo, prNumber, input) {
|
|
1273
|
+
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
1274
|
+
const num = Number(prNumber);
|
|
1275
|
+
if (!num || isNaN(num) || num <= 0) {
|
|
1276
|
+
return { ok: false, error: '缺少有效 PR 编号(必须为正整数)' };
|
|
1277
|
+
}
|
|
1278
|
+
// 仓库名优先用显式参数;缺省时从本地 git remote 推导(与 getPrDetail 同一套规则)
|
|
1279
|
+
const cleanRepo = this.normalizeRepoFullName(repo);
|
|
1280
|
+
if (cleanRepo === null) {
|
|
1281
|
+
return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
|
|
1282
|
+
}
|
|
1283
|
+
const fullName = cleanRepo || this.getRepoIdentityFromLocal(targetCwd)?.nameWithOwner || '';
|
|
1284
|
+
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(fullName)) {
|
|
1285
|
+
return { ok: false, error: '无法确定仓库全名:请传 repo 参数,或在 Git 仓库目录下调用' };
|
|
1286
|
+
}
|
|
1287
|
+
const built = buildPrReviewPayload(input);
|
|
1288
|
+
if (!built.ok || !built.payload) {
|
|
1289
|
+
return { ok: false, error: `评注参数校验未通过:\n- ${(built.errors || ['未知错误']).join('\n- ')}` };
|
|
1290
|
+
}
|
|
1291
|
+
const payload = built.payload;
|
|
1292
|
+
const endpoint = `repos/${fullName}/pulls/${num}/reviews`;
|
|
1293
|
+
const post = (body) => this.run(['api', '--method', 'POST', endpoint, '--input', '-'], {
|
|
1294
|
+
cwd: targetCwd,
|
|
1295
|
+
timeoutMs: 20_000,
|
|
1296
|
+
stdin: JSON.stringify(body),
|
|
1297
|
+
});
|
|
1298
|
+
const first = await post(payload);
|
|
1299
|
+
if (first.ok) {
|
|
1300
|
+
const data = first.data;
|
|
1301
|
+
return {
|
|
1302
|
+
ok: true,
|
|
1303
|
+
data: {
|
|
1304
|
+
id: data?.id,
|
|
1305
|
+
htmlUrl: data?.html_url,
|
|
1306
|
+
state: data?.state,
|
|
1307
|
+
submittedAt: data?.submitted_at,
|
|
1308
|
+
// 逐条回传评论地址,供人复核「到底挂到了哪一行」
|
|
1309
|
+
comments: Array.isArray(data?.comments)
|
|
1310
|
+
? data.comments.map((c) => ({
|
|
1311
|
+
path: String(c?.path || ''),
|
|
1312
|
+
line: typeof c?.line === 'number' ? c.line : undefined,
|
|
1313
|
+
htmlUrl: c?.html_url ? String(c.html_url) : undefined,
|
|
1314
|
+
}))
|
|
1315
|
+
: [],
|
|
1316
|
+
},
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
// 只在「行级评注被拒」时降级;403/404/网络错误原样抛出(降级也没用)
|
|
1320
|
+
const hasComments = Array.isArray(payload.comments) && payload.comments.length > 0;
|
|
1321
|
+
if (hasComments && isLineCommentRejected(first.error || '')) {
|
|
1322
|
+
const folded = foldCommentsIntoBody(payload.comments, payload.body);
|
|
1323
|
+
const retry = await post({ ...payload, body: folded, comments: undefined });
|
|
1324
|
+
// 把 GitHub 的原始拒绝原因带进说明里:否则用户只看到「已折叠进正文」,
|
|
1325
|
+
// 却不知道是「行不在 diff 可评论侧」还是「path 根本不存在」——两者处置完全不同。
|
|
1326
|
+
const why = String(first.error || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
1327
|
+
if (retry.ok) {
|
|
1328
|
+
const data = retry.data;
|
|
1329
|
+
return {
|
|
1330
|
+
ok: true,
|
|
1331
|
+
degraded: true,
|
|
1332
|
+
degradedReason: `行级评注被 GitHub 拒绝(${why}),已折叠进 review 正文提交,内容未丢失。`,
|
|
1333
|
+
data: { id: data?.id, htmlUrl: data?.html_url, state: data?.state, submittedAt: data?.submitted_at, comments: [] },
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
return {
|
|
1337
|
+
ok: false,
|
|
1338
|
+
degraded: true,
|
|
1339
|
+
degradedReason: `行级评注被拒(${why})后降级重试仍失败。`,
|
|
1340
|
+
error: retry.error || first.error,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
return { ok: false, error: first.error };
|
|
1344
|
+
}
|
|
1022
1345
|
/**
|
|
1023
1346
|
* 获取 Issue 完整详情(供 IssueDetailModal 在线查看消费)。
|
|
1024
1347
|
*
|
|
@@ -1035,8 +1358,8 @@ export class GhExecutor {
|
|
|
1035
1358
|
if (!num || isNaN(num) || num <= 0 || !Number.isInteger(num)) {
|
|
1036
1359
|
return { ok: false, error: '缺少有效 Issue 编号(必须为正整数)' };
|
|
1037
1360
|
}
|
|
1038
|
-
const cleanRepo =
|
|
1039
|
-
if (cleanRepo
|
|
1361
|
+
const cleanRepo = this.normalizeRepoFullName(repo);
|
|
1362
|
+
if (cleanRepo === null) {
|
|
1040
1363
|
return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
|
|
1041
1364
|
}
|
|
1042
1365
|
const buildArgs = (fields) => {
|
|
@@ -1166,6 +1489,249 @@ export class GhExecutor {
|
|
|
1166
1489
|
};
|
|
1167
1490
|
return { ok: true, data: detail };
|
|
1168
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* 详情类接口的仓库参数归一化(PR / Issue / Run 共用)。
|
|
1494
|
+
*
|
|
1495
|
+
* 返回 `''` = 未传(由本地 remote 决定);返回 `null` = 非法。
|
|
1496
|
+
*
|
|
1497
|
+
* 为什么要收编三处各自的校验:历史实现各写一份正则,只有 `setDefaultRepo` 额外
|
|
1498
|
+
* 拒绝 `-` 开头与 `..`,其余靠「恒以 `-R <value>` 成对传入」兜底(复核报告 C-13)。
|
|
1499
|
+
* 「成对传入所以安全」是当前调用形状的安全,不是接口的安全——多一处调用方就多
|
|
1500
|
+
* 一次赌。统一到这里后,`-` 开头/`..`/反斜杠在任何详情入口都进不来。
|
|
1501
|
+
*/
|
|
1502
|
+
normalizeRepoFullName(repo) {
|
|
1503
|
+
const clean = typeof repo === 'string' ? repo.trim() : '';
|
|
1504
|
+
if (!clean)
|
|
1505
|
+
return '';
|
|
1506
|
+
if (clean.startsWith('-') || clean.includes('..') || clean.includes('\\'))
|
|
1507
|
+
return null;
|
|
1508
|
+
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(clean))
|
|
1509
|
+
return null;
|
|
1510
|
+
return clean;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* 获取单次 Actions Run 的完整详情(run + jobs + steps)。
|
|
1514
|
+
*
|
|
1515
|
+
* 架构设计:
|
|
1516
|
+
* 1. **run_id 只认纯数字**。它会被当作 `gh run view` 的位置参数拼进 argv,
|
|
1517
|
+
* 不做白名单就等于把「用参数值注入 flag」这条已知缺陷(复核报告 C-01)
|
|
1518
|
+
* 复制到新通道——`--web`、`--log` 都能冒充编号。
|
|
1519
|
+
* 2. 单次往返拿全:`jobs` 里已经包含 steps 树,没有需要并行拉取的第二路。
|
|
1520
|
+
* 3. **日志不在这里拉**。实测单次失败日志 4–40 秒(本机代理下还直接连接重置),
|
|
1521
|
+
* 挂在详情上等于让弹窗首屏去赌网络;日志走 getRunLogExcerpts() 按需拉。
|
|
1522
|
+
*/
|
|
1523
|
+
async getRunDetail(cwd, runId, repo) {
|
|
1524
|
+
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
1525
|
+
const id = String(runId ?? '').trim();
|
|
1526
|
+
if (!/^\d+$/.test(id) || Number(id) <= 0) {
|
|
1527
|
+
return { ok: false, error: '缺少有效 Run ID(必须为正整数)' };
|
|
1528
|
+
}
|
|
1529
|
+
const cleanRepo = this.normalizeRepoFullName(repo);
|
|
1530
|
+
if (cleanRepo === null) {
|
|
1531
|
+
return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
|
|
1532
|
+
}
|
|
1533
|
+
const buildArgs = (fields) => {
|
|
1534
|
+
const args = ['run', 'view', id, '--json', fields];
|
|
1535
|
+
if (cleanRepo)
|
|
1536
|
+
args.push('-R', cleanRepo);
|
|
1537
|
+
return args;
|
|
1538
|
+
};
|
|
1539
|
+
/**
|
|
1540
|
+
* 输出阈值必须显式放宽。
|
|
1541
|
+
*
|
|
1542
|
+
* 为什么:矩阵构建的 jobs 数组动辄上百个 job、每个十几步,JSON 轻松过 100KB;
|
|
1543
|
+
* 沿用默认 24KB 会跳过 JSON.parse,data 恒为 undefined,于是弹窗渲染出
|
|
1544
|
+
* 「元数据全空 + 零 job」的假详情——比报错更糟(同 getIssueDetail 的教训)。
|
|
1545
|
+
*/
|
|
1546
|
+
const execOpts = {
|
|
1547
|
+
cwd: targetCwd,
|
|
1548
|
+
timeoutMs: 20_000,
|
|
1549
|
+
maxOutputChars: 2_000_000,
|
|
1550
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
1551
|
+
};
|
|
1552
|
+
let fieldsDegraded = false;
|
|
1553
|
+
let r = await this.run(buildArgs(RUN_DETAIL_FULL_FIELDS), execOpts);
|
|
1554
|
+
if (!r.ok && /Unknown JSON field/i.test(String(r.error || ''))) {
|
|
1555
|
+
fieldsDegraded = true;
|
|
1556
|
+
r = await this.run(buildArgs(RUN_DETAIL_BASE_FIELDS), execOpts);
|
|
1557
|
+
}
|
|
1558
|
+
if (!r.ok) {
|
|
1559
|
+
return { ok: false, error: r.error || `获取 Run #${id} 详情失败` };
|
|
1560
|
+
}
|
|
1561
|
+
// 截断 / 解析失败必须显式失败:渲染空 jobs 会被读成「这次运行没有 job」
|
|
1562
|
+
if (r.truncated || !r.data || typeof r.data !== 'object') {
|
|
1563
|
+
const link = cleanRepo ? `https://github.com/${cleanRepo}/actions/runs/${id}` : '仓库的 Actions 页面';
|
|
1564
|
+
return {
|
|
1565
|
+
ok: false,
|
|
1566
|
+
error: `Run #${id} 的 job 清单过长,已超出插件单次读取上限。请在浏览器中查看完整运行(${link})。`,
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
const raw = r.data;
|
|
1570
|
+
const jobs = Array.isArray(raw.jobs)
|
|
1571
|
+
? raw.jobs
|
|
1572
|
+
.filter((j) => j && (j.databaseId || j.name))
|
|
1573
|
+
.map((j) => {
|
|
1574
|
+
const steps = Array.isArray(j.steps)
|
|
1575
|
+
? j.steps
|
|
1576
|
+
.filter((s) => s && typeof s === 'object')
|
|
1577
|
+
.map((s) => ({
|
|
1578
|
+
number: Number(s.number) || 0,
|
|
1579
|
+
name: String(s.name || ''),
|
|
1580
|
+
status: String(s.status || ''),
|
|
1581
|
+
...(s.conclusion ? { conclusion: String(s.conclusion) } : {}),
|
|
1582
|
+
...(s.startedAt ? { startedAt: String(s.startedAt) } : {}),
|
|
1583
|
+
...(s.completedAt ? { completedAt: String(s.completedAt) } : {}),
|
|
1584
|
+
}))
|
|
1585
|
+
: [];
|
|
1586
|
+
return {
|
|
1587
|
+
databaseId: Number(j.databaseId) || 0,
|
|
1588
|
+
name: String(j.name || '未命名 job'),
|
|
1589
|
+
status: String(j.status || ''),
|
|
1590
|
+
...(j.conclusion ? { conclusion: String(j.conclusion) } : {}),
|
|
1591
|
+
...(j.startedAt ? { startedAt: String(j.startedAt) } : {}),
|
|
1592
|
+
...(j.completedAt ? { completedAt: String(j.completedAt) } : {}),
|
|
1593
|
+
...(j.url ? { url: String(j.url) } : {}),
|
|
1594
|
+
steps,
|
|
1595
|
+
};
|
|
1596
|
+
})
|
|
1597
|
+
: [];
|
|
1598
|
+
// 失败口径只算一次,UI 直接消费(避免前端再推导出第二套口径)
|
|
1599
|
+
const failedJobs = jobs.filter((j) => j.conclusion === 'failure').length;
|
|
1600
|
+
const failedSteps = jobs.reduce((sum, j) => sum + j.steps.filter((s) => s.conclusion === 'failure').length, 0);
|
|
1601
|
+
const detail = {
|
|
1602
|
+
runId: Number(raw.databaseId) || Number(id),
|
|
1603
|
+
...(typeof raw.attempt === 'number' && raw.attempt > 0 ? { attempt: raw.attempt } : {}),
|
|
1604
|
+
name: String(raw.name || raw.workflowName || `Run ${id}`),
|
|
1605
|
+
...(raw.displayTitle ? { displayTitle: String(raw.displayTitle) } : {}),
|
|
1606
|
+
status: String(raw.status || ''),
|
|
1607
|
+
...(raw.conclusion ? { conclusion: String(raw.conclusion) } : {}),
|
|
1608
|
+
...(raw.event ? { event: String(raw.event) } : {}),
|
|
1609
|
+
...(raw.headBranch ? { headBranch: String(raw.headBranch) } : {}),
|
|
1610
|
+
...(raw.headSha ? { headSha: String(raw.headSha) } : {}),
|
|
1611
|
+
url: String(raw.url || ''),
|
|
1612
|
+
createdAt: String(raw.createdAt || ''),
|
|
1613
|
+
...(raw.startedAt ? { startedAt: String(raw.startedAt) } : {}),
|
|
1614
|
+
updatedAt: String(raw.updatedAt || ''),
|
|
1615
|
+
...(raw.workflowName ? { workflowName: String(raw.workflowName) } : {}),
|
|
1616
|
+
jobs,
|
|
1617
|
+
failedJobs,
|
|
1618
|
+
failedSteps,
|
|
1619
|
+
...(fieldsDegraded ? { fieldsDegraded: true } : {}),
|
|
1620
|
+
};
|
|
1621
|
+
return { ok: true, data: detail };
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* 按需拉取失败日志并压成纯文本摘要(供 Run 详情弹窗消费)。
|
|
1625
|
+
*
|
|
1626
|
+
* 与 github_run(log_failed) 的关系:同一份 `--log-failed` 原始输出、同一套锚点
|
|
1627
|
+
* 萃取与尾部回退,只是排版不同(弹窗要的是 `<pre>` 纯文本,不是 Markdown)。
|
|
1628
|
+
*
|
|
1629
|
+
* 为什么逐 job 拉而不是整 run 一次拉:`gh run view --log-failed` 会把**整次运行**
|
|
1630
|
+
* 的日志包都下载下来再过滤,矩阵构建下等于为了一个失败 job 拖几十 MB;`-j <jobId>`
|
|
1631
|
+
* 是 gh 支持的定向形态(见 `gh run view --help`),失败 job 通常只有 1–3 个。
|
|
1632
|
+
*
|
|
1633
|
+
* 为什么单个 job 失败仍返回 ok:true:部分成功比整体失败有用——已拿到的块照常展示,
|
|
1634
|
+
* 失败的那个进 errors[] 并在界面上显式可重试。只有参数非法才是 ok:false。
|
|
1635
|
+
*/
|
|
1636
|
+
async getRunLogExcerpts(cwd, runId, jobIds, repo,
|
|
1637
|
+
/**
|
|
1638
|
+
* 本批日志的总预算(毫秒,默认 `RUN_LOG_TOTAL_BUDGET_MS`)。
|
|
1639
|
+
*
|
|
1640
|
+
* 为什么需要:单 job 超时 60s × 最多 3 个串行 = 最坏 180s。这个调用挂在 HTTP
|
|
1641
|
+
* 路由上,而宿主 WebServer 是全 GUI 共享的——一次点击占住三分钟不可接受。
|
|
1642
|
+
* 超预算的 job 进 `errors[]`(显式、可单独重试),而不是让连接一直挂着。
|
|
1643
|
+
* 允许外部传入只为可测:传 0 能验证「超预算」分支而不必真的等待。
|
|
1644
|
+
*/
|
|
1645
|
+
opts = {}) {
|
|
1646
|
+
const targetCwd = this.resolveWorkspaceCwd(cwd);
|
|
1647
|
+
const id = String(runId ?? '').trim();
|
|
1648
|
+
if (!/^\d+$/.test(id) || Number(id) <= 0) {
|
|
1649
|
+
return { ok: false, error: '缺少有效 Run ID(必须为正整数)' };
|
|
1650
|
+
}
|
|
1651
|
+
const cleanRepo = this.normalizeRepoFullName(repo);
|
|
1652
|
+
if (cleanRepo === null) {
|
|
1653
|
+
return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
|
|
1654
|
+
}
|
|
1655
|
+
const wanted = Array.from(new Set((Array.isArray(jobIds) ? jobIds : [])
|
|
1656
|
+
.map((v) => String(v ?? '').trim())
|
|
1657
|
+
.filter((v) => /^\d+$/.test(v) && Number(v) > 0)));
|
|
1658
|
+
if (wanted.length === 0) {
|
|
1659
|
+
return { ok: false, error: '缺少有效 job_id(必须为正整数,最多 3 个)' };
|
|
1660
|
+
}
|
|
1661
|
+
// 超上限必须显式拒绝:静默 slice 会让调用方以为 5 个 job 的日志都拉了,
|
|
1662
|
+
// 与仓库自己立的「截断必须可见」纪律(filesTruncated / totalUnknown)相悖。
|
|
1663
|
+
if (wanted.length > RUN_LOG_MAX_JOBS) {
|
|
1664
|
+
return {
|
|
1665
|
+
ok: false,
|
|
1666
|
+
error: `一次最多拉取 ${RUN_LOG_MAX_JOBS} 个 job 的失败日志(本次请求 ${wanted.length} 个)。请分批重试,或直接在 GitHub 查看完整日志。`,
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
// 名字从详情树里取(job id → 名称)。取不到不影响拉日志,但**必须显式标注**:
|
|
1670
|
+
// 名字退化成 `job #111` 是可见的降级,不能让它悄悄发生(本仓的降级纪律)。
|
|
1671
|
+
const detail = await this.getRunDetail(targetCwd, id, cleanRepo || undefined).catch(() => null);
|
|
1672
|
+
const nameById = new Map();
|
|
1673
|
+
for (const job of detail && detail.ok && detail.data ? detail.data.jobs : []) {
|
|
1674
|
+
if (job.databaseId > 0)
|
|
1675
|
+
nameById.set(job.databaseId, job.name);
|
|
1676
|
+
}
|
|
1677
|
+
const namesUnavailable = wanted.some((jobId) => !nameById.has(Number(jobId)));
|
|
1678
|
+
const jobs = [];
|
|
1679
|
+
const errors = [];
|
|
1680
|
+
// 总预算:3 个 job 各 60s 串行会把一条 HTTP 请求拖到 180s(见方法注释)
|
|
1681
|
+
const budget = Number.isFinite(opts.totalBudgetMs)
|
|
1682
|
+
? Math.max(0, Math.floor(opts.totalBudgetMs))
|
|
1683
|
+
: RUN_LOG_TOTAL_BUDGET_MS;
|
|
1684
|
+
const deadline = Date.now() + budget;
|
|
1685
|
+
// 串行而非并发:每次都是一次整包下载,并发只会同时挤爆代理与 gh 缓存目录
|
|
1686
|
+
for (const jobId of wanted) {
|
|
1687
|
+
const jobName = nameById.get(Number(jobId)) || `job #${jobId}`;
|
|
1688
|
+
// 定向日志通道:`-j <jobId> --log-failed` 只下这个 job 的失败步骤日志
|
|
1689
|
+
const logArgs = ['run', 'view', id, '-j', jobId, '--log-failed'];
|
|
1690
|
+
if (cleanRepo)
|
|
1691
|
+
logArgs.push('-R', cleanRepo);
|
|
1692
|
+
// 预算在**每次**调用前重算:前一个 job 慢,后面的就该直接判超预算而不是再赌一次
|
|
1693
|
+
const remaining = deadline - Date.now();
|
|
1694
|
+
if (remaining <= 0) {
|
|
1695
|
+
errors.push({
|
|
1696
|
+
jobName,
|
|
1697
|
+
error: `超出本批日志总预算(${Math.round(budget / 1000)}s),该 job 未拉取。请单独重试。`,
|
|
1698
|
+
});
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
const res = await this.run(logArgs, {
|
|
1702
|
+
cwd: targetCwd,
|
|
1703
|
+
// 单个 job 最多 60s,但绝不越过本批总预算
|
|
1704
|
+
timeoutMs: Math.min(60_000, remaining),
|
|
1705
|
+
rawText: true,
|
|
1706
|
+
maxOutputChars: 2_000_000,
|
|
1707
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
1708
|
+
});
|
|
1709
|
+
if (!res.ok) {
|
|
1710
|
+
const friendly = interpretRunLogError(String(res.error || ''), id);
|
|
1711
|
+
if (friendly) {
|
|
1712
|
+
// 确定性结论(该 job 没有失败步骤 / 缓存目录无权限)不是故障,进正文而非 errors
|
|
1713
|
+
jobs.push({ jobId: Number(jobId), jobName, excerpt: friendly, truncated: false });
|
|
1714
|
+
}
|
|
1715
|
+
else {
|
|
1716
|
+
errors.push({ jobName, error: String(res.error || '日志拉取失败').trim().slice(0, 400) });
|
|
1717
|
+
}
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
const built = buildPlainFailureExcerpt(res.rawOutput || '', {
|
|
1721
|
+
budget: RUN_LOG_BUDGET_CHARS,
|
|
1722
|
+
truncated: Boolean(res.truncated),
|
|
1723
|
+
});
|
|
1724
|
+
jobs.push({ jobId: Number(jobId), jobName, excerpt: built.text, truncated: built.truncated });
|
|
1725
|
+
}
|
|
1726
|
+
const data = {
|
|
1727
|
+
runId: Number(id),
|
|
1728
|
+
jobs,
|
|
1729
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
1730
|
+
...(namesUnavailable ? { namesUnavailable: true } : {}),
|
|
1731
|
+
fetchedAt: new Date().toISOString(),
|
|
1732
|
+
};
|
|
1733
|
+
return { ok: true, data };
|
|
1734
|
+
}
|
|
1169
1735
|
/** 生成当前操作系统平台的推荐安装引导与命令 */
|
|
1170
1736
|
getPlatformInstallGuide() {
|
|
1171
1737
|
return getPlatformInstallGuide();
|