diff --git a/README.md b/README.md index 72e00e8..f50bbb3 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 "2,10,50,100,200") -slack-output string Generate JSON output in slack format (use dash [-] for stdout) -threshold int @@ -61,6 +65,48 @@ 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 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-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: + +```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..3cde443 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", 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() @@ -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..17def21 --- /dev/null +++ b/cmd/junit2jira/priority.go @@ -0,0 +1,137 @@ +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, bool) { + switch name { + case "Critical": + return Critical, true + case "Blocker": + return Blocker, true + case "Major": + return Major, true + case "Normal": + return Normal, true + case "Minor": + return Minor, true + case "Undefined": + return Undefined, true + default: + return Undefined, false + } +} + +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 + } + } + + 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 + } +} + +// 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 new file mode 100644 index 0000000..8ddea81 --- /dev/null +++ b/cmd/junit2jira/priority_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +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 + 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 new file mode 100644 index 0000000..137d114 --- /dev/null +++ b/cmd/junit2jira/priority_update.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "fmt" + "strconv" + "strings" + "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)) + } + + 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 +} + +// 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 { + 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 { + commentCount = 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 + } + } + + // Count comments in time windows for time-based escalation + last30Days, last10Days := countCommentsInTimeWindows(allComments) + + // 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 { + logEntry(issueKey, "").Warnf("Unrecognized priority %q, skipping auto-escalation", issue.Fields.Priority.Name) + return nil + } + } + + // 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 (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) + } + + 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) + }) + } +}