Skip to content
Merged
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
24 changes: 24 additions & 0 deletions packages/control-plane/src/github/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,30 @@ describe('GithubService.uninstallInstallation', () => {
await expect(svc.uninstallInstallation(123456789n)).resolves.toBeUndefined()
})

it("uninstalls an organization App's installation with that App's own JWT", async () => {
const issuers: string[] = []
const svc = new GithubService({
cfg: cfg(),
clock: new FakeClock(1_700_000_000_000),
installations: {} as never,
installState: { put: async () => {}, consume: async () => true },
pepper: 'p'.repeat(32),
appRegistry: {
configForInstallation: async (id: bigint) =>
id === 43n ? { ...cfg(), appId: 2000, jwtIssuer: '2000' } : undefined
} as never,
fetchImpl: async (_url, init) => {
const jwt = String(new Headers(init?.headers).get('authorization')).replace(/^Bearer /, '')
issuers.push(JSON.parse(Buffer.from(jwt.split('.')[1]!, 'base64url').toString()).iss)
return new Response(null, { status: 204 })
}
})

await svc.uninstallInstallation(43n)
await svc.uninstallInstallation(42n)
expect(issuers).toEqual(['2000', cfg().jwtIssuer])
})

it('preserves non-terminal uninstall failures', async () => {
const svc = new GithubService({
cfg: cfg(),
Expand Down
11 changes: 3 additions & 8 deletions packages/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1764,25 +1764,20 @@
"updatePermissions": "Update permissions",
"orgApps": {
"free": "free",
"usedBy": "used by {agent}",
"inUse": "in use",
"notInstalled": "not installed",
"more": "More",
"delete": "Delete App…",
"deleteTitle": "Delete GitHub App",
"deleteBody": "Remove <slug></slug> from this organization? Its key and installations are forgotten here; the App stays on GitHub until you delete it there.",
"close": "Close",
"cancel": "Cancel",
"deleting": "Deleting…",
"deleteConfirm": "Delete",
"noInstallations": "Not installed on any account yet."
"noInstallations": "Not installed on any account yet.",
"openAgent": "Open {agent}"
},
"appColumn": "App",
"installationsColumn": "Installations",
"builtIn": "built-in",
"expand": "Show installations",
"collapse": "Hide installations",
"installationCount": "{count, plural, one {# installation} other {# installations}}"
"collapse": "Hide installations"
},
"codeHostActions": {
"addRepository": "Add repository",
Expand Down
11 changes: 3 additions & 8 deletions packages/web/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1764,25 +1764,20 @@
"updatePermissions": "更新权限",
"orgApps": {
"free": "空闲",
"usedBy": "{agent} 在用",
"inUse": "使用中",
"notInstalled": "未安装",
"more": "更多",
"delete": "删除 App…",
"deleteTitle": "删除 GitHub App",
"deleteBody": "从此组织移除 <slug></slug>?这里会忘掉它的密钥和安装记录;App 本身仍留在 GitHub 上,需要到 GitHub 上删除。",
"close": "关闭",
"cancel": "取消",
"deleting": "正在删除…",
"deleteConfirm": "删除",
"noInstallations": "还没有安装到任何账号。"
"noInstallations": "还没有安装到任何账号。",
"openAgent": "打开 {agent}"
},
"appColumn": "App",
"installationsColumn": "安装",
"builtIn": "内置",
"expand": "展开安装列表",
"collapse": "收起安装列表",
"installationCount": "{count, plural, other {# 个安装}}"
"collapse": "收起安装列表"
},
"codeHostActions": {
"addRepository": "添加仓库",
Expand Down
101 changes: 101 additions & 0 deletions packages/web/src/components/console/GithubCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// @vitest-environment happy-dom
/** The GitHub card's one Sync re-pulls every App's installations, the built-in App's and each organization App's. */
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { SWRConfig } from 'swr'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GithubInstallationDto, GithubOrgAppDto } from '@/lib/api'

Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })

const installation = (over: Partial<GithubInstallationDto>): GithubInstallationDto => ({
id: 'inst-1',
installationId: 42,
accountLogin: 'example-org',
accountType: 'Organization',
repositorySelection: 'all',
suspended: false,
permissionsStatus: 'current',
pullRequestsPermission: 'write',
checksPermission: 'write',
settingsUrl: 'https://github.example.test/settings/installations/42',
createdAt: '2026-10-01T00:00:00.000Z',
...over
})

const orgApp = (id: string, slug: string): GithubOrgAppDto => ({
id,
appId: 2001,
slug,
botUserId: 9001,
agentId: null,
installations: [],
createdAt: '2026-10-20T00:00:00.000Z'
})

const mocks = vi.hoisted(() => ({
syncGithubInstallations: vi.fn(),
syncGithubApp: vi.fn()
}))

// The real hook returns a stable org; a fresh object per render would re-run the card's fetch forever.
const orgs = vi.hoisted(() => ({ activeOrg: { id: 'org-1' } }))
vi.mock('@/lib/org-context', () => ({ useOrgs: () => orgs }))
vi.mock('@/lib/data-context', () => ({ useConsoleData: () => ({ getAgent: () => undefined }) }))
vi.mock('@/lib/api', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/api')>()),
fetchGithubInstallations: vi.fn(async () => ({ enabled: true, installations: [installation({})] })),
fetchBuiltinGithubAppSlug: vi.fn(async () => 'example-connect'),
fetchGithubApps: vi.fn(async () => [orgApp('app-a', 'review-example'), orgApp('app-b', 'triage-example')]),
syncGithubInstallations: mocks.syncGithubInstallations,
syncGithubApp: mocks.syncGithubApp
}))

const GithubCard = (await import('./GithubCard')).default

let root: Root | undefined
let host: HTMLDivElement | undefined

afterEach(async () => {
if (root) await act(async () => root?.unmount())
host?.remove()
root = undefined
host = undefined
mocks.syncGithubInstallations.mockReset()
mocks.syncGithubApp.mockReset()
})

async function render() {
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
await act(async () => {
root?.render(
<SWRConfig value={{ provider: () => new Map() }}>
<GithubCard canWrite isOwner />
</SWRConfig>
)
})
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)))
return host
}

describe('GithubCard', () => {
it('syncs the built-in App and every organization App from one Sync, reporting a failure without stopping the rest', async () => {
mocks.syncGithubInstallations.mockResolvedValue([installation({ id: 'inst-2', accountLogin: 'second-org' })])
mocks.syncGithubApp.mockImplementation(async (id: string) => {
if (id === 'app-b') throw new Error('the private key of this App is missing')
})
const scope = await render()
// No column header row: each App row speaks for itself.
expect(scope.textContent).not.toContain('Installations')

const sync = [...scope.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.trim() === 'Sync')
await act(async () => sync!.click())

expect(mocks.syncGithubInstallations).toHaveBeenCalledTimes(1)
expect(mocks.syncGithubApp.mock.calls.map(([id]) => id)).toEqual(['app-a', 'app-b'])
expect(scope.textContent).toContain('second-org')
expect(scope.textContent).toContain('the private key of this App is missing')
})
})
76 changes: 27 additions & 49 deletions packages/web/src/components/console/GithubCard.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { Fragment, useEffect, useState } from 'react'
import useSWR from 'swr'
import useSWR, { useSWRConfig } from 'swr'
import { useTranslations } from 'next-intl'
import { Button, Icon } from '@/components/ui'
import { SQUARE_MARK_FILL_PCT } from '@/components/mark-box'
import { GithubMark, LoadingState } from '@/components/marks'
import { useOrgs } from '@/lib/org-context'
import {
fetchBuiltinGithubAppSlug,
fetchGithubApps,
fetchGithubInstallUrl,
fetchGithubInstallations,
syncGithubApp,
syncGithubInstallations,
type GithubInstallationDto
} from '@/lib/api'
import UninstallGithubInstallationModal from '@/components/console/modals/UninstallGithubInstallationModal'
import { Scrim } from '@/components/console/Scrim'
import { GithubAppRow, GithubOrgApps, INSTALLATION_ROW } from '@/components/console/GithubOrgApps'
import { GithubAppRow, GithubOrgApps, INSTALLATION_ROW, InstallAppButton } from '@/components/console/GithubOrgApps'
import { consoleKeys } from '@/lib/swr-keys'

// No 'use client' here: rendered only inside a client boundary — the Integrations page, or the
Expand All @@ -26,16 +28,7 @@ import { consoleKeys } from '@/lib/swr-keys'
// Installations are org-level infrastructure (like bots) — every member can
// see them; installing and syncing are writes (viewers don't get those buttons),
// while uninstalling the App from an account is owner-only.
export default function GithubCard({
canWrite,
isOwner,
agentNameOf
}: {
canWrite: boolean
isOwner: boolean
/** Names the agent an organization App serves; the console passes it, the webchat dialog does not. */
agentNameOf?: (agentId: string) => string | undefined
}) {
export default function GithubCard({ canWrite, isOwner }: { canWrite: boolean; isOwner: boolean }) {
const t = useTranslations('Integrations')
// Gate the org-scoped fetch on the active org (same hard-refresh race as SlackCard):
// before OrgProvider resolves, `orgBase()` throws → the catch would show "not enabled"
Expand All @@ -48,6 +41,7 @@ export default function GithubCard({
const [uninstalling, setUninstalling] = useState<GithubInstallationDto | null>(null)
// Each App is a first-level row over its installations; the built-in App opens expanded.
const [builtInOpen, setBuiltInOpen] = useState(true)
const { mutate } = useSWRConfig()
const { data: builtInSlug } = useSWR(activeOrg ? consoleKeys.githubBuiltinApp(activeOrg.id) : null, ([, id]) =>
fetchBuiltinGithubAppSlug(id ?? undefined)
)
Expand Down Expand Up @@ -80,14 +74,22 @@ export default function GithubCard({
}
}

// Refresh the org's claimed installations after GitHub changes or an install
// finished in the other tab just now.
// Re-pull every App's installations from GitHub, the built-in App's and each organization App's; one failing App doesn't stop the rest.
const sync = async () => {
if (busy) return
if (busy || !activeOrg) return
setBusy(true)
setErr(null)
try {
setInstalls(await syncGithubInstallations())
const [builtIn, ...orgApps] = await Promise.allSettled([
syncGithubInstallations(),
...(await fetchGithubApps(activeOrg.id)).map((app) => syncGithubApp(app.id))
])
if (builtIn?.status === 'fulfilled') setInstalls(builtIn.value)
await mutate(consoleKeys.githubApps(activeOrg.id))
const failed = [builtIn, ...orgApps].find((result) => result?.status === 'rejected')
if (failed?.status === 'rejected') {
setErr(failed.reason instanceof Error ? failed.reason.message : String(failed.reason))
}
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
Expand Down Expand Up @@ -118,35 +120,13 @@ export default function GithubCard({
{t('github.notEnabled')}
</div>
)}
{/* Desktop only: the row collapses to one stacked column below the
breakpoint, where a two-track header would label nothing. */}
{enabled === true && (
<div className="row h hidden grid-cols-[minmax(0,1fr)_auto] gap-[11px] desktop:grid">
<span>{t('github.appColumn')}</span>
<span>{t('github.installationsColumn')}</span>
</div>
)}
{enabled === true && (
<div data-builtin-app>
<GithubAppRow
open={builtInOpen}
onToggle={() => setBuiltInOpen((open) => !open)}
label={builtInOpen ? t('github.collapse') : t('github.expand')}
aside={
<>
<span className="font-sans text-[12px] font-normal leading-normal text-(--text-tertiary)">
{installs.length > 0
? t('github.installationCount', { count: installs.length })
: t('github.orgApps.notInstalled')}
</span>
{canWrite && (
<Button size="xs" variant="ghost" onClick={install}>
<Icon name="external-link" size={13} />
{t('github.install')}
</Button>
)}
</>
}
aside={canWrite && <InstallAppButton onClick={() => void install()} />}
>
<span className="mono min-w-0 truncate text-[12.5px]">{builtInSlug ?? t('github.label')}</span>
<span className="badge bg-(--surface-active) text-(--text-tertiary)">{t('github.builtIn')}</span>
Expand Down Expand Up @@ -186,15 +166,15 @@ export default function GithubCard({
{i.repositorySelection === 'all' ? t('github.allRepositories') : t('github.selectedRepositories')}
</span>
{isOwner && (
<Button
variant="ghost"
size="xs"
className="text-(--status-error) hover:text-(--status-error)"
<button
type="button"
className="iconbtn text-(--status-error)"
title={t('github.uninstall')}
aria-label={t('github.uninstall')}
onClick={() => setUninstalling(i)}
>
<Icon name="unplug" size={13} />
{t('github.uninstall')}
</Button>
<Icon name="unplug" size={14} />
</button>
)}
</span>
</div>
Expand Down Expand Up @@ -222,9 +202,7 @@ export default function GithubCard({
))}
</div>
)}
{enabled === true && (
<GithubOrgApps canWrite={canWrite} isOwner={isOwner} {...(agentNameOf ? { agentNameOf } : {})} />
)}
{enabled === true && <GithubOrgApps canWrite={canWrite} isOwner={isOwner} />}
{err && (
<div className="px-4 py-2 font-sans text-[12px] font-normal leading-normal text-(--status-error)">{err}</div>
)}
Expand Down
Loading
Loading