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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务级原语的纯函数层:提交归类、SemVer 推导、预算裁剪、信封封装。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:
|
|
5
|
+
* 1. 这三个原语的价值是「一轮拿到可执行结论」,结论对不对完全取决于归类与裁剪
|
|
6
|
+
* 这些纯逻辑——它们必须能脱离网络单测,否则只能靠"跑一次看看"验证;
|
|
7
|
+
* 2. `cost` 是原语存在的唯一判据(是否真的比手动串步骤省钱),必须由代码计算,
|
|
8
|
+
* 不能手写常量;
|
|
9
|
+
* 3. 工具层只负责取数与编排,不承载判定规则。
|
|
10
|
+
*/
|
|
11
|
+
const CC_RE = /^(\w+)(?:\([^)]*\))?(!)?:\s*(.+)$/;
|
|
12
|
+
/**
|
|
13
|
+
* 按 Conventional Commits 归类提交标题。
|
|
14
|
+
*
|
|
15
|
+
* 判定优先级:`!` 破坏性标记 > 类型关键字。为什么先判 `!`:
|
|
16
|
+
* `feat(api)!: drop v1` 既是 feat 也是 breaking,而它决定的是 **major** 版本,
|
|
17
|
+
* 归到 feat 会让版本号少跳一级——这是发版事故里最常见的一种。
|
|
18
|
+
*/
|
|
19
|
+
export function classifyCommits(subjects, bodies = []) {
|
|
20
|
+
const out = { breaking: [], feat: [], fix: [], perf: [], refactor: [], docs: [], other: [] };
|
|
21
|
+
const list = Array.isArray(subjects) ? subjects : [];
|
|
22
|
+
list.forEach((raw, i) => {
|
|
23
|
+
const subject = String(raw ?? '').trim();
|
|
24
|
+
if (!subject)
|
|
25
|
+
return;
|
|
26
|
+
const body = String(bodies[i] ?? '');
|
|
27
|
+
const m = CC_RE.exec(subject);
|
|
28
|
+
const type = m ? m[1].toLowerCase() : '';
|
|
29
|
+
const bang = Boolean(m && m[2]);
|
|
30
|
+
const isBreaking = bang || /BREAKING[ -]CHANGE/i.test(body);
|
|
31
|
+
if (isBreaking) {
|
|
32
|
+
out.breaking.push(subject);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (type === 'feat' || type === 'feature')
|
|
36
|
+
out.feat.push(subject);
|
|
37
|
+
else if (type === 'fix' || type === 'bugfix')
|
|
38
|
+
out.fix.push(subject);
|
|
39
|
+
else if (type === 'perf')
|
|
40
|
+
out.perf.push(subject);
|
|
41
|
+
else if (type === 'refactor')
|
|
42
|
+
out.refactor.push(subject);
|
|
43
|
+
else if (type === 'docs' || type === 'doc')
|
|
44
|
+
out.docs.push(subject);
|
|
45
|
+
else
|
|
46
|
+
out.other.push(subject);
|
|
47
|
+
});
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** 解析 `v1.2.3` / `1.2.3` / `v1.2.3-rc.1` 形式的版本号 */
|
|
51
|
+
export function parseSemver(tag) {
|
|
52
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/i.exec(String(tag ?? '').trim());
|
|
53
|
+
if (!m)
|
|
54
|
+
return null;
|
|
55
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 推导下一个版本号。
|
|
59
|
+
*
|
|
60
|
+
* 无 tag 时按 `0.1.0` 起步(而不是 1.0.0):新仓库的第一个版本不该被当成稳定 API。
|
|
61
|
+
* `auto` 的判据:breaking → major,feat → minor,其余 → patch。
|
|
62
|
+
*/
|
|
63
|
+
export function suggestVersion(currentTag, groups, bump = 'auto') {
|
|
64
|
+
const cur = parseSemver(currentTag) || { major: 0, minor: 0, patch: 0 };
|
|
65
|
+
const hasBreaking = groups.breaking.length > 0;
|
|
66
|
+
const hasFeat = groups.feat.length > 0;
|
|
67
|
+
let kind;
|
|
68
|
+
if (bump !== 'auto')
|
|
69
|
+
kind = bump;
|
|
70
|
+
else if (hasBreaking)
|
|
71
|
+
kind = 'major';
|
|
72
|
+
else if (hasFeat)
|
|
73
|
+
kind = 'minor';
|
|
74
|
+
else
|
|
75
|
+
kind = 'patch';
|
|
76
|
+
let next;
|
|
77
|
+
if (kind === 'major')
|
|
78
|
+
next = { major: cur.major + 1, minor: 0, patch: 0 };
|
|
79
|
+
else if (kind === 'minor')
|
|
80
|
+
next = { major: cur.major, minor: cur.minor + 1, patch: 0 };
|
|
81
|
+
else
|
|
82
|
+
next = { major: cur.major, minor: cur.minor, patch: cur.patch + 1 };
|
|
83
|
+
const reason = bump !== 'auto'
|
|
84
|
+
? `按显式指定的 ${bump} 规则递增`
|
|
85
|
+
: hasBreaking
|
|
86
|
+
? `存在 ${groups.breaking.length} 条破坏性变更 → major`
|
|
87
|
+
: hasFeat
|
|
88
|
+
? `存在 ${groups.feat.length} 条新功能 → minor`
|
|
89
|
+
: `只有修复/其它提交(fix ${groups.fix.length} / perf ${groups.perf.length})→ patch`;
|
|
90
|
+
return { version: `${next.major}.${next.minor}.${next.patch}`, bump: kind, reason };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 按字符预算挑选文件,保证「送进上下文的东西一定装得下」。
|
|
94
|
+
*
|
|
95
|
+
* 三条约束:
|
|
96
|
+
* 1. **按变更量降序**取——大改动更可能是问题的所在,小改动先丢;
|
|
97
|
+
* 2. 单个文件即使超预算也不硬塞(否则一个超大 diff 会把预算吃光,剩下的文件
|
|
98
|
+
* 一个都看不到)。超预算的文件只保留 path/增删行数,patch 留空并计入 omitted;
|
|
99
|
+
* 3. **条目数也要封顶**:60 个文件的元信息本身就有 3–4K 字符,只约束 patch 大小
|
|
100
|
+
* 等于把「固定开销」排除在预算之外(实测整包因此超预算 4.8K)。超出条目上限的
|
|
101
|
+
* 文件连元信息都不列,只计入 omitted。
|
|
102
|
+
*/
|
|
103
|
+
export function selectFilesByBudget(files, budgetChars, maxEntries = 40) {
|
|
104
|
+
const budget = Number.isFinite(budgetChars) && budgetChars > 0 ? Math.floor(budgetChars) : 24_000;
|
|
105
|
+
const limit = Number.isFinite(maxEntries) && maxEntries > 0 ? Math.floor(maxEntries) : 40;
|
|
106
|
+
const list = Array.isArray(files) ? [...files] : [];
|
|
107
|
+
list.sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
|
|
108
|
+
const selected = [];
|
|
109
|
+
let used = 0;
|
|
110
|
+
let omitted = 0;
|
|
111
|
+
for (const f of list) {
|
|
112
|
+
if (selected.length >= limit) {
|
|
113
|
+
omitted++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const patch = typeof f.patch === 'string' ? f.patch : '';
|
|
117
|
+
const cost = patch.length + f.path.length + 40;
|
|
118
|
+
if (used + cost > budget) {
|
|
119
|
+
omitted++;
|
|
120
|
+
// 元信息仍保留:让调用方知道"还有这些文件",只是没给正文
|
|
121
|
+
selected.push({ path: f.path, additions: f.additions, deletions: f.deletions });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
used += cost;
|
|
125
|
+
selected.push(patch ? { ...f, patch } : { path: f.path, additions: f.additions, deletions: f.deletions });
|
|
126
|
+
}
|
|
127
|
+
return { selected, omitted, chars: used };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 封装信封并计算 `cost.chars`。
|
|
131
|
+
*
|
|
132
|
+
* `chars` 的口径是**最终返回给模型的那份 JSON 的字符数**(不含 cost 自身,
|
|
133
|
+
* 否则会自指)。这样「原语是否省钱」才有可比的数字。
|
|
134
|
+
*/
|
|
135
|
+
export function finalizeEnvelope(summary, facts, nextActions, meta) {
|
|
136
|
+
const payload = {
|
|
137
|
+
summary,
|
|
138
|
+
facts,
|
|
139
|
+
...(meta.truncated ? { truncated: meta.truncated } : {}),
|
|
140
|
+
nextActions,
|
|
141
|
+
};
|
|
142
|
+
const chars = JSON.stringify(payload).length;
|
|
143
|
+
return {
|
|
144
|
+
...payload,
|
|
145
|
+
cost: {
|
|
146
|
+
chars,
|
|
147
|
+
ghCalls: Math.max(0, Math.floor(meta.ghCalls || 0)),
|
|
148
|
+
elapsedMs: Math.max(0, Math.floor(meta.elapsedMs || 0)),
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** 从日志片段里抽取 `path:line` 形态的定位锚点(供 diagnose_ci 汇总疑似文件) */
|
|
153
|
+
export function extractFileAnchors(text) {
|
|
154
|
+
const src = typeof text === 'string' ? text : '';
|
|
155
|
+
const out = [];
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
// 形如 `src/a.ts:12:5` / `app/divide.go:9` / `tests/test_api.py::test_x` 的前两种
|
|
158
|
+
const re = /(?:^|\s)([\w./\\-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|cpp|c|h|swift|vue|svelte)):(\d+)/g;
|
|
159
|
+
let m;
|
|
160
|
+
while ((m = re.exec(src)) !== null) {
|
|
161
|
+
const file = m[1].replace(/\\/g, '/');
|
|
162
|
+
const line = Number(m[2]);
|
|
163
|
+
const key = `${file}:${line}`;
|
|
164
|
+
if (!Number.isFinite(line) || line <= 0 || seen.has(key))
|
|
165
|
+
continue;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
out.push({ file, line });
|
|
168
|
+
if (out.length >= 20)
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** 把归类结果渲染成 Release notes 草稿(按类型分组,breaking 置顶) */
|
|
174
|
+
export function renderReleaseNotes(version, groups) {
|
|
175
|
+
const section = (title, items) => items.length > 0 ? `### ${title}\n${items.map((s) => `- ${s}`).join('\n')}` : '';
|
|
176
|
+
const parts = [`## v${version}`];
|
|
177
|
+
const breaking = section('⚠️ Breaking Changes', groups.breaking);
|
|
178
|
+
if (breaking)
|
|
179
|
+
parts.push(breaking);
|
|
180
|
+
const feat = section('Features', groups.feat);
|
|
181
|
+
if (feat)
|
|
182
|
+
parts.push(feat);
|
|
183
|
+
const fix = section('Fixes', groups.fix);
|
|
184
|
+
if (fix)
|
|
185
|
+
parts.push(fix);
|
|
186
|
+
const perf = section('Performance', groups.perf);
|
|
187
|
+
if (perf)
|
|
188
|
+
parts.push(perf);
|
|
189
|
+
const rest = [...groups.refactor, ...groups.docs, ...groups.other];
|
|
190
|
+
const other = section('Others', rest);
|
|
191
|
+
if (other)
|
|
192
|
+
parts.push(other);
|
|
193
|
+
return parts.join('\n\n');
|
|
194
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具档位(toolsets)+ 只读档位(read_only)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:档位与写清单必须是**一张可读的表**。散落在 18 个工具文件里的话,
|
|
5
|
+
* 「当前到底有哪些工具、哪些是写」就无法一眼回答,也就无法被测试穷举。
|
|
6
|
+
* 本文件是纯函数 + 纯数据,零 I/O、零依赖,可离线单测。
|
|
7
|
+
*
|
|
8
|
+
* 默认零破坏:`toolsets` 缺省 → full 全量 18 个;`read_only` 缺省 → 不剪任何 action。
|
|
9
|
+
*/
|
|
10
|
+
/** 工具的最小结构面(只需要 name / description / parameters 三个字段)。 */
|
|
11
|
+
export interface ToolLike {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
parameters?: {
|
|
15
|
+
type?: string;
|
|
16
|
+
properties?: Record<string, any>;
|
|
17
|
+
required?: string[];
|
|
18
|
+
[key: string]: any;
|
|
19
|
+
};
|
|
20
|
+
[key: string]: any;
|
|
21
|
+
}
|
|
22
|
+
export interface TierConfig {
|
|
23
|
+
toolsets?: string[];
|
|
24
|
+
read_only?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** 预设档位。`full` 不代表具体名单,由 resolveToolNames 特判为「全部」。 */
|
|
27
|
+
export declare const TOOL_PRESETS: Record<string, string[]>;
|
|
28
|
+
/**
|
|
29
|
+
* 写 action 清单:只读档位下这些 action 会从 enum 里被**注册期剔除**(不是运行时 if)。
|
|
30
|
+
*
|
|
31
|
+
* 判定口径:逐个 `case '...'` 分支看它是否产生远端写、本地落盘或不可逆副作用;
|
|
32
|
+
* 读不准的一律保守归为写(例如 `run download` 会往本地磁盘写文件,计入写)。
|
|
33
|
+
* `github_api` 记 `'*'`:端点与 method 均可任意,无法降级,只读模式下整体剔除。
|
|
34
|
+
*/
|
|
35
|
+
export declare const WRITE_ACTIONS: Record<string, string[] | '*'>;
|
|
36
|
+
/**
|
|
37
|
+
* 把 `toolsets` 配置解析成具体工具名清单。
|
|
38
|
+
*
|
|
39
|
+
* - `undefined` / 空数组 → 返回 `'all'`(默认全量,零破坏)
|
|
40
|
+
* - 预设名与裸工具名可混用,取并集
|
|
41
|
+
* - 未知名字:console.warn 一次并忽略。**不静默**——静默减少工具会让模型
|
|
42
|
+
* 「看不见工具」却不知道为什么,是比多注册一个工具更难排查的故障。
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveToolNames(toolsets: string[] | undefined, allNames: string[]): string[] | 'all';
|
|
45
|
+
/**
|
|
46
|
+
* 按档位裁剪工具列表(纯函数,不修改入参)。
|
|
47
|
+
*
|
|
48
|
+
* 只读档位下对**混合工具**剪 action enum 而不是整只剔除:模型在 schema 层就看不到
|
|
49
|
+
* 写 action,宿主的参数校验会直接拒绝,可审计且不需要运行时分支。
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyTier(tools: ToolLike[], cfg: TierConfig): {
|
|
52
|
+
kept: ToolLike[];
|
|
53
|
+
/** 被整体剔除的工具名(只读模式下的 github_api,或档位外的工具) */
|
|
54
|
+
dropped: string[];
|
|
55
|
+
/** 被剪掉写 action 的工具名 → 剪掉前的完整 enum(供诊断与测试消费) */
|
|
56
|
+
pruned: Record<string, string[]>;
|
|
57
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具档位(toolsets)+ 只读档位(read_only)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独成文件:档位与写清单必须是**一张可读的表**。散落在 18 个工具文件里的话,
|
|
5
|
+
* 「当前到底有哪些工具、哪些是写」就无法一眼回答,也就无法被测试穷举。
|
|
6
|
+
* 本文件是纯函数 + 纯数据,零 I/O、零依赖,可离线单测。
|
|
7
|
+
*
|
|
8
|
+
* 默认零破坏:`toolsets` 缺省 → full 全量 18 个;`read_only` 缺省 → 不剪任何 action。
|
|
9
|
+
*/
|
|
10
|
+
/** 预设档位。`full` 不代表具体名单,由 resolveToolNames 特判为「全部」。 */
|
|
11
|
+
export const TOOL_PRESETS = {
|
|
12
|
+
full: [],
|
|
13
|
+
/**
|
|
14
|
+
* 最小可用面。
|
|
15
|
+
*
|
|
16
|
+
* s8 之后 core 的取向变了:**用 3 个任务替代 15 个领域工具**——
|
|
17
|
+
* `github_task`(审 PR / 诊断 CI / 准备发版)+ `github_repo`(认仓库、读文件)
|
|
18
|
+
* + `github_help`(查指南)。要细粒度控制时再显式挂 github_pr / github_run。
|
|
19
|
+
*/
|
|
20
|
+
core: ['github_task', 'github_repo', 'github_help'],
|
|
21
|
+
/** 流水线值守:CI 诊断与排障所需的只读面更大 */
|
|
22
|
+
ci: [
|
|
23
|
+
'github_run', 'github_pr', 'github_issue', 'github_repo',
|
|
24
|
+
'github_status', 'github_ruleset', 'github_help',
|
|
25
|
+
],
|
|
26
|
+
/** 社区维护:议题/评审/发版/标签/看板 */
|
|
27
|
+
maintain: [
|
|
28
|
+
'github_issue', 'github_pr', 'github_label', 'github_release',
|
|
29
|
+
'github_discussion', 'github_project', 'github_skill', 'github_help',
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* 写 action 清单:只读档位下这些 action 会从 enum 里被**注册期剔除**(不是运行时 if)。
|
|
34
|
+
*
|
|
35
|
+
* 判定口径:逐个 `case '...'` 分支看它是否产生远端写、本地落盘或不可逆副作用;
|
|
36
|
+
* 读不准的一律保守归为写(例如 `run download` 会往本地磁盘写文件,计入写)。
|
|
37
|
+
* `github_api` 记 `'*'`:端点与 method 均可任意,无法降级,只读模式下整体剔除。
|
|
38
|
+
*/
|
|
39
|
+
export const WRITE_ACTIONS = {
|
|
40
|
+
github_pr: ['checkout', 'create', 'review', 'merge', 'ready', 'revert', 'update_branch', 'comment', 'edit'],
|
|
41
|
+
github_issue: ['create', 'edit', 'develop', 'comment', 'close', 'reopen', 'pin', 'unpin', 'lock', 'unlock', 'delete', 'transfer'],
|
|
42
|
+
github_run: ['workflow_enable', 'workflow_disable', 'trigger', 'rerun', 'cancel', 'download'],
|
|
43
|
+
github_repo: ['sync'],
|
|
44
|
+
github_cache: ['delete'],
|
|
45
|
+
github_release: ['create', 'upload', 'delete', 'download'],
|
|
46
|
+
github_secret: ['set', 'delete'],
|
|
47
|
+
github_label: ['create', 'edit', 'delete', 'clone'],
|
|
48
|
+
github_gist: ['create', 'edit', 'delete'],
|
|
49
|
+
github_project: ['item-add', 'item-create', 'item-delete'],
|
|
50
|
+
github_codespace: ['stop', 'delete'],
|
|
51
|
+
github_discussion: ['create', 'edit', 'comment'],
|
|
52
|
+
github_agent_task: ['create'],
|
|
53
|
+
github_skill: ['install', 'publish', 'update'],
|
|
54
|
+
// 任务原语里只有 review_pr 会写远端(且需 write+confirm 双门);diagnose_ci / prepare_release 全只读
|
|
55
|
+
github_task: ['review_pr'],
|
|
56
|
+
github_ruleset: [],
|
|
57
|
+
github_status: [],
|
|
58
|
+
github_help: [],
|
|
59
|
+
github_api: '*',
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* 把 `toolsets` 配置解析成具体工具名清单。
|
|
63
|
+
*
|
|
64
|
+
* - `undefined` / 空数组 → 返回 `'all'`(默认全量,零破坏)
|
|
65
|
+
* - 预设名与裸工具名可混用,取并集
|
|
66
|
+
* - 未知名字:console.warn 一次并忽略。**不静默**——静默减少工具会让模型
|
|
67
|
+
* 「看不见工具」却不知道为什么,是比多注册一个工具更难排查的故障。
|
|
68
|
+
*/
|
|
69
|
+
export function resolveToolNames(toolsets, allNames) {
|
|
70
|
+
if (!toolsets || toolsets.length === 0)
|
|
71
|
+
return 'all';
|
|
72
|
+
const picked = new Set();
|
|
73
|
+
const unknown = [];
|
|
74
|
+
for (const raw of toolsets) {
|
|
75
|
+
const name = typeof raw === 'string' ? raw.trim() : '';
|
|
76
|
+
if (!name)
|
|
77
|
+
continue;
|
|
78
|
+
const preset = TOOL_PRESETS[name];
|
|
79
|
+
if (preset) {
|
|
80
|
+
if (name === 'full')
|
|
81
|
+
return 'all';
|
|
82
|
+
for (const t of preset)
|
|
83
|
+
picked.add(t);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (allNames.includes(name)) {
|
|
87
|
+
picked.add(name);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
unknown.push(name);
|
|
91
|
+
}
|
|
92
|
+
if (unknown.length > 0) {
|
|
93
|
+
console.warn(`[dsh-github-flow] toolsets 中存在未知档位/工具名:${unknown.join(', ')}。` +
|
|
94
|
+
`可用档位:${Object.keys(TOOL_PRESETS).join(' / ')};也可直接写工具名(如 github_pr)。已忽略未知项。`);
|
|
95
|
+
}
|
|
96
|
+
// 过滤掉本版本不存在的预设成员(预设表可能超前于实际注册的工具)
|
|
97
|
+
return allNames.filter((n) => picked.has(n));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 按档位裁剪工具列表(纯函数,不修改入参)。
|
|
101
|
+
*
|
|
102
|
+
* 只读档位下对**混合工具**剪 action enum 而不是整只剔除:模型在 schema 层就看不到
|
|
103
|
+
* 写 action,宿主的参数校验会直接拒绝,可审计且不需要运行时分支。
|
|
104
|
+
*/
|
|
105
|
+
export function applyTier(tools, cfg) {
|
|
106
|
+
const allNames = tools.map((t) => t.name);
|
|
107
|
+
const names = resolveToolNames(cfg.toolsets, allNames);
|
|
108
|
+
let kept = names === 'all' ? [...tools] : tools.filter((t) => names.includes(t.name));
|
|
109
|
+
const dropped = allNames.filter((n) => !kept.some((t) => t.name === n));
|
|
110
|
+
const pruned = {};
|
|
111
|
+
if (cfg.read_only !== true)
|
|
112
|
+
return { kept, dropped, pruned };
|
|
113
|
+
const readOnlyKept = [];
|
|
114
|
+
for (const tool of kept) {
|
|
115
|
+
const writes = WRITE_ACTIONS[tool.name] ?? [];
|
|
116
|
+
if (writes === '*') {
|
|
117
|
+
dropped.push(tool.name);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const params = tool.parameters;
|
|
121
|
+
const actionSchema = params?.properties?.action;
|
|
122
|
+
if (!params || !Array.isArray(actionSchema?.enum) || writes.length === 0) {
|
|
123
|
+
// 没有 action 维度的工具(github_help / github_status / github_ruleset)原样保留
|
|
124
|
+
readOnlyKept.push(tool);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const keptEnum = actionSchema.enum.filter((a) => !writes.includes(a));
|
|
128
|
+
if (keptEnum.length === 0) {
|
|
129
|
+
// 整只工具都是写操作 → 剔除,而不是留一个空 enum
|
|
130
|
+
dropped.push(tool.name);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
pruned[tool.name] = [...actionSchema.enum];
|
|
134
|
+
const allowed = new Set(keptEnum);
|
|
135
|
+
readOnlyKept.push({
|
|
136
|
+
...tool,
|
|
137
|
+
description: `[只读模式] ${tool.description ?? ''}`.trim(),
|
|
138
|
+
parameters: {
|
|
139
|
+
...params,
|
|
140
|
+
properties: {
|
|
141
|
+
...params.properties,
|
|
142
|
+
action: { ...actionSchema, enum: keptEnum },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
/**
|
|
146
|
+
* 只读档位的**执行期**拦截。
|
|
147
|
+
*
|
|
148
|
+
* 为什么剪了 enum 还要再拦一次:本插件的工具是手写 descriptor,不是
|
|
149
|
+
* `defineTool` 产物——宿主只把 parameters 当 JSON Schema 发给模型,**不做执行前
|
|
150
|
+
* 参数校验**。所以模型只要发出一个被剪掉的 action(最常见的是从历史消息里复读,
|
|
151
|
+
* 或用户手工构造),执行路径会照常走到 `gh pr merge`。实测确认过:
|
|
152
|
+
* `applyTier([pr], {read_only:true})` 之后调 `{action:'merge', confirm:true}`
|
|
153
|
+
* 仍然会真的发起合并。只剪 schema 等于只做了「看不见」,没做「做不到」。
|
|
154
|
+
*
|
|
155
|
+
* 位置在闸门之外(applyTier 在工厂 wrapWithGate 之后套),所以先拒只读违规,
|
|
156
|
+
* 再走 confirm 闸门。
|
|
157
|
+
*/
|
|
158
|
+
async execute(args, ctx) {
|
|
159
|
+
const action = args?.action;
|
|
160
|
+
if (typeof action === 'string' && !allowed.has(action)) {
|
|
161
|
+
throw new Error(`只读模式(read_only: true)下 ${tool.name} 不允许 action "${action}";可用: ${keptEnum.join(' / ')}`);
|
|
162
|
+
}
|
|
163
|
+
return tool.execute(args, ctx);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return { kept: readOnlyKept, dropped, pruned };
|
|
168
|
+
}
|
package/lib/tools/agent-task.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { wrapWithGate } from '../destructive.js';
|
|
1
2
|
import { renderAgentTaskMarkdown } from '../renderers/markdown.js';
|
|
3
|
+
import { resolveBodySource } from './body-input.js';
|
|
2
4
|
function normalizeOutput(data) {
|
|
3
5
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
4
6
|
return data;
|
|
@@ -10,7 +12,7 @@ function normalizeOutput(data) {
|
|
|
10
12
|
}
|
|
11
13
|
const AGENT_TASK_JSON_FIELDS = 'completedAt,createdAt,id,name,pullRequestNumber,pullRequestState,pullRequestTitle,pullRequestUrl,repository,state,updatedAt,user';
|
|
12
14
|
export function createAgentTaskTool(executor) {
|
|
13
|
-
return {
|
|
15
|
+
return wrapWithGate({
|
|
14
16
|
name: 'github_agent_task',
|
|
15
17
|
description: '管理 GitHub Copilot Coding Agent 云端智能体任务会话:查看已分派任务列表、检视任务状态与会话日志、向云端智能体发起新编码与重构任务。基于 gh CLI preview 特性。',
|
|
16
18
|
parameters: {
|
|
@@ -31,7 +33,11 @@ export function createAgentTaskTool(executor) {
|
|
|
31
33
|
},
|
|
32
34
|
prompt: {
|
|
33
35
|
type: 'string',
|
|
34
|
-
description: '任务描述正文或编码指令(create 动作必需,如 "为用户模块增加单元测试"
|
|
36
|
+
description: '任务描述正文或编码指令(create 动作必需,如 "为用户模块增加单元测试")。与 prompt_file 二选一。',
|
|
37
|
+
},
|
|
38
|
+
prompt_file: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: '任务描述来源:本地 Markdown 文件路径(相对路径按当前工作区解析)。与 prompt 二选一。',
|
|
35
41
|
},
|
|
36
42
|
base_branch: {
|
|
37
43
|
type: 'string',
|
|
@@ -59,10 +65,11 @@ export function createAgentTaskTool(executor) {
|
|
|
59
65
|
},
|
|
60
66
|
},
|
|
61
67
|
async execute(args, execContext) {
|
|
62
|
-
const cwd
|
|
63
|
-
const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
|
|
68
|
+
const { cwd, sessionId } = executor.resolveExecScope(execContext);
|
|
64
69
|
const repoArgs = args.repo ? ['-R', args.repo] : [];
|
|
65
70
|
const runOpts = { cwd, sessionId };
|
|
71
|
+
// 任务描述来自文件时经 stdin 注入(`--from-file -`):绕开 Windows 约 32K 的命令行上限
|
|
72
|
+
const stdinPatch = (body) => (body.stdin === undefined ? {} : { stdin: body.stdin });
|
|
66
73
|
switch (args.action) {
|
|
67
74
|
case 'list': {
|
|
68
75
|
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(args.limit, 100) : 30;
|
|
@@ -111,15 +118,21 @@ export function createAgentTaskTool(executor) {
|
|
|
111
118
|
return taskData || { id: cleanId };
|
|
112
119
|
}
|
|
113
120
|
case 'create': {
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
// 内联 prompt 继续走位置参数:它的 argv 必须与改造前逐字节一致,只有文件来源才切
|
|
122
|
+
// `--from-file -`。gh agent-task 是 preview 命令,该 flag 形态可能随上游变化。
|
|
123
|
+
const inlinePrompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
|
|
124
|
+
const body = resolveBodySource({ inline: inlinePrompt, file: args.prompt_file }, { cwd, fileFlag: '--from-file', inlineField: 'prompt', fileField: 'prompt_file' });
|
|
125
|
+
if (body.chars === 0) {
|
|
126
|
+
throw new Error('create 操作必须提供 prompt 或 prompt_file(任务描述正文或编码指令)');
|
|
116
127
|
}
|
|
117
|
-
const createCmd =
|
|
128
|
+
const createCmd = body.fromFile
|
|
129
|
+
? ['agent-task', 'create', ...repoArgs, ...body.argv]
|
|
130
|
+
: ['agent-task', 'create', inlinePrompt, ...repoArgs];
|
|
118
131
|
if (args.base_branch)
|
|
119
132
|
createCmd.push('-b', args.base_branch.trim());
|
|
120
133
|
if (args.custom_agent)
|
|
121
134
|
createCmd.push('-a', args.custom_agent.trim());
|
|
122
|
-
const res = await executor.run(createCmd, { ...runOpts, rawText: true, timeoutMs: 60_000 });
|
|
135
|
+
const res = await executor.run(createCmd, { ...runOpts, rawText: true, timeoutMs: 60_000, ...stdinPatch(body) });
|
|
123
136
|
if (!res.ok)
|
|
124
137
|
throw new Error(res.error);
|
|
125
138
|
return {
|
|
@@ -131,5 +144,5 @@ export function createAgentTaskTool(executor) {
|
|
|
131
144
|
throw new Error(`未知的 Agent Task 操作: ${args.action}`);
|
|
132
145
|
}
|
|
133
146
|
},
|
|
134
|
-
};
|
|
147
|
+
});
|
|
135
148
|
}
|
package/lib/tools/api.d.ts
CHANGED
package/lib/tools/api.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { wrapWithGate } from '../destructive.js';
|
|
2
|
+
import { resolveBodySource } from './body-input.js';
|
|
1
3
|
function normalizeOutput(data) {
|
|
2
4
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
3
5
|
return data;
|
|
@@ -8,7 +10,7 @@ function normalizeOutput(data) {
|
|
|
8
10
|
return { result: data !== undefined && data !== null ? data : '' };
|
|
9
11
|
}
|
|
10
12
|
export function createApiTool(executor) {
|
|
11
|
-
return {
|
|
13
|
+
return wrapWithGate({
|
|
12
14
|
name: 'github_api',
|
|
13
15
|
description: '通过 GitHub CLI 认证环境直接调用任意 GitHub REST 或 GraphQL API 端点(兜底逃生门)。支持 --jq 过滤;fields 一律按字面字符串传参,避免 gh 的 -F 类型转换改写请求体。',
|
|
14
16
|
parameters: {
|
|
@@ -21,7 +23,7 @@ export function createApiTool(executor) {
|
|
|
21
23
|
method: {
|
|
22
24
|
type: 'string',
|
|
23
25
|
enum: ['GET', 'POST', 'PATCH', 'DELETE'],
|
|
24
|
-
description: 'HTTP 方法,默认为 GET(GraphQL
|
|
26
|
+
description: 'HTTP 方法,默认为 GET(GraphQL 端点或提供 input_file 时默认 POST)。',
|
|
25
27
|
},
|
|
26
28
|
jq: {
|
|
27
29
|
type: 'string',
|
|
@@ -29,7 +31,11 @@ export function createApiTool(executor) {
|
|
|
29
31
|
},
|
|
30
32
|
fields: {
|
|
31
33
|
type: 'object',
|
|
32
|
-
description: '请求参数或请求体键值对。所有值按字面字符串(gh -f
|
|
34
|
+
description: '请求参数或请求体键值对。所有值按字面字符串(gh -f)发送,不做布尔/数字类型转换;嵌套结构无法作为表单参数,请把完整请求体写入本地文件后用 input_file 传入。',
|
|
35
|
+
},
|
|
36
|
+
input_file: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: '请求体来源:本地文件路径(相对路径按当前工作区解析)。映射 gh api --input,文件内容原样作为请求体(GraphQL 需写 {"query":...} 形式的 JSON);与 fields 并用时 gh 会把 fields 追加为查询参数。',
|
|
33
39
|
},
|
|
34
40
|
raw: {
|
|
35
41
|
type: 'boolean',
|
|
@@ -57,14 +63,42 @@ export function createApiTool(executor) {
|
|
|
57
63
|
},
|
|
58
64
|
},
|
|
59
65
|
async execute(args, execContext) {
|
|
60
|
-
const cwd
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
const { cwd, sessionId } = executor.resolveExecScope(execContext);
|
|
67
|
+
// schema 声明了 required: ['endpoint'],但宿主校验不一定兜住(模型可能传空串)。
|
|
68
|
+
// 不校验时 `gh api ""` 退出码 0 并返回整个 API 索引 JSON,模型会误以为"查到了数据"。
|
|
69
|
+
const endpoint = typeof args.endpoint === 'string' ? args.endpoint.trim() : '';
|
|
70
|
+
if (!endpoint) {
|
|
71
|
+
throw new Error('endpoint 为必填参数:请传入 REST 路径(如 "repos/owner/repo/pulls" 或 "user")或 graphql 查询。');
|
|
72
|
+
}
|
|
73
|
+
// 只拒绝以 - 开头的形态:那才是真正的注入面(会被 gh 当成 flag 吃掉)。
|
|
74
|
+
// 注意不要要求"必须含 /"——`user` / `rate_limit` / `notifications` 都是合法单段端点,
|
|
75
|
+
// 实测 `gh api user --jq .login` 正常返回;按"/"判形态会误杀这些合法调用。
|
|
76
|
+
if (endpoint.startsWith('-')) {
|
|
77
|
+
throw new Error(`endpoint 不能以 - 开头(会被 gh 当作命令行 flag):"${endpoint}"。`);
|
|
78
|
+
}
|
|
79
|
+
const cmd = ['api', endpoint];
|
|
80
|
+
/*
|
|
81
|
+
* 请求体可来自本地文件(`--input -` + stdin)。
|
|
82
|
+
*
|
|
83
|
+
* 只接文件、不接内联:`fields` 就是内联通道,二者按契约可共存(gh 把 fields 追加为
|
|
84
|
+
* 查询参数),所以这里不传 inline——传了会被 helper 判成互斥而误拒合法调用。
|
|
85
|
+
* `maxChars: 0`:`--input` 的请求体是 JSON/GraphQL,不受 GitHub「正文 65536 字符」
|
|
86
|
+
* 口径约束,套用正文上限会把合法的大 mutation 拦死。
|
|
87
|
+
*/
|
|
88
|
+
const inputFileGiven = typeof args.input_file === 'string' && args.input_file.trim().length > 0;
|
|
89
|
+
const body = resolveBodySource({ file: args.input_file }, { cwd, flag: '--input', fileFlag: '--input', inlineField: 'fields', fileField: 'input_file', maxChars: 0 });
|
|
90
|
+
// 空文件若静默放过,发出去的是一个没有请求体的请求——服务端报的错与真实原因无关,极难排查
|
|
91
|
+
if (inputFileGiven && body.chars === 0) {
|
|
92
|
+
throw new Error(`input_file 内容为空:指向的文件没有任何内容("${args.input_file}")。请写入完整请求体后重试,或改用 fields。`);
|
|
93
|
+
}
|
|
94
|
+
// 请求体来自文件时经 stdin 注入(`--input -`):绕开 Windows 约 32K 的命令行上限
|
|
95
|
+
const stdinPatch = (b) => (b.stdin === undefined ? {} : { stdin: b.stdin });
|
|
63
96
|
// gh api 默认在提供 -f/-F 时会自动将 HTTP 方法隐式切换为 POST。
|
|
64
97
|
// 若调用方未指定 method,且 endpoint 不是以 graphql 开头,则按契约默认为 GET。
|
|
65
98
|
// 必须显式传入 -X,防止带有 fields 参数的 GET 请求被 gh 自动改写为 POST 导致 422 报错。
|
|
66
|
-
const isGraphql =
|
|
67
|
-
|
|
99
|
+
const isGraphql = endpoint.toLowerCase().startsWith('graphql');
|
|
100
|
+
// 带了 `--input` 却不给 -X 时,gh 仍按 GET 发出,请求体会被服务端静默丢弃——有正文即 POST
|
|
101
|
+
const method = args.method || (isGraphql || body.chars > 0 ? 'POST' : 'GET');
|
|
68
102
|
cmd.push('-X', method);
|
|
69
103
|
if (args.jq) {
|
|
70
104
|
cmd.push('--jq', args.jq);
|
|
@@ -84,18 +118,20 @@ export function createApiTool(executor) {
|
|
|
84
118
|
continue;
|
|
85
119
|
if (typeof v === 'object') {
|
|
86
120
|
// 不把嵌套结构 JSON.stringify 后塞进 -F:gh 侧解析结果不可预期,且会静默产出错误请求体。
|
|
87
|
-
// 宁可显式报错,让调用方改用
|
|
88
|
-
throw new Error(`github_api 的字段 "${k}"
|
|
121
|
+
// 宁可显式报错,让调用方改用 input_file 传完整请求体,或把结构序列化后作为字符串字段。
|
|
122
|
+
throw new Error(`github_api 的字段 "${k}" 是嵌套结构,无法作为表单参数发送。请把完整请求体写入本地 JSON 文件后用 input_file 传入(映射 gh api --input),或先把该结构序列化后作为字符串字段传入。`);
|
|
89
123
|
}
|
|
90
124
|
// 用 -f(字面字符串)而非 -F:-F 会做 "true"→布尔、"1"→数字的类型转换,
|
|
91
125
|
// 导致字符串请求体被悄悄改写,是跨 fork 提 PR 等场景失败的常见根因。
|
|
92
126
|
cmd.push('-f', `${k}=${String(v)}`);
|
|
93
127
|
}
|
|
94
128
|
}
|
|
95
|
-
|
|
129
|
+
// 与 fields 并用时 gh 会把 fields 追加为查询参数,两者不互斥
|
|
130
|
+
cmd.push(...body.argv);
|
|
131
|
+
const res = await executor.run(cmd, { cwd, sessionId, rawText: args.raw === true, ...stdinPatch(body) });
|
|
96
132
|
if (!res.ok)
|
|
97
133
|
throw new Error(res.error);
|
|
98
134
|
return normalizeOutput(res.data !== undefined ? res.data : res.rawOutput);
|
|
99
135
|
},
|
|
100
|
-
};
|
|
136
|
+
});
|
|
101
137
|
}
|