* 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 CItags/v1.11.0-rc1
@@ -5,6 +5,8 @@ | |||
package models | |||
import ( | |||
"fmt" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/references" | |||
@@ -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 | |||
} | |||
@@ -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 | |||
} | |||
@@ -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)...) | |||
@@ -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, | |||
}) | |||
} | |||
} | |||
@@ -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 { | |||
@@ -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 | |||
@@ -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 { | |||
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 | |||
} |
@@ -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 | |||
} |
@@ -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], |
@@ -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 | |||
} |
@@ -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 |
@@ -21,8 +21,10 @@ import ( | |||
"code.gitea.io/gitea/modules/git" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/notification" | |||
"code.gitea.io/gitea/modules/references" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
issue_service "code.gitea.io/gitea/services/issue" | |||
"github.com/mcuadros/go-version" | |||
) | |||
@@ -447,6 +449,26 @@ func Merge(pr *models.PullRequest, doer *models.User, baseGitRepo *git.Repositor | |||
notification.NotifyIssueChangeStatus(doer, pr.Issue, true) | |||
// Resolve cross references | |||
refs, err := pr.ResolveCrossReferences() | |||
if err != nil { | |||
log.Error("ResolveCrossReferences: %v", err) | |||
return nil | |||
} | |||
for _, ref := range refs { | |||
if err = ref.LoadIssue(); err != nil { | |||
return err | |||
} | |||
if err = ref.Issue.LoadRepo(); err != nil { | |||
return err | |||
} | |||
close := (ref.RefAction == references.XRefActionCloses) | |||
if err = issue_service.ChangeStatus(ref.Issue, doer, close); err != nil { | |||
return err | |||
} | |||
} | |||
return nil | |||
} | |||
@@ -88,7 +88,20 @@ | |||
</a> | |||
<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.GetDisplayName}}</a> {{$.i18n.Tr "repo.issues.closed_at" .EventTag $createdStr | Safe}}</span> | |||
</div> | |||
{{else if or (eq .Type 3) (eq .Type 5) (eq .Type 6)}} | |||
{{else if eq .Type 3 5 6}} | |||
{{ $refFrom:= "" }} | |||
{{if ne .RefRepoID .Issue.RepoID}} | |||
{{ $refFrom = $.i18n.Tr "repo.issues.ref_from" .RefRepo.FullName }} | |||
{{end}} | |||
{{ $refTr := "repo.issues.ref_issue_from" }} | |||
{{if .Issue.IsPull}} | |||
{{ $refTr = "repo.issues.ref_pull_from" }} | |||
{{else if eq .RefAction 1 }} | |||
{{ $refTr = "repo.issues.ref_closing_from" }} | |||
{{else if eq .RefAction 2 }} | |||
{{ $refTr = "repo.issues.ref_reopening_from" }} | |||
{{end}} | |||
{{ $createdStr:= TimeSinceUnix .CreatedUnix $.Lang }} | |||
<div class="event" id="{{.HashTag}}"> | |||
<span class="octicon octicon-bookmark"></span> | |||
<a class="ui avatar image" href="{{.Poster.HomeLink}}"> | |||
@@ -96,19 +109,7 @@ | |||
</a> | |||
{{if eq .RefAction 3}}<del>{{end}} | |||
<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.GetDisplayName}}</a> | |||
{{if (eq .RefRepoID .Issue.RepoID)}} | |||
{{if .Issue.IsPull}} | |||
<a href="{{.RefCommentHTMLURL}}">{{$.i18n.Tr "repo.issues.ref_pull_at" $createdStr | Safe}}</a> | |||
{{else}} | |||
<a href="{{.RefCommentHTMLURL}}">{{$.i18n.Tr "repo.issues.ref_issue_at" $createdStr | Safe}}</a> | |||
{{end}} | |||
{{else}} | |||
{{if .Issue.IsPull}} | |||
<a href="{{.RefCommentHTMLURL}}">{{$.i18n.Tr "repo.issues.ref_pull_ext_at" .RefRepo.FullName $createdStr | Safe}}</a> | |||
{{else}} | |||
<a href="{{.RefCommentHTMLURL}}">{{$.i18n.Tr "repo.issues.ref_issue_ext_at" .RefRepo.FullName $createdStr | Safe}}</a> | |||
{{end}} | |||
{{end}} | |||
{{$.i18n.Tr $refTr .EventTag $createdStr .RefCommentHTMLURL $refFrom | Safe}} | |||
</span> | |||
{{if eq .RefAction 3}}</del>{{end}} | |||