dsh-github-flow 0.6.0 → 0.7.1
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 +102 -15
- package/README.md +105 -15
- package/docs/UPGRADE-NOTES.md +12 -11
- package/docs/architecture.md +67 -14
- package/lib/api/route-helpers.d.ts +19 -0
- package/lib/api/route-helpers.js +54 -0
- package/lib/api/routes-actions.js +126 -2
- 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 +2108 -658
- 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 +204 -3
- package/lib/executor.js +670 -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.d.ts +4 -0
- package/lib/tools/agent-task.js +22 -9
- package/lib/tools/api.d.ts +4 -0
- package/lib/tools/api.js +48 -12
- package/lib/tools/body-input.d.ts +58 -0
- package/lib/tools/body-input.js +123 -0
- package/lib/tools/cache.js +42 -40
- package/lib/tools/codespace.js +4 -12
- package/lib/tools/discussion.d.ts +4 -0
- package/lib/tools/discussion.js +33 -17
- package/lib/tools/gist.d.ts +1 -39
- package/lib/tools/gist.js +4 -14
- package/lib/tools/help.js +65 -13
- package/lib/tools/issue.d.ts +4 -0
- package/lib/tools/issue.js +37 -38
- package/lib/tools/label.js +4 -15
- package/lib/tools/pr.d.ts +51 -0
- package/lib/tools/pr.js +204 -46
- package/lib/tools/project.js +4 -13
- package/lib/tools/release.d.ts +4 -0
- package/lib/tools/release.js +26 -26
- 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 +223 -0
- package/lib/tools/task.js +464 -0
- package/lib/types.d.ts +183 -1
- package/lib/ui/commit-modal.js +108 -4
- package/lib/ui/diff-utils.d.ts +51 -0
- package/lib/ui/diff-utils.js +155 -0
- package/lib/ui/file-diff-modal.d.ts +33 -0
- package/lib/ui/file-diff-modal.js +250 -0
- package/lib/ui/help-modal.d.ts +1 -1
- package/lib/ui/help-modal.js +24 -12
- 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 +376 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把 gh 的非零退出翻译成给人看的结论;返回 `null` 表示「这是真错误,原样上抛」。
|
|
3
|
+
*
|
|
4
|
+
* 两类必须吸收的错误(实测):
|
|
5
|
+
* 1. `no failed jobs` —— 工作流本身成功或没有失败步骤,这是确定性答案而非故障;
|
|
6
|
+
* 2. `Access is denied` —— gh 往本机缓存目录写日志 zip 被系统拒绝,属宿主环境
|
|
7
|
+
* 问题。原样透传会让调用方(尤其是模型)误判成构建故障,给出自查路径更可行动。
|
|
8
|
+
*
|
|
9
|
+
* runId 参与文案:同一个弹窗里可能并排看几个 run,不带编号的报错无法定位。
|
|
10
|
+
*/
|
|
11
|
+
export declare function interpretRunLogError(error: string, runId: string | number): string | null;
|
|
12
|
+
export interface PlainFailureExcerpt {
|
|
13
|
+
/** 可直接塞进 `<pre>` 的纯文本;空日志时为空串 */
|
|
14
|
+
text: string;
|
|
15
|
+
mode: 'anchors' | 'tail' | 'empty';
|
|
16
|
+
/** 萃取到的失败块总数(可能大于展示的块数) */
|
|
17
|
+
blockCount: number;
|
|
18
|
+
droppedBlocks: number;
|
|
19
|
+
/** 原始日志或二次裁剪是否发生过截断(真值即「结论可能不完整」) */
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 把整段日志压成纯文本失败摘要。
|
|
24
|
+
*
|
|
25
|
+
* 为什么要有独立的纯文本渲染:弹窗里用 `<pre>` 显示,Markdown 的代码围栏与
|
|
26
|
+
* 引用块会被当成字面量原样打出来(` ``` ` 出现在日志正文里时更乱)。Markdown 版
|
|
27
|
+
* 留给 Agent 工具,两者共用同一套锚点萃取与尾部回退,只是排版不同。
|
|
28
|
+
*
|
|
29
|
+
* 预算裁剪是**第二道**:gh 侧已按 2MB 截过一次,这里再按 `budget` 裁一次的目的
|
|
30
|
+
* 是别把几万字塞进 DOM(浏览器渲染与滚动都会明显变卡)。两道截断都必须写进
|
|
31
|
+
* 返回文本——「没找到更多失败块」与「更多失败块被裁掉了」是相反结论。
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildPlainFailureExcerpt(rawLog: string, opts?: {
|
|
34
|
+
budget?: number;
|
|
35
|
+
truncated?: boolean;
|
|
36
|
+
}): PlainFailureExcerpt;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 失败日志的两个共用件:错误语义映射 + 纯文本锚点摘要。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成模块而不是各写一遍:
|
|
5
|
+
* 同一份 `gh run view --log-failed` 输出有两个消费方——Agent 工具(`github_run`
|
|
6
|
+
* 的 `log_failed`,渲染成 Markdown 进模型上下文)与人在面板里看的 Run 详情弹窗
|
|
7
|
+
* (渲染成 `<pre>` 纯文本)。两者的**排版**不同,但「什么算失败、哪条是环境故障
|
|
8
|
+
* 不是构建故障、被截断了必须说明」这三条语义必须一致;各写一份的下场是
|
|
9
|
+
* Agent 说「没有失败步骤」而弹窗说「拉取失败」。
|
|
10
|
+
*
|
|
11
|
+
* 约束:本模块只做纯文本变换,零 I/O、零子进程。真正的 gh 调用留在调用方,
|
|
12
|
+
* 方便两边各自决定超时与输出预算。
|
|
13
|
+
*/
|
|
14
|
+
import { extractFailureAnchors, tailLines } from './failure-anchors.js';
|
|
15
|
+
/**
|
|
16
|
+
* 把 gh 的非零退出翻译成给人看的结论;返回 `null` 表示「这是真错误,原样上抛」。
|
|
17
|
+
*
|
|
18
|
+
* 两类必须吸收的错误(实测):
|
|
19
|
+
* 1. `no failed jobs` —— 工作流本身成功或没有失败步骤,这是确定性答案而非故障;
|
|
20
|
+
* 2. `Access is denied` —— gh 往本机缓存目录写日志 zip 被系统拒绝,属宿主环境
|
|
21
|
+
* 问题。原样透传会让调用方(尤其是模型)误判成构建故障,给出自查路径更可行动。
|
|
22
|
+
*
|
|
23
|
+
* runId 参与文案:同一个弹窗里可能并排看几个 run,不带编号的报错无法定位。
|
|
24
|
+
*/
|
|
25
|
+
export function interpretRunLogError(error, runId) {
|
|
26
|
+
const err = String(error || '');
|
|
27
|
+
if (err.includes('no failed jobs') || err.includes('not failed')) {
|
|
28
|
+
return `工作流 Run #${runId} 中未检测到任何失败的任务步骤 (状态正常)。`;
|
|
29
|
+
}
|
|
30
|
+
if (err.includes('Access is denied') || err.includes('拒绝访问')) {
|
|
31
|
+
return `无法提取 Run #${runId} 的失败日志:gh CLI 向本机缓存目录写入日志压缩包时被系统拒绝 (${err.trim().slice(0, 200)})。建议检查 gh 缓存目录(如 %LocalAppData%\\GitHub CLI)的写权限后重试,或改用 github_api 拉取日志端点。`;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 把整段日志压成纯文本失败摘要。
|
|
37
|
+
*
|
|
38
|
+
* 为什么要有独立的纯文本渲染:弹窗里用 `<pre>` 显示,Markdown 的代码围栏与
|
|
39
|
+
* 引用块会被当成字面量原样打出来(` ``` ` 出现在日志正文里时更乱)。Markdown 版
|
|
40
|
+
* 留给 Agent 工具,两者共用同一套锚点萃取与尾部回退,只是排版不同。
|
|
41
|
+
*
|
|
42
|
+
* 预算裁剪是**第二道**:gh 侧已按 2MB 截过一次,这里再按 `budget` 裁一次的目的
|
|
43
|
+
* 是别把几万字塞进 DOM(浏览器渲染与滚动都会明显变卡)。两道截断都必须写进
|
|
44
|
+
* 返回文本——「没找到更多失败块」与「更多失败块被裁掉了」是相反结论。
|
|
45
|
+
*/
|
|
46
|
+
export function buildPlainFailureExcerpt(rawLog, opts = {}) {
|
|
47
|
+
const budget = Number.isFinite(opts.budget) && opts.budget > 0 ? Math.floor(opts.budget) : 120_000;
|
|
48
|
+
const src = typeof rawLog === 'string' ? rawLog : '';
|
|
49
|
+
if (!src.trim()) {
|
|
50
|
+
return { text: '', mode: 'empty', blockCount: 0, droppedBlocks: 0, truncated: Boolean(opts.truncated) };
|
|
51
|
+
}
|
|
52
|
+
const anchored = extractFailureAnchors(src);
|
|
53
|
+
const mode = anchored.blocks.length > 0 ? 'anchors' : 'tail';
|
|
54
|
+
const parts = [];
|
|
55
|
+
if (opts.truncated) {
|
|
56
|
+
parts.push('⚠️ 原始日志体量超出单次读取上限,以下仅基于已获取的部分。');
|
|
57
|
+
}
|
|
58
|
+
if (mode === 'anchors') {
|
|
59
|
+
parts.push(`共命中 ${anchored.totalBlocks} 个失败块,以下为前 ${anchored.blocks.length} 个:`);
|
|
60
|
+
anchored.blocks.forEach((b, i) => {
|
|
61
|
+
parts.push(`\n===== 失败块 ${i + 1}(日志第 ${b.startLine} 行起)=====\n${b.text}`);
|
|
62
|
+
});
|
|
63
|
+
if (anchored.droppedBlocks > 0) {
|
|
64
|
+
parts.push(`\n⚠️ 另有 ${anchored.droppedBlocks} 个失败块未展示(共 ${anchored.totalBlocks} 个),完整日志请在 GitHub 打开。`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
parts.push('未匹配到已知框架的失败锚点,以下为日志尾部 200 行:\n');
|
|
69
|
+
parts.push(tailLines(src, 200));
|
|
70
|
+
}
|
|
71
|
+
let text = parts.join('\n');
|
|
72
|
+
let truncated = Boolean(opts.truncated);
|
|
73
|
+
if (text.length > budget) {
|
|
74
|
+
text = `${text.slice(0, budget)}\n\n⚠️ 摘要已按 ${budget} 字符上限截断,上述失败块可能不完整。`;
|
|
75
|
+
truncated = true;
|
|
76
|
+
}
|
|
77
|
+
return { text, mode, blockCount: anchored.totalBlocks, droppedBlocks: anchored.droppedBlocks, truncated };
|
|
78
|
+
}
|
package/lib/executor.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { GhExecutionOptions, GhResult, AuthStatus, RepoMetadata, PluginConfig, GitLocalStatus, GitCommitLog, GitCommitEntry, GitBranchesResult, AssociatedRepoItem, PrDetailData, IssueDetailData } from './types.js';
|
|
1
|
+
import type { GhExecutionOptions, GhResult, AuthStatus, RepoMetadata, PluginConfig, GitLocalStatus, WorkspaceCleanResult, GitCommitLog, GitCommitEntry, GitBranchesResult, AssociatedRepoItem, PrDetailData, IssueDetailData, RunDetailData, RunLogExcerptData } from './types.js';
|
|
2
2
|
import type { ProxyManager } from './proxy.js';
|
|
3
3
|
import { parseGhVersion, compareGhVersions } from './executor/version-utils.js';
|
|
4
4
|
import { spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput } from './executor/process-utils.js';
|
|
5
|
+
import { type PrReviewResult } from './executor/pr-review.js';
|
|
5
6
|
import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff } from './executor/git-parser.js';
|
|
6
7
|
import type { ParsedGitConfig, ParsedRemote } from './executor/git-parser.js';
|
|
7
8
|
import { getPlatformInstallGuide, getGhUpgradeHint } from './executor/platform-guide.js';
|
|
@@ -14,6 +15,14 @@ export declare class GhExecutor {
|
|
|
14
15
|
maxOutputChars: number;
|
|
15
16
|
/** UTF-8 字节口径的硬上限;未配置时按 maxOutputChars × 4 推导(覆盖 CJK 3 字节与 emoji 4 字节) */
|
|
16
17
|
maxOutputBytes: number;
|
|
18
|
+
/**
|
|
19
|
+
* 累计 gh 子进程调用次数(只增不清零,`run()` 每次 +1)。
|
|
20
|
+
*
|
|
21
|
+
* 为什么需要:任务原语(github_task)要如实汇报 `cost.ghCalls`,
|
|
22
|
+
* 而「原语是否真的比手动串步骤省钱」是这个能力存在的唯一判据。
|
|
23
|
+
* 消费方用「调用前快照 → 调用后求差」的方式取区间计数。
|
|
24
|
+
*/
|
|
25
|
+
ghCalls: number;
|
|
17
26
|
/**
|
|
18
27
|
* 列表类查询的默认条数上限。
|
|
19
28
|
* 为什么挂在 executor 而不是给每个工具工厂加 config 参数:executor 是全部
|
|
@@ -54,6 +63,44 @@ export declare class GhExecutor {
|
|
|
54
63
|
* 3. 仅在完全未指定 cwd 且无会话时(全局命令行调用),才兜底回退到最近活跃的 Git 工作区或 process.cwd()。
|
|
55
64
|
*/
|
|
56
65
|
resolveWorkspaceCwd(explicitCwd?: string, explicitSessionId?: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* 18 个工具的唯一工作区入口:把 execContext 归一成 `{ cwd, sessionId }`。
|
|
68
|
+
*
|
|
69
|
+
* 为什么必须走这里而不是各工具写 `execContext?.cwd || process.cwd()`:
|
|
70
|
+
* 三级解析里第 1 级是「显式 cwd 且磁盘存在即原样返回」,而 `process.cwd()`
|
|
71
|
+
* 恒满足该条件——于是第 2 级「按 sessionId 反查真实工作区」**永远不会触发**。
|
|
72
|
+
* 工具层把 `process.cwd()` 当兜底传下去,等于把整个会话亲和机制架空。
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ 刻意**不复用** `resolveWorkspaceCwd` 的第 3 级(`findMostRecentWorkspacePath`):
|
|
75
|
+
* 那一级是给「完全无上下文的 CLI 场景」用的,会把目录定到**最近活跃的任意仓库**。
|
|
76
|
+
* 工具调用的默认目录必须可预测——落到一个与本会话无关的仓库意味着读错库、
|
|
77
|
+
* 更糟的是写到错库(merge / release create / label delete 都会打错地方)。
|
|
78
|
+
* 实测:抹掉 DSH_SESSION_ID 后 `resolveWorkspaceCwd(undefined, undefined)` 返回的是
|
|
79
|
+
* 一个与本会话无关的仓库路径,而 `process.cwd()` 是当前进程目录。
|
|
80
|
+
*
|
|
81
|
+
* 解析顺序:显式 cwd(原样透传,含不存在的情况,与旧行为一致)→ session 反查 →
|
|
82
|
+
* process.cwd()。三级都拿不到时才回到进程目录,与 s5 之前的兜底完全一致。
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ `agent.id` 必须参与反查:DSH 真实的 ToolRunContext(宿主 tools 服务的
|
|
85
|
+
* `ToolExecutionInput`)只有 `agent: { id: SessionId }`,**既没有 cwd 也没有 sessionId**。
|
|
86
|
+
* 早先只认 `sessionId` / `session.id` / 环境变量,于是这一级在桌面端永远不触发,
|
|
87
|
+
* 所有工具退化到宿主进程的 process.cwd()(实测是 profile 目录)——表现为 `repo`
|
|
88
|
+
* 留空时无法自动识别仓库、`body_file` 这类相对路径参数被解析到错误目录。
|
|
89
|
+
* 这里改成「候选逐个试到命中为止」而非短路:任一来源能反查到工作区就返回。
|
|
90
|
+
*/
|
|
91
|
+
resolveExecScope(execContext?: {
|
|
92
|
+
cwd?: string;
|
|
93
|
+
sessionId?: string;
|
|
94
|
+
session?: {
|
|
95
|
+
id?: string;
|
|
96
|
+
};
|
|
97
|
+
agent?: {
|
|
98
|
+
id?: string;
|
|
99
|
+
};
|
|
100
|
+
}): {
|
|
101
|
+
cwd: string;
|
|
102
|
+
sessionId?: string;
|
|
103
|
+
};
|
|
57
104
|
/**
|
|
58
105
|
* 安全执行 gh 命令
|
|
59
106
|
* 采用 execFile 参数数组隔离,彻底杜绝 Shell 字符串拼接注入漏洞
|
|
@@ -116,7 +163,7 @@ export declare class GhExecutor {
|
|
|
116
163
|
* 零网络:status 基于 remote-tracking ref(上次 fetch 的快照),毫秒级本地读取,
|
|
117
164
|
* 符合 global-overview 端点「工作区探测不发网络」的性能红线。
|
|
118
165
|
*/
|
|
119
|
-
getLocalGitStatus(cwd?: string): Promise<GitLocalStatus | null>;
|
|
166
|
+
getLocalGitStatus(cwd?: string, timeoutMs?: number): Promise<GitLocalStatus | null>;
|
|
120
167
|
/**
|
|
121
168
|
* 分页获取本地未提交文件列表(无网络开销,纯本地毫秒级响应)
|
|
122
169
|
*/
|
|
@@ -167,18 +214,97 @@ export declare class GhExecutor {
|
|
|
167
214
|
* 语法都不可能通过校验)。输出为 git show --stat 原文(含文件变更统计)。
|
|
168
215
|
*/
|
|
169
216
|
getGitCommitDetail(cwd: string | undefined, sha: string): Promise<string | null>;
|
|
217
|
+
/**
|
|
218
|
+
* 某次提交的完整补丁(提交详情弹窗里点单个文件时,前端按 `diff --git` 切分后取对应段)。
|
|
219
|
+
*
|
|
220
|
+
* 为什么整包取一次,而不是按文件逐个 `git show <sha> -- <path>`:
|
|
221
|
+
* 用户点哪个文件是未知的,逐个文件来一次就是 N 次进程启动(Windows 上每次几十毫秒),
|
|
222
|
+
* 同一提交内多个文件还要重复解析。一次拿全量交给前端切分,把延迟从「每次点击」
|
|
223
|
+
* 摊到「打开弹窗一次」。
|
|
224
|
+
*
|
|
225
|
+
* 白名单纪律延续 getGitCommitDetail:sha 先过 `/^[0-9a-f]{4,40}$/i` 才拼接,
|
|
226
|
+
* 值域锁死为纯十六进制,注入面为零;命令形态整体常量硬编码。各选项为什么存在:
|
|
227
|
+
* - `--format=`:清空提交头。默认输出前半截是 commit/Author/Date/message,
|
|
228
|
+
* 前端按 `diff --git` 切片时会把这段脏头当成第一个文件的内容;
|
|
229
|
+
* - `--no-ext-diff`:用户一旦配置 `diff.external`,git 会去执行任意外部程序。
|
|
230
|
+
* 这是本地服务,不能因为一条读请求就把用户的外部工具跑起来;
|
|
231
|
+
* - `--no-color`:用户若设了 `color.ui=always`,patch 里会混进 ANSI 转义序列,
|
|
232
|
+
* 前端按行首 `+`/`-` 判定增删行的逻辑会直接判错;
|
|
233
|
+
* - `core.quotepath=false`:quotepath 为 true(git 内建默认)时非 ASCII 路径被转义成
|
|
234
|
+
* `\344\270\255` 并包上双引号(实测),前端按 `a/` 前缀切出的文件名与真实路径对不上;
|
|
235
|
+
* - `--src-prefix=a/ --dst-prefix=b/`:用户全局开 `diff.noprefix=true` 时路径没有
|
|
236
|
+
* `a/`/`b/` 前缀,切分与还原文件名的逻辑全部错位,这里强制钉死前缀;
|
|
237
|
+
* - 末尾 `--`:关死 sha 之后的参数位,避免可被解释成 pathspec 的输入混进命令。
|
|
238
|
+
*
|
|
239
|
+
* 体积护栏:大提交的 patch 可达数十 MB,整包透传给 WebView 会卡死渲染。超过
|
|
240
|
+
* 400_000 字符时在**最后一个 `\ndiff --git ` 边界**处截断——绝不切在 hunk 中间,
|
|
241
|
+
* 否则前端拿到半截 hunk 会渲染出错行;只有找不到边界(极端单文件巨型 diff)
|
|
242
|
+
* 才硬切到上限,此时 truncated 标记让前端给出「已截断」提示。
|
|
243
|
+
*
|
|
244
|
+
* 空 patch 是正常结果而非错误:合并提交默认不展开 diff、空提交也没有内容,
|
|
245
|
+
* 两者都返回 ok:true + 空字符串,由 UI 决定怎么展示。
|
|
246
|
+
*/
|
|
247
|
+
getCommitDiff(cwd: string | undefined, sha: string): Promise<{
|
|
248
|
+
ok: boolean;
|
|
249
|
+
sha?: string;
|
|
250
|
+
patch?: string;
|
|
251
|
+
chars?: number;
|
|
252
|
+
truncated?: boolean;
|
|
253
|
+
error?: string;
|
|
254
|
+
}>;
|
|
170
255
|
/**
|
|
171
256
|
* 纯本地读取工作区的所有 Git 分支(按提交时间倒序)。
|
|
172
257
|
* 包含当前 HEAD 分支标记、关联 upstream、最新提交简述与相对时间。
|
|
173
258
|
* 零网络往返,毫秒级本地管道输出。
|
|
174
259
|
*/
|
|
175
260
|
getGitBranches(cwd?: string): Promise<GitBranchesResult | null>;
|
|
261
|
+
/**
|
|
262
|
+
* 发版输入:最近 tag + 该 tag 以来的提交(供 `github_task(prepare_release)` 使用)。
|
|
263
|
+
*
|
|
264
|
+
* 为什么要在 executor 里开这个口子,而不是让工具自己拼 git 命令:
|
|
265
|
+
* 命令形态必须集中(本文件顶部就是"git 白名单"纪律),否则每个新工具都会
|
|
266
|
+
* 自己发明一套 argv,`phase34` 的契约网也就管不到它们了。
|
|
267
|
+
*
|
|
268
|
+
* 安全性:两条命令的形态**全部固定**——`describe --tags --abbrev=0` 无参数注入;
|
|
269
|
+
* log 的修订范围段只拼 `git describe` 自己的输出,且先过 `/^[A-Za-z0-9._\/-]+$/`
|
|
270
|
+
* 白名单校验,杜绝把任意字符串当 revision 传进 git。
|
|
271
|
+
*/
|
|
272
|
+
getReleaseInputs(cwd?: string, fromTagOverride?: string): Promise<{
|
|
273
|
+
ok: boolean;
|
|
274
|
+
fromTag: string | null;
|
|
275
|
+
commits: Array<{
|
|
276
|
+
sha: string;
|
|
277
|
+
subject: string;
|
|
278
|
+
author: string;
|
|
279
|
+
date: string;
|
|
280
|
+
body: string;
|
|
281
|
+
}>;
|
|
282
|
+
totalCommits: number;
|
|
283
|
+
error?: string;
|
|
284
|
+
}>;
|
|
285
|
+
/**
|
|
286
|
+
* 工作区干净度闸门(唯一实现)。
|
|
287
|
+
*
|
|
288
|
+
* 为什么抽成公共方法:会改变工作区分支/内容的入口有三条——界面切分支(switchGitBranch)、
|
|
289
|
+
* `github_pr(checkout)`、`github_issue(develop)`。此前只有第一条有脏检查,后两条直穿,
|
|
290
|
+
* 于是「强阻断」的成文承诺只对界面成立。安全不变量必须只有一处实现。
|
|
291
|
+
*
|
|
292
|
+
* 三态语义(fail-closed):
|
|
293
|
+
* - `null`(git 读不到)→ 拒绝。读不到状态≠干净,放行等于闸门失效。
|
|
294
|
+
* - `dirtyCount > 0` → 拒绝,并带前 5 条样例便于用户判断。
|
|
295
|
+
* - 其余 → 放行。
|
|
296
|
+
*
|
|
297
|
+
* 超时为什么单独给 20s:`getLocalGitStatus` 默认 3s 是给概览面板用的(读不到就不渲染那一行),
|
|
298
|
+
* 但这里是一次安全判决——大型仓库 / 网络盘 / OneDrive 同步目录下 `git status` 冷启动超 3s
|
|
299
|
+
* 很常见,用 3s 会把"慢"误判成"脏/不可用",直接把 checkout 挡死。
|
|
300
|
+
*/
|
|
301
|
+
ensureCleanWorkspace(cwd: string | undefined): Promise<WorkspaceCleanResult>;
|
|
176
302
|
/**
|
|
177
303
|
* 安全切换当前工作区的 Git 分支。
|
|
178
304
|
* 三重安全防御纪律:
|
|
179
305
|
* 1. 目标工作区必须存在有效的 .git 目录;
|
|
180
306
|
* 2. 分支名称严格执行字符白名单校验,防命令行参数注入;
|
|
181
|
-
* 3.
|
|
307
|
+
* 3. 工作区干净度交给 ensureCleanWorkspace(唯一实现,与 checkout / develop 共用)。
|
|
182
308
|
*/
|
|
183
309
|
switchGitBranch(cwd: string | undefined, branchName: string): Promise<{
|
|
184
310
|
ok: boolean;
|
|
@@ -221,6 +347,25 @@ export declare class GhExecutor {
|
|
|
221
347
|
data?: PrDetailData;
|
|
222
348
|
error?: string;
|
|
223
349
|
}>;
|
|
350
|
+
/**
|
|
351
|
+
* 提交带行级评注的 PR review(REST 通道)。
|
|
352
|
+
*
|
|
353
|
+
* 为什么必须走 REST:`gh pr review` 只有 `--approve/--request-changes/--comment`,
|
|
354
|
+
* 没有任何行级参数(`gh pr review --help` 里 line/side/path 零命中)。行级评注
|
|
355
|
+
* 的唯一入口是 `POST /repos/{owner}/{repo}/pulls/{n}/reviews` 的 `comments[]`。
|
|
356
|
+
*
|
|
357
|
+
* 为什么一次 review 一个请求(而不是逐条 `POST /pulls/{n}/comments`):
|
|
358
|
+
* 通知只发一轮,也避开 secondary rate limit 的「创建内容过快」判定。
|
|
359
|
+
*
|
|
360
|
+
* 422 降级:行级评注被拒(典型原因:该行不在 diff 的可评论侧,如纯删除行)
|
|
361
|
+
* 时,把评注折叠进 review 正文重试一次。内容不能丢,但降级必须显式可见。
|
|
362
|
+
*/
|
|
363
|
+
createPrReview(cwd: string | undefined, repo: string | undefined, prNumber: number | string, input: {
|
|
364
|
+
event?: unknown;
|
|
365
|
+
body?: unknown;
|
|
366
|
+
comments?: unknown;
|
|
367
|
+
commit_id?: unknown;
|
|
368
|
+
}): Promise<PrReviewResult>;
|
|
224
369
|
/**
|
|
225
370
|
* 获取 Issue 完整详情(供 IssueDetailModal 在线查看消费)。
|
|
226
371
|
*
|
|
@@ -236,6 +381,62 @@ export declare class GhExecutor {
|
|
|
236
381
|
data?: IssueDetailData;
|
|
237
382
|
error?: string;
|
|
238
383
|
}>;
|
|
384
|
+
/**
|
|
385
|
+
* 详情类接口的仓库参数归一化(PR / Issue / Run 共用)。
|
|
386
|
+
*
|
|
387
|
+
* 返回 `''` = 未传(由本地 remote 决定);返回 `null` = 非法。
|
|
388
|
+
*
|
|
389
|
+
* 为什么要收编三处各自的校验:历史实现各写一份正则,只有 `setDefaultRepo` 额外
|
|
390
|
+
* 拒绝 `-` 开头与 `..`,其余靠「恒以 `-R <value>` 成对传入」兜底(复核报告 C-13)。
|
|
391
|
+
* 「成对传入所以安全」是当前调用形状的安全,不是接口的安全——多一处调用方就多
|
|
392
|
+
* 一次赌。统一到这里后,`-` 开头/`..`/反斜杠在任何详情入口都进不来。
|
|
393
|
+
*/
|
|
394
|
+
private normalizeRepoFullName;
|
|
395
|
+
/**
|
|
396
|
+
* 获取单次 Actions Run 的完整详情(run + jobs + steps)。
|
|
397
|
+
*
|
|
398
|
+
* 架构设计:
|
|
399
|
+
* 1. **run_id 只认纯数字**。它会被当作 `gh run view` 的位置参数拼进 argv,
|
|
400
|
+
* 不做白名单就等于把「用参数值注入 flag」这条已知缺陷(复核报告 C-01)
|
|
401
|
+
* 复制到新通道——`--web`、`--log` 都能冒充编号。
|
|
402
|
+
* 2. 单次往返拿全:`jobs` 里已经包含 steps 树,没有需要并行拉取的第二路。
|
|
403
|
+
* 3. **日志不在这里拉**。实测单次失败日志 4–40 秒(本机代理下还直接连接重置),
|
|
404
|
+
* 挂在详情上等于让弹窗首屏去赌网络;日志走 getRunLogExcerpts() 按需拉。
|
|
405
|
+
*/
|
|
406
|
+
getRunDetail(cwd?: string, runId?: number | string, repo?: string): Promise<{
|
|
407
|
+
ok: boolean;
|
|
408
|
+
data?: RunDetailData;
|
|
409
|
+
error?: string;
|
|
410
|
+
}>;
|
|
411
|
+
/**
|
|
412
|
+
* 按需拉取失败日志并压成纯文本摘要(供 Run 详情弹窗消费)。
|
|
413
|
+
*
|
|
414
|
+
* 与 github_run(log_failed) 的关系:同一份 `--log-failed` 原始输出、同一套锚点
|
|
415
|
+
* 萃取与尾部回退,只是排版不同(弹窗要的是 `<pre>` 纯文本,不是 Markdown)。
|
|
416
|
+
*
|
|
417
|
+
* 为什么逐 job 拉而不是整 run 一次拉:`gh run view --log-failed` 会把**整次运行**
|
|
418
|
+
* 的日志包都下载下来再过滤,矩阵构建下等于为了一个失败 job 拖几十 MB;`-j <jobId>`
|
|
419
|
+
* 是 gh 支持的定向形态(见 `gh run view --help`),失败 job 通常只有 1–3 个。
|
|
420
|
+
*
|
|
421
|
+
* 为什么单个 job 失败仍返回 ok:true:部分成功比整体失败有用——已拿到的块照常展示,
|
|
422
|
+
* 失败的那个进 errors[] 并在界面上显式可重试。只有参数非法才是 ok:false。
|
|
423
|
+
*/
|
|
424
|
+
getRunLogExcerpts(cwd?: string, runId?: number | string, jobIds?: Array<number | string>, repo?: string,
|
|
425
|
+
/**
|
|
426
|
+
* 本批日志的总预算(毫秒,默认 `RUN_LOG_TOTAL_BUDGET_MS`)。
|
|
427
|
+
*
|
|
428
|
+
* 为什么需要:单 job 超时 60s × 最多 3 个串行 = 最坏 180s。这个调用挂在 HTTP
|
|
429
|
+
* 路由上,而宿主 WebServer 是全 GUI 共享的——一次点击占住三分钟不可接受。
|
|
430
|
+
* 超预算的 job 进 `errors[]`(显式、可单独重试),而不是让连接一直挂着。
|
|
431
|
+
* 允许外部传入只为可测:传 0 能验证「超预算」分支而不必真的等待。
|
|
432
|
+
*/
|
|
433
|
+
opts?: {
|
|
434
|
+
totalBudgetMs?: number;
|
|
435
|
+
}): Promise<{
|
|
436
|
+
ok: boolean;
|
|
437
|
+
data?: RunLogExcerptData;
|
|
438
|
+
error?: string;
|
|
439
|
+
}>;
|
|
239
440
|
/** 生成当前操作系统平台的推荐安装引导与命令 */
|
|
240
441
|
private getPlatformInstallGuide;
|
|
241
442
|
/**
|