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

Configurable close and reopen keywords for PRs #8120

Merged
merged 15 commits into from
Oct 30, 2019
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
4 changes: 4 additions & 0 deletions custom/conf/app.ini.sample
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ MAX_FILES = 5
[repository.pull-request]
; List of prefixes used in Pull Request title to mark them as Work In Progress
WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
; List of keywords used in Pull Request comments to automatically close a related issue
CLOSE_KEYWORDS=close,closes,closed,fix,fixes,fixed,resolve,resolves,resolved
; List of keywords used in Pull Request comments to automatically reopen a related issue
REOPEN_KEYWORDS=reopen,reopens,reopened

[repository.issue]
; List of reasons why a Pull Request or Issue can be locked
Expand Down
4 changes: 4 additions & 0 deletions docs/content/doc/advanced/config-cheat-sheet.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.

- `WORK_IN_PROGRESS_PREFIXES`: **WIP:,\[WIP\]**: List of prefixes used in Pull Request
title to mark them as Work In Progress
- `CLOSE_KEYWORDS`: **close**, **closes**, **closed**, **fix**, **fixes**, **fixed**, **resolve**, **resolves**, **resolved**: List of
keywords used in Pull Request comments to automatically close a related issue
- `REOPEN_KEYWORDS`: **reopen**, **reopens**, **reopened**: List of keywords used in Pull Request comments to automatically reopen
a related issue

### Repository - Issue (`repository.issue`)

Expand Down
131 changes: 79 additions & 52 deletions models/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"

Expand Down Expand Up @@ -55,28 +56,25 @@ const (
)

var (
// Same as GitHub. See
// https://help.github.com/articles/closing-issues-via-commit-messages
issueCloseKeywords = []string{"close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"}
issueReopenKeywords = []string{"reopen", "reopens", "reopened"}

issueCloseKeywordsPat, issueReopenKeywordsPat *regexp.Regexp
issueReferenceKeywordsPat *regexp.Regexp
issueKeywordsOnce sync.Once
)

const issueRefRegexpStr = `(?:([0-9a-zA-Z-_\.]+)/([0-9a-zA-Z-_\.]+))?(#[0-9]+)+`
const issueRefRegexpStrNoKeyword = `(?:\s|^|\(|\[)(?:([0-9a-zA-Z-_\.]+)/([0-9a-zA-Z-_\.]+))?(#[0-9]+)(?:\s|$|\)|\]|:|\.(\s|$))`

func assembleKeywordsPattern(words []string) string {
return fmt.Sprintf(`(?i)(?:%s)(?::?) %s`, strings.Join(words, "|"), issueRefRegexpStr)
}

func init() {
issueCloseKeywordsPat = regexp.MustCompile(assembleKeywordsPattern(issueCloseKeywords))
issueReopenKeywordsPat = regexp.MustCompile(assembleKeywordsPattern(issueReopenKeywords))
issueReferenceKeywordsPat = regexp.MustCompile(issueRefRegexpStrNoKeyword)
}

func initKeywordsRegexp() {
issueKeywordsOnce.Do(func() {
issueCloseKeywordsPat = buildKeywordsRegexp(setting.Repository.PullRequest.CloseKeywords)
issueReopenKeywordsPat = buildKeywordsRegexp(setting.Repository.PullRequest.ReopenKeywords)
})
}

// Action represents user operation type and other information to
// repository. It implemented interface base.Actioner so that can be
// used in template render.
Expand Down Expand Up @@ -562,6 +560,7 @@ func changeIssueStatus(repo *Repository, doer *User, ref string, refMarked map[i

// UpdateIssuesCommit checks if issues are manipulated by commit message.
func UpdateIssuesCommit(doer *User, repo *Repository, commits []*PushCommit, branchName string) error {
initKeywordsRegexp()
guillep2k marked this conversation as resolved.
Show resolved Hide resolved
// Commits are appended in the reverse order.
for i := len(commits) - 1; i >= 0; i-- {
c := commits[i]
Expand Down Expand Up @@ -606,61 +605,65 @@ func UpdateIssuesCommit(doer *User, repo *Repository, commits []*PushCommit, bra
continue
}
refMarked = make(map[int64]bool)
for _, m := range issueCloseKeywordsPat.FindAllStringSubmatch(c.Message, -1) {
if len(m[3]) == 0 {
continue
}
ref := m[3]

// issue is from another repo
if len(m[1]) > 0 && len(m[2]) > 0 {
refRepo, err = GetRepositoryFromMatch(m[1], m[2])
if err != nil {
if issueCloseKeywordsPat != nil {
for _, m := range issueCloseKeywordsPat.FindAllStringSubmatch(c.Message, -1) {
if len(m[3]) == 0 {
continue
}
} else {
refRepo = repo
}
ref := m[3]

// issue is from another repo
if len(m[1]) > 0 && len(m[2]) > 0 {
refRepo, err = GetRepositoryFromMatch(m[1], m[2])
if err != nil {
continue
}
} else {
refRepo = repo
}

perm, err := GetUserRepoPermission(refRepo, doer)
if err != nil {
return err
}
// only close issues in another repo if user has push access
if perm.CanWrite(UnitTypeCode) {
if err := changeIssueStatus(refRepo, doer, ref, refMarked, true); err != nil {
perm, err := GetUserRepoPermission(refRepo, doer)
if err != nil {
return err
}
// only close issues in another repo if user has push access
if perm.CanWrite(UnitTypeCode) {
if err := changeIssueStatus(refRepo, doer, ref, refMarked, true); err != nil {
return err
}
}
}
}

// It is conflict to have close and reopen at same time, so refsMarked doesn't need to reinit here.
for _, m := range issueReopenKeywordsPat.FindAllStringSubmatch(c.Message, -1) {
if len(m[3]) == 0 {
continue
}
ref := m[3]

// issue is from another repo
if len(m[1]) > 0 && len(m[2]) > 0 {
refRepo, err = GetRepositoryFromMatch(m[1], m[2])
if err != nil {
if issueReopenKeywordsPat != nil {
for _, m := range issueReopenKeywordsPat.FindAllStringSubmatch(c.Message, -1) {
if len(m[3]) == 0 {
continue
}
} else {
refRepo = repo
}

perm, err := GetUserRepoPermission(refRepo, doer)
if err != nil {
return err
}
ref := m[3]

// issue is from another repo
if len(m[1]) > 0 && len(m[2]) > 0 {
refRepo, err = GetRepositoryFromMatch(m[1], m[2])
if err != nil {
continue
}
} else {
refRepo = repo
}

// only reopen issues in another repo if user has push access
if perm.CanWrite(UnitTypeCode) {
if err := changeIssueStatus(refRepo, doer, ref, refMarked, false); err != nil {
perm, err := GetUserRepoPermission(refRepo, doer)
if err != nil {
return err
}

// only reopen issues in another repo if user has push access
if perm.CanWrite(UnitTypeCode) {
if err := changeIssueStatus(refRepo, doer, ref, refMarked, false); err != nil {
return err
}
}
}
}
}
Expand Down Expand Up @@ -837,3 +840,27 @@ func GetFeeds(opts GetFeedsOptions) ([]*Action, error) {

return actions, nil
}

func parseKeywords(words []string) []string {
acceptedWords := make([]string, 0, 5)
wordPat := regexp.MustCompile(`^[\pL]+$`)
for _, word := range words {
word = strings.ToLower(strings.TrimSpace(word))
// Accept Unicode letter class runes (a-z, á, à, ä, )
if wordPat.MatchString(word) {
acceptedWords = append(acceptedWords, word)
} else {
log.Info("Invalid keyword: %s", word)
}
}
return acceptedWords
}

func buildKeywordsRegexp(words []string) *regexp.Regexp {
acceptedWords := parseKeywords(words)
if len(acceptedWords) == 0 {
// Never match
return nil
}
return regexp.MustCompile(fmt.Sprintf(`(?i)(?:%s)(?::?) %s`, strings.Join(acceptedWords, "|"), issueRefRegexpStr))
}
25 changes: 25 additions & 0 deletions models/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,28 @@ func TestGetFeeds2(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, actions, 0)
}

func TestParseCloseKeywords(t *testing.T) {
// Test parsing of CloseKeywords and ReopenKeywords
assert.Len(t, parseKeywords([]string{""}), 0)
assert.Len(t, parseKeywords([]string{" aa ", " bb ", "99", "#", "", "this is", "cc"}), 3)

for _, test := range []struct {
pattern string
match string
}{
{"close", "Close #123"},
{"cerró", "cerró #123"},
{"cerró", "CERRÓ #123"},
{"закрывается", "закрывается #123"},
{"κλείνει", "κλείνει #123"},
{"关闭", "关闭 #123"},
{"閉じます", "閉じます #123"},
} {
pat := buildKeywordsRegexp([]string{test.pattern})
assert.NotNil(t, pat)
res := pat.FindAllStringSubmatch(test.match, -1)
assert.Len(t, res, 1)
assert.EqualValues(t, [][]string([][]string{{test.match, "", "", "#123"}}), res)
}
}
8 changes: 8 additions & 0 deletions modules/setting/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ var (
// Pull request settings
PullRequest struct {
WorkInProgressPrefixes []string
CloseKeywords []string
ReopenKeywords []string
} `ini:"repository.pull-request"`

// Issue Setting
Expand Down Expand Up @@ -112,8 +114,14 @@ var (
// Pull request settings
PullRequest: struct {
WorkInProgressPrefixes []string
CloseKeywords []string
ReopenKeywords []string
}{
WorkInProgressPrefixes: []string{"WIP:", "[WIP]"},
// Same as GitHub. See
// https://help.github.com/articles/closing-issues-via-commit-messages
CloseKeywords: strings.Split("close,closes,closed,fix,fixes,fixed,resolve,resolves,resolved", ","),
ReopenKeywords: strings.Split("reopen,reopens,reopened", ","),
},

// Issue settings
Expand Down