Add top author stats to activity page (#9615)
parent
7d7ab1eeae
commit
81cfe243f9
|
@ -19,6 +19,7 @@ type ActivityAuthorData struct {
|
|||
Name string `json:"name"`
|
||||
Login string `json:"login"`
|
||||
AvatarLink string `json:"avatar_link"`
|
||||
HomeLink string `json:"home_link"`
|
||||
Commits int64 `json:"commits"`
|
||||
}
|
||||
|
||||
|
@ -91,12 +92,20 @@ func GetActivityStatsTopAuthors(repo *Repository, timeFrom time.Time, count int)
|
|||
return nil, nil
|
||||
}
|
||||
users := make(map[int64]*ActivityAuthorData)
|
||||
for k, v := range code.Authors {
|
||||
if len(k) == 0 {
|
||||
var unknownUserID int64
|
||||
unknownUserAvatarLink := NewGhostUser().AvatarLink()
|
||||
for _, v := range code.Authors {
|
||||
if len(v.Email) == 0 {
|
||||
continue
|
||||
}
|
||||
u, err := GetUserByEmail(k)
|
||||
u, err := GetUserByEmail(v.Email)
|
||||
if u == nil || IsErrUserNotExist(err) {
|
||||
unknownUserID--
|
||||
users[unknownUserID] = &ActivityAuthorData{
|
||||
Name: v.Name,
|
||||
AvatarLink: unknownUserAvatarLink,
|
||||
Commits: v.Commits,
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
|
@ -107,10 +116,11 @@ func GetActivityStatsTopAuthors(repo *Repository, timeFrom time.Time, count int)
|
|||
Name: u.DisplayName(),
|
||||
Login: u.LowerName,
|
||||
AvatarLink: u.AvatarLink(),
|
||||
Commits: v,
|
||||
HomeLink: u.HomeLink(),
|
||||
Commits: v.Commits,
|
||||
}
|
||||
} else {
|
||||
user.Commits += v
|
||||
user.Commits += v.Commits
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return v[i].Commits < v[j].Commits
|
||||
return v[i].Commits > v[j].Commits
|
||||
})
|
||||
|
||||
cnt := count
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -21,7 +22,14 @@ type CodeActivityStats struct {
|
|||
Additions int64
|
||||
Deletions 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
|
||||
|
@ -58,8 +66,9 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
|
|||
stats.CommitCount = 0
|
||||
stats.Additions = 0
|
||||
stats.Deletions = 0
|
||||
authors := make(map[string]int64)
|
||||
authors := make(map[string]*CodeActivityAuthor)
|
||||
files := make(map[string]bool)
|
||||
var author string
|
||||
p := 0
|
||||
for scanner.Scan() {
|
||||
l := strings.TrimSpace(scanner.Text())
|
||||
|
@ -78,10 +87,17 @@ func (repo *Repository) GetCodeActivityStats(fromTime time.Time, branch string)
|
|||
case 2: // Commit sha-1
|
||||
stats.CommitCount++
|
||||
case 3: // Author
|
||||
author = l
|
||||
case 4: // E-mail
|
||||
email := strings.ToLower(l)
|
||||
i := authors[email]
|
||||
authors[email] = i + 1
|
||||
if _, ok := authors[email]; !ok {
|
||||
authors[email] = &CodeActivityAuthor{
|
||||
Name: author,
|
||||
Email: email,
|
||||
Commits: 0,
|
||||
}
|
||||
}
|
||||
authors[email].Commits++
|
||||
default: // Changed file
|
||||
if parts := strings.Fields(l); len(parts) >= 3 {
|
||||
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.ChangedFiles = int64(len(files))
|
||||
stats.Authors = authors
|
||||
stats.Authors = a
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ func TestRepository_GetCodeActivityStats(t *testing.T) {
|
|||
assert.EqualValues(t, 10, code.Additions)
|
||||
assert.EqualValues(t, 1, code.Deletions)
|
||||
assert.Len(t, code.Authors, 3)
|
||||
assert.Contains(t, code.Authors, "tris.git@shoddynet.org")
|
||||
assert.EqualValues(t, 3, code.Authors["tris.git@shoddynet.org"])
|
||||
assert.EqualValues(t, 5, code.Authors[""])
|
||||
assert.EqualValues(t, "tris.git@shoddynet.org", code.Authors[1].Email)
|
||||
assert.EqualValues(t, 3, code.Authors[1].Commits)
|
||||
assert.EqualValues(t, 5, code.Authors[0].Commits)
|
||||
}
|
||||
|
|
|
@ -182,6 +182,13 @@ func NewFuncMap() []template.FuncMap {
|
|||
}
|
||||
return path
|
||||
},
|
||||
"Json": func(in interface{}) string {
|
||||
out, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
},
|
||||
"JsonPrettyPrint": func(in string) string {
|
||||
var out bytes.Buffer
|
||||
err := json.Indent(&out, []byte(in), "", " ")
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -5,10 +5,12 @@
|
|||
"node": ">=10"
|
||||
},
|
||||
"dependencies": {
|
||||
"swagger-ui": "3.24.3"
|
||||
"swagger-ui": "3.24.3",
|
||||
"vue-bar-graph": "1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.7.7",
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.7.7",
|
||||
"@babel/plugin-transform-runtime": "7.7.6",
|
||||
"@babel/preset-env": "7.7.7",
|
||||
"@babel/runtime": "7.7.7",
|
||||
|
@ -27,6 +29,8 @@
|
|||
"stylelint-config-standard": "19.0.0",
|
||||
"terser-webpack-plugin": "2.3.2",
|
||||
"updates": "9.3.3",
|
||||
"vue-loader": "15.8.3",
|
||||
"vue-template-compiler": "2.6.11",
|
||||
"webpack": "4.41.5",
|
||||
"webpack-cli": "3.3.10"
|
||||
},
|
||||
|
|
|
@ -59,6 +59,11 @@ func Activity(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if ctx.Data["ActivityTopAuthors"], err = models.GetActivityStatsTopAuthors(ctx.Repo.Repository, timeFrom, 10); err != nil {
|
||||
ctx.ServerError("GetActivityStatsTopAuthors", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(200, tplActivity)
|
||||
}
|
||||
|
||||
|
|
|
@ -108,6 +108,12 @@
|
|||
{{.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>.
|
||||
</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>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
@ -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 initContextPopups from './features/contextPopup';
|
||||
|
||||
import ActivityTopAuthors from './components/ActivityTopAuthors.vue';
|
||||
|
||||
function htmlEncode(text) {
|
||||
return jQuery('<div />').text(text).html();
|
||||
}
|
||||
|
@ -2894,9 +2896,13 @@ function initVueApp() {
|
|||
delimiters: ['${', '}'],
|
||||
el,
|
||||
data: {
|
||||
searchLimit: document.querySelector('meta[name=_search_limit]').content,
|
||||
searchLimit: (document.querySelector('meta[name=_search_limit]') || {}).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;
|
||||
}
|
||||
|
||||
.activity-bar-graph {
|
||||
background-color: #6cc644;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.activity-bar-graph-alt {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.archived-icon {
|
||||
color: lighten(#000000, 70%) !important;
|
||||
}
|
||||
|
|
|
@ -1353,6 +1353,11 @@ a.ui.labels .label:hover {
|
|||
.heatmap(100%);
|
||||
}
|
||||
|
||||
.activity-bar-graph {
|
||||
background-color: #a0cc75;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
/* code mirror dark theme */
|
||||
|
||||
.CodeMirror {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const path = require('path');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const { SourceMapDevToolPlugin } = require('webpack');
|
||||
const VueLoaderPlugin = require('vue-loader/lib/plugin');
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
|
@ -28,6 +29,11 @@ module.exports = {
|
|||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'vue-loader'
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
|
@ -49,7 +55,8 @@ module.exports = {
|
|||
{
|
||||
regenerator: true,
|
||||
}
|
||||
]
|
||||
],
|
||||
'@babel/plugin-proposal-object-rest-spread',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
@ -61,6 +68,7 @@ module.exports = {
|
|||
]
|
||||
},
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
new SourceMapDevToolPlugin({
|
||||
filename: '[name].js.map',
|
||||
exclude: [
|
||||
|
|
Loading…
Reference in New Issue