auto-update-daily-20260202
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}