mirror of
https://github.com/go-gitea/gitea
synced 2026-02-03 08:50:36 +00:00
Support closing keywords with URL references (#36221)
## Summary This PR adds support for closing keywords (`closes`, `fixes`, `reopens`, etc.) with full URL references in markdown links. **Before:** - `closes #123` ✅ works - `closes org/repo#123` ✅ works - `Closes [this issue](https://gitea.io/user/repo/issues/123)` ❌ didn't work - `Fixes [#456](https://gitea.io/org/project/issues/456)` ❌ didn't work **After:** All of the above now work correctly. ## Problem When users reference issues using full URLs in markdown links (e.g., `Closes [this issue](https://gitea.io/user/repo/issues/123)`), the closing keywords were not detected. This was because the URL processing code explicitly stated: ```go // Note: closing/reopening keywords not supported with URLs ``` Both methods of writing the reference render the same in the UI, so users expected the closing keywords to behave the same. ## Solution The fix works by: 1. Passing the original (unstripped) content to `findAllIssueReferencesBytes` 2. When processing URL links from markdown, finding the URL position in the original content 3. For markdown links `[text](url)`, finding the opening bracket `[` position 4. Using that position to detect closing keywords before the link ## Testing Added test cases for: - `Closes [this issue](url)` - single URL with closing keyword - `This fixes [#456](url)` - keyword in middle of text - `Reopens [PR](url)` - reopen keyword with pull request URL - Multiple URLs where only one has a closing keyword All existing tests continue to pass. Fixes #27549
This commit is contained in:
committed by
GitHub
parent
19e1997ee2
commit
83527d3f8a
@@ -248,7 +248,7 @@ func FindAllIssueReferencesMarkdown(content string) []IssueReference {
|
|||||||
|
|
||||||
func findAllIssueReferencesMarkdown(content string) []*rawReference {
|
func findAllIssueReferencesMarkdown(content string) []*rawReference {
|
||||||
bcontent, links := mdstripper.StripMarkdownBytes([]byte(content))
|
bcontent, links := mdstripper.StripMarkdownBytes([]byte(content))
|
||||||
return findAllIssueReferencesBytes(bcontent, links)
|
return findAllIssueReferencesBytes(bcontent, links, []byte(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertFullHTMLReferencesToShortRefs(re *regexp.Regexp, contentBytes *[]byte) {
|
func convertFullHTMLReferencesToShortRefs(re *regexp.Regexp, contentBytes *[]byte) {
|
||||||
@@ -326,7 +326,7 @@ func FindAllIssueReferences(content string) []IssueReference {
|
|||||||
} else {
|
} else {
|
||||||
log.Debug("No GiteaIssuePullPattern pattern")
|
log.Debug("No GiteaIssuePullPattern pattern")
|
||||||
}
|
}
|
||||||
return rawToIssueReferenceList(findAllIssueReferencesBytes(contentBytes, []string{}))
|
return rawToIssueReferenceList(findAllIssueReferencesBytes(contentBytes, []string{}, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindRenderizableReferenceNumeric returns the first unvalidated reference found in a string.
|
// FindRenderizableReferenceNumeric returns the first unvalidated reference found in a string.
|
||||||
@@ -406,7 +406,8 @@ func FindRenderizableReferenceAlphanumeric(content string) *RenderizableReferenc
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindAllIssueReferencesBytes returns a list of unvalidated references found in a byte slice.
|
// FindAllIssueReferencesBytes returns a list of unvalidated references found in a byte slice.
|
||||||
func findAllIssueReferencesBytes(content []byte, links []string) []*rawReference {
|
// originalContent is optional and used to detect closing/reopening keywords for URL references.
|
||||||
|
func findAllIssueReferencesBytes(content []byte, links []string, originalContent []byte) []*rawReference {
|
||||||
ret := make([]*rawReference, 0, 10)
|
ret := make([]*rawReference, 0, 10)
|
||||||
pos := 0
|
pos := 0
|
||||||
|
|
||||||
@@ -470,10 +471,27 @@ func findAllIssueReferencesBytes(content []byte, links []string) []*rawReference
|
|||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Note: closing/reopening keywords not supported with URLs
|
refBytes := []byte(parts[1] + "/" + parts[2] + sep + parts[4])
|
||||||
bytes := []byte(parts[1] + "/" + parts[2] + sep + parts[4])
|
if ref := getCrossReference(refBytes, 0, len(refBytes), true, false); ref != nil {
|
||||||
if ref := getCrossReference(bytes, 0, len(bytes), true, false); ref != nil {
|
|
||||||
ref.refLocation = nil
|
ref.refLocation = nil
|
||||||
|
// Detect closing/reopening keywords by finding the URL position in original content
|
||||||
|
if originalContent != nil {
|
||||||
|
if idx := bytes.Index(originalContent, []byte(link)); idx > 0 {
|
||||||
|
// For markdown links [text](url), find the opening bracket before the URL
|
||||||
|
// to properly detect keywords like "closes [text](url)"
|
||||||
|
searchStart := idx
|
||||||
|
if idx >= 2 && originalContent[idx-1] == '(' {
|
||||||
|
// Find the matching '[' for this markdown link
|
||||||
|
bracketIdx := bytes.LastIndex(originalContent[:idx-1], []byte{'['})
|
||||||
|
if bracketIdx >= 0 {
|
||||||
|
searchStart = bracketIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action, location := findActionKeywords(originalContent, searchStart)
|
||||||
|
ref.action = action
|
||||||
|
ref.actionLocation = location
|
||||||
|
}
|
||||||
|
}
|
||||||
ret = append(ret, ref)
|
ret = append(ret, ref)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,62 @@ func TestFindAllIssueReferences(t *testing.T) {
|
|||||||
|
|
||||||
testFixtures(t, fixtures, "default")
|
testFixtures(t, fixtures, "default")
|
||||||
|
|
||||||
|
// Test closing/reopening keywords with URLs (issue #27549)
|
||||||
|
// Uses the same AppURL as testFixtures (https://gitea.com:3000/)
|
||||||
|
urlFixtures := []testFixture{
|
||||||
|
{
|
||||||
|
"Closes [this issue](https://gitea.com:3000/user/repo/issues/123)",
|
||||||
|
[]testResult{
|
||||||
|
{123, "user", "repo", "123", false, XRefActionCloses, nil, &RefSpan{Start: 0, End: 6}, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"This fixes [#456](https://gitea.com:3000/org/project/issues/456)",
|
||||||
|
[]testResult{
|
||||||
|
{456, "org", "project", "456", false, XRefActionCloses, nil, &RefSpan{Start: 5, End: 10}, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Reopens [PR](https://gitea.com:3000/owner/repo/pulls/789)",
|
||||||
|
[]testResult{
|
||||||
|
{789, "owner", "repo", "789", true, XRefActionReopens, nil, &RefSpan{Start: 0, End: 7}, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"See [issue](https://gitea.com:3000/user/repo/issues/100) but closes [another](https://gitea.com:3000/user/repo/issues/200)",
|
||||||
|
[]testResult{
|
||||||
|
{100, "user", "repo", "100", false, XRefActionNone, nil, nil, ""},
|
||||||
|
{200, "user", "repo", "200", false, XRefActionCloses, nil, &RefSpan{Start: 61, End: 67}, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
testFixtures(t, urlFixtures, "url-keywords")
|
||||||
|
|
||||||
|
// Test bare URLs (not markdown links) with closing keywords
|
||||||
|
// These use FindAllIssueReferences (non-markdown) which converts full URLs to short refs first
|
||||||
|
setting.AppURL = "https://gitea.com:3000/"
|
||||||
|
bareURLTests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected XRefAction
|
||||||
|
}{
|
||||||
|
{"Fixes bare URL", "Fixes https://gitea.com:3000/org/project/issues/456", XRefActionCloses},
|
||||||
|
{"Fixes with colon", "Fixes: https://gitea.com:3000/org/project/issues/456", XRefActionCloses},
|
||||||
|
{"Closes bare URL", "Closes https://gitea.com:3000/user/repo/issues/123", XRefActionCloses},
|
||||||
|
{"Closes with colon", "Closes: https://gitea.com:3000/user/repo/issues/123", XRefActionCloses},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range bareURLTests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
refs := FindAllIssueReferences(tt.input)
|
||||||
|
assert.Len(t, refs, 1, "Expected 1 reference for: %s", tt.input)
|
||||||
|
if len(refs) > 0 {
|
||||||
|
assert.Equal(t, tt.expected, refs[0].Action, "Expected action %v for: %s", tt.expected, tt.input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type alnumFixture struct {
|
type alnumFixture struct {
|
||||||
input string
|
input string
|
||||||
issue string
|
issue string
|
||||||
|
|||||||
Reference in New Issue
Block a user