Skip to content

Commit da405fd

Browse files
committed
wip: add sync cmd and update models
1 parent 2b495b9 commit da405fd

File tree

6 files changed

+290
-10
lines changed

6 files changed

+290
-10
lines changed

tools/flakeguard/cmd/create_jira_tickets.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -560,8 +560,8 @@ func (m tmodel) View() string {
560560
// Assignee
561561
var assigneeLine string
562562
var assigneeDisplayValue string
563-
if t.AssigneeName != "" {
564-
assigneeDisplayValue = fmt.Sprintf("%s (%s)", t.AssigneeName, t.AssigneeId)
563+
if t.AssigneeId != "" {
564+
assigneeDisplayValue = fmt.Sprintf("%s (%s)", t.AssigneeId, t.AssigneeId)
565565
} else {
566566
assigneeDisplayValue = t.AssigneeId
567567
}

tools/flakeguard/cmd/sync_jira.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/andygrunwald/go-jira"
9+
"github.com/rs/zerolog/log"
10+
"github.com/smartcontractkit/chainlink-testing-framework/tools/flakeguard/jirautils"
11+
"github.com/smartcontractkit/chainlink-testing-framework/tools/flakeguard/localdb"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var (
16+
syncJiraSearchLabel string
17+
syncTestDBPath string
18+
syncDryRun bool
19+
updateAssignees bool
20+
)
21+
22+
var SyncJiraCmd = &cobra.Command{
23+
Use: "sync-jira",
24+
Short: "Sync Jira tickets with local database",
25+
Long: `Searches for all flaky test tickets in Jira that aren't yet tracked in the local database.
26+
This command will:
27+
1. Search Jira for all tickets with the flaky_test label
28+
2. Compare against the local database
29+
3. Add any missing tickets to the local database
30+
4. Update assignee information from Jira
31+
5. Optionally show which tickets were added/updated (--dry-run)`,
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
// 1) Set default label if not provided
34+
if syncJiraSearchLabel == "" {
35+
syncJiraSearchLabel = "flaky_test"
36+
}
37+
38+
// 2) Load local DB
39+
db, err := localdb.LoadDBWithPath(syncTestDBPath)
40+
if err != nil {
41+
log.Warn().Err(err).Msg("Failed to load local DB; continuing with empty DB.")
42+
db = localdb.NewDB()
43+
}
44+
45+
// 3) Get Jira client
46+
client, err := jirautils.GetJiraClient()
47+
if err != nil {
48+
log.Error().Err(err).Msg("Failed to create Jira client")
49+
return err
50+
}
51+
52+
// 4) Search for all flaky test tickets in Jira
53+
jql := fmt.Sprintf(`labels = "%s" ORDER BY created DESC`, syncJiraSearchLabel)
54+
var startAt int
55+
var allIssues []jira.Issue
56+
57+
for {
58+
issues, resp, err := client.Issue.SearchWithContext(context.Background(), jql, &jira.SearchOptions{
59+
StartAt: startAt,
60+
MaxResults: 50, // Fetch in batches of 50
61+
Fields: []string{"summary", "assignee"}, // Include assignee field
62+
})
63+
if err != nil {
64+
return fmt.Errorf("error searching Jira: %w (resp: %v)", err, resp)
65+
}
66+
if len(issues) == 0 {
67+
break
68+
}
69+
allIssues = append(allIssues, issues...)
70+
startAt += len(issues)
71+
}
72+
73+
// 5) Process each issue
74+
var added int
75+
var updated int
76+
var skipped int
77+
var assigneeUpdated int
78+
79+
// Get all entries for efficient updating
80+
entries := db.GetAllEntries()
81+
entriesMap := make(map[string]*localdb.Entry) // map by Jira ticket key
82+
for i := range entries {
83+
entriesMap[entries[i].JiraTicket] = &entries[i]
84+
}
85+
86+
for _, issue := range allIssues {
87+
// Extract test name from summary
88+
summary := issue.Fields.Summary
89+
testName := extractTestName(summary)
90+
if testName == "" {
91+
log.Warn().Msgf("Could not extract test name from summary: %s", summary)
92+
skipped++
93+
continue
94+
}
95+
96+
// Get assignee ID if available
97+
var assigneeID string
98+
if issue.Fields.Assignee != nil {
99+
assigneeID = issue.Fields.Assignee.AccountID
100+
}
101+
102+
// Check if this ticket is already in the local DB
103+
if entry, exists := entriesMap[issue.Key]; exists {
104+
if assigneeID != "" && entry.AssigneeID != assigneeID {
105+
if !syncDryRun {
106+
entry.AssigneeID = assigneeID
107+
// Update the entry in the DB
108+
db.UpdateEntry(*entry)
109+
}
110+
assigneeUpdated++
111+
log.Info().Msgf("Updated assignee for ticket %s to %s", issue.Key, assigneeID)
112+
}
113+
updated++
114+
} else {
115+
if !syncDryRun {
116+
// Create new entry with assignee information
117+
entry := localdb.Entry{
118+
TestPackage: "", // Empty as we can't reliably extract it
119+
TestName: testName,
120+
JiraTicket: issue.Key,
121+
AssigneeID: assigneeID,
122+
}
123+
db.AddEntry(entry)
124+
}
125+
added++
126+
log.Info().Msgf("Added ticket %s for test %s (assignee: %s)", issue.Key, testName, assigneeID)
127+
}
128+
}
129+
130+
// 6) Save DB if not in dry run mode
131+
if !syncDryRun && (added > 0 || assigneeUpdated > 0) {
132+
if err := db.Save(); err != nil {
133+
log.Error().Err(err).Msg("Failed to save local DB")
134+
return err
135+
}
136+
log.Info().Msgf("Local DB has been updated at: %s", db.FilePath())
137+
}
138+
139+
// 7) Print summary
140+
fmt.Printf("\nSummary:\n")
141+
fmt.Printf("Total Jira tickets found: %d\n", len(allIssues))
142+
fmt.Printf("New tickets added to DB: %d\n", added)
143+
fmt.Printf("Existing tickets found: %d\n", updated)
144+
fmt.Printf("Assignees updated: %d\n", assigneeUpdated)
145+
fmt.Printf("Skipped (could not parse): %d\n", skipped)
146+
if syncDryRun {
147+
fmt.Printf("\nThis was a dry run. No changes were made to the local database.\n")
148+
}
149+
150+
return nil
151+
},
152+
}
153+
154+
func init() {
155+
SyncJiraCmd.Flags().StringVar(&syncJiraSearchLabel, "jira-search-label", "", "Jira label to filter existing tickets (default: flaky_test)")
156+
SyncJiraCmd.Flags().StringVar(&syncTestDBPath, "test-db-path", "", "Path to the flaky test JSON database (default: ~/.flaky_test_db.json)")
157+
SyncJiraCmd.Flags().BoolVar(&syncDryRun, "dry-run", false, "If true, only show what would be added without making changes")
158+
InitCommonFlags(SyncJiraCmd)
159+
}
160+
161+
// extractTestName attempts to extract the test name from a ticket summary
162+
func extractTestName(summary string) string {
163+
// Expected format: "Fix Flaky Test: TestName (X% flake rate)"
164+
parts := strings.Split(summary, ": ")
165+
if len(parts) != 2 {
166+
return ""
167+
}
168+
testPart := parts[1]
169+
// Split on " (" to remove the flake rate part
170+
testName := strings.Split(testPart, " (")[0]
171+
return strings.TrimSpace(testName)
172+
}

tools/flakeguard/cmd/tickets.go

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"strings"
78
"time"
89

910
"github.com/andygrunwald/go-jira"
11+
"github.com/briandowns/spinner"
1012
tea "github.com/charmbracelet/bubbletea"
1113
"github.com/charmbracelet/lipgloss"
1214
"github.com/rs/zerolog/log"
@@ -22,6 +24,7 @@ var (
2224
jiraComment bool // if true, post a comment when marking as skipped
2325
ticketsDryRun bool // if true, do not send anything to Jira
2426
hideSkipped bool // if true, do not show skipped tests
27+
missingPillars bool // if true, only show tickets with missing pillar names
2528
)
2629

2730
// TicketsCmd is the new CLI command for managing tickets.
@@ -99,11 +102,13 @@ Actions:
99102
SkippedAt: entry.SkippedAt,
100103
}
101104

102-
// Map user ID based on test package pattern
103-
if userID := model.MapTestPackageToUser(entry.TestPackage, patternToUserID); userID != "" {
104-
if userMapping, exists := userMap[userID]; exists {
105-
tickets[i].AssigneeId = userID
106-
tickets[i].AssigneeName = userMapping.UserName
105+
// Map user based on assignee ID from local DB
106+
if entry.AssigneeID != "" {
107+
if _, exists := userMap[entry.AssigneeID]; exists {
108+
tickets[i].AssigneeId = entry.AssigneeID
109+
} else {
110+
tickets[i].AssigneeId = entry.AssigneeID
111+
tickets[i].MissingUserMapping = true
107112
}
108113
}
109114
}
@@ -126,6 +131,77 @@ Actions:
126131
jiraClient = nil
127132
}
128133

134+
// Fetch pillar names with spinner
135+
if jiraClient != nil {
136+
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
137+
s.Suffix = " Fetching pillar names from Jira..."
138+
s.Start()
139+
140+
// Collect all Jira keys that need pillar names
141+
var jiraKeys []string
142+
for _, t := range tickets {
143+
if t.ExistingJiraKey != "" {
144+
jiraKeys = append(jiraKeys, t.ExistingJiraKey)
145+
}
146+
}
147+
148+
// Process tickets in batches of 50 (Jira's recommended batch size)
149+
batchSize := 50
150+
for i := 0; i < len(jiraKeys); i += batchSize {
151+
end := i + batchSize
152+
if end > len(jiraKeys) {
153+
end = len(jiraKeys)
154+
}
155+
batch := jiraKeys[i:end]
156+
157+
// Create JQL query for the batch
158+
jql := fmt.Sprintf("key IN (%s)", strings.Join(batch, ","))
159+
160+
// Fetch issues in batch
161+
issues, _, err := jiraClient.Issue.Search(jql, &jira.SearchOptions{
162+
Fields: []string{"key", "customfield_11016"},
163+
MaxResults: batchSize,
164+
})
165+
166+
if err != nil {
167+
log.Warn().Err(err).Msgf("Failed to fetch pillar names for batch of tickets")
168+
continue
169+
}
170+
171+
// Update tickets with pillar names
172+
for _, issue := range issues {
173+
// Find the corresponding ticket
174+
for j := range tickets {
175+
if tickets[j].ExistingJiraKey == issue.Key {
176+
if issue.Fields != nil {
177+
if pillarField, ok := issue.Fields.Unknowns["customfield_11016"].(map[string]interface{}); ok {
178+
if value, ok := pillarField["value"].(string); ok {
179+
tickets[j].PillarName = value
180+
}
181+
}
182+
}
183+
break
184+
}
185+
}
186+
}
187+
188+
// Update spinner progress
189+
s.Suffix = fmt.Sprintf(" Fetching pillar names from Jira... (%d/%d)", end, len(jiraKeys))
190+
}
191+
s.Stop()
192+
}
193+
194+
// Filter tickets with missing pillars if the flag is set
195+
if missingPillars {
196+
filtered := make([]model.FlakyTicket, 0, len(tickets))
197+
for _, t := range tickets {
198+
if t.PillarName == "" {
199+
filtered = append(filtered, t)
200+
}
201+
}
202+
tickets = filtered
203+
}
204+
129205
// 6) Initialize the Bubble Tea model.
130206
m := initialTicketsModel(tickets, userMap, testPatternMap)
131207
m.JiraClient = jiraClient
@@ -153,7 +229,8 @@ func init() {
153229
TicketsCmd.Flags().StringVar(&ticketsJSONPath, "test-db-path", "flaky_test_db.json", "Path to the JSON file containing tickets")
154230
TicketsCmd.Flags().BoolVar(&jiraComment, "jira-comment", true, "If true, post a comment to the Jira ticket when marking as skipped")
155231
TicketsCmd.Flags().BoolVar(&ticketsDryRun, "dry-run", false, "If true, do not send anything to Jira")
156-
TicketsCmd.Flags().BoolVar(&hideSkipped, "hide-skipped", false, "If true, do not show skipped tests")
232+
TicketsCmd.Flags().BoolVar(&hideSkipped, "hide-skipped", false, "If true, dbto not show skipped tests")
233+
TicketsCmd.Flags().BoolVar(&missingPillars, "missing-pillars", false, "If true, only show tickets with missing pillar names")
157234
TicketsCmd.Flags().StringVar(&userMappingPath, "user-mapping-path", "user_mapping.json", "Path to the JSON file containing user mapping")
158235
TicketsCmd.Flags().StringVar(&userTestMappingPath, "user-test-mapping-path", "user_test_mapping.json", "Path to the JSON file containing user test mapping")
159236
InitCommonFlags(TicketsCmd)
@@ -328,18 +405,34 @@ func (m ticketModel) View() string {
328405
view += fmt.Sprintf("%s %s\n", labelStyle.Render("Jira:"), jirautils.GetJiraLink(t.ExistingJiraKey))
329406
}
330407

408+
// Show assignee information
409+
if t.AssigneeId != "" {
410+
view += fmt.Sprintf("%s %s\n", labelStyle.Render("Assignee ID:"), t.AssigneeId)
411+
}
412+
331413
// Show status with color.
332414
if !t.SkippedAt.IsZero() {
333415
view += fmt.Sprintf("%s %s\n", labelStyle.Render("Status:"), fmt.Sprintf("skipped at: %s", t.SkippedAt.UTC().Format(time.RFC822)))
334416
} else {
335417
view += fmt.Sprintf("%s %s\n", labelStyle.Render("Status:"), infoStyle.Render("not skipped"))
336418
}
337419

420+
view += fmt.Sprintf("%s %s\n", labelStyle.Render("Pillar:"), t.PillarName)
421+
338422
// Display any info message.
339423
if m.infoMessage != "" {
340424
view += "\n" + infoStyle.Render(m.infoMessage) + "\n"
341425
}
342426

343-
view += "\n" + actionStyle.Render("Actions:") + " [s] mark as skipped, [u] unskip, [i] set pillar name, [n] next, [q] quit"
427+
// Build actions list
428+
actions := []string{
429+
"[s] mark as skipped",
430+
"[u] unskip",
431+
"[i] set pillar name",
432+
"[n] next",
433+
"[q] quit",
434+
}
435+
436+
view += "\n" + actionStyle.Render("Actions:") + " " + strings.Join(actions, ", ")
344437
return view
345438
}

tools/flakeguard/localdb/localdb.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type Entry struct {
1717
TestPackage string `json:"test_package"`
1818
TestName string `json:"test_name"`
1919
JiraTicket string `json:"jira_ticket"`
20+
AssigneeID string `json:"jira_assignee_id,omitempty"`
2021
IsSkipped bool `json:"is_skipped,omitempty"`
2122
SkippedAt time.Time `json:"skipped_at,omitempty"`
2223
}
@@ -158,3 +159,15 @@ func getDefaultDBPath() string {
158159
func makeKey(pkg, testName string) string {
159160
return pkg + "::" + testName
160161
}
162+
163+
// UpdateEntry updates an existing entry in the DB
164+
func (db *DB) UpdateEntry(entry Entry) {
165+
key := makeKey(entry.TestPackage, entry.TestName)
166+
db.data[key] = entry
167+
}
168+
169+
// AddEntry adds a new entry to the DB
170+
func (db *DB) AddEntry(entry Entry) {
171+
key := makeKey(entry.TestPackage, entry.TestName)
172+
db.data[key] = entry
173+
}

tools/flakeguard/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func init() {
4545
rootCmd.AddCommand(cmd.SendToSplunkCmd)
4646
rootCmd.AddCommand(cmd.TicketsCmd)
4747
rootCmd.AddCommand(cmd.CreateTicketsCmd)
48+
rootCmd.AddCommand(cmd.SyncJiraCmd)
4849
}
4950

5051
func main() {

tools/flakeguard/model/ticket.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ type FlakyTicket struct {
2121
ExistingJiraKey string
2222
ExistingTicketSource string // "localdb" or "jira"
2323
AssigneeId string
24-
AssigneeName string
2524
Priority string
2625
FlakeRate float64
2726
SkippedAt time.Time // timestamp when the ticket was marked as skipped
27+
MissingUserMapping bool // true if the assignee ID exists but has no mapping in user_mapping.json
28+
PillarName string // pillar name from Jira customfield_11016
2829
}
2930

3031
// MapTestPackageToUser maps a test package to a user ID using regex patterns

0 commit comments

Comments
 (0)