From 118061ae4370f61b1fe9f5f26b0f376da17e1e78 Mon Sep 17 00:00:00 2001
From: Anna Finn <afinn12@bu.edu>
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 <afinn12@bu.edu>
---
 .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 "<gha@runner>"
+          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 (
       <div key={`${job.name}-runs`} className="p-3 bg-gray-100">
         {/* Display last 10 runs */}
@@ -149,7 +187,7 @@ export default function Home() {
                   : "⚠️";
               return (
                 <span key={`${job.name}-runs-${run.run_num}`}>
-                  <a href={run.url}>
+                  <a href={run.url} target="_blank" rel="noopener noreferrer">
                     {emoji} {run.run_num}
                   </a>
                   &nbsp;&nbsp;&nbsp;&nbsp;
@@ -251,9 +289,10 @@ export default function Home() {
     );
   };
 
-  const renderTable = () => (
+  // Render table for nightly view.
+  const renderNightlyTable = () => (
     <DataTable
-      value={rows}
+      value={rowsNightly}
       expandedRows={expandedRows}
       stripedRows
       rowExpansionTemplate={rowExpansionTemplate}
@@ -263,27 +302,63 @@ export default function Home() {
       sortField="fails"    
       sortOrder={-1}       
     >
-      <Column expander style={{ width: "5rem" }} />
+      <Column expander/>
       <Column
         field="name"
         header="Name"
         body={nameTemplate}
-        filter
+        className="select-text"
         sortable
-        maxConstraints={4}
-        filterHeader="Filter by Name"
-        filterPlaceholder="Search..."
       />
-      <Column field="required" header="Required" sortable />
-      <Column field="runs" header="Runs" sortable />
-      <Column field="fails" header="Fails" sortable />
-      <Column field="skips" header="Skips" sortable />
+      <Column field = "required"      header = "Required" sortable/>
+      <Column 
+        field = "runs"
+        header = "Runs"
+        className="whitespace-nowrap px-2"
+        sortable />
+      <Column field = "fails"         header = "Fails"   sortable/>
+      <Column field = "skips"         header = "Skips"   sortable/>
+      <Column 
+        field = "weather"
+        header = "Weather"
+        body = {weatherTemplate} 
+        sortable />
+    </DataTable>
+  );
+
+  const renderPRTable = () => (
+    <DataTable
+      value={rowsPR}
+      expandedRows={expandedRows}
+      stripedRows
+      rowExpansionTemplate={rowExpansionTemplate}
+      onRowToggle={(e) => setExpandedRows(e.data)}
+      loading={loading}
+      emptyMessage="No results found."
+      sortField="fails"
+      sortOrder={-1}
+    >
+      <Column expander/>
       <Column
-        field="weather"
-        header="Weather"
-        body={weatherTemplate}
+        field="name"
+        header="Name"
+        body={nameTemplate}
+        className="select-text"
         sortable
       />
+      <Column field = "required"      header = "Required" sortable/>
+      <Column 
+        field = "runs"   
+        header = "Runs"
+        className="whitespace-nowrap px-2"
+        sortable />
+      <Column field = "fails"         header = "Fails"   sortable/>
+      <Column field = "skips"         header = "Skips"   sortable/>
+      <Column 
+        field = "weather"  
+        header = "Weather"  
+        body = {weatherTemplate} 
+        sortable />
     </DataTable>
   );
 
@@ -299,30 +374,49 @@ export default function Home() {
         }
       >
         <a
-          href={
-            "https://github.com/kata-containers/kata-containers/" +
-            "actions/workflows/ci-nightly.yaml"
-          }
-          target="_blank"
-          rel="noopener noreferrer"
-        >
-          Kata CI Dashboard
-        </a>
+            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
+          </a>
       </h1>
+      <div className="flex flex-wrap mt-2 p-4 md:text-base text-xs">
+        <div className="space-x-2 pb-2 pr-3 mx-auto flex">
+          <button 
+            className={tabClass(display === "nightly")}
+            onClick={() => {
+              setDisplay("nightly");
+            }}>
+            Nightly Jobs
+          </button>
+          <button 
+            className={tabClass(display === "prchecks")}
+            onClick={() => {
+              setDisplay("prchecks");
+            }}>
+            PR Checks
+          </button>
+          </div>
+      </div>
 
-      <main
-        className={
-          "m-0 h-full p-4 overflow-x-hidden overflow-y-auto bg-surface-ground font-normal text-text-color antialiased select-text"
-        }
-      >
+      
+      <div className={"m-0 h-full px-4 overflow-x-hidden overflow-y-auto \
+                        bg-surface-ground antialiased select-text"}>
         <button 
-              className={buttonClass(requiredFilter)} 
-              onClick={() => setRequiredFilter(!requiredFilter)}>
-              Required Jobs Only
+          className={buttonClass(requiredFilter)} 
+          onClick={() => setRequiredFilter(!requiredFilter)}>
+          Required Jobs Only
         </button>
-        <div className="mt-4 text-lg">Total Rows: {rows.length}</div>
-        <div>{renderTable()}</div>
-      </main>
+        <div className="mt-4 text-center md:text-lg text-base">
+          Total Rows: {display === "prchecks" ? rowsPR.length : rowsNightly.length}
+        </div>
+        <div>{display === "prchecks" ? renderPRTable() : renderNightlyTable()}</div>
+      </div>
     </div>
   );
 }
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 <GITHUB_PAT_OR_OTHER_VALID_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();