feat: add repo overview
This commit is contained in:
parent
58ca9f5fe9
commit
97a29b940b
418
GITHUB_API_FEASIBILITY.md
Normal file
418
GITHUB_API_FEASIBILITY.md
Normal 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)
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'))
|
||||
})
|
||||
}
|
||||
|
||||
@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user