✨ feat(vscode): 选工单也列「进行中」列里已经有 PR 的工单(开发完了没走自动审查的),弹框按「审查」「进行中」分组,进审查、刷新、重开窗口按同一条列规则判断

This commit is contained in:
cruldra committed 2026-09-29 04:23:57 +08:00
1 parent f2b17e5389
commit 489d0ef41f
7 files changed
+159 -68

No files matched your search

+30 -6
View File
@@ -1,8 +1,8 @@
import type { Issue } from '../../gitea/types'
import { describe, expect, it } from 'vitest'
import { boardIssues } from '../../panel/kanbanSnapshot'
import { countingFiles, pickEntries, pickItemText, withFileCount } from './pickList'
import { inReviewColumn } from './reviewRules'
import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList'
import { reviewCandidates } from './reviewRules'
function issue(number: number, title: string, pr?: string): Issue {
return { id: `o/r#${number}`, number, title, column: 'review', ...(pr ? { pr } : {}), assignees: [], htmlUrl: `https://x/${number}` }
@@ -76,6 +76,29 @@ describe('弹框的各项:先摆出来,文件数读到一个换一个', () =
})
})
describe('弹框按列分组', () => {
const idle = { ...issue(449, '开发完了没走自动审查', '450'), column: 'in-progress' as const }
const older = { ...issue(300, '也停在进行中', '301'), column: 'in-progress' as const }
const rows = (entries: ReturnType<typeof pickEntries<Issue>>): (string | number)[] =>
pickRows(entries).map(row => 'separator' in row ? row.separator : row.entry.issue.number)
it('先「审查」一组再「进行中」一组,每组前面一行列名', () => {
const entries = pickEntries(reviewCandidates([older, noPr, idle, avatar, settings]), () => false)
expect(rows(entries)).toEqual(['审查', 452, 451, 446, '进行中', 449, 300])
})
it('没有工单的列不出标题', () => {
expect(rows(pickEntries([idle, older], () => false))).toEqual(['进行中', 449, 300])
expect(rows(pickEntries([avatar], () => false))).toEqual(['审查', 446])
expect(rows([])).toEqual([])
})
it('读到文件数换掉一项后分组不变', () => {
const entries = pickEntries(reviewCandidates([idle, avatar]), () => true)
expect(rows(withFileCount(entries, 449, { count: 3 }))).toEqual(['审查', 446, '进行中', 449])
})
})
describe('弹框列的工单从哪来', () => {
// 存下的看板快照是 JSON,PR 号是字符串
const saved = JSON.parse(JSON.stringify({
@@ -83,7 +106,8 @@ describe('弹框列的工单从哪来', () => {
{ ...issue(460, '已经合并', '461'), column: 'done' },
{ ...issue(454, '看板快照里的审查工单', '454'), worktreePath: '~/Sources/worktree/p/x' },
{ ...issue(12, 'YouTrack 的审查工单'), source: 'youtrack' },
{ ...issue(300, '进行中'), column: 'in-progress' },
{ ...issue(300, '进行中、还没有 PR'), column: 'in-progress' },
{ ...issue(310, '进行中、已经有 PR', '311'), column: 'in-progress' },
],
scope: 'all',
globalAutoReview: false,
@@ -94,11 +118,11 @@ describe('弹框列的工单从哪来', () => {
expect(boardIssues([avatar], saved)).toEqual({ issues: [avatar], from: 'loaded' })
})
it('这次还没加载过:用存下的快照;「审查」列的 Gitea 工单照常列出,字符串的 PR 号认得', () => {
it('这次还没加载过:用存下的快照;「审查」列和有 PR 的「进行中」列的 Gitea 工单照常列出,字符串的 PR 号认得', () => {
const board = boardIssues(undefined, saved)
expect(board?.from).toBe('saved')
const entries = pickEntries(inReviewColumn(board!.issues), () => false)
expect(entries.map(entry => [entry.issue.number, entry.pr?.number])).toEqual([[454, 454]])
const entries = pickEntries(reviewCandidates(board!.issues), () => false)
expect(entries.map(entry => [entry.issue.number, entry.pr?.number])).toEqual([[454, 454], [310, 311]])
})
it('都没有、快照是坏的:undefined(看板还没加载过)', () => {
+11 -1
View File
@@ -1,5 +1,5 @@
import type { Issue } from '../../gitea/types'
import { prNumberOf } from './reviewRules'
import { columnLabel, prNumberOf } from './reviewRules'
/** PR 改了几个文件:还在读、没读到、读到了。 */
export type FileCount = 'loading' | 'failed' | { count: number }
@@ -30,6 +30,16 @@ export function withFileCount<T extends Pick<Issue, 'number' | 'title'>>(entries
return entries.map(entry => entry.issue.number === issueNumber && entry.pr ? { ...entry, pr: { ...entry.pr, files } } : entry)
}
/** 弹框里的一行:分组标题(列名)或一项。 */
export type PickRow<T extends Pick<Issue, 'number' | 'title' | 'column'>> = { separator: string } | { entry: PickEntry<T> }
/** 给按列排好的各项插上分组标题:列一变先插一行列名(没有工单的列不出标题)。 */
export function pickRows<T extends Pick<Issue, 'number' | 'title' | 'column'>>(entries: readonly PickEntry<T>[]): PickRow<T>[] {
return entries.flatMap((entry, index) => index > 0 && entries[index - 1].issue.column === entry.issue.column
? [{ entry }]
: [{ separator: columnLabel(entry.issue.column) }, { entry }])
}
/** 还有 PR 的文件数没读完:弹框接着转圈。 */
export function countingFiles(entries: readonly PickEntry<Pick<Issue, 'number' | 'title'>>[]): boolean {
return entries.some(entry => entry.pr?.files === 'loading')
+22 -20
View File
@@ -5,13 +5,13 @@ import type { Issue } from '../../gitea/types'
import type { FileNode, FileTreeProvider } from '../explorer/fileTree'
import type { FileCount } from './pickList'
import * as path from 'node:path'
import { commands, Uri, window, workspace } from 'vscode'
import { commands, QuickPickItemKind, Uri, window, workspace } from 'vscode'
import { logger } from '../../logging/logger'
import { ChangeDecorations } from './changeDecorations'
import { buildChangedTree, changeOf } from './changedFiles'
import { countingFiles, pickEntries, pickItemText, withFileCount } from './pickList'
import { reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewColumnIssues } from './reviewSource'
import { countingFiles, pickEntries, pickItemText, pickRows, withFileCount } from './pickList'
import { columnLabel, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewCandidateIssues } from './reviewSource'
import { localWorktree, prepareWorktree } from './reviewWorktree'
/** workspaceState:正在审查的工单号,重开窗口接着审查。 */
@@ -33,8 +33,9 @@ interface Loaded {
worktree: string
}
interface IssuePick extends QuickPickItem {
issue: Issue
/** 弹框里的一行:工单的一项带着工单,分组标题(列名)没有。 */
interface PickerItem extends QuickPickItem {
issue?: Issue
}
function errorText(err: unknown): string {
@@ -42,7 +43,7 @@ function errorText(err: unknown): string {
}
/**
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」列的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
* 侧栏按审查工单筛文件:「选工单」列出看板「审查」「进行中」列里能审查的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色),
* 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。
*/
export class ReviewMode implements Disposable {
@@ -80,14 +81,13 @@ export class ReviewMode implements Disposable {
return this.reviewing !== undefined
}
/** 「选工单」:顶部弹出快速选择,读看板「审查」列的工单和各自改了几个文件时转圈。 */
/**
* 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」列工单,马上就有;
* 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」「进行中」列里能审查的工单(按列分组),马上就有;
* 各 PR 改了几个文件一起去读,读到一个换一项,都读完才停止转圈。
*/
async pick(): Promise<void> {
const picker = window.createQuickPick<IssuePick>()
picker.title = '选工单 · 看板「审查」列'
const picker = window.createQuickPick<PickerItem>()
picker.title = `选工单 · 看板${REVIEW_COLUMNS_TEXT}列`
picker.placeholder = '输入工单号或标题筛选'
picker.busy = true
let open = true
@@ -96,11 +96,11 @@ export class ReviewMode implements Disposable {
picker.dispose()
})
picker.onDidAccept(() => {
const chosen = picker.selectedItems[0]
const chosen = picker.selectedItems[0]?.issue
if (!chosen)
return
picker.hide()
void this.review(chosen.issue.number, 'pick')
void this.review(chosen.number, 'pick')
})
picker.show()
@@ -114,7 +114,7 @@ export class ReviewMode implements Disposable {
void window.showErrorMessage(`没法选工单:${errorText(err)}`)
return
}
const board = reviewColumnIssues(this.context)
const board = reviewCandidateIssues(this.context)
if (!open)
return
if (!board) {
@@ -126,21 +126,23 @@ export class ReviewMode implements Disposable {
logger.add({
level: 'info',
source: 'codeReader',
message: `选工单:看板「审查」列 ${board.issues.length} 张(${board.from === 'loaded' ? '看板这次加载的' : '上次存下的看板快照'})`,
details: board.issues.map(issue => `#${issue.number} PR ${issue.pr ?? '无'}`).join('\n'),
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 = '看板「审查」列里没有工单'
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 = entries.map(entry => ({ issue: entry.issue, ...pickItemText(entry) }))
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.number === active)
const stay = items.find(item => item.issue !== undefined && item.issue.number === active)
if (stay)
picker.activeItems = [stay]
picker.busy = countingFiles(entries)
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { inReviewColumn, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
import { columnLabel, prNumberOf, REVIEW_COLUMNS_TEXT, reviewableIssue, reviewablePullRequest, reviewCandidates, reviewDescription, reviewMessage } from './reviewRules'
const issue = { number: 446, title: '用户资料:上传头像,收紧非管理员改资料的权限' }
@@ -10,25 +10,43 @@ describe('审查时的视图标题', () => {
})
})
describe('看板「审查」列', () => {
it('只要审查列的,工单号大的在前(和看板列里一样)', () => {
describe('选工单弹框列哪些工单', () => {
it('「审查」列的都列(没 PR 的也列),「进行中」列的只列有 PR 的,别的列不列', () => {
const issues = [
{ number: 438, column: 'review' as const },
{ number: 452, column: 'review' as const },
{ number: 452, column: 'review' as const, pr: '455' },
{ number: 451, column: 'in-progress' as const },
{ number: 446, column: 'review' as const },
{ number: 460, column: 'done' as const },
{ number: 449, column: 'in-progress' as const, pr: '450' },
{ number: 460, column: 'done' as const, pr: '461' },
{ number: 470, column: 'todo' as const, pr: '471' },
]
expect(inReviewColumn(issues).map(item => item.number)).toEqual([452, 446, 438])
expect(reviewCandidates(issues).map(item => item.number)).toEqual([452, 438, 449])
})
it('先「审查」一组再「进行中」一组,组里工单号大的在前(和看板列里一样)', () => {
const issues = [
{ number: 300, column: 'in-progress' as const, pr: '301' },
{ number: 438, column: 'review' as const, pr: '440' },
{ number: 500, column: 'in-progress' as const, pr: '501' },
{ number: 446, column: 'review' as const, pr: '447' },
]
expect(reviewCandidates(issues).map(item => item.number)).toEqual([446, 438, 500, 300])
})
it('只要 Gitea 的工单(PR 在 Gitea 上),YouTrack 的不算,没写来源的算 Gitea 的', () => {
const issues = [
{ number: 12, column: 'review' as const, source: 'youtrack' as const },
{ number: 13, column: 'in-progress' as const, source: 'youtrack' as const, pr: '14' },
{ number: 446, column: 'review' as const, source: 'gitea' as const },
{ number: 438, column: 'review' as const },
]
expect(inReviewColumn(issues).map(item => item.number)).toEqual([446, 438])
expect(reviewCandidates(issues).map(item => item.number)).toEqual([446, 438])
})
it('列名:分组标题和连着写的几列', () => {
expect(columnLabel('review')).toBe('审查')
expect(columnLabel('in-progress')).toBe('进行中')
expect(REVIEW_COLUMNS_TEXT).toBe('「审查」「进行中」')
})
it('工单的 PR 号取 state JSON 里的正整数(看板快照里也是字符串)', () => {
@@ -42,16 +60,19 @@ describe('看板「审查」列', () => {
})
describe('还能不能审查(选中、刷新、重开窗口时判断)', () => {
it('在审查列、有 PR:带上工单和 PR 号', () => {
const current = { column: 'review' as const, pr: '447' }
expect(reviewableIssue(446, current)).toEqual({ issue: current, pr: 447 })
it('在「审查」或「进行中」列、有 PR:带上工单和 PR 号', () => {
const inReview = { column: 'review' as const, pr: '447' }
const inProgress = { column: 'in-progress' as const, pr: '450' }
expect(reviewableIssue(446, inReview)).toEqual({ issue: inReview, pr: 447 })
expect(reviewableIssue(449, inProgress)).toEqual({ issue: inProgress, pr: 450 })
})
it('工单没了、离开了审查列、没有 PR:给出原因', () => {
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: 'done', pr: '447' })).toEqual({ blocked: '#446 已经不在看板「审查」「进行中」列' })
expect(reviewableIssue(446, { column: 'todo', pr: '447' })).toEqual({ blocked: '#446 已经不在看板「审查」「进行中」列' })
expect(reviewableIssue(446, { column: 'review' })).toEqual({ blocked: '#446 没有关联 PR' })
expect(reviewableIssue(449, { column: 'in-progress' })).toEqual({ blocked: '#449 没有关联 PR' })
})
it('关联的 PR 开着:带上它;没了、合并了、关了:给出原因', () => {
+40 -9
View File
@@ -1,4 +1,4 @@
import type { Issue } from '../../gitea/types'
import type { Issue, IssueColumn } from '../../gitea/types'
/** 工单关联的 PR 号;state JSON 里没有、或不是正整数时没有。 */
export function prNumberOf(issue: Pick<Issue, 'pr'>): number | undefined {
@@ -6,19 +6,50 @@ export function prNumberOf(issue: Pick<Issue, 'pr'>): number | undefined {
return Number.isInteger(pr) && pr > 0 ? pr : undefined
}
/** 看板「审查」列里的 Gitea 工单(PR 在 Gitea 上,YouTrack 的不算),照看板列里的次序(工单号大的在前)。 */
export function inReviewColumn<T extends Pick<Issue, 'number' | 'column' | 'source'>>(issues: readonly T[]): T[] {
return issues
.filter(issue => issue.column === 'review' && (issue.source ?? 'gitea') === 'gitea')
.sort((a, b) => b.number - a.number)
/**
* 能拿来审查的看板列,也是选工单弹框里分组的次序:先「审查」,再「进行中」
* (开发完了、没走自动审查,停在「进行中」的工单)。
*/
export const REVIEW_COLUMNS: readonly { column: IssueColumn, label: string }[] = [
{ column: 'review', label: '审查' },
{ column: 'in-progress', label: '进行中' },
]
/** 这几列连着写:「审查」「进行中」。 */
export const REVIEW_COLUMNS_TEXT = REVIEW_COLUMNS.map(({ label }) => `「${label}」`).join('')
/** 列的名字(选工单弹框的分组标题)。 */
export function columnLabel(column: IssueColumn): string {
return REVIEW_COLUMNS.find(item => item.column === column)?.label ?? column
}
/** 这张工单(最新状态,工单已经不存在时 null)现在能不能审查:能时带上它和它的 PR 号,不能时给出原因(给人看的话)。 */
function inReviewColumns(column: IssueColumn): boolean {
return REVIEW_COLUMNS.some(item => item.column === column)
}
/**
* 选工单弹框列哪些工单:只要 Gitea 的(PR 在 Gitea 上,YouTrack 的不算);「审查」列的都列(没 PR 的选中时说一声),
* 「进行中」列的只列已经有 PR 的(没 PR 的还在开发,列出来只会添乱)。按列分组(REVIEW_COLUMNS 的次序),
* 组里工单号大的在前(和看板列里一样)。
*/
export function reviewCandidates<T extends Pick<Issue, 'number' | 'column' | 'source' | 'pr'>>(issues: readonly T[]): T[] {
const order = (issue: T): number => REVIEW_COLUMNS.findIndex(item => item.column === issue.column)
return issues
.filter(issue => (issue.source ?? 'gitea') === 'gitea'
&& inReviewColumns(issue.column)
&& (issue.column === 'review' || prNumberOf(issue) !== undefined))
.sort((a, b) => order(a) - order(b) || b.number - a.number)
}
/**
* 这张工单(最新状态,工单已经不存在时 null)现在能不能审查:在「审查」「进行中」列、有 PR 才能,
* 能时带上它和它的 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} 已经不在看板「审查」列` }
if (!inReviewColumns(issue.column))
return { blocked: `#${issueNumber} 已经不在看板${REVIEW_COLUMNS_TEXT}列` }
const pr = prNumberOf(issue)
if (pr === undefined)
return { blocked: `#${issueNumber} 没有关联 PR` }
+4 -4
View File
@@ -13,7 +13,7 @@ import { loadSingleIssue } from '../../gitea/issueLoader'
import { resolveRepoContext } from '../../gitea/repoContext'
import { overlayLocalIssueState } from '../../issues/localState'
import { KanbanWebviewPanel } from '../../panel/KanbanPanel'
import { inReviewColumn } from './reviewRules'
import { reviewCandidates } from './reviewRules'
/** 当前工作区的 Gitea 仓库和 token;没有时抛出给人看的错。 */
export async function requireRepo(context: ExtensionContext): Promise<RepoContext> {
@@ -24,12 +24,12 @@ export async function requireRepo(context: ExtensionContext): Promise<RepoContex
}
/**
* 看板「审查」列里的工单:取看板上现在摆着的(用户看到的就是它),不再加载一遍;
* 选工单弹框列的工单(哪些列、哪些工单见 reviewCandidates):取看板上现在摆着的(用户看到的就是它),不再加载一遍;
* 看板还没加载过时 undefined。进审查时再单独重读选中的那张(loadIssue)。
*/
export function reviewColumnIssues(context: ExtensionContext): BoardIssues | undefined {
export function reviewCandidateIssues(context: ExtensionContext): BoardIssues | undefined {
const board = KanbanWebviewPanel.issuesOnBoard(context)
return board && { ...board, issues: inReviewColumn(board.issues) }
return board && { ...board, issues: reviewCandidates(board.issues) }
}
/** 一张工单的最新状态;工单已经不存在时 null。 */