Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Usage of junit2jira:
Enable debug log level
-dry-run
When set to true issues will NOT be created.
-enable-auto-priority
Enable automatic priority escalation based on comment count.
-html-output string
Generate HTML report to this file (use dash [-] for stdout)
-jira-url string
Expand All @@ -50,6 +52,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
Expand All @@ -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:
Expand Down
34 changes: 22 additions & 12 deletions cmd/junit2jira/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
137 changes: 137 additions & 0 deletions cmd/junit2jira/priority.go
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +50 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we please derive one from the other so there is no room for skew?
Please also add a comment describing the unit (comment count I guess?).


// calculatePriority maps comment count to JIRA priority using default thresholds
func calculatePriority(commentCount int) Priority {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unused apart from tests?

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should cause an error instead 🤔
In general, it feels like this is a wrong place for correctness validation.

} 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
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in recent go we should be able to use just a generix max?

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the magic values here should be defined in a block with a comment reminding to keep README in sync...

return Critical // Top 11% - extremely hot

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like critical should be reserved for at most top 1%.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that will vary between the runs. Currently it's 11% but in next 10 days it could be 0. I think there is no easy way to autotune it to keep 1% for critical so I think thresholds are ok.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, but then perhaps we should not advertise the percentiles as the source of the count numbers....

}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OTOH, I'd put the "normal" bar closer to 50%...

}

// Step 4: Fall back to base priority
return basePriority
}
Loading