Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ jest.mock('@/lib/integrations/db/platform-integrations', () => ({
findGitHubBotLinkIntegrations: jest.fn(),
findIntegrationByInstallationId: jest.fn(),
findIntegrationByInstallationIdForOwner: jest.fn(),
findPendingInstallationByRequesterId: jest.fn(),
upsertPlatformIntegrationForOwner: jest.fn(async () => ({ ok: true })),
}));
jest.mock('@/lib/integrations/db/github-installations', () => ({
Expand Down
31 changes: 20 additions & 11 deletions apps/web/src/components/integrations/GitHubIntegrationDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,10 @@ function GitHubIntegrationDetailsContent({
};

const handleCancelPending = async () => {
// Cancel exactly the pending row this view rendered; the id is required so
// the request cannot fall back to another pending request.
const pendingIntegrationId = installationData?.installation?.id;
if (!pendingIntegrationId) return;
if (
await confirm({
title: 'Cancel this installation request?',
Expand All @@ -605,17 +609,22 @@ function GitHubIntegrationDetailsContent({
destructive: true,
})
) {
cancelPendingInstallation.mutate(managementInput, {
onSuccess: async () => {
toast.success('Installation request cancelled');
await refetch();
},
onError: error => {
toast.error('Failed to cancel installation request', {
description: error.message,
});
},
});
cancelPendingInstallation.mutate(
organizationId
? { organizationId, integrationId: pendingIntegrationId }
: { integrationId: pendingIntegrationId },
{
onSuccess: async () => {
toast.success('Installation request cancelled');
await refetch();
},
onError: error => {
toast.error('Failed to cancel installation request', {
description: error.message,
});
},
}
);
}
};

Expand Down
29 changes: 8 additions & 21 deletions apps/web/src/lib/integrations/db/platform-integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,27 +823,13 @@ export async function findPendingInstallations() {
}

/**
* Find a pending installation by GitHub requester ID
*/
export async function findPendingInstallationByRequesterId(githubRequesterId: string) {
const [result] = await db
.select()
.from(platform_integrations)
.where(
and(
eq(platform_integrations.platform, PLATFORM.GITHUB),
eq(platform_integrations.platform_requester_account_id, githubRequesterId),
eq(platform_integrations.integration_status, INTEGRATION_STATUS.PENDING),
isNull(platform_integrations.platform_installation_id)
)
)
.limit(1);

return result || null;
}

/**
* Find a pending installation by Kilo user ID
* Find the pending installation a Kilo user most recently requested.
*
* Several pending rows can exist for one user (the pending-target unique
* indexes are per owner, app type, and GitHub target account), so the lookup
* orders explicitly: newest request first, ties broken by id. Without the
* order Postgres may return any matching row, and a resumed view or a cancel
* could address a different request than the one the user saw.
*/
export async function findPendingInstallationByKiloUserId(kiloUserId: string) {
const [result] = await db
Expand All @@ -857,6 +843,7 @@ export async function findPendingInstallationByKiloUserId(kiloUserId: string) {
isNull(platform_integrations.platform_installation_id)
)
)
.orderBy(desc(platform_integrations.created_at), desc(platform_integrations.id))
Comment thread
iscekic marked this conversation as resolved.
Resolved
.limit(1);

return result || null;
Expand Down
146 changes: 144 additions & 2 deletions apps/web/src/lib/integrations/github-apps-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { describe, expect, it } from '@jest/globals';
import { organizations, platform_integrations } from '@kilocode/db/schema';
import { kilocode_users, organizations, platform_integrations } from '@kilocode/db/schema';
import { eq } from 'drizzle-orm';
import { db } from '@kilocode/web-shared/lib/drizzle';
import { insertTestUser } from '@kilocode/web-shared/tests/helpers/user.helper';
import {
getIntegrationForOrganization,
getIntegrationForOwner,
getPrimaryGitHubIntegrationForOrganization,
} from '@/lib/integrations/db/platform-integrations';
import { getInstallation, isInstallationGoneError, updateModel } from './github-apps-service';
import {
cancelPendingInstallation,
checkUserPendingInstallation,
getInstallation,
isInstallationGoneError,
updateModel,
} from './github-apps-service';

describe('getInstallation', () => {
it('prefers a healthy installation when the owner has multiple GitHub rows', async () => {
Expand Down Expand Up @@ -204,6 +211,141 @@ describe('updateModel', () => {
});
});

describe('pending installation selection', () => {
type PendingRow = typeof platform_integrations.$inferInsert;

function pendingRow(input: { userId: string; accountId: string; createdAt: string }): PendingRow {
return {
owned_by_user_id: input.userId,
platform: 'github',
integration_type: 'app',
platform_installation_id: null,
integration_status: 'pending',
github_app_type: 'standard',
platform_account_id: input.accountId,
platform_account_login: `target-${input.accountId}`,
kilo_requester_user_id: input.userId,
installed_at: input.createdAt,
created_at: input.createdAt,
updated_at: input.createdAt,
};
}

async function cleanupUser(userId: string): Promise<void> {
await db
.delete(platform_integrations)
.where(eq(platform_integrations.owned_by_user_id, userId));
await db.delete(kilocode_users).where(eq(kilocode_users.id, userId));
}

it('returns the newest pending request regardless of the order rows were written', async () => {
const user = await insertTestUser();
try {
// The older request is written first, so an unordered SELECT returns it.
const rows = await db
.insert(platform_integrations)
.values([
pendingRow({
userId: user.id,
accountId: 'older',
createdAt: '2026-01-01T00:00:00.000Z',
}),
pendingRow({
userId: user.id,
accountId: 'newer',
createdAt: '2026-02-01T00:00:00.000Z',
}),
])
.returning();

const [older, newer] = rows;

await expect(checkUserPendingInstallation(user.id)).resolves.toMatchObject({ id: newer.id });

// Touching the older row moves it to the end of the heap, flipping the
// physical order a second time; the answer must not follow it.
await db
.update(platform_integrations)
.set({ updated_at: '2026-03-01T00:00:00.000Z' })
.where(eq(platform_integrations.id, older.id));

await expect(checkUserPendingInstallation(user.id)).resolves.toMatchObject({ id: newer.id });
} finally {
await cleanupUser(user.id);
}
});

it('breaks equal pending creation times by descending integration id', async () => {
const user = await insertTestUser();
const [lowerId, higherId] = [crypto.randomUUID(), crypto.randomUUID()].sort();
const createdAt = '2026-02-01T00:00:00.000Z';
try {
await db.insert(platform_integrations).values([
{
...pendingRow({ userId: user.id, accountId: 'lower-id', createdAt }),
id: lowerId,
},
{
...pendingRow({ userId: user.id, accountId: 'higher-id', createdAt }),
id: higherId,
},
]);

await expect(checkUserPendingInstallation(user.id)).resolves.toMatchObject({ id: higherId });

await cancelPendingInstallation({ type: 'user', id: user.id }, higherId);

await expect(checkUserPendingInstallation(user.id)).resolves.toMatchObject({ id: lowerId });
} finally {
await cleanupUser(user.id);
}
});

it('cancels exactly the named pending request and refuses any other id', async () => {
const user = await insertTestUser();
const otherUser = await insertTestUser();
try {
const rows = await db
.insert(platform_integrations)
.values([
pendingRow({
userId: user.id,
accountId: 'first',
createdAt: '2026-01-01T00:00:00.000Z',
}),
pendingRow({
userId: user.id,
accountId: 'second',
createdAt: '2026-02-01T00:00:00.000Z',
}),
])
.returning();
const [first, second] = rows;

await cancelPendingInstallation({ type: 'user', id: user.id }, first.id);

const remaining = await db
.select({ id: platform_integrations.id })
.from(platform_integrations)
.where(eq(platform_integrations.owned_by_user_id, user.id));
expect(remaining).toEqual([{ id: second.id }]);

await expect(
cancelPendingInstallation({ type: 'user', id: otherUser.id }, second.id)
).rejects.toMatchObject({ code: 'NOT_FOUND' });

const stillPending = await db
.select({ id: platform_integrations.id })
.from(platform_integrations)
.where(eq(platform_integrations.owned_by_user_id, user.id));
expect(stillPending).toEqual([{ id: second.id }]);
} finally {
await cleanupUser(user.id);
await cleanupUser(otherUser.id);
}
});
});

describe('isInstallationGoneError', () => {
it('should return true for 404 Not Found errors', () => {
const error = { status: 404, message: 'Not Found' };
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/lib/integrations/github-apps-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,22 @@ export async function listRepositories(
}

/**
* Cancel a pending installation
* Cancel one pending installation request.
*
* The id is required: a user can hold several pending requests, so the caller
* must name the row it displayed. An id that is not a pending request for this
* owner is refused rather than replaced by another matching row.
*/
export async function cancelPendingInstallation(owner: Owner, integrationId?: string) {
export async function cancelPendingInstallation(owner: Owner, integrationId: string) {
const ownershipCondition = ownerCondition(owner);

// Find the pending installation
const [integration] = await db
.select()
.from(platform_integrations)
.where(
and(
ownershipCondition,
...(integrationId ? [eq(platform_integrations.id, integrationId)] : []),
eq(platform_integrations.id, integrationId),
eq(platform_integrations.platform, 'github'),
eq(platform_integrations.integration_status, INTEGRATION_STATUS.PENDING),
isNull(platform_integrations.platform_installation_id)
Expand Down
Loading
Loading