diff --git a/models/issues/comment.go b/models/issues/comment.go
index 48b8e335d48ef..7065712bfd231 100644
--- a/models/issues/comment.go
+++ b/models/issues/comment.go
@@ -114,6 +114,8 @@ const (
CommentTypePin // 36 pin Issue
CommentTypeUnpin // 37 unpin Issue
+
+ CommentTypeChangeTimeEstimate // 38 Change time estimate
)
var commentStrings = []string{
@@ -153,6 +155,7 @@ var commentStrings = []string{
"change_issue_ref",
"pull_scheduled_merge",
"pull_cancel_scheduled_merge",
+ "change_time_estimate",
"pin",
"unpin",
}
diff --git a/models/issues/issue.go b/models/issues/issue.go
index 9ccf2859ea413..d4992f7f92668 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -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,33 @@ 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) (err error) {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ 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 committer.Commit()
+}
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index e0361af86ba8e..8ccea29ebf54a 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -367,6 +367,7 @@ func prepareMigrationTasks() []*migration {
newMigration(307, "Fix milestone deadline_unix when there is no due date", v1_23.FixMilestoneNoDueDate),
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, "Improve Notification table indices", v1_23.AddTimeEstimateColumnToIssueTable),
}
return preparedMigrations
}
diff --git a/models/migrations/v1_23/v310.go b/models/migrations/v1_23/v310.go
new file mode 100644
index 0000000000000..0fc1ac8c0e85c
--- /dev/null
+++ b/models/migrations/v1_23/v310.go
@@ -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))
+}
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index d5b32358da752..4b815e8729949 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -67,9 +67,11 @@ func NewFuncMap() template.FuncMap {
// -----------------------------------------------------------------
// time / number / format
- "FileSize": base.FileSize,
- "CountFmt": base.FormatNumberSI,
- "Sec2Time": util.SecToTime,
+ "FileSize": base.FileSize,
+ "CountFmt": base.FormatNumberSI,
+ "Sec2Time": util.SecToTime,
+ "SecToTimeExact": util.SecToTimeExact,
+ "TimeEstimateToStr": util.TimeEstimateToStr,
"LoadTimes": func(startTime time.Time) string {
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
},
diff --git a/modules/util/sec_to_time.go b/modules/util/sec_to_time.go
index ad0fb1a68b4a7..aede619ae963d 100644
--- a/modules/util/sec_to_time.go
+++ b/modules/util/sec_to_time.go
@@ -66,6 +66,43 @@ func SecToTime(durationVal any) string {
return strings.TrimRight(formattedTime, " ")
}
+func SecToTimeExact(duration int64, withSeconds bool) string {
+ formattedTime := ""
+
+ // The following four variables are calculated by taking
+ // into account the previously calculated variables, this avoids
+ // pitfalls when using remainders. As that could lead to incorrect
+ // results when the calculated number equals the quotient number.
+ remainingDays := duration / (60 * 60 * 24)
+ years := remainingDays / 365
+ remainingDays -= years * 365
+ months := remainingDays * 12 / 365
+ remainingDays -= months * 365 / 12
+ weeks := remainingDays / 7
+ remainingDays -= weeks * 7
+ days := remainingDays
+
+ // The following three variables are calculated without depending
+ // on the previous calculated variables.
+ hours := (duration / 3600) % 24
+ minutes := (duration / 60) % 60
+ seconds := duration % 60
+
+ // Show exact time information
+ formattedTime = formatTime(years, "year", formattedTime)
+ formattedTime = formatTime(months, "month", formattedTime)
+ formattedTime = formatTime(weeks, "week", formattedTime)
+ formattedTime = formatTime(days, "day", formattedTime)
+ formattedTime = formatTime(hours, "hour", formattedTime)
+ formattedTime = formatTime(minutes, "minute", formattedTime)
+ if withSeconds {
+ formattedTime = formatTime(seconds, "second", formattedTime)
+ }
+
+ // The formatTime() function always appends a space at the end. This will be trimmed
+ return strings.TrimRight(formattedTime, " ")
+}
+
// formatTime appends the given value to the existing forammattedTime. E.g:
// formattedTime = "1 year"
// input: value = 3, name = "month"
diff --git a/modules/util/time_str.go b/modules/util/time_str.go
new file mode 100644
index 0000000000000..02a4a82e6bd99
--- /dev/null
+++ b/modules/util/time_str.go
@@ -0,0 +1,99 @@
+// Copyright 2022 Gitea. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package util
+
+import (
+ "fmt"
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+var (
+ // Time estimate match regex
+ rTimeEstimateOnlyHours = regexp.MustCompile(`^([\d]+)$`)
+ rTimeEstimateWeeks = regexp.MustCompile(`([\d]+)w`)
+ rTimeEstimateDays = regexp.MustCompile(`([\d]+)d`)
+ rTimeEstimateHours = regexp.MustCompile(`([\d]+)h`)
+ rTimeEstimateMinutes = regexp.MustCompile(`([\d]+)m`)
+)
+
+// TimeEstimateFromStr returns time estimate in seconds from formatted string
+func TimeEstimateFromStr(timeStr string) int64 {
+ timeTotal := 0
+
+ // If single number entered, assume hours
+ timeStrMatches := rTimeEstimateOnlyHours.FindStringSubmatch(timeStr)
+ if len(timeStrMatches) > 0 {
+ raw, _ := strconv.Atoi(timeStrMatches[1])
+ timeTotal += raw * (60 * 60)
+ } else {
+ // Find time weeks
+ timeStrMatches = rTimeEstimateWeeks.FindStringSubmatch(timeStr)
+ if len(timeStrMatches) > 0 {
+ raw, _ := strconv.Atoi(timeStrMatches[1])
+ timeTotal += raw * (60 * 60 * 24 * 7)
+ }
+
+ // Find time days
+ timeStrMatches = rTimeEstimateDays.FindStringSubmatch(timeStr)
+ if len(timeStrMatches) > 0 {
+ raw, _ := strconv.Atoi(timeStrMatches[1])
+ timeTotal += raw * (60 * 60 * 24)
+ }
+
+ // Find time hours
+ timeStrMatches = rTimeEstimateHours.FindStringSubmatch(timeStr)
+ if len(timeStrMatches) > 0 {
+ raw, _ := strconv.Atoi(timeStrMatches[1])
+ timeTotal += raw * (60 * 60)
+ }
+
+ // Find time minutes
+ timeStrMatches = rTimeEstimateMinutes.FindStringSubmatch(timeStr)
+ if len(timeStrMatches) > 0 {
+ raw, _ := strconv.Atoi(timeStrMatches[1])
+ timeTotal += raw * (60)
+ }
+ }
+
+ return int64(timeTotal)
+}
+
+// TimeEstimateStr returns formatted time estimate string from seconds (e.g. "2w 4d 12h 5m")
+func TimeEstimateToStr(amount int64) string {
+ var timeParts []string
+
+ timeSeconds := float64(amount)
+
+ // Format weeks
+ weeks := math.Floor(timeSeconds / (60 * 60 * 24 * 7))
+ if weeks > 0 {
+ timeParts = append(timeParts, fmt.Sprintf("%dw", int64(weeks)))
+ }
+ timeSeconds -= weeks * (60 * 60 * 24 * 7)
+
+ // Format days
+ days := math.Floor(timeSeconds / (60 * 60 * 24))
+ if days > 0 {
+ timeParts = append(timeParts, fmt.Sprintf("%dd", int64(days)))
+ }
+ timeSeconds -= days * (60 * 60 * 24)
+
+ // Format hours
+ hours := math.Floor(timeSeconds / (60 * 60))
+ if hours > 0 {
+ timeParts = append(timeParts, fmt.Sprintf("%dh", int64(hours)))
+ }
+ timeSeconds -= hours * (60 * 60)
+
+ // Format minutes
+ minutes := math.Floor(timeSeconds / (60))
+ if minutes > 0 {
+ timeParts = append(timeParts, fmt.Sprintf("%dm", int64(minutes)))
+ }
+
+ return strings.Join(timeParts, " ")
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 9945eb4949d68..f6fbe5a56bd06 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1506,6 +1506,11 @@ issues.add_assignee_at = `was assigned by %s %s`
issues.remove_assignee_at = `was unassigned by %s %s`
issues.remove_self_assignment = `removed their assignment %s`
issues.change_title_at = `changed title from %s to %s %s`
+issues.time_estimate = `Time Estimate`
+issues.add_time_estimate = `3w 4d 12h`
+issues.change_time_estimate_at = `changed time estimate to %s %s`
+issues.remove_time_estimate = `removed time estimate %s`
+issues.time_estimate_invalid = `Time estimate format is invalid`
issues.change_ref_at = `changed reference from %s to %s %s`
issues.remove_ref_at = `removed reference %s %s`
issues.add_ref_at = `added reference %s %s`
@@ -1676,15 +1681,15 @@ 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 another issue!`
issues.stop_tracking = Stop Timer
-issues.stop_tracking_history = `stopped working %s`
+issues.stop_tracking_history = `worked for %s %s`
issues.cancel_tracking = Discard
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.del_time_history= `deleted spent time %s`
+issues.add_time_history = `added spent time %s %s`
+issues.del_time_history= `deleted spent time %s %s`
issues.add_time_hours = Hours
issues.add_time_minutes = Minutes
issues.add_time_sum_to_small = No time was entered.
diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go
index 9a941ce85714b..08e463ff50e10 100644
--- a/routers/web/repo/issue_new.go
+++ b/routers/web/repo/issue_new.go
@@ -8,6 +8,7 @@ import (
"fmt"
"html/template"
"net/http"
+ "regexp"
"slices"
"sort"
"strconv"
@@ -401,3 +402,54 @@ func NewIssuePost(ctx *context.Context) {
ctx.JSONRedirect(issue.Link())
}
}
+
+// UpdateIssueTimeEstimate change issue's planned time
+var (
+ rTimeEstimateStr = regexp.MustCompile(`^([\d]+w)?\s?([\d]+d)?\s?([\d]+h)?\s?([\d]+m)?$`)
+ rTimeEstimateStrHoursOnly = regexp.MustCompile(`^([\d]+)$`)
+)
+
+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
+ }
+
+ url := issue.Link()
+
+ timeStr := ctx.FormString("time_estimate")
+
+ // Validate input
+ if !rTimeEstimateStr.MatchString(timeStr) && !rTimeEstimateStrHoursOnly.MatchString(timeStr) {
+ ctx.Flash.Error(ctx.Tr("repo.issues.time_estimate_invalid"))
+ ctx.Redirect(url, http.StatusSeeOther)
+ return
+ }
+
+ total := util.TimeEstimateFromStr(timeStr)
+
+ // User entered something wrong
+ if total == 0 && len(timeStr) != 0 {
+ ctx.Flash.Error(ctx.Tr("repo.issues.time_estimate_invalid"))
+ ctx.Redirect(url, http.StatusSeeOther)
+ return
+ }
+
+ // No time changed
+ if issue.TimeEstimate == total {
+ ctx.Redirect(url, http.StatusSeeOther)
+ return
+ }
+
+ if err := issue_service.ChangeTimeEstimate(ctx, issue, ctx.Doer, total); err != nil {
+ ctx.ServerError("ChangeTimeEstimate", err)
+ return
+ }
+
+ ctx.Redirect(url, http.StatusSeeOther)
+}
diff --git a/routers/web/repo/issue_timetrack.go b/routers/web/repo/issue_timetrack.go
index 099711c5e9cf8..e54bd32e9486c 100644
--- a/routers/web/repo/issue_timetrack.go
+++ b/routers/web/repo/issue_timetrack.go
@@ -34,7 +34,7 @@ func AddTimeManually(c *context.Context) {
return
}
- total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute
+ total := util.TimeEstimateFromStr(form.TimeString)
if total <= 0 {
c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small"))
@@ -42,7 +42,7 @@ func AddTimeManually(c *context.Context) {
return
}
- if _, err := issues_model.AddTime(c, c.Doer, issue, int64(total.Seconds()), time.Now()); err != nil {
+ if _, err := issues_model.AddTime(c, c.Doer, issue, total, time.Now()); err != nil {
c.ServerError("AddTime", err)
return
}
diff --git a/routers/web/web.go b/routers/web/web.go
index b96d06ed66eb6..69e73e3d67643 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1232,6 +1232,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)
diff --git a/services/convert/issue_comment.go b/services/convert/issue_comment.go
index 9ec9ac7684a1d..961f5690434f0 100644
--- a/services/convert/issue_comment.go
+++ b/services/convert/issue_comment.go
@@ -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.SecToTimeExact(timeSec, timeSec < 60)
+ }
}
comment := &api.TimelineComment{
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 8e663084f86e1..40bce039508b6 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -847,8 +847,7 @@ func (f *DeleteRepoFileForm) Validate(req *http.Request, errs binding.Errors) bi
// AddTimeManuallyForm form that adds spent time manually.
type AddTimeManuallyForm struct {
- Hours int `binding:"Range(0,1000)"`
- Minutes int `binding:"Range(0,1000)"`
+ TimeString string
}
// Validate validates the fields
diff --git a/services/forms/user_form_hidden_comments.go b/services/forms/user_form_hidden_comments.go
index b9677c1800631..76382ddfdde68 100644
--- a/services/forms/user_form_hidden_comments.go
+++ b/services/forms/user_form_hidden_comments.go
@@ -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,
diff --git a/services/issue/commit.go b/services/issue/commit.go
index 0579e0f5c53e6..ed80be328bae4 100644
--- a/services/issue/commit.go
+++ b/services/issue/commit.go
@@ -9,8 +9,6 @@ import (
"fmt"
"html"
"net/url"
- "regexp"
- "strconv"
"strings"
"time"
@@ -23,64 +21,11 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/util"
)
-const (
- secondsByMinute = float64(time.Minute / time.Second) // seconds in a minute
- secondsByHour = 60 * secondsByMinute // seconds in an hour
- secondsByDay = 8 * secondsByHour // seconds in a day
- secondsByWeek = 5 * secondsByDay // seconds in a week
- secondsByMonth = 4 * secondsByWeek // seconds in a month
-)
-
-var reDuration = regexp.MustCompile(`(?i)^(?:(\d+([\.,]\d+)?)(?:mo))?(?:(\d+([\.,]\d+)?)(?:w))?(?:(\d+([\.,]\d+)?)(?:d))?(?:(\d+([\.,]\d+)?)(?:h))?(?:(\d+([\.,]\d+)?)(?:m))?$`)
-
-// timeLogToAmount parses time log string and returns amount in seconds
-func timeLogToAmount(str string) int64 {
- matches := reDuration.FindAllStringSubmatch(str, -1)
- if len(matches) == 0 {
- return 0
- }
-
- match := matches[0]
-
- var a int64
-
- // months
- if len(match[1]) > 0 {
- mo, _ := strconv.ParseFloat(strings.Replace(match[1], ",", ".", 1), 64)
- a += int64(mo * secondsByMonth)
- }
-
- // weeks
- if len(match[3]) > 0 {
- w, _ := strconv.ParseFloat(strings.Replace(match[3], ",", ".", 1), 64)
- a += int64(w * secondsByWeek)
- }
-
- // days
- if len(match[5]) > 0 {
- d, _ := strconv.ParseFloat(strings.Replace(match[5], ",", ".", 1), 64)
- a += int64(d * secondsByDay)
- }
-
- // hours
- if len(match[7]) > 0 {
- h, _ := strconv.ParseFloat(strings.Replace(match[7], ",", ".", 1), 64)
- a += int64(h * secondsByHour)
- }
-
- // minutes
- if len(match[9]) > 0 {
- d, _ := strconv.ParseFloat(strings.Replace(match[9], ",", ".", 1), 64)
- a += int64(d * secondsByMinute)
- }
-
- return a
-}
-
func issueAddTime(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, time time.Time, timeLog string) error {
- amount := timeLogToAmount(timeLog)
+ amount := util.TimeEstimateFromStr(timeLog)
if amount == 0 {
return nil
}
diff --git a/services/issue/issue.go b/services/issue/issue.go
index 72ea66c8d98c5..c6a52cc0fe512 100644
--- a/services/issue/issue.go
+++ b/services/issue/issue.go
@@ -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
diff --git a/templates/mail/issue/default.tmpl b/templates/mail/issue/default.tmpl
index 395b118d3ef22..7859a94e1014b 100644
--- a/templates/mail/issue/default.tmpl
+++ b/templates/mail/issue/default.tmpl
@@ -62,7 +62,7 @@
{{end -}}
{{- range .ReviewComments}}
{{.Patch}}