mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 06:33:42 +09:00
On GitHub, one can close PRs via `Fixes: #123` references which was not possible on Gitea before, but now is. Verified fully that behaviour matches GH and ensured no regressions for external trackers.
239 lines
7.1 KiB
Go
239 lines
7.1 KiB
Go
// Copyright 2021 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package issue
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
issues_model "gitea.dev/models/issues"
|
|
access_model "gitea.dev/models/perm/access"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unit"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/container"
|
|
"gitea.dev/modules/git"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/references"
|
|
"gitea.dev/modules/repository"
|
|
)
|
|
|
|
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)
|
|
if amount == 0 {
|
|
return nil
|
|
}
|
|
|
|
_, err := issues_model.AddTime(ctx, doer, issue, amount, time)
|
|
return err
|
|
}
|
|
|
|
// isSelfReference checks if a commit is the merge commit of the PR it references.
|
|
// This prevents creating self-referencing timeline entries when a PR merge commit
|
|
// contains a reference to its own PR number in the commit message.
|
|
func isSelfReference(ctx context.Context, issue *issues_model.Issue, commitSHA string) bool {
|
|
if !issue.IsPull {
|
|
return false
|
|
}
|
|
|
|
if err := issue.LoadPullRequest(ctx); err != nil {
|
|
if !issues_model.IsErrPullRequestNotExist(err) {
|
|
log.Error("LoadPullRequest: %v", err)
|
|
}
|
|
return false
|
|
}
|
|
|
|
return issue.PullRequest.MergedCommitID == commitSHA
|
|
}
|
|
|
|
// getIssueFromRef returns the issue referenced by a ref. Returns a nil *Issue
|
|
// if the provided ref references a non-existent issue.
|
|
func getIssueFromRef(ctx context.Context, repo *repo_model.Repository, index int64) (*issues_model.Issue, error) {
|
|
issue, err := issues_model.GetIssueByIndex(ctx, repo.ID, index)
|
|
if err != nil {
|
|
if issues_model.IsErrIssueNotExist(err) {
|
|
return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist
|
|
}
|
|
return nil, err
|
|
}
|
|
return issue, nil
|
|
}
|
|
|
|
// UpdateIssuesCommit checks if issues are manipulated by commit message.
|
|
func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, commits []*repository.PushCommit, branchName string) error {
|
|
// Commits are appended in the reverse order.
|
|
for _, c := range slices.Backward(commits) {
|
|
type markKey struct {
|
|
ID int64
|
|
Action references.XRefAction
|
|
}
|
|
|
|
refMarked := make(container.Set[markKey])
|
|
var refRepo *repo_model.Repository
|
|
var refIssue *issues_model.Issue
|
|
var err error
|
|
for _, ref := range references.FindAllIssueReferences(c.Message) {
|
|
// issue is from another repo
|
|
if len(ref.Owner) > 0 && len(ref.Name) > 0 {
|
|
refRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Name)
|
|
if err != nil {
|
|
if repo_model.IsErrRepoNotExist(err) {
|
|
log.Warn("Repository referenced in commit but does not exist: %v", err)
|
|
} else {
|
|
log.Error("repo_model.GetRepositoryByOwnerAndName: %v", err)
|
|
}
|
|
continue
|
|
}
|
|
} else {
|
|
refRepo = repo
|
|
}
|
|
if refIssue, err = getIssueFromRef(ctx, refRepo, ref.Index); err != nil {
|
|
return err
|
|
}
|
|
if refIssue == nil {
|
|
continue
|
|
}
|
|
|
|
perm, err := access_model.GetDoerRepoPermission(ctx, refRepo, doer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
key := markKey{ID: refIssue.ID, Action: ref.Action}
|
|
if !refMarked.Add(key) {
|
|
continue
|
|
}
|
|
|
|
// FIXME: this kind of condition is all over the code, it should be consolidated in a single place
|
|
canclose := perm.IsAdmin() || perm.IsOwner() || perm.CanWriteIssuesOrPulls(refIssue.IsPull) || refIssue.PosterID == doer.ID
|
|
cancomment := canclose || perm.CanReadIssuesOrPulls(refIssue.IsPull)
|
|
|
|
// Don't proceed if the user can't comment
|
|
if !cancomment {
|
|
continue
|
|
}
|
|
|
|
// Skip self-references: if this commit is the merge commit of the PR it references
|
|
if isSelfReference(ctx, refIssue, c.Sha1) {
|
|
continue
|
|
}
|
|
|
|
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]))
|
|
if err = CreateRefComment(ctx, doer, refRepo, refIssue, message, c.Sha1); err != nil {
|
|
if errors.Is(err, user_model.ErrBlockedUser) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Only issues can be reopened this way, and user needs the correct permissions
|
|
if !canclose || (refIssue.IsPull && ref.Action == references.XRefActionReopens) {
|
|
continue
|
|
}
|
|
|
|
// Only process closing/reopening keywords
|
|
if ref.Action != references.XRefActionCloses && ref.Action != references.XRefActionReopens {
|
|
continue
|
|
}
|
|
|
|
// With an external tracker, pull requests are referenced as "!N"
|
|
if !ref.IsPull && refIssue.IsPull && refRepo.UnitEnabled(ctx, unit.TypeExternalTracker) {
|
|
continue
|
|
}
|
|
|
|
if !repo.CloseIssuesViaCommitInAnyBranch {
|
|
// If the issue was specified to be in a particular branch, don't allow commits in other branches to close it
|
|
if refIssue.Ref != "" {
|
|
issueBranchName := strings.TrimPrefix(refIssue.Ref, git.BranchPrefix)
|
|
if branchName != issueBranchName {
|
|
continue
|
|
}
|
|
// Otherwise, only process commits to the default branch
|
|
} else if branchName != repo.DefaultBranch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
refIssue.Repo = refRepo
|
|
if ref.Action == references.XRefActionCloses && !refIssue.IsClosed {
|
|
if len(ref.TimeLog) > 0 {
|
|
if err := issueAddTime(ctx, refIssue, doer, c.Timestamp, ref.TimeLog); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := CloseIssue(ctx, refIssue, doer, c.Sha1); err != nil {
|
|
return err
|
|
}
|
|
} else if ref.Action == references.XRefActionReopens && refIssue.IsClosed {
|
|
if err := ReopenIssue(ctx, refIssue, doer, c.Sha1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|