From 428008ac19185125b7cb1e3d379254d7b1932529 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C5=9Eahin=20Akkaya?= <sahin@sahinakkaya.dev>
Date: Sat, 24 Feb 2024 13:22:51 +0300
Subject: [PATCH] Implement recent commits graph (#29210)

This is the implementation of Recent Commits page. This feature was
mentioned on #18262.

It adds another tab to Activity page called Recent Commits. Recent
Commits tab shows number of commits since last year for the repository.

(cherry picked from commit d3982bcd814bac93e3cbce1c7eb749b17e413fbd)
---
 options/locale/locale_en-US.ini             |   4 +-
 routers/web/repo/recent_commits.go          |  41 ++++++
 routers/web/web.go                          |   4 +
 templates/repo/activity.tmpl                |   1 +
 templates/repo/navbar.tmpl                  |   3 +
 templates/repo/recent_commits.tmpl          |   9 ++
 web_src/js/components/RepoRecentCommits.vue | 149 ++++++++++++++++++++
 web_src/js/features/recent-commits.js       |  21 +++
 web_src/js/index.js                         |   2 +
 9 files changed, 233 insertions(+), 1 deletion(-)
 create mode 100644 routers/web/repo/recent_commits.go
 create mode 100644 templates/repo/recent_commits.tmpl
 create mode 100644 web_src/js/components/RepoRecentCommits.vue
 create mode 100644 web_src/js/features/recent-commits.js

diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index d4a5ca4517..3e4872420f 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1961,8 +1961,9 @@ wiki.original_git_entry_tooltip = View original Git file instead of using friend
 
 activity = Activity
 activity.navbar.pulse = Pulse
-activity.navbar.contributors = Contributors
 activity.navbar.code_frequency = Code Frequency
+activity.navbar.contributors = Contributors
+activity.navbar.recent_commits = Recent Commits
 activity.period.filter_label = Period:
 activity.period.daily = 1 day
 activity.period.halfweekly = 3 days
@@ -2659,6 +2660,7 @@ component_loading_info = This might take a bit…
 component_failed_to_load = An unexpected error happened.
 code_frequency.what = code frequency
 contributors.what = contributions
+recent_commits.what = recent commits
 
 [org]
 org_name_holder = Organization Name
diff --git a/routers/web/repo/recent_commits.go b/routers/web/repo/recent_commits.go
new file mode 100644
index 0000000000..3507cb8752
--- /dev/null
+++ b/routers/web/repo/recent_commits.go
@@ -0,0 +1,41 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"errors"
+	"net/http"
+
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/context"
+	contributors_service "code.gitea.io/gitea/services/repository"
+)
+
+const (
+	tplRecentCommits base.TplName = "repo/activity"
+)
+
+// RecentCommits renders the page to show recent commit frequency on repository
+func RecentCommits(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.recent_commits")
+
+	ctx.Data["PageIsActivity"] = true
+	ctx.Data["PageIsRecentCommits"] = true
+	ctx.PageData["repoLink"] = ctx.Repo.RepoLink
+
+	ctx.HTML(http.StatusOK, tplRecentCommits)
+}
+
+// RecentCommitsData returns JSON of recent commits data
+func RecentCommitsData(ctx *context.Context) {
+	if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil {
+		if errors.Is(err, contributors_service.ErrAwaitGeneration) {
+			ctx.Status(http.StatusAccepted)
+			return
+		}
+		ctx.ServerError("RecentCommitsData", err)
+	} else {
+		ctx.JSON(http.StatusOK, contributorStats["total"].Weeks)
+	}
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index cef563f615..6f4a7543e7 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1452,6 +1452,10 @@ func registerRoutes(m *web.Route) {
 				m.Get("", repo.CodeFrequency)
 				m.Get("/data", repo.CodeFrequencyData)
 			})
+			m.Group("/recent-commits", func() {
+				m.Get("", repo.RecentCommits)
+				m.Get("/data", repo.RecentCommitsData)
+			})
 		}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases))
 
 		m.Group("/activity_author_data", func() {
diff --git a/templates/repo/activity.tmpl b/templates/repo/activity.tmpl
index 94f52b0e26..a19fb66261 100644
--- a/templates/repo/activity.tmpl
+++ b/templates/repo/activity.tmpl
@@ -9,6 +9,7 @@
 			{{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}}
 			{{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}}
 			{{if .PageIsCodeFrequency}}{{template "repo/code_frequency" .}}{{end}}
+			{{if .PageIsRecentCommits}}{{template "repo/recent_commits" .}}{{end}}
 		</div>
 	</div>
 </div>
diff --git a/templates/repo/navbar.tmpl b/templates/repo/navbar.tmpl
index aa5021e73a..b2471dc17e 100644
--- a/templates/repo/navbar.tmpl
+++ b/templates/repo/navbar.tmpl
@@ -8,4 +8,7 @@
 	<a class="{{if .PageIsCodeFrequency}}active{{end}} item" href="{{.RepoLink}}/activity/code-frequency">
 		{{ctx.Locale.Tr "repo.activity.navbar.code_frequency"}}
 	</a>
+	<a class="{{if .PageIsRecentCommits}}active{{end}} item" href="{{.RepoLink}}/activity/recent-commits">
+		{{ctx.Locale.Tr "repo.activity.navbar.recent_commits"}}
+	</a>
 </div>
diff --git a/templates/repo/recent_commits.tmpl b/templates/repo/recent_commits.tmpl
new file mode 100644
index 0000000000..5c241d635c
--- /dev/null
+++ b/templates/repo/recent_commits.tmpl
@@ -0,0 +1,9 @@
+{{if .Permission.CanRead $.UnitTypeCode}}
+	<div id="repo-recent-commits-chart"
+		data-locale-loading-title="{{ctx.Locale.Tr "graphs.component_loading" (ctx.Locale.Tr "graphs.recent_commits.what")}}"
+		data-locale-loading-title-failed="{{ctx.Locale.Tr "graphs.component_loading_failed" (ctx.Locale.Tr "graphs.recent_commits.what")}}"
+		data-locale-loading-info="{{ctx.Locale.Tr "graphs.component_loading_info"}}"
+		data-locale-component-failed-to-load="{{ctx.Locale.Tr "graphs.component_failed_to_load"}}"
+	>
+	</div>
+{{end}}
diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue
new file mode 100644
index 0000000000..77697cd413
--- /dev/null
+++ b/web_src/js/components/RepoRecentCommits.vue
@@ -0,0 +1,149 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {
+  Chart,
+  Tooltip,
+  BarElement,
+  LinearScale,
+  TimeScale,
+} from 'chart.js';
+import {GET} from '../modules/fetch.js';
+import {Bar} from 'vue-chartjs';
+import {
+  startDaysBetween,
+  firstStartDateAfterDate,
+  fillEmptyStartDaysWithZeroes,
+} from '../utils/time.js';
+import {chartJsColors} from '../utils/color.js';
+import {sleep} from '../utils.js';
+import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
+
+const {pageData} = window.config;
+
+Chart.defaults.color = chartJsColors.text;
+Chart.defaults.borderColor = chartJsColors.border;
+
+Chart.register(
+  TimeScale,
+  LinearScale,
+  BarElement,
+  Tooltip,
+);
+
+export default {
+  components: {Bar, SvgIcon},
+  props: {
+    locale: {
+      type: Object,
+      required: true
+    },
+  },
+  data: () => ({
+    isLoading: false,
+    errorText: '',
+    repoLink: pageData.repoLink || [],
+    data: [],
+  }),
+  mounted() {
+    this.fetchGraphData();
+  },
+  methods: {
+    async fetchGraphData() {
+      this.isLoading = true;
+      try {
+        let response;
+        do {
+          response = await GET(`${this.repoLink}/activity/recent-commits/data`);
+          if (response.status === 202) {
+            await sleep(1000); // wait for 1 second before retrying
+          }
+        } while (response.status === 202);
+        if (response.ok) {
+          const data = await response.json();
+          const start = Object.values(data)[0].week;
+          const end = firstStartDateAfterDate(new Date());
+          const startDays = startDaysBetween(new Date(start), new Date(end));
+          this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
+          this.errorText = '';
+        } else {
+          this.errorText = response.statusText;
+        }
+      } catch (err) {
+        this.errorText = err.message;
+      } finally {
+        this.isLoading = false;
+      }
+    },
+
+    toGraphData(data) {
+      return {
+        datasets: [
+          {
+            data: data.map((i) => ({x: i.week, y: i.commits})),
+            label: 'Commits',
+            backgroundColor: chartJsColors['commits'],
+            borderWidth: 0,
+            tension: 0.3,
+          },
+        ],
+      };
+    },
+
+    getOptions() {
+      return {
+        responsive: true,
+        maintainAspectRatio: false,
+        animation: true,
+        scales: {
+          x: {
+            type: 'time',
+            grid: {
+              display: false,
+            },
+            time: {
+              minUnit: 'week',
+            },
+            ticks: {
+              maxRotation: 0,
+              maxTicksLimit: 52
+            },
+          },
+          y: {
+            ticks: {
+              maxTicksLimit: 6
+            },
+          },
+        },
+      };
+    },
+  },
+};
+</script>
+<template>
+  <div>
+    <div class="ui header gt-df gt-ac gt-sb">
+      {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "Number of commits in the past year" }}
+    </div>
+    <div class="gt-df ui segment main-graph">
+      <div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto">
+        <div v-if="isLoading">
+          <SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/>
+          {{ locale.loadingInfo }}
+        </div>
+        <div v-else class="text red">
+          <SvgIcon name="octicon-x-circle-fill"/>
+          {{ errorText }}
+        </div>
+      </div>
+      <Bar
+        v-memo="data" v-if="data.length !== 0"
+        :data="toGraphData(data)" :options="getOptions()"
+      />
+    </div>
+  </div>
+</template>
+<style scoped>
+.main-graph {
+  height: 250px;
+}
+</style>
diff --git a/web_src/js/features/recent-commits.js b/web_src/js/features/recent-commits.js
new file mode 100644
index 0000000000..ded10d39be
--- /dev/null
+++ b/web_src/js/features/recent-commits.js
@@ -0,0 +1,21 @@
+import {createApp} from 'vue';
+
+export async function initRepoRecentCommits() {
+  const el = document.getElementById('repo-recent-commits-chart');
+  if (!el) return;
+
+  const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue');
+  try {
+    const View = createApp(RepoRecentCommits, {
+      locale: {
+        loadingTitle: el.getAttribute('data-locale-loading-title'),
+        loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
+        loadingInfo: el.getAttribute('data-locale-loading-info'),
+      }
+    });
+    View.mount(el);
+  } catch (err) {
+    console.error('RepoRecentCommits failed to load', err);
+    el.textContent = el.getAttribute('data-locale-component-failed-to-load');
+  }
+}
diff --git a/web_src/js/index.js b/web_src/js/index.js
index d9cfff4084..b7f3ba99a0 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -85,6 +85,7 @@ import {initRepoIssueList} from './features/repo-issue-list.js';
 import {initCommonIssueListQuickGoto} from './features/common-issue-list.js';
 import {initRepoContributors} from './features/contributors.js';
 import {initRepoCodeFrequency} from './features/code-frequency.js';
+import {initRepoRecentCommits} from './features/recent-commits.js';
 import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
 import {initDirAuto} from './modules/dirauto.js';
 
@@ -176,6 +177,7 @@ onDomReady(() => {
   initRepositoryActionView();
   initRepoContributors();
   initRepoCodeFrequency();
+  initRepoRecentCommits();
 
   initCommitStatuses();
   initCaptcha();