diff --git a/README.md b/README.md index 72e00e8..ffff909 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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: diff --git a/cmd/flakechecker/flake_config.go b/cmd/flakechecker/flake_config.go index 49233ae..c218016 100644 --- a/cmd/flakechecker/flake_config.go +++ b/cmd/flakechecker/flake_config.go @@ -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)) } @@ -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) diff --git a/cmd/flakechecker/flake_config_test.go b/cmd/flakechecker/flake_config_test.go index 067ef16..cacd84a 100644 --- a/cmd/flakechecker/flake_config_test.go +++ b/cmd/flakechecker/flake_config_test.go @@ -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 { @@ -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 diff --git a/cmd/flakechecker/main.go b/cmd/flakechecker/main.go index 9b8315d..d3a3e76 100644 --- a/cmd/flakechecker/main.go +++ b/cmd/flakechecker/main.go @@ -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" @@ -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)) @@ -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 +} diff --git a/cmd/flakechecker/review_regression_test.go b/cmd/flakechecker/review_regression_test.go new file mode 100644 index 0000000..417d7f7 --- /dev/null +++ b/cmd/flakechecker/review_regression_test.go @@ -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 := `` + 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") +} diff --git a/cmd/junit2jira/closed_issue_test.go b/cmd/junit2jira/closed_issue_test.go new file mode 100644 index 0000000..3ab2cd1 --- /dev/null +++ b/cmd/junit2jira/closed_issue_test.go @@ -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) +} diff --git a/cmd/junit2jira/main.go b/cmd/junit2jira/main.go index 7d90030..3b697f6 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -28,13 +28,13 @@ import ( ) const ( - jql = `project in (%s) + jql = `project = %s AND issuetype = Bug AND status != Closed AND labels = CI_Failure AND summary ~ %q ORDER BY created DESC` - jqlClosedTicketsQuery = `project in (%s) + jqlClosedTicketsQuery = `project = %s AND issuetype = Bug AND status = Closed AND labels = CI_Failure @@ -67,6 +67,9 @@ func main() { flag.StringVar(&p.BuildTag, "build-tag", "", "Built tag or revision.") flag.StringVar(&p.JobName, "job-name", "", "Name of CI job.") flag.StringVar(&p.Orchestrator, "orchestrator", "", "Orchestrator name (such as GKE or OpenShift), if any.") + flag.BoolVar(&p.enableAutoPriority, "enable-auto-priority", false, "Enable automatic priority escalation based on comment count.") + flag.StringVar(&p.priorityThresholds, "priority-thresholds", priorityThresholdsString(defaultPriorityThresholds), "Comma-separated minimum comment counts for priority escalation (Minor,Normal,Major,Blocker,Critical).") + flag.IntVar(&p.priorityWindowDays, "priority-window-days", 30, "Number of recent days used for automatic priority escalation.") flag.BoolVar(&debug, "debug", false, "Enable debug log level") versioninfo.AddFlag(flag.CommandLine) flag.Parse() @@ -86,7 +89,7 @@ func main() { log.SetLevel(log.DebugLevel) } - err = run(p) + err = run(context.Background(), p) if err != nil { log.Fatal(err) } @@ -103,7 +106,16 @@ type testIssue struct { testCase j2jTestCase } -func run(p params) error { +func run(ctx context.Context, p params) error { + if p.priorityWindowDays <= 0 { + return fmt.Errorf("priority window days must be positive, got %d", p.priorityWindowDays) + } + var err error + p.parsedPriorityThresholds, err = parsePriorityThresholds(p.priorityThresholds) + if err != nil { + return fmt.Errorf("invalid priority thresholds: %w", err) + } + // Check for username (email) for Basic Auth jiraUser := os.Getenv("JIRA_USER") jiraToken := os.Getenv("JIRA_TOKEN") @@ -143,7 +155,7 @@ func run(p params) error { return errors.Wrap(err, "could not find failed tests") } - issues, err := j.createIssuesOrComments(failedTests) + issues, err := j.createIssuesOrComments(ctx, failedTests) if err != nil { return errors.Wrap(err, "could not create issues or comments") } @@ -277,11 +289,11 @@ func (j junit2jira) createCsv(testSuites []junit.Suite) error { return junit2csv(testSuites, j.params, out) } -func (j junit2jira) createIssuesOrComments(failedTests []j2jTestCase) ([]*testIssue, error) { +func (j junit2jira) createIssuesOrComments(ctx context.Context, failedTests []j2jTestCase) ([]*testIssue, error) { var result error issues := make([]*testIssue, 0, len(failedTests)) for _, tc := range failedTests { - issue, err := j.createIssueOrComment(tc) + issue, err := j.createIssueOrComment(ctx, tc) if err != nil { result = multierror.Append(result, err) } @@ -325,14 +337,14 @@ func (j junit2jira) linkIssues(issues []*models.IssueScheme) error { return result } -func (j junit2jira) linkToClosedTicket(newIssue, closedIssue *models.IssueScheme) error { +func (j junit2jira) linkToClosedTicket(ctx context.Context, newIssue, closedIssue *models.IssueScheme) error { payload := &models.LinkPayloadSchemeV3{ Type: &models.LinkTypeScheme{Name: linkType}, InwardIssue: &models.LinkedIssueScheme{Key: newIssue.Key}, OutwardIssue: &models.LinkedIssueScheme{Key: closedIssue.Key}, } - response, err := j.jiraClient.Issue.Link.Create(context.Background(), payload) + response, err := j.jiraClient.Issue.Link.Create(ctx, payload) if err != nil { if response != nil { return fmt.Errorf("create link (HTTP %d): %w", response.Code, err) @@ -343,7 +355,7 @@ func (j junit2jira) linkToClosedTicket(newIssue, closedIssue *models.IssueScheme return nil } -func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { +func (j junit2jira) createIssueOrComment(ctx context.Context, tc j2jTestCase) (*testIssue, error) { summary, err := tc.summary() if err != nil { return nil, fmt.Errorf("could not get summary: %w", err) @@ -355,7 +367,7 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { const NA = "?" logEntry(NA, summary).Debug("Searching for issue") searchResult, response, err := j.jiraClient.Issue.Search.SearchJQL( - context.TODO(), + ctx, fmt.Sprintf(jql, j.jiraProject, summary), []string{"summary"}, // fields - request summary field nil, // expand @@ -380,7 +392,7 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { return nil, nil } issue = newIssue(j.jiraProject, summary, description) - create, response, err := j.jiraClient.Issue.Create(context.TODO(), issue, nil) + create, response, err := j.jiraClient.Issue.Create(ctx, issue, nil) if err != nil { logError(err, response) return nil, fmt.Errorf("could not create issue %s: %w", summary, err) @@ -393,13 +405,13 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { issueWithTestCase.issue = issue issueWithTestCase.newJIRA = true - closedIssue, err := j.findMostRecentClosedIssue(summary) + closedIssue, err := j.findMostRecentClosedIssue(ctx, summary) if err != nil { logEntry(issue.Key, summary).WithError(err).Warn("Failed to search for closed tickets") } else if closedIssue != nil { logEntry(issue.Key, summary).Infof("Found closed ticket %s, creating link...", closedIssue.Key) if !j.dryRun { - err = j.linkToClosedTicket(issue, closedIssue) + err = j.linkToClosedTicket(ctx, issue, closedIssue) if err != nil { logEntry(issue.Key, summary).WithError(err).Warn("Failed to link to closed ticket") } else { @@ -425,12 +437,18 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { return &issueWithTestCase, nil } - addComment, response, err := j.jiraClient.Issue.Comment.Add(context.TODO(), issue.Key, comment, nil) + addComment, response, err := j.jiraClient.Issue.Comment.Add(ctx, issue.Key, comment, nil) if err != nil { logError(err, response) return nil, fmt.Errorf("could not comment on issue %s: %w", summary, err) } logEntry(issue.Key, summary).Infof("Created comment %s", addComment.ID) + + // Update priority based on comment count if auto-priority is enabled + if err := j.updatePriorityIfNeeded(ctx, issue.Key); err != nil { + logEntry(issue.Key, summary).WithError(err).Warn("Failed to update priority") + } + return &issueWithTestCase, nil } @@ -507,30 +525,37 @@ func findMatchingIssue(search []*models.IssueScheme, summary string) *models.Iss return nil } -func (j junit2jira) findMostRecentClosedIssue(summary string) (*models.IssueScheme, error) { +func (j junit2jira) findMostRecentClosedIssue(ctx context.Context, summary string) (*models.IssueScheme, error) { jqlQuery := fmt.Sprintf(jqlClosedTicketsQuery, j.jiraProject, summary) - search, response, err := j.jiraClient.Issue.Search.SearchJQL( - context.Background(), - jqlQuery, - []string{"summary", "updated"}, // fields - nil, // expand - 1, // maxResults: only need the most recent - "", // nextPageToken - ) - - if err != nil { - if response != nil { - return nil, fmt.Errorf("search closed tickets (HTTP %d): %w", response.Code, err) + const pageSize = 50 + nextPageToken := "" + seenTokens := make(map[string]bool) + for { + search, response, err := j.jiraClient.Issue.Search.SearchJQL( + ctx, jqlQuery, []string{"summary", "updated"}, nil, pageSize, nextPageToken, + ) + if err != nil { + if response != nil { + return nil, fmt.Errorf("search closed tickets (HTTP %d): %w", response.Code, err) + } + return nil, fmt.Errorf("search closed tickets: %w", err) } - return nil, fmt.Errorf("search closed tickets: %w", err) - } - - if search == nil || len(search.Issues) == 0 { - return nil, nil + if search == nil { + return nil, nil + } + if issue := findMatchingIssue(search.Issues, summary); issue != nil { + return issue, nil + } + if search.NextPageToken == "" { + return nil, nil + } + if seenTokens[search.NextPageToken] { + return nil, fmt.Errorf("search closed tickets: repeated continuation token") + } + nextPageToken = search.NextPageToken + seenTokens[nextPageToken] = true } - - return findMatchingIssue(search.Issues, summary), nil } func logError(e error, response *models.ResponseScheme) { @@ -655,16 +680,20 @@ type params struct { BaseLink string BuildLink string - threshold int - dryRun bool - jiraUrl *url.URL - jiraProject string - junitReportsDir string - timestamp string - csvOutput string - htmlOutput string - slackOutput string - summaryOutput string + threshold int + dryRun bool + jiraUrl *url.URL + jiraProject string + junitReportsDir string + timestamp string + csvOutput string + htmlOutput string + slackOutput string + summaryOutput string + enableAutoPriority bool + priorityThresholds string + priorityWindowDays int + parsedPriorityThresholds []int } func newJ2jTestCase(testCase testcase.TestCase, p params) j2jTestCase { diff --git a/cmd/junit2jira/priority.go b/cmd/junit2jira/priority.go new file mode 100644 index 0000000..715490f --- /dev/null +++ b/cmd/junit2jira/priority.go @@ -0,0 +1,84 @@ +package main + +import ( + "strconv" + "strings" +) + +type Priority int + +const ( + Undefined Priority = iota + Minor + Normal + Major + Blocker + Critical +) + +func (p Priority) String() string { + switch p { + case Critical: + return "Critical" + case Blocker: + return "Blocker" + case Major: + return "Major" + case Normal: + return "Normal" + case Minor: + return "Minor" + default: + return "Undefined" + } +} + +func parsePriority(name string) (Priority, bool) { + switch name { + case "Critical": + return Critical, true + case "Blocker": + return Blocker, true + case "Major": + return Major, true + case "Normal": + return Normal, true + case "Minor": + return Minor, true + case "Undefined": + return Undefined, true + default: + return Undefined, false + } +} + +// defaultPriorityThresholds are minimum comment counts within the configured +// activity window for Minor, Normal, Major, Blocker, and Critical, respectively. +// Keep the defaults and count ranges in README.md in sync when changing these. +var defaultPriorityThresholds = []int{2, 10, 50, 100, 200} + +func priorityThresholdsString(thresholds []int) string { + values := make([]string, len(thresholds)) + for i, threshold := range thresholds { + values[i] = strconv.Itoa(threshold) + } + return strings.Join(values, ",") +} + +// priorityForCommentCount maps the selected activity-window count to a priority. +func priorityForCommentCount(commentCount int, thresholds []int) Priority { + switch { + case commentCount >= thresholds[4]: + return Critical + case commentCount >= thresholds[3]: + return Blocker + case commentCount >= thresholds[2]: + return Major + case commentCount >= thresholds[1]: + return Normal + case commentCount >= thresholds[0]: + return Minor + default: + return Undefined + } +} diff --git a/cmd/junit2jira/priority_test.go b/cmd/junit2jira/priority_test.go new file mode 100644 index 0000000..f56fce4 --- /dev/null +++ b/cmd/junit2jira/priority_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "testing" + "time" + + "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" + "github.com/stretchr/testify/require" +) + +func TestPriorityForCommentCount(t *testing.T) { + thresholds := []int{2, 10, 50, 100, 200} + for _, test := range []struct { + count int + expected Priority + }{ + {0, Undefined}, {1, Undefined}, {2, Minor}, {10, Normal}, + {50, Major}, {100, Blocker}, {200, Critical}, {1000, Critical}, + } { + require.Equal(t, test.expected, priorityForCommentCount(test.count, thresholds)) + } +} + +func TestPriorityForCommentCountCustomThresholds(t *testing.T) { + customThresholds, err := parsePriorityThresholds("10,50,100,200,400") + require.NoError(t, err) + for name, test := range map[string]struct { + count int + defaultPriority Priority + customPriority Priority + }{ + "below Minor": {9, Minor, Undefined}, + "Minor boundary": {10, Normal, Minor}, + "same count different priority": {12, Normal, Minor}, + "below Normal": {49, Normal, Minor}, + "Normal boundary": {50, Major, Normal}, + "below Major": {99, Major, Normal}, + "Major boundary": {100, Blocker, Major}, + "below Blocker": {199, Blocker, Major}, + "Blocker boundary": {200, Critical, Blocker}, + "below Critical": {399, Critical, Blocker}, + "Critical boundary": {400, Critical, Critical}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, test.defaultPriority, priorityForCommentCount(test.count, defaultPriorityThresholds)) + require.Equal(t, test.customPriority, priorityForCommentCount(test.count, customThresholds)) + }) + } +} + +func TestCountCommentsInWindow(t *testing.T) { + now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC) + comments := []*models.IssueCommentScheme{ + {Created: "2026-09-11T11:00:00.000+0000"}, + {Created: "2026-08-11T12:00:00.000+0000"}, + {Created: "2026-08-12T12:00:00.000+0000"}, + {Created: "not-a-timestamp"}, + nil, + {Created: "another-invalid-timestamp"}, + } + warnings := 0 + count := countCommentsInWindow(comments, 30, now, func(index int, timestamp string) { + require.Equal(t, 3, index) + require.Equal(t, "not-a-timestamp", timestamp) + warnings++ + }) + require.Equal(t, 1, count) + require.Equal(t, 1, warnings) +} diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go new file mode 100644 index 0000000..447716f --- /dev/null +++ b/cmd/junit2jira/priority_update.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" +) + +// parsePriorityThresholds parses minimum comment counts for Minor, Normal, Major, +// Blocker, and Critical. Strict ordering gives each priority a distinct count range. +func parsePriorityThresholds(thresholdsStr string) ([]int, error) { + parts := strings.Split(thresholdsStr, ",") + if len(parts) != len(defaultPriorityThresholds) { + return nil, fmt.Errorf("expected %d thresholds, got %d", len(defaultPriorityThresholds), len(parts)) + } + + thresholds := make([]int, len(parts)) + for i, part := range parts { + value, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil { + return nil, fmt.Errorf("invalid threshold value %q: %w", part, err) + } + if value < 0 { + return nil, fmt.Errorf("threshold %d must not be negative", i+1) + } + if i > 0 && value <= thresholds[i-1] { + return nil, fmt.Errorf("thresholds must be strictly ascending") + } + thresholds[i] = value + } + return thresholds, nil +} + +func countCommentsInWindow(comments []*models.IssueCommentScheme, windowDays int, now time.Time, warn func(index int, timestamp string)) int { + windowStart := now.AddDate(0, 0, -windowDays) + count := 0 + warned := false + for index, comment := range comments { + if comment == nil || comment.Created == "" { + continue + } + + created, err := parseCommentTime(comment.Created) + if err != nil { + if !warned { + warn(index, comment.Created) + warned = true + } + continue + } + if created.After(windowStart) { + count++ + } + } + return count +} + +func parseCommentTime(value string) (time.Time, error) { + for _, layout := range []string{ + "2006-01-02T15:04:05.000-0700", + "2006-01-02T15:04:05-0700", + time.RFC3339, + } { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("unsupported timestamp format") +} + +func (j junit2jira) updatePriorityIfNeeded(ctx context.Context, issueKey string) error { + if !j.enableAutoPriority { + return nil + } + if j.priorityWindowDays <= 0 { + return fmt.Errorf("priority window must be positive") + } + if len(j.parsedPriorityThresholds) != len(defaultPriorityThresholds) { + return fmt.Errorf("priority thresholds have not been configured") + } + + priority, err := j.fetchIssuePriority(ctx, issueKey) + if err != nil { + return err + } + + now := time.Now() + comments, err := j.fetchIssueComments(ctx, issueKey, now.AddDate(0, 0, -j.priorityWindowDays)) + if err != nil { + return err + } + + windowCount := countCommentsInWindow(comments, j.priorityWindowDays, now, func(index int, timestamp string) { + logEntry(issueKey, "").Warnf("Skipping comment %d with unparseable timestamp %q", index, timestamp) + }) + + return j.escalateIssuePriority(ctx, issueKey, priority, windowCount) +} + +func (j junit2jira) fetchIssuePriority(ctx context.Context, issueKey string) (*models.PriorityScheme, error) { + issue, response, err := j.jiraClient.Issue.Get(ctx, issueKey, []string{"priority"}, nil) + if err != nil { + logError(err, response) + return nil, fmt.Errorf("could not fetch issue %s: %w", issueKey, err) + } + if issue == nil || issue.Fields == nil { + return nil, nil + } + return issue.Fields.Priority, nil +} + +func (j junit2jira) escalateIssuePriority(ctx context.Context, issueKey string, priority *models.PriorityScheme, windowCount int) error { + currentPriority := Undefined + if priority != nil { + var recognized bool + currentPriority, recognized = parsePriority(priority.Name) + if !recognized { + logEntry(issueKey, "").Warnf("Unrecognized priority %q, skipping auto-escalation", priority.Name) + return nil + } + } + + targetPriority := priorityForCommentCount(windowCount, j.parsedPriorityThresholds) + if targetPriority <= currentPriority { + logEntry(issueKey, "").Debugf("Priority %s is already high enough for %d comments in the last %d days", currentPriority, windowCount, j.priorityWindowDays) + return nil + } + + logEntry(issueKey, "").Infof("Auto-escalating priority from %s to %s (%d comments in the last %d days)", currentPriority, targetPriority, windowCount, j.priorityWindowDays) + if j.dryRun { + logEntry(issueKey, "").Debug("Dry run: would update priority") + return nil + } + + return j.setIssuePriority(ctx, issueKey, targetPriority) +} + +func (j junit2jira) fetchIssueComments(ctx context.Context, issueKey string, windowStart time.Time) ([]*models.IssueCommentScheme, error) { + // Match Jira Cloud's documented default comment page size; the server may return fewer comments. + // https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/#api-rest-api-3-issue-issueidorkey-comment-get + const pageSize = 100 + var comments []*models.IssueCommentScheme + // Use the comment endpoint's totals; the issue's embedded count can be stale. + for startAt := 0; ; { + page, response, err := j.jiraClient.Issue.Comment.Gets(ctx, issueKey, "-created", nil, startAt, pageSize) + if err != nil { + logError(err, response) + return nil, fmt.Errorf("could not fetch comments for issue %s: %w", issueKey, err) + } + if page == nil || len(page.Comments) == 0 { + break + } + for _, comment := range page.Comments { + if comment != nil { + created, err := parseCommentTime(comment.Created) + // Newest-first ordering means all subsequent comments are outside the window. + // Unparseable timestamps cannot establish the cutoff; retain them for warnings. + if err == nil && !created.After(windowStart) { + return comments, nil + } + } + comments = append(comments, comment) + } + startAt += len(page.Comments) + if startAt >= page.Total { + break + } + } + + return comments, nil +} + +func (j junit2jira) setIssuePriority(ctx context.Context, issueKey string, priority Priority) error { + payload := &models.IssueScheme{Fields: &models.IssueFieldsScheme{Priority: &models.PriorityScheme{Name: priority.String()}}} + response, err := j.jiraClient.Issue.Update(ctx, issueKey, true, payload, nil, nil) + if err != nil { + logError(err, response) + return fmt.Errorf("could not update priority for issue %s: %w", issueKey, err) + } + logEntry(issueKey, "").Infof("Updated priority to %s", priority) + return nil +} diff --git a/cmd/junit2jira/priority_update_test.go b/cmd/junit2jira/priority_update_test.go new file mode 100644 index 0000000..208608d --- /dev/null +++ b/cmd/junit2jira/priority_update_test.go @@ -0,0 +1,266 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + jira "github.com/ctreminiom/go-atlassian/v2/jira/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePriorityThresholds(t *testing.T) { + tests := []struct { + name string + input string + expected []int + expectError bool + }{ + { + name: "valid default thresholds", + input: priorityThresholdsString(defaultPriorityThresholds), + expected: defaultPriorityThresholds, + }, + { + name: "valid custom thresholds", + input: "10,50,100,200,400", + expected: []int{10, 50, 100, 200, 400}, + }, + { + name: "valid with spaces", + input: " 4 , 16 , 64 , 128 , 256 ", + expected: []int{4, 16, 64, 128, 256}, + }, + { + name: "invalid - too few values", + input: "4,16,64", + expected: nil, + expectError: true, + }, + { + name: "invalid - too many values", + input: "4,16,64,128,256,512", + expected: nil, + expectError: true, + }, + { + name: "invalid - non-numeric value", + input: "4,16,abc,128,256", + expected: nil, + expectError: true, + }, + { + name: "empty string", + input: "", + expected: nil, + expectError: true, + }, + { + name: "negative value", + input: "-1,16,64,128,256", + expected: nil, + expectError: true, + }, + { + name: "duplicate values", + input: "4,16,16,128,256", + expected: nil, + expectError: true, + }, + { + name: "descending values", + input: "256,128,64,16,4", + expected: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parsePriorityThresholds(tt.input) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.expected, result) + }) + } +} + +type priorityRoundTripper func(*http.Request) (*http.Response, error) + +func (f priorityRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestUpdatePriorityIfNeeded(t *testing.T) { + for _, test := range []struct { + name string + initialTotal int + current string + dryRun bool + empty bool + wantUpdate bool + failOn string + wantError string + }{ + {name: "all pages and selected window", initialTotal: 4, current: "Undefined", wantUpdate: true}, + {name: "total grows", initialTotal: 1, current: "Undefined", wantUpdate: true}, + {name: "initial total is stale zero", current: "Undefined", wantUpdate: true}, + {name: "preserve Critical", initialTotal: 4, current: "Critical"}, + {name: "preserve Blocker", initialTotal: 4, current: "Blocker"}, + {name: "unknown priority", initialTotal: 4, current: "Custom"}, + {name: "dry run", initialTotal: 4, current: "Undefined", dryRun: true}, + {name: "no comments", current: "Undefined", empty: true}, + {name: "comment fetch failure", current: "Undefined", failOn: "comments", wantError: "could not fetch comments for issue TEST-1"}, + {name: "priority update failure", current: "Undefined", wantUpdate: true, failOn: "update", wantError: "could not update priority for issue TEST-1"}, + } { + t.Run(test.name, func(t *testing.T) { + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "caller") + var offsets []string + updates := 0 + apiErr := errors.New("Jira request failed") + client, err := jira.New(&http.Client{Transport: priorityRoundTripper(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "caller", req.Context().Value(contextKey{}), "caller context must reach every Jira request") + var body any + switch { + case req.Method == http.MethodGet && req.URL.Path == "/rest/api/3/issue/TEST-1": + body = map[string]any{"fields": map[string]any{"priority": map[string]string{"name": test.current}, "comment": map[string]any{"total": test.initialTotal, "comments": []any{}}}} + case req.Method == http.MethodGet && req.URL.Path == "/rest/api/3/issue/TEST-1/comment": + offset := req.URL.Query().Get("startAt") + offsets = append(offsets, offset) + require.Equal(t, "100", req.URL.Query().Get("maxResults")) + require.Equal(t, "-created", req.URL.Query().Get("orderBy")) + if test.failOn == "comments" && offset == "2" { + return nil, apiErr + } + created := time.Now().AddDate(0, 0, -60).Format(time.RFC3339) + if offset == "0" { + created = time.Now().Add(-time.Hour).Format(time.RFC3339) + } else { + require.Equal(t, "2", offset) + } + body = map[string]any{"total": 4, "comments": []any{map[string]string{"created": created}, map[string]string{"created": created}}} + if test.empty { + body = map[string]any{"total": 0, "comments": []any{}} + } + case req.Method == http.MethodPut && req.URL.Path == "/rest/api/3/issue/TEST-1": + updates++ + if test.failOn == "update" { + return nil, apiErr + } + payload, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.JSONEq(t, `{"fields":{"priority":{"name":"Minor"}}}`, string(payload)) + return &http.Response{Request: req, StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL) + } + data, err := json.Marshal(body) + require.NoError(t, err) + return &http.Response{Request: req, StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(data))), Header: http.Header{"Content-Type": {"application/json"}}}, nil + })}, "https://jira.example/") + require.NoError(t, err) + j := junit2jira{params: params{enableAutoPriority: true, priorityWindowDays: 30, parsedPriorityThresholds: []int{2, 3, 4, 5, 6}, dryRun: test.dryRun}, jiraClient: client} + err = j.updatePriorityIfNeeded(ctx, "TEST-1") + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + require.ErrorIs(t, err, apiErr) + } else { + require.NoError(t, err) + } + if test.empty { + require.Equal(t, []string{"0"}, offsets) + } else { + require.Equal(t, []string{"0", "2"}, offsets) + } + if test.wantUpdate { + require.Equal(t, 1, updates) + } else { + require.Zero(t, updates) + } + }) + } +} + +func TestUpdatePriorityIfNeededCanceledContext(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) { + require.ErrorIs(t, req.Context().Err(), context.Canceled) + return nil, req.Context().Err() + })}, "https://jira.example/") + require.NoError(t, err) + j := junit2jira{params: params{enableAutoPriority: true, priorityWindowDays: 30, parsedPriorityThresholds: defaultPriorityThresholds}, jiraClient: client} + require.ErrorIs(t, j.updatePriorityIfNeeded(ctx, "TEST-1"), context.Canceled) +} + +func TestRunRejectsInvalidPriorityConfiguration(t *testing.T) { + for _, test := range []struct { + name string + window int + thresholds string + wantError string + }{ + {"invalid window", 0, "2,10,50,100,200", "priority window days must be positive"}, + {"invalid thresholds", 30, "2,10,10,100,200", "invalid priority thresholds"}, + } { + t.Run(test.name, func(t *testing.T) { + require.ErrorContains(t, run(context.Background(), params{priorityWindowDays: test.window, priorityThresholds: test.thresholds}), test.wantError) + }) + } +} + +func TestFetchIssueCommentsStopsAtWindow(t *testing.T) { + now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + cutoff := now.AddDate(0, 0, -30) + recent := now.Add(-time.Hour).Format(time.RFC3339) + for name, test := range map[string]struct { + pages [][]string + total int + wantPages int + wantCount int + }{ + "cutoff within first page": {[][]string{{recent, cutoff.Format(time.RFC3339), "older"}}, 1000, 1, 1}, + "old timestamp within first page": {[][]string{{recent, cutoff.Add(-time.Second).Format(time.RFC3339)}}, 1000, 1, 1}, + "recent pages before cutoff": {[][]string{{recent, recent}, {recent, cutoff.Format(time.RFC3339)}}, 1000, 2, 3}, + "malformed and missing timestamps do not stop paging": {[][]string{{"invalid", ""}, {recent, cutoff.Format(time.RFC3339)}}, 1000, 2, 1}, + "endpoint total ends recent history": {[][]string{{recent, recent}}, 2, 1, 2}, + "empty page ends paging": {[][]string{{}}, 1000, 1, 0}, + } { + t.Run(name, func(t *testing.T) { + requests := 0 + offset := 0 + client, err := jira.New(&http.Client{Transport: priorityRoundTripper(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "-created", req.URL.Query().Get("orderBy")) + require.Equal(t, fmt.Sprint(offset), req.URL.Query().Get("startAt")) + require.Less(t, requests, len(test.pages), "must not fetch beyond the window") + timestamps := test.pages[requests] + requests++ + offset += len(timestamps) + comments := make([]map[string]string, 0, len(timestamps)) + for _, timestamp := range timestamps { + comments = append(comments, map[string]string{"created": timestamp}) + } + data, err := json.Marshal(map[string]any{"total": test.total, "comments": comments}) + require.NoError(t, err) + return &http.Response{Request: req, StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(data))), Header: http.Header{"Content-Type": {"application/json"}}}, nil + })}, "https://jira.example/") + require.NoError(t, err) + j := junit2jira{jiraClient: client} + comments, err := j.fetchIssueComments(context.Background(), "TEST-1", cutoff) + require.NoError(t, err) + require.Equal(t, test.wantPages, requests) + require.Equal(t, test.wantCount, countCommentsInWindow(comments, 30, now, func(int, string) {})) + }) + } +}