Skip to content

Commit 4e18e33

Browse files
cojiclaude
andcommitted
feat(repo-ui): broken repository recovery (#283 PR 5/7)
UX 哲学: 通常時は repository 一覧/詳細に installation 名を出さない (PR 4 の Vercel 哲学を継承)。canonical installation を失って `github_installation_id IS NULL` になった broken state の repository だけを救済導線として可視化する。 repositories list (settings/repositories): - github_app モードかつ github_installation_id IS NULL の repository に "Needs reconnection" バッジ + tooltip - 1-click "Reassign" ボタンで canonical reassignment helper を再実行 - 候補 1 件 → 自動 reassign + バッジ消失 (success toast) - 候補 0 件 → "Reinstall the GitHub App and try again" (error toast) - 候補 2+ 件 → "Disconnect unwanted installations to resolve" (error toast) - loader が getIntegration().method を返し、columns が github_app モードのみバッジを出す mutation (app/services/github-app-membership.server.ts): - reassignBrokenRepository(orgId, repositoryId, source) helper を追加 - canonical reassignment helper と同じ eligibility ルール - 結果を { reassigned | no_candidates | ambiguous | not_broken } の discriminated union で返す - tenant first / shared second + audit log route mutation wrapper (settings/repositories._index/mutations.server.ts): - reassignBrokenRepositoryFromUI() で source='manual_reassign' を固定して呼ぶ batch CLI (batch/commands/reassign-broken-repositories.ts): - batch/cli.ts に `reassign-broken-repositories <orgId> [--repository <id>]` コマンド追加 - 全 broken repo を順次救済、結果を集計表示 - source='cli_repair' tests (github-app-membership.server.test.ts): - reassignBrokenRepository の 6 ケース追加 (1/0/2+ 候補, not_broken, suspended/uninitialized link 除外) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad26a55 commit 4e18e33

File tree

7 files changed

+554
-39
lines changed

7 files changed

+554
-39
lines changed

app/libs/github-account.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,15 @@ export const buildInstallationSettingsUrl = (
3030
isPersonalAccount(link)
3131
? `https://github.com/settings/installations/${link.installationId}`
3232
: `https://github.com/organizations/${encodeURIComponent(link.githubOrg)}/settings/installations`
33+
34+
/**
35+
* Predicate for the "broken repository" state: a repository whose canonical
36+
* GitHub App installation was lost (e.g. the installation was uninstalled and
37+
* canonical reassignment found 0 or 2+ candidates). Shared between the
38+
* repositories list UI and the batch CLI command.
39+
*/
40+
export const isRepositoryBroken = (
41+
repo: { githubInstallationId: number | null },
42+
integrationMethod: 'token' | 'github_app' | null,
43+
): boolean =>
44+
integrationMethod === 'github_app' && repo.githubInstallationId === null

app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ColumnDef } from '@tanstack/react-table'
2-
import { ExternalLinkIcon } from 'lucide-react'
2+
import { AlertTriangleIcon, ExternalLinkIcon } from 'lucide-react'
33
import { Link, useFetcher } from 'react-router'
44
import { match } from 'ts-pattern'
55
import {
@@ -8,14 +8,53 @@ import {
88
SelectItem,
99
SelectTrigger,
1010
SelectValue,
11+
Tooltip,
12+
TooltipContent,
13+
TooltipTrigger,
1114
} from '~/app/components/ui'
1215
import { Badge } from '~/app/components/ui/badge'
16+
import { Button } from '~/app/components/ui/button'
1317
import { Checkbox } from '~/app/components/ui/checkbox'
18+
import { isRepositoryBroken } from '~/app/libs/github-account'
1419
import type { TeamRow } from '../../teams._index/queries.server'
1520
import type { RepositoryRow } from '../queries.server'
1621
import { DataTableColumnHeader } from './data-table-column-header'
1722
import { RepoRowActions } from './repo-row-actions'
1823

24+
function NeedsReconnectionBadge({ repositoryId }: { repositoryId: string }) {
25+
const fetcher = useFetcher()
26+
const isReassigning = fetcher.state !== 'idle'
27+
return (
28+
<span className="ml-2 inline-flex items-center gap-1">
29+
<Tooltip>
30+
<TooltipTrigger asChild>
31+
<Badge variant="destructive" className="gap-1">
32+
<AlertTriangleIcon className="h-3 w-3" />
33+
Needs reconnection
34+
</Badge>
35+
</TooltipTrigger>
36+
<TooltipContent>
37+
The GitHub App installation that owned this repository was removed.
38+
Click Reassign to try another active installation, or reinstall the
39+
GitHub App.
40+
</TooltipContent>
41+
</Tooltip>
42+
<fetcher.Form method="post">
43+
<input type="hidden" name="intent" value="reassignBroken" />
44+
<input type="hidden" name="repositoryId" value={repositoryId} />
45+
<Button
46+
type="submit"
47+
size="sm"
48+
variant="outline"
49+
loading={isReassigning}
50+
>
51+
Reassign
52+
</Button>
53+
</fetcher.Form>
54+
</span>
55+
)
56+
}
57+
1958
function TeamSelect({
2059
repositoryId,
2160
currentTeamId,
@@ -56,6 +95,7 @@ function TeamSelect({
5695
export const createColumns = (
5796
orgSlug: string,
5897
teams: TeamRow[],
98+
integrationMethod: 'token' | 'github_app' | null,
5999
): ColumnDef<RepositoryRow>[] => [
60100
{
61101
id: 'select',
@@ -86,20 +126,24 @@ export const createColumns = (
86126
<DataTableColumnHeader column={column} title="Repository" />
87127
),
88128
cell: ({ row }) => {
89-
const { owner, repo, provider } = row.original
129+
const { id, owner, repo, provider } = row.original
90130
const repoUrl = match(provider)
91131
.with('github', () => `https://github.com/${owner}/${repo}`)
92132
.otherwise(() => '')
133+
const isBroken = isRepositoryBroken(row.original, integrationMethod)
93134
return (
94-
<Link
95-
to={repoUrl}
96-
target="_blank"
97-
rel="noopener noreferrer"
98-
className="inline-flex items-center gap-1 font-medium hover:underline"
99-
>
100-
{owner}/{repo}
101-
<ExternalLinkIcon className="h-3 w-3" />
102-
</Link>
135+
<span className="inline-flex items-center gap-1">
136+
<Link
137+
to={repoUrl}
138+
target="_blank"
139+
rel="noopener noreferrer"
140+
className="inline-flex items-center gap-1 font-medium hover:underline"
141+
>
142+
{owner}/{repo}
143+
<ExternalLinkIcon className="h-3 w-3" />
144+
</Link>
145+
{isBroken && <NeedsReconnectionBadge repositoryId={id} />}
146+
</span>
103147
)
104148
},
105149
enableHiding: false,

app/routes/$orgSlug/settings/repositories._index/index.tsx

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { z } from 'zod'
77
import { isOrgOwner } from '~/app/libs/auth.server'
88
import { getErrorMessage } from '~/app/libs/error-message'
99
import { orgContext } from '~/app/middleware/context'
10+
import { reassignBrokenRepository } from '~/app/services/github-app-membership.server'
11+
import { getIntegration } from '~/app/services/github-integration-queries.server'
1012
import ContentSection from '../+components/content-section'
1113
import { listTeams } from '../teams._index/queries.server'
1214
import { createColumns } from './+components/repo-columns'
@@ -42,24 +44,35 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => {
4244
per_page: searchParams.get('per_page'),
4345
})
4446

45-
const { data: repositories, pagination } = await listFilteredRepositories({
46-
organizationId: organization.id,
47-
repo,
48-
teamId: team || undefined,
49-
currentPage,
50-
pageSize,
51-
sortBy,
52-
sortOrder,
53-
})
47+
const [{ data: repositories, pagination }, teams, integration] =
48+
await Promise.all([
49+
listFilteredRepositories({
50+
organizationId: organization.id,
51+
repo,
52+
teamId: team || undefined,
53+
currentPage,
54+
pageSize,
55+
sortBy,
56+
sortOrder,
57+
}),
58+
listTeams(organization.id),
59+
getIntegration(organization.id),
60+
])
5461

55-
const teams = await listTeams(organization.id)
62+
const integrationMethod: 'token' | 'github_app' | null =
63+
integration?.method === 'github_app'
64+
? 'github_app'
65+
: integration?.method === 'token'
66+
? 'token'
67+
: null
5668

5769
return {
5870
organization,
5971
repositories,
6072
pagination,
6173
teams,
6274
canAddRepositories: isOrgOwner(membership.role),
75+
integrationMethod,
6376
}
6477
}
6578

@@ -80,9 +93,15 @@ const bulkUpdateTeamSchema = z.object({
8093
teamId: nullableTeamId,
8194
})
8295

96+
const reassignBrokenSchema = z.object({
97+
intent: z.literal('reassignBroken'),
98+
repositoryId: z.string().min(1),
99+
})
100+
83101
const actionSchema = z.discriminatedUnion('intent', [
84102
updateTeamSchema,
85103
bulkUpdateTeamSchema,
104+
reassignBrokenSchema,
86105
])
87106

88107
export const action = async ({ request, context }: Route.ActionArgs) => {
@@ -132,6 +151,50 @@ export const action = async ({ request, context }: Route.ActionArgs) => {
132151
{ message: `${repositoryIds.length}件のチームを変更しました` },
133152
)
134153
})
154+
.with({ intent: 'reassignBroken' }, async ({ repositoryId }) => {
155+
try {
156+
const result = await reassignBrokenRepository({
157+
organizationId: organization.id,
158+
repositoryId,
159+
source: 'manual_reassign',
160+
})
161+
return match(result)
162+
.with({ status: 'reassigned' }, () =>
163+
dataWithSuccess(
164+
{ ok: true, lastResult: null },
165+
{ message: 'Repository reassigned to an active installation.' },
166+
),
167+
)
168+
.with({ status: 'no_candidates' }, () =>
169+
dataWithError(
170+
{ ok: false, lastResult: null },
171+
{
172+
message:
173+
'No active installation can see this repository. Reinstall the GitHub App and try again.',
174+
},
175+
),
176+
)
177+
.with({ status: 'ambiguous' }, ({ candidateCount }) =>
178+
dataWithError(
179+
{ ok: false, lastResult: null },
180+
{
181+
message: `${candidateCount} installations can see this repository. Manual reassignment is required — disconnect the unwanted installations to resolve.`,
182+
},
183+
),
184+
)
185+
.with({ status: 'not_broken' }, () =>
186+
dataWithSuccess(
187+
{ ok: true, lastResult: null },
188+
{ message: 'Repository is already assigned.' },
189+
),
190+
)
191+
.exhaustive()
192+
} catch (e) {
193+
console.error('Failed to reassign broken repository:', e)
194+
const message = getErrorMessage(e)
195+
return dataWithError({ ok: false, lastResult: null }, { message })
196+
}
197+
})
135198
.exhaustive()
136199
}
137200

@@ -142,10 +205,14 @@ export default function OrganizationRepositoryIndexPage({
142205
pagination,
143206
teams,
144207
canAddRepositories,
208+
integrationMethod,
145209
},
146210
}: Route.ComponentProps) {
147211
const slug = organization.slug
148-
const columns = useMemo(() => createColumns(slug, teams), [slug, teams])
212+
const columns = useMemo(
213+
() => createColumns(slug, teams, integrationMethod),
214+
[slug, teams, integrationMethod],
215+
)
149216

150217
return (
151218
<ContentSection

0 commit comments

Comments
 (0)