feat(ui): replace jira link detection with a generic link finder

Fixes #1140
This commit is contained in:
Łukasz Mierzwa
2019-11-11 22:42:34 +00:00
parent f0430c42b7
commit 0015d3fa4e
19 changed files with 256 additions and 228 deletions

View File

@@ -932,8 +932,8 @@ func testAlert(version string, t *testing.T, expectedAlert, gotAlert models.Aler
for _, gs := range gotAM.Silences {
if es.Comment == gs.Comment &&
es.CreatedBy == gs.CreatedBy &&
es.JiraID == gs.JiraID &&
es.JiraURL == gs.JiraURL {
es.TicketID == gs.TicketID &&
es.TicketURL == gs.TicketURL {
foundSilence = true
}
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"os/signal"
"path"
"regexp"
"strings"
"syscall"
"time"
@@ -197,11 +198,18 @@ func main() {
config.Config.LogValues()
}
jiraRules := []models.JiraRule{}
for _, rule := range config.Config.JIRA {
jiraRules = append(jiraRules, models.JiraRule{Regex: rule.Regex, URI: rule.URI})
linkDetectRules := []models.LinkDetectRule{}
for _, rule := range config.Config.Silences.Comments.LinkDetect.Rules {
if rule.Regex == "" || rule.URITemplate == "" {
log.Fatalf("Invalid link detect rule, regex '%s' uriTemplate '%s'", rule.Regex, rule.URITemplate)
}
re, err := regexp.Compile(rule.Regex)
if err != nil {
log.Fatalf("Invalid link detect rule '%s': %s", rule.Regex, err)
}
linkDetectRules = append(linkDetectRules, models.LinkDetectRule{Regex: re, URITemplate: rule.URITemplate})
}
transform.ParseRules(jiraRules)
transform.SetLinkRules(linkDetectRules)
apiCache = cache.New(cache.NoExpiration, 10*time.Second)

View File

@@ -64,9 +64,12 @@ log:
sentry:
private: https://84a9ef37a6ed4fdb80e9ea2310d1ed26:8c6ee6f0ab02406482ff4b4e824e2c27@sentry.io/1279017
public: https://84a9ef37a6ed4fdb80e9ea2310d1ed26@sentry.io/1279017
jira:
- regex: DEVOPS-[0-9]+
uri: https://jira.example.com
silences:
comments:
linkDetect:
rules:
- regex: "(DEVOPS-[0-9]+)"
uriTemplate: https://jira.example.com/browse/$1
silenceForm:
author:
populate_from_header:

View File

@@ -684,30 +684,38 @@ log:
format: text
```
### JIRA
### Silences
`jira` section allows specifying a list of regex rules for finding links to Jira
issues in silence comments. If a string inside a comment matches one of the
rules it will be rendered as a link.
`silences` section allows specifying to configure silence post post-processing.
Syntax:
```YAML
jira:
- regex: string
- uri: string
silences:
comments:
linkDetect:
rules: list of link detection rules
```
- `regex` - regular expression for matching Jira issue ID.
- `uri` - base URL for Jira instance, `/browse/FOO-1` will be appended to it
(where `FOO-1` is example issue ID).
- `comments:linkDetect:rules` allows to specify a list of rules to detect links
inside silence comments. It's intended to find ticket system ID strings and
turn them into links.
Each rule must specify:
- `regex` - regular expression that matches ticket system IDs. Each regex must
contain at least one capture group `(regex)`.
- `uriTemplate` - template string that will be used to generate a link.
Each template must include `$1` which will be replaced with text matched
by the `regex`.
Example where a string `DEVOPS-123` inside a comment would be rendered as a link
to `https://jira.example.com/browse/DEVOPS-123`.
to a JIRA ticket `https://jira.example.com/browse/DEVOPS-123`.
```YAML
jira:
- regex: DEVOPS-[0-9]+
uri: https://jira.example.com
silences:
comments:
linkDetect:
rules:
- regex: "(DEVOPS-[0-9]+)"
uriTemplate: https://jira.example.com/browse/$1
```
Defaults:

View File

@@ -46,9 +46,12 @@ listen:
log:
config: false
level: info
jira:
- regex: DEVOPS-[0-9]+
uri: https://jira.example.com
silences:
comments:
linkDetect:
rules:
- regex: "(DEVOPS-[0-9]+)"
uriTemplate: https://jira.example.com/browse/$1
receivers:
keep: []
strip: []

View File

@@ -184,11 +184,11 @@ func (am *Alertmanager) pullSilences(version string) error {
}
log.Infof("[%s] Got %d silences(s) in %s", am.Name, len(silences), time.Since(start))
log.Infof("[%s] Detecting JIRA links in silences (%d)", am.Name, len(silences))
log.Infof("[%s] Detecting ticket links in silences (%d)", am.Name, len(silences))
silenceMap := map[string]models.Silence{}
for _, silence := range silences {
silence := silence // scopelint pin
silence.JiraID, silence.JiraURL = transform.DetectJIRAs(&silence)
silence.TicketID, silence.TicketURL = transform.DetectLinks(&silence)
silenceMap[silence.ID] = silence
}

View File

@@ -218,7 +218,7 @@ func (config *configSchema) Read() {
}
}
err = v.UnmarshalKey("jira", &config.JIRA)
err = v.UnmarshalKey("silences.comments.linkDetect.rules", &config.Silences.Comments.LinkDetect.Rules)
if err != nil {
log.Fatal(err)
}

View File

@@ -123,13 +123,16 @@ log:
config: true
level: info
format: text
jira: []
receivers:
keep: []
strip: []
sentry:
private: secret key
public: public key
silences:
comments:
linkDetect:
rules: []
silenceForm:
author:
populate_from_header:

View File

@@ -20,9 +20,9 @@ type alertmanagerConfig struct {
Headers map[string]string
}
type jiraRule struct {
Regex string
URI string
type LinkDetectRules struct {
Regex string `yaml:"regex" mapstructure:"regex"`
URITemplate string `yaml:"uriTemplate" mapstructure:"uriTemplate"`
}
type CustomLabelColor struct {
@@ -94,7 +94,6 @@ type configSchema struct {
Level string
Format string
}
JIRA []jiraRule
Receivers struct {
Keep []string
Strip []string
@@ -103,6 +102,13 @@ type configSchema struct {
Private string
Public string
}
Silences struct {
Comments struct {
LinkDetect struct {
Rules []LinkDetectRules `yaml:"rules" mapstructure:"rules"`
} `yaml:"linkDetect" mapstructure:"linkDetect"`
} `yaml:"comments" mapstructure:"comments"`
} `yaml:"silences" mapstructure:"silences"`
SilenceForm struct {
Author struct {
PopulateFromHeader struct {

View File

@@ -8,7 +8,7 @@ import (
"github.com/prymitive/karma/internal/filters"
"github.com/prymitive/karma/internal/models"
"github.com/pmezard/go-difflib/difflib"
"github.com/google/go-cmp/cmp"
)
type acTest struct {
@@ -57,7 +57,7 @@ var acTests = []acTest{
"1234567890": {
ID: "1234567890",
CreatedBy: "me@example.com",
JiraID: "JIRA-1",
TicketID: "JIRA-1",
},
}},
},
@@ -89,10 +89,10 @@ var acTests = []acTest{
"@silence_author=~me@example.com",
"@silence_id!=1234567890",
"@silence_id=1234567890",
"@silence_jira!=JIRA-1",
"@silence_jira!~JIRA-1",
"@silence_jira=JIRA-1",
"@silence_jira=~JIRA-1",
"@silence_ticket!=JIRA-1",
"@silence_ticket!~JIRA-1",
"@silence_ticket=JIRA-1",
"@silence_ticket=~JIRA-1",
"@state!=active",
"@state!=suppressed",
"@state=active",
@@ -131,18 +131,9 @@ func TestBuildAutocomplete(t *testing.T) {
expectedJSON, _ := json.Marshal(acTest.Expected)
if string(resultJSON) != string(expectedJSON) {
diff := difflib.UnifiedDiff{
A: difflib.SplitLines(string(expectedJSON)),
B: difflib.SplitLines(string(resultJSON)),
FromFile: "Expected",
ToFile: "Returned",
Context: 3,
if diff := cmp.Diff(expectedJSON, resultJSON); diff != "" {
t.Errorf("Wrong autocomplete data returned (-want +got):\n%s", diff)
}
text, err := difflib.GetUnifiedDiffString(diff)
if err != nil {
t.Error(err)
}
t.Errorf("Autocomplete mismatch:\n%s", text)
}
}
}

View File

@@ -7,11 +7,11 @@ import (
"github.com/prymitive/karma/internal/models"
)
type silenceJiraFilter struct {
type silenceTicketFilter struct {
alertFilter
}
func (filter *silenceJiraFilter) Match(alert *models.Alert, matches int) bool {
func (filter *silenceTicketFilter) Match(alert *models.Alert, matches int) bool {
if filter.IsValid {
var isMatch bool
if alert.IsSilenced() {
@@ -19,7 +19,7 @@ func (filter *silenceJiraFilter) Match(alert *models.Alert, matches int) bool {
for _, am := range alert.Alertmanager {
silence, found := am.Silences[silenceID]
if found {
m := filter.Matcher.Compare(silence.JiraID, filter.Value)
m := filter.Matcher.Compare(silence.TicketID, filter.Value)
if m {
isMatch = m
}
@@ -38,26 +38,26 @@ func (filter *silenceJiraFilter) Match(alert *models.Alert, matches int) bool {
panic(e)
}
func newSilenceJiraFilter() FilterT {
f := silenceJiraFilter{}
func newSilenceTicketFilter() FilterT {
f := silenceTicketFilter{}
return &f
}
func silenceJiraIDAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete {
func silenceTicketIDAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete {
tokens := map[string]models.Autocomplete{}
for _, alert := range alerts {
if alert.IsSilenced() {
for _, silenceID := range alert.SilencedBy {
for _, am := range alert.Alertmanager {
silence, found := am.Silences[silenceID]
if found && silence.JiraID != "" {
if found && silence.TicketID != "" {
for _, operator := range operators {
token := fmt.Sprintf("%s%s%s", name, operator, silence.JiraID)
token := fmt.Sprintf("%s%s%s", name, operator, silence.TicketID)
tokens[token] = makeAC(token, []string{
name,
strings.TrimPrefix(name, "@"),
fmt.Sprintf("%s%s", name, operator),
silence.JiraID,
silence.TicketID,
})
}
}

View File

@@ -146,73 +146,73 @@ var tests = []filterTest{
},
{
Expression: "@silence_jira=1",
Expression: "@silence_ticket=1",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1", JiraID: "1"},
Silence: models.Silence{ID: "1", TicketID: "1"},
IsMatch: true,
},
{
Expression: "@silence_jira=2",
Expression: "@silence_ticket=2",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1"},
IsMatch: false,
},
{
Expression: "@silence_jira!=3",
Expression: "@silence_ticket!=3",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1", JiraID: "x"},
Silence: models.Silence{ID: "1", TicketID: "x"},
IsMatch: true,
},
{
Expression: "@silence_jira!=4",
Expression: "@silence_ticket!=4",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1", JiraID: "4"},
Silence: models.Silence{ID: "1", TicketID: "4"},
IsMatch: false,
},
{
Expression: "@silence_jira!=5",
Expression: "@silence_ticket!=5",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1"},
IsMatch: true,
},
{
Expression: "@silence_jira=~abc",
Expression: "@silence_ticket=~abc",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1", JiraID: "xxabcxx"},
Silence: models.Silence{ID: "1", TicketID: "xxabcxx"},
IsMatch: true,
},
{
Expression: "@silence_jira=~abc",
Expression: "@silence_ticket=~abc",
IsValid: true,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1", JiraID: "xxx"},
Silence: models.Silence{ID: "1", TicketID: "xxx"},
IsMatch: false,
},
{
Expression: "@silence_jira=~",
Expression: "@silence_ticket=~",
IsValid: false,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1", JiraID: "xxx"},
Silence: models.Silence{ID: "1", TicketID: "xxx"},
IsMatch: false,
},
{
Expression: "@silence_jira~=",
Expression: "@silence_ticket~=",
IsValid: false,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1", JiraID: "xxx"},
Silence: models.Silence{ID: "1", TicketID: "xxx"},
IsMatch: false,
},
{
Expression: "@silence_jira~=1",
Expression: "@silence_ticket~=1",
IsValid: false,
Alert: models.Alert{State: "suppressed", SilencedBy: []string{"1"}},
Silence: models.Silence{ID: "1", JiraID: "xxx"},
Silence: models.Silence{ID: "1", TicketID: "xxx"},
IsMatch: false,
},

View File

@@ -76,11 +76,11 @@ var AllFilters = []filterConfig{
Autocomplete: silenceIDAutocomplete,
},
{
Label: "@silence_jira",
LabelRe: regexp.MustCompile("^@silence_jira$"),
Label: "@silence_ticket",
LabelRe: regexp.MustCompile("^@silence_ticket$"),
SupportedOperators: []string{regexpOperator, negativeRegexOperator, equalOperator, notEqualOperator},
Factory: newSilenceJiraFilter,
Autocomplete: silenceJiraIDAutocomplete,
Factory: newSilenceTicketFilter,
Autocomplete: silenceTicketIDAutocomplete,
},
{
Label: "@silence_author",

View File

@@ -1,8 +1,10 @@
package models
import "regexp"
// JiraRule is used to detect JIRA issue IDs in strings and turn those into
// links
type JiraRule struct {
Regex string
URI string
type LinkDetectRule struct {
Regex *regexp.Regexp
URITemplate string
}

View File

@@ -21,8 +21,8 @@ type Silence struct {
CreatedBy string `json:"createdBy"`
Comment string `json:"comment"`
// karma fields
JiraID string `json:"jiraID"`
JiraURL string `json:"jiraURL"`
TicketID string `json:"ticketID"`
TicketURL string `json:"ticketURL"`
}
// ManagedSilence is a standalone silence detached from any alert

View File

@@ -1,45 +0,0 @@
package transform
import (
"fmt"
"log"
"regexp"
"github.com/prymitive/karma/internal/models"
)
type jiraDetectRule struct {
Regexp *regexp.Regexp
URL string
}
var jiraDetectRules = []jiraDetectRule{}
// ParseRules will parse and validate list of JIRA detection rules provided
// from config, valid rules will be stored for future use in DetectJIRAs() calls
func ParseRules(rules []models.JiraRule) {
for _, rule := range rules {
if rule.Regex == "" || rule.URI == "" {
log.Fatalf("Invalid JIRA rule with regexp '%s' and url '%s'", rule.Regex, rule.URI)
}
jdr := jiraDetectRule{
Regexp: regexp.MustCompile(rule.Regex),
URL: rule.URI,
}
jiraDetectRules = append(jiraDetectRules, jdr)
}
}
// DetectJIRAs will try to find JIRA links in Alertmanager silence objects
// using regexp rules from configuration that were parsed and populated
// by ParseRules call
func DetectJIRAs(silence *models.Silence) (jiraID, jiraLink string) {
for _, jdr := range jiraDetectRules {
jiraID := jdr.Regexp.FindString(silence.Comment)
if jiraID != "" {
jiraLink := fmt.Sprintf("%s/browse/%s", jdr.URL, jiraID)
return jiraID, jiraLink
}
}
return "", ""
}

View File

@@ -1,96 +0,0 @@
package transform_test
import (
"testing"
"github.com/prymitive/karma/internal/models"
"github.com/prymitive/karma/internal/transform"
)
type jiraTest struct {
silence models.Silence
jiraID string
jiraLink string
}
var jiraRules = []models.JiraRule{
{
Regex: "DEVOPS-[0-9]+",
URI: "https://jira.example.com",
},
{
Regex: "PROJECT-[0-9]+",
URI: "https://example.com",
},
}
var jiraTests = []jiraTest{
{
silence: models.Silence{
Comment: "Lorem ipsum dolor sit amet",
},
},
{
silence: models.Silence{
Comment: "DVOPS-123",
},
},
{
silence: models.Silence{
Comment: "DEVOPS team",
},
},
{
silence: models.Silence{
Comment: "a project-1 b",
},
},
{
silence: models.Silence{
Comment: "a PROJECT- b",
},
},
{
silence: models.Silence{
Comment: "DEVOPS-1",
},
jiraID: "DEVOPS-1",
jiraLink: "https://jira.example.com/browse/DEVOPS-1",
},
{
silence: models.Silence{
Comment: "DEVOPS-123",
},
jiraID: "DEVOPS-123",
jiraLink: "https://jira.example.com/browse/DEVOPS-123",
},
{
silence: models.Silence{
Comment: "a DEVOPS-1 b",
},
jiraID: "DEVOPS-1",
jiraLink: "https://jira.example.com/browse/DEVOPS-1",
},
{
silence: models.Silence{
Comment: "PROJECT-9",
},
jiraID: "PROJECT-9",
jiraLink: "https://example.com/browse/PROJECT-9",
},
}
func TestDetectJIRAs(t *testing.T) {
transform.ParseRules(jiraRules)
for _, testCase := range jiraTests {
jiraID, jiraLink := transform.DetectJIRAs(&testCase.silence)
if jiraID != testCase.jiraID {
t.Errorf("Invalid JIRA ID detected in silence comment '%s', expected '%s', got '%s'",
testCase.silence.Comment, testCase.jiraID, jiraID)
}
if jiraID != testCase.jiraID {
t.Errorf("Invalid JIRA link detected in silence comment '%s', expected '%s', got '%s'",
testCase.silence.Comment, testCase.jiraLink, jiraLink)
}
}
}

View File

@@ -0,0 +1,27 @@
package transform
import (
"github.com/prymitive/karma/internal/models"
)
var linkDetectRules []models.LinkDetectRule
func SetLinkRules(rules []models.LinkDetectRule) {
linkDetectRules = rules
}
// DetectLinks will try to find all links in Alertmanager silence objects
// using regexp rules from configuration
func DetectLinks(silence *models.Silence) (text, uri string) {
for _, rule := range linkDetectRules {
m := rule.Regex.FindString(silence.Comment)
if m != "" {
result := []byte{}
for _, submatches := range rule.Regex.FindAllStringSubmatchIndex(silence.Comment, -1) {
result = rule.Regex.ExpandString(result, rule.URITemplate, silence.Comment, submatches)
}
return m, string(result)
}
}
return "", ""
}

View File

@@ -0,0 +1,118 @@
package transform_test
import (
"regexp"
"testing"
"github.com/prymitive/karma/internal/config"
"github.com/prymitive/karma/internal/models"
"github.com/prymitive/karma/internal/transform"
)
type linkTest struct {
silence models.Silence
text string
uri string
}
var linkRules = []config.LinkDetectRules{
{
Regex: "(DEVOPS-[0-9]+)",
URITemplate: "https://jira.example.com/browse/$1",
},
{
Regex: "(PROJECT-[0-9]+)",
URITemplate: "https://example.com/browse/$1",
},
{
Regex: "(redmine[0-9]+)",
URITemplate: "https://redmine.example.com/issue/$1.php",
},
}
var linkTests = []linkTest{
{
silence: models.Silence{
Comment: "Lorem ipsum dolor sit amet",
},
},
{
silence: models.Silence{
Comment: "DVOPS-123",
},
},
{
silence: models.Silence{
Comment: "DEVOPS team",
},
},
{
silence: models.Silence{
Comment: "a project-1 b",
},
},
{
silence: models.Silence{
Comment: "a PROJECT- b",
},
},
{
silence: models.Silence{
Comment: "DEVOPS-1",
},
text: "DEVOPS-1",
uri: "https://jira.example.com/browse/DEVOPS-1",
},
{
silence: models.Silence{
Comment: "DEVOPS-123",
},
text: "DEVOPS-123",
uri: "https://jira.example.com/browse/DEVOPS-123",
},
{
silence: models.Silence{
Comment: "a DEVOPS-1 b",
},
text: "DEVOPS-1",
uri: "https://jira.example.com/browse/DEVOPS-1",
},
{
silence: models.Silence{
Comment: "PROJECT-9",
},
text: "PROJECT-9",
uri: "https://example.com/browse/PROJECT-9",
},
{
silence: models.Silence{
Comment: "redmine0",
},
text: "redmine0",
uri: "https://redmine.example.com/issue/redmine0.php",
},
}
func TestDetectTickets(t *testing.T) {
linkDetectRules := []models.LinkDetectRule{}
for _, rule := range linkRules {
re, err := regexp.Compile(rule.Regex)
if err != nil {
t.Errorf("Invalid link detect rule '%s': %s", rule.Regex, err)
}
linkDetectRules = append(linkDetectRules, models.LinkDetectRule{Regex: re, URITemplate: rule.URITemplate})
}
transform.SetLinkRules(linkDetectRules)
for _, testCase := range linkTests {
text, uri := transform.DetectLinks(&testCase.silence)
if text != testCase.text {
t.Errorf("Invalid ticket ID detected in silence comment '%s', expected '%s', got '%s'",
testCase.silence.Comment, testCase.text, text)
}
if text != testCase.text {
t.Errorf("Invalid ticket link detected in silence comment '%s', expected '%s', got '%s'",
testCase.silence.Comment, testCase.uri, uri)
}
}
}