@modesto_ma/dsh-guanxiang 0.2.0-beta.3 → 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 +19 -29
- package/lib/client.js +172 -150
- package/lib/client.js.map +1 -1
- package/lib/index.js +425 -794
- package/lib/index.js.map +1 -1
- package/package.json +3 -2
- package/src/client/api.ts +43 -6
- package/src/client/page.tsx +63 -98
- package/src/config.ts +26 -43
- package/src/index.ts +9 -2
- package/src/repos/lookup.ts +92 -0
- package/src/repos/registry.ts +57 -293
- package/src/service.ts +165 -60
- 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 +20 -30
- package/src/repos/credentials.ts +0 -104
- package/src/repos/git.ts +0 -74
- package/src/skill-update/git.ts +0 -84
- package/src/skill-update/repos.ts +0 -51
package/src/skill-update/tool.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
|
|
5
5
|
* 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
|
|
6
6
|
* - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
|
|
7
|
-
* 父提交,提交到新分支 `guanxiang/skill-<应用 id
|
|
7
|
+
* 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<改动摘要前缀>`,从不提交到
|
|
8
8
|
* 已有分支或默认分支。
|
|
9
9
|
* - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
|
|
10
10
|
* GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
|
|
@@ -24,14 +24,13 @@ import { z } from 'zod'
|
|
|
24
24
|
import { describeIssue } from '../config.ts'
|
|
25
25
|
import { isDelegatedAgent } from '../dispatch/tool.ts'
|
|
26
26
|
import type { ContentBlock, HostApproval, ToolDefinition, ToolRunContext } from '../host.ts'
|
|
27
|
-
import
|
|
27
|
+
import { messageOf, repoErrorCode, requireRepos } from '../repos/lookup.ts'
|
|
28
28
|
import type { RepoRegistry } from '../repos/registry.ts'
|
|
29
29
|
import type { AppManifest, EditableSettings } from '../settings/contract.ts'
|
|
30
|
-
import { prepareChange } from './
|
|
31
|
-
import type { FileStat, PreparedChange } from './
|
|
30
|
+
import { prepareChange } from './change.ts'
|
|
31
|
+
import type { FileStat, PreparedChange } from './change.ts'
|
|
32
32
|
import { DIFF_FILE, FILES_DIR, PROPOSAL_DIR, ProposalError, readProposal, REASON_FILE } from './proposal.ts'
|
|
33
33
|
import type { ProposedFile } from './proposal.ts'
|
|
34
|
-
import { DSH_REPOS_SERVICE, DSH_REPOS_VERSION, lookupRepos, repoErrorCode } from './repos.ts'
|
|
35
34
|
|
|
36
35
|
/** Model-facing tool name. */
|
|
37
36
|
export const PROPOSE_TOOL_NAME = 'guanxiang_propose_skill'
|
|
@@ -105,7 +104,7 @@ const PARAMETERS: Record<string, unknown> = {
|
|
|
105
104
|
const FILE_STAT_SCHEMA = {
|
|
106
105
|
type: 'object',
|
|
107
106
|
additionalProperties: false,
|
|
108
|
-
required: ['path'],
|
|
107
|
+
required: ['path', 'added', 'removed'],
|
|
109
108
|
properties: { path: { type: 'string' }, added: { type: 'number' }, removed: { type: 'number' } },
|
|
110
109
|
} as const
|
|
111
110
|
|
|
@@ -132,7 +131,7 @@ const OUTPUT_SCHEMA = {
|
|
|
132
131
|
* @returns one line.
|
|
133
132
|
*/
|
|
134
133
|
export function describeFiles(files: readonly FileStat[]): string {
|
|
135
|
-
return files.map(file =>
|
|
134
|
+
return files.map(file => `${file.path}(+${file.added} −${file.removed})`).join('、')
|
|
136
135
|
}
|
|
137
136
|
|
|
138
137
|
/**
|
|
@@ -157,8 +156,6 @@ export function renderProposeValue(value: ProposeValue): ContentBlock[] {
|
|
|
157
156
|
export interface SkillUpdaterDeps {
|
|
158
157
|
readonly registry: RepoRegistry
|
|
159
158
|
settings(): EditableSettings
|
|
160
|
-
readonly git: GitRunner
|
|
161
|
-
readonly gitTimeoutMs: number
|
|
162
159
|
readonly author?: CommitAuthor
|
|
163
160
|
/** DSH's approval service, when the composition has one. */
|
|
164
161
|
approval(): HostApproval | undefined
|
|
@@ -166,10 +163,6 @@ export interface SkillUpdaterDeps {
|
|
|
166
163
|
repos(): unknown
|
|
167
164
|
}
|
|
168
165
|
|
|
169
|
-
function messageOf(error: unknown): string {
|
|
170
|
-
return error instanceof Error ? error.message : String(error)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
166
|
/**
|
|
174
167
|
* Write a file in an agent-writable directory without following whatever
|
|
175
168
|
* entry the agent left at its path: the content goes to a new file created
|
|
@@ -194,7 +187,7 @@ function skillDirOf(app: AppManifest): string {
|
|
|
194
187
|
export class SkillUpdater {
|
|
195
188
|
private readonly tasks = new Map<string, DispatchedTask>()
|
|
196
189
|
|
|
197
|
-
/** @param deps - registry, settings,
|
|
190
|
+
/** @param deps - registry, settings, approval, and `dshRepos` access. */
|
|
198
191
|
constructor(private readonly deps: SkillUpdaterDeps) {}
|
|
199
192
|
|
|
200
193
|
/** @returns whether proposals are enabled in the current settings. */
|
|
@@ -231,16 +224,13 @@ export class SkillUpdater {
|
|
|
231
224
|
/**
|
|
232
225
|
* Read the proposal, build its tree, and write the diff file.
|
|
233
226
|
* @returns undefined when the task wrote no proposal.
|
|
234
|
-
* @throws ProposalError or
|
|
227
|
+
* @throws ProposalError, or an error when dsh-repo is unavailable or the mirror cannot be read.
|
|
235
228
|
*/
|
|
236
|
-
private async prepare(task: DispatchedTask): Promise<{ reason: string; files: readonly ProposedFile[]; 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> {
|
|
237
230
|
const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates)
|
|
238
231
|
if (proposal === undefined) return undefined
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
change: await prepareChange(commands, clone.dir, task.commit, skillDirOf(task.app), proposal.files),
|
|
242
|
-
target: clone.branch,
|
|
243
|
-
}))
|
|
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)
|
|
244
234
|
if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`)
|
|
245
235
|
const diffPath = join(task.workDir, DIFF_FILE)
|
|
246
236
|
await replaceFile(diffPath, change.diff)
|
|
@@ -286,18 +276,18 @@ export class SkillUpdater {
|
|
|
286
276
|
if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`)
|
|
287
277
|
const approval = this.deps.approval()
|
|
288
278
|
if (approval === undefined) throw new Error('当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交')
|
|
289
|
-
const
|
|
290
|
-
if (lookup.availability !== 'ready') {
|
|
291
|
-
throw new Error(lookup.availability === 'missing'
|
|
292
|
-
? `提交 SKILL 修改需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务;请安装 dsh-repo,并在它的 hosts 中配置该应用仓库所在的主机和令牌`
|
|
293
|
-
: `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(观象需要服务版本 ${DSH_REPOS_VERSION} 且提供 getBranch,即 dsh-repo 0.1.0-beta.2 及以上);请升级 dsh-repo`)
|
|
294
|
-
}
|
|
295
|
-
const repos = lookup.service
|
|
279
|
+
const repos = requireRepos(this.deps.repos())
|
|
296
280
|
|
|
297
|
-
|
|
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
|
+
}
|
|
298
288
|
if (prepared === undefined) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`)
|
|
299
289
|
const { change, target, diffPath, reason } = prepared
|
|
300
|
-
const branch = `guanxiang/skill-${task.app.id}-${change.
|
|
290
|
+
const branch = `guanxiang/skill-${task.app.id}-${change.digest.slice(0, 10)}`
|
|
301
291
|
const base = { app: task.app.id, repo: task.url, branch, target, files: [...change.files], diffPath }
|
|
302
292
|
|
|
303
293
|
if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== undefined) return { ...base, status: 'exists' }
|
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,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 在拉取仓库中用 git 底层命令检查 SKILL 修改:用临时索引文件在分析时的提交上
|
|
3
|
-
* 替换文件、写出树对象,算出逐文件行数和 diff,不改动任何检出、快照或本地
|
|
4
|
-
* 分支。树对象的前缀用作新分支名,提交本身经 dsh-repo 完成。
|
|
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
|
-
const DIFF_FLAGS = ['--no-color', '--no-ext-diff', '--no-renames']
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Write the proposed files over the skill directory of `base` into a new tree.
|
|
44
|
-
* @param commands - git runner and timeout.
|
|
45
|
-
* @param dir - repository directory whose object database receives the objects.
|
|
46
|
-
* @param base - commit the analysis ran on.
|
|
47
|
-
* @param skillDir - skill directory relative to the repository root, `/`-separated.
|
|
48
|
-
* @param files - proposed files relative to `skillDir`.
|
|
49
|
-
* @returns the tree, per-file line counts, and the diff; `files` is empty when nothing changes.
|
|
50
|
-
* @throws when a proposed path replaces a symbolic link or a directory, or a git command fails.
|
|
51
|
-
*/
|
|
52
|
-
export async function prepareChange(commands: GitCommands, dir: string, base: string, skillDir: string, files: readonly ProposedFile[]): Promise<PreparedChange> {
|
|
53
|
-
const { git, timeoutMs } = commands
|
|
54
|
-
const scratch = await mkdtemp(join(tmpdir(), 'guanxiang-index-'))
|
|
55
|
-
try {
|
|
56
|
-
const env = { GIT_INDEX_FILE: join(scratch, 'index') }
|
|
57
|
-
await git(['read-tree', base], { cwd: dir, timeoutMs, env })
|
|
58
|
-
const listing = await git(['ls-tree', '-r', '-t', '-z', base, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
59
|
-
const modes = new Map<string, string>()
|
|
60
|
-
for (const record of listing.split('\0')) {
|
|
61
|
-
const tab = record.indexOf('\t')
|
|
62
|
-
if (tab < 0) continue
|
|
63
|
-
modes.set(record.slice(tab + 1), record.slice(0, record.indexOf(' ')))
|
|
64
|
-
}
|
|
65
|
-
for (const file of files) {
|
|
66
|
-
const path = `${skillDir}/${file.path}`
|
|
67
|
-
const existing = modes.get(path)
|
|
68
|
-
if (existing === '120000') throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`)
|
|
69
|
-
if (existing === '040000') throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`)
|
|
70
|
-
const blob = (await git(['hash-object', '-w', '--stdin'], { cwd: dir, timeoutMs, input: file.content })).trim()
|
|
71
|
-
await git(['update-index', '--add', '--cacheinfo', `${existing === '100755' ? '100755' : '100644'},${blob},${path}`], { cwd: dir, timeoutMs, env })
|
|
72
|
-
}
|
|
73
|
-
const tree = (await git(['write-tree'], { cwd: dir, timeoutMs, env })).trim()
|
|
74
|
-
const numstat = await git(['diff', ...DIFF_FLAGS, '--numstat', base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
75
|
-
const stats = numstat.split('\n').filter(line => line !== '').map((line): FileStat => {
|
|
76
|
-
const [added = '-', removed = '-', ...path] = line.split('\t')
|
|
77
|
-
return { path: path.join('\t'), ...added === '-' ? {} : { added: Number(added) }, ...removed === '-' ? {} : { removed: Number(removed) } }
|
|
78
|
-
})
|
|
79
|
-
const diff = stats.length === 0 ? '' : await git(['diff', ...DIFF_FLAGS, base, tree, '--', `${skillDir}/`], { cwd: dir, timeoutMs })
|
|
80
|
-
return { base, tree, files: stats, diff }
|
|
81
|
-
} finally {
|
|
82
|
-
await rm(scratch, { recursive: true, force: true })
|
|
83
|
-
}
|
|
84
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-repo `dshRepos` 服务的查找:SKILL 修改提议经它在代码托管平台上建分支、
|
|
3
|
-
* 提交并创建合并请求(GitLab MR 或 GitHub PR)。
|
|
4
|
-
*
|
|
5
|
-
* 只从 `dsh-repo/contract` 引入类型;服务在运行时经 `ctx.get('dshRepos')`
|
|
6
|
-
* 取得,版本不符或缺少 `getBranch`(早于 0.1.0-beta.2 的 dsh-repo,提交时会忽略
|
|
7
|
-
* `baseSha` 与 `newBranch`)时不调用。
|
|
8
|
-
*
|
|
9
|
-
* @module dsh-guanxiang/skill-update/repos
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { DshRepoService, DshReposVersion, RepoErrorCode } from 'dsh-repo/contract'
|
|
13
|
-
|
|
14
|
-
/** Name dsh-repo provides the service under. */
|
|
15
|
-
export const DSH_REPOS_SERVICE = 'dshRepos'
|
|
16
|
-
|
|
17
|
-
/** The `dshRepos` contract version this plugin speaks. */
|
|
18
|
-
export const DSH_REPOS_VERSION: DshReposVersion = 1
|
|
19
|
-
|
|
20
|
-
/** Result of looking the service up. */
|
|
21
|
-
export type ReposLookup =
|
|
22
|
-
| { readonly availability: 'ready'; readonly service: DshRepoService }
|
|
23
|
-
| { readonly availability: 'missing' | 'incompatible' }
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Classify the value `ctx.get('dshRepos')` returned. The provider is another
|
|
27
|
-
* package that may be older or newer than this one, so its version is checked
|
|
28
|
-
* at run time; `getBranch` marks a provider that also honors `baseSha` and
|
|
29
|
-
* `newBranch` on commits.
|
|
30
|
-
* @param value - the looked-up service, or undefined.
|
|
31
|
-
* @returns the usable service, or why there is none.
|
|
32
|
-
*/
|
|
33
|
-
export function lookupRepos(value: unknown): ReposLookup {
|
|
34
|
-
if (value === undefined || value === null) return { availability: 'missing' }
|
|
35
|
-
const service = value as Partial<Record<keyof DshRepoService, unknown>>
|
|
36
|
-
if (service.version !== DSH_REPOS_VERSION || typeof service.getBranch !== 'function' || typeof service.commitFiles !== 'function' || typeof service.createChange !== 'function') {
|
|
37
|
-
return { availability: 'incompatible' }
|
|
38
|
-
}
|
|
39
|
-
return { availability: 'ready', service: value as DshRepoService }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* The failure class of a `dshRepos` rejection.
|
|
44
|
-
* @param error - the rejection.
|
|
45
|
-
* @returns its `code` when it is a `RepoError`.
|
|
46
|
-
*/
|
|
47
|
-
export function repoErrorCode(error: unknown): RepoErrorCode | undefined {
|
|
48
|
-
if (!(error instanceof Error) || error.name !== 'RepoError') return undefined
|
|
49
|
-
const code = (error as Error & { code?: unknown }).code
|
|
50
|
-
return typeof code === 'string' ? code as RepoErrorCode : undefined
|
|
51
|
-
}
|