Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
455 changes: 455 additions & 0 deletions docs/superpowers/plans/2026-07-18-github-multiple-installations.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# GitHub 设置页支持多个安装:设计规格

## 问题

GitHub 设置接口返回 `installations` 数组,数据库也允许同一个 Multica 工作区绑定多个 GitHub App installation。当前设置页却把数组折叠成了一个“已连接”状态:首次连接后隐藏连接入口,把所有账号名称拼成一句话,并让唯一的断开按钮固定操作 `installations[0]`。

结果是:用户无法通过正常界面添加第二个 installation,也无法断开除第一条以外的 installation。

## 范围

本次以共享设置页为主,不新增 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 部分

## 用户体验

连接区域保留现有说明。存在 installation 时,不再把账号拼成一句话,而是把每条 installation 渲染为独立行。

每行展示:

- GitHub 账号头像;无头像或加载失败时显示 GitHub 图标;
- GitHub 账号名称;
- 账号类型:个人或组织;
- 后端提供 `connected_by` 时,展示连接人;
- 对工作区所有者和管理员展示该行专属的“断开”操作。

部署已配置 GitHub 集成时,工作区所有者和管理员始终能看到连接操作。没有 installation 时显示“连接 GitHub”;已有一条或多条时显示“连接另一个 GitHub”。该操作继续调用现有签名连接 URL 接口,并在新标签页打开 GitHub App 安装流程。

没有管理权限的成员可以看到相同的 installation 列表,但看不到连接或断开操作,并继续看到现有只读提示。没有 installation 时,仍提示联系管理员连接。

即使工作区的 GitHub 功能总开关已关闭,断开操作仍然可用,因为“隐藏 GitHub 功能”和“解除 installation 绑定”是两个独立意图。

## 状态与数据流

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 检查、新标签页打开和错误处理,只是不再与“已连接”状态互斥。

## 错误与边界情况

- installation 数组为空时,展示现有未连接说明。
- 缺少可选的 `connected_by` 时,只省略连接人信息。
- 缺少头像或头像加载失败时展示 GitHub 图标,不影响账号识别与操作。
- 后端出现未来账号类型时展示“未知账号类型”,不误标为个人账号。
- 部署未配置 GitHub 集成时,即使已有 installation,也禁用连接操作并保留现有配置提示。
- 断开失败时保留确认框和选中行,允许用户重试或取消。
- 连接请求进行中时禁用连接按钮;断开请求进行中时禁用所有行的断开按钮,避免重复请求。

## 测试

组件测试通过真实渲染结果和现有 API mock 证明以下行为:

1. 多个 installation 分别渲染为独立行,不再拼成逗号分隔的一句话。
2. 所有者和管理员在已有 installation 时仍能看到“连接另一个 GitHub”。
3. 每行都有自己的断开操作;确认第二行时,接口收到第二行 ID。
4. 确认框明确显示当前选中的 GitHub 账号。
5. 普通成员能看到全部行,但看不到任何管理操作。
6. 单条 installation 仍能正常展示,并且 GitHub 总开关关闭时仍可断开。
7. 现有的空状态、部署未配置、连接人、设置开关和代码仓库跳转测试继续通过。
8. 头像 URL 与空头像 fallback、未知账号类型、损坏 API 响应和服务端连接人 enrichment 均有回归覆盖。

完成前必须通过组件与 API schema 的 Vitest 测试、`packages/views`/`packages/core` TypeScript 检查及 GitHub handler Go 测试。浏览器验证使用本地工作区中两条模拟 installation,确认两行各自拥有头像 fallback 和断开操作,同时连接入口仍然存在。

## 不在本次范围

- 选择默认或主要 installation
- 跨 installation 去重 GitHub 账号
- 在 Multica 中修改 GitHub App 的仓库访问范围
- 在 GitHub 侧撤销 GitHub App 授权
- 增加 installation 分页、排序或搜索
- 改变一个 GitHub installation 绑定多个 Multica 工作区的机制
12 changes: 11 additions & 1 deletion packages/core/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -2645,7 +2647,15 @@ export class ApiClient {
}

async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
const raw = await this.fetch<unknown>(
`/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<void> {
Expand Down
35 changes: 35 additions & 0 deletions packages/core/api/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
InboxItemListSchema,
InboxUnreadSummarySchema,
IssueTriggerPreviewSchema,
ListGitHubInstallationsResponseSchema,
EMPTY_LIST_GITHUB_INSTALLATIONS_RESPONSE,
ListIssuesResponseSchema,
ListPropertiesResponseSchema,
SearchProjectsResponseSchema,
Expand All @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions packages/core/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ListPropertiesResponse,
IssuePropertiesResponse,
ListIssuesResponse,
ListGitHubInstallationsResponse,
ListLabelsResponse,
ListWebhookDeliveriesResponse,
NotificationPreferenceResponse,
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/types/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions packages/views/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -416,13 +416,19 @@
"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",
"account_type_unknown": "Unknown account type",
"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",
Expand Down
10 changes: 8 additions & 2 deletions packages/views/locales/ja/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -408,13 +408,19 @@
"connection_description_done": "完了",
"connection_identifier_example": "MUL-123",
"connected_to": "{{login}} に接続済み",
"connected_installations": "このワークスペースには次の GitHub App インストールが接続されています。",
"account_type_user": "個人アカウント",
"account_type_organization": "組織",
"account_type_unknown": "不明なアカウント種別",
"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 連携が設定されていません。運営者が次の値を設定する必要があります:",
Expand Down
10 changes: 8 additions & 2 deletions packages/views/locales/ko/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -408,13 +408,19 @@
"connection_description_done": "완료",
"connection_identifier_example": "MUL-123",
"connected_to": "{{login}}에 연결됨",
"connected_installations": "이 워크스페이스에 연결된 GitHub App 설치입니다.",
"account_type_user": "개인 계정",
"account_type_organization": "조직",
"account_type_unknown": "알 수 없는 계정 유형",
"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 연동이 설정되어 있지 않습니다. 운영자가 다음 값을 설정해야 합니다:",
Expand Down
10 changes: 8 additions & 2 deletions packages/views/locales/zh-Hans/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,13 +415,19 @@
"connection_description_done": "已完成",
"connection_identifier_example": "MUL-123",
"connected_to": "已连接到 {{login}}",
"connected_installations": "当前工作区已连接以下 GitHub App 安装。",
"account_type_user": "个人账号",
"account_type_organization": "组织",
"account_type_unknown": "未知账号类型",
"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 集成。运维需要设置",
Expand Down
Loading
Loading