diff --git a/content/_index.md b/content/_index.md
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/layouts/partials/head.html b/layouts/partials/head.html
index 7eb45d134235361c75460834eb867a65fa1289b7..cf553cf13a227583ef473433c9bbfeeff997b783 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="module" 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 0000000000000000000000000000000000000000..837010ab82a467b1bc0a33a3d422178c16199b2b
--- /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"></h1>
+  <!--
+  <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/layouts/shortcodes/gitlab-single-version.html b/layouts/shortcodes/gitlab-single-version.html
new file mode 100644
index 0000000000000000000000000000000000000000..0d3cbc79d4d8d33748470e86998129629cb6b23e
--- /dev/null
+++ b/layouts/shortcodes/gitlab-single-version.html
@@ -0,0 +1,56 @@
+<div class="gitlab-single-version-container">
+  <!-- Adds widget arguments to DOM -->
+
+  {{ range $key, $value := $.Params }}
+    <input type="hidden" id={{ $key }} value={{ $value }}></input>
+  {{ end }}
+
+  {{ if isset .Params `stats` }}
+    <input type="hidden" id="stats" value={{ .Get "stats" }}></input>
+  {{ end }}
+
+  <!-- End of widget arguments -->
+
+  <h1 class="release-name">Test</h1>
+
+  <div class="gitlab-single-version-overview" class="gitlab-overview">
+
+    <table class="gitlab-milestone-progress">
+      <th>Release</th>
+      <th class="completion-tableheader">Completion</th>
+      <th class="due-date-tableheader">Due Date</th>
+      <th class="percentage-column">Percentage</th>
+      <tr class="gitlab-single-version-entry">
+        <td class="title"></td>
+        <td class="percentage-complete"></td>
+        <td class="due-date"></td>
+        <td class="progress-bar">
+          <progress></progress>
+        </td>
+      </tr>
+    </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>
+  </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 0000000000000000000000000000000000000000..6adcfa6a2677658d99d322a009b473f867257bc7
--- /dev/null
+++ b/static/css/gitlab-widgets/gitlab-widgets.css
@@ -0,0 +1,151 @@
+/* Gitlab overview widget css */
+
+.project-name {
+  font-size: 36px !important;
+  text-align: center;
+  background-color: #gray;
+}
+
+.release-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;
+}
+
+.footer-element .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 !important;
+}
+
+/* Gitlab single version css */
+.gitlab-single-version-overview {
+
+}
+
+.gitlab-milestone-progress {
+  width: 90%;
+  margin: auto;
+}
+
+.gitlab-milestone-progress .percentage-column {
+  width: 100%;
+}
+
+.gitlab-milestone-progress progress {
+  width: 100%;
+  margin: 0 0;
+}
+
+.gitlab-single-version-overview a {
+  width: 100%;
+  text-align: center;
+}
+
+.gitlab-single-version-entry {
+  width: auto;
+  margin: 0 0;
+}
diff --git a/static/javascripts/gitlab-widgets/gitlab-widgets.js b/static/javascripts/gitlab-widgets/gitlab-widgets.js
new file mode 100644
index 0000000000000000000000000000000000000000..2c4dcac1b3a9cc5299d11787b3b707aa8b6c5b9a
--- /dev/null
+++ b/static/javascripts/gitlab-widgets/gitlab-widgets.js
@@ -0,0 +1,402 @@
+/*
+ *
+ * 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/" >}}
+ */
+
+import * as gitlabWidgets from "./helpers.js"
+
+const progressBarTitle = '<span class="releaseTitle"></span>';
+const progressBar = '<progress class="releaseProgressBar"></progress>';
+
+const issuesSearchParams = { scope: "all" };
+const issuesBugSearchParams = { scope: "all", labels: "bug" };
+const issuesOpenSearchParams = { scope: "all", state: "opened" };
+const issuesClosedSearchParams = { scope: "all", state: "closed" };
+
+function initWidgets() {
+    for(var widget of $(".gitlab-overview-container")) {
+        initializeOverviewWidget(widget)
+    }
+    for(var widget of $(".gitlab-single-version-container")) {
+        initializeSingleVersionWidget(widget)
+    }
+}
+
+function initializeOverviewWidget(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 = gitlabWidgets.getGitlabServerProjectID(overviewWidget, 0)
+    var displayCount = gitlabWidgets.getDisplayCount(overviewWidget)
+
+    gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(overviewWidget, 0), (data) => {
+        projectName.text(data['path_with_namespace'])
+    });
+
+    gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(overviewWidget, 0) + gitlabWidgets.ENDPOINTS.P.MILESTONES, (data) => {
+
+        var displayedData = null
+
+        if(displayCount != null) {
+            displayedData = data.slice(0, displayCount)
+        } else {
+            displayedData = data
+        }
+
+        displayedData.forEach(release => {
+            let releaseAnchor = gitlabWidgets.generateMilestoneAnchor(widget, release)
+            releaseAnchor.appendTo(releaseList)
+        })
+
+        styleReleaseName(overviewWidget, releaseList.children())
+
+        populateProgressList(overviewWidget, progressList, data)
+
+        let displayStats = overviewWidget.find('#stats')
+        if(displayStats.length > 0 && displayStats[0].value == "true") {
+            populateFooter(overviewWidget)
+        } else {
+            removeFooter(overviewWidget)
+        }
+    })
+}
+
+async function initializeSingleVersionWidget(widget) {
+    var singleVersionWidget = $(widget)
+    var releaseName = singleVersionWidget.find(".release-name")
+    var percentageComplete = singleVersionWidget.find(".gitlab-single-version-entry .percentage-complete")
+
+    var releaseData = null
+    var releaseAnchor = null
+
+    var addBugsField = false
+
+    await gitlabWidgets.apiCall(gitlabWidgets.buildProjectMilestoneURL(widget, 0), (data) => {
+        releaseName.text(data['title'])
+        releaseAnchor = gitlabWidgets.generateMilestoneAnchor(widget, data)
+        let releaseNameDatum = $("<td></td>").append(releaseAnchor)
+
+        singleVersionWidget.find(".gitlab-single-version-entry .title").append(releaseAnchor)//text(data['title'])
+        singleVersionWidget.find(".gitlab-single-version-entry .due-date").text(data['due_date'])
+
+        releaseData = data
+    })
+
+    await gitlabWidgets.apiCall(gitlabWidgets.buildProjectMilestoneURL(widget, 0), (data) => {
+        if(data['state'] == "active") {
+            initializeSingleVersionInProgress(widget, data)
+        } else if(data['state'] == "closed") {
+            addBugsField = true
+            initializeSingleVersionReleased(widget, releaseData)
+        }
+    })
+
+    let displayStats = singleVersionWidget.find('#stats')
+    if(displayStats.length > 0 && displayStats[0].value == "true") {
+        populateSingleVersionFooter(widget, releaseData['title'], addBugsField)
+    } else {
+        removeFooter(singleVersionWidget)
+    }
+}
+
+async function initializeSingleVersionReleased(widget, data) {
+    var singleVersionWidget = $(widget)
+    var title = singleVersionWidget.find(".title")
+    // DOM manipulations to get rid of fields that make no sense to display
+    singleVersionWidget.find(".percentage-column").remove()
+    singleVersionWidget.find(".progress-bar").remove()
+    singleVersionWidget.find(".due-date-tableheader").text("Release Date")
+    singleVersionWidget.find(".percentage-complete").text("Released")
+    singleVersionWidget.find(".completion-tableheader").css("width", "100%")
+
+    var milestoneBugIssuesParams = new URLSearchParams({ ...issuesSearchParams, milestone: data['title'], labels: ["Type::Bug"] })
+
+    // Color title based on whether bug issues exist
+    await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, 0) + gitlabWidgets.ENDPOINTS.P.ISSUES + "?" + milestoneBugIssuesParams.toString(), (data) => {
+        if(data.length > 0) {
+            title.children("a").addClass("red")
+        } else {
+            title.children("a").addClass("green")
+        }
+    })
+
+
+}
+
+async function initializeSingleVersionInProgress(widget, data) {
+    var singleVersionWidget = $(widget)
+
+    // TODO: Calculate percentage complete
+    var allIssuesParams = new URLSearchParams({ ...issuesSearchParams, milestone: data['title'] })
+    var closedIssuesParams = new URLSearchParams({ ...issuesClosedSearchParams, milestone: data['title'] })
+
+    var percentageComplete = singleVersionWidget.find(".gitlab-single-version-entry .percentage-complete")
+
+    var allIssuesCount = 0
+    var closedIssuesCount = 0
+
+    for(let i = 0; i < gitlabWidgets.getGitlabServerCount(singleVersionWidget); i++) {
+        await gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, i) + gitlabWidgets.ENDPOINTS.ISSUES + "?" + allIssuesParams.toString(), (allIssuesData) => {
+            allIssuesCount += allIssuesData.length
+        })
+
+        await gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, i) + gitlabWidgets.ENDPOINTS.ISSUES + "?" + closedIssuesParams.toString(), (closedIssuesData) => {
+            closedIssuesCount += closedIssuesData.length
+        })
+    }
+
+    singleVersionWidget.find("progress").attr('max', allIssuesCount)
+    singleVersionWidget.find("progress").val(closedIssuesCount)
+
+    if(allIssuesCount == 0) {
+        percentageComplete.text("0%")
+    } else {
+        // TODO: Extract this to helper
+        let progressText = ((closedIssuesCount / allIssuesCount)*100).toFixed(1) + "%"
+        percentageComplete.text(progressText)
+    }
+
+    //populateSingleVersionFooter(widget, data['title'])
+}
+
+async function populateSingleVersionFooter(widget, milestoneTitle, addBugsField=false) {
+    //var milestoneId = $(widget).find("#milestone_id").val()
+
+    var commitCount = 0
+    var issueCount = 0
+    var issueTotal = 0
+    var mergeRequestCount = 0
+    var mergeRequestTotal = 0
+
+    var bugFooterElement = $('<span class="footer-element"></span>')
+
+    let issuesMilestoneSearchParams = new URLSearchParams({ ...issuesSearchParams, milestone: milestoneTitle })
+    let issuesOpenMilestoneSearchParams = new URLSearchParams({ ...issuesOpenSearchParams, milestone: milestoneTitle })
+
+    let openMRSearchParams = new URLSearchParams({ state: "opened", milestone: milestoneTitle })
+    let allMRSearchParams = new URLSearchParams({ state: "all", milestone: milestoneTitle })
+
+    for(let i = 0; i < gitlabWidgets.getGitlabServerCount(widget); i++) {
+        await gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, i) + gitlabWidgets.ENDPOINTS.ISSUES + "?" + issuesMilestoneSearchParams.toString(), (data) => {
+            issueTotal += data.length 
+        })
+
+        await gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, i) + gitlabWidgets.ENDPOINTS.ISSUES + "?" + issuesOpenMilestoneSearchParams.toString(), (data) => {
+            issueCount += data.length 
+        })
+
+        await gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, i) + gitlabWidgets.ENDPOINTS.MERGE_REQUESTS + "?" + openMRSearchParams.toString(), (data) => {
+            mergeRequestCount += data.length
+        })
+
+        await gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, i) + gitlabWidgets.ENDPOINTS.MERGE_REQUESTS + "?" + allMRSearchParams.toString(), (data) => {
+            mergeRequestTotal += data.length
+        })
+    }
+
+    $(widget).find("#issue-count").text(issueCount)
+    $(widget).find("#issue-total").text(issueTotal)
+    $(widget).find("#mr-count").text(mergeRequestCount)
+    $(widget).find("#mr-total").text(mergeRequestTotal)
+
+    if(addBugsField == true) {
+        $('<span class="label">Bugs:</span>').appendTo(bugFooterElement)
+        $('<span id="bugs"></span>').appendTo(bugFooterElement)
+        $(widget).find(".gitlab-overview-footer").prepend(bugFooterElement)
+    }
+
+    var milestoneBugIssuesParams = new URLSearchParams({ ...issuesSearchParams, milestone: milestoneTitle, labels: ["Type::Bug"] })
+
+    // Color title based on whether bug issues exist
+    await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, 0) + gitlabWidgets.ENDPOINTS.P.ISSUES + "?" + milestoneBugIssuesParams.toString(), (data) => {
+        getBugCountSinceRelease(widget, milestoneTitle).then((bugCount) => {
+            bugFooterElement.find("#bugs").text(bugCount)
+        })
+    })
+
+    let projectStatsSearchParams = new URLSearchParams({ statistics: "true" })
+    await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, 0) + "?" + projectStatsSearchParams.toString(), (data) => {
+        $(widget).find("#commits").text(data['statistics']['commit_count'])
+    })
+}
+
+async function getBugCountSinceRelease(widget, milestoneTitle) {
+    var milestones = new Array()
+    var indice = 0
+    var bugCount = 0
+
+    let serverCount = gitlabWidgets.getGitlabServerCount(widget)
+
+    await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, 0) + gitlabWidgets.ENDPOINTS.P.MILESTONES, (data) => {
+        //console.log(data)
+        for(var milestone of data) {
+            milestones.push(milestone['title'])
+        }
+    })
+
+    milestones.sort(gitlabWidgets.compareSemanticVersions)
+    indice = milestones.indexOf(milestoneTitle)
+    var subsequentMilestones = milestones.slice(indice, milestones.length)
+
+    for(var milestone of subsequentMilestones) {
+        let milestoneBugIssuesParams = new URLSearchParams({ ...issuesSearchParams, milestone: milestone, labels: ["Type::Bug"] })
+        for(let i = 0; i < serverCount; i++) {
+            await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, i) + gitlabWidgets.ENDPOINTS.P.ISSUES + "?" + milestoneBugIssuesParams.toString(), (data) => {
+                bugCount += data.length
+            })
+        }
+    }
+
+    return bugCount
+}
+
+/* Overview widget */
+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 < gitlabWidgets.getGitlabServerCount(widget); i++) {
+        // Project info
+        await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, i), (data) => {
+            starCount += data['star_count']
+            issueCount += data['open_issues_count']
+        })
+
+        // Issue count
+        await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, i) + gitlabWidgets.ENDPOINTS.P.ISSUES, (data) => {
+            issueTotal += data.length
+        })
+
+        // Merge request count
+        let openMRSearchParams = new URLSearchParams({ state: "opened" })
+        await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, i) + gitlabWidgets.ENDPOINTS.P.MERGE_REQUESTS + "?" + openMRSearchParams.toString(), (data) => {
+            mergeRequestCount += data.length
+        })
+
+        // Merge request count, part 2
+        let allMRSearchParams = new URLSearchParams({ state: "all" })
+        await gitlabWidgets.apiCall(gitlabWidgets.buildProjectURL(widget, i) + gitlabWidgets.ENDPOINTS.P.MERGE_REQUESTS + "?" + allMRSearchParams.toString(), (data) => {
+            mergeRequestTotal += data.length
+        })
+
+        // Count the project commits
+        let projectStatsSearchParams = new URLSearchParams({ statistics: "true" })
+        await gitlabWidgets.apiCall(gitlabWidgets.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(widget) {
+    $(widget).find('.gitlab-overview-footer').remove()
+}
+
+function styleReleaseName(widget, releases) {
+    Array.from(releases).forEach(release => {
+        var allBugIssuesParams = new URLSearchParams({ ...issuesBugSearchParams, milestone: release.innerText })
+        gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, 0) + gitlabWidgets.ENDPOINTS.ISSUES + "?" + allBugIssuesParams.toString(), (data) => {
+            if(data.length == 0) {
+                $(release).addClass("green")
+            } else {
+                $(release).addClass("red")
+            }
+        });
+    })
+}
+
+async function populateProgressList(widget, progressList, releases) {
+    var displayCount = gitlabWidgets.getDisplayCount(widget)
+
+    var displayedReleases = null
+
+    if(displayCount != null) {
+        displayedReleases = releases.slice(0, displayCount)
+    } else {
+        displayedReleases = releases
+    }
+
+
+    for(const release of displayedReleases) {
+        let releaseAnchor = gitlabWidgets.generateMilestoneAnchor(widget, 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 < gitlabWidgets.getGitlabServerCount(widget); i++) {
+
+            // Get issues of milestone_title, open and closed
+            await gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, i) + gitlabWidgets.ENDPOINTS.ISSUES + "?" + allIssuesParams.toString(), (allIssuesData) => {
+                allIssuesCount += allIssuesData.length
+            });
+
+            await gitlabWidgets.apiCall(gitlabWidgets.getGitlabServerURL(widget, i) + gitlabWidgets.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'])
+        }
+    }
+}
+
+
+// Only initialize widgets when page is done loading
+$(document).ready(() => {
+    initWidgets()
+})
diff --git a/static/javascripts/gitlab-widgets/helpers.js b/static/javascripts/gitlab-widgets/helpers.js
new file mode 100644
index 0000000000000000000000000000000000000000..6ea587950d73f4ee18f5f8ab1b52fb0486f8d6d9
--- /dev/null
+++ b/static/javascripts/gitlab-widgets/helpers.js
@@ -0,0 +1,113 @@
+export { getDisplayCount, getGitlabServerCount, getGitlabServerURLs, getGitlabServerURL,
+         getGitlabServerProjectID, generateMilestoneAnchor, buildProjectURL, buildProjectMilestoneURL, 
+         apiCall, compareSemanticVersions, ENDPOINTS, PUBLIC_PROJECT_ENDPOINT };
+
+const GITLAB_CLEVERTHIS_TOKEN = "glpat--dYnjsqjXfHxkf6yrzvL"
+const GITLAB_QOTO_TOKEN = "glpat-7LzfVvDzWyb_8gwgpG2b"
+
+const PUBLIC_PROJECT_ENDPOINT = "/projects/"
+
+const ENDPOINTS = {
+    PROJECT: "/api/v4/projects/",
+
+    P: {
+        MILESTONES: "/milestones/",
+        ISSUES: "/issues",
+        MERGE_REQUESTS: "/merge_requests",
+        COMMITS: "/repository/commits",
+    },
+    MERGE_REQUESTS: "/api/v4/merge_requests",
+    ISSUES: "/api/v4/issues",
+}
+
+/*
+ * 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(widget, release) {
+    let override = $(widget).find("#" + $.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 buildProjectMilestoneURL(widget, index) {
+    const milestone_id = $(widget).find("#milestone_id").val()
+    const endpoint = ENDPOINTS.PROJECT + getGitlabServerProjectID(widget, index) + ENDPOINTS.P.MILESTONES + milestone_id
+    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)
+}
+
+const compareSemanticVersions = (a, b) => {
+ 
+    // 1. Split the strings into their parts.
+    const a1 = a.split('.');
+    const b1 = b.split('.');    // 2. Contingency in case there's a 4th or 5th version
+    const len = Math.min(a1.length, b1.length);    // 3. Look through each version number and compare.
+    for (let i = 0; i < len; i++) {
+        const a2 = +a1[ i ] || 0;
+        const b2 = +b1[ i ] || 0;
+        
+        if (a2 !== b2) {
+            return a2 > b2 ? 1 : -1;        
+        }    }
+    
+    // 4. We hit this if the all checked versions so far are equal
+    //
+    return b1.length - a1.length;
+};