diff --git a/app/libs/github-account.ts b/app/libs/github-account.ts
index 7a6b4810..ccbc023f 100644
--- a/app/libs/github-account.ts
+++ b/app/libs/github-account.ts
@@ -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
diff --git a/app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx b/app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx
index bc5ba2a5..a99bcdc0 100644
--- a/app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx
+++ b/app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx
@@ -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 {
@@ -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 (
+
+
+
+
+
+ Needs reconnection
+
+
+
+ The GitHub App installation that owned this repository was removed.
+ Click Reassign to try another active installation, or reinstall the
+ GitHub App.
+
+
+
+
+
+
+
+
+ )
+}
+
function TeamSelect({
repositoryId,
currentTeamId,
@@ -56,6 +95,7 @@ function TeamSelect({
export const createColumns = (
orgSlug: string,
teams: TeamRow[],
+ integrationMethod: 'token' | 'github_app' | null,
): ColumnDef[] => [
{
id: 'select',
@@ -86,20 +126,24 @@ export const createColumns = (
),
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 (
-
- {owner}/{repo}
-
-
+
+
+ {owner}/{repo}
+
+
+ {isBroken && }
+
)
},
enableHiding: false,
diff --git a/app/routes/$orgSlug/settings/repositories._index/index.tsx b/app/routes/$orgSlug/settings/repositories._index/index.tsx
index d0ccb091..bf22761a 100644
--- a/app/routes/$orgSlug/settings/repositories._index/index.tsx
+++ b/app/routes/$orgSlug/settings/repositories._index/index.tsx
@@ -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'
@@ -42,17 +44,27 @@ 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,
@@ -60,6 +72,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => {
pagination,
teams,
canAddRepositories: isOrgOwner(membership.role),
+ integrationMethod,
}
}
@@ -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) => {
@@ -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()
}
@@ -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 (
{
expect(events).toHaveLength(0)
})
})
+
+describe('reassignBrokenRepository', () => {
+ const seedBrokenRepo = async () => {
+ const { getTenantDb } = await import('~/app/services/tenant-db.server')
+ const tenantDb = getTenantDb(ORG_ID)
+ await tenantDb
+ .insertInto('repositories')
+ .values({
+ id: REPO_ID,
+ integrationId: 'int-1',
+ provider: 'github',
+ owner: 'octo',
+ repo: 'hello',
+ githubInstallationId: null,
+ updatedAt: '2026-04-07T00:00:00Z',
+ })
+ .execute()
+ }
+
+ beforeEach(async () => {
+ await db.deleteFrom('githubAppLinkEvents').execute()
+ await db.deleteFrom('githubAppLinks').execute()
+ const { getTenantDb } = await import('~/app/services/tenant-db.server')
+ const tenantDb = getTenantDb(ORG_ID)
+ await tenantDb.deleteFrom('repositoryInstallationMemberships').execute()
+ await tenantDb.deleteFrom('repositories').execute()
+ })
+
+ test('1 eligible candidate → reassigned + canonical_reassigned event', async () => {
+ await insertLink(ALT_INSTALLATION)
+ await seedBrokenRepo()
+ await insertMembership(ALT_INSTALLATION)
+
+ const result = await reassignBrokenRepository({
+ organizationId: ORG_ID,
+ repositoryId: REPO_ID,
+ source: 'manual_reassign',
+ })
+
+ expect(result).toEqual({
+ status: 'reassigned',
+ installationId: ALT_INSTALLATION,
+ })
+
+ const { getTenantDb } = await import('~/app/services/tenant-db.server')
+ const repo = await getTenantDb(ORG_ID)
+ .selectFrom('repositories')
+ .select('githubInstallationId')
+ .where('id', '=', REPO_ID)
+ .executeTakeFirstOrThrow()
+ expect(repo.githubInstallationId).toBe(ALT_INSTALLATION)
+
+ const events = await db
+ .selectFrom('githubAppLinkEvents')
+ .selectAll()
+ .execute()
+ expect(events.map((e) => e.eventType)).toEqual(['canonical_reassigned'])
+ })
+
+ test('0 candidates → no_candidates, no audit event written', async () => {
+ await seedBrokenRepo()
+
+ const result = await reassignBrokenRepository({
+ organizationId: ORG_ID,
+ repositoryId: REPO_ID,
+ source: 'manual_reassign',
+ })
+
+ expect(result).toEqual({ status: 'no_candidates' })
+
+ const { getTenantDb } = await import('~/app/services/tenant-db.server')
+ const repo = await getTenantDb(ORG_ID)
+ .selectFrom('repositories')
+ .select('githubInstallationId')
+ .where('id', '=', REPO_ID)
+ .executeTakeFirstOrThrow()
+ expect(repo.githubInstallationId).toBeNull()
+
+ const events = await db
+ .selectFrom('githubAppLinkEvents')
+ .selectAll()
+ .execute()
+ expect(events).toHaveLength(0)
+ })
+
+ test('2+ candidates → ambiguous, no audit event written', async () => {
+ await insertLink(ALT_INSTALLATION)
+ await insertLink(SECOND_ALT_INSTALLATION)
+ await seedBrokenRepo()
+ await insertMembership(ALT_INSTALLATION)
+ await insertMembership(SECOND_ALT_INSTALLATION)
+
+ const result = await reassignBrokenRepository({
+ organizationId: ORG_ID,
+ repositoryId: REPO_ID,
+ source: 'manual_reassign',
+ })
+
+ expect(result).toEqual({ status: 'ambiguous', candidateCount: 2 })
+
+ const { getTenantDb } = await import('~/app/services/tenant-db.server')
+ const repo = await getTenantDb(ORG_ID)
+ .selectFrom('repositories')
+ .select('githubInstallationId')
+ .where('id', '=', REPO_ID)
+ .executeTakeFirstOrThrow()
+ expect(repo.githubInstallationId).toBeNull()
+
+ const events = await db
+ .selectFrom('githubAppLinkEvents')
+ .selectAll()
+ .execute()
+ expect(events).toHaveLength(0)
+ })
+
+ test('not_broken: repo already has a canonical installation → no event', async () => {
+ await insertLink(ALT_INSTALLATION)
+ const { getTenantDb } = await import('~/app/services/tenant-db.server')
+ const tenantDb = getTenantDb(ORG_ID)
+ await tenantDb
+ .insertInto('repositories')
+ .values({
+ id: REPO_ID,
+ integrationId: 'int-1',
+ provider: 'github',
+ owner: 'octo',
+ repo: 'hello',
+ githubInstallationId: ALT_INSTALLATION,
+ updatedAt: '2026-04-07T00:00:00Z',
+ })
+ .execute()
+
+ const result = await reassignBrokenRepository({
+ organizationId: ORG_ID,
+ repositoryId: REPO_ID,
+ source: 'manual_reassign',
+ })
+ expect(result).toEqual({ status: 'not_broken' })
+
+ const events = await db
+ .selectFrom('githubAppLinkEvents')
+ .selectAll()
+ .execute()
+ expect(events).toHaveLength(0)
+ })
+
+ test('suspended link is excluded from candidates', async () => {
+ await insertLink(ALT_INSTALLATION, {
+ suspendedAt: '2026-04-07T00:00:00Z',
+ })
+ await seedBrokenRepo()
+ await insertMembership(ALT_INSTALLATION)
+
+ const result = await reassignBrokenRepository({
+ organizationId: ORG_ID,
+ repositoryId: REPO_ID,
+ source: 'manual_reassign',
+ })
+ expect(result).toEqual({ status: 'no_candidates' })
+ })
+
+ test('uninitialized link is excluded from candidates', async () => {
+ await insertLink(ALT_INSTALLATION, { membershipInitializedAt: null })
+ await seedBrokenRepo()
+ await insertMembership(ALT_INSTALLATION)
+
+ const result = await reassignBrokenRepository({
+ organizationId: ORG_ID,
+ repositoryId: REPO_ID,
+ source: 'manual_reassign',
+ })
+ expect(result).toEqual({ status: 'no_candidates' })
+ })
+})
diff --git a/app/services/github-app-membership.server.ts b/app/services/github-app-membership.server.ts
index a16e1e34..d4b5399c 100644
--- a/app/services/github-app-membership.server.ts
+++ b/app/services/github-app-membership.server.ts
@@ -1,6 +1,9 @@
import { db } from '~/app/services/db.server'
import type { GithubAppLinkEventSource } from '~/app/services/github-app-link-events.server'
-import { tryLogGithubAppLinkEvent } from '~/app/services/github-app-link-events.server'
+import {
+ logGithubAppLinkEvent,
+ tryLogGithubAppLinkEvent,
+} from '~/app/services/github-app-link-events.server'
import { getTenantDb } from '~/app/services/tenant-db.server'
import type { OrganizationId } from '~/app/types/organization'
@@ -13,6 +16,115 @@ export type ReassignmentSource = Extract<
| 'manual_reassign'
>
+/**
+ * Active GitHub App installation ids that are eligible to receive a canonical
+ * reassignment for a repository: not deleted, not suspended, and with their
+ * `repository_installation_memberships` initialized.
+ */
+async function fetchEligibleInstallationIds(
+ organizationId: OrganizationId,
+ options: { excludeInstallationId?: number } = {},
+): Promise<{ ids: Set; hasUninitializedLink: boolean }> {
+ let linkQuery = db
+ .selectFrom('githubAppLinks')
+ .select(['installationId', 'suspendedAt', 'membershipInitializedAt'])
+ .where('organizationId', '=', organizationId)
+ .where('deletedAt', 'is', null)
+ if (options.excludeInstallationId !== undefined) {
+ linkQuery = linkQuery.where(
+ 'installationId',
+ '!=',
+ options.excludeInstallationId,
+ )
+ }
+ const links = await linkQuery.execute()
+ const ids = new Set(
+ links
+ .filter((l) => !l.suspendedAt && l.membershipInitializedAt !== null)
+ .map((l) => l.installationId),
+ )
+ const hasUninitializedLink = links.some(
+ (l) => l.membershipInitializedAt === null,
+ )
+ return { ids, hasUninitializedLink }
+}
+
+export type ReassignBrokenRepositoryResult =
+ | { status: 'reassigned'; installationId: number }
+ | { status: 'no_candidates' }
+ | { status: 'ambiguous'; candidateCount: number }
+ | { status: 'not_broken' }
+
+/**
+ * Try to assign a canonical installation to a single repository whose
+ * `github_installation_id` is currently `NULL`. Used by the "Try auto-reassign"
+ * UI button and the `reassign-repository-installation` CLI command.
+ *
+ * Eligibility rules match {@link reassignCanonicalAfterLinkLoss}: candidate
+ * link must be active, non-suspended, and have `membership_initialized_at` set;
+ * membership row must be active.
+ *
+ * Returns a discriminated result so callers can show the appropriate UI:
+ * - `reassigned`: a single eligible candidate was found, repo is now fixed
+ * - `no_candidates`: no installation can see this repo; user must reinstall
+ * - `ambiguous`: 2+ candidates, manual choice needed
+ * - `not_broken`: repository already has a `github_installation_id` set
+ */
+export async function reassignBrokenRepository(input: {
+ organizationId: OrganizationId
+ repositoryId: string
+ source: Extract
+}): Promise {
+ const { organizationId, repositoryId, source } = input
+ const tenantDb = getTenantDb(organizationId)
+
+ const repo = await tenantDb
+ .selectFrom('repositories')
+ .select(['id', 'githubInstallationId'])
+ .where('id', '=', repositoryId)
+ .executeTakeFirst()
+ if (!repo) return { status: 'not_broken' }
+ if (repo.githubInstallationId !== null) return { status: 'not_broken' }
+
+ const { ids: eligibleSet } =
+ await fetchEligibleInstallationIds(organizationId)
+
+ const memberships = await tenantDb
+ .selectFrom('repositoryInstallationMemberships')
+ .select(['installationId'])
+ .where('repositoryId', '=', repositoryId)
+ .where('deletedAt', 'is', null)
+ .execute()
+ const candidates = memberships
+ .map((m) => m.installationId)
+ .filter((id) => eligibleSet.has(id))
+
+ if (candidates.length === 1) {
+ const nextCanonical = candidates[0]
+ await tenantDb
+ .updateTable('repositories')
+ .set({ githubInstallationId: nextCanonical })
+ .where('id', '=', repositoryId)
+ .execute()
+ await logGithubAppLinkEvent({
+ organizationId,
+ installationId: nextCanonical,
+ eventType: 'canonical_reassigned',
+ source,
+ status: 'success',
+ details: { repositoryId, candidateCount: 1, recoveredFromBroken: true },
+ })
+ return { status: 'reassigned', installationId: nextCanonical }
+ }
+
+ // Skip the audit log entry for the no-candidates / ambiguous cases: there is
+ // no installation to attribute the event to (and the audit table requires a
+ // non-null `installationId`). The function return value already conveys the
+ // outcome to the UI / CLI caller, which surfaces it via toast / console.
+ if (candidates.length === 0) return { status: 'no_candidates' }
+ return { status: 'ambiguous', candidateCount: candidates.length }
+}
+
/**
* Replace `repository.github_installation_id` when a link is lost. By default
* operates on every repository whose canonical is still `lostInstallationId`
@@ -46,21 +158,10 @@ export async function reassignCanonicalAfterLinkLoss(input: {
}): Promise {
const { organizationId, lostInstallationId, source, repositoryIds } = input
- const links = await db
- .selectFrom('githubAppLinks')
- .select(['installationId', 'suspendedAt', 'membershipInitializedAt'])
- .where('organizationId', '=', organizationId)
- .where('deletedAt', 'is', null)
- .where('installationId', '!=', lostInstallationId)
- .execute()
- const eligibleSet = new Set(
- links
- .filter((l) => !l.suspendedAt && l.membershipInitializedAt !== null)
- .map((l) => l.installationId),
- )
- const hasUninitializedLink = links.some(
- (l) => l.membershipInitializedAt === null,
- )
+ const { ids: eligibleSet, hasUninitializedLink } =
+ await fetchEligibleInstallationIds(organizationId, {
+ excludeInstallationId: lostInstallationId,
+ })
const tenantDb = getTenantDb(organizationId)
diff --git a/batch/cli.ts b/batch/cli.ts
index 9d06bbe0..f9ad2c13 100644
--- a/batch/cli.ts
+++ b/batch/cli.ts
@@ -145,6 +145,32 @@ const report = command(
},
)
+const reassignBrokenRepositories = command(
+ {
+ name: 'reassign-broken-repositories',
+ parameters: ['[organization id]'],
+ flags: {
+ repository: {
+ type: String,
+ description:
+ 'Reassign a single repository by id (default: every broken repo)',
+ },
+ },
+ help: {
+ description:
+ 'Reassign repositories whose canonical GitHub App installation was lost. Picks a new canonical from the membership table when exactly one eligible candidate exists.',
+ },
+ },
+ async (argv) => {
+ const { reassignBrokenRepositoriesCommand } =
+ await import('./commands/reassign-broken-repositories')
+ await reassignBrokenRepositoriesCommand({
+ organizationId: argv._.organizationId,
+ repositoryId: argv.flags.repository,
+ })
+ },
+)
+
process.on('unhandledRejection', async (error) => {
captureExceptionToSentry(error, { tags: { component: 'batch-cli' } })
await flushSentryNode()
@@ -152,5 +178,12 @@ process.on('unhandledRejection', async (error) => {
})
cli({
- commands: [crawl, processCmd, classify, backfill, report],
+ commands: [
+ crawl,
+ processCmd,
+ classify,
+ backfill,
+ report,
+ reassignBrokenRepositories,
+ ],
})
diff --git a/batch/commands/reassign-broken-repositories.ts b/batch/commands/reassign-broken-repositories.ts
new file mode 100644
index 00000000..d1eb78f9
--- /dev/null
+++ b/batch/commands/reassign-broken-repositories.ts
@@ -0,0 +1,84 @@
+import consola from 'consola'
+import { match } from 'ts-pattern'
+import { reassignBrokenRepository } from '~/app/services/github-app-membership.server'
+import { getTenantDb } from '~/app/services/tenant-db.server'
+import { requireOrganization } from './helpers'
+import { shutdown } from './shutdown'
+
+interface ReassignBrokenRepositoriesCommandProps {
+ organizationId?: string
+ repositoryId?: string
+}
+
+/**
+ * Operator command to recover repositories whose canonical
+ * `github_installation_id` was lost. Walks every broken repository in the org
+ * (or a single one if `repositoryId` is given) and asks
+ * {@link reassignBrokenRepository} to assign a new canonical from the
+ * membership table.
+ */
+export async function reassignBrokenRepositoriesCommand(
+ props: ReassignBrokenRepositoriesCommandProps,
+) {
+ const result = await requireOrganization(props.organizationId)
+ if (!result) return
+
+ const { orgId } = result
+ const tenantDb = getTenantDb(orgId)
+
+ try {
+ let query = tenantDb
+ .selectFrom('repositories')
+ .select(['id', 'owner', 'repo'])
+ .where('githubInstallationId', 'is', null)
+ if (props.repositoryId) {
+ query = query.where('id', '=', props.repositoryId)
+ }
+ const broken = await query.execute()
+
+ if (broken.length === 0) {
+ consola.info('No broken repositories found.')
+ return
+ }
+ consola.info(`Found ${broken.length} broken repositories. Reassigning...`)
+
+ const counts = {
+ reassigned: 0,
+ no_candidates: 0,
+ ambiguous: 0,
+ not_broken: 0,
+ }
+ for (const repo of broken) {
+ const label = `${repo.owner}/${repo.repo}`
+ try {
+ const outcome = await reassignBrokenRepository({
+ organizationId: orgId,
+ repositoryId: repo.id,
+ source: 'cli_repair',
+ })
+ counts[outcome.status]++
+ match(outcome)
+ .with({ status: 'reassigned' }, ({ installationId }) =>
+ consola.success(`${label} → installation ${installationId}`),
+ )
+ .with({ status: 'ambiguous' }, ({ candidateCount }) =>
+ consola.warn(
+ `${label}: ${candidateCount} candidates, manual disconnect required`,
+ ),
+ )
+ .with({ status: 'no_candidates' }, () =>
+ consola.warn(`${label}: no active installation can see this repo`),
+ )
+ .with({ status: 'not_broken' }, () =>
+ consola.info(`${label}: already assigned`),
+ )
+ .exhaustive()
+ } catch (e) {
+ consola.error(`${label}:`, e)
+ }
+ }
+ consola.info('Summary:', counts)
+ } finally {
+ await shutdown()
+ }
+}