mirror of https://github.com/go-gitea/gitea.git
Compare commits
95 Commits
8d8108e2a9
...
46d980a0e1
Author | SHA1 | Date |
---|---|---|
Illya Marchenko | 46d980a0e1 | |
Kemal Zebari | 22c7b3a744 | |
wxiaoguang | 982b20d259 | |
stuzer05 | a71b4578e8 | |
stuzer05 | 0c44cf7512 | |
stuzer05 | 8c92f4675c | |
stuzer05 | 0a8fd353ef | |
stuzer05 | 12553d7dc2 | |
stuzer05 | b97139b232 | |
stuzer05 | b452e13f35 | |
stuzer05 | e92bedc81d | |
stuzer05 | 1e22cc242f | |
stuzer05 | 93d09fa8fc | |
stuzer05 | 0820db04ed | |
stuzer05 | 09723c5e0b | |
stuzer05 | c272512d9a | |
stuzer05 | 0c4b2dfa8f | |
stuzer05 | 805af19ef1 | |
stuzer05 | bb5ca4c40b | |
stuzer05 | a737a8c10b | |
stuzer05 | 92dc2cd22c | |
stuzer05 | 39b8b1929d | |
stuzer05 | a999055954 | |
stuzer05 | a6fa4c3d04 | |
stuzer05 | 2a9009fb02 | |
stuzer05 | 3037d6cdde | |
stuzer05 | 349b959022 | |
stuzer05 | 64de74d540 | |
stuzer05 | fa662ec087 | |
stuzer05 | 62094d8cf3 | |
スツゼル | e933a89bd9 | |
stuzer05 | db49783fa8 | |
stuzer05 | a45c1e9c86 | |
stuzer05 | 721069d766 | |
stuzer05 | 3310440ed1 | |
スツゼル | 598e2d5bed | |
silverwind | b211b9e66d | |
stuzer05 | ffaa4babcb | |
stuzer05 | cef496af2a | |
stuzer05 | 015ad01513 | |
stuzer05 | 748bd67814 | |
スツゼル | 3924cb0062 | |
stuzer05 | e9afd60d6b | |
stuzer05 | 40e1373dce | |
stuzer05 | bf4fa112d0 | |
stuzer05 | fc93006f50 | |
スツゼル | 879d96f077 | |
スツゼル | fd5adc55f3 | |
stuzer05 | 57a3664eb2 | |
stuzer05 | bf323cf26c | |
スツゼル | fb8126035a | |
stuzer05 | deddce59cf | |
stuzer05 | 79e18c6711 | |
stuzer05 | 49a176d37c | |
stuzer05 | 8ef3a478aa | |
stuzer05 | 37e8e8ddaf | |
stuzer05 | e20e23b60a | |
スツゼル | 8c0bf885b2 | |
stuzer05 | b9cdc7c670 | |
stuzer05 | 7be748f63b | |
stuzer05 | 29dd61722b | |
stuzer05 | 875087061f | |
スツゼル | 775c663805 | |
stuzer05 | db3c697178 | |
stuzer05 | 5eea230714 | |
stuzer05 | 0ab85af1ce | |
stuzer05 | 5c4dc8739c | |
stuzer05 | 79f507b81f | |
stuzer05 | 7a570440f1 | |
stuzer05 | f463765fec | |
stuzer05 | e15549501d | |
stuzer05 | 1cff1a9e05 | |
stuzer05 | f7427d8b50 | |
stuzer05 | 1b7ba4189b | |
stuzer05 | 11b9719b5f | |
stuzer05 | 0f5b609f9d | |
stuzer05 | 09c05e8b76 | |
stuzer05 | d247b0ffd1 | |
stuzer05 | 870bb922cc | |
stuzer05 | b062fc9849 | |
stuzer05 | 2fc2f63c6c | |
スツゼル | 4e1aed8a61 | |
stuzer05 | f33b0a0773 | |
stuzer05 | e187364d7a | |
スツゼル | f7a4c9e0aa | |
スツゼル | 5f3edad64f | |
stuzer05 | 4be8c50a6f | |
stuzer05 | d75b7acac6 | |
stuzer05 | c248042c8e | |
stuzer05 | 7ed86ff00a | |
stuzer05 | d9446629c3 | |
stuzer05 | a8778f4db7 | |
stuzer05 | c783692f6c | |
stuzer05 | d11ba9f46c | |
stuzer05 | 1363205f29 |
|
@ -112,6 +112,8 @@ const (
|
|||
|
||||
CommentTypePin // 36 pin Issue
|
||||
CommentTypeUnpin // 37 unpin Issue
|
||||
|
||||
CommentTypeChangeTimeEstimate // 38 Change time estimate
|
||||
)
|
||||
|
||||
var commentStrings = []string{
|
||||
|
@ -151,6 +153,7 @@ var commentStrings = []string{
|
|||
"change_issue_ref",
|
||||
"pull_scheduled_merge",
|
||||
"pull_cancel_scheduled_merge",
|
||||
"change_time_estimate",
|
||||
"pin",
|
||||
"unpin",
|
||||
}
|
||||
|
|
|
@ -140,6 +140,9 @@ type Issue struct {
|
|||
|
||||
// For view issue page.
|
||||
ShowRole RoleDescriptor `xorm:"-"`
|
||||
|
||||
// Time estimate
|
||||
TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -909,3 +912,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(issue *Issue, doer *user_model.User, timeEstimate int64) (err error) {
|
||||
ctx, committer, err := db.TxContext(db.DefaultContext)
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
"code.gitea.io/gitea/models/migrations/v1_20"
|
||||
"code.gitea.io/gitea/models/migrations/v1_21"
|
||||
"code.gitea.io/gitea/models/migrations/v1_22"
|
||||
"code.gitea.io/gitea/models/migrations/v1_23"
|
||||
"code.gitea.io/gitea/models/migrations/v1_6"
|
||||
"code.gitea.io/gitea/models/migrations/v1_7"
|
||||
"code.gitea.io/gitea/models/migrations/v1_8"
|
||||
|
@ -573,7 +574,7 @@ var migrations = []Migration{
|
|||
// v293 -> v294
|
||||
NewMigration("Ensure every project has exactly one default column", v1_22.CheckProjectColumnsConsistency),
|
||||
|
||||
// Gitea 1.22.0-rc0 ends at 294
|
||||
// Gitea 1.22.0 ends at 294
|
||||
|
||||
// v294 -> v295
|
||||
NewMigration("Add unique index for project issue table", v1_22.AddUniqueIndexForProjectIssue),
|
||||
|
@ -587,6 +588,9 @@ var migrations = []Migration{
|
|||
NewMigration("Drop wrongly created table o_auth2_application", v1_22.DropWronglyCreatedTable),
|
||||
|
||||
// Gitea 1.22.0-rc1 ends at 299
|
||||
NewMigration("Drop wrongly created table o_auth2_application", v1_22.DropWronglyCreatedTable),
|
||||
// v300 -> v301
|
||||
NewMigration("Add TimeEstimate to issue table", v1_23.AddTimeEstimateColumnToIssueTable),
|
||||
}
|
||||
|
||||
// GetCurrentDBVersion returns the current db version
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -65,12 +65,14 @@ func NewFuncMap() template.FuncMap {
|
|||
|
||||
// -----------------------------------------------------------------
|
||||
// time / number / format
|
||||
"FileSize": base.FileSize,
|
||||
"CountFmt": base.FormatNumberSI,
|
||||
"TimeSince": timeutil.TimeSince,
|
||||
"TimeSinceUnix": timeutil.TimeSinceUnix,
|
||||
"DateTime": timeutil.DateTime,
|
||||
"Sec2Time": util.SecToTime,
|
||||
"FileSize": base.FileSize,
|
||||
"CountFmt": base.FormatNumberSI,
|
||||
"TimeSince": timeutil.TimeSince,
|
||||
"TimeSinceUnix": timeutil.TimeSinceUnix,
|
||||
"DateTime": timeutil.DateTime,
|
||||
"Sec2Time": util.SecToTime,
|
||||
"SecToTimeExact": util.SecToTimeExact,
|
||||
"TimeEstimateToStr": util.TimeEstimateToStr,
|
||||
"LoadTimes": func(startTime time.Time) string {
|
||||
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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, " ")
|
||||
}
|
|
@ -1480,6 +1480,11 @@ issues.add_assignee_at = `was assigned by <b>%s</b> %s`
|
|||
issues.remove_assignee_at = `was unassigned by <b>%s</b> %s`
|
||||
issues.remove_self_assignment = `removed their assignment %s`
|
||||
issues.change_title_at = `changed title from <b><strike>%s</strike></b> to <b>%s</b> %s`
|
||||
issues.time_estimate = `Time Estimate`
|
||||
issues.add_time_estimate = `3w 4d 12h`
|
||||
issues.change_time_estimate_at = `changed time estimate to <b>%s</b> %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 <b><strike>%s</strike></b> to <b>%s</b> %s`
|
||||
issues.remove_ref_at = `removed reference <b>%s</b> %s`
|
||||
issues.add_ref_at = `added reference <b>%s</b> %s`
|
||||
|
@ -1650,20 +1655,20 @@ 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.stop_tracking_history = `worked for <b>%s</b> %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 <b>%s</b> %s`
|
||||
issues.del_time_history= `deleted spent time <b>%s</b> %s`
|
||||
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.time_spent_from_all_authors = `Total Time Spent:`
|
||||
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."
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
@ -1771,6 +1772,9 @@ func ViewIssue(ctx *context.Context) {
|
|||
comment.Content = comment.Content[1:]
|
||||
}
|
||||
}
|
||||
} else if comment.Type == issues_model.CommentTypeChangeTimeEstimate {
|
||||
timeSec, _ := util.ToInt64(comment.Content)
|
||||
comment.Content = util.SecToTimeExact(timeSec, timeSec < 60)
|
||||
}
|
||||
|
||||
if comment.Type == issues_model.CommentTypeClose || comment.Type == issues_model.CommentTypeMergePull {
|
||||
|
@ -2211,6 +2215,57 @@ func UpdateIssueTitle(ctx *context.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// 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(issue, ctx.Doer, total); err != nil {
|
||||
ctx.ServerError("ChangeTimeEstimate", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(url, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// UpdateIssueRef change issue's ref (branch)
|
||||
func UpdateIssueRef(ctx *context.Context) {
|
||||
issue := GetActionIssue(ctx)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1202,6 +1202,7 @@ func registerRoutes(m *web.Route) {
|
|||
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.SecToTimeExact(timeSec, timeSec < 60)
|
||||
}
|
||||
}
|
||||
|
||||
comment := &api.TimelineComment{
|
||||
|
|
|
@ -877,8 +877,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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(issue *issues_model.Issue, doer *user_model.User, timeEstimate int64) (err error) {
|
||||
issue.TimeEstimate = timeEstimate
|
||||
|
||||
return issues_model.ChangeIssueTimeEstimate(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
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
{{end -}}
|
||||
{{- range .ReviewComments}}
|
||||
<hr>
|
||||
{{$.locale.Tr "mail.issue.in_tree_path" .TreePath}}
|
||||
{{ctx.Locale.Tr "mail.issue.in_tree_path" .TreePath}}
|
||||
<div class="review">
|
||||
<pre>{{.Patch}}</pre>
|
||||
<div>{{.RenderedContent}}</div>
|
||||
|
|
|
@ -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}}
|
||||
|
@ -249,18 +250,18 @@
|
|||
{{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}}
|
||||
</span>
|
||||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
||||
<div class="detail flex-text-block">
|
||||
{{svg "octicon-clock"}}
|
||||
|
||||
{{$timeStr := ""}}
|
||||
{{if .RenderedContent}}
|
||||
{{/* compatibility with time comments made before v1.21 */}}
|
||||
<span class="text grey muted-links">{{.RenderedContent}}</span>
|
||||
{{$timeStr = .RenderedContent}}
|
||||
{{else}}
|
||||
<span class="text grey muted-links">{{.Content|Sec2Time}}</span>
|
||||
{{$timeStr = .Content|Sec2Time}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{ctx.Locale.Tr "repo.issues.stop_tracking_history" $timeStr $createdStr | SafeHTML}}
|
||||
</span>
|
||||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
||||
</div>
|
||||
{{else if eq .Type 14}}
|
||||
<div class="timeline-item event" id="{{.HashTag}}">
|
||||
|
@ -268,18 +269,18 @@
|
|||
{{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}}
|
||||
</span>
|
||||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
||||
<div class="detail flex-text-block">
|
||||
{{svg "octicon-clock"}}
|
||||
|
||||
{{$timeStr := ""}}
|
||||
{{if .RenderedContent}}
|
||||
{{/* compatibility with time comments made before v1.21 */}}
|
||||
<span class="text grey muted-links">{{.RenderedContent}}</span>
|
||||
{{$timeStr = .RenderedContent}}
|
||||
{{else}}
|
||||
<span class="text grey muted-links">{{.Content|Sec2Time}}</span>
|
||||
{{$timeStr = .Content|Sec2Time}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{ctx.Locale.Tr "repo.issues.add_time_history" $timeStr $createdStr | SafeHTML}}
|
||||
</span>
|
||||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
||||
</div>
|
||||
{{else if eq .Type 15}}
|
||||
<div class="timeline-item event" id="{{.HashTag}}">
|
||||
|
@ -680,6 +681,15 @@
|
|||
{{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}}
|
||||
{{ctx.Locale.Tr "repo.issues.change_time_estimate_at" .Content $createdStr | SafeHTML}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
@ -278,8 +278,22 @@
|
|||
{{end}}
|
||||
{{if .Repository.IsTimetrackerEnabled $.Context}}
|
||||
{{if and .CanUseTimetracker (not .Repository.IsArchived)}}
|
||||
<div class="divider"></div>
|
||||
<div class="ui timetrack">
|
||||
<div class="ui divider"></div>
|
||||
<div>
|
||||
<span class="text"><strong>{{ctx.Locale.Tr "repo.issues.time_estimate"}}</strong></span>
|
||||
|
||||
<form method="post" id="set_time_estimate_form" class="gt-mt-3" action="{{.Issue.Link}}/time_estimate">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<div class="ui input fluid">
|
||||
<input name="time_estimate" placeholder='{{ctx.Locale.Tr "repo.issues.add_time_estimate"}}' value="{{TimeEstimateToStr .Issue.TimeEstimate}}" data-value="{{$.Issue.TimeEstimate}}" type="text" >
|
||||
</div>
|
||||
<button class="ui fluid button green tooltip tw-mt-1">
|
||||
{{ctx.Locale.Tr "repo.issues.save"}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<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">
|
||||
|
@ -311,9 +325,8 @@
|
|||
<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">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input placeholder='{{ctx.Locale.Tr "repo.issues.add_time_estimate"}}' type="text" name="time_string">
|
||||
</form>
|
||||
</div>
|
||||
<div class="actions">
|
||||
|
@ -332,8 +345,9 @@
|
|||
{{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>
|
||||
<div class="text"><strong>{{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" | SafeHTML}}</strong></div>
|
||||
<div>{{SecToTimeExact .Issue.TotalTrackedTime false}}</div>
|
||||
<div class="gt-mt-3">
|
||||
{{range $user, $trackedtime := .WorkingUsers}}
|
||||
<div class="comment tw-mt-2">
|
||||
<a class="avatar">
|
||||
|
|
|
@ -73,8 +73,7 @@ func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo
|
|||
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)
|
||||
}
|
||||
|
|
|
@ -6,13 +6,20 @@
|
|||
// This file must be imported before any lazy-loading is being attempted.
|
||||
__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
|
||||
|
||||
export function showGlobalErrorMessage(msg) {
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
if (!pageContent) return;
|
||||
function shouldIgnoreError(err) {
|
||||
const ignorePatterns = [
|
||||
'/assets/js/monaco.', // https://github.com/go-gitea/gitea/issues/30861 , https://github.com/microsoft/monaco-editor/issues/4496
|
||||
];
|
||||
for (const pattern of ignorePatterns) {
|
||||
if (err.stack?.includes(pattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// compact the message to a data attribute to avoid too many duplicated messages
|
||||
const msgCompact = msg.replace(/\W/g, '').trim();
|
||||
let msgDiv = pageContent.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||
export function showGlobalErrorMessage(msg) {
|
||||
const msgContainer = document.querySelector('.page-content') ?? document.body;
|
||||
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
|
||||
let msgDiv = msgContainer.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||
if (!msgDiv) {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = `<div class="ui container negative message center aligned js-global-error tw-mt-[15px] tw-whitespace-pre-line"></div>`;
|
||||
|
@ -23,7 +30,7 @@ export function showGlobalErrorMessage(msg) {
|
|||
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
|
||||
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
|
||||
msgDiv.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
|
||||
pageContent.prepend(msgDiv);
|
||||
msgContainer.prepend(msgDiv);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -52,10 +59,12 @@ function processWindowErrorEvent({error, reason, message, type, filename, lineno
|
|||
if (runModeIsProd) return;
|
||||
}
|
||||
|
||||
// If the error stack trace does not include the base URL of our script assets, it likely came
|
||||
// from a browser extension or inline script. Do not show such errors in production.
|
||||
if (err instanceof Error && !err.stack?.includes(assetBaseUrl) && runModeIsProd) {
|
||||
return;
|
||||
if (err instanceof Error) {
|
||||
// If the error stack trace does not include the base URL of our script assets, it likely came
|
||||
// from a browser extension or inline script. Do not show such errors in production.
|
||||
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
|
||||
// Ignore some known errors that are unable to fix
|
||||
if (shouldIgnoreError(err)) return;
|
||||
}
|
||||
|
||||
let msg = err?.message ?? message;
|
||||
|
|
|
@ -67,7 +67,7 @@ export default {
|
|||
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));
|
||||
const startDays = startDaysBetween(start, end);
|
||||
this.data = fillEmptyStartDaysWithZeroes(startDays, this.data);
|
||||
this.errorText = '';
|
||||
} else {
|
||||
|
|
|
@ -114,7 +114,7 @@ export default {
|
|||
const weekValues = Object.values(total.weeks);
|
||||
this.xAxisStart = weekValues[0].week;
|
||||
this.xAxisEnd = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd));
|
||||
const startDays = startDaysBetween(this.xAxisStart, this.xAxisEnd);
|
||||
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
|
|
|
@ -62,7 +62,7 @@ export default {
|
|||
const data = await response.json();
|
||||
const start = Object.values(data)[0].week;
|
||||
const end = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(new Date(start), new Date(end));
|
||||
const startDays = startDaysBetween(start, end);
|
||||
this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
|
||||
this.errorText = '';
|
||||
} else {
|
||||
|
|
|
@ -1,25 +1,30 @@
|
|||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import {getCurrentLocale} from '../utils.js';
|
||||
|
||||
// Returns an array of millisecond-timestamps of start-of-week days (Sundays)
|
||||
export function startDaysBetween(startDate, endDate) {
|
||||
// Ensure the start date is a Sunday
|
||||
while (startDate.getDay() !== 0) {
|
||||
startDate.setDate(startDate.getDate() + 1);
|
||||
}
|
||||
dayjs.extend(utc);
|
||||
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate);
|
||||
const startDays = [];
|
||||
/**
|
||||
* Returns an array of millisecond-timestamps of start-of-week days (Sundays)
|
||||
*
|
||||
* @param startConfig The start date. Can take any type that `Date` accepts.
|
||||
* @param endConfig The end date. Can take any type that `Date` accepts.
|
||||
*/
|
||||
export function startDaysBetween(startDate, endDate) {
|
||||
const start = dayjs.utc(startDate);
|
||||
const end = dayjs.utc(endDate);
|
||||
|
||||
let current = start;
|
||||
|
||||
// Ensure the start date is a Sunday
|
||||
while (current.day() !== 0) {
|
||||
current = current.add(1, 'day');
|
||||
}
|
||||
|
||||
const startDays = [];
|
||||
while (current.isBefore(end)) {
|
||||
startDays.push(current.valueOf());
|
||||
// we are adding 7 * 24 hours instead of 1 week because we don't want
|
||||
// date library to use local time zone to calculate 1 week from now.
|
||||
// local time zone is problematic because of daylight saving time (dst)
|
||||
// used on some countries
|
||||
current = current.add(7 * 24, 'hour');
|
||||
current = current.add(1, 'week');
|
||||
}
|
||||
|
||||
return startDays;
|
||||
|
@ -29,10 +34,10 @@ export function firstStartDateAfterDate(inputDate) {
|
|||
if (!(inputDate instanceof Date)) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
const dayOfWeek = inputDate.getDay();
|
||||
const dayOfWeek = inputDate.getUTCDay();
|
||||
const daysUntilSunday = 7 - dayOfWeek;
|
||||
const resultDate = new Date(inputDate.getTime());
|
||||
resultDate.setDate(resultDate.getDate() + daysUntilSunday);
|
||||
resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday);
|
||||
return resultDate.valueOf();
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue