Skip to content
Open
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
17 changes: 16 additions & 1 deletion src/github/github-sync.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Octokit } from '@octokit/rest';
import { Repository as TypeOrmRepository } from 'typeorm';
Expand Down Expand Up @@ -224,6 +230,15 @@ export class GithubSyncService {
return this.repositoryRepo.findOne({ where: { githubRepoId } });
}

async findRepositoryByOwnerAndName(
owner: string,
name: string,
): Promise<Repository | null> {
return this.repositoryRepo.findOne({
where: { owner, name },
});
}

/** Fetches a single PR's merge status directly from the API (used by webhook fallback verification). */
async getPullRequest(owner: string, repo: string, pullNumber: number) {
const { data } = await this.octokit.pulls.get({
Expand Down
96 changes: 96 additions & 0 deletions src/github/github.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { GithubController } from './github.controller';
import { GithubSyncService } from './github-sync.service';
import { UserRole } from '../common/enums';
import { Repository, User } from '../common/entities';

describe('GithubController', () => {
let controller: GithubController;
let syncService: {
syncRepository: jest.Mock;
findRepositoryByOwnerAndName: jest.Mock;
};

beforeEach(async () => {
syncService = {
syncRepository: jest.fn(),
findRepositoryByOwnerAndName: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [GithubController],
providers: [{ provide: GithubSyncService, useValue: syncService }],
}).compile();

controller = module.get<GithubController>(GithubController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

it('rejects unprivileged user role (e.g. contributor only)', async () => {
const mockReq = {
user: {
id: 'u-1',
roles: [UserRole.CONTRIBUTOR],
} as User,
} as any;

await expect(
controller.sync('acme', 'widgets', mockReq),
).rejects.toThrow(ForbiddenException);
expect(syncService.syncRepository).not.toHaveBeenCalled();
});

it('rejects sync for untracked repository', async () => {
const mockReq = {
user: {
id: 'u-2',
roles: [UserRole.MAINTAINER],
} as User,
} as any;

syncService.findRepositoryByOwnerAndName.mockResolvedValue(null);

await expect(
controller.sync('torvalds', 'linux', mockReq),
).rejects.toThrow(NotFoundException);
expect(syncService.syncRepository).not.toHaveBeenCalled();
});

it('allows maintainer to sync tracked repository', async () => {
const mockReq = {
user: {
id: 'u-2',
roles: [UserRole.MAINTAINER],
} as User,
} as any;

const mockRepo = { id: 'repo-1', owner: 'acme', name: 'widgets' } as Repository;
syncService.findRepositoryByOwnerAndName.mockResolvedValue(mockRepo);
syncService.syncRepository.mockResolvedValue(mockRepo);

const result = await controller.sync('acme', 'widgets', mockReq);
expect(result).toEqual(mockRepo);
expect(syncService.findRepositoryByOwnerAndName).toHaveBeenCalledWith('acme', 'widgets');
expect(syncService.syncRepository).toHaveBeenCalledWith('acme', 'widgets');
});

it('allows sponsor to sync tracked repository', async () => {
const mockReq = {
user: {
id: 'u-3',
roles: [UserRole.SPONSOR],
} as User,
} as any;

const mockRepo = { id: 'repo-1', owner: 'acme', name: 'widgets' } as Repository;
syncService.findRepositoryByOwnerAndName.mockResolvedValue(mockRepo);
syncService.syncRepository.mockResolvedValue(mockRepo);

const result = await controller.sync('acme', 'widgets', mockReq);
expect(result).toEqual(mockRepo);
});
});
52 changes: 49 additions & 3 deletions src/github/github.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,60 @@
import { Controller, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
Controller,
ForbiddenException,
NotFoundException,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { GithubSyncService } from './github-sync.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UserRole } from '../common/enums';
import { User } from '../common/entities';

interface RequestWithUser extends Request {
user: User;
}

@ApiTags('github')
@Controller('github')
export class GithubController {
constructor(private readonly syncService: GithubSyncService) {}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('sync/:owner/:repo')
sync(@Param('owner') owner: string, @Param('repo') repo: string) {
async sync(
@Param('owner') owner: string,
@Param('repo') repo: string,
@Req() req: RequestWithUser,
) {
const user = req.user;
const isMaintainerOrAdmin =
user?.roles &&
(user.roles.includes(UserRole.MAINTAINER) ||
user.roles.includes(UserRole.SPONSOR) ||
(user.roles as unknown as string[]).includes('admin'));

if (!isMaintainerOrAdmin) {
throw new ForbiddenException(
'Only maintainers or sponsors may trigger repository synchronization',
);
}

const tracked = await this.syncService.findRepositoryByOwnerAndName(
owner,
repo,
);
if (!tracked) {
throw new NotFoundException(
`Repository ${owner}/${repo} is not tracked by MergeFi. Only registered repositories can be synced.`,
);
}

return this.syncService.syncRepository(owner, repo);
}
}