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
4 changes: 4 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ model User {
githubUrl String?
role Role @default(CONTRIBUTOR)
contributorScore Int @default(0)
githubAccessToken String?
githubRefreshToken String?
lastSyncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand All @@ -69,6 +72,7 @@ model Project {
forks Int @default(0)
topics String[]
logoUrl String?
isCollaborative Boolean @default(false)
ownerId String
owner User @relation(fields: [ownerId], references: [id])
issues Issue[]
Expand Down
53 changes: 39 additions & 14 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,63 @@ import axios from 'axios';
import { env } from '../config/env';
import { cacheGet, cacheSet } from './redis';

const githubApi = axios.create({
baseURL: 'https://api.github.com',
headers: {
Accept: 'application/vnd.github+json',
...(env.GITHUB_API_TOKEN
? { Authorization: `Bearer ${env.GITHUB_API_TOKEN}` }
: {}),
},
});

export const getRepoInfo = async (owner: string, repo: string) => {
const getGithubClient = (token?: string) => {
return axios.create({
baseURL: 'https://api.github.com',
headers: {
Accept: 'application/vnd.github+json',
...(token
? { Authorization: `Bearer ${token}` }
: env.GITHUB_API_TOKEN
? { Authorization: `Bearer ${env.GITHUB_API_TOKEN}` }
: {}),
},
});
};

const githubApi = getGithubClient();

export const getRepoInfo = async (owner: string, repo: string, token?: string) => {
const client = token ? getGithubClient(token) : githubApi;
const cacheKey = `github:repo:${owner}/${repo}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;

const { data } = await githubApi.get(`/repos/${owner}/${repo}`);
const { data } = await client.get(`/repos/${owner}/${repo}`);
await cacheSet(cacheKey, data, 300); // cache 5 min
return data;
};

export const getRepoIssues = async (owner: string, repo: string, page = 1) => {
export const getRepoIssues = async (owner: string, repo: string, page = 1, token?: string) => {
const client = token ? getGithubClient(token) : githubApi;
const cacheKey = `github:issues:${owner}/${repo}:${page}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;

const { data } = await githubApi.get(
const { data } = await client.get(
`/repos/${owner}/${repo}/issues?state=open&per_page=30&page=${page}&labels=good+first+issue,help+wanted`
);
await cacheSet(cacheKey, data, 300);
return data;
};

export const fetchUserRepositories = async (token: string) => {
const client = getGithubClient(token);
let repos: any[] = [];
let page = 1;
let hasNext = true;

while (hasNext) {
const { data, headers } = await client.get(`/user/repos?per_page=100&page=${page}`);
repos = [...repos, ...data];

const linkHeader = headers['link'];
hasNext = linkHeader && linkHeader.includes('rel="next"');
page++;
}
return repos;
};

export const getUserContributions = async (username: string) => {
const cacheKey = `github:contributions:${username}`;
const cached = await cacheGet(cacheKey);
Expand Down
4 changes: 4 additions & 0 deletions src/modules/auth/auth.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const configurePassport = () => {
email: profile.emails?.[0]?.value,
avatarUrl: profile.photos?.[0]?.value,
githubUrl: profile.profileUrl,
githubAccessToken: _accessToken,
githubRefreshToken: _refreshToken,
},
create: {
githubId: String(profile.id),
Expand All @@ -29,6 +31,8 @@ export const configurePassport = () => {
email: profile.emails?.[0]?.value,
avatarUrl: profile.photos?.[0]?.value,
githubUrl: profile.profileUrl,
githubAccessToken: _accessToken,
githubRefreshToken: _refreshToken,
},
});
return done(null, user);
Expand Down
68 changes: 67 additions & 1 deletion src/modules/projects/projects.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { z } from 'zod';
import { prisma } from '../../lib/prisma';
import { AppError } from '../../middleware/errorHandler';
import { getRepoInfo, getRepoIssues, parseRepoUrl } from '../../lib/github';
import { getRepoInfo, getRepoIssues, parseRepoUrl, fetchUserRepositories } from '../../lib/github';
import { uploadFile } from '../../lib/minio';

const createProjectSchema = z.object({
Expand All @@ -13,6 +13,12 @@ export const listProjects = async (req: Request, res: Response) => {
const { page = '1', limit = '20', language, search } = req.query as Record<string, string>;

const where: any = {};

// If not requesting own projects, only show collaborative public ones
if (!req.user || req.user.id !== req.query.ownerId) {
where.isCollaborative = true;
}

if (language) where.language = language;
if (search) where.name = { contains: search, mode: 'insensitive' };

Expand Down Expand Up @@ -103,3 +109,63 @@ export const syncProjectIssues = async (req: Request, res: Response) => {
await Promise.all(upserts);
res.json({ synced: upserts.length });
};

export const syncRepositories = async (req: Request, res: Response) => {
const userId = req.user!.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new AppError('User not found', 404);

if (!user.githubAccessToken) {
throw new AppError('GitHub account not connected', 401);
}

// Rate limit: once per 15 minutes
if (user.lastSyncedAt) {
const diff = new Date().getTime() - user.lastSyncedAt.getTime();
if (diff < 15 * 60 * 1000) {
const waitTime = Math.ceil((15 * 60 * 1000 - diff) / 1000 / 60);
throw new AppError(`Sync limit reached. Please try again in ${waitTime} minute(s).`, 429);
}
}

const repos = await fetchUserRepositories(user.githubAccessToken);

const upserts = repos.map((repo: any) => {
const githubRepoUrl = `https://github.com/${repo.owner.login}/${repo.name}`;
return prisma.project.upsert({
where: { githubRepoUrl },
update: {
name: repo.full_name,
description: repo.description,
language: repo.language,
stars: repo.stargazers_count,
forks: repo.forks_count,
topics: repo.topics || [],
// Preserve isCollaborative flag on update
},
create: {
githubRepoUrl,
name: repo.full_name,
description: repo.description,
language: repo.language,
stars: repo.stargazers_count,
forks: repo.forks_count,
topics: repo.topics || [],
ownerId: userId,
isCollaborative: false,
},
});
});

await Promise.all(upserts);

await prisma.user.update({
where: { id: userId },
data: { lastSyncedAt: new Date() },
});

res.json({
message: 'Repositories synced successfully',
count: repos.length
});
};
20 changes: 19 additions & 1 deletion src/modules/projects/projects.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Router } from 'express';
import { listProjects, createProject, getProject, syncProjectIssues } from './projects.controller';
import { listProjects, createProject, getProject, syncProjectIssues, syncRepositories } from './projects.controller';
import { authenticate } from '../../middleware/auth';
import { upload } from '../../lib/minio';

Expand Down Expand Up @@ -124,4 +124,22 @@ router.get('/:id', getProject);
*/
router.post('/:id/sync-issues', authenticate, syncProjectIssues);

/**
* @swagger
* /api/projects/sync-repos:
* post:
* summary: Sync all repositories from GitHub for the connected user
* tags: [Projects]
* security:
* - cookieAuth: []
* responses:
* 200:
* description: Repositories synced successfully
* 401:
* description: Not authenticated
* 429:
* description: Sync limit reached
*/
router.post('/sync-repos', authenticate, syncRepositories);

export default router;