mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 11:41:32 +01:00 
			
		
		
		
	Issue time estimate, meaningful time tracking (#23113)
Redesign the time tracker side bar, and add "time estimate" support (in "1d 2m" format) Closes #23112 --------- Co-authored-by: stuzer05 <stuzer05@gmail.com> Co-authored-by: Yarden Shoham <hrsi88@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		
							parent
							
								
									c5422fae9a
								
							
						
					
					
						commit
						936665bf85
					
				| @ -114,6 +114,8 @@ const ( | ||||
| 
 | ||||
| 	CommentTypePin   // 36 pin Issue | ||||
| 	CommentTypeUnpin // 37 unpin Issue | ||||
| 
 | ||||
| 	CommentTypeChangeTimeEstimate // 38 Change time estimate | ||||
| ) | ||||
| 
 | ||||
| var commentStrings = []string{ | ||||
| @ -155,6 +157,7 @@ var commentStrings = []string{ | ||||
| 	"pull_cancel_scheduled_merge", | ||||
| 	"pin", | ||||
| 	"unpin", | ||||
| 	"change_time_estimate", | ||||
| } | ||||
| 
 | ||||
| func (t CommentType) String() string { | ||||
|  | ||||
| @ -147,6 +147,9 @@ type Issue struct { | ||||
| 
 | ||||
| 	// For view issue page. | ||||
| 	ShowRole RoleDescriptor `xorm:"-"` | ||||
| 
 | ||||
| 	// Time estimate | ||||
| 	TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"` | ||||
| } | ||||
| 
 | ||||
| var ( | ||||
| @ -934,3 +937,28 @@ func insertIssue(ctx context.Context, issue *Issue) error { | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // ChangeIssueTimeEstimate changes the plan time of this issue, as the given user. | ||||
| func ChangeIssueTimeEstimate(ctx context.Context, issue *Issue, doer *user_model.User, timeEstimate int64) error { | ||||
| 	return db.WithTx(ctx, func(ctx context.Context) error { | ||||
| 		if err := UpdateIssueCols(ctx, &Issue{ID: issue.ID, TimeEstimate: timeEstimate}, "time_estimate"); err != nil { | ||||
| 			return fmt.Errorf("updateIssueCols: %w", err) | ||||
| 		} | ||||
| 
 | ||||
| 		if err := issue.LoadRepo(ctx); err != nil { | ||||
| 			return fmt.Errorf("loadRepo: %w", err) | ||||
| 		} | ||||
| 
 | ||||
| 		opts := &CreateCommentOptions{ | ||||
| 			Type:    CommentTypeChangeTimeEstimate, | ||||
| 			Doer:    doer, | ||||
| 			Repo:    issue.Repo, | ||||
| 			Issue:   issue, | ||||
| 			Content: fmt.Sprintf("%d", timeEstimate), | ||||
| 		} | ||||
| 		if _, err := CreateComment(ctx, opts); err != nil { | ||||
| 			return fmt.Errorf("createComment: %w", err) | ||||
| 		} | ||||
| 		return nil | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| @ -368,6 +368,7 @@ func prepareMigrationTasks() []*migration { | ||||
| 		newMigration(308, "Add index(user_id, is_deleted) for action table", v1_23.AddNewIndexForUserDashboard), | ||||
| 		newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices), | ||||
| 		newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch), | ||||
| 		newMigration(311, "Add TimeEstimate to Issue table", v1_23.AddTimeEstimateColumnToIssueTable), | ||||
| 	} | ||||
| 	return preparedMigrations | ||||
| } | ||||
|  | ||||
							
								
								
									
										16
									
								
								models/migrations/v1_23/v311.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								models/migrations/v1_23/v311.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | ||||
| // Copyright 2024 The Gitea Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
| 
 | ||||
| package v1_23 //nolint | ||||
| 
 | ||||
| import ( | ||||
| 	"xorm.io/xorm" | ||||
| ) | ||||
| 
 | ||||
| func AddTimeEstimateColumnToIssueTable(x *xorm.Engine) error { | ||||
| 	type Issue struct { | ||||
| 		TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"` | ||||
| 	} | ||||
| 
 | ||||
| 	return x.Sync(new(Issue)) | ||||
| } | ||||
| @ -70,6 +70,9 @@ func NewFuncMap() template.FuncMap { | ||||
| 		"FileSize": base.FileSize, | ||||
| 		"CountFmt": base.FormatNumberSI, | ||||
| 		"Sec2Time": util.SecToTime, | ||||
| 
 | ||||
| 		"TimeEstimateString": timeEstimateString, | ||||
| 
 | ||||
| 		"LoadTimes": func(startTime time.Time) string { | ||||
| 			return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" | ||||
| 		}, | ||||
| @ -282,6 +285,14 @@ func userThemeName(user *user_model.User) string { | ||||
| 	return setting.UI.DefaultTheme | ||||
| } | ||||
| 
 | ||||
| func timeEstimateString(timeSec any) string { | ||||
| 	v, _ := util.ToInt64(timeSec) | ||||
| 	if v == 0 { | ||||
| 		return "" | ||||
| 	} | ||||
| 	return util.TimeEstimateString(v) | ||||
| } | ||||
| 
 | ||||
| func panicIfDevOrTesting() { | ||||
| 	if !setting.IsProd || setting.IsInTesting { | ||||
| 		panic("legacy template functions are for backward compatibility only, do not use them in new code") | ||||
|  | ||||
							
								
								
									
										85
									
								
								modules/util/time_str.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								modules/util/time_str.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,85 @@ | ||||
| // Copyright 2024 Gitea. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
| 
 | ||||
| package util | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"regexp" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| ) | ||||
| 
 | ||||
| type timeStrGlobalVarsType struct { | ||||
| 	units []struct { | ||||
| 		name string | ||||
| 		num  int64 | ||||
| 	} | ||||
| 	re *regexp.Regexp | ||||
| } | ||||
| 
 | ||||
| // When tracking working time, only hour/minute/second units are accurate and could be used. | ||||
| // For other units like "day", it depends on "how many working hours in a day": 6 or 7 or 8? | ||||
| // So at the moment, we only support hour/minute/second units. | ||||
| // In the future, it could be some configurable options to help users | ||||
| // to convert the working time to different units. | ||||
| 
 | ||||
| var timeStrGlobalVars = sync.OnceValue[*timeStrGlobalVarsType](func() *timeStrGlobalVarsType { | ||||
| 	v := &timeStrGlobalVarsType{} | ||||
| 	v.re = regexp.MustCompile(`(?i)(\d+)\s*([hms])`) | ||||
| 	v.units = []struct { | ||||
| 		name string | ||||
| 		num  int64 | ||||
| 	}{ | ||||
| 		{"h", 60 * 60}, | ||||
| 		{"m", 60}, | ||||
| 		{"s", 1}, | ||||
| 	} | ||||
| 	return v | ||||
| }) | ||||
| 
 | ||||
| func TimeEstimateParse(timeStr string) (int64, error) { | ||||
| 	if timeStr == "" { | ||||
| 		return 0, nil | ||||
| 	} | ||||
| 	var total int64 | ||||
| 	matches := timeStrGlobalVars().re.FindAllStringSubmatchIndex(timeStr, -1) | ||||
| 	if len(matches) == 0 { | ||||
| 		return 0, fmt.Errorf("invalid time string: %s", timeStr) | ||||
| 	} | ||||
| 	if matches[0][0] != 0 || matches[len(matches)-1][1] != len(timeStr) { | ||||
| 		return 0, fmt.Errorf("invalid time string: %s", timeStr) | ||||
| 	} | ||||
| 	for _, match := range matches { | ||||
| 		amount, err := strconv.ParseInt(timeStr[match[2]:match[3]], 10, 64) | ||||
| 		if err != nil { | ||||
| 			return 0, fmt.Errorf("invalid time string: %v", err) | ||||
| 		} | ||||
| 		unit := timeStr[match[4]:match[5]] | ||||
| 		found := false | ||||
| 		for _, u := range timeStrGlobalVars().units { | ||||
| 			if strings.ToLower(unit) == u.name { | ||||
| 				total += amount * u.num | ||||
| 				found = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 		if !found { | ||||
| 			return 0, fmt.Errorf("invalid time unit: %s", unit) | ||||
| 		} | ||||
| 	} | ||||
| 	return total, nil | ||||
| } | ||||
| 
 | ||||
| func TimeEstimateString(amount int64) string { | ||||
| 	var timeParts []string | ||||
| 	for _, u := range timeStrGlobalVars().units { | ||||
| 		if amount >= u.num { | ||||
| 			num := amount / u.num | ||||
| 			amount %= u.num | ||||
| 			timeParts = append(timeParts, fmt.Sprintf("%d%s", num, u.name)) | ||||
| 		} | ||||
| 	} | ||||
| 	return strings.Join(timeParts, " ") | ||||
| } | ||||
							
								
								
									
										55
									
								
								modules/util/time_str_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								modules/util/time_str_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | ||||
| // Copyright 2024 Gitea. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
| 
 | ||||
| package util | ||||
| 
 | ||||
| import ( | ||||
| 	"testing" | ||||
| 
 | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| 
 | ||||
| func TestTimeStr(t *testing.T) { | ||||
| 	t.Run("Parse", func(t *testing.T) { | ||||
| 		// Test TimeEstimateParse | ||||
| 		tests := []struct { | ||||
| 			input  string | ||||
| 			output int64 | ||||
| 			err    bool | ||||
| 		}{ | ||||
| 			{"1h", 3600, false}, | ||||
| 			{"1m", 60, false}, | ||||
| 			{"1s", 1, false}, | ||||
| 			{"1h 1m 1s", 3600 + 60 + 1, false}, | ||||
| 			{"1d1x", 0, true}, | ||||
| 		} | ||||
| 		for _, test := range tests { | ||||
| 			t.Run(test.input, func(t *testing.T) { | ||||
| 				output, err := TimeEstimateParse(test.input) | ||||
| 				if test.err { | ||||
| 					assert.NotNil(t, err) | ||||
| 				} else { | ||||
| 					assert.Nil(t, err) | ||||
| 				} | ||||
| 				assert.Equal(t, test.output, output) | ||||
| 			}) | ||||
| 		} | ||||
| 	}) | ||||
| 	t.Run("String", func(t *testing.T) { | ||||
| 		tests := []struct { | ||||
| 			input  int64 | ||||
| 			output string | ||||
| 		}{ | ||||
| 			{3600, "1h"}, | ||||
| 			{60, "1m"}, | ||||
| 			{1, "1s"}, | ||||
| 			{3600 + 1, "1h 1s"}, | ||||
| 		} | ||||
| 		for _, test := range tests { | ||||
| 			t.Run(test.output, func(t *testing.T) { | ||||
| 				output := TimeEstimateString(test.input) | ||||
| 				assert.Equal(t, test.output, output) | ||||
| 			}) | ||||
| 		} | ||||
| 	}) | ||||
| } | ||||
| @ -1670,27 +1670,34 @@ issues.comment_on_locked = You cannot comment on a locked issue. | ||||
| issues.delete = Delete | ||||
| issues.delete.title = Delete this issue? | ||||
| issues.delete.text = Do you really want to delete this issue? (This will permanently remove all content. Consider closing it instead, if you intend to keep it archived) | ||||
| 
 | ||||
| issues.tracker = Time Tracker | ||||
| issues.start_tracking_short = Start Timer | ||||
| issues.start_tracking = Start Time Tracking | ||||
| issues.start_tracking_history = `started working %s` | ||||
| issues.timetracker_timer_start = Start timer | ||||
| issues.timetracker_timer_stop = Stop timer | ||||
| issues.timetracker_timer_discard = Discard timer | ||||
| issues.timetracker_timer_manually_add = Add Time | ||||
| 
 | ||||
| issues.time_estimate_placeholder = 1h 2m | ||||
| issues.time_estimate_set = Set estimated time | ||||
| issues.time_estimate_display = Estimate: %s | ||||
| issues.change_time_estimate_at = changed time estimate to <b>%s</b> %s | ||||
| issues.remove_time_estimate_at = removed time estimate %s | ||||
| issues.time_estimate_invalid = Time estimate format is invalid | ||||
| issues.start_tracking_history = started working %s | ||||
| issues.tracker_auto_close = Timer will be stopped automatically when this issue gets closed | ||||
| issues.tracking_already_started = `You have already started time tracking on <a href="%s">another issue</a>!` | ||||
| issues.stop_tracking = Stop Timer | ||||
| issues.stop_tracking_history = `stopped working %s` | ||||
| issues.cancel_tracking = Discard | ||||
| issues.stop_tracking_history = worked for <b>%s</b> %s | ||||
| issues.cancel_tracking_history = `canceled time tracking %s` | ||||
| issues.add_time = Manually Add Time | ||||
| issues.del_time = Delete this time log | ||||
| issues.add_time_short = Add Time | ||||
| issues.add_time_cancel = Cancel | ||||
| issues.add_time_history = `added spent time %s` | ||||
| issues.add_time_history = added spent time <b>%s</b> %s | ||||
| issues.del_time_history= `deleted spent time %s` | ||||
| issues.add_time_manually = Manually Add Time | ||||
| issues.add_time_hours = Hours | ||||
| issues.add_time_minutes = Minutes | ||||
| issues.add_time_sum_to_small = No time was entered. | ||||
| issues.time_spent_total = Total Time Spent | ||||
| issues.time_spent_from_all_authors = `Total Time Spent: %s` | ||||
| 
 | ||||
| issues.due_date = Due Date | ||||
| issues.invalid_due_date_format = "Due date format must be 'yyyy-mm-dd'." | ||||
| issues.error_modifying_due_date = "Failed to modify the due date." | ||||
|  | ||||
| @ -4,7 +4,6 @@ | ||||
| package repo | ||||
| 
 | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| @ -40,8 +39,7 @@ func IssueStopwatch(c *context.Context) { | ||||
| 		c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) | ||||
| 	} | ||||
| 
 | ||||
| 	url := issue.Link() | ||||
| 	c.Redirect(url, http.StatusSeeOther) | ||||
| 	c.JSONRedirect("") | ||||
| } | ||||
| 
 | ||||
| // CancelStopwatch cancel the stopwatch | ||||
| @ -72,8 +70,7 @@ func CancelStopwatch(c *context.Context) { | ||||
| 		}) | ||||
| 	} | ||||
| 
 | ||||
| 	url := issue.Link() | ||||
| 	c.Redirect(url, http.StatusSeeOther) | ||||
| 	c.JSONRedirect("") | ||||
| } | ||||
| 
 | ||||
| // GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context | ||||
|  | ||||
| @ -5,6 +5,7 @@ package repo | ||||
| 
 | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| @ -13,6 +14,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/web" | ||||
| 	"code.gitea.io/gitea/services/context" | ||||
| 	"code.gitea.io/gitea/services/forms" | ||||
| 	issue_service "code.gitea.io/gitea/services/issue" | ||||
| ) | ||||
| 
 | ||||
| // AddTimeManually tracks time manually | ||||
| @ -26,19 +28,16 @@ func AddTimeManually(c *context.Context) { | ||||
| 		c.NotFound("CanUseTimetracker", nil) | ||||
| 		return | ||||
| 	} | ||||
| 	url := issue.Link() | ||||
| 
 | ||||
| 	if c.HasError() { | ||||
| 		c.Flash.Error(c.GetErrMsg()) | ||||
| 		c.Redirect(url) | ||||
| 		c.JSONError(c.GetErrMsg()) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute | ||||
| 
 | ||||
| 	if total <= 0 { | ||||
| 		c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small")) | ||||
| 		c.Redirect(url, http.StatusSeeOther) | ||||
| 		c.JSONError(c.Tr("repo.issues.add_time_sum_to_small")) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| @ -47,7 +46,7 @@ func AddTimeManually(c *context.Context) { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	c.Redirect(url, http.StatusSeeOther) | ||||
| 	c.JSONRedirect("") | ||||
| } | ||||
| 
 | ||||
| // DeleteTime deletes tracked time | ||||
| @ -83,5 +82,38 @@ func DeleteTime(c *context.Context) { | ||||
| 	} | ||||
| 
 | ||||
| 	c.Flash.Success(c.Tr("repo.issues.del_time_history", util.SecToTime(t.Time))) | ||||
| 	c.Redirect(issue.Link()) | ||||
| 	c.JSONRedirect("") | ||||
| } | ||||
| 
 | ||||
| func UpdateIssueTimeEstimate(ctx *context.Context) { | ||||
| 	issue := GetActionIssue(ctx) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { | ||||
| 		ctx.Error(http.StatusForbidden) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	timeStr := strings.TrimSpace(ctx.FormString("time_estimate")) | ||||
| 
 | ||||
| 	total, err := util.TimeEstimateParse(timeStr) | ||||
| 	if err != nil { | ||||
| 		ctx.JSONError(ctx.Tr("repo.issues.time_estimate_invalid")) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// No time changed | ||||
| 	if issue.TimeEstimate == total { | ||||
| 		ctx.JSONRedirect("") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if err := issue_service.ChangeTimeEstimate(ctx, issue, ctx.Doer, total); err != nil { | ||||
| 		ctx.ServerError("ChangeTimeEstimate", err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	ctx.JSONRedirect("") | ||||
| } | ||||
|  | ||||
| @ -1235,6 +1235,7 @@ func registerRoutes(m *web.Router) { | ||||
| 						m.Post("/cancel", repo.CancelStopwatch) | ||||
| 					}) | ||||
| 				}) | ||||
| 				m.Post("/time_estimate", repo.UpdateIssueTimeEstimate) | ||||
| 				m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeIssueReaction) | ||||
| 				m.Post("/lock", reqRepoIssuesOrPullsWriter, web.Bind(forms.IssueLockForm{}), repo.LockIssue) | ||||
| 				m.Post("/unlock", reqRepoIssuesOrPullsWriter, repo.UnlockIssue) | ||||
|  | ||||
| @ -76,6 +76,11 @@ func ToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *issu | ||||
| 			// so we check for the "|" delimiter and convert new to legacy format on demand | ||||
| 			c.Content = util.SecToTime(c.Content[1:]) | ||||
| 		} | ||||
| 
 | ||||
| 		if c.Type == issues_model.CommentTypeChangeTimeEstimate { | ||||
| 			timeSec, _ := util.ToInt64(c.Content) | ||||
| 			c.Content = util.TimeEstimateString(timeSec) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	comment := &api.TimelineComment{ | ||||
|  | ||||
| @ -43,6 +43,7 @@ var hiddenCommentTypeGroups = hiddenCommentTypeGroupsType{ | ||||
| 		/*14*/ issues_model.CommentTypeAddTimeManual, | ||||
| 		/*15*/ issues_model.CommentTypeCancelTracking, | ||||
| 		/*26*/ issues_model.CommentTypeDeleteTimeManual, | ||||
| 		/*38*/ issues_model.CommentTypeChangeTimeEstimate, | ||||
| 	}, | ||||
| 	"deadline": { | ||||
| 		/*16*/ issues_model.CommentTypeAddedDeadline, | ||||
|  | ||||
| @ -105,6 +105,13 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // ChangeTimeEstimate changes the time estimate of this issue, as the given user. | ||||
| func ChangeTimeEstimate(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, timeEstimate int64) (err error) { | ||||
| 	issue.TimeEstimate = timeEstimate | ||||
| 
 | ||||
| 	return issues_model.ChangeIssueTimeEstimate(ctx, issue, doer, timeEstimate) | ||||
| } | ||||
| 
 | ||||
| // ChangeIssueRef changes the branch of this issue, as the given user. | ||||
| func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, ref string) error { | ||||
| 	oldRef := issue.Ref | ||||
|  | ||||
| @ -1,60 +1,78 @@ | ||||
| {{if .Repository.IsTimetrackerEnabled ctx}} | ||||
| 	{{if and .CanUseTimetracker (not .Repository.IsArchived)}} | ||||
| 		<div class="divider"></div> | ||||
| 		<div class="ui timetrack"> | ||||
| 			<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.tracker"}}</strong></span> | ||||
| 			<div class="tw-mt-2"> | ||||
| 				<form method="post" action="{{.Issue.Link}}/times/stopwatch/toggle" id="toggle_stopwatch_form"> | ||||
| 					{{$.CsrfTokenHtml}} | ||||
| 				</form> | ||||
| 				<form method="post" action="{{.Issue.Link}}/times/stopwatch/cancel" id="cancel_stopwatch_form"> | ||||
| 					{{$.CsrfTokenHtml}} | ||||
| 				</form> | ||||
| 				{{if $.IsStopwatchRunning}} | ||||
| 					<button class="ui fluid button issue-stop-time"> | ||||
| 						{{svg "octicon-stopwatch" 16 "tw-mr-2"}} | ||||
| 						{{ctx.Locale.Tr "repo.issues.stop_tracking"}} | ||||
| 					</button> | ||||
| 					<button class="ui fluid button issue-cancel-time tw-mt-2"> | ||||
| 						{{svg "octicon-trash" 16 "tw-mr-2"}} | ||||
| 						{{ctx.Locale.Tr "repo.issues.cancel_tracking"}} | ||||
| 					</button> | ||||
| 				{{else}} | ||||
| 					{{if .HasUserStopwatch}} | ||||
| 						<div class="ui warning message"> | ||||
| 							{{ctx.Locale.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL}} | ||||
| 						</div> | ||||
| 		<div> | ||||
| 			<div class="ui dropdown jump"> | ||||
| 				<a class="text muted"> | ||||
| 					<strong>{{ctx.Locale.Tr "repo.issues.tracker"}}</strong> {{svg "octicon-gear"}} | ||||
| 					{{if $.IsStopwatchRunning}}{{svg "octicon-stopwatch"}}{{end}} | ||||
| 				</a> | ||||
| 				<div class="menu"> | ||||
| 					<a class="item issue-set-time-estimate show-modal" data-modal="#issue-time-set-estimate-modal"> | ||||
| 						{{svg "octicon-pencil"}} {{ctx.Locale.Tr "repo.issues.time_estimate_set"}} | ||||
| 					</a> | ||||
| 					<div class="divider"></div> | ||||
| 					{{if $.IsStopwatchRunning}} | ||||
| 					<a class="item issue-stop-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/toggle"> | ||||
| 						{{svg "octicon-stopwatch"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_stop"}} | ||||
| 					</a> | ||||
| 					<a class="item issue-cancel-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/cancel"> | ||||
| 						{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_discard"}} | ||||
| 					</a> | ||||
| 					{{else}} | ||||
| 					<a class="item issue-start-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/toggle"> | ||||
| 						{{svg "octicon-stopwatch"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_start"}} | ||||
| 					</a> | ||||
| 					<a class="item issue-add-time show-modal" data-modal="#issue-time-manually-add-modal"> | ||||
| 						{{svg "octicon-plus"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_manually_add"}} | ||||
| 					</a> | ||||
| 					{{end}} | ||||
| 					<button class="ui fluid button issue-start-time" data-tooltip-content='{{ctx.Locale.Tr "repo.issues.start_tracking"}}'> | ||||
| 						{{svg "octicon-stopwatch" 16 "tw-mr-2"}} | ||||
| 						{{ctx.Locale.Tr "repo.issues.start_tracking_short"}} | ||||
| 					</button> | ||||
| 					<div class="ui mini modal issue-start-time-modal"> | ||||
| 						<div class="header">{{ctx.Locale.Tr "repo.issues.add_time"}}</div> | ||||
| 						<div class="content"> | ||||
| 							<form method="post" id="add_time_manual_form" action="{{.Issue.Link}}/times/add" class="ui input fluid tw-gap-2"> | ||||
| 								{{$.CsrfTokenHtml}} | ||||
| 								<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_hours"}}' type="number" name="hours"> | ||||
| 								<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_minutes"}}' type="number" name="minutes" class="ui compact"> | ||||
| 							</form> | ||||
| 						</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 
 | ||||
| 			{{if and (not $.IsStopwatchRunning) .HasUserStopwatch}} | ||||
| 				<div class="ui warning message">{{ctx.Locale.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL}}</div> | ||||
| 			{{end}} | ||||
| 
 | ||||
| 			{{if .Issue.TimeEstimate}} | ||||
| 				<div class="tw-my-2">{{ctx.Locale.Tr "repo.issues.time_estimate_display" (TimeEstimateString .Issue.TimeEstimate)}}</div> | ||||
| 			{{end}} | ||||
| 
 | ||||
| 			{{/* set time estimate modal */}} | ||||
| 			<div class="ui mini modal" id="issue-time-set-estimate-modal"> | ||||
| 				<div class="header">{{ctx.Locale.Tr "repo.issues.time_estimate_set"}}</div> | ||||
| 				<form method="post" class="ui form form-fetch-action" action="{{.Issue.Link}}/time_estimate"> | ||||
| 					<div class="content"> | ||||
| 						{{$.CsrfTokenHtml}} | ||||
| 						<input name="time_estimate" placeholder="{{ctx.Locale.Tr "repo.issues.time_estimate_placeholder"}}" value="{{TimeEstimateString .Issue.TimeEstimate}}"> | ||||
| 						<div class="actions"> | ||||
| 							<button class="ui primary approve button">{{ctx.Locale.Tr "repo.issues.add_time_short"}}</button> | ||||
| 							<button class="ui cancel button">{{ctx.Locale.Tr "repo.issues.add_time_cancel"}}</button> | ||||
| 							<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button> | ||||
| 							<button class="ui primary button">{{ctx.Locale.Tr "repo.issues.save"}}</button> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<button class="ui fluid button issue-add-time tw-mt-2" data-tooltip-content='{{ctx.Locale.Tr "repo.issues.add_time"}}'> | ||||
| 						{{svg "octicon-plus" 16 "tw-mr-2"}} | ||||
| 						{{ctx.Locale.Tr "repo.issues.add_time_short"}} | ||||
| 					</button> | ||||
| 				{{end}} | ||||
| 				</form> | ||||
| 			</div> | ||||
| 
 | ||||
| 			{{/* manually add time modal */}} | ||||
| 			<div class="ui mini modal" id="issue-time-manually-add-modal"> | ||||
| 				<div class="header">{{ctx.Locale.Tr "repo.issues.add_time_manually"}}</div> | ||||
| 				<form method="post" class="ui form form-fetch-action" action="{{.Issue.Link}}/times/add"> | ||||
| 					<div class="content flex-text-block"> | ||||
| 						{{$.CsrfTokenHtml}} | ||||
| 						<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_hours"}}' type="number" name="hours">: | ||||
| 						<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_minutes"}}' type="number" name="minutes"> | ||||
| 					</div> | ||||
| 					<div class="actions"> | ||||
| 						<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button> | ||||
| 						<button class="ui primary button">{{ctx.Locale.Tr "repo.issues.timetracker_timer_manually_add"}}</button> | ||||
| 					</div> | ||||
| 				</form> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 	{{end}} | ||||
| 	{{if .WorkingUsers}} | ||||
| 		<div class="divider"></div> | ||||
| 		<div class="ui comments"> | ||||
| 			<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}}</strong></span> | ||||
| 		<div class="ui comments tw-mt-2"> | ||||
| 			{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}} | ||||
| 			<div> | ||||
| 				{{range $user, $trackedtime := .WorkingUsers}} | ||||
| 					<div class="comment tw-mt-2"> | ||||
|  | ||||
| @ -12,7 +12,8 @@ | ||||
| 		26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST, | ||||
| 		29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED | ||||
| 		32 = DISMISSED_REVIEW, 33 = COMMENT_TYPE_CHANGE_ISSUE_REF, 34 = PR_SCHEDULE_TO_AUTO_MERGE, | ||||
| 		35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE --> | ||||
| 		35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE, | ||||
| 		38 = COMMENT_TYPE_CHANGE_TIME_ESTIMATE --> | ||||
| 		{{if eq .Type 0}} | ||||
| 			<div class="timeline-item comment" id="{{.HashTag}}"> | ||||
| 			{{if .OriginalAuthor}} | ||||
| @ -250,18 +251,11 @@ | ||||
| 				{{template "shared/user/avatarlink" dict "user" .Poster}} | ||||
| 				<span class="text grey muted-links"> | ||||
| 					{{template "shared/user/authorlink" .Poster}} | ||||
| 					{{ctx.Locale.Tr "repo.issues.stop_tracking_history" $createdStr}} | ||||
| 					{{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}} | ||||
| 					{{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}} | ||||
| 					{{ctx.Locale.Tr "repo.issues.stop_tracking_history" $timeStr $createdStr}} | ||||
| 				</span> | ||||
| 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | ||||
| 				<div class="detail flex-text-block"> | ||||
| 					{{svg "octicon-clock"}} | ||||
| 					{{if .RenderedContent}} | ||||
| 						{{/* compatibility with time comments made before v1.21 */}} | ||||
| 						<span class="text grey muted-links">{{.RenderedContent}}</span> | ||||
| 					{{else}} | ||||
| 						<span class="text grey muted-links">{{.Content|Sec2Time}}</span> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		{{else if eq .Type 14}} | ||||
| 			<div class="timeline-item event" id="{{.HashTag}}"> | ||||
| @ -269,18 +263,11 @@ | ||||
| 				{{template "shared/user/avatarlink" dict "user" .Poster}} | ||||
| 				<span class="text grey muted-links"> | ||||
| 					{{template "shared/user/authorlink" .Poster}} | ||||
| 					{{ctx.Locale.Tr "repo.issues.add_time_history" $createdStr}} | ||||
| 					{{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}} | ||||
| 					{{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}} | ||||
| 					{{ctx.Locale.Tr "repo.issues.add_time_history" $timeStr $createdStr}} | ||||
| 				</span> | ||||
| 				{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} | ||||
| 				<div class="detail flex-text-block"> | ||||
| 					{{svg "octicon-clock"}} | ||||
| 					{{if .RenderedContent}} | ||||
| 						{{/* compatibility with time comments made before v1.21 */}} | ||||
| 						<span class="text grey muted-links">{{.RenderedContent}}</span> | ||||
| 					{{else}} | ||||
| 						<span class="text grey muted-links">{{.Content|Sec2Time}}</span> | ||||
| 					{{end}} | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		{{else if eq .Type 15}} | ||||
| 			<div class="timeline-item event" id="{{.HashTag}}"> | ||||
| @ -703,6 +690,20 @@ | ||||
| 					{{else}}{{ctx.Locale.Tr "repo.issues.unpin_comment" $createdStr}}{{end}} | ||||
| 				</span> | ||||
| 			</div> | ||||
| 		{{else if eq .Type 38}} | ||||
| 			<div class="timeline-item event" id="{{.HashTag}}"> | ||||
| 				<span class="badge">{{svg "octicon-clock"}}</span> | ||||
| 				{{template "shared/user/avatarlink" dict "Context" $.Context "user" .Poster}} | ||||
| 				<span class="text grey muted-links"> | ||||
| 					{{template "shared/user/authorlink" .Poster}} | ||||
| 					{{$timeStr := .Content|TimeEstimateString}} | ||||
| 					{{if $timeStr}} | ||||
| 						{{ctx.Locale.Tr "repo.issues.change_time_estimate_at" $timeStr $createdStr}} | ||||
| 					{{else}} | ||||
| 						{{ctx.Locale.Tr "repo.issues.remove_time_estimate_at" $createdStr}} | ||||
| 					{{end}} | ||||
| 				</span> | ||||
| 			</div> | ||||
| 		{{end}} | ||||
| 	{{end}} | ||||
| {{end}} | ||||
|  | ||||
| @ -2,14 +2,10 @@ | ||||
| 	{{if (not .comment.Time.Deleted)}} | ||||
| 		{{if (or .ctxData.IsAdmin (and .ctxData.IsSigned (eq .ctxData.SignedUserID .comment.PosterID)))}} | ||||
| 			<span class="tw-float-right"> | ||||
| 				<div class="ui mini modal issue-delete-time-modal" data-id="{{.comment.Time.ID}}"> | ||||
| 					<form method="post" class="delete-time-form" action="{{.ctxData.RepoLink}}/issues/{{.ctxData.Issue.Index}}/times/{{.comment.TimeID}}/delete"> | ||||
| 						{{.ctxData.CsrfTokenHtml}} | ||||
| 					</form> | ||||
| 					<div class="header">{{ctx.Locale.Tr "repo.issues.del_time"}}</div> | ||||
| 					{{template "base/modal_actions_confirm"}} | ||||
| 				</div> | ||||
| 				<button class="ui icon button compact mini issue-delete-time" data-id="{{.comment.Time.ID}}" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.del_time"}}"> | ||||
| 				<button class="ui icon button compact mini link-action" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.del_time"}}" | ||||
| 								data-url="{{.ctxData.RepoLink}}/issues/{{.ctxData.Issue.Index}}/times/{{.comment.TimeID}}/delete?id={{.comment.Time.ID}}" | ||||
| 								data-modal-confirm="{{ctx.Locale.Tr "repo.issues.del_time"}}" | ||||
| 				> | ||||
| 					{{svg "octicon-trash"}} | ||||
| 				</button> | ||||
| 			</span> | ||||
|  | ||||
| @ -24,15 +24,9 @@ func NewHTMLParser(t testing.TB, body *bytes.Buffer) *HTMLDoc { | ||||
| 	return &HTMLDoc{doc: doc} | ||||
| } | ||||
| 
 | ||||
| // GetInputValueByID for get input value by id | ||||
| func (doc *HTMLDoc) GetInputValueByID(id string) string { | ||||
| 	text, _ := doc.doc.Find("#" + id).Attr("value") | ||||
| 	return text | ||||
| } | ||||
| 
 | ||||
| // GetInputValueByName for get input value by name | ||||
| func (doc *HTMLDoc) GetInputValueByName(name string) string { | ||||
| 	text, _ := doc.doc.Find("input[name=\"" + name + "\"]").Attr("value") | ||||
| 	text, _ := doc.doc.Find(`input[name="` + name + `"]`).Attr("value") | ||||
| 	return text | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -9,7 +9,6 @@ import ( | ||||
| 	"testing" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/modules/test" | ||||
| 	"code.gitea.io/gitea/tests" | ||||
| 
 | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| @ -17,22 +16,24 @@ import ( | ||||
| 
 | ||||
| func TestViewTimetrackingControls(t *testing.T) { | ||||
| 	defer tests.PrepareTestEnv(t)() | ||||
| 	session := loginUser(t, "user2") | ||||
| 	testViewTimetrackingControls(t, session, "user2", "repo1", "1", true) | ||||
| 	// user2/repo1 | ||||
| } | ||||
| 
 | ||||
| func TestNotViewTimetrackingControls(t *testing.T) { | ||||
| 	defer tests.PrepareTestEnv(t)() | ||||
| 	session := loginUser(t, "user5") | ||||
| 	testViewTimetrackingControls(t, session, "user2", "repo1", "1", false) | ||||
| 	// user2/repo1 | ||||
| } | ||||
| 	t.Run("Exist", func(t *testing.T) { | ||||
| 		defer tests.PrintCurrentTest(t)() | ||||
| 		session := loginUser(t, "user2") | ||||
| 		testViewTimetrackingControls(t, session, "user2", "repo1", "1", true) | ||||
| 	}) | ||||
| 
 | ||||
| func TestViewTimetrackingControlsDisabled(t *testing.T) { | ||||
| 	defer tests.PrepareTestEnv(t)() | ||||
| 	session := loginUser(t, "user2") | ||||
| 	testViewTimetrackingControls(t, session, "org3", "repo3", "1", false) | ||||
| 	t.Run("Non-exist", func(t *testing.T) { | ||||
| 		defer tests.PrintCurrentTest(t)() | ||||
| 		session := loginUser(t, "user5") | ||||
| 		testViewTimetrackingControls(t, session, "user2", "repo1", "1", false) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("Disabled", func(t *testing.T) { | ||||
| 		defer tests.PrintCurrentTest(t)() | ||||
| 		session := loginUser(t, "user2") | ||||
| 		testViewTimetrackingControls(t, session, "org3", "repo3", "1", false) | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo, issue string, canTrackTime bool) { | ||||
| @ -41,40 +42,40 @@ func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo | ||||
| 
 | ||||
| 	htmlDoc := NewHTMLParser(t, resp.Body) | ||||
| 
 | ||||
| 	htmlDoc.AssertElement(t, ".timetrack .issue-start-time", canTrackTime) | ||||
| 	htmlDoc.AssertElement(t, ".timetrack .issue-add-time", canTrackTime) | ||||
| 	htmlDoc.AssertElement(t, ".issue-start-time", canTrackTime) | ||||
| 	htmlDoc.AssertElement(t, ".issue-add-time", canTrackTime) | ||||
| 
 | ||||
| 	req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{ | ||||
| 	issueLink := path.Join(user, repo, "issues", issue) | ||||
| 	req = NewRequestWithValues(t, "POST", path.Join(issueLink, "times", "stopwatch", "toggle"), map[string]string{ | ||||
| 		"_csrf": htmlDoc.GetCSRF(), | ||||
| 	}) | ||||
| 	if canTrackTime { | ||||
| 		resp = session.MakeRequest(t, req, http.StatusSeeOther) | ||||
| 		session.MakeRequest(t, req, http.StatusOK) | ||||
| 
 | ||||
| 		req = NewRequest(t, "GET", test.RedirectURL(resp)) | ||||
| 		req = NewRequest(t, "GET", issueLink) | ||||
| 		resp = session.MakeRequest(t, req, http.StatusOK) | ||||
| 		htmlDoc = NewHTMLParser(t, resp.Body) | ||||
| 
 | ||||
| 		events := htmlDoc.doc.Find(".event > span.text") | ||||
| 		assert.Contains(t, events.Last().Text(), "started working") | ||||
| 
 | ||||
| 		htmlDoc.AssertElement(t, ".timetrack .issue-stop-time", true) | ||||
| 		htmlDoc.AssertElement(t, ".timetrack .issue-cancel-time", true) | ||||
| 		htmlDoc.AssertElement(t, ".issue-stop-time", true) | ||||
| 		htmlDoc.AssertElement(t, ".issue-cancel-time", true) | ||||
| 
 | ||||
| 		// Sleep for 1 second to not get wrong order for stopping timer | ||||
| 		time.Sleep(time.Second) | ||||
| 
 | ||||
| 		req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{ | ||||
| 		req = NewRequestWithValues(t, "POST", path.Join(issueLink, "times", "stopwatch", "toggle"), map[string]string{ | ||||
| 			"_csrf": htmlDoc.GetCSRF(), | ||||
| 		}) | ||||
| 		resp = session.MakeRequest(t, req, http.StatusSeeOther) | ||||
| 		session.MakeRequest(t, req, http.StatusOK) | ||||
| 
 | ||||
| 		req = NewRequest(t, "GET", test.RedirectURL(resp)) | ||||
| 		req = NewRequest(t, "GET", issueLink) | ||||
| 		resp = session.MakeRequest(t, req, http.StatusOK) | ||||
| 		htmlDoc = NewHTMLParser(t, resp.Body) | ||||
| 
 | ||||
| 		events = htmlDoc.doc.Find(".event > span.text") | ||||
| 		assert.Contains(t, events.Last().Text(), "stopped working") | ||||
| 		htmlDoc.AssertElement(t, ".event .detail .octicon-clock", true) | ||||
| 		assert.Contains(t, events.Last().Text(), "worked for ") | ||||
| 	} else { | ||||
| 		session.MakeRequest(t, req, http.StatusNotFound) | ||||
| 	} | ||||
|  | ||||
| @ -11,37 +11,6 @@ import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; | ||||
| 
 | ||||
| const {appSubUrl} = window.config; | ||||
| 
 | ||||
| export function initRepoIssueTimeTracking() { | ||||
|   $(document).on('click', '.issue-add-time', () => { | ||||
|     $('.issue-start-time-modal').modal({ | ||||
|       duration: 200, | ||||
|       onApprove() { | ||||
|         $('#add_time_manual_form').trigger('submit'); | ||||
|       }, | ||||
|     }).modal('show'); | ||||
|     $('.issue-start-time-modal input').on('keydown', (e) => { | ||||
|       if (e.key === 'Enter') { | ||||
|         $('#add_time_manual_form').trigger('submit'); | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
|   $(document).on('click', '.issue-start-time, .issue-stop-time', () => { | ||||
|     $('#toggle_stopwatch_form').trigger('submit'); | ||||
|   }); | ||||
|   $(document).on('click', '.issue-cancel-time', () => { | ||||
|     $('#cancel_stopwatch_form').trigger('submit'); | ||||
|   }); | ||||
|   $(document).on('click', 'button.issue-delete-time', function () { | ||||
|     const sel = `.issue-delete-time-modal[data-id="${$(this).data('id')}"]`; | ||||
|     $(sel).modal({ | ||||
|       duration: 200, | ||||
|       onApprove() { | ||||
|         $(`${sel} form`).trigger('submit'); | ||||
|       }, | ||||
|     }).modal('show'); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * @param {HTMLElement} item | ||||
|  */ | ||||
|  | ||||
| @ -26,7 +26,6 @@ import {initPdfViewer} from './render/pdf.ts'; | ||||
| import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts'; | ||||
| import { | ||||
|   initRepoIssueReferenceRepositorySearch, | ||||
|   initRepoIssueTimeTracking, | ||||
|   initRepoIssueWipTitle, | ||||
|   initRepoPullRequestMergeInstruction, | ||||
|   initRepoPullRequestAllowMaintainerEdit, | ||||
| @ -184,7 +183,6 @@ onDomReady(() => { | ||||
|     initRepoIssueList, | ||||
|     initRepoIssueSidebarList, | ||||
|     initRepoIssueReferenceRepositorySearch, | ||||
|     initRepoIssueTimeTracking, | ||||
|     initRepoIssueWipTitle, | ||||
|     initRepoMigration, | ||||
|     initRepoMigrationStatusChecker, | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user