Skip to content

Commit 42b0559

Browse files
authored
Merge pull request #55 from VectorInstitute/add_ci_status
Add CI status check
2 parents 7cbb159 + 3239949 commit 42b0559

File tree

9 files changed

+5338
-938
lines changed

9 files changed

+5338
-938
lines changed

catalog-analytics/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ REDIRECT_URI=https://catalog.vectorinstitute.ai/analytics/api/auth/callback
1111

1212
# Domain Restrictions (comma-separated)
1313
ALLOWED_DOMAINS=vectorinstitute.ai
14+
15+
# GitHub Configuration (for CI status checks)
16+
# Note: Use GH_TOKEN in GitHub Actions (GITHUB_* is reserved)
17+
GH_TOKEN=your-github-personal-access-token
18+
# Alternative names supported: CATALOG_GITHUB_TOKEN, GITHUB_TOKEN, METRICS_GITHUB_TOKEN

catalog-analytics/app/analytics-content.tsx

Lines changed: 459 additions & 152 deletions
Large diffs are not rendered by default.
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { NextResponse } from 'next/server';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
interface CIStatusRequest {
6+
repositories: string[]; // Array of repo_ids like "VectorInstitute/cyclops"
7+
}
8+
9+
interface CIStatus {
10+
repo_id: string;
11+
state: 'success' | 'failure' | 'pending' | 'error' | 'unknown';
12+
total_checks: number;
13+
updated_at: string;
14+
details?: string;
15+
}
16+
17+
function isValidRepoId(repo_id: string): boolean {
18+
// Expect GitHub-style "owner/repo" with safe characters only
19+
const trimmed = repo_id.trim();
20+
const repoPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
21+
return repoPattern.test(trimmed);
22+
}
23+
24+
function isValidCommitSha(sha: string): boolean {
25+
// Git SHAs are 40-character hexadecimal strings
26+
const shaPattern = /^[a-f0-9]{40}$/;
27+
return shaPattern.test(sha);
28+
}
29+
30+
function buildGitHubApiUrl(path: string): URL {
31+
// Always use the official GitHub API base URL to prevent SSRF
32+
const baseUrl = 'https://api.github.com';
33+
// URL constructor will throw if path is malformed
34+
return new URL(path, baseUrl);
35+
}
36+
37+
export async function POST(request: Request) {
38+
try {
39+
const { repositories }: CIStatusRequest = await request.json();
40+
41+
if (!Array.isArray(repositories)) {
42+
return NextResponse.json(
43+
{ error: 'Invalid request: "repositories" must be an array of strings' },
44+
{ status: 400 }
45+
);
46+
}
47+
48+
const token = process.env.GH_TOKEN || process.env.CATALOG_GITHUB_TOKEN || process.env.GITHUB_TOKEN || process.env.METRICS_GITHUB_TOKEN;
49+
50+
if (!token) {
51+
// Return unknown status for all repos if token is not configured
52+
const statusMap = repositories.reduce((acc, repo_id) => {
53+
acc[repo_id] = {
54+
repo_id,
55+
state: 'unknown' as const,
56+
total_checks: 0,
57+
updated_at: new Date().toISOString(),
58+
details: 'GitHub token not configured',
59+
};
60+
return acc;
61+
}, {} as Record<string, CIStatus>);
62+
63+
return NextResponse.json(statusMap);
64+
}
65+
66+
// Fetch CI status for all repos in parallel
67+
const statusPromises = repositories.map(async (repo_id) => {
68+
// Validate repo_id before using it in an outbound request
69+
const repoIdStr = String(repo_id).trim();
70+
if (!isValidRepoId(repoIdStr)) {
71+
return {
72+
repo_id: repoIdStr,
73+
state: 'unknown' as const,
74+
total_checks: 0,
75+
updated_at: new Date().toISOString(),
76+
details: 'Invalid repository identifier',
77+
};
78+
}
79+
80+
try {
81+
// First, get the latest commit SHA on main branch
82+
// Use URL constructor to prevent SSRF
83+
const branchUrl = buildGitHubApiUrl(`/repos/${encodeURIComponent(repoIdStr)}/branches/main`);
84+
const branchResponse = await fetch(
85+
branchUrl.toString(),
86+
{
87+
headers: {
88+
'Authorization': `Bearer ${token}`,
89+
'Accept': 'application/vnd.github+json',
90+
'X-GitHub-Api-Version': '2022-11-28',
91+
},
92+
}
93+
);
94+
95+
if (!branchResponse.ok) {
96+
if (branchResponse.status === 404) {
97+
return {
98+
repo_id,
99+
state: 'unknown' as const,
100+
total_checks: 0,
101+
updated_at: new Date().toISOString(),
102+
details: 'Main branch not found',
103+
};
104+
}
105+
throw new Error(`GitHub API error: ${branchResponse.status}`);
106+
}
107+
108+
const branchData = await branchResponse.json();
109+
const latestCommitSha = branchData.commit.sha;
110+
111+
// Validate the commit SHA to prevent SSRF
112+
if (!isValidCommitSha(latestCommitSha)) {
113+
return {
114+
repo_id,
115+
state: 'unknown' as const,
116+
total_checks: 0,
117+
updated_at: new Date().toISOString(),
118+
details: 'Invalid commit SHA received from API',
119+
};
120+
}
121+
122+
// Now get check runs for this specific commit
123+
// Use URL constructor to prevent SSRF
124+
const checksUrl = buildGitHubApiUrl(`/repos/${encodeURIComponent(repoIdStr)}/commits/${latestCommitSha}/check-runs`);
125+
const checksResponse = await fetch(
126+
checksUrl.toString(),
127+
{
128+
headers: {
129+
'Authorization': `Bearer ${token}`,
130+
'Accept': 'application/vnd.github+json',
131+
'X-GitHub-Api-Version': '2022-11-28',
132+
},
133+
}
134+
);
135+
136+
if (!checksResponse.ok) {
137+
throw new Error(`GitHub API error: ${checksResponse.status}`);
138+
}
139+
140+
const data = await checksResponse.json();
141+
const checkRuns = data.check_runs || [];
142+
143+
if (checkRuns.length === 0) {
144+
return {
145+
repo_id,
146+
state: 'unknown' as const,
147+
total_checks: 0,
148+
updated_at: new Date().toISOString(),
149+
details: 'No CI configured',
150+
};
151+
}
152+
153+
// Check if any workflow runs failed
154+
// Status: completed, in_progress, queued, waiting, requested, pending
155+
// Conclusion (when completed): success, failure, neutral, cancelled, skipped, timed_out, action_required, startup_failure, stale
156+
let hasFailure = false;
157+
let hasPending = false;
158+
let mostRecentUpdate = '';
159+
160+
for (const check of checkRuns) {
161+
// Skip Dependabot checks - they mark as "failure" for dependency conflicts which aren't CI failures
162+
if (check.app?.slug === 'dependabot' || check.name === 'Dependabot') {
163+
continue;
164+
}
165+
166+
// If the check hasn't completed yet, mark as pending
167+
if (check.status !== 'completed') {
168+
hasPending = true;
169+
}
170+
// If completed, check the conclusion
171+
else if (check.conclusion === 'failure' ||
172+
check.conclusion === 'timed_out' ||
173+
check.conclusion === 'action_required' ||
174+
check.conclusion === 'startup_failure') {
175+
hasFailure = true;
176+
}
177+
178+
// Track most recent update
179+
const updateTime = check.completed_at || check.started_at;
180+
if (updateTime && (!mostRecentUpdate || updateTime > mostRecentUpdate)) {
181+
mostRecentUpdate = updateTime;
182+
}
183+
}
184+
185+
// Determine overall state based on the checks
186+
let state: 'success' | 'failure' | 'pending' | 'error' | 'unknown';
187+
if (hasFailure) {
188+
state = 'failure';
189+
} else if (hasPending) {
190+
state = 'pending';
191+
} else {
192+
// All checks completed without failure
193+
state = 'success';
194+
}
195+
196+
return {
197+
repo_id,
198+
state,
199+
total_checks: checkRuns.length,
200+
updated_at: mostRecentUpdate || new Date().toISOString(),
201+
details: `${checkRuns.length} check(s)`,
202+
};
203+
} catch (error) {
204+
console.error('Error fetching CI status for %s:', repo_id, error);
205+
return {
206+
repo_id,
207+
state: 'unknown' as const,
208+
total_checks: 0,
209+
updated_at: new Date().toISOString(),
210+
details: 'Error fetching status',
211+
};
212+
}
213+
});
214+
215+
const results = await Promise.all(statusPromises);
216+
217+
// Convert array to object keyed by repo_id for easy lookup
218+
const statusMap = results.reduce((acc, status) => {
219+
acc[status.repo_id] = status;
220+
return acc;
221+
}, {} as Record<string, CIStatus>);
222+
223+
return NextResponse.json(statusMap);
224+
} catch (error) {
225+
console.error('CI status API error:', error);
226+
return NextResponse.json(
227+
{ error: 'Failed to fetch CI statuses' },
228+
{ status: 500 }
229+
);
230+
}
231+
}

0 commit comments

Comments
 (0)