✨ feat(vscode): 代码阅读侧栏可以按审查工单只看 PR 改过的文件:标题栏「选工单」列出看板「审查」列,选中后树里只剩改过的文件(M / A / D 着色),点开的是本机工作副本里 PR 最新的代码,没有就拉 PR 分支建一个,「显示全部」退出
This commit is contained in:
1 parent
c009187f6c
commit
8e2cdfaf17
18 files changed
+1053
-40
No files matched your search
@@ -1,7 +1,9 @@
|
||||
import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode'
|
||||
import type { ChangedTree, FileChange } from '../review/changedFiles'
|
||||
import type { ExcludeSetting } from './fileEntries'
|
||||
import * as path from 'node:path'
|
||||
import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode'
|
||||
import { CHANGE_STYLES } from '../review/changedFiles'
|
||||
import { createExcludeMatcher, entryContext, sortEntries } from './fileEntries'
|
||||
|
||||
export interface FileNode {
|
||||
@@ -9,6 +11,14 @@ export interface FileNode {
|
||||
folder: WorkspaceFolder
|
||||
isDirectory: boolean
|
||||
isRoot: boolean
|
||||
/** 审查工单时这个文件在 PR 里怎么改的;普通的树、目录没有 */
|
||||
change?: FileChange
|
||||
}
|
||||
|
||||
/** 审查工单时树上摆什么:工作副本当唯一的根(folder),下面只有 PR 改过的文件(tree)。 */
|
||||
export interface ChangedFilesView {
|
||||
folder: WorkspaceFolder
|
||||
tree: ChangedTree
|
||||
}
|
||||
|
||||
/** 给树上的行添信息(比如文件注释):每次画行时调 decorate,onDidChange 报出哪些 Uri 的行要重画。 */
|
||||
@@ -23,6 +33,7 @@ const REFRESH_DELAY_MS = 300
|
||||
/**
|
||||
* 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。
|
||||
* 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。
|
||||
* 审查工单时换成只摆 PR 改过的文件(showChanges):目录全部展开,删掉的文件列出来但打不开。
|
||||
*/
|
||||
export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
/** undefined 是整棵树重画,数组是只重画这几行 */
|
||||
@@ -32,6 +43,8 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
/** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */
|
||||
private readonly nodes = new Map<string, FileNode>()
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
/** 审查工单时只摆的那些文件;undefined 是普通的工作区文件树 */
|
||||
private changes: ChangedFilesView | undefined
|
||||
|
||||
/**
|
||||
* @param openCommand 点文件时执行的命令,参数是文件 Uri
|
||||
@@ -68,7 +81,15 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
return item
|
||||
}
|
||||
|
||||
/** 换成只摆 PR 改过的文件(undefined 回到工作区文件树),整棵树重画。 */
|
||||
showChanges(view: ChangedFilesView | undefined): void {
|
||||
this.changes = view
|
||||
this.refresh()
|
||||
}
|
||||
|
||||
async getChildren(node?: FileNode): Promise<FileNode[]> {
|
||||
if (this.changes)
|
||||
return this.changedChildren(this.changes, node)
|
||||
if (!node)
|
||||
return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true))
|
||||
|
||||
@@ -97,26 +118,38 @@ export class FileTreeProvider implements TreeDataProvider<FileNode> {
|
||||
if (!node.isDirectory) {
|
||||
const item = new TreeItem(node.uri, TreeItemCollapsibleState.None)
|
||||
item.iconPath = ThemeIcon.File
|
||||
item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] }
|
||||
if (node.change === 'D')
|
||||
item.tooltip = CHANGE_STYLES.D.tooltip // 删掉的文件没有最新的版本可看,不挂打开的命令
|
||||
else
|
||||
item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] }
|
||||
return item
|
||||
}
|
||||
const item = new TreeItem(node.uri, node.isRoot ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed)
|
||||
const expanded = node.isRoot || this.changes !== undefined
|
||||
const item = new TreeItem(node.uri, expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed)
|
||||
item.iconPath = ThemeIcon.Folder
|
||||
if (node.isRoot)
|
||||
item.label = node.folder.name
|
||||
return item
|
||||
}
|
||||
|
||||
private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean): FileNode {
|
||||
private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean, change?: FileChange): FileNode {
|
||||
const id = uri.toString()
|
||||
const known = this.nodes.get(id)
|
||||
if (known && known.folder === folder && known.isDirectory === isDirectory && known.isRoot === isRoot)
|
||||
if (known && known.folder === folder && known.isDirectory === isDirectory && known.isRoot === isRoot && known.change === change)
|
||||
return known
|
||||
const node: FileNode = { uri, folder, isDirectory, isRoot }
|
||||
const node: FileNode = { uri, folder, isDirectory, isRoot, ...(change ? { change } : {}) }
|
||||
this.nodes.set(id, node)
|
||||
return node
|
||||
}
|
||||
|
||||
/** 审查工单时的一层:根下面是工作副本,再往下只有 PR 改过的文件和通往它们的目录。 */
|
||||
private changedChildren({ folder, tree }: ChangedFilesView, node?: FileNode): FileNode[] {
|
||||
if (!node)
|
||||
return [this.nodeAt(folder.uri, folder, true, true)]
|
||||
const dir = path.posix.relative(folder.uri.path, node.uri.path)
|
||||
return (tree.get(dir) ?? []).map(entry => this.nodeAt(Uri.joinPath(folder.uri, entry.path), folder, entry.isDirectory, false, entry.change))
|
||||
}
|
||||
|
||||
/** 还没展开过的行不用管,展开时自然按最新的画。 */
|
||||
private redraw(uris: readonly Uri[]): void {
|
||||
const nodes = uris.flatMap(uri => this.nodes.get(uri.toString()) ?? [])
|
||||
|
||||
@@ -102,3 +102,36 @@ describe('右键菜单的注释', () => {
|
||||
expect(itemsFor('view/item/context', 'notes', root)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('审查工单', () => {
|
||||
const REVIEWING = 'superpowers.codeReader.reviewing'
|
||||
const PICK = 'superpowers.codeReader.pickReviewIssue'
|
||||
const SHOW_ALL = 'superpowers.codeReader.showAllFiles'
|
||||
|
||||
it('标题栏:第一个「选工单」,审查时才有「显示全部」,然后「刷新」', () => {
|
||||
const title = menus['view/title']
|
||||
.filter(item => item.when.startsWith('view == superpowers.codeReader.files'))
|
||||
.sort((a, b) => orderOf(a) - orderOf(b))
|
||||
expect(title.map(item => item.command)).toEqual([PICK, SHOW_ALL, 'superpowers.codeReader.refresh'])
|
||||
expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true)
|
||||
expect(title.find(item => item.command === SHOW_ALL)?.when).toContain(`&& ${REVIEWING}`)
|
||||
expect(title.find(item => item.command === PICK)?.when).not.toContain(REVIEWING)
|
||||
})
|
||||
|
||||
it('按钮的字和图标:选工单 git-pull-request,显示全部 close', () => {
|
||||
expect(commands.find(item => item.command === PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' })
|
||||
expect(commands.find(item => item.command === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' })
|
||||
})
|
||||
|
||||
it('审查时行上没有注释按钮(工作副本里不记注释),复制照旧', () => {
|
||||
const noteItems = menus['view/item/context'].filter(item => item.command === ADD_NOTE || item.command === EDIT_NOTE)
|
||||
expect(noteItems.length).toBeGreaterThan(0)
|
||||
expect(noteItems.every(item => item.when.includes(`!${REVIEWING}`))).toBe(true)
|
||||
const copyItems = menus['view/item/context'].filter(item => item.submenu === COPY_MENU || item.command?.startsWith('superpowers.codeReader.copy'))
|
||||
expect(copyItems.some(item => item.when.includes(REVIEWING))).toBe(false)
|
||||
})
|
||||
|
||||
it('命令面板里「显示全部」只在审查时有', () => {
|
||||
expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(REVIEWING)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import { registerNoteCommands } from './notes/commands'
|
||||
import { FileNotes } from './notes/fileNotes'
|
||||
import { CodeQa } from './qa/codeQa'
|
||||
import { CodeReaderRegistry } from './registry'
|
||||
import { ReviewMode } from './review/reviewMode'
|
||||
import { celeryRule } from './rules/celery'
|
||||
import { dataclassRule } from './rules/dataclass'
|
||||
import { enumRule } from './rules/enum'
|
||||
@@ -53,7 +54,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 接入代码阅读:注册表、侧栏文件树(复制、文件注释、按审查工单只看 PR 改过的文件)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。
|
||||
* 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。
|
||||
*/
|
||||
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
@@ -75,6 +76,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn)
|
||||
const notes = new FileNotes()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, notes)
|
||||
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
||||
const review = new ReviewMode(context, FILES_VIEW, view, files)
|
||||
|
||||
context.subscriptions.push(
|
||||
qa,
|
||||
@@ -88,8 +91,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
}),
|
||||
window.registerCustomEditorProvider(PREVIEW_VIEW_TYPE, previews),
|
||||
files,
|
||||
window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }),
|
||||
commands.registerCommand('superpowers.codeReader.refresh', () => files.refresh()),
|
||||
view,
|
||||
review,
|
||||
// 审查工单时刷新是重读 PR 改过的文件,平时是重读工作区文件树
|
||||
commands.registerCommand('superpowers.codeReader.refresh', () => review.active ? review.reload() : files.refresh()),
|
||||
commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()),
|
||||
commands.registerCommand('superpowers.codeReader.showAllFiles', () => review.exit()),
|
||||
commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)),
|
||||
// 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签
|
||||
commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Disposable, Event, FileDecorationProvider, Uri } from 'vscode'
|
||||
import type { FileChange } from './changedFiles'
|
||||
import { EventEmitter, FileDecoration, ThemeColor } from 'vscode'
|
||||
import { CHANGE_STYLES } from './changedFiles'
|
||||
|
||||
/**
|
||||
* 审查工单时给 PR 改过的文件上色、挂 M / A / D 徽标,颜色和 VS Code 源代码管理一样;不审查时什么都不挂。
|
||||
* 挂在文件 Uri 上:侧栏的行、编辑器标签页上打开的这些文件都带着。
|
||||
*/
|
||||
export class ChangeDecorations implements FileDecorationProvider, Disposable {
|
||||
private readonly changed = new EventEmitter<Uri[]>()
|
||||
readonly onDidChangeFileDecorations: Event<Uri[]> = this.changed.event
|
||||
private changes = new Map<string, FileChange>()
|
||||
private uris: Uri[] = []
|
||||
|
||||
/** 换成这些文件的改动(空数组清掉),原来挂着的和新挂的都重画。 */
|
||||
show(files: readonly { uri: Uri, change: FileChange }[]): void {
|
||||
const before = this.uris
|
||||
this.changes = new Map(files.map(file => [file.uri.toString(), file.change]))
|
||||
this.uris = files.map(file => file.uri)
|
||||
this.changed.fire([...before, ...this.uris])
|
||||
}
|
||||
|
||||
provideFileDecoration(uri: Uri): FileDecoration | undefined {
|
||||
const change = this.changes.get(uri.toString())
|
||||
if (!change)
|
||||
return undefined
|
||||
const style = CHANGE_STYLES[change]
|
||||
return new FileDecoration(change, style.tooltip, new ThemeColor(style.color))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.changed.dispose()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildChangedTree, CHANGE_STYLES, changeOf } from './changedFiles'
|
||||
|
||||
describe('改动状态', () => {
|
||||
it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => {
|
||||
expect(changeOf('added')).toBe('A')
|
||||
expect(changeOf('deleted')).toBe('D')
|
||||
for (const status of ['changed', 'modified', 'renamed', 'copied', 'unchanged', ''])
|
||||
expect(changeOf(status)).toBe('M')
|
||||
})
|
||||
|
||||
it('颜色照 VS Code 源代码管理的 git 装饰色,删掉的提示「PR 里删掉了」', () => {
|
||||
expect(CHANGE_STYLES.M.color).toBe('gitDecoration.modifiedResourceForeground')
|
||||
expect(CHANGE_STYLES.A.color).toBe('gitDecoration.addedResourceForeground')
|
||||
expect(CHANGE_STYLES.D.color).toBe('gitDecoration.deletedResourceForeground')
|
||||
expect(CHANGE_STYLES.D.tooltip).toBe('PR 里删掉了')
|
||||
})
|
||||
})
|
||||
|
||||
describe('改动的文件树', () => {
|
||||
// 原型「审查 · 改动的文件」里的 6 个文件,外加一个改名的
|
||||
const files = [
|
||||
{ path: 'backend/src/luffy_agent/user/routes.py', status: 'changed' },
|
||||
{ path: 'backend/tests/user/test_avatar.py', status: 'added' },
|
||||
{ path: 'backend/src/luffy_agent/core/storage.py', status: 'changed' },
|
||||
{ path: 'backend/src/luffy_agent/user/legacy_profile.py', status: 'deleted' },
|
||||
{ path: 'backend/src/luffy_agent/user/services.py', status: 'changed' },
|
||||
{ path: 'backend/src/luffy_agent/user/schemas.py', status: 'renamed' },
|
||||
]
|
||||
const tree = buildChangedTree(files)
|
||||
const names = (dir: string): string[] => (tree.get(dir) ?? []).map(entry => entry.name)
|
||||
|
||||
it('目录层级照留,每层只有通往改过的文件的目录', () => {
|
||||
expect(names('')).toEqual(['backend'])
|
||||
expect(names('backend')).toEqual(['src', 'tests'])
|
||||
expect(names('backend/src')).toEqual(['luffy_agent'])
|
||||
expect(names('backend/src/luffy_agent')).toEqual(['core', 'user'])
|
||||
expect(names('backend/src/luffy_agent/core')).toEqual(['storage.py'])
|
||||
expect(names('backend/tests/user')).toEqual(['test_avatar.py'])
|
||||
})
|
||||
|
||||
it('同一层里的文件按名字排,带着各自的改动和相对仓库根的路径', () => {
|
||||
expect(tree.get('backend/src/luffy_agent/user')).toEqual([
|
||||
{ name: 'legacy_profile.py', path: 'backend/src/luffy_agent/user/legacy_profile.py', isDirectory: false, change: 'D' },
|
||||
{ name: 'routes.py', path: 'backend/src/luffy_agent/user/routes.py', isDirectory: false, change: 'M' },
|
||||
{ name: 'schemas.py', path: 'backend/src/luffy_agent/user/schemas.py', isDirectory: false, change: 'M' },
|
||||
{ name: 'services.py', path: 'backend/src/luffy_agent/user/services.py', isDirectory: false, change: 'M' },
|
||||
])
|
||||
})
|
||||
|
||||
it('目录没有改动,路径相对仓库根', () => {
|
||||
expect(tree.get('backend')).toEqual([
|
||||
{ name: 'src', path: 'backend/src', isDirectory: true },
|
||||
{ name: 'tests', path: 'backend/tests', isDirectory: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('目录在前、文件在后,名字不区分大小写,和普通的树一样', () => {
|
||||
const mixed = buildChangedTree([
|
||||
{ path: 'README.md', status: 'changed' },
|
||||
{ path: 'src/a.ts', status: 'added' },
|
||||
{ path: 'Docs/b.md', status: 'added' },
|
||||
{ path: 'package.json', status: 'changed' },
|
||||
])
|
||||
expect(mixed.get('')?.map(entry => entry.name)).toEqual(['Docs', 'src', 'package.json', 'README.md'])
|
||||
})
|
||||
|
||||
it('文件换成同名目录(删掉 a、新加 a/b)两项都留着', () => {
|
||||
const replaced = buildChangedTree([{ path: 'a', status: 'deleted' }, { path: 'a/b', status: 'added' }])
|
||||
expect(replaced.get('')).toEqual([
|
||||
{ name: 'a', path: 'a', isDirectory: true },
|
||||
{ name: 'a', path: 'a', isDirectory: false, change: 'D' },
|
||||
])
|
||||
expect(replaced.get('a')?.map(entry => entry.change)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('没有改动的文件时根下面是空的', () => {
|
||||
expect(buildChangedTree([]).get('')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { DirEntry } from '../explorer/fileEntries'
|
||||
import { sortEntries } from '../explorer/fileEntries'
|
||||
|
||||
/** PR 里一个文件怎么改的,照 VS Code 源代码管理的写法:修改 M、新增 A、删除 D。 */
|
||||
export type FileChange = 'M' | 'A' | 'D'
|
||||
|
||||
/**
|
||||
* Gitea 给的改动状态 → M / A / D。Gitea 的状态有 added、deleted、changed、renamed、copied、unchanged:
|
||||
* 新增、删除各算各的,别的都算修改(改名的在新路径上算修改)。
|
||||
*/
|
||||
export function changeOf(status: string): FileChange {
|
||||
return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'
|
||||
}
|
||||
|
||||
/** 各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */
|
||||
export const CHANGE_STYLES: Record<FileChange, { color: string, tooltip: string }> = {
|
||||
M: { color: 'gitDecoration.modifiedResourceForeground', tooltip: 'PR 里改了' },
|
||||
A: { color: 'gitDecoration.addedResourceForeground', tooltip: 'PR 里新加的' },
|
||||
D: { color: 'gitDecoration.deletedResourceForeground', tooltip: 'PR 里删掉了' },
|
||||
}
|
||||
|
||||
/** 改动的文件树上的一项。 */
|
||||
export interface ChangedEntry extends DirEntry {
|
||||
/** 相对仓库根,`/` 分隔 */
|
||||
path: string
|
||||
/** 文件怎么改的;目录没有 */
|
||||
change?: FileChange
|
||||
}
|
||||
|
||||
/** 按目录分好的改动:键是目录相对仓库根的路径(仓库根是 `''`),值是它下面一层的目录和文件。 */
|
||||
export type ChangedTree = ReadonlyMap<string, readonly ChangedEntry[]>
|
||||
|
||||
/**
|
||||
* PR 改过的文件 → 按目录分好的树:只有改过的文件和通往它们的目录,目录层级照留,
|
||||
* 每层的排法和侧栏普通的树一样(目录在前、名字不区分大小写)。
|
||||
* 同一路径上一个文件换成了同名目录(删掉文件 `a`、新加 `a/b`)时两项都留着。
|
||||
*/
|
||||
export function buildChangedTree(files: readonly { path: string, status: string }[]): ChangedTree {
|
||||
const levels = new Map<string, Map<string, ChangedEntry>>([['', new Map()]])
|
||||
const add = (dir: string, entry: ChangedEntry): void => {
|
||||
let level = levels.get(dir)
|
||||
if (!level)
|
||||
levels.set(dir, level = new Map())
|
||||
const key = `${entry.isDirectory ? 'dir' : 'file'}:${entry.name}`
|
||||
if (!level.has(key))
|
||||
level.set(key, entry)
|
||||
}
|
||||
for (const file of files) {
|
||||
const segments = file.path.split('/')
|
||||
segments.forEach((name, index) => {
|
||||
const dir = segments.slice(0, index).join('/')
|
||||
const path = segments.slice(0, index + 1).join('/')
|
||||
add(dir, index === segments.length - 1
|
||||
? { name, path, isDirectory: false, change: changeOf(file.status) }
|
||||
: { name, path, isDirectory: true })
|
||||
})
|
||||
}
|
||||
return new Map([...levels].map(([dir, level]) => [dir, sortEntries([...level.values()])]))
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { Disposable, 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 { FileNode, FileTreeProvider } from '../explorer/fileTree'
|
||||
import type { FileCount } from './reviewRules'
|
||||
import * as path from 'node:path'
|
||||
import { commands, Uri, window, workspace } from 'vscode'
|
||||
import { ChangeDecorations } from './changeDecorations'
|
||||
import { buildChangedTree, changeOf } from './changedFiles'
|
||||
import { pickItemText, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
||||
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewColumnIssues } from './reviewSource'
|
||||
import { localWorktree, prepareWorktree } from './reviewWorktree'
|
||||
|
||||
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
|
||||
const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue'
|
||||
/** 上下文键:正在审查工单。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */
|
||||
const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing'
|
||||
|
||||
/**
|
||||
* 怎么进的审查,决定不能审查、出错时怎么收场:
|
||||
* pick 选工单(说一声,什么都不变)、reload 刷新(说一声,回到全部文件)、restore 重开窗口接着审查(悄悄回到全部文件)。
|
||||
*/
|
||||
type How = 'pick' | 'reload' | 'restore'
|
||||
|
||||
/** 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本。 */
|
||||
interface Loaded {
|
||||
issue: Issue
|
||||
pr: number
|
||||
files: PrFileDto[]
|
||||
worktree: string
|
||||
}
|
||||
|
||||
interface IssuePick extends QuickPickItem {
|
||||
issue: Issue
|
||||
}
|
||||
|
||||
function errorText(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」列的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
|
||||
* 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。
|
||||
*/
|
||||
export class ReviewMode implements Disposable {
|
||||
private readonly decorations = new ChangeDecorations()
|
||||
private readonly disposables: Disposable[]
|
||||
/** 正在审查的工单号 */
|
||||
private reviewing: number | undefined
|
||||
/** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */
|
||||
private generation = 0
|
||||
|
||||
/** viewId 是侧栏视图的 id:进审查时在这个视图上转进度。 */
|
||||
constructor(
|
||||
private readonly context: ExtensionContext,
|
||||
private readonly viewId: string,
|
||||
private readonly view: TreeView<FileNode>,
|
||||
private readonly files: FileTreeProvider,
|
||||
) {
|
||||
this.disposables = [this.decorations, window.registerFileDecorationProvider(this.decorations)]
|
||||
// 视图第一次露出来时才接着上次的审查:没打开侧栏就不去拉 PR、动工作副本
|
||||
if (view.visible) {
|
||||
void this.restore()
|
||||
}
|
||||
else {
|
||||
const shown = view.onDidChangeVisibility(({ visible }) => {
|
||||
if (!visible)
|
||||
return
|
||||
shown.dispose()
|
||||
void this.restore()
|
||||
})
|
||||
this.disposables.push(shown)
|
||||
}
|
||||
}
|
||||
|
||||
get active(): boolean {
|
||||
return this.reviewing !== undefined
|
||||
}
|
||||
|
||||
/** 「选工单」:顶部弹出快速选择,读看板「审查」列的工单和各自改了几个文件时转圈。 */
|
||||
async pick(): Promise<void> {
|
||||
const picker = window.createQuickPick<IssuePick>()
|
||||
picker.title = '选工单 · 看板「审查」列'
|
||||
picker.placeholder = '输入工单号或标题筛选'
|
||||
picker.busy = true
|
||||
let open = true
|
||||
picker.onDidHide(() => {
|
||||
open = false
|
||||
picker.dispose()
|
||||
})
|
||||
picker.onDidAccept(() => {
|
||||
const chosen = picker.selectedItems[0]
|
||||
if (!chosen)
|
||||
return
|
||||
picker.hide()
|
||||
void this.review(chosen.issue, 'pick')
|
||||
})
|
||||
picker.show()
|
||||
try {
|
||||
const repo = await requireRepo(this.context)
|
||||
const issues = await reviewColumnIssues(this.context, repo)
|
||||
const items = await Promise.all(issues.map(issue => this.pickItem(repo, issue)))
|
||||
if (!open)
|
||||
return
|
||||
picker.items = items
|
||||
if (items.length === 0)
|
||||
picker.placeholder = '看板「审查」列里没有工单'
|
||||
picker.busy = false
|
||||
}
|
||||
catch (err) {
|
||||
if (!open)
|
||||
return
|
||||
picker.hide()
|
||||
void window.showErrorMessage(`读不到看板「审查」列:${errorText(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 「显示全部」:回到工作区文件树,不再记着这张工单。 */
|
||||
async exit(): Promise<void> {
|
||||
this.generation++
|
||||
this.reviewing = undefined
|
||||
this.files.showChanges(undefined)
|
||||
this.decorations.show([])
|
||||
this.view.description = undefined
|
||||
this.view.message = undefined
|
||||
await Promise.all([
|
||||
commands.executeCommand('setContext', REVIEWING_CONTEXT, false),
|
||||
this.context.workspaceState.update(REVIEW_ISSUE_KEY, undefined),
|
||||
])
|
||||
}
|
||||
|
||||
/** 审查时的「刷新」:重读工单和 PR 改过的文件,工作副本也更新到 PR 最新的提交。 */
|
||||
async reload(): Promise<void> {
|
||||
if (this.reviewing !== undefined)
|
||||
await this.review(this.reviewing, 'reload')
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
private async restore(): Promise<void> {
|
||||
const issueNumber = this.context.workspaceState.get<number>(REVIEW_ISSUE_KEY)
|
||||
if (issueNumber !== undefined)
|
||||
await this.review(issueNumber, 'restore')
|
||||
}
|
||||
|
||||
/** 弹框里的一项:有 PR 的工单去读 PR 改了几个文件(各项一起读),读不到就写读不到。 */
|
||||
private async pickItem(repo: RepoContext, issue: Issue): Promise<IssuePick> {
|
||||
const pr = prNumberOf(issue)
|
||||
if (pr === undefined)
|
||||
return { issue, ...pickItemText(issue) }
|
||||
const files: FileCount = await loadPullRequestFiles(repo, pr).then(
|
||||
list => ({ count: list.length }),
|
||||
(err: unknown) => ({ error: errorText(err) }),
|
||||
)
|
||||
const hasWorktree = localWorktree(this.context, repo.workspaceRoot, issue) !== undefined
|
||||
return { issue, ...pickItemText(issue, { number: pr, files, hasWorktree }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 进审查:读工单和 PR、备工作副本,都好了才换树;这期间视图上转进度,标题下面那行写正在做哪一步。
|
||||
* target 在选工单时是弹框里那张工单(刚读的),刷新、重开窗口时是工单号(重读)。
|
||||
*/
|
||||
private async review(target: Issue | number, how: How): Promise<void> {
|
||||
const issueNumber = typeof target === 'number' ? target : target.number
|
||||
const generation = ++this.generation
|
||||
const latest = (): boolean => generation === this.generation
|
||||
const before = this.view.message
|
||||
let result: Loaded | { blocked: string }
|
||||
try {
|
||||
result = await window.withProgress({ location: { viewId: this.viewId } }, () => this.load(target, (text) => {
|
||||
if (latest())
|
||||
this.view.message = text
|
||||
}))
|
||||
}
|
||||
catch (err) {
|
||||
if (!latest())
|
||||
return
|
||||
this.view.message = before
|
||||
void window.showErrorMessage(`没能审查 #${issueNumber}:${errorText(err)}`)
|
||||
if (how === 'restore')
|
||||
await this.exit()
|
||||
return
|
||||
}
|
||||
if (!latest())
|
||||
return
|
||||
if ('blocked' in result) {
|
||||
this.view.message = before
|
||||
if (how !== 'restore')
|
||||
void window.showInformationMessage(result.blocked)
|
||||
if (how !== 'pick')
|
||||
await this.exit()
|
||||
return
|
||||
}
|
||||
await this.show(result)
|
||||
}
|
||||
|
||||
private async load(target: Issue | number, step: (text: string) => void): Promise<Loaded | { blocked: string }> {
|
||||
const repo = await requireRepo(this.context)
|
||||
const issueNumber = typeof target === 'number' ? target : target.number
|
||||
if (typeof target === 'number')
|
||||
step(`正在读取 #${issueNumber}…`)
|
||||
const reviewable = reviewableIssue(issueNumber, typeof target === 'number' ? await loadIssue(this.context, repo, target) : target)
|
||||
if ('blocked' in reviewable)
|
||||
return reviewable
|
||||
const { issue, pr } = reviewable
|
||||
step(`正在读取 PR #${pr} 改过的文件…`)
|
||||
const reviewablePull = reviewablePullRequest(pr, await loadPullRequest(repo, pr))
|
||||
if ('blocked' in reviewablePull)
|
||||
return reviewablePull
|
||||
const files = await loadPullRequestFiles(repo, pr)
|
||||
const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha: reviewablePull.pull.headSha }, step)
|
||||
return { issue, pr, files, worktree }
|
||||
}
|
||||
|
||||
private async show({ issue, pr, files, worktree }: Loaded): Promise<void> {
|
||||
const root = Uri.file(worktree)
|
||||
// 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码
|
||||
const folder: WorkspaceFolder = { uri: root, name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 }
|
||||
this.reviewing = issue.number
|
||||
this.files.showChanges({ folder, tree: buildChangedTree(files) })
|
||||
this.decorations.show(files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) })))
|
||||
this.view.description = reviewDescription(issue.number)
|
||||
this.view.message = reviewMessage(issue, pr, files.length)
|
||||
await Promise.all([
|
||||
commands.executeCommand('setContext', REVIEWING_CONTEXT, true),
|
||||
this.context.workspaceState.update(REVIEW_ISSUE_KEY, issue.number),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inReviewColumn, pickItemText, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
||||
|
||||
const issue = { number: 446, title: '用户资料:上传头像,收紧非管理员改资料的权限' }
|
||||
|
||||
describe('选工单弹框的一项', () => {
|
||||
it('本机有工作副本:工单号 + 标题,淡色 PR 号,第二行改了几个文件', () => {
|
||||
expect(pickItemText(issue, { number: 447, files: { count: 6 }, hasWorktree: true })).toEqual({
|
||||
label: '#446 用户资料:上传头像,收紧非管理员改资料的权限',
|
||||
description: 'PR #447',
|
||||
detail: '改了 6 个文件 · 本机有工作副本',
|
||||
})
|
||||
})
|
||||
|
||||
it('本机没有工作副本:说选中后拉取 PR 分支', () => {
|
||||
expect(pickItemText({ number: 452, title: '桌面端:照 Figma 原型重做设置页' }, { number: 455, files: { count: 12 }, hasWorktree: false }).detail)
|
||||
.toBe('改了 12 个文件 · 本机没有工作副本,选中后拉取 PR 分支')
|
||||
})
|
||||
|
||||
it('读不到改了哪些文件:写出错的原话,不编数', () => {
|
||||
expect(pickItemText(issue, { number: 447, files: { error: 'Gitea 502' }, hasWorktree: true }).detail)
|
||||
.toBe('读不到改了哪些文件:Gitea 502 · 本机有工作副本')
|
||||
})
|
||||
|
||||
it('没有关联 PR:没有 PR 号,第二行只写「没有关联 PR」', () => {
|
||||
expect(pickItemText(issue)).toEqual({ label: '#446 用户资料:上传头像,收紧非管理员改资料的权限', detail: '没有关联 PR' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('审查时的视图标题', () => {
|
||||
it('标题后面「审查 #446」,下面一行工单标题、PR、改了几个文件', () => {
|
||||
expect(reviewDescription(446)).toBe('审查 #446')
|
||||
expect(reviewMessage(issue, 447, 6)).toBe('#446 用户资料:上传头像,收紧非管理员改资料的权限 · PR #447 · 改了 6 个文件')
|
||||
})
|
||||
})
|
||||
|
||||
describe('看板「审查」列', () => {
|
||||
it('只要审查列的,工单号大的在前(和看板列里一样)', () => {
|
||||
const issues = [
|
||||
{ number: 438, column: 'review' as const },
|
||||
{ number: 452, column: 'review' as const },
|
||||
{ number: 451, column: 'in-progress' as const },
|
||||
{ number: 446, column: 'review' as const },
|
||||
{ number: 460, column: 'done' as const },
|
||||
]
|
||||
expect(inReviewColumn(issues).map(item => item.number)).toEqual([452, 446, 438])
|
||||
})
|
||||
|
||||
it('工单的 PR 号取 state JSON 里的正整数', () => {
|
||||
expect(prNumberOf({ pr: '447' })).toBe(447)
|
||||
expect(prNumberOf({})).toBeUndefined()
|
||||
expect(prNumberOf({ pr: '' })).toBeUndefined()
|
||||
expect(prNumberOf({ pr: '0' })).toBeUndefined()
|
||||
expect(prNumberOf({ pr: 'abc' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('还能不能审查(选中、刷新、重开窗口时判断)', () => {
|
||||
it('在审查列、有 PR:带上工单和 PR 号', () => {
|
||||
const current = { column: 'review' as const, pr: '447' }
|
||||
expect(reviewableIssue(446, current)).toEqual({ issue: current, pr: 447 })
|
||||
})
|
||||
|
||||
it('工单没了、离开了审查列、没有 PR:给出原因', () => {
|
||||
expect(reviewableIssue(446, null)).toEqual({ blocked: '#446 已经不存在' })
|
||||
expect(reviewableIssue(446, { column: 'done', pr: '447' })).toEqual({ blocked: '#446 已经不在看板「审查」列' })
|
||||
expect(reviewableIssue(446, { column: 'in-progress', pr: '447' })).toEqual({ blocked: '#446 已经不在看板「审查」列' })
|
||||
expect(reviewableIssue(446, { column: 'review' })).toEqual({ blocked: '#446 没有关联 PR' })
|
||||
})
|
||||
|
||||
it('关联的 PR 开着:带上它;没了、合并了、关了:给出原因', () => {
|
||||
const open = { state: 'open', merged: false, headSha: 'abc' }
|
||||
expect(reviewablePullRequest(447, open)).toEqual({ pull: open })
|
||||
expect(reviewablePullRequest(447, null)).toEqual({ blocked: 'PR #447 已经不存在' })
|
||||
expect(reviewablePullRequest(447, { state: 'closed', merged: true })).toEqual({ blocked: 'PR #447 已经合并' })
|
||||
expect(reviewablePullRequest(447, { state: 'closed', merged: false })).toEqual({ blocked: 'PR #447 已经关闭' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Issue } from '../../gitea/types'
|
||||
|
||||
/** 工单关联的 PR 号;state JSON 里没有、或不是正整数时没有。 */
|
||||
export function prNumberOf(issue: Pick<Issue, 'pr'>): number | undefined {
|
||||
const pr = Number(issue.pr)
|
||||
return Number.isInteger(pr) && pr > 0 ? pr : undefined
|
||||
}
|
||||
|
||||
/** 看板「审查」列里的工单,照看板列里的次序(工单号大的在前)。 */
|
||||
export function inReviewColumn<T extends Pick<Issue, 'number' | 'column'>>(issues: readonly T[]): T[] {
|
||||
return issues.filter(issue => issue.column === 'review').sort((a, b) => b.number - a.number)
|
||||
}
|
||||
|
||||
/** 这张工单(最新状态,工单已经不存在时 null)现在能不能审查:能时带上它和它的 PR 号,不能时给出原因(给人看的话)。 */
|
||||
export function reviewableIssue<T extends Pick<Issue, 'column' | 'pr'>>(issueNumber: number, issue: T | null): { issue: T, pr: number } | { blocked: string } {
|
||||
if (!issue)
|
||||
return { blocked: `#${issueNumber} 已经不存在` }
|
||||
if (issue.column !== 'review')
|
||||
return { blocked: `#${issueNumber} 已经不在看板「审查」列` }
|
||||
const pr = prNumberOf(issue)
|
||||
if (pr === undefined)
|
||||
return { blocked: `#${issueNumber} 没有关联 PR` }
|
||||
return { issue, pr }
|
||||
}
|
||||
|
||||
/** 工单关联的 PR(现状,PR 已经不存在时 null)还能不能看:能时带上它,不能时给出原因。 */
|
||||
export function reviewablePullRequest<T extends { state: string, merged: boolean }>(pr: number, pull: T | null): { pull: T } | { blocked: string } {
|
||||
if (!pull)
|
||||
return { blocked: `PR #${pr} 已经不存在` }
|
||||
if (pull.merged)
|
||||
return { blocked: `PR #${pr} 已经合并` }
|
||||
if (pull.state !== 'open')
|
||||
return { blocked: `PR #${pr} 已经关闭` }
|
||||
return { pull }
|
||||
}
|
||||
|
||||
/** PR 改了几个文件;没读到时是出错的原话。 */
|
||||
export type FileCount = { count: number } | { error: string }
|
||||
|
||||
/** 选工单弹框(QuickPick)里一项的字。 */
|
||||
export interface PickText {
|
||||
label: string
|
||||
description?: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 选工单弹框里的一项:第一行工单号 + 标题,后面淡色 PR 号;第二行改了几个文件、本机有没有这张工单的工作副本。
|
||||
* 工单没有关联 PR(不给 pr)时第二行只写「没有关联 PR」。
|
||||
*/
|
||||
export function pickItemText(issue: Pick<Issue, 'number' | 'title'>, pr?: { number: number, files: FileCount, hasWorktree: boolean }): PickText {
|
||||
const label = `#${issue.number} ${issue.title}`
|
||||
if (!pr)
|
||||
return { label, detail: '没有关联 PR' }
|
||||
const files = 'count' in pr.files ? `改了 ${pr.files.count} 个文件` : `读不到改了哪些文件:${pr.files.error}`
|
||||
const worktree = pr.hasWorktree ? '本机有工作副本' : '本机没有工作副本,选中后拉取 PR 分支'
|
||||
return { label, description: `PR #${pr.number}`, detail: `${files} · ${worktree}` }
|
||||
}
|
||||
|
||||
/** 审查时视图标题后面淡色的字(TreeView.description)。 */
|
||||
export function reviewDescription(issueNumber: number): string {
|
||||
return `审查 #${issueNumber}`
|
||||
}
|
||||
|
||||
/** 审查时视图标题下面的一行(TreeView.message)。 */
|
||||
export function reviewMessage(issue: Pick<Issue, 'number' | 'title'>, pr: number, fileCount: number): string {
|
||||
return `#${issue.number} ${issue.title} · PR #${pr} · 改了 ${fileCount} 个文件`
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 审查工单要的看板数据,全走看板自己的那套:工单和所在列用看板的加载(state JSON 定列、看板范围),
|
||||
* 本机记录(worktree 路径)照看板叠上,PR 用 Gitea 的接口。
|
||||
*/
|
||||
|
||||
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 { KanbanScope } from '../../panel/kanbanScope'
|
||||
import { getPullRequest, GiteaApiError, listPullRequestFiles } from '../../gitea/api'
|
||||
import { loadIssues, loadSingleIssue } from '../../gitea/issueLoader'
|
||||
import { resolveRepoContext } from '../../gitea/repoContext'
|
||||
import { overlayLocalIssueState } from '../../issues/localState'
|
||||
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../../panel/kanbanScope'
|
||||
import { inReviewColumn } 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
|
||||
}
|
||||
|
||||
/** 看板「审查」列里的工单(只含 Gitea 工单:PR 在 Gitea 上)。 */
|
||||
export async function reviewColumnIssues(context: ExtensionContext, repo: RepoContext): Promise<Issue[]> {
|
||||
const scope = context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
|
||||
const { issues } = await loadIssues({ ...repo, scope })
|
||||
return inReviewColumn(overlayLocalIssueState(context, issues, repo.workspaceRoot))
|
||||
}
|
||||
|
||||
/** 一张工单的最新状态;工单已经不存在时 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]
|
||||
}
|
||||
|
||||
/** PR 的现状;PR 已经不存在时 null。 */
|
||||
export async function loadPullRequest(repo: RepoContext, pr: number): Promise<GiteaPullRequest | null> {
|
||||
try {
|
||||
return await getPullRequest({ ...repo, index: pr })
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof GiteaApiError && err.status === 404)
|
||||
return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** PR 改过的全部文件。 */
|
||||
export function loadPullRequestFiles(repo: RepoContext, pr: number): Promise<PrFileDto[]> {
|
||||
return listPullRequestFiles({ ...repo, index: pr })
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ExtensionContext } from 'vscode'
|
||||
import type { RepoContext } from '../../gitea/repoContext'
|
||||
import type { Issue } from '../../gitea/types'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { window } from 'vscode'
|
||||
import { addDetachedWorktree, fastForwardWorktree, fetchPullRequestHead, resolveWorktreePath } from '../../git/worktree'
|
||||
import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, reviewWorktreeSlug } from '../../git/worktreePath'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getSettings } from '../../settings/store'
|
||||
|
||||
/** 审查时建的 worktree 放哪儿:和实施的 worktree 同一个目录模板,目录名 `review-<工单号>`。 */
|
||||
function reviewWorktreePath(context: ExtensionContext, workspaceRoot: string, issueNumber: number): string {
|
||||
const template = getSettings(context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE
|
||||
return resolveWorktreeDir(template, workspaceRoot, reviewWorktreeSlug(issueNumber))
|
||||
}
|
||||
|
||||
/** 本机这张工单的工作副本:看板记着的 worktree(盘上还在)优先,其次是之前审查它时建的;都没有时 undefined。 */
|
||||
export function localWorktree(context: ExtensionContext, workspaceRoot: string, issue: Pick<Issue, 'number' | 'worktreePath' | 'worktreeExists'>): string | undefined {
|
||||
if (issue.worktreePath && issue.worktreeExists)
|
||||
return resolveWorktreePath(issue.worktreePath, workspaceRoot)
|
||||
const review = reviewWorktreePath(context, workspaceRoot, issue.number)
|
||||
return existsSync(review) ? review : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 备好装着 PR 最新代码的工作副本,返回它的路径。
|
||||
* 已有的:拉 PR 最新的提交,不丢东西才快进;有没提交的改动、本地另有提交时照原样用。拉取、快进出错也照原样用,提醒一声。
|
||||
* 没有的:拉 PR 最新的提交,按目录模板建一个停在这个提交上、不挂分支的 worktree。
|
||||
* 每做一步用 step 报一声。
|
||||
*/
|
||||
export async function prepareWorktree(
|
||||
context: ExtensionContext,
|
||||
repo: RepoContext,
|
||||
issue: Pick<Issue, 'number' | 'worktreePath' | 'worktreeExists'>,
|
||||
pull: { number: number, headSha: string },
|
||||
step: (text: string) => void,
|
||||
): Promise<string> {
|
||||
const existing = localWorktree(context, repo.workspaceRoot, issue)
|
||||
if (existing) {
|
||||
step(`正在把工作副本更新到 PR #${pull.number} 最新的提交…`)
|
||||
try {
|
||||
await fetchPullRequestHead(repo.workspaceRoot, pull.number)
|
||||
const result = await fastForwardWorktree(existing, pull.headSha)
|
||||
if (result === 'dirty' || result === 'diverged') {
|
||||
const reason = result === 'dirty' ? '有没提交的改动' : '本地另有 PR 之外的提交'
|
||||
logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issue.number}:工作副本${reason},照原样用`, details: existing })
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
void window.showWarningMessage(`没能把 #${issue.number} 的工作副本更新到 PR #${pull.number} 最新的提交,先看本机现有的:${message}`)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
step(`正在拉取 PR #${pull.number} 的分支…`)
|
||||
await fetchPullRequestHead(repo.workspaceRoot, pull.number)
|
||||
const created = reviewWorktreePath(context, repo.workspaceRoot, issue.number)
|
||||
await addDetachedWorktree({ workspaceRoot: repo.workspaceRoot, worktreePath: created, commit: pull.headSha })
|
||||
return created
|
||||
}
|
||||
Reference in new issue
Block a user