| @@ -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: [ | |||