snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
This commit is contained in:
481
web_src/js/components/RepoContributors.vue
Normal file
481
web_src/js/components/RepoContributors.vue
Normal file
@@ -0,0 +1,481 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
import SvgIcon from './SvgIcon.vue';
|
||||
import dayjs from 'dayjs';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import ChartCanvas from './ChartCanvas.vue';
|
||||
import {
|
||||
Chart,
|
||||
Title,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
type ChartOptions,
|
||||
type ChartData,
|
||||
type Plugin,
|
||||
} from 'chart.js';
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
type DayData,
|
||||
type DayDataObject,
|
||||
} from '../utils/time.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
|
||||
type ContributionType = 'commits' | 'additions' | 'deletions';
|
||||
type ChartType = 'main' | 'contributor';
|
||||
|
||||
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const customEventListener: Plugin = {
|
||||
id: 'customEventListener',
|
||||
afterEvent: (chart, args, opts) => {
|
||||
// event will be replayed from chart.update when reset zoom,
|
||||
// so we need to check whether args.replay is true to avoid call loops
|
||||
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
|
||||
chart.resetZoom();
|
||||
opts.onDoubleClick({chart}, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
type LineOptions = ChartOptions<'line'> & {
|
||||
plugins?: {
|
||||
customEventListener?: {
|
||||
chartType: ChartType;
|
||||
onDoubleClick: (args: {chart: Chart}, reset: boolean) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Chart.register(
|
||||
Title,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
zoomPlugin,
|
||||
customEventListener,
|
||||
);
|
||||
|
||||
// rounds up to the next multiple of the leading power of ten, so the axis does not rescale on zoom and pan
|
||||
function roundUpMax(maxValue: number) {
|
||||
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
|
||||
return Math.ceil(coefficient) * 10 ** exp;
|
||||
}
|
||||
|
||||
type ContributorInfo = {
|
||||
name: string,
|
||||
avatar_link: string,
|
||||
home_link: string,
|
||||
};
|
||||
|
||||
type ContributorStats = ContributorInfo & {weeks: DayData[]};
|
||||
|
||||
type Contributor = ContributorStats & {
|
||||
email: string,
|
||||
total_commits: number,
|
||||
total_additions: number,
|
||||
total_deletions: number,
|
||||
max_contribution_type: number,
|
||||
};
|
||||
type ContributorsData = Record<string, ContributorInfo & {weeks: DayDataObject}>;
|
||||
|
||||
const props = defineProps<{
|
||||
locale: {
|
||||
filterLabel: string;
|
||||
contributionType: Record<ContributionType, string>;
|
||||
loadingTitle: string;
|
||||
loadingTitleFailed: string;
|
||||
loadingInfo: string;
|
||||
chartZoomHint: string;
|
||||
};
|
||||
repoLink: string;
|
||||
repoDefaultBranchName: string;
|
||||
}>();
|
||||
|
||||
const isLoading = shallowRef(false);
|
||||
const errorText = shallowRef('');
|
||||
const totalStats = shallowRef<DayData[]>([]);
|
||||
const sortedContributors = shallowRef<Contributor[]>([]);
|
||||
const type = shallowRef<ContributionType>('commits');
|
||||
let contributorsStats: Record<string, ContributorStats> = {};
|
||||
// plain values, so the main chart options do not follow the zoomed range
|
||||
let xAxisStart: number | null = null;
|
||||
let xAxisEnd: number | null = null;
|
||||
const xAxisMin = shallowRef<number | null>(null);
|
||||
const xAxisMax = shallowRef<number | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
fetchGraphData();
|
||||
|
||||
fomanticQuery('#repo-contributors').dropdown({
|
||||
onChange: (val: ContributionType) => {
|
||||
xAxisMin.value = xAxisStart;
|
||||
xAxisMax.value = xAxisEnd;
|
||||
type.value = val;
|
||||
sortContributors();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function sortContributors() {
|
||||
const criteria = `total_${type.value}` as const;
|
||||
sortedContributors.value = filterContributorWeeksByDateRange()
|
||||
.filter((contributor) => contributor[criteria] !== 0)
|
||||
.sort((a, b) => b[criteria] - a[criteria])
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
const searchBase = computed(() => {
|
||||
const min = dayjs(xAxisMin.value).format('YYYY-MM-DD');
|
||||
const max = dayjs(xAxisMax.value).format('YYYY-MM-DD');
|
||||
return {prefix: `after:${min}, before:${max}, author:`, branch: pathEscapeSegments(props.repoDefaultBranchName)};
|
||||
});
|
||||
|
||||
function getContributorSearchQuery(contributorEmail: string) {
|
||||
const params = new URLSearchParams({'q': `${searchBase.value.prefix}${contributorEmail}`});
|
||||
return `${props.repoLink}/commits/branch/${searchBase.value.branch}/search?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function fetchGraphData() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let response: Response;
|
||||
do {
|
||||
response = await GET(`${props.repoLink}/activity/contributors/data`);
|
||||
if (response.status === 202) {
|
||||
await sleep(1000); // wait for 1 second before retrying
|
||||
}
|
||||
} while (response.status === 202);
|
||||
if (response.ok) {
|
||||
const data: ContributorsData = await response.json();
|
||||
const {total, ...other} = data;
|
||||
// below line might be deleted if we are sure go produces map always sorted by keys
|
||||
const totalWeeks = Object.fromEntries(Object.entries(total.weeks).sort());
|
||||
|
||||
const weekValues = Object.values(totalWeeks);
|
||||
xAxisStart = weekValues[0].week;
|
||||
xAxisEnd = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(xAxisStart, xAxisEnd);
|
||||
xAxisMin.value = xAxisStart;
|
||||
xAxisMax.value = xAxisEnd;
|
||||
contributorsStats = Object.fromEntries(Object.entries(other).map(([email, user]) => {
|
||||
return [email, {...user, weeks: fillEmptyStartDaysWithZeroes(startDays, user.weeks)}];
|
||||
}));
|
||||
sortContributors();
|
||||
totalStats.value = fillEmptyStartDaysWithZeroes(startDays, totalWeeks);
|
||||
errorText.value = '';
|
||||
} else {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = errorMessage(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function filterContributorWeeksByDateRange() {
|
||||
const filteredData: Contributor[] = [];
|
||||
const minTime = xAxisMin.value! - oneWeek;
|
||||
const maxTime = xAxisMax.value! + oneWeek;
|
||||
const contributionType = type.value;
|
||||
for (const [key, user] of Object.entries(contributorsStats)) {
|
||||
let totalCommits = 0;
|
||||
let totalAdditions = 0;
|
||||
let totalDeletions = 0;
|
||||
let maxContributionType = 0;
|
||||
const filteredWeeks = user.weeks.filter((week) => {
|
||||
if (week.week >= minTime && week.week <= maxTime) {
|
||||
totalCommits += week.commits;
|
||||
totalAdditions += week.additions;
|
||||
totalDeletions += week.deletions;
|
||||
if (week[contributionType] > maxContributionType) {
|
||||
maxContributionType = week[contributionType];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
|
||||
// for details.
|
||||
maxContributionType += 1;
|
||||
|
||||
filteredData.push({
|
||||
...user,
|
||||
weeks: filteredWeeks,
|
||||
total_commits: totalCommits,
|
||||
total_additions: totalAdditions,
|
||||
total_deletions: totalDeletions,
|
||||
max_contribution_type: maxContributionType,
|
||||
email: key,
|
||||
});
|
||||
}
|
||||
|
||||
return filteredData;
|
||||
}
|
||||
|
||||
const maxMainGraph = computed(() => {
|
||||
return roundUpMax(Math.max(...totalStats.value.map((o) => o[type.value])));
|
||||
});
|
||||
|
||||
// one shared maximum, otherwise the contributor graphs cannot be compared
|
||||
const maxContributorGraph = computed(() => {
|
||||
return roundUpMax(Math.max(...sortedContributors.value.map((c) => c.max_contribution_type)));
|
||||
});
|
||||
|
||||
function toGraphData(data: DayData[]): ChartData<'line'> {
|
||||
const contributionType = type.value;
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: i[contributionType]})),
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 0,
|
||||
fill: 'start',
|
||||
backgroundColor: chartJsColors[type.value],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) {
|
||||
const minVal = Number(chart.options.scales?.x?.min);
|
||||
const maxVal = Number(chart.options.scales?.x?.max);
|
||||
if (reset) {
|
||||
xAxisMin.value = xAxisStart;
|
||||
xAxisMax.value = xAxisEnd;
|
||||
sortContributors();
|
||||
} else if (minVal) {
|
||||
xAxisMin.value = minVal;
|
||||
xAxisMax.value = maxVal;
|
||||
sortContributors();
|
||||
}
|
||||
}
|
||||
|
||||
function getOptions(chartType: ChartType): LineOptions {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
|
||||
plugins: {
|
||||
title: {
|
||||
display: chartType === 'main',
|
||||
text: props.locale.chartZoomHint,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
},
|
||||
customEventListener: {
|
||||
chartType,
|
||||
onDoubleClick: updateOtherCharts,
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'shift',
|
||||
mode: 'x',
|
||||
threshold: 20,
|
||||
onPanComplete: updateOtherCharts,
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
// Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
|
||||
// to know what each option means
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
|
||||
minRange: 2 * oneWeek, // do not zoom in tighter than two weeks
|
||||
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
drag: {
|
||||
enabled: chartType === 'main',
|
||||
},
|
||||
pinch: {
|
||||
enabled: chartType === 'main',
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomComplete: updateOtherCharts,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
// the main chart keeps its own zoom range
|
||||
min: (chartType === 'main' ? xAxisStart : xAxisMin.value) ?? undefined,
|
||||
max: (chartType === 'main' ? xAxisEnd : xAxisMax.value) ?? undefined,
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'month',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: chartType === 'main' ? 12 : 6,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: chartType === 'main' ? maxMainGraph.value : maxContributorGraph.value,
|
||||
ticks: {
|
||||
maxTicksLimit: chartType === 'main' ? 6 : 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mainChart = computed(() => ({
|
||||
graphData: toGraphData(totalStats.value),
|
||||
chartOptions: getOptions('main'),
|
||||
}));
|
||||
|
||||
const contributorCharts = computed(() => sortedContributors.value.map((contributor) => ({
|
||||
contributor,
|
||||
graphData: toGraphData(contributor.weeks),
|
||||
chartOptions: getOptions('contributor'), // chart.js mutates it, so each chart needs its own
|
||||
})));
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui header flex-left-right">
|
||||
<div>
|
||||
<relative-time
|
||||
v-if="xAxisMin && xAxisMin > 0"
|
||||
format="datetime"
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="numeric"
|
||||
weekday=""
|
||||
:datetime="new Date(xAxisMin)"
|
||||
>
|
||||
{{ new Date(xAxisMin) }}
|
||||
</relative-time>
|
||||
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
|
||||
<relative-time
|
||||
v-if="xAxisMax && xAxisMax > 0"
|
||||
format="datetime"
|
||||
year="numeric"
|
||||
month="short"
|
||||
day="numeric"
|
||||
weekday=""
|
||||
:datetime="new Date(xAxisMax)"
|
||||
>
|
||||
{{ new Date(xAxisMax) }}
|
||||
</relative-time>
|
||||
</div>
|
||||
<div>
|
||||
<!-- Contribution type -->
|
||||
<div class="ui floating dropdown jump" id="repo-contributors">
|
||||
<div class="ui basic compact button">
|
||||
<span class="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong>
|
||||
<svg-icon name="octicon-triangle-down" :size="14"/>
|
||||
</div>
|
||||
<div class="left menu">
|
||||
<div :class="['item', {'selected': type === 'commits'}]" data-value="commits">
|
||||
{{ locale.contributionType.commits }}
|
||||
</div>
|
||||
<div :class="['item', {'selected': type === 'additions'}]" data-value="additions">
|
||||
{{ locale.contributionType.additions }}
|
||||
</div>
|
||||
<div :class="['item', {'selected': type === 'deletions'}]" data-value="deletions">
|
||||
{{ locale.contributionType.deletions }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex ui segment main-graph">
|
||||
<div v-if="isLoading || errorText !== ''" class="tw-m-auto">
|
||||
<div v-if="isLoading">
|
||||
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
||||
{{ locale.loadingInfo }}
|
||||
</div>
|
||||
<div v-else class="tw-text-red">
|
||||
<SvgIcon name="octicon-x-circle-fill"/>
|
||||
{{ errorText }}
|
||||
</div>
|
||||
</div>
|
||||
<ChartCanvas
|
||||
v-if="totalStats.length"
|
||||
type="line" :data="mainChart.graphData" :options="mainChart.chartOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="contributor-grid">
|
||||
<div
|
||||
v-for="({contributor, graphData, chartOptions}, index) in contributorCharts"
|
||||
:key="index"
|
||||
>
|
||||
<div class="ui top attached header tw-flex tw-flex-1">
|
||||
<b class="ui right">#{{ index + 1 }}</b>
|
||||
<a :href="contributor.home_link">
|
||||
<img loading="lazy" class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt="">
|
||||
</a>
|
||||
<div class="tw-ml-2">
|
||||
<a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
|
||||
<h4 v-else class="contributor-name">
|
||||
{{ contributor.name }}
|
||||
</h4>
|
||||
<p class="tw-text-12 tw-flex tw-gap-1">
|
||||
<strong v-if="contributor.total_commits">
|
||||
<a class="silenced" :href="getContributorSearchQuery(contributor.email)">
|
||||
{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}
|
||||
</a>
|
||||
</strong>
|
||||
<strong v-if="contributor.total_additions" class="tw-text-green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
|
||||
<strong v-if="contributor.total_deletions" class="tw-text-red">
|
||||
{{ contributor.total_deletions.toLocaleString() }}--</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached segment">
|
||||
<div>
|
||||
<ChartCanvas
|
||||
type="line"
|
||||
:data="graphData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.main-graph {
|
||||
height: 260px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.contributor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.contributor-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.contributor-grid {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.contributor-name {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user