From 7c3834a5f28aafd2518e91b41c68bff026689953 Mon Sep 17 00:00:00 2001 From: Anna Finn Date: Thu, 5 Dec 2024 15:28:18 -0500 Subject: [PATCH 1/4] 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(); From 5f9bf61d504a1d0f8e743ac9686b95613bf7dcf4 Mon Sep 17 00:00:00 2001 From: Anna Finn Date: Fri, 6 Dec 2024 11:09:32 -0500 Subject: [PATCH 2/4] dashboard: Indicate if a test was rerun Added code to get rerun information to both fetch scripts. Display the reruns as a superscript in the rowExpansionTemplate with the result/URL. Fixes: #8 Signed-off-by: Anna Finn --- pages/index.js | 126 +++++++++++++++++----- scripts/fetch-ci-nightly-data.js | 172 ++++++++++++++++++++++++++++--- scripts/fetch-ci-pr-data.js | 113 +++++++++++++------- 3 files changed, 329 insertions(+), 82 deletions(-) diff --git a/pages/index.js b/pages/index.js index 90125eb..03730ce 100644 --- a/pages/index.js +++ b/pages/index.js @@ -75,6 +75,8 @@ export default function Home() { skips : job.skips, required : job.required, weather : getWeatherIndex(job), + reruns : job.reruns, + total_reruns : job.reruns.reduce((total, r) => total + r, 0), })) ); setLoading(false); @@ -94,6 +96,8 @@ export default function Home() { skips : check.skips, required : check.required, weather : getWeatherIndex(check), + reruns : check.reruns, + total_reruns : check.reruns.reduce((total, r) => total + r, 0), })) ); setLoading(false); @@ -137,21 +141,43 @@ export default function Home() { }; const maintainRefs = useRef([]); + const rerunRefs = useRef([]); const rowExpansionTemplate = (data) => { const job = (display === "nightly" ? jobs : checks).find((job) => job.name === data.name); + if (!job) return ( +
+ No data available for this job. +
+ ); + // Prepare run data - const runs = []; - for (let i = 0; i < job.runs; i++) { - runs.push({ - run_num: job.run_nums[i], - result: job.results[i], - url: job.urls[i], - }); - } + const getRunStatusIcon = (runs) => { + if (Array.isArray(runs)) { + const allPass = runs.every(run => run === "Pass"); + const allFail = runs.every(run => run === "Fail"); + + if (allPass) {return "✅";} + if (allFail) {return "❌";} + } else if (runs === "Pass") { + return "✅"; + } else if (runs === "Fail") { + return "❌"; + } + return "⚠️"; // return a warning if a mix of results + }; + + const runEntries = job.run_nums.map((run_num, idx) => ({ + run_num, + result: job.results[idx], + reruns: job.reruns[idx], + rerun_result: job.rerun_results[idx], + url: job.urls[idx], + attempt_urls: job.attempt_urls[idx], + })); // Find maintainers for the given job const maintainerData = MaintainerMapping.mappings @@ -177,28 +203,70 @@ export default function Home() {
{/* Display last 10 runs */}
- {runs.length > 0 ? ( - runs.map((run) => { - const emoji = - run.result === "Pass" - ? "✅" - : run.result === "Fail" - ? "❌" - : "⚠️"; - return ( - - - {emoji} {run.run_num} + {runEntries.map(({ + run_num, + result, + url, + reruns, + rerun_result, + attempt_urls + }, idx) => { + const allResults = rerun_result + ? [result, ...rerun_result] + : [result]; + + const runStatuses = allResults.map((result, idx) => + `${allResults.length - idx}. ${result === 'Pass' + ? '✅ Success' + : result === 'Fail' + ? '❌ Fail' + : '⚠️ Warning'}`); + + // IDs can't have a '/'... + const sanitizedJobName = job.name.replace(/[^a-zA-Z0-9-_]/g, ''); + + const badgeReruns = `reruns-${sanitizedJobName}-${run_num}`; + + rerunRefs.current[badgeReruns] = rerunRefs.current[badgeReruns] + || React.createRef(); + + return ( +
+
+ {/* */} + + {getRunStatusIcon(allResults)} {run_num} -      - - ); - }) - ) : ( -
No Nightly Runs associated with this job
- )} +
+ {reruns > 0 &&( + + + rerunRefs.current[badgeReruns].current.toggle(e)}> + {reruns+1} + + + rerunRefs.current[badgeReruns].current.toggle(e)}> + + + + )} +
+ ); + })}
- {/* Display Maintainers, if there's any */}
{Object.keys(groupedMaintainers).length > 0 ? ( @@ -316,6 +384,7 @@ export default function Home() { header = "Runs" className="whitespace-nowrap px-2" sortable /> + + = jobs_request["total_count"]) { return run_with_job_data; } return fetch_jobs(p + 1); + }) + .catch(function (error) { + console.error("Error fetching checks:", error); + throw error; }); } @@ -123,6 +128,8 @@ function get_job_data(run) { id: run["id"], run_number: run["run_number"], created_at: run["created_at"], + previous_attempt_url: run["previous_attempt_url"], + html_url: run["html_url"], // URL to the overall run conclusion: null, jobs: [], }; @@ -134,27 +141,158 @@ function get_job_data(run) { run_with_job_data["conclusion"] = run["conclusion"]; return fetch_jobs(1); } + + +// Using the previous URL, this will look at the json with jobs. +// This will have the results for each job for a previous run. +async function fetch_attempt_results(prev_url) { + // Initialize an array to hold all jobs. + const result = []; + + // Fetch from pages until its processed all jobs. + let p = 1; + while (true) { + const jobs_url = `${prev_url}/jobs?per_page=500&page=${p}`; + const response = await fetch(jobs_url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `token ${TOKEN}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + console.error(`Failed to fetch attempt results: ${response.statusText}`); + } + + const json = await response.json(); + fetch_count++; + result.push(...json.jobs); + + // Break we no have more pages to fetch. + if (p * jobs_per_request >= json.total_count) { + break; + } + p++; + } + return { jobs: result }; +} + +// Get the attempt results for reruns for each job in each run. +// This constructs the field "attempt_results" and "rerun_urls" for each job. +// Results will have the result for each rerun. If no reruns, it's null. +async function get_atttempt_results(runs_with_job_data){ + for (const run of runs_with_job_data) { + var prev_url = run["previous_attempt_url"]; + // If the run has a previous attempt (prev_url isn't null), process it. + while (prev_url !== null){ + // Get json with results for the run, which has job information. + const json1 = await fetch_attempt_results(prev_url); + if(json1 === null){ + console.error("json1 empty"); + } + + // For each job in the run, append the result. + for (const job of run["jobs"]) { + // Find the job in the json that matches the current job name. + const match = json1.jobs.find((j) => j.name === job["name"]); + + // Initialize structures if not initialized before. + job["attempt_results"] ??= []; + job["rerun_urls"] ??= []; + + if (match) { + // Add the URLs for all attempts. + job["rerun_urls"].push(match.html_url); + + // Find the last step to see the final conclusion for the job. + const completed = match.steps.find((step) => + step.name === "Complete job"); + // If there's a conclusion, add it to the job's attempt_results. + if(completed){ + if (completed.conclusion != "success") { + if (completed.conclusion == "skipped") { + job["attempt_results"].push("Skip"); + } else { + // Failed or cancelled. + job["attempt_results"].push("Fail"); + } + } else { + job["attempt_results"].push("Pass"); + } + } else { + // Never completed. + job["attempt_results"].push("Fail"); + } + } else { + // Not Ran. + job["rerun_urls"].push(null); + job["attempt_results"].push("Skip"); + } + } + // Get json with next attempt URL. + const json2 = await fetch_url(prev_url); + if(json2 === null){ + console.error("json2 empty"); + } + prev_url = json2.previous_attempt_url + } + } + return runs_with_job_data; +} + + +// Add rerun results to fails/skips. +function count_stats(result, job_stat){ + if (result === "Skip") { + job_stat["skips"] += 1; + } else if (result === "Fail"){ + job_stat["fails"] += 1; + } + return job_stat; +} + + // Calculate and return job stats across all runs function compute_job_stats(runs_with_job_data, required_jobs) { const job_stats = {}; for (const run of runs_with_job_data) { for (const job of run["jobs"]) { if (!(job["name"] in job_stats)) { - job_stats[job["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: [], // ordered list of URLs associated w/ each run - results: [], // an array of strings, e.g. 'Pass', 'Fail', ... - run_nums: [], // ordered list of github-assigned run numbers - }; + job_stats[job["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: [], // ordered list of URLs to each run + results: [], // an array of strings, e.g. 'Pass', 'Fail', ... + run_nums: [], // ordered list of github-assigned run numbers + reruns: [], // the total number of times the test was rerun + rerun_results: [], // an array of strings, e.g. 'Pass', for reruns + attempt_urls: [], // ordered list of URLs to each job in a specific run + }; } - const job_stat = job_stats[job["name"]]; + var job_stat = job_stats[job["name"]]; job_stat["runs"] += 1; job_stat["run_nums"].push(run["run_number"]); - job_stat["urls"].push(job["html_url"]); - if (job["conclusion"] !== "success") { - if (job["conclusion"] === "skipped") { + job_stat["required"] = required_jobs.includes(job["name"]); + job_stat["reruns"].push(job["reruns"]); + job_stat["rerun_results"].push(job["attempt_results"]); + job_stat["urls"].push(run["html_url"]); + + // Always add the URL from the latest attempt. + const jobURLs = [job["html_url"]]; + if(job["attempt_results"]){ + // Recompute the fails/skips for the job with the rerun results. + job["attempt_results"].forEach(result => { + job_stat = count_stats(result, job_stat); + }); + // Add the rerun URLs if they exist. + jobURLs.push(...job["rerun_urls"]); + } + job_stat["attempt_urls"].push(jobURLs); + + if (job["conclusion"] != "success") { + if (job["conclusion"] == "skipped") { job_stat["skips"] += 1; job_stat["results"].push("Skip"); } else { @@ -164,8 +302,7 @@ function compute_job_stats(runs_with_job_data, required_jobs) { } } else { job_stat["results"].push("Pass"); - } - job_stat["required"] = required_jobs.includes(job["name"]); + } } } return job_stats; @@ -186,8 +323,11 @@ async function main() { for (const run of workflow_runs["workflow_runs"]) { promises_buf.push(get_job_data(run)); } - let runs_with_job_data = await Promise.all(promises_buf); + var runs_with_job_data = await Promise.all(promises_buf); + // Get the attempt_results for each job. + runs_with_job_data = await get_atttempt_results(runs_with_job_data) + // Transform the raw details of each run and its jobs' results into a // an array of just the jobs and their overall results (e.g. pass or fail, // and the URLs associated with them). diff --git a/scripts/fetch-ci-pr-data.js b/scripts/fetch-ci-pr-data.js index 8130c58..a5e9968 100644 --- a/scripts/fetch-ci-pr-data.js +++ b/scripts/fetch-ci-pr-data.js @@ -62,6 +62,11 @@ async function fetch_url(url) { return await json; } +// 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"]; +} + // 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) { @@ -76,7 +81,8 @@ function get_check_data(pr) { }); if (!response.ok) { - throw new Error(`Failed to fetch check data: ${response.status}`); + console.error(`Failed to fetch check data: ${response.status}: ` + + `${response.statusText}`); } const json = await response.json(); @@ -92,7 +98,8 @@ function get_check_data(pr) { for (const check of checks_request["check_runs"]) { prs_with_check_data["checks"].push({ name: check["name"], - conclusion: check["conclusion"] + conclusion: check["conclusion"], + attempt_urls: check["html_url"], // URL to an attempt }); } if (p * results_per_request >= checks_request["total_count"]) { @@ -112,54 +119,84 @@ function get_check_data(pr) { 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 = {}; + const check_stats = {}; for (const pr of prs_with_check_data) { + const check_reruns = {}; + const check_rerun_results = {}; + const check__urls = {}; + 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"]] = { + 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: [], // ordered list of URLs to each PR + 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 + reruns: [], // the total number of times the test was rerun + rerun_results: [], // an array of strings, e.g. 'Pass', 'Fail', for reruns + attempt_urls: [], // ordered list of URLs to each job in a specific PR + }; + } + const check_stat = check_stats[check["name"]]; + check_stat["required"] = required_jobs.includes(check["name"]); + + // If run number is already found, it's a rerun + if (check_stat["run_nums"].includes(pr["number"])) { + // Increment rerun count for the job. + check_reruns[check["name"]] += 1; + if (check["conclusion"] != "success") { + if (check["conclusion"] == "skipped") { + check_stat["skips"] += 1; + check_rerun_results[check["name"]].push("Skip"); + } else { + // failed or cancelled + check_stat["fails"] += 1; + check_rerun_results[check["name"]].push("Fail"); + } + } else { + check_rerun_results[check["name"]].push("Pass"); } - 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"]); + }else{ + // Initilize structures if not initialized before. + check_reruns[check["name"]] ??= 0; + check_rerun_results[check["name"]] ??= []; + check__urls[check["name"]] ??= []; + + check_stat["run_nums"].push(pr["number"]); + check_stat["runs"] += 1; + check_stat["urls"].push(pr["html_url"] + "/checks") + + 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__urls[check["name"]].push(check["attempt_urls"]); + } + for (const check in check_reruns) { + if (check_stats[check]) { + check_stats[check]["reruns"].push(check_reruns[check]); + check_stats[check]["rerun_results"].push(check_rerun_results[check]); + check_stats[check]["attempt_urls"].push(check__urls[check]); + } } } return check_stats; From e8f2314c00d76e67f98a84c7061cff3724f58ddc Mon Sep 17 00:00:00 2001 From: Anna Finn Date: Fri, 6 Dec 2024 11:36:19 -0500 Subject: [PATCH 3/4] dashboard: Add single PR view Added a separate view to display all tests for a given PR. Added the display to the URL, rowExpansionTemplate is unchanged. Fixes: #12 Signed-off-by: Anna Finn --- pages/index.js | 159 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 139 insertions(+), 20 deletions(-) diff --git a/pages/index.js b/pages/index.js index 03730ce..274abac 100644 --- a/pages/index.js +++ b/pages/index.js @@ -5,17 +5,20 @@ import Head from "next/head"; import { weatherTemplate, getWeatherIndex } from "../components/weatherTemplate"; import { OverlayPanel } from 'primereact/overlaypanel'; import MaintainerMapping from "../maintainers.yml"; +import { basePath } from "../next.config.js"; export default function Home() { const [loading, setLoading] = useState(true); const [checks, setChecks] = useState([]); const [jobs, setJobs] = useState([]); - const [rowsPR, setRowsPR] = useState([]); - const [rowsNightly, setRowsNightly] = useState([]); + const [rowsSingle, setRowsSingle] = useState([]); + const [rowsPR, setRowsPR] = useState([]); + const [rowsNightly, setRowsNightly] = useState([]); const [expandedRows, setExpandedRows] = useState([]); const [requiredFilter, setRequiredFilter] = useState(false); - const [display, setDisplay] = useState("nightly"); + const [display, setDisplay] = useState("nightly"); + const [selectedPR, setSelectedPR] = useState(""); useEffect(() => { const fetchData = async () => { @@ -53,6 +56,19 @@ export default function Home() { fetchData(); }, []); + // Set the display based on the URL. + useEffect(() => { + const initialDisplay = new URLSearchParams(window.location.search).get("display"); + if (initialDisplay) { + if(initialDisplay === "prsingle"){ + const initialPR = new URLSearchParams(window.location.search).get("pr"); + if(initialPR){ + setSelectedPR(initialPR); + } + } + setDisplay(initialDisplay); + } + }, []); // Filter based on required tag. const filterRequired = (filteredJobs) => { @@ -103,12 +119,47 @@ export default function Home() { setLoading(false); }, [checks, requiredFilter]); + // Filter and set the rows for Single PR view. + useEffect(() => { + setLoading(true); + + let filteredData = filterRequired(checks); + + filteredData = filteredData.map((check) => { + // Only if the check include the run number, add it to the data. + const index = check.run_nums.indexOf(Number(selectedPR)); + return index !== -1 + ? { + name: check.name, + required: check.required, + result: check.results[index], + runs: check.reruns[index] + 1, + } + : null; + }).filter(Boolean); + + setRowsSingle(filteredData); + setLoading(false); + }, [checks, selectedPR, requiredFilter]); + // Close all rows on view switch. // Needed because if view is switched, breaks expanded row toggling. useEffect(() => { setExpandedRows([]) }, [display]); + // Update the URL on display change + const updateUrl = (view, pr) => { + const path = new URLSearchParams(); + path.append("display", view); + // Add PR number Single PR view and a PR is provided + if (view === "prsingle" && pr) { + path.append("pr", pr); + } + // Update the URL without reloading + window.history.pushState({}, '', `${basePath}/?${path.toString()}`); + }; + const toggleRow = (rowData) => { const isRowExpanded = expandedRows.includes(rowData); @@ -432,6 +483,46 @@ export default function Home() { ); + // Make a list of all unique run numbers in the check data. + const runNumOptions = [...new Set(checks.flatMap(check => check.run_nums))].sort((a, b) => b - a); + + // Render table for prsingle view + const renderSingleViewTable = () => ( + setExpandedRows(e.data)} + loading={loading} + emptyMessage={selectedPR.length == 0 ? "Select a Pull Request above." : "No results found."} + > + + + + + + + ); + return (
@@ -457,21 +548,49 @@ export default function Home() {
- - -
+ + + + {display === "prsingle" && ( +
+ +
+ )} +
@@ -483,9 +602,9 @@ export default function Home() { Required Jobs Only
- Total Rows: {display === "prchecks" ? rowsPR.length : rowsNightly.length} + Total Rows: {display === "prsingle" ? rowsSingle.length : display === "prchecks" ? rowsPR.length : rowsNightly.length}
-
{display === "prchecks" ? renderPRTable() : renderNightlyTable()}
+
{display === "prsingle" ? renderSingleViewTable() : display === "prchecks" ? renderPRTable() : renderNightlyTable()}
); From 8e9b709b33916fa4aae1d207b21350b1d0432a4d Mon Sep 17 00:00:00 2001 From: Anna Finn Date: Fri, 6 Dec 2024 12:34:00 -0500 Subject: [PATCH 4/4] dashboard: Add search box and link to URL Adds an input form for searching job names. Searches are appended to the URL. Fixes #4 Signed-off-by: Anna Finn --- components/searchForm.js | 14 +++++++++ pages/index.js | 64 +++++++++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 components/searchForm.js diff --git a/components/searchForm.js b/components/searchForm.js new file mode 100644 index 0000000..ecf58bf --- /dev/null +++ b/components/searchForm.js @@ -0,0 +1,14 @@ +export const SearchForm = ({ handleSearch }) => { + return ( +
+
+
handleSearch(e)}> +
+ + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/pages/index.js b/pages/index.js index 274abac..fa8985d 100644 --- a/pages/index.js +++ b/pages/index.js @@ -6,6 +6,7 @@ import { weatherTemplate, getWeatherIndex } from "../components/weatherTemplate" import { OverlayPanel } from 'primereact/overlaypanel'; import MaintainerMapping from "../maintainers.yml"; import { basePath } from "../next.config.js"; +import { SearchForm } from "../components/searchForm"; export default function Home() { @@ -60,9 +61,9 @@ export default function Home() { useEffect(() => { const initialDisplay = new URLSearchParams(window.location.search).get("display"); if (initialDisplay) { - if(initialDisplay === "prsingle"){ + if (initialDisplay === "prsingle"){ const initialPR = new URLSearchParams(window.location.search).get("pr"); - if(initialPR){ + if (initialPR){ setSelectedPR(initialPR); } } @@ -70,18 +71,25 @@ export default function Home() { } }, []); - // Filter based on required tag. - const filterRequired = (filteredJobs) => { + // Filter the checks/jobs. + const applyFilters = (filteredJobs) => { + // Filter based on the required tag. if (requiredFilter){ filteredJobs = filteredJobs.filter((job) => job.required); } + // Filter based on the URL. + const val = new URLSearchParams(window.location.search).get("value"); + if (val){ + filteredJobs= filteredJobs.filter((job) => job.name.includes(decodeURIComponent(val))); + } return filteredJobs; }; // Filter and set the rows for Nightly view. useEffect(() => { setLoading(true); - let filteredJobs = filterRequired(jobs); + let filteredJobs = applyFilters(jobs); + //Set the rows for the table. setRowsNightly( filteredJobs.map((job) => ({ @@ -101,7 +109,7 @@ export default function Home() { // Filter and set the rows for PR Checks view. useEffect(() => { setLoading(true); - let filteredChecks = filterRequired(checks) + let filteredChecks = applyFilters(checks) //Set the rows for the table. setRowsPR( @@ -122,8 +130,7 @@ export default function Home() { // Filter and set the rows for Single PR view. useEffect(() => { setLoading(true); - - let filteredData = filterRequired(checks); + let filteredData = applyFilters(checks); filteredData = filteredData.map((check) => { // Only if the check include the run number, add it to the data. @@ -156,10 +163,35 @@ export default function Home() { if (view === "prsingle" && pr) { path.append("pr", pr); } + if(window.location.href.includes("value")){ + const urlParams = new URLSearchParams(window.location.search); + path.append("value", urlParams.get("value")); + } + // Update the URL without reloading window.history.pushState({}, '', `${basePath}/?${path.toString()}`); }; + // Apply search terms to the URL and reload the page. + const handleSearch= (e) => { + // Prevent the default behavior so that we can keep search terms. + e.preventDefault(); + // Trim value here if desired (not trimmed now) + const value = e.target.value.value; + if (value) { + // Add the display type to the URL. + const path = new URLSearchParams(); + path.append("display", display); + if(display === "prsingle" && selectedPR){ + path.append("pr", selectedPR); + } + + // Add the search term from the form and redirect. + path.append("value", value); + window.location.assign(`${basePath}/?${path.toString()}`); + } + }; + const toggleRow = (rowData) => { const isRowExpanded = expandedRows.includes(rowData); @@ -418,8 +450,8 @@ export default function Home() { onRowToggle={(e) => setExpandedRows(e.data)} loading={loading} emptyMessage="No results found." - sortField="fails" - sortOrder={-1} + sortField="fails" + sortOrder={-1} > - -
- + +
+ + +
Total Rows: {display === "prsingle" ? rowsSingle.length : display === "prchecks" ? rowsPR.length : rowsNightly.length}