@modesto_ma/dsh-guanxiang 0.2.0-beta.2 → 0.2.0-beta.4
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.md +25 -35
- package/lib/client.js +173 -151
- package/lib/client.js.map +1 -1
- package/lib/index.js +493 -891
- package/lib/index.js.map +1 -1
- package/package.json +3 -1
- package/src/client/api.ts +43 -6
- package/src/client/page.tsx +64 -99
- package/src/config.ts +28 -45
- package/src/index.ts +11 -4
- package/src/repos/lookup.ts +92 -0
- package/src/repos/registry.ts +57 -295
- package/src/service.ts +166 -59
- package/src/settings/contract.ts +50 -26
- package/src/settings/routes.ts +19 -1
- package/src/skill-update/change.ts +81 -0
- package/src/skill-update/tool.ts +74 -49
- package/src/repos/credentials.ts +0 -104
- package/src/repos/git.ts +0 -74
- package/src/skill-update/git.ts +0 -157
package/src/skill-update/tool.ts
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
|
|
5
5
|
* 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
|
|
6
6
|
* - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<改动摘要前缀>`,从不提交到
|
|
8
|
+
* 已有分支或默认分支。
|
|
9
|
+
* - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
|
|
10
|
+
* GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
|
|
11
|
+
* - 提交前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
|
|
12
|
+
* 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才提交;
|
|
13
|
+
* 没有审批服务时工具报错,不提交。
|
|
12
14
|
* - 子会话中不提供本工具。
|
|
13
15
|
*
|
|
14
16
|
* @module dsh-guanxiang/skill-update/tool
|
|
@@ -17,16 +19,18 @@
|
|
|
17
19
|
import { randomBytes } from 'node:crypto'
|
|
18
20
|
import { rename, rm, writeFile } from 'node:fs/promises'
|
|
19
21
|
import { join, resolve } from 'node:path'
|
|
22
|
+
import type { CommitAuthor } from 'dsh-repo/contract'
|
|
20
23
|
import { z } from 'zod'
|
|
21
24
|
import { describeIssue } from '../config.ts'
|
|
22
25
|
import { isDelegatedAgent } from '../dispatch/tool.ts'
|
|
23
26
|
import type { ContentBlock, HostApproval, ToolDefinition, ToolRunContext } from '../host.ts'
|
|
24
|
-
import
|
|
27
|
+
import { messageOf, repoErrorCode, requireRepos } from '../repos/lookup.ts'
|
|
25
28
|
import type { RepoRegistry } from '../repos/registry.ts'
|
|
26
29
|
import type { AppManifest, EditableSettings } from '../settings/contract.ts'
|
|
27
|
-
import {
|
|
28
|
-
import type {
|
|
30
|
+
import { prepareChange } from './change.ts'
|
|
31
|
+
import type { FileStat, PreparedChange } from './change.ts'
|
|
29
32
|
import { DIFF_FILE, FILES_DIR, PROPOSAL_DIR, ProposalError, readProposal, REASON_FILE } from './proposal.ts'
|
|
33
|
+
import type { ProposedFile } from './proposal.ts'
|
|
30
34
|
|
|
31
35
|
/** Model-facing tool name. */
|
|
32
36
|
export const PROPOSE_TOOL_NAME = 'guanxiang_propose_skill'
|
|
@@ -69,7 +73,7 @@ export interface ProposeValue {
|
|
|
69
73
|
status: ProposeStatus
|
|
70
74
|
app: string
|
|
71
75
|
repo: string
|
|
72
|
-
/** New branch the change was or would be
|
|
76
|
+
/** New branch the change was or would be committed to. */
|
|
73
77
|
branch: string
|
|
74
78
|
/** Branch the merge request targets. */
|
|
75
79
|
target: string
|
|
@@ -77,6 +81,8 @@ export interface ProposeValue {
|
|
|
77
81
|
/** Diff file in the task's run directory. */
|
|
78
82
|
diffPath: string
|
|
79
83
|
mergeRequestUrl?: string
|
|
84
|
+
/** Why the merge request was not created; only with `pushed`. */
|
|
85
|
+
mergeRequestError?: string
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
const argsSchema = z.object({
|
|
@@ -98,7 +104,7 @@ const PARAMETERS: Record<string, unknown> = {
|
|
|
98
104
|
const FILE_STAT_SCHEMA = {
|
|
99
105
|
type: 'object',
|
|
100
106
|
additionalProperties: false,
|
|
101
|
-
required: ['path'],
|
|
107
|
+
required: ['path', 'added', 'removed'],
|
|
102
108
|
properties: { path: { type: 'string' }, added: { type: 'number' }, removed: { type: 'number' } },
|
|
103
109
|
} as const
|
|
104
110
|
|
|
@@ -115,6 +121,7 @@ const OUTPUT_SCHEMA = {
|
|
|
115
121
|
files: { type: 'array', items: FILE_STAT_SCHEMA },
|
|
116
122
|
diffPath: { type: 'string' },
|
|
117
123
|
mergeRequestUrl: { type: 'string' },
|
|
124
|
+
mergeRequestError: { type: 'string' },
|
|
118
125
|
},
|
|
119
126
|
} as const
|
|
120
127
|
|
|
@@ -124,7 +131,7 @@ const OUTPUT_SCHEMA = {
|
|
|
124
131
|
* @returns one line.
|
|
125
132
|
*/
|
|
126
133
|
export function describeFiles(files: readonly FileStat[]): string {
|
|
127
|
-
return files.map(file =>
|
|
134
|
+
return files.map(file => `${file.path}(+${file.added} −${file.removed})`).join('、')
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
/**
|
|
@@ -135,12 +142,12 @@ export function describeFiles(files: readonly FileStat[]): string {
|
|
|
135
142
|
export function renderProposeValue(value: ProposeValue): ContentBlock[] {
|
|
136
143
|
const where = `${value.repo} 的分支 ${value.branch}(目标 ${value.target})`
|
|
137
144
|
const text = {
|
|
138
|
-
opened:
|
|
139
|
-
pushed:
|
|
140
|
-
exists: `${where}
|
|
141
|
-
rejected: '
|
|
142
|
-
cancelled: '
|
|
143
|
-
unavailable: '
|
|
145
|
+
opened: `已提交到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ''}`,
|
|
146
|
+
pushed: `已提交到 ${where},但创建合并请求失败(${value.mergeRequestError ?? '未知原因'});请在代码托管平台上从该分支手动创建。`,
|
|
147
|
+
exists: `${where} 已存在,同一改动可能已经提交过,本次没有提交。`,
|
|
148
|
+
rejected: '授权被拒绝,没有提交:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。',
|
|
149
|
+
cancelled: '授权请求被撤回,没有提交。',
|
|
150
|
+
unavailable: '当前没有可以回答授权请求的界面,没有提交。',
|
|
144
151
|
}[value.status]
|
|
145
152
|
return [{ type: 'text', text: `${text}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}` }]
|
|
146
153
|
}
|
|
@@ -149,15 +156,11 @@ export function renderProposeValue(value: ProposeValue): ContentBlock[] {
|
|
|
149
156
|
export interface SkillUpdaterDeps {
|
|
150
157
|
readonly registry: RepoRegistry
|
|
151
158
|
settings(): EditableSettings
|
|
152
|
-
readonly git: GitRunner
|
|
153
|
-
readonly gitTimeoutMs: number
|
|
154
159
|
readonly author?: CommitAuthor
|
|
155
160
|
/** DSH's approval service, when the composition has one. */
|
|
156
161
|
approval(): HostApproval | undefined
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
function messageOf(error: unknown): string {
|
|
160
|
-
return error instanceof Error ? error.message : String(error)
|
|
162
|
+
/** What `ctx.get('dshRepos')` returns now. */
|
|
163
|
+
repos(): unknown
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
/**
|
|
@@ -184,7 +187,7 @@ function skillDirOf(app: AppManifest): string {
|
|
|
184
187
|
export class SkillUpdater {
|
|
185
188
|
private readonly tasks = new Map<string, DispatchedTask>()
|
|
186
189
|
|
|
187
|
-
/** @param deps - registry, settings,
|
|
190
|
+
/** @param deps - registry, settings, approval, and `dshRepos` access. */
|
|
188
191
|
constructor(private readonly deps: SkillUpdaterDeps) {}
|
|
189
192
|
|
|
190
193
|
/** @returns whether proposals are enabled in the current settings. */
|
|
@@ -221,20 +224,17 @@ export class SkillUpdater {
|
|
|
221
224
|
/**
|
|
222
225
|
* Read the proposal, build its tree, and write the diff file.
|
|
223
226
|
* @returns undefined when the task wrote no proposal.
|
|
224
|
-
* @throws ProposalError or
|
|
227
|
+
* @throws ProposalError, or an error when dsh-repo is unavailable or the mirror cannot be read.
|
|
225
228
|
*/
|
|
226
|
-
private async prepare(task: DispatchedTask): Promise<{ reason: string; change: PreparedChange; target: string; diffPath: string } | undefined> {
|
|
229
|
+
private async prepare(task: DispatchedTask, signal?: AbortSignal): Promise<{ reason: string; files: readonly ProposedFile[]; change: PreparedChange; target: string; diffPath: string } | undefined> {
|
|
227
230
|
const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates)
|
|
228
231
|
if (proposal === undefined) return undefined
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
change: await prepareChange(commands, clone.dir, task.commit, skillDirOf(task.app), proposal.files),
|
|
232
|
-
target: clone.branch,
|
|
233
|
-
}))
|
|
232
|
+
const target = this.deps.registry.branchOf(task.url)
|
|
233
|
+
const change = await prepareChange(requireRepos(this.deps.repos()), { url: task.url, base: task.commit, skillDir: skillDirOf(task.app) }, proposal.files, signal)
|
|
234
234
|
if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`)
|
|
235
235
|
const diffPath = join(task.workDir, DIFF_FILE)
|
|
236
236
|
await replaceFile(diffPath, change.diff)
|
|
237
|
-
return { reason: proposal.reason, change, target, diffPath }
|
|
237
|
+
return { reason: proposal.reason, files: proposal.files, change, target, diffPath }
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
/**
|
|
@@ -244,7 +244,7 @@ export class SkillUpdater {
|
|
|
244
244
|
definition(): ToolDefinition<ProposeValue> {
|
|
245
245
|
return {
|
|
246
246
|
name: PROPOSE_TOOL_NAME,
|
|
247
|
-
description: '观象:把派发结果中某个应用的 SKILL
|
|
247
|
+
description: '观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后提交到新分支并创建合并请求,不会直接改动默认分支。'
|
|
248
248
|
+ '仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。',
|
|
249
249
|
parameters: PARAMETERS,
|
|
250
250
|
output: {
|
|
@@ -261,7 +261,7 @@ export class SkillUpdater {
|
|
|
261
261
|
* @param rawArgs - model arguments.
|
|
262
262
|
* @param exec - execution context of the calling agent.
|
|
263
263
|
* @returns how the submission ended.
|
|
264
|
-
* @throws when the arguments, task, proposal, or
|
|
264
|
+
* @throws when the arguments, task, proposal, approval service, or `dshRepos` service are unusable, or the commit fails; nothing is committed then.
|
|
265
265
|
*/
|
|
266
266
|
async execute(rawArgs: unknown, exec: ToolRunContext): Promise<ProposeValue> {
|
|
267
267
|
const parsed = argsSchema.safeParse(rawArgs)
|
|
@@ -275,22 +275,26 @@ export class SkillUpdater {
|
|
|
275
275
|
if (task === undefined) throw new Error(`${parsed.data.workDir} 不是本次 DSH 运行中观象派发任务的运行目录;DSH 重启后需要重新分析`)
|
|
276
276
|
if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`)
|
|
277
277
|
const approval = this.deps.approval()
|
|
278
|
-
if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval
|
|
278
|
+
if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交')
|
|
279
|
+
const repos = requireRepos(this.deps.repos())
|
|
279
280
|
|
|
280
|
-
|
|
281
|
+
let prepared: Awaited<ReturnType<SkillUpdater['prepare']>>
|
|
282
|
+
try {
|
|
283
|
+
prepared = await this.prepare(task, exec.signal)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
exec.signal.throwIfAborted()
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
281
288
|
if (prepared === undefined) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`)
|
|
282
289
|
const { change, target, diffPath, reason } = prepared
|
|
283
|
-
const branch = `guanxiang/skill-${task.app.id}-${change.
|
|
290
|
+
const branch = `guanxiang/skill-${task.app.id}-${change.digest.slice(0, 10)}`
|
|
284
291
|
const base = { app: task.app.id, repo: task.url, branch, target, files: [...change.files], diffPath }
|
|
285
|
-
const commands = { git: this.deps.git, timeoutMs: this.deps.gitTimeoutMs }
|
|
286
292
|
|
|
287
|
-
if (await
|
|
288
|
-
return { ...base, status: 'exists' }
|
|
289
|
-
}
|
|
293
|
+
if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== undefined) return { ...base, status: 'exists' }
|
|
290
294
|
|
|
291
295
|
const preview = reason.replace(/\s+/g, ' ').slice(0, REASON_PREVIEW_CHARS)
|
|
292
|
-
const zh =
|
|
293
|
-
const en = `GuanXiang will
|
|
296
|
+
const zh = `观象将在 ${task.url} 提交新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`
|
|
297
|
+
const en = `GuanXiang will commit branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map(file => file.path).join(', ')}. Summary: ${summary}`
|
|
294
298
|
const outcome = await approval.request({
|
|
295
299
|
agent,
|
|
296
300
|
toolName: PROPOSE_TOOL_NAME,
|
|
@@ -304,11 +308,32 @@ export class SkillUpdater {
|
|
|
304
308
|
|
|
305
309
|
const message = `${summary}\n\n${reason}\n\nProposed-by: 观象 GuanXiang\nAnalyzed-commit: ${task.commit}\nSession: ${task.sessionId}\n`
|
|
306
310
|
const description = `由观象在日志分析中提出(应用 ${task.app.id},分析提交 ${task.commit.slice(0, 12)},会话 ${task.sessionId})。理由:${reason.slice(0, REASON_DESCRIPTION_CHARS)}`
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
311
|
+
// Only files that differ from the analyzed commit; an unchanged one would be an empty update.
|
|
312
|
+
const changed = new Set(change.files.map(file => file.path))
|
|
313
|
+
const files = prepared.files
|
|
314
|
+
.map(file => ({ action: 'write' as const, path: `${skillDirOf(task.app)}/${file.path}`, content: file.content }))
|
|
315
|
+
.filter(file => changed.has(file.path))
|
|
316
|
+
exec.signal.throwIfAborted()
|
|
317
|
+
try {
|
|
318
|
+
await repos.commitFiles(task.url, {
|
|
319
|
+
branch,
|
|
320
|
+
baseSha: task.commit,
|
|
321
|
+
newBranch: true,
|
|
322
|
+
message,
|
|
323
|
+
files,
|
|
324
|
+
...this.deps.author === undefined ? {} : { author: this.deps.author },
|
|
325
|
+
signal: exec.signal,
|
|
326
|
+
})
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (repoErrorCode(error) === 'conflict') return { ...base, status: 'exists' }
|
|
329
|
+
throw error
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const opened = await repos.createChange(task.url, { title: summary, body: description, sourceBranch: branch, targetBranch: target, signal: exec.signal })
|
|
333
|
+
return { ...base, status: 'opened', mergeRequestUrl: opened.webUrl }
|
|
334
|
+
} catch (error) {
|
|
335
|
+
// The branch exists now; report it so the user can open the request by hand.
|
|
336
|
+
return { ...base, status: 'pushed', mergeRequestError: messageOf(error) }
|
|
337
|
+
}
|
|
313
338
|
}
|
|
314
339
|
}
|
package/src/repos/credentials.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTPS 访问令牌:按地址前缀为仓库挑选令牌配置,并生成只对一次 git 进程生效的
|
|
3
|
-
* 凭据助手配置。令牌经子进程环境变量交给 git,不出现在命令行参数、仓库的
|
|
4
|
-
* `.git/config` 或 git 输出里;只有服务器要求认证时 git 才向助手取令牌,
|
|
5
|
-
* 本机为同一主机配置的其他凭据助手在这次调用中不参与。
|
|
6
|
-
*
|
|
7
|
-
* @module dsh-guanxiang/repos/credentials
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { GitCredentialSpec } from '../settings/contract.ts'
|
|
11
|
-
|
|
12
|
-
export { TOKEN_ENV_PATTERN } from '../settings/contract.ts'
|
|
13
|
-
|
|
14
|
-
/** Environment variables the helper reads; set only on git child processes. */
|
|
15
|
-
const USERNAME_VARIABLE = 'GUANXIANG_GIT_USERNAME'
|
|
16
|
-
const TOKEN_VARIABLE = 'GUANXIANG_GIT_TOKEN'
|
|
17
|
-
|
|
18
|
-
/** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
|
|
19
|
-
const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`
|
|
20
|
-
|
|
21
|
-
/** Longest accepted token; personal access tokens are far shorter. */
|
|
22
|
-
export const MAX_TOKEN_LENGTH = 4096
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Normalize an access-token address prefix to `scheme://host[:port][/path]`
|
|
26
|
-
* with a lowercase host and no trailing slash.
|
|
27
|
-
* @param value - candidate prefix.
|
|
28
|
-
* @returns the normalized prefix, or undefined when it is not an `https`/`http` URL without credentials, query, or fragment.
|
|
29
|
-
*/
|
|
30
|
-
export function normalizeCredentialUrl(value: string): string | undefined {
|
|
31
|
-
let url: URL
|
|
32
|
-
try {
|
|
33
|
-
url = new URL(value)
|
|
34
|
-
} catch {
|
|
35
|
-
// Not a URL: refused.
|
|
36
|
-
return undefined
|
|
37
|
-
}
|
|
38
|
-
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined
|
|
39
|
-
if (url.host === '' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') return undefined
|
|
40
|
-
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '')}`
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Whether a token value can be handed to git: non-empty, bounded, and free of
|
|
45
|
-
* control characters, which would break the credential helper protocol.
|
|
46
|
-
* @param value - candidate token.
|
|
47
|
-
* @returns whether it is accepted.
|
|
48
|
-
*/
|
|
49
|
-
export function isAcceptedToken(value: string): boolean {
|
|
50
|
-
return value.length > 0 && value.length <= MAX_TOKEN_LENGTH && !/[\u0000-\u001f\u007f]/.test(value)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The access-token configuration for a repository address: the entry whose
|
|
55
|
-
* prefix has the same scheme and host and the longest path that contains the
|
|
56
|
-
* repository path. scp-like addresses, SSH URLs, and local paths never match.
|
|
57
|
-
* @param url - repository address.
|
|
58
|
-
* @param specs - configured access tokens with normalized prefixes.
|
|
59
|
-
* @returns the matching entry, if any.
|
|
60
|
-
*/
|
|
61
|
-
export function credentialFor(url: string, specs: readonly GitCredentialSpec[]): GitCredentialSpec | undefined {
|
|
62
|
-
let repo: URL
|
|
63
|
-
try {
|
|
64
|
-
repo = new URL(url)
|
|
65
|
-
} catch {
|
|
66
|
-
// scp-like addresses and local paths use other credentials.
|
|
67
|
-
return undefined
|
|
68
|
-
}
|
|
69
|
-
let best: { spec: GitCredentialSpec; length: number } | undefined
|
|
70
|
-
for (const spec of specs) {
|
|
71
|
-
const prefix = new URL(spec.url)
|
|
72
|
-
if (prefix.protocol !== repo.protocol || prefix.host !== repo.host) continue
|
|
73
|
-
const path = prefix.pathname.replace(/\/+$/, '')
|
|
74
|
-
if (path !== '' && repo.pathname !== path && !repo.pathname.startsWith(`${path}/`)) continue
|
|
75
|
-
if (best === undefined || path.length > best.length) best = { spec, length: path.length }
|
|
76
|
-
}
|
|
77
|
-
return best?.spec
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Environment that makes one git process answer the credential request for
|
|
82
|
-
* the token's host with the given token. Existing `GIT_CONFIG_COUNT` entries
|
|
83
|
-
* are kept; the helper list for that host is reset first so a stale password
|
|
84
|
-
* from another helper cannot shadow the token.
|
|
85
|
-
* @param spec - matching access-token configuration.
|
|
86
|
-
* @param token - token value.
|
|
87
|
-
* @param env - environment of the git process before this call.
|
|
88
|
-
* @returns variables to add to the git process environment.
|
|
89
|
-
*/
|
|
90
|
-
export function credentialEnvironment(spec: GitCredentialSpec, token: string, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
91
|
-
const prefix = new URL(spec.url)
|
|
92
|
-
const key = `credential.${prefix.protocol}//${prefix.host}.helper`
|
|
93
|
-
const parsed = Number(env.GIT_CONFIG_COUNT ?? '0')
|
|
94
|
-
const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0
|
|
95
|
-
return {
|
|
96
|
-
GIT_CONFIG_COUNT: String(base + 2),
|
|
97
|
-
[`GIT_CONFIG_KEY_${base}`]: key,
|
|
98
|
-
[`GIT_CONFIG_VALUE_${base}`]: '',
|
|
99
|
-
[`GIT_CONFIG_KEY_${base + 1}`]: key,
|
|
100
|
-
[`GIT_CONFIG_VALUE_${base + 1}`]: HELPER,
|
|
101
|
-
[USERNAME_VARIABLE]: spec.username,
|
|
102
|
-
[TOKEN_VARIABLE]: token,
|
|
103
|
-
}
|
|
104
|
-
}
|
package/src/repos/git.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本机 git 命令的调用封装:沿用本机已有凭据(SSH key 或 credential helper),
|
|
3
|
-
* 或由调用方经环境变量提供的访问令牌;禁止交互式提示,避免后台拉取卡在
|
|
4
|
-
* 密码输入上。
|
|
5
|
-
*
|
|
6
|
-
* @module dsh-guanxiang/repos/git
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { execFile } from 'node:child_process'
|
|
10
|
-
|
|
11
|
-
/** A failed git command with its combined output. */
|
|
12
|
-
export class GitError extends Error {
|
|
13
|
-
/**
|
|
14
|
-
* @param args - git arguments that failed.
|
|
15
|
-
* @param output - trimmed stderr (or stdout when stderr is empty).
|
|
16
|
-
*/
|
|
17
|
-
constructor(readonly args: readonly string[], readonly output: string) {
|
|
18
|
-
super(`git ${args.join(' ')} 失败:${output === '' ? '无输出' : output}`)
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Options of one git command. */
|
|
23
|
-
export interface GitRunOptions {
|
|
24
|
-
cwd?: string
|
|
25
|
-
timeoutMs: number
|
|
26
|
-
/** Variables added to the environment of this command only (credentials for network commands). */
|
|
27
|
-
env?: Readonly<Record<string, string>>
|
|
28
|
-
/** Text written to the command's standard input. */
|
|
29
|
-
input?: string
|
|
30
|
-
/** `all` resolves, or reports on failure, stdout followed by stderr, for commands such as `push --porcelain` that split their report. */
|
|
31
|
-
output?: 'stdout' | 'all'
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Runs one git command and resolves its stdout (or all output, per {@link GitRunOptions.output}). */
|
|
35
|
-
export type GitRunner = (args: readonly string[], options: GitRunOptions) => Promise<string>
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Environment for non-interactive git: no terminal prompts, and SSH in batch
|
|
39
|
-
* mode unless the user configured their own SSH command.
|
|
40
|
-
* @param env - base environment.
|
|
41
|
-
* @returns environment for git child processes.
|
|
42
|
-
*/
|
|
43
|
-
export function gitEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
44
|
-
const next: NodeJS.ProcessEnv = { ...env, GIT_TERMINAL_PROMPT: '0' }
|
|
45
|
-
if (env.GIT_SSH_COMMAND === undefined && env.GIT_SSH === undefined) next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
|
|
46
|
-
return next
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Default runner backed by the `git` executable on PATH.
|
|
51
|
-
* @param args - git arguments.
|
|
52
|
-
* @param options - working directory, timeout, extra environment, input, and output selection.
|
|
53
|
-
* @returns stdout, or stdout followed by stderr.
|
|
54
|
-
*/
|
|
55
|
-
export const runGit: GitRunner = (args, options) => new Promise((resolve, reject) => {
|
|
56
|
-
const child = execFile('git', [...args], {
|
|
57
|
-
cwd: options.cwd,
|
|
58
|
-
env: { ...gitEnvironment(), ...options.env },
|
|
59
|
-
timeout: options.timeoutMs,
|
|
60
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
61
|
-
windowsHide: true,
|
|
62
|
-
}, (error, stdout, stderr) => {
|
|
63
|
-
if (error === null) {
|
|
64
|
-
resolve(options.output === 'all' ? `${stdout}${stderr}` : stdout)
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
const output = (options.output === 'all' || stderr.trim() === '' ? `${stdout}${stderr}` : stderr).trim()
|
|
68
|
-
const killed = (error as { killed?: boolean }).killed === true
|
|
69
|
-
reject(new GitError(args, killed ? `超时(${options.timeoutMs} ms)后被终止${output === '' ? '' : `:${output}`}` : output || error.message))
|
|
70
|
-
})
|
|
71
|
-
// A command that exits without reading its input fails through its exit status, not EPIPE.
|
|
72
|
-
child.stdin?.on('error', () => {})
|
|
73
|
-
child.stdin?.end(options.input)
|
|
74
|
-
})
|
package/src/skill-update/git.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 在拉取仓库中用 git 底层命令生成 SKILL 修改的提交并推送:用临时索引文件
|
|
3
|
-
* 在分析时的提交上替换文件、写出树对象,不改动任何检出、快照或本地分支;
|
|
4
|
-
* 推送时用 GitLab push options 顺带创建合并请求。
|
|
5
|
-
*
|
|
6
|
-
* @module dsh-guanxiang/skill-update/git
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
10
|
-
import { tmpdir } from 'node:os'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
-
import type { GitRunner } from '../repos/git.ts'
|
|
13
|
-
import type { ProposedFile } from './proposal.ts'
|
|
14
|
-
|
|
15
|
-
/** Line counts of one changed file. */
|
|
16
|
-
export interface FileStat {
|
|
17
|
-
/** Path relative to the repository root. */
|
|
18
|
-
readonly path: string
|
|
19
|
-
/** Added lines; undefined for a binary change. */
|
|
20
|
-
readonly added?: number
|
|
21
|
-
/** Removed lines; undefined for a binary change. */
|
|
22
|
-
readonly removed?: number
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** A proposal turned into a tree object on top of its base commit. */
|
|
26
|
-
export interface PreparedChange {
|
|
27
|
-
readonly base: string
|
|
28
|
-
readonly tree: string
|
|
29
|
-
readonly files: readonly FileStat[]
|
|
30
|
-
/** Unified diff from the base commit to {@link tree}, limited to the skill directory. */
|
|
31
|
-
readonly diff: string
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** git runner and the timeout of one command. */
|
|
35
|
-
export interface GitCommands {
|
|
36
|
-
readonly git: GitRunner
|
|
37
|
-
readonly timeoutMs: number
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Author and committer of a commit. */
|
|
41
|
-
export interface CommitAuthor {
|
|
42
|
-
readonly name: string
|
|
43
|
-
readonly email: string
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Merge request fields sent as GitLab push options. */
|
|
47
|
-
export interface MergeRequestOptions {
|
|
48
|
-
readonly target: string
|
|
49
|
-
readonly title: string
|
|
50
|
-
readonly description: string
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const DIFF_FLAGS = ['--no-color', '--no-ext-diff', '--no-renames']
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Write the proposed files over the skill directory of `base` into a new tree.
|
|
57
|
-
* @param commands - git runner and timeout.
|
|
58
|
-
* @param dir - repository directory whose object database receives the objects.
|
|
59
|
-
* @param base - commit the analysis ran on.
|
|
60
|
-
* @param skillDir - skill directory relative to the repository root, `/`-separated.
|
|
61
|
-
* @param files - proposed files relative to `skillDir`.
|
|
62
|
-
* @returns the tree, per-file line counts, and the diff; `files` is empty when nothing changes.
|
|
63
|
-
* @throws when a proposed path replaces a symbolic link or a directory, or a git command fails.
|
|
64
|
-
*/
|
|
65
|
-
export async function prepareChange(commands: GitCommands, dir: string, base: string, skillDir: string, files: readonly ProposedFile[]): Promise<PreparedChange> {
|
|
66
|
-
const { git, timeoutMs } = commands
|
|
67
|
-
const scratch = await mkdtemp(join(tmpdir(), 'guanxiang-index-'))
|
|
68
|
-
try {
|
|
69
|
-
const env = { GIT_INDEX_FILE: join(scratch, 'index') }
|
|
70
|
-
await git(['read-tree', base], { cwd: dir, timeoutMs, env })
|
|
71
|
-
const listing = await git(['ls-tree', '-r', '-t', '-z', base, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
72
|
-
const modes = new Map<string, string>()
|
|
73
|
-
for (const record of listing.split('\0')) {
|
|
74
|
-
const tab = record.indexOf('\t')
|
|
75
|
-
if (tab < 0) continue
|
|
76
|
-
modes.set(record.slice(tab + 1), record.slice(0, record.indexOf(' ')))
|
|
77
|
-
}
|
|
78
|
-
for (const file of files) {
|
|
79
|
-
const path = `${skillDir}/${file.path}`
|
|
80
|
-
const existing = modes.get(path)
|
|
81
|
-
if (existing === '120000') throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`)
|
|
82
|
-
if (existing === '040000') throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`)
|
|
83
|
-
const blob = (await git(['hash-object', '-w', '--stdin'], { cwd: dir, timeoutMs, input: file.content })).trim()
|
|
84
|
-
await git(['update-index', '--add', '--cacheinfo', `${existing === '100755' ? '100755' : '100644'},${blob},${path}`], { cwd: dir, timeoutMs, env })
|
|
85
|
-
}
|
|
86
|
-
const tree = (await git(['write-tree'], { cwd: dir, timeoutMs, env })).trim()
|
|
87
|
-
const numstat = await git(['diff', ...DIFF_FLAGS, '--numstat', base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
88
|
-
const stats = numstat.split('\n').filter(line => line !== '').map((line): FileStat => {
|
|
89
|
-
const [added = '-', removed = '-', ...path] = line.split('\t')
|
|
90
|
-
return { path: path.join('\t'), ...added === '-' ? {} : { added: Number(added) }, ...removed === '-' ? {} : { removed: Number(removed) } }
|
|
91
|
-
})
|
|
92
|
-
const diff = stats.length === 0 ? '' : await git(['diff', ...DIFF_FLAGS, base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
93
|
-
return { base, tree, files: stats, diff }
|
|
94
|
-
} finally {
|
|
95
|
-
await rm(scratch, { recursive: true, force: true })
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Whether the remote already has a branch.
|
|
101
|
-
* @param commands - git runner and timeout.
|
|
102
|
-
* @param dir - clone directory.
|
|
103
|
-
* @param branch - branch name.
|
|
104
|
-
* @param env - network environment of the clone's address.
|
|
105
|
-
* @returns whether `refs/heads/<branch>` exists on `origin`.
|
|
106
|
-
*/
|
|
107
|
-
export async function remoteBranchExists(commands: GitCommands, dir: string, branch: string, env: Readonly<Record<string, string>>): Promise<boolean> {
|
|
108
|
-
const output = await commands.git(['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env })
|
|
109
|
-
return output.trim() !== ''
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Commit a prepared tree on its base commit without touching any ref.
|
|
114
|
-
* @param commands - git runner and timeout.
|
|
115
|
-
* @param dir - clone directory.
|
|
116
|
-
* @param change - prepared tree and its base.
|
|
117
|
-
* @param message - full commit message.
|
|
118
|
-
* @param author - explicit identity; omitted uses the host's git configuration.
|
|
119
|
-
* @returns the new commit id.
|
|
120
|
-
*/
|
|
121
|
-
export async function commitChange(commands: GitCommands, dir: string, change: PreparedChange, message: string, author: CommitAuthor | undefined): Promise<string> {
|
|
122
|
-
const env = author === undefined
|
|
123
|
-
? undefined
|
|
124
|
-
: { GIT_AUTHOR_NAME: author.name, GIT_AUTHOR_EMAIL: author.email, GIT_COMMITTER_NAME: author.name, GIT_COMMITTER_EMAIL: author.email }
|
|
125
|
-
const output = await commands.git(['commit-tree', change.tree, '-p', change.base, '-F', '-'], { cwd: dir, timeoutMs: commands.timeoutMs, input: message, ...env === undefined ? {} : { env } })
|
|
126
|
-
return output.trim()
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Push options cannot carry line breaks; collapse every whitespace run. */
|
|
130
|
-
function oneLine(text: string): string {
|
|
131
|
-
return text.replace(/\s+/g, ' ').trim()
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Push a commit to a new remote branch and ask GitLab to open a merge request
|
|
136
|
-
* for it (`merge_request.*` push options). The push carries an empty lease on
|
|
137
|
-
* the branch, so it fails instead of updating a branch that appeared after
|
|
138
|
-
* {@link remoteBranchExists} was checked. A server that does not support push
|
|
139
|
-
* options rejects the push.
|
|
140
|
-
* @param commands - git runner and timeout.
|
|
141
|
-
* @param dir - clone directory.
|
|
142
|
-
* @param commit - commit to push.
|
|
143
|
-
* @param branch - new branch name.
|
|
144
|
-
* @param request - merge request target and text.
|
|
145
|
-
* @param env - network environment of the clone's address.
|
|
146
|
-
* @returns the merge request URL the server printed, if any.
|
|
147
|
-
*/
|
|
148
|
-
export async function pushForMergeRequest(commands: GitCommands, dir: string, commit: string, branch: string, request: MergeRequestOptions, env: Readonly<Record<string, string>>): Promise<string | undefined> {
|
|
149
|
-
const options = [
|
|
150
|
-
'merge_request.create',
|
|
151
|
-
`merge_request.target=${request.target}`,
|
|
152
|
-
`merge_request.title=${oneLine(request.title)}`,
|
|
153
|
-
`merge_request.description=${oneLine(request.description)}`,
|
|
154
|
-
].flatMap(option => ['-o', option])
|
|
155
|
-
const output = await commands.git(['push', '--porcelain', `--force-with-lease=refs/heads/${branch}:`, ...options, 'origin', `${commit}:refs/heads/${branch}`], { cwd: dir, timeoutMs: commands.timeoutMs, env, output: 'all' })
|
|
156
|
-
return /https?:\/\/\S+\/-\/merge_requests\/\d+/.exec(output)?.[0]
|
|
157
|
-
}
|