♻️ 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

@@ -1,11 +1,12 @@
import type { PrFileStatus } from '../../forge/types'
import { describe, expect, it } from 'vitest'
import { CHANGE_STYLES, changeOf, reviewBaseFile, reviewTree } from './changedFiles'
describe('改动状态', () => {
it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => {
it('新增 A、删除 D,别的(改名、复制、修改)都算修改 M', () => {
expect(changeOf('added')).toBe('A')
expect(changeOf('deleted')).toBe('D')
for (const status of ['changed', 'modified', 'renamed', 'copied', 'unchanged', ''])
for (const status of ['modified', 'renamed', 'copied'] as const)
expect(changeOf(status)).toBe('M')
})
@@ -18,10 +19,10 @@ describe('改动状态', () => {
})
describe('审查时在 PR 目标分支上取哪个文件', () => {
const files = [
const files: { path: string, status: PrFileStatus, previousFilename?: string }[] = [
{ path: 'src/new.ts', status: 'added' },
{ path: 'src/moved.ts', status: 'renamed', previousFilename: 'src/old.ts' },
{ path: 'src/changed.ts', status: 'changed' },
{ path: 'src/changed.ts', status: 'modified' },
]
it('新加的目标分支上没有,改名的取改名前的路径,别的照原路径', () => {
@@ -33,13 +34,13 @@ describe('审查时在 PR 目标分支上取哪个文件', () => {
})
describe('审查时树上摆哪些文件', () => {
const files = [
{ path: 'backend/app.py', status: 'changed' },
const files: { path: string, status: PrFileStatus }[] = [
{ path: 'backend/app.py', status: 'modified' },
{ path: 'backend/test_app.py', status: 'added' },
{ path: 'backend/tests/test_avatar.py', status: 'added' },
{ path: 'backend/tests/fixtures/avatar.png', status: 'added' },
{ path: 'web/src/__tests__/Avatar.tsx', status: 'changed' },
{ path: 'web/src/Avatar.tsx', status: 'changed' },
{ path: 'web/src/__tests__/Avatar.tsx', status: 'modified' },
{ path: 'web/src/Avatar.tsx', status: 'modified' },
]
const names = (tree: ReturnType<typeof reviewTree>['tree'], dir: string): string[] => (tree.get(dir) ?? []).map(entry => entry.name)
@@ -62,7 +63,7 @@ describe('审查时树上摆哪些文件', () => {
expect(names(tree, 'web/src')).toEqual(['__tests__', 'Avatar.tsx'])
})
it('改动状态照 Gitea 的', () => {
it('改动状态照 PR 文件的', () => {
const { tree } = reviewTree(files, true)
expect(tree.get('backend')?.[0]).toMatchObject({ path: 'backend/app.py', change: 'M' })
})
+6 -8
View File
@@ -1,12 +1,10 @@
import type { PrFileStatus } from '../../forge/types'
import type { ChangedTree, FileChange } from '../explorer/changedTree'
import { buildChangedTree } from '../explorer/changedTree'
import { isTestFile } from '../uncommitted/testFiles'
/**
* Gitea 给的改动状态 → M / A / D。Gitea 的状态有 added、deleted、changed、renamed、copied、unchanged:
* 新增、删除各算各的,别的都算修改(改名的在新路径上算修改)。
*/
export function changeOf(status: string): FileChange {
/** PR 文件的改动类型 → M / A / D:新增、删除各算各的,别的都算修改(改名的在新路径上算修改)。 */
export function changeOf(status: PrFileStatus): FileChange {
return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'
}
@@ -14,7 +12,7 @@ export function changeOf(status: string): FileChange {
* 审查时树上摆的:PR 改过的文件和通往它们的目录。隐藏测试文件时先剔掉测试文件再摆,只装着测试文件的目录跟着不摆;
* hidden 是剔了几个。
*/
export function reviewTree(files: readonly { path: string, status: string }[], hideTests: boolean): { tree: ChangedTree, hidden: number } {
export function reviewTree(files: readonly { path: string, status: PrFileStatus }[], hideTests: boolean): { tree: ChangedTree, hidden: number } {
const changes = files.map(file => ({ path: file.path, change: changeOf(file.status) }))
const shown = hideTests ? changes.filter(file => !isTestFile(file.path)) : changes
return { tree: buildChangedTree(shown), hidden: changes.length - shown.length }
@@ -22,9 +20,9 @@ export function reviewTree(files: readonly { path: string, status: string }[], h
/**
* 审查时工作副本里的一个文件(path 相对工作副本的根)在 PR 目标分支上叫什么、有没有:
* 改名的取改名前的路径,PR 里新加的(Gitea 的 added)目标分支上没有。PR 没改过的文件照原路径取。
* 改名的取改名前的路径,PR 里新加的目标分支上没有。PR 没改过的文件照原路径取。
*/
export function reviewBaseFile(files: readonly { path: string, status: string, previousFilename?: string }[], path: string): { path: string, added: boolean } {
export function reviewBaseFile(files: readonly { path: string, status: PrFileStatus, previousFilename?: string }[], path: string): { path: string, added: boolean } {
const file = files.find(item => item.path === path)
return { path: file?.previousFilename ?? path, added: file?.status === 'added' }
}
@@ -1,4 +1,4 @@
import type { Issue } from '../../gitea/types'
import type { Issue } from '../../issues/types'
import { describe, expect, it } from 'vitest'
import { boardIssues } from '../../panel/kanbanSnapshot'
import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList'
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Issue } from '../../gitea/types'
import type { Issue } from '../../issues/types'
import { columnLabel, prNumberOf } from './reviewRules'
/** PR 改了几个文件:还在读、没读到、读到了。 */
+17 -15
View File
@@ -1,12 +1,13 @@
import type { Disposable, Event, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode'
import type { PrFileDto } from '../../gitea/api'
import type { RepoContext } from '../../gitea/repoContext'
import type { Issue } from '../../gitea/types'
import type { ForgeContext } from '../../forge/context'
import type { ForgePrFile } from '../../forge/types'
import type { Issue } from '../../issues/types'
import type { DiffBase, DiffBaseSource } from '../diff/diffBase'
import type { FileTreeProvider, TreeNode } from '../explorer/fileTree'
import type { FileCount } from './pickList'
import * as path from 'node:path'
import { commands, EventEmitter, QuickPickItemKind, Uri, window, workspace } from 'vscode'
import { requireForge } from '../../forge/context'
import { fileAtRef } from '../../git/fileAt'
import { logger } from '../../logging/logger'
import { WORKSPACE_TREE } from '../explorer/fileTree'
@@ -17,7 +18,7 @@ import { ChangeDecorations } from './changeDecorations'
import { changeOf, reviewBaseFile, reviewTree } from './changedFiles'
import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList'
import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewBaseLabel, reviewBaseMissing, reviewDescription, reviewMessage } from './reviewRules'
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource'
import { loadIssue, loadPullRequest, reviewCandidateIssues } from './reviewSource'
import { localWorktree, prepareWorktree } from './reviewWorktree'
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
@@ -35,12 +36,12 @@ type How = 'pick' | 'reload' | 'restore'
/**
* 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本,
* 和预览对比的基准:PR 的目标分支名、目标分支与 PR 头的合并基(Gitea 没给时退回目标分支的提交,同看板「改动」tab)。
* 和预览对比的基准:PR 的目标分支名、目标分支与 PR 头的合并基(取不到时退回目标分支的提交,同看板「改动」tab)。
*/
interface Loaded {
issue: Issue
pr: number
files: PrFileDto[]
files: ForgePrFile[]
worktree: string
base: { ref: string, commit: string }
}
@@ -115,12 +116,12 @@ export class ReviewMode implements DiffBaseSource, Disposable {
})
picker.show()
let repo: RepoContext
let repo: ForgeContext
try {
repo = await requireRepo(this.context)
repo = await requireForge(this.context)
}
catch (err) {
logger.add({ level: 'error', source: 'codeReader', message: '选工单:读不到当前工作区的 Gitea 仓库', details: errorText(err) })
logger.add({ level: 'error', source: 'codeReader', message: '选工单:读不到当前工作区的代码托管仓库', details: errorText(err) })
picker.hide()
void window.showErrorMessage(`没法选工单:${errorText(err)}`)
return
@@ -228,9 +229,9 @@ export class ReviewMode implements DiffBaseSource, Disposable {
/** 弹框里的一项:有 PR 的工单去读 PR 改了几个文件(各项一起读),读不到就写读不到。 */
/** 读一张工单的 PR 改了几个文件;没读到记进日志,这一项写读不到。 */
private async countFiles(repo: RepoContext, issueNumber: number, pr: number): Promise<FileCount> {
private async countFiles(repo: ForgeContext, issueNumber: number, pr: number): Promise<FileCount> {
try {
return { count: (await loadPullRequestFiles(repo, pr)).length }
return { count: (await repo.provider.listPullRequestFiles(pr)).length }
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `选工单:读不到 #${issueNumber} 的 PR #${pr} 改了几个文件`, details: errorText(err) })
@@ -281,7 +282,7 @@ export class ReviewMode implements DiffBaseSource, Disposable {
}
private async load(issueNumber: number, step: (text: string) => void): Promise<Loaded | { blocked: string }> {
const repo = await requireRepo(this.context)
const repo = await requireForge(this.context)
step(`正在读取 #${issueNumber}…`)
const reviewable = reviewableIssue(issueNumber, await loadIssue(this.context, repo, issueNumber))
if ('blocked' in reviewable)
@@ -291,10 +292,11 @@ export class ReviewMode implements DiffBaseSource, Disposable {
const reviewablePull = reviewablePullRequest(pr, await loadPullRequest(repo, pr))
if ('blocked' in reviewablePull)
return reviewablePull
const files = await loadPullRequestFiles(repo, pr)
const { headSha, baseRef, mergeBase, baseSha } = reviewablePull.pull
const files = await repo.provider.listPullRequestFiles(pr)
const { headSha, baseRef } = reviewablePull.pull
const baseCommit = await repo.provider.pullRequestDiffBase(reviewablePull.pull)
const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha }, step)
return { issue, pr, files, worktree, base: { ref: baseRef, commit: mergeBase || baseSha } }
return { issue, pr, files, worktree, base: { ref: baseRef, commit: baseCommit } }
}
private async show(loaded: Loaded): Promise<void> {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Issue, IssueColumn } from '../../gitea/types'
import type { Issue, IssueColumn } from '../../issues/types'
import { withHiddenTests } from '../uncommitted/testFiles'
/** 工单关联的 PR 号;state JSON 里没有、或不是正整数时没有。 */
+12 -26
View File
@@ -1,28 +1,19 @@
/**
* 审查工单要的看板数据,全走看板自己的那套:列工单用看板上现在摆着的,重读一张工单用看板的单张加载
* (state JSON 定列),本机记录(worktree 路径)照看板叠上,PR 用 Gitea 的接口。
* (状态评论定列),本机记录(worktree 路径)照看板叠上,PR 用代码托管仓库的接口。
*/
import type { ExtensionContext } from 'vscode'
import type { GiteaPullRequest, PrFileDto } from '../../gitea/api'
import type { RepoContext } from '../../gitea/repoContext'
import type { Issue } from '../../gitea/types'
import type { ForgeContext } from '../../forge/context'
import type { ForgePullRequest } from '../../forge/types'
import type { Issue } from '../../issues/types'
import type { BoardIssues } from '../../panel/kanbanSnapshot'
import { getPullRequest, GiteaApiError, listPullRequestFiles } from '../../gitea/api'
import { loadSingleIssue } from '../../gitea/issueLoader'
import { resolveRepoContext } from '../../gitea/repoContext'
import { loadSingleIssue } from '../../forge/issueLoader'
import { ForgeApiError } from '../../forge/types'
import { overlayLocalIssueState } from '../../issues/localState'
import { KanbanWebviewPanel } from '../../panel/KanbanPanel'
import { reviewCandidates } from './reviewRules'
/** 当前工作区的 Gitea 仓库和 token;没有时抛出给人看的错。 */
export async function requireRepo(context: ExtensionContext): Promise<RepoContext> {
const repo = await resolveRepoContext(context)
if (!repo)
throw new Error('当前工作区没有 Gitea 仓库或未配置 token')
return repo
}
/**
* 选工单弹框列的工单(哪些列、哪些工单见 reviewCandidates):取看板上现在摆着的(用户看到的就是它),不再加载一遍;
* 看板还没加载过时 undefined。进审查时再单独重读选中的那张(loadIssue)。
@@ -33,24 +24,19 @@ export function reviewCandidateIssues(context: ExtensionContext): BoardIssues |
}
/** 一张工单的最新状态;工单已经不存在时 null。 */
export async function loadIssue(context: ExtensionContext, repo: RepoContext, issueNumber: number): Promise<Issue | null> {
const issue = await loadSingleIssue({ ...repo, issueNumber })
return issue && overlayLocalIssueState(context, [issue], repo.workspaceRoot)[0]
export async function loadIssue(context: ExtensionContext, forge: ForgeContext, issueNumber: number): Promise<Issue | null> {
const issue = await loadSingleIssue(forge, issueNumber)
return issue && overlayLocalIssueState(context, [issue], forge.workspaceRoot)[0]
}
/** PR 的现状;PR 已经不存在时 null。 */
export async function loadPullRequest(repo: RepoContext, pr: number): Promise<GiteaPullRequest | null> {
export async function loadPullRequest(forge: ForgeContext, pr: number): Promise<ForgePullRequest | null> {
try {
return await getPullRequest({ ...repo, index: pr })
return await forge.provider.getPullRequest(pr)
}
catch (err) {
if (err instanceof GiteaApiError && err.status === 404)
if (err instanceof ForgeApiError && err.status === 404)
return null
throw err
}
}
/** PR 改过的全部文件。 */
export function loadPullRequestFiles(repo: RepoContext, pr: number): Promise<PrFileDto[]> {
return listPullRequestFiles({ ...repo, index: pr })
}
@@ -1,6 +1,6 @@
import type { ExtensionContext } from 'vscode'
import type { RepoContext } from '../../gitea/repoContext'
import type { Issue } from '../../gitea/types'
import type { ForgeContext } from '../../forge/context'
import type { Issue } from '../../issues/types'
import { existsSync } from 'node:fs'
import { window } from 'vscode'
import { addDetachedWorktree, fastForwardWorktree, fetchPullRequestHead, resolveWorktreePath } from '../../git/worktree'
@@ -34,7 +34,7 @@ export function localWorktree(context: ExtensionContext, workspaceRoot: string,
*/
export async function prepareWorktree(
context: ExtensionContext,
repo: RepoContext,
repo: ForgeContext,
issue: Pick<Issue, 'number' | 'worktreePath'>,
pull: { number: number, headSha: string },
step: (text: string) => void,