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
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ProjectsModule } from './projects/projects.module';
import { NotesModule } from './notes/notes.module';

import { AiModule } from './ai/ai.module';
import { GithubModule } from './github/github.module';

@Module({
imports: [
Expand All @@ -21,6 +22,7 @@ import { AiModule } from './ai/ai.module';
ProjectsModule,
NotesModule,
AiModule,
GithubModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
11 changes: 11 additions & 0 deletions apps/backend/src/github/dto/connect-repo.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsString, IsUrl, IsNotEmpty } from 'class-validator';

export class ConnectRepoDto {
@IsString()
@IsNotEmpty()
projectId: string;

@IsUrl()
@IsNotEmpty()
repoUrl: string;
}
18 changes: 18 additions & 0 deletions apps/backend/src/github/github.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GithubController } from './github.controller';

describe('GithubController', () => {
let controller: GithubController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GithubController],
}).compile();

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

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
59 changes: 59 additions & 0 deletions apps/backend/src/github/github.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
Controller,
Post,
Body,
UseGuards,
Request,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { GithubService } from './github.service';
import { ConnectRepoDto } from './dto/connect-repo.dto';
import { JwtAuthGuard } from '../auth/guards/jwt.guard';
import { PrismaService } from '../prisma/prisma.service';

@Controller('github')
@UseGuards(JwtAuthGuard)
export class GithubController {
constructor(
private readonly githubService: GithubService,
private readonly prisma: PrismaService,
) {}

@Post('connect')
async connectRepo(
@Request() req: { user: { id: string } },
@Body() connectRepoDto: ConnectRepoDto,
) {
const userId = req.user.id;
const { projectId, repoUrl } = connectRepoDto;

// Verify project belongs to a workspace owned by the user
const project = await this.prisma.project.findUnique({
where: { id: projectId },
include: { workspace: true },
});

if (!project) {
throw new NotFoundException('Project not found');
}

if (project.workspace.userId !== userId) {
throw new ForbiddenException('Access denied to this project');
}

// Fetch repository metadata to validate it exists and is public
const metadata = await this.githubService.fetchRepoMetadata(repoUrl);

// Update the project with the GitHub URL
await this.prisma.project.update({
where: { id: projectId },
data: { githubUrl: repoUrl },
});

return {
message: 'Repository successfully connected',
metadata,
};
}
}
11 changes: 11 additions & 0 deletions apps/backend/src/github/github.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { GithubService } from './github.service';
import { GithubController } from './github.controller';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
imports: [PrismaModule],
providers: [GithubService],
controllers: [GithubController],
})
export class GithubModule {}
18 changes: 18 additions & 0 deletions apps/backend/src/github/github.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GithubService } from './github.service';

describe('GithubService', () => {
let service: GithubService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GithubService],
}).compile();

service = module.get<GithubService>(GithubService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
92 changes: 92 additions & 0 deletions apps/backend/src/github/github.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
Injectable,
BadRequestException,
NotFoundException,
} from '@nestjs/common';

export interface RepoMetadata {
name: string;
fullName: string;
description: string;
stargazersCount: number;
forksCount: number;
language: string;
defaultBranch: string;
}

@Injectable()
export class GithubService {
/**
* Parse a GitHub URL to extract the owner and repo name.
*/
private parseRepoUrl(repoUrl: string): { owner: string; repo: string } {
try {
const url = new URL(repoUrl);
if (url.hostname !== 'github.com') {
throw new Error();
}
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length < 2) {
throw new Error();
}
return { owner: parts[0], repo: parts[1].replace('.git', '') };
} catch {
throw new BadRequestException('Invalid GitHub repository URL');
}
}

/**
* Fetch repository metadata from the GitHub API.
*/
async fetchRepoMetadata(repoUrl: string): Promise<RepoMetadata> {
const { owner, repo } = this.parseRepoUrl(repoUrl);

try {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'DevFlowAI',
},
},
);

if (response.status === 404) {
throw new NotFoundException('Repository not found or is not public');
}

if (!response.ok) {
throw new BadRequestException('Failed to fetch repository metadata');
}

const data = (await response.json()) as {
name: string;
full_name: string;
description: string;
stargazers_count: number;
forks_count: number;
language: string;
default_branch: string;
};

return {
name: data.name,
fullName: data.full_name,
description: data.description,
stargazersCount: data.stargazers_count,
forksCount: data.forks_count,
language: data.language,
defaultBranch: data.default_branch,
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof BadRequestException
) {
throw error;
}
throw new BadRequestException('Failed to connect to GitHub API');
}
}
}
2 changes: 1 addition & 1 deletion apps/backend/src/notes/notes.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class NotesService {

private async indexNote(noteId: string, content: string | null) {
if (!content) return;

// Delete old embeddings
await this.prisma.noteEmbedding.deleteMany({ where: { noteId } });

Expand Down
Loading