Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue time estimate, meaningful time tracking #23113

Open
wants to merge 102 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 95 commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
1363205
Commit
Feb 24, 2023
d11ba9f
Commit
Feb 24, 2023
c783692
Commit
Feb 24, 2023
a8778f4
Commit
Feb 24, 2023
d944662
Commit
Feb 24, 2023
7ed86ff
Commit
Feb 24, 2023
c248042
Commit
Feb 24, 2023
d75b7ac
Commit
Feb 24, 2023
4be8c50
Commit
Feb 24, 2023
5f3edad
Update services/issue/issue.go
stuzer05 Feb 24, 2023
f7a4c9e
Update services/issue/issue.go
stuzer05 Feb 24, 2023
e187364
Commit
Feb 24, 2023
f33b0a0
Commit
Feb 24, 2023
4e1aed8
Merge branch 'main' into add-issue-planned-time
stuzer05 Feb 24, 2023
2fc2f63
Commit
Feb 24, 2023
b062fc9
Commit
Feb 24, 2023
870bb92
Commit
Feb 24, 2023
d247b0f
Commit
Feb 24, 2023
09c05e8
Commit
Feb 24, 2023
0f5b609
Commit
Feb 24, 2023
11b9719
Commit
Feb 24, 2023
1b7ba41
Commit
Feb 24, 2023
f7427d8
Commit
Feb 24, 2023
1cff1a9
Commit
Feb 24, 2023
e155495
Commit
Feb 24, 2023
f463765
Commit
Feb 24, 2023
7a57044
Commit
Feb 24, 2023
79f507b
Commit
Feb 24, 2023
5c4dc87
Commit
Feb 24, 2023
0ab85af
Commit
Feb 24, 2023
5eea230
Commit
Feb 24, 2023
db3c697
Commit
Feb 24, 2023
775c663
Merge branch 'main' into add-issue-planned-time
stuzer05 Feb 24, 2023
8750870
Commit
Feb 24, 2023
29dd617
Commit
Feb 24, 2023
7be748f
Commit
Feb 24, 2023
b9cdc7c
Commit
Feb 26, 2023
8c0bf88
Merge branch 'main' into add-issue-planned-time
stuzer05 Feb 27, 2023
e20e23b
Commit
stuzer05 Feb 27, 2023
37e8e8d
Commit
stuzer05 Feb 27, 2023
8ef3a47
Commit
stuzer05 Feb 27, 2023
49a176d
Commit
stuzer05 Feb 28, 2023
79e18c6
Commit
stuzer05 Feb 28, 2023
deddce5
Commit
stuzer05 Feb 28, 2023
fb81260
Update models/issues/issue.go
stuzer05 Feb 28, 2023
bf323cf
Commit
stuzer05 Feb 28, 2023
57a3664
Commit
stuzer05 Feb 28, 2023
fd5adc5
Merge branch 'main' into add-issue-planned-time
stuzer05 Feb 28, 2023
879d96f
Merge branch 'main' into add-issue-planned-time
stuzer05 Mar 4, 2023
fc93006
Merge branch 'main' into add-issue-planned-time
stuzer05 Apr 18, 2023
bf4fa11
Merge branch 'main' into add-issue-planned-time
stuzer05 Jun 3, 2023
40e1373
Merge branch 'main' into add-issue-planned-time
stuzer05 Jun 6, 2023
e9afd60
Refactor helper functions
stuzer05 Jun 11, 2023
3924cb0
Merge branch 'go-gitea:main' into add-issue-planned-time
stuzer05 Jun 11, 2023
748bd67
Fix displaying issue estimation
stuzer05 Jun 14, 2023
015ad01
Merge branch 'main' into add-issue-planned-time
stuzer05 Jun 18, 2023
cef496a
Remove unused code
stuzer05 Jun 18, 2023
ffaa4ba
Format
stuzer05 Jun 18, 2023
b211b9e
Merge branch 'main' into add-issue-planned-time
silverwind Jun 18, 2023
598e2d5
Merge branch 'main' into add-issue-planned-time
stuzer05 Jun 19, 2023
3310440
Hide time tracking
stuzer05 Jun 23, 2023
721069d
Merge branch 'main' into add-issue-planned-time
stuzer05 Jun 23, 2023
a45c1e9
Merge branch 'main' into add-issue-planned-time
stuzer05 Jun 24, 2023
db49783
Commit
stuzer05 Jun 24, 2023
e933a89
Delete serviceworker.js
stuzer05 Jun 24, 2023
62094d8
Merge branch 'main' into add-issue-planned-time
stuzer05 Jun 26, 2023
fa662ec
Merge branch 'main' into add-issue-planned-time
stuzer05 Jul 10, 2023
64de74d
Merge branch 'main' into add-issue-planned-time
stuzer05 Sep 11, 2023
349b959
Merge branch 'main' into add-issue-planned-time
stuzer05 Oct 7, 2023
3037d6c
Merge branch 'main' into add-issue-planned-time
stuzer05 Oct 8, 2023
2a9009f
Merge branch 'main' into add-issue-planned-time
stuzer05 Oct 16, 2023
a6fa4c3
Merge branch 'main' into add-issue-planned-time
stuzer05 Jan 15, 2024
a999055
Merge branch 'main' into add-issue-planned-time
stuzer05 Jan 17, 2024
39b8b19
Merge branch 'main' into add-issue-planned-time
stuzer05 Jan 22, 2024
92dc2cd
Merge branch 'main' into add-issue-planned-time
stuzer05 Feb 11, 2024
a737a8c
Merge branch 'main' into add-issue-planned-time
stuzer05 Feb 19, 2024
bb5ca4c
Merge branch 'main' into add-issue-planned-time
stuzer05 Feb 28, 2024
805af19
Merge branch 'main' into add-issue-planned-time
stuzer05 Mar 4, 2024
0c4b2df
Commit
stuzer05 Mar 4, 2024
c272512
Merge branch 'main' into add-issue-planned-time
stuzer05 Mar 4, 2024
09723c5
Merge branch 'main' into add-issue-planned-time
stuzer05 Mar 17, 2024
0820db0
Merge branch 'main' into add-issue-planned-time
stuzer05 Mar 28, 2024
93d09fa
Merge branch 'main' into add-issue-planned-time
stuzer05 Mar 29, 2024
1e22cc2
Merge branch 'main' into add-issue-planned-time
stuzer05 Apr 13, 2024
e92bedc
Commit
stuzer05 Apr 13, 2024
b452e13
Commit
stuzer05 Apr 13, 2024
b97139b
Merge branch 'main' into add-issue-planned-time
stuzer05 Apr 15, 2024
12553d7
Merge branch 'main' into add-issue-planned-time
stuzer05 Apr 22, 2024
0a8fd35
Merge branch 'main' into add-issue-planned-time
stuzer05 Apr 25, 2024
8c92f46
Merge branch 'main' into add-issue-planned-time
stuzer05 Apr 30, 2024
0c44cf7
Commit
stuzer05 Apr 30, 2024
a71b457
Commit
stuzer05 May 2, 2024
52d62fb
Merge branch 'main' into add-issue-planned-time
stuzer05 May 17, 2024
ff60376
Merge branch 'main' into add-issue-planned-time
stuzer05 May 30, 2024
be18b73
Merge branch 'main' into add-issue-planned-time
stuzer05 Jun 17, 2024
84d9dee
Merge branch 'main' into add-issue-planned-time
6543 Jul 5, 2024
823d67f
pass context down
6543 Jul 5, 2024
4ff958b
Merge branch 'main' into add-issue-planned-time
stuzer05 Jul 8, 2024
3499c44
Use RenderedContent
stuzer05 Jul 8, 2024
bf7fcba
Merge branch 'main' into add-issue-planned-time
stuzer05 Aug 1, 2024
392fa48
Merge branch 'main' into add-issue-planned-time
stuzer05 Aug 26, 2024
66b2c4a
Merge branch 'main' into add-issue-planned-time
stuzer05 Oct 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions models/issues/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ const (

CommentTypePin // 36 pin Issue
CommentTypeUnpin // 37 unpin Issue

CommentTypeChangeTimeEstimate // 38 Change time estimate
)

var commentStrings = []string{
Expand Down Expand Up @@ -153,6 +155,7 @@ var commentStrings = []string{
"change_issue_ref",
"pull_scheduled_merge",
"pull_cancel_scheduled_merge",
"change_time_estimate",
"pin",
"unpin",
}
Expand Down
33 changes: 33 additions & 0 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ type Issue struct {

// For view issue page.
ShowRole RoleDescriptor `xorm:"-"`

// Time estimate
TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"`
}

var (
Expand Down Expand Up @@ -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(issue *Issue, doer *user_model.User, timeEstimate int64) (err error) {
6543 marked this conversation as resolved.
Show resolved Hide resolved
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()
}
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,8 @@ var migrations = []Migration{

// v299 -> v300
NewMigration("Add content version to issue and comment table", v1_23.AddContentVersionToIssueAndComment),
// v300 -> v301
NewMigration("Add TimeEstimate to issue table", v1_23.AddTimeEstimateColumnToIssueTable),
}

// GetCurrentDBVersion returns the current db version
Expand Down
16 changes: 16 additions & 0 deletions models/migrations/v1_23/v300.go
Original file line number Diff line number Diff line change
@@ -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))
}
14 changes: 8 additions & 6 deletions modules/templates/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,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"
},
Expand Down
37 changes: 37 additions & 0 deletions modules/util/sec_to_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
99 changes: 99 additions & 0 deletions modules/util/time_str.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding tests for this function would be awesome!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will work on it

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, " ")
}
13 changes: 9 additions & 4 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,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`
Expand Down Expand Up @@ -1652,20 +1657,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."
Expand Down
55 changes: 55 additions & 0 deletions routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"math/big"
"net/http"
"net/url"
"regexp"
"slices"
"sort"
"strconv"
Expand Down Expand Up @@ -1774,6 +1775,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)
stuzer05 marked this conversation as resolved.
Show resolved Hide resolved
}

if comment.Type == issues_model.CommentTypeClose || comment.Type == issues_model.CommentTypeMergePull {
Expand Down Expand Up @@ -2215,6 +2219,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)
Expand Down
4 changes: 2 additions & 2 deletions routers/web/repo/issue_timetrack.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ 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"))
c.Redirect(url, http.StatusSeeOther)
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
}
Expand Down
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,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)
Expand Down
Loading