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}