Skip to content

Commit c4e5950

Browse files
authored
feat(seed): add app:github-integration topic (#5088)
* feat(seed): add app:github-integration topic Adds a dev:seed topic that connects a local test user to an existing GitHub App installation for Cloud Agent web testing. The installation and an optional repository are validated against GitHub with the configured app credentials, then an active user-owned platform_integrations row is upserted using the same two-step conflict pattern as the web app (same-owner refresh, cross-owner claim refused). No keys or tokens are printed or persisted. * fix(seed): reject empty --flag= values in github-integration Empty inline forms like --installation-id= were falling through to the next argv without skipping it, so the value was also parsed as user-id.
1 parent 42f911f commit c4e5950

2 files changed

Lines changed: 443 additions & 0 deletions

File tree

dev/seed/app/github-integration.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import { kilocode_users, platform_integrations } from '@kilocode/db/schema';
2+
import { and, eq } from 'drizzle-orm';
3+
4+
import { getSeedDb } from '../lib/db';
5+
import {
6+
fetchSeedGitHubInstallationDetails,
7+
fetchSeedGitHubRepositories,
8+
type SeedGitHubAppType,
9+
} from '../lib/github-app';
10+
import type { SeedResult } from '../index';
11+
12+
export const usage =
13+
'<user-id> --installation-id=<id> [--repository=<owner/repo>] [--app-type=standard|lite]';
14+
15+
function printUsage(): void {
16+
console.log(`Usage: pnpm dev:seed app:github-integration ${usage}`);
17+
console.log('');
18+
console.log('Connects a local development user to an existing GitHub App installation by');
19+
console.log('upserting an active user-owned platform_integrations row. The installation and');
20+
console.log('the optional repository are validated against GitHub with the configured app');
21+
console.log('credentials (GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY, or the GITHUB_LITE_APP_* pair).');
22+
console.log('No private keys or tokens are printed or stored; git-token-service mints the');
23+
console.log('installation token at runtime.');
24+
console.log('');
25+
console.log('Options:');
26+
console.log(' --installation-id=<id> GitHub App installation id (required)');
27+
console.log(
28+
' --repository=<owner/repo> Repository that must be accessible to the installation'
29+
);
30+
console.log(' --app-type=standard|lite GitHub App to validate against (default: standard)');
31+
console.log('');
32+
console.log('Examples:');
33+
console.log(' pnpm dev:seed app:github-integration <user-id> --installation-id 107732005 \\');
34+
console.log(' --repository na2-org/hi-how-are-you');
35+
console.log(' pnpm dev:seed app:github-integration <user-id> --installation-id=107732005');
36+
}
37+
38+
type GitHubIntegrationOptions = {
39+
userId: string;
40+
installationId: string;
41+
repository: string | null;
42+
appType: SeedGitHubAppType;
43+
};
44+
45+
function takeFlagValue(args: string[], index: number, flag: string): string {
46+
const arg = args[index];
47+
if (arg.length > flag.length && arg[flag.length] === '=') {
48+
const inline = arg.slice(flag.length + 1).trim();
49+
if (!inline) {
50+
throw new Error(`${flag} requires a value`);
51+
}
52+
return inline;
53+
}
54+
55+
const next = args[index + 1];
56+
if (next === undefined || next.startsWith('--')) {
57+
throw new Error(`${flag} requires a value`);
58+
}
59+
return next.trim();
60+
}
61+
62+
function parseArgs(args: string[]): GitHubIntegrationOptions {
63+
let userId: string | null = null;
64+
let installationId: string | null = null;
65+
let repository: string | null = null;
66+
let appType: SeedGitHubAppType = 'standard';
67+
68+
const VALUE_FLAGS = ['--installation-id', '--repository', '--app-type'];
69+
70+
for (let index = 0; index < args.length; index++) {
71+
const arg = args[index];
72+
const flag = VALUE_FLAGS.find(name => arg === name || arg.startsWith(`${name}=`));
73+
74+
if (flag) {
75+
const value = takeFlagValue(args, index, flag);
76+
if (!value) {
77+
throw new Error(`${flag} requires a value`);
78+
}
79+
if (arg === flag) index++; // value came from the next argv slot
80+
81+
if (flag === '--installation-id') {
82+
if (!/^\d+$/.test(value)) {
83+
throw new Error('--installation-id must be a numeric GitHub installation id');
84+
}
85+
installationId = value;
86+
} else if (flag === '--repository') {
87+
if (!/^[^\s/]+\/[^\s/]+$/.test(value)) {
88+
throw new Error('--repository must look like owner/repo');
89+
}
90+
repository = value;
91+
} else {
92+
if (value !== 'standard' && value !== 'lite') {
93+
throw new Error('--app-type must be standard or lite');
94+
}
95+
appType = value;
96+
}
97+
continue;
98+
}
99+
100+
if (arg.startsWith('--')) {
101+
throw new Error(`Unknown argument: ${arg}`);
102+
}
103+
104+
if (userId !== null) {
105+
throw new Error(`Unexpected positional argument: ${arg}`);
106+
}
107+
userId = arg.trim();
108+
}
109+
110+
if (!userId) {
111+
printUsage();
112+
throw new Error('user-id is required');
113+
}
114+
if (!installationId) {
115+
printUsage();
116+
throw new Error('--installation-id is required');
117+
}
118+
119+
return { userId, installationId, repository, appType };
120+
}
121+
122+
export async function run(...args: string[]): Promise<SeedResult | void> {
123+
if (args.includes('--help') || args.includes('-h')) {
124+
printUsage();
125+
return;
126+
}
127+
128+
const options = parseArgs(args);
129+
const db = getSeedDb();
130+
131+
const [user] = await db
132+
.select({ id: kilocode_users.id, email: kilocode_users.google_user_email })
133+
.from(kilocode_users)
134+
.where(eq(kilocode_users.id, options.userId))
135+
.limit(1);
136+
137+
if (!user) {
138+
throw new Error(
139+
`User ${options.userId} was not found. Create one first: ` +
140+
`pnpm dev:seed app:create-user "Cloud Agent Test" cloud-agent-test@example.com`
141+
);
142+
}
143+
144+
// Validate against GitHub before writing anything, mirroring the dev-only
145+
// devAddInstallation flow in apps/web/src/routers/github-apps-router.ts.
146+
const details = await fetchSeedGitHubInstallationDetails(options.installationId, options.appType);
147+
const repositories = await fetchSeedGitHubRepositories(options.installationId, options.appType);
148+
149+
let canonicalRepository: string | null = null;
150+
if (options.repository) {
151+
const requested = options.repository.toLowerCase();
152+
const match = repositories.find(repo => repo.full_name.toLowerCase() === requested);
153+
if (!match) {
154+
const sample = repositories
155+
.slice(0, 5)
156+
.map(repo => repo.full_name)
157+
.join(', ');
158+
throw new Error(
159+
`Repository ${options.repository} is not accessible to installation ${options.installationId} ` +
160+
`(${details.accountLogin}, repository access: ${details.repositorySelection}, ` +
161+
`${repositories.length} repositories${sample ? `: ${sample}${repositories.length > 5 ? ', …' : ''}` : ''}). ` +
162+
`Grant the app access to the repository on GitHub first.`
163+
);
164+
}
165+
canonicalRepository = match.full_name;
166+
}
167+
168+
// Upsert replicating upsertPlatformIntegrationForOwner from
169+
// apps/web/src/lib/integrations/db/platform-integrations.ts (GitHub two-step
170+
// pattern): insert against the global (platform, github_app_type,
171+
// platform_installation_id) unique index, then re-read on conflict to allow a
172+
// same-owner refresh and refuse cross-owner claims.
173+
const nowIso = new Date().toISOString();
174+
const values = {
175+
owned_by_user_id: user.id,
176+
owned_by_organization_id: null,
177+
platform: 'github',
178+
integration_type: 'app',
179+
platform_installation_id: options.installationId,
180+
platform_account_id: String(details.accountId),
181+
platform_account_login: details.accountLogin,
182+
permissions: details.permissions,
183+
scopes: details.events,
184+
repository_access: details.repositorySelection,
185+
integration_status: 'active',
186+
repositories,
187+
installed_at: details.createdAt,
188+
github_app_type: options.appType,
189+
repositories_synced_at: nowIso,
190+
} satisfies typeof platform_integrations.$inferInsert;
191+
192+
const inserted = await db
193+
.insert(platform_integrations)
194+
.values(values)
195+
.onConflictDoNothing()
196+
.returning({ id: platform_integrations.id });
197+
198+
let integrationId: string;
199+
let wasInserted: boolean;
200+
201+
if (inserted.length > 0) {
202+
const [row] = inserted;
203+
integrationId = row.id;
204+
wasInserted = true;
205+
} else {
206+
const [existing] = await db
207+
.select({
208+
id: platform_integrations.id,
209+
ownedByUserId: platform_integrations.owned_by_user_id,
210+
ownedByOrganizationId: platform_integrations.owned_by_organization_id,
211+
})
212+
.from(platform_integrations)
213+
.where(
214+
and(
215+
eq(platform_integrations.platform, 'github'),
216+
eq(platform_integrations.github_app_type, options.appType),
217+
eq(platform_integrations.platform_installation_id, options.installationId)
218+
)
219+
)
220+
.limit(1);
221+
222+
if (!existing) {
223+
// Edge case: a concurrent delete blocked the insert without leaving a row
224+
// to re-read. Retry so the database enforces uniqueness (or throws).
225+
const [retried] = await db
226+
.insert(platform_integrations)
227+
.values(values)
228+
.returning({ id: platform_integrations.id });
229+
integrationId = retried.id;
230+
wasInserted = true;
231+
} else if (existing.ownedByUserId === user.id && existing.ownedByOrganizationId === null) {
232+
await db
233+
.update(platform_integrations)
234+
.set({
235+
platform_account_id: values.platform_account_id,
236+
platform_account_login: values.platform_account_login,
237+
permissions: values.permissions,
238+
scopes: values.scopes,
239+
repository_access: values.repository_access,
240+
integration_status: 'active',
241+
repositories: values.repositories,
242+
github_app_type: options.appType,
243+
auth_invalid_at: null,
244+
auth_invalid_reason: null,
245+
repositories_synced_at: nowIso,
246+
updated_at: nowIso,
247+
})
248+
.where(eq(platform_integrations.id, existing.id));
249+
integrationId = existing.id;
250+
wasInserted = false;
251+
} else {
252+
const ownerHint = existing.ownedByUserId
253+
? `user ${existing.ownedByUserId}`
254+
: `organization ${existing.ownedByOrganizationId ?? 'unknown'}`;
255+
throw new Error(
256+
`This GitHub installation is already claimed by another account (${ownerHint}). ` +
257+
`Delete that platform_integrations row first, or seed the integration for the owning account.`
258+
);
259+
}
260+
}
261+
262+
console.log('');
263+
console.log(
264+
'This fixture represents: a user-owned GitHub App integration (platform_integrations row)'
265+
);
266+
console.log('for local Cloud Agent web testing.');
267+
console.log(
268+
'Note: no keys or tokens were stored; git-token-service mints the installation token at runtime.'
269+
);
270+
if (canonicalRepository) {
271+
console.log(
272+
`Suggested next step: start a Cloud Agent session against ${canonicalRepository} from the web app.`
273+
);
274+
}
275+
276+
return {
277+
userId: user.id,
278+
userEmail: user.email,
279+
integrationId,
280+
inserted: wasInserted,
281+
installationId: options.installationId,
282+
appType: options.appType,
283+
accountLogin: details.accountLogin,
284+
accountId: String(details.accountId),
285+
repositoryAccess: details.repositorySelection,
286+
repositoryCount: repositories.length,
287+
repository: canonicalRepository,
288+
};
289+
}

0 commit comments

Comments
 (0)