dsh-github-flow 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +112 -17
- package/README.md +115 -17
- package/docs/UPGRADE-NOTES.md +12 -11
- package/docs/architecture.md +81 -22
- package/docs/images/1.png +0 -0
- package/lib/api/route-helpers.d.ts +19 -0
- package/lib/api/route-helpers.js +54 -0
- package/lib/api/routes-actions.js +214 -9
- package/lib/api/routes-lifecycle.js +8 -1
- package/lib/api/routes-overview.d.ts +26 -0
- package/lib/api/routes-overview.js +396 -36
- package/lib/api/routes-proxy.js +8 -1
- package/lib/api/routes.d.ts +11 -1
- package/lib/api/routes.js +15 -2
- package/lib/cache.d.ts +56 -4
- package/lib/cache.js +77 -7
- package/lib/client.d.ts +8 -3
- package/lib/client.js +5542 -1391
- package/lib/destructive.d.ts +59 -0
- package/lib/destructive.js +285 -0
- package/lib/executor/args-guard.d.ts +17 -0
- package/lib/executor/args-guard.js +23 -0
- package/lib/executor/error-classifier.d.ts +55 -0
- package/lib/executor/error-classifier.js +199 -0
- package/lib/executor/failure-anchors.d.ts +52 -0
- package/lib/executor/failure-anchors.js +116 -0
- package/lib/executor/git-parser.d.ts +124 -0
- package/lib/executor/git-parser.js +397 -0
- package/lib/executor/pr-review.d.ts +95 -0
- package/lib/executor/pr-review.js +175 -0
- package/lib/executor/run-log.d.ts +36 -0
- package/lib/executor/run-log.js +78 -0
- package/lib/executor.d.ts +277 -6
- package/lib/executor.js +1265 -94
- package/lib/github-service.d.ts +1 -1
- package/lib/main.d.ts +92 -18
- package/lib/main.js +186 -95
- package/lib/renderers/common.d.ts +0 -3
- package/lib/renderers/common.js +8 -44
- package/lib/renderers/markdown.d.ts +1 -0
- package/lib/renderers/markdown.js +1 -0
- package/lib/renderers/pr.js +26 -1
- package/lib/renderers/task.d.ts +9 -0
- package/lib/renderers/task.js +120 -0
- package/lib/review-rules.d.ts +60 -0
- package/lib/review-rules.js +204 -0
- package/lib/shared/state-badges.d.ts +42 -0
- package/lib/shared/state-badges.js +102 -0
- package/lib/task-primitives.d.ts +109 -0
- package/lib/task-primitives.js +194 -0
- package/lib/tool-tiers.d.ts +57 -0
- package/lib/tool-tiers.js +168 -0
- package/lib/tools/agent-task.js +4 -4
- package/lib/tools/api.js +18 -6
- package/lib/tools/cache.js +42 -40
- package/lib/tools/codespace.js +4 -12
- package/lib/tools/discussion.js +4 -4
- package/lib/tools/gist.d.ts +1 -39
- package/lib/tools/gist.js +4 -14
- package/lib/tools/help.js +72 -18
- package/lib/tools/image-utils.d.ts +27 -0
- package/lib/tools/image-utils.js +73 -3
- package/lib/tools/issue.js +16 -28
- package/lib/tools/label.js +4 -15
- package/lib/tools/pr.d.ts +47 -0
- package/lib/tools/pr.js +170 -33
- package/lib/tools/project.js +4 -13
- package/lib/tools/release.js +11 -22
- package/lib/tools/repo.js +4 -4
- package/lib/tools/ruleset.js +9 -6
- package/lib/tools/run.js +76 -24
- package/lib/tools/secret.js +11 -17
- package/lib/tools/skill.js +18 -22
- package/lib/tools/status.js +4 -4
- package/lib/tools/task.d.ts +219 -0
- package/lib/tools/task.js +457 -0
- package/lib/types.d.ts +514 -1
- package/lib/ui/help-modal.d.ts +6 -0
- package/lib/ui/help-modal.js +124 -16
- package/lib/ui/issue-modal.d.ts +14 -0
- package/lib/ui/issue-modal.js +370 -0
- package/lib/ui/markdown-lite.d.ts +66 -0
- package/lib/ui/markdown-lite.js +312 -0
- package/lib/ui/network-banner.d.ts +12 -0
- package/lib/ui/network-banner.js +35 -0
- package/lib/ui/pr-modal.d.ts +17 -0
- package/lib/ui/pr-modal.js +555 -0
- package/lib/ui/repo-badges.d.ts +43 -0
- package/lib/ui/repo-badges.js +82 -0
- package/lib/ui/repo-selector.d.ts +14 -0
- package/lib/ui/repo-selector.js +112 -0
- package/lib/ui/run-modal.d.ts +26 -0
- package/lib/ui/run-modal.js +486 -0
- package/lib/ui/storage.d.ts +47 -0
- package/lib/ui/storage.js +139 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +1330 -44
- package/lib/ui/sync-settings-modal.js +1 -1
- package/lib/ui/utils.js +13 -1
- package/lib/ui/workspace-panel.js +226 -29
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +9 -2
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `github_task`:任务级原语(s8)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:现有 18 个工具是**动词表**(list / view / diff / checks / review…),
|
|
5
|
+
* 而用户真正说的是**任务**("帮我审一下这个 PR" / "CI 为什么挂" / "该发什么版本")。
|
|
6
|
+
* 一个任务要 Agent 自己串 5–10 个动词,每一步都可能选错参数。
|
|
7
|
+
*
|
|
8
|
+
* 三条纪律(写在最前面,因为它们是这个工具能存在的前提):
|
|
9
|
+
* 1. **不自己拼 gh 命令**:全部经 executor 与既有工具函数,`phase34` 的 argv 契约网
|
|
10
|
+
* 因此能覆盖到这里的每一次调用;
|
|
11
|
+
* 2. **默认零写回**:三个原语里只有 `review_pr` 会写远端,且必须 `write: true` +
|
|
12
|
+
* `confirm: true` 双门(闸门见 `destructive.ts` 的 `github_task` 段);
|
|
13
|
+
* 3. **任何裁剪都显式**:装不下就写进 `truncated`,并区分「裁了多少」与「连总数都不知道」。
|
|
14
|
+
*/
|
|
15
|
+
import { wrapWithGate } from '../destructive.js';
|
|
16
|
+
import { readReviewRules } from '../review-rules.js';
|
|
17
|
+
import { extractFailureAnchors } from '../executor/failure-anchors.js';
|
|
18
|
+
import { renderTaskMarkdown } from '../renderers/markdown.js';
|
|
19
|
+
import { classifyCommits, extractFileAnchors, finalizeEnvelope, renderReleaseNotes, selectFilesByBudget, suggestVersion, } from '../task-primitives.js';
|
|
20
|
+
/**
|
|
21
|
+
* 把 `pr checks` 的 bucket/state 归并成四态计数。
|
|
22
|
+
*
|
|
23
|
+
* 为什么是四态而不是三态:gh 的 bucket 有 `skipping` / `cancel` 两个值,
|
|
24
|
+
* 把它们算进 pass 会让结论显示「CI 全绿」——而实际是「跳过/取消」,两者对
|
|
25
|
+
* 「这个 PR 能不能合」的含义完全不同。宁可多一个 ⏭ 计数,也不能谎报绿。
|
|
26
|
+
*/
|
|
27
|
+
function summarizeChecks(items) {
|
|
28
|
+
let pass = 0;
|
|
29
|
+
let fail = 0;
|
|
30
|
+
let pending = 0;
|
|
31
|
+
let skipped = 0;
|
|
32
|
+
const failing = [];
|
|
33
|
+
const FAIL_STATES = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED']);
|
|
34
|
+
const PENDING_STATES = new Set(['PENDING', 'IN_PROGRESS', 'QUEUED', 'WAITING', 'REQUESTED', 'EXPECTED']);
|
|
35
|
+
const SKIP_STATES = new Set(['SKIPPED', 'CANCELLED', 'NEUTRAL', 'STALE']);
|
|
36
|
+
for (const it of Array.isArray(items) ? items : []) {
|
|
37
|
+
const bucket = String(it?.bucket || '').toLowerCase();
|
|
38
|
+
const state = String(it?.state || '').toUpperCase();
|
|
39
|
+
if (bucket === 'fail' || FAIL_STATES.has(state)) {
|
|
40
|
+
fail++;
|
|
41
|
+
if (it?.name)
|
|
42
|
+
failing.push(String(it.name));
|
|
43
|
+
}
|
|
44
|
+
else if (bucket === 'pending' || PENDING_STATES.has(state)) {
|
|
45
|
+
pending++;
|
|
46
|
+
}
|
|
47
|
+
else if (bucket === 'skipping' || bucket === 'cancel' || SKIP_STATES.has(state)) {
|
|
48
|
+
skipped++;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
pass++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { pass, fail, pending, skipped, failing };
|
|
55
|
+
}
|
|
56
|
+
export function createTaskTool(executor) {
|
|
57
|
+
return wrapWithGate({
|
|
58
|
+
name: 'github_task',
|
|
59
|
+
description: '任务级原语:一次调用完成「审一个 PR」「诊断一次 CI 失败」「准备一次发版」三件事,返回已裁剪、可直接执行的结构化结论(summary + facts + nextActions + cost)。默认只读;只有 review_pr 在 write:true 且 confirm:true 时才写回行级评注。需要细粒度控制时请改用对应的原子工具(github_pr / github_run / github_release)。',
|
|
60
|
+
parameters: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
action: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
enum: ['review_pr', 'diagnose_ci', 'prepare_release'],
|
|
66
|
+
description: '任务类型。review_pr=读 PR 元数据/diff/CI/仓库规则并给出审阅上下文包(可选写回行级评注);diagnose_ci=定位失败 job/step 并萃取日志锚点;prepare_release=按 Conventional Commits 推导版本并生成 notes 草稿(只准备,不发版)。',
|
|
67
|
+
},
|
|
68
|
+
pr_number: {
|
|
69
|
+
type: 'number',
|
|
70
|
+
description: 'PR 编号(review_pr 必填;diagnose_ci 可选,给了就取该 PR 的失败 run)。',
|
|
71
|
+
},
|
|
72
|
+
run_id: {
|
|
73
|
+
type: 'number',
|
|
74
|
+
description: 'Actions run id(diagnose_ci 使用;与 pr_number 二选一,同时给时以 run_id 为准)。',
|
|
75
|
+
},
|
|
76
|
+
repo: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: '目标仓库 [owner/repo],留空自动识别当前本地 Git 仓库。',
|
|
79
|
+
},
|
|
80
|
+
focus: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
enum: ['security', 'correctness', 'perf', 'style'],
|
|
83
|
+
description: 'review_pr 的审阅侧重,仅影响 summary 的提示语与 nextActions,不改变取数。',
|
|
84
|
+
},
|
|
85
|
+
budget_chars: {
|
|
86
|
+
type: 'number',
|
|
87
|
+
description: 'review_pr 的 diff 预算(字符,默认 24000)。超出预算的文件只保留元信息并计入 truncated。',
|
|
88
|
+
},
|
|
89
|
+
write: {
|
|
90
|
+
type: 'boolean',
|
|
91
|
+
description: 'review_pr:是否真的写回 review(默认 false=只读)。为 true 时必须同时传 confirm: true。',
|
|
92
|
+
},
|
|
93
|
+
body: {
|
|
94
|
+
type: 'string',
|
|
95
|
+
description: 'review_pr 写回时的整体说明(缺省会被补 [P2] 严重度前缀)。',
|
|
96
|
+
},
|
|
97
|
+
comments: {
|
|
98
|
+
type: 'array',
|
|
99
|
+
items: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
path: { type: 'string' },
|
|
103
|
+
line: { type: 'number' },
|
|
104
|
+
side: { type: 'string', enum: ['LEFT', 'RIGHT'] },
|
|
105
|
+
start_line: { type: 'number' },
|
|
106
|
+
body: { type: 'string' },
|
|
107
|
+
},
|
|
108
|
+
required: ['path', 'body'],
|
|
109
|
+
},
|
|
110
|
+
description: 'review_pr 写回时的行级评注列表(上限 50 条),字段语义与 github_pr(action:"review") 的 comments 完全一致。',
|
|
111
|
+
},
|
|
112
|
+
review_decision: {
|
|
113
|
+
type: 'string',
|
|
114
|
+
enum: ['APPROVE', 'REQUEST_CHANGES', 'COMMENT'],
|
|
115
|
+
description: 'review_pr 写回时的判定,默认 COMMENT。',
|
|
116
|
+
},
|
|
117
|
+
framework: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
enum: ['jest', 'vitest', 'go', 'cargo', 'auto'],
|
|
120
|
+
description: 'diagnose_ci 的框架提示。当前萃取器按日志锚点自适应(auto 即可),该参数用于在 summary 中标注预期。',
|
|
121
|
+
},
|
|
122
|
+
from_tag: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'prepare_release 的起始 tag,缺省取最近一个 tag(没有 tag 时统计全部提交)。',
|
|
125
|
+
},
|
|
126
|
+
bump: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
enum: ['auto', 'patch', 'minor', 'major'],
|
|
129
|
+
description: 'prepare_release 的版本递增规则,auto 时按 breaking→major / feat→minor / 其余→patch 推导。',
|
|
130
|
+
},
|
|
131
|
+
draft: {
|
|
132
|
+
type: 'boolean',
|
|
133
|
+
description: 'prepare_release:建议的发布命令是否带 --draft(默认 true)。本原语不会真的发版。',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
required: ['action'],
|
|
137
|
+
},
|
|
138
|
+
output: {
|
|
139
|
+
schema: { type: 'object' },
|
|
140
|
+
render(args, value) {
|
|
141
|
+
return [{ type: 'text', text: renderTaskMarkdown(args, value) }];
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
async execute(args, execContext) {
|
|
145
|
+
const { cwd, sessionId } = executor.resolveExecScope(execContext);
|
|
146
|
+
const startedAt = Date.now();
|
|
147
|
+
// 只增计数器 → 取差值即本原语真实的 gh 调用数(不手写、不估算)
|
|
148
|
+
const ghCallsBefore = typeof executor.ghCalls === 'number' ? executor.ghCalls : 0;
|
|
149
|
+
const costMeta = () => ({
|
|
150
|
+
ghCalls: (typeof executor.ghCalls === 'number' ? executor.ghCalls : 0) - ghCallsBefore,
|
|
151
|
+
elapsedMs: Date.now() - startedAt,
|
|
152
|
+
});
|
|
153
|
+
let repoTarget = args.repo;
|
|
154
|
+
if (!repoTarget) {
|
|
155
|
+
const meta = await executor.getRepoMetadata(cwd, sessionId);
|
|
156
|
+
if (meta?.nameWithOwner)
|
|
157
|
+
repoTarget = meta.nameWithOwner;
|
|
158
|
+
}
|
|
159
|
+
const repoArgs = repoTarget ? ['-R', repoTarget] : [];
|
|
160
|
+
const runOpts = { cwd, sessionId };
|
|
161
|
+
// ---------------------------------------------------------------- review_pr
|
|
162
|
+
if (args.action === 'review_pr') {
|
|
163
|
+
const prNumber = Number(args.pr_number);
|
|
164
|
+
if (!prNumber || !Number.isInteger(prNumber) || prNumber <= 0) {
|
|
165
|
+
throw new Error('review_pr 需要有效的 pr_number(正整数)');
|
|
166
|
+
}
|
|
167
|
+
const budget = typeof args.budget_chars === 'number' && args.budget_chars > 0 ? args.budget_chars : 24_000;
|
|
168
|
+
// 一次拿齐元数据 + diff + 既有行级评论(getPrDetail 内部 3 路并发)
|
|
169
|
+
const detail = await executor.getPrDetail(cwd, prNumber, repoTarget);
|
|
170
|
+
if (!detail.ok || !detail.data)
|
|
171
|
+
throw new Error(detail.error || `读取 PR #${prNumber} 失败`);
|
|
172
|
+
const d = detail.data;
|
|
173
|
+
const checksRes = await executor.run(['pr', 'checks', String(prNumber), ...repoArgs, '--json', 'name,state,bucket,link'], { ...runOpts, timeoutMs: 15_000 });
|
|
174
|
+
// checks 失败不算致命:有的仓库没有 check,或 token 无 checks 读权限
|
|
175
|
+
const checks = summarizeChecks(checksRes.ok && Array.isArray(checksRes.data) ? checksRes.data : []);
|
|
176
|
+
const rules = readReviewRules(cwd, d.files.map((f) => f.path));
|
|
177
|
+
const allFiles = d.files.map((f) => ({
|
|
178
|
+
path: f.path,
|
|
179
|
+
additions: f.additions,
|
|
180
|
+
deletions: f.deletions,
|
|
181
|
+
patch: d.fileDiffs?.[f.path],
|
|
182
|
+
}));
|
|
183
|
+
/*
|
|
184
|
+
* 预算口径 = **整包**,不是「只算 diff」。
|
|
185
|
+
*
|
|
186
|
+
* 为什么:原语的存在意义是「别把上下文撑爆」,如果只约束 diff 而让
|
|
187
|
+
* summary/facts/nextActions 的固定开销把总量顶到预算之上,那这条约束就是假的
|
|
188
|
+
* (实测 61 文件 PR:diff 按 24K 选完,整包 28.8K,反而超了 4.8K)。
|
|
189
|
+
* 所以先按预算选一次,量出整包体积;超了就按超出量回缩再选一次(一次收敛即可)。
|
|
190
|
+
*/
|
|
191
|
+
const buildEnvelope = (budgetUsed) => {
|
|
192
|
+
const budgeted = selectFilesByBudget(allFiles, budgetUsed);
|
|
193
|
+
const truncatedFields = [];
|
|
194
|
+
if (budgeted.omitted > 0)
|
|
195
|
+
truncatedFields.push('files[].patch');
|
|
196
|
+
if (d.filesTruncated)
|
|
197
|
+
truncatedFields.push('files(gh 100 条上限)');
|
|
198
|
+
if (d.diffTruncated)
|
|
199
|
+
truncatedFields.push('fileDiffs(输出阈值)');
|
|
200
|
+
const facts = {
|
|
201
|
+
pr: prNumber,
|
|
202
|
+
title: d.title,
|
|
203
|
+
sha: d.headRefOid,
|
|
204
|
+
baseSha: d.baseRefOid,
|
|
205
|
+
changedFiles: d.changedFiles ?? d.files.length,
|
|
206
|
+
filesTruncated: d.filesTruncated === true,
|
|
207
|
+
files: budgeted.selected,
|
|
208
|
+
checks,
|
|
209
|
+
rules: rules ? { sources: rules.sources.map((s) => ({ path: s.path, content: s.content })), truncated: rules.truncated } : null,
|
|
210
|
+
existingLineComments: Array.isArray(d.lineComments) ? d.lineComments.length : 0,
|
|
211
|
+
};
|
|
212
|
+
const summaryBits = [
|
|
213
|
+
`PR #${prNumber}「${d.title}」`,
|
|
214
|
+
`锚点 ${String(d.headRefOid || '').slice(0, 7) || '(无 SHA)'}`,
|
|
215
|
+
`改动 ${d.changedFiles ?? d.files.length} 文件 (+${d.additions}/-${d.deletions})`,
|
|
216
|
+
`CI ✅${checks.pass} ❌${checks.fail} ⏳${checks.pending}${checks.skipped > 0 ? ` ⏭${checks.skipped}` : ''}`,
|
|
217
|
+
`已附 ${budgeted.selected.filter((f) => f.patch).length} 个文件的 diff`,
|
|
218
|
+
];
|
|
219
|
+
if (rules)
|
|
220
|
+
summaryBits.push(`仓库规则 ${rules.sources.length} 份`);
|
|
221
|
+
const focusHint = args.focus === 'security'
|
|
222
|
+
? '重点看权限校验、输入边界与密钥处理。'
|
|
223
|
+
: args.focus === 'correctness'
|
|
224
|
+
? '重点看分支条件、错误路径与并发。'
|
|
225
|
+
: args.focus === 'perf'
|
|
226
|
+
? '重点看循环内的重复 IO、无界集合与缓存失效。'
|
|
227
|
+
: args.focus === 'style'
|
|
228
|
+
? '重点看命名、注释与一致性。'
|
|
229
|
+
: '';
|
|
230
|
+
const summary = summaryBits.join(';') + (focusHint ? `\n${focusHint}` : '');
|
|
231
|
+
const nextActions = [
|
|
232
|
+
`github_pr(action:"checks", pr_number:${prNumber}) — 逐条看 CI 结论`,
|
|
233
|
+
`github_pr(action:"diff", pr_number:${prNumber}, stat_only:true) — 全量文件清单`,
|
|
234
|
+
`github_pr(action:"review", pr_number:${prNumber}, comments:[…], confirm:true) — 写回行级评注`,
|
|
235
|
+
];
|
|
236
|
+
if (checks.failing.length > 0) {
|
|
237
|
+
nextActions.unshift(`github_task(action:"diagnose_ci", pr_number:${prNumber}) — 先解释失败项:${checks.failing.slice(0, 3).join('、')}`);
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
facts,
|
|
241
|
+
envelope: finalizeEnvelope(summary, facts, nextActions, {
|
|
242
|
+
...costMeta(),
|
|
243
|
+
...(truncatedFields.length > 0
|
|
244
|
+
? {
|
|
245
|
+
truncated: {
|
|
246
|
+
fields: truncatedFields,
|
|
247
|
+
reason: `结论包预算 ${budget} 字符;未包含的文件可能只有元信息,也可能整条省略(条目上限 40)`,
|
|
248
|
+
totalUnknown: false,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
: {}),
|
|
252
|
+
}),
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
let built = buildEnvelope(budget);
|
|
256
|
+
/*
|
|
257
|
+
* 整包超预算 → 按超出量回缩,最多两轮。
|
|
258
|
+
*
|
|
259
|
+
* 为什么可能不止一轮:条目元信息本身也是体积(已由 maxEntries=40 封顶,
|
|
260
|
+
* 但 summary/facts/nextActions 的固定开销仍随 PR 大小浮动)。两轮足够收敛,
|
|
261
|
+
* 再多就是拿精度换死磕——真要更小,调用方该自己传更小的 budget_chars。
|
|
262
|
+
*/
|
|
263
|
+
for (let round = 0; round < 2 && built.envelope.cost.chars > budget && budget > 2_000; round++) {
|
|
264
|
+
const shrink = Math.max(1_500, budget - (built.envelope.cost.chars - budget) - 500);
|
|
265
|
+
if (shrink >= budget)
|
|
266
|
+
break;
|
|
267
|
+
built = buildEnvelope(shrink);
|
|
268
|
+
}
|
|
269
|
+
const envelope = built.envelope;
|
|
270
|
+
// 写回:只有显式 write + confirm(闸门已在 wrapWithGate 里挡过一次)才执行
|
|
271
|
+
if (args.write === true) {
|
|
272
|
+
const res = await executor.createPrReview(cwd, repoTarget, prNumber, {
|
|
273
|
+
event: args.review_decision,
|
|
274
|
+
body: args.body,
|
|
275
|
+
comments: Array.isArray(args.comments) ? args.comments : [],
|
|
276
|
+
commit_id: d.headRefOid,
|
|
277
|
+
});
|
|
278
|
+
if (!res.ok)
|
|
279
|
+
throw new Error(res.error || 'Review 写回失败');
|
|
280
|
+
const writeEnvelope = finalizeEnvelope(`${envelope.summary}\n\n✅ 已写回 Review${res.degraded ? '(行级评注降级为正文)' : ''}:${res.data?.htmlUrl || ''}`, { ...built.facts, written: { htmlUrl: res.data?.htmlUrl, state: res.data?.state, degraded: res.degraded === true } }, [`github_pr(action:"view", pr_number:${prNumber}) — 复核已写入的内容`], { ...costMeta(), ...(envelope.truncated ? { truncated: envelope.truncated } : {}) });
|
|
281
|
+
return { ...writeEnvelope, degraded: res.degraded === true, degradedReason: res.degradedReason };
|
|
282
|
+
}
|
|
283
|
+
return envelope;
|
|
284
|
+
}
|
|
285
|
+
// ---------------------------------------------------------------- diagnose_ci
|
|
286
|
+
if (args.action === 'diagnose_ci') {
|
|
287
|
+
let runId = Number(args.run_id);
|
|
288
|
+
let fromPr = null;
|
|
289
|
+
if ((!runId || !Number.isInteger(runId)) && args.pr_number) {
|
|
290
|
+
fromPr = Number(args.pr_number);
|
|
291
|
+
const checksRes = await executor.run(['pr', 'checks', String(fromPr), ...repoArgs, '--json', 'name,state,bucket,link'], { ...runOpts, timeoutMs: 15_000 });
|
|
292
|
+
if (!checksRes.ok || !Array.isArray(checksRes.data)) {
|
|
293
|
+
throw new Error(checksRes.error || `读取 PR #${fromPr} 的 checks 失败`);
|
|
294
|
+
}
|
|
295
|
+
// 从 check 的 link 里抠 run id:`https://github.com/o/r/actions/runs/123/job/456`
|
|
296
|
+
for (const it of checksRes.data) {
|
|
297
|
+
const m = /\/runs\/(\d+)/.exec(String(it?.link || ''));
|
|
298
|
+
if (m) {
|
|
299
|
+
runId = Number(m[1]);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!runId)
|
|
304
|
+
throw new Error(`PR #${fromPr} 的 checks 里没有可解析的 Actions run(可能是第三方 CI)`);
|
|
305
|
+
}
|
|
306
|
+
if (!runId || !Number.isInteger(runId) || runId <= 0) {
|
|
307
|
+
throw new Error('diagnose_ci 需要 run_id 或 pr_number 之一');
|
|
308
|
+
}
|
|
309
|
+
const viewRes = await executor.run(['run', 'view', String(runId), ...repoArgs, '--json', 'databaseId,name,status,conclusion,jobs,url'], { ...runOpts, timeoutMs: 20_000 });
|
|
310
|
+
if (!viewRes.ok || !viewRes.data)
|
|
311
|
+
throw new Error(viewRes.error || `读取 run #${runId} 失败`);
|
|
312
|
+
const run = viewRes.data;
|
|
313
|
+
const jobs = Array.isArray(run.jobs) ? run.jobs : [];
|
|
314
|
+
const failedJobs = jobs
|
|
315
|
+
.filter((j) => String(j?.conclusion || '').toLowerCase() === 'failure')
|
|
316
|
+
.map((j) => ({
|
|
317
|
+
name: String(j?.name || '(未命名)'),
|
|
318
|
+
conclusion: String(j?.conclusion || ''),
|
|
319
|
+
jobId: Number(j?.databaseId) || undefined,
|
|
320
|
+
steps: Array.isArray(j?.steps)
|
|
321
|
+
? j.steps.filter((s) => String(s?.conclusion || '').toLowerCase() === 'failure').map((s) => String(s?.name || ''))
|
|
322
|
+
: [],
|
|
323
|
+
}));
|
|
324
|
+
// 日志:与 log_failed 同口径放开预算(否则截断发生在萃取之前)
|
|
325
|
+
const logRes = await executor.run(['run', 'view', String(runId), ...repoArgs, '--log-failed'], { ...runOpts, timeoutMs: 60_000, rawText: true, maxOutputChars: 2_000_000, maxOutputBytes: 8 * 1024 * 1024 });
|
|
326
|
+
const rawLog = logRes.ok ? String(logRes.rawOutput || '') : '';
|
|
327
|
+
const anchored = extractFailureAnchors(rawLog);
|
|
328
|
+
const excerpts = anchored.blocks.slice(0, 8).map((b) => {
|
|
329
|
+
const hit = extractFileAnchors(b.text)[0];
|
|
330
|
+
const anchorLine = b.text.split('\n')[0] || '';
|
|
331
|
+
return {
|
|
332
|
+
file: hit ? hit.file : '(日志)',
|
|
333
|
+
line: hit ? hit.line : b.startLine,
|
|
334
|
+
message: anchorLine.replace(/\s+/g, ' ').trim().slice(0, 300),
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
const suspectedFiles = Array.from(new Set(excerpts.map((e) => e.file).filter((f) => f && f !== '(日志)'))).slice(0, 10);
|
|
338
|
+
const summary = [
|
|
339
|
+
`run #${runId}「${run.name || ''}」${run.conclusion || run.status || ''}`,
|
|
340
|
+
`失败任务 ${failedJobs.length} 个`,
|
|
341
|
+
// 日志拉取失败与「没有锚点」是两件事:前者是取数问题,后者是内容问题
|
|
342
|
+
!logRes.ok
|
|
343
|
+
? `日志拉取失败:${String(logRes.error || '').slice(0, 80)}`
|
|
344
|
+
: anchored.blocks.length > 0
|
|
345
|
+
? `萃取 ${anchored.blocks.length} 个失败锚点`
|
|
346
|
+
: '未匹配到框架锚点(已回退尾部 200 行)',
|
|
347
|
+
suspectedFiles.length > 0 ? `疑似文件 ${suspectedFiles.slice(0, 3).join('、')}` : '',
|
|
348
|
+
]
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
.join(';');
|
|
351
|
+
const nextActions = [
|
|
352
|
+
`github_run(action:"log_failed", run_id:${runId}) — 看完整失败日志`,
|
|
353
|
+
`github_run(action:"view", run_id:${runId}) — 看 job/step 状态`,
|
|
354
|
+
];
|
|
355
|
+
if (fromPr)
|
|
356
|
+
nextActions.push(`github_pr(action:"checks", pr_number:${fromPr}) — 看该 PR 的 CI 汇总`);
|
|
357
|
+
const truncated = logRes.ok && logRes.truncated
|
|
358
|
+
? {
|
|
359
|
+
fields: ['log'],
|
|
360
|
+
reason: '日志体量超出输出上限(2MB 字符 / 8MB 字节)',
|
|
361
|
+
// 被裁掉的日志里可能还有别的失败块,所以「总数未知」
|
|
362
|
+
totalUnknown: true,
|
|
363
|
+
}
|
|
364
|
+
: anchored.droppedBlocks > 0
|
|
365
|
+
? {
|
|
366
|
+
fields: ['excerpts'],
|
|
367
|
+
reason: `仅保留前 ${anchored.blocks.length} 个失败块`,
|
|
368
|
+
totalUnknown: false,
|
|
369
|
+
}
|
|
370
|
+
: undefined;
|
|
371
|
+
return finalizeEnvelope(summary, {
|
|
372
|
+
runId,
|
|
373
|
+
name: run.name || '',
|
|
374
|
+
status: run.status || '',
|
|
375
|
+
conclusion: run.conclusion || '',
|
|
376
|
+
url: run.url || '',
|
|
377
|
+
failedJobs,
|
|
378
|
+
excerpts,
|
|
379
|
+
suspectedFiles,
|
|
380
|
+
framework: args.framework || 'auto',
|
|
381
|
+
totalBlocks: anchored.totalBlocks,
|
|
382
|
+
// 日志拉取失败时明确记下来:否则「没锚点」会被误读成「没有失败」
|
|
383
|
+
...(logRes.ok ? {} : { logError: String(logRes.error || '').slice(0, 300) }),
|
|
384
|
+
}, nextActions, { ...costMeta(), ...(truncated ? { truncated } : {}) });
|
|
385
|
+
}
|
|
386
|
+
// ---------------------------------------------------------------- prepare_release
|
|
387
|
+
if (args.action === 'prepare_release') {
|
|
388
|
+
// from_tag 必须透传进 range 计算——只当「版本号基数」会让统计区间与标签对不上
|
|
389
|
+
const inputs = await executor.getReleaseInputs(cwd, typeof args.from_tag === 'string' ? args.from_tag : undefined);
|
|
390
|
+
if (!inputs.ok)
|
|
391
|
+
throw new Error(inputs.error || '读取提交记录失败');
|
|
392
|
+
const fromTag = inputs.fromTag;
|
|
393
|
+
// 正文一起参与归类:BREAKING CHANGE 常写在 body 里,只看标题会漏判 major
|
|
394
|
+
const groups = classifyCommits(inputs.commits.map((c) => c.subject), inputs.commits.map((c) => c.body || ''));
|
|
395
|
+
const suggestion = suggestVersion(fromTag, groups, args.bump || 'auto');
|
|
396
|
+
const notes = renderReleaseNotes(suggestion.version, groups);
|
|
397
|
+
// 体检:本地状态(零 gh 调用)+ 最新 CI 结论(1 次 gh 调用)
|
|
398
|
+
const local = await executor.getLocalGitStatus(cwd);
|
|
399
|
+
const branch = local?.branch || '';
|
|
400
|
+
let ciGreen = null;
|
|
401
|
+
if (branch) {
|
|
402
|
+
const ciRes = await executor.run(['run', 'list', ...repoArgs, '--branch', branch, '--limit', '1', '--json', 'conclusion,status,url'], { ...runOpts, timeoutMs: 15_000 });
|
|
403
|
+
if (ciRes.ok && Array.isArray(ciRes.data) && ciRes.data.length > 0) {
|
|
404
|
+
const c = String(ciRes.data[0]?.conclusion || '').toLowerCase();
|
|
405
|
+
ciGreen = c === 'success' ? true : c === 'failure' ? false : null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const health = {
|
|
409
|
+
clean: (local?.dirtyCount ?? 0) === 0,
|
|
410
|
+
synced: (local?.behind ?? 0) <= 0,
|
|
411
|
+
ciGreen,
|
|
412
|
+
branch,
|
|
413
|
+
};
|
|
414
|
+
const draftFlag = args.draft === false ? '' : ' --draft';
|
|
415
|
+
const nextActions = [
|
|
416
|
+
`git tag v${suggestion.version} && git push origin v${suggestion.version} — 打 tag(人的决定)`,
|
|
417
|
+
`gh release create v${suggestion.version}${draftFlag} --notes-file <notes.md> — 创建 Release`,
|
|
418
|
+
`github_release(action:"create", tag:"v${suggestion.version}", draft:${args.draft === false ? 'false' : 'true'}, confirm:true) — 用工具发版`,
|
|
419
|
+
];
|
|
420
|
+
if (!health.clean)
|
|
421
|
+
nextActions.unshift('先提交或 stash 未提交变更(发版前工作区必须干净)');
|
|
422
|
+
if (!health.synced)
|
|
423
|
+
nextActions.unshift('先 git pull 同步远端(本地落后,发出去的 tag 会漏提交)');
|
|
424
|
+
if (health.ciGreen === false)
|
|
425
|
+
nextActions.unshift('最新 CI 是红的,先修再发版');
|
|
426
|
+
const summary = [
|
|
427
|
+
`自 ${fromTag || '(无 tag)'} 以来 ${inputs.totalCommits} 个提交`,
|
|
428
|
+
`建议版本 v${suggestion.version}(${suggestion.reason})`,
|
|
429
|
+
`体检:工作区${health.clean ? '干净' : '有变更'} / 同步${health.synced ? '正常' : '落后'} / CI ${health.ciGreen === true ? '绿' : health.ciGreen === false ? '红' : '未知'}`,
|
|
430
|
+
].join(';');
|
|
431
|
+
return finalizeEnvelope(summary, {
|
|
432
|
+
fromTag: fromTag || null,
|
|
433
|
+
commitCount: inputs.totalCommits,
|
|
434
|
+
suggestedVersion: suggestion.version,
|
|
435
|
+
bump: suggestion.bump,
|
|
436
|
+
bumpReason: suggestion.reason,
|
|
437
|
+
groups,
|
|
438
|
+
health,
|
|
439
|
+
notes,
|
|
440
|
+
commits: inputs.commits.slice(0, 20).map((c) => ({ sha: c.sha, subject: c.subject })),
|
|
441
|
+
}, nextActions, {
|
|
442
|
+
...costMeta(),
|
|
443
|
+
...(inputs.totalCommits > 20
|
|
444
|
+
? {
|
|
445
|
+
truncated: {
|
|
446
|
+
fields: ['commits'],
|
|
447
|
+
reason: `只列出前 20 个提交(共 ${inputs.totalCommits} 个),版本推导基于全部提交`,
|
|
448
|
+
totalUnknown: false,
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
: {}),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
throw new Error(`未知的 action: ${String(args.action)}(支持 review_pr / diagnose_ci / prepare_release)`);
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
}
|