flake-update-20260505
1// Package jira provides Jira issue fetching via the jira CLI.
2package jira
3
4import (
5 "bytes"
6 "context"
7 "fmt"
8 "os/exec"
9 "regexp"
10 "strings"
11 "time"
12)
13
14// Issue represents a Jira issue.
15type Issue struct {
16 Key string
17 Summary string
18 Status string
19 Priority string
20 Type string
21 Labels string
22}
23
24// FetchByStatus fetches issues assigned to user with given statuses.
25func FetchByStatus(ctx context.Context, user string, statuses []string) ([]Issue, error) {
26 var allIssues []Issue
27 for _, status := range statuses {
28 issues, err := fetchIssueList(ctx, user, status)
29 if err != nil {
30 continue // Skip on error, don't fail entirely
31 }
32 allIssues = append(allIssues, issues...)
33 }
34 return allIssues, nil
35}
36
37// FetchUpdatedSince fetches issues assigned to the current user updated since the given date.
38func FetchUpdatedSince(ctx context.Context, since time.Time) ([]Issue, error) {
39 sinceStr := since.Format("2006-01-02")
40 args := []string{
41 "issue", "list", "--plain", "--no-truncate",
42 "-a", jiraMe(),
43 "--updated-after", sinceStr,
44 }
45 return runJiraList(ctx, args)
46}
47
48// FetchSecuritySince fetches Security-labeled issues created since the given date.
49func FetchSecuritySince(ctx context.Context, since time.Time) ([]Issue, error) {
50 sinceStr := since.Format("2006-01-02")
51 args := []string{
52 "issue", "list", "--plain", "--no-truncate",
53 "-l", "Security",
54 "--created-after", sinceStr,
55 }
56 return runJiraList(ctx, args)
57}
58
59// FetchCompletedSince fetches issues resolved by user since the given date.
60// Uses JQL with resolutiondate to get only issues actually resolved in the
61// period, not merely updated while in a Done/Closed state.
62func FetchCompletedSince(ctx context.Context, user string, since time.Time) ([]Issue, error) {
63 sinceStr := since.Format("2006-01-02")
64 jql := fmt.Sprintf(
65 `assignee = currentUser() AND statusCategory = Done AND resolution not in ("Obsolete", "Won't Do", "Won't Fix", "Duplicate") AND resolutiondate >= "%s"`,
66 sinceStr,
67 )
68 args := []string{
69 "issue", "list", "--plain", "--no-truncate",
70 "-q", jql,
71 }
72 return runJiraList(ctx, args)
73}
74
75// FetchSummary fetches the summary for a single issue key.
76func FetchSummary(ctx context.Context, key string) (string, error) {
77 cmd := exec.CommandContext(ctx, "jira", "issue", "view", key, "--plain")
78 var stdout bytes.Buffer
79 cmd.Stdout = &stdout
80 if err := cmd.Run(); err != nil {
81 return "", fmt.Errorf("failed to view issue %s: %w", key, err)
82 }
83 // Extract summary from "# Title" line
84 for line := range strings.SplitSeq(stdout.String(), "\n") {
85 trimmed := strings.TrimSpace(line)
86 if after, ok := strings.CutPrefix(trimmed, "# "); ok {
87 return strings.TrimSpace(after), nil
88 }
89 }
90 return "", fmt.Errorf("could not find summary for %s", key)
91}
92
93// GroupByCVE deduplicates issues by CVE ID, returning one issue per CVE.
94func GroupByCVE(issues []Issue) ([]Issue, int) {
95 cveRe := regexp.MustCompile(`CVE-\d+-\d+`)
96 seen := make(map[string]bool)
97 var grouped []Issue
98 for _, issue := range issues {
99 cve := cveRe.FindString(issue.Summary)
100 if cve != "" {
101 if seen[cve] {
102 continue
103 }
104 seen[cve] = true
105 }
106 grouped = append(grouped, issue)
107 }
108 return grouped, len(issues)
109}
110
111func fetchIssueList(ctx context.Context, user, status string) ([]Issue, error) {
112 args := []string{
113 "issue", "list", "--plain", "--no-truncate",
114 "-a", jiraMe(),
115 "-s", status,
116 }
117 return runJiraList(ctx, args)
118}
119
120func runJiraList(ctx context.Context, args []string) ([]Issue, error) {
121 cmd := exec.CommandContext(ctx, "jira", args...)
122 var stdout, stderr bytes.Buffer
123 cmd.Stdout = &stdout
124 cmd.Stderr = &stderr
125
126 if err := cmd.Run(); err != nil {
127 return nil, fmt.Errorf("jira CLI error: %s", stderr.String())
128 }
129
130 return parsePlainOutput(stdout.String()), nil
131}
132
133func parsePlainOutput(output string) []Issue {
134 lines := strings.Split(strings.TrimSpace(output), "\n")
135 if len(lines) < 2 {
136 return nil
137 }
138
139 var issues []Issue
140 // Skip header line, collapse consecutive tabs
141 tabCollapser := regexp.MustCompile(`\t+`)
142 for _, line := range lines[1:] {
143 collapsed := tabCollapser.ReplaceAllString(line, "\t")
144 fields := strings.Split(collapsed, "\t")
145 if len(fields) < 4 {
146 continue
147 }
148 issue := Issue{
149 Key: strings.TrimSpace(fields[1]),
150 Summary: strings.TrimSpace(fields[2]),
151 Status: strings.TrimSpace(fields[3]),
152 }
153 if issue.Key == "" {
154 continue
155 }
156 if len(fields) > 6 {
157 issue.Priority = strings.TrimSpace(fields[6])
158 }
159 issues = append(issues, issue)
160 }
161 return issues
162}
163
164func jiraMe() string {
165 cmd := exec.Command("jira", "me")
166 var stdout bytes.Buffer
167 cmd.Stdout = &stdout
168 if err := cmd.Run(); err != nil {
169 return ""
170 }
171 return strings.TrimSpace(stdout.String())
172}