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

Custom regexp external issues #17624

Merged
merged 18 commits into from
Jun 10, 2022
Merged
Show file tree
Hide file tree
Changes from 15 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
3 changes: 3 additions & 0 deletions models/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,9 @@ func (repo *Repository) ComposeMetas() map[string]string {
switch unit.ExternalTrackerConfig().ExternalTrackerStyle {
case markup.IssueNameStyleAlphanumeric:
metas["style"] = markup.IssueNameStyleAlphanumeric
case markup.IssueNameStyleRegexp:
metas["style"] = markup.IssueNameStyleRegexp
metas["regexp"] = unit.ExternalTrackerConfig().ExternalTrackerRegexpPattern
default:
metas["style"] = markup.IssueNameStyleNumeric
}
Expand Down
7 changes: 4 additions & 3 deletions models/repo/repo_unit.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ func (cfg *ExternalWikiConfig) ToDB() ([]byte, error) {

// ExternalTrackerConfig describes external tracker config
type ExternalTrackerConfig struct {
ExternalTrackerURL string
ExternalTrackerFormat string
ExternalTrackerStyle string
ExternalTrackerURL string
ExternalTrackerFormat string
ExternalTrackerStyle string
ExternalTrackerRegexpPattern string
}

// FromDB fills up a ExternalTrackerConfig from serialized format.
Expand Down
3 changes: 3 additions & 0 deletions models/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ func TestMetas(t *testing.T) {
externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleNumeric
testSuccess(markup.IssueNameStyleNumeric)

externalTracker.ExternalTrackerConfig().ExternalTrackerStyle = markup.IssueNameStyleRegexp
testSuccess(markup.IssueNameStyleRegexp)

repo, err := repo_model.GetRepositoryByID(3)
assert.NoError(t, err)

Expand Down
40 changes: 29 additions & 11 deletions modules/markup/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/regexplru"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates/vars"
"code.gitea.io/gitea/modules/util"
Expand All @@ -33,6 +34,7 @@ import (
const (
IssueNameStyleNumeric = "numeric"
IssueNameStyleAlphanumeric = "alphanumeric"
IssueNameStyleRegexp = "regexp"
)

var (
Expand Down Expand Up @@ -815,19 +817,35 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
)

next := node.NextSibling

for node != nil && node != next {
_, exttrack := ctx.Metas["format"]
alphanum := ctx.Metas["style"] == IssueNameStyleAlphanumeric
_, hasExtTrackFormat := ctx.Metas["format"]

// Repos with external issue trackers might still need to reference local PRs
// We need to concern with the first one that shows up in the text, whichever it is
found, ref = references.FindRenderizableReferenceNumeric(node.Data, exttrack && alphanum)
if exttrack && alphanum {
if found2, ref2 := references.FindRenderizableReferenceAlphanumeric(node.Data); found2 {
if !found || ref2.RefLocation.Start < ref.RefLocation.Start {
found = true
ref = ref2
}
isNumericStyle := ctx.Metas["style"] == "" || ctx.Metas["style"] == IssueNameStyleNumeric
foundNumeric, refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle)

switch ctx.Metas["style"] {
case "", IssueNameStyleNumeric:
found, ref = foundNumeric, refNumeric
case IssueNameStyleAlphanumeric:
found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
case IssueNameStyleRegexp:
pattern, err := regexplru.GetCompiled(ctx.Metas["regexp"])
if err != nil {
return
}
found, ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
}

// Repos with external issue trackers might still need to reference local PRs
// We need to concern with the first one that shows up in the text, whichever it is
if hasExtTrackFormat && !isNumericStyle {
// If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that
if foundNumeric && refNumeric.RefLocation.Start < ref.RefLocation.Start {
found = foundNumeric
ref = refNumeric
}
}
if !found {
Expand All @@ -836,7 +854,7 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {

var link *html.Node
reftext := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
if exttrack && !ref.IsPull {
if hasExtTrackFormat && !ref.IsPull {
ctx.Metas["index"] = ref.Issue

res, err := vars.Expand(ctx.Metas["format"], ctx.Metas)
Expand Down Expand Up @@ -869,7 +887,7 @@ func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {

// Decorate action keywords if actionable
var keyword *html.Node
if references.IsXrefActionable(ref, exttrack, alphanum) {
if references.IsXrefActionable(ref, hasExtTrackFormat) {
keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
} else {
keyword = &html.Node{
Expand Down
50 changes: 47 additions & 3 deletions modules/markup/html_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const (
)

// alphanumLink an HTML link to an alphanumeric-style issue
6543 marked this conversation as resolved.
Show resolved Hide resolved
func alphanumIssueLink(baseURL, class, name string) string {
func externalIssueLink(baseURL, class, name string) string {
return link(util.URLJoin(baseURL, name), class, name)
}

Expand Down Expand Up @@ -54,6 +54,13 @@ var alphanumericMetas = map[string]string{
"style": IssueNameStyleAlphanumeric,
}

var regexpMetas = map[string]string{
"format": "https://someurl.com/{user}/{repo}/{index}",
"user": "someUser",
"repo": "someRepo",
"style": IssueNameStyleRegexp,
}

// these values should match the TestOrgRepo const above
var localMetas = map[string]string{
"user": "gogits",
Expand Down Expand Up @@ -184,7 +191,7 @@ func TestRender_IssueIndexPattern4(t *testing.T) {
test := func(s, expectedFmt string, names ...string) {
links := make([]interface{}, len(names))
for i, name := range names {
links[i] = alphanumIssueLink("https://someurl.com/someUser/someRepo/", "ref-issue ref-external-issue", name)
links[i] = externalIssueLink("https://someurl.com/someUser/someRepo/", "ref-issue ref-external-issue", name)
}
expected := fmt.Sprintf(expectedFmt, links...)
testRenderIssueIndexPattern(t, s, expected, &RenderContext{Metas: alphanumericMetas})
Expand All @@ -194,6 +201,43 @@ func TestRender_IssueIndexPattern4(t *testing.T) {
test("test issue ABCDEFGHIJ-1234567890", "test issue %s", "ABCDEFGHIJ-1234567890")
}

func TestRender_IssueIndexPattern5(t *testing.T) {
setting.AppURL = TestAppURL

// regexp: render inputs without valid mentions
test := func(s, expectedFmt, pattern string, ids, names []string) {
metas := regexpMetas
metas["regexp"] = pattern
links := make([]interface{}, len(ids))
for i, id := range ids {
links[i] = link(util.URLJoin("https://someurl.com/someUser/someRepo/", id), "ref-issue ref-external-issue", names[i])
}

expected := fmt.Sprintf(expectedFmt, links...)
testRenderIssueIndexPattern(t, s, expected, &RenderContext{Metas: metas})
}

test("abc ISSUE-123 def", "abc %s def",
"ISSUE-(\\d+)",
[]string{"123"},
[]string{"ISSUE-123"},
)

test("abc (ISSUE 123) def", "abc %s def",
"\\(ISSUE (\\d+)\\)",
[]string{"123"},
[]string{"(ISSUE 123)"},
)

test("abc ISSUE-123 def", "abc %s def",
"(ISSUE-(\\d+))",
[]string{"ISSUE-123"},
[]string{"ISSUE-123"},
)

testRenderIssueIndexPattern(t, "will not match", "will not match", &RenderContext{Metas: regexpMetas})
}

func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
if ctx.URLPrefix == "" {
ctx.URLPrefix = TestAppURL
Expand All @@ -202,7 +246,7 @@ func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *Rend
var buf strings.Builder
err := postProcess(ctx, []processor{issueIndexPatternProcessor}, strings.NewReader(input), &buf)
assert.NoError(t, err)
assert.Equal(t, expected, buf.String())
assert.Equal(t, expected, buf.String(), "input=%q", input)
}

func TestRender_AutoLink(t *testing.T) {
Expand Down
20 changes: 19 additions & 1 deletion modules/references/references.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,24 @@ func FindRenderizableReferenceNumeric(content string, prOnly bool) (bool, *Rende
}
}

// FindRenderizableReferenceRegexp returns the first regexp unvalidated references found in a string.
func FindRenderizableReferenceRegexp(content string, pattern *regexp.Regexp) (bool, *RenderizableReference) {
match := pattern.FindStringSubmatchIndex(content)
if len(match) < 4 {
return false, nil
}

action, location := findActionKeywords([]byte(content), match[2])

return true, &RenderizableReference{
Issue: content[match[2]:match[3]],
RefLocation: &RefSpan{Start: match[0], End: match[1]},
Action: action,
ActionLocation: location,
IsPull: false,
}
}

// FindRenderizableReferenceAlphanumeric returns the first alphanumeric unvalidated references found in a string.
func FindRenderizableReferenceAlphanumeric(content string) (bool, *RenderizableReference) {
match := issueAlphanumericPattern.FindStringSubmatchIndex(content)
Expand Down Expand Up @@ -547,7 +565,7 @@ func findActionKeywords(content []byte, start int) (XRefAction, *RefSpan) {
}

// IsXrefActionable returns true if the xref action is actionable (i.e. produces a result when resolved)
func IsXrefActionable(ref *RenderizableReference, extTracker, alphaNum bool) bool {
func IsXrefActionable(ref *RenderizableReference, extTracker bool) bool {
if extTracker {
// External issues cannot be automatically closed
return false
Expand Down
45 changes: 45 additions & 0 deletions modules/regexplru/regexplru.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2022 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 regexplru

import (
"regexp"

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

lru "github.com/hashicorp/golang-lru"
)

var lruCache *lru.Cache

func init() {
var err error
lruCache, err = lru.New(1000)
if err != nil {
log.Fatal("failed to new LRU cache, err: %v", err)
}
}

// GetCompiled works like regexp.Compile, the compiled expr or error is stored in LRU cache
func GetCompiled(expr string) (r *regexp.Regexp, err error) {
v, ok := lruCache.Get(expr)
6543 marked this conversation as resolved.
Show resolved Hide resolved
if !ok {
r, err = regexp.Compile(expr)
if err != nil {
lruCache.Add(expr, err)
return nil, err
}
lruCache.Add(expr, r)
} else {
r, ok = v.(*regexp.Regexp)
if !ok {
if err, ok = v.(error); ok {
return nil, err
}
panic("impossible")
}
}
return r, nil
}
27 changes: 27 additions & 0 deletions modules/regexplru/regexplru_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2022 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 regexplru

import (
"testing"

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

func TestRegexpLru(t *testing.T) {
r, err := GetCompiled("a")
assert.NoError(t, err)
assert.True(t, r.MatchString("a"))

r, err = GetCompiled("a")
assert.NoError(t, err)
assert.True(t, r.MatchString("a"))

assert.EqualValues(t, 1, lruCache.Len())

_, err = GetCompiled("(")
assert.Error(t, err)
assert.EqualValues(t, 2, lruCache.Len())
}
3 changes: 3 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1811,6 +1811,9 @@ settings.tracker_url_format_error = The external issue tracker URL format is not
settings.tracker_issue_style = External Issue Tracker Number Format
settings.tracker_issue_style.numeric = Numeric
settings.tracker_issue_style.alphanumeric = Alphanumeric
settings.tracker_issue_style.regexp = Regular Expression
settings.tracker_issue_style.regexp_pattern = Regular Expression Pattern
settings.tracker_issue_style.regexp_pattern_desc = The first captured group will be used in place of <code>{index}</code>.
settings.tracker_url_format_desc = Use the placeholders <code>{user}</code>, <code>{repo}</code> and <code>{index}</code> for the username, repository name and issue index.
settings.enable_timetracker = Enable Time Tracking
settings.allow_only_contributors_to_track_time = Let Only Contributors Track Time
Expand Down
7 changes: 4 additions & 3 deletions routers/web/repo/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,9 +434,10 @@ func SettingsPost(ctx *context.Context) {
RepoID: repo.ID,
Type: unit_model.TypeExternalTracker,
Config: &repo_model.ExternalTrackerConfig{
ExternalTrackerURL: form.ExternalTrackerURL,
ExternalTrackerFormat: form.TrackerURLFormat,
ExternalTrackerStyle: form.TrackerIssueStyle,
ExternalTrackerURL: form.ExternalTrackerURL,
ExternalTrackerFormat: form.TrackerURLFormat,
ExternalTrackerStyle: form.TrackerIssueStyle,
ExternalTrackerRegexpPattern: form.ExternalTrackerRegexpPattern,
},
})
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues)
Expand Down
1 change: 1 addition & 0 deletions services/forms/repo_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ type RepoSettingForm struct {
ExternalTrackerURL string
TrackerURLFormat string
TrackerIssueStyle string
ExternalTrackerRegexpPattern string
EnableCloseIssuesViaCommitInAnyBranch bool
EnableProjects bool
EnablePackages bool
Expand Down
19 changes: 15 additions & 4 deletions templates/repo/settings/options.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -361,16 +361,27 @@
<div class="ui radio checkbox">
{{$externalTracker := (.Repository.MustGetUnit $.UnitTypeExternalTracker)}}
{{$externalTrackerStyle := $externalTracker.ExternalTrackerConfig.ExternalTrackerStyle}}
<input class="hidden" tabindex="0" name="tracker_issue_style" type="radio" value="numeric" {{if $externalTrackerStyle}}{{if eq $externalTrackerStyle "numeric"}}checked=""{{end}}{{end}}/>
<label>{{.i18n.Tr "repo.settings.tracker_issue_style.numeric"}} <span class="ui light grey text">(#1234)</span></label>
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="numeric" {{if eq $externalTrackerStyle "numeric"}}checked{{end}}>
<label>{{.i18n.Tr "repo.settings.tracker_issue_style.numeric"}} <span class="ui light grey text">#1234</span></label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input class="hidden" tabindex="0" name="tracker_issue_style" type="radio" value="alphanumeric" {{if $externalTrackerStyle}}{{if eq $externalTracker.ExternalTrackerConfig.ExternalTrackerStyle "alphanumeric"}}checked=""{{end}}{{end}} />
<label>{{.i18n.Tr "repo.settings.tracker_issue_style.alphanumeric"}} <span class="ui light grey text">(ABC-123, DEFG-234)</span></label>
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="alphanumeric" {{if eq $externalTrackerStyle "alphanumeric"}}checked{{end}}>
<label>{{.i18n.Tr "repo.settings.tracker_issue_style.alphanumeric"}} <span class="ui light grey text">ABC-123 , DEFG-234</span></label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input class="js-tracker-issue-style" name="tracker_issue_style" type="radio" value="regexp" {{if eq $externalTrackerStyle "regexp"}}checked{{end}}>
<label>{{.i18n.Tr "repo.settings.tracker_issue_style.regexp"}} <span class="ui light grey text">(ISSUE-\d+) , ISSUE-(\d+)</span></label>
</div>
</div>
</div>
<div class="field {{if ne $externalTrackerStyle "regexp"}}disabled{{end}}" id="tracker-issue-style-regex-box">
<label for="external_tracker_regexp_pattern">{{.i18n.Tr "repo.settings.tracker_issue_style.regexp_pattern"}}</label>
<input id="external_tracker_regexp_pattern" name="external_tracker_regexp_pattern" value="{{(.Repository.MustGetUnit $.UnitTypeExternalTracker).ExternalTrackerConfig.ExternalTrackerRegexpPattern}}">
<p class="help">{{.i18n.Tr "repo.settings.tracker_issue_style.regexp_pattern_desc" | Str2html}}</p>
</div>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions web_src/js/features/repo-legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,11 @@ export function initRepository() {
if (typeof $(this).data('context') !== 'undefined') $($(this).data('context')).addClass('disabled');
}
});
const $trackerIssueStyleRadios = $('.js-tracker-issue-style');
$trackerIssueStyleRadios.on('change input', () => {
const checkedVal = $trackerIssueStyleRadios.filter(':checked').val();
$('#tracker-issue-style-regex-box').toggleClass('disabled', checkedVal !== 'regexp');
});
}

// Labels
Expand Down