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