♻️ refactor(vscode): 代码托管仓库抽成统一接口,Gitea 实现搬到 provider 后面,翻页改看 Link 头

This commit is contained in:
cruldra committed 2026-10-19 07:56:40 +11:00
1 parent c90d84d7f2
commit 14bee9f505
64 files changed
+1881 -2718

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { cleanCommentSummary, systemCommentText } from '../../src/forge/issueComment'
describe('cleanCommentSummary', () => {
it('保留普通单行纯文本', () => {
expect(cleanCommentSummary('好的我来看下')).toBe('好的我来看下')
})
it('空文本返回空字符串', () => {
expect(cleanCommentSummary('')).toBe('')
})
it('去除 Markdown 引用(> 开头的行)', () => {
const raw = `> 上一条评论的内容
> 引用多行
好的收到,马上修改`
expect(cleanCommentSummary(raw)).toBe('好的收到,马上修改')
})
it('当全部内容都是引用时,回退提取去掉 > 后的文字', () => {
const raw = `> 收到回复`
expect(cleanCommentSummary(raw)).toBe('收到回复')
})
it('去除围栏代码块,保留正文', () => {
const raw = `问题定位如下:
\`\`\`ts
const x = 1
console.log(x)
\`\`\`
请检查修复。`
expect(cleanCommentSummary(raw)).toBe('问题定位如下: 请检查修复。')
})
it('清洗行内代码为纯文本', () => {
expect(cleanCommentSummary('请检查 `coordinator.ts` 中的参数配置')).toBe('请检查 coordinator.ts 中的参数配置')
})
it('清洗 Markdown 链接与图片', () => {
const raw = '![效果图](https://img.example.com/pic.png) 请参考 [规范文档](https://example.com/doc) 进行修改'
expect(cleanCommentSummary(raw)).toBe('请参考 规范文档 进行修改')
})
it('去除 Markdown 表格线与表格符号', () => {
const raw = `审查结论如下:
| 模块 | 结果 |
| --- | --- |
| 登录 | 通过 |
| 支付 | 待测 |`
expect(cleanCommentSummary(raw)).toBe('审查结论如下: 模块 结果 登录 通过 支付 待测')
})
it('去除粗体、斜体、删除线、标题符号', () => {
const raw = '### **重要通知**:*今天* ~~不~~ 发布'
expect(cleanCommentSummary(raw)).toBe('重要通知:今天 不 发布')
})
it('去除任务列表标记与列表符号', () => {
const raw = `- [x] 第一步\n- [ ] 第二步`
expect(cleanCommentSummary(raw)).toBe('第一步 第二步')
})
it('去除 HTML 注释', () => {
const raw = '<!-- spx:review=1 -->这是一条评论'
expect(cleanCommentSummary(raw)).toBe('这是一条评论')
})
it('单行截断:不超过 60 字符不截断,超过 60 字符截断并追加 ...', () => {
const exact60 = 'a'.repeat(60)
expect(cleanCommentSummary(exact60)).toBe(exact60)
const over60 = 'b'.repeat(70)
expect(cleanCommentSummary(over60)).toBe(`${'b'.repeat(60)}...`)
})
})
describe('systemCommentText', () => {
it('识别 review 评论', () => {
expect(systemCommentText('<!-- spx:review=1 -->评审意见')).toBe('评审意见')
expect(systemCommentText('<!-- spx:review=1 -->')).toBe('提交了审查')
})
it('普通文本不是系统评论', () => {
expect(systemCommentText('这是一条普通的讨论')).toBeUndefined()
})
})
+421
View File
@@ -0,0 +1,421 @@
import type { ForgeProvider } from '../../src/forge/types'
import { Buffer } from 'node:buffer'
import { EventEmitter } from 'node:events'
import { delimiter } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { parseStateComment } from '../../src/issues/stateComment'
const execFile = vi.fn()
const spawn = vi.fn()
function mockClaudeSpawnSuccess(stdout: string) {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
kill: ReturnType<typeof vi.fn>
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn()
spawn.mockImplementationOnce(() => {
queueMicrotask(() => {
child.stdout.emit('data', Buffer.from(stdout))
child.emit('close', 0)
})
return child
})
return child
}
function mockClaudeSpawnFailure(code: number, stdout: string, stderr = '') {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
kill: ReturnType<typeof vi.fn>
}
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.kill = vi.fn()
spawn.mockImplementationOnce(() => {
queueMicrotask(() => {
if (stdout)
child.stdout.emit('data', Buffer.from(stdout))
if (stderr)
child.stderr.emit('data', Buffer.from(stderr))
child.emit('close', code)
})
return child
})
return child
}
vi.mock('node:child_process', () => ({
execFile,
spawn,
}))
// spawnClaude 用解析出的绝对路径起进程并把 bin 目录前插 PATH(9255b55);
// 固定住解析结果,断言不再依赖本机 claude 的安装位置。
vi.mock('../../src/cc/claudeBin', () => ({
resolveClaudeBin: () => '/fake/claude-bin/claude',
}))
const listIssueComments = vi.fn()
const postIssueComment = vi.fn()
const provider = { listIssueComments, postIssueComment } as unknown as ForgeProvider
describe('mergeForgeStateGuarded', () => {
beforeEach(() => {
listIssueComments.mockReset()
postIssueComment.mockReset()
})
it('keeps done column when an automatic flow tries to merge review while preserving other fields', async () => {
const { mergeForgeStateGuarded } = await import('../../src/forge/state.js')
listIssueComments.mockResolvedValue([
{ body: JSON.stringify({ column: 'done', implementStatus: 'done', prMerged: true }) },
])
await mergeForgeStateGuarded(provider, 123, { column: 'review', reviewSessionId: 'review-1' }, { protectDoneColumn: true })
expect(postIssueComment).toHaveBeenCalledOnce()
expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual({
column: 'done',
implementStatus: 'done',
prMerged: true,
reviewSessionId: 'review-1',
})
})
it('skips posting when protected done would only merge a stale column', async () => {
const { mergeForgeStateGuarded } = await import('../../src/forge/state.js')
listIssueComments.mockResolvedValue([
{ body: JSON.stringify({ column: 'done', implementStatus: 'done' }) },
])
await mergeForgeStateGuarded(provider, 123, { column: 'review' }, { protectDoneColumn: true })
expect(postIssueComment).not.toHaveBeenCalled()
})
})
describe('state JSON 尾部普通评论不丢状态', () => {
beforeEach(() => {
listIssueComments.mockReset()
postIssueComment.mockReset()
})
it('merge 时跳过尾部普通文本评论,保留完整历史 state', async () => {
const { mergeForgeState } = await import('../../src/forge/state.js')
const fullState = {
color: 'terminal.ansiCyan',
column: 'in-progress',
planFile: 'docs/superpowers/plans/x/plan.md',
sessionId: '5cf217c2',
branch: 'feature/be3beabc',
worktreePath: '.claude/worktrees/be3beabc',
implementStatus: 'running',
}
// merge 现在 post 后会读回校验(乐观并发检测),mock 必须有状态:
// postIssueComment 之后列表要包含新评论,否则校验误判丢失导致重放。
const comments = [
{ body: JSON.stringify(fullState) },
{ body: '这是一条普通评论,不是 state JSON' },
{ body: JSON.stringify({ note: '像 JSON 但没有任何已知 state 字段' }) },
]
listIssueComments.mockImplementation(async () => [...comments])
postIssueComment.mockImplementation(async (_issueNumber: number, body: string) => {
comments.push({ body })
})
await mergeForgeState(provider, 182, { pr: '182', implementStatus: 'done' })
expect(postIssueComment).toHaveBeenCalledOnce()
expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual({
...fullState,
pr: '182',
implementStatus: 'done',
})
})
it('post 后读回发现字段被并发覆盖时,在最新状态上重放一次', async () => {
const { mergeForgeState } = await import('../../src/forge/state.js')
const comments: Array<{ body: string }> = [
{ body: JSON.stringify({ column: 'in-progress' }) },
]
listIssueComments.mockImplementation(async () => [...comments])
let postCount = 0
postIssueComment.mockImplementation(async (_issueNumber: number, body: string) => {
postCount++
if (postCount === 1) {
// 模拟并发写者在我们 post 之后又盖了一条:我们的字段丢失。
comments.push({ body: JSON.stringify({ column: 'review' }) })
return
}
comments.push({ body })
})
await mergeForgeState(provider, 7, { pr: '99' })
expect(postIssueComment).toHaveBeenCalledTimes(2)
// 重放基于并发写者的最新状态,两边的字段都保留。
expect(parseStateComment(postIssueComment.mock.calls[1][1])).toEqual({
column: 'review',
pr: '99',
})
})
it('read 时跳过尾部普通评论返回最近一条 state JSON', async () => {
const { readForgeState } = await import('../../src/forge/state.js')
listIssueComments.mockResolvedValue([
{ body: JSON.stringify({ column: 'review', pr: '9' }) },
{ body: 'PR 已关联(Gitea 自动评论)' },
])
const state = await readForgeState(provider, 9)
expect(state).toEqual({ column: 'review', pr: '9' })
})
})
describe('state defaults', () => {
beforeEach(() => {
listIssueComments.mockReset()
postIssueComment.mockReset()
})
it.each([
[{}, { column: 'todo', color: 'blue', pr: '5' }],
[{ column: 'review', color: 'red' }, { column: 'review', color: 'red', pr: '5' }],
])('fills missing defaults without replacing existing values: %j', async (existing, expected) => {
const { mergeForgeState } = await import('../../src/forge/state.js')
const comments = [{ body: JSON.stringify(existing) }]
listIssueComments.mockImplementation(async () => [...comments])
postIssueComment.mockImplementation(async (_issueNumber: number, body: string) => comments.push({ body }))
await mergeForgeState(provider, 5, { pr: '5' }, { column: 'todo', color: 'blue' })
expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual(expected)
expect(postIssueComment).toHaveBeenCalledOnce()
})
it('explicit changes override defaults and existing state', async () => {
const { mergeForgeState } = await import('../../src/forge/state.js')
const comments = [{ body: JSON.stringify({ column: 'review' }) }]
listIssueComments.mockImplementation(async () => [...comments])
postIssueComment.mockImplementation(async (_issueNumber: number, body: string) => comments.push({ body }))
await mergeForgeState(provider, 5, { column: 'done' }, { column: 'todo', color: 'blue' })
expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual({ column: 'done', color: 'blue' })
})
it('read-back does not force defaults over a concurrent update', async () => {
const { mergeForgeState } = await import('../../src/forge/state.js')
listIssueComments.mockResolvedValueOnce([]).mockResolvedValue([{ body: JSON.stringify({ column: 'review', color: 'red' }) }])
await mergeForgeState(provider, 5, {}, { column: 'todo', color: 'blue' })
expect(postIssueComment).toHaveBeenCalledOnce()
expect(parseStateComment(postIssueComment.mock.calls[0][1])).toEqual({ column: 'todo', color: 'blue' })
})
})
describe('spawnClaude', () => {
beforeEach(() => {
execFile.mockReset()
spawn.mockReset()
})
it('runs text prompts with stdin ignored so claude -p does not wait for piped input', async () => {
execFile.mockImplementationOnce((_cmd, _args, _opts, cb) => {
cb(null, '{"session_id":"legacy","result":"wrong"}', '')
})
mockClaudeSpawnSuccess('{"session_id":"session-1","result":"ok"}')
const { spawnClaude } = await import('../../src/cc/spawnClaude.js')
const result = await spawnClaude({
prompt: 'hi',
cwd: '/repo',
timeoutMs: 1_000,
})
expect(result).toEqual({
sessionId: 'session-1',
resultText: 'ok',
rawJson: '{"session_id":"session-1","result":"ok"}',
})
expect(execFile).not.toHaveBeenCalled()
expect(spawn).toHaveBeenCalledOnce()
expect(spawn.mock.calls[0][0]).toBe('/fake/claude-bin/claude')
expect(spawn.mock.calls[0][2]).toMatchObject({
cwd: '/repo',
stdio: ['ignore', 'pipe', 'pipe'],
})
})
it('reports JSON stdout diagnostics when claude exits non-zero without stderr', async () => {
mockClaudeSpawnFailure(1, '{"error":"quota exceeded","session_id":"session-2"}')
const { spawnClaude } = await import('../../src/cc/spawnClaude.js')
await expect(spawnClaude({
prompt: 'hi',
cwd: '/repo',
timeoutMs: 1_000,
})).rejects.toThrow('Claude 退出码非零 (1): quota exceeded')
})
it('reports text stdout diagnostics when claude exits non-zero without stderr', async () => {
mockClaudeSpawnFailure(1, 'plain failure from stdout')
const { spawnClaude } = await import('../../src/cc/spawnClaude.js')
await expect(spawnClaude({
prompt: 'hi',
cwd: '/repo',
timeoutMs: 1_000,
})).rejects.toThrow('Claude 退出码非零 (1): plain failure from stdout')
})
it('strips API keys and nested-session env vars so headless claude falls back to subscription OAuth', async () => {
const saved = {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
CLAUDECODE: process.env.CLAUDECODE,
CLAUDE_CODE_ENTRYPOINT: process.env.CLAUDE_CODE_ENTRYPOINT,
CLAUDE_CODE_SESSION_ID: process.env.CLAUDE_CODE_SESSION_ID,
CLAUDE_CODE_SESSION: process.env.CLAUDE_CODE_SESSION,
}
process.env.ANTHROPIC_API_KEY = 'sk-restricted-org-key'
process.env.ANTHROPIC_AUTH_TOKEN = 'auth-token'
process.env.ANTHROPIC_BASE_URL = 'https://relay.example/v1'
process.env.ANTHROPIC_MODEL = 'relay-model'
process.env.CLAUDECODE = '1'
process.env.CLAUDE_CODE_ENTRYPOINT = 'cli'
process.env.CLAUDE_CODE_SESSION_ID = 'parent-session'
process.env.CLAUDE_CODE_SESSION = 'parent-session'
try {
mockClaudeSpawnSuccess('{"session_id":"session-env","result":"ok"}')
const { spawnClaude } = await import('../../src/cc/spawnClaude.js')
await spawnClaude({
prompt: 'hi',
cwd: '/repo',
timeoutMs: 1_000,
})
const env = spawn.mock.calls[0][2].env as NodeJS.ProcessEnv
expect(env).not.toHaveProperty('ANTHROPIC_API_KEY')
expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN')
expect(env).not.toHaveProperty('ANTHROPIC_BASE_URL')
expect(env).not.toHaveProperty('ANTHROPIC_MODEL')
expect(env).not.toHaveProperty('CLAUDECODE')
expect(env).not.toHaveProperty('CLAUDE_CODE_ENTRYPOINT')
expect(env).not.toHaveProperty('CLAUDE_CODE_SESSION_ID')
expect(env).not.toHaveProperty('CLAUDE_CODE_SESSION')
expect(env.PATH).toBe(`/fake/claude-bin${delimiter}${process.env.PATH}`)
}
finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined)
delete process.env[key]
else
process.env[key] = value
}
}
})
})
describe('deleteLocalBranch', () => {
beforeEach(() => {
execFile.mockReset()
})
it('treats a missing local branch as already deleted', async () => {
// heads 不存在 + remote-tracking 也不存在
execFile
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
cb(new Error('missing'), '', '')
})
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
cb(new Error('missing remote'), '', '')
})
const { deleteLocalBranch } = await import('../../src/git/branches.js')
const result = await deleteLocalBranch('/repo', 'feature/test')
expect(result).toEqual({ ok: true, stdout: '', stderr: '' })
expect(execFile).toHaveBeenCalledTimes(2)
expect(execFile.mock.calls[0][1]).toEqual([
'-C',
'/repo',
'show-ref',
'--verify',
'--quiet',
'refs/heads/feature/test',
])
expect(execFile.mock.calls[1][1]).toEqual([
'-C',
'/repo',
'show-ref',
'--verify',
'--quiet',
'refs/remotes/origin/feature/test',
])
})
it('deletes an existing local branch with git args instead of shell interpolation', async () => {
execFile
// show-ref heads: exists
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
cb(null, '', '')
})
// branch -D
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
cb(null, 'deleted', '')
})
// show-ref remotes: missing
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
cb(new Error('missing remote'), '', '')
})
const { deleteLocalBranch } = await import('../../src/git/branches.js')
const result = await deleteLocalBranch('/repo', 'feature/weird name;rm -rf')
expect(result).toEqual({ ok: true, stdout: 'deleted', stderr: '' })
expect(execFile).toHaveBeenCalledTimes(3)
expect(execFile.mock.calls[1][1]).toEqual([
'-C',
'/repo',
'branch',
'-D',
'feature/weird name;rm -rf',
])
})
})
describe('hasStateColumn', () => {
it('带 column 的 state JSON → true', async () => {
const { hasStateColumn } = await import('../../src/issues/stateComment.js')
expect(hasStateColumn('{"column":"done","pr":"318"}')).toBe(true)
expect(hasStateColumn(' {"column":"in-progress"} ')).toBe(true)
})
it('没有 column 的 state JSON → false', async () => {
const { hasStateColumn } = await import('../../src/issues/stateComment.js')
expect(hasStateColumn('{"pr":"318","branch":"feature/x"}')).toBe(false)
})
it('普通文本、审查评论、空值 → false', async () => {
const { hasStateColumn } = await import('../../src/issues/stateComment.js')
expect(hasStateColumn('<!-- spx:review=1 -->\n看着没问题')).toBe(false)
expect(hasStateColumn('拖到完成列了')).toBe(false)
expect(hasStateColumn('')).toBe(false)
expect(hasStateColumn(undefined)).toBe(false)
})
it('json 数组不算 state 评论', async () => {
const { hasStateColumn } = await import('../../src/issues/stateComment.js')
expect(hasStateColumn('[{"column":"done"}]')).toBe(false)
})
})