feat: add eslint config

This commit is contained in:
grtsinry43 2025-10-26 23:54:00 +08:00
parent 434da70b06
commit 5e290945fb
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
27 changed files with 5221 additions and 3378 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules node_modules
.env .env
.idea/
dist dist

1
.husky/commit-msg Normal file
View File

@ -0,0 +1 @@
npx --no -- commitlint --edit $1

1
.husky/pre-commit Normal file
View File

@ -0,0 +1 @@
npx lint-staged

29
.prettierrc Normal file
View File

@ -0,0 +1,29 @@
{
"semi": false,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"overrides": [
{
"files": "*.prisma",
"options": {
"tabWidth": 2
}
},
{
"files": ["*.json", "*.jsonc"],
"options": {
"tabWidth": 2,
"printWidth": 100
}
}
]
}

22
commitlint.config.js Normal file
View File

@ -0,0 +1,22 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'feat', // 新功能
'fix', // 修复bug
'docs', // 文档变更
'style', // 代码格式(不影响代码运行)
'refactor', // 重构
'perf', // 性能优化
'test', // 测试相关
'chore', // 构建过程或辅助工具的变动
'revert', // 回退
'build', // 构建系统或外部依赖项的更改
],
],
'subject-case': [0], // 关闭主题大小写限制
},
}

168
eslint.config.mjs Normal file
View File

@ -0,0 +1,168 @@
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import prettierConfig from 'eslint-config-prettier'
import pluginNode from 'eslint-plugin-n'
import pluginSecurity from 'eslint-plugin-security'
export default tseslint.config(
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/.git/**',
'**/coverage/**',
'**/*.config.js',
'**/*.config.mjs',
'prisma/migrations/**',
],
},
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ['**/*.ts', '**/*.js', '**/*.mjs'],
plugins: {
'@typescript-eslint': tseslint.plugin,
n: pluginNode,
security: pluginSecurity,
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
// Node.js 全局变量
console: 'readonly',
process: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
},
},
rules: {
'n/no-process-exit': 'error',
'n/no-deprecated-api': 'error',
'n/no-unpublished-import': [
'error',
{
allowModules: [
'fastify',
'@fastify/autoload',
'@fastify/cors',
'@fastify/jwt',
'@fastify/sensible',
'prisma',
'@prisma/client',
'vitest',
'tsx',
],
},
],
'n/prefer-global/buffer': ['error', 'always'],
'n/prefer-global/console': ['error', 'always'],
'n/prefer-global/process': ['error', 'always'],
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
'no-await-in-loop': 'warn',
'no-return-await': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/return-await': ['error', 'in-try-catch'],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_|^reply|^request',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'separate-type-imports',
},
],
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'@typescript-eslint/prefer-optional-chain': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
'no-throw-literal': 'off',
'@typescript-eslint/only-throw-error': 'error',
'@typescript-eslint/prefer-promise-reject-errors': 'error',
'security/detect-object-injection': 'warn',
'security/detect-non-literal-regexp': 'warn',
'security/detect-unsafe-regex': 'error',
'security/detect-buffer-noassert': 'error',
'security/detect-eval-with-expression': 'error',
'security/detect-non-literal-fs-filename': 'warn',
'security/detect-possible-timing-attacks': 'warn',
'no-nested-ternary': 'warn',
'max-depth': ['warn', 4],
'max-lines-per-function': [
'warn',
{
max: 150,
skipBlankLines: true,
skipComments: true,
},
],
complexity: ['warn', 15],
'sort-imports': [
'error',
{
ignoreCase: true,
ignoreDeclarationSort: true,
},
],
'@typescript-eslint/no-confusing-void-expression': ['error', { ignoreArrowShorthand: true }],
'@typescript-eslint/promise-function-async': 'error',
},
},
{
files: ['**/*.test.ts', '**/*.spec.ts', '**/tests/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'max-lines-per-function': 'off',
'n/no-unpublished-import': 'off',
},
},
{
files: ['*.config.js', '*.config.mjs', '*.config.ts'],
rules: {
'n/no-unpublished-import': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
},
prettierConfig
)

2620
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,14 @@
"build": "prisma generate && tsc", "build": "prisma generate && tsc",
"start": "node dist/server.js", "start": "node dist/server.js",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev" "prisma:migrate": "prisma migrate dev",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"type-check": "tsc --noEmit",
"validate": "npm run type-check && npm run lint && npm run format:check",
"prepare": "husky"
}, },
"author": "Google LLC", "author": "Google LLC",
"license": "Apache-2.0", "license": "Apache-2.0",
@ -17,22 +24,54 @@
"@fastify/cors": "^11.1.0", "@fastify/cors": "^11.1.0",
"@fastify/jwt": "^10.0.0", "@fastify/jwt": "^10.0.0",
"@fastify/swagger": "^9.5.2", "@fastify/swagger": "^9.5.2",
"@octokit/rest": "^19.0.13", "@octokit/rest": "^22.0.0",
"@octokit/types": "^15.0.1", "@octokit/types": "^15.0.1",
"@prisma/client": "^6.18.0", "@prisma/client": "^6.18.0",
"@upstash/redis": "^1.35.6", "@upstash/redis": "^1.35.6",
"axios": "^1.12.2", "axios": "^1.12.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"fastify": "^5.6.1", "fastify": "^5.6.1",
"fastify-type-provider-zod": "^6.0.0", "fastify-type-provider-zod": "^6.1.0",
"prettier-plugin-eslint": "^1.0.2",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.12", "@commitlint/cli": "^20.1.0",
"nodemon": "^3.1.0", "@commitlint/config-conventional": "^20.0.0",
"@eslint/js": "^9.38.0",
"@types/node": "^24.9.1",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-helpers": "^1.2.1",
"eslint-plugin-fastify-security-rules": "^1.2.0",
"eslint-plugin-n": "^17.23.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-security-node": "^1.1.4",
"fastify-tsconfig": "^3.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"nodemon": "^3.1.10",
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
"prettier": "^3.6.2",
"prisma": "^6.18.0", "prisma": "^6.18.0",
"ts-node-dev": "^2.0.0", "ts-node-dev": "^2.0.0",
"typescript": "^5.4.5" "typescript": "^5.9.3",
"typescript-eslint": "^8.46.2"
},
"lint-staged": {
"*.{ts,js,mjs}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
],
"*.prisma": [
"prisma format"
]
} }
} }

4932
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,24 @@
import Fastify from 'fastify'; import Fastify from 'fastify'
const fastify = Fastify({ const fastify = Fastify({
logger: true logger: true,
}); })
fastify.get('/', async (request, reply) => { fastify.get('/', async () => {
const name = process.env.NAME || 'World'; const name = process.env['NAME'] || 'World'
return `Hello ${name}!`; return `Hello ${name}!`
}); })
const port = parseInt(process.env.PORT || '3000'); const port = parseInt(process.env['PORT'] || '3000')
const start = async () => { const start = async () => {
try { try {
await fastify.listen({ port }); await fastify.listen({ port })
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err)
process.exit(1); // eslint-disable-next-line n/no-process-exit
process.exit(1)
}
} }
};
start(); // eslint-disable-next-line @typescript-eslint/no-floating-promises
start().then((r) => console.log(r))

View File

@ -1,9 +1,17 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client'
// Prevent creating multiple instances of PrismaClient in development const prismaClientSingleton = () => {
// when using hot-reloading (ts-node-dev / nodemon). return new PrismaClient()
const g = global as any; }
export const prisma: PrismaClient = g.__prisma ?? new PrismaClient(); declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton> | undefined
} & typeof global
if (process.env.NODE_ENV !== 'production') g.__prisma = prisma; const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env['NODE_ENV'] !== 'production') {
globalThis.prismaGlobal = prisma
}

View File

@ -1,7 +1,7 @@
import { Redis } from '@upstash/redis'; import { Redis } from '@upstash/redis'
import 'dotenv/config'; import 'dotenv/config'
export const redis = new Redis({ export const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!, url: process.env.UPSTASH_REDIS_URL,
token: process.env.UPSTASH_REDIS_TOKEN!, token: process.env.UPSTASH_REDIS_TOKEN,
}); })

View File

@ -1,4 +1,4 @@
import { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'
/** /**
* This middleware function verifies the JWT token from the request. * This middleware function verifies the JWT token from the request.
@ -9,9 +9,10 @@ import { FastifyRequest, FastifyReply } from 'fastify';
export const authMiddleware = async (req: FastifyRequest, reply: FastifyReply) => { export const authMiddleware = async (req: FastifyRequest, reply: FastifyReply) => {
try { try {
// This will verify the token and attach the user payload to `req.user` // This will verify the token and attach the user payload to `req.user`
await req.jwtVerify(); await req.jwtVerify()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) { } catch (error) {
// If verification fails, send an unauthorized error // If verification fails, send an unauthorized error
reply.status(401).send({ error: 'Unauthorized' }); reply.status(401).send({ error: 'Unauthorized' })
}
} }
};

View File

@ -1,8 +1,8 @@
import { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify'
export async function errorMiddleware(app: FastifyInstance) { export async function errorMiddleware(app: FastifyInstance) {
app.setErrorHandler((error, request, reply) => { app.setErrorHandler((error, _, reply) => {
app.log.error(error); app.log.error(error)
reply.status(500).send({ error: 'Internal Server Error' }); reply.status(500).send({ error: 'Internal Server Error' })
}); })
} }

View File

@ -1,17 +1,19 @@
import { FastifyRequest, FastifyReply} from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'
import * as crypto from 'crypto'; import * as crypto from 'crypto'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
export function verifyGithubWebhook(req: FastifyRequest, reply: FastifyReply) { export function verifyGithubWebhook(req: FastifyRequest, reply: FastifyReply) {
const signature = req.headers['x-hub-signature-256'] as string; const signature = req.headers['x-hub-signature-256'] as string
if (!signature) { if (!signature) {
return reply.status(401).send({ error: 'No signature found' }); return reply.status(401).send({ error: 'No signature found' })
} }
const secret = process.env.GITHUB_WEBHOOK_SECRET!; const secret = process.env['GITHUB_WEBHOOK_SECRET']
const hmac = crypto.createHmac('sha256', secret); const hmac = crypto.createHmac('sha256', secret!)
const digest = `sha256=${hmac.update(JSON.stringify(req.body)).digest('hex')}`; const digest = `sha256=${hmac.update(JSON.stringify(req.body)).digest('hex')}`
if (digest !== signature) { if (digest !== signature) {
return reply.status(401).send({ error: 'Invalid signature' }); return reply.status(401).send({ error: 'Invalid signature' })
} }
} }

View File

@ -1,22 +1,22 @@
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'; import { z } from 'zod'
import { exchangeCodeForToken, getGithubUser } from '../services/github.service'; import { exchangeCodeForToken, getGithubUser } from '@services/github.service'
import { prisma } from '../lib/prisma'; import prisma from '@/lib/prisma'
import { createResponseSchema, successResponse, errorResponse, ErrorCode } from '../types/response'; import { createResponseSchema, ErrorCode, errorResponse, successResponse } from '@/types/response'
// Schema for login request body // Schema for login request body
const loginBodySchema = z.object({ const loginBodySchema = z.object({
code: z.string().min(1, 'Authorization code is required'), code: z.string().min(1, 'Authorization code is required'),
}); })
// Schema for login response data // Schema for login response data
const loginDataSchema = z.object({ const loginDataSchema = z.object({
token: z.string(), token: z.string(),
}); })
// Use the unified response format // Use the unified response format
const loginSuccessSchema = createResponseSchema(loginDataSchema); const loginSuccessSchema = createResponseSchema(loginDataSchema)
const loginErrorSchema = createResponseSchema(z.null()); const loginErrorSchema = createResponseSchema(z.null())
export const authRoutes: FastifyPluginAsyncZod = async (app) => { export const authRoutes: FastifyPluginAsyncZod = async (app) => {
app.post( app.post(
@ -34,16 +34,16 @@ export const authRoutes: FastifyPluginAsyncZod = async (app) => {
}, },
async (request, reply) => { async (request, reply) => {
// ✅ Type is automatically inferred from Zod schema // ✅ Type is automatically inferred from Zod schema
const { code } = request.body; const { code } = request.body
try { try {
const accessToken = await exchangeCodeForToken(code); const accessToken = await exchangeCodeForToken(code)
const githubUser = await getGithubUser(accessToken); const githubUser = await getGithubUser(accessToken)
// Convert number to string as Prisma schema expects String // Convert number to string as Prisma schema expects String
let user = await prisma.user.findUnique({ let user = await prisma.user.findUnique({
where: { githubId: String(githubUser.id) }, where: { githubId: String(githubUser.id) },
}); })
if (!user) { if (!user) {
user = await prisma.user.create({ user = await prisma.user.create({
@ -52,7 +52,7 @@ export const authRoutes: FastifyPluginAsyncZod = async (app) => {
username: githubUser.login, username: githubUser.login,
avatarUrl: githubUser.avatar_url, avatarUrl: githubUser.avatar_url,
}, },
}); })
} }
// Generate JWT token // Generate JWT token
@ -63,16 +63,14 @@ export const authRoutes: FastifyPluginAsyncZod = async (app) => {
avatarUrl: user.avatarUrl, avatarUrl: user.avatarUrl,
accessToken, accessToken,
}, },
{ expiresIn: '1d' }, { expiresIn: '1d' }
); )
return successResponse({ token }); return successResponse({ token })
} catch (error) { } catch (error) {
app.log.error({ error }, 'Authentication Error'); app.log.error({ error }, 'Authentication Error')
return reply return reply.status(500).send(errorResponse(ErrorCode.AUTH_FAILED, 'Authentication failed'))
.status(500) }
.send(errorResponse(ErrorCode.AUTH_FAILED, 'Authentication failed')); }
)
} }
},
);
};

View File

@ -1,14 +1,14 @@
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'; import { z } from 'zod'
import { GitHubService } from '../services/github.service'; import { GitHubService } from '@/services/github.service'
import { authMiddleware } from '../middlewares/auth.middleware'; import { authMiddleware } from '@/middlewares/auth.middleware'
import { createResponseSchema, successResponse } from '../types/response'; import { createResponseSchema, successResponse } from '@/types/response'
// Schema for request parameters // Schema for request parameters
const repoParamsSchema = z.object({ const repoParamsSchema = z.object({
owner: z.string(), owner: z.string(),
repo: z.string(), repo: z.string(),
}); })
// Schema for repository statistics response // Schema for repository statistics response
const repoStatsSchema = z.object({ const repoStatsSchema = z.object({
@ -18,7 +18,7 @@ const repoStatsSchema = z.object({
commits: z.number(), commits: z.number(),
releases: z.number(), releases: z.number(),
contributors: z.number(), contributors: z.number(),
}); })
// Schema for repository list response // Schema for repository list response
const repoListItemSchema = z.object({ const repoListItemSchema = z.object({
@ -26,7 +26,7 @@ const repoListItemSchema = z.object({
name: z.string(), name: z.string(),
full_name: z.string(), full_name: z.string(),
private: z.boolean(), private: z.boolean(),
html_url: z.string().url(), html_url: z.url(),
description: z.string().nullable(), description: z.string().nullable(),
stargazers_count: z.number(), stargazers_count: z.number(),
watchers_count: z.number(), watchers_count: z.number(),
@ -34,19 +34,19 @@ const repoListItemSchema = z.object({
language: z.string().nullable(), language: z.string().nullable(),
owner: z.object({ owner: z.object({
login: z.string(), login: z.string(),
avatar_url: z.string().url(), avatar_url: z.url(),
}), }),
}); })
const reposListSchema = z.array(repoListItemSchema); const reposListSchema = z.array(repoListItemSchema)
// Use the unified response format // Use the unified response format
const reposListResponseSchema = createResponseSchema(reposListSchema); const reposListResponseSchema = createResponseSchema(reposListSchema)
const repoStatsResponseSchema = createResponseSchema(repoStatsSchema); const repoStatsResponseSchema = createResponseSchema(repoStatsSchema)
export const repoRoutes: FastifyPluginAsyncZod = async (app) => { export const repoRoutes: FastifyPluginAsyncZod = async (app) => {
// All routes in this plugin require authentication // All routes in this plugin require authentication
app.addHook('preHandler', authMiddleware); app.addHook('preHandler', authMiddleware)
// Route to list repositories for the authenticated user // Route to list repositories for the authenticated user
app.get( app.get(
@ -61,12 +61,12 @@ export const repoRoutes: FastifyPluginAsyncZod = async (app) => {
}, },
}, },
async (request) => { async (request) => {
const { accessToken, username } = request.user!; const { accessToken, username } = request.user
const githubService = new GitHubService(accessToken, username); const githubService = new GitHubService(accessToken, username)
const repos = await githubService.getRepositories(); const repos = await githubService.getRepositories()
return successResponse(repos); return successResponse(repos)
}, }
); )
// Route to get statistics for a specific repository // Route to get statistics for a specific repository
app.get( app.get(
@ -83,11 +83,11 @@ export const repoRoutes: FastifyPluginAsyncZod = async (app) => {
}, },
async (request) => { async (request) => {
// ✅ Type is automatically inferred from Zod schema // ✅ Type is automatically inferred from Zod schema
const { owner, repo } = request.params; const { owner, repo } = request.params
const { accessToken, username } = request.user!; const { accessToken, username } = request.user
const githubService = new GitHubService(accessToken, username); const githubService = new GitHubService(accessToken, username)
const stats = await githubService.getRepositoryStats(owner, repo); const stats = await githubService.getRepositoryStats(owner, repo)
return successResponse(stats); return successResponse(stats)
}, }
); )
}; }

View File

@ -1,27 +1,27 @@
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'; import { z } from 'zod'
import { StatsService } from '../services/stats.service'; import { StatsService } from '@/services/stats.service'
import { authMiddleware } from '../middlewares/auth.middleware'; import { authMiddleware } from '@/middlewares/auth.middleware'
import { createResponseSchema, successResponse } from '../types/response'; import { createResponseSchema, successResponse } from '@/types/response'
// Schema for overview stats response // Schema for overview stats response
const overviewStatsSchema = z.object({ const overviewStatsSchema = z.object({
totalRepos: z.number(), totalRepos: z.number(),
totalStars: z.number(), totalStars: z.number(),
totalForks: z.number(), totalForks: z.number(),
}); })
// Schema for activity stats response // Schema for activity stats response
const activityStatsSchema = z.object({ const activityStatsSchema = z.object({
totalCommitsLast30Days: z.number(), totalCommitsLast30Days: z.number(),
}); })
// Use the unified response format // Use the unified response format
const overviewStatsResponseSchema = createResponseSchema(overviewStatsSchema); const overviewStatsResponseSchema = createResponseSchema(overviewStatsSchema)
const activityStatsResponseSchema = createResponseSchema(activityStatsSchema); const activityStatsResponseSchema = createResponseSchema(activityStatsSchema)
export const statsRoutes: FastifyPluginAsyncZod = async (app) => { export const statsRoutes: FastifyPluginAsyncZod = async (app) => {
app.addHook('preHandler', authMiddleware); app.addHook('preHandler', authMiddleware)
app.get( app.get(
'/overview', '/overview',
@ -35,12 +35,12 @@ export const statsRoutes: FastifyPluginAsyncZod = async (app) => {
}, },
}, },
async (request) => { async (request) => {
const { accessToken, username } = request.user!; const { accessToken, username } = request.user
const statsService = new StatsService(accessToken, username); const statsService = new StatsService(accessToken, username)
const overviewStats = await statsService.getOverviewStats(); const overviewStats = await statsService.getOverviewStats()
return successResponse(overviewStats); return successResponse(overviewStats)
}, }
); )
app.get( app.get(
'/activity', '/activity',
@ -54,10 +54,10 @@ export const statsRoutes: FastifyPluginAsyncZod = async (app) => {
}, },
}, },
async (request) => { async (request) => {
const { accessToken, username } = request.user!; const { accessToken, username } = request.user
const statsService = new StatsService(accessToken, username); const statsService = new StatsService(accessToken, username)
const activityStats = await statsService.getActivityStats(); const activityStats = await statsService.getActivityStats()
return successResponse(activityStats); return successResponse(activityStats)
}, }
); )
}; }

View File

@ -1,26 +1,27 @@
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'; import { z } from 'zod'
import { verifyGithubWebhook } from '../middlewares/verify-webhook.middleware'; import { verifyGithubWebhook } from '@/middlewares/verify-webhook.middleware'
import { handleWebhook } from '../services/webhook.service'; import { handleWebhook } from '@/services/webhook.service'
import { GitHubWebhookPayload } from '../types/github'; import type { GitHubWebhookPayload } from '@/types/github'
import { createResponseSchema, successResponse, errorResponse, ErrorCode } from '../types/response'; import { createResponseSchema, ErrorCode, errorResponse, successResponse } from '@/types/response'
// Schema for webhook payload (flexible since payloads vary by event type) // Schema for webhook payload (flexible since payloads vary by event type)
const webhookBodySchema = z.record(z.string(), z.any()); const webhookBodySchema = z.record(z.string(), z.any())
// Schema for webhook response data // Schema for webhook response data
const webhookDataSchema = z.object({ const webhookDataSchema = z.object({
message: z.string(), message: z.string(),
}); })
// Use the unified response format // Use the unified response format
const webhookSuccessSchema = createResponseSchema(webhookDataSchema); const webhookSuccessSchema = createResponseSchema(webhookDataSchema)
const webhookErrorSchema = createResponseSchema(z.null()); const webhookErrorSchema = createResponseSchema(z.null())
export const webhookRoutes: FastifyPluginAsyncZod = async (app) => { export const webhookRoutes: FastifyPluginAsyncZod = async (app) => {
app.post( app.post(
'/github', '/github',
{ {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
preHandler: verifyGithubWebhook, preHandler: verifyGithubWebhook,
schema: { schema: {
description: 'Handle GitHub webhook events', description: 'Handle GitHub webhook events',
@ -35,14 +36,14 @@ export const webhookRoutes: FastifyPluginAsyncZod = async (app) => {
async (request, reply) => { async (request, reply) => {
try { try {
// Type cast to GitHubWebhookPayload for service function // Type cast to GitHubWebhookPayload for service function
await handleWebhook(request.body as GitHubWebhookPayload); await handleWebhook(request.body as GitHubWebhookPayload)
return successResponse({ message: 'Webhook received' }); return successResponse({ message: 'Webhook received' })
} catch (error) { } catch (error) {
app.log.error({ error }, 'Webhook processing error'); app.log.error({ error }, 'Webhook processing error')
return reply return reply
.status(500) .status(500)
.send(errorResponse(ErrorCode.INTERNAL_ERROR, 'Webhook processing failed')); .send(errorResponse(ErrorCode.INTERNAL_ERROR, 'Webhook processing failed'))
}
}
)
} }
},
);
};

View File

@ -1,19 +1,16 @@
import fastify from 'fastify'; import fastify from 'fastify'
import { import type { ZodTypeProvider } from 'fastify-type-provider-zod'
serializerCompiler, import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
validatorCompiler, import fastifyJwt from '@fastify/jwt'
ZodTypeProvider import fastifyCors from '@fastify/cors'
} from 'fastify-type-provider-zod'; import 'dotenv/config'
import fastifyJwt from '@fastify/jwt';
import fastifyCors from '@fastify/cors';
import 'dotenv/config';
import { authRoutes } from './routes/auth'; import { authRoutes } from '@/routes/auth'
import { repoRoutes } from './routes/repos'; import { repoRoutes } from '@/routes/repos'
import { statsRoutes } from './routes/stats'; import { statsRoutes } from '@/routes/stats'
import { webhookRoutes } from './routes/webhooks'; import { webhookRoutes } from '@/routes/webhooks'
import swagger from '@fastify/swagger'; import swagger from '@fastify/swagger'
// Main function to bootstrap the server // Main function to bootstrap the server
async function bootstrap() { async function bootstrap() {
@ -28,21 +25,21 @@ async function bootstrap() {
}, },
}, },
}, },
}).withTypeProvider<ZodTypeProvider>(); }).withTypeProvider<ZodTypeProvider>()
// Set the validator and serializer for Zod // Set the validator and serializer for Zod
app.setValidatorCompiler(validatorCompiler); app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler); app.setSerializerCompiler(serializerCompiler)
// Register plugins // Register plugins
await app.register(fastifyJwt, { await app.register(fastifyJwt, {
secret: process.env.JWT_SECRET!, secret: process.env.JWT_SECRET,
}); })
await app.register(fastifyCors, { await app.register(fastifyCors, {
origin: process.env.FRONTEND_URL || 'http://localhost:3000', origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true, credentials: true,
}); })
// Register Swagger for OpenAPI spec generation // Register Swagger for OpenAPI spec generation
await app.register(swagger, { await app.register(swagger, {
@ -67,11 +64,11 @@ async function bootstrap() {
}, },
], ],
}, },
}); })
// --- Docs portal --- // --- Docs portal ---
// Top-level docs landing page that links to multiple renderers (ReDoc, Stoplight Elements, RapiDoc) // Top-level docs landing page that links to multiple renderers (ReDoc, Stoplight Elements, RapiDoc)
app.get('/docs', (req, reply) => { app.get('/docs', (_, reply) => {
reply.type('text/html').send(` reply.type('text/html').send(`
<!doctype html> <!doctype html>
<html> <html>
@ -118,11 +115,11 @@ async function bootstrap() {
</div> </div>
</body> </body>
</html> </html>
`); `)
}); })
// --- RapiDoc route (moved from /docs to /docs/rapidoc) --- // --- RapiDoc route (moved from /docs to /docs/rapidoc) ---
app.get('/docs/rapidoc', (req, reply) => { app.get('/docs/rapidoc', (_, reply) => {
reply.type('text/html').send(` reply.type('text/html').send(`
<!doctype html> <!doctype html>
<html> <html>
@ -141,12 +138,12 @@ async function bootstrap() {
> </rapi-doc> > </rapi-doc>
</body> </body>
</html> </html>
`); `)
}); })
// --- API Documentation with ReDoc (alternative nicer UI) --- // --- API Documentation with ReDoc (alternative nicer UI) ---
// ReDoc gives a clean, modern single-page OpenAPI rendering similar to Knife4j's look. // ReDoc gives a clean, modern single-page OpenAPI rendering similar to Knife4j's look.
app.get('/docs/redoc', (req, reply) => { app.get('/docs/redoc', (_, reply) => {
reply.type('text/html').send(` reply.type('text/html').send(`
<!doctype html> <!doctype html>
<html> <html>
@ -175,12 +172,12 @@ async function bootstrap() {
</script> </script>
</body> </body>
</html> </html>
`); `)
}); })
// --- Stoplight Elements (interactive, Knife4j-like feel) --- // --- Stoplight Elements (interactive, Knife4j-like feel) ---
// Uses Stoplight Elements Web Components from CDN to render a modern interactive portal. // Uses Stoplight Elements Web Components from CDN to render a modern interactive portal.
app.get('/docs/stoplight', (req, reply) => { app.get('/docs/stoplight', (_, reply) => {
reply.type('text/html').send(` reply.type('text/html').send(`
<!doctype html> <!doctype html>
<html> <html>
@ -227,35 +224,36 @@ async function bootstrap() {
</main> </main>
</body> </body>
</html> </html>
`); `)
}); })
app.get('/docs/json', (req, reply) => { app.get('/docs/json', (_, reply) => {
reply.send(app.swagger()); reply.send(app.swagger())
}); })
// Register routes // Register routes
await app.register(authRoutes, { prefix: '/auth' }); await app.register(authRoutes, { prefix: '/auth' })
await app.register(repoRoutes, { prefix: '/repos' }); await app.register(repoRoutes, { prefix: '/repos' })
await app.register(statsRoutes, { prefix: '/stats' }); await app.register(statsRoutes, { prefix: '/stats' })
await app.register(webhookRoutes, { prefix: '/webhooks' }); await app.register(webhookRoutes, { prefix: '/webhooks' })
// Add a generic error handler // Add a generic error handler
app.setErrorHandler((error, request, reply) => { app.setErrorHandler((error, _, reply) => {
app.log.error(error); app.log.error(error)
reply.status(500).send({ error: 'Internal Server Error' }); reply.status(500).send({ error: 'Internal Server Error' })
}); })
return app; return app
} }
// Start the server // Start the server
(async () => { await (async () => {
try { try {
const app = await bootstrap(); const app = await bootstrap()
await app.listen({ port: 3333, host: '0.0.0.0' }); await app.listen({ port: 3333, host: '0.0.0.0' })
} catch (err) { } catch (err) {
console.error(err); console.error(err)
process.exit(1); // eslint-disable-next-line n/no-process-exit
process.exit(1)
} }
})(); })()

View File

@ -1,20 +1,22 @@
import { Redis } from '@upstash/redis'; import { Redis } from '@upstash/redis'
export const redis = new Redis({ export const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!, url: process.env.UPSTASH_REDIS_URL,
token: process.env.UPSTASH_REDIS_TOKEN!, token: process.env.UPSTASH_REDIS_TOKEN,
}); })
export class CacheService { export class CacheService {
async get(key: string) { async get(key: string) {
return await redis.get(key); return redis.get(key)
} }
async set(key: string, value: any, ttl?: number) { async set(key: string, value: any, ttl?: number) {
// Only pass ex option if ttl is defined // Only pass ex option if ttl is defined
if (ttl !== undefined) { if (ttl !== undefined) {
return await redis.set(key, value, { ex: ttl }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return redis.set(key, value, { ex: ttl })
} }
return await redis.set(key, value); // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return redis.set(key, value)
} }
} }

View File

@ -1,14 +1,13 @@
import { Octokit } from '@octokit/rest'; import { Octokit } from '@octokit/rest'
import { Endpoints } from '@octokit/types'; import type { Endpoints } from '@octokit/types'
import axios from 'axios'; import axios from 'axios'
import { redis } from '../lib/redis'; import { redis } from '@/lib/redis'
import { GitHubUser } from '../types/github'; import type { GitHubUser } from '@/types/github'
const GITHUB_API_BASE_URL = 'https://api.github.com'; const GITHUB_API_BASE_URL = 'https://api.github.com'
// Define a type for the repository list response data for clarity // Define a type for the repository list response data for clarity
type ReposListForAuthenticatedUserResponse = type ReposListForAuthenticatedUserResponse = Endpoints['GET /user/repos']['response']['data']
Endpoints['GET /user/repos']['response']['data'];
export async function exchangeCodeForToken(code: string): Promise<string> { export async function exchangeCodeForToken(code: string): Promise<string> {
const response = await axios.post( const response = await axios.post(
@ -20,41 +19,43 @@ export async function exchangeCodeForToken(code: string): Promise<string> {
}, },
{ {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}, }
); )
return response.data.access_token; // eslint-disable-next-line @typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-member-access
return response.data?.access_token
} }
export async function getGithubUser(accessToken: string): Promise<GitHubUser> { export async function getGithubUser(accessToken: string): Promise<GitHubUser> {
const response = await axios.get<GitHubUser>(`${GITHUB_API_BASE_URL}/user`, { const response = await axios.get<GitHubUser>(`${GITHUB_API_BASE_URL}/user`, {
headers: { Authorization: `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}); })
return response.data; return response.data
} }
export class GitHubService { export class GitHubService {
private octokit: Octokit; private octokit: Octokit
private username: string; private username: string
constructor(accessToken: string, username: string) { constructor(accessToken: string, username: string) {
this.octokit = new Octokit({ auth: accessToken }); this.octokit = new Octokit({ auth: accessToken })
this.username = username; this.username = username
} }
async getRepositories(): Promise<ReposListForAuthenticatedUserResponse> { async getRepositories(): Promise<ReposListForAuthenticatedUserResponse> {
const cacheKey = `repos:${this.username}`; const cacheKey = `repos:${this.username}`
const cachedRepos = await redis.get(cacheKey); const cachedRepos = await redis.get(cacheKey)
if (cachedRepos) { if (cachedRepos) {
return JSON.parse(cachedRepos as string); // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(cachedRepos as string)
} }
const { data } = await this.octokit.repos.listForAuthenticatedUser(); const { data } = await this.octokit.repos.listForAuthenticatedUser()
await redis.set(cacheKey, JSON.stringify(data), { ex: 3600 }); // Cache for 1 hour await redis.set(cacheKey, JSON.stringify(data), { ex: 3600 }) // Cache for 1 hour
return data; return data
} }
async getRepositoryStats(owner: string, repo: string, since?: string) { async getRepositoryStats(owner: string, repo: string, since?: string) {
@ -63,7 +64,7 @@ export class GitHubService {
this.octokit.repos.listCommits({ owner, repo, since }), this.octokit.repos.listCommits({ owner, repo, since }),
this.octokit.repos.listReleases({ owner, repo }), this.octokit.repos.listReleases({ owner, repo }),
this.octokit.repos.listContributors({ owner, repo }), this.octokit.repos.listContributors({ owner, repo }),
]); ])
const stats = { const stats = {
stars: repoData.data.stargazers_count, stars: repoData.data.stargazers_count,
@ -72,8 +73,8 @@ export class GitHubService {
commits: commits.data.length, commits: commits.data.length,
releases: releases.data.length, releases: releases.data.length,
contributors: contributors.data.length, contributors: contributors.data.length,
}; }
return stats; return stats
} }
} }

View File

@ -1,42 +1,42 @@
import { prisma } from '../lib/prisma'; import { GitHubService } from './github.service'
import { GitHubService } from './github.service';
export class StatsService { export class StatsService {
private githubService: GitHubService; private githubService: GitHubService
constructor(accessToken: string, username: string) { constructor(accessToken: string, username: string) {
this.githubService = new GitHubService(accessToken, username); this.githubService = new GitHubService(accessToken, username)
} }
async getOverviewStats() { async getOverviewStats() {
const repos = await this.githubService.getRepositories(); const repos = await this.githubService.getRepositories()
const totalStars = repos.reduce((acc, repo) => acc + (repo.stargazers_count || 0), 0); const totalStars = repos.reduce((acc, repo) => acc + (repo.stargazers_count || 0), 0)
const totalForks = repos.reduce((acc, repo) => acc + (repo.forks_count || 0), 0); const totalForks = repos.reduce((acc, repo) => acc + (repo.forks_count || 0), 0)
return { return {
totalRepos: repos.length, totalRepos: repos.length,
totalStars, totalStars,
totalForks, totalForks,
}; }
} }
async getActivityStats() { async getActivityStats() {
const repos = await this.githubService.getRepositories(); const repos = await this.githubService.getRepositories()
const thirtyDaysAgo = new Date( const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
Date.now() - 30 * 24 * 60 * 60 * 1000,
).toISOString();
const commitPromises = repos.map((repo) => { const commitPromises = repos.map(async (repo) => {
const [owner, repoName] = repo.full_name.split('/'); const [owner, repoName] = repo.full_name.split('/')
return this.githubService.getRepositoryStats(owner, repoName, thirtyDaysAgo); if (!owner || !repoName) {
}); return { commits: 0 }
}
return this.githubService.getRepositoryStats(owner, repoName, thirtyDaysAgo)
})
const allStats = await Promise.all(commitPromises); const allStats = await Promise.all(commitPromises)
const totalCommits = allStats.reduce((acc, stats) => acc + stats.commits, 0); const totalCommits = allStats.reduce((acc, stats) => acc + stats.commits, 0)
return { return {
totalCommitsLast30Days: totalCommits, totalCommitsLast30Days: totalCommits,
}; }
} }
} }

View File

@ -1,17 +1,17 @@
import { prisma } from '../lib/prisma'; import prisma from '@/lib/prisma'
import { GitHubWebhookPayload } from '../types/github'; import type { GitHubWebhookPayload } from '@/types/github'
export async function handleWebhook(payload: GitHubWebhookPayload) { export async function handleWebhook(payload: GitHubWebhookPayload) {
const { action, pull_request, repository } = payload; const { action, pull_request, repository } = payload
if (action === 'opened' || action === 'closed') { if (action === 'opened' || action === 'closed') {
const { number, title, state, user, created_at, closed_at } = pull_request; const { number, title, state, user, created_at, closed_at } = pull_request
await prisma.pullRequest.upsert({ await prisma.pullRequest.upsert({
where: { repositoryId_number: { repositoryId: repository.id, number } }, where: { repositoryId_number: { repositoryId: repository.id, number } },
update: { update: {
state, state,
closedAt: closed_at ? new Date(closed_at) : null closedAt: closed_at ? new Date(closed_at) : null,
}, },
create: { create: {
number, number,
@ -40,6 +40,6 @@ export async function handleWebhook(payload: GitHubWebhookPayload) {
}, },
}, },
}, },
}); })
} }
} }

View File

@ -2,14 +2,35 @@
* Represents the user data structure returned from the GitHub API. * Represents the user data structure returned from the GitHub API.
*/ */
export interface GitHubUser { export interface GitHubUser {
id: number; id: number
login: string; login: string
name: string | null; name: string | null
avatar_url: string; avatar_url: string
} }
/** /**
* A generic type for the GitHub webhook payload. * A generic type for the GitHub webhook payload.
* The actual structure varies widely depending on the event type. * The actual structure varies widely depending on the event type.
*/ */
export type GitHubWebhookPayload = Record<string, any>; export interface GitHubWebhookPayload {
action: string
pull_request: {
number: number
title: string
state: string
user: {
id: number
login: string
avatar_url: string
}
created_at: string
closed_at: string | null
}
repository: {
id: number
name: string
owner: {
login: string
}
}
}

View File

@ -1,4 +1,4 @@
import { z } from 'zod'; import { z } from 'zod'
/** /**
* Standard API Response Format * Standard API Response Format
@ -8,12 +8,12 @@ import { z } from 'zod';
*/ */
// Generic response schema creator // Generic response schema creator
export function createResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) { export function createResponseSchema<T extends z.ZodType>(dataSchema: T) {
return z.object({ return z.object({
code: z.number(), code: z.number(),
msg: z.string(), msg: z.string(),
data: dataSchema, data: dataSchema,
}); })
} }
// Success response with data // Success response with data
@ -22,7 +22,7 @@ export function successResponse<T>(data: T) {
code: 0, code: 0,
msg: '', msg: '',
data, data,
}; }
} }
// Error response // Error response
@ -31,7 +31,7 @@ export function errorResponse(code: number, msg: string) {
code, code,
msg, msg,
data: null, data: null,
}; }
} }
// Common error codes // Common error codes
@ -45,11 +45,11 @@ export const ErrorCode = {
AUTH_FAILED: 1001, AUTH_FAILED: 1001,
GITHUB_API_ERROR: 1002, GITHUB_API_ERROR: 1002,
WEBHOOK_VERIFICATION_FAILED: 1003, WEBHOOK_VERIFICATION_FAILED: 1003,
} as const; } as const
// Error response schema // Error response schema
export const errorResponseSchema = z.object({ export const errorResponseSchema = z.object({
code: z.number(), code: z.number(),
msg: z.string(), msg: z.string(),
data: z.null(), data: z.null(),
}); })

View File

@ -1,18 +1,58 @@
{ {
"extends": "fastify-tsconfig",
"compilerOptions": { "compilerOptions": {
"target": "ES2020", // More modern target
"module": "commonjs",
"strict": true, // Enable all strict type-checking options
"esModuleInterop": true,
"skipLibCheck": true, // Skip type checking of declaration files
"forceConsistentCasingInFileNames": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"sourceMap": true "declaration": true,
}, "declarationMap": true,
"include": [ "sourceMap": true,
"src/**/*.ts", // Include all .ts files in the src directory
"src/types/**/*.d.ts" // Include our custom type definitions "module": "NodeNext",
], "moduleResolution": "NodeNext",
"exclude": ["node_modules"] "target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowJs": false,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@config/*": ["src/config/*"],
"@routes/*": ["src/routes/*"],
"@services/*": ["src/services/*"],
"@utils/*": ["src/utils/*"],
"@types/*": ["src/types/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "node_modules/@prisma/client"],
"exclude": ["node_modules", "dist", "build", "coverage", "**/*.test.ts", "**/*.spec.ts"],
"ts-node": {
"require": ["tsconfig-paths/register"],
"transpileOnly": true,
"files": true
}
} }