From 8574c19b4ffe677c4a4e9e9f26612b029e2f679b Mon Sep 17 00:00:00 2001 From: Anna Finn Date: Thu, 5 Dec 2024 15:28:18 -0500 Subject: [PATCH] dashboard: Add view for PR runs Added a script that fetches PR data and created a separate view on the dashboard. Tweaked dotenv require. Fixes #1 Signed-off-by: Anna Finn --- .github/workflows/fectch-ci-data.yml | 42 ++++++ pages/index.js | 198 ++++++++++++++++++++------- scripts/fetch-ci-nightly-data.js | 2 +- scripts/fetch-ci-pr-data.js | 195 ++++++++++++++++++++++++++ 4 files changed, 384 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/fectch-ci-data.yml create mode 100644 scripts/fetch-ci-pr-data.js diff --git a/.github/workflows/fectch-ci-data.yml b/.github/workflows/fectch-ci-data.yml new file mode 100644 index 0000000..cfb59c6 --- /dev/null +++ b/.github/workflows/fectch-ci-data.yml @@ -0,0 +1,42 @@ +name: Fetch CI Data +run-name: Fetch CI Data +on: + schedule: + - cron: '0 */2 * * *' + workflow_dispatch: + +jobs: + fetch-and-commit-data: + runs-on: ubuntu-22.04 + + env: + NODE_ENV: production + TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Update dashboard data + run: | + # fetch ci nightly data as temporary file + node scripts/fetch-ci-nightly-data.js | tee tmp-data.json + node scripts/fetch-ci-pr-data.js | tee tmp-data2.json + + # switch to a branch specifically for holding latest data + git config --global user.name "GH Actions Workflow" + git config --global user.email "" + git fetch --all + git checkout latest-dashboard-data + + # back out whatever data was there + git reset HEAD~1 + + # overwrite the old data + mkdir -p data/ + mv tmp-data.json data/job_stats.json + mv tmp-data2.json data/check_stats.json + + # commit + git add data + git commit -m '[skip ci] latest ci nightly data' + git push --force diff --git a/pages/index.js b/pages/index.js index a8d3534..90125eb 100644 --- a/pages/index.js +++ b/pages/index.js @@ -9,31 +9,39 @@ import MaintainerMapping from "../maintainers.yml"; export default function Home() { const [loading, setLoading] = useState(true); + const [checks, setChecks] = useState([]); const [jobs, setJobs] = useState([]); - const [rows, setRows] = useState([]); + const [rowsPR, setRowsPR] = useState([]); + const [rowsNightly, setRowsNightly] = useState([]); const [expandedRows, setExpandedRows] = useState([]); const [requiredFilter, setRequiredFilter] = useState(false); + const [display, setDisplay] = useState("nightly"); useEffect(() => { const fetchData = async () => { - let data = {}; + let nightlyData = {}; + let prData = {}; if (process.env.NODE_ENV === "development") { - data = (await import("../localData/job_stats.json")).default; + nightlyData = (await import("../localData/job_stats.json")).default; + prData = (await import("../localData/check_stats.json")).default; } else { - const response = await fetch( + nightlyData = await fetch( "https://raw.githubusercontent.com/kata-containers/kata-containers.github.io" + "/refs/heads/latest-dashboard-data/data/job_stats.json" - ); - data = await response.json(); + ).then((res) => res.json()); + prData = await fetch( + "https://raw.githubusercontent.com/kata-containers/kata-containers.github.io" + + "/refs/heads/latest-dashboard-data/data/check_stats.json" + ).then((res) => res.json()); } try { - const jobData = Object.keys(data).map((key) => { - const job = data[key]; - return { name: key, ...job }; - }); - setJobs(jobData); + const mapData = (data) => Object.keys(data).map((key) => + ({ name: key, ...data[key] }) + ); + setJobs(mapData(nightlyData)); + setChecks(mapData(prData)); } catch (error) { // TODO: Add pop-up/toast message for error console.error("Error fetching data:", error); @@ -54,14 +62,12 @@ export default function Home() { return filteredJobs; }; + // Filter and set the rows for Nightly view. useEffect(() => { setLoading(true); - - // Filter based on required tag. let filteredJobs = filterRequired(jobs); - //Set the rows for the table. - setRows( + setRowsNightly( filteredJobs.map((job) => ({ name : job.name, runs : job.runs, @@ -74,6 +80,31 @@ export default function Home() { setLoading(false); }, [jobs, requiredFilter]); + // Filter and set the rows for PR Checks view. + useEffect(() => { + setLoading(true); + let filteredChecks = filterRequired(checks) + + //Set the rows for the table. + setRowsPR( + filteredChecks.map((check) => ({ + name : check.name, + runs : check.runs, + fails : check.fails, + skips : check.skips, + required : check.required, + weather : getWeatherIndex(check), + })) + ); + setLoading(false); + }, [checks, requiredFilter]); + + // Close all rows on view switch. + // Needed because if view is switched, breaks expanded row toggling. + useEffect(() => { + setExpandedRows([]) + }, [display]); + const toggleRow = (rowData) => { const isRowExpanded = expandedRows.includes(rowData); @@ -91,6 +122,10 @@ export default function Home() { ${active ? "border-blue-500 bg-blue-500 text-white" : "border-gray-300 bg-white hover:bg-gray-100"}`; + const tabClass = (active) => `tab md:px-4 px-2 py-2 border-b-2 focus:outline-none + ${active ? "border-blue-500 bg-gray-300" + : "border-gray-300 bg-white hover:bg-gray-100"}`; + // Template for rendering the Name column as a clickable item const nameTemplate = (rowData) => { @@ -104,7 +139,9 @@ export default function Home() { const maintainRefs = useRef([]); const rowExpansionTemplate = (data) => { - const job = jobs.find((job) => job.name === data.name); + const job = (display === "nightly" + ? jobs + : checks).find((job) => job.name === data.name); // Prepare run data const runs = []; @@ -115,7 +152,7 @@ export default function Home() { url: job.urls[i], }); } - + // Find maintainers for the given job const maintainerData = MaintainerMapping.mappings .filter(({ regex }) => new RegExp(regex).test(job.name)) @@ -135,6 +172,7 @@ export default function Home() { return acc; }, {}); + return (
{/* Display last 10 runs */} @@ -149,7 +187,7 @@ export default function Home() { : "⚠️"; return ( - + {emoji} {run.run_num}      @@ -251,9 +289,10 @@ export default function Home() { ); }; - const renderTable = () => ( + // Render table for nightly view. + const renderNightlyTable = () => ( - + - - - - + + + + + + + ); + + const renderPRTable = () => ( + setExpandedRows(e.data)} + loading={loading} + emptyMessage="No results found." + sortField="fails" + sortOrder={-1} + > + + + + + + ); @@ -299,30 +374,49 @@ export default function Home() { } > - Kata CI Dashboard - + href={display === 'nightly' + ? "https://github.com/kata-containers/kata-containers/" + + "actions/workflows/ci-nightly.yaml" + : "https://github.com/kata-containers/kata-containers/" + + "/pulls?state=closed"} + target="_blank" + rel="noopener noreferrer" + > + Kata CI Dashboard + +
+
+ + +
+
-
+ +
-
Total Rows: {rows.length}
-
{renderTable()}
-
+
+ Total Rows: {display === "prchecks" ? rowsPR.length : rowsNightly.length} +
+
{display === "prchecks" ? renderPRTable() : renderNightlyTable()}
+
); } diff --git a/scripts/fetch-ci-nightly-data.js b/scripts/fetch-ci-nightly-data.js index d1c5cb7..2ac2a47 100644 --- a/scripts/fetch-ci-nightly-data.js +++ b/scripts/fetch-ci-nightly-data.js @@ -20,7 +20,7 @@ // Set token used for making Authorized GitHub API calls. // In dev, set by .env file; in prod, set by GitHub Secret. -if(process.env.NODE_ENV === "development"){ +if(process.env.NODE_ENV !== "production"){ require('dotenv').config(); } const TOKEN = process.env.TOKEN; diff --git a/scripts/fetch-ci-pr-data.js b/scripts/fetch-ci-pr-data.js new file mode 100644 index 0000000..8130c58 --- /dev/null +++ b/scripts/fetch-ci-pr-data.js @@ -0,0 +1,195 @@ +// +// This script is designed to query the github API for a useful summary +// of recent PR CI tests. +// +// The general flow is as follows: +// 1. retrieve the last 10 closed PRs +// 2. fetch the checks for each PR (using the head commit SHA) +// +// To run locally: +// node --require dotenv/config scripts/fetch-ci-pr-data.js +// .env file with: +// NODE_ENV=development +// TOKEN=token + + +// Set token used for making Authorized GitHub API calls +if(process.env.NODE_ENV !== "production"){ + require('dotenv').config(); +} +const TOKEN = process.env.TOKEN; // In dev, set by .env file; in prod, set by GitHub Secret + + +// The number of checks to fetch from the github API on each paged request. +const results_per_request = 100; +// The last X closed PRs to retrieve +const pr_count = 10; +// Count of the number of fetches +var fetch_count = 0; + +// pull_requests attribute often empty if commit/branch from a fork: https://github.com/orgs/community/discussions/25220 +const pull_requests_url = + "https://api.github.com/repos/" + + `kata-containers/kata-containers/pulls?state=closed&per_page=${pr_count}`; + const pr_checks_url = // for our purposes, 'check' refers to a job in the context of a PR + "https://api.github.com/repos/" + + "kata-containers/kata-containers/commits/"; // will be followed by {commit_sha}/check-runs + +// Github API URL for the main branch of the kata-containers repo. +// Used to get the list of required jobs/checks. +const main_branch_url = + "https://api.github.com/repos/" + + "kata-containers/kata-containers/branches/main"; + + +// Perform a github API request for the given url. +async function fetch_url(url) { + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `token ${TOKEN}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch workflow runs: ${response.status}: ` + + `${response.statusText}`); + } + + const json = await response.json(); + fetch_count++; + return await json; +} + + +// Perform a github API request for a list of commits for a PR (takes in the PR's head commit SHA) +function get_check_data(pr) { + async function fetch_checks_by_page(which_page) { + const checks_url = `${pr_checks_url}${prs_with_check_data["commit_sha"]}/check-runs?per_page=${results_per_request}&page=${which_page}`; + const response = await fetch(checks_url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `token ${TOKEN}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch check data: ${response.status}`); + } + + const json = await response.json(); + fetch_count++; + return json; + } + + // Fetch the checks for a PR. + function fetch_checks(p) { + return fetch_checks_by_page(p) + .then(function (checks_request) { + // console.log('checks_request', checks_request); + for (const check of checks_request["check_runs"]) { + prs_with_check_data["checks"].push({ + name: check["name"], + conclusion: check["conclusion"] + }); + } + if (p * results_per_request >= checks_request["total_count"]) { + return prs_with_check_data; + } + return fetch_checks(p + 1); + }) + .catch(function (error) { + console.error("Error fetching checks:", error); + throw error; + }); + } + + // Extract list of objects with PR commit SHAs, PR URLs, and PR number (i.e. id) + var prs_with_check_data = { + html_url: pr["html_url"], // link to PR page + number: pr["number"], // PR number (used as PR id); displayed on dashboard + commit_sha: pr["head"]["sha"], // For getting checks run on PR branch + // commit_sha: pr["merge_commit_sha"], // For getting checks run on main branch after merge + // NOTE: using for now b/c we'll be linking to the PR page, where these checks are listed... + checks: [], // will be populated later with fetch_checks + }; + + return fetch_checks(1); +} + + +// Extract list of required jobs (i.e. main branch details: protection: required_status_checks: contexts) +function get_required_jobs(main_branch) { + return main_branch["protection"]["required_status_checks"]["contexts"]; +} + + +// Calculate and return check stats across all runs +function compute_check_stats(prs_with_check_data, required_jobs) { + var check_stats = {}; + for (const pr of prs_with_check_data) { + for (const check of pr["checks"]) { + if (!(check["name"] in check_stats)) { + check_stats[check["name"]] = { + runs: 0, // e.g. 10, if it ran 10 times + fails: 0, // e.g. 3, if it failed 3 out of 10 times + skips: 0, // e.g. 7, if it got skipped the other 7 times + urls: [], // list of PR URLs that this check is associated with + results: [], // list of check statuses for the PRs in which the check was run + run_nums: [], // list of PR numbers that this check is associated with + }; + } + if ((check["name"] in check_stats) && !check_stats[check["name"]]["run_nums"].includes(pr["number"])) { + var check_stat = check_stats[check["name"]]; + check_stat["runs"] += 1; + check_stat["urls"].push(pr["html_url"]) + check_stat["run_nums"].push(pr["number"]) + if (check["conclusion"] != "success") { + if (check["conclusion"] == "skipped") { + check_stat["skips"] += 1; + check_stat["results"].push("Skip"); + } else { + // failed or cancelled + check_stat["fails"] += 1; + check_stat["results"].push("Fail"); + } + } else { + check_stat["results"].push("Pass"); + } + check_stat["required"] = required_jobs.includes(check["name"]); + } + } + } + return check_stats; +} + + +async function main() { + // Fetch recent pull requests via the github API + const pull_requests = await fetch_url(pull_requests_url); + + // Fetch required jobs from main branch + const main_branch = await fetch_url(main_branch_url); + const required_jobs = get_required_jobs(main_branch); + + // Fetch last pr_count closed PRs + // Store all of this in an array of maps, prs_with_check_data. + const promises_buffer = []; + for (const pr of pull_requests) { + promises_buffer.push(get_check_data(pr)); + } + prs_with_check_data = await Promise.all(promises_buffer); + + // Transform the raw details of each run and its checks' results into a + // an array of just the checks and their overall results (e.g. pass or fail, + // and the URLs associated with them). + const check_stats = compute_check_stats(prs_with_check_data, required_jobs); + + // Write the check_stats to console as a JSON object + console.log(JSON.stringify(check_stats)); +} + + +main();