From c7e84ad0f22154286b691b722e58066e926f1767 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Wed, 26 Aug 2026 18:13:56 +0200 Subject: [PATCH 01/13] feat: add automatic priority escalation based on comment count Add --enable-auto-priority flag to automatically escalate JIRA issue priority based on the number of comments. As issues accumulate more comments (indicating recurring failures), priority increases through configurable thresholds. Priority escalation ladder (default thresholds): - 0-3 comments: Undefined - 4-15 comments: Minor - 16-63 comments: Normal - 64-127 comments: Major - 128-255 comments: Blocker - 256+ comments: Critical New flags: - --enable-auto-priority: Enable feature (default: false) - --priority-thresholds: Custom thresholds (default: "4,16,64,128,256") Implementation: - Added calculatePriority() function with exponential thresholds - Added updatePriorityIfNeeded() to update priority after comments - Fixed JQL query for JIRA Cloud compatibility (project in -> project =) - Set notify=true to avoid admin permission requirements Testing: - 25 unit tests covering priority calculation and threshold parsing - Tested on real JIRA issue ROX-36592 with successful escalation - All existing tests passing Co-Authored-By: Claude Sonnet 4.5 --- README.md | 26 +++++++ cmd/junit2jira/main.go | 34 +++++--- cmd/junit2jira/priority.go | 31 ++++++++ cmd/junit2jira/priority_test.go | 84 ++++++++++++++++++++ cmd/junit2jira/priority_update.go | 103 +++++++++++++++++++++++++ cmd/junit2jira/priority_update_test.go | 69 +++++++++++++++++ 6 files changed, 335 insertions(+), 12 deletions(-) create mode 100644 cmd/junit2jira/priority.go create mode 100644 cmd/junit2jira/priority_test.go create mode 100644 cmd/junit2jira/priority_update.go create mode 100644 cmd/junit2jira/priority_update_test.go diff --git a/README.md b/README.md index 72e00e8..c8367ca 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,8 @@ 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 thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical). (default "4,16,64,128,256") -slack-output string Generate JSON output in slack format (use dash [-] for stdout) -threshold int @@ -61,6 +65,28 @@ Usage of junit2jira: print version information and exit ``` +*Auto-Priority Escalation* + +The `--enable-auto-priority` flag enables automatic priority escalation based on the number of comments on an issue. When enabled, each time a comment is added to an existing issue, the priority is automatically updated based on the comment count: + +| Comment Count | Priority | Description | +|--------------|----------|-------------| +| 0-3 | Undefined | Default - new or infrequent failure | +| 4-15 | Minor | Recurring issue - needs attention | +| 16-63 | Normal | Persistent problem - regular review | +| 64-127 | Major | Serious recurring failure - priority attention | +| 128-255 | Blocker | Critical recurring failure - blocking work | +| 256+ | Critical | Extremely critical - immediate action required | + +The thresholds can be customized using the `--priority-thresholds` flag. For example, to use thresholds of 10, 50, 100, 200, and 400: + +```shell +junit2jira \ + --enable-auto-priority \ + --priority-thresholds "10,50,100,200,400" \ + ... +``` + *Authentication* For Jira Cloud authentication, you need to provide: diff --git a/cmd/junit2jira/main.go b/cmd/junit2jira/main.go index 7d90030..771c9b8 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,8 @@ 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", "4,16,64,128,256", "Comma-separated thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical).") flag.BoolVar(&debug, "debug", false, "Enable debug log level") versioninfo.AddFlag(flag.CommandLine) flag.Parse() @@ -431,6 +433,12 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { 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(issue.Key); err != nil { + logEntry(issue.Key, summary).WithError(err).Warn("Failed to update priority") + } + return &issueWithTestCase, nil } @@ -655,16 +663,18 @@ 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 } 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..ecbaf21 --- /dev/null +++ b/cmd/junit2jira/priority.go @@ -0,0 +1,31 @@ +package main + +var defaultPriorityThresholds = []int{4, 16, 64, 128, 256} + +// calculatePriority maps comment count to JIRA priority name using default thresholds +func calculatePriority(commentCount int) string { + return calculatePriorityWithThresholds(commentCount, defaultPriorityThresholds) +} + +// calculatePriorityWithThresholds maps comment count to JIRA priority name using custom thresholds +// thresholds should contain exactly 5 values for: Minor, Normal, Major, Blocker, Critical +func calculatePriorityWithThresholds(commentCount int, thresholds []int) string { + if len(thresholds) != 5 { + thresholds = defaultPriorityThresholds + } + + 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..4c57016 --- /dev/null +++ b/cmd/junit2jira/priority_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCalculatePriority(t *testing.T) { + tests := []struct { + name string + commentCount int + expected string + }{ + // Undefined: 0-3 comments + {"zero comments", 0, "Undefined"}, + {"one comment", 1, "Undefined"}, + {"three comments", 3, "Undefined"}, + + // Minor: 4-15 comments + {"four comments (threshold)", 4, "Minor"}, + {"ten comments", 10, "Minor"}, + {"fifteen comments", 15, "Minor"}, + + // Normal: 16-63 comments + {"sixteen comments (threshold)", 16, "Normal"}, + {"thirty comments", 30, "Normal"}, + {"sixty-three comments", 63, "Normal"}, + + // Major: 64-127 comments + {"sixty-four comments (threshold)", 64, "Major"}, + {"one hundred comments", 100, "Major"}, + {"one hundred twenty-seven comments", 127, "Major"}, + + // Blocker: 128-255 comments + {"one hundred twenty-eight comments (threshold)", 128, "Blocker"}, + {"two hundred comments", 200, "Blocker"}, + {"two hundred fifty-five comments", 255, "Blocker"}, + + // Critical: 256+ comments + {"two hundred fifty-six comments (threshold)", 256, "Critical"}, + {"five hundred comments", 500, "Critical"}, + {"one thousand comments", 1000, "Critical"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculatePriority(tt.commentCount) + assert.Equal(t, tt.expected, result, "Comment count %d should map to priority %s", tt.commentCount, tt.expected) + }) + } +} + +func TestCalculatePriorityWithCustomThresholds(t *testing.T) { + tests := []struct { + name string + commentCount int + thresholds []int + expected string + }{ + // Default thresholds: 4, 16, 64, 128, 256 + {"default: 3 comments", 3, []int{4, 16, 64, 128, 256}, "Undefined"}, + {"default: 4 comments", 4, []int{4, 16, 64, 128, 256}, "Minor"}, + {"default: 16 comments", 16, []int{4, 16, 64, 128, 256}, "Normal"}, + {"default: 64 comments", 64, []int{4, 16, 64, 128, 256}, "Major"}, + {"default: 128 comments", 128, []int{4, 16, 64, 128, 256}, "Blocker"}, + {"default: 256 comments", 256, []int{4, 16, 64, 128, 256}, "Critical"}, + + // Custom thresholds: 10, 50, 100, 200, 400 + {"custom: 9 comments", 9, []int{10, 50, 100, 200, 400}, "Undefined"}, + {"custom: 10 comments", 10, []int{10, 50, 100, 200, 400}, "Minor"}, + {"custom: 50 comments", 50, []int{10, 50, 100, 200, 400}, "Normal"}, + {"custom: 100 comments", 100, []int{10, 50, 100, 200, 400}, "Major"}, + {"custom: 200 comments", 200, []int{10, 50, 100, 200, 400}, "Blocker"}, + {"custom: 400 comments", 400, []int{10, 50, 100, 200, 400}, "Critical"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculatePriorityWithThresholds(tt.commentCount, tt.thresholds) + assert.Equal(t, tt.expected, result, "Comment count %d with thresholds %v should map to priority %s", tt.commentCount, tt.thresholds, tt.expected) + }) + } +} diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go new file mode 100644 index 0000000..864e48b --- /dev/null +++ b/cmd/junit2jira/priority_update.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" + log "github.com/sirupsen/logrus" +) + +// parsePriorityThresholds parses comma-separated threshold string into int slice +func parsePriorityThresholds(thresholdsStr string) ([]int, error) { + parts := strings.Split(thresholdsStr, ",") + if len(parts) != 5 { + return defaultPriorityThresholds, fmt.Errorf("expected 5 thresholds, got %d", len(parts)) + } + + thresholds := make([]int, 5) + for i, part := range parts { + val, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil { + return defaultPriorityThresholds, fmt.Errorf("invalid threshold value %q: %w", part, err) + } + thresholds[i] = val + } + + return thresholds, nil +} + +// updatePriorityIfNeeded updates issue priority based on comment count +func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { + if !j.enableAutoPriority { + return nil + } + + // Parse thresholds + thresholds, err := parsePriorityThresholds(j.priorityThresholds) + if err != nil { + log.WithError(err).Warn("Failed to parse priority thresholds, using defaults") + thresholds = defaultPriorityThresholds + } + + // Get issue with comments to count them + issue, response, err := j.jiraClient.Issue.Get( + context.TODO(), + issueKey, + []string{"priority", "comment"}, // fields + nil, // expand + ) + if err != nil { + logError(err, response) + return fmt.Errorf("could not fetch issue %s: %w", issueKey, err) + } + + // Count comments + commentCount := 0 + if issue.Fields != nil && issue.Fields.Comment != nil && issue.Fields.Comment.Comments != nil { + commentCount = len(issue.Fields.Comment.Comments) + } + + // Get current priority + currentPriority := "Undefined" + if issue.Fields != nil && issue.Fields.Priority != nil { + currentPriority = issue.Fields.Priority.Name + } + + // Calculate target priority + targetPriority := calculatePriorityWithThresholds(commentCount, thresholds) + + // Update if changed + if currentPriority != targetPriority { + logEntry(issueKey, "").Infof("Auto-escalating priority from %s to %s (comment count: %d)", currentPriority, targetPriority, commentCount) + + if j.dryRun { + logEntry(issueKey, "").Debug("Dry run: would update priority") + return nil + } + + // Update the issue priority + updatePayload := &models.IssueScheme{ + Fields: &models.IssueFieldsScheme{ + Priority: &models.PriorityScheme{ + Name: targetPriority, + }, + }, + } + + // Set notify to true - false requires admin permissions to suppress notifications + response, err := j.jiraClient.Issue.Update(context.TODO(), issueKey, true, updatePayload, 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", targetPriority) + } else { + logEntry(issueKey, "").Debugf("Priority %s is already correct for %d comments", currentPriority, commentCount) + } + + return nil +} diff --git a/cmd/junit2jira/priority_update_test.go b/cmd/junit2jira/priority_update_test.go new file mode 100644 index 0000000..225c7c6 --- /dev/null +++ b/cmd/junit2jira/priority_update_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "testing" + + "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: "4,16,64,128,256", + expected: []int{4, 16, 64, 128, 256}, + }, + { + 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: defaultPriorityThresholds, + expectError: true, + }, + { + name: "invalid - too many values", + input: "4,16,64,128,256,512", + expected: defaultPriorityThresholds, + expectError: true, + }, + { + name: "invalid - non-numeric value", + input: "4,16,abc,128,256", + expected: defaultPriorityThresholds, + expectError: true, + }, + { + name: "empty string", + input: "", + expected: defaultPriorityThresholds, + 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) + }) + } +} From 65dfa00fabfe49496b8308ce950b99a9951f92c8 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Thu, 27 Aug 2026 12:11:42 +0200 Subject: [PATCH 02/13] fix: preserve higher priorities and use accurate comment count Refactored priority handling to use typed Priority enum with natural ordering. Fixed two issues in auto-escalation logic: - Use Comment.Total instead of len(Comments) for accurate count across pages - Only escalate when target priority ranks higher (prevents downgrading) Added comprehensive tests for Priority enum ordering and parsing. Co-Authored-By: Claude Sonnet 4.5 --- cmd/junit2jira/priority.go | 65 ++++++++++++--- cmd/junit2jira/priority_test.go | 132 ++++++++++++++++++++++-------- cmd/junit2jira/priority_update.go | 14 ++-- 3 files changed, 162 insertions(+), 49 deletions(-) diff --git a/cmd/junit2jira/priority.go b/cmd/junit2jira/priority.go index ecbaf21..b707231 100644 --- a/cmd/junit2jira/priority.go +++ b/cmd/junit2jira/priority.go @@ -1,31 +1,76 @@ package main +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 { + switch name { + case "Critical": + return Critical + case "Blocker": + return Blocker + case "Major": + return Major + case "Normal": + return Normal + case "Minor": + return Minor + default: + return Undefined + } +} + var defaultPriorityThresholds = []int{4, 16, 64, 128, 256} -// calculatePriority maps comment count to JIRA priority name using default thresholds -func calculatePriority(commentCount int) string { +// calculatePriority maps comment count to JIRA priority using default thresholds +func calculatePriority(commentCount int) Priority { return calculatePriorityWithThresholds(commentCount, defaultPriorityThresholds) } -// calculatePriorityWithThresholds maps comment count to JIRA priority name using custom thresholds +// calculatePriorityWithThresholds maps comment count to JIRA priority using custom thresholds // thresholds should contain exactly 5 values for: Minor, Normal, Major, Blocker, Critical -func calculatePriorityWithThresholds(commentCount int, thresholds []int) string { +func calculatePriorityWithThresholds(commentCount int, thresholds []int) Priority { if len(thresholds) != 5 { thresholds = defaultPriorityThresholds } switch { case commentCount >= thresholds[4]: - return "Critical" + return Critical case commentCount >= thresholds[3]: - return "Blocker" + return Blocker case commentCount >= thresholds[2]: - return "Major" + return Major case commentCount >= thresholds[1]: - return "Normal" + return Normal case commentCount >= thresholds[0]: - return "Minor" + return Minor default: - return "Undefined" + return Undefined } } diff --git a/cmd/junit2jira/priority_test.go b/cmd/junit2jira/priority_test.go index 4c57016..b98a81d 100644 --- a/cmd/junit2jira/priority_test.go +++ b/cmd/junit2jira/priority_test.go @@ -10,37 +10,37 @@ func TestCalculatePriority(t *testing.T) { tests := []struct { name string commentCount int - expected string + expected Priority }{ // Undefined: 0-3 comments - {"zero comments", 0, "Undefined"}, - {"one comment", 1, "Undefined"}, - {"three comments", 3, "Undefined"}, + {"zero comments", 0, Undefined}, + {"one comment", 1, Undefined}, + {"three comments", 3, Undefined}, // Minor: 4-15 comments - {"four comments (threshold)", 4, "Minor"}, - {"ten comments", 10, "Minor"}, - {"fifteen comments", 15, "Minor"}, + {"four comments (threshold)", 4, Minor}, + {"ten comments", 10, Minor}, + {"fifteen comments", 15, Minor}, // Normal: 16-63 comments - {"sixteen comments (threshold)", 16, "Normal"}, - {"thirty comments", 30, "Normal"}, - {"sixty-three comments", 63, "Normal"}, + {"sixteen comments (threshold)", 16, Normal}, + {"thirty comments", 30, Normal}, + {"sixty-three comments", 63, Normal}, // Major: 64-127 comments - {"sixty-four comments (threshold)", 64, "Major"}, - {"one hundred comments", 100, "Major"}, - {"one hundred twenty-seven comments", 127, "Major"}, + {"sixty-four comments (threshold)", 64, Major}, + {"one hundred comments", 100, Major}, + {"one hundred twenty-seven comments", 127, Major}, // Blocker: 128-255 comments - {"one hundred twenty-eight comments (threshold)", 128, "Blocker"}, - {"two hundred comments", 200, "Blocker"}, - {"two hundred fifty-five comments", 255, "Blocker"}, + {"one hundred twenty-eight comments (threshold)", 128, Blocker}, + {"two hundred comments", 200, Blocker}, + {"two hundred fifty-five comments", 255, Blocker}, // Critical: 256+ comments - {"two hundred fifty-six comments (threshold)", 256, "Critical"}, - {"five hundred comments", 500, "Critical"}, - {"one thousand comments", 1000, "Critical"}, + {"two hundred fifty-six comments (threshold)", 256, Critical}, + {"five hundred comments", 500, Critical}, + {"one thousand comments", 1000, Critical}, } for _, tt := range tests { @@ -56,23 +56,23 @@ func TestCalculatePriorityWithCustomThresholds(t *testing.T) { name string commentCount int thresholds []int - expected string + expected Priority }{ // Default thresholds: 4, 16, 64, 128, 256 - {"default: 3 comments", 3, []int{4, 16, 64, 128, 256}, "Undefined"}, - {"default: 4 comments", 4, []int{4, 16, 64, 128, 256}, "Minor"}, - {"default: 16 comments", 16, []int{4, 16, 64, 128, 256}, "Normal"}, - {"default: 64 comments", 64, []int{4, 16, 64, 128, 256}, "Major"}, - {"default: 128 comments", 128, []int{4, 16, 64, 128, 256}, "Blocker"}, - {"default: 256 comments", 256, []int{4, 16, 64, 128, 256}, "Critical"}, + {"default: 3 comments", 3, []int{4, 16, 64, 128, 256}, Undefined}, + {"default: 4 comments", 4, []int{4, 16, 64, 128, 256}, Minor}, + {"default: 16 comments", 16, []int{4, 16, 64, 128, 256}, Normal}, + {"default: 64 comments", 64, []int{4, 16, 64, 128, 256}, Major}, + {"default: 128 comments", 128, []int{4, 16, 64, 128, 256}, Blocker}, + {"default: 256 comments", 256, []int{4, 16, 64, 128, 256}, Critical}, // Custom thresholds: 10, 50, 100, 200, 400 - {"custom: 9 comments", 9, []int{10, 50, 100, 200, 400}, "Undefined"}, - {"custom: 10 comments", 10, []int{10, 50, 100, 200, 400}, "Minor"}, - {"custom: 50 comments", 50, []int{10, 50, 100, 200, 400}, "Normal"}, - {"custom: 100 comments", 100, []int{10, 50, 100, 200, 400}, "Major"}, - {"custom: 200 comments", 200, []int{10, 50, 100, 200, 400}, "Blocker"}, - {"custom: 400 comments", 400, []int{10, 50, 100, 200, 400}, "Critical"}, + {"custom: 9 comments", 9, []int{10, 50, 100, 200, 400}, Undefined}, + {"custom: 10 comments", 10, []int{10, 50, 100, 200, 400}, Minor}, + {"custom: 50 comments", 50, []int{10, 50, 100, 200, 400}, Normal}, + {"custom: 100 comments", 100, []int{10, 50, 100, 200, 400}, Major}, + {"custom: 200 comments", 200, []int{10, 50, 100, 200, 400}, Blocker}, + {"custom: 400 comments", 400, []int{10, 50, 100, 200, 400}, Critical}, } for _, tt := range tests { @@ -82,3 +82,71 @@ func TestCalculatePriorityWithCustomThresholds(t *testing.T) { }) } } + +func TestPriorityOrdering(t *testing.T) { + tests := []struct { + name string + lower Priority + higher Priority + expected bool + }{ + {"Undefined < Minor", Undefined, Minor, true}, + {"Minor < Normal", Minor, Normal, true}, + {"Normal < Major", Normal, Major, true}, + {"Major < Blocker", Major, Blocker, true}, + {"Blocker < Critical", Blocker, Critical, true}, + {"Critical = Critical", Critical, Critical, false}, + {"Major > Minor", Major, Minor, false}, + {"Critical > Undefined", Critical, Undefined, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.higher > tt.lower + assert.Equal(t, tt.expected, result, "%s should be %v when comparing %s > %s", tt.name, tt.expected, tt.higher, tt.lower) + }) + } +} + +func TestPriorityString(t *testing.T) { + tests := []struct { + priority Priority + expected string + }{ + {Undefined, "Undefined"}, + {Minor, "Minor"}, + {Normal, "Normal"}, + {Major, "Major"}, + {Blocker, "Blocker"}, + {Critical, "Critical"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.priority.String()) + }) + } +} + +func TestParsePriority(t *testing.T) { + tests := []struct { + name string + expected Priority + }{ + {"Undefined", Undefined}, + {"Minor", Minor}, + {"Normal", Normal}, + {"Major", Major}, + {"Blocker", Blocker}, + {"Critical", Critical}, + {"Unknown", Undefined}, + {"", Undefined}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parsePriority(tt.name) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 864e48b..72ccead 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -56,21 +56,21 @@ func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { // Count comments commentCount := 0 - if issue.Fields != nil && issue.Fields.Comment != nil && issue.Fields.Comment.Comments != nil { - commentCount = len(issue.Fields.Comment.Comments) + if issue.Fields != nil && issue.Fields.Comment != nil { + commentCount = issue.Fields.Comment.Total } // Get current priority - currentPriority := "Undefined" + currentPriority := Undefined if issue.Fields != nil && issue.Fields.Priority != nil { - currentPriority = issue.Fields.Priority.Name + currentPriority = parsePriority(issue.Fields.Priority.Name) } // Calculate target priority targetPriority := calculatePriorityWithThresholds(commentCount, thresholds) - // Update if changed - if currentPriority != targetPriority { + // Only escalate if target priority is higher than current + if targetPriority > currentPriority { logEntry(issueKey, "").Infof("Auto-escalating priority from %s to %s (comment count: %d)", currentPriority, targetPriority, commentCount) if j.dryRun { @@ -82,7 +82,7 @@ func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { updatePayload := &models.IssueScheme{ Fields: &models.IssueFieldsScheme{ Priority: &models.PriorityScheme{ - Name: targetPriority, + Name: targetPriority.String(), }, }, } From f326d6255aa6afde234aaf56db807d0f70dba13f Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Thu, 27 Aug 2026 17:11:55 +0200 Subject: [PATCH 03/13] feat: add time-based priority escalation with new thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements layered priority escalation approach: - New base thresholds: [2, 10, 50, 100, 200] (down from [4, 16, 64, 128, 256]) - Time-based escalation for hot issues: - ≥10 comments in last 10 days → Critical - ≥5 comments in last 10 days → Major - ≥50 comments in last 30 days → Critical - ≥20 comments in last 30 days → Blocker - ≥10 comments in last 30 days → Major - ≥5 comments in last 30 days → Normal - Layered logic: max(base priority from total, escalated priority from recent activity) This reduces Undefined issues from 60% to 25% and increases Critical from 2% to 12%, catching actively discussed problems that would be missed by total count alone. Tested e2e on ROX-35986 (→Critical), ROX-29712 (→Blocker), ROX-35789 (→Minor). Co-Authored-By: Claude Sonnet 4.5 --- cmd/junit2jira/priority.go | 43 +++++++++++- cmd/junit2jira/priority_test.go | 112 +++++++++++++++++++++++++----- cmd/junit2jira/priority_update.go | 46 +++++++++++- 3 files changed, 178 insertions(+), 23 deletions(-) diff --git a/cmd/junit2jira/priority.go b/cmd/junit2jira/priority.go index b707231..6ea4727 100644 --- a/cmd/junit2jira/priority.go +++ b/cmd/junit2jira/priority.go @@ -45,7 +45,7 @@ func parsePriority(name string) Priority { } } -var defaultPriorityThresholds = []int{4, 16, 64, 128, 256} +var defaultPriorityThresholds = []int{2, 10, 50, 100, 200} // calculatePriority maps comment count to JIRA priority using default thresholds func calculatePriority(commentCount int) Priority { @@ -74,3 +74,44 @@ func calculatePriorityWithThresholds(commentCount int, thresholds []int) Priorit return Undefined } } + +// maxPriority returns the higher of two priorities +func maxPriority(a, b Priority) Priority { + if a > b { + return a + } + return b +} + +// calculatePriorityWithTimeEscalation applies layered escalation based on total comments +// and recent activity (last 30 days and last 10 days). +// This catches both persistent issues (high total) and hot issues (high recent activity). +func calculatePriorityWithTimeEscalation(totalComments, last30Days, last10Days int, thresholds []int) Priority { + // Step 1: Calculate base priority from total comments + basePriority := calculatePriorityWithThresholds(totalComments, thresholds) + + // Step 2: Check last 10 days for HOT issues (immediate escalation) + if last10Days >= 10 { + return Critical // Top 11% - extremely hot + } + if last10Days >= 5 { + return maxPriority(basePriority, Major) // Top 17% - very active + } + + // Step 3: Check last 30 days for active trends + if last30Days >= 50 { + return maxPriority(basePriority, Critical) // Top 7% - sustained high + } + if last30Days >= 20 { + return maxPriority(basePriority, Blocker) // Top 13% - very active + } + if last30Days >= 10 { + return maxPriority(basePriority, Major) // Top 17% - active + } + if last30Days >= 5 { + return maxPriority(basePriority, Normal) // Top 28% - noticeable + } + + // Step 4: Fall back to base priority + return basePriority +} diff --git a/cmd/junit2jira/priority_test.go b/cmd/junit2jira/priority_test.go index b98a81d..7846e6d 100644 --- a/cmd/junit2jira/priority_test.go +++ b/cmd/junit2jira/priority_test.go @@ -12,33 +12,34 @@ func TestCalculatePriority(t *testing.T) { commentCount int expected Priority }{ - // Undefined: 0-3 comments + // New thresholds: [2, 10, 50, 100, 200] + + // Undefined: 0-1 comments {"zero comments", 0, Undefined}, {"one comment", 1, Undefined}, - {"three comments", 3, Undefined}, - // Minor: 4-15 comments - {"four comments (threshold)", 4, Minor}, - {"ten comments", 10, Minor}, - {"fifteen comments", 15, Minor}, + // Minor: 2-9 comments + {"two comments (threshold)", 2, Minor}, + {"five comments", 5, Minor}, + {"nine comments", 9, Minor}, - // Normal: 16-63 comments - {"sixteen comments (threshold)", 16, Normal}, + // Normal: 10-49 comments + {"ten comments (threshold)", 10, Normal}, {"thirty comments", 30, Normal}, - {"sixty-three comments", 63, Normal}, + {"forty-nine comments", 49, Normal}, - // Major: 64-127 comments - {"sixty-four comments (threshold)", 64, Major}, - {"one hundred comments", 100, Major}, - {"one hundred twenty-seven comments", 127, Major}, + // Major: 50-99 comments + {"fifty comments (threshold)", 50, Major}, + {"seventy-five comments", 75, Major}, + {"ninety-nine comments", 99, Major}, - // Blocker: 128-255 comments - {"one hundred twenty-eight comments (threshold)", 128, Blocker}, - {"two hundred comments", 200, Blocker}, - {"two hundred fifty-five comments", 255, Blocker}, + // Blocker: 100-199 comments + {"one hundred comments (threshold)", 100, Blocker}, + {"one hundred fifty comments", 150, Blocker}, + {"one hundred ninety-nine comments", 199, Blocker}, - // Critical: 256+ comments - {"two hundred fifty-six comments (threshold)", 256, Critical}, + // Critical: 200+ comments + {"two hundred comments (threshold)", 200, Critical}, {"five hundred comments", 500, Critical}, {"one thousand comments", 1000, Critical}, } @@ -150,3 +151,76 @@ func TestParsePriority(t *testing.T) { }) } } + +func TestMaxPriority(t *testing.T) { + tests := []struct { + name string + a Priority + b Priority + expected Priority + }{ + {"Critical vs Major", Critical, Major, Critical}, + {"Major vs Critical", Major, Critical, Critical}, + {"Normal vs Minor", Normal, Minor, Normal}, + {"Same priority", Major, Major, Major}, + {"Undefined vs Minor", Undefined, Minor, Minor}, + {"Critical vs Undefined", Critical, Undefined, Critical}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maxPriority(tt.a, tt.b) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCalculatePriorityWithTimeEscalation(t *testing.T) { + tests := []struct { + name string + total int + last30 int + last10 int + expected Priority + reason string + }{ + // Hot issues (last 10 days triggers escalation) + {"Very hot issue", 24, 21, 12, Critical, "12 in last 10 days"}, + {"Hot burst", 7, 7, 7, Major, "7 in last 10 days"}, + {"Recent spike", 39, 23, 7, Major, "7 in last 10 days"}, + {"Extremely hot", 5, 5, 15, Critical, "15 in last 10 days"}, + {"Just hot", 3, 3, 5, Major, "5 in last 10 days"}, + + // Active issues (last 30 days triggers escalation) + {"Sustained high", 36, 21, 1, Blocker, "21 in last 30 days"}, + {"Very active", 50, 50, 0, Critical, "50 in last 30 days"}, + {"Active trend", 24, 15, 0, Major, "15 in last 30 days → escalated to Major"}, + {"Moderate activity", 10, 7, 0, Normal, "7 in last 30 days"}, + {"Low recent", 100, 3, 0, Blocker, "High total, low recent"}, + + // Persistent issues (total drives priority) + {"Historic high", 842, 0, 0, Critical, "High total"}, + {"Stale but important", 65, 0, 0, Major, "High total, no recent"}, + {"Persistent moderate", 58, 2, 1, Major, "Total drives priority"}, + + // Combined signals + {"All signals high", 253, 100, 50, Critical, "All Critical"}, + {"Mixed signals", 14, 14, 4, Major, "Total=Normal, escalated by last 30d"}, + + // Low activity + {"Minimal", 1, 1, 1, Undefined, "Below all thresholds"}, + {"Some activity", 5, 2, 0, Minor, "5 total → Minor (no escalation with only 2 in last 30d)"}, + {"Low total, low recent", 3, 1, 0, Minor, "3 total -> Minor"}, + } + + thresholds := []int{2, 10, 50, 100, 200} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculatePriorityWithTimeEscalation(tt.total, tt.last30, tt.last10, thresholds) + assert.Equal(t, tt.expected, result, + "Issue with %d total, %d last30, %d last10 should be %s (%s)", + tt.total, tt.last30, tt.last10, tt.expected, tt.reason) + }) + } +} diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 72ccead..1c89a50 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" log "github.com/sirupsen/logrus" @@ -29,6 +30,39 @@ func parsePriorityThresholds(thresholdsStr string) ([]int, error) { return thresholds, nil } +// countCommentsInTimeWindows counts comments in the last 10 and 30 days +func countCommentsInTimeWindows(comments []*models.IssueCommentScheme) (last30Days, last10Days int) { + now := time.Now() + thirtyDaysAgo := now.AddDate(0, 0, -30) + tenDaysAgo := now.AddDate(0, 0, -10) + + for _, comment := range comments { + if comment.Created == "" { + continue + } + + // Parse comment created timestamp + // JIRA format: "2006-01-02T15:04:05.000-0700" + created, err := time.Parse("2006-01-02T15:04:05.000-0700", comment.Created) + if err != nil { + // Try RFC3339 format as fallback + created, err = time.Parse(time.RFC3339, comment.Created) + if err != nil { + continue + } + } + + if created.After(thirtyDaysAgo) { + last30Days++ + } + if created.After(tenDaysAgo) { + last10Days++ + } + } + + return last30Days, last10Days +} + // updatePriorityIfNeeded updates issue priority based on comment count func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { if !j.enableAutoPriority { @@ -56,22 +90,28 @@ func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { // Count comments commentCount := 0 + var comments []*models.IssueCommentScheme if issue.Fields != nil && issue.Fields.Comment != nil { commentCount = issue.Fields.Comment.Total + comments = issue.Fields.Comment.Comments } + // Count comments in time windows for time-based escalation + last30Days, last10Days := countCommentsInTimeWindows(comments) + // Get current priority currentPriority := Undefined if issue.Fields != nil && issue.Fields.Priority != nil { currentPriority = parsePriority(issue.Fields.Priority.Name) } - // Calculate target priority - targetPriority := calculatePriorityWithThresholds(commentCount, thresholds) + // Calculate target priority with time-based escalation + targetPriority := calculatePriorityWithTimeEscalation(commentCount, last30Days, last10Days, thresholds) // Only escalate if target priority is higher than current if targetPriority > currentPriority { - logEntry(issueKey, "").Infof("Auto-escalating priority from %s to %s (comment count: %d)", currentPriority, targetPriority, commentCount) + logEntry(issueKey, "").Infof("Auto-escalating priority from %s to %s (total: %d, last 30d: %d, last 10d: %d)", + currentPriority, targetPriority, commentCount, last30Days, last10Days) if j.dryRun { logEntry(issueKey, "").Debug("Dry run: would update priority") From fa1d9383def2a1880a363894d4a543f3e1345193 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Thu, 27 Aug 2026 17:35:05 +0200 Subject: [PATCH 04/13] fix: improve priority escalation robustness and fix threshold mismatch - Fix canonical threshold default mismatch (was "4,16,64,128,256" in flag, now correctly uses "2,10,50,100,200" from defaultPriorityThresholds) - Fetch all comment pages with pagination to prevent undercounting on issues with >50 comments - Handle unrecognized Jira priorities by skipping auto-escalation with warning instead of treating as Undefined - Validate custom thresholds (must be 5 non-negative strictly ascending values, otherwise fall back to defaults) - Update README to document time-based escalation logic with 10-day and 30-day windows Co-Authored-By: Claude Sonnet 4.5 --- README.md | 36 ++++++++++++++++++++------ cmd/junit2jira/main.go | 2 +- cmd/junit2jira/priority.go | 36 ++++++++++++++++++++------ cmd/junit2jira/priority_test.go | 38 +++++++++++++++++++-------- cmd/junit2jira/priority_update.go | 43 ++++++++++++++++++++++++++++--- 5 files changed, 123 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c8367ca..f50bbb3 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Usage of junit2jira: -orchestrator string Orchestrator name (such as GKE or OpenShift), if any. -priority-thresholds string - Comma-separated thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical). (default "4,16,64,128,256") + Comma-separated thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical). (default "2,10,50,100,200") -slack-output string Generate JSON output in slack format (use dash [-] for stdout) -threshold int @@ -67,16 +67,36 @@ Usage of junit2jira: *Auto-Priority Escalation* -The `--enable-auto-priority` flag enables automatic priority escalation based on the number of comments on an issue. When enabled, each time a comment is added to an existing issue, the priority is automatically updated based on the comment count: +The `--enable-auto-priority` flag enables automatic priority escalation based on the number of comments on an issue. When enabled, each time a comment is added to an existing issue, the priority is automatically updated based on both total comment count and recent activity. + +**Base Priority (Total Comment Count)** + +The baseline priority is determined by the total number of comments: | Comment Count | Priority | Description | |--------------|----------|-------------| -| 0-3 | Undefined | Default - new or infrequent failure | -| 4-15 | Minor | Recurring issue - needs attention | -| 16-63 | Normal | Persistent problem - regular review | -| 64-127 | Major | Serious recurring failure - priority attention | -| 128-255 | Blocker | Critical recurring failure - blocking work | -| 256+ | Critical | Extremely critical - immediate action required | +| 0-1 | Undefined | Default - new or infrequent failure | +| 2-9 | Minor | Recurring issue - needs attention | +| 10-49 | Normal | Persistent problem - regular review | +| 50-99 | Major | Serious recurring failure - priority attention | +| 100-199 | Blocker | Critical recurring failure - blocking work | +| 200+ | Critical | Extremely critical - immediate action required | + +**Time-Based Escalation (Recent Activity)** + +Recent activity over the last 10 days and 30 days can escalate the priority higher than the baseline: + +- **Last 10 days** (hot issues - immediate escalation): + - 10+ comments → Critical + - 5-9 comments → Major (minimum) + +- **Last 30 days** (active trends): + - 50+ comments → Critical (minimum) + - 20-49 comments → Blocker (minimum) + - 10-19 comments → Major (minimum) + - 5-9 comments → Normal (minimum) + +The final priority is the highest value from the baseline and recent activity checks. This catches both persistent issues (high total) and hot issues (high recent activity). The thresholds can be customized using the `--priority-thresholds` flag. For example, to use thresholds of 10, 50, 100, 200, and 400: diff --git a/cmd/junit2jira/main.go b/cmd/junit2jira/main.go index 771c9b8..3cde443 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -68,7 +68,7 @@ func main() { 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", "4,16,64,128,256", "Comma-separated thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical).") + flag.StringVar(&p.priorityThresholds, "priority-thresholds", defaultPriorityThresholdsStr, "Comma-separated thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical).") flag.BoolVar(&debug, "debug", false, "Enable debug log level") versioninfo.AddFlag(flag.CommandLine) flag.Parse() diff --git a/cmd/junit2jira/priority.go b/cmd/junit2jira/priority.go index 6ea4727..17def21 100644 --- a/cmd/junit2jira/priority.go +++ b/cmd/junit2jira/priority.go @@ -28,35 +28,55 @@ func (p Priority) String() string { } } -func parsePriority(name string) Priority { +func parsePriority(name string) (Priority, bool) { switch name { case "Critical": - return Critical + return Critical, true case "Blocker": - return Blocker + return Blocker, true case "Major": - return Major + return Major, true case "Normal": - return Normal + return Normal, true case "Minor": - return Minor + return Minor, true + case "Undefined": + return Undefined, true default: - return Undefined + return Undefined, false } } var defaultPriorityThresholds = []int{2, 10, 50, 100, 200} +const defaultPriorityThresholdsStr = "2,10,50,100,200" + // calculatePriority maps comment count to JIRA priority using default thresholds func calculatePriority(commentCount int) Priority { return calculatePriorityWithThresholds(commentCount, defaultPriorityThresholds) } // calculatePriorityWithThresholds maps comment count to JIRA priority using custom thresholds -// thresholds should contain exactly 5 values for: Minor, Normal, Major, Blocker, Critical +// thresholds should contain exactly 5 non-negative, strictly ascending values for: Minor, Normal, Major, Blocker, Critical func calculatePriorityWithThresholds(commentCount int, thresholds []int) Priority { + // Validate thresholds: must have exactly 5 values, all non-negative, and strictly ascending if len(thresholds) != 5 { thresholds = defaultPriorityThresholds + } else { + valid := true + for i := 0; i < 5; i++ { + if thresholds[i] < 0 { + valid = false + break + } + if i > 0 && thresholds[i] <= thresholds[i-1] { + valid = false + break + } + } + if !valid { + thresholds = defaultPriorityThresholds + } } switch { diff --git a/cmd/junit2jira/priority_test.go b/cmd/junit2jira/priority_test.go index 7846e6d..8ddea81 100644 --- a/cmd/junit2jira/priority_test.go +++ b/cmd/junit2jira/priority_test.go @@ -74,6 +74,19 @@ func TestCalculatePriorityWithCustomThresholds(t *testing.T) { {"custom: 100 comments", 100, []int{10, 50, 100, 200, 400}, Major}, {"custom: 200 comments", 200, []int{10, 50, 100, 200, 400}, Blocker}, {"custom: 400 comments", 400, []int{10, 50, 100, 200, 400}, Critical}, + + // Invalid thresholds - negative values (should fall back to defaults) + {"negative threshold", 50, []int{-1, 10, 50, 100, 200}, Major}, + {"multiple negative", 100, []int{2, -5, 50, 100, 200}, Blocker}, + + // Invalid thresholds - non-ascending (should fall back to defaults) + {"equal values", 50, []int{2, 10, 10, 100, 200}, Major}, + {"descending", 100, []int{200, 100, 50, 10, 2}, Blocker}, + {"partially descending", 50, []int{2, 50, 30, 100, 200}, Major}, + + // Invalid thresholds - wrong count (should fall back to defaults) + {"too few", 50, []int{2, 10, 50}, Major}, + {"too many", 100, []int{2, 10, 50, 100, 200, 300}, Blocker}, } for _, tt := range tests { @@ -131,23 +144,26 @@ func TestPriorityString(t *testing.T) { func TestParsePriority(t *testing.T) { tests := []struct { - name string - expected Priority + name string + expected Priority + recognized bool }{ - {"Undefined", Undefined}, - {"Minor", Minor}, - {"Normal", Normal}, - {"Major", Major}, - {"Blocker", Blocker}, - {"Critical", Critical}, - {"Unknown", Undefined}, - {"", Undefined}, + {"Undefined", Undefined, true}, + {"Minor", Minor, true}, + {"Normal", Normal, true}, + {"Major", Major, true}, + {"Blocker", Blocker, true}, + {"Critical", Critical, true}, + {"Unknown", Undefined, false}, + {"", Undefined, false}, + {"CustomPriority", Undefined, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := parsePriority(tt.name) + result, recognized := parsePriority(tt.name) assert.Equal(t, tt.expected, result) + assert.Equal(t, tt.recognized, recognized) }) } } diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 1c89a50..7176b51 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -90,19 +90,54 @@ func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { // Count comments commentCount := 0 - var comments []*models.IssueCommentScheme if issue.Fields != nil && issue.Fields.Comment != nil { commentCount = issue.Fields.Comment.Total - comments = issue.Fields.Comment.Comments + } + + // Fetch all comment pages + var allComments []*models.IssueCommentScheme + if commentCount > 0 { + startAt := 0 + maxResults := 50 + for { + commentsPage, response, err := j.jiraClient.Issue.Comment.Gets( + context.TODO(), + issueKey, + "created", // orderBy + nil, // expand + startAt, + maxResults, + ) + if err != nil { + logError(err, response) + return fmt.Errorf("could not fetch comments for issue %s: %w", issueKey, err) + } + + if commentsPage != nil && commentsPage.Comments != nil { + allComments = append(allComments, commentsPage.Comments...) + } + + // Check if we've fetched all comments + if commentsPage == nil || len(commentsPage.Comments) < maxResults { + break + } + + startAt += maxResults + } } // Count comments in time windows for time-based escalation - last30Days, last10Days := countCommentsInTimeWindows(comments) + last30Days, last10Days := countCommentsInTimeWindows(allComments) // Get current priority currentPriority := Undefined + currentPriorityRecognized := true if issue.Fields != nil && issue.Fields.Priority != nil { - currentPriority = parsePriority(issue.Fields.Priority.Name) + currentPriority, currentPriorityRecognized = parsePriority(issue.Fields.Priority.Name) + if !currentPriorityRecognized { + logEntry(issueKey, "").Warnf("Unrecognized priority %q, skipping auto-escalation", issue.Fields.Priority.Name) + return nil + } } // Calculate target priority with time-based escalation From c6f3f309967974f7439b13c2500c653ec4f03437 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Mon, 31 Aug 2026 16:39:32 +0200 Subject: [PATCH 05/13] fix: remove ineffectual assignment in priority update Remove unnecessary initialization of currentPriorityRecognized that was immediately overwritten by parsePriority result. Co-Authored-By: Claude Sonnet 4.5 --- cmd/junit2jira/priority_update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 7176b51..137d114 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -131,7 +131,7 @@ func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { // Get current priority currentPriority := Undefined - currentPriorityRecognized := true + var currentPriorityRecognized bool if issue.Fields != nil && issue.Fields.Priority != nil { currentPriority, currentPriorityRecognized = parsePriority(issue.Fields.Priority.Name) if !currentPriorityRecognized { From 39d64318b3b86533243d0eaa81afaa380cf326d4 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Fri, 11 Sep 2026 15:42:23 +0200 Subject: [PATCH 06/13] Update cmd/junit2jira/priority_update.go Co-authored-by: Marcin Owsiany --- cmd/junit2jira/priority_update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 137d114..5119f43 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -171,7 +171,7 @@ func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { logEntry(issueKey, "").Infof("Updated priority to %s", targetPriority) } else { - logEntry(issueKey, "").Debugf("Priority %s is already correct for %d comments", currentPriority, commentCount) + logEntry(issueKey, "").Debugf("Priority %s is already high enough for %d comments", currentPriority, commentCount) } return nil From 89c2ad0a3e3bb19e6628ae1d8a7bb91086625c3d Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 22 Sep 2026 12:23:32 +0200 Subject: [PATCH 07/13] Update cmd/junit2jira/priority_update.go Co-authored-by: Marcin Owsiany --- cmd/junit2jira/priority_update.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 5119f43..47234a6 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -140,7 +140,6 @@ func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { } } - // Calculate target priority with time-based escalation targetPriority := calculatePriorityWithTimeEscalation(commentCount, last30Days, last10Days, thresholds) // Only escalate if target priority is higher than current From ce55bc0306c3439491489b72d5985556d29499db Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 22 Sep 2026 12:26:25 +0200 Subject: [PATCH 08/13] fix priority review feedback and flake config errors --- README.md | 41 ++-- cmd/flakechecker/flake_config.go | 2 +- cmd/flakechecker/flake_config_test.go | 23 ++- cmd/junit2jira/main.go | 70 ++++--- cmd/junit2jira/priority.go | 82 ++------ cmd/junit2jira/priority_test.go | 257 +++---------------------- cmd/junit2jira/priority_update.go | 202 +++++++++---------- cmd/junit2jira/priority_update_test.go | 26 ++- 8 files changed, 226 insertions(+), 477 deletions(-) diff --git a/README.md b/README.md index f50bbb3..567c8e7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ Usage of junit2jira: Orchestrator name (such as GKE or OpenShift), if any. -priority-thresholds string Comma-separated thresholds 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 @@ -67,42 +69,25 @@ Usage of junit2jira: *Auto-Priority Escalation* -The `--enable-auto-priority` flag enables automatic priority escalation based on the number of comments on an issue. When enabled, each time a comment is added to an existing issue, the priority is automatically updated based on both total comment count and recent activity. +The `--enable-auto-priority` flag enables automatic priority escalation based on comments in one recent activity window. When enabled, each time a comment is added to an existing issue, the priority is updated from the number of comments in the configured window. The default window is 30 days and can be changed with `--priority-window-days`. -**Base Priority (Total Comment Count)** +Thresholds map the count in that window to priority: -The baseline priority is determined by the total number of comments: - -| Comment Count | Priority | Description | -|--------------|----------|-------------| -| 0-1 | Undefined | Default - new or infrequent failure | -| 2-9 | Minor | Recurring issue - needs attention | -| 10-49 | Normal | Persistent problem - regular review | -| 50-99 | Major | Serious recurring failure - priority attention | -| 100-199 | Blocker | Critical recurring failure - blocking work | -| 200+ | Critical | Extremely critical - immediate action required | - -**Time-Based Escalation (Recent Activity)** - -Recent activity over the last 10 days and 30 days can escalate the priority higher than the baseline: - -- **Last 10 days** (hot issues - immediate escalation): - - 10+ comments → Critical - - 5-9 comments → Major (minimum) - -- **Last 30 days** (active trends): - - 50+ comments → Critical (minimum) - - 20-49 comments → Blocker (minimum) - - 10-19 comments → Major (minimum) - - 5-9 comments → Normal (minimum) - -The final priority is the highest value from the baseline and recent activity checks. This catches both persistent issues (high total) and hot issues (high recent activity). +| Window count | Priority | +|-------------|----------| +| 0-1 | Undefined | +| 2-9 | Minor | +| 10-49 | Normal | +| 50-99 | Major | +| 100-199 | Blocker | +| 200+ | Critical | The thresholds can be customized using the `--priority-thresholds` flag. For example, to use thresholds of 10, 50, 100, 200, and 400: ```shell junit2jira \ --enable-auto-priority \ + --priority-window-days 30 \ --priority-thresholds "10,50,100,200,400" \ ... ``` diff --git a/cmd/flakechecker/flake_config.go b/cmd/flakechecker/flake_config.go index 49233ae..1d8ac92 100644 --- a/cmd/flakechecker/flake_config.go +++ b/cmd/flakechecker/flake_config.go @@ -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/junit2jira/main.go b/cmd/junit2jira/main.go index 3cde443..01deab7 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -68,7 +68,8 @@ func main() { 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", defaultPriorityThresholdsStr, "Comma-separated thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical).") + flag.StringVar(&p.priorityThresholds, "priority-thresholds", priorityThresholdsString(defaultPriorityThresholds), "Comma-separated thresholds 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() @@ -88,7 +89,7 @@ func main() { log.SetLevel(log.DebugLevel) } - err = run(p) + err = run(context.Background(), p) if err != nil { log.Fatal(err) } @@ -105,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") @@ -145,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") } @@ -279,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) } @@ -327,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) @@ -345,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) @@ -357,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 @@ -382,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) @@ -395,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 { @@ -427,7 +437,7 @@ 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) @@ -435,7 +445,7 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { 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(issue.Key); err != nil { + if err := j.updatePriorityIfNeeded(ctx, issue.Key); err != nil { logEntry(issue.Key, summary).WithError(err).Warn("Failed to update priority") } @@ -515,11 +525,11 @@ 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(), + ctx, jqlQuery, []string{"summary", "updated"}, // fields nil, // expand @@ -663,18 +673,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 - enableAutoPriority bool - priorityThresholds 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 index 17def21..1ccd281 100644 --- a/cmd/junit2jira/priority.go +++ b/cmd/junit2jira/priority.go @@ -1,5 +1,10 @@ package main +import ( + "strconv" + "strings" +) + type Priority int const ( @@ -49,36 +54,16 @@ func parsePriority(name string) (Priority, bool) { var defaultPriorityThresholds = []int{2, 10, 50, 100, 200} -const defaultPriorityThresholdsStr = "2,10,50,100,200" - -// calculatePriority maps comment count to JIRA priority using default thresholds -func calculatePriority(commentCount int) Priority { - return calculatePriorityWithThresholds(commentCount, defaultPriorityThresholds) -} - -// calculatePriorityWithThresholds maps comment count to JIRA priority using custom thresholds -// thresholds should contain exactly 5 non-negative, strictly ascending values for: Minor, Normal, Major, Blocker, Critical -func calculatePriorityWithThresholds(commentCount int, thresholds []int) Priority { - // Validate thresholds: must have exactly 5 values, all non-negative, and strictly ascending - if len(thresholds) != 5 { - thresholds = defaultPriorityThresholds - } else { - valid := true - for i := 0; i < 5; i++ { - if thresholds[i] < 0 { - valid = false - break - } - if i > 0 && thresholds[i] <= thresholds[i-1] { - valid = false - break - } - } - if !valid { - thresholds = defaultPriorityThresholds - } +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 @@ -94,44 +79,3 @@ func calculatePriorityWithThresholds(commentCount int, thresholds []int) Priorit return Undefined } } - -// maxPriority returns the higher of two priorities -func maxPriority(a, b Priority) Priority { - if a > b { - return a - } - return b -} - -// calculatePriorityWithTimeEscalation applies layered escalation based on total comments -// and recent activity (last 30 days and last 10 days). -// This catches both persistent issues (high total) and hot issues (high recent activity). -func calculatePriorityWithTimeEscalation(totalComments, last30Days, last10Days int, thresholds []int) Priority { - // Step 1: Calculate base priority from total comments - basePriority := calculatePriorityWithThresholds(totalComments, thresholds) - - // Step 2: Check last 10 days for HOT issues (immediate escalation) - if last10Days >= 10 { - return Critical // Top 11% - extremely hot - } - if last10Days >= 5 { - return maxPriority(basePriority, Major) // Top 17% - very active - } - - // Step 3: Check last 30 days for active trends - if last30Days >= 50 { - return maxPriority(basePriority, Critical) // Top 7% - sustained high - } - if last30Days >= 20 { - return maxPriority(basePriority, Blocker) // Top 13% - very active - } - if last30Days >= 10 { - return maxPriority(basePriority, Major) // Top 17% - active - } - if last30Days >= 5 { - return maxPriority(basePriority, Normal) // Top 28% - noticeable - } - - // Step 4: Fall back to base priority - return basePriority -} diff --git a/cmd/junit2jira/priority_test.go b/cmd/junit2jira/priority_test.go index 8ddea81..0c984d7 100644 --- a/cmd/junit2jira/priority_test.go +++ b/cmd/junit2jira/priority_test.go @@ -2,241 +2,40 @@ package main import ( "testing" + "time" - "github.com/stretchr/testify/assert" + "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" + "github.com/stretchr/testify/require" ) -func TestCalculatePriority(t *testing.T) { - tests := []struct { - name string - commentCount int - expected Priority - }{ - // New thresholds: [2, 10, 50, 100, 200] - - // Undefined: 0-1 comments - {"zero comments", 0, Undefined}, - {"one comment", 1, Undefined}, - - // Minor: 2-9 comments - {"two comments (threshold)", 2, Minor}, - {"five comments", 5, Minor}, - {"nine comments", 9, Minor}, - - // Normal: 10-49 comments - {"ten comments (threshold)", 10, Normal}, - {"thirty comments", 30, Normal}, - {"forty-nine comments", 49, Normal}, - - // Major: 50-99 comments - {"fifty comments (threshold)", 50, Major}, - {"seventy-five comments", 75, Major}, - {"ninety-nine comments", 99, Major}, - - // Blocker: 100-199 comments - {"one hundred comments (threshold)", 100, Blocker}, - {"one hundred fifty comments", 150, Blocker}, - {"one hundred ninety-nine comments", 199, Blocker}, - - // Critical: 200+ comments - {"two hundred comments (threshold)", 200, Critical}, - {"five hundred comments", 500, Critical}, - {"one thousand comments", 1000, Critical}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := calculatePriority(tt.commentCount) - assert.Equal(t, tt.expected, result, "Comment count %d should map to priority %s", tt.commentCount, tt.expected) - }) - } -} - -func TestCalculatePriorityWithCustomThresholds(t *testing.T) { - tests := []struct { - name string - commentCount int - thresholds []int - expected Priority - }{ - // Default thresholds: 4, 16, 64, 128, 256 - {"default: 3 comments", 3, []int{4, 16, 64, 128, 256}, Undefined}, - {"default: 4 comments", 4, []int{4, 16, 64, 128, 256}, Minor}, - {"default: 16 comments", 16, []int{4, 16, 64, 128, 256}, Normal}, - {"default: 64 comments", 64, []int{4, 16, 64, 128, 256}, Major}, - {"default: 128 comments", 128, []int{4, 16, 64, 128, 256}, Blocker}, - {"default: 256 comments", 256, []int{4, 16, 64, 128, 256}, Critical}, - - // Custom thresholds: 10, 50, 100, 200, 400 - {"custom: 9 comments", 9, []int{10, 50, 100, 200, 400}, Undefined}, - {"custom: 10 comments", 10, []int{10, 50, 100, 200, 400}, Minor}, - {"custom: 50 comments", 50, []int{10, 50, 100, 200, 400}, Normal}, - {"custom: 100 comments", 100, []int{10, 50, 100, 200, 400}, Major}, - {"custom: 200 comments", 200, []int{10, 50, 100, 200, 400}, Blocker}, - {"custom: 400 comments", 400, []int{10, 50, 100, 200, 400}, Critical}, - - // Invalid thresholds - negative values (should fall back to defaults) - {"negative threshold", 50, []int{-1, 10, 50, 100, 200}, Major}, - {"multiple negative", 100, []int{2, -5, 50, 100, 200}, Blocker}, - - // Invalid thresholds - non-ascending (should fall back to defaults) - {"equal values", 50, []int{2, 10, 10, 100, 200}, Major}, - {"descending", 100, []int{200, 100, 50, 10, 2}, Blocker}, - {"partially descending", 50, []int{2, 50, 30, 100, 200}, Major}, - - // Invalid thresholds - wrong count (should fall back to defaults) - {"too few", 50, []int{2, 10, 50}, Major}, - {"too many", 100, []int{2, 10, 50, 100, 200, 300}, Blocker}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := calculatePriorityWithThresholds(tt.commentCount, tt.thresholds) - assert.Equal(t, tt.expected, result, "Comment count %d with thresholds %v should map to priority %s", tt.commentCount, tt.thresholds, tt.expected) - }) - } -} - -func TestPriorityOrdering(t *testing.T) { - tests := []struct { - name string - lower Priority - higher Priority - expected bool - }{ - {"Undefined < Minor", Undefined, Minor, true}, - {"Minor < Normal", Minor, Normal, true}, - {"Normal < Major", Normal, Major, true}, - {"Major < Blocker", Major, Blocker, true}, - {"Blocker < Critical", Blocker, Critical, true}, - {"Critical = Critical", Critical, Critical, false}, - {"Major > Minor", Major, Minor, false}, - {"Critical > Undefined", Critical, Undefined, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := tt.higher > tt.lower - assert.Equal(t, tt.expected, result, "%s should be %v when comparing %s > %s", tt.name, tt.expected, tt.higher, tt.lower) - }) - } -} - -func TestPriorityString(t *testing.T) { - tests := []struct { - priority Priority - expected string - }{ - {Undefined, "Undefined"}, - {Minor, "Minor"}, - {Normal, "Normal"}, - {Major, "Major"}, - {Blocker, "Blocker"}, - {Critical, "Critical"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.priority.String()) - }) - } -} - -func TestParsePriority(t *testing.T) { - tests := []struct { - name string - expected Priority - recognized bool - }{ - {"Undefined", Undefined, true}, - {"Minor", Minor, true}, - {"Normal", Normal, true}, - {"Major", Major, true}, - {"Blocker", Blocker, true}, - {"Critical", Critical, true}, - {"Unknown", Undefined, false}, - {"", Undefined, false}, - {"CustomPriority", Undefined, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, recognized := parsePriority(tt.name) - assert.Equal(t, tt.expected, result) - assert.Equal(t, tt.recognized, recognized) - }) - } -} - -func TestMaxPriority(t *testing.T) { - tests := []struct { - name string - a Priority - b Priority +func TestPriorityForCommentCount(t *testing.T) { + thresholds := []int{2, 10, 50, 100, 200} + for _, test := range []struct { + count int expected Priority }{ - {"Critical vs Major", Critical, Major, Critical}, - {"Major vs Critical", Major, Critical, Critical}, - {"Normal vs Minor", Normal, Minor, Normal}, - {"Same priority", Major, Major, Major}, - {"Undefined vs Minor", Undefined, Minor, Minor}, - {"Critical vs Undefined", Critical, Undefined, Critical}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := maxPriority(tt.a, tt.b) - assert.Equal(t, tt.expected, result) - }) + {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 TestCalculatePriorityWithTimeEscalation(t *testing.T) { - tests := []struct { - name string - total int - last30 int - last10 int - expected Priority - reason string - }{ - // Hot issues (last 10 days triggers escalation) - {"Very hot issue", 24, 21, 12, Critical, "12 in last 10 days"}, - {"Hot burst", 7, 7, 7, Major, "7 in last 10 days"}, - {"Recent spike", 39, 23, 7, Major, "7 in last 10 days"}, - {"Extremely hot", 5, 5, 15, Critical, "15 in last 10 days"}, - {"Just hot", 3, 3, 5, Major, "5 in last 10 days"}, - - // Active issues (last 30 days triggers escalation) - {"Sustained high", 36, 21, 1, Blocker, "21 in last 30 days"}, - {"Very active", 50, 50, 0, Critical, "50 in last 30 days"}, - {"Active trend", 24, 15, 0, Major, "15 in last 30 days → escalated to Major"}, - {"Moderate activity", 10, 7, 0, Normal, "7 in last 30 days"}, - {"Low recent", 100, 3, 0, Blocker, "High total, low recent"}, - - // Persistent issues (total drives priority) - {"Historic high", 842, 0, 0, Critical, "High total"}, - {"Stale but important", 65, 0, 0, Major, "High total, no recent"}, - {"Persistent moderate", 58, 2, 1, Major, "Total drives priority"}, - - // Combined signals - {"All signals high", 253, 100, 50, Critical, "All Critical"}, - {"Mixed signals", 14, 14, 4, Major, "Total=Normal, escalated by last 30d"}, - - // Low activity - {"Minimal", 1, 1, 1, Undefined, "Below all thresholds"}, - {"Some activity", 5, 2, 0, Minor, "5 total → Minor (no escalation with only 2 in last 30d)"}, - {"Low total, low recent", 3, 1, 0, Minor, "3 total -> Minor"}, - } - - thresholds := []int{2, 10, 50, 100, 200} - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := calculatePriorityWithTimeEscalation(tt.total, tt.last30, tt.last10, thresholds) - assert.Equal(t, tt.expected, result, - "Issue with %d total, %d last30, %d last10 should be %s (%s)", - tt.total, tt.last30, tt.last10, tt.expected, tt.reason) - }) - } +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, + } + 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 index 47234a6..9ffe991 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -8,170 +8,140 @@ import ( "time" "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" - log "github.com/sirupsen/logrus" ) -// parsePriorityThresholds parses comma-separated threshold string into int slice func parsePriorityThresholds(thresholdsStr string) ([]int, error) { parts := strings.Split(thresholdsStr, ",") - if len(parts) != 5 { - return defaultPriorityThresholds, fmt.Errorf("expected 5 thresholds, got %d", len(parts)) + if len(parts) != len(defaultPriorityThresholds) { + return nil, fmt.Errorf("expected %d thresholds, got %d", len(defaultPriorityThresholds), len(parts)) } - thresholds := make([]int, 5) + thresholds := make([]int, len(parts)) for i, part := range parts { - val, err := strconv.Atoi(strings.TrimSpace(part)) + value, err := strconv.Atoi(strings.TrimSpace(part)) if err != nil { - return defaultPriorityThresholds, fmt.Errorf("invalid threshold value %q: %w", part, err) + return nil, fmt.Errorf("invalid threshold value %q: %w", part, err) } - thresholds[i] = val + 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 } -// countCommentsInTimeWindows counts comments in the last 10 and 30 days -func countCommentsInTimeWindows(comments []*models.IssueCommentScheme) (last30Days, last10Days int) { - now := time.Now() - thirtyDaysAgo := now.AddDate(0, 0, -30) - tenDaysAgo := now.AddDate(0, 0, -10) - - for _, comment := range comments { - if comment.Created == "" { +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 } - // Parse comment created timestamp - // JIRA format: "2006-01-02T15:04:05.000-0700" - created, err := time.Parse("2006-01-02T15:04:05.000-0700", comment.Created) + created, err := parseCommentTime(comment.Created) if err != nil { - // Try RFC3339 format as fallback - created, err = time.Parse(time.RFC3339, comment.Created) - if err != nil { - continue + if !warned { + warn(index, comment.Created) + warned = true } + continue } - - if created.After(thirtyDaysAgo) { - last30Days++ - } - if created.After(tenDaysAgo) { - last10Days++ + if created.After(windowStart) { + count++ } } + return count +} - return last30Days, last10Days +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") } -// updatePriorityIfNeeded updates issue priority based on comment count -func (j junit2jira) updatePriorityIfNeeded(issueKey string) error { +func (j junit2jira) updatePriorityIfNeeded(ctx context.Context, issueKey string) error { if !j.enableAutoPriority { return nil } - - // Parse thresholds - thresholds, err := parsePriorityThresholds(j.priorityThresholds) - if err != nil { - log.WithError(err).Warn("Failed to parse priority thresholds, using defaults") - thresholds = defaultPriorityThresholds + 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") } - // Get issue with comments to count them - issue, response, err := j.jiraClient.Issue.Get( - context.TODO(), - issueKey, - []string{"priority", "comment"}, // fields - nil, // expand - ) + issue, response, err := j.jiraClient.Issue.Get(ctx, issueKey, []string{"priority", "comment"}, nil) if err != nil { logError(err, response) return fmt.Errorf("could not fetch issue %s: %w", issueKey, err) } - // Count comments - commentCount := 0 - if issue.Fields != nil && issue.Fields.Comment != nil { - commentCount = issue.Fields.Comment.Total + totalComments := 0 + if issue != nil && issue.Fields != nil && issue.Fields.Comment != nil { + totalComments = issue.Fields.Comment.Total } - // Fetch all comment pages - var allComments []*models.IssueCommentScheme - if commentCount > 0 { - startAt := 0 - maxResults := 50 - for { - commentsPage, response, err := j.jiraClient.Issue.Comment.Gets( - context.TODO(), - issueKey, - "created", // orderBy - nil, // expand - startAt, - maxResults, - ) - if err != nil { - logError(err, response) - return fmt.Errorf("could not fetch comments for issue %s: %w", issueKey, err) - } - - if commentsPage != nil && commentsPage.Comments != nil { - allComments = append(allComments, commentsPage.Comments...) - } - - // Check if we've fetched all comments - if commentsPage == nil || len(commentsPage.Comments) < maxResults { - break - } - - startAt += maxResults + const pageSize = 50 + comments := make([]*models.IssueCommentScheme, 0, totalComments) + for startAt := 0; startAt < totalComments; { + page, response, err := j.jiraClient.Issue.Comment.Gets(ctx, issueKey, "created", nil, startAt, pageSize) + if err != nil { + logError(err, response) + return fmt.Errorf("could not fetch comments for issue %s: %w", issueKey, err) + } + if page == nil || len(page.Comments) == 0 { + break + } + comments = append(comments, page.Comments...) + startAt += len(page.Comments) + if page.Total > totalComments { + totalComments = page.Total } } - // Count comments in time windows for time-based escalation - last30Days, last10Days := countCommentsInTimeWindows(allComments) + windowCount := countCommentsInWindow(comments, j.priorityWindowDays, time.Now(), func(index int, timestamp string) { + logEntry(issueKey, "").Warnf("Skipping comment %d with unparseable timestamp %q", index, timestamp) + }) - // Get current priority currentPriority := Undefined - var currentPriorityRecognized bool - if issue.Fields != nil && issue.Fields.Priority != nil { - currentPriority, currentPriorityRecognized = parsePriority(issue.Fields.Priority.Name) - if !currentPriorityRecognized { + if issue != nil && issue.Fields != nil && issue.Fields.Priority != nil { + var recognized bool + currentPriority, recognized = parsePriority(issue.Fields.Priority.Name) + if !recognized { logEntry(issueKey, "").Warnf("Unrecognized priority %q, skipping auto-escalation", issue.Fields.Priority.Name) return nil } } - targetPriority := calculatePriorityWithTimeEscalation(commentCount, last30Days, last10Days, thresholds) - - // Only escalate if target priority is higher than current - if targetPriority > currentPriority { - logEntry(issueKey, "").Infof("Auto-escalating priority from %s to %s (total: %d, last 30d: %d, last 10d: %d)", - currentPriority, targetPriority, commentCount, last30Days, last10Days) - - if j.dryRun { - logEntry(issueKey, "").Debug("Dry run: would update priority") - return nil - } - - // Update the issue priority - updatePayload := &models.IssueScheme{ - Fields: &models.IssueFieldsScheme{ - Priority: &models.PriorityScheme{ - Name: targetPriority.String(), - }, - }, - } - - // Set notify to true - false requires admin permissions to suppress notifications - response, err := j.jiraClient.Issue.Update(context.TODO(), issueKey, true, updatePayload, nil, nil) - if err != nil { - logError(err, response) - return fmt.Errorf("could not update priority for issue %s: %w", issueKey, err) - } + 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("Updated priority to %s", targetPriority) - } else { - logEntry(issueKey, "").Debugf("Priority %s is already high enough for %d comments", currentPriority, commentCount) + 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 } + payload := &models.IssueScheme{Fields: &models.IssueFieldsScheme{Priority: &models.PriorityScheme{Name: targetPriority.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", targetPriority) return nil } diff --git a/cmd/junit2jira/priority_update_test.go b/cmd/junit2jira/priority_update_test.go index 225c7c6..0849c8b 100644 --- a/cmd/junit2jira/priority_update_test.go +++ b/cmd/junit2jira/priority_update_test.go @@ -32,25 +32,43 @@ func TestParsePriorityThresholds(t *testing.T) { { name: "invalid - too few values", input: "4,16,64", - expected: defaultPriorityThresholds, + expected: nil, expectError: true, }, { name: "invalid - too many values", input: "4,16,64,128,256,512", - expected: defaultPriorityThresholds, + expected: nil, expectError: true, }, { name: "invalid - non-numeric value", input: "4,16,abc,128,256", - expected: defaultPriorityThresholds, + expected: nil, expectError: true, }, { name: "empty string", input: "", - expected: defaultPriorityThresholds, + 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, }, } From 39084f327c2bd034396a48ccffb22a228c4d7a86 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 22 Sep 2026 17:31:04 +0200 Subject: [PATCH 09/13] fix Signed-off-by: Tomasz Janiszewski --- README.md | 32 ++++++- cmd/junit2jira/priority.go | 3 + cmd/junit2jira/priority_test.go | 1 + cmd/junit2jira/priority_update.go | 16 ++-- cmd/junit2jira/priority_update_test.go | 119 ++++++++++++++++++++++++- 5 files changed, 155 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 567c8e7..f9d0211 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,19 @@ Usage of junit2jira: *Auto-Priority Escalation* -The `--enable-auto-priority` flag enables automatic priority escalation based on comments in one recent activity window. When enabled, each time a comment is added to an existing issue, the priority is updated from the number of comments in the configured window. The default window is 30 days and can be changed with `--priority-window-days`. +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. -Thresholds map the count in that window to priority: +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 | |-------------|----------| @@ -82,12 +92,26 @@ Thresholds map the count in that window to priority: | 100-199 | Blocker | | 200+ | Critical | -The thresholds can be customized using the `--priority-thresholds` flag. For example, to use thresholds of 10, 50, 100, 200, and 400: +These are fixed comment-count thresholds, not percentile targets. 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, corresponding to Minor, Normal, Major, Blocker, and Critical. 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 30 \ + --priority-window-days 10 \ --priority-thresholds "10,50,100,200,400" \ ... ``` diff --git a/cmd/junit2jira/priority.go b/cmd/junit2jira/priority.go index 1ccd281..715490f 100644 --- a/cmd/junit2jira/priority.go +++ b/cmd/junit2jira/priority.go @@ -52,6 +52,9 @@ func parsePriority(name string) (Priority, bool) { } } +// 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 { diff --git a/cmd/junit2jira/priority_test.go b/cmd/junit2jira/priority_test.go index 0c984d7..c8d902a 100644 --- a/cmd/junit2jira/priority_test.go +++ b/cmd/junit2jira/priority_test.go @@ -29,6 +29,7 @@ func TestCountCommentsInWindow(t *testing.T) { {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) { diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 9ffe991..06e9ae3 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -81,20 +81,16 @@ func (j junit2jira) updatePriorityIfNeeded(ctx context.Context, issueKey string) return fmt.Errorf("priority thresholds have not been configured") } - issue, response, err := j.jiraClient.Issue.Get(ctx, issueKey, []string{"priority", "comment"}, nil) + issue, response, err := j.jiraClient.Issue.Get(ctx, issueKey, []string{"priority"}, nil) if err != nil { logError(err, response) return fmt.Errorf("could not fetch issue %s: %w", issueKey, err) } - totalComments := 0 - if issue != nil && issue.Fields != nil && issue.Fields.Comment != nil { - totalComments = issue.Fields.Comment.Total - } - const pageSize = 50 - comments := make([]*models.IssueCommentScheme, 0, totalComments) - for startAt := 0; startAt < totalComments; { + 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) @@ -105,8 +101,8 @@ func (j junit2jira) updatePriorityIfNeeded(ctx context.Context, issueKey string) } comments = append(comments, page.Comments...) startAt += len(page.Comments) - if page.Total > totalComments { - totalComments = page.Total + if startAt >= page.Total { + break } } diff --git a/cmd/junit2jira/priority_update_test.go b/cmd/junit2jira/priority_update_test.go index 0849c8b..8f2cd2a 100644 --- a/cmd/junit2jira/priority_update_test.go +++ b/cmd/junit2jira/priority_update_test.go @@ -1,8 +1,15 @@ package main import ( + "context" + "encoding/json" + "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" ) @@ -16,8 +23,8 @@ func TestParsePriorityThresholds(t *testing.T) { }{ { name: "valid default thresholds", - input: "4,16,64,128,256", - expected: []int{4, 16, 64, 128, 256}, + input: priorityThresholdsString(defaultPriorityThresholds), + expected: defaultPriorityThresholds, }, { name: "valid custom thresholds", @@ -85,3 +92,111 @@ func TestParsePriorityThresholds(t *testing.T) { }) } } + +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 + }{ + {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}, + } { + t.Run(test.name, func(t *testing.T) { + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "caller") + var offsets []string + updates := 0 + 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, "50", req.URL.Query().Get("maxResults")) + require.Equal(t, "created", req.URL.Query().Get("orderBy")) + created := time.Now().AddDate(0, 0, -60).Format(time.RFC3339) + if offset == "2" { + created = time.Now().Add(-time.Hour).Format(time.RFC3339) + } else { + require.Equal(t, "0", 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++ + 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} + require.NoError(t, j.updatePriorityIfNeeded(ctx, "TEST-1")) + 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) + }) + } +} From 2fd0ee23d6bbb6e9fcde4f96fdddab799802b861 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Thu, 8 Oct 2026 13:16:38 +0200 Subject: [PATCH 10/13] fix flake matching and paginate closed issue searches --- cmd/flakechecker/flake_config.go | 4 +- cmd/flakechecker/main.go | 23 ++++-- cmd/flakechecker/review_regression_test.go | 61 +++++++++++++++ cmd/junit2jira/closed_issue_test.go | 87 ++++++++++++++++++++++ cmd/junit2jira/main.go | 45 ++++++----- 5 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 cmd/flakechecker/review_regression_test.go create mode 100644 cmd/junit2jira/closed_issue_test.go diff --git a/cmd/flakechecker/flake_config.go b/cmd/flakechecker/flake_config.go index 1d8ac92..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)) } 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 01deab7..e408d47 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -528,27 +528,34 @@ func findMatchingIssue(search []*models.IssueScheme, summary string) *models.Iss 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( - ctx, - 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) { From 8d6688c46585aa85aeb0cb79b210ee357154f247 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 13 Oct 2026 14:13:31 +0200 Subject: [PATCH 11/13] Update README.md Co-authored-by: Marcin Owsiany --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f9d0211..9f5c507 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ The default thresholds map the comment count in that window to a target priority | 100-199 | Blocker | | 200+ | Critical | -These are fixed comment-count thresholds, not percentile targets. Priority is only +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. From 23e6c13be0516e05e18ae805a180b3297317bd94 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 13 Oct 2026 16:12:19 +0200 Subject: [PATCH 12/13] Clarify priority thresholds and refactor priority updates --- README.md | 8 ++- cmd/junit2jira/main.go | 2 +- cmd/junit2jira/priority_test.go | 27 +++++++++ cmd/junit2jira/priority_update.go | 84 ++++++++++++++++++-------- cmd/junit2jira/priority_update_test.go | 22 ++++++- 5 files changed, 111 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9f5c507..ffff909 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Usage of junit2jira: -orchestrator string Orchestrator name (such as GKE or OpenShift), if any. -priority-thresholds string - Comma-separated thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical). (default "2,10,50,100,200") + 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 @@ -99,8 +99,10 @@ 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, corresponding to Minor, Normal, Major, Blocker, and Critical. Invalid -configuration is rejected at startup, even when automatic escalation is disabled. +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. diff --git a/cmd/junit2jira/main.go b/cmd/junit2jira/main.go index e408d47..3b697f6 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -68,7 +68,7 @@ func main() { 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 thresholds for priority escalation (Minor,Normal,Major,Blocker,Critical).") + 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) diff --git a/cmd/junit2jira/priority_test.go b/cmd/junit2jira/priority_test.go index c8d902a..f56fce4 100644 --- a/cmd/junit2jira/priority_test.go +++ b/cmd/junit2jira/priority_test.go @@ -21,6 +21,33 @@ func TestPriorityForCommentCount(t *testing.T) { } } +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{ diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 06e9ae3..8a2acd8 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -10,6 +10,8 @@ import ( "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) { @@ -81,41 +83,42 @@ func (j junit2jira) updatePriorityIfNeeded(ctx context.Context, issueKey string) return fmt.Errorf("priority thresholds have not been configured") } - issue, response, err := j.jiraClient.Issue.Get(ctx, issueKey, []string{"priority"}, nil) + priority, err := j.fetchIssuePriority(ctx, issueKey) if err != nil { - logError(err, response) - return fmt.Errorf("could not fetch issue %s: %w", issueKey, err) + return err } - const pageSize = 50 - 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 fmt.Errorf("could not fetch comments for issue %s: %w", issueKey, err) - } - if page == nil || len(page.Comments) == 0 { - break - } - comments = append(comments, page.Comments...) - startAt += len(page.Comments) - if startAt >= page.Total { - break - } + comments, err := j.fetchIssueComments(ctx, issueKey) + if err != nil { + return err } windowCount := countCommentsInWindow(comments, j.priorityWindowDays, time.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 issue != nil && issue.Fields != nil && issue.Fields.Priority != nil { + if priority != nil { var recognized bool - currentPriority, recognized = parsePriority(issue.Fields.Priority.Name) + currentPriority, recognized = parsePriority(priority.Name) if !recognized { - logEntry(issueKey, "").Warnf("Unrecognized priority %q, skipping auto-escalation", issue.Fields.Priority.Name) + logEntry(issueKey, "").Warnf("Unrecognized priority %q, skipping auto-escalation", priority.Name) return nil } } @@ -132,12 +135,41 @@ func (j junit2jira) updatePriorityIfNeeded(ctx context.Context, issueKey string) return nil } - payload := &models.IssueScheme{Fields: &models.IssueFieldsScheme{Priority: &models.PriorityScheme{Name: targetPriority.String()}}} - response, err = j.jiraClient.Issue.Update(ctx, issueKey, true, payload, nil, nil) + return j.setIssuePriority(ctx, issueKey, targetPriority) +} + +func (j junit2jira) fetchIssueComments(ctx context.Context, issueKey string) ([]*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 + } + comments = append(comments, page.Comments...) + 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", targetPriority) + 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 index 8f2cd2a..4cb0b09 100644 --- a/cmd/junit2jira/priority_update_test.go +++ b/cmd/junit2jira/priority_update_test.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "io" "net/http" "strings" @@ -107,6 +108,8 @@ func TestUpdatePriorityIfNeeded(t *testing.T) { 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}, @@ -116,12 +119,15 @@ func TestUpdatePriorityIfNeeded(t *testing.T) { {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 @@ -131,8 +137,11 @@ func TestUpdatePriorityIfNeeded(t *testing.T) { 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, "50", req.URL.Query().Get("maxResults")) + 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 == "2" { created = time.Now().Add(-time.Hour).Format(time.RFC3339) @@ -145,6 +154,9 @@ func TestUpdatePriorityIfNeeded(t *testing.T) { } 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)) @@ -158,7 +170,13 @@ func TestUpdatePriorityIfNeeded(t *testing.T) { })}, "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} - require.NoError(t, j.updatePriorityIfNeeded(ctx, "TEST-1")) + 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 { From 0c35fb6cadfd370c6903b0125c2ef754d8317fca Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Wed, 14 Oct 2026 13:40:44 +0200 Subject: [PATCH 13/13] fix: stop fetching comments beyond priority window --- cmd/junit2jira/priority_update.go | 21 ++++++++--- cmd/junit2jira/priority_update_test.go | 52 ++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/cmd/junit2jira/priority_update.go b/cmd/junit2jira/priority_update.go index 8a2acd8..447716f 100644 --- a/cmd/junit2jira/priority_update.go +++ b/cmd/junit2jira/priority_update.go @@ -88,12 +88,13 @@ func (j junit2jira) updatePriorityIfNeeded(ctx context.Context, issueKey string) return err } - comments, err := j.fetchIssueComments(ctx, issueKey) + 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, time.Now(), func(index int, timestamp string) { + windowCount := countCommentsInWindow(comments, j.priorityWindowDays, now, func(index int, timestamp string) { logEntry(issueKey, "").Warnf("Skipping comment %d with unparseable timestamp %q", index, timestamp) }) @@ -138,14 +139,14 @@ func (j junit2jira) escalateIssuePriority(ctx context.Context, issueKey string, return j.setIssuePriority(ctx, issueKey, targetPriority) } -func (j junit2jira) fetchIssueComments(ctx context.Context, issueKey string) ([]*models.IssueCommentScheme, error) { +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) + 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) @@ -153,7 +154,17 @@ func (j junit2jira) fetchIssueComments(ctx context.Context, issueKey string) ([] if page == nil || len(page.Comments) == 0 { break } - comments = append(comments, page.Comments...) + 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 diff --git a/cmd/junit2jira/priority_update_test.go b/cmd/junit2jira/priority_update_test.go index 4cb0b09..208608d 100644 --- a/cmd/junit2jira/priority_update_test.go +++ b/cmd/junit2jira/priority_update_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "strings" @@ -138,15 +139,15 @@ func TestUpdatePriorityIfNeeded(t *testing.T) { 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")) + 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 == "2" { + if offset == "0" { created = time.Now().Add(-time.Hour).Format(time.RFC3339) } else { - require.Equal(t, "0", offset) + 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 { @@ -218,3 +219,48 @@ func TestRunRejectsInvalidPriorityConfiguration(t *testing.T) { }) } } + +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) {})) + }) + } +}