flake-update-20260201
1package sources
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "os/exec"
9 "time"
10
11 "github.com/vdemeester/home/tools/review-tool/internal/activity"
12 "github.com/vdemeester/home/tools/review-tool/internal/config"
13)
14
15// GitHubSource fetches activity from GitHub via the gh CLI.
16type GitHubSource struct {
17 cfg *config.GitHubConfig
18}
19
20// NewGitHubSource creates a new GitHub source.
21func NewGitHubSource(cfg *config.GitHubConfig) *GitHubSource {
22 return &GitHubSource{cfg: cfg}
23}
24
25// Name returns the source identifier.
26func (g *GitHubSource) Name() string {
27 return "github"
28}
29
30// Validate checks if gh CLI is available and authenticated.
31func (g *GitHubSource) Validate() error {
32 cmd := exec.Command("gh", "auth", "status")
33 if err := cmd.Run(); err != nil {
34 return fmt.Errorf("gh CLI not authenticated: %w", err)
35 }
36 return nil
37}
38
39// Fetch retrieves GitHub activities within the time range.
40func (g *GitHubSource) Fetch(ctx context.Context, start, end time.Time) (*activity.Activity, error) {
41 act := &activity.Activity{
42 Source: "github",
43 Items: []activity.ActivityItem{},
44 }
45
46 dateFilter := fmt.Sprintf(">%s", start.Format("2006-01-02"))
47
48 // Fetch merged PRs
49 if g.cfg.IncludePRs {
50 items, err := g.fetchPRs(ctx, dateFilter, "pr_merged", "--merged")
51 if err == nil {
52 act.Items = append(act.Items, filterByDateRange(items, start, end)...)
53 }
54 }
55
56 // Fetch reviewed PRs
57 if g.cfg.IncludeReviews {
58 items, err := g.fetchReviewedPRs(ctx, dateFilter)
59 if err == nil {
60 act.Items = append(act.Items, filterByDateRange(items, start, end)...)
61 }
62 }
63
64 // Fetch issues
65 if g.cfg.IncludeIssues {
66 items, err := g.fetchIssues(ctx, dateFilter)
67 if err == nil {
68 act.Items = append(act.Items, filterByDateRange(items, start, end)...)
69 }
70 }
71
72 // Fetch discussions
73 if g.cfg.IncludeDiscussions {
74 items, err := g.fetchDiscussions(ctx, start)
75 if err == nil {
76 act.Items = append(act.Items, filterByDateRange(items, start, end)...)
77 }
78 }
79
80 // Fetch comments
81 if g.cfg.IncludeComments {
82 items, err := g.fetchComments(ctx, start)
83 if err == nil {
84 act.Items = append(act.Items, filterByDateRange(items, start, end)...)
85 }
86 }
87
88 return act, nil
89}
90
91func (g *GitHubSource) fetchPRs(ctx context.Context, dateFilter, itemType, extraFlag string) ([]activity.ActivityItem, error) {
92 args := []string{
93 "search", "prs",
94 "--author=@me",
95 fmt.Sprintf("--merged-at=%s", dateFilter),
96 "--json", "number,title,url,repository,closedAt",
97 "--limit", "100",
98 }
99 if extraFlag != "" && extraFlag != "--merged" {
100 args = append(args, extraFlag)
101 }
102
103 cmd := exec.CommandContext(ctx, "gh", args...)
104 var stdout, stderr bytes.Buffer
105 cmd.Stdout = &stdout
106 cmd.Stderr = &stderr
107
108 if err := cmd.Run(); err != nil {
109 return nil, fmt.Errorf("gh search prs failed: %s", stderr.String())
110 }
111
112 return parsePRJSON(stdout.Bytes(), itemType)
113}
114
115func (g *GitHubSource) fetchReviewedPRs(ctx context.Context, dateFilter string) ([]activity.ActivityItem, error) {
116 args := []string{
117 "search", "prs",
118 "--reviewed-by=@me",
119 fmt.Sprintf("--updated=%s", dateFilter),
120 "--json", "number,title,url,repository,closedAt",
121 "--limit", "100",
122 }
123
124 cmd := exec.CommandContext(ctx, "gh", args...)
125 var stdout, stderr bytes.Buffer
126 cmd.Stdout = &stdout
127 cmd.Stderr = &stderr
128
129 if err := cmd.Run(); err != nil {
130 return nil, fmt.Errorf("gh search prs (reviewed) failed: %s", stderr.String())
131 }
132
133 return parsePRJSON(stdout.Bytes(), "pr_reviewed")
134}
135
136func (g *GitHubSource) fetchIssues(ctx context.Context, dateFilter string) ([]activity.ActivityItem, error) {
137 args := []string{
138 "search", "issues",
139 "--author=@me",
140 fmt.Sprintf("--created=%s", dateFilter),
141 "--json", "number,title,url,repository,createdAt",
142 "--limit", "100",
143 }
144
145 cmd := exec.CommandContext(ctx, "gh", args...)
146 var stdout, stderr bytes.Buffer
147 cmd.Stdout = &stdout
148 cmd.Stderr = &stderr
149
150 if err := cmd.Run(); err != nil {
151 return nil, fmt.Errorf("gh search issues failed: %s", stderr.String())
152 }
153
154 return parseIssueJSON(stdout.Bytes())
155}
156
157// prResult represents a PR from gh search
158type prResult struct {
159 Number int `json:"number"`
160 Title string `json:"title"`
161 URL string `json:"url"`
162 ClosedAt string `json:"closedAt"`
163 Repository struct {
164 Name string `json:"name"`
165 NameWithOwner string `json:"nameWithOwner"`
166 } `json:"repository"`
167}
168
169// issueResult represents an issue from gh search
170type issueResult struct {
171 Number int `json:"number"`
172 Title string `json:"title"`
173 URL string `json:"url"`
174 CreatedAt string `json:"createdAt"`
175 Repository struct {
176 Name string `json:"name"`
177 NameWithOwner string `json:"nameWithOwner"`
178 } `json:"repository"`
179}
180
181func parsePRJSON(data []byte, itemType string) ([]activity.ActivityItem, error) {
182 var prs []prResult
183 if err := json.Unmarshal(data, &prs); err != nil {
184 return nil, err
185 }
186
187 items := make([]activity.ActivityItem, 0, len(prs))
188 for _, pr := range prs {
189 ts, _ := time.Parse(time.RFC3339, pr.ClosedAt)
190
191 items = append(items, activity.ActivityItem{
192 ID: fmt.Sprintf("github:pr:%s:%d", pr.Repository.NameWithOwner, pr.Number),
193 Title: pr.Title,
194 Type: itemType,
195 Category: activity.CategoryGitHub,
196 Timestamp: ts,
197 URL: pr.URL,
198 Metadata: map[string]string{
199 "repository": pr.Repository.NameWithOwner,
200 "number": fmt.Sprintf("%d", pr.Number),
201 },
202 })
203 }
204
205 return items, nil
206}
207
208func parseIssueJSON(data []byte) ([]activity.ActivityItem, error) {
209 var issues []issueResult
210 if err := json.Unmarshal(data, &issues); err != nil {
211 return nil, err
212 }
213
214 items := make([]activity.ActivityItem, 0, len(issues))
215 for _, issue := range issues {
216 ts, _ := time.Parse(time.RFC3339, issue.CreatedAt)
217
218 items = append(items, activity.ActivityItem{
219 ID: fmt.Sprintf("github:issue:%s:%d", issue.Repository.NameWithOwner, issue.Number),
220 Title: issue.Title,
221 Type: "issue_created",
222 Category: activity.CategoryGitHub,
223 Timestamp: ts,
224 URL: issue.URL,
225 Metadata: map[string]string{
226 "repository": issue.Repository.NameWithOwner,
227 "number": fmt.Sprintf("%d", issue.Number),
228 },
229 })
230 }
231
232 return items, nil
233}
234
235func (g *GitHubSource) fetchDiscussions(ctx context.Context, since time.Time) ([]activity.ActivityItem, error) {
236 // Use GraphQL to search for discussions authored by the user
237 query := `{
238 viewer {
239 repositoryDiscussions(first: 50, orderBy: {field: CREATED_AT, direction: DESC}) {
240 nodes {
241 title
242 url
243 createdAt
244 category {
245 name
246 }
247 repository {
248 nameWithOwner
249 }
250 }
251 }
252 repositoryDiscussionComments(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) {
253 nodes {
254 discussion {
255 title
256 url
257 repository {
258 nameWithOwner
259 }
260 }
261 createdAt
262 url
263 }
264 }
265 }
266}`
267 args := []string{
268 "api", "graphql",
269 "-f", fmt.Sprintf("query=%s", query),
270 }
271
272 cmd := exec.CommandContext(ctx, "gh", args...)
273 var stdout, stderr bytes.Buffer
274 cmd.Stdout = &stdout
275 cmd.Stderr = &stderr
276
277 if err := cmd.Run(); err != nil {
278 return nil, fmt.Errorf("gh api graphql failed: %s", stderr.String())
279 }
280
281 return parseDiscussionGraphQL(stdout.Bytes(), since)
282}
283
284// graphqlDiscussionResponse represents the GraphQL response
285type graphqlDiscussionResponse struct {
286 Data struct {
287 Viewer struct {
288 RepositoryDiscussions struct {
289 Nodes []struct {
290 Title string `json:"title"`
291 URL string `json:"url"`
292 CreatedAt string `json:"createdAt"`
293 Category struct {
294 Name string `json:"name"`
295 } `json:"category"`
296 Repository struct {
297 NameWithOwner string `json:"nameWithOwner"`
298 } `json:"repository"`
299 } `json:"nodes"`
300 } `json:"repositoryDiscussions"`
301 RepositoryDiscussionComments struct {
302 Nodes []struct {
303 Discussion struct {
304 Title string `json:"title"`
305 URL string `json:"url"`
306 Repository struct {
307 NameWithOwner string `json:"nameWithOwner"`
308 } `json:"repository"`
309 } `json:"discussion"`
310 CreatedAt string `json:"createdAt"`
311 URL string `json:"url"`
312 } `json:"nodes"`
313 } `json:"repositoryDiscussionComments"`
314 } `json:"viewer"`
315 } `json:"data"`
316}
317
318func parseDiscussionGraphQL(data []byte, since time.Time) ([]activity.ActivityItem, error) {
319 var resp graphqlDiscussionResponse
320 if err := json.Unmarshal(data, &resp); err != nil {
321 return nil, err
322 }
323
324 var items []activity.ActivityItem
325 seen := make(map[string]bool)
326
327 // Add discussions authored by user
328 for _, d := range resp.Data.Viewer.RepositoryDiscussions.Nodes {
329 ts, _ := time.Parse(time.RFC3339, d.CreatedAt)
330 if ts.Before(since) {
331 continue
332 }
333 if seen[d.URL] {
334 continue
335 }
336 seen[d.URL] = true
337
338 items = append(items, activity.ActivityItem{
339 ID: fmt.Sprintf("github:discussion:%s", d.URL),
340 Title: d.Title,
341 Type: "discussion",
342 Category: activity.CategoryGitHub,
343 Timestamp: ts,
344 URL: d.URL,
345 Metadata: map[string]string{
346 "repository": d.Repository.NameWithOwner,
347 "category": d.Category.Name,
348 },
349 })
350 }
351
352 // Add discussions where user commented
353 for _, c := range resp.Data.Viewer.RepositoryDiscussionComments.Nodes {
354 ts, _ := time.Parse(time.RFC3339, c.CreatedAt)
355 if ts.Before(since) {
356 continue
357 }
358 if seen[c.URL] {
359 continue
360 }
361 seen[c.URL] = true
362
363 items = append(items, activity.ActivityItem{
364 ID: fmt.Sprintf("github:discussion_comment:%s", c.URL),
365 Title: fmt.Sprintf("Comment on discussion: %s", c.Discussion.Title),
366 Type: "discussion_comment",
367 Category: activity.CategoryGitHub,
368 Timestamp: ts,
369 URL: c.URL,
370 Metadata: map[string]string{
371 "repository": c.Discussion.Repository.NameWithOwner,
372 "discussion_title": c.Discussion.Title,
373 },
374 })
375 }
376
377 return items, nil
378}
379
380func (g *GitHubSource) fetchComments(ctx context.Context, since time.Time) ([]activity.ActivityItem, error) {
381 // Use GraphQL to get issue/PR comments by the user
382 query := `{
383 viewer {
384 issueComments(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) {
385 nodes {
386 body
387 url
388 createdAt
389 issue {
390 title
391 number
392 repository {
393 nameWithOwner
394 }
395 }
396 }
397 }
398 }
399}`
400 args := []string{
401 "api", "graphql",
402 "-f", fmt.Sprintf("query=%s", query),
403 }
404
405 cmd := exec.CommandContext(ctx, "gh", args...)
406 var stdout, stderr bytes.Buffer
407 cmd.Stdout = &stdout
408 cmd.Stderr = &stderr
409
410 if err := cmd.Run(); err != nil {
411 return nil, fmt.Errorf("gh api graphql (comments) failed: %s", stderr.String())
412 }
413
414 return parseCommentsGraphQL(stdout.Bytes(), since)
415}
416
417// graphqlCommentsResponse represents the GraphQL response for comments
418type graphqlCommentsResponse struct {
419 Data struct {
420 Viewer struct {
421 IssueComments struct {
422 Nodes []struct {
423 Body string `json:"body"`
424 URL string `json:"url"`
425 CreatedAt string `json:"createdAt"`
426 Issue struct {
427 Title string `json:"title"`
428 Number int `json:"number"`
429 Repository struct {
430 NameWithOwner string `json:"nameWithOwner"`
431 } `json:"repository"`
432 } `json:"issue"`
433 } `json:"nodes"`
434 } `json:"issueComments"`
435 } `json:"viewer"`
436 } `json:"data"`
437}
438
439func parseCommentsGraphQL(data []byte, since time.Time) ([]activity.ActivityItem, error) {
440 var resp graphqlCommentsResponse
441 if err := json.Unmarshal(data, &resp); err != nil {
442 return nil, err
443 }
444
445 var items []activity.ActivityItem
446 seen := make(map[string]bool)
447
448 for _, c := range resp.Data.Viewer.IssueComments.Nodes {
449 ts, _ := time.Parse(time.RFC3339, c.CreatedAt)
450 if ts.Before(since) {
451 continue
452 }
453 if seen[c.URL] {
454 continue
455 }
456 seen[c.URL] = true
457
458 items = append(items, activity.ActivityItem{
459 ID: fmt.Sprintf("github:comment:%s", c.URL),
460 Title: fmt.Sprintf("Comment on: %s", c.Issue.Title),
461 Type: "comment",
462 Category: activity.CategoryGitHub,
463 Timestamp: ts,
464 URL: c.URL,
465 Metadata: map[string]string{
466 "repository": c.Issue.Repository.NameWithOwner,
467 "issue_number": fmt.Sprintf("%d", c.Issue.Number),
468 "issue_title": c.Issue.Title,
469 },
470 })
471 }
472
473 return items, nil
474}
475
476func filterByDateRange(items []activity.ActivityItem, start, end time.Time) []activity.ActivityItem {
477 filtered := make([]activity.ActivityItem, 0)
478 for _, item := range items {
479 if !item.Timestamp.Before(start) && !item.Timestamp.After(end) {
480 filtered = append(filtered, item)
481 }
482 }
483 return filtered
484}
485
486// discussionResult represents a discussion from GraphQL API
487type discussionResult struct {
488 Title string `json:"title"`
489 URL string `json:"url"`
490 CreatedAt string `json:"createdAt"`
491 Repository string `json:"repository"`
492 Category string `json:"category"`
493}
494
495// commentResult represents a comment from GraphQL API
496type commentResult struct {
497 Body string `json:"body"`
498 URL string `json:"url"`
499 CreatedAt string `json:"createdAt"`
500 Repository string `json:"repository"`
501 IssueTitle string `json:"issueTitle"`
502 IssueNumber int `json:"issueNumber"`
503}
504
505func parseDiscussionJSON(data []byte) ([]activity.ActivityItem, error) {
506 var discussions []discussionResult
507 if err := json.Unmarshal(data, &discussions); err != nil {
508 return nil, err
509 }
510
511 items := make([]activity.ActivityItem, 0, len(discussions))
512 for _, d := range discussions {
513 ts, _ := time.Parse(time.RFC3339, d.CreatedAt)
514
515 items = append(items, activity.ActivityItem{
516 ID: fmt.Sprintf("github:discussion:%s", d.URL),
517 Title: d.Title,
518 Type: "discussion",
519 Category: activity.CategoryGitHub,
520 Timestamp: ts,
521 URL: d.URL,
522 Metadata: map[string]string{
523 "repository": d.Repository,
524 "category": d.Category,
525 },
526 })
527 }
528
529 return items, nil
530}
531
532func parseCommentJSON(data []byte) ([]activity.ActivityItem, error) {
533 var comments []commentResult
534 if err := json.Unmarshal(data, &comments); err != nil {
535 return nil, err
536 }
537
538 items := make([]activity.ActivityItem, 0, len(comments))
539 for _, c := range comments {
540 ts, _ := time.Parse(time.RFC3339, c.CreatedAt)
541
542 items = append(items, activity.ActivityItem{
543 ID: fmt.Sprintf("github:comment:%s", c.URL),
544 Title: fmt.Sprintf("Comment on: %s", c.IssueTitle),
545 Type: "comment",
546 Category: activity.CategoryGitHub,
547 Timestamp: ts,
548 URL: c.URL,
549 Metadata: map[string]string{
550 "repository": c.Repository,
551 "issue_number": fmt.Sprintf("%d", c.IssueNumber),
552 "issue_title": c.IssueTitle,
553 },
554 })
555 }
556
557 return items, nil
558}