flake-update-20260201
  1package sources
  2
  3import (
  4	"bytes"
  5	"context"
  6	"fmt"
  7	"os/exec"
  8	"regexp"
  9	"strings"
 10	"time"
 11
 12	"github.com/vdemeester/home/tools/review-tool/internal/activity"
 13	"github.com/vdemeester/home/tools/review-tool/internal/config"
 14)
 15
 16// JiraSource fetches activity from Jira via the jira CLI.
 17type JiraSource struct {
 18	cfg *config.JiraConfig
 19}
 20
 21// NewJiraSource creates a new Jira source.
 22func NewJiraSource(cfg *config.JiraConfig) *JiraSource {
 23	return &JiraSource{cfg: cfg}
 24}
 25
 26// Name returns the source identifier.
 27func (j *JiraSource) Name() string {
 28	return "jira"
 29}
 30
 31// Validate checks if jira CLI is available and configured.
 32func (j *JiraSource) Validate() error {
 33	cmd := exec.Command("jira", "me")
 34	if err := cmd.Run(); err != nil {
 35		return fmt.Errorf("jira CLI not configured: %w", err)
 36	}
 37	return nil
 38}
 39
 40// Fetch retrieves Jira activities within the time range.
 41func (j *JiraSource) Fetch(ctx context.Context, start, end time.Time) (*activity.Activity, error) {
 42	act := &activity.Activity{
 43		Source: "jira",
 44		Items:  []activity.ActivityItem{},
 45	}
 46
 47	// Calculate days since start
 48	daysSince := int(time.Since(start).Hours()/24) + 1
 49
 50	// Build JQL query
 51	var jql string
 52	if len(j.cfg.Projects) > 0 {
 53		projects := strings.Join(j.cfg.Projects, ", ")
 54		jql = fmt.Sprintf("project in (%s) AND assignee = currentUser() AND updated >= -%dd", projects, daysSince)
 55	} else {
 56		jql = fmt.Sprintf("assignee = currentUser() AND updated >= -%dd", daysSince)
 57	}
 58
 59	// Execute jira CLI
 60	args := []string{
 61		"issue", "list",
 62		"--jql", jql,
 63		"--plain",
 64		"--columns", "key,summary,status,updated",
 65	}
 66
 67	cmd := exec.CommandContext(ctx, "jira", args...)
 68	var stdout, stderr bytes.Buffer
 69	cmd.Stdout = &stdout
 70	cmd.Stderr = &stderr
 71
 72	if err := cmd.Run(); err != nil {
 73		return act, nil // Return empty activity on error, don't fail
 74	}
 75
 76	items, err := parseJiraPlainOutput(stdout.String(), j.cfg.Server)
 77	if err != nil {
 78		return act, nil
 79	}
 80
 81	act.Items = filterByDateRange(items, start, end)
 82	return act, nil
 83}
 84
 85// parseJiraPlainOutput parses the --plain output from jira CLI
 86func parseJiraPlainOutput(output, serverURL string) ([]activity.ActivityItem, error) {
 87	lines := strings.Split(output, "\n")
 88	if len(lines) < 2 {
 89		return nil, nil // No data
 90	}
 91
 92	// Skip header line
 93	var items []activity.ActivityItem
 94
 95	// Parse each line (tab-separated)
 96	// Format: KEY\tSUMMARY\tSTATUS\tUPDATED
 97	tabRe := regexp.MustCompile(`\t+`)
 98
 99	for _, line := range lines[1:] {
100		line = strings.TrimSpace(line)
101		if line == "" {
102			continue
103		}
104
105		fields := tabRe.Split(line, -1)
106		if len(fields) < 4 {
107			continue
108		}
109
110		key := strings.TrimSpace(fields[0])
111		summary := strings.TrimSpace(fields[1])
112		status := strings.TrimSpace(fields[2])
113		updated := strings.TrimSpace(fields[3])
114
115		ts, err := parseJiraTimestamp(updated)
116		if err != nil {
117			continue
118		}
119
120		items = append(items, activity.ActivityItem{
121			ID:        fmt.Sprintf("jira:%s", key),
122			Title:     summary,
123			Type:      "issue_updated",
124			Category:  activity.CategoryJira,
125			Timestamp: ts,
126			URL:       fmt.Sprintf("%s/browse/%s", serverURL, key),
127			Metadata: map[string]string{
128				"key":    key,
129				"status": status,
130			},
131		})
132	}
133
134	return items, nil
135}
136
137// parseJiraTimestamp parses Jira's timestamp format
138func parseJiraTimestamp(s string) (time.Time, error) {
139	// Format: 2026-01-26 15:55:24
140	return time.Parse("2006-01-02 15:04:05", s)
141}