Add top author stats to activity page (#9615)
This commit is contained in:
parent
7d7ab1eeae
commit
81cfe243f9
13 changed files with 524 additions and 906 deletions
|
@ -19,6 +19,7 @@ type ActivityAuthorData struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
AvatarLink string `json:"avatar_link"`
|
AvatarLink string `json:"avatar_link"`
|
||||||
|
HomeLink string `json:"home_link"`
|
||||||
Commits int64 `json:"commits"`
|
Commits int64 `json:"commits"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,12 +92,20 @@ func GetActivityStatsTopAuthors(repo *Repository, timeFrom time.Time, count int)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
users := make(map[int64]*ActivityAuthorData)
|
users := make(map[int64]*ActivityAuthorData)
|
||||||
for k, v := range code.Authors {
|
var unknownUserID int64
|
||||||
if len(k) == 0 {
|
unknownUserAvatarLink := NewGhostUser().AvatarLink()
|
||||||
|
for _, v := range code.Authors {
|
||||||
|
if len(v.Email) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u, err := GetUserByEmail(k)
|
u, err := GetUserByEmail(v.Email)
|
||||||
if u == nil || IsErrUserNotExist(err) {
|
if u == nil || IsErrUserNotExist(err) {
|
||||||
|
unknownUserID--
|
||||||
|
users[unknownUserID] = &ActivityAuthorData{
|
||||||
|
Name: v.Name,
|
||||||
|
AvatarLink: unknownUserAvatarLink,
|
||||||
|
Commits: v.Commits,
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -107,10 +116,11 @@ func GetActivityStatsTopAuthors(repo *Repository, timeFrom time.Time, count int)
|
||||||
Name: u.DisplayName(),
|
Name: u.DisplayName(),
|
||||||
Login: u.LowerName,
|
Login: u.LowerName,
|
||||||
AvatarLink: u.AvatarLink(),
|
AvatarLink: u.AvatarLink(),
|
||||||
Commits: v,
|
HomeLink: u.HomeLink(),
|
||||||
|
Commits: v.Commits,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user.Commits += v
|
user.Commits += v.Commits
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
v := make([]*ActivityAuthorData, 0)
|
v := make([]*ActivityAuthorData, 0)
|
||||||
|
@ -119,7 +129,7 @@ func GetActivityStatsTopAuthors(repo *Repository, timeFrom time.Time, count int)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(v, func(i, j int) bool {
|
sort.Slice(v, func(i, j int) bool {
|
||||||
return v[i].Commits < v[j].Commits
|
return v[i].Commits > v[j].Commits
|
||||||
})
|
})
|
||||||
|
|
||||||
cnt := count
|
cnt := count
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -21,7 +22,14 @@ type CodeActivityStats struct {
|
||||||
Additions int64
|
Additions int64
|
||||||
Deletions int64
|
Deletions int64
|
||||||
CommitCountInAllBranches int64
|
CommitCountInAllBranches int64
|
||||||
Authors map[string]int64
|
Authors []*CodeActivityAuthor
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodeActivityAuthor represents git statistics data for commit authors
|
||||||
|
type CodeActivityAuthor struct {
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Commits int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCodeActivityStats returns code statistics for acitivity page
|
// GetCodeActivityStats returns code statistics for acitivity page
|
||||||
|
@ -58,8 +66,9 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
|
||||||
stats.CommitCount = 0
|
stats.CommitCount = 0
|
||||||
stats.Additions = 0
|
stats.Additions = 0
|
||||||
stats.Deletions = 0
|
stats.Deletions = 0
|
||||||
authors := make(map[string]int64)
|
authors := make(map[string]*CodeActivityAuthor)
|
||||||
files := make(map[string]bool)
|
files := make(map[string]bool)
|
||||||
|
var author string
|
||||||
p := 0
|
p := 0
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
l := strings.TrimSpace(scanner.Text())
|
l := strings.TrimSpace(scanner.Text())
|
||||||
|
@ -78,10 +87,17 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
|
||||||
case 2: // Commit sha-1
|
case 2: // Commit sha-1
|
||||||
stats.CommitCount++
|
stats.CommitCount++
|
||||||
case 3: // Author
|
case 3: // Author
|
||||||
|
author = l
|
||||||
case 4: // E-mail
|
case 4: // E-mail
|
||||||
email := strings.ToLower(l)
|
email := strings.ToLower(l)
|
||||||
i := authors[email]
|
if _, ok := authors[email]; !ok {
|
||||||
authors[email] = i + 1
|
authors[email] = &CodeActivityAuthor{
|
||||||
|
Name: author,
|
||||||
|
Email: email,
|
||||||
|
Commits: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authors[email].Commits++
|
||||||
default: // Changed file
|
default: // Changed file
|
||||||
if parts := strings.Fields(l); len(parts) >= 3 {
|
if parts := strings.Fields(l); len(parts) >= 3 {
|
||||||
if parts[0] != "-" {
|
if parts[0] != "-" {
|
||||||
|
@ -100,9 +116,19 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a := make([]*CodeActivityAuthor, 0, len(authors))
|
||||||
|
for _, v := range authors {
|
||||||
|
a = append(a, v)
|
||||||
|
}
|
||||||
|
// Sort authors descending depending on commit count
|
||||||
|
sort.Slice(a, func(i, j int) bool {
|
||||||
|
return a[i].Commits > a[j].Commits
|
||||||
|
})
|
||||||
|
|
||||||
stats.AuthorCount = int64(len(authors))
|
stats.AuthorCount = int64(len(authors))
|
||||||
stats.ChangedFiles = int64(len(files))
|
stats.ChangedFiles = int64(len(files))
|
||||||
stats.Authors = authors
|
stats.Authors = a
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ func TestRepository_GetCodeActivityStats(t *testing.T) {
|
||||||
assert.EqualValues(t, 10, code.Additions)
|
assert.EqualValues(t, 10, code.Additions)
|
||||||
assert.EqualValues(t, 1, code.Deletions)
|
assert.EqualValues(t, 1, code.Deletions)
|
||||||
assert.Len(t, code.Authors, 3)
|
assert.Len(t, code.Authors, 3)
|
||||||
assert.Contains(t, code.Authors, "tris.git@shoddynet.org")
|
assert.EqualValues(t, "tris.git@shoddynet.org", code.Authors[1].Email)
|
||||||
assert.EqualValues(t, 3, code.Authors["tris.git@shoddynet.org"])
|
assert.EqualValues(t, 3, code.Authors[1].Commits)
|
||||||
assert.EqualValues(t, 5, code.Authors[""])
|
assert.EqualValues(t, 5, code.Authors[0].Commits)
|
||||||
}
|
}
|
||||||
|
|
|
@ -182,6 +182,13 @@ func NewFuncMap() []template.FuncMap {
|
||||||
}
|
}
|
||||||
return path
|
return path
|
||||||
},
|
},
|
||||||
|
"Json": func(in interface{}) string {
|
||||||
|
out, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
},
|
||||||
"JsonPrettyPrint": func(in string) string {
|
"JsonPrettyPrint": func(in string) string {
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := json.Indent(&out, []byte(in), "", " ")
|
err := json.Indent(&out, []byte(in), "", " ")
|
||||||
|
|
1206
package-lock.json
generated
1206
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -5,10 +5,12 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"swagger-ui": "3.24.3"
|
"swagger-ui": "3.24.3",
|
||||||
|
"vue-bar-graph": "1.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.7.7",
|
"@babel/core": "7.7.7",
|
||||||
|
"@babel/plugin-proposal-object-rest-spread": "7.7.7",
|
||||||
"@babel/plugin-transform-runtime": "7.7.6",
|
"@babel/plugin-transform-runtime": "7.7.6",
|
||||||
"@babel/preset-env": "7.7.7",
|
"@babel/preset-env": "7.7.7",
|
||||||
"@babel/runtime": "7.7.7",
|
"@babel/runtime": "7.7.7",
|
||||||
|
@ -27,6 +29,8 @@
|
||||||
"stylelint-config-standard": "19.0.0",
|
"stylelint-config-standard": "19.0.0",
|
||||||
"terser-webpack-plugin": "2.3.2",
|
"terser-webpack-plugin": "2.3.2",
|
||||||
"updates": "9.3.3",
|
"updates": "9.3.3",
|
||||||
|
"vue-loader": "15.8.3",
|
||||||
|
"vue-template-compiler": "2.6.11",
|
||||||
"webpack": "4.41.5",
|
"webpack": "4.41.5",
|
||||||
"webpack-cli": "3.3.10"
|
"webpack-cli": "3.3.10"
|
||||||
},
|
},
|
||||||
|
|
|
@ -59,6 +59,11 @@ func Activity(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ctx.Data["ActivityTopAuthors"], err = models.GetActivityStatsTopAuthors(ctx.Repo.Repository, timeFrom, 10); err != nil {
|
||||||
|
ctx.ServerError("GetActivityStatsTopAuthors", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx.HTML(200, tplActivity)
|
ctx.HTML(200, tplActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -108,6 +108,12 @@
|
||||||
{{.i18n.Tr "repo.activity.git_stats_and_deletions" }}
|
{{.i18n.Tr "repo.activity.git_stats_and_deletions" }}
|
||||||
<strong class="text red">{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n") .Activity.Code.Deletions }}</strong>.
|
<strong class="text red">{{.i18n.Tr (TrN .i18n.Lang .Activity.Code.Deletions "repo.activity.git_stats_deletion_1" "repo.activity.git_stats_deletion_n") .Activity.Code.Deletions }}</strong>.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ui attached segment" id="app">
|
||||||
|
<script type="text/javascript">
|
||||||
|
var ActivityTopAuthors = {{Json .ActivityTopAuthors | SafeJS}};
|
||||||
|
</script>
|
||||||
|
<activity-top-authors :data="activityTopAuthors" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
102
web_src/js/components/ActivityTopAuthors.vue
Normal file
102
web_src/js/components/ActivityTopAuthors.vue
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="activity-bar-graph" ref="style" style="width:0px;height:0px"></div>
|
||||||
|
<div class="activity-bar-graph-alt" ref="altStyle" style="width:0px;height:0px"></div>
|
||||||
|
<vue-bar-graph
|
||||||
|
:points="graphData"
|
||||||
|
:show-x-axis="true"
|
||||||
|
:show-y-axis="false"
|
||||||
|
:show-values="true"
|
||||||
|
:width="graphWidth"
|
||||||
|
:bar-color="colors.barColor"
|
||||||
|
:text-color="colors.textColor"
|
||||||
|
:text-alt-color="colors.textAltColor"
|
||||||
|
:height="100"
|
||||||
|
:label-height="20"
|
||||||
|
>
|
||||||
|
<template v-slot:label="opt">
|
||||||
|
<g v-for="(author, idx) in authors" :key="author.position">
|
||||||
|
<a
|
||||||
|
v-if="opt.bar.index === idx && author.home_link !== ''"
|
||||||
|
:href="author.home_link"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
:x="`${opt.bar.midPoint - 10}px`"
|
||||||
|
:y="`${opt.bar.yLabel}px`"
|
||||||
|
height="20"
|
||||||
|
width="20"
|
||||||
|
:href="author.avatar_link"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<image
|
||||||
|
v-else-if="opt.bar.index === idx"
|
||||||
|
:x="`${opt.bar.midPoint - 10}px`"
|
||||||
|
:y="`${opt.bar.yLabel}px`"
|
||||||
|
height="20"
|
||||||
|
width="20"
|
||||||
|
:href="author.avatar_link"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
<template v-slot:title="opt">
|
||||||
|
<tspan v-for="(author, idx) in authors" :key="author.position"><tspan v-if="opt.bar.index === idx">{{ author.name }}</tspan></tspan>
|
||||||
|
</template>
|
||||||
|
</vue-bar-graph>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import VueBarGraph from 'vue-bar-graph';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
VueBarGraph,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
data: { type: Array, default: () => [] },
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
const st = window.getComputedStyle(this.$refs.style);
|
||||||
|
const stalt = window.getComputedStyle(this.$refs.altStyle);
|
||||||
|
|
||||||
|
this.colors.barColor = st.backgroundColor;
|
||||||
|
this.colors.textColor = st.color;
|
||||||
|
this.colors.textAltColor = stalt.color;
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
colors: {
|
||||||
|
barColor: 'green',
|
||||||
|
textColor: 'black',
|
||||||
|
textAltColor: 'white',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
graphData() {
|
||||||
|
return this.data.map((item) => {
|
||||||
|
return {
|
||||||
|
value: item.commits,
|
||||||
|
label: item.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
authors() {
|
||||||
|
return this.data.map((item, idx) => {
|
||||||
|
return {
|
||||||
|
position: idx+1,
|
||||||
|
...item,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
graphWidth() {
|
||||||
|
return this.data.length * 40;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
hasHomeLink(i) {
|
||||||
|
return this.graphData[i].homeLink !== '' && this.graphData[i].homeLink !== null;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -7,6 +7,8 @@ import './gitGraphLoader.js';
|
||||||
import './semanticDropdown.js';
|
import './semanticDropdown.js';
|
||||||
import initContextPopups from './features/contextPopup';
|
import initContextPopups from './features/contextPopup';
|
||||||
|
|
||||||
|
import ActivityTopAuthors from './components/ActivityTopAuthors.vue';
|
||||||
|
|
||||||
function htmlEncode(text) {
|
function htmlEncode(text) {
|
||||||
return jQuery('<div />').text(text).html();
|
return jQuery('<div />').text(text).html();
|
||||||
}
|
}
|
||||||
|
@ -2894,9 +2896,13 @@ function initVueApp() {
|
||||||
delimiters: ['${', '}'],
|
delimiters: ['${', '}'],
|
||||||
el,
|
el,
|
||||||
data: {
|
data: {
|
||||||
searchLimit: document.querySelector('meta[name=_search_limit]').content,
|
searchLimit: (document.querySelector('meta[name=_search_limit]') || {}).content,
|
||||||
suburl: document.querySelector('meta[name=_suburl]').content,
|
suburl: document.querySelector('meta[name=_suburl]').content,
|
||||||
uid: Number(document.querySelector('meta[name=_context_uid]').content),
|
uid: Number((document.querySelector('meta[name=_context_uid]') || {}).content),
|
||||||
|
activityTopAuthors: window.ActivityTopAuthors || [],
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ActivityTopAuthors,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -999,6 +999,15 @@ footer {
|
||||||
background-color: #025900;
|
background-color: #025900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-bar-graph {
|
||||||
|
background-color: #6cc644;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-bar-graph-alt {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
.archived-icon {
|
.archived-icon {
|
||||||
color: lighten(#000000, 70%) !important;
|
color: lighten(#000000, 70%) !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1353,6 +1353,11 @@ a.ui.labels .label:hover {
|
||||||
.heatmap(100%);
|
.heatmap(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-bar-graph {
|
||||||
|
background-color: #a0cc75;
|
||||||
|
color: #9e9e9e;
|
||||||
|
}
|
||||||
|
|
||||||
/* code mirror dark theme */
|
/* code mirror dark theme */
|
||||||
|
|
||||||
.CodeMirror {
|
.CodeMirror {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const TerserPlugin = require('terser-webpack-plugin');
|
const TerserPlugin = require('terser-webpack-plugin');
|
||||||
const { SourceMapDevToolPlugin } = require('webpack');
|
const { SourceMapDevToolPlugin } = require('webpack');
|
||||||
|
const VueLoaderPlugin = require('vue-loader/lib/plugin');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
mode: 'production',
|
mode: 'production',
|
||||||
|
@ -28,6 +29,11 @@ module.exports = {
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.vue$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
loader: 'vue-loader'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
test: /\.js$/,
|
test: /\.js$/,
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
|
@ -49,7 +55,8 @@ module.exports = {
|
||||||
{
|
{
|
||||||
regenerator: true,
|
regenerator: true,
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
'@babel/plugin-proposal-object-rest-spread',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -61,6 +68,7 @@ module.exports = {
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
new VueLoaderPlugin(),
|
||||||
new SourceMapDevToolPlugin({
|
new SourceMapDevToolPlugin({
|
||||||
filename: '[name].js.map',
|
filename: '[name].js.map',
|
||||||
exclude: [
|
exclude: [
|
||||||
|
|
Loading…
Reference in a new issue