From 8e2cdfaf1775dcfd62aa702cf5f23c730f271868 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 17:36:02 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E4=BE=A7=E6=A0=8F=E5=8F=AF=E4=BB=A5=E6=8C=89?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=B7=A5=E5=8D=95=E5=8F=AA=E7=9C=8B=20PR=20?= =?UTF-8?q?=E6=94=B9=E8=BF=87=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=9A=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E3=80=8C=E9=80=89=E5=B7=A5=E5=8D=95=E3=80=8D?= =?UTF-8?q?=E5=88=97=E5=87=BA=E7=9C=8B=E6=9D=BF=E3=80=8C=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E3=80=8D=E5=88=97=EF=BC=8C=E9=80=89=E4=B8=AD=E5=90=8E=E6=A0=91?= =?UTF-8?q?=E9=87=8C=E5=8F=AA=E5=89=A9=E6=94=B9=E8=BF=87=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=EF=BC=88M=20/=20A=20/=20D=20=E7=9D=80=E8=89=B2?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E7=82=B9=E5=BC=80=E7=9A=84=E6=98=AF=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=E5=B7=A5=E4=BD=9C=E5=89=AF=E6=9C=AC=E9=87=8C=20PR=20?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E7=9A=84=E4=BB=A3=E7=A0=81=EF=BC=8C=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E5=B0=B1=E6=8B=89=20PR=20=E5=88=86=E6=94=AF=E5=BB=BA?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=EF=BC=8C=E3=80=8C=E6=98=BE=E7=A4=BA=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E3=80=8D=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 41 ++- vscode/package.json | 36 ++- vscode/src/codeReader/explorer/fileTree.ts | 43 +++- .../src/codeReader/explorer/rowMenus.test.ts | 33 +++ vscode/src/codeReader/index.ts | 13 +- .../codeReader/review/changeDecorations.ts | 35 +++ .../codeReader/review/changedFiles.test.ts | 80 ++++++ vscode/src/codeReader/review/changedFiles.ts | 59 +++++ vscode/src/codeReader/review/reviewMode.ts | 233 ++++++++++++++++++ .../src/codeReader/review/reviewRules.test.ts | 78 ++++++ vscode/src/codeReader/review/reviewRules.ts | 68 +++++ vscode/src/codeReader/review/reviewSource.ts | 54 ++++ .../src/codeReader/review/reviewWorktree.ts | 60 +++++ vscode/src/git/worktree.ts | 42 ++++ vscode/src/git/worktreePath.ts | 5 + vscode/src/gitea/repoContext.ts | 30 +++ vscode/src/panel/handlers/prFiles.ts | 28 +-- vscode/test/git/reviewWorktree.test.ts | 155 ++++++++++++ 18 files changed, 1053 insertions(+), 40 deletions(-) create mode 100644 vscode/src/codeReader/review/changeDecorations.ts create mode 100644 vscode/src/codeReader/review/changedFiles.test.ts create mode 100644 vscode/src/codeReader/review/changedFiles.ts create mode 100644 vscode/src/codeReader/review/reviewMode.ts create mode 100644 vscode/src/codeReader/review/reviewRules.test.ts create mode 100644 vscode/src/codeReader/review/reviewRules.ts create mode 100644 vscode/src/codeReader/review/reviewSource.ts create mode 100644 vscode/src/codeReader/review/reviewWorktree.ts create mode 100644 vscode/src/gitea/repoContext.ts create mode 100644 vscode/test/git/reviewWorktree.test.ts diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 04ce2c6..e0bbfa9 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -26,7 +26,8 @@ | 注释按 Markdown 显示 | 注释块和函数、类、变量、字段的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色,再用 focusBorder 色补一笔(当前那一处描框、别的画细线);看不见的(折叠、筛掉的)点的时候不自动展开,筛选栏上写「名字 · 2/5」;⌘↓ / ⌘↑(可改绑的命令)在各处之间跳,跳到折叠着的地方会展开 | -| 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释;不做新建 / 重命名 / 删除 | +| 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释、按审查工单只看 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` → 捕获,`===` → 全等) | @@ -47,7 +48,7 @@ - 活动栏新增图标(书本,`book-open-text` 样式),视图容器标题「代码阅读」,里面一个视图「代码阅读」。 - 视图是工作区文件树:每个工作区文件夹一个根节点(默认展开),目录在前、文件在后,名字不区分大小写排序;遵守 `files.exclude`。 - 树节点用 `resourceUri`,图标跟随用户的文件图标主题,git 改动颜色(M / U)由 VS Code 自动加上,外观与自带资源管理器一致。 -- 视图标题栏:刷新、全部折叠(`showCollapseAll`)。文件变化时自动刷新(防抖)。 +- 视图标题栏:选工单(见「审查工单」)、刷新、全部折叠(`showCollapseAll`)。文件变化时自动刷新(防抖)。 - 点文件:语言受支持 → 用「代码阅读预览」自定义编辑器打开;否则 `vscode.open`。 - 鼠标停在目录 / 文件行上,行尾一排按钮(`view/item/context` 的 `inline` 组):先「复制」,再「添加注释 / 编辑注释」(见下两节)。工作区根节点没有。 @@ -77,6 +78,28 @@ - 刷新:只重画注释变了的那几行(文件树按 Uri 记住交给 VS Code 的节点对象,VS Code 按对象认行);还没展开过的行不用管。 - 注释和代码阅读的解析、翻译无关,单独放在 `notes/`;文件树只留一个「给行添信息」的接口(`FileTreeDecorator`),由 `index.ts` 接上。 +### 审查工单 + +看板「审查」列常压着好几张工单,逐张读 PR 改了什么时,侧栏只看这张工单的 PR 改过的文件(原型画板「审查 · 选工单」「审查 · 改动的文件」)。范围就是原型画的这些:不标差异、不开 diff、没有「改动」筛选。 + +- 选工单:视图标题栏第一个按钮(`view/title` 的 `navigation@1`,`$(git-pull-request)`,命令 `superpowers.codeReader.pickReviewIssue`)。点开 VS Code 顶部的快速选择(`window.createQuickPick`):标题 `选工单 · 看板「审查」列`,占位 `输入工单号或标题筛选`(按第一行筛)。读数据期间转圈(`busy`),都读好了才列出来,不先摆假的数: + - 工单:走看板自己的加载(`loadIssues`,看板当前的范围 `superpowers.kanbanScope`,state JSON 定列),本机记录(worktree 路径)照看板叠上(`overlayLocalIssueState`),只留「审查」列的,次序同看板列(工单号大的在前)。只有 Gitea 工单:PR 在 Gitea 上。 + - 每项第一行 `#<工单号> <标题>`,后面淡色 `PR #`(`description`);第二行(`detail`)`改了 N 个文件 · 本机有工作副本` 或 `改了 N 个文件 · 本机没有工作副本,选中后拉取 PR 分支`。N 是 PR 改过的文件数(`listPullRequestFiles`,各项一起读);读不到时写 `读不到改了哪些文件:<原话>`。 + - state JSON 里没有 PR 号的工单也列出来,第二行只写 `没有关联 PR`;选中它只提示 `#<工单号> 没有关联 PR`,什么都不变。 + - 读不到看板(没有 Gitea 仓库、没配 token、网络):关掉弹框,报错。「审查」列没有工单:占位换成 `看板「审查」列里没有工单`。 +- 选中后进审查:先确认工单还在「审查」列、有 PR,PR 还开着(没了、合并了、关了都不进,提示原因),再读 PR 改过的文件、备好工作副本,都好了才换树。这期间视图上转进度(`withProgress` 的 `viewId`),标题下面那行写正在做哪一步(`正在读取 PR #447 改过的文件…`、`正在拉取 PR #447 的分支…`)。出错:报错,树不换(原来在审查别的工单就还是那张)。 +- 工作副本(PR 最新的代码): + - 本机有这张工单的工作副本就用它:看板记着的 worktree(`worktreePath`,盘上还在)优先,其次是之前审查它时建的。用之前先 `git fetch origin refs/pull//head`(Gitea 给每个 PR 留的引用),能不丢东西时快进到 PR 最新的提交(`headSha`):有没提交的改动(未跟踪的文件不算)、本地另有 PR 之外的提交时照原样用;拉取、快进出错也照原样用,提醒一声。目录必须是工作副本的根,否则不动(`git -C` 会找到外面的仓库上去)。 + - 没有就拉 PR 的分支(同上的 `refs/pull//head`)建一个:和实施的 worktree 同一个目录模板(插件设置里的 worktree 目录,`$feature_name` 换成 `review-<工单号>`),停在 PR 最新的提交上、不挂分支(`git worktree add --detach`),不和实施用的 `feature/…` 分支抢名字。不写进工单的本机记录:看板上删 worktree 会连带删工单的 feature 分支,审查建的这个不该走那条路。 +- 审查时的树:根只有一个,是工作副本,名字照写工作区文件夹的;下面只有 PR 改过的文件和通往它们的目录,目录层级照留、全部展开,每层排法同普通的树。节点照旧用 `resourceUri`(工作副本里的文件),点文件和平时一样打开(受支持的语言用预览),打开的就是 PR 最新的代码。 +- 改动的颜色:`FileDecorationProvider` 挂在这些文件的 Uri 上,颜色用 VS Code 源代码管理的主题色——修改 `gitDecoration.modifiedResourceForeground` 徽标 `M`,新增 `gitDecoration.addedResourceForeground` 徽标 `A`,删除 `gitDecoration.deletedResourceForeground` 徽标 `D`;目录不上色。Gitea 的状态 `added` → A,`deleted` → D,别的(`changed`、改名的 `renamed` 在新路径上、`copied`……)→ M。编辑器标签页上打开的这些文件也带着,退出审查就清掉。 +- 删掉的文件:列出来但打不开(没有命令),悬停提示 `PR 里删掉了`。原型把名字划掉,VS Code 的树行没有删除线,这是和原型的有意差异,只靠红色和 `D`。 +- 标题栏:视图 `description` 写 `审查 #<工单号>`(标题后面淡色),`message` 写 `#<工单号> <标题> · PR # · 改了 N 个文件`;上下文键 `superpowers.codeReader.reviewing` 为真时出「显示全部」(`$(close)`,`navigation@2`,命令 `superpowers.codeReader.showAllFiles`,命令面板里也只在审查时有),点了回到工作区文件树。真实的视图没有新建按钮,原型里被「显示全部」换掉的那两个不用管。 +- 行上的按钮:「复制」照旧(相对路径相对工作副本的根,就是仓库里的路径);注释按钮审查时不出(`when` 带 `!superpowers.codeReader.reviewing`):注释记在工作区文件夹里,工作副本不是工作区文件夹。 +- 刷新:审查时重读工单和 PR 改过的文件,工作副本照上面的规则更新;工单离开「审查」列、PR 不能看了就提示原因、回到工作区文件树。 +- 记住:选中的工单号存 `workspaceState`(`superpowers.codeReader.reviewIssue`),重开窗口后视图第一次露出来时接着审查(没打开侧栏不去拉);工单离开了「审查」列、没了 PR、PR 没了 / 合并了 / 关了,悄悄回到工作区文件树并忘掉;读的时候出错,报错后同样回去。 +- 代码:`review/`。纯函数(改动状态、改动的文件树、弹框和标题的字、能不能审查)在 `changedFiles.ts`、`reviewRules.ts`;看板数据在 `reviewSource.ts`(仓库和 token 与看板「改动」tab 共用 `gitea/repoContext.ts`);工作副本在 `reviewWorktree.ts`(git 操作在 `git/worktree.ts`);着色在 `changeDecorations.ts`;弹框、进出审查、记住在 `reviewMode.ts`。文件树只多一个「换成这些文件」的入口(`showChanges`)。 + ### 编辑器右上角切换 - 受支持语言的文件:普通编辑器里标题栏显示「预览」,预览里显示「编辑」。点一下在**同一个标签页位置**换成另一种:用 VS Code 内部的 `reopenActiveEditorWith`(「重新打开编辑器的方式」背后的机制)原位替换。不能用「另开再关原标签」:文件有未保存改动时关原标签会弹保存框,选「不保存」会丢改动。 @@ -1190,9 +1213,16 @@ vscode/src/codeReader/ translators/zhTemplate.ts 模板直译引擎(按语言的表驱动,词性、遍历结构从 SourceDocument.language 取;推导式排进框见 iterationGroup) translators/typescriptZh.ts TS 表 translators/pythonZh.ts Python 表(含注解位置的判断) - explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口) + explorer/fileTree.ts 侧栏文件树(FileTreeDecorator:给行添信息的接口;showChanges:审查工单时只摆 PR 改过的文件) explorer/copyText.ts 复制菜单的命令和复制出什么(纯函数) explorer/copyCommands.ts 复制命令:写剪贴板、状态栏提示 + review/changedFiles.ts 改动状态 → M / A / D、颜色,PR 改过的文件 → 按目录分好的树(纯函数) + review/reviewRules.ts 「审查」列的工单、PR 号、能不能审查、弹框和标题栏的字(纯函数) + review/reviewSource.ts 看板数据:「审查」列的工单、单张工单、PR、PR 改过的文件(走看板的加载和 Gitea 接口) + review/reviewWorktree.ts 本机这张工单的工作副本在哪、备好 PR 最新的代码(已有的快进、没有的建) + review/changeDecorations.ts FileDecorationProvider:审查时给改过的文件上色、挂徽标 + review/reviewMode.ts ReviewMode:选工单弹框、进出审查、刷新、按工作区记住 + (gitea/repoContext.ts 的 resolveRepoContext:当前工作区的 Gitea 仓库和 token,看板「改动」tab 共用;git/worktree.ts 的 fetchPullRequestHead / addDetachedWorktree / fastForwardWorktree:拉 PR 的提交、建不挂分支的 worktree、不丢东西地快进) pathMap.ts 按文件 / 目录记东西的表(文件注释、问答共用):路径的键、改名 / 挪位置 / 删除时怎么改、.spx 里 JSON 的读写、条目带着的文件哪些不再有人带、删文件(不碰 VS Code) folderStore.ts FolderStore:每个工作区文件夹一份表,内存里的一份、文件监听、跟着改名 / 删除走、读写排队、改表后删掉不再有条目带着的文件 notes/noteMap.ts 保存一条注释(纯函数) @@ -1258,6 +1288,11 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;全选 / 取消全选只动这份文件里的值、别的文件的去掉状态和别的属性不动、取消全选藏掉有这个属性的块(整棵子树)、再全选全回来;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 - 侧栏行上的菜单(`explorer/rowMenus.test.ts`,读 `package.json`):行尾按钮先「复制」子菜单再注释,有没有注释换「添加 / 编辑注释」,工作区根都没有;「复制」子菜单带 `$(copy)`,注释图标 `$(note)` / `$(edit)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。 +- 审查工单: + - 改动的文件树(`review/changedFiles.test.ts`):原型那 6 个文件加一个改名的,目录层级照留、每层只有通往改过的文件的目录,文件带改动和相对仓库根的路径,目录没有改动;排法同普通的树;文件换成同名目录两项都留着;没有文件时根下面是空的。状态 `added` → A、`deleted` → D、别的都是 M,三种颜色是 git 装饰色、删掉的提示「PR 里删掉了」。 + - 规则和字(`review/reviewRules.test.ts`):弹框一项的三行(有 / 没有工作副本、读不到文件数写原话、没有 PR),标题栏的 description / message;「审查」列的筛选和次序,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 反斜杠)。 - 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 - 问 AI: diff --git a/vscode/package.json b/vscode/package.json index 5e907ec..de94f2d 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -103,6 +103,18 @@ "title": "Set Gitea Token", "category": "Superpowers" }, + { + "command": "superpowers.codeReader.pickReviewIssue", + "title": "选工单", + "category": "代码阅读", + "icon": "$(git-pull-request)" + }, + { + "command": "superpowers.codeReader.showAllFiles", + "title": "显示全部", + "category": "代码阅读", + "icon": "$(close)" + }, { "command": "superpowers.codeReader.refresh", "title": "刷新", @@ -237,10 +249,20 @@ "when": "view == superpowers.kanban", "group": "navigation" }, + { + "command": "superpowers.codeReader.pickReviewIssue", + "when": "view == superpowers.codeReader.files", + "group": "navigation@1" + }, + { + "command": "superpowers.codeReader.showAllFiles", + "when": "view == superpowers.codeReader.files && superpowers.codeReader.reviewing", + "group": "navigation@2" + }, { "command": "superpowers.codeReader.refresh", "when": "view == superpowers.codeReader.files", - "group": "navigation" + "group": "navigation@3" } ], "view/item/context": [ @@ -251,12 +273,12 @@ }, { "command": "superpowers.codeReader.addNote", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)$/", + "when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder)$/", "group": "inline@2" }, { "command": "superpowers.codeReader.editNote", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder) noted$/", + "when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder) noted$/", "group": "inline@2" }, { @@ -286,12 +308,12 @@ }, { "command": "superpowers.codeReader.addNote", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder)$/", + "when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder)$/", "group": "notes" }, { "command": "superpowers.codeReader.editNote", - "when": "view == superpowers.codeReader.files && viewItem =~ /^(file|folder) noted$/", + "when": "view == superpowers.codeReader.files && !superpowers.codeReader.reviewing && viewItem =~ /^(file|folder) noted$/", "group": "notes" } ], @@ -312,6 +334,10 @@ "command": "superpowers.codeReader.open", "when": "false" }, + { + "command": "superpowers.codeReader.showAllFiles", + "when": "superpowers.codeReader.reviewing" + }, { "command": "superpowers.codeReader.showPreview", "when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python)$/ || resourceLangId in superpowers.codeReader.languageIds)" diff --git a/vscode/src/codeReader/explorer/fileTree.ts b/vscode/src/codeReader/explorer/fileTree.ts index 393dd48..364670c 100644 --- a/vscode/src/codeReader/explorer/fileTree.ts +++ b/vscode/src/codeReader/explorer/fileTree.ts @@ -1,7 +1,9 @@ import type { Event, TreeDataProvider, WorkspaceFolder } from 'vscode' +import type { ChangedTree, FileChange } from '../review/changedFiles' import type { ExcludeSetting } from './fileEntries' import * as path from 'node:path' import { EventEmitter, FileType, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, workspace } from 'vscode' +import { CHANGE_STYLES } from '../review/changedFiles' import { createExcludeMatcher, entryContext, sortEntries } from './fileEntries' export interface FileNode { @@ -9,6 +11,14 @@ export interface FileNode { folder: WorkspaceFolder isDirectory: boolean isRoot: boolean + /** 审查工单时这个文件在 PR 里怎么改的;普通的树、目录没有 */ + change?: FileChange +} + +/** 审查工单时树上摆什么:工作副本当唯一的根(folder),下面只有 PR 改过的文件(tree)。 */ +export interface ChangedFilesView { + folder: WorkspaceFolder + tree: ChangedTree } /** 给树上的行添信息(比如文件注释):每次画行时调 decorate,onDidChange 报出哪些 Uri 的行要重画。 */ @@ -23,6 +33,7 @@ const REFRESH_DELAY_MS = 300 /** * 侧栏的工作区文件树:每个工作区文件夹一个根节点,只看和打开。 * 节点用 resourceUri,图标、git 改动颜色都由 VS Code 按文件图标主题和 SCM 装饰自动加上。 + * 审查工单时换成只摆 PR 改过的文件(showChanges):目录全部展开,删掉的文件列出来但打不开。 */ export class FileTreeProvider implements TreeDataProvider { /** undefined 是整棵树重画,数组是只重画这几行 */ @@ -32,6 +43,8 @@ export class FileTreeProvider implements TreeDataProvider { /** 按 Uri 记下交给 VS Code 的节点:只重画几行时要交回同一个对象,VS Code 按对象认行。 */ private readonly nodes = new Map() private timer: NodeJS.Timeout | undefined + /** 审查工单时只摆的那些文件;undefined 是普通的工作区文件树 */ + private changes: ChangedFilesView | undefined /** * @param openCommand 点文件时执行的命令,参数是文件 Uri @@ -68,7 +81,15 @@ export class FileTreeProvider implements TreeDataProvider { return item } + /** 换成只摆 PR 改过的文件(undefined 回到工作区文件树),整棵树重画。 */ + showChanges(view: ChangedFilesView | undefined): void { + this.changes = view + this.refresh() + } + async getChildren(node?: FileNode): Promise { + if (this.changes) + return this.changedChildren(this.changes, node) if (!node) return (workspace.workspaceFolders ?? []).map(folder => this.nodeAt(folder.uri, folder, true, true)) @@ -97,26 +118,38 @@ export class FileTreeProvider implements TreeDataProvider { if (!node.isDirectory) { const item = new TreeItem(node.uri, TreeItemCollapsibleState.None) item.iconPath = ThemeIcon.File - item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] } + if (node.change === 'D') + item.tooltip = CHANGE_STYLES.D.tooltip // 删掉的文件没有最新的版本可看,不挂打开的命令 + else + item.command = { command: this.openCommand, title: '打开', arguments: [node.uri] } return item } - const item = new TreeItem(node.uri, node.isRoot ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed) + const expanded = node.isRoot || this.changes !== undefined + const item = new TreeItem(node.uri, expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed) item.iconPath = ThemeIcon.Folder if (node.isRoot) item.label = node.folder.name return item } - private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean): FileNode { + private nodeAt(uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, isRoot: boolean, change?: FileChange): FileNode { const id = uri.toString() const known = this.nodes.get(id) - if (known && known.folder === folder && known.isDirectory === isDirectory && known.isRoot === isRoot) + if (known && known.folder === folder && known.isDirectory === isDirectory && known.isRoot === isRoot && known.change === change) return known - const node: FileNode = { uri, folder, isDirectory, isRoot } + const node: FileNode = { uri, folder, isDirectory, isRoot, ...(change ? { change } : {}) } this.nodes.set(id, node) return node } + /** 审查工单时的一层:根下面是工作副本,再往下只有 PR 改过的文件和通往它们的目录。 */ + private changedChildren({ folder, tree }: ChangedFilesView, node?: FileNode): FileNode[] { + if (!node) + return [this.nodeAt(folder.uri, folder, true, true)] + const dir = path.posix.relative(folder.uri.path, node.uri.path) + return (tree.get(dir) ?? []).map(entry => this.nodeAt(Uri.joinPath(folder.uri, entry.path), folder, entry.isDirectory, false, entry.change)) + } + /** 还没展开过的行不用管,展开时自然按最新的画。 */ private redraw(uris: readonly Uri[]): void { const nodes = uris.flatMap(uri => this.nodes.get(uri.toString()) ?? []) diff --git a/vscode/src/codeReader/explorer/rowMenus.test.ts b/vscode/src/codeReader/explorer/rowMenus.test.ts index ef04ba4..10dcef7 100644 --- a/vscode/src/codeReader/explorer/rowMenus.test.ts +++ b/vscode/src/codeReader/explorer/rowMenus.test.ts @@ -102,3 +102,36 @@ describe('右键菜单的注释', () => { expect(itemsFor('view/item/context', 'notes', root)).toEqual([]) }) }) + +describe('审查工单', () => { + const REVIEWING = 'superpowers.codeReader.reviewing' + const PICK = 'superpowers.codeReader.pickReviewIssue' + const SHOW_ALL = 'superpowers.codeReader.showAllFiles' + + it('标题栏:第一个「选工单」,审查时才有「显示全部」,然后「刷新」', () => { + const title = menus['view/title'] + .filter(item => item.when.startsWith('view == superpowers.codeReader.files')) + .sort((a, b) => orderOf(a) - orderOf(b)) + expect(title.map(item => item.command)).toEqual([PICK, SHOW_ALL, 'superpowers.codeReader.refresh']) + expect(title.every(item => item.group?.startsWith('navigation'))).toBe(true) + expect(title.find(item => item.command === SHOW_ALL)?.when).toContain(`&& ${REVIEWING}`) + expect(title.find(item => item.command === PICK)?.when).not.toContain(REVIEWING) + }) + + it('按钮的字和图标:选工单 git-pull-request,显示全部 close', () => { + expect(commands.find(item => item.command === PICK)).toMatchObject({ title: '选工单', icon: '$(git-pull-request)' }) + expect(commands.find(item => item.command === SHOW_ALL)).toMatchObject({ title: '显示全部', icon: '$(close)' }) + }) + + it('审查时行上没有注释按钮(工作副本里不记注释),复制照旧', () => { + const noteItems = menus['view/item/context'].filter(item => item.command === ADD_NOTE || item.command === EDIT_NOTE) + expect(noteItems.length).toBeGreaterThan(0) + expect(noteItems.every(item => item.when.includes(`!${REVIEWING}`))).toBe(true) + const copyItems = menus['view/item/context'].filter(item => item.submenu === COPY_MENU || item.command?.startsWith('superpowers.codeReader.copy')) + expect(copyItems.some(item => item.when.includes(REVIEWING))).toBe(false) + }) + + it('命令面板里「显示全部」只在审查时有', () => { + expect(menus.commandPalette.find(item => item.command === SHOW_ALL)?.when).toBe(REVIEWING) + }) +}) diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 7a51dc0..052f58a 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -17,6 +17,7 @@ import { registerNoteCommands } from './notes/commands' import { FileNotes } from './notes/fileNotes' import { CodeQa } from './qa/codeQa' import { CodeReaderRegistry } from './registry' +import { ReviewMode } from './review/reviewMode' import { celeryRule } from './rules/celery' import { dataclassRule } from './rules/dataclass' import { enumRule } from './rules/enum' @@ -53,7 +54,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { } /** - * 接入代码阅读:注册表、侧栏文件树(复制、文件注释)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。 + * 接入代码阅读:注册表、侧栏文件树(复制、文件注释、按审查工单只看 PR 改过的文件)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。 * 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。 */ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { @@ -75,6 +76,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) + const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }) + const review = new ReviewMode(context, FILES_VIEW, view, files) context.subscriptions.push( qa, @@ -88,8 +91,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { }), window.registerCustomEditorProvider(PREVIEW_VIEW_TYPE, previews), files, - window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }), - commands.registerCommand('superpowers.codeReader.refresh', () => files.refresh()), + view, + review, + // 审查工单时刷新是重读 PR 改过的文件,平时是重读工作区文件树 + commands.registerCommand('superpowers.codeReader.refresh', () => review.active ? review.reload() : files.refresh()), + commands.registerCommand('superpowers.codeReader.pickReviewIssue', () => review.pick()), + commands.registerCommand('superpowers.codeReader.showAllFiles', () => review.exit()), commands.registerCommand(OPEN_COMMAND, (uri: Uri) => openFile(uri, registry)), // 标题栏按钮传来的是 (文件 Uri, { groupId }),原样转给 VS Code 定位是哪一组的哪个标签 commands.registerCommand('superpowers.codeReader.showPreview', (...args: unknown[]) => showPreview(previews, args)), diff --git a/vscode/src/codeReader/review/changeDecorations.ts b/vscode/src/codeReader/review/changeDecorations.ts new file mode 100644 index 0000000..d397078 --- /dev/null +++ b/vscode/src/codeReader/review/changeDecorations.ts @@ -0,0 +1,35 @@ +import type { Disposable, Event, FileDecorationProvider, Uri } from 'vscode' +import type { FileChange } from './changedFiles' +import { EventEmitter, FileDecoration, ThemeColor } from 'vscode' +import { CHANGE_STYLES } from './changedFiles' + +/** + * 审查工单时给 PR 改过的文件上色、挂 M / A / D 徽标,颜色和 VS Code 源代码管理一样;不审查时什么都不挂。 + * 挂在文件 Uri 上:侧栏的行、编辑器标签页上打开的这些文件都带着。 + */ +export class ChangeDecorations implements FileDecorationProvider, Disposable { + private readonly changed = new EventEmitter() + readonly onDidChangeFileDecorations: Event = this.changed.event + private changes = new Map() + private uris: Uri[] = [] + + /** 换成这些文件的改动(空数组清掉),原来挂着的和新挂的都重画。 */ + show(files: readonly { uri: Uri, change: FileChange }[]): void { + const before = this.uris + this.changes = new Map(files.map(file => [file.uri.toString(), file.change])) + this.uris = files.map(file => file.uri) + this.changed.fire([...before, ...this.uris]) + } + + provideFileDecoration(uri: Uri): FileDecoration | undefined { + const change = this.changes.get(uri.toString()) + if (!change) + return undefined + const style = CHANGE_STYLES[change] + return new FileDecoration(change, style.tooltip, new ThemeColor(style.color)) + } + + dispose(): void { + this.changed.dispose() + } +} diff --git a/vscode/src/codeReader/review/changedFiles.test.ts b/vscode/src/codeReader/review/changedFiles.test.ts new file mode 100644 index 0000000..4bf2f78 --- /dev/null +++ b/vscode/src/codeReader/review/changedFiles.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { buildChangedTree, CHANGE_STYLES, changeOf } from './changedFiles' + +describe('改动状态', () => { + it('新增 A、删除 D,别的(改名、复制、改了、没改)都算修改 M', () => { + expect(changeOf('added')).toBe('A') + expect(changeOf('deleted')).toBe('D') + for (const status of ['changed', 'modified', 'renamed', 'copied', 'unchanged', '']) + expect(changeOf(status)).toBe('M') + }) + + it('颜色照 VS Code 源代码管理的 git 装饰色,删掉的提示「PR 里删掉了」', () => { + expect(CHANGE_STYLES.M.color).toBe('gitDecoration.modifiedResourceForeground') + expect(CHANGE_STYLES.A.color).toBe('gitDecoration.addedResourceForeground') + expect(CHANGE_STYLES.D.color).toBe('gitDecoration.deletedResourceForeground') + expect(CHANGE_STYLES.D.tooltip).toBe('PR 里删掉了') + }) +}) + +describe('改动的文件树', () => { + // 原型「审查 · 改动的文件」里的 6 个文件,外加一个改名的 + const files = [ + { path: 'backend/src/luffy_agent/user/routes.py', status: 'changed' }, + { path: 'backend/tests/user/test_avatar.py', status: 'added' }, + { path: 'backend/src/luffy_agent/core/storage.py', status: 'changed' }, + { path: 'backend/src/luffy_agent/user/legacy_profile.py', status: 'deleted' }, + { path: 'backend/src/luffy_agent/user/services.py', status: 'changed' }, + { path: 'backend/src/luffy_agent/user/schemas.py', status: 'renamed' }, + ] + const tree = buildChangedTree(files) + const names = (dir: string): string[] => (tree.get(dir) ?? []).map(entry => entry.name) + + it('目录层级照留,每层只有通往改过的文件的目录', () => { + expect(names('')).toEqual(['backend']) + expect(names('backend')).toEqual(['src', 'tests']) + expect(names('backend/src')).toEqual(['luffy_agent']) + expect(names('backend/src/luffy_agent')).toEqual(['core', 'user']) + expect(names('backend/src/luffy_agent/core')).toEqual(['storage.py']) + expect(names('backend/tests/user')).toEqual(['test_avatar.py']) + }) + + it('同一层里的文件按名字排,带着各自的改动和相对仓库根的路径', () => { + expect(tree.get('backend/src/luffy_agent/user')).toEqual([ + { name: 'legacy_profile.py', path: 'backend/src/luffy_agent/user/legacy_profile.py', isDirectory: false, change: 'D' }, + { name: 'routes.py', path: 'backend/src/luffy_agent/user/routes.py', isDirectory: false, change: 'M' }, + { name: 'schemas.py', path: 'backend/src/luffy_agent/user/schemas.py', isDirectory: false, change: 'M' }, + { name: 'services.py', path: 'backend/src/luffy_agent/user/services.py', isDirectory: false, change: 'M' }, + ]) + }) + + it('目录没有改动,路径相对仓库根', () => { + expect(tree.get('backend')).toEqual([ + { name: 'src', path: 'backend/src', isDirectory: true }, + { name: 'tests', path: 'backend/tests', isDirectory: true }, + ]) + }) + + it('目录在前、文件在后,名字不区分大小写,和普通的树一样', () => { + const mixed = buildChangedTree([ + { path: 'README.md', status: 'changed' }, + { path: 'src/a.ts', status: 'added' }, + { path: 'Docs/b.md', status: 'added' }, + { path: 'package.json', status: 'changed' }, + ]) + expect(mixed.get('')?.map(entry => entry.name)).toEqual(['Docs', 'src', 'package.json', 'README.md']) + }) + + it('文件换成同名目录(删掉 a、新加 a/b)两项都留着', () => { + const replaced = buildChangedTree([{ path: 'a', status: 'deleted' }, { path: 'a/b', status: 'added' }]) + expect(replaced.get('')).toEqual([ + { name: 'a', path: 'a', isDirectory: true }, + { name: 'a', path: 'a', isDirectory: false, change: 'D' }, + ]) + expect(replaced.get('a')?.map(entry => entry.change)).toEqual(['A']) + }) + + it('没有改动的文件时根下面是空的', () => { + expect(buildChangedTree([]).get('')).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/review/changedFiles.ts b/vscode/src/codeReader/review/changedFiles.ts new file mode 100644 index 0000000..f4a7665 --- /dev/null +++ b/vscode/src/codeReader/review/changedFiles.ts @@ -0,0 +1,59 @@ +import type { DirEntry } from '../explorer/fileEntries' +import { sortEntries } from '../explorer/fileEntries' + +/** PR 里一个文件怎么改的,照 VS Code 源代码管理的写法:修改 M、新增 A、删除 D。 */ +export type FileChange = 'M' | 'A' | 'D' + +/** + * Gitea 给的改动状态 → M / A / D。Gitea 的状态有 added、deleted、changed、renamed、copied、unchanged: + * 新增、删除各算各的,别的都算修改(改名的在新路径上算修改)。 + */ +export function changeOf(status: string): FileChange { + return status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M' +} + +/** 各种改动在侧栏上的样子:颜色用 VS Code 源代码管理的 git 装饰色(主题色 id),徽标就是字母本身。 */ +export const CHANGE_STYLES: Record = { + M: { color: 'gitDecoration.modifiedResourceForeground', tooltip: 'PR 里改了' }, + A: { color: 'gitDecoration.addedResourceForeground', tooltip: 'PR 里新加的' }, + D: { color: 'gitDecoration.deletedResourceForeground', tooltip: 'PR 里删掉了' }, +} + +/** 改动的文件树上的一项。 */ +export interface ChangedEntry extends DirEntry { + /** 相对仓库根,`/` 分隔 */ + path: string + /** 文件怎么改的;目录没有 */ + change?: FileChange +} + +/** 按目录分好的改动:键是目录相对仓库根的路径(仓库根是 `''`),值是它下面一层的目录和文件。 */ +export type ChangedTree = ReadonlyMap + +/** + * PR 改过的文件 → 按目录分好的树:只有改过的文件和通往它们的目录,目录层级照留, + * 每层的排法和侧栏普通的树一样(目录在前、名字不区分大小写)。 + * 同一路径上一个文件换成了同名目录(删掉文件 `a`、新加 `a/b`)时两项都留着。 + */ +export function buildChangedTree(files: readonly { path: string, status: string }[]): ChangedTree { + const levels = new Map>([['', new Map()]]) + const add = (dir: string, entry: ChangedEntry): void => { + let level = levels.get(dir) + if (!level) + levels.set(dir, level = new Map()) + const key = `${entry.isDirectory ? 'dir' : 'file'}:${entry.name}` + if (!level.has(key)) + level.set(key, entry) + } + for (const file of files) { + const segments = file.path.split('/') + segments.forEach((name, index) => { + const dir = segments.slice(0, index).join('/') + const path = segments.slice(0, index + 1).join('/') + add(dir, index === segments.length - 1 + ? { name, path, isDirectory: false, change: changeOf(file.status) } + : { name, path, isDirectory: true }) + }) + } + return new Map([...levels].map(([dir, level]) => [dir, sortEntries([...level.values()])])) +} diff --git a/vscode/src/codeReader/review/reviewMode.ts b/vscode/src/codeReader/review/reviewMode.ts new file mode 100644 index 0000000..d5567e1 --- /dev/null +++ b/vscode/src/codeReader/review/reviewMode.ts @@ -0,0 +1,233 @@ +import type { Disposable, ExtensionContext, QuickPickItem, TreeView, WorkspaceFolder } from 'vscode' +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 * as path from 'node:path' +import { commands, Uri, window, workspace } from 'vscode' +import { ChangeDecorations } from './changeDecorations' +import { buildChangedTree, changeOf } from './changedFiles' +import { pickItemText, prNumberOf, reviewableIssue, reviewablePullRequest, reviewDescription, reviewMessage } from './reviewRules' +import { loadIssue, loadPullRequest, loadPullRequestFiles, requireRepo, reviewColumnIssues } from './reviewSource' +import { localWorktree, prepareWorktree } from './reviewWorktree' + +/** workspaceState:正在审查的工单号,重开窗口接着审查。 */ +const REVIEW_ISSUE_KEY = 'superpowers.codeReader.reviewIssue' +/** 上下文键:正在审查工单。标题栏的「显示全部」、行上的注释按钮按它显示 / 藏起来。 */ +const REVIEWING_CONTEXT = 'superpowers.codeReader.reviewing' + +/** + * 怎么进的审查,决定不能审查、出错时怎么收场: + * pick 选工单(说一声,什么都不变)、reload 刷新(说一声,回到全部文件)、restore 重开窗口接着审查(悄悄回到全部文件)。 + */ +type How = 'pick' | 'reload' | 'restore' + +/** 进审查要的东西都备好了:工单、它的 PR、PR 改过的文件、装着 PR 最新代码的工作副本。 */ +interface Loaded { + issue: Issue + pr: number + files: PrFileDto[] + worktree: string +} + +interface IssuePick extends QuickPickItem { + issue: Issue +} + +function errorText(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** + * 侧栏按审查工单筛文件:「选工单」列出看板「审查」列的工单,选中后树里只剩这张工单的 PR 改过的文件(按改动着色), + * 点开的是本机这张工单的工作副本里 PR 最新的代码;「显示全部」回到工作区文件树。选的工单按工作区记住。 + */ +export class ReviewMode implements Disposable { + private readonly decorations = new ChangeDecorations() + private readonly disposables: Disposable[] + /** 正在审查的工单号 */ + private reviewing: number | undefined + /** 每开始一次进审查、退出审查都加一:慢的那次回来时已经不是最新的,结果丢掉。 */ + private generation = 0 + + /** viewId 是侧栏视图的 id:进审查时在这个视图上转进度。 */ + constructor( + private readonly context: ExtensionContext, + private readonly viewId: string, + private readonly view: TreeView, + private readonly files: FileTreeProvider, + ) { + this.disposables = [this.decorations, window.registerFileDecorationProvider(this.decorations)] + // 视图第一次露出来时才接着上次的审查:没打开侧栏就不去拉 PR、动工作副本 + if (view.visible) { + void this.restore() + } + else { + const shown = view.onDidChangeVisibility(({ visible }) => { + if (!visible) + return + shown.dispose() + void this.restore() + }) + this.disposables.push(shown) + } + } + + get active(): boolean { + return this.reviewing !== undefined + } + + /** 「选工单」:顶部弹出快速选择,读看板「审查」列的工单和各自改了几个文件时转圈。 */ + async pick(): Promise { + const picker = window.createQuickPick() + picker.title = '选工单 · 看板「审查」列' + picker.placeholder = '输入工单号或标题筛选' + picker.busy = true + let open = true + picker.onDidHide(() => { + open = false + picker.dispose() + }) + picker.onDidAccept(() => { + const chosen = picker.selectedItems[0] + if (!chosen) + return + picker.hide() + void this.review(chosen.issue, 'pick') + }) + picker.show() + 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 + } + catch (err) { + if (!open) + return + picker.hide() + void window.showErrorMessage(`读不到看板「审查」列:${errorText(err)}`) + } + } + + /** 「显示全部」:回到工作区文件树,不再记着这张工单。 */ + async exit(): Promise { + this.generation++ + this.reviewing = undefined + this.files.showChanges(undefined) + this.decorations.show([]) + 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 { + if (this.reviewing !== undefined) + await this.review(this.reviewing, 'reload') + } + + dispose(): void { + for (const disposable of this.disposables) + disposable.dispose() + } + + private async restore(): Promise { + const issueNumber = this.context.workspaceState.get(REVIEW_ISSUE_KEY) + if (issueNumber !== undefined) + await this.review(issueNumber, 'restore') + } + + /** 弹框里的一项:有 PR 的工单去读 PR 改了几个文件(各项一起读),读不到就写读不到。 */ + private async pickItem(repo: RepoContext, issue: Issue): Promise { + 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、备工作副本,都好了才换树;这期间视图上转进度,标题下面那行写正在做哪一步。 + * target 在选工单时是弹框里那张工单(刚读的),刷新、重开窗口时是工单号(重读)。 + */ + private async review(target: Issue | number, how: How): Promise { + const issueNumber = typeof target === 'number' ? target : target.number + const generation = ++this.generation + const latest = (): boolean => generation === this.generation + const before = this.view.message + let result: Loaded | { blocked: string } + try { + result = await window.withProgress({ location: { viewId: this.viewId } }, () => this.load(target, (text) => { + if (latest()) + this.view.message = text + })) + } + catch (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) { + this.view.message = before + if (how !== 'restore') + void window.showInformationMessage(result.blocked) + if (how !== 'pick') + await this.exit() + return + } + await this.show(result) + } + + private async load(target: Issue | number, step: (text: string) => void): Promise { + 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) + 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 loadPullRequestFiles(repo, pr) + const worktree = await prepareWorktree(this.context, repo, issue, { number: pr, headSha: reviewablePull.pull.headSha }, step) + return { issue, pr, files, worktree } + } + + private async show({ issue, pr, files, worktree }: Loaded): Promise { + const root = Uri.file(worktree) + // 工作副本当根,名字照写工作区文件夹的:看的还是这个仓库,只是 PR 最新的代码 + const folder: WorkspaceFolder = { uri: root, name: workspace.workspaceFolders?.[0]?.name ?? path.basename(worktree), index: 0 } + this.reviewing = issue.number + this.files.showChanges({ folder, tree: buildChangedTree(files) }) + this.decorations.show(files.map(file => ({ uri: Uri.joinPath(root, file.path), change: changeOf(file.status) }))) + this.view.description = reviewDescription(issue.number) + this.view.message = reviewMessage(issue, pr, files.length) + await Promise.all([ + commands.executeCommand('setContext', REVIEWING_CONTEXT, true), + this.context.workspaceState.update(REVIEW_ISSUE_KEY, issue.number), + ]) + } +} diff --git a/vscode/src/codeReader/review/reviewRules.test.ts b/vscode/src/codeReader/review/reviewRules.test.ts new file mode 100644 index 0000000..f53ec11 --- /dev/null +++ b/vscode/src/codeReader/review/reviewRules.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { inReviewColumn, pickItemText, 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') + expect(reviewMessage(issue, 447, 6)).toBe('#446 用户资料:上传头像,收紧非管理员改资料的权限 · PR #447 · 改了 6 个文件') + }) +}) + +describe('看板「审查」列', () => { + it('只要审查列的,工单号大的在前(和看板列里一样)', () => { + const issues = [ + { number: 438, column: 'review' as const }, + { number: 452, column: 'review' as const }, + { number: 451, column: 'in-progress' as const }, + { number: 446, column: 'review' as const }, + { number: 460, column: 'done' as const }, + ] + expect(inReviewColumn(issues).map(item => item.number)).toEqual([452, 446, 438]) + }) + + it('工单的 PR 号取 state JSON 里的正整数', () => { + expect(prNumberOf({ pr: '447' })).toBe(447) + expect(prNumberOf({})).toBeUndefined() + expect(prNumberOf({ pr: '' })).toBeUndefined() + expect(prNumberOf({ pr: '0' })).toBeUndefined() + expect(prNumberOf({ pr: 'abc' })).toBeUndefined() + }) +}) + +describe('还能不能审查(选中、刷新、重开窗口时判断)', () => { + it('在审查列、有 PR:带上工单和 PR 号', () => { + const current = { column: 'review' as const, pr: '447' } + expect(reviewableIssue(446, current)).toEqual({ issue: current, pr: 447 }) + }) + + 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: 'review' })).toEqual({ blocked: '#446 没有关联 PR' }) + }) + + it('关联的 PR 开着:带上它;没了、合并了、关了:给出原因', () => { + const open = { state: 'open', merged: false, headSha: 'abc' } + expect(reviewablePullRequest(447, open)).toEqual({ pull: open }) + expect(reviewablePullRequest(447, null)).toEqual({ blocked: 'PR #447 已经不存在' }) + expect(reviewablePullRequest(447, { state: 'closed', merged: true })).toEqual({ blocked: 'PR #447 已经合并' }) + expect(reviewablePullRequest(447, { state: 'closed', merged: false })).toEqual({ blocked: 'PR #447 已经关闭' }) + }) +}) diff --git a/vscode/src/codeReader/review/reviewRules.ts b/vscode/src/codeReader/review/reviewRules.ts new file mode 100644 index 0000000..f85a13e --- /dev/null +++ b/vscode/src/codeReader/review/reviewRules.ts @@ -0,0 +1,68 @@ +import type { Issue } from '../../gitea/types' + +/** 工单关联的 PR 号;state JSON 里没有、或不是正整数时没有。 */ +export function prNumberOf(issue: Pick): number | undefined { + const pr = Number(issue.pr) + return Number.isInteger(pr) && pr > 0 ? pr : undefined +} + +/** 看板「审查」列里的工单,照看板列里的次序(工单号大的在前)。 */ +export function inReviewColumn>(issues: readonly T[]): T[] { + return issues.filter(issue => issue.column === 'review').sort((a, b) => b.number - a.number) +} + +/** 这张工单(最新状态,工单已经不存在时 null)现在能不能审查:能时带上它和它的 PR 号,不能时给出原因(给人看的话)。 */ +export function reviewableIssue>(issueNumber: number, issue: T | null): { issue: T, pr: number } | { blocked: string } { + if (!issue) + return { blocked: `#${issueNumber} 已经不存在` } + if (issue.column !== 'review') + return { blocked: `#${issueNumber} 已经不在看板「审查」列` } + const pr = prNumberOf(issue) + if (pr === undefined) + return { blocked: `#${issueNumber} 没有关联 PR` } + return { issue, pr } +} + +/** 工单关联的 PR(现状,PR 已经不存在时 null)还能不能看:能时带上它,不能时给出原因。 */ +export function reviewablePullRequest(pr: number, pull: T | null): { pull: T } | { blocked: string } { + if (!pull) + return { blocked: `PR #${pr} 已经不存在` } + if (pull.merged) + return { blocked: `PR #${pr} 已经合并` } + if (pull.state !== 'open') + return { blocked: `PR #${pr} 已经关闭` } + 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, 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}` +} + +/** 审查时视图标题下面的一行(TreeView.message)。 */ +export function reviewMessage(issue: Pick, pr: number, fileCount: number): string { + return `#${issue.number} ${issue.title} · PR #${pr} · 改了 ${fileCount} 个文件` +} diff --git a/vscode/src/codeReader/review/reviewSource.ts b/vscode/src/codeReader/review/reviewSource.ts new file mode 100644 index 0000000..ff2d252 --- /dev/null +++ b/vscode/src/codeReader/review/reviewSource.ts @@ -0,0 +1,54 @@ +/** + * 审查工单要的看板数据,全走看板自己的那套:工单和所在列用看板的加载(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 { getPullRequest, GiteaApiError, listPullRequestFiles } from '../../gitea/api' +import { loadIssues, 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 { inReviewColumn } from './reviewRules' + +/** 当前工作区的 Gitea 仓库和 token;没有时抛出给人看的错。 */ +export async function requireRepo(context: ExtensionContext): Promise { + const repo = await resolveRepoContext(context) + if (!repo) + throw new Error('当前工作区没有 Gitea 仓库或未配置 token') + return repo +} + +/** 看板「审查」列里的工单(只含 Gitea 工单:PR 在 Gitea 上)。 */ +export async function reviewColumnIssues(context: ExtensionContext, repo: RepoContext): Promise { + const scope = context.workspaceState.get(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE + const { issues } = await loadIssues({ ...repo, scope }) + return inReviewColumn(overlayLocalIssueState(context, issues, repo.workspaceRoot)) +} + +/** 一张工单的最新状态;工单已经不存在时 null。 */ +export async function loadIssue(context: ExtensionContext, repo: RepoContext, issueNumber: number): Promise { + const issue = await loadSingleIssue({ ...repo, issueNumber }) + return issue && overlayLocalIssueState(context, [issue], repo.workspaceRoot)[0] +} + +/** PR 的现状;PR 已经不存在时 null。 */ +export async function loadPullRequest(repo: RepoContext, pr: number): Promise { + try { + return await getPullRequest({ ...repo, index: pr }) + } + catch (err) { + if (err instanceof GiteaApiError && err.status === 404) + return null + throw err + } +} + +/** PR 改过的全部文件。 */ +export function loadPullRequestFiles(repo: RepoContext, pr: number): Promise { + return listPullRequestFiles({ ...repo, index: pr }) +} diff --git a/vscode/src/codeReader/review/reviewWorktree.ts b/vscode/src/codeReader/review/reviewWorktree.ts new file mode 100644 index 0000000..a3a672f --- /dev/null +++ b/vscode/src/codeReader/review/reviewWorktree.ts @@ -0,0 +1,60 @@ +import type { ExtensionContext } from 'vscode' +import type { RepoContext } from '../../gitea/repoContext' +import type { Issue } from '../../gitea/types' +import { existsSync } from 'node:fs' +import { window } from 'vscode' +import { addDetachedWorktree, fastForwardWorktree, fetchPullRequestHead, resolveWorktreePath } from '../../git/worktree' +import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, reviewWorktreeSlug } from '../../git/worktreePath' +import { logger } from '../../logging/logger' +import { getSettings } from '../../settings/store' + +/** 审查时建的 worktree 放哪儿:和实施的 worktree 同一个目录模板,目录名 `review-<工单号>`。 */ +function reviewWorktreePath(context: ExtensionContext, workspaceRoot: string, issueNumber: number): string { + const template = getSettings(context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE + return resolveWorktreeDir(template, workspaceRoot, reviewWorktreeSlug(issueNumber)) +} + +/** 本机这张工单的工作副本:看板记着的 worktree(盘上还在)优先,其次是之前审查它时建的;都没有时 undefined。 */ +export function localWorktree(context: ExtensionContext, workspaceRoot: string, issue: Pick): string | undefined { + if (issue.worktreePath && issue.worktreeExists) + return resolveWorktreePath(issue.worktreePath, workspaceRoot) + const review = reviewWorktreePath(context, workspaceRoot, issue.number) + return existsSync(review) ? review : undefined +} + +/** + * 备好装着 PR 最新代码的工作副本,返回它的路径。 + * 已有的:拉 PR 最新的提交,不丢东西才快进;有没提交的改动、本地另有提交时照原样用。拉取、快进出错也照原样用,提醒一声。 + * 没有的:拉 PR 最新的提交,按目录模板建一个停在这个提交上、不挂分支的 worktree。 + * 每做一步用 step 报一声。 + */ +export async function prepareWorktree( + context: ExtensionContext, + repo: RepoContext, + issue: Pick, + pull: { number: number, headSha: string }, + step: (text: string) => void, +): Promise { + const existing = localWorktree(context, repo.workspaceRoot, issue) + if (existing) { + step(`正在把工作副本更新到 PR #${pull.number} 最新的提交…`) + try { + await fetchPullRequestHead(repo.workspaceRoot, pull.number) + const result = await fastForwardWorktree(existing, pull.headSha) + if (result === 'dirty' || result === 'diverged') { + const reason = result === 'dirty' ? '有没提交的改动' : '本地另有 PR 之外的提交' + logger.add({ level: 'info', source: 'codeReader', message: `审查 #${issue.number}:工作副本${reason},照原样用`, details: existing }) + } + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + void window.showWarningMessage(`没能把 #${issue.number} 的工作副本更新到 PR #${pull.number} 最新的提交,先看本机现有的:${message}`) + } + return existing + } + step(`正在拉取 PR #${pull.number} 的分支…`) + await fetchPullRequestHead(repo.workspaceRoot, pull.number) + const created = reviewWorktreePath(context, repo.workspaceRoot, issue.number) + await addDetachedWorktree({ workspaceRoot: repo.workspaceRoot, worktreePath: created, commit: pull.headSha }) + return created +} diff --git a/vscode/src/git/worktree.ts b/vscode/src/git/worktree.ts index 2c59114..df33113 100644 --- a/vscode/src/git/worktree.ts +++ b/vscode/src/git/worktree.ts @@ -166,6 +166,48 @@ export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise/head`,PR 的分支本机没有也拉得到。 */ +export async function fetchPullRequestHead(workspaceRoot: string, pr: number): Promise { + await runGit(workspaceRoot, ['fetch', 'origin', `refs/pull/${pr}/head`], `拉取 PR #${pr}`) +} + +/** + * 审查用的 worktree:停在 commit 上、不挂分支(detached),不和实施用的 feature 分支抢同一个分支名。 + * 先 prune 掉目录已经没了的登记,否则同一路径的目录被手动删过之后再建会被 git 拦下。 + */ +export async function addDetachedWorktree(opts: { workspaceRoot: string, worktreePath: string, commit: string }): Promise { + await runGit(opts.workspaceRoot, ['worktree', 'prune'], 'git worktree prune') + await fsp.mkdir(path.dirname(opts.worktreePath), { recursive: true }) + await runGit(opts.workspaceRoot, ['worktree', 'add', '--detach', opts.worktreePath, opts.commit], 'git worktree add') +} + +/** 快进 worktree 的结果:已快进 / 本来就含那个提交 / 有没提交的改动所以没动 / 本地另有提交快进不了所以没动。 */ +export type FastForwardResult = 'updated' | 'current' | 'dirty' | 'diverged' + +/** + * 把 worktree 快进到 commit(commit 要已经在本机,先 fetch)。只在不丢东西时动: + * 有没提交的改动(未跟踪的文件不算)、本地有 commit 之外的提交时原样不动。 + */ +export async function fastForwardWorktree(worktreePath: string, commit: string): Promise { + // 目录本身得是工作副本的根:只是某个仓库里的普通目录时,git -C 会一路找上去动到外面那个仓库 + const top = (await runGit(worktreePath, ['rev-parse', '--show-toplevel'])).trim() + if (await fsp.realpath(top) !== await fsp.realpath(worktreePath)) + throw new Error(`${worktreePath} 不是 git 工作副本的根目录`) + if ((await runGit(worktreePath, ['status', '--porcelain', '--untracked-files=no'])).trim()) + return 'dirty' + // 左边是 HEAD 有而 commit 没有的提交数,右边反过来 + const [ahead, behind] = (await runGit(worktreePath, ['rev-list', '--left-right', '--count', `HEAD...${commit}`])) + .trim() + .split(/\s+/) + .map(Number) + if (behind === 0) + return 'current' + if (ahead > 0) + return 'diverged' + await runGit(worktreePath, ['merge', '--ff-only', commit], 'git merge --ff-only') + return 'updated' +} + /** * 删除一个 worktree 目录并清掉 git 侧注册。 * diff --git a/vscode/src/git/worktreePath.ts b/vscode/src/git/worktreePath.ts index dce23ed..c36e6a6 100644 --- a/vscode/src/git/worktreePath.ts +++ b/vscode/src/git/worktreePath.ts @@ -24,6 +24,11 @@ export function slugFromBranch(branch: string): string { return branch.startsWith('feature/') ? branch.slice('feature/'.length) : branch } +/** 本机没有工单的 worktree 时,审查它的 PR 建的 worktree 用这个目录名:按工单号,和实施用的 `feature/` 目录分开。 */ +export function reviewWorktreeSlug(issueNumber: number): string { + return `review-${issueNumber}` +} + /** * 把 worktreeDirectory 模板展开成绝对路径。`~` 在这里展开:git/execFile 不经 shell, * 不会自己做 tilde 展开。 diff --git a/vscode/src/gitea/repoContext.ts b/vscode/src/gitea/repoContext.ts new file mode 100644 index 0000000..81198ca --- /dev/null +++ b/vscode/src/gitea/repoContext.ts @@ -0,0 +1,30 @@ +import type { ExtensionContext } from 'vscode' +import { workspace } from 'vscode' +import { getToken } from '../auth/secrets' +import { detectRepo } from '../git/remote' + +/** 当前工作区(第一个工作区文件夹)对应的 Gitea 仓库和 token。 */ +export interface RepoContext { + workspaceRoot: string + host: string + owner: string + repo: string + token: string +} + +/** + * 解析当前工作区的 Gitea 仓库与 token。没打开工作区、origin 不是 Gitea 仓库、没配 token + * 任一情况返回 undefined,调用方据此走空态/错误分支。 + */ +export async function resolveRepoContext(context: ExtensionContext): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) + return undefined + const remote = await detectRepo(workspaceRoot) + if (!remote) + return undefined + const token = await getToken(context, remote.host) + if (!token) + return undefined + return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token } +} diff --git a/vscode/src/panel/handlers/prFiles.ts b/vscode/src/panel/handlers/prFiles.ts index d0b5925..01bc6dc 100644 --- a/vscode/src/panel/handlers/prFiles.ts +++ b/vscode/src/panel/handlers/prFiles.ts @@ -13,38 +13,18 @@ import type { KanbanWebviewPanel } from '../KanbanPanel' import { accessSync, constants } from 'node:fs' import { createPatch } from 'diff' import { workspace } from 'vscode' -import { getToken } from '../../auth/secrets' import { resolveCommitProfileForRun } from '../../cc/commitProfile' import { parsePrDiffMode } from '../../cc/prDiffMode' import { resolveProfilePath } from '../../cc/profiles' import { spawnClaude } from '../../cc/spawnClaude' -import { detectRepo } from '../../git/remote' import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/api' +import { resolveRepoContext } from '../../gitea/repoContext' import { readStateJsonComment } from '../../gitea/stateJson' import { readFileSummaries, readSummarySession, writeFileSummary, writeSummarySession } from '../../sessions/prFileSummaryStore' import { readConfirmedSigs, writeConfirmedSigs } from '../../sessions/prReviewStore' import { getEffectivePrDiffSummaryProfilePath } from '../../settings/store' import { makeNonce } from '../../webview/html' -/** - * 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined, - * 调用方据此走空态/错误分支。 - */ -async function resolveRepoContext(panel: KanbanWebviewPanel): Promise< - { host: string, owner: string, repo: string, token: string } | undefined -> { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) - return undefined - const remote = await detectRepo(workspaceRoot) - if (!remote) - return undefined - const token = await getToken(panel.context, remote.host) - if (!token) - return undefined - return { host: remote.host, owner: remote.owner, repo: remote.repo, token } -} - /** 从工单的 state JSON 取 PR 号。无则返回 undefined(前端按空态处理,不算错误)。 */ async function resolvePrNumber( ctx: { host: string, owner: string, repo: string, token: string }, @@ -124,7 +104,7 @@ function fileSig(f: { status: string, additions: number, deletions: number }): s */ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: number): Promise { try { - const ctx = await resolveRepoContext(panel) + const ctx = await resolveRepoContext(panel.context) if (!ctx) { panel.postMessage({ type: 'pr-files/show', issueNumber, files: [], confirmed: [], summaries: {} }) return @@ -188,7 +168,7 @@ export async function handleGetPrFileDiff( args: { issueNumber: number, path: string, previousPath?: string }, ): Promise { try { - const ctx = await resolveRepoContext(panel) + const ctx = await resolveRepoContext(panel.context) if (!ctx) { panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' }) return @@ -277,7 +257,7 @@ export async function handleGeneratePrFileSummary( const { issueNumber, path: filePath } = args try { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - const ctx = await resolveRepoContext(panel) + const ctx = await resolveRepoContext(panel.context) if (!ctx || !workspaceRoot) { panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '当前工作区没有 Gitea 仓库或未配置 token' }) return diff --git a/vscode/test/git/reviewWorktree.test.ts b/vscode/test/git/reviewWorktree.test.ts new file mode 100644 index 0000000..945d88b --- /dev/null +++ b/vscode/test/git/reviewWorktree.test.ts @@ -0,0 +1,155 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, promises as fsp, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { addDetachedWorktree, fastForwardWorktree, fetchPullRequestHead } from '../../src/git/worktree' + +const gitEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + GIT_AUTHOR_NAME: 'review-test', + GIT_AUTHOR_EMAIL: 'review@test.local', + GIT_COMMITTER_NAME: 'review-test', + GIT_COMMITTER_EMAIL: 'review@test.local', +} + +function git(cwd: string, args: string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim() +} + +function commit(cwd: string, file: string, content: string): string { + writeFileSync(path.join(cwd, file), content) + git(cwd, ['add', file]) + git(cwd, ['commit', '-m', `edit ${file}`]) + return git(cwd, ['rev-parse', 'HEAD']) +} + +interface Fixture { root: string, peer: string, repo: string, worktree: string } + +/** origin(bare)上有 main,同事把 PR 的提交推到 Gitea 给 PR 留的 refs/pull/7/head;repo 是审查方的 clone,本机没有 PR 的分支。 */ +async function setup(): Promise { + const root = realpathSync(await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-review-'))) + const origin = path.join(root, 'origin.git') + git(root, ['init', '--bare', '-b', 'main', origin]) + const peer = path.join(root, 'peer') + git(root, ['clone', origin, peer]) + commit(peer, 'README.md', 'hello\n') + git(peer, ['push', '-u', 'origin', 'main']) + git(peer, ['checkout', '-b', 'feature/x']) + commit(peer, 'x.txt', 'x1\n') + git(peer, ['push', 'origin', 'HEAD:refs/pull/7/head']) + const repo = path.join(root, 'repo') + git(root, ['clone', origin, repo]) + return { root, peer, repo, worktree: path.join(root, 'wt', 'repo', 'review-6') } +} + +/** 同事在 PR 上又推了一个提交 */ +function pushToPullRequest(fx: Fixture, content: string): string { + const sha = commit(fx.peer, 'x.txt', content) + git(fx.peer, ['push', 'origin', 'HEAD:refs/pull/7/head']) + return sha +} + +let fx: Fixture | undefined +afterEach(() => { + if (fx) + rmSync(fx.root, { recursive: true, force: true }) + fx = undefined +}) + +async function reviewWorktree(fixture: Fixture): Promise { + await fetchPullRequestHead(fixture.repo, 7) + const head = git(fixture.peer, ['rev-parse', 'HEAD']) + await addDetachedWorktree({ workspaceRoot: fixture.repo, worktreePath: fixture.worktree, commit: head }) + return head +} + +describe('审查用的 worktree', () => { + it('从 refs/pull//head 拉 PR 的提交,建一个停在它上面、不挂分支的 worktree(父目录不在也建)', async () => { + fx = await setup() + const head = await reviewWorktree(fx) + expect(readFileSync(path.join(fx.worktree, 'x.txt'), 'utf8')).toBe('x1\n') + expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(head) + expect(git(fx.worktree, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('HEAD') + expect(git(fx.repo, ['branch', '--list'])).not.toContain('feature/x') + }) + + it('目录被手动删掉后同一路径能再建', async () => { + fx = await setup() + await reviewWorktree(fx) + rmSync(fx.worktree, { recursive: true, force: true }) + await reviewWorktree(fx) + expect(existsSync(path.join(fx.worktree, 'x.txt'))).toBe(true) + }) +}) + +describe('把 worktree 快进到 PR 最新的提交', () => { + it('同事在 PR 上又推了提交、工作副本干净:快进过去', async () => { + fx = await setup() + await reviewWorktree(fx) + const latest = pushToPullRequest(fx, 'x2\n') + await fetchPullRequestHead(fx.repo, 7) + expect(await fastForwardWorktree(fx.worktree, latest)).toBe('updated') + expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(latest) + expect(readFileSync(path.join(fx.worktree, 'x.txt'), 'utf8')).toBe('x2\n') + }) + + it('已经在最新的提交上:不动', async () => { + fx = await setup() + const head = await reviewWorktree(fx) + expect(await fastForwardWorktree(fx.worktree, head)).toBe('current') + }) + + it('只有未跟踪的文件:照样快进,文件留着', async () => { + fx = await setup() + await reviewWorktree(fx) + writeFileSync(path.join(fx.worktree, 'notes.txt'), 'mine\n') + const latest = pushToPullRequest(fx, 'x2\n') + await fetchPullRequestHead(fx.repo, 7) + expect(await fastForwardWorktree(fx.worktree, latest)).toBe('updated') + expect(readFileSync(path.join(fx.worktree, 'notes.txt'), 'utf8')).toBe('mine\n') + }) + + it('有没提交的改动:不动,改动留着', async () => { + fx = await setup() + const head = await reviewWorktree(fx) + writeFileSync(path.join(fx.worktree, 'x.txt'), 'local edit\n') + const latest = pushToPullRequest(fx, 'x2\n') + await fetchPullRequestHead(fx.repo, 7) + expect(await fastForwardWorktree(fx.worktree, latest)).toBe('dirty') + expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(head) + expect(readFileSync(path.join(fx.worktree, 'x.txt'), 'utf8')).toBe('local edit\n') + }) + + it('本地另有提交、PR 也往前走了:快进不了,不动', async () => { + fx = await setup() + await reviewWorktree(fx) + const local = commit(fx.worktree, 'local.txt', 'local\n') + const latest = pushToPullRequest(fx, 'x2\n') + await fetchPullRequestHead(fx.repo, 7) + expect(await fastForwardWorktree(fx.worktree, latest)).toBe('diverged') + expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(local) + }) + + it('目录不是工作副本的根(只是仓库里的普通目录):报错,外面的仓库不动', async () => { + fx = await setup() + await fetchPullRequestHead(fx.repo, 7) + const latest = git(fx.peer, ['rev-parse', 'HEAD']) + const before = git(fx.repo, ['rev-parse', 'HEAD']) + const inside = path.join(fx.repo, 'review-6') + await fsp.mkdir(inside) + await expect(fastForwardWorktree(inside, latest)).rejects.toThrow('不是 git 工作副本的根目录') + expect(git(fx.repo, ['rev-parse', 'HEAD'])).toBe(before) + }) + + it('本地在 PR 最新的提交之上还有提交:已经含着它,不动', async () => { + fx = await setup() + const head = await reviewWorktree(fx) + const local = commit(fx.worktree, 'local.txt', 'local\n') + expect(await fastForwardWorktree(fx.worktree, head)).toBe('current') + expect(git(fx.worktree, ['rev-parse', 'HEAD'])).toBe(local) + }) +})