feat: add repo overview

This commit is contained in:
grtsinry43 2025-10-28 06:04:33 +00:00
parent 58ca9f5fe9
commit 97a29b940b
11 changed files with 871 additions and 31 deletions

418
GITHUB_API_FEASIBILITY.md Normal file
View File

@ -0,0 +1,418 @@
# GitHub Dashboard API 可行性分析报告
## 📊 功能模块 vs GitHub API 支持情况
---
## 1. 仓库概览卡片区
### ✅ 完全支持
| 功能 | API 端点 | 返回数据 |
| ------------------------ | ------------------------------------- | --------------------------------------------------- |
| **基本信息** | `GET /repos/{owner}/{repo}` | 仓库名、描述、创建时间 |
| **Star/Fork/Watch 数量** | `GET /repos/{owner}/{repo}` | `stargazers_count`, `forks_count`, `watchers_count` |
| **语言分布** | `GET /repos/{owner}/{repo}/languages` | `{ "JavaScript": 12345, "Python": 6789 }` |
| **License** | `GET /repos/{owner}/{repo}` | `license.name`, `license.key` |
| **Topics** | `GET /repos/{owner}/{repo}/topics` | 标签数组 |
| **最后更新时间** | `GET /repos/{owner}/{repo}` | `updated_at`, `pushed_at` |
| **仓库大小** | `GET /repos/{owner}/{repo}` | `size` (KB) |
| **开源评分** | - | ❌ **不支持**,需自行计算 |
**实现建议:**
- 开源评分可通过组合多个指标计算Star 数量、贡献者数量、更新频率、文档完整性等)
---
## 2. 活动时间线 (Activity Timeline)
### ⚠️ 部分支持(有限制)
| 功能 | API 端点 | 数据可用性 | 限制 |
| ----------------------- | ------------------------------------------------- | ---------- | ----------------------------- |
| **最近 commits** | `GET /repos/{owner}/{repo}/commits` | ✅ 支持 | 分页限制,最多 100/页 |
| **最近 PRs** | `GET /repos/{owner}/{repo}/pulls` | ✅ 支持 | 可过滤状态open/closed/all |
| **最近 Issues** | `GET /repos/{owner}/{repo}/issues` | ✅ 支持 | Issues 和 PRs 混合返回 |
| **每日/每周提交热力图** | `GET /repos/{owner}/{repo}/stats/commit_activity` | ✅ 支持 | **仅最近 52 周** |
| **贡献者活跃度趋势** | `GET /repos/{owner}/{repo}/stats/contributors` | ✅ 支持 | **<10k commits 仓库** |
**API 示例:**
```bash
# 提交活动(按周统计)
GET /repos/{owner}/{repo}/stats/commit_activity
# 返回:[{ "days": [0,3,5,2,1,0,0], "total": 11, "week": 1302508800 }]
# 贡献者统计
GET /repos/{owner}/{repo}/stats/contributors
# 返回:[{ "author": {...}, "total": 135, "weeks": [...] }]
```
**⚠️ 重要限制:**
- 统计数据需要缓存,首次请求返回 `202 Accepted`,需要等待后台计算
- 超过 10,000 commits 的仓库**无法获取详细统计**
---
## 3. 代码统计面板
### ⚠️ 有限支持
| 功能 | API 端点 | 数据可用性 | 限制 |
| ------------------------ | ------------------------------------------------------------ | ------------- | ------------------------------ |
| **语言分布饼图** | `GET /repos/{owner}/{repo}/languages` | ✅ 支持 | 返回字节数,非百分比 |
| **代码量趋势** | `GET /repos/{owner}/{repo}/stats/code_frequency` | ⚠️ 有限 | **仅周级别统计,<10k commits** |
| **文件结构** | `GET /repos/{owner}/{repo}/git/trees/{tree_sha}?recursive=1` | ✅ 支持 | 需遍历 tree SHA |
| **热点文件(最常修改)** | - | ❌ **不支持** | 需自行分析 commits |
**代码频率 API**
```bash
GET /repos/{owner}/{repo}/stats/code_frequency
# 返回:[[1302508800, 1124, -435], ...] # [周时间戳, 新增行, 删除行]
```
**实现建议:**
- 热点文件需要自己爬取所有 commits分析每个文件的修改频次
- 文件结构可以通过 Git Trees API 递归获取
---
## 4. Issue/PR 管理看板
### ✅ 完全支持
| 功能 | API 端点 | 返回数据 |
| ---------------- | ---------------------------------------------------------- | ------------------------- |
| **状态分布** | `GET /repos/{owner}/{repo}/issues?state=all` | Open/Closed 数量 |
| **优先级标签云** | `GET /repos/{owner}/{repo}/labels` | 所有标签及颜色 |
| **响应时间** | `GET /repos/{owner}/{repo}/issues` | `created_at`, `closed_at` |
| **Issue 时间线** | `GET /repos/{owner}/{repo}/issues/{issue_number}/timeline` | 完整事件历史 |
| **PR 审查状态** | `GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews` | 审查状态和评论 |
**API 示例:**
```bash
# 获取所有 Issues包含 PRs
GET /repos/{owner}/{repo}/issues?state=all&per_page=100
# 区分 Issue 和 PR
if (issue.pull_request) { /* 这是 PR */ }
# 获取 Issue 时间线
GET /repos/{owner}/{repo}/issues/1/timeline
# 返回:创建、关闭、标签变更、评论等事件
```
**计算响应时间:**
- 平均关闭时间 = `closed_at - created_at` 的平均值
- 首次响应时间 = 第一条评论时间 - 创建时间
---
## 5. 贡献者分析
### ⚠️ 部分支持(有性能限制)
| 功能 | API 端点 | 数据可用性 | 限制 |
| ------------------- | ---------------------------------------------- | ------------- | ---------------------------- |
| **核心贡献者榜单** | `GET /repos/{owner}/{repo}/contributors` | ✅ 支持 | 按提交数排序 |
| **贡献者头像墙** | `GET /repos/{owner}/{repo}/contributors` | ✅ 支持 | 包含 `avatar_url` |
| **提交量/增删行数** | `GET /repos/{owner}/{repo}/stats/contributors` | ⚠️ 有限 | **<10k commits** |
| **新老贡献者比例** | `GET /repos/{owner}/{repo}/stats/contributors` | ⚠️ 有限 | 需自行分析 |
| **协作网络图** | - | ❌ **不支持** | 需分析 commits 的 co-authors |
**Contributors API**
```bash
# 获取贡献者列表
GET /repos/{owner}/{repo}/contributors
# 返回:[{ "login": "user", "contributions": 123, "avatar_url": "..." }]
# 获取详细统计(需等待缓存)
GET /repos/{owner}/{repo}/stats/contributors
# 返回:[{ "author": {...}, "total": 135, "weeks": [{ "w": 1302508800, "a": 6898, "d": 77, "c": 10 }] }]
```
**⚠️ 限制:**
- GraphQL API **不支持**获取贡献者,必须用 REST API
- 协作网络图需要自己分析 Git 的 `Co-authored-by` 标记
---
## 6. Release & 版本管理
### ✅ 完全支持
| 功能 | API 端点 | 返回数据 |
| ---------------- | ---------------------------------------------------- | ------------------------------ |
| **版本时间轴** | `GET /repos/{owner}/{repo}/releases` | 所有 releases |
| **最新 Release** | `GET /repos/{owner}/{repo}/releases/latest` | 最新版本信息 |
| **按标签获取** | `GET /repos/{owner}/{repo}/releases/tags/{tag}` | 指定版本 |
| **更新日志** | `POST /repos/{owner}/{repo}/releases/generate-notes` | 自动生成 Changelog |
| **下载统计** | `GET /repos/{owner}/{repo}/releases/{id}` | 每个 asset 的 `download_count` |
**Release API 示例:**
```bash
# 获取所有 releases
GET /repos/{owner}/{repo}/releases
# 返回:[{ "tag_name": "v1.0.0", "name": "Release 1.0", "assets": [...] }]
# 每个 asset 包含下载统计
{
"name": "app.zip",
"download_count": 12345,
"size": 9876543,
"created_at": "2023-01-01T12:00:00Z"
}
# 自动生成 Changelog
POST /repos/{owner}/{repo}/releases/generate-notes
{
"tag_name": "v1.0.0",
"previous_tag_name": "v0.9.0"
}
```
---
## 7. 依赖关系可视化
### ⚠️ 仅 GraphQL 支持
| 功能 | API 端点 | 数据可用性 | 限制 |
| ------------ | ---------------------------------- | ------------- | ------------------- |
| **依赖树** | GraphQL `dependencyGraphManifests` | ⚠️ 仅 GraphQL | REST API **不支持** |
| **安全漏洞** | GraphQL `vulnerabilityAlerts` | ⚠️ 仅 GraphQL | 需启用 Dependabot |
| **更新提醒** | GraphQL `dependabot` | ⚠️ 仅 GraphQL | - |
**GraphQL 查询示例:**
```graphql
query {
repository(owner: "facebook", name: "react") {
dependencyGraphManifests {
edges {
node {
filename
dependencies {
edges {
node {
packageName
requirements
hasDependencies
}
}
}
}
}
}
vulnerabilityAlerts(first: 10) {
edges {
node {
securityVulnerability {
package {
name
}
severity
advisory {
summary
}
}
}
}
}
}
}
```
**⚠️ 限制:**
- **必须使用 GraphQL API**REST API 无此功能
- 需要仓库启用 Dependency Graph 和 Dependabot
- 私有仓库需要 `repo` 权限
---
## 8. CI/CD 状态监控
### ✅ 完全支持
| 功能 | API 端点 | 返回数据 |
| -------------------- | --------------------------------------------------------- | -------------------- |
| **工作流列表** | `GET /repos/{owner}/{repo}/actions/workflows` | 所有 workflows |
| **工作流运行状态** | `GET /repos/{owner}/{repo}/actions/runs` | 运行历史 |
| **特定工作流的运行** | `GET /repos/{owner}/{repo}/actions/workflows/{id}/runs` | 特定 workflow 的运行 |
| **运行详情** | `GET /repos/{owner}/{repo}/actions/runs/{run_id}` | 状态、结论、时长 |
| **任务详情** | `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` | 每个 job 的状态 |
| **使用统计** | `GET /repos/{owner}/{repo}/actions/workflows/{id}/timing` | ⚠️ 已废弃 |
**GitHub Actions API**
```bash
# 获取工作流运行历史
GET /repos/{owner}/{repo}/actions/runs
# 返回:{
# "workflow_runs": [{
# "id": 123,
# "status": "completed",
# "conclusion": "success",
# "run_started_at": "2023-01-01T10:00:00Z",
# "updated_at": "2023-01-01T10:05:30Z"
# }]
# }
# 计算运行时长
duration = updated_at - run_started_at
```
**构建成功率计算:**
```javascript
const runs = await fetch('/repos/owner/repo/actions/runs')
const successRate = runs.filter((r) => r.conclusion === 'success').length / runs.length
const avgDuration =
runs.reduce((sum, r) => sum + (r.updated_at - r.run_started_at), 0) / runs.length
```
---
## 9. 流量统计 (Traffic)
### ⚠️ 有严重限制
| 功能 | API 端点 | 数据可用性 | 限制 |
| ----------------- | ----------------------------------------------------- | ------------- | ------------ |
| **页面访问量** | `GET /repos/{owner}/{repo}/traffic/views` | ⚠️ 有限 | **仅 14 天** |
| **Clone 统计** | `GET /repos/{owner}/{repo}/traffic/clones` | ⚠️ 有限 | **仅 14 天** |
| **Top Referrers** | `GET /repos/{owner}/{repo}/traffic/popular/referrers` | ⚠️ 有限 | **仅 14 天** |
| **Top Paths** | `GET /repos/{owner}/{repo}/traffic/popular/paths` | ⚠️ 有限 | **仅 14 天** |
| **历史流量** | - | ❌ **不支持** | - |
**❗ 严重限制:**
- GitHub **仅保留 14 天**的流量数据
- 无法获取历史流量统计
- **必须定期轮询并自行存储**才能构建历史趋势图
**解决方案:**
1. 使用 GitHub Actions 每天自动抓取数据存入数据库
2. 参考开源项目:[github-repo-stats](https://github.com/marketplace/actions/github-repo-stats)
---
## 10. Star 历史趋势
### ⚠️ 有分页限制
| 功能 | API 端点 | 数据可用性 | 限制 |
| ------------------- | ------------------------------------------------------ | ---------- | -------------- |
| **Stargazers 列表** | `GET /repos/{owner}/{repo}/stargazers` | ✅ 支持 | 最多 40k stars |
| **Star 时间戳** | 使用 header `Accept: application/vnd.github.star+json` | ✅ 支持 | 最多 40k stars |
**获取 Star 时间戳:**
```bash
curl -H "Accept: application/vnd.github.star+json" \
https://api.github.com/repos/facebook/react/stargazers?per_page=100&page=1
# 返回:[{ "starred_at": "2023-01-01T12:00:00Z", "user": {...} }]
```
**⚠️ 限制:**
- 每页最多 100 个,最多 400 页 = **40,000 stars**
- 超过 40k stars 的仓库**无法获取完整历史**
---
## 📋 API 限制总结
### Rate Limits速率限制
| 认证方式 | 速率限制 |
| --------------- | -------------- |
| **未认证** | 60 次/小时 |
| **OAuth Token** | 5,000 次/小时 |
| **GitHub App** | 15,000 次/小时 |
### 数据限制
| 数据类型 | 限制 |
| ----------------------- | --------------------------- |
| **统计数据stats** | 仅 <10,000 commits 的仓库 |
| **流量数据traffic** | 仅 14 天 |
| **Star 历史** | 最多 40,000 个 |
| **分页** | 每页最多 100 条 |
| **Contributors** | GraphQL 不支持,必须用 REST |
---
## ✅ 推荐实现方案
### 核心功能(完全支持)
1. ✅ 仓库概览卡片
2. ✅ Issue/PR 管理看板
3. ✅ Release 版本管理
4. ✅ CI/CD 状态监控
### 需要定期采集数据
5. ⚠️ 流量统计(每天爬取一次,存入数据库)
6. ⚠️ Star 历史(定期更新)
### 需要混合使用 REST + GraphQL
7. ⚠️ 依赖关系可视化GraphQL
8. ⚠️ 安全漏洞GraphQL
### 需要自行计算/分析
9. 📊 开源评分(组合多个指标)
10. 📊 热点文件分析(分析 commits
11. 📊 协作网络图(分析 co-authors
---
## 🚀 技术栈建议
### 后端
- **REST API**: 使用 Octokit (`@octokit/rest`)
- **GraphQL**: 使用 `@octokit/graphql`
- **定时任务**: GitHub Actions + Cron
- **缓存**: Redis缓存 stats 数据,避免 202 响应)
### 前端
- **图表库**: Recharts / D3.js / Apache ECharts
- **热力图**: `react-calendar-heatmap`
- **网络图**: `react-force-graph` / `cytoscape.js`
- **时间线**: `react-vertical-timeline`
---
## 📌 注意事项
1. **统计数据可能需要等待**:首次请求返回 `202`,需要轮询直到返回 `200`
2. **流量数据必须自行存储**:否则 14 天后数据丢失
3. **大型仓库限制多**>10k commits 的仓库很多统计不可用
4. **GraphQL 和 REST 混用**:依赖图必须用 GraphQL其他用 REST 更方便
5. **速率限制**:建议使用 GitHub App 获取更高的速率限制
---
## 🔗 参考文档
- [GitHub REST API 文档](https://docs.github.com/en/rest)
- [GitHub GraphQL API 文档](https://docs.github.com/en/graphql)
- [Octokit.js 官方文档](https://octokit.github.io/rest.js/)
- [GitHub Actions API](https://docs.github.com/en/rest/actions)

View File

@ -12,14 +12,79 @@ model User {
githubId String @unique
username String
avatarUrl String
accessToken String? // Store encrypted GitHub access token
pullRequest PullRequest[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Repository {
id Int @id
name String
owner String
id Int @id // GitHub repository ID
name String
fullName String @unique @map("full_name") // owner/repo
owner String
description String?
htmlUrl String @map("html_url")
homepage String?
// Visibility and status
isPrivate Boolean @default(false) @map("is_private")
isFork Boolean @default(false) @map("is_fork")
isArchived Boolean @default(false) @map("is_archived")
isTemplate Boolean @default(false) @map("is_template")
// Real-time statistics (cached in Redis, snapshot in DB)
stargazersCount Int @default(0) @map("stargazers_count")
forksCount Int @default(0) @map("forks_count")
watchersCount Int @default(0) @map("watchers_count")
openIssuesCount Int @default(0) @map("open_issues_count")
size Int @default(0) // Repository size in KB
// Language and topics
language String? // Primary language
languages Json? // Language distribution { "JavaScript": 12345, "TypeScript": 6789 }
topics String[] @default([])
// License
licenseName String? @map("license_name")
licenseKey String? @map("license_key")
// Timestamps
githubCreatedAt DateTime @map("github_created_at")
githubUpdatedAt DateTime @map("github_updated_at")
githubPushedAt DateTime? @map("github_pushed_at")
// Local timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastSyncedAt DateTime? @map("last_synced_at") // Last time we synced from GitHub API
// Relations
pullRequests PullRequest[]
stats RepositoryStats[]
@@map("repositories")
}
// Historical statistics snapshots for trend analysis
model RepositoryStats {
id String @id @default(cuid())
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
repositoryId Int @map("repository_id")
// Statistics snapshot
stargazersCount Int @map("stargazers_count")
forksCount Int @map("forks_count")
watchersCount Int @map("watchers_count")
openIssuesCount Int @map("open_issues_count")
// Timestamp
snapshotAt DateTime @default(now()) @map("snapshot_at")
@@index([repositoryId, snapshotAt])
@@map("repository_stats")
}
model PullRequest {
@ -29,10 +94,11 @@ model PullRequest {
state String
createdAt DateTime @map("created_at")
closedAt DateTime? @map("closed_at")
repository Repository @relation(fields: [repositoryId], references: [id])
repositoryId Int
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
repositoryId Int @map("repository_id")
author User @relation(fields: [authorId], references: [id])
authorId String
authorId String @map("author_id")
@@unique([repositoryId, number])
@@map("pull_requests")
}

View File

@ -1,4 +1,5 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import { ErrorCode, errorResponse } from '../types/response'
/**
* This middleware function verifies the JWT token from the request.
@ -13,6 +14,6 @@ export const authMiddleware = async (req: FastifyRequest, reply: FastifyReply) =
// 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(errorResponse(ErrorCode.UNAUTHORIZED, 'Unauthorized'))
}
}

View File

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

View File

@ -1,12 +1,15 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import * as crypto from 'crypto'
import { ErrorCode, errorResponse } from '../types/response'
// 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
if (!signature) {
return reply.status(401).send({ error: 'No signature found' })
return reply
.status(401)
.send(errorResponse(ErrorCode.WEBHOOK_VERIFICATION_FAILED, 'No signature found'))
}
const secret = process.env['GITHUB_WEBHOOK_SECRET']
@ -14,6 +17,8 @@ export function verifyGithubWebhook(req: FastifyRequest, reply: FastifyReply) {
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(errorResponse(ErrorCode.WEBHOOK_VERIFICATION_FAILED, 'Invalid signature'))
}
}

View File

@ -1,17 +0,0 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
githubId String @unique
username String
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -1,8 +1,14 @@
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'
import { GitHubService } from '@/services/github.service'
import {
createResponseSchema,
ErrorCode,
errorResponse,
errorResponseSchema,
successResponse,
} from '@/types/response'
// Schema for request parameters
const repoParamsSchema = z.object({
@ -20,6 +26,43 @@ const repoStatsSchema = z.object({
contributors: z.number(),
})
// Schema for repository overview response
const repoOverviewSchema = z.object({
id: z.number(),
name: z.string(),
fullName: z.string(),
owner: z.string(),
description: z.string().nullable(),
htmlUrl: z.string(),
homepage: z.string().nullable(),
isPrivate: z.boolean(),
isFork: z.boolean(),
isArchived: z.boolean(),
isTemplate: z.boolean(),
stargazersCount: z.number(),
forksCount: z.number(),
watchersCount: z.number(),
openIssuesCount: z.number(),
size: z.number(),
language: z.string().nullable(),
languages: z.record(z.string(), z.number()).nullable(),
topics: z.array(z.string()),
licenseName: z.string().nullable(),
licenseKey: z.string().nullable(),
githubCreatedAt: z.date(),
githubUpdatedAt: z.date(),
githubPushedAt: z.date().nullable(),
lastSyncedAt: z.date(),
})
// Schema for real-time stats
const realtimeStatsSchema = z.object({
stargazersCount: z.number(),
forksCount: z.number(),
watchersCount: z.number(),
openIssuesCount: z.number(),
})
// Schema for repository list response
const repoListItemSchema = z.object({
id: z.number(),
@ -43,6 +86,8 @@ const reposListSchema = z.array(repoListItemSchema)
// Use the unified response format
const reposListResponseSchema = createResponseSchema(reposListSchema)
const repoStatsResponseSchema = createResponseSchema(repoStatsSchema)
const repoOverviewResponseSchema = createResponseSchema(repoOverviewSchema)
const realtimeStatsResponseSchema = createResponseSchema(realtimeStatsSchema)
export const repoRoutes: FastifyPluginAsyncZod = async (app) => {
// All routes in this plugin require authentication
@ -90,4 +135,63 @@ export const repoRoutes: FastifyPluginAsyncZod = async (app) => {
return successResponse(stats)
}
)
// Route to get complete repository overview
app.get(
'/:owner/:repo/overview',
{
schema: {
description: 'Get complete repository overview including metadata, languages, and topics',
tags: ['repos'],
params: repoParamsSchema,
response: {
200: repoOverviewResponseSchema,
500: errorResponseSchema,
},
},
},
async (request, reply) => {
try {
const { owner, repo } = request.params
const { accessToken, username } = request.user
const githubService = new GitHubService(accessToken, username)
const overview = await githubService.getRepositoryOverview(owner, repo)
return successResponse(overview)
} catch (err) {
const error = err as Error
const errorMessage = error.message || 'Failed to fetch repository overview'
return reply.status(500).send(errorResponse(ErrorCode.GITHUB_API_ERROR, errorMessage))
}
}
)
// Route to get real-time statistics (cached for 1 minute)
app.get(
'/:owner/:repo/stats/realtime',
{
schema: {
description:
'Get real-time statistics (stars, forks, watchers, issues) with 1-minute cache',
tags: ['repos'],
params: repoParamsSchema,
response: {
200: realtimeStatsResponseSchema,
500: errorResponseSchema,
},
},
},
async (request, reply) => {
try {
const { owner, repo } = request.params
const { accessToken, username } = request.user
const githubService = new GitHubService(accessToken, username)
const stats = await githubService.getRealtimeStats(owner, repo)
return successResponse(stats)
} catch (err) {
const error = err as Error
const errorMessage = error.message || 'Failed to fetch real-time stats'
return reply.status(500).send(errorResponse(ErrorCode.GITHUB_API_ERROR, errorMessage))
}
}
)
}

View File

@ -13,6 +13,7 @@ import { authRoutes } from '@/routes/auth'
import { repoRoutes } from '@/routes/repos'
import { statsRoutes } from '@/routes/stats'
import { webhookRoutes } from '@/routes/webhooks'
import { ErrorCode, errorResponse } from '@/types/response'
import swagger from '@fastify/swagger'
@ -177,10 +178,17 @@ GitHub API rate limits apply. Authenticated requests: 5,000/hour.
await app.register(statsRoutes, { prefix: '/stats' })
await app.register(webhookRoutes, { prefix: '/webhooks' })
// Add 404 not found handler
app.setNotFoundHandler((request, reply) => {
reply
.status(404)
.send(errorResponse(ErrorCode.NOT_FOUND, `Route ${request.method}:${request.url} not found`))
})
// Add a generic error handler
app.setErrorHandler((error, _, reply) => {
app.log.error(error)
reply.status(500).send({ error: 'Internal Server Error' })
reply.status(500).send(errorResponse(ErrorCode.INTERNAL_ERROR, 'Internal Server Error'))
})
return app
}

View File

@ -1,6 +1,8 @@
import { Octokit } from '@octokit/rest'
import type { Endpoints } from '@octokit/types'
import { Prisma } from '@prisma/client'
import axios from 'axios'
import prisma from '@/lib/prisma'
import { redis } from '@/lib/redis'
import type { GitHubUser } from '@/types/github'
@ -18,6 +20,43 @@ interface GitHubTokenResponse {
error_description?: string
}
// Repository overview data for dashboard
export interface RepositoryOverview {
id: number
name: string
fullName: string
owner: string
description: string | null
htmlUrl: string
homepage: string | null
isPrivate: boolean
isFork: boolean
isArchived: boolean
isTemplate: boolean
stargazersCount: number
forksCount: number
watchersCount: number
openIssuesCount: number
size: number
language: string | null
languages: Record<string, number> | null
topics: string[]
licenseName: string | null
licenseKey: string | null
githubCreatedAt: Date
githubUpdatedAt: Date
githubPushedAt: Date | null
lastSyncedAt: Date
}
// Real-time statistics
export interface RealtimeStats {
stargazersCount: number
forksCount: number
watchersCount: number
openIssuesCount: number
}
/**
* Exchange GitHub OAuth code for access token
* Works with both OAuth Apps and GitHub Apps (user-to-server flow)
@ -121,4 +160,177 @@ export class GitHubService {
return stats
}
/**
* Fetch complete repository overview data
* Combines repo metadata, languages, and topics
*/
async getRepositoryOverview(owner: string, repo: string): Promise<RepositoryOverview> {
const cacheKey = `repo:overview:${owner}/${repo}`
const CACHE_TTL = 300 // 5 minutes for overview data
// Try to get from Redis cache
try {
const cached = await redis.get(cacheKey)
if (cached) {
console.log(`Cache hit for repository overview: ${owner}/${repo}`)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(cached as string)
}
} catch (error) {
console.warn('Redis cache unavailable:', error)
}
// Fetch from GitHub API
const [repoData, languagesData] = await Promise.all([
this.octokit.repos.get({ owner, repo }),
this.octokit.repos.listLanguages({ owner, repo }),
])
const overview: RepositoryOverview = {
id: repoData.data.id,
name: repoData.data.name,
fullName: repoData.data.full_name,
owner: repoData.data.owner.login,
description: repoData.data.description,
htmlUrl: repoData.data.html_url,
homepage: repoData.data.homepage,
isPrivate: repoData.data.private,
isFork: repoData.data.fork,
isArchived: repoData.data.archived,
isTemplate: repoData.data.is_template ?? false,
stargazersCount: repoData.data.stargazers_count,
forksCount: repoData.data.forks_count,
watchersCount: repoData.data.watchers_count,
openIssuesCount: repoData.data.open_issues_count,
size: repoData.data.size,
language: repoData.data.language,
languages: Object.keys(languagesData.data).length > 0 ? languagesData.data : null,
topics: repoData.data.topics ?? [],
licenseName: repoData.data.license?.name ?? null,
licenseKey: repoData.data.license?.key ?? null,
githubCreatedAt: new Date(repoData.data.created_at),
githubUpdatedAt: new Date(repoData.data.updated_at),
githubPushedAt: repoData.data.pushed_at ? new Date(repoData.data.pushed_at) : null,
lastSyncedAt: new Date(),
}
// Cache in Redis
try {
await redis.set(cacheKey, JSON.stringify(overview), { ex: CACHE_TTL })
} catch (error) {
console.warn('Failed to cache repository overview:', error)
}
// Save to database
try {
await prisma.repository.upsert({
where: { id: overview.id },
create: {
id: overview.id,
name: overview.name,
fullName: overview.fullName,
owner: overview.owner,
description: overview.description,
htmlUrl: overview.htmlUrl,
homepage: overview.homepage,
isPrivate: overview.isPrivate,
isFork: overview.isFork,
isArchived: overview.isArchived,
isTemplate: overview.isTemplate,
stargazersCount: overview.stargazersCount,
forksCount: overview.forksCount,
watchersCount: overview.watchersCount,
openIssuesCount: overview.openIssuesCount,
size: overview.size,
language: overview.language,
languages: overview.languages ?? Prisma.JsonNull,
topics: overview.topics,
licenseName: overview.licenseName,
licenseKey: overview.licenseKey,
githubCreatedAt: overview.githubCreatedAt,
githubUpdatedAt: overview.githubUpdatedAt,
githubPushedAt: overview.githubPushedAt,
lastSyncedAt: new Date(),
},
update: {
name: overview.name,
fullName: overview.fullName,
owner: overview.owner,
description: overview.description,
htmlUrl: overview.htmlUrl,
homepage: overview.homepage,
isPrivate: overview.isPrivate,
isFork: overview.isFork,
isArchived: overview.isArchived,
isTemplate: overview.isTemplate,
stargazersCount: overview.stargazersCount,
forksCount: overview.forksCount,
watchersCount: overview.watchersCount,
openIssuesCount: overview.openIssuesCount,
size: overview.size,
language: overview.language,
languages: overview.languages ?? Prisma.JsonNull,
topics: overview.topics,
licenseName: overview.licenseName,
licenseKey: overview.licenseKey,
githubCreatedAt: overview.githubCreatedAt,
githubUpdatedAt: overview.githubUpdatedAt,
githubPushedAt: overview.githubPushedAt,
lastSyncedAt: new Date(),
},
})
// Save historical snapshot
await prisma.repositoryStats.create({
data: {
repositoryId: overview.id,
stargazersCount: overview.stargazersCount,
forksCount: overview.forksCount,
watchersCount: overview.watchersCount,
openIssuesCount: overview.openIssuesCount,
},
})
} catch (err) {
const error = err as Error
console.error('Failed to save repository overview to database:', error.message)
}
return overview
}
/**
* Get real-time statistics (stars, forks, watchers)
* These are cached in Redis with short TTL for real-time updates
*/
async getRealtimeStats(owner: string, repo: string): Promise<RealtimeStats> {
const cacheKey = `repo:stats:${owner}/${repo}`
const CACHE_TTL = 60 // 1 minute for real-time stats
try {
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached as string) as RealtimeStats
}
} catch (error) {
console.warn('Redis cache unavailable:', error)
}
const { data } = await this.octokit.repos.get({ owner, repo })
const stats: RealtimeStats = {
stargazersCount: data.stargazers_count,
forksCount: data.forks_count,
watchersCount: data.watchers_count,
openIssuesCount: data.open_issues_count,
}
try {
await redis.set(cacheKey, JSON.stringify(stats), { ex: CACHE_TTL })
} catch (error) {
console.warn('Failed to cache realtime stats:', error)
}
return stats
}
}

View File

@ -25,7 +25,27 @@ export async function handleWebhook(payload: GitHubWebhookPayload) {
create: {
id: repository.id,
name: repository.name,
fullName: repository.full_name,
owner: repository.owner.login,
description: repository.description,
htmlUrl: repository.html_url,
homepage: repository.homepage,
isPrivate: repository.private,
isFork: repository.fork,
isArchived: repository.archived,
isTemplate: repository.is_template ?? false,
stargazersCount: repository.stargazers_count,
forksCount: repository.forks_count,
watchersCount: repository.watchers_count,
openIssuesCount: repository.open_issues_count,
size: repository.size,
language: repository.language,
topics: repository.topics ?? [],
licenseName: repository.license?.name,
licenseKey: repository.license?.key,
githubCreatedAt: new Date(repository.created_at),
githubUpdatedAt: new Date(repository.updated_at),
githubPushedAt: repository.pushed_at ? new Date(repository.pushed_at) : null,
},
},
},

View File

@ -29,6 +29,28 @@ export interface GitHubWebhookPayload {
repository: {
id: number
name: string
full_name: string
description: string | null
html_url: string
homepage: string | null
private: boolean
fork: boolean
archived: boolean
is_template?: boolean
stargazers_count: number
forks_count: number
watchers_count: number
open_issues_count: number
size: number
language: string | null
topics?: string[]
license?: {
key: string
name: string
} | null
created_at: string
updated_at: string
pushed_at: string | null
owner: {
login: string
}