1
1
mirror of https://github.com/go-gitea/gitea synced 2024-12-22 16:44:26 +00:00

Add spent time to referenced issue in commit message (#12220)

This commit is contained in:
Lauris BH 2020-09-04 18:37:37 +03:00 committed by GitHub
parent 4c557eff5d
commit e710a34981
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 184 additions and 40 deletions

View File

@ -42,7 +42,6 @@ Example:
This is also valid for teams and organizations: This is also valid for teams and organizations:
> [@Documenters](#), we need to plan for this. > [@Documenters](#), we need to plan for this.
> [@CoolCompanyInc](#), this issue concerns us all! > [@CoolCompanyInc](#), this issue concerns us all!
Teams will receive mail notifications when appropriate, but whole organizations won't. Teams will receive mail notifications when appropriate, but whole organizations won't.
@ -123,6 +122,33 @@ The default _keywords_ are:
* **Closing**: close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved * **Closing**: close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved
* **Reopening**: reopen, reopens, reopened * **Reopening**: reopen, reopens, reopened
## Time tracking in Pull Requests and Commit Messages
When commit or merging of pull request results in automatic closing of issue
it is possible to also add spent time resolving this issue through commit message.
To specify spent time on resolving issue you need to specify time in format
`@<number><time-unit>` after issue number. In one commit message you can specify
multiple fixed issues and spent time for each of them.
Supported time units (`<time-unit>`):
* `m` - minutes
* `h` - hours
* `d` - days (equals to 8 hours)
* `w` - weeks (equals to 5 days)
* `mo` - months (equals to 4 weeks)
Numbers to specify time (`<number>`) can be also decimal numbers, ex. `@1.5h` would
result in one and half hours. Multiple time units can be combined, ex. `@1h10m` would
mean 1 hour and 10 minutes.
Example of commit message:
> Fixed #123 spent @1h, refs #102, fixes #124 @1.5h
This would result in 1 hour added to issue #123 and 1 and half hours added to issue #124.
## External Trackers ## External Trackers
Gitea supports the use of external issue trackers, and references to issues Gitea supports the use of external issue trackers, and references to issues
@ -132,7 +158,6 @@ the pull requests hosted in Gitea. To address this, Gitea allows the use of
the `!` marker to identify pull requests. For example: the `!` marker to identify pull requests. For example:
> This is issue [#1234](#), and links to the external tracker. > This is issue [#1234](#), and links to the external tracker.
> This is pull request [!1234](#), and links to a pull request in Gitea. > This is pull request [!1234](#), and links to a pull request in Gitea.
The `!` and `#` can be used interchangeably for issues and pull request _except_ The `!` and `#` can be used interchangeably for issues and pull request _except_

View File

@ -37,6 +37,8 @@ var (
crossReferenceIssueNumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-zA-Z-_\.]+/[0-9a-zA-Z-_\.]+[#!][0-9]+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`) crossReferenceIssueNumericPattern = regexp.MustCompile(`(?:\s|^|\(|\[)([0-9a-zA-Z-_\.]+/[0-9a-zA-Z-_\.]+[#!][0-9]+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
// spaceTrimmedPattern let's us find the trailing space // spaceTrimmedPattern let's us find the trailing space
spaceTrimmedPattern = regexp.MustCompile(`(?:.*[0-9a-zA-Z-_])\s`) spaceTrimmedPattern = regexp.MustCompile(`(?:.*[0-9a-zA-Z-_])\s`)
// timeLogPattern matches string for time tracking
timeLogPattern = regexp.MustCompile(`(?:\s|^|\(|\[)(@([0-9]+([\.,][0-9]+)?(w|d|m|h))+)(?:\s|$|\)|\]|[:;,.?!]\s|[:;,.?!]$)`)
issueCloseKeywordsPat, issueReopenKeywordsPat *regexp.Regexp issueCloseKeywordsPat, issueReopenKeywordsPat *regexp.Regexp
issueKeywordsOnce sync.Once issueKeywordsOnce sync.Once
@ -62,10 +64,11 @@ const (
// IssueReference contains an unverified cross-reference to a local issue or pull request // IssueReference contains an unverified cross-reference to a local issue or pull request
type IssueReference struct { type IssueReference struct {
Index int64 Index int64
Owner string Owner string
Name string Name string
Action XRefAction Action XRefAction
TimeLog string
} }
// RenderizableReference contains an unverified cross-reference to with rendering information // RenderizableReference contains an unverified cross-reference to with rendering information
@ -91,16 +94,18 @@ type rawReference struct {
issue string issue string
refLocation *RefSpan refLocation *RefSpan
actionLocation *RefSpan actionLocation *RefSpan
timeLog string
} }
func rawToIssueReferenceList(reflist []*rawReference) []IssueReference { func rawToIssueReferenceList(reflist []*rawReference) []IssueReference {
refarr := make([]IssueReference, len(reflist)) refarr := make([]IssueReference, len(reflist))
for i, r := range reflist { for i, r := range reflist {
refarr[i] = IssueReference{ refarr[i] = IssueReference{
Index: r.index, Index: r.index,
Owner: r.owner, Owner: r.owner,
Name: r.name, Name: r.name,
Action: r.action, Action: r.action,
TimeLog: r.timeLog,
} }
} }
return refarr return refarr
@ -386,6 +391,38 @@ func findAllIssueReferencesBytes(content []byte, links []string) []*rawReference
} }
} }
if len(ret) == 0 {
return ret
}
pos = 0
for {
match := timeLogPattern.FindSubmatchIndex(content[pos:])
if match == nil {
break
}
timeLogEntry := string(content[match[2]+pos+1 : match[3]+pos])
var f *rawReference
for _, ref := range ret {
if ref.refLocation != nil && ref.refLocation.End < match[2]+pos && (f == nil || f.refLocation.End < ref.refLocation.End) {
f = ref
}
}
pos = match[1] + pos
if f == nil {
f = ret[0]
}
if len(f.timeLog) == 0 {
f.timeLog = timeLogEntry
}
}
return ret return ret
} }

View File

@ -26,6 +26,7 @@ type testResult struct {
Action XRefAction Action XRefAction
RefLocation *RefSpan RefLocation *RefSpan
ActionLocation *RefSpan ActionLocation *RefSpan
TimeLog string
} }
func TestFindAllIssueReferences(t *testing.T) { func TestFindAllIssueReferences(t *testing.T) {
@ -34,19 +35,19 @@ func TestFindAllIssueReferences(t *testing.T) {
{ {
"Simply closes: #29 yes", "Simply closes: #29 yes",
[]testResult{ []testResult{
{29, "", "", "29", false, XRefActionCloses, &RefSpan{Start: 15, End: 18}, &RefSpan{Start: 7, End: 13}}, {29, "", "", "29", false, XRefActionCloses, &RefSpan{Start: 15, End: 18}, &RefSpan{Start: 7, End: 13}, ""},
}, },
}, },
{ {
"Simply closes: !29 yes", "Simply closes: !29 yes",
[]testResult{ []testResult{
{29, "", "", "29", true, XRefActionCloses, &RefSpan{Start: 15, End: 18}, &RefSpan{Start: 7, End: 13}}, {29, "", "", "29", true, XRefActionCloses, &RefSpan{Start: 15, End: 18}, &RefSpan{Start: 7, End: 13}, ""},
}, },
}, },
{ {
" #124 yes, this is a reference.", " #124 yes, this is a reference.",
[]testResult{ []testResult{
{124, "", "", "124", false, XRefActionNone, &RefSpan{Start: 0, End: 4}, nil}, {124, "", "", "124", false, XRefActionNone, &RefSpan{Start: 0, End: 4}, nil, ""},
}, },
}, },
{ {
@ -60,13 +61,13 @@ func TestFindAllIssueReferences(t *testing.T) {
{ {
"This user3/repo4#200 yes.", "This user3/repo4#200 yes.",
[]testResult{ []testResult{
{200, "user3", "repo4", "200", false, XRefActionNone, &RefSpan{Start: 5, End: 20}, nil}, {200, "user3", "repo4", "200", false, XRefActionNone, &RefSpan{Start: 5, End: 20}, nil, ""},
}, },
}, },
{ {
"This user3/repo4!200 yes.", "This user3/repo4!200 yes.",
[]testResult{ []testResult{
{200, "user3", "repo4", "200", true, XRefActionNone, &RefSpan{Start: 5, End: 20}, nil}, {200, "user3", "repo4", "200", true, XRefActionNone, &RefSpan{Start: 5, End: 20}, nil, ""},
}, },
}, },
{ {
@ -76,19 +77,19 @@ func TestFindAllIssueReferences(t *testing.T) {
{ {
"This [two](/user2/repo1/issues/921) yes.", "This [two](/user2/repo1/issues/921) yes.",
[]testResult{ []testResult{
{921, "user2", "repo1", "921", false, XRefActionNone, nil, nil}, {921, "user2", "repo1", "921", false, XRefActionNone, nil, nil, ""},
}, },
}, },
{ {
"This [three](/user2/repo1/pulls/922) yes.", "This [three](/user2/repo1/pulls/922) yes.",
[]testResult{ []testResult{
{922, "user2", "repo1", "922", true, XRefActionNone, nil, nil}, {922, "user2", "repo1", "922", true, XRefActionNone, nil, nil, ""},
}, },
}, },
{ {
"This [four](http://gitea.com:3000/user3/repo4/issues/203) yes.", "This [four](http://gitea.com:3000/user3/repo4/issues/203) yes.",
[]testResult{ []testResult{
{203, "user3", "repo4", "203", false, XRefActionNone, nil, nil}, {203, "user3", "repo4", "203", false, XRefActionNone, nil, nil, ""},
}, },
}, },
{ {
@ -102,49 +103,49 @@ func TestFindAllIssueReferences(t *testing.T) {
{ {
"This http://gitea.com:3000/user4/repo5/pulls/202 yes.", "This http://gitea.com:3000/user4/repo5/pulls/202 yes.",
[]testResult{ []testResult{
{202, "user4", "repo5", "202", true, XRefActionNone, nil, nil}, {202, "user4", "repo5", "202", true, XRefActionNone, nil, nil, ""},
}, },
}, },
{ {
"This http://GiTeA.COM:3000/user4/repo6/pulls/205 yes.", "This http://GiTeA.COM:3000/user4/repo6/pulls/205 yes.",
[]testResult{ []testResult{
{205, "user4", "repo6", "205", true, XRefActionNone, nil, nil}, {205, "user4", "repo6", "205", true, XRefActionNone, nil, nil, ""},
}, },
}, },
{ {
"Reopens #15 yes", "Reopens #15 yes",
[]testResult{ []testResult{
{15, "", "", "15", false, XRefActionReopens, &RefSpan{Start: 8, End: 11}, &RefSpan{Start: 0, End: 7}}, {15, "", "", "15", false, XRefActionReopens, &RefSpan{Start: 8, End: 11}, &RefSpan{Start: 0, End: 7}, ""},
}, },
}, },
{ {
"This closes #20 for you yes", "This closes #20 for you yes",
[]testResult{ []testResult{
{20, "", "", "20", false, XRefActionCloses, &RefSpan{Start: 12, End: 15}, &RefSpan{Start: 5, End: 11}}, {20, "", "", "20", false, XRefActionCloses, &RefSpan{Start: 12, End: 15}, &RefSpan{Start: 5, End: 11}, ""},
}, },
}, },
{ {
"Do you fix user6/repo6#300 ? yes", "Do you fix user6/repo6#300 ? yes",
[]testResult{ []testResult{
{300, "user6", "repo6", "300", false, XRefActionCloses, &RefSpan{Start: 11, End: 26}, &RefSpan{Start: 7, End: 10}}, {300, "user6", "repo6", "300", false, XRefActionCloses, &RefSpan{Start: 11, End: 26}, &RefSpan{Start: 7, End: 10}, ""},
}, },
}, },
{ {
"For 999 #1235 no keyword, but yes", "For 999 #1235 no keyword, but yes",
[]testResult{ []testResult{
{1235, "", "", "1235", false, XRefActionNone, &RefSpan{Start: 8, End: 13}, nil}, {1235, "", "", "1235", false, XRefActionNone, &RefSpan{Start: 8, End: 13}, nil, ""},
}, },
}, },
{ {
"For [!123] yes", "For [!123] yes",
[]testResult{ []testResult{
{123, "", "", "123", true, XRefActionNone, &RefSpan{Start: 5, End: 9}, nil}, {123, "", "", "123", true, XRefActionNone, &RefSpan{Start: 5, End: 9}, nil, ""},
}, },
}, },
{ {
"For (#345) yes", "For (#345) yes",
[]testResult{ []testResult{
{345, "", "", "345", false, XRefActionNone, &RefSpan{Start: 5, End: 9}, nil}, {345, "", "", "345", false, XRefActionNone, &RefSpan{Start: 5, End: 9}, nil, ""},
}, },
}, },
{ {
@ -154,31 +155,39 @@ func TestFindAllIssueReferences(t *testing.T) {
{ {
"For #24, and #25. yes; also #26; #27? #28! and #29: should", "For #24, and #25. yes; also #26; #27? #28! and #29: should",
[]testResult{ []testResult{
{24, "", "", "24", false, XRefActionNone, &RefSpan{Start: 4, End: 7}, nil}, {24, "", "", "24", false, XRefActionNone, &RefSpan{Start: 4, End: 7}, nil, ""},
{25, "", "", "25", false, XRefActionNone, &RefSpan{Start: 13, End: 16}, nil}, {25, "", "", "25", false, XRefActionNone, &RefSpan{Start: 13, End: 16}, nil, ""},
{26, "", "", "26", false, XRefActionNone, &RefSpan{Start: 28, End: 31}, nil}, {26, "", "", "26", false, XRefActionNone, &RefSpan{Start: 28, End: 31}, nil, ""},
{27, "", "", "27", false, XRefActionNone, &RefSpan{Start: 33, End: 36}, nil}, {27, "", "", "27", false, XRefActionNone, &RefSpan{Start: 33, End: 36}, nil, ""},
{28, "", "", "28", false, XRefActionNone, &RefSpan{Start: 38, End: 41}, nil}, {28, "", "", "28", false, XRefActionNone, &RefSpan{Start: 38, End: 41}, nil, ""},
{29, "", "", "29", false, XRefActionNone, &RefSpan{Start: 47, End: 50}, nil}, {29, "", "", "29", false, XRefActionNone, &RefSpan{Start: 47, End: 50}, nil, ""},
}, },
}, },
{ {
"This user3/repo4#200, yes.", "This user3/repo4#200, yes.",
[]testResult{ []testResult{
{200, "user3", "repo4", "200", false, XRefActionNone, &RefSpan{Start: 5, End: 20}, nil}, {200, "user3", "repo4", "200", false, XRefActionNone, &RefSpan{Start: 5, End: 20}, nil, ""},
}, },
}, },
{ {
"Which abc. #9434 same as above", "Which abc. #9434 same as above",
[]testResult{ []testResult{
{9434, "", "", "9434", false, XRefActionNone, &RefSpan{Start: 11, End: 16}, nil}, {9434, "", "", "9434", false, XRefActionNone, &RefSpan{Start: 11, End: 16}, nil, ""},
}, },
}, },
{ {
"This closes #600 and reopens #599", "This closes #600 and reopens #599",
[]testResult{ []testResult{
{600, "", "", "600", false, XRefActionCloses, &RefSpan{Start: 12, End: 16}, &RefSpan{Start: 5, End: 11}}, {600, "", "", "600", false, XRefActionCloses, &RefSpan{Start: 12, End: 16}, &RefSpan{Start: 5, End: 11}, ""},
{599, "", "", "599", false, XRefActionReopens, &RefSpan{Start: 29, End: 33}, &RefSpan{Start: 21, End: 28}}, {599, "", "", "599", false, XRefActionReopens, &RefSpan{Start: 29, End: 33}, &RefSpan{Start: 21, End: 28}, ""},
},
},
{
"This fixes #100 spent @40m and reopens #101, also fixes #102 spent @4h15m",
[]testResult{
{100, "", "", "100", false, XRefActionCloses, &RefSpan{Start: 11, End: 15}, &RefSpan{Start: 5, End: 10}, "40m"},
{101, "", "", "101", false, XRefActionReopens, &RefSpan{Start: 39, End: 43}, &RefSpan{Start: 31, End: 38}, ""},
{102, "", "", "102", false, XRefActionCloses, &RefSpan{Start: 56, End: 60}, &RefSpan{Start: 50, End: 55}, "4h15m"},
}, },
}, },
} }
@ -237,6 +246,7 @@ func testFixtures(t *testing.T, fixtures []testFixture, context string) {
issue: e.Issue, issue: e.Issue,
refLocation: e.RefLocation, refLocation: e.RefLocation,
actionLocation: e.ActionLocation, actionLocation: e.ActionLocation,
timeLog: e.TimeLog,
} }
} }
expref := rawToIssueReferenceList(expraw) expref := rawToIssueReferenceList(expraw)
@ -382,25 +392,25 @@ func TestCustomizeCloseKeywords(t *testing.T) {
{ {
"Simplemente cierra: #29 yes", "Simplemente cierra: #29 yes",
[]testResult{ []testResult{
{29, "", "", "29", false, XRefActionCloses, &RefSpan{Start: 20, End: 23}, &RefSpan{Start: 12, End: 18}}, {29, "", "", "29", false, XRefActionCloses, &RefSpan{Start: 20, End: 23}, &RefSpan{Start: 12, End: 18}, ""},
}, },
}, },
{ {
"Closes: #123 no, this English.", "Closes: #123 no, this English.",
[]testResult{ []testResult{
{123, "", "", "123", false, XRefActionNone, &RefSpan{Start: 8, End: 12}, nil}, {123, "", "", "123", false, XRefActionNone, &RefSpan{Start: 8, End: 12}, nil, ""},
}, },
}, },
{ {
"Cerró user6/repo6#300 yes", "Cerró user6/repo6#300 yes",
[]testResult{ []testResult{
{300, "user6", "repo6", "300", false, XRefActionCloses, &RefSpan{Start: 7, End: 22}, &RefSpan{Start: 0, End: 6}}, {300, "user6", "repo6", "300", false, XRefActionCloses, &RefSpan{Start: 7, End: 22}, &RefSpan{Start: 0, End: 6}, ""},
}, },
}, },
{ {
"Reabre user3/repo4#200 yes", "Reabre user3/repo4#200 yes",
[]testResult{ []testResult{
{200, "user3", "repo4", "200", false, XRefActionReopens, &RefSpan{Start: 7, End: 22}, &RefSpan{Start: 0, End: 6}}, {200, "user3", "repo4", "200", false, XRefActionReopens, &RefSpan{Start: 7, End: 22}, &RefSpan{Start: 0, End: 6}, ""},
}, },
}, },
} }

View File

@ -8,7 +8,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
"regexp"
"strconv"
"strings" "strings"
"time"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
@ -19,6 +22,16 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
) )
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))?$`)
// getIssueFromRef returns the issue referenced by a ref. Returns a nil *Issue // getIssueFromRef returns the issue referenced by a ref. Returns a nil *Issue
// if the provided ref references a non-existent issue. // if the provided ref references a non-existent issue.
func getIssueFromRef(repo *models.Repository, index int64) (*models.Issue, error) { func getIssueFromRef(repo *models.Repository, index int64) (*models.Issue, error) {
@ -32,6 +45,60 @@ func getIssueFromRef(repo *models.Repository, index int64) (*models.Issue, error
return issue, nil return issue, nil
} }
// 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(issue *models.Issue, doer *models.User, time time.Time, timeLog string) error {
amount := timeLogToAmount(timeLog)
if amount == 0 {
return nil
}
_, err := models.AddTime(doer, issue, amount, time)
return err
}
func changeIssueStatus(repo *models.Repository, issue *models.Issue, doer *models.User, closed bool) error { func changeIssueStatus(repo *models.Repository, issue *models.Issue, doer *models.User, closed bool) error {
stopTimerIfAvailable := func(doer *models.User, issue *models.Issue) error { stopTimerIfAvailable := func(doer *models.User, issue *models.Issue) error {
@ -139,6 +206,11 @@ func UpdateIssuesCommit(doer *models.User, repo *models.Repository, commits []*r
} }
} }
close := (ref.Action == references.XRefActionCloses) close := (ref.Action == references.XRefActionCloses)
if close && len(ref.TimeLog) > 0 {
if err := issueAddTime(refIssue, doer, c.Timestamp, ref.TimeLog); err != nil {
return err
}
}
if close != refIssue.IsClosed { if close != refIssue.IsClosed {
if err := changeIssueStatus(refRepo, refIssue, doer, close); err != nil { if err := changeIssueStatus(refRepo, refIssue, doer, close); err != nil {
return err return err