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

Close/reopen issues by keywords in titles and comments #8866

Merged
merged 22 commits into from
Nov 18, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
103 changes: 77 additions & 26 deletions models/issue_xref.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
package models

import (
"fmt"

"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references"

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

func neuterCrossReferences(e Engine, issueID int64, commentID int64) error {
active := make([]*Comment, 0, 10)
sess := e.Where("`ref_action` IN (?, ?, ?)", references.XRefActionNone, references.XRefActionCloses, references.XRefActionReopens)
if issueID != 0 {
sess = sess.And("`ref_issue_id` = ?", issueID)
}
if commentID != 0 {
sess = sess.And("`ref_comment_id` = ?", commentID)
}
sess := e.Where("`ref_action` IN (?, ?, ?)", references.XRefActionNone, references.XRefActionCloses, references.XRefActionReopens).
And("`ref_issue_id` = ?", issueID).
And("`ref_comment_id` = ?", commentID)
if err := sess.Find(&active); err != nil || len(active) == 0 {
return err
}
Expand Down Expand Up @@ -87,7 +85,7 @@ func (issue *Issue) createCrossReferences(e *xorm.Session, ctx *crossReferencesC
RefIssueID: ctx.OrigIssue.ID,
RefCommentID: refCommentID,
RefAction: xref.Action,
RefIsPull: xref.Issue.IsPull,
RefIsPull: ctx.OrigIssue.IsPull,
}); err != nil {
return err
}
Expand All @@ -98,9 +96,10 @@ func (issue *Issue) createCrossReferences(e *xorm.Session, ctx *crossReferencesC
func (issue *Issue) getCrossReferences(e *xorm.Session, ctx *crossReferencesContext, plaincontent, mdcontent string) ([]*crossReference, error) {
xreflist := make([]*crossReference, 0, 5)
var (
refRepo *Repository
refIssue *Issue
err error
refRepo *Repository
refIssue *Issue
refAction references.XRefAction
err error
)

allrefs := append(references.FindAllIssueReferences(plaincontent), references.FindAllIssueReferencesMarkdown(mdcontent)...)
Expand All @@ -122,15 +121,13 @@ func (issue *Issue) getCrossReferences(e *xorm.Session, ctx *crossReferencesCont
return nil, err
}
}
if refIssue, err = ctx.OrigIssue.findReferencedIssue(e, ctx, refRepo, ref.Index); err != nil {
if refIssue, refAction, err = ctx.OrigIssue.verifyReferencedIssue(e, ctx, refRepo, ref); err != nil {
return nil, err
}
if refIssue != nil {
xreflist = ctx.OrigIssue.updateCrossReferenceList(xreflist, &crossReference{
Issue: refIssue,
// FIXME: currently ignore keywords
// Action: ref.Action,
Action: references.XRefActionNone,
Issue: refIssue,
Action: refAction,
})
}
}
Expand All @@ -153,25 +150,42 @@ func (issue *Issue) updateCrossReferenceList(list []*crossReference, xref *cross
return append(list, xref)
}

func (issue *Issue) findReferencedIssue(e Engine, ctx *crossReferencesContext, repo *Repository, index int64) (*Issue, error) {
refIssue := &Issue{RepoID: repo.ID, Index: index}
// verifyReferencedIssue will check if the referenced issue exists, and whether the doer has permission to do what
func (issue *Issue) verifyReferencedIssue(e Engine, ctx *crossReferencesContext, repo *Repository,
ref references.IssueReference) (*Issue, references.XRefAction, error) {

refIssue := &Issue{RepoID: repo.ID, Index: ref.Index}
refAction := ref.Action

if has, _ := e.Get(refIssue); !has {
return nil, nil
return nil, references.XRefActionNone, nil
}
if err := refIssue.loadRepo(e); err != nil {
return nil, err
return nil, references.XRefActionNone, err
}
// Check user permissions
if refIssue.RepoID != ctx.OrigIssue.RepoID {

// Close/reopen actions can only be set from pull requests to issues
if refIssue.IsPull || !issue.IsPull {
refAction = references.XRefActionNone
}

// Check doer permissions; set action to None if the doer can't change the destination
if refIssue.RepoID != ctx.OrigIssue.RepoID || ref.Action != references.XRefActionNone {
perm, err := getUserRepoPermission(e, refIssue.Repo, ctx.Doer)
if err != nil {
return nil, err
return nil, references.XRefActionNone, err
}
if !perm.CanReadIssuesOrPulls(refIssue.IsPull) {
return nil, nil
return nil, references.XRefActionNone, nil
}
if ref.Action != references.XRefActionNone &&
ctx.Doer.ID != refIssue.PosterID &&
!perm.CanWriteIssuesOrPulls(refIssue.IsPull) {
refAction = references.XRefActionNone
}
}
return refIssue, nil

return refIssue, refAction, nil
}

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

func (comment *Comment) neuterCrossReferences(e Engine) error {
return neuterCrossReferences(e, 0, comment.ID)
return neuterCrossReferences(e, comment.IssueID, comment.ID)
}

// LoadRefComment loads comment that created this reference from database
Expand Down Expand Up @@ -268,3 +282,40 @@ func (comment *Comment) RefIssueIdent() string {
// FIXME: check this name for cross-repository references (#7901 if it gets merged)
return "#" + com.ToStr(comment.RefIssue.Index)
}

// __________ .__ .__ __________ __
// \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_
// | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\
// | | | | / |_| |_| | \ ___< <_| | | /\ ___/ \___ \ | |
// |____| |____/|____/____/____|_ /\___ >__ |____/ \___ >____ > |__|
// \/ \/ |__| \/ \/

// ResolveCrossReferences will return the list of references to close/reopen by this PR
func (pr *PullRequest) ResolveCrossReferences() ([]*Comment, error) {
unfiltered := make([]*Comment, 0, 5)
if err := x.
Where("ref_repo_id = ? AND ref_issue_id = ?", pr.Issue.RepoID, pr.Issue.ID).
In("ref_action", []references.XRefAction{references.XRefActionCloses, references.XRefActionReopens}).
OrderBy("id").
Find(&unfiltered); err != nil {
guillep2k marked this conversation as resolved.
Show resolved Hide resolved
return nil, fmt.Errorf("get reference: %v", err)
}

refs := make([]*Comment, 0, len(unfiltered))
for _, ref := range unfiltered {
found := false
for i, r := range refs {
if r.IssueID == ref.IssueID {
// Keep only the latest
refs[i] = ref
found = true
break
}
}
if !found {
refs = append(refs, ref)
}
}

return refs, nil
}
164 changes: 164 additions & 0 deletions models/issue_xref_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package models

import (
"fmt"
"testing"

"code.gitea.io/gitea/modules/references"

"github.com/stretchr/testify/assert"
)

func TestXRef_AddCrossReferences(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())

// Issue #1 to test against
itarget := testCreateIssue(t, 1, 2, "title1", "content1", false)

// PR to close issue #1
content := fmt.Sprintf("content2, closes #%d", itarget.Index)
pr := testCreateIssue(t, 1, 2, "title2", content, true)
ref := AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: 0}).(*Comment)
assert.Equal(t, CommentTypePullRef, ref.Type)
assert.Equal(t, pr.RepoID, ref.RefRepoID)
assert.Equal(t, true, ref.RefIsPull)
assert.Equal(t, references.XRefActionCloses, ref.RefAction)

// Comment on PR to reopen issue #1
content = fmt.Sprintf("content2, reopens #%d", itarget.Index)
c := testCreateComment(t, 1, 2, pr.ID, content)
ref = AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: pr.ID, RefCommentID: c.ID}).(*Comment)
assert.Equal(t, CommentTypeCommentRef, ref.Type)
assert.Equal(t, pr.RepoID, ref.RefRepoID)
assert.Equal(t, true, ref.RefIsPull)
assert.Equal(t, references.XRefActionReopens, ref.RefAction)

// Issue mentioning issue #1
content = fmt.Sprintf("content3, mentions #%d", itarget.Index)
i := testCreateIssue(t, 1, 2, "title3", content, false)
ref = AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*Comment)
assert.Equal(t, CommentTypeIssueRef, ref.Type)
assert.Equal(t, pr.RepoID, ref.RefRepoID)
assert.Equal(t, false, ref.RefIsPull)
assert.Equal(t, references.XRefActionNone, ref.RefAction)

// Issue #4 to test against
itarget = testCreateIssue(t, 3, 3, "title4", "content4", false)

// Cross-reference to issue #4 by admin
content = fmt.Sprintf("content5, mentions user3/repo3#%d", itarget.Index)
i = testCreateIssue(t, 2, 1, "title5", content, false)
ref = AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*Comment)
assert.Equal(t, CommentTypeIssueRef, ref.Type)
assert.Equal(t, i.RepoID, ref.RefRepoID)
assert.Equal(t, false, ref.RefIsPull)
assert.Equal(t, references.XRefActionNone, ref.RefAction)

// Cross-reference to issue #4 with no permission
content = fmt.Sprintf("content6, mentions user3/repo3#%d", itarget.Index)
i = testCreateIssue(t, 4, 5, "title6", content, false)
AssertNotExistsBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0})
}

func TestXRef_NeuterCrossReferences(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())

// Issue #1 to test against
itarget := testCreateIssue(t, 1, 2, "title1", "content1", false)

// Issue mentioning issue #1
title := fmt.Sprintf("title2, mentions #%d", itarget.Index)
i := testCreateIssue(t, 1, 2, title, "content2", false)
ref := AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*Comment)
assert.Equal(t, CommentTypeIssueRef, ref.Type)
assert.Equal(t, references.XRefActionNone, ref.RefAction)

d := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
i.Title = "title2, no mentions"
assert.NoError(t, i.ChangeTitle(d, title))

ref = AssertExistsAndLoadBean(t, &Comment{IssueID: itarget.ID, RefIssueID: i.ID, RefCommentID: 0}).(*Comment)
assert.Equal(t, CommentTypeIssueRef, ref.Type)
assert.Equal(t, references.XRefActionNeutered, ref.RefAction)
}

func TestXRef_ResolveCrossReferences(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())

d := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)

i1 := testCreateIssue(t, 1, 2, "title1", "content1", false)
i2 := testCreateIssue(t, 1, 2, "title2", "content2", false)
i3 := testCreateIssue(t, 1, 2, "title3", "content3", false)
assert.NoError(t, i3.ChangeStatus(d, true))

pr := testCreatePR(t, 1, 2, "titlepr", fmt.Sprintf("closes #%d", i1.Index))
rp := AssertExistsAndLoadBean(t, &Comment{IssueID: i1.ID, RefIssueID: pr.Issue.ID, RefCommentID: 0}).(*Comment)

c1 := testCreateComment(t, 1, 2, pr.Issue.ID, fmt.Sprintf("closes #%d", i2.Index))
r1 := AssertExistsAndLoadBean(t, &Comment{IssueID: i2.ID, RefIssueID: pr.Issue.ID, RefCommentID: c1.ID}).(*Comment)

// Must be ignored
c2 := testCreateComment(t, 1, 2, pr.Issue.ID, fmt.Sprintf("mentions #%d", i2.Index))
AssertExistsAndLoadBean(t, &Comment{IssueID: i2.ID, RefIssueID: pr.Issue.ID, RefCommentID: c2.ID})

// Must be superseded by c4/r4
c3 := testCreateComment(t, 1, 2, pr.Issue.ID, fmt.Sprintf("reopens #%d", i3.Index))
AssertExistsAndLoadBean(t, &Comment{IssueID: i3.ID, RefIssueID: pr.Issue.ID, RefCommentID: c3.ID})

c4 := testCreateComment(t, 1, 2, pr.Issue.ID, fmt.Sprintf("closes #%d", i3.Index))
r4 := AssertExistsAndLoadBean(t, &Comment{IssueID: i3.ID, RefIssueID: pr.Issue.ID, RefCommentID: c4.ID}).(*Comment)

refs, err := pr.ResolveCrossReferences()
assert.NoError(t, err)
assert.Len(t, refs, 3)
assert.Equal(t, rp.ID, refs[0].ID, "bad ref rp: %+v", refs[0])
assert.Equal(t, r1.ID, refs[1].ID, "bad ref r1: %+v", refs[1])
assert.Equal(t, r4.ID, refs[2].ID, "bad ref r4: %+v", refs[2])
}

func testCreateIssue(t *testing.T, repo, doer int64, title, content string, ispull bool) *Issue {
r := AssertExistsAndLoadBean(t, &Repository{ID: repo}).(*Repository)
d := AssertExistsAndLoadBean(t, &User{ID: doer}).(*User)
i := &Issue{RepoID: r.ID, PosterID: d.ID, Poster: d, Title: title, Content: content, IsPull: ispull}

sess := x.NewSession()
defer sess.Close()
assert.NoError(t, sess.Begin())
_, err := sess.SetExpr("`index`", "coalesce(MAX(`index`),0)+1").Where("repo_id=?", repo).Insert(i)
assert.NoError(t, err)
i, err = getIssueByID(sess, i.ID)
assert.NoError(t, err)
assert.NoError(t, i.addCrossReferences(sess, d))
assert.NoError(t, sess.Commit())
return i
}

func testCreatePR(t *testing.T, repo, doer int64, title, content string) *PullRequest {
r := AssertExistsAndLoadBean(t, &Repository{ID: repo}).(*Repository)
d := AssertExistsAndLoadBean(t, &User{ID: doer}).(*User)
i := &Issue{RepoID: r.ID, PosterID: d.ID, Poster: d, Title: title, Content: content, IsPull: true}
pr := &PullRequest{HeadRepoID: repo, BaseRepoID: repo, HeadBranch: "head", BaseBranch: "base"}
assert.NoError(t, NewPullRequest(r, i, nil, nil, pr, nil))
pr.Issue = i
return pr
}

func testCreateComment(t *testing.T, repo, doer, issue int64, content string) *Comment {
d := AssertExistsAndLoadBean(t, &User{ID: doer}).(*User)
i := AssertExistsAndLoadBean(t, &Issue{ID: issue}).(*Issue)
c := &Comment{Type: CommentTypeComment, PosterID: doer, Poster: d, IssueID: issue, Issue: i, Content: content}

sess := x.NewSession()
defer sess.Close()
assert.NoError(t, sess.Begin())
_, err := sess.Insert(c)
assert.NoError(t, err)
assert.NoError(t, c.addCrossReferences(sess, d))
assert.NoError(t, sess.Commit())
return c
}
12 changes: 10 additions & 2 deletions modules/markup/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,8 +659,16 @@ func issueIndexPatternProcessor(ctx *postProcessCtx, node *html.Node) {
return
}

// Decorate action keywords
keyword := createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
// Decorate action keywords if actionable
var keyword *html.Node
if references.IsXrefActionable(ref.Action) {
keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
} else {
keyword = &html.Node{
Type: html.TextNode,
Data: node.Data[ref.ActionLocation.Start:ref.ActionLocation.End],
}
}
spaces := &html.Node{
Type: html.TextNode,
Data: node.Data[ref.ActionLocation.End:ref.RefLocation.Start],
Expand Down
5 changes: 5 additions & 0 deletions modules/references/references.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,8 @@ func findActionKeywords(content []byte, start int) (XRefAction, *RefSpan) {
}
return XRefActionNone, nil
}

// IsXrefActionable returns true if the xref action is actionable (i.e. produces a result when resolved)
func IsXrefActionable(a XRefAction) bool {
return a == XRefActionCloses || a == XRefActionReopens
}
11 changes: 7 additions & 4 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -876,10 +876,13 @@ issues.create_comment = Comment
issues.closed_at = `closed <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.reopened_at = `reopened <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.commit_ref_at = `referenced this issue from a commit <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_issue_at = `referenced this issue %[1]s`
issues.ref_pull_at = `referenced this pull request %[1]s`
issues.ref_issue_ext_at = `referenced this issue from %[1]s %[2]s`
issues.ref_pull_ext_at = `referenced this pull request from %[1]s %[2]s`
issues.ref_issue_from = `<a href="%[3]s">referenced this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
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>`
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>`
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>`
issues.ref_closed_from = `<a href="%[3]s">closed this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_reopened_from = `<a href="%[3]s">reopened this issue %[4]s</a> <a id="%[1]s" href="#%[1]s">%[2]s</a>`
issues.ref_from = `from %[1]s`
issues.poster = Poster
issues.collaborator = Collaborator
issues.owner = Owner
Expand Down
Loading