Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Usage of junit2jira:
Enable debug log level
-dry-run
When set to true issues will NOT be created.
-enable-auto-priority
Enable automatic priority escalation based on comment count.
-html-output string
Generate HTML report to this file (use dash [-] for stdout)
-jira-url string
Expand All @@ -50,6 +52,10 @@ Usage of junit2jira:
Dir that contains jUnit reports XML files
-orchestrator string
Orchestrator name (such as GKE or OpenShift), if any.
-priority-thresholds string
Comma-separated minimum comment counts for priority escalation (Minor,Normal,Major,Blocker,Critical). (default "2,10,50,100,200")
-priority-window-days int
Number of recent days used for automatic priority escalation. (default 30)
-slack-output string
Generate JSON output in slack format (use dash [-] for stdout)
-threshold int
Expand All @@ -61,6 +67,57 @@ Usage of junit2jira:
print version information and exit
```

*Auto-Priority Escalation*

Automatic priority escalation is disabled by default. With `--enable-auto-priority`,
`junit2jira` evaluates an existing issue after successfully adding a comment to it.
It counts comments in one recent activity window, which defaults to 30 days and
can be changed with `--priority-window-days`. Newly created issues are not evaluated
for escalation during that run.

All available comment pages are fetched before counting. Comments from any author
count if their creation timestamp is strictly after the window's start; comments
exactly at the cutoff are excluded. Comments with missing or unparseable timestamps
are skipped. Unparseable timestamps produce at most one warning per issue evaluation,
including the timestamp and zero-based comment index.

The default thresholds map the comment count in that window to a target priority:

| Window count | Priority |
|-------------|----------|
| 0-1 | Undefined |
| 2-9 | Minor |
| 10-49 | Normal |
| 50-99 | Major |
| 100-199 | Blocker |
| 200+ | Critical |

Priority is only
raised: for example, an issue with 12 recent comments moves from Minor to Normal,
but an issue already at Blocker stays at Blocker. Unrecognized Jira priority names
are left unchanged with a warning.

`--priority-window-days` must be a positive integer. `--priority-thresholds` must
contain exactly five comma-separated, non-negative integers in strictly ascending
order. These are minimum comment counts for Minor, Normal, Major, Blocker, and
Critical, respectively. For example, 12 comments in the selected window yields
Normal with the defaults, but Minor with `10,50,100,200,400`. Invalid configuration
is rejected at startup, even when automatic escalation is disabled.

With `--dry-run`, no comment is added and automatic priority evaluation is skipped;
the command does not preview the resulting priority.

For example, to count comments from the last 10 days and use thresholds of
10, 50, 100, 200, and 400:

```shell
junit2jira \
--enable-auto-priority \
--priority-window-days 10 \
--priority-thresholds "10,50,100,200,400" \
...
```

*Authentication*

For Jira Cloud authentication, you need to provide:
Expand Down
6 changes: 3 additions & 3 deletions cmd/flakechecker/flake_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ type flakeDetectionPolicy struct {
}

func newFlakeDetectionPolicy(config flakeDetectionPolicyConfig) (*flakeDetectionPolicy, error) {
compiledJobNameRegex, err := regexp.Compile(fmt.Sprintf("^%s$", config.JobNameRegex))
compiledJobNameRegex, err := regexp.Compile(fmt.Sprintf("^(?:%s)$", config.JobNameRegex))
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("invalid flake config match job regex: %v", config.JobNameRegex))
}

compiledTestNameRegex, err := regexp.Compile(fmt.Sprintf("^%s$", config.TestNameRegex))
compiledTestNameRegex, err := regexp.Compile(fmt.Sprintf("^(?:%s)$", config.TestNameRegex))
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("invalid flake config test name regex: %v", config.TestNameRegex))
}
Expand Down Expand Up @@ -100,7 +100,7 @@ func loadFlakeConfigFile(fileName string) ([]*flakeDetectionPolicy, error) {
for _, flakeConfig := range flakeConfigs {
detectionPolicy, errNewPolicy := newFlakeDetectionPolicy(flakeConfig)
if errNewPolicy != nil {
return nil, errors.Wrap(err, fmt.Sprintf("create flake detection policy from config: %v", flakeConfig))
return nil, errors.Wrap(errNewPolicy, fmt.Sprintf("create flake detection policy from config: %v", flakeConfig))
}

detectionPolicies = append(detectionPolicies, detectionPolicy)
Expand Down
23 changes: 22 additions & 1 deletion cmd/flakechecker/flake_config_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package main

import (
"github.com/stretchr/testify/assert"
"os"
"path/filepath"
"strings"
"testing"

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

func newFlakeDetectionPolicyMust(config flakeDetectionPolicyConfig) *flakeDetectionPolicy {
Expand All @@ -14,6 +18,23 @@ func newFlakeDetectionPolicyMust(config flakeDetectionPolicyConfig) *flakeDetect
return policy
}

func TestLoadFlakeConfigFileInvalidRegex(t *testing.T) {
fileName := filepath.Join(t.TempDir(), "invalid.yml")
err := os.WriteFile(fileName, []byte(strings.TrimSpace(`
- jobNameRegex: "["
className: "Test"
testNameRegex: ".*"
ratioJobName: "main"
ratioThreshold: 5
`)), 0o600)
assert.NoError(t, err)

config, err := loadFlakeConfigFile(fileName)
assert.Nil(t, config)
assert.ErrorContains(t, err, "create flake detection policy from config")
assert.ErrorContains(t, err, "invalid flake config match job regex")
}

func TestLoadFlakeConfigFile(t *testing.T) {
samples := []struct {
name string
Expand Down
23 changes: 15 additions & 8 deletions cmd/flakechecker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"flag"
"fmt"
"github.com/carlmjohnson/versioninfo"
junit "github.com/joshdk/go-junit"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/stackrox/junit2jira/pkg/testcase"
Expand Down Expand Up @@ -59,14 +58,9 @@ func (p *flakeCheckerParams) checkFailedTests(bqClient biqQueryClient, failedTes
}

func (p *flakeCheckerParams) run() error {
testSuites, err := junit.IngestDir(p.junitReportsDir)
failedTests, err := loadFailedTests(p.junitReportsDir)
if err != nil {
return errors.Wrap(err, "could not read files")
}

failedTests, err := testcase.GetFailedTests(testSuites)
if err != nil {
return errors.Wrap(err, "could not find failed tests")
return err
}
log.Infof("Found %d failed tests", len(failedTests))

Expand Down Expand Up @@ -107,3 +101,16 @@ func main() {
log.Fatal(err)
}
}

func loadFailedTests(reportDir string) ([]testcase.TestCase, error) {
testSuites, err := testcase.LoadTestSuites(reportDir)
if err != nil {
return nil, errors.Wrap(err, "could not read files")
}

failedTests, err := testcase.GetFailedTests(testSuites)
if err != nil {
return nil, errors.Wrap(err, "could not find failed tests")
}
return failedTests, nil
}
61 changes: 61 additions & 0 deletions cmd/flakechecker/review_regression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"os"
"path/filepath"
"testing"

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

func TestPolicyAnchorsAlternatives(t *testing.T) {
p, err := newFlakeDetectionPolicy(flakeDetectionPolicyConfig{JobNameRegex: "foo|bar", TestNameRegex: "foo|bar"})
require.NoError(t, err)
for _, name := range []string{"foo", "bar"} {
require.True(t, p.matchJobName(name))
require.True(t, p.matchTestName(name))
}
for _, name := range []string{"foo-unlisted", "unlisted-bar"} {
require.False(t, p.matchJobName(name))
require.False(t, p.matchTestName(name))
}
p, err = newFlakeDetectionPolicy(flakeDetectionPolicyConfig{JobNameRegex: ".*", TestNameRegex: ".*"})
require.NoError(t, err)
require.True(t, p.matchJobName("anything"))
require.True(t, p.matchTestName("anything"))
_, err = newFlakeDetectionPolicy(flakeDetectionPolicyConfig{JobNameRegex: ".*", TestNameRegex: "["})
require.ErrorContains(t, err, "invalid flake config test name regex")
}

func TestLoadFailedTestsNormalizedIdentities(t *testing.T) {
for _, tc := range []struct{ name, attrs, class, test string }{
{"missing class", `name="test"`, "fallback-classname", "test"},
{"missing name", `classname="class"`, "class", "fallback-name"},
{"both missing", "", "fallback-classname", "fallback-name"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
xml := `<testsuites><testsuite name="outer"><testsuite name="inner"><testcase ` + tc.attrs + `><failure message="failed"/></testcase><testcase classname="runtime.MemStats"><failure/></testcase></testsuite></testsuite></testsuites>`
require.NoError(t, os.WriteFile(filepath.Join(dir, "report.xml"), []byte(xml), 0600))
failed, err := loadFailedTests(dir)
require.NoError(t, err)
require.Len(t, failed, 1)
require.Equal(t, tc.class, failed[0].Classname)
require.Equal(t, tc.test, failed[0].Name)
policy, err := newFlakeDetectionPolicy(flakeDetectionPolicyConfig{JobNameRegex: "job", ClassName: tc.class, TestNameRegex: tc.test, RatioThreshold: 5})
require.NoError(t, err)
calls := 0
client := &mockBigQueryClient{getRatioForTest: func(config flakeDetectionPolicyConfig, name string) (int, int, error) {
calls++
require.Equal(t, tc.class, config.ClassName)
require.Equal(t, tc.test, name)
return minHistoricalRuns, 1, nil
}}
p := flakeCheckerParams{jobName: "job"}
require.NoError(t, p.checkFailedTests(client, failed, []*flakeDetectionPolicy{policy}))
require.Equal(t, 1, calls)
})
}
_, err := loadFailedTests(filepath.Join(t.TempDir(), "missing"))
require.ErrorContains(t, err, "could not read files")
}
87 changes: 87 additions & 0 deletions cmd/junit2jira/closed_issue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"

jira "github.com/ctreminiom/go-atlassian/v2/jira/v3"
"github.com/stretchr/testify/require"
)

func TestFindMostRecentClosedIssue(t *testing.T) {
for _, tc := range []struct {
name string
pages []string
want string
err string
}{
{"same page", []string{`{"issues":[{"key":"similar","fields":{"summary":"target extra"}},{"key":"newest","fields":{"summary":"target"}},{"key":"older","fields":{"summary":"target"}}],"nextPageToken":"next"}`}, "newest", ""},
{"later page", []string{`{"issues":[{"fields":{"summary":"similar"}}],"nextPageToken":"next"}`, `{"issues":[{"key":"exact","fields":{"summary":"target"}}]}`}, "exact", ""},
{"exhausted", []string{`{"issues":[],"nextPageToken":"next"}`, `{"issues":[]}`}, "", ""},
{"nil response", []string{`null`}, "", ""},
{"later error", []string{`{"issues":[],"nextPageToken":"next"}`, `ERROR`}, "", "HTTP 500"},
{"repeated token", []string{`{"issues":[],"nextPageToken":"next"}`, `{"issues":[],"nextPageToken":"next"}`}, "", "repeated"},
} {
t.Run(tc.name, func(t *testing.T) {
type key struct{}
ctx := context.WithValue(context.Background(), key{}, "caller")
calls := 0
client, err := jira.New(&http.Client{Transport: priorityRoundTripper(func(req *http.Request) (*http.Response, error) {
require.Equal(t, "caller", req.Context().Value(key{}))
require.Equal(t, "POST", req.Method)
require.Equal(t, "/rest/api/3/search/jql", req.URL.Path)
var payload struct {
MaxResults int `json:"maxResults"`
NextPageToken string `json:"nextPageToken"`
JQL string `json:"jql"`
}
require.NoError(t, json.NewDecoder(req.Body).Decode(&payload))
require.Equal(t, 50, payload.MaxResults)
require.Contains(t, payload.JQL, "ORDER BY updated DESC")
if calls == 0 {
require.Empty(t, payload.NextPageToken)
} else {
require.Equal(t, "next", payload.NextPageToken)
}
require.Less(t, calls, len(tc.pages))
body := tc.pages[calls]
calls++
status := 200
if body == "ERROR" {
status = 500
body = `{"errorMessages":["failure"]}`
}
return &http.Response{Request: req, StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
})}, "https://jira.example/")
require.NoError(t, err)
j := junit2jira{jiraClient: client, params: params{jiraProject: "TEST"}}
issue, err := j.findMostRecentClosedIssue(ctx, "target")
if tc.err != "" {
require.ErrorContains(t, err, tc.err)
} else {
require.NoError(t, err)
}
if tc.want == "" {
require.Nil(t, issue)
} else {
require.NotNil(t, issue)
require.Equal(t, tc.want, issue.Key)
}
require.Equal(t, len(tc.pages), calls)
})
}
}

func TestFindMostRecentClosedIssueCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
client, err := jira.New(&http.Client{Transport: priorityRoundTripper(func(req *http.Request) (*http.Response, error) { return nil, req.Context().Err() })}, "https://jira.example/")
require.NoError(t, err)
j := junit2jira{jiraClient: client, params: params{jiraProject: "TEST"}}
_, err = j.findMostRecentClosedIssue(ctx, "target")
require.ErrorIs(t, err, context.Canceled)
}
Loading
Loading