From 8b36de862669e72788188cb91ad599fe82c3c4c3 Mon Sep 17 00:00:00 2001
From: maddiebaka <madeline@cray.lgbt>
Date: Mon, 10 Jun 2024 13:53:40 -0400
Subject: [PATCH] Implementation of gitlab overview widget as a hugo shortcode

---
 layouts/partials/head.html                    |   2 +
 layouts/shortcodes/gitlab-overview.html       |  64 ++++
 static/css/gitlab-widgets/gitlab-widgets.css  | 110 +++++++
 .../gitlab-widgets/gitlab-widgets.js          | 289 ++++++++++++++++++
 4 files changed, 465 insertions(+)
 create mode 100644 layouts/shortcodes/gitlab-overview.html
 create mode 100644 static/css/gitlab-widgets/gitlab-widgets.css
 create mode 100644 static/javascripts/gitlab-widgets/gitlab-widgets.js

diff --git a/layouts/partials/head.html b/layouts/partials/head.html
index 7eb45d134..de8d4e583 100644
--- a/layouts/partials/head.html
+++ b/layouts/partials/head.html
@@ -126,6 +126,7 @@
 <link rel="stylesheet" href="/css/glyphicons.css">
 <link rel="stylesheet" href="/css/pseudocode.css">
 <link rel="stylesheet" href="/javascripts/cal-heatmap/cal-heatmap.css">
+<link rel="stylesheet" href="/css/gitlab-widgets/gitlab-widgets.css">
 <link rel="stylesheet" href="/css/main.css">
 {{ if eq .Params.w3c "working_draft" -}}
 <link rel="stylesheet" href="/css/W3C-WD.css">
@@ -141,6 +142,7 @@
 <script type="text/javascript" src="/javascripts/pseudocode.js"></script>
 <script type="text/javascript" src="/javascripts/jquery.js"></script>
 <script type="text/javascript" src="/javascripts/head.js"></script>
+<script type="text/javascript" src="/javascripts/gitlab-widgets/gitlab-widgets.js"></script>
 <script type="text/javascript" >
 document.addEventListener("DOMContentLoaded", function() {
     var blocks = document.getElementsByClassName("pseudocode");
diff --git a/layouts/shortcodes/gitlab-overview.html b/layouts/shortcodes/gitlab-overview.html
new file mode 100644
index 000000000..8ad679971
--- /dev/null
+++ b/layouts/shortcodes/gitlab-overview.html
@@ -0,0 +1,64 @@
+<div class="gitlab-overview-container">
+  <!-- Adds widget arguments to DOM -->
+
+  {{ range $key, $value := $.Params }}
+    <input type="hidden" id={{ $key }} value={{ $value }}></input>
+  {{ end }}
+
+  {{ if isset .Params `displayCount` }}
+    <input type="hidden" class="displayCount" value={{ .Get "displayCount" }}></input>
+  {{ end }}
+
+  {{ if isset .Params `stats` }}
+    <input type="hidden" id="stats" value={{ .Get "stats" }}></input>
+  {{ end }}
+
+  <!-- End of widget arguments -->
+
+  <h1 class="project-name"></h2>
+  <!--
+  <h2 class="project-header">Overview</h2>
+  -->
+
+  <div class="gitlab-overview" class="gitlab-overview">
+    
+    <div id="overview">
+      <span class="label">Releases:</span>
+      <span class="gitlab-overview-release-list"></span>
+    </div>
+
+    <table class="gitlab-progress-list">
+      <th>Release</th>
+      <th>Completion</th>
+      <th>Due Date</th>
+      <th class="percentage-column">Percentage</th>
+    </table>
+
+    <div class="gitlab-overview-footer">
+      <div class="footer-element">
+        <span class="label">Commits:</span>
+        <span id="commits"></span>
+      </div>
+      <div class="footer-element">
+        <span class="label">Tickets:</span>
+        <span id="issues">
+          <span id="issue-count"></span>
+          <span>/</span>
+          <span id="issue-total"></span>
+        </span>
+      </div>
+      <div class="footer-element">
+        <span class="label">MR:</span>
+        <span id="merge-requests">
+          <span id="mr-count"></span>
+          <span>/</span>
+          <span id="mr-total"></span>
+        </span>
+      </div>
+      <div class="footer-element">
+        <span class="label">Stars:</span>
+        <span id="stars"></span>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/static/css/gitlab-widgets/gitlab-widgets.css b/static/css/gitlab-widgets/gitlab-widgets.css
new file mode 100644
index 000000000..d3e748ca5
--- /dev/null
+++ b/static/css/gitlab-widgets/gitlab-widgets.css
@@ -0,0 +1,110 @@
+.project-name {
+  font-size: 36px !important;
+  text-align: center;
+  background-color: #gray;
+}
+
+.project-header {
+  font-size: 32px !important;
+  text-align: center;
+  margin-bottom: 0;
+}
+
+.gitlab-overview #overview {
+  width: 50%;
+  padding-top: 1em;
+  padding-bottom: 1em;
+  margin: auto;
+  display: flex;
+}
+
+#gitlab-overview .label {
+  font-weight: bold;
+}
+
+#overview-release-list span {
+  margin: auto;
+}
+
+.gitlab-overview-release-list {
+  width: 80%;
+  display: flex;
+}
+
+.gitlab-overview-release-list a {
+  width: 100%;
+  text-align: center;
+}
+
+
+.gitlab-progress-list {
+  width: 90%;
+  margin: auto;
+}
+
+.gitlab-progress-list .gitlabOverviewEntry {
+  width: auto;
+  margin: 0 0;
+}
+
+.gitlab-progress-list tr td {
+  white-space: nowrap;
+}
+
+.gitlab-progress-list .percentage-column {
+  width: 100%;
+}
+
+.gitlab-progress-list .gitlabOverviewEntry span {
+  width: 15em;
+}
+
+.gitlab-progress-list .gitlabOverviewEntry progress {
+  width: 100%;
+  margin: 0 0;
+}
+
+.gitlab-progress-list .gitlabOverviewEntry td {
+  padding: 1em;
+}
+
+.gitlab-overview-footer {
+  display: flex;
+  justify-content: space-between;
+  margin: auto;
+  width: 90%;
+  padding-top: 1em;
+}
+
+.gitlab-overview-footer .footer-element {
+  padding-left: 2em;
+  padding-right: 2em;
+}
+
+.gitlab-overview-footer .footer-element span {
+  padding: 1em;
+}
+
+.gitlab-overview-footer #commits {
+  text-align: center;
+}
+
+.gitlab-overview-footer #issues {
+  text-align: center;
+}
+
+.gitlab-overview-footer #merge-requests {
+  text-align: center;
+}
+
+.gitlab-overview-footer #stars {
+  text-align: center;
+}
+
+.green {
+  color: green;
+}
+
+.red {
+  color: red;
+}
diff --git a/static/javascripts/gitlab-widgets/gitlab-widgets.js b/static/javascripts/gitlab-widgets/gitlab-widgets.js
new file mode 100644
index 000000000..a1ef8c512
--- /dev/null
+++ b/static/javascripts/gitlab-widgets/gitlab-widgets.js
@@ -0,0 +1,289 @@
+/*
+ *
+ * Examples of use of the gitlab overview widget 
+ * {{< gitlab-overview servers="158:https://git.cleverthis.com/,1876:https://git.qoto.org/" displayCount=2 stats=true v9.2.6="http://example.com" >}}
+ *
+ * {{< gitlab-overview servers="158:https://git.cleverthis.com/" displayCount=1 stats=true v9.2.6="http://example.com" >}}
+ *
+ * {{< gitlab-overview servers="166:https://git.cleverthis.com/" v1.0="http://example2.com/" >}}
+ */
+
+
+const GITLAB_CLEVERTHIS_TOKEN = "TOKEN_HERE"
+const GITLAB_QOTO_TOKEN = "TOKEN2_HERE"
+
+const PUBLIC_PROJECT_ENDPOINT = "/projects/"
+
+const ENDPOINTS = {
+    PROJECT: "/api/v4/projects/",
+
+    P: {
+        MILESTONES: "/milestones",
+        ISSUES: "/issues",
+        MERGE_REQUESTS: "/merge_requests",
+        COMMITS: "/repository/commits",
+    },
+    ISSUES: "/api/v4/issues",
+}
+
+const progressBarTitle = '<span class="releaseTitle"></span>';
+const progressBar = '<progress class="releaseProgressBar"></progress>';
+
+const issuesSearchParams = { scope: "all" };
+const issuesBugSearchParams = { scope: "all", labels: "bug" };
+const issuesClosedSearchParams = { scope: "all", state: "closed" };
+
+function initOverviewWidgets() {
+    for(var widget of $(".gitlab-overview-container")) {
+        initializeWidget(widget)
+    }
+}
+
+function initializeWidget(widget) {
+    var overviewWidget = $(widget)
+    var projectName = overviewWidget.find(".project-name")
+    var releaseList = overviewWidget.find(".gitlab-overview-release-list")
+    var progressList = overviewWidget.find(".gitlab-progress-list")
+    var projectId = getGitlabServerProjectID(overviewWidget, 0)
+    var displayCount = getDisplayCount(overviewWidget)
+
+    apiCall(buildProjectURL(overviewWidget, 0), (data) => {
+        projectName.text(data['path_with_namespace'])
+    });
+
+    apiCall(buildProjectURL(overviewWidget, 0) + ENDPOINTS.P.MILESTONES, (data) => {
+
+        var displayedData = null
+
+        if(displayCount != null) {
+            displayedData = data.slice(0, displayCount)
+        } else {
+            displayedData = data
+        }
+
+        displayedData.forEach(release => {
+            let releaseAnchor = generateMilestoneAnchor(release)
+            releaseAnchor.appendTo(releaseList)
+        })
+
+        styleReleaseName(overviewWidget, releaseList.children())
+
+        populateProgressList(overviewWidget, progressList, data)
+
+        let displayStats = $('#stats')
+        if(displayStats.length > 0 && displayStats[0].value == "true") {
+            populateFooter(overviewWidget)
+        } else {
+            removeFooter()
+        }
+    })
+}
+
+async function populateFooter(widget) {
+    var starCount = 0
+    var issueCount = 0
+    var issueTotal = 0
+    var mergeRequestCount = 0
+    var mergeRequestTotal = 0
+    var commitCount = 0
+
+    for(let i = 0; i < getGitlabServerCount(widget); i++) {
+        // Project info
+        await apiCall(buildProjectURL(widget, i), (data) => {
+            starCount += data['star_count']
+            issueCount += data['open_issues_count']
+        })
+
+        // Issue count
+        await apiCall(buildProjectURL(widget, i) + ENDPOINTS.P.ISSUES, (data) => {
+            issueTotal += data.length
+        })
+
+        // Merge request count
+        let openMRSearchParams = new URLSearchParams({ state: "opened" })
+        await apiCall(buildProjectURL(widget, i) + ENDPOINTS.P.MERGE_REQUESTS + "?" + openMRSearchParams.toString(), (data) => {
+            mergeRequestCount += data.length
+        })
+
+        // Merge request count, part 2
+        let allMRSearchParams = new URLSearchParams({ state: "all" })
+        await apiCall(buildProjectURL(widget, i) + ENDPOINTS.P.MERGE_REQUESTS + "?" + allMRSearchParams.toString(), (data) => {
+            mergeRequestTotal += data.length
+        })
+
+        // Count the project commits
+        let projectStatsSearchParams = new URLSearchParams({ statistics: "true" })
+        await apiCall(buildProjectURL(widget, i) + "?" + projectStatsSearchParams.toString(), (data) => {
+            // We don't sum these values because we don't want to double-count commits
+            // We want the higher count, though
+            let singleProjectCommitCount = data['statistics']['commit_count']
+            if(commitCount < singleProjectCommitCount) {
+                commitCount = singleProjectCommitCount
+            }
+        })
+    }
+
+    widget.find(".gitlab-overview-footer #stars").text(starCount)
+    widget.find(".gitlab-overview-footer #issue-count").text(issueCount)
+    widget.find(".gitlab-overview-footer #issue-total").text(issueTotal)
+    widget.find(".gitlab-overview-footer #mr-count").text(mergeRequestCount)
+    widget.find(".gitlab-overview-footer #mr-total").text(mergeRequestTotal)
+    widget.find(".gitlab-overview-footer #commits").text(commitCount)
+}
+
+function removeFooter() {
+    $('.gitlab-overview-footer').remove()
+}
+
+function styleReleaseName(widget, releases) {
+    Array.from(releases).forEach(release => {
+        var allBugIssuesParams = new URLSearchParams({ ...issuesBugSearchParams, milestone: release.innerText })
+        apiCall(getGitlabServerURL(widget, 0) + ENDPOINTS.ISSUES + "?" + allBugIssuesParams.toString(), (data) => {
+            if(data.length == 0) {
+                $(release).addClass("green")
+            } else {
+                $(release).addClass("red")
+            }
+        });
+    })
+}
+
+async function populateProgressList(widget, progressList, releases) {
+    var displayCount = getDisplayCount(widget)
+
+    var displayedReleases = null
+
+    if(displayCount != null) {
+        displayedReleases = releases.slice(0, displayCount)
+    } else {
+        displayedReleases = releases
+    }
+
+
+    for(const release of displayedReleases) {
+        let releaseAnchor = generateMilestoneAnchor(release)
+        let releaseNameDatum = $("<td></td>").append(releaseAnchor)
+
+        var entry = $('<tr class="gitlabOverviewEntry"></div>').appendTo(progressList);
+
+        releaseNameDatum.appendTo(entry)
+
+        var percentageComplete = $('<td></td>').appendTo(entry)
+        var dueDate = $('<td></td>').appendTo(entry)
+
+        var entryBar = entry.append('<td><progress></progress></td>');
+
+        var allIssuesParams = new URLSearchParams({ ...issuesSearchParams, milestone: release.title })
+        var closedIssuesParams = new URLSearchParams({ ...issuesClosedSearchParams, milestone: release.title })
+
+        var allIssuesCount = 0
+        var closedIssuesCount = 0
+
+        for(let i = 0; i < getGitlabServerCount(widget); i++) {
+
+            // Get issues of milestone_title, open and closed
+            await apiCall(getGitlabServerURL(widget, i) + ENDPOINTS.ISSUES + "?" + allIssuesParams.toString(), (allIssuesData) => {
+                allIssuesCount += allIssuesData.length
+            });
+
+            await apiCall(getGitlabServerURL(widget, i) + ENDPOINTS.ISSUES + "?" + closedIssuesParams.toString(), (closedIssuesData) => {
+                closedIssuesCount += closedIssuesData.length
+            });
+        }
+
+
+        // Initialize progress bar
+        if(allIssuesCount == 0) {
+            percentageComplete.text("0%")
+        } else {
+            let progressText = ((closedIssuesCount / allIssuesCount)*100).toFixed(1) + "%"
+            percentageComplete.text(progressText)
+        }
+
+        entry.find("progress").attr('max', allIssuesCount);
+        entry.find("progress").val(closedIssuesCount);
+
+        let progress = entryBar.find("progress")
+        let progressMax = progress[0].max
+        let progressCount = progress.val()
+
+        let progressText = (progressCount / progressMax)*100 + "%"
+
+        if(release['due_date'] == null) {
+            dueDate.text("No due date") 
+        } else { 
+            dueDate.text(release['due_date'])
+        }
+    }
+}
+
+/*
+ * Helper functions
+ *
+ */
+
+function getDisplayCount(widget) {
+    var overviewWidget = $(widget)
+    var displayCount = null
+
+    if(overviewWidget.find('.displayCount').length > 0) {
+        displayCount = overviewWidget.find('.displayCount')[0].value
+    }
+
+    return displayCount
+}
+
+function getGitlabServerCount(widget) {
+    return widget.find("#servers").val().split(",").length
+}
+
+function getGitlabServerURLs() {
+    return $("#servers")
+}
+
+function getGitlabServerURL(widget, index) {
+    var overviewWidget = $(widget)
+    return overviewWidget.find("#servers").val().split(",")[index].split(/:(.*)/s)[1]
+}
+
+function getGitlabServerProjectID(widget, index) {
+    var overviewWidget = $(widget)
+    return overviewWidget.find("#servers").val().split(",")[index].split(/:(.*)/s)[0]
+}
+
+function generateMilestoneAnchor(release) {
+    let override = $("#" + $.escapeSelector(release.title))
+    if (override.length > 0) {
+        return $("<a href='" + override.val() + "'>" + release.title + "</a>")
+    } else {
+        return $("<a href='" + release['web_url'] + "'>" + release.title + "</a>")
+    }
+}
+
+function buildProjectURL(widget, index) {
+    const endpoint = ENDPOINTS.PROJECT + getGitlabServerProjectID(widget, index)
+    var url = new URL(endpoint, getGitlabServerURL(widget, index))
+    return url
+}
+
+function apiCall(endpoint, callback) {
+    var token = null
+    if(endpoint.toString().includes("cleverthis.com")) {
+        token = GITLAB_CLEVERTHIS_TOKEN
+    } else if(endpoint.toString().includes("qoto.org")) {
+        token = GITLAB_QOTO_TOKEN
+    } else {
+        return
+    }
+
+    return $.ajax({
+        url: endpoint,
+        dataType: 'json',
+        headers: { "PRIVATE-TOKEN": token },
+    }).done(callback)
+}
+
+// Only initialize widgets when page is done loading
+$(document).ready(() => {
+    initOverviewWidgets()
+})
-- 
GitLab