✨ 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

+17 -14
View File
@@ -28,7 +28,7 @@
| 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 |
| 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色,再用 focusBorder 色补一笔(当前那一处描框、别的画细线);看不见的(折叠、筛掉的)点的时候不自动展开,筛选栏上写「名字 · 2/5」;⌘↓ / ⌘↑(可改绑的命令)在各处之间跳,跳到折叠着的地方会展开 |
| 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释、按审查工单只看 PR 改过的文件;不做新建 / 重命名 / 删除 |
| 审查工单 | 标题栏第一个按钮「选工单」列出看板「审查」列的工单,选中后树里只剩这张工单的 PR 改过的文件(目录层级照留、全部展开,按 M / A / D 着色,删掉的列出来但打不开);点开的是本机这张工单的工作副本里 PR 最新的代码,不标差异、不开 diff;本机没有工作副本就拉 PR 分支建一个。弹框列的是看板上现在摆着的工单(不再加载一遍),选中后重读那张工单和它的 PR。见「审查工单」 |
| 审查工单 | 标题栏第一个按钮「选工单」列出看板「审查」列的工单和「进行中」列里已经有 PR 的工单(按列分组),选中后树里只剩这张工单的 PR 改过的文件(目录层级照留、全部展开,按 M / A / D 着色,删掉的列出来但打不开);点开的是本机这张工单的工作副本里 PR 最新的代码,不标差异、不开 diff;本机没有工作副本就拉 PR 分支建一个。弹框列的是看板上现在摆着的工单(不再加载一遍),选中后重读那张工单和它的 PR。见「审查工单」 |
| 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 |
| 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用输入框旁边选的 profile,记住上次选的,没选过用插件默认的)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;输入框里能粘贴 / 拖进图片随问题发(存在 `.spx/code-qa/`,跟着问答删);问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 |
| 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) |
@@ -87,17 +87,20 @@
### 审查工单
看板「审查」列常压着好几张工单,逐张读 PR 改了什么时,侧栏只看这张工单的 PR 改过的文件(原型画板「审查 · 选工单」「审查 · 改动的文件」)。范围就是原型画的这些:不标差异、不开 diff、没有「改动」筛选。
看板「审查」列常压着好几张工单,逐张读 PR 改了什么时,侧栏只看这张工单的 PR 改过的文件(原型画板「审查 · 选工单」「审查 · 改动的文件」)。开发完了却没走自动审查的工单会停在「进行中」列,它们的 PR 一样要看,所以「进行中」列里已经有 PR 的也能选。范围就是原型画的这些:不标差异、不开 diff、没有「改动」筛选。
- 选工单:视图标题栏第一个按钮(`view/title` 的 `navigation@1`,`$(git-pull-request)`,命令 `superpowers.codeReader.pickReviewIssue`)。点开 VS Code 顶部的快速选择(`window.createQuickPick`):标题 `选工单 · 看板「审查」列`,占位 `输入工单号或标题筛选`(按第一行筛)。工单马上列出来,各 PR 改了几个文件随后一项项补上,都补完才停止转圈(`busy`):
- 工单:看板上现在摆着的,不再加载一遍(整个看板重新加载要翻每张工单的评论,几百张工单时很慢,哪个请求卡住就一直转)。面板这次会话里加载过就用它最近一次加载的(`KanbanWebviewPanel.issuesOnBoard` → `snapshotIssues()`),否则用上次存下的看板快照(`workspaceState` 的 `superpowers.kanbanSnapshot`,重开窗口时看板先画的也是它);二者都是看板加载时算好的(state JSON 定列、看板的范围、叠好本机记录)。只留「审查」列的 Gitea 工单(PR 在 Gitea 上),次序同看板列(工单号大的在前)。快照里 PR 号是字符串(`"454"`),和 `Issue.pr` 的类型一致,按正整数认。
- 看板还没加载过(两样都没有):占位换成 `看板还没加载过,先打开一次看板`,不转圈。「审查」列没有工单:占位换成 `看板「审查」列里没有工单`。
- 快照可能落后于看板上后来的挪动:刚挪进「审查」列、看板还没重新加载过的工单要等看板刷新后才列出来;选中时会重读这张工单(见下),已经挪走的不会进审查。
- 能审查的列只有一条规则、写在一处(`reviewRules.ts` 的 `REVIEW_COLUMNS`:先「审查」、再「进行中」):弹框列哪些、选中 / 刷新 / 重开窗口时还能不能审查都按它。
- 选工单:视图标题栏第一个按钮(`view/title` 的 `navigation@1`,`$(git-pull-request)`,命令 `superpowers.codeReader.pickReviewIssue`)。点开 VS Code 顶部的快速选择(`window.createQuickPick`):标题 `选工单 · 看板「审查」「进行中」列`,占位 `输入工单号或标题筛选`(按第一行筛)。工单马上列出来,各 PR 改了几个文件随后一项项补上,都补完才停止转圈(`busy`):
- 工单:看板上现在摆着的,不再加载一遍(整个看板重新加载要翻每张工单的评论,几百张工单时很慢,哪个请求卡住就一直转)。面板这次会话里加载过就用它最近一次加载的(`KanbanWebviewPanel.issuesOnBoard` → `snapshotIssues()`),否则用上次存下的看板快照(`workspaceState` 的 `superpowers.kanbanSnapshot`,重开窗口时看板先画的也是它);二者都是看板加载时算好的(state JSON 定列、看板的范围、叠好本机记录)。快照里 PR 号是字符串(`"454"`),和 `Issue.pr` 的类型一致,按正整数认。
- 列哪些(`reviewCandidates`):只要 Gitea 工单(PR 在 Gitea 上);「审查」列的都列(没 PR 的也列,见下);「进行中」列的只列已经有 PR 的——没 PR 的还在开发,没东西可看,列出来只会添乱。
- 分组:按列用快速选择的分隔行(`QuickPickItemKind.Separator`,写列名)分成两组,先「审查」再「进行中」,没有工单的列不出分隔行;组里次序同看板列(工单号大的在前)。
- 看板还没加载过(两样都没有):占位换成 `看板还没加载过,先打开一次看板`,不转圈。两列都没有能审查的工单:占位换成 `看板「审查」「进行中」列里没有能审查的工单`。
- 快照可能落后于看板上后来的挪动:刚挪进这两列、看板还没重新加载过的工单要等看板刷新后才列出来;选中时会重读这张工单(见下),已经挪走的不会进审查。
- 每项第一行 `#<工单号> <标题>`,后面淡色 `PR #<PR 号>`(`description`);第二行(`detail`)`<文件数> · 本机有工作副本` 或 `<文件数> · 本机没有工作副本,选中后拉取 PR 分支`。文件数先写 `正在读改动文件数…`,读到换成 `改了 N 个文件`(`listPullRequestFiles`,各项一起读),没读到写 `读不到改动文件数`、原话记进日志。每补一项整组换一次 `items`,光标按工单号留在原来那项上。本机有没有工作副本按盘上现在的看。
- state JSON 里没有 PR 号的工单也列出来,第二行只写 `没有关联 PR`;选中它只提示 `#<工单号> 没有关联 PR`,什么都不变。
- 「审查」列里 state JSON 没有 PR 号的工单也列出来,第二行只写 `没有关联 PR`;选中它只提示 `#<工单号> 没有关联 PR`,什么都不变。
- 当前工作区没有 Gitea 仓库、没配 token:关掉弹框,报错。
- 日志(Superpowers 输出通道,来源 `codeReader`):列了几张、数据来自看板这次加载的还是存下的快照;哪张的文件数没读到和原话;进审查的每一步、不进的原因、出错。
- 选中后进审查:先重读这张工单(看板的单张加载 `loadSingleIssue`,弹框里的是看板上摆着的,可能旧了),确认它还在「审查」列、有 PR,PR 还开着(没了、合并了、关了都不进,提示原因),再读 PR 改过的文件、备好工作副本,都好了才换树。这期间视图上转进度(`withProgress` 的 `viewId`),标题下面那行写正在做哪一步(`正在读取 PR #447 改过的文件…`、`正在拉取 PR #447 的分支…`)。出错:报错,树不换(原来在审查别的工单就还是那张)。
- 选中后进审查:先重读这张工单(看板的单张加载 `loadSingleIssue`,弹框里的是看板上摆着的,可能旧了),确认它还在「审查」或「进行中」列、有 PR,PR 还开着(没了、合并了、关了都不进,提示原因),再读 PR 改过的文件、备好工作副本,都好了才换树。这期间视图上转进度(`withProgress` 的 `viewId`),标题下面那行写正在做哪一步(`正在读取 PR #447 改过的文件…`、`正在拉取 PR #447 的分支…`)。出错:报错,树不换(原来在审查别的工单就还是那张)。
- 工作副本(PR 最新的代码):
- 本机有这张工单的工作副本就用它:看板记着的 worktree(`worktreePath`,盘上还在)优先,其次是之前审查它时建的。用之前先 `git fetch origin refs/pull/<PR 号>/head`(Gitea 给每个 PR 留的引用),能不丢东西时快进到 PR 最新的提交(`headSha`):有没提交的改动(未跟踪的文件不算)、本地另有 PR 之外的提交时照原样用;拉取、快进出错也照原样用,提醒一声。目录必须是工作副本的根,否则不动(`git -C` 会找到外面的仓库上去)。
- 没有就拉 PR 的分支(同上的 `refs/pull/<n>/head`)建一个:和实施的 worktree 同一个目录模板(插件设置里的 worktree 目录,`$feature_name` 换成 `review-<工单号>`),停在 PR 最新的提交上、不挂分支(`git worktree add --detach`),不和实施用的 `feature/…` 分支抢名字。不写进工单的本机记录:看板上删 worktree 会连带删工单的 feature 分支,审查建的这个不该走那条路。
@@ -106,8 +109,8 @@
- 删掉的文件:列出来但打不开(没有命令),悬停提示 `PR 里删掉了`。原型把名字划掉,VS Code 的树行没有删除线,这是和原型的有意差异,只靠红色和 `D`。
- 标题栏:视图 `description` 写 `审查 #<工单号>`(标题后面淡色),`message` 写 `#<工单号> <标题> · PR #<PR 号> · 改了 N 个文件`;上下文键 `superpowers.codeReader.reviewing` 为真时出「显示全部」(`$(close)`,`navigation@2`,命令 `superpowers.codeReader.showAllFiles`,命令面板里也只在审查时有),点了回到工作区文件树。真实的视图没有新建按钮,原型里被「显示全部」换掉的那两个不用管。
- 行上的按钮:「复制」照旧(相对路径相对工作副本的根,就是仓库里的路径);注释按钮审查时不出(`when` 带 `!superpowers.codeReader.reviewing`):注释记在工作区文件夹里,工作副本不是工作区文件夹。
- 刷新:审查时重读工单和 PR 改过的文件,工作副本照上面的规则更新;工单离开「审查」列、PR 不能看了就提示原因、回到工作区文件树。
- 记住:选中的工单号存 `workspaceState`(`superpowers.codeReader.reviewIssue`),重开窗口后视图第一次露出来时接着审查(没打开侧栏不去拉);工单离开了「审查」列、没了 PR、PR 没了 / 合并了 / 关了,悄悄回到工作区文件树并忘掉;读的时候出错,报错后同样回去。
- 刷新:审查时重读工单和 PR 改过的文件,工作副本照上面的规则更新;工单两列都离开了(「审查」↔「进行中」之间挪动不算)、PR 不能看了就提示原因、回到工作区文件树。
- 记住:选中的工单号存 `workspaceState`(`superpowers.codeReader.reviewIssue`),重开窗口后视图第一次露出来时接着审查(没打开侧栏不去拉);工单两列都离开了、没了 PR、PR 没了 / 合并了 / 关了,悄悄回到工作区文件树并忘掉;读的时候出错,报错后同样回去。
- 代码:`review/`。纯函数(改动状态、改动的文件树、能不能审查、标题的字、弹框的各项)在 `changedFiles.ts`、`reviewRules.ts`、`pickList.ts`;看板数据在 `reviewSource.ts`(看板上摆着的工单由看板给:`panel/kanbanSnapshot.ts` 的 `boardIssues`;仓库和 token 与看板「改动」tab 共用 `gitea/repoContext.ts`);工作副本在 `reviewWorktree.ts`(git 操作在 `git/worktree.ts`);着色在 `changeDecorations.ts`;弹框、进出审查、记住在 `reviewMode.ts`。文件树只多一个「换成这些文件」的入口(`showChanges`)。
### 编辑器右上角切换
@@ -1732,9 +1735,9 @@ vscode/src/codeReader/
explorer/copyText.ts 复制菜单的命令和复制出什么(纯函数)
explorer/copyCommands.ts 复制命令:写剪贴板、状态栏提示
review/changedFiles.ts 改动状态 → M / A / D、颜色,PR 改过的文件 → 按目录分好的树(纯函数)
review/reviewRules.ts 「审查」列的工单、PR 号、能不能审查、标题栏的字(纯函数)
review/reviewRules.ts 能审查的列(「审查」「进行中」)、弹框列哪些工单、PR 号、能不能审查、标题栏的字(纯函数)
review/pickList.ts 选工单弹框的各项:先摆出来、文件数读到一个换一个、还转不转圈、每项的字(纯函数)
review/reviewSource.ts 看板数据:「审查」列的工单(看板上摆着的)、重读单张工单、PR、PR 改过的文件
review/reviewSource.ts 看板数据:弹框列的工单(看板上摆着的)、重读单张工单、PR、PR 改过的文件
(panel/kanbanSnapshot.ts 的 boardIssues:看板上现在摆着的工单——这次加载的,否则存下的快照;KanbanWebviewPanel.issuesOnBoard 接上面板和 workspaceState)
review/reviewWorktree.ts 本机这张工单的工作副本在哪、备好 PR 最新的代码(已有的快进、没有的建)
review/changeDecorations.ts FileDecorationProvider:审查时给改过的文件上色、挂徽标
@@ -1821,8 +1824,8 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 侧栏行上的菜单(`explorer/rowMenus.test.ts`,读 `package.json`):行尾按钮先「复制」子菜单再注释,有没有注释换「添加 / 编辑注释」,工作区根都没有;「复制」子菜单带 `$(copy)`,注释图标 `$(note)` / `$(edit)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。
- 审查工单:
- 改动的文件树(`review/changedFiles.test.ts`):原型那 6 个文件加一个改名的,目录层级照留、每层只有通往改过的文件的目录,文件带改动和相对仓库根的路径,目录没有改动;排法同普通的树;文件换成同名目录两项都留着;没有文件时根下面是空的。状态 `added` → A、`deleted` → D、别的都是 M,三种颜色是 git 装饰色、删掉的提示「PR 里删掉了」。
- 选工单弹框(`review/pickList.test.ts`):一项的字(有 / 没有工作副本、正在读、读不到、没有 PR);一打开就摆出各项、有 PR 的在读、在转圈,读到一个只换那一项(别的项是同一个对象、次序不变),都读完(没读到也算)停止转圈,没 PR 的、没有工单的一开始就不转;工单从哪来:看板这次加载过用它、没加载过用存下的快照(JSON 里字符串的 PR 号认得,只留「审查」列的 Gitea 工单)、都没有或快照坏了是 undefined。
- 规则和字(`review/reviewRules.test.ts`):标题栏的 description / message;「审查」列的筛选(只要 Gitea 的)和次序,PR 号只认正整数;工单没了、离开「审查」列、没有 PR,PR 没了、合并了、关了的原因。
- 选工单弹框(`review/pickList.test.ts`):一项的字(有 / 没有工作副本、正在读、读不到、没有 PR);一打开就摆出各项、有 PR 的在读、在转圈,读到一个只换那一项(别的项是同一个对象、次序不变),都读完(没读到也算)停止转圈,没 PR 的、没有工单的一开始就不转;按列分组:先「审查」再「进行中」、每组前一行列名、没有工单的列不出标题、换掉一项后分组不变;工单从哪来:看板这次加载过用它、没加载过用存下的快照(JSON 里字符串的 PR 号认得,只留「审查」列和有 PR 的「进行中」列的 Gitea 工单)、都没有或快照坏了是 undefined。
- 规则和字(`review/reviewRules.test.ts`):标题栏的 description / message;弹框列哪些(「审查」列都列、「进行中」列只列有 PR 的、别的列不列、只要 Gitea 的)和次序(先「审查」组再「进行中」组,组里工单号大的在前),列名,PR 号只认正整数;「审查」「进行中」列有 PR 的能审查,工单没了、两列都离开了、没有 PR(两列都一样),PR 没了、合并了、关了的原因。
- 菜单(`explorer/rowMenus.test.ts`):标题栏先「选工单」、审查时才有「显示全部」、再「刷新」,字和图标;审查时注释按钮不出、复制照旧;命令面板里「显示全部」只在审查时有。
- 工作副本(`test/git/reviewWorktree.test.ts`,真的 git:bare 的 origin 上推 `refs/pull/7/head`):拉 PR 的提交建不挂分支的 worktree(父目录不在也建、本机不多出分支),目录手删后同一路径能再建;快进:PR 往前走了且干净 → 快进,已经最新 / 本地在它之上 → 不动,只有未跟踪的文件照样快进,有没提交的改动、本地另有提交 → 不动且改动留着,目录不是工作副本的根 → 报错、外面的仓库不动。
- 复制出什么(`explorer/copyText.test.ts`):文件、目录的名字 / 相对路径 / 绝对路径;不带后缀只去最后一个扩展名(`a.test.ts`、`archive.tar.gz`),`.env` 原样、`.env.local` → `.env`、没有扩展名的原样;相对路径总用 `/`,绝对路径照本机写法(Windows 反斜杠)。
+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。 */