Skip to content

Commit b15f26b

Browse files
guillep2klafriks
authored andcommitted
Close/reopen issues by keywords in titles and comments (#8866)
* Add close/reopen from comment functionality * Fix comment * Rewrite closing/reopening template * Check xref permissions, move action to services/pull * Fix RefIsPull field * Add xref tests * Fix xref unique filter * Only highlight keywords for actionable xrefs * Fix xref neuter filter * Fix check return status * Restart CI
1 parent 08ae6bb commit b15f26b

File tree

7 files changed

+300
-46
lines changed

7 files changed

+300
-46
lines changed

models/issue_xref.go

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package models
66

77
import (
8+
"fmt"
9+
810
"code.gitea.io/gitea/modules/log"
911
"code.gitea.io/gitea/modules/references"
1012

@@ -27,13 +29,9 @@ type crossReferencesContext struct {
2729

2830
func neuterCrossReferences(e Engine, issueID int64, commentID int64) error {
2931
active := make([]*Comment, 0, 10)
30-
sess := e.Where("`ref_action` IN (?, ?, ?)", references.XRefActionNone, references.XRefActionCloses, references.XRefActionReopens)
31-
if issueID != 0 {
32-
sess = sess.And("`ref_issue_id` = ?", issueID)
33-
}
34-
if commentID != 0 {
35-
sess = sess.And("`ref_comment_id` = ?", commentID)
36-
}
32+
sess := e.Where("`ref_action` IN (?, ?, ?)", references.XRefActionNone, references.XRefActionCloses, references.XRefActionReopens).
33+
And("`ref_issue_id` = ?", issueID).
34+
And("`ref_comment_id` = ?", commentID)
3735
if err := sess.Find(&active); err != nil || len(active) == 0 {
3836
return err
3937
}
@@ -87,7 +85,7 @@ func (issue *Issue) createCrossReferences(e *xorm.Session, ctx *crossReferencesC
8785
RefIssueID: ctx.OrigIssue.ID,
8886
RefCommentID: refCommentID,
8987
RefAction: xref.Action,
90-
RefIsPull: xref.Issue.IsPull,
88+
RefIsPull: ctx.OrigIssue.IsPull,
9189
}); err != nil {
9290
return err
9391
}
@@ -98,9 +96,10 @@ func (issue *Issue) createCrossReferences(e *xorm.Session, ctx *crossReferencesC
9896
func (issue *Issue) getCrossReferences(e *xorm.Session, ctx *crossReferencesContext, plaincontent, mdcontent string) ([]*crossReference, error) {
9997
xreflist := make([]*crossReference, 0, 5)
10098
var (
101-
refRepo *Repository
102-
refIssue *Issue
103-
err error
99+
refRepo *Repository
100+
refIssue *Issue
101+
refAction references.XRefAction
102+
err error
104103
)
105104

106105
allrefs := append(references.FindAllIssueReferences(plaincontent), references.FindAllIssueReferencesMarkdown(mdcontent)...)
@@ -122,15 +121,13 @@ func (issue *Issue) getCrossReferences(e *xorm.Session, ctx *crossReferencesCont
122121
return nil, err
123122
}
124123
}
125-
if refIssue, err = ctx.OrigIssue.findReferencedIssue(e, ctx, refRepo, ref.Index); err != nil {
124+
if refIssue, refAction, err = ctx.OrigIssue.verifyReferencedIssue(e, ctx, refRepo, ref); err != nil {
126125
return nil, err
127126
}
128127
if refIssue != nil {
129128
xreflist = ctx.OrigIssue.updateCrossReferenceList(xreflist, &crossReference{
130-
Issue: refIssue,
131-
// FIXME: currently ignore keywords
132-
// Action: ref.Action,
133-
Action: references.XRefActionNone,
129+
Issue: refIssue,
130+
Action: refAction,
134131
})
135132
}
136133
}
@@ -153,25 +150,42 @@ func (issue *Issue) updateCrossReferenceList(list []*crossReference, xref *cross
153150
return append(list, xref)
154151
}
155152

156-
func (issue *Issue) findReferencedIssue(e Engine, ctx *crossReferencesContext, repo *Repository, index int64) (*Issue, error) {
157-
refIssue := &Issue{RepoID: repo.ID, Index: index}
153+
// verifyReferencedIssue will check if the referenced issue exists, and whether the doer has permission to do what
154+
func (issue *Issue) verifyReferencedIssue(e Engine, ctx *crossReferencesContext, repo *Repository,
155+
ref references.IssueReference) (*Issue, references.XRefAction, error) {
156+
157+
refIssue := &Issue{RepoID: repo.ID, Index: ref.Index}
158+
refAction := ref.Action
159+
158160
if has, _ := e.Get(refIssue); !has {
159-
return nil, nil
161+
return nil, references.XRefActionNone, nil
160162
}
161163
if err := refIssue.loadRepo(e); err != nil {
162-
return nil, err
164+
return nil, references.XRefActionNone, err
163165
}
164-
// Check user permissions
165-
if refIssue.RepoID != ctx.OrigIssue.RepoID {
166+
167+
// Close/reopen actions can only be set from pull requests to issues
168+
if refIssue.IsPull || !issue.IsPull {
169+
refAction = references.XRefActionNone
170+
}
171+
172+
// Check doer permissions; set action to None if the doer can't change the destination
173+
if refIssue.RepoID != ctx.OrigIssue.RepoID || ref.Action != references.XRefActionNone {
166174
perm, err := getUserRepoPermission(e, refIssue.Repo, ctx.Doer)
167175
if err != nil {
168-
return nil, err
176+
return nil, references.XRefActionNone, err
169177
}
170178
if !perm.CanReadIssuesOrPulls(refIssue.IsPull) {
171-
return nil, nil
179+
return nil, references.XRefActionNone, nil
180+
}
181+
if ref.Action != references.XRefActionNone &&
182+
ctx.Doer.ID != refIssue.PosterID &&
183+
!perm.CanWriteIssuesOrPulls(refIssue.IsPull) {
184+
refAction = references.XRefActionNone
172185
}
173186
}
174-
return refIssue, nil
187+
188+
return refIssue, refAction, nil
175189
}
176190

177191
func (issue *Issue) neuterCrossReferences(e Engine) error {
@@ -203,7 +217,7 @@ func (comment *Comment) addCrossReferences(e *xorm.Session, doer *User) error {
203217
}
204218

205219
func (comment *Comment) neuterCrossReferences(e Engine) error {
206-
return neuterCrossReferences(e, 0, comment.ID)
220+
return neuterCrossReferences(e, comment.IssueID, comment.ID)
207221
}
208222

209223
// LoadRefComment loads comment that created this reference from database
@@ -268,3 +282,40 @@ func (comment *Comment) RefIssueIdent() string {
268282
// FIXME: check this name for cross-repository references (#7901 if it gets merged)
269283
return "#" + com.ToStr(comment.RefIssue.Index)
270284
}
285+
286+
// __________ .__ .__ __________ __
287+
// \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_
288+
// | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\
289+
// | | | | / |_| |_| | \ ___< <_| | | /\ ___/ \___ \ | |
290+
// |____| |____/|____/____/____|_ /\___ >__ |____/ \___ >____ > |__|
291+
// \/ \/ |__| \/ \/
292+
293+
// ResolveCrossReferences will return the list of references to close/reopen by this PR
294+
func (pr *PullRequest) ResolveCrossReferences() ([]*Comment, error) {
295+
unfiltered := make([]*Comment, 0, 5)
296+
if err := x.
297+
Where("ref_repo_id = ? AND ref_issue_id = ?", pr.Issue.RepoID, pr.Issue.ID).
298+
In("ref_action", []references.XRefAction{references.XRefActionCloses, references.XRefActionReopens}).
299+
OrderBy("id").
300+
Find(&unfiltered); err != nil {
301+
return nil, fmt.Errorf("get reference: %v", err)
302+
}
303+
304+
refs := make([]*Comment, 0, len(unfiltered))
305+
for _, ref := range unfiltered {
306+
found := false
307+
for i, r := range refs {
308+
if r.IssueID == ref.IssueID {
309+
// Keep only the latest
310+
refs[i] = ref
311+
found = true
312+
break
313+
}
314+
}
315+
if !found {
316+
refs = append(refs, ref)
317+
}
318+
}
319+
320+
return refs, nil
321+
}

models/issue_xref_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package models
6+
7+
import (
8+
"fmt"
9+
"testing"
10+
11+
"code.gitea.io/gitea/modules/references"
12+
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
func TestXRef_AddCrossReferences(t *testing.T) {
17+
assert.NoError(t, PrepareTestDatabase())
18+
19+
// Issue #1 to test against
20+
itarget := testCreateIssue(t, 1, 2, "title1", "content1", false)
21+
22+
// PR to close issue #1
23+
content := fmt.Sprintf("content2, closes #%d", itarget.Index)
24+
pr := testCreateIssue(t, 1, 2, "title2", content, true)
25+
ref := AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: 0}).(*Comment)
26+
assert.Equal(t, CommentTypePullRef, ref.Type)
27+
assert.Equal(t, pr.RepoID, ref.RefRepoID)
28+
assert.Equal(t, true, ref.RefIsPull)
29+
assert.Equal(t, references.XRefActionCloses, ref.RefAction)
30+
31+
// Comment on PR to reopen issue #1
32+
content = fmt.Sprintf("content2, reopens #%d", itarget.Index)
33+
c := testCreateComment(t, 1, 2, pr.ID, content)
34+
ref = AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: c.ID}).(*Comment)
35+
assert.Equal(t, CommentTypeCommentRef, ref.Type)
36+
assert.Equal(t, pr.RepoID, ref.RefRepoID)
37+
assert.Equal(t, true, ref.RefIsPull)
38+
assert.Equal(t, references.XRefActionReopens, ref.RefAction)
39+
40+
// Issue mentioning issue #1
41+
content = fmt.Sprintf("content3, mentions #%d", itarget.Index)
42+
i := testCreateIssue(t, 1, 2, "title3", content, false)
43+
ref = AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*Comment)
44+
assert.Equal(t, CommentTypeIssueRef, ref.Type)
45+
assert.Equal(t, pr.RepoID, ref.RefRepoID)
46+
assert.Equal(t, false, ref.RefIsPull)
47+
assert.Equal(t, references.XRefActionNone, ref.RefAction)
48+
49+
// Issue #4 to test against
50+
itarget = testCreateIssue(t, 3, 3, "title4", "content4", false)
51+
52+
// Cross-reference to issue #4 by admin
53+
content = fmt.Sprintf("content5, mentions user3/repo3#%d", itarget.Index)
54+
i = testCreateIssue(t, 2, 1, "title5", content, false)
55+
ref = AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*Comment)
56+
assert.Equal(t, CommentTypeIssueRef, ref.Type)
57+
assert.Equal(t, i.RepoID, ref.RefRepoID)
58+
assert.Equal(t, false, ref.RefIsPull)
59+
assert.Equal(t, references.XRefActionNone, ref.RefAction)
60+
61+
// Cross-reference to issue #4 with no permission
62+
content = fmt.Sprintf("content6, mentions user3/repo3#%d", itarget.Index)
63+
i = testCreateIssue(t, 4, 5, "title6", content, false)
64+
AssertNotExistsBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0})
65+
}
66+
67+
func TestXRef_NeuterCrossReferences(t *testing.T) {
68+
assert.NoError(t, PrepareTestDatabase())
69+
70+
// Issue #1 to test against
71+
itarget := testCreateIssue(t, 1, 2, "title1", "content1", false)
72+
73+
// Issue mentioning issue #1
74+
title := fmt.Sprintf("title2, mentions #%d", itarget.Index)
75+
i := testCreateIssue(t, 1, 2, title, "content2", false)
76+
ref := AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*Comment)
77+
assert.Equal(t, CommentTypeIssueRef, ref.Type)
78+
assert.Equal(t, references.XRefActionNone, ref.RefAction)
79+
80+
d := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
81+
i.Title = "title2, no mentions"
82+
assert.NoError(t, i.ChangeTitle(d, title))
83+
84+
ref = AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*Comment)
85+
assert.Equal(t, CommentTypeIssueRef, ref.Type)
86+
assert.Equal(t, references.XRefActionNeutered, ref.RefAction)
87+
}
88+
89+
func TestXRef_ResolveCrossReferences(t *testing.T) {
90+
assert.NoError(t, PrepareTestDatabase())
91+
92+
d := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
93+
94+
i1 := testCreateIssue(t, 1, 2, "title1", "content1", false)
95+
i2 := testCreateIssue(t, 1, 2, "title2", "content2", false)
96+
i3 := testCreateIssue(t, 1, 2, "title3", "content3", false)
97+
assert.NoError(t, i3.ChangeStatus(d, true))
98+
99+
pr := testCreatePR(t, 1, 2, "titlepr", fmt.Sprintf("closes #%d", i1.Index))
100+
rp := AssertExistsAndLoadBean(t, &Comment{IssueID: i1.ID, RefIssueID: pr.Issue.ID, RefCommentID: 0}).(*Comment)
101+
102+
c1 := testCreateComment(t, 1, 2, pr.Issue.ID, fmt.Sprintf("closes #%d", i2.Index))
103+
r1 := AssertExistsAndLoadBean(t, &Comment{IssueID: i2.ID, RefIssueID: pr.Issue.ID, RefCommentID: c1.ID}).(*Comment)
104+
105+
// Must be ignored
106+
c2 := testCreateComment(t, 1, 2, pr.Issue.ID, fmt.Sprintf("mentions #%d", i2.Index))
107+
AssertExistsAndLoadBean(t, &Comment{IssueID: i2.ID, RefIssueID: pr.Issue.ID, RefCommentID: c2.ID})
108+
109+
// Must be superseded by c4/r4
110+
c3 := testCreateComment(t, 1, 2, pr.Issue.ID, fmt.Sprintf("reopens #%d", i3.Index))
111+
AssertExistsAndLoadBean(t, &Comment{IssueID: i3.ID, RefIssueID: pr.Issue.ID, RefCommentID: c3.ID})
112+
113+
c4 := testCreateComment(t, 1, 2, pr.Issue.ID, fmt.Sprintf("closes #%d", i3.Index))
114+
r4 := AssertExistsAndLoadBean(t, &Comment{IssueID: i3.ID, RefIssueID: pr.Issue.ID, RefCommentID: c4.ID}).(*Comment)
115+
116+
refs, err := pr.ResolveCrossReferences()
117+
assert.NoError(t, err)
118+
assert.Len(t, refs, 3)
119+
assert.Equal(t, rp.ID, refs[0].ID, "bad ref rp: %+v", refs[0])
120+
assert.Equal(t, r1.ID, refs[1].ID, "bad ref r1: %+v", refs[1])
121+
assert.Equal(t, r4.ID, refs[2].ID, "bad ref r4: %+v", refs[2])
122+
}
123+
124+
func testCreateIssue(t *testing.T, repo, doer int64, title, content string, ispull bool) *Issue {
125+
r := AssertExistsAndLoadBean(t, &Repository{ID: repo}).(*Repository)
126+
d := AssertExistsAndLoadBean(t, &User{ID: doer}).(*User)
127+
i := &Issue{RepoID: r.ID, PosterID: d.ID, Poster: d, Title: title, Content: content, IsPull: ispull}
128+
129+
sess := x.NewSession()
130+
defer sess.Close()
131+
assert.NoError(t, sess.Begin())
132+
_, err := sess.SetExpr("`index`", "coalesce(MAX(`index`),0)+1").Where("repo_id=?", repo).Insert(i)
133+
assert.NoError(t, err)
134+
i, err = getIssueByID(sess, i.ID)
135+
assert.NoError(t, err)
136+
assert.NoError(t, i.addCrossReferences(sess, d))
137+
assert.NoError(t, sess.Commit())
138+
return i
139+
}
140+
141+
func testCreatePR(t *testing.T, repo, doer int64, title, content string) *PullRequest {
142+
r := AssertExistsAndLoadBean(t, &Repository{ID: repo}).(*Repository)
143+
d := AssertExistsAndLoadBean(t, &User{ID: doer}).(*User)
144+
i := &Issue{RepoID: r.ID, PosterID: d.ID, Poster: d, Title: title, Content: content, IsPull: true}
145+
pr := &PullRequest{HeadRepoID: repo, BaseRepoID: repo, HeadBranch: "head", BaseBranch: "base"}
146+
assert.NoError(t, NewPullRequest(r, i, nil, nil, pr, nil))
147+
pr.Issue = i
148+
return pr
149+
}
150+
151+
func testCreateComment(t *testing.T, repo, doer, issue int64, content string) *Comment {
152+
d := AssertExistsAndLoadBean(t, &User{ID: doer}).(*User)
153+
i := AssertExistsAndLoadBean(t, &Issue{ID: issue}).(*Issue)
154+
c := &Comment{Type: CommentTypeComment, PosterID: doer, Poster: d, IssueID: issue, Issue: i, Content: content}
155+
156+
sess := x.NewSession()
157+
defer sess.Close()
158+
assert.NoError(t, sess.Begin())
159+
_, err := sess.Insert(c)
160+
assert.NoError(t, err)
161+
assert.NoError(t, c.addCrossReferences(sess, d))
162+
assert.NoError(t, sess.Commit())
163+
return c
164+
}

modules/markup/html.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,16 @@ func issueIndexPatternProcessor(ctx *postProcessCtx, node *html.Node) {
659659
return
660660
}
661661

662-
// Decorate action keywords
663-
keyword := createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
662+
// Decorate action keywords if actionable
663+
var keyword *html.Node
664+
if references.IsXrefActionable(ref.Action) {
665+
keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
666+
} else {
667+
keyword = &html.Node{
668+
Type: html.TextNode,
669+
Data: node.Data[ref.ActionLocation.Start:ref.ActionLocation.End],
670+
}
671+
}
664672
spaces := &html.Node{
665673
Type: html.TextNode,
666674
Data: node.Data[ref.ActionLocation.End:ref.RefLocation.Start],

modules/references/references.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,8 @@ func findActionKeywords(content []byte, start int) (XRefAction, *RefSpan) {
350350
}
351351
return XRefActionNone, nil
352352
}
353+
354+
// IsXrefActionable returns true if the xref action is actionable (i.e. produces a result when resolved)
355+
func IsXrefActionable(a XRefAction) bool {
356+
return a == XRefActionCloses || a == XRefActionReopens
357+
}

options/locale/locale_en-US.ini

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -876,10 +876,13 @@ issues.create_comment = Comment
876876
issues.closed_at = `closed <a id="%[1]s" href="#%[1]s">%[2]s</a>`
877877
issues.reopened_at = `reopened <a id="%[1]s" href="#%[1]s">%[2]s</a>`
878878
issues.commit_ref_at = `referenced this issue from a commit <a id="%[1]s" href="#%[1]s">%[2]s</a>`
879-
issues.ref_issue_at = `referenced this issue %[1]s`
880-
issues.ref_pull_at = `referenced this pull request %[1]s`
881-
issues.ref_issue_ext_at = `referenced this issue from %[1]s %[2]s`
882-
issues.ref_pull_ext_at = `referenced this pull request from %[1]s %[2]s`
879+
issues.ref_issue_from = `<a href="%[3]s">referenced this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
880+
issues.ref_pull_from = `<a href="%[3]s">referenced this pull request %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
881+
issues.ref_closing_from = `<a href="%[3]s">referenced a pull request %[4]s that will close this issue</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
882+
issues.ref_reopening_from = `<a href="%[3]s">referenced a pull request %[4]s that will reopen this issue</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
883+
issues.ref_closed_from = `<a href="%[3]s">closed this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
884+
issues.ref_reopened_from = `<a href="%[3]s">reopened this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
885+
issues.ref_from = `from %[1]s`
883886
issues.poster = Poster
884887
issues.collaborator = Collaborator
885888
issues.owner = Owner

0 commit comments

Comments
 (0)