Skip to content
Draft
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
54 changes: 13 additions & 41 deletions apps/web/src/app/api/chat/link-account/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { bot } from '@/lib/bot';
import { APP_URL } from '@/lib/constants';
import { captureException } from '@sentry/nextjs';
import { after } from 'next/server';
import {
consumeLinkAccountContext,
Expand All @@ -14,9 +13,8 @@ import {
getPlatformIntegration,
} from '@/lib/bot/platform-helpers';
import { botPlatforms } from '@/lib/bot/platforms';
import { processLinkedMessage } from '@/lib/bot/run';
import { Message, ThreadImpl, type Thread } from 'chat';
import type { User } from '@kilocode/db';
import { reprocessLinkedMessage } from '@/lib/bot/run';
import { Message, ThreadImpl } from 'chat';

function errorPage(title: string, message: string, status: number): Response {
return new Response(
Expand Down Expand Up @@ -115,9 +113,17 @@ export async function GET(request: Request) {
await linkKiloUser(bot.getState(), identity, user.id);

if (await consumeLinkAccountContext(bot.getState(), contextKey)) {
after(() =>
reprocessLinkedMessage(identity, ThreadImpl.fromJSON(thread), Message.fromJSON(message), user)
);
const platformIntegration = await getPlatformIntegration(identity);
if (platformIntegration) {
after(() =>
reprocessLinkedMessage({
platformIntegration,
thread: ThreadImpl.fromJSON(thread),
message: Message.fromJSON(message),
user,
})
);
}
}

return new Response(
Expand All @@ -133,37 +139,3 @@ export async function GET(request: Request) {
{ headers: { 'content-type': 'text/html; charset=utf-8' } }
);
}

async function reprocessLinkedMessage(
identity: PlatformIdentity,
thread: Thread,
message: Message,
user: User
): Promise<void> {
try {
const platformIntegration = await getPlatformIntegration(identity);
if (!platformIntegration) return;

await botPlatforms.require(platformIntegration.platform).withAuthContext({
platformIntegration,
fn: async () => {
await processLinkedMessage({
thread,
message,
platformIntegration,
user,
});
},
});
} catch (error) {
console.error('[Bot] Failed to reprocess linked message:', error);
captureException(error, {
tags: { component: 'kilo-bot', op: 'link-account-reprocess-message' },
extra: {
threadId: thread.id,
messageId: message.id,
userId: user.id,
},
});
}
}
151 changes: 149 additions & 2 deletions apps/web/src/app/api/integrations/github/callback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,35 @@ 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 {
consumeLinkAccountContext,
linkKiloUser,
readLinkAccountContext,
} from '@/lib/bot-identity';
import { bot } from '@/lib/bot';
import { reprocessLinkedMessage } from '@/lib/bot/run';
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';
import type { SerializedMessage, SerializedThread, StateAdapter } from 'chat';

const mockState = { kind: 'state' } as unknown as StateAdapter;
const mockIsEnabledForBot = jest.fn();
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/user.server');
jest.mock('@/lib/bot/github-link-state');
jest.mock('@/lib/bot-identity');
jest.mock('@/lib/bot/run', () => ({
reprocessLinkedMessage: jest.fn(async () => undefined),
}));
jest.mock('@/lib/integrations/platforms/github/adapter');
jest.mock('@/lib/bot', () => ({
bot: {
Expand Down Expand Up @@ -66,11 +82,22 @@ jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
}));
jest.mock(
'chat',
() => ({
Message: { fromJSON: jest.fn(value => value) },
ThreadImpl: { fromJSON: jest.fn(value => value) },
}),
{ virtual: true }
);

const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedVerifyGitHubBotLinkState = jest.mocked(verifyGitHubBotLinkState);
const mockedExchangeGitHubOAuthCode = jest.mocked(exchangeGitHubOAuthCode);
const mockedLinkKiloUser = jest.mocked(linkKiloUser);
const mockedReadLinkAccountContext = jest.mocked(readLinkAccountContext);
const mockedConsumeLinkAccountContext = jest.mocked(consumeLinkAccountContext);
const mockedReprocessLinkedMessage = jest.mocked(reprocessLinkedMessage);
const mockedBot = jest.mocked(bot);
const mockedFindIntegrationByInstallationId = jest.mocked(findIntegrationByInstallationId);
const mockedIsOrganizationMember = jest.mocked(isOrganizationMember);
Expand Down Expand Up @@ -103,6 +130,7 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
contextKey: null,
});
mockedExchangeGitHubOAuthCode.mockResolvedValue({ id: GITHUB_USER_ID, login: 'octocat' });
mockedFindIntegrationByInstallationId.mockResolvedValue({
Expand All @@ -113,6 +141,8 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
} as never);
mockedIsOrganizationMember.mockResolvedValue(true);
mockIsEnabledForBot.mockReturnValue(true);
mockedReadLinkAccountContext.mockResolvedValue(null);
mockedConsumeLinkAccountContext.mockResolvedValue(true);
});

test('redirects unauthenticated bot-link callbacks to existing callback auth fallback', async () => {
Expand Down Expand Up @@ -150,6 +180,7 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
userId: OTHER_USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
contextKey: null,
});

const { GET } = await import('./route');
Expand Down Expand Up @@ -213,6 +244,122 @@ describe('GET /api/integrations/github/callback bot link flow', () => {
expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'lite');
});

test('replays the original mention when the link state carries a context key', async () => {
const integration = {
owned_by_organization_id: 'org_1',
owned_by_user_id: null,
github_app_type: 'standard',
metadata: { bot_enabled: true },
};
const thread: SerializedThread = {
_type: 'chat:Thread',
adapterName: 'github',
channelId: 'github:acme/widgets',
id: 'github:acme/widgets:issue:42',
isDM: false,
};
const message: SerializedMessage = {
_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 fix this',
threadId: 'github:acme/widgets:issue:42',
};

mockedFindIntegrationByInstallationId.mockResolvedValue(integration as never);
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
contextKey: 'link-account-context:abc',
});
mockedReadLinkAccountContext.mockResolvedValue({ thread, message });
mockedConsumeLinkAccountContext.mockResolvedValue(true);

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('Kilo is already processing your message');
expect(mockedLinkKiloUser).toHaveBeenCalled();
expect(mockedReadLinkAccountContext).toHaveBeenCalledWith(
mockState,
'link-account-context:abc'
);
expect(mockedConsumeLinkAccountContext).toHaveBeenCalledWith(
mockState,
'link-account-context:abc'
);
expect(mockedAfter).toHaveBeenCalledTimes(1);
const scheduled = mockedAfter.mock.calls[0][0] as () => Promise<void>;
await scheduled();
expect(mockedReprocessLinkedMessage).toHaveBeenCalledWith(
expect.objectContaining({
platformIntegration: integration,
thread,
message,
user: expect.objectContaining({ id: USER_ID }),
})
);
});

test('skips replay when the context was already consumed (duplicate OAuth callback)', async () => {
mockedVerifyGitHubBotLinkState.mockReturnValue({
userId: USER_ID,
installationId: INSTALLATION_ID,
callbackPath: '/github/link',
contextKey: 'link-account-context:abc',
});
mockedReadLinkAccountContext.mockResolvedValue({
thread: {
_type: 'chat:Thread',
adapterName: 'github',
channelId: 'github:acme/widgets',
id: 'github:acme/widgets:issue:42',
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 fix this',
threadId: 'github:acme/widgets:issue:42',
},
});
mockedConsumeLinkAccountContext.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(200);
await expect(response.text()).resolves.toContain('mention Kilo again');
expect(mockedAfter).not.toHaveBeenCalled();
});

test('rejects bot-link callbacks for integrations without bot_enabled metadata', async () => {
mockedFindIntegrationByInstallationId.mockResolvedValue({
owned_by_organization_id: 'org_1',
Expand Down
49 changes: 45 additions & 4 deletions apps/web/src/app/api/integrations/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { NextResponse, after } from 'next/server';
import { getUserFromAuth } from '@/lib/user.server';
import { Octokit } from '@octokit/rest';
import { createAppAuth } from '@octokit/auth-app';
Expand All @@ -22,11 +22,18 @@ import type {
} from '@/lib/integrations/core/types';
import { captureException, captureMessage } from '@sentry/nextjs';
import { verifyGitHubBotLinkState } from '@/lib/bot/github-link-state';
import { linkKiloUser } from '@/lib/bot-identity';
import {
consumeLinkAccountContext,
linkKiloUser,
readLinkAccountContext,
} from '@/lib/bot-identity';
import { bot } from '@/lib/bot';
import { reprocessLinkedMessage } from '@/lib/bot/run';
import { isOrganizationMember } from '@/lib/organizations/organizations';
import { PLATFORM } from '@/lib/integrations/core/constants';
import { botPlatforms } from '@/lib/bot/platforms';
import { Message, ThreadImpl } from 'chat';
import type { PlatformIntegration, User } from '@kilocode/db';

function htmlPage(title: string, message: string, status = 200): Response {
return new Response(
Expand All @@ -42,7 +49,7 @@ function htmlPage(title: string, message: string, status = 200): Response {
);
}

async function handleGitHubBotLinkCallback(request: NextRequest, user: { id: string }) {
async function handleGitHubBotLinkCallback(request: NextRequest, user: User) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = verifyGitHubBotLinkState(searchParams.get('state'));
Expand Down Expand Up @@ -100,10 +107,44 @@ async function handleGitHubBotLinkCallback(request: NextRequest, user: { id: str
user.id
);

const replayed = await scheduleMessageReplay(state.contextKey, integration, user);

return htmlPage(
'GitHub account linked',
`GitHub account ${githubUser.login} has been linked to your Kilo account.<br>You can return to GitHub and mention Kilo again.`
`GitHub account ${githubUser.login} has been linked to your Kilo account.<br>${
replayed
? 'You can close this tab — Kilo is already processing your message.'
: 'You can return to GitHub and mention Kilo again.'
}`
);
}

/**
* If the link flow carried a message context key, replay the original
* mention so the user does not need to ping Kilo a second time. Matches
* the Slack link-account UX where replay happens automatically.
*/
async function scheduleMessageReplay(
contextKey: string | null,
platformIntegration: PlatformIntegration,
user: User
): Promise<boolean> {
if (!contextKey) return false;

const context = await readLinkAccountContext(bot.getState(), contextKey);
if (!context) return false;

if (!(await consumeLinkAccountContext(bot.getState(), contextKey))) return false;

after(() =>
reprocessLinkedMessage({
platformIntegration,
thread: ThreadImpl.fromJSON(context.thread),
message: Message.fromJSON(context.message),
user,
})
);
return true;
}

/**
Expand Down
Loading