1// Copyright 2021 The Gitea Authors. All rights reserved.
2// SPDX-License-Identifier: MIT
3
4package issue
5
6import (
7 "context"
8 "fmt"
9 "html"
10 "net/url"
11 "regexp"
12 "strconv"
13 "strings"
14 "time"
15
16 issues_model "forgejo.org/models/issues"
17 access_model "forgejo.org/models/perm/access"
18 repo_model "forgejo.org/models/repo"
19 user_model "forgejo.org/models/user"
20 "forgejo.org/modules/container"
21 "forgejo.org/modules/git"
22 "forgejo.org/modules/log"
23 "forgejo.org/modules/references"
24 "forgejo.org/modules/repository"
25)
26
27const (
28 secondsByMinute = float64(time.Minute / time.Second) // seconds in a minute
29 secondsByHour = 60 * secondsByMinute // seconds in an hour
30 secondsByDay = 8 * secondsByHour // seconds in a day
31 secondsByWeek = 5 * secondsByDay // seconds in a week
32 secondsByMonth = 4 * secondsByWeek // seconds in a month
33)
34
35var reDuration = regexp.MustCompile(`(?i)^(?:(\d+([\.,]\d+)?)(?:mo))?(?:(\d+([\.,]\d+)?)(?:w))?(?:(\d+([\.,]\d+)?)(?:d))?(?:(\d+([\.,]\d+)?)(?:h))?(?:(\d+([\.,]\d+)?)(?:m))?$`)
36
37// timeLogToAmount parses time log string and returns amount in seconds
38func timeLogToAmount(str string) int64 {
39 matches := reDuration.FindAllStringSubmatch(str, -1)
40 if len(matches) == 0 {
41 return 0
42 }
43
44 match := matches[0]
45
46 var a int64
47
48 // months
49 if len(match[1]) > 0 {
50 mo, _ := strconv.ParseFloat(strings.Replace(match[1], ",", ".", 1), 64)
51 a += int64(mo * secondsByMonth)
52 }
53
54 // weeks
55 if len(match[3]) > 0 {
56 w, _ := strconv.ParseFloat(strings.Replace(match[3], ",", ".", 1), 64)
57 a += int64(w * secondsByWeek)
58 }
59
60 // days
61 if len(match[5]) > 0 {
62 d, _ := strconv.ParseFloat(strings.Replace(match[5], ",", ".", 1), 64)
63 a += int64(d * secondsByDay)
64 }
65
66 // hours
67 if len(match[7]) > 0 {
68 h, _ := strconv.ParseFloat(strings.Replace(match[7], ",", ".", 1), 64)
69 a += int64(h * secondsByHour)
70 }
71
72 // minutes
73 if len(match[9]) > 0 {
74 d, _ := strconv.ParseFloat(strings.Replace(match[9], ",", ".", 1), 64)
75 a += int64(d * secondsByMinute)
76 }
77
78 return a
79}
80
81func issueAddTime(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, time time.Time, timeLog string) error {
82 amount := timeLogToAmount(timeLog)
83 if amount == 0 {
84 return nil
85 }
86
87 _, err := issues_model.AddTime(ctx, doer, issue, amount, time)
88 return err
89}
90
91// getIssueFromRef returns the issue referenced by a ref. Returns a nil *Issue
92// if the provided ref references a non-existent issue.
93func getIssueFromRef(ctx context.Context, repo *repo_model.Repository, index int64) (*issues_model.Issue, error) {
94 issue, err := issues_model.GetIssueByIndex(ctx, repo.ID, index)
95 if err != nil {
96 if issues_model.IsErrIssueNotExist(err) {
97 return nil, nil
98 }
99 return nil, err
100 }
101 return issue, nil
102}
103
104// UpdateIssuesCommit checks if issues are manipulated by commit message.
105func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, commits []*repository.PushCommit, branchName string) error {
106 // Commits are appended in the reverse order.
107 for i := len(commits) - 1; i >= 0; i-- {
108 c := commits[i]
109
110 type markKey struct {
111 ID int64
112 Action references.XRefAction
113 }
114
115 refMarked := make(container.Set[markKey])
116 var refRepo *repo_model.Repository
117 var refIssue *issues_model.Issue
118 var err error
119 for _, ref := range references.FindAllIssueReferences(c.Message) {
120 // issue is from another repo
121 if len(ref.Owner) > 0 && len(ref.Name) > 0 {
122 refRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Name)
123 if err != nil {
124 if repo_model.IsErrRepoNotExist(err) {
125 log.Warn("Repository referenced in commit but does not exist: %v", err)
126 } else {
127 log.Error("repo_model.GetRepositoryByOwnerAndName: %v", err)
128 }
129 continue
130 }
131 } else {
132 refRepo = repo
133 }
134 if refIssue, err = getIssueFromRef(ctx, refRepo, ref.Index); err != nil {
135 return err
136 }
137 if refIssue == nil {
138 continue
139 }
140
141 perm, err := access_model.GetUserRepoPermission(ctx, refRepo, doer)
142 if err != nil {
143 return err
144 }
145
146 key := markKey{ID: refIssue.ID, Action: ref.Action}
147 if !refMarked.Add(key) {
148 continue
149 }
150
151 // FIXME: this kind of condition is all over the code, it should be consolidated in a single place
152 canclose := perm.IsAdmin() || perm.IsOwner() || perm.CanWriteIssuesOrPulls(refIssue.IsPull) || refIssue.PosterID == doer.ID
153 cancomment := canclose || perm.CanReadIssuesOrPulls(refIssue.IsPull)
154
155 // Don't proceed if the user can't comment
156 if !cancomment {
157 continue
158 }
159
160 message := fmt.Sprintf(`<a href="%s/commit/%s">%s</a>`, html.EscapeString(repo.Link()), html.EscapeString(url.PathEscape(c.Sha1)), html.EscapeString(strings.SplitN(c.Message, "\n", 2)[0]))
161 if err = CreateRefComment(ctx, doer, refRepo, refIssue, message, c.Sha1); err != nil {
162 return err
163 }
164
165 // Only issues can be closed/reopened this way, and user needs the correct permissions
166 if refIssue.IsPull || !canclose {
167 continue
168 }
169
170 // Only process closing/reopening keywords
171 if ref.Action != references.XRefActionCloses && ref.Action != references.XRefActionReopens {
172 continue
173 }
174
175 if !repo.CloseIssuesViaCommitInAnyBranch {
176 // If the issue was specified to be in a particular branch, don't allow commits in other branches to close it
177 if refIssue.Ref != "" {
178 issueBranchName := strings.TrimPrefix(refIssue.Ref, git.BranchPrefix)
179 if branchName != issueBranchName {
180 continue
181 }
182 // Otherwise, only process commits to the default branch
183 } else if branchName != repo.DefaultBranch {
184 continue
185 }
186 }
187 isClosed := ref.Action == references.XRefActionCloses
188 if isClosed && len(ref.TimeLog) > 0 {
189 if err := issueAddTime(ctx, refIssue, doer, c.Timestamp, ref.TimeLog); err != nil {
190 return err
191 }
192 }
193 if isClosed != refIssue.IsClosed {
194 refIssue.Repo = refRepo
195 if err := ChangeStatus(ctx, refIssue, doer, c.Sha1, isClosed); err != nil {
196 return err
197 }
198 }
199 }
200 }
201 return nil
202}