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
.env
.idea/
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",
"start": "node dist/server.js",
"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",
"license": "Apache-2.0",
@ -17,22 +24,54 @@
"@fastify/cors": "^11.1.0",
"@fastify/jwt": "^10.0.0",
"@fastify/swagger": "^9.5.2",
"@octokit/rest": "^19.0.13",
"@octokit/rest": "^22.0.0",
"@octokit/types": "^15.0.1",
"@prisma/client": "^6.18.0",
"@upstash/redis": "^1.35.6",
"axios": "^1.12.2",
"dotenv": "^17.2.3",
"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"
},
"devDependencies": {
"@types/node": "^20.12.12",
"nodemon": "^3.1.0",
"@commitlint/cli": "^20.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",
"prettier": "^3.6.2",
"prisma": "^6.18.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"
]
}
}
}

5094
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({
logger: true
});
logger: true,
})
fastify.get('/', async (request, reply) => {
const name = process.env.NAME || 'World';
return `Hello ${name}!`;
});
fastify.get('/', async () => {
const name = process.env['NAME'] || 'World'
return `Hello ${name}!`
})
const port = parseInt(process.env.PORT || '3000');
const port = parseInt(process.env['PORT'] || '3000')
const start = async () => {
try {
await fastify.listen({ port });
await fastify.listen({ port })
} catch (err) {
fastify.log.error(err);
process.exit(1);
fastify.log.error(err)
// 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
// when using hot-reloading (ts-node-dev / nodemon).
const g = global as any;
const prismaClientSingleton = () => {
return new PrismaClient()
}
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 'dotenv/config';
import { Redis } from '@upstash/redis'
import 'dotenv/config'
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
url: process.env.UPSTASH_REDIS_URL,
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.
@ -9,9 +9,10 @@ import { FastifyRequest, FastifyReply } from 'fastify';
export const authMiddleware = async (req: FastifyRequest, reply: FastifyReply) => {
try {
// 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) {
// 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) {
app.setErrorHandler((error, request, reply) => {
app.log.error(error);
reply.status(500).send({ error: 'Internal Server Error' });
});
app.setErrorHandler((error, _, reply) => {
app.log.error(error)
reply.status(500).send({ error: 'Internal Server Error' })
})
}

View File

@ -1,17 +1,19 @@
import { FastifyRequest, FastifyReply} from 'fastify';
import * as crypto from 'crypto';
import type { FastifyReply, FastifyRequest } from 'fastify'
import * as crypto from 'crypto'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
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) {
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 hmac = crypto.createHmac('sha256', secret);
const digest = `sha256=${hmac.update(JSON.stringify(req.body)).digest('hex')}`;
const secret = process.env['GITHUB_WEBHOOK_SECRET']
const hmac = crypto.createHmac('sha256', secret!)
const digest = `sha256=${hmac.update(JSON.stringify(req.body)).digest('hex')}`
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 { z } from 'zod';
import { exchangeCodeForToken, getGithubUser } from '../services/github.service';
import { prisma } from '../lib/prisma';
import { createResponseSchema, successResponse, errorResponse, ErrorCode } from '../types/response';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'
import { exchangeCodeForToken, getGithubUser } from '@services/github.service'
import prisma from '@/lib/prisma'
import { createResponseSchema, ErrorCode, errorResponse, successResponse } from '@/types/response'
// Schema for login request body
const loginBodySchema = z.object({
code: z.string().min(1, 'Authorization code is required'),
});
})
// Schema for login response data
const loginDataSchema = z.object({
token: z.string(),
});
})
// Use the unified response format
const loginSuccessSchema = createResponseSchema(loginDataSchema);
const loginErrorSchema = createResponseSchema(z.null());
const loginSuccessSchema = createResponseSchema(loginDataSchema)
const loginErrorSchema = createResponseSchema(z.null())
export const authRoutes: FastifyPluginAsyncZod = async (app) => {
app.post(
@ -34,16 +34,16 @@ export const authRoutes: FastifyPluginAsyncZod = async (app) => {
},
async (request, reply) => {
// ✅ Type is automatically inferred from Zod schema
const { code } = request.body;
const { code } = request.body
try {
const accessToken = await exchangeCodeForToken(code);
const githubUser = await getGithubUser(accessToken);
const accessToken = await exchangeCodeForToken(code)
const githubUser = await getGithubUser(accessToken)
// Convert number to string as Prisma schema expects String
let user = await prisma.user.findUnique({
where: { githubId: String(githubUser.id) },
});
})
if (!user) {
user = await prisma.user.create({
@ -52,7 +52,7 @@ export const authRoutes: FastifyPluginAsyncZod = async (app) => {
username: githubUser.login,
avatarUrl: githubUser.avatar_url,
},
});
})
}
// Generate JWT token
@ -63,16 +63,14 @@ export const authRoutes: FastifyPluginAsyncZod = async (app) => {
avatarUrl: user.avatarUrl,
accessToken,
},
{ expiresIn: '1d' },
);
{ expiresIn: '1d' }
)
return successResponse({ token });
return successResponse({ token })
} catch (error) {
app.log.error({ error }, 'Authentication Error');
return reply
.status(500)
.send(errorResponse(ErrorCode.AUTH_FAILED, 'Authentication failed'));
app.log.error({ error }, 'Authentication Error')
return reply.status(500).send(errorResponse(ErrorCode.AUTH_FAILED, 'Authentication failed'))
}
},
);
};
}
)
}

View File

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

View File

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

View File

@ -1,26 +1,27 @@
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { verifyGithubWebhook } from '../middlewares/verify-webhook.middleware';
import { handleWebhook } from '../services/webhook.service';
import { GitHubWebhookPayload } from '../types/github';
import { createResponseSchema, successResponse, errorResponse, ErrorCode } from '../types/response';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'
import { verifyGithubWebhook } from '@/middlewares/verify-webhook.middleware'
import { handleWebhook } from '@/services/webhook.service'
import type { GitHubWebhookPayload } from '@/types/github'
import { createResponseSchema, ErrorCode, errorResponse, successResponse } from '@/types/response'
// 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
const webhookDataSchema = z.object({
message: z.string(),
});
})
// Use the unified response format
const webhookSuccessSchema = createResponseSchema(webhookDataSchema);
const webhookErrorSchema = createResponseSchema(z.null());
const webhookSuccessSchema = createResponseSchema(webhookDataSchema)
const webhookErrorSchema = createResponseSchema(z.null())
export const webhookRoutes: FastifyPluginAsyncZod = async (app) => {
app.post(
'/github',
{
// eslint-disable-next-line @typescript-eslint/no-misused-promises
preHandler: verifyGithubWebhook,
schema: {
description: 'Handle GitHub webhook events',
@ -35,14 +36,14 @@ export const webhookRoutes: FastifyPluginAsyncZod = async (app) => {
async (request, reply) => {
try {
// Type cast to GitHubWebhookPayload for service function
await handleWebhook(request.body as GitHubWebhookPayload);
return successResponse({ message: 'Webhook received' });
await handleWebhook(request.body as GitHubWebhookPayload)
return successResponse({ message: 'Webhook received' })
} catch (error) {
app.log.error({ error }, 'Webhook processing error');
app.log.error({ error }, 'Webhook processing error')
return reply
.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 {
serializerCompiler,
validatorCompiler,
ZodTypeProvider
} from 'fastify-type-provider-zod';
import fastifyJwt from '@fastify/jwt';
import fastifyCors from '@fastify/cors';
import 'dotenv/config';
import fastify from 'fastify'
import type { ZodTypeProvider } from 'fastify-type-provider-zod'
import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
import fastifyJwt from '@fastify/jwt'
import fastifyCors from '@fastify/cors'
import 'dotenv/config'
import { authRoutes } from './routes/auth';
import { repoRoutes } from './routes/repos';
import { statsRoutes } from './routes/stats';
import { webhookRoutes } from './routes/webhooks';
import { authRoutes } from '@/routes/auth'
import { repoRoutes } from '@/routes/repos'
import { statsRoutes } from '@/routes/stats'
import { webhookRoutes } from '@/routes/webhooks'
import swagger from '@fastify/swagger';
import swagger from '@fastify/swagger'
// Main function to bootstrap the server
async function bootstrap() {
@ -28,21 +25,21 @@ async function bootstrap() {
},
},
},
}).withTypeProvider<ZodTypeProvider>();
}).withTypeProvider<ZodTypeProvider>()
// Set the validator and serializer for Zod
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// Register plugins
await app.register(fastifyJwt, {
secret: process.env.JWT_SECRET!,
});
secret: process.env.JWT_SECRET,
})
await app.register(fastifyCors, {
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true,
});
})
// Register Swagger for OpenAPI spec generation
await app.register(swagger, {
@ -67,11 +64,11 @@ async function bootstrap() {
},
],
},
});
})
// --- Docs portal ---
// 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(`
<!doctype html>
<html>
@ -118,11 +115,11 @@ async function bootstrap() {
</div>
</body>
</html>
`);
});
`)
})
// --- RapiDoc route (moved from /docs to /docs/rapidoc) ---
app.get('/docs/rapidoc', (req, reply) => {
app.get('/docs/rapidoc', (_, reply) => {
reply.type('text/html').send(`
<!doctype html>
<html>
@ -141,12 +138,12 @@ async function bootstrap() {
> </rapi-doc>
</body>
</html>
`);
});
`)
})
// --- API Documentation with ReDoc (alternative nicer UI) ---
// 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(`
<!doctype html>
<html>
@ -175,12 +172,12 @@ async function bootstrap() {
</script>
</body>
</html>
`);
});
`)
})
// --- Stoplight Elements (interactive, Knife4j-like feel) ---
// 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(`
<!doctype html>
<html>
@ -227,35 +224,36 @@ async function bootstrap() {
</main>
</body>
</html>
`);
});
`)
})
app.get('/docs/json', (req, reply) => {
reply.send(app.swagger());
});
app.get('/docs/json', (_, reply) => {
reply.send(app.swagger())
})
// Register routes
await app.register(authRoutes, { prefix: '/auth' });
await app.register(repoRoutes, { prefix: '/repos' });
await app.register(statsRoutes, { prefix: '/stats' });
await app.register(webhookRoutes, { prefix: '/webhooks' });
await app.register(authRoutes, { prefix: '/auth' })
await app.register(repoRoutes, { prefix: '/repos' })
await app.register(statsRoutes, { prefix: '/stats' })
await app.register(webhookRoutes, { prefix: '/webhooks' })
// Add a generic error handler
app.setErrorHandler((error, request, reply) => {
app.log.error(error);
reply.status(500).send({ error: 'Internal Server Error' });
});
app.setErrorHandler((error, _, reply) => {
app.log.error(error)
reply.status(500).send({ error: 'Internal Server Error' })
})
return app;
return app
}
// Start the server
(async () => {
await (async () => {
try {
const app = await bootstrap();
await app.listen({ port: 3333, host: '0.0.0.0' });
const app = await bootstrap()
await app.listen({ port: 3333, host: '0.0.0.0' })
} catch (err) {
console.error(err);
process.exit(1);
console.error(err)
// 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({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
url: process.env.UPSTASH_REDIS_URL,
token: process.env.UPSTASH_REDIS_TOKEN,
})
export class CacheService {
async get(key: string) {
return await redis.get(key);
return redis.get(key)
}
async set(key: string, value: any, ttl?: number) {
// Only pass ex option if ttl is defined
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 { Endpoints } from '@octokit/types';
import axios from 'axios';
import { redis } from '../lib/redis';
import { GitHubUser } from '../types/github';
import { Octokit } from '@octokit/rest'
import type { Endpoints } from '@octokit/types'
import axios from 'axios'
import { redis } from '@/lib/redis'
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
type ReposListForAuthenticatedUserResponse =
Endpoints['GET /user/repos']['response']['data'];
type ReposListForAuthenticatedUserResponse = Endpoints['GET /user/repos']['response']['data']
export async function exchangeCodeForToken(code: string): Promise<string> {
const response = await axios.post(
@ -20,41 +19,43 @@ export async function exchangeCodeForToken(code: string): Promise<string> {
},
{
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> {
const response = await axios.get<GitHubUser>(`${GITHUB_API_BASE_URL}/user`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
})
return response.data;
return response.data
}
export class GitHubService {
private octokit: Octokit;
private username: string;
private octokit: Octokit
private username: string
constructor(accessToken: string, username: string) {
this.octokit = new Octokit({ auth: accessToken });
this.username = username;
this.octokit = new Octokit({ auth: accessToken })
this.username = username
}
async getRepositories(): Promise<ReposListForAuthenticatedUserResponse> {
const cacheKey = `repos:${this.username}`;
const cachedRepos = await redis.get(cacheKey);
const cacheKey = `repos:${this.username}`
const cachedRepos = await redis.get(cacheKey)
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();
await redis.set(cacheKey, JSON.stringify(data), { ex: 3600 }); // Cache for 1 hour
const { data } = await this.octokit.repos.listForAuthenticatedUser()
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) {
@ -63,7 +64,7 @@ export class GitHubService {
this.octokit.repos.listCommits({ owner, repo, since }),
this.octokit.repos.listReleases({ owner, repo }),
this.octokit.repos.listContributors({ owner, repo }),
]);
])
const stats = {
stars: repoData.data.stargazers_count,
@ -72,8 +73,8 @@ export class GitHubService {
commits: commits.data.length,
releases: releases.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 {
private githubService: GitHubService;
private githubService: GitHubService
constructor(accessToken: string, username: string) {
this.githubService = new GitHubService(accessToken, username);
this.githubService = new GitHubService(accessToken, username)
}
async getOverviewStats() {
const repos = await this.githubService.getRepositories();
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 repos = await this.githubService.getRepositories()
const totalStars = repos.reduce((acc, repo) => acc + (repo.stargazers_count || 0), 0)
const totalForks = repos.reduce((acc, repo) => acc + (repo.forks_count || 0), 0)
return {
totalRepos: repos.length,
totalStars,
totalForks,
};
}
}
async getActivityStats() {
const repos = await this.githubService.getRepositories();
const thirtyDaysAgo = new Date(
Date.now() - 30 * 24 * 60 * 60 * 1000,
).toISOString();
const repos = await this.githubService.getRepositories()
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const commitPromises = repos.map((repo) => {
const [owner, repoName] = repo.full_name.split('/');
return this.githubService.getRepositoryStats(owner, repoName, thirtyDaysAgo);
});
const commitPromises = repos.map(async (repo) => {
const [owner, repoName] = repo.full_name.split('/')
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 {
totalCommitsLast30Days: totalCommits,
};
}
}
}

View File

@ -1,17 +1,17 @@
import { prisma } from '../lib/prisma';
import { GitHubWebhookPayload } from '../types/github';
import prisma from '@/lib/prisma'
import type { GitHubWebhookPayload } from '@/types/github'
export async function handleWebhook(payload: GitHubWebhookPayload) {
const { action, pull_request, repository } = payload;
const { action, pull_request, repository } = payload
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({
where: { repositoryId_number: { repositoryId: repository.id, number } },
update: {
state,
closedAt: closed_at ? new Date(closed_at) : null
update: {
state,
closedAt: closed_at ? new Date(closed_at) : null,
},
create: {
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.
*/
export interface GitHubUser {
id: number;
login: string;
name: string | null;
avatar_url: string;
id: number
login: string
name: string | null
avatar_url: string
}
/**
* A generic type for the GitHub webhook payload.
* 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
@ -8,12 +8,12 @@ import { z } from 'zod';
*/
// 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({
code: z.number(),
msg: z.string(),
data: dataSchema,
});
})
}
// Success response with data
@ -22,7 +22,7 @@ export function successResponse<T>(data: T) {
code: 0,
msg: '',
data,
};
}
}
// Error response
@ -31,7 +31,7 @@ export function errorResponse(code: number, msg: string) {
code,
msg,
data: null,
};
}
}
// Common error codes
@ -45,11 +45,11 @@ export const ErrorCode = {
AUTH_FAILED: 1001,
GITHUB_API_ERROR: 1002,
WEBHOOK_VERIFICATION_FAILED: 1003,
} as const;
} as const
// Error response schema
export const errorResponseSchema = z.object({
code: z.number(),
msg: z.string(),
data: z.null(),
});
})

View File

@ -1,18 +1,58 @@
{
"extends": "fastify-tsconfig",
"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",
"rootDir": "./src",
"sourceMap": true
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"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", // Include all .ts files in the src directory
"src/types/**/*.d.ts" // Include our custom type definitions
],
"exclude": ["node_modules"]
"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
}
}