Skip to content
Merged
Show file tree
Hide file tree
Changes from 122 commits
Commits
Show all changes
125 commits
Select commit Hold shift + click to select a range
c2d45c0
feat(github): enable shared web agent installations
pandemicsyn Sep 8, 2026
bb189ad
fix(github): harden shared installation races
pandemicsyn Sep 8, 2026
9e78435
fix(github): serialize shared callback cleanup
pandemicsyn Sep 8, 2026
a3e2582
fix(integrations): fence provider state mutations
pandemicsyn Sep 8, 2026
9cde1c7
fix(integrations): serialize provider state sessions
pandemicsyn Sep 8, 2026
7c7f87c
fix(integrations): restore provider session consistency
pandemicsyn Sep 8, 2026
a7c3c79
Revert "fix(integrations): restore provider session consistency"
pandemicsyn Sep 8, 2026
66e2100
Revert "fix(integrations): serialize provider state sessions"
pandemicsyn Sep 8, 2026
4e43c9b
Revert "fix(integrations): fence provider state mutations"
pandemicsyn Sep 8, 2026
602cc9e
feat(integrations): reserve provider OAuth attempts
pandemicsyn Sep 8, 2026
6d96f55
fix(integrations): lock OAuth reservations by owner row
pandemicsyn Sep 8, 2026
5c2801a
fix(integrations): fence provider OAuth state purpose
pandemicsyn Sep 8, 2026
eec4f26
test(integrations): harden OAuth reservation races
pandemicsyn Sep 8, 2026
da98aa3
test(integrations): prove OAuth owner-row blocking
pandemicsyn Sep 8, 2026
b533c8c
fix(github): serialize verified owner cardinality
pandemicsyn Sep 9, 2026
1d4ad35
test(integrations): include OAuth state issue time
pandemicsyn Sep 9, 2026
d67f75b
chore(db): regenerate shared GitHub migration
pandemicsyn Sep 9, 2026
1d2f970
fix(github): close shared runtime isolation gaps
pandemicsyn Sep 9, 2026
b4b1dfa
fix(github): fence exclusive lifecycle retries
pandemicsyn Sep 9, 2026
50c41b1
fix(github): close legacy authorization edges
pandemicsyn Sep 9, 2026
3a4208f
fix(github): fence direct token legacy paths
pandemicsyn Sep 9, 2026
a518526
fix(github): retry in-progress lifecycle receipts
pandemicsyn Sep 9, 2026
a26013b
refactor(github): simplify lifecycle cleanup
pandemicsyn Sep 10, 2026
fe7651d
fix(github): align repository refresh lock order
pandemicsyn Sep 10, 2026
454bb92
test(github): prove lifecycle lock overlap
pandemicsyn Sep 10, 2026
3fe432d
test(github): match exact advisory lock query
pandemicsyn Sep 10, 2026
884f872
fix(github): bind bot links to tenant association
pandemicsyn Sep 10, 2026
af1734d
fix(github): constrain legacy bot-link fallback
pandemicsyn Sep 10, 2026
57f5e26
feat(slack): fence shared installation activation
pandemicsyn Sep 11, 2026
233a4d0
feat(slack): delegate with exact GitHub connection
pandemicsyn Sep 11, 2026
b9aeab1
fix(slack): make installation activation recoverable
pandemicsyn Sep 11, 2026
27d5944
fix(slack): resolve all-access GitHub repositories
pandemicsyn Sep 11, 2026
300d02a
fix(slack): retire expired installation generations
pandemicsyn Sep 11, 2026
296df67
test(slack): reject unhealthy all-access GitHub links
pandemicsyn Sep 11, 2026
1d9b194
fix(slack): preserve cleanup reservations until revoke
pandemicsyn Sep 11, 2026
0636b8f
fix(slack): clean verified legacy token metadata
pandemicsyn Sep 11, 2026
4357a03
fix(slack): retain cleanup identity barriers
pandemicsyn Sep 11, 2026
8db09b0
fix(slack): bridge Enterprise workspace identities
pandemicsyn Sep 11, 2026
439013f
fix(slack): order Enterprise identity aliases
pandemicsyn Sep 11, 2026
41538c3
fix(slack): fence alias and cleanup ordering
pandemicsyn Sep 11, 2026
89ac167
fix(slack): persist cleanup and alias progress
pandemicsyn Sep 11, 2026
4eab6e0
fix(slack): defer shared Enterprise Grid support
pandemicsyn Sep 11, 2026
31ac813
fix(slack): isolate shared workspace activation
pandemicsyn Sep 11, 2026
3bf72f0
fix(slack): fence workspace sharing conversion
pandemicsyn Sep 11, 2026
b567d09
fix(slack): validate workspace conversion state
pandemicsyn Sep 11, 2026
185270d
fix(slack): route shared and cleanup states exactly
pandemicsyn Sep 12, 2026
f386ae0
fix(slack): preserve unshared runtime boundaries
pandemicsyn Sep 12, 2026
fd6c560
fix(slack): enforce runtime health boundaries
pandemicsyn Sep 12, 2026
d8eecc6
fix(slack): reserve successful unshared workspaces
pandemicsyn Sep 12, 2026
31e4964
fix(slack): finalize SDK effects with generation CAS
pandemicsyn Sep 12, 2026
8ac1656
fix(slack): separate workspace and Enterprise cleanup
pandemicsyn Sep 12, 2026
bfe544e
fix(slack): terminalize legacy cleanup safely
pandemicsyn Sep 12, 2026
6d67a1e
fix(slack): release terminal cleanup tombstones
pandemicsyn Sep 12, 2026
9bfcd8d
fix(slack): isolate webhook maintenance failures
pandemicsyn Sep 12, 2026
bf4f256
fix(slack): exclude Enterprise installs from backfill
pandemicsyn Sep 12, 2026
06f0792
fix(slack): validate Enterprise IDs and reset health
pandemicsyn Sep 12, 2026
0440374
fix(slack): make Enterprise activation success-only
pandemicsyn Sep 12, 2026
0d98e23
fix(slack): reconstruct Enterprise runtime state
pandemicsyn Sep 12, 2026
365a247
Revert "fix(slack): reconstruct Enterprise runtime state"
pandemicsyn Sep 12, 2026
a9f3db9
Revert "fix(slack): make Enterprise activation success-only"
pandemicsyn Sep 12, 2026
53c6893
Revert "fix(slack): validate Enterprise IDs and reset health"
pandemicsyn Sep 12, 2026
7677da4
Revert "fix(slack): exclude Enterprise installs from backfill"
pandemicsyn Sep 12, 2026
12ff9d3
Revert "fix(slack): isolate webhook maintenance failures"
pandemicsyn Sep 12, 2026
f2213c8
Revert "fix(slack): release terminal cleanup tombstones"
pandemicsyn Sep 12, 2026
0bf885a
Revert "fix(slack): terminalize legacy cleanup safely"
pandemicsyn Sep 12, 2026
a4765e4
Revert "fix(slack): separate workspace and Enterprise cleanup"
pandemicsyn Sep 12, 2026
316a94b
Revert "fix(slack): finalize SDK effects with generation CAS"
pandemicsyn Sep 12, 2026
86e22b0
Revert "fix(slack): reserve successful unshared workspaces"
pandemicsyn Sep 12, 2026
96b12da
Revert "fix(slack): enforce runtime health boundaries"
pandemicsyn Sep 12, 2026
030bf07
Revert "fix(slack): preserve unshared runtime boundaries"
pandemicsyn Sep 12, 2026
16bf7d0
Revert "fix(slack): route shared and cleanup states exactly"
pandemicsyn Sep 12, 2026
e2b28a7
Revert "fix(slack): validate workspace conversion state"
pandemicsyn Sep 12, 2026
68449c5
Revert "fix(slack): fence workspace sharing conversion"
pandemicsyn Sep 12, 2026
c351966
Revert "fix(slack): isolate shared workspace activation"
pandemicsyn Sep 12, 2026
72d2f0f
Revert "fix(slack): defer shared Enterprise Grid support"
pandemicsyn Sep 12, 2026
8eca867
Revert "fix(slack): persist cleanup and alias progress"
pandemicsyn Sep 12, 2026
efd7202
Revert "fix(slack): fence alias and cleanup ordering"
pandemicsyn Sep 12, 2026
b6048b6
Revert "fix(slack): order Enterprise identity aliases"
pandemicsyn Sep 12, 2026
dc43dcc
Revert "fix(slack): bridge Enterprise workspace identities"
pandemicsyn Sep 12, 2026
e0b32fa
Revert "fix(slack): retain cleanup identity barriers"
pandemicsyn Sep 12, 2026
5993f0c
Revert "fix(slack): clean verified legacy token metadata"
pandemicsyn Sep 12, 2026
73856a5
Revert "fix(slack): preserve cleanup reservations until revoke"
pandemicsyn Sep 12, 2026
fb1b7ce
Revert "test(slack): reject unhealthy all-access GitHub links"
pandemicsyn Sep 12, 2026
4fd2f6d
Revert "fix(slack): retire expired installation generations"
pandemicsyn Sep 12, 2026
8c22ca7
Revert "fix(slack): resolve all-access GitHub repositories"
pandemicsyn Sep 12, 2026
d2bcb9e
Revert "fix(slack): make installation activation recoverable"
pandemicsyn Sep 12, 2026
6d824dc
Revert "feat(slack): fence shared installation activation"
pandemicsyn Sep 12, 2026
d617c5c
test(slack): verify minimal shared delegation
pandemicsyn Sep 12, 2026
3ea54c5
test(slack): preserve unsupported provider fences
pandemicsyn Sep 12, 2026
527ce8a
test(slack): tighten minimal sharing fixtures
pandemicsyn Sep 12, 2026
7a78158
fix(slack): validate cached GitHub repositories
pandemicsyn Sep 13, 2026
ec51eee
fix(db): reconcile orphans before validating github FK
pandemicsyn Sep 14, 2026
1158178
fix(github): only record webhook receipt on 2xx dispatch
pandemicsyn Sep 14, 2026
731e775
fix(integrations): supersede unexpired pending OAuth attempts
pandemicsyn Sep 14, 2026
242eb98
fix(cloud-agent): dedupe exact-duplicate repository entries
pandemicsyn Sep 14, 2026
296112d
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
0e1c8b1
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 14, 2026
50b7505
fix(slack): accept adapter-written repository cache
pandemicsyn Sep 14, 2026
09238be
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
015fd21
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 14, 2026
805e161
fix(github): harden shared delivery and repository validation
pandemicsyn Sep 14, 2026
12b0e56
fix(cloud-agent): repair personal GitHub session start and stale refresh
pandemicsyn Sep 14, 2026
866465c
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
85e48a8
fix(github): reclaim stale installation delivery claims
pandemicsyn Sep 14, 2026
4a7c23c
Merge remote-tracking branch 'origin/feat/github-connections-pr2' int…
pandemicsyn Sep 14, 2026
5e72145
fix(github): enforce webhook delivery-claim window
pandemicsyn Sep 14, 2026
9710652
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
5776bde
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 14, 2026
6b2e740
fix(db): make github FK migration crash-safe and guard orphan reconci…
pandemicsyn Sep 14, 2026
3dcd749
fix(db): make concurrent index builds in 0245 retry-safe
pandemicsyn Sep 14, 2026
79025d2
Merge PR2 into PR3 and renumber receipt migration to 0247
pandemicsyn Sep 14, 2026
1465240
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 14, 2026
a332616
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 14, 2026
169563f
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 15, 2026
616f690
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 15, 2026
81628ee
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 15, 2026
ea8d5ed
fix(github): restore exact-id shared runtime authorization lost in th…
pandemicsyn Sep 15, 2026
9e7a7ce
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 15, 2026
a2fb46e
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 15, 2026
3ae05d4
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 15, 2026
f352b0c
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 15, 2026
0f65542
Merge branch 'feat/github-connections-pr2' into feat/github-connectio…
pandemicsyn Sep 15, 2026
d6b6b79
Merge branch 'main' into feat/github-connections-pr2
pandemicsyn Sep 16, 2026
6a6e30a
Merge PR2 into PR3: adopt renumbered migrations 0245-0249
pandemicsyn Sep 16, 2026
45f15ad
Merge main: reconcile PR3 hardening with squash-merged PR2
pandemicsyn Sep 16, 2026
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
3 changes: 3 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `GITHUB_LITE_APP_PRIVATE_KEY` - Private key for the lite GitHub App. `[SECRET]`
- `GITHUB_LITE_APP_CLIENT_ID` - OAuth Client ID for the lite GitHub App install/login flow. [PUBLIC]
- `GITHUB_MULTIPLE_INSTALLATION_ORGANIZATION_IDS` - Comma-separated Kilo organization UUIDs allowed to connect multiple GitHub App installations. Unset or empty disables multiple installations for all organizations. [SERVER]
- `GITHUB_SHARED_INSTALLATION_ORGANIZATION_IDS` - Comma-separated destination Kilo organization UUIDs allowed to create an association to a GitHub App installation already associated elsewhere. Unset or empty disables new shared associations without revoking existing ones. [SERVER]
- `GITHUB_CONNECTION_MANAGEMENT_ENABLED` - Set to exact `true` to admit new existing-installation connection management and local disconnect. Unset or any other value keeps new management admission disabled without changing incumbent GitHub integration workflows. [SERVER]
- Keep disabled for at least one OAuth state TTL (10 minutes) after deploying reservation-aware callbacks so purpose-less states issued by the previous version can complete.
- During the migration-to-app promotion window, old pending-install callbacks may fail against the replaced pending indexes. Keep the window brief, monitor deploy health, and retry the GitHub connection after promotion completes.
- `PER_REPO_SETTINGS` - Set to exactly `true` to reveal the Repository Customizations UI (per-installation default AI model / PR review mode, plus per-repository overrides) on the GitHub integration settings pages, for both personal accounts and organizations. Defaults to disabled so the feature can ship dark. [SERVER]
- `GITHUB_ADMIN_STATS_TOKEN` - Token for admin GitHub API stats lookups; used in `apps/web/src/scripts/backfill-pr-author-github-ids.ts`. `[SECRET]`
- `GITHUB_CLI_PAT` - GitHub personal access token for `gh` CLI operations inside contractors; used in `services/gastown/container/src/process-manager.ts`. `[SECRET]`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe('GET /api/integrations/bitbucket/connect', () => {
owner: `org_${ORGANIZATION_ID}`,
userId: USER_ID,
returnTo: `/organizations/${ORGANIZATION_ID}/integrations/bitbucket`,
issuedAt: expect.any(Number),
});
expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith(
{ user: expect.objectContaining({ id: USER_ID }) },
Expand Down
134 changes: 106 additions & 28 deletions apps/web/src/app/api/integrations/github/callback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { eq } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
import type { Owner } from '@/lib/integrations/core/types';
import {
findGitHubBotLinkIntegrations,
findIntegrationByInstallationId,
findIntegrationByInstallationIdForOwner,
upsertPlatformIntegrationForOwner,
Expand Down Expand Up @@ -85,6 +86,7 @@ jest.mock('@/routers/organizations/utils', () => ({
}));
jest.mock('@/lib/integrations/db/platform-integrations', () => ({
createPendingIntegration: jest.fn(),
findGitHubBotLinkIntegrations: jest.fn(),
findIntegrationByInstallationId: jest.fn(),
findIntegrationByInstallationIdForOwner: jest.fn(),
findPendingInstallationByRequesterId: jest.fn(),
Expand Down Expand Up @@ -113,6 +115,7 @@ const mockedExchangeGitHubOAuthCode = jest.mocked(exchangeGitHubOAuthCode);
const mockedLinkKiloUser = jest.mocked(linkKiloUser);
const mockedBot = jest.mocked(bot);
const mockedFindIntegrationByInstallationId = jest.mocked(findIntegrationByInstallationId);
const mockedFindGitHubBotLinkIntegrations = jest.mocked(findGitHubBotLinkIntegrations);
const mockedFindIntegrationByInstallationIdForOwner = jest.mocked(
findIntegrationByInstallationIdForOwner
);
Expand Down Expand Up @@ -160,6 +163,18 @@ beforeEach(() => {
github_app_type: 'standard',
}) as never
);
mockedFindGitHubBotLinkIntegrations.mockImplementation(
async () =>
[
{
id: '00000000-0000-4000-8000-000000000099',
owned_by_user_id: writtenOwner.type === 'user' ? writtenOwner.id : null,
owned_by_organization_id: writtenOwner.type === 'org' ? writtenOwner.id : null,
platform_installation_id: INSTALLATION_ID,
github_app_type: 'standard',
},
] as never
);
mockedFindIntegrationByInstallationIdForOwner.mockImplementation(
async () =>
({
Expand Down Expand Up @@ -197,6 +212,8 @@ function expectRedirectLocation(response: Response, expectedPathWithQuery: strin
describe('GET /api/integrations/github/callback bot link flow', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.GITHUB_CONNECTION_MANAGEMENT_ENABLED = 'false';
process.env.GITHUB_SHARED_INSTALLATION_ORGANIZATION_IDS = '';

mockedGetUserFromAuth.mockResolvedValue({
user: { id: USER_ID },
Expand All @@ -206,6 +223,7 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
mockedExchangeGitHubOAuthCode.mockResolvedValue({
id: GITHUB_USER_ID,
Expand All @@ -218,6 +236,15 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
github_app_type: 'standard',
metadata: null,
} as never);
mockedFindGitHubBotLinkIntegrations.mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000099',
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
platform_installation_id: INSTALLATION_ID,
github_app_type: 'standard',
} as never,
]);
mockedIsOrganizationMember.mockResolvedValue(true);
});

Expand Down Expand Up @@ -281,11 +308,11 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
await expect(response.text()).resolves.toContain(
'not a member of the organization that owns this GitHub integration'
);
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith(
'github',
INSTALLATION_ID,
'standard'
);
expect(mockedFindGitHubBotLinkIntegrations).toHaveBeenCalledWith({
installationId: INSTALLATION_ID,
appType: 'standard',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});
Expand All @@ -299,11 +326,11 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
expect(response.status).toBe(200);
await expect(response.text()).resolves.toContain('GitHub account octocat has been linked');
expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'standard');
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith(
'github',
INSTALLATION_ID,
'standard'
);
expect(mockedFindGitHubBotLinkIntegrations).toHaveBeenCalledWith({
installationId: INSTALLATION_ID,
appType: 'standard',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
expect(mockedIsOrganizationMember).toHaveBeenCalledWith('org_1', USER_ID);
expect(mockedBot.initialize).toHaveBeenCalled();
expect(mockedLinkKiloUser).toHaveBeenCalledWith(
Expand All @@ -318,27 +345,80 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
);
});

test('allows one healthy legacy no-ID state while management and sharing are disabled', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);
expect(response.status).toBe(200);
expect(mockedFindGitHubBotLinkIntegrations).toHaveBeenCalledWith({
installationId: INSTALLATION_ID,
appType: 'standard',
platformIntegrationId: undefined,
});
});

test('rejects a no-ID state after connection management is enabled', async () => {
process.env.GITHUB_CONNECTION_MANAGEMENT_ENABLED = 'true';
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);
expect(response.status).toBe(404);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
});

test('rejects a no-ID state after shared-installation admission is enabled', async () => {
process.env.GITHUB_SHARED_INSTALLATION_ORGANIZATION_IDS =
'00000000-0000-4000-8000-000000000001';
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);
expect(response.status).toBe(404);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
});

test('routes a Lite bot-link callback through the Lite app identity', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
githubAppType: 'lite',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
github_app_type: 'lite',
} as never);
mockedFindGitHubBotLinkIntegrations.mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000099',
owned_by_organization_id: 'org_1',
github_app_type: 'lite',
} as never,
]);
const { GET } = await import('./route');
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);
expect(response.status).toBe(200);
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith(
'github',
INSTALLATION_ID,
'lite'
);
expect(mockedFindGitHubBotLinkIntegrations).toHaveBeenCalledWith({
installationId: INSTALLATION_ID,
appType: 'lite',
platformIntegrationId: '00000000-0000-4000-8000-000000000099',
});
expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'lite');
expect(mockedLinkKiloUser).toHaveBeenCalledWith(
mockState,
Expand All @@ -347,18 +427,16 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
);
});

test("exchanges the OAuth code against the integration's github_app_type", async () => {
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
github_app_type: 'lite',
metadata: null,
} as never);
test('rejects an integration whose app type does not match signed state', async () => {
mockedFindGitHubBotLinkIntegrations.mockResolvedValue([]);

const { GET } = await import('./route');
await GET(makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never);
const response = await GET(
makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never
);

expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'lite');
expect(response.status).toBe(404);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
});
});

Expand Down
28 changes: 21 additions & 7 deletions apps/web/src/app/api/integrations/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { ensureOrganizationAccess } from '@/routers/organizations/utils';
import {
createPendingIntegration,
findIntegrationByInstallationId,
findGitHubBotLinkIntegrations,
findIntegrationByInstallationIdForOwner,
upsertPlatformIntegrationForOwner,
} from '@/lib/integrations/db/platform-integrations';
Expand All @@ -47,6 +47,7 @@ import {
type InstallStateRejectionReason,
} from '@/lib/integrations/github/install-state';
import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations';
import { isGitHubSharedInstallationAdmissionEnabled } from '@/lib/integrations/github/multiple-installations';

const appendQueryParam = (path: string, queryParam: string): string =>
`${path}${path.includes('?') ? '&' : '?'}${queryParam}`;
Expand Down Expand Up @@ -102,11 +103,18 @@ async function handleGitHubBotLinkCallback(request: NextRequest, user: { id: str
}

const stateAppType = state.githubAppType ?? 'standard';
const integration = await findIntegrationByInstallationId(
PLATFORM.GITHUB,
state.installationId,
stateAppType
);
const usableCandidates = await findGitHubBotLinkIntegrations({
installationId: state.installationId,
appType: stateAppType,
platformIntegrationId: state.platformIntegrationId,
});
const legacyAllowed =
!isGitHubConnectionManagementEnabled() && !isGitHubSharedInstallationAdmissionEnabled();
const integration = state.platformIntegrationId
? usableCandidates[0]
: legacyAllowed && usableCandidates.length === 1
? usableCandidates[0]
: undefined;

if (!integration) {
return htmlPage('Link Failed', 'No matching GitHub integration was found.', 404);
Expand Down Expand Up @@ -677,7 +685,13 @@ async function handleCoreInstallFlow(params: {
const error =
upsertResult.reason === 'multiple_installations_disabled'
? 'multiple_installations_disabled'
: 'installation_already_claimed';
: upsertResult.reason === 'shared_installation_disabled'
? 'shared_installation_disabled'
: upsertResult.reason === 'incompatible_workflow'
? 'incompatible_workflow'
: upsertResult.reason === 'retryable_conflict'
? 'connection_temporarily_unavailable'
: 'installation_already_claimed';
if (isAppInitiated) {
return NextResponse.redirect(new URL(appFallbackPath(`error=${error}`), APP_URL));
}
Expand Down
Loading