@@ -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 { | |||||
if len(k) == 0 { | |||||
var unknownUserID int64 | |||||
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] | |||||
authors[email] = i + 1 | |||||
if _, ok := authors[email]; !ok { | |||||
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, 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 | 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), "", " ") | ||||
@@ -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}} | ||||
@@ -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', | ||||
@@ -29,6 +30,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/, | ||||
use: { | use: { | ||||
@@ -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: [ | ||||