From 03b7b0396a866e55d43b5b580b151c890cb3b9dc Mon Sep 17 00:00:00 2001 From: Shokudaikiri Date: Mon, 15 Jun 2026 19:50:03 +0300 Subject: [PATCH] feat: repo sync to gh account connected --- prisma/schema.prisma | 4 ++ src/lib/github.ts | 53 +++++++++++----- src/modules/auth/auth.strategy.ts | 4 ++ src/modules/projects/projects.controller.ts | 68 ++++++++++++++++++++- src/modules/projects/projects.routes.ts | 20 +++++- 5 files changed, 133 insertions(+), 16 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b010cca..5d8a942 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 @@ -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[] diff --git a/src/lib/github.ts b/src/lib/github.ts index d7c81e5..356ce77 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -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); diff --git a/src/modules/auth/auth.strategy.ts b/src/modules/auth/auth.strategy.ts index 276d185..0db5e78 100644 --- a/src/modules/auth/auth.strategy.ts +++ b/src/modules/auth/auth.strategy.ts @@ -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), @@ -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); diff --git a/src/modules/projects/projects.controller.ts b/src/modules/projects/projects.controller.ts index 5121b08..06e8496 100644 --- a/src/modules/projects/projects.controller.ts +++ b/src/modules/projects/projects.controller.ts @@ -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({ @@ -13,6 +13,12 @@ export const listProjects = async (req: Request, res: Response) => { const { page = '1', limit = '20', language, search } = req.query as Record; 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' }; @@ -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 + }); +}; diff --git a/src/modules/projects/projects.routes.ts b/src/modules/projects/projects.routes.ts index e53ca1d..2220319 100644 --- a/src/modules/projects/projects.routes.ts +++ b/src/modules/projects/projects.routes.ts @@ -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'; @@ -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;