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)
This commit is contained in:
Şahin Akkaya 2024-02-24 13:22:51 +03:00 committed by Earl Warren
parent 1608ef0ce9
commit 428008ac19
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG key ID: 0579CB2928A78A00
9 changed files with 233 additions and 1 deletions

View file

@ -1961,8 +1961,9 @@ wiki.original_git_entry_tooltip = View original Git file instead of using friend
activity = Activity activity = Activity
activity.navbar.pulse = Pulse activity.navbar.pulse = Pulse
activity.navbar.contributors = Contributors
activity.navbar.code_frequency = Code Frequency activity.navbar.code_frequency = Code Frequency
activity.navbar.contributors = Contributors
activity.navbar.recent_commits = Recent Commits
activity.period.filter_label = Period: activity.period.filter_label = Period:
activity.period.daily = 1 day activity.period.daily = 1 day
activity.period.halfweekly = 3 days 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. component_failed_to_load = An unexpected error happened.
code_frequency.what = code frequency code_frequency.what = code frequency
contributors.what = contributions contributors.what = contributions
recent_commits.what = recent commits
[org] [org]
org_name_holder = Organization Name org_name_holder = Organization Name

View file

@ -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)
}
}

View file

@ -1452,6 +1452,10 @@ func registerRoutes(m *web.Route) {
m.Get("", repo.CodeFrequency) m.Get("", repo.CodeFrequency)
m.Get("/data", repo.CodeFrequencyData) 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)) }, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases))
m.Group("/activity_author_data", func() { m.Group("/activity_author_data", func() {

View file

@ -9,6 +9,7 @@
{{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}} {{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}}
{{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}} {{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}}
{{if .PageIsCodeFrequency}}{{template "repo/code_frequency" .}}{{end}} {{if .PageIsCodeFrequency}}{{template "repo/code_frequency" .}}{{end}}
{{if .PageIsRecentCommits}}{{template "repo/recent_commits" .}}{{end}}
</div> </div>
</div> </div>
</div> </div>

View file

@ -8,4 +8,7 @@
<a class="{{if .PageIsCodeFrequency}}active{{end}} item" href="{{.RepoLink}}/activity/code-frequency"> <a class="{{if .PageIsCodeFrequency}}active{{end}} item" href="{{.RepoLink}}/activity/code-frequency">
{{ctx.Locale.Tr "repo.activity.navbar.code_frequency"}} {{ctx.Locale.Tr "repo.activity.navbar.code_frequency"}}
</a> </a>
<a class="{{if .PageIsRecentCommits}}active{{end}} item" href="{{.RepoLink}}/activity/recent-commits">
{{ctx.Locale.Tr "repo.activity.navbar.recent_commits"}}
</a>
</div> </div>

View file

@ -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}}

View file

@ -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>

View file

@ -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');
}
}

View file

@ -85,6 +85,7 @@ import {initRepoIssueList} from './features/repo-issue-list.js';
import {initCommonIssueListQuickGoto} from './features/common-issue-list.js'; import {initCommonIssueListQuickGoto} from './features/common-issue-list.js';
import {initRepoContributors} from './features/contributors.js'; import {initRepoContributors} from './features/contributors.js';
import {initRepoCodeFrequency} from './features/code-frequency.js'; import {initRepoCodeFrequency} from './features/code-frequency.js';
import {initRepoRecentCommits} from './features/recent-commits.js';
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js'; import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
import {initDirAuto} from './modules/dirauto.js'; import {initDirAuto} from './modules/dirauto.js';
@ -176,6 +177,7 @@ onDomReady(() => {
initRepositoryActionView(); initRepositoryActionView();
initRepoContributors(); initRepoContributors();
initRepoCodeFrequency(); initRepoCodeFrequency();
initRepoRecentCommits();
initCommitStatuses(); initCommitStatuses();
initCaptcha(); initCaptcha();