mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Add top author stats to activity page (#9615)
This commit is contained in:
		| @@ -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 { | 	var unknownUserID int64 | ||||||
| 		if len(k) == 0 { | 	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] | 			if _, ok := authors[email]; !ok { | ||||||
| 			authors[email] = i + 1 | 				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, "tris.git@shoddynet.org", code.Authors[1].Email) | ||||||
| 	assert.EqualValues(t, 3, code.Authors["tris.git@shoddynet.org"]) | 	assert.EqualValues(t, 3, code.Authors[1].Commits) | ||||||
| 	assert.EqualValues(t, 5, code.Authors[""]) | 	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), "", "  ") | ||||||
|   | |||||||
							
								
								
									
										1206
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1206
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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}} | ||||||
|   | |||||||
							
								
								
									
										102
									
								
								web_src/js/components/ActivityTopAuthors.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								web_src/js/components/ActivityTopAuthors.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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: [ | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Lauris BH
					Lauris BH