From d342322d0e39c7b345845ef11bd5b90d8949048a Mon Sep 17 00:00:00 2001 From: wangbinteng Date: Sat, 18 Jul 2026 15:37:13 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E3=80=90AI=E3=80=91docs:=20add=20GitHub=20?= =?UTF-8?q?multiple=20installations=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-Generated: true AI-Author: wangbinteng AI-Team: multica-ai AI-Model: unknown AI-Agent: codex AI-Timestamp: 2026-07-18T07:37:13Z --- ...18-github-multiple-installations-design.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-18-github-multiple-installations-design.md diff --git a/docs/superpowers/specs/2026-07-18-github-multiple-installations-design.md b/docs/superpowers/specs/2026-07-18-github-multiple-installations-design.md new file mode 100644 index 00000000000..0f52d976a44 --- /dev/null +++ b/docs/superpowers/specs/2026-07-18-github-multiple-installations-design.md @@ -0,0 +1,73 @@ +# GitHub 设置页支持多个安装:设计规格 + +## 问题 + +GitHub 设置接口返回 `installations` 数组,数据库也允许同一个 Multica 工作区绑定多个 GitHub App installation。当前设置页却把数组折叠成了一个“已连接”状态:首次连接后隐藏连接入口,把所有账号名称拼成一句话,并让唯一的断开按钮固定操作 `installations[0]`。 + +结果是:用户无法通过正常界面添加第二个 installation,也无法断开除第一条以外的 installation。 + +## 范围 + +本次只修正共享设置页前端,不修改 GitHub App 回调、后端接口、数据库结构、installation 排序、工作区权限或 GitHub 功能开关。 + +改动范围: + +- `packages/views/settings/components/github-tab.tsx` +- `packages/views/settings/components/github-tab.test.tsx` +- 英文、简体中文、日文和韩文设置文案中的 GitHub 部分 + +## 用户体验 + +连接区域保留现有说明。存在 installation 时,不再把账号拼成一句话,而是把每条 installation 渲染为独立行。 + +每行展示: + +- GitHub 账号名称; +- 账号类型:个人或组织; +- 后端提供 `connected_by` 时,展示连接人; +- 对工作区所有者和管理员展示该行专属的“断开”操作。 + +部署已配置 GitHub 集成时,工作区所有者和管理员始终能看到连接操作。没有 installation 时显示“连接 GitHub”;已有一条或多条时显示“连接另一个 GitHub”。该操作继续调用现有签名连接 URL 接口,并在新标签页打开 GitHub App 安装流程。 + +没有管理权限的成员可以看到相同的 installation 列表,但看不到连接或断开操作,并继续看到现有只读提示。没有 installation 时,仍提示联系管理员连接。 + +即使工作区的 GitHub 功能总开关已关闭,断开操作仍然可用,因为“隐藏 GitHub 功能”和“解除 installation 绑定”是两个独立意图。 + +## 状态与数据流 + +React Query 返回值继续作为唯一服务端状态来源。组件遍历 `installationData.installations` 的全部条目,不再选取所谓的 primary installation。 + +断开确认框保存用户选中的 installation 行 ID,并从当前数组取得对应行,以便在确认文案中写明将断开的 GitHub 账号。确认后调用现有 `deleteGitHubInstallation(workspaceId, installationRowId)` 接口,使 GitHub 查询失效并重新获取;成功后关闭弹窗,沿用现有成功提示。 + +连接操作保留现有的加载态、部署配置检查、URL 检查、新标签页打开和错误处理,只是不再与“已连接”状态互斥。 + +## 错误与边界情况 + +- installation 数组为空时,展示现有未连接说明。 +- 缺少可选的 `connected_by` 时,只省略连接人信息。 +- 部署未配置 GitHub 集成时,即使已有 installation,也禁用连接操作并保留现有配置提示。 +- 断开失败时保留确认框和选中行,允许用户重试或取消。 +- 连接请求进行中时禁用连接按钮;断开请求进行中时禁用所有行的断开按钮,避免重复请求。 + +## 测试 + +组件测试通过真实渲染结果和现有 API mock 证明以下行为: + +1. 多个 installation 分别渲染为独立行,不再拼成逗号分隔的一句话。 +2. 所有者和管理员在已有 installation 时仍能看到“连接另一个 GitHub”。 +3. 每行都有自己的断开操作;确认第二行时,接口收到第二行 ID。 +4. 确认框明确显示当前选中的 GitHub 账号。 +5. 普通成员能看到全部行,但看不到任何管理操作。 +6. 单条 installation 仍能正常展示,并且 GitHub 总开关关闭时仍可断开。 +7. 现有的空状态、部署未配置、连接人、设置开关和代码仓库跳转测试继续通过。 + +完成前必须通过该组件的 Vitest 测试和 `packages/views` TypeScript 检查。浏览器验证使用本地工作区中两条模拟 installation,确认两行各自拥有断开操作,同时连接入口仍然存在。 + +## 不在本次范围 + +- 选择默认或主要 installation +- 跨 installation 去重 GitHub 账号 +- 在 Multica 中修改 GitHub App 的仓库访问范围 +- 在 GitHub 侧撤销 GitHub App 授权 +- 增加 installation 分页、排序或搜索 +- 改变一个 GitHub installation 绑定多个 Multica 工作区的机制 From d19a94aa0d58f39d7385e3484d7b78fcf05c1ef6 Mon Sep 17 00:00:00 2001 From: wangbinteng Date: Sat, 18 Jul 2026 15:40:02 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E3=80=90AI=E3=80=91docs:=20add=20GitHub=20?= =?UTF-8?q?multiple=20installations=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-Generated: true AI-Author: wangbinteng AI-Team: multica-ai AI-Model: unknown AI-Agent: codex AI-Timestamp: 2026-07-18T07:40:01Z --- ...026-07-18-github-multiple-installations.md | 422 ++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-18-github-multiple-installations.md diff --git a/docs/superpowers/plans/2026-07-18-github-multiple-installations.md b/docs/superpowers/plans/2026-07-18-github-multiple-installations.md new file mode 100644 index 00000000000..e3926ddd767 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-github-multiple-installations.md @@ -0,0 +1,422 @@ +# GitHub 设置页支持多个安装 Implementation Plan(实现计划) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 当前任务按用户要求在本会话内联执行。 + +**目标:** 让一个 Multica 工作区可以在设置页添加、查看并分别断开多个 GitHub App installation。 + +**架构:** 保持 React Query 返回的 `installations` 数组为唯一服务端状态来源,不增加 store 或后端接口。`GitHubTab` 直接遍历数组生成独立行,连接入口与数组是否为空解耦,断开确认框保存并提交被选中行的数据库 ID。 + +**技术栈:** React、TypeScript、TanStack Query、Vitest、Testing Library、项目现有 JSON i18n。 + +## 全局约束 + +- 只改共享前端 `packages/views`,不修改后端、数据库或 GitHub 回调。 +- 普通成员只能查看 installation;只有后端返回 `can_manage: true` 时才能连接或断开。 +- GitHub 总开关关闭时仍允许断开 installation。 +- 已有 installation 时连接按钮文案为“连接另一个 GitHub”;没有时仍为“连接 GitHub”。 +- 每一条断开请求必须使用该行的 `installation.id`,不得使用数组下标或 GitHub numeric installation ID。 +- 英文、简体中文、日文和韩文文案必须同步。 + +--- + +### 任务 1:用组件测试固定多 installation 行为 + +**文件:** + +- 修改:`packages/views/settings/components/github-tab.test.tsx` +- 测试:`packages/views/settings/components/github-tab.test.tsx` + +**接口:** + +- 输入:`installationsRef.current.installations` 中的多个 `{ id, account_login, account_type, installation_id, connected_by? }`。 +- 输出:独立账号行、“Connect another GitHub”按钮、`Disconnect ` 无障碍名称,以及精确的 `deleteGitHubInstallation(workspaceId, rowId)` 调用。 + +- [ ] **步骤 1:补充测试 fixture 的账号类型** + +```ts +installations: [] as { + id: string; + account_login: string; + account_type: "User" | "Organization"; + installation_id?: number; + connected_by?: string; +}[], +``` + +所有现有 installation fixture 都显式补上 `account_type`。 + +- [ ] **步骤 2:新增多行展示和持续连接入口测试** + +```ts +it("renders every installation separately and keeps Connect another available", () => { + installationsRef.current = { + configured: true, + can_manage: true, + installations: [ + { + id: "inst-user", + account_login: "octocat", + account_type: "User", + installation_id: 41, + }, + { + id: "inst-org", + account_login: "acme-org", + account_type: "Organization", + installation_id: 42, + connected_by: "Jiayuan", + }, + ], + }; + + render(, { wrapper: I18nWrapper }); + + expect(screen.getByText("octocat")).toBeTruthy(); + expect(screen.getByText("acme-org")).toBeTruthy(); + expect(screen.getByText("Personal account")).toBeTruthy(); + expect(screen.getByText("Organization")).toBeTruthy(); + expect(screen.queryByText(/octocat, acme-org/)).toBeNull(); + expect(screen.getByRole("button", { name: "Connect another GitHub" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Disconnect octocat" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Disconnect acme-org" })).toBeTruthy(); +}); +``` + +- [ ] **步骤 3:把断开测试改成验证第二行 ID 和账号确认文案** + +```ts +it("disconnects the selected installation row", async () => { + const user = userEvent.setup(); + installationsRef.current = { + configured: true, + can_manage: true, + installations: [ + { + id: "inst-first", + account_login: "octocat", + account_type: "User", + installation_id: 41, + }, + { + id: "inst-second", + account_login: "acme-org", + account_type: "Organization", + installation_id: 42, + }, + ], + }; + mockDeleteInstallation.mockResolvedValue(undefined); + + render(, { wrapper: I18nWrapper }); + + await user.click(screen.getByRole("button", { name: "Disconnect acme-org" })); + expect(screen.getByRole("heading", { name: "Disconnect acme-org?" })).toBeTruthy(); + expect(mockDeleteInstallation).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: /^Disconnect$/ })); + + await waitFor(() => { + expect(mockDeleteInstallation).toHaveBeenCalledWith("workspace-1", "inst-second"); + }); +}); +``` + +同步更新现有单条 installation、总开关关闭和只读测试,使其使用新的账号类型与 `Disconnect ` 无障碍名称。 + +- [ ] **步骤 4:运行测试并确认 RED** + +运行: + +```bash +pnpm --filter @multica/views test -- settings/components/github-tab.test.tsx +``` + +预期:测试因为找不到 “Connect another GitHub”、独立账号类型或 `Disconnect acme-org` 而失败;失败原因必须是功能尚未实现,而不是 fixture、导入或语法错误。 + +--- + +### 任务 2:实现逐行展示、持续连接入口和精确断开 + +**文件:** + +- 修改:`packages/views/settings/components/github-tab.tsx` +- 修改:`packages/views/locales/en/settings.json` +- 修改:`packages/views/locales/zh-Hans/settings.json` +- 修改:`packages/views/locales/ja/settings.json` +- 修改:`packages/views/locales/ko/settings.json` +- 测试:`packages/views/settings/components/github-tab.test.tsx` + +**接口:** + +- 消费:现有 `GitHubInstallation` 的 `id`、`account_login`、`account_type` 和可选 `connected_by`。 +- 产出:每行独立渲染;`disconnectTarget: string | null` 仍保存行 ID;`disconnectInstallation` 由当前数组按 ID 派生。 + +- [ ] **步骤 1:移除 primary installation 假设并派生选中行** + +```ts +const connected = installations.length > 0; +const disconnectInstallation = + installations.find((installation) => installation.id === disconnectTarget) ?? null; +``` + +删除 `primaryInstallation`,不引入默认 installation 或重新排序。 + +- [ ] **步骤 2:让连接按钮始终对管理员可见** + +```tsx +{canManage && ( + +)} +``` + +- [ ] **步骤 3:把 installation 数组渲染为独立行** + +```tsx +{connected && ( +
+ {installations.map((installation) => ( +
+
+

{installation.account_login}

+

+ {installation.account_type === "Organization" + ? t(($) => $.github.account_type_organization) + : t(($) => $.github.account_type_user)} +

+ {installation.connected_by && ( +

+ {t(($) => $.github.connected_by, { name: installation.connected_by })} +

+ )} +
+ {canManage && ( + + )} +
+ ))} +
+)} +``` + +连接说明位于列表上方;已连接时使用新的 `connected_installations` 文案,未连接时保留现有 PR 自动关联说明或联系管理员提示。 + +- [ ] **步骤 4:让确认框明确显示选中账号** + +```tsx + + + {t(($) => $.github.disconnect_confirm_title, { + login: disconnectInstallation?.account_login ?? "", + })} + + + {t(($) => $.github.disconnect_confirm_description, { + login: disconnectInstallation?.account_login ?? "", + })} + + +``` + +保留现有关闭控制、失败重试、成功后 query invalidation 和 toast 行为。 + +- [ ] **步骤 5:同步四种语言文案** + +英文键值: + +```json +"connected_installations": "Connected GitHub installations for this workspace.", +"account_type_user": "Personal account", +"account_type_organization": "Organization", +"connect_another_github": "Connect another GitHub", +"disconnect_account": "Disconnect {{login}}", +"disconnect_confirm_title": "Disconnect {{login}}?", +"disconnect_confirm_description": "Multica will stop receiving webhooks for the {{login}} installation. Existing PR mirrors and links are kept; reconnect any time to resume updates." +``` + +简体中文键值: + +```json +"connected_installations": "当前工作区已连接以下 GitHub App 安装。", +"account_type_user": "个人账号", +"account_type_organization": "组织", +"connect_another_github": "连接另一个 GitHub", +"disconnect_account": "断开 {{login}}", +"disconnect_confirm_title": "断开 {{login}}?", +"disconnect_confirm_description": "Multica 将不再接收 {{login}} 这条安装的 webhook。已有的 PR 镜像与关联会保留,再次连接即可恢复同步。" +``` + +日文键值: + +```json +"connected_installations": "このワークスペースには次の GitHub App インストールが接続されています。", +"account_type_user": "個人アカウント", +"account_type_organization": "組織", +"connect_another_github": "別の GitHub を接続", +"disconnect_account": "{{login}} の接続を解除", +"disconnect_confirm_title": "{{login}} の接続を解除しますか?", +"disconnect_confirm_description": "Multica は {{login}} インストールの Webhook 受信を停止します。既存の PR ミラーとリンクは保持され、再接続すると更新を再開できます。" +``` + +韩文键值: + +```json +"connected_installations": "이 워크스페이스에 연결된 GitHub App 설치입니다.", +"account_type_user": "개인 계정", +"account_type_organization": "조직", +"connect_another_github": "다른 GitHub 연결", +"disconnect_account": "{{login}} 연결 해제", +"disconnect_confirm_title": "{{login}} 연결을 해제할까요?", +"disconnect_confirm_description": "Multica가 {{login}} 설치의 Webhook 수신을 중단합니다. 기존 PR 미러와 연결은 유지되며, 다시 연결하면 업데이트를 재개할 수 있습니다." +``` + +- [ ] **步骤 6:运行聚焦测试并确认 GREEN** + +运行: + +```bash +pnpm --filter @multica/views test -- settings/components/github-tab.test.tsx +``` + +预期:该文件全部测试通过,0 个失败。 + +- [ ] **步骤 7:提交功能改动** + +仅暂存上述组件、测试和四个 locale 文件,通过 `git-guard pre-commit` 后提交: + +```bash +git commit -m "fix(settings): support multiple GitHub installations" +``` + +--- + +### 任务 3:自动化验证与浏览器回归 + +**文件:** + +- 验证:`packages/views/settings/components/github-tab.test.tsx` +- 验证:`packages/views/settings/components/github-tab.tsx` +- 验证:四个 `packages/views/locales/*/settings.json` + +**接口:** + +- 自动化输入:`@multica/views` 的单测与 TypeScript 编译。 +- 浏览器输入:本地 `github-5599` 工作区中已存在的两条模拟 installation。 +- 输出:两个独立账号行、持续可见的连接入口、两个精确断开入口;测试期间不确认断开,保留两条本地数据。 + +- [ ] **步骤 1:运行完整 views 单测** + +```bash +pnpm --filter @multica/views test +``` + +预期:全部测试通过,0 个失败。 + +- [ ] **步骤 2:运行 views 类型检查** + +```bash +pnpm --filter @multica/views typecheck +``` + +预期:退出码 0,无 TypeScript 错误。 + +- [ ] **步骤 3:检查 JSON 与 diff** + +```bash +node -e 'for (const f of process.argv.slice(1)) JSON.parse(require("node:fs").readFileSync(f, "utf8"))' packages/views/locales/en/settings.json packages/views/locales/zh-Hans/settings.json packages/views/locales/ja/settings.json packages/views/locales/ko/settings.json +git diff --check +``` + +预期:两个命令退出码均为 0。 + +- [ ] **步骤 4:使用 @电脑 做本地浏览器回归** + +在 `http://localhost:3000/github-5599/settings?tab=github` 验证: + +1. `debug-user-installation` 和 `debug-org-installation` 各占一行; +2. 两行分别显示“个人账号”和“组织”; +3. 页面存在“连接另一个 GitHub”; +4. 存在“断开 debug-user-installation”和“断开 debug-org-installation”两个无障碍操作; +5. 打开第二行确认框后,标题包含 `debug-org-installation`; +6. 取消确认框,不删除本地 installation。 + +- [ ] **步骤 5:提交验证后产生的必要修正** + +如果浏览器验证发现问题,先新增或调整失败测试,再按 RED-GREEN 修正并重新执行任务 3 的全部验证。若没有代码修正,则不创建空提交。 + +--- + +### 任务 4:推送并创建 Pull Request + +**文件:** + +- 审查:本分支相对 `origin/main` 的全部提交和 diff。 + +**接口:** + +- 输入:已通过任务 3 全部验证的本地分支。 +- 输出:远端分支和一个指向 `multica-ai/multica` 的 Pull Request。 + +- [ ] **步骤 1:完成提交前审计** + +```bash +git status --short +git diff origin/main...HEAD --stat +git log --oneline origin/main..HEAD +``` + +预期:只包含 #5599 的规格、组件、测试和四个 locale 文件;`.git-safe-ops.json` 与其他既有未跟踪文件不进入提交。 + +- [ ] **步骤 2:运行推送安全检查并推送** + +```bash +git-guard pre-push --branch fix/5599-github-multi-installations +git push -u origin fix/5599-github-multi-installations +``` + +预期:安全检查通过,远端分支创建成功。 + +- [ ] **步骤 3:创建 PR** + +```bash +gh pr create \ + --base main \ + --head fix/5599-github-multi-installations \ + --title "fix(settings): support multiple GitHub installations" \ + --body '## Summary +- render every GitHub App installation as an independently managed row +- keep the connect flow available so admins can add another installation +- disconnect the selected row and identify its account in the confirmation dialog + +## Verification +- `pnpm --filter @multica/views test` +- `pnpm --filter @multica/views typecheck` +- local Chrome regression with two seeded installations + +Closes #5599' +``` + +PR 正文必须包含:问题根因、前端修复范围、权限行为、单测/类型检查/浏览器验证结果,并使用 `Closes #5599` 自动关联 issue。 From f893a6e7ee318ad153c3923b056ab2c4b493ac45 Mon Sep 17 00:00:00 2001 From: wangbinteng Date: Sat, 18 Jul 2026 15:52:42 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E3=80=90AI=E3=80=91fix(settings):=20suppor?= =?UTF-8?q?t=20multiple=20GitHub=20installations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-Generated: true AI-Author: wangbinteng AI-Team: multica-ai AI-Model: unknown AI-Agent: codex AI-Timestamp: 2026-07-18T07:52:42Z --- packages/views/locales/en/settings.json | 9 +- packages/views/locales/ja/settings.json | 9 +- packages/views/locales/ko/settings.json | 9 +- packages/views/locales/zh-Hans/settings.json | 9 +- .../settings/components/github-tab.test.tsx | 83 ++++++++++--- .../views/settings/components/github-tab.tsx | 117 +++++++++++------- 6 files changed, 166 insertions(+), 70 deletions(-) diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 1da9dc61fc1..07b53e36565 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -416,13 +416,18 @@ "connection_description_done": "Done", "connection_identifier_example": "MUL-123", "connected_to": "Connected to {{login}}", + "connected_installations": "Connected GitHub installations for this workspace.", + "account_type_user": "Personal account", + "account_type_organization": "Organization", "connect_github": "Connect GitHub", + "connect_another_github": "Connect another GitHub", "connect_opening": "Opening…", "connect_disabled_tooltip": "GitHub App is not configured on this server", "disconnect": "Disconnect", + "disconnect_account": "Disconnect {{login}}", "disconnecting": "Disconnecting…", - "disconnect_confirm_title": "Disconnect GitHub App", - "disconnect_confirm_description": "Multica will stop receiving webhooks for this installation. Existing PR mirrors and links are kept; reconnect any time to resume updates.", + "disconnect_confirm_title": "Disconnect {{login}}?", + "disconnect_confirm_description": "Multica will stop receiving webhooks for the {{login}} installation. Existing PR mirrors and links are kept; reconnect any time to resume updates.", "disconnect_confirm_cancel": "Cancel", "disconnect_confirm_action": "Disconnect", "not_configured": "GitHub integration is not configured for this deployment. Operators must set", diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index 9775c159410..4ec3b02798f 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -408,13 +408,18 @@ "connection_description_done": "完了", "connection_identifier_example": "MUL-123", "connected_to": "{{login}} に接続済み", + "connected_installations": "このワークスペースには次の GitHub App インストールが接続されています。", + "account_type_user": "個人アカウント", + "account_type_organization": "組織", "connect_github": "GitHub を接続", + "connect_another_github": "別の GitHub を接続", "connect_opening": "開いています…", "connect_disabled_tooltip": "このサーバーには GitHub App が設定されていません", "disconnect": "接続解除", + "disconnect_account": "{{login}} の接続を解除", "disconnecting": "接続解除中…", - "disconnect_confirm_title": "GitHub App の接続を解除", - "disconnect_confirm_description": "Multica はこのインストールの Webhook 受信を停止します。既存の PR ミラーとリンクは保持され、いつでも再接続して更新を再開できます。", + "disconnect_confirm_title": "{{login}} の接続を解除しますか?", + "disconnect_confirm_description": "Multica は {{login}} インストールの Webhook 受信を停止します。既存の PR ミラーとリンクは保持され、再接続すると更新を再開できます。", "disconnect_confirm_cancel": "キャンセル", "disconnect_confirm_action": "接続解除", "not_configured": "このデプロイには GitHub 連携が設定されていません。運営者が次の値を設定する必要があります:", diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index f14aafa549a..cfa244f872d 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -408,13 +408,18 @@ "connection_description_done": "완료", "connection_identifier_example": "MUL-123", "connected_to": "{{login}}에 연결됨", + "connected_installations": "이 워크스페이스에 연결된 GitHub App 설치입니다.", + "account_type_user": "개인 계정", + "account_type_organization": "조직", "connect_github": "GitHub 연결", + "connect_another_github": "다른 GitHub 연결", "connect_opening": "여는 중...", "connect_disabled_tooltip": "이 서버에 GitHub App이 설정되어 있지 않습니다", "disconnect": "연결 해제", + "disconnect_account": "{{login}} 연결 해제", "disconnecting": "연결 해제 중...", - "disconnect_confirm_title": "GitHub App 연결 해제", - "disconnect_confirm_description": "Multica가 이 설치의 Webhook 수신을 중단합니다. 기존 PR 미러와 연결은 유지되며, 언제든 다시 연결해 업데이트를 재개할 수 있습니다.", + "disconnect_confirm_title": "{{login}} 연결을 해제할까요?", + "disconnect_confirm_description": "Multica가 {{login}} 설치의 Webhook 수신을 중단합니다. 기존 PR 미러와 연결은 유지되며, 다시 연결하면 업데이트를 재개할 수 있습니다.", "disconnect_confirm_cancel": "취소", "disconnect_confirm_action": "연결 해제", "not_configured": "이 배포에는 GitHub 연동이 설정되어 있지 않습니다. 운영자가 다음 값을 설정해야 합니다:", diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index 74dfab00b57..d10f29b8556 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -415,13 +415,18 @@ "connection_description_done": "已完成", "connection_identifier_example": "MUL-123", "connected_to": "已连接到 {{login}}", + "connected_installations": "当前工作区已连接以下 GitHub App 安装。", + "account_type_user": "个人账号", + "account_type_organization": "组织", "connect_github": "连接 GitHub", + "connect_another_github": "连接另一个 GitHub", "connect_opening": "正在打开…", "connect_disabled_tooltip": "服务端未配置 GitHub App", "disconnect": "断开", + "disconnect_account": "断开 {{login}}", "disconnecting": "断开中…", - "disconnect_confirm_title": "断开 GitHub App", - "disconnect_confirm_description": "Multica 将不再接收该安装的 webhook。已有的 PR 镜像与关联会保留,再次连接即可恢复同步。", + "disconnect_confirm_title": "断开 {{login}}?", + "disconnect_confirm_description": "Multica 将不再接收 {{login}} 这条安装的 webhook。已有的 PR 镜像与关联会保留,再次连接即可恢复同步。", "disconnect_confirm_cancel": "取消", "disconnect_confirm_action": "断开", "not_configured": "当前部署没有配置 GitHub 集成。运维需要设置", diff --git a/packages/views/settings/components/github-tab.test.tsx b/packages/views/settings/components/github-tab.test.tsx index f6c59d641b8..585b1e806d8 100644 --- a/packages/views/settings/components/github-tab.test.tsx +++ b/packages/views/settings/components/github-tab.test.tsx @@ -32,6 +32,7 @@ const installationsRef = vi.hoisted(() => ({ installations: [] as { id: string; account_login: string; + account_type: "User" | "Organization"; installation_id?: number; connected_by?: string; }[], @@ -194,28 +195,71 @@ describe("GitHubTab", () => { }); }); - it("clicking Disconnect opens the confirmation and only fires on confirm", async () => { + it("renders every installation separately and keeps Connect another available", () => { + installationsRef.current = { + configured: true, + can_manage: true, + installations: [ + { + id: "inst-user", + account_login: "octocat", + account_type: "User", + installation_id: 41, + }, + { + id: "inst-org", + account_login: "acme-org", + account_type: "Organization", + installation_id: 42, + connected_by: "Jiayuan", + }, + ], + }; + + render(, { wrapper: I18nWrapper }); + + expect(screen.getByText("octocat")).toBeTruthy(); + expect(screen.getByText("acme-org")).toBeTruthy(); + expect(screen.getByText("Personal account")).toBeTruthy(); + expect(screen.getByText("Organization")).toBeTruthy(); + expect(screen.queryByText(/octocat, acme-org/)).toBeNull(); + expect(screen.getByRole("button", { name: "Connect another GitHub" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Disconnect octocat" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Disconnect acme-org" })).toBeTruthy(); + }); + + it("disconnects the selected installation row", async () => { const user = userEvent.setup(); installationsRef.current = { configured: true, can_manage: true, - installations: [{ id: "inst-42", account_login: "acme", installation_id: 42 }], + installations: [ + { + id: "inst-first", + account_login: "octocat", + account_type: "User", + installation_id: 41, + }, + { + id: "inst-second", + account_login: "acme-org", + account_type: "Organization", + installation_id: 42, + }, + ], }; mockDeleteInstallation.mockResolvedValue(undefined); render(, { wrapper: I18nWrapper }); - await user.click(screen.getByRole("button", { name: /^Disconnect$/ })); - expect(screen.getByText(/Multica will stop receiving webhooks/i)).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Disconnect acme-org" })); + expect(screen.getByRole("heading", { name: "Disconnect acme-org?" })).toBeTruthy(); expect(mockDeleteInstallation).not.toHaveBeenCalled(); - const dialogConfirm = screen - .getAllByRole("button", { name: /^Disconnect$/ }) - .find((b) => b.getAttribute("data-slot")?.includes("alert-dialog")); - await user.click(dialogConfirm ?? screen.getAllByRole("button", { name: /^Disconnect$/ })[1]!); + await user.click(screen.getByRole("button", { name: /^Disconnect$/ })); await waitFor(() => { - expect(mockDeleteInstallation).toHaveBeenCalledWith("workspace-1", "inst-42"); + expect(mockDeleteInstallation).toHaveBeenCalledWith("workspace-1", "inst-second"); }); }); @@ -224,10 +268,17 @@ describe("GitHubTab", () => { installationsRef.current = { configured: true, can_manage: true, - installations: [{ id: "inst-1", account_login: "acme", installation_id: 1 }], + installations: [ + { + id: "inst-1", + account_login: "acme", + account_type: "Organization", + installation_id: 1, + }, + ], }; render(, { wrapper: I18nWrapper }); - expect(screen.getByRole("button", { name: /^Disconnect$/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Disconnect acme" })).toBeTruthy(); }); it("non-admin sees the existing connection but no Connect/Disconnect controls", () => { @@ -235,14 +286,15 @@ describe("GitHubTab", () => { installationsRef.current = { configured: true, can_manage: false, - installations: [{ id: "inst-1", account_login: "acme" }], + installations: [{ id: "inst-1", account_login: "acme", account_type: "User" }], }; render(, { wrapper: I18nWrapper }); - expect(screen.getByText(/Connected to acme/i)).toBeTruthy(); + expect(screen.getByText("acme")).toBeTruthy(); + expect(screen.getByText("Personal account")).toBeTruthy(); expect(screen.getByText(/Read-only view\./i)).toBeTruthy(); - expect(screen.queryByRole("button", { name: /^Connect GitHub$/ })).toBeNull(); - expect(screen.queryByRole("button", { name: /^Disconnect$/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Connect/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Disconnect/ })).toBeNull(); }); it("non-admin with no connection sees the contact-admin hint", () => { @@ -266,6 +318,7 @@ describe("GitHubTab", () => { { id: "inst-7", account_login: "acme", + account_type: "Organization", installation_id: 7, connected_by: "Jiayuan", }, diff --git a/packages/views/settings/components/github-tab.tsx b/packages/views/settings/components/github-tab.tsx index a1ed4b6480a..09c809a7307 100644 --- a/packages/views/settings/components/github-tab.tsx +++ b/packages/views/settings/components/github-tab.tsx @@ -63,13 +63,14 @@ export function GitHubTab() { const configured = installationData?.configured ?? false; const canManage = installationData?.can_manage === true; const connected = installations.length > 0; - const primaryInstallation = installations[0] ?? null; const flags = deriveGitHubSettings(workspace); const [savingKey, setSavingKey] = useState(null); const [connecting, setConnecting] = useState(false); const [disconnectTarget, setDisconnectTarget] = useState(null); const [disconnecting, setDisconnecting] = useState(false); + const disconnectInstallation = + installations.find((installation) => installation.id === disconnectTarget) ?? null; async function persistSetting(key: SettingsKey, next: boolean) { if (!workspace || savingKey) return; @@ -173,20 +174,9 @@ export function GitHubTab() {

{t(($) => $.github.connection_title)}

{connected ? ( - <> -

- {t(($) => $.github.connected_to, { - login: installations.map((i) => i.account_login).join(", "), - })} -

- {primaryInstallation?.connected_by && ( -

- {t(($) => $.github.connected_by, { - name: primaryInstallation.connected_by!, - })} -

- )} - +

+ {t(($) => $.github.connected_installations)} +

) : canManage ? (

{t(($) => $.github.connection_description_prefix)}{" "} @@ -204,38 +194,67 @@ export function GitHubTab() {

{canManage && ( -
- {connected && primaryInstallation ? ( - // Disconnect must stay reachable even when the master switch - // is off — disconnect is a separate intent (revoke the App - // grant) from hiding the feature. - - ) : ( - - )} -
+ )} + {connected && ( +
+ {installations.map((installation) => ( +
+
+

+ {installation.account_login} +

+

+ {installation.account_type === "Organization" + ? t(($) => $.github.account_type_organization) + : t(($) => $.github.account_type_user)} +

+ {installation.connected_by && ( +

+ {t(($) => $.github.connected_by, { + name: installation.connected_by, + })} +

+ )} +
+ {canManage && ( + + )} +
+ ))} +
+ )} + {canManage && !configured && (

{t(($) => $.github.not_configured)}{" "} @@ -329,7 +348,7 @@ export function GitHubTab() { { if (!v && !disconnecting) setDisconnectTarget(null); }} @@ -337,10 +356,14 @@ export function GitHubTab() { - {t(($) => $.github.disconnect_confirm_title)} + {t(($) => $.github.disconnect_confirm_title, { + login: disconnectInstallation?.account_login ?? "", + })} - {t(($) => $.github.disconnect_confirm_description)} + {t(($) => $.github.disconnect_confirm_description, { + login: disconnectInstallation?.account_login ?? "", + })} From 0785dce79feb4fae8e7fafc08eeb0a2da0ac1278 Mon Sep 17 00:00:00 2001 From: wangbinteng Date: Sat, 18 Jul 2026 16:37:27 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E3=80=90AI=E3=80=91fix(github):=20harden?= =?UTF-8?q?=20installation=20list=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-Generated: true AI-Author: wangbinteng AI-Team: multica-ai AI-Model: unknown AI-Agent: codex AI-Timestamp: 2026-07-18T08:37:27Z --- ...026-07-18-github-multiple-installations.md | 43 +++++- ...18-github-multiple-installations-design.md | 12 +- packages/core/api/client.ts | 12 +- packages/core/api/schemas.test.ts | 35 +++++ packages/core/api/schemas.ts | 26 ++++ packages/core/types/github.ts | 4 +- packages/views/locales/en/settings.json | 1 + packages/views/locales/ja/settings.json | 1 + packages/views/locales/ko/settings.json | 1 + packages/views/locales/zh-Hans/settings.json | 1 + .../settings/components/github-tab.test.tsx | 130 ++++++++++++++++-- .../views/settings/components/github-tab.tsx | 50 ++++--- server/internal/handler/github.go | 17 ++- server/internal/handler/github_test.go | 23 +++- server/pkg/db/generated/github.sql.go | 38 ++++- server/pkg/db/queries/github.sql | 18 ++- 16 files changed, 360 insertions(+), 52 deletions(-) diff --git a/docs/superpowers/plans/2026-07-18-github-multiple-installations.md b/docs/superpowers/plans/2026-07-18-github-multiple-installations.md index e3926ddd767..6402b75620f 100644 --- a/docs/superpowers/plans/2026-07-18-github-multiple-installations.md +++ b/docs/superpowers/plans/2026-07-18-github-multiple-installations.md @@ -4,18 +4,20 @@ **目标:** 让一个 Multica 工作区可以在设置页添加、查看并分别断开多个 GitHub App installation。 -**架构:** 保持 React Query 返回的 `installations` 数组为唯一服务端状态来源,不增加 store 或后端接口。`GitHubTab` 直接遍历数组生成独立行,连接入口与数组是否为空解耦,断开确认框保存并提交被选中行的数据库 ID。 +**架构:** 保持 React Query 返回的 `installations` 数组为唯一服务端状态来源,不增加 store 或新后端接口。`GitHubTab` 直接遍历数组生成独立行,连接入口与数组是否为空解耦,断开确认框保存并提交被选中行的数据库 ID。现有列表查询 enrich `connected_by`,核心 API 客户端以宽松 Zod schema 解析响应。 -**技术栈:** React、TypeScript、TanStack Query、Vitest、Testing Library、项目现有 JSON i18n。 +**技术栈:** React、TypeScript、TanStack Query、Zod、Vitest、Testing Library、Go、sqlc、项目现有 JSON i18n。 ## 全局约束 -- 只改共享前端 `packages/views`,不修改后端、数据库或 GitHub 回调。 +- 不新增端点、迁移或 GitHub 回调;允许修正现有列表查询/响应,使 `connected_by` 与 API 兼容解析满足生产契约。 - 普通成员只能查看 installation;只有后端返回 `can_manage: true` 时才能连接或断开。 - GitHub 总开关关闭时仍允许断开 installation。 - 已有 installation 时连接按钮文案为“连接另一个 GitHub”;没有时仍为“连接 GitHub”。 - 每一条断开请求必须使用该行的 `installation.id`,不得使用数组下标或 GitHub numeric installation ID。 - 英文、简体中文、日文和韩文文案必须同步。 +- 每行展示 `account_avatar_url`,空值或加载失败时使用 GitHub 图标 fallback。 +- 未知 `account_type` 必须明确显示为未知,不得误标为个人账号。 --- @@ -313,17 +315,38 @@ git commit -m "fix(settings): support multiple GitHub installations" --- +### 任务 2.5:修复独立审查发现的生产契约缺口 + +**文件:** + +- 修改:`server/pkg/db/queries/github.sql`、`server/pkg/db/generated/github.sql.go` +- 修改:`server/internal/handler/github.go`、`server/internal/handler/github_test.go` +- 修改:`packages/core/types/github.ts` +- 修改:`packages/core/api/schemas.ts`、`packages/core/api/schemas.test.ts`、`packages/core/api/client.ts` +- 修改:GitHub 设置组件、测试与四语文案 + +- [ ] 列表查询左连接连接用户名称,handler 返回可选 `connected_by`,并用 handler 测试固定头像与连接人响应。 +- [ ] 使用 sqlc v1.31.1 生成查询代码,不手改生成文件。 +- [ ] 为 installation 列表添加宽松 Zod schema;保留未来账号类型,损坏响应降级为空列表。 +- [ ] 每行展示头像与 fallback,未知账号类型显示明确文案,连接区在窄屏改为纵向布局。 +- [ ] 增加头像、未知类型、失败断开、普通成员多行可见和 API 损坏响应测试。 + +--- + ### 任务 3:自动化验证与浏览器回归 **文件:** - 验证:`packages/views/settings/components/github-tab.test.tsx` - 验证:`packages/views/settings/components/github-tab.tsx` +- 验证:`packages/core/api/schemas.test.ts` 与 `packages/core` TypeScript +- 验证:`server/internal/handler/github_test.go` - 验证:四个 `packages/views/locales/*/settings.json` **接口:** - 自动化输入:`@multica/views` 的单测与 TypeScript 编译。 +- 契约输入:`@multica/core` schema 单测/类型检查和 GitHub handler Go 测试。 - 浏览器输入:本地 `github-5599` 工作区中已存在的两条模拟 installation。 - 输出:两个独立账号行、持续可见的连接入口、两个精确断开入口;测试期间不确认断开,保留两条本地数据。 @@ -352,6 +375,16 @@ git diff --check 预期:两个命令退出码均为 0。 +- [ ] **步骤 3.5:运行 API schema 与 Go handler 验证** + +```bash +pnpm --filter @multica/core test +pnpm --filter @multica/core typecheck +go test ./internal/handler -run TestListGitHubInstallations_RoleGating -count=1 +``` + +预期:全部退出码为 0;handler 响应包含头像与连接人,损坏 API 响应安全降级。 + - [ ] **步骤 4:使用 @电脑 做本地浏览器回归** 在 `http://localhost:3000/github-5599/settings?tab=github` 验证: @@ -388,7 +421,7 @@ git diff origin/main...HEAD --stat git log --oneline origin/main..HEAD ``` -预期:只包含 #5599 的规格、组件、测试和四个 locale 文件;`.git-safe-ops.json` 与其他既有未跟踪文件不进入提交。 +预期:只包含 #5599 的规格、组件、核心 API schema、GitHub 列表响应、测试和四个 locale 文件;`.git-safe-ops.json` 与其他既有未跟踪文件不进入提交。 - [ ] **步骤 2:运行推送安全检查并推送** @@ -419,4 +452,4 @@ gh pr create \ Closes #5599' ``` -PR 正文必须包含:问题根因、前端修复范围、权限行为、单测/类型检查/浏览器验证结果,并使用 `Closes #5599` 自动关联 issue。 +PR 正文必须包含:问题根因、设置页与列表响应契约修复范围、权限行为、单测/类型检查/浏览器验证结果,并使用 `Closes #5599` 自动关联 issue。 diff --git a/docs/superpowers/specs/2026-07-18-github-multiple-installations-design.md b/docs/superpowers/specs/2026-07-18-github-multiple-installations-design.md index 0f52d976a44..c4a9701ce7f 100644 --- a/docs/superpowers/specs/2026-07-18-github-multiple-installations-design.md +++ b/docs/superpowers/specs/2026-07-18-github-multiple-installations-design.md @@ -8,12 +8,14 @@ GitHub 设置接口返回 `installations` 数组,数据库也允许同一个 M ## 范围 -本次只修正共享设置页前端,不修改 GitHub App 回调、后端接口、数据库结构、installation 排序、工作区权限或 GitHub 功能开关。 +本次以共享设置页为主,不新增 GitHub App 端点,不修改回调、数据库结构、installation 排序、工作区权限或 GitHub 功能开关。现有安装列表查询补充连接人显示名,核心 API 客户端为该响应增加兼容解析,确保 issue 描述中的 `account_avatar_url` 与 `connected_by` 在生产响应中真实可用。 改动范围: - `packages/views/settings/components/github-tab.tsx` - `packages/views/settings/components/github-tab.test.tsx` +- `packages/core/api` 中 GitHub installation 响应 schema 与客户端解析 +- `server/pkg/db/queries/github.sql`、对应 sqlc 生成文件和 GitHub handler - 英文、简体中文、日文和韩文设置文案中的 GitHub 部分 ## 用户体验 @@ -22,6 +24,7 @@ GitHub 设置接口返回 `installations` 数组,数据库也允许同一个 M 每行展示: +- GitHub 账号头像;无头像或加载失败时显示 GitHub 图标; - GitHub 账号名称; - 账号类型:个人或组织; - 后端提供 `connected_by` 时,展示连接人; @@ -37,6 +40,8 @@ GitHub 设置接口返回 `installations` 数组,数据库也允许同一个 M React Query 返回值继续作为唯一服务端状态来源。组件遍历 `installationData.installations` 的全部条目,不再选取所谓的 primary installation。 +列表响应在 `packages/core/api` 通过宽松 Zod schema 解析:未知的未来 `account_type` 字符串保留给 UI,并显示明确的“未知账号类型”;结构损坏的响应安全降级为空列表。服务端列表查询通过左连接读取 `connected_by_id` 对应用户的显示名,用户已不存在时省略连接人。 + 断开确认框保存用户选中的 installation 行 ID,并从当前数组取得对应行,以便在确认文案中写明将断开的 GitHub 账号。确认后调用现有 `deleteGitHubInstallation(workspaceId, installationRowId)` 接口,使 GitHub 查询失效并重新获取;成功后关闭弹窗,沿用现有成功提示。 连接操作保留现有的加载态、部署配置检查、URL 检查、新标签页打开和错误处理,只是不再与“已连接”状态互斥。 @@ -45,6 +50,8 @@ React Query 返回值继续作为唯一服务端状态来源。组件遍历 `ins - installation 数组为空时,展示现有未连接说明。 - 缺少可选的 `connected_by` 时,只省略连接人信息。 +- 缺少头像或头像加载失败时展示 GitHub 图标,不影响账号识别与操作。 +- 后端出现未来账号类型时展示“未知账号类型”,不误标为个人账号。 - 部署未配置 GitHub 集成时,即使已有 installation,也禁用连接操作并保留现有配置提示。 - 断开失败时保留确认框和选中行,允许用户重试或取消。 - 连接请求进行中时禁用连接按钮;断开请求进行中时禁用所有行的断开按钮,避免重复请求。 @@ -60,8 +67,9 @@ React Query 返回值继续作为唯一服务端状态来源。组件遍历 `ins 5. 普通成员能看到全部行,但看不到任何管理操作。 6. 单条 installation 仍能正常展示,并且 GitHub 总开关关闭时仍可断开。 7. 现有的空状态、部署未配置、连接人、设置开关和代码仓库跳转测试继续通过。 +8. 头像 URL 与空头像 fallback、未知账号类型、损坏 API 响应和服务端连接人 enrichment 均有回归覆盖。 -完成前必须通过该组件的 Vitest 测试和 `packages/views` TypeScript 检查。浏览器验证使用本地工作区中两条模拟 installation,确认两行各自拥有断开操作,同时连接入口仍然存在。 +完成前必须通过组件与 API schema 的 Vitest 测试、`packages/views`/`packages/core` TypeScript 检查及 GitHub handler Go 测试。浏览器验证使用本地工作区中两条模拟 installation,确认两行各自拥有头像 fallback 和断开操作,同时连接入口仍然存在。 ## 不在本次范围 diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 00971a7318d..e6327f1ed81 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -259,6 +259,8 @@ import { EMPTY_LABEL, EMPTY_LIST_LABELS_RESPONSE, EMPTY_RESOURCE_LABELS_RESPONSE, + ListGitHubInstallationsResponseSchema, + EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE, } from "./schemas"; /** Identifies the calling client to the server. @@ -2645,7 +2647,15 @@ export class ApiClient { } async listGitHubInstallations(workspaceId: string): Promise { - return this.fetch(`/api/workspaces/${workspaceId}/github/installations`); + const raw = await this.fetch( + `/api/workspaces/${workspaceId}/github/installations`, + ); + return parseWithFallback( + raw, + ListGitHubInstallationsResponseSchema, + EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE, + { endpoint: "GET /api/workspaces/:id/github/installations" }, + ); } async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise { diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index 712e2aaf2ed..29477e27eb6 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -20,6 +20,8 @@ import { InboxItemListSchema, InboxUnreadSummarySchema, IssueTriggerPreviewSchema, + ListGitHubInstallationsResponseSchema, + EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE, ListIssuesResponseSchema, ListPropertiesResponseSchema, SearchProjectsResponseSchema, @@ -34,6 +36,39 @@ import { } from "./schemas"; import { parseWithFallback } from "./schema"; +describe("ListGitHubInstallationsResponseSchema", () => { + const installation = { + id: "installation-1", + workspace_id: "workspace-1", + installation_id: 42, + account_login: "octocat", + account_type: "User", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", + }; + + it("preserves a future account type so the UI can render an explicit fallback", () => { + const parsed = ListGitHubInstallationsResponseSchema.parse({ + installations: [{ ...installation, account_type: "Enterprise" }], + configured: true, + can_manage: true, + }); + + expect(parsed.installations[0]?.account_type).toBe("Enterprise"); + }); + + it("falls back to an empty safe response for malformed installation arrays", () => { + expect( + parseWithFallback( + { installations: "not-an-array", configured: true, can_manage: true }, + ListGitHubInstallationsResponseSchema, + EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE, + { endpoint: "GET /api/workspaces/:id/github/installations" }, + ), + ).toEqual(EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE); + }); +}); + const baseIssue = { id: "11111111-1111-1111-1111-111111111111", workspace_id: "ws-1", diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 2b60a355421..176f21c89b3 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -26,6 +26,7 @@ import type { ListPropertiesResponse, IssuePropertiesResponse, ListIssuesResponse, + ListGitHubInstallationsResponse, ListLabelsResponse, ListWebhookDeliveriesResponse, NotificationPreferenceResponse, @@ -40,6 +41,31 @@ import type { import type { CloudRuntimeNode } from "../runtimes/cloud-runtime"; import type { CreateFeedbackResponse } from "../feedback/types"; +export const GitHubInstallationSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + installation_id: z.number().optional(), + account_login: z.string(), + // Keep server enums lenient so an installed desktop client can render an + // explicit unknown label when GitHub adds a new account type. + account_type: z.string(), + account_avatar_url: z.string().nullable().optional().default(null), + connected_by: z.string().optional(), + created_at: z.string(), +}).loose(); + +export const ListGitHubInstallationsResponseSchema = z.object({ + installations: z.array(GitHubInstallationSchema).default([]), + configured: z.boolean().default(false), + can_manage: z.boolean().optional().default(false), +}).loose(); + +export const EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE: ListGitHubInstallationsResponse = { + installations: [], + configured: false, + can_manage: false, +}; + // Label responses are consumed by settings tables and resource pickers. Keep // the resource type lenient so newer server scopes do not break older clients, // while defaulting fields that predate scoped label catalogs. diff --git a/packages/core/types/github.ts b/packages/core/types/github.ts index b77351f6933..f59ddcbe6b4 100644 --- a/packages/core/types/github.ts +++ b/packages/core/types/github.ts @@ -19,7 +19,9 @@ export interface GitHubInstallation { * integrations (see `ListGitHubInstallationsResponse.can_manage`). */ installation_id?: number; account_login: string; - account_type: "User" | "Organization"; + /** Kept as a string for forward compatibility with future GitHub account + * types. UI consumers must handle unknown values explicitly. */ + account_type: string; account_avatar_url: string | null; created_at: string; /** Display name of the workspace member who connected this installation. diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 07b53e36565..c0f4b42d578 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -419,6 +419,7 @@ "connected_installations": "Connected GitHub installations for this workspace.", "account_type_user": "Personal account", "account_type_organization": "Organization", + "account_type_unknown": "Unknown account type", "connect_github": "Connect GitHub", "connect_another_github": "Connect another GitHub", "connect_opening": "Opening…", diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index 4ec3b02798f..a5b81a90bc2 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -411,6 +411,7 @@ "connected_installations": "このワークスペースには次の GitHub App インストールが接続されています。", "account_type_user": "個人アカウント", "account_type_organization": "組織", + "account_type_unknown": "不明なアカウント種別", "connect_github": "GitHub を接続", "connect_another_github": "別の GitHub を接続", "connect_opening": "開いています…", diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index cfa244f872d..aec0d257450 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -411,6 +411,7 @@ "connected_installations": "이 워크스페이스에 연결된 GitHub App 설치입니다.", "account_type_user": "개인 계정", "account_type_organization": "조직", + "account_type_unknown": "알 수 없는 계정 유형", "connect_github": "GitHub 연결", "connect_another_github": "다른 GitHub 연결", "connect_opening": "여는 중...", diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index d10f29b8556..05161042b27 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -418,6 +418,7 @@ "connected_installations": "当前工作区已连接以下 GitHub App 安装。", "account_type_user": "个人账号", "account_type_organization": "组织", + "account_type_unknown": "未知账号类型", "connect_github": "连接 GitHub", "connect_another_github": "连接另一个 GitHub", "connect_opening": "正在打开…", diff --git a/packages/views/settings/components/github-tab.test.tsx b/packages/views/settings/components/github-tab.test.tsx index 585b1e806d8..30208297ed7 100644 --- a/packages/views/settings/components/github-tab.test.tsx +++ b/packages/views/settings/components/github-tab.test.tsx @@ -1,8 +1,9 @@ import type { ReactNode } from "react"; -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { I18nProvider } from "@multica/core/i18n/react"; +import type { GitHubInstallation } from "@multica/core/types"; import enCommon from "../../locales/en/common.json"; import enSettings from "../../locales/en/settings.json"; @@ -13,6 +14,7 @@ const mockInvalidate = vi.hoisted(() => vi.fn()); const mockNavPush = vi.hoisted(() => vi.fn()); const mockSetQueryData = vi.hoisted(() => vi.fn()); const mockToastSuccess = vi.hoisted(() => vi.fn()); +const mockToastError = vi.hoisted(() => vi.fn()); const workspaceRef = vi.hoisted(() => ({ current: { @@ -29,13 +31,7 @@ const membersRef = vi.hoisted(() => ({ })); const installationsRef = vi.hoisted(() => ({ current: { - installations: [] as { - id: string; - account_login: string; - account_type: "User" | "Organization"; - installation_id?: number; - connected_by?: string; - }[], + installations: [] as GitHubInstallation[], configured: true, can_manage: true as boolean, }, @@ -109,7 +105,7 @@ vi.mock("../../navigation", () => ({ })); vi.mock("sonner", () => ({ - toast: { success: mockToastSuccess, error: vi.fn() }, + toast: { success: mockToastSuccess, error: mockToastError }, })); import { GitHubTab } from "./github-tab"; @@ -141,6 +137,7 @@ function resetFixtures() { describe("GitHubTab", () => { beforeEach(resetFixtures); + afterEach(() => vi.unstubAllGlobals()); it("folds the non-dev hint into the master switch description (no separate callout)", () => { render(, { wrapper: I18nWrapper }); @@ -195,28 +192,46 @@ describe("GitHubTab", () => { }); }); - it("renders every installation separately and keeps Connect another available", () => { + it("renders every installation separately and keeps Connect another available", async () => { + class LoadedImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + referrerPolicy = ""; + crossOrigin: string | null = null; + + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } + } + vi.stubGlobal("Image", LoadedImage); + installationsRef.current = { configured: true, can_manage: true, installations: [ { id: "inst-user", + workspace_id: "workspace-1", account_login: "octocat", account_type: "User", + account_avatar_url: "https://avatars.example/octocat.png", + created_at: "2026-07-18T00:00:00Z", installation_id: 41, }, { id: "inst-org", + workspace_id: "workspace-1", account_login: "acme-org", account_type: "Organization", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", installation_id: 42, connected_by: "Jiayuan", }, ], }; - render(, { wrapper: I18nWrapper }); + const { container } = render(, { wrapper: I18nWrapper }); expect(screen.getByText("octocat")).toBeTruthy(); expect(screen.getByText("acme-org")).toBeTruthy(); @@ -226,6 +241,34 @@ describe("GitHubTab", () => { expect(screen.getByRole("button", { name: "Connect another GitHub" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Disconnect octocat" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Disconnect acme-org" })).toBeTruthy(); + expect(container.querySelectorAll('[data-slot="avatar"]')).toHaveLength(2); + await waitFor(() => { + expect( + container.querySelector('img[src="https://avatars.example/octocat.png"]'), + ).toBeTruthy(); + }); + expect(container.querySelectorAll('[data-slot="avatar-fallback"]')).toHaveLength(1); + }); + + it("renders an explicit fallback for a future GitHub account type", () => { + installationsRef.current = { + configured: true, + can_manage: true, + installations: [ + { + id: "inst-future", + workspace_id: "workspace-1", + account_login: "future-account", + account_type: "Enterprise", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", + }, + ], + }; + + render(, { wrapper: I18nWrapper }); + + expect(screen.getByText("Unknown account type")).toBeTruthy(); }); it("disconnects the selected installation row", async () => { @@ -236,14 +279,20 @@ describe("GitHubTab", () => { installations: [ { id: "inst-first", + workspace_id: "workspace-1", account_login: "octocat", account_type: "User", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", installation_id: 41, }, { id: "inst-second", + workspace_id: "workspace-1", account_login: "acme-org", account_type: "Organization", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", installation_id: 42, }, ], @@ -263,6 +312,36 @@ describe("GitHubTab", () => { }); }); + it("keeps the selected installation dialog open when disconnect fails", async () => { + const user = userEvent.setup(); + installationsRef.current = { + configured: true, + can_manage: true, + installations: [ + { + id: "inst-failing", + workspace_id: "workspace-1", + account_login: "acme-failing", + account_type: "Organization", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", + installation_id: 43, + }, + ], + }; + mockDeleteInstallation.mockRejectedValue(new Error("disconnect failed")); + + render(, { wrapper: I18nWrapper }); + + await user.click(screen.getByRole("button", { name: "Disconnect acme-failing" })); + await user.click(screen.getByRole("button", { name: /^Disconnect$/ })); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("disconnect failed"); + }); + expect(screen.getByRole("heading", { name: "Disconnect acme-failing?" })).toBeTruthy(); + }); + it("Disconnect button is still visible when the master switch is off", () => { workspaceRef.current.settings = { github_enabled: false }; installationsRef.current = { @@ -271,8 +350,11 @@ describe("GitHubTab", () => { installations: [ { id: "inst-1", + workspace_id: "workspace-1", account_login: "acme", account_type: "Organization", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", installation_id: 1, }, ], @@ -286,12 +368,31 @@ describe("GitHubTab", () => { installationsRef.current = { configured: true, can_manage: false, - installations: [{ id: "inst-1", account_login: "acme", account_type: "User" }], + installations: [ + { + id: "inst-1", + workspace_id: "workspace-1", + account_login: "octocat", + account_type: "User", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", + }, + { + id: "inst-2", + workspace_id: "workspace-1", + account_login: "acme-org", + account_type: "Organization", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", + }, + ], }; render(, { wrapper: I18nWrapper }); - expect(screen.getByText("acme")).toBeTruthy(); + expect(screen.getByText("octocat")).toBeTruthy(); + expect(screen.getByText("acme-org")).toBeTruthy(); expect(screen.getByText("Personal account")).toBeTruthy(); + expect(screen.getByText("Organization")).toBeTruthy(); expect(screen.getByText(/Read-only view\./i)).toBeTruthy(); expect(screen.queryByRole("button", { name: /^Connect/ })).toBeNull(); expect(screen.queryByRole("button", { name: /^Disconnect/ })).toBeNull(); @@ -317,8 +418,11 @@ describe("GitHubTab", () => { installations: [ { id: "inst-7", + workspace_id: "workspace-1", account_login: "acme", account_type: "Organization", + account_avatar_url: null, + created_at: "2026-07-18T00:00:00Z", installation_id: 7, connected_by: "Jiayuan", }, diff --git a/packages/views/settings/components/github-tab.tsx b/packages/views/settings/components/github-tab.tsx index 09c809a7307..8013a7f396e 100644 --- a/packages/views/settings/components/github-tab.tsx +++ b/packages/views/settings/components/github-tab.tsx @@ -5,6 +5,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { ExternalLink, GitCommitHorizontal, Link2, PanelRight } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@multica/ui/components/ui/avatar"; import { Card, CardContent } from "@multica/ui/components/ui/card"; import { Label } from "@multica/ui/components/ui/label"; import { Switch } from "@multica/ui/components/ui/switch"; @@ -168,8 +173,8 @@ export function GitHubTab() {

{t(($) => $.github.section_connection)}

-
-
+
+

{t(($) => $.github.connection_title)}

@@ -195,6 +200,7 @@ export function GitHubTab() {
{canManage && (