329 lines
15 KiB
TypeScript
329 lines
15 KiB
TypeScript
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)
|
||
}
|
||
}
|