From 97a29b940b285cdf6b4e5b7eed55a7e443ea1a0b Mon Sep 17 00:00:00 2001 From: grtsinry43 Date: Tue, 28 Oct 2025 06:04:33 +0000 Subject: [PATCH] feat: add repo overview --- GITHUB_API_FEASIBILITY.md | 418 +++++++++++++++++++ prisma/schema.prisma | 78 +++- src/middlewares/auth.middleware.ts | 3 +- src/middlewares/error.middleware.ts | 5 +- src/middlewares/verify-webhook.middleware.ts | 9 +- src/prisma/schema.prisma | 17 - src/routes/repos.ts | 108 ++++- src/server.ts | 10 +- src/services/github.service.ts | 212 ++++++++++ src/services/webhook.service.ts | 20 + src/types/github.ts | 22 + 11 files changed, 871 insertions(+), 31 deletions(-) create mode 100644 GITHUB_API_FEASIBILITY.md delete mode 100644 src/prisma/schema.prisma diff --git a/GITHUB_API_FEASIBILITY.md b/GITHUB_API_FEASIBILITY.md new file mode 100644 index 0000000..c3ae3f3 --- /dev/null +++ b/GITHUB_API_FEASIBILITY.md @@ -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) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b13220..2d8cabb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 5cc8fe0..4b9e672 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -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')) } } diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts index c7ebca9..12a7f68 100644 --- a/src/middlewares/error.middleware.ts +++ b/src/middlewares/error.middleware.ts @@ -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')) }) } diff --git a/src/middlewares/verify-webhook.middleware.ts b/src/middlewares/verify-webhook.middleware.ts index 3a17507..48ab2d5 100644 --- a/src/middlewares/verify-webhook.middleware.ts +++ b/src/middlewares/verify-webhook.middleware.ts @@ -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')) } } diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma deleted file mode 100644 index 7a7b883..0000000 --- a/src/prisma/schema.prisma +++ /dev/null @@ -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 -} diff --git a/src/routes/repos.ts b/src/routes/repos.ts index edb0581..33a9c0a 100644 --- a/src/routes/repos.ts +++ b/src/routes/repos.ts @@ -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)) + } + } + ) } diff --git a/src/server.ts b/src/server.ts index 043e78f..3bc65e1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 } diff --git a/src/services/github.service.ts b/src/services/github.service.ts index 887f00b..65846f5 100644 --- a/src/services/github.service.ts +++ b/src/services/github.service.ts @@ -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 | 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 { + 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 { + 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 + } } diff --git a/src/services/webhook.service.ts b/src/services/webhook.service.ts index 17f744f..649c326 100644 --- a/src/services/webhook.service.ts +++ b/src/services/webhook.service.ts @@ -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, }, }, }, diff --git a/src/types/github.ts b/src/types/github.ts index 23d0795..d2e6d60 100644 --- a/src/types/github.ts +++ b/src/types/github.ts @@ -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 }