Skip to content

Commit 7b823cf

Browse files
committed
refactor: harden types, fix injection, split render/compute
1 parent b2b81f3 commit 7b823cf

4 files changed

Lines changed: 333 additions & 251 deletions

File tree

.github/dashboard/src/compute.ts

Lines changed: 65 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,7 @@ export function parsePRs(raw: GHPullRequestNode[]): PullRequest[] {
107107
}
108108
}
109109
}
110-
const allApproved =
111-
r.reviews.nodes.filter(rv => rv.submittedAt).length > 0 &&
112-
r.reviews.nodes.filter(rv => rv.submittedAt).every(rv => rv.state === 'APPROVED');
110+
const allApproved = sortedReviews.length > 0 && sortedReviews.every(rv => rv.state === 'APPROVED');
113111
const state: 'open' | 'closed' = r.state === 'OPEN' ? 'open' : 'closed';
114112
let bucket: PullRequest['bucket'];
115113
if (state === 'closed') {
@@ -280,12 +278,12 @@ function computeDistribution(field: string, items: (Issue | PullRequest)[]): Cha
280278
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
281279
return { labels: sorted.map(([l]) => l), values: sorted.map(([, v]) => v) };
282280
} else if (field === 'bucket') {
283-
for (const item of items.filter(i => i.state === 'open' && !isIssue(i))) {
284-
inc((item as PullRequest).bucket);
281+
for (const item of items) {
282+
if (item.state === 'open' && !isIssue(item)) inc(item.bucket);
285283
}
286284
} else if (field === 'linkedIssuePriority') {
287-
for (const item of items.filter(i => !isIssue(i))) {
288-
inc((item as PullRequest).linkedIssuePriority ?? '(none)');
285+
for (const item of items) {
286+
if (!isIssue(item)) inc(item.linkedIssuePriority ?? '(none)');
289287
}
290288
}
291289

@@ -489,7 +487,7 @@ function computeTermFrequency(
489487
.slice(0, 20)
490488
.map(([term, count]) => ({ term, count }));
491489

492-
const usedLabels = new Set(items.flatMap(i => i.labels));
490+
const usedLabels = new Set(filtered.flatMap(i => i.labels));
493491
const allLabels = new Set(items.flatMap(i => i.labels));
494492
const unusedLabels = [...allLabels].filter(l => !usedLabels.has(l));
495493

@@ -514,7 +512,7 @@ export function computePage(
514512
const now = new Date();
515513
result.windowedStats = {};
516514
for (const w of sec.windows) {
517-
const cutoff = new Date(now.getTime() - w.days * 24 * 60 * 60 * 1000);
515+
const cutoff = new Date(now.getTime() - w.days * MS_PER_DAY);
518516
const filtered = items.filter(i => i.created >= cutoff);
519517
result.windowedStats[w.label] = computeStats(sec.metrics, filtered);
520518
}
@@ -548,37 +546,33 @@ export function computePage(
548546
};
549547
}
550548

551-
function computeCI(runs: WorkflowRun[]): CIData {
552-
// Pass rate helpers
553-
function calcPassRates(subset: WorkflowRun[]): { overall: number; perWf: Record<string, number> } {
554-
const overall =
555-
subset.length > 0 ? Math.round((subset.filter(r => r.conclusion === 'success').length / subset.length) * 100) : 0;
556-
const perWf: Record<string, number> = {};
557-
const byW: Record<string, WorkflowRun[]> = {};
558-
for (const r of subset) (byW[r.workflowName] ??= []).push(r);
559-
for (const [name, wfRuns] of Object.entries(byW)) {
560-
perWf[name] =
561-
wfRuns.length > 0
562-
? Math.round((wfRuns.filter(r => r.conclusion === 'success').length / wfRuns.length) * 100)
563-
: 0;
564-
}
565-
return { overall, perWf };
566-
}
549+
function groupBy<T>(items: T[], keyFn: (item: T) => string): Record<string, T[]> {
550+
const result: Record<string, T[]> = {};
551+
for (const item of items) (result[keyFn(item)] ??= []).push(item);
552+
return result;
553+
}
567554

568-
const allRates = calcPassRates(runs);
569-
const overallPassRate = allRates.overall;
570-
const passRate = allRates.perWf;
555+
function calcPassRates(runs: WorkflowRun[]): { overall: number; perWf: Record<string, number> } {
556+
const overall =
557+
runs.length > 0 ? Math.round((runs.filter(r => r.conclusion === 'success').length / runs.length) * 100) : 0;
558+
const perWf: Record<string, number> = {};
559+
for (const [name, wfRuns] of Object.entries(groupBy(runs, r => r.workflowName))) {
560+
perWf[name] =
561+
wfRuns.length > 0 ? Math.round((wfRuns.filter(r => r.conclusion === 'success').length / wfRuns.length) * 100) : 0;
562+
}
563+
return { overall, perWf };
564+
}
571565

572-
// Weekly timeline
566+
function buildCITimeline(runs: WorkflowRun[]): CIData['timeline'] {
573567
const sorted = [...runs].sort((a, b) => a.created.getTime() - b.created.getTime());
574568
const start =
575-
sorted.length > 0 ? new Date(sorted[0].created.getTime() - sorted[0].created.getDay() * 86400000) : new Date();
569+
sorted.length > 0 ? new Date(sorted[0].created.getTime() - sorted[0].created.getDay() * MS_PER_DAY) : new Date();
576570
start.setHours(0, 0, 0, 0);
577571
const end = sorted.length > 0 ? sorted[sorted.length - 1].created : new Date();
578572
const timeline: CIData['timeline'] = [];
579573
const cur = new Date(start);
580574
while (cur <= end) {
581-
const nxt = new Date(cur.getTime() + 7 * 86400000);
575+
const nxt = new Date(cur.getTime() + 7 * MS_PER_DAY);
582576
const weekRuns = runs.filter(r => r.created >= cur && r.created < nxt);
583577
timeline.push({
584578
week: cur.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
@@ -587,8 +581,10 @@ function computeCI(runs: WorkflowRun[]): CIData {
587581
});
588582
cur.setTime(nxt.getTime());
589583
}
584+
return timeline;
585+
}
590586

591-
// Failing jobs — aggregate across all runs
587+
function findFailingJobs(runs: WorkflowRun[]): CIData['failingJobs'] {
592588
const jobStats: Record<string, { failures: number; total: number }> = {};
593589
for (const r of runs) {
594590
for (const j of r.jobs) {
@@ -598,16 +594,15 @@ function computeCI(runs: WorkflowRun[]): CIData {
598594
if (j.conclusion === 'failure') s.failures++;
599595
}
600596
}
601-
const failingJobs = Object.entries(jobStats)
597+
return Object.entries(jobStats)
602598
.filter(([, s]) => s.failures > 0)
603599
.map(([job, s]) => ({ job, failures: s.failures, total: s.total, rate: Math.round((s.failures / s.total) * 100) }))
604600
.sort((a, b) => b.failures - a.failures);
601+
}
605602

606-
// Flaky detection — jobs that flip between pass/fail across consecutive runs per workflow
603+
function detectFlakyJobs(runs: WorkflowRun[]): CIData['flaky'] {
607604
const flaky: CIData['flaky'] = [];
608-
const byWf: Record<string, WorkflowRun[]> = {};
609-
for (const r of runs) (byWf[r.workflowName] ??= []).push(r);
610-
for (const wfRuns of Object.values(byWf)) {
605+
for (const wfRuns of Object.values(groupBy(runs, r => r.workflowName))) {
611606
const chronological = [...wfRuns].sort((a, b) => a.created.getTime() - b.created.getTime());
612607
const jobHistory: Record<string, string[]> = {};
613608
for (const r of chronological) {
@@ -628,10 +623,11 @@ function computeCI(runs: WorkflowRun[]): CIData {
628623
}
629624
}
630625
}
631-
flaky.sort((a, b) => b.flipCount - a.flipCount);
626+
return flaky.sort((a, b) => b.flipCount - a.flipCount);
627+
}
632628

633-
// Recent failures
634-
const recentFailures = runs
629+
function getRecentFailures(runs: WorkflowRun[]): CIData['recentFailures'] {
630+
return runs
635631
.filter(r => r.conclusion === 'failure')
636632
.sort((a, b) => b.created.getTime() - a.created.getTime())
637633
.slice(0, 20)
@@ -641,38 +637,47 @@ function computeCI(runs: WorkflowRun[]): CIData {
641637
date: r.created.toISOString().slice(0, 16).replace('T', ' '),
642638
failedJobs: r.jobs.filter(j => j.conclusion === 'failure').map(j => j.name),
643639
}));
640+
}
644641

645-
// Avg duration per job
642+
function calcAvgDuration(runs: WorkflowRun[]): Record<string, number> {
646643
const jobDurations: Record<string, number[]> = {};
647644
for (const r of runs) {
648645
for (const j of r.jobs) {
649646
if (j.durationMin > 0) (jobDurations[j.name] ??= []).push(j.durationMin);
650647
}
651648
}
652-
const avgDuration: Record<string, number> = {};
649+
const result: Record<string, number> = {};
653650
for (const [job, durations] of Object.entries(jobDurations)) {
654-
avgDuration[job] = Math.round((durations.reduce((a, b) => a + b, 0) / durations.length) * 10) / 10;
651+
result[job] = Math.round((durations.reduce((a, b) => a + b, 0) / durations.length) * 10) / 10;
655652
}
653+
return result;
654+
}
655+
656+
function calcCIWindows(runs: WorkflowRun[]): CIData['windows'] {
657+
return Object.fromEntries(
658+
[
659+
['Past 24h', 1],
660+
['Past 7 days', 7],
661+
['Past 30 days', 30],
662+
].map(([label, days]) => {
663+
const cutoff = new Date(Date.now() - (days as number) * MS_PER_DAY);
664+
const subset = runs.filter(r => r.created >= cutoff);
665+
const rates = calcPassRates(subset);
666+
return [label, { overallPassRate: rates.overall, passRate: rates.perWf }];
667+
})
668+
);
669+
}
656670

671+
function computeCI(runs: WorkflowRun[]): CIData {
672+
const { overall: overallPassRate, perWf: passRate } = calcPassRates(runs);
657673
return {
658674
overallPassRate,
659675
passRate,
660-
timeline,
661-
failingJobs,
662-
flaky,
663-
recentFailures,
664-
avgDuration,
665-
windows: Object.fromEntries(
666-
[
667-
['Past 24h', 1],
668-
['Past 7 days', 7],
669-
['Past 30 days', 30],
670-
].map(([label, days]) => {
671-
const cutoff = new Date(Date.now() - (days as number) * 86400000);
672-
const subset = runs.filter(r => r.created >= cutoff);
673-
const rates = calcPassRates(subset);
674-
return [label, { overallPassRate: rates.overall, passRate: rates.perWf }];
675-
})
676-
),
676+
timeline: buildCITimeline(runs),
677+
failingJobs: findFailingJobs(runs),
678+
flaky: detectFlakyJobs(runs),
679+
recentFailures: getRecentFailures(runs),
680+
avgDuration: calcAvgDuration(runs),
681+
windows: calcCIWindows(runs),
677682
};
678683
}

.github/dashboard/src/github.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
1-
import type { GHIssue, GHPullRequestNode, WorkflowJob, WorkflowRun } from './types.js';
2-
import { execSync } from 'node:child_process';
1+
import type { GHIssue, GHPullRequestNode, RunConclusion, WorkflowJob, WorkflowRun } from './types.js';
2+
import { execFileSync } from 'node:child_process';
33

44
const EXEC_OPTS = { encoding: 'utf-8' as const, maxBuffer: 50 * 1024 * 1024 };
55

6+
function ghApi(...args: string[]): string {
7+
try {
8+
return execFileSync('gh', ['api', ...args], EXEC_OPTS);
9+
} catch (err) {
10+
const msg = err instanceof Error ? err.message : String(err);
11+
throw new Error(`gh api call failed (args: ${args.join(' ')}): ${msg}`);
12+
}
13+
}
14+
15+
function parseJSON<T>(raw: string, context: string): T {
16+
try {
17+
return JSON.parse(raw) as T;
18+
} catch (err) {
19+
const msg = err instanceof Error ? err.message : String(err);
20+
throw new Error(`Failed to parse JSON (${context}): ${msg}`);
21+
}
22+
}
23+
624
export function fetchIssues(repo: string): GHIssue[] {
725
process.stderr.write(`Fetching issues for ${repo}...\n`);
8-
const raw = execSync(`gh api --paginate '/repos/${repo}/issues?state=all&per_page=100'`, EXEC_OPTS);
9-
const items = JSON.parse(raw.trim()) as GHIssue[];
26+
const raw = ghApi('--paginate', `/repos/${repo}/issues?state=all&per_page=100`);
27+
const items = parseJSON<GHIssue[]>(raw.trim(), 'fetchIssues');
1028
const issues = items.filter(i => !i.pull_request);
1129
process.stderr.write(` Fetched ${issues.length} issues\n`);
1230
return issues;
@@ -19,8 +37,8 @@ export function fetchPRs(repo: string): GHPullRequestNode[] {
1937
let page = 0;
2038

2139
const query = `
22-
query($cursor: String) {
23-
repository(owner: "${owner}", name: "${name}") {
40+
query($owner: String!, $name: String!, $cursor: String) {
41+
repository(owner: $owner, name: $name) {
2442
pullRequests(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) {
2543
pageInfo { hasNextPage endCursor }
2644
nodes {
@@ -38,9 +56,12 @@ export function fetchPRs(repo: string): GHPullRequestNode[] {
3856
for (;;) {
3957
page++;
4058
process.stderr.write(`Fetching PRs page ${page}...\n`);
41-
const cursorArg = cursor ? `-f cursor="${cursor}"` : '';
42-
const raw = execSync(`gh api graphql -f query='${query}' ${cursorArg}`, EXEC_OPTS);
43-
const resp = JSON.parse(raw) as {
59+
const args = ['graphql', '-F', `owner=${owner}`, '-F', `name=${name}`, '-f', `query=${query}`];
60+
if (cursor) {
61+
args.push('-f', `cursor=${cursor}`);
62+
}
63+
const raw = ghApi(...args);
64+
const resp = parseJSON<{
4465
data: {
4566
repository: {
4667
pullRequests: {
@@ -49,7 +70,7 @@ export function fetchPRs(repo: string): GHPullRequestNode[] {
4970
};
5071
};
5172
};
52-
};
73+
}>(raw, `fetchPRs page ${page}`);
5374
const data = resp.data.repository.pullRequests;
5475
prs.push(...data.nodes);
5576
if (!data.pageInfo.hasNextPage) break;
@@ -66,17 +87,18 @@ interface GHWorkflow {
6687
name: string;
6788
}
6889
interface GHRunsResponse {
69-
workflow_runs: { id: number; conclusion: string; created_at: string }[];
90+
workflow_runs: { id: number; conclusion: RunConclusion | null; created_at: string }[];
7091
}
7192
interface GHJobsResponse {
72-
jobs: { name: string; conclusion: string; started_at: string; completed_at: string }[];
93+
jobs: { name: string; conclusion: RunConclusion | null; started_at: string; completed_at: string }[];
7394
}
7495

7596
export function fetchCIRuns(repo: string, workflowNames: string[], branch: string, maxRuns: number): WorkflowRun[] {
7697
process.stderr.write(`Fetching CI runs for ${branch}...\n`);
77-
const wfList = JSON.parse(
78-
execSync(`gh api '/repos/${repo}/actions/workflows' --jq '.workflows'`, EXEC_OPTS)
79-
) as GHWorkflow[];
98+
const wfList = parseJSON<GHWorkflow[]>(
99+
ghApi(`/repos/${repo}/actions/workflows`, '--jq', '.workflows'),
100+
'fetchCIRuns workflows'
101+
);
80102
const matched = wfList.filter(w => workflowNames.includes(w.name));
81103
const runs: WorkflowRun[] = [];
82104
const perWf = Math.ceil(maxRuns / matched.length);
@@ -86,21 +108,19 @@ export function fetchCIRuns(repo: string, workflowNames: string[], branch: strin
86108
let fetched = 0;
87109
let page = 1;
88110
while (fetched < perWf) {
89-
const resp = JSON.parse(
90-
execSync(
91-
`gh api '/repos/${repo}/actions/workflows/${wf.id}/runs?branch=${branch}&per_page=100&page=${page}'`,
92-
EXEC_OPTS
93-
)
94-
) as GHRunsResponse;
111+
const resp = parseJSON<GHRunsResponse>(
112+
ghApi(`/repos/${repo}/actions/workflows/${wf.id}/runs?branch=${branch}&per_page=100&page=${page}`),
113+
`fetchCIRuns runs page ${page}`
114+
);
95115
if (resp.workflow_runs.length === 0) break;
96116
for (const run of resp.workflow_runs) {
97117
if (fetched >= perWf) break;
98-
// Only fetch job details for failed runs — success runs don't need them
99118
let jobs: WorkflowJob[] = [];
100119
if (run.conclusion === 'failure') {
101-
const jobsResp = JSON.parse(
102-
execSync(`gh api '/repos/${repo}/actions/runs/${run.id}/jobs'`, EXEC_OPTS)
103-
) as GHJobsResponse;
120+
const jobsResp = parseJSON<GHJobsResponse>(
121+
ghApi(`/repos/${repo}/actions/runs/${run.id}/jobs`),
122+
`fetchCIRuns jobs for run ${run.id}`
123+
);
104124
jobs = jobsResp.jobs.map(
105125
(j): WorkflowJob => ({
106126
name: j.name,

0 commit comments

Comments
 (0)