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}