@@ -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), "", " ") |
@@ -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: [ |