Implement code frequency graph (#29191)

### Overview
This is the implementation of Code Frequency page. This feature was
mentioned on these issues: #18262, #7392.


It adds another tab to Activity page called Code Frequency. Code
Frequency tab shows additions and deletions over time since the
repository existed.


Before:
<img width="1296" alt="image"
src="https://github.com/go-gitea/gitea/assets/32161460/2603504f-aee7-4929-a8c4-fb3412a7a0f6">

After:
<img width="1296" alt="image"
src="https://github.com/go-gitea/gitea/assets/32161460/58c03721-729f-4536-a663-9f337f240963">

---


#### Features
- See additions deletions over time since repository existed
- Click on "Additions" or "Deletions" legend to show only one type of
contribution
- Use the same cache from Contributors page so that the loading of data
will be fast once it is cached by visiting either one of the pages

---------

Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
Şahin Akkaya 2024-02-24 02:41:24 +03:00 committed by GitHub
parent 6f6120dfa8
commit 875f5ea6d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 277 additions and 32 deletions

View File

@ -1919,6 +1919,7 @@ 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.contributors = Contributors
activity.navbar.code_frequency = Code Frequency
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
@ -2597,6 +2598,7 @@ component_loading = Loading %s...
component_loading_failed = Could not load %s component_loading_failed = Could not load %s
component_loading_info = This might take a bit… 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
contributors.what = contributions contributors.what = contributions
[org] [org]

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 (
tplCodeFrequency base.TplName = "repo/activity"
)
// CodeFrequency renders the page to show repository code frequency
func CodeFrequency(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.code_frequency")
ctx.Data["PageIsActivity"] = true
ctx.Data["PageIsCodeFrequency"] = true
ctx.PageData["repoLink"] = ctx.Repo.RepoLink
ctx.HTML(http.StatusOK, tplCodeFrequency)
}
// CodeFrequencyData returns JSON of code frequency data
func CodeFrequencyData(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("GetCodeFrequencyData", err)
} else {
ctx.JSON(http.StatusOK, contributorStats["total"].Weeks)
}
}

View File

@ -1403,6 +1403,10 @@ func registerRoutes(m *web.Route) {
m.Get("", repo.Contributors) m.Get("", repo.Contributors)
m.Get("/data", repo.ContributorsData) m.Get("/data", repo.ContributorsData)
}) })
m.Group("/code-frequency", func() {
m.Get("", repo.CodeFrequency)
m.Get("/data", repo.CodeFrequencyData)
})
}, 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

@ -143,7 +143,6 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
_ = stdoutWriter.Close() _ = stdoutWriter.Close()
scanner := bufio.NewScanner(stdoutReader) scanner := bufio.NewScanner(stdoutReader)
scanner.Split(bufio.ScanLines)
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
@ -180,7 +179,6 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int
} }
} }
commitStats.Total = commitStats.Additions + commitStats.Deletions commitStats.Total = commitStats.Additions + commitStats.Deletions
scanner.Scan()
scanner.Text() // empty line at the end scanner.Text() // empty line at the end
res := &ExtendedCommitStats{ res := &ExtendedCommitStats{

View File

@ -8,6 +8,7 @@
<div class="flex-container-main"> <div class="flex-container-main">
{{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}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,9 @@
{{if .Permission.CanRead $.UnitTypeCode}}
<div id="repo-code-frequency-chart"
data-locale-loading-title="{{ctx.Locale.Tr "graphs.component_loading" (ctx.Locale.Tr "graphs.code_frequency.what")}}"
data-locale-loading-title-failed="{{ctx.Locale.Tr "graphs.component_loading_failed" (ctx.Locale.Tr "graphs.code_frequency.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

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

View File

@ -0,0 +1,172 @@
<script>
import {SvgIcon} from '../svg.js';
import {
Chart,
Legend,
LinearScale,
TimeScale,
PointElement,
LineElement,
Filler,
} from 'chart.js';
import {GET} from '../modules/fetch.js';
import {Line as ChartLine} 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,
Legend,
PointElement,
LineElement,
Filler,
);
export default {
components: {ChartLine, 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/code-frequency/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
this.data = await response.json();
const weekValues = Object.values(this.data);
const start = weekValues[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(new Date(start), new Date(end));
this.data = fillEmptyStartDaysWithZeroes(startDays, this.data);
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.additions})),
pointRadius: 0,
pointHitRadius: 0,
fill: true,
label: 'Additions',
backgroundColor: chartJsColors['additions'],
borderWidth: 0,
tension: 0.3,
},
{
data: data.map((i) => ({x: i.week, y: -i.deletions})),
pointRadius: 0,
pointHitRadius: 0,
fill: true,
label: 'Deletions',
backgroundColor: chartJsColors['deletions'],
borderWidth: 0,
tension: 0.3,
},
],
};
},
getOptions() {
return {
responsive: true,
maintainAspectRatio: false,
animation: true,
plugins: {
legend: {
display: true,
},
},
scales: {
x: {
type: 'time',
grid: {
display: false,
},
time: {
minUnit: 'month',
},
ticks: {
maxRotation: 0,
maxTicksLimit: 12
},
},
y: {
ticks: {
maxTicksLimit: 6
},
},
},
};
},
},
};
</script>
<template>
<div>
<div class="ui header gt-df gt-ac gt-sb">
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: `Code frequency over the history of ${repoLink.slice(1)}` }}
</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>
<ChartLine
v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="getOptions()"
/>
</div>
</div>
</template>
<style scoped>
.main-graph {
height: 440px;
}
</style>

View File

@ -3,10 +3,7 @@ import {SvgIcon} from '../svg.js';
import { import {
Chart, Chart,
Title, Title,
Tooltip,
Legend,
BarElement, BarElement,
CategoryScale,
LinearScale, LinearScale,
TimeScale, TimeScale,
PointElement, PointElement,
@ -21,27 +18,13 @@ import {
firstStartDateAfterDate, firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes, fillEmptyStartDaysWithZeroes,
} from '../utils/time.js'; } 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'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import $ from 'jquery'; import $ from 'jquery';
const {pageData} = window.config; const {pageData} = window.config;
const colors = {
text: '--color-text',
border: '--color-secondary-alpha-60',
commits: '--color-primary-alpha-60',
additions: '--color-green',
deletions: '--color-red',
title: '--color-secondary-dark-4',
};
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name) => styles.getPropertyValue(name).trim();
for (const [key, value] of Object.entries(colors)) {
colors[key] = getColor(value);
}
const customEventListener = { const customEventListener = {
id: 'customEventListener', id: 'customEventListener',
afterEvent: (chart, args, opts) => { afterEvent: (chart, args, opts) => {
@ -54,17 +37,14 @@ const customEventListener = {
} }
}; };
Chart.defaults.color = colors.text; Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = colors.border; Chart.defaults.borderColor = chartJsColors.border;
Chart.register( Chart.register(
TimeScale, TimeScale,
CategoryScale,
LinearScale, LinearScale,
BarElement, BarElement,
Title, Title,
Tooltip,
Legend,
PointElement, PointElement,
LineElement, LineElement,
Filler, Filler,
@ -122,7 +102,7 @@ export default {
do { do {
response = await GET(`${this.repoLink}/activity/contributors/data`); response = await GET(`${this.repoLink}/activity/contributors/data`);
if (response.status === 202) { if (response.status === 202) {
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying await sleep(1000); // wait for 1 second before retrying
} }
} while (response.status === 202); } while (response.status === 202);
if (response.ok) { if (response.ok) {
@ -222,7 +202,7 @@ export default {
pointRadius: 0, pointRadius: 0,
pointHitRadius: 0, pointHitRadius: 0,
fill: 'start', fill: 'start',
backgroundColor: colors[this.type], backgroundColor: chartJsColors[this.type],
borderWidth: 0, borderWidth: 0,
tension: 0.3, tension: 0.3,
}, },
@ -254,7 +234,6 @@ export default {
title: { title: {
display: type === 'main', display: type === 'main',
text: 'drag: zoom, shift+drag: pan, double click: reset zoom', text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
color: colors.title,
position: 'top', position: 'top',
align: 'center', align: 'center',
}, },
@ -262,9 +241,6 @@ export default {
chartType: type, chartType: type,
instance: this, instance: this,
}, },
legend: {
display: false,
},
zoom: { zoom: {
pan: { pan: {
enabled: true, enabled: true,

View File

@ -0,0 +1,21 @@
import {createApp} from 'vue';
export async function initRepoCodeFrequency() {
const el = document.getElementById('repo-code-frequency-chart');
if (!el) return;
const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue');
try {
const View = createApp(RepoCodeFrequency, {
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('RepoCodeFrequency failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}

View File

@ -87,6 +87,7 @@ import {onDomReady} from './utils/dom.js';
import {initRepoIssueList} from './features/repo-issue-list.js'; 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 {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';
@ -177,6 +178,7 @@ onDomReady(() => {
initRepository(); initRepository();
initRepositoryActionView(); initRepositoryActionView();
initRepoContributors(); initRepoContributors();
initRepoCodeFrequency();
initCommitStatuses(); initCommitStatuses();
initCaptcha(); initCaptcha();

View File

@ -139,3 +139,5 @@ export function parseDom(text, contentType) {
export function serializeXml(node) { export function serializeXml(node) {
return xmlSerializer.serializeToString(node); return xmlSerializer.serializeToString(node);
} }
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

View File

@ -19,3 +19,17 @@ function getLuminance(r, g, b) {
export function useLightTextOnBackground(r, g, b) { export function useLightTextOnBackground(r, g, b) {
return getLuminance(r, g, b) < 0.453; return getLuminance(r, g, b) < 0.453;
} }
function resolveColors(obj) {
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name) => styles.getPropertyValue(name).trim();
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)]));
}
export const chartJsColors = resolveColors({
text: '--color-text',
border: '--color-secondary-alpha-60',
commits: '--color-primary-alpha-60',
additions: '--color-green',
deletions: '--color-red',
});