🐛 fix(vscode): 选工单不再重新加载整个看板,直接列看板上摆着的「审查」列工单,文件数一项项补上,每一步记进日志
This commit is contained in:
1 parent
4ec38cff12
commit
b3a829e658
10 files changed
+310
-112
No files matched your search
@@ -0,0 +1,108 @@
|
||||
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'
|
||||
|
||||
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}` }
|
||||
}
|
||||
|
||||
const avatar = issue(446, '用户资料:上传头像,收紧非管理员改资料的权限', '447')
|
||||
const settings = issue(452, '桌面端:照 Figma 原型重做设置页', '455')
|
||||
const noPr = issue(451, '会话消息区 UI 优化')
|
||||
|
||||
describe('选工单弹框的一项', () => {
|
||||
it('本机有工作副本:工单号 + 标题,淡色 PR 号,第二行改了几个文件', () => {
|
||||
expect(pickItemText({ issue: avatar, pr: { number: 447, files: { count: 6 }, hasWorktree: true } })).toEqual({
|
||||
label: '#446 用户资料:上传头像,收紧非管理员改资料的权限',
|
||||
description: 'PR #447',
|
||||
detail: '改了 6 个文件 · 本机有工作副本',
|
||||
})
|
||||
})
|
||||
|
||||
it('本机没有工作副本:说选中后拉取 PR 分支', () => {
|
||||
expect(pickItemText({ issue: settings, pr: { number: 455, files: { count: 12 }, hasWorktree: false } }).detail)
|
||||
.toBe('改了 12 个文件 · 本机没有工作副本,选中后拉取 PR 分支')
|
||||
})
|
||||
|
||||
it('文件数还在读、没读到:照实写,不编数', () => {
|
||||
expect(pickItemText({ issue: avatar, pr: { number: 447, files: 'loading', hasWorktree: true } }).detail)
|
||||
.toBe('正在读改动文件数… · 本机有工作副本')
|
||||
expect(pickItemText({ issue: avatar, pr: { number: 447, files: 'failed', hasWorktree: true } }).detail)
|
||||
.toBe('读不到改动文件数 · 本机有工作副本')
|
||||
})
|
||||
|
||||
it('没有关联 PR:没有 PR 号,第二行只写「没有关联 PR」', () => {
|
||||
expect(pickItemText({ issue: noPr })).toEqual({ label: '#451 会话消息区 UI 优化', detail: '没有关联 PR' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('弹框的各项:先摆出来,文件数读到一个换一个', () => {
|
||||
const opened = pickEntries([avatar, settings, noPr], item => item.number === 446)
|
||||
|
||||
it('一打开:有 PR 的写「正在读」,本机有没有工作副本当场判断,没 PR 的没有文件数;在转圈', () => {
|
||||
expect(opened).toEqual([
|
||||
{ issue: avatar, pr: { number: 447, files: 'loading', hasWorktree: true } },
|
||||
{ issue: settings, pr: { number: 455, files: 'loading', hasWorktree: false } },
|
||||
{ issue: noPr },
|
||||
])
|
||||
expect(countingFiles(opened)).toBe(true)
|
||||
})
|
||||
|
||||
it('读到一个只换那一项,别的项是同一个对象、次序不变;还有没读完的接着转圈', () => {
|
||||
const next = withFileCount(opened, 452, { count: 12 })
|
||||
expect(next.map(entry => entry.issue.number)).toEqual([446, 452, 451])
|
||||
expect(next[1].pr?.files).toEqual({ count: 12 })
|
||||
expect(next[0]).toBe(opened[0])
|
||||
expect(next[2]).toBe(opened[2])
|
||||
expect(opened[1].pr?.files).toBe('loading')
|
||||
expect(countingFiles(next)).toBe(true)
|
||||
})
|
||||
|
||||
it('都读完(没读到的也算完)就停止转圈', () => {
|
||||
const done = withFileCount(withFileCount(opened, 452, { count: 12 }), 446, 'failed')
|
||||
expect(done.map(entry => pickItemText(entry).detail)).toEqual([
|
||||
'读不到改动文件数 · 本机有工作副本',
|
||||
'改了 12 个文件 · 本机没有工作副本,选中后拉取 PR 分支',
|
||||
'没有关联 PR',
|
||||
])
|
||||
expect(countingFiles(done)).toBe(false)
|
||||
})
|
||||
|
||||
it('没有 PR 的工单、没有工单:一开始就不转圈', () => {
|
||||
expect(countingFiles(pickEntries([noPr], () => false))).toBe(false)
|
||||
expect(countingFiles(pickEntries([], () => false))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('弹框列的工单从哪来', () => {
|
||||
// 存下的看板快照是 JSON,PR 号是字符串
|
||||
const saved = JSON.parse(JSON.stringify({
|
||||
issues: [
|
||||
{ ...issue(460, '已经合并', '461'), column: 'done' },
|
||||
{ ...issue(454, '看板快照里的审查工单', '454'), worktreePath: '~/Sources/worktree/p/x' },
|
||||
{ ...issue(12, 'YouTrack 的审查工单'), source: 'youtrack' },
|
||||
{ ...issue(300, '进行中'), column: 'in-progress' },
|
||||
],
|
||||
scope: 'all',
|
||||
globalAutoReview: false,
|
||||
youtrackConfigured: true,
|
||||
})) as unknown
|
||||
|
||||
it('看板这次加载过就用它最近一次加载的,不看存下的快照', () => {
|
||||
expect(boardIssues([avatar], saved)).toEqual({ issues: [avatar], from: 'loaded' })
|
||||
})
|
||||
|
||||
it('这次还没加载过:用存下的快照;「审查」列的 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]])
|
||||
})
|
||||
|
||||
it('都没有、快照是坏的:undefined(看板还没加载过)', () => {
|
||||
expect(boardIssues(undefined, undefined)).toBeUndefined()
|
||||
expect(boardIssues(undefined, { issues: 'x' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Issue } from '../../gitea/types'
|
||||
import { prNumberOf } from './reviewRules'
|
||||
|
||||
/** PR 改了几个文件:还在读、没读到、读到了。 */
|
||||
export type FileCount = 'loading' | 'failed' | { count: number }
|
||||
|
||||
/** 选工单弹框里一项背后的数据;工单没有关联 PR 时没有 pr。 */
|
||||
export interface PickEntry<T extends Pick<Issue, 'number' | 'title'>> {
|
||||
issue: T
|
||||
pr?: { number: number, files: FileCount, hasWorktree: boolean }
|
||||
}
|
||||
|
||||
/** 弹框里一项的字(QuickPickItem 的 label / description / detail)。 */
|
||||
export interface PickText {
|
||||
label: string
|
||||
description?: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
/** 弹框一打开就摆出来的各项:有 PR 的文件数先写「正在读」,读到一个换一个(withFileCount)。 */
|
||||
export function pickEntries<T extends Pick<Issue, 'number' | 'title' | 'pr'>>(issues: readonly T[], hasWorktree: (issue: T) => boolean): PickEntry<T>[] {
|
||||
return issues.map((issue) => {
|
||||
const pr = prNumberOf(issue)
|
||||
return pr === undefined ? { issue } : { issue, pr: { number: pr, files: 'loading', hasWorktree: hasWorktree(issue) } }
|
||||
})
|
||||
}
|
||||
|
||||
/** 读到(或没读到)一张工单的 PR 改了几个文件:只换掉那一项,别的项原样(同一个对象),次序不变。 */
|
||||
export function withFileCount<T extends Pick<Issue, 'number' | 'title'>>(entries: readonly PickEntry<T>[], issueNumber: number, files: FileCount): PickEntry<T>[] {
|
||||
return entries.map(entry => entry.issue.number === issueNumber && entry.pr ? { ...entry, pr: { ...entry.pr, files } } : entry)
|
||||
}
|
||||
|
||||
/** 还有 PR 的文件数没读完:弹框接着转圈。 */
|
||||
export function countingFiles(entries: readonly PickEntry<Pick<Issue, 'number' | 'title'>>[]): boolean {
|
||||
return entries.some(entry => entry.pr?.files === 'loading')
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹框里的一项:第一行工单号 + 标题,后面淡色 PR 号;第二行改了几个文件、本机有没有这张工单的工作副本。
|
||||
* 没有关联 PR 的第二行只写「没有关联 PR」。
|
||||
*/
|
||||
export function pickItemText({ issue, pr }: PickEntry<Pick<Issue, 'number' | 'title'>>): PickText {
|
||||
const label = `#${issue.number} ${issue.title}`
|
||||
if (!pr)
|
||||
return { label, detail: '没有关联 PR' }
|
||||
const files = pr.files === 'loading'
|
||||
? '正在读改动文件数…'
|
||||
: pr.files === 'failed' ? '读不到改动文件数' : `改了 ${pr.files.count} 个文件`
|
||||
const worktree = pr.hasWorktree ? '本机有工作副本' : '本机没有工作副本,选中后拉取 PR 分支'
|
||||
return { label, description: `PR #${pr.number}`, detail: `${files} · ${worktree}` }
|
||||
}
|
||||
@@ -3,12 +3,14 @@ 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 type { FileCount } from './pickList'
|
||||
import * as path from 'node:path'
|
||||
import { commands, Uri, window, workspace } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { ChangeDecorations } from './changeDecorations'
|
||||
import { buildChangedTree, changeOf } from './changedFiles'
|
||||
import { pickItemText, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
||||
import { countingFiles, pickEntries, pickItemText, withFileCount } from './pickList'
|
||||
import { reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
||||
import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewColumnIssues } from './reviewSource'
|
||||
import { localWorktree, prepareWorktree } from './reviewWorktree'
|
||||
|
||||
@@ -79,6 +81,10 @@ export class ReviewMode implements Disposable {
|
||||
}
|
||||
|
||||
/** 「选工单」:顶部弹出快速选择,读看板「审查」列的工单和各自改了几个文件时转圈。 */
|
||||
/**
|
||||
* 「选工单」:顶部弹出快速选择,列出看板上现在摆着的「审查」列工单,马上就有;
|
||||
* 各 PR 改了几个文件一起去读,读到一个换一项,都读完才停止转圈。
|
||||
*/
|
||||
async pick(): Promise<void> {
|
||||
const picker = window.createQuickPick<IssuePick>()
|
||||
picker.title = '选工单 · 看板「审查」列'
|
||||
@@ -94,26 +100,58 @@ export class ReviewMode implements Disposable {
|
||||
if (!chosen)
|
||||
return
|
||||
picker.hide()
|
||||
void this.review(chosen.issue, 'pick')
|
||||
void this.review(chosen.issue.number, 'pick')
|
||||
})
|
||||
picker.show()
|
||||
|
||||
let repo: RepoContext
|
||||
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
|
||||
repo = await requireRepo(this.context)
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'error', source: 'codeReader', message: '选工单:读不到当前工作区的 Gitea 仓库', details: errorText(err) })
|
||||
picker.hide()
|
||||
void window.showErrorMessage(`没法选工单:${errorText(err)}`)
|
||||
return
|
||||
}
|
||||
const board = reviewColumnIssues(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: `选工单:看板「审查」列 ${board.issues.length} 张(${board.from === 'loaded' ? '看板这次加载的' : '上次存下的看板快照'})`,
|
||||
details: board.issues.map(issue => `#${issue.number} PR ${issue.pr ?? '无'}`).join('\n'),
|
||||
})
|
||||
if (board.issues.length === 0)
|
||||
picker.placeholder = '看板「审查」列里没有工单'
|
||||
|
||||
let entries = pickEntries(board.issues, issue => localWorktree(this.context, repo.workspaceRoot, issue) !== undefined)
|
||||
// 换 items 时 VS Code 会把光标挪回第一项:按工单号挪回用户停着的那一项
|
||||
const render = (): void => {
|
||||
if (!open)
|
||||
return
|
||||
picker.hide()
|
||||
void window.showErrorMessage(`读不到看板「审查」列:${errorText(err)}`)
|
||||
const active = picker.activeItems[0]?.issue.number
|
||||
const items = entries.map(entry => ({ issue: entry.issue, ...pickItemText(entry) }))
|
||||
picker.items = items
|
||||
const stay = items.find(item => 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()
|
||||
})]
|
||||
: []))
|
||||
}
|
||||
|
||||
/** 「显示全部」:回到工作区文件树,不再记着这张工单。 */
|
||||
@@ -148,35 +186,36 @@ export class ReviewMode implements Disposable {
|
||||
}
|
||||
|
||||
/** 弹框里的一项:有 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 改了几个文件;没读到记进日志,这一项写读不到。 */
|
||||
private async countFiles(repo: RepoContext, issueNumber: number, pr: number): Promise<FileCount> {
|
||||
try {
|
||||
return { count: (await loadPullRequestFiles(repo, pr)).length }
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `选工单:读不到 #${issueNumber} 的 PR #${pr} 改了几个文件`, details: errorText(err) })
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 进审查:读工单和 PR、备工作副本,都好了才换树;这期间视图上转进度,标题下面那行写正在做哪一步。
|
||||
* target 在选工单时是弹框里那张工单(刚读的),刷新、重开窗口时是工单号(重读)。
|
||||
* 进审查:重读这张工单(弹框里的是看板上摆着的,可能旧了)和它的 PR、备工作副本,都好了才换树;
|
||||
* 这期间视图上转进度,标题下面那行写正在做哪一步。每一步、不进的原因、出错都记进日志。
|
||||
*/
|
||||
private async review(target: Issue | number, how: How): Promise<void> {
|
||||
const issueNumber = typeof target === 'number' ? target : target.number
|
||||
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(target, (text) => {
|
||||
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
|
||||
@@ -188,6 +227,7 @@ export class ReviewMode implements Disposable {
|
||||
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)
|
||||
@@ -195,15 +235,14 @@ export class ReviewMode implements Disposable {
|
||||
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(target: Issue | number, step: (text: string) => void): Promise<Loaded | { blocked: string }> {
|
||||
private async load(issueNumber: 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)
|
||||
step(`正在读取 #${issueNumber}…`)
|
||||
const reviewable = reviewableIssue(issueNumber, await loadIssue(this.context, repo, issueNumber))
|
||||
if ('blocked' in reviewable)
|
||||
return reviewable
|
||||
const { issue, pr } = reviewable
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inReviewColumn, pickItemText, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules'
|
||||
import { inReviewColumn, 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')
|
||||
@@ -46,7 +22,17 @@ describe('看板「审查」列', () => {
|
||||
expect(inReviewColumn(issues).map(item => item.number)).toEqual([452, 446, 438])
|
||||
})
|
||||
|
||||
it('工单的 PR 号取 state JSON 里的正整数', () => {
|
||||
it('只要 Gitea 的工单(PR 在 Gitea 上),YouTrack 的不算,没写来源的算 Gitea 的', () => {
|
||||
const issues = [
|
||||
{ number: 12, column: 'review' as const, source: 'youtrack' as const },
|
||||
{ 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])
|
||||
})
|
||||
|
||||
it('工单的 PR 号取 state JSON 里的正整数(看板快照里也是字符串)', () => {
|
||||
expect(prNumberOf({ pr: '454' })).toBe(454)
|
||||
expect(prNumberOf({ pr: '447' })).toBe(447)
|
||||
expect(prNumberOf({})).toBeUndefined()
|
||||
expect(prNumberOf({ pr: '' })).toBeUndefined()
|
||||
|
||||
@@ -6,9 +6,11 @@ export function prNumberOf(issue: Pick<Issue, 'pr'>): number | undefined {
|
||||
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)
|
||||
/** 看板「审查」列里的 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)
|
||||
}
|
||||
|
||||
/** 这张工单(最新状态,工单已经不存在时 null)现在能不能审查:能时带上它和它的 PR 号,不能时给出原因(给人看的话)。 */
|
||||
@@ -34,29 +36,6 @@ export function reviewablePullRequest<T extends { state: string, merged: boolean
|
||||
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}`
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* 审查工单要的看板数据,全走看板自己的那套:工单和所在列用看板的加载(state JSON 定列、看板范围),
|
||||
* 本机记录(worktree 路径)照看板叠上,PR 用 Gitea 的接口。
|
||||
* 审查工单要的看板数据,全走看板自己的那套:列工单用看板上现在摆着的,重读一张工单用看板的单张加载
|
||||
* (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 type { BoardIssues } from '../../panel/kanbanSnapshot'
|
||||
import { getPullRequest, GiteaApiError, listPullRequestFiles } from '../../gitea/api'
|
||||
import { loadIssues, loadSingleIssue } from '../../gitea/issueLoader'
|
||||
import { 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 { KanbanWebviewPanel } from '../../panel/KanbanPanel'
|
||||
import { inReviewColumn } from './reviewRules'
|
||||
|
||||
/** 当前工作区的 Gitea 仓库和 token;没有时抛出给人看的错。 */
|
||||
@@ -23,11 +23,13 @@ export async function requireRepo(context: ExtensionContext): Promise<RepoContex
|
||||
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))
|
||||
/**
|
||||
* 看板「审查」列里的工单:取看板上现在摆着的(用户看到的就是它),不再加载一遍;
|
||||
* 看板还没加载过时 undefined。进审查时再单独重读选中的那张(loadIssue)。
|
||||
*/
|
||||
export function reviewColumnIssues(context: ExtensionContext): BoardIssues | undefined {
|
||||
const board = KanbanWebviewPanel.issuesOnBoard(context)
|
||||
return board && { ...board, issues: inReviewColumn(board.issues) }
|
||||
}
|
||||
|
||||
/** 一张工单的最新状态;工单已经不存在时 null。 */
|
||||
|
||||
@@ -14,10 +14,14 @@ function reviewWorktreePath(context: ExtensionContext, workspaceRoot: string, is
|
||||
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)
|
||||
/**
|
||||
* 本机这张工单的工作副本:看板记着的 worktree 优先,其次是之前审查它时建的;都没有时 undefined。
|
||||
* 在不在按盘上现在的看:看板快照里的 worktreeExists 是加载那会儿的。
|
||||
*/
|
||||
export function localWorktree(context: ExtensionContext, workspaceRoot: string, issue: Pick<Issue, 'number' | 'worktreePath'>): string | undefined {
|
||||
const recorded = issue.worktreePath && resolveWorktreePath(issue.worktreePath, workspaceRoot)
|
||||
if (recorded && existsSync(recorded))
|
||||
return recorded
|
||||
const review = reviewWorktreePath(context, workspaceRoot, issue.number)
|
||||
return existsSync(review) ? review : undefined
|
||||
}
|
||||
@@ -31,7 +35,7 @@ export function localWorktree(context: ExtensionContext, workspaceRoot: string,
|
||||
export async function prepareWorktree(
|
||||
context: ExtensionContext,
|
||||
repo: RepoContext,
|
||||
issue: Pick<Issue, 'number' | 'worktreePath' | 'worktreeExists'>,
|
||||
issue: Pick<Issue, 'number' | 'worktreePath'>,
|
||||
pull: { number: number, headSha: string },
|
||||
step: (text: string) => void,
|
||||
): Promise<string> {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { IssueRef } from '../issues/stateRouter'
|
||||
import type { SessionTabKind } from './handlers/terminals'
|
||||
import type { IssueOwnershipFacts } from './issueOwnership'
|
||||
import type { KanbanScope } from './kanbanScope'
|
||||
import type { BoardIssues } from './kanbanSnapshot'
|
||||
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
||||
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
||||
import { deleteToken, getToken } from '../auth/secrets'
|
||||
@@ -44,7 +45,7 @@ import * as youtrackIssues from './handlers/youtrackIssues'
|
||||
import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
|
||||
import { isIssueMine } from './issueOwnership'
|
||||
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from './kanbanScope'
|
||||
import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot'
|
||||
import { boardIssues, KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot'
|
||||
import { isOwnerOnlyMessage } from './readOnlyGuard'
|
||||
|
||||
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
|
||||
@@ -357,6 +358,11 @@ export class KanbanWebviewPanel {
|
||||
KanbanWebviewPanel.current?.loadAndPush().catch(() => {})
|
||||
}
|
||||
|
||||
/** 看板上现在摆着的工单(见 boardIssues),给别处(代码阅读的「选工单」)直接用,不再加载一遍。 */
|
||||
static issuesOnBoard(context: ExtensionContext): BoardIssues | undefined {
|
||||
return boardIssues(KanbanWebviewPanel.current?.snapshotIssues(), context.workspaceState.get(KANBAN_SNAPSHOT_KEY))
|
||||
}
|
||||
|
||||
/**
|
||||
* 同事工单的写操作最终拦截点。前端已按归属藏掉按钮,这里兜住快照过期
|
||||
* 或工单刚被重新指派的竞态:不是我的工单就丢消息 + 提示,不落任何副作用。
|
||||
|
||||
@@ -34,6 +34,23 @@ export function parseKanbanSnapshot(raw: unknown): KanbanSnapshot | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 看板上现在摆着的工单,和它从哪来:面板这次会话里加载好的(loaded)、上次存下的快照(saved)。 */
|
||||
export interface BoardIssues {
|
||||
issues: Issue[]
|
||||
from: 'loaded' | 'saved'
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板上现在摆着的工单:面板这次会话里加载好了就用它最近一次加载的,否则用上次存下的快照
|
||||
* (重开窗口时看板先画的也是它);都没有时 undefined,看板还没加载过。
|
||||
*/
|
||||
export function boardIssues(loaded: Issue[] | undefined, persisted: unknown): BoardIssues | undefined {
|
||||
if (loaded)
|
||||
return { issues: loaded, from: 'loaded' }
|
||||
const snapshot = parseKanbanSnapshot(persisted)
|
||||
return snapshot ? { issues: snapshot.issues, from: 'saved' } : undefined
|
||||
}
|
||||
|
||||
/** 写入 HTML 的 JSON:小于号换成 \\u003c,避免 script 标签被提前关掉。 */
|
||||
export function snapshotToScriptJson(snapshot: KanbanSnapshot): string {
|
||||
return JSON.stringify(snapshot).replace(/</g, '\\u003c')
|
||||
|
||||
Reference in new issue
Block a user