Files
superwork/vscode/src/codeReader/review/reviewMode.ts
T

329 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Disposable, Event, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode'
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'
import { TestsToggle } from '../explorer/testsToggle'
import { whenShown } from '../explorer/viewShown'
import { toPathKey } from '../pathMap'
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, reviewCandidateIssues } from './reviewSource'
import { localWorktree, prepareWorktree } from './reviewWorktree'
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue'
/** 上下文键:主栏正审查着工单。标题栏的「显示全部」、隐藏 / 显示测试文件按它显示,主栏行上的注释、收藏按钮按它藏起来(那些行是工作副本里的)。 */
const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing'
/** 审查时隐藏测试文件的开关:记在 workspaceState 的键、标题栏按它换按钮的上下文键 */
const REVIEW_TESTS = { state: 'superpowers.codeReader.reviewHideTests', context: 'superpowers.codeReader.reviewTestsHidden' }
/**
* 怎么进的审查,决定不能审查、出错时怎么收场:
* pick 选工单(说一声,什么都不变)、reload 刷新(说一声,回到全部文件)、restore 重开窗口接着审查(悄悄回到全部文件)。
*/
type How = 'pick' | 'reload' | 'restore'
/**
* 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本,
* 和预览对比的基准:PR 的目标分支名、目标分支与 PR 头的合并基(取不到时退回目标分支的提交,同看板「改动」tab)。
*/
interface Loaded {
issue: Issue
pr: number
files: ForgePrFile[]
worktree: string
base: { ref: string, commit: string }
}
/** 弹框里的一行:工单的一项带着工单,分组标题(列名)没有。 */
interface PickerItem extends QuickPickItem {
issue?: Issue
}
function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/**
* 主栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后主栏只剩这张工单的 PR 改过的文件(按改动着色),
* 点开的是本机这张工单的工作副本里 PR 最新的代码,工作副本里的文件预览对比 PR 的目标分支(diffBase);「显示全部」回到工作区文件树。
* 选的工单按工作区记住,主栏第一次露出来时接着审查;隐藏测试文件的开关是审查自己的,也按工作区记住。
*/
export class ReviewMode implements DiffBaseSource, Disposable {
private readonly decorations = new ChangeDecorations()
private readonly baseChange = new EventEmitter<void>()
/** 审查的对比基准变了(进了审查、重读了 PR、换了工单、退出了):打开着的预览重新问 */
readonly onDidChange: Event<void> = this.baseChange.event
private readonly tests: TestsToggle
private readonly disposables: Disposable[]
/** 正在审查的(主栏摆着它):工单、PR、改过的文件、工作副本、对比基准 */
private loaded: Loaded | undefined
/** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */
private generation = 0
/** viewId、view、files 是主栏的视图 id、视图、文件树:进审查时在主栏上转进度、换上 PR 改过的文件。 */
constructor(
private readonly context: ExtensionContext,
private readonly viewId: string,
private readonly view: TreeView<TreeNode>,
private readonly files: FileTreeProvider,
) {
this.tests = new TestsToggle(context.workspaceState, REVIEW_TESTS, () => this.place())
this.disposables = [
this.decorations,
this.baseChange,
window.registerFileDecorationProvider(this.decorations),
whenShown(view, () => void this.restore()),
]
}
/** 正审查着工单:主栏只摆着 PR 改过的文件 */
get reviewing(): boolean {
return this.loaded !== undefined
}
/**
* 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」「进行中」列里能审查的工单(按列分组),马上就有;
* 各 PR 改了几个文件一起去读,读到一个换一项,都读完才停止转圈。
*/
async pick(): Promise<void> {
const picker = window.createQuickPick<PickerItem>()
picker.title = `选工单 · 看板${REVIEW_COLUMNS_TEXT}列`
picker.placeholder = '输入工单号或标题筛选'
picker.busy = true
let open = true
picker.onDidHide(() => {
open = false
picker.dispose()
})
picker.onDidAccept(() => {
const chosen = picker.selectedItems[0]?.issue
if (!chosen)
return
picker.hide()
void this.review(chosen.number, 'pick')
})
picker.show()
let repo: ForgeContext
try {
repo = await requireForge(this.context)
}
catch (err) {
logger.add({ level: 'error', source: 'codeReader', message: '选工单:读不到当前工作区的代码托管仓库', details: errorText(err) })
picker.hide()
void window.showErrorMessage(`没法选工单:${errorText(err)}`)
return
}
const board = reviewCandidateIssues(this.context)
if (!open)
return
if (!board) {
logger.add({ level: 'info', source: 'codeReader', message: '选工单:看板还没加载过,没有工单可列' })
picker.placeholder = '看板还没加载过,先打开一次看板'
picker.busy = false
return
}
logger.add({
level: 'info',
source: 'codeReader',
message: `选工单:看板${REVIEW_COLUMNS_TEXT}列能审查的 ${board.issues.length} 张(${board.from === 'loaded' ? '看板这次加载的' : '上次存下的看板快照'})`,
details: board.issues.map(issue => `#${issue.number} ${columnLabel(issue.column)} PR ${issue.pr ?? '无'}`).join('\n'),
})
if (board.issues.length === 0)
picker.placeholder = `看板${REVIEW_COLUMNS_TEXT}列里没有能审查的工单`
let entries = pickEntries(board.issues, issue => localWorktree(this.context, repo.workspaceRoot, issue) !== undefined)
// 换 items 时 VS Code 会把光标挪回第一项:按工单号挪回用户停着的那一项
const render = (): void => {
if (!open)
return
const active = picker.activeItems[0]?.issue?.number
const items: PickerItem[] = pickRows(entries).map(row => 'separator' in row
? { label: row.separator, kind: QuickPickItemKind.Separator }
: { issue: row.entry.issue, ...pickItemText(row.entry) })
picker.items = items
const stay = items.find(item => item.issue !== undefined && item.issue.number === active)
if (stay)
picker.activeItems = [stay]
picker.busy = countingFiles(entries)
}
render()
await Promise.all(entries.flatMap(({ issue, pr }) => pr
? [this.countFiles(repo, issue.number, pr.number).then((files) => {
entries = withFileCount(entries, issue.number, files)
render()
})]
: []))
}
/** 「显示全部」:不再审查、不再记着这张工单,正在进的审查作废;正审查着的主栏回到工作区文件树,清掉标题后面的字和标题下面的一行。 */
async exit(): Promise<void> {
this.generation++
const shown = this.loaded !== undefined
this.loaded = undefined
this.decorations.show([])
if (shown) {
this.baseChange.fire()
this.files.show(WORKSPACE_TREE)
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.loaded)
await this.review(this.loaded.issue.number, 'reload')
}
/**
* 审查时,工作副本里的文件对比 PR 目标分支与 PR 头的合并基上的版本(改名的取改名前的路径);工作副本外面的文件不比。
* 合并基是 PR 头的祖先,拉 PR 的提交时已经在本机了,直接在工作副本里取。
*/
diffBase(uri: Uri): DiffBase | undefined {
const loaded = this.loaded
const relative = loaded && toPathKey(Uri.file(loaded.worktree).path, uri.path)
if (!loaded || relative === undefined || !loaded.base.commit)
return undefined
const { worktree, base } = loaded
const file = reviewBaseFile(loaded.files, relative)
return {
label: reviewBaseLabel(base.ref),
key: `${base.commit}:${file.path}`,
read: () => file.added ? Promise.resolve(null) : fileAtRef(worktree, base.commit, file.path),
missing: reviewBaseMissing(base.ref),
}
}
/** 审查时标题栏的「隐藏 / 显示测试文件」:正审查着的文件按新开关重摆。 */
setTestsHidden(hidden: boolean): Promise<void> {
return this.tests.set(hidden)
}
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 改了几个文件(各项一起读),读不到就写读不到。 */
/** 读一张工单的 PR 改了几个文件;没读到记进日志,这一项写读不到。 */
private async countFiles(repo: ForgeContext, issueNumber: number, pr: number): Promise<FileCount> {
try {
return { count: (await repo.provider.listPullRequestFiles(pr)).length }
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `选工单:读不到 #${issueNumber} 的 PR #${pr} 改了几个文件`, details: errorText(err) })
return 'failed'
}
}
/**
* 进审查:重读这张工单(弹框里的是看板上摆着的,可能旧了)和它的 PR、备工作副本,都好了才换树;
* 这期间视图上转进度,标题下面那行写正在做哪一步。每一步、不进的原因、出错都记进日志。
*/
private async review(issueNumber: number, how: How): Promise<void> {
const generation = ++this.generation
const latest = (): boolean => generation === this.generation
const before = this.view.message
logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issueNumber}:开始(${how})` })
let result: Loaded | { blocked: string }
try {
result = await window.withProgress({ location: { viewId: this.viewId } }, () => this.load(issueNumber, (text) => {
logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issueNumber}:${text}` })
if (latest())
this.view.message = text
}))
}
catch (err) {
logger.add({ level: 'error', source: 'codeReader', message: `审查 #${issueNumber} 失败`, details: errorText(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) {
logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issueNumber} 不进:${result.blocked}` })
this.view.message = before
if (how !== 'restore')
void window.showInformationMessage(result.blocked)
if (how !== 'pick')
await this.exit()
return
}
logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issueNumber}:PR #${result.pr} 改了 ${result.files.length} 个文件`, details: result.worktree })
await this.show(result)
}
private async load(issueNumber: number, step: (text: string) => void): Promise<Loaded | { blocked: string }> {
const repo = await requireForge(this.context)
step(`正在读取 #${issueNumber}…`)
const reviewable = reviewableIssue(issueNumber, await loadIssue(this.context, repo, issueNumber))
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 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: baseCommit } }
}
private async show(loaded: Loaded): Promise<void> {
const root = Uri.file(loaded.worktree)
// 先记下再通知:打开着的预览马上来问对比基准
this.loaded = loaded
this.baseChange.fire()
this.place()
this.decorations.show(loaded.files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) })))
await Promise.all([
commands.executeCommand('setContext', REVIEWING_CONTEXT, true),
this.context.workspaceState.update(REVIEW_ISSUE_KEY, loaded.issue.number),
])
}
/** 把正审查着的 PR 改过的文件摆上树(隐藏测试文件时剔掉),标题后面写上藏了几个。 */
private place(): void {
const loaded = this.loaded
if (!loaded)
return
const { issue, pr, files, worktree } = loaded
// 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码
const folder: WorkspaceFolder = { uri: Uri.file(worktree), name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 }
const { tree, hidden } = reviewTree(files, this.tests.hidden)
this.files.show({ kind: 'changes', view: [{ roots: [{ folder, tree }] }] })
this.view.description = reviewDescription(issue.number, hidden)
this.view.message = reviewMessage(issue, pr, files.length)
}
}