Skip to content
Open
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
12 changes: 12 additions & 0 deletions app/libs/github-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,15 @@ export const buildInstallationSettingsUrl = (
isPersonalAccount(link)
? `https://github.com/settings/installations/${link.installationId}`
: `https://github.com/organizations/${encodeURIComponent(link.githubOrg)}/settings/installations`

/**
* Predicate for the "broken repository" state: a repository whose canonical
* GitHub App installation was lost (e.g. the installation was uninstalled and
* canonical reassignment found 0 or 2+ candidates). Shared between the
* repositories list UI and the batch CLI command.
*/
export const isRepositoryBroken = (
repo: { githubInstallationId: number | null },
integrationMethod: 'token' | 'github_app' | null,
): boolean =>
integrationMethod === 'github_app' && repo.githubInstallationId === null
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ColumnDef } from '@tanstack/react-table'
import { ExternalLinkIcon } from 'lucide-react'
import { AlertTriangleIcon, ExternalLinkIcon } from 'lucide-react'
import { Link, useFetcher } from 'react-router'
import { match } from 'ts-pattern'
import {
Expand All @@ -8,14 +8,53 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Tooltip,
TooltipContent,
TooltipTrigger,
} from '~/app/components/ui'
import { Badge } from '~/app/components/ui/badge'
import { Button } from '~/app/components/ui/button'
import { Checkbox } from '~/app/components/ui/checkbox'
import { isRepositoryBroken } from '~/app/libs/github-account'
import type { TeamRow } from '../../teams._index/queries.server'
import type { RepositoryRow } from '../queries.server'
import { DataTableColumnHeader } from './data-table-column-header'
import { RepoRowActions } from './repo-row-actions'

function NeedsReconnectionBadge({ repositoryId }: { repositoryId: string }) {
const fetcher = useFetcher()
const isReassigning = fetcher.state !== 'idle'
return (
<span className="ml-2 inline-flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="destructive" className="gap-1">
<AlertTriangleIcon className="h-3 w-3" />
Needs reconnection
</Badge>
</TooltipTrigger>
<TooltipContent>
The GitHub App installation that owned this repository was removed.
Click Reassign to try another active installation, or reinstall the
GitHub App.
</TooltipContent>
</Tooltip>
<fetcher.Form method="post">
<input type="hidden" name="intent" value="reassignBroken" />
<input type="hidden" name="repositoryId" value={repositoryId} />
<Button
type="submit"
size="sm"
variant="outline"
loading={isReassigning}
>
Reassign
</Button>
</fetcher.Form>
</span>
)
}

function TeamSelect({
repositoryId,
currentTeamId,
Expand Down Expand Up @@ -56,6 +95,7 @@ function TeamSelect({
export const createColumns = (
orgSlug: string,
teams: TeamRow[],
integrationMethod: 'token' | 'github_app' | null,
): ColumnDef<RepositoryRow>[] => [
{
id: 'select',
Expand Down Expand Up @@ -86,20 +126,24 @@ export const createColumns = (
<DataTableColumnHeader column={column} title="Repository" />
),
cell: ({ row }) => {
const { owner, repo, provider } = row.original
const { id, owner, repo, provider } = row.original
const repoUrl = match(provider)
.with('github', () => `https://github.com/${owner}/${repo}`)
.otherwise(() => '')
const isBroken = isRepositoryBroken(row.original, integrationMethod)
return (
<Link
to={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium hover:underline"
>
{owner}/{repo}
<ExternalLinkIcon className="h-3 w-3" />
</Link>
<span className="inline-flex items-center gap-1">
<Link
to={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium hover:underline"
>
{owner}/{repo}
<ExternalLinkIcon className="h-3 w-3" />
</Link>
{isBroken && <NeedsReconnectionBadge repositoryId={id} />}
</span>
)
},
enableHiding: false,
Expand Down
89 changes: 78 additions & 11 deletions app/routes/$orgSlug/settings/repositories._index/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { z } from 'zod'
import { isOrgOwner } from '~/app/libs/auth.server'
import { getErrorMessage } from '~/app/libs/error-message'
import { orgContext } from '~/app/middleware/context'
import { reassignBrokenRepository } from '~/app/services/github-app-membership.server'
import { getIntegration } from '~/app/services/github-integration-queries.server'
import ContentSection from '../+components/content-section'
import { listTeams } from '../teams._index/queries.server'
import { createColumns } from './+components/repo-columns'
Expand Down Expand Up @@ -42,24 +44,35 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => {
per_page: searchParams.get('per_page'),
})

const { data: repositories, pagination } = await listFilteredRepositories({
organizationId: organization.id,
repo,
teamId: team || undefined,
currentPage,
pageSize,
sortBy,
sortOrder,
})
const [{ data: repositories, pagination }, teams, integration] =
await Promise.all([
listFilteredRepositories({
organizationId: organization.id,
repo,
teamId: team || undefined,
currentPage,
pageSize,
sortBy,
sortOrder,
}),
listTeams(organization.id),
getIntegration(organization.id),
])

const teams = await listTeams(organization.id)
const integrationMethod: 'token' | 'github_app' | null =
integration?.method === 'github_app'
? 'github_app'
: integration?.method === 'token'
? 'token'
: null

return {
organization,
repositories,
pagination,
teams,
canAddRepositories: isOrgOwner(membership.role),
integrationMethod,
}
}

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

const reassignBrokenSchema = z.object({
intent: z.literal('reassignBroken'),
repositoryId: z.string().min(1),
})

const actionSchema = z.discriminatedUnion('intent', [
updateTeamSchema,
bulkUpdateTeamSchema,
reassignBrokenSchema,
])

export const action = async ({ request, context }: Route.ActionArgs) => {
Expand Down Expand Up @@ -132,6 +151,50 @@ export const action = async ({ request, context }: Route.ActionArgs) => {
{ message: `${repositoryIds.length}件のチームを変更しました` },
)
})
.with({ intent: 'reassignBroken' }, async ({ repositoryId }) => {
try {
const result = await reassignBrokenRepository({
organizationId: organization.id,
repositoryId,
source: 'manual_reassign',
})
return match(result)
.with({ status: 'reassigned' }, () =>
dataWithSuccess(
{ ok: true, lastResult: null },
{ message: 'Repository reassigned to an active installation.' },
),
)
.with({ status: 'no_candidates' }, () =>
dataWithError(
{ ok: false, lastResult: null },
{
message:
'No active installation can see this repository. Reinstall the GitHub App and try again.',
},
),
)
.with({ status: 'ambiguous' }, ({ candidateCount }) =>
dataWithError(
{ ok: false, lastResult: null },
{
message: `${candidateCount} installations can see this repository. Manual reassignment is required — disconnect the unwanted installations to resolve.`,
},
),
)
.with({ status: 'not_broken' }, () =>
dataWithSuccess(
{ ok: true, lastResult: null },
{ message: 'Repository is already assigned.' },
),
)
.exhaustive()
} catch (e) {
console.error('Failed to reassign broken repository:', e)
const message = getErrorMessage(e)
return dataWithError({ ok: false, lastResult: null }, { message })
}
})
.exhaustive()
}

Expand All @@ -142,10 +205,14 @@ export default function OrganizationRepositoryIndexPage({
pagination,
teams,
canAddRepositories,
integrationMethod,
},
}: Route.ComponentProps) {
const slug = organization.slug
const columns = useMemo(() => createColumns(slug, teams), [slug, teams])
const columns = useMemo(
() => createColumns(slug, teams, integrationMethod),
[slug, teams, integrationMethod],
)

return (
<ContentSection
Expand Down
Loading
Loading