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
116 changes: 82 additions & 34 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable: add optional githubInstallationId to Repository
ALTER TABLE "Repository" ADD COLUMN "githubInstallationId" TEXT;

-- CreateIndex: allows looking up a repo by its GitHub App installation
CREATE INDEX "Repository_githubInstallationId_idx" ON "Repository"("githubInstallationId");
19 changes: 10 additions & 9 deletions coverage-service/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ datasource db {
}

model Repository {
id String @id @default(cuid())
githubRepo String @unique
defaultBranch String @default("main")
coverageCommand String @default("npm test -- --coverage")
testCommand String @default("npm test")
installCommand String @default("")
createdAt DateTime @default(now())
pullRequestRuns PullRequestRun[]
testGenerationRuns TestGenerationRun[]
id String @id @default(cuid())
githubRepo String @unique
defaultBranch String @default("main")
coverageCommand String @default("npm test -- --coverage")
testCommand String @default("npm test")
installCommand String @default("")
githubInstallationId String? // set automatically when app is installed via webhook
createdAt DateTime @default(now())
pullRequestRuns PullRequestRun[]
testGenerationRuns TestGenerationRun[]
}

enum PrRunStatus {
Expand Down
3 changes: 3 additions & 0 deletions coverage-service/api/src/github/github.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Global, Module } from '@nestjs/common';

import { RepositoriesModule } from '../repositories/repositories.module';

import { GitHubService } from './github.service';

@Global()
@Module({
imports: [RepositoriesModule],
providers: [GitHubService],
exports: [GitHubService],
})
Expand Down
8 changes: 6 additions & 2 deletions coverage-service/api/src/github/github.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@ import {
import type { GitHubAuthMode} from '@openreview/coverage-lib';
import { GitHubProvider } from '@openreview/coverage-lib';

import { RepositoriesService } from '../repositories/repositories.service';

@Injectable()
export class GitHubService {
private readonly provider: GitHubProvider;

constructor() {
constructor(private readonly repositories: RepositoriesService) {
const authMode = (process.env.GITHUB_AUTH_MODE ?? 'pat') as GitHubAuthMode;
this.provider = new GitHubProvider({
authMode,
pat: process.env.GITHUB_PAT,
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
installationId: process.env.GITHUB_APP_INSTALLATION_ID,
installationId: process.env.GITHUB_APP_INSTALLATION_ID || undefined,
resolveInstallationId: (githubRepo) =>
this.repositories.resolveInstallationId(githubRepo),
});
}

Expand Down
33 changes: 33 additions & 0 deletions coverage-service/api/src/repositories/repositories.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,37 @@ export class RepositoriesService {
where: { githubRepo },
});
}

/**
* Called by the installation webhook handler to store (or clear) the
* GitHub App installation ID for a given repo. Creates the repo record
* automatically when the app is installed on a new repo that has not yet
* been manually registered.
*/
async upsertByGithubRepo(
githubRepo: string,
installationId: string | null,
) {
return this.prisma.repository.upsert({
where: { githubRepo },
update: { githubInstallationId: installationId },
create: {
githubRepo,
githubInstallationId: installationId,
},
});
}

/**
* Resolves the installation ID for a given repo — used by the worker
* so each repo authenticates with its own installation token.
*/
async resolveInstallationId(githubRepo: string): Promise<string | null> {
const repo = await this.prisma.repository.findFirst({
where: { githubRepo },
select: { githubInstallationId: true },
});
return repo?.githubInstallationId ?? null;
}
}

6 changes: 6 additions & 0 deletions coverage-service/api/src/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export class WebhooksController {
return this.webhooksService.handlePullRequest(payload);
}

// GitHub App installation events — fired when someone installs/uninstalls
// the app or grants/revokes access to individual repos.
if (event === 'installation' || event === 'installation_repositories') {
return this.webhooksService.handleInstallation(payload);
}

return { received: true, event };
}

Expand Down
61 changes: 61 additions & 0 deletions coverage-service/api/src/webhooks/webhooks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ interface PullRequestPayload {
repository: { full_name: string };
}

interface InstallationPayload {
action: string; // 'created' | 'deleted' | 'added' | 'removed'
installation: { id: number };
/** Present on 'created' (full install) */
repositories?: { full_name: string }[];
/** Present on 'installation_repositories' events (selective add/remove) */
repositories_added?: { full_name: string }[];
repositories_removed?: { full_name: string }[];
}

@Injectable()
export class WebhooksService {
private readonly logger = new Logger(WebhooksService.name);
Expand Down Expand Up @@ -46,4 +56,55 @@ export class WebhooksService {
baseBranch: pr.pull_request.base.ref,
});
}

/**
* Handles GitHub App installation/uninstallation events.
*
* - `installation` event with action `created`: the app was installed on one
* or more repos. We upsert each repo and store the installation ID.
* - `installation_repositories` event with action `added`: the app was granted
* access to additional repos within an existing installation.
* - `installation` with action `deleted` / `installation_repositories` with
* action `removed`: the app was removed — clear the installation ID.
*/
async handleInstallation(payload: Record<string, unknown>) {
const data = payload as unknown as InstallationPayload;
const installationId = String(data.installation.id);
const action = data.action;

this.logger.log(
`GitHub App installation event: action=${action} installationId=${installationId}`,
);

const added: string[] = [
...(data.repositories ?? []),
...(data.repositories_added ?? []),
].map((r) => r.full_name);

const removed: string[] = (data.repositories_removed ?? []).map(
(r) => r.full_name,
);

if (action === 'deleted') {
// Entire installation removed — we don't know the exact repos from this
// payload variant, so clear by installation ID if we have it.
this.logger.warn(
`App uninstalled for installationId=${installationId}. ` +
`Repos will retain their row but lose the installation ID.`,
);
// The repositories[] list IS present on 'deleted', re-use added[] logic.
}

for (const repo of added) {
await this.repositories.upsertByGithubRepo(repo, installationId);
this.logger.log(`Linked installationId=${installationId} → ${repo}`);
}

for (const repo of removed) {
await this.repositories.upsertByGithubRepo(repo, null);
this.logger.log(`Cleared installationId for ${repo}`);
}

return { received: true, action, added, removed };
}
}
68 changes: 42 additions & 26 deletions coverage-service/lib/src/providers/github-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,42 +24,54 @@ export interface GitHubProviderConfig {
pat?: string;
appId?: string;
privateKey?: string;
/** Static installation ID (single-org / Option A). Takes priority over the resolver. */
installationId?: string;
/**
* Dynamic resolver for multi-tenant GitHub App (Option C).
* Called with the `owner/repo` string and must return the installation ID
* for that specific repo, or null if unknown.
*/
resolveInstallationId?: (githubRepo: string) => Promise<string | null>;
}

export class GitHubProvider implements RepositoryProvider {
readonly name = 'github';
private octokit: Octokit | null = null;

constructor(private readonly config: GitHubProviderConfig) {}

private async getOctokit(): Promise<Octokit> {
if (this.octokit) return this.octokit;
private async resolveId(githubRepo?: string): Promise<string> {
// Static ID wins — keeps Option A working with zero changes.
if (this.config.installationId) return this.config.installationId;

if (this.config.resolveInstallationId && githubRepo) {
const id = await this.config.resolveInstallationId(githubRepo);
if (id) return id;
}

throw new Error(
`No GitHub App installation ID found for ${githubRepo ?? 'unknown repo'}. ` +
'Install the app on this repo or set GITHUB_APP_INSTALLATION_ID.',
);
}

private async getOctokit(githubRepo?: string): Promise<Octokit> {
if (this.config.authMode === 'app') {
if (
!this.config.appId ||
!this.config.privateKey ||
!this.config.installationId
) {
throw new Error(
'GitHub App auth requires appId, privateKey, and installationId',
);
if (!this.config.appId || !this.config.privateKey) {
throw new Error('GitHub App auth requires appId and privateKey');
}
const installationId = parseInt(await this.resolveId(githubRepo), 10);
const auth = createAppAuth({
appId: this.config.appId,
privateKey: this.config.privateKey.replace(/\\n/g, '\n'),
installationId: parseInt(this.config.installationId, 10),
installationId,
});
this.octokit = new Octokit({ auth: (await auth({ type: 'installation' })).token });
} else {
if (!this.config.pat) {
throw new Error('PAT auth requires GITHUB_PAT');
}
this.octokit = new Octokit({ auth: this.config.pat });
return new Octokit({ auth: (await auth({ type: 'installation' })).token });
}

return this.octokit;
if (!this.config.pat) {
throw new Error('PAT auth requires GITHUB_PAT');
}
return new Octokit({ auth: this.config.pat });
}

private async getCloneUrl(repoUrl: string): Promise<string> {
Expand All @@ -70,18 +82,22 @@ export class GitHubProvider implements RepositoryProvider {
return url.toString();
}

const octokit = await this.getOctokit();
const match = repoUrl.match(/github\.com[:/](.+?)(?:\.git)?$/);
if (!match) return repoUrl;

const [owner, repo] = match[1].split('/');
const { data } = await octokit.rest.apps.createInstallationAccessToken({
installation_id: parseInt(this.config.installationId!, 10),
const githubRepo = match[1]; // 'owner/repo'
const installationId = parseInt(await this.resolveId(githubRepo), 10);

const auth = createAppAuth({
appId: this.config.appId!,
privateKey: this.config.privateKey!.replace(/\\n/g, '\n'),
installationId,
});
const { token } = await auth({ type: 'installation' });

const url = new URL(`https://github.com/${owner}/${repo}.git`);
const url = new URL(`https://github.com/${githubRepo}.git`);
url.username = 'x-access-token';
url.password = data.token;
url.password = token;
return url.toString();
}

Expand All @@ -96,7 +112,7 @@ export class GitHubProvider implements RepositoryProvider {
baseBranch: string;
}> {
const [owner, repo] = githubRepo.split('/');
const octokit = await this.getOctokit();
const octokit = await this.getOctokit(githubRepo);
const { data } = await octokit.rest.pulls.get({
owner,
repo,
Expand Down
16 changes: 15 additions & 1 deletion coverage-service/worker/src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,28 @@ import {
GitHubProvider
} from '@openreview/coverage-lib';

import { prisma } from './prisma';

export function createRepositoryProvider() {
const authMode = (process.env.GITHUB_AUTH_MODE ?? 'pat') as GitHubAuthMode;

return new GitHubProvider({
authMode,
pat: process.env.GITHUB_PAT,
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
installationId: process.env.GITHUB_APP_INSTALLATION_ID,
// Static ID: set when running in single-org mode (Option A).
// Leave blank in multi-tenant mode (Option C) — the resolver below takes over.
installationId: process.env.GITHUB_APP_INSTALLATION_ID || undefined,
// Dynamic resolver: looks up the installation ID per-repo from the database.
// Only invoked when authMode === 'app' and no static installationId is set.
resolveInstallationId: async (githubRepo: string) => {
const repo = await prisma.repository.findFirst({
where: { githubRepo },
select: { githubInstallationId: true },
});
return repo?.githubInstallationId ?? null;
},
});
}

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,8 @@
"typescript-eslint": "^8.57.1",
"vitest": "^4.1.0",
"zod": "^4.3.6"
},
"dependencies": {
"thread-stream": "^4.2.0"
}
}
Loading
Loading