mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Implement code frequency graph (#29191)
### Overview This is the implementation of Code Frequency page. This feature was mentioned on these issues: #18262, #7392. It adds another tab to Activity page called Code Frequency. Code Frequency tab shows additions and deletions over time since the repository existed. Before: <img width="1296" alt="image" src="https://github.com/go-gitea/gitea/assets/32161460/2603504f-aee7-4929-a8c4-fb3412a7a0f6"> After: <img width="1296" alt="image" src="https://github.com/go-gitea/gitea/assets/32161460/58c03721-729f-4536-a663-9f337f240963"> --- #### Features - See additions deletions over time since repository existed - Click on "Additions" or "Deletions" legend to show only one type of contribution - Use the same cache from Contributors page so that the loading of data will be fast once it is cached by visiting either one of the pages --------- Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
		| @@ -1919,6 +1919,7 @@ wiki.original_git_entry_tooltip = View original Git file instead of using friend | ||||
| activity = Activity | ||||
| activity.navbar.pulse = Pulse | ||||
| activity.navbar.contributors = Contributors | ||||
| activity.navbar.code_frequency = Code Frequency | ||||
| activity.period.filter_label = Period: | ||||
| activity.period.daily = 1 day | ||||
| activity.period.halfweekly = 3 days | ||||
| @@ -2597,6 +2598,7 @@ component_loading = Loading %s... | ||||
| component_loading_failed = Could not load %s | ||||
| component_loading_info = This might take a bit… | ||||
| component_failed_to_load = An unexpected error happened. | ||||
| code_frequency.what = code frequency | ||||
| contributors.what = contributions | ||||
|  | ||||
| [org] | ||||
|   | ||||
							
								
								
									
										41
									
								
								routers/web/repo/code_frequency.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								routers/web/repo/code_frequency.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| // Copyright 2023 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
|  | ||||
| package repo | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"net/http" | ||||
|  | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/context" | ||||
| 	contributors_service "code.gitea.io/gitea/services/repository" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	tplCodeFrequency base.TplName = "repo/activity" | ||||
| ) | ||||
|  | ||||
| // CodeFrequency renders the page to show repository code frequency | ||||
| func CodeFrequency(ctx *context.Context) { | ||||
| 	ctx.Data["Title"] = ctx.Tr("repo.activity.navbar.code_frequency") | ||||
|  | ||||
| 	ctx.Data["PageIsActivity"] = true | ||||
| 	ctx.Data["PageIsCodeFrequency"] = true | ||||
| 	ctx.PageData["repoLink"] = ctx.Repo.RepoLink | ||||
|  | ||||
| 	ctx.HTML(http.StatusOK, tplCodeFrequency) | ||||
| } | ||||
|  | ||||
| // CodeFrequencyData returns JSON of code frequency data | ||||
| func CodeFrequencyData(ctx *context.Context) { | ||||
| 	if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { | ||||
| 		if errors.Is(err, contributors_service.ErrAwaitGeneration) { | ||||
| 			ctx.Status(http.StatusAccepted) | ||||
| 			return | ||||
| 		} | ||||
| 		ctx.ServerError("GetCodeFrequencyData", err) | ||||
| 	} else { | ||||
| 		ctx.JSON(http.StatusOK, contributorStats["total"].Weeks) | ||||
| 	} | ||||
| } | ||||
| @@ -1403,6 +1403,10 @@ func registerRoutes(m *web.Route) { | ||||
| 				m.Get("", repo.Contributors) | ||||
| 				m.Get("/data", repo.ContributorsData) | ||||
| 			}) | ||||
| 			m.Group("/code-frequency", func() { | ||||
| 				m.Get("", repo.CodeFrequency) | ||||
| 				m.Get("/data", repo.CodeFrequencyData) | ||||
| 			}) | ||||
| 		}, context.RepoRef(), repo.MustBeNotEmpty, context.RequireRepoReaderOr(unit.TypePullRequests, unit.TypeIssues, unit.TypeReleases)) | ||||
|  | ||||
| 		m.Group("/activity_author_data", func() { | ||||
|   | ||||
| @@ -143,7 +143,6 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int | ||||
| 		PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { | ||||
| 			_ = stdoutWriter.Close() | ||||
| 			scanner := bufio.NewScanner(stdoutReader) | ||||
| 			scanner.Split(bufio.ScanLines) | ||||
|  | ||||
| 			for scanner.Scan() { | ||||
| 				line := strings.TrimSpace(scanner.Text()) | ||||
| @@ -180,7 +179,6 @@ func getExtendedCommitStats(repo *git.Repository, revision string /*, limit int | ||||
| 					} | ||||
| 				} | ||||
| 				commitStats.Total = commitStats.Additions + commitStats.Deletions | ||||
| 				scanner.Scan() | ||||
| 				scanner.Text() // empty line at the end | ||||
|  | ||||
| 				res := &ExtendedCommitStats{ | ||||
|   | ||||
| @@ -8,6 +8,7 @@ | ||||
| 		<div class="flex-container-main"> | ||||
| 			{{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}} | ||||
| 			{{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}} | ||||
| 			{{if .PageIsCodeFrequency}}{{template "repo/code_frequency" .}}{{end}} | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
|   | ||||
							
								
								
									
										9
									
								
								templates/repo/code_frequency.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								templates/repo/code_frequency.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| {{if .Permission.CanRead $.UnitTypeCode}} | ||||
| 	<div id="repo-code-frequency-chart" | ||||
| 		data-locale-loading-title="{{ctx.Locale.Tr "graphs.component_loading" (ctx.Locale.Tr "graphs.code_frequency.what")}}" | ||||
| 		data-locale-loading-title-failed="{{ctx.Locale.Tr "graphs.component_loading_failed" (ctx.Locale.Tr "graphs.code_frequency.what")}}" | ||||
| 		data-locale-loading-info="{{ctx.Locale.Tr "graphs.component_loading_info"}}" | ||||
| 		data-locale-component-failed-to-load="{{ctx.Locale.Tr "graphs.component_failed_to_load"}}" | ||||
| 	> | ||||
| 	</div> | ||||
| {{end}} | ||||
| @@ -5,4 +5,7 @@ | ||||
| 	<a class="{{if .PageIsContributors}}active {{end}}item" href="{{.RepoLink}}/activity/contributors"> | ||||
| 		{{ctx.Locale.Tr "repo.activity.navbar.contributors"}} | ||||
| 	</a> | ||||
| 	<a class="{{if .PageIsCodeFrequency}}active{{end}} item" href="{{.RepoLink}}/activity/code-frequency"> | ||||
| 		{{ctx.Locale.Tr "repo.activity.navbar.code_frequency"}} | ||||
| 	</a> | ||||
| </div> | ||||
|   | ||||
							
								
								
									
										172
									
								
								web_src/js/components/RepoCodeFrequency.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								web_src/js/components/RepoCodeFrequency.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| <script> | ||||
| import {SvgIcon} from '../svg.js'; | ||||
| import { | ||||
|   Chart, | ||||
|   Legend, | ||||
|   LinearScale, | ||||
|   TimeScale, | ||||
|   PointElement, | ||||
|   LineElement, | ||||
|   Filler, | ||||
| } from 'chart.js'; | ||||
| import {GET} from '../modules/fetch.js'; | ||||
| import {Line as ChartLine} from 'vue-chartjs'; | ||||
| import { | ||||
|   startDaysBetween, | ||||
|   firstStartDateAfterDate, | ||||
|   fillEmptyStartDaysWithZeroes, | ||||
| } from '../utils/time.js'; | ||||
| import {chartJsColors} from '../utils/color.js'; | ||||
| import {sleep} from '../utils.js'; | ||||
| import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; | ||||
|  | ||||
| const {pageData} = window.config; | ||||
|  | ||||
| Chart.defaults.color = chartJsColors.text; | ||||
| Chart.defaults.borderColor = chartJsColors.border; | ||||
|  | ||||
| Chart.register( | ||||
|   TimeScale, | ||||
|   LinearScale, | ||||
|   Legend, | ||||
|   PointElement, | ||||
|   LineElement, | ||||
|   Filler, | ||||
| ); | ||||
|  | ||||
| export default { | ||||
|   components: {ChartLine, SvgIcon}, | ||||
|   props: { | ||||
|     locale: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     }, | ||||
|   }, | ||||
|   data: () => ({ | ||||
|     isLoading: false, | ||||
|     errorText: '', | ||||
|     repoLink: pageData.repoLink || [], | ||||
|     data: [], | ||||
|   }), | ||||
|   mounted() { | ||||
|     this.fetchGraphData(); | ||||
|   }, | ||||
|   methods: { | ||||
|     async fetchGraphData() { | ||||
|       this.isLoading = true; | ||||
|       try { | ||||
|         let response; | ||||
|         do { | ||||
|           response = await GET(`${this.repoLink}/activity/code-frequency/data`); | ||||
|           if (response.status === 202) { | ||||
|             await sleep(1000); // wait for 1 second before retrying | ||||
|           } | ||||
|         } while (response.status === 202); | ||||
|         if (response.ok) { | ||||
|           this.data = await response.json(); | ||||
|           const weekValues = Object.values(this.data); | ||||
|           const start = weekValues[0].week; | ||||
|           const end = firstStartDateAfterDate(new Date()); | ||||
|           const startDays = startDaysBetween(new Date(start), new Date(end)); | ||||
|           this.data = fillEmptyStartDaysWithZeroes(startDays, this.data); | ||||
|           this.errorText = ''; | ||||
|         } else { | ||||
|           this.errorText = response.statusText; | ||||
|         } | ||||
|       } catch (err) { | ||||
|         this.errorText = err.message; | ||||
|       } finally { | ||||
|         this.isLoading = false; | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     toGraphData(data) { | ||||
|       return { | ||||
|         datasets: [ | ||||
|           { | ||||
|             data: data.map((i) => ({x: i.week, y: i.additions})), | ||||
|             pointRadius: 0, | ||||
|             pointHitRadius: 0, | ||||
|             fill: true, | ||||
|             label: 'Additions', | ||||
|             backgroundColor: chartJsColors['additions'], | ||||
|             borderWidth: 0, | ||||
|             tension: 0.3, | ||||
|           }, | ||||
|           { | ||||
|             data: data.map((i) => ({x: i.week, y: -i.deletions})), | ||||
|             pointRadius: 0, | ||||
|             pointHitRadius: 0, | ||||
|             fill: true, | ||||
|             label: 'Deletions', | ||||
|             backgroundColor: chartJsColors['deletions'], | ||||
|             borderWidth: 0, | ||||
|             tension: 0.3, | ||||
|           }, | ||||
|         ], | ||||
|       }; | ||||
|     }, | ||||
|  | ||||
|     getOptions() { | ||||
|       return { | ||||
|         responsive: true, | ||||
|         maintainAspectRatio: false, | ||||
|         animation: true, | ||||
|         plugins: { | ||||
|           legend: { | ||||
|             display: true, | ||||
|           }, | ||||
|         }, | ||||
|         scales: { | ||||
|           x: { | ||||
|             type: 'time', | ||||
|             grid: { | ||||
|               display: false, | ||||
|             }, | ||||
|             time: { | ||||
|               minUnit: 'month', | ||||
|             }, | ||||
|             ticks: { | ||||
|               maxRotation: 0, | ||||
|               maxTicksLimit: 12 | ||||
|             }, | ||||
|           }, | ||||
|           y: { | ||||
|             ticks: { | ||||
|               maxTicksLimit: 6 | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="ui header gt-df gt-ac gt-sb"> | ||||
|       {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: `Code frequency over the history of ${repoLink.slice(1)}` }} | ||||
|     </div> | ||||
|     <div class="gt-df ui segment main-graph"> | ||||
|       <div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto"> | ||||
|         <div v-if="isLoading"> | ||||
|           <SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/> | ||||
|           {{ locale.loadingInfo }} | ||||
|         </div> | ||||
|         <div v-else class="text red"> | ||||
|           <SvgIcon name="octicon-x-circle-fill"/> | ||||
|           {{ errorText }} | ||||
|         </div> | ||||
|       </div> | ||||
|       <ChartLine | ||||
|         v-memo="data" v-if="data.length !== 0" | ||||
|         :data="toGraphData(data)" :options="getOptions()" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| <style scoped> | ||||
| .main-graph { | ||||
|   height: 440px; | ||||
| } | ||||
| </style> | ||||
| @@ -3,10 +3,7 @@ import {SvgIcon} from '../svg.js'; | ||||
| import { | ||||
|   Chart, | ||||
|   Title, | ||||
|   Tooltip, | ||||
|   Legend, | ||||
|   BarElement, | ||||
|   CategoryScale, | ||||
|   LinearScale, | ||||
|   TimeScale, | ||||
|   PointElement, | ||||
| @@ -21,27 +18,13 @@ import { | ||||
|   firstStartDateAfterDate, | ||||
|   fillEmptyStartDaysWithZeroes, | ||||
| } from '../utils/time.js'; | ||||
| import {chartJsColors} from '../utils/color.js'; | ||||
| import {sleep} from '../utils.js'; | ||||
| import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; | ||||
| import $ from 'jquery'; | ||||
|  | ||||
| const {pageData} = window.config; | ||||
|  | ||||
| const colors = { | ||||
|   text: '--color-text', | ||||
|   border: '--color-secondary-alpha-60', | ||||
|   commits: '--color-primary-alpha-60', | ||||
|   additions: '--color-green', | ||||
|   deletions: '--color-red', | ||||
|   title: '--color-secondary-dark-4', | ||||
| }; | ||||
|  | ||||
| const styles = window.getComputedStyle(document.documentElement); | ||||
| const getColor = (name) => styles.getPropertyValue(name).trim(); | ||||
|  | ||||
| for (const [key, value] of Object.entries(colors)) { | ||||
|   colors[key] = getColor(value); | ||||
| } | ||||
|  | ||||
| const customEventListener = { | ||||
|   id: 'customEventListener', | ||||
|   afterEvent: (chart, args, opts) => { | ||||
| @@ -54,17 +37,14 @@ const customEventListener = { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| Chart.defaults.color = colors.text; | ||||
| Chart.defaults.borderColor = colors.border; | ||||
| Chart.defaults.color = chartJsColors.text; | ||||
| Chart.defaults.borderColor = chartJsColors.border; | ||||
|  | ||||
| Chart.register( | ||||
|   TimeScale, | ||||
|   CategoryScale, | ||||
|   LinearScale, | ||||
|   BarElement, | ||||
|   Title, | ||||
|   Tooltip, | ||||
|   Legend, | ||||
|   PointElement, | ||||
|   LineElement, | ||||
|   Filler, | ||||
| @@ -122,7 +102,7 @@ export default { | ||||
|         do { | ||||
|           response = await GET(`${this.repoLink}/activity/contributors/data`); | ||||
|           if (response.status === 202) { | ||||
|             await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying | ||||
|             await sleep(1000); // wait for 1 second before retrying | ||||
|           } | ||||
|         } while (response.status === 202); | ||||
|         if (response.ok) { | ||||
| @@ -222,7 +202,7 @@ export default { | ||||
|             pointRadius: 0, | ||||
|             pointHitRadius: 0, | ||||
|             fill: 'start', | ||||
|             backgroundColor: colors[this.type], | ||||
|             backgroundColor: chartJsColors[this.type], | ||||
|             borderWidth: 0, | ||||
|             tension: 0.3, | ||||
|           }, | ||||
| @@ -254,7 +234,6 @@ export default { | ||||
|           title: { | ||||
|             display: type === 'main', | ||||
|             text: 'drag: zoom, shift+drag: pan, double click: reset zoom', | ||||
|             color: colors.title, | ||||
|             position: 'top', | ||||
|             align: 'center', | ||||
|           }, | ||||
| @@ -262,9 +241,6 @@ export default { | ||||
|             chartType: type, | ||||
|             instance: this, | ||||
|           }, | ||||
|           legend: { | ||||
|             display: false, | ||||
|           }, | ||||
|           zoom: { | ||||
|             pan: { | ||||
|               enabled: true, | ||||
|   | ||||
							
								
								
									
										21
									
								
								web_src/js/features/code-frequency.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								web_src/js/features/code-frequency.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import {createApp} from 'vue'; | ||||
|  | ||||
| export async function initRepoCodeFrequency() { | ||||
|   const el = document.getElementById('repo-code-frequency-chart'); | ||||
|   if (!el) return; | ||||
|  | ||||
|   const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue'); | ||||
|   try { | ||||
|     const View = createApp(RepoCodeFrequency, { | ||||
|       locale: { | ||||
|         loadingTitle: el.getAttribute('data-locale-loading-title'), | ||||
|         loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'), | ||||
|         loadingInfo: el.getAttribute('data-locale-loading-info'), | ||||
|       } | ||||
|     }); | ||||
|     View.mount(el); | ||||
|   } catch (err) { | ||||
|     console.error('RepoCodeFrequency failed to load', err); | ||||
|     el.textContent = el.getAttribute('data-locale-component-failed-to-load'); | ||||
|   } | ||||
| } | ||||
| @@ -87,6 +87,7 @@ import {onDomReady} from './utils/dom.js'; | ||||
| import {initRepoIssueList} from './features/repo-issue-list.js'; | ||||
| import {initCommonIssueListQuickGoto} from './features/common-issue-list.js'; | ||||
| import {initRepoContributors} from './features/contributors.js'; | ||||
| import {initRepoCodeFrequency} from './features/code-frequency.js'; | ||||
| import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js'; | ||||
| import {initDirAuto} from './modules/dirauto.js'; | ||||
|  | ||||
| @@ -177,6 +178,7 @@ onDomReady(() => { | ||||
|   initRepository(); | ||||
|   initRepositoryActionView(); | ||||
|   initRepoContributors(); | ||||
|   initRepoCodeFrequency(); | ||||
|  | ||||
|   initCommitStatuses(); | ||||
|   initCaptcha(); | ||||
|   | ||||
| @@ -139,3 +139,5 @@ export function parseDom(text, contentType) { | ||||
| export function serializeXml(node) { | ||||
|   return xmlSerializer.serializeToString(node); | ||||
| } | ||||
|  | ||||
| export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | ||||
|   | ||||
| @@ -19,3 +19,17 @@ function getLuminance(r, g, b) { | ||||
| export function useLightTextOnBackground(r, g, b) { | ||||
|   return getLuminance(r, g, b) < 0.453; | ||||
| } | ||||
|  | ||||
| function resolveColors(obj) { | ||||
|   const styles = window.getComputedStyle(document.documentElement); | ||||
|   const getColor = (name) => styles.getPropertyValue(name).trim(); | ||||
|   return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)])); | ||||
| } | ||||
|  | ||||
| export const chartJsColors = resolveColors({ | ||||
|   text: '--color-text', | ||||
|   border: '--color-secondary-alpha-60', | ||||
|   commits: '--color-primary-alpha-60', | ||||
|   additions: '--color-green', | ||||
|   deletions: '--color-red', | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Şahin Akkaya
					Şahin Akkaya