Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8818bbb
feat(bot): add GitHub adapter
RSO May 4, 2026
f64eb78
fix(bot): mirror GitHub webhooks to adapter
RSO May 5, 2026
a3a1f58
Simplify
RSO May 5, 2026
ecb6a2a
Set proper username for mention-detection
RSO May 5, 2026
49b6cf2
Fix model slug lookup
RSO May 5, 2026
b05858a
feat(bot): add GitHub issue context
RSO May 5, 2026
5c1fa46
Set logger level for dev
RSO May 5, 2026
e8cee64
Different branch for github
RSO May 5, 2026
76f84cd
fix(bot): secure GitHub account linking
RSO May 5, 2026
0713859
fix(bot): reuse GitHub app callback for linking
RSO May 5, 2026
f748cad
test(bot): fix GitHub webhook CI expectations
RSO May 5, 2026
7eb6d96
Clean up stupid code
RSO May 5, 2026
d0cb4e0
Clean up platformIdentity retrieval
RSO May 5, 2026
676296d
Fix types
RSO May 5, 2026
e5e6f5b
Clean up
RSO May 5, 2026
dcb8ff5
nit
RSO May 5, 2026
311ae37
fix(bot): preserve GitHub webhook body
RSO May 5, 2026
8ec79eb
fix(bot): authorize GitHub account links
RSO May 5, 2026
b3cced2
fix(bot): improve GitHub context
RSO May 5, 2026
f521ad9
fix(bot): break platform helper cycle
RSO May 5, 2026
b0d367b
fix(bot): satisfy link prompt lint
RSO May 5, 2026
89791bb
fix(bot): capture GitHub webhook adapter errors
RSO May 5, 2026
b62a141
refactor(bot): simplify recent issue comment fetching
RSO May 5, 2026
12a31cb
fix(bot): cap GitHub review comment pagination
RSO May 5, 2026
0cb4fc4
fix(bot): use integration github_app_type for bot account links
RSO May 5, 2026
39e0ec0
fix(bot): scope GitHub account links per installation
RSO May 6, 2026
e8b865a
feat(bot): gate GitHub bot path on metadata.bot_enabled canary flag
RSO May 6, 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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.1009.0",
"@chat-adapter/github": "4.27.0",
"@chat-adapter/slack": "^4.27.0",
"@chat-adapter/state-memory": "^4.27.0",
"@chat-adapter/state-redis": "^4.27.0",
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/app/api/chat/link-account/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, test } from '@jest/globals';
import { NextRequest } from 'next/server';
import { bot } from '@/lib/bot';
import { verifyLinkToken, linkKiloUser } from '@/lib/bot-identity';
import { getUserFromAuth } from '@/lib/user.server';
import { getPlatformIntegration } from '@/lib/bot/platform-helpers';
import { PLATFORM } from '@/lib/integrations/core/constants';
import type { SerializedMessage } from 'chat';

const mockedAfter = jest.fn();

jest.mock('next/server', () => {
const actual = jest.requireActual('next/server');
return {
...actual,
after: (fn: () => Promise<void> | void) => mockedAfter(fn),
};
});
jest.mock('@/lib/bot', () => ({
bot: {
initialize: jest.fn(async () => undefined),
getState: jest.fn(() => ({ kind: 'state' })),
},
}));
jest.mock('@/lib/bot-identity', () => ({
verifyLinkToken: jest.fn(),
linkKiloUser: jest.fn(async () => undefined),
consumeLinkAccountContext: jest.fn(async () => true),
}));
jest.mock('@/lib/user.server');
jest.mock('@/lib/bot/platform-helpers');
jest.mock('@/lib/organizations/organizations', () => ({
isOrganizationMember: jest.fn(async () => true),
}));
jest.mock('@/lib/bot/run', () => ({
processLinkedMessage: jest.fn(async () => undefined),
}));
jest.mock('@/lib/bot/platform-auth-context', () => ({
withBotPlatformAuthContext: jest.fn(async (_integration, callback) => callback()),
}));
jest.mock(
'chat',
() => ({
Message: {
fromJSON: jest.fn(value => value),
},
ThreadImpl: {
fromJSON: jest.fn(value => value),
},
}),
{ virtual: true }
);
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
}));

const mockedBot = jest.mocked(bot);
const mockedVerifyLinkToken = jest.mocked(verifyLinkToken);
const mockedLinkKiloUser = jest.mocked(linkKiloUser);
const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedGetPlatformIntegration = jest.mocked(getPlatformIntegration);

function makeRequest(pathWithQuery: string) {
return new NextRequest(`http://localhost:3000${pathWithQuery}`);
}

describe('GET /api/chat/link-account', () => {
beforeEach(() => {
jest.clearAllMocks();

mockedGetUserFromAuth.mockResolvedValue({
user: { id: 'kilo-user-id' },
authFailedResponse: null,
} as never);
mockedGetPlatformIntegration.mockResolvedValue({
owned_by_user_id: 'kilo-user-id',
owned_by_organization_id: null,
} as never);
});

test('rejects GitHub link token payloads before linking', async () => {
mockedVerifyLinkToken.mockResolvedValue({
contextKey: 'context-key',
identity: { platform: PLATFORM.GITHUB, teamId: '98765', userId: '12345' },
thread: {
_type: 'chat:Thread',
adapterName: 'github',
channelId: 'github:acme/widgets',
id: 'github:acme/widgets:issue:1',
isDM: false,
},
message: {
_type: 'chat:Message',
attachments: [],
author: {
fullName: 'octocat',
isBot: false,
isMe: false,
userId: '12345',
userName: 'octocat',
},
formatted: { type: 'root', children: [] },
id: 'm_1',
metadata: {
dateSent: '2026-05-05T07:32:52.000Z',
edited: false,
},
raw: {},
text: '@kilocode-dev fix this',
threadId: 'github:acme/widgets:issue:1',
} satisfies SerializedMessage,
});

const { GET } = await import('./route');
const response = await GET(makeRequest('/api/chat/link-account?token=signed') as never);

expect(response.status).toBe(400);
await expect(response.text()).resolves.toContain('GitHub account links must be created');
expect(mockedBot.initialize).toHaveBeenCalled();
expect(mockedGetUserFromAuth).not.toHaveBeenCalled();
expect(mockedGetPlatformIntegration).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
expect(mockedAfter).not.toHaveBeenCalled();
});
});
9 changes: 9 additions & 0 deletions apps/web/src/app/api/chat/link-account/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { processLinkedMessage } from '@/lib/bot/run';
import { withBotPlatformAuthContext } from '@/lib/bot/platform-auth-context';
import { Message, ThreadImpl, type Thread } from 'chat';
import type { User } from '@kilocode/db';
import { PLATFORM } from '@/lib/integrations/core/constants';

function errorPage(title: string, message: string, status: number): Response {
return new Response(
Expand Down Expand Up @@ -100,6 +101,14 @@ export async function GET(request: Request) {

const { contextKey, identity, thread, message } = linkPayload;

if (identity.platform === PLATFORM.GITHUB) {
return errorPage(
'Link Not Supported',
'GitHub account links must be created from the GitHub link page.',
400
);
}

// Authenticate — redirect to sign-in if no session, then back here
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) {
Expand Down
229 changes: 229 additions & 0 deletions apps/web/src/app/api/integrations/github/callback/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, test } from '@jest/globals';
import { NextRequest, NextResponse } from 'next/server';
import { getUserFromAuth } from '@/lib/user.server';
import { verifyGitHubBotLinkState } from '@/lib/bot/github-link-state';
import { exchangeGitHubOAuthCode } from '@/lib/integrations/platforms/github/adapter';
import { linkKiloUser } from '@/lib/bot-identity';
import { bot } from '@/lib/bot';
import { failureResult } from '@/lib/maybe-result';
import { findIntegrationByInstallationId } from '@/lib/integrations/db/platform-integrations';
import { isOrganizationMember } from '@/lib/organizations/organizations';
import type { StateAdapter } from 'chat';

const mockState = { kind: 'state' } as unknown as StateAdapter;

jest.mock('@/lib/user.server');
jest.mock('@/lib/bot/github-link-state');
jest.mock('@/lib/bot-identity');
jest.mock('@/lib/integrations/platforms/github/adapter');
jest.mock('@/lib/bot', () => ({
bot: {
initialize: jest.fn(async () => undefined),
getState: jest.fn(() => mockState),
},
}));
jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => ({
apps: {
getInstallation: jest.fn(),
listReposAccessibleToInstallation: jest.fn(),
},
})),
}));
jest.mock('@octokit/auth-app', () => ({
createAppAuth: jest.fn(),
}));
jest.mock('@/lib/integrations/platforms/github/app-selector', () => ({
getGitHubAppTypeForOrganization: jest.fn(async () => 'standard'),
getGitHubAppCredentials: jest.fn(() => ({
appId: 'app-id',
privateKey: 'private-key',
clientId: 'client-id',
clientSecret: 'client-secret',
appName: 'KiloConnect',
webhookSecret: 'webhook-secret',
})),
}));
jest.mock('@/routers/organizations/utils', () => ({
ensureOrganizationAccess: jest.fn(),
}));
jest.mock('@/lib/integrations/db/platform-integrations', () => ({
createPendingIntegration: jest.fn(),
findIntegrationByInstallationId: jest.fn(),
findPendingInstallationByRequesterId: jest.fn(),
upsertPlatformIntegrationForOwner: jest.fn(),
}));
jest.mock('@/lib/organizations/organizations', () => ({
isOrganizationMember: jest.fn(),
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
}));

const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedVerifyGitHubBotLinkState = jest.mocked(verifyGitHubBotLinkState);
const mockedExchangeGitHubOAuthCode = jest.mocked(exchangeGitHubOAuthCode);
const mockedLinkKiloUser = jest.mocked(linkKiloUser);
const mockedBot = jest.mocked(bot);
const mockedFindIntegrationByInstallationId = jest.mocked(findIntegrationByInstallationId);
const mockedIsOrganizationMember = jest.mocked(isOrganizationMember);

const USER_ID = '034489e8-19e0-4479-9d69-2edad719e847';
const OTHER_USER_ID = 'c00b91a1-6959-4b04-9ef8-e8d37b340f4a';
const GITHUB_USER_ID = '12345';
const INSTALLATION_ID = '98765';

function makeRequest(pathWithQuery: string) {
return new NextRequest(`http://localhost:3000${pathWithQuery}`);
}

function expectRedirectLocation(response: Response, expectedPathWithQuery: string) {
const location = response.headers.get('location');
expect(location).toBeTruthy();
const url = new URL(location ?? '');
expect(`${url.pathname}${url.search}`).toBe(expectedPathWithQuery);
}

describe('GET /api/integrations/github/callback bot link flow', () => {
beforeEach(() => {
jest.clearAllMocks();

mockedGetUserFromAuth.mockResolvedValue({
user: { id: USER_ID },
authFailedResponse: null,
} as never);
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
});
mockedExchangeGitHubOAuthCode.mockResolvedValue({ id: GITHUB_USER_ID, login: 'octocat' });
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
github_app_type: 'standard',
metadata: { bot_enabled: true },
} as never);
mockedIsOrganizationMember.mockResolvedValue(true);
});

test('redirects unauthenticated bot-link callbacks to existing callback auth fallback', async () => {
mockedGetUserFromAuth.mockResolvedValue({
user: null,
authFailedResponse: NextResponse.json(failureResult('Unauthorized'), { status: 401 }),
} 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(307);
expectRedirectLocation(response, '/');
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});

test('rejects invalid bot-link state without running installation callback logic', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue(null);

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

expect(response.status).toBe(307);
expectRedirectLocation(response, '/');
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});

test('rejects bot-link state user mismatches', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: OTHER_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(403);
await expect(response.text()).resolves.toContain('started by another Kilo user');
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});

test('rejects bot-link callbacks when the Kilo user cannot access the integration owner', async () => {
mockedIsOrganizationMember.mockResolvedValue(false);

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

expect(response.status).toBe(403);
await expect(response.text()).resolves.toContain(
'not a member of the organization that owns this GitHub integration'
);
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith('github', INSTALLATION_ID);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});

test('links the OAuth-verified GitHub user per installation', async () => {
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);
await expect(response.text()).resolves.toContain('GitHub account octocat has been linked');
expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'standard');
expect(mockedFindIntegrationByInstallationId).toHaveBeenCalledWith('github', INSTALLATION_ID);
expect(mockedIsOrganizationMember).toHaveBeenCalledWith('org_1', USER_ID);
expect(mockedBot.initialize).toHaveBeenCalled();
expect(mockedLinkKiloUser).toHaveBeenCalledWith(
mockState,
{ platform: 'github', teamId: INSTALLATION_ID, userId: GITHUB_USER_ID },
USER_ID
);
});

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: { bot_enabled: true },
} as never);

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

expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'lite');
});

test('rejects bot-link callbacks for integrations without bot_enabled metadata', async () => {
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
github_app_type: 'standard',
metadata: null,
} 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(404);
await expect(response.text()).resolves.toContain(
'GitHub linking is not enabled for this integration'
);
expect(mockedExchangeGitHubOAuthCode).not.toHaveBeenCalled();
expect(mockedLinkKiloUser).not.toHaveBeenCalled();
});
});
Loading