flake-update-20260505
  1// Package github provides GitHub issue/PR fetching via the gh CLI.
  2package github
  3
  4import (
  5	"bytes"
  6	"context"
  7	"encoding/json"
  8	"fmt"
  9	"os/exec"
 10	"strings"
 11	"time"
 12)
 13
 14// Item represents a GitHub issue or PR.
 15type Item struct {
 16	Repo      string
 17	Number    int
 18	Title     string
 19	Author    string
 20	CreatedAt time.Time
 21	ClosedAt  *time.Time
 22	Kind      string // "issue", "pr"
 23}
 24
 25// Ref returns "org/repo#123" format.
 26func (i Item) Ref() string {
 27	return fmt.Sprintf("%s#%d", i.Repo, i.Number)
 28}
 29
 30// URL returns the GitHub URL.
 31func (i Item) URL() string {
 32	kind := "issues"
 33	if i.Kind == "pr" {
 34		kind = "pull"
 35	}
 36	return fmt.Sprintf("https://github.com/%s/%s/%d", i.Repo, kind, i.Number)
 37}
 38
 39type ghSearchResult struct {
 40	Repository struct {
 41		NameWithOwner string `json:"nameWithOwner"`
 42	} `json:"repository"`
 43	Number int    `json:"number"`
 44	Title  string `json:"title"`
 45	Author struct {
 46		Login string `json:"login"`
 47	} `json:"author"`
 48	CreatedAt time.Time  `json:"createdAt"`
 49	ClosedAt  *time.Time `json:"closedAt"`
 50}
 51
 52// FetchAssignedIssues returns open issues assigned to user across owners.
 53func FetchAssignedIssues(ctx context.Context, username string, owners []string) ([]Item, error) {
 54	args := []string{
 55		"search", "issues",
 56		"--assignee", username,
 57		"--state", "open",
 58		"--limit", "30",
 59		"--json", "repository,number,title,createdAt,author",
 60	}
 61	args = append(args, ownerFlags(owners)...)
 62	return runGHSearch(ctx, args, "issue")
 63}
 64
 65// FetchReviewRequests returns open PRs where review is requested from user.
 66func FetchReviewRequests(ctx context.Context, username string, owners []string) ([]Item, error) {
 67	args := []string{
 68		"search", "prs",
 69		"--review-requested", username,
 70		"--state", "open",
 71		"--limit", "30",
 72		"--json", "repository,number,title,createdAt,author",
 73	}
 74	args = append(args, ownerFlags(owners)...)
 75	return runGHSearch(ctx, args, "pr")
 76}
 77
 78// FetchMyPRs returns open PRs authored by user.
 79func FetchMyPRs(ctx context.Context, username string, owners []string) ([]Item, error) {
 80	args := []string{
 81		"search", "prs",
 82		"--author", username,
 83		"--state", "open",
 84		"--limit", "30",
 85		"--json", "repository,number,title,createdAt",
 86	}
 87	args = append(args, ownerFlags(owners)...)
 88	return runGHSearch(ctx, args, "pr")
 89}
 90
 91// FetchNewIssuesSince returns issues created since the given date.
 92func FetchNewIssuesSince(ctx context.Context, since time.Time, owners []string) ([]Item, error) {
 93	args := []string{
 94		"search", "issues",
 95		"--state", "open",
 96		"--created", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
 97		"--limit", "30",
 98		"--json", "repository,number,title,createdAt,author",
 99	}
100	args = append(args, ownerFlags(owners)...)
101	return runGHSearch(ctx, args, "issue")
102}
103
104// FetchNewPRsSince returns PRs created since the given date.
105func FetchNewPRsSince(ctx context.Context, since time.Time, owners []string) ([]Item, error) {
106	args := []string{
107		"search", "prs",
108		"--state", "open",
109		"--created", fmt.Sprintf(">=%s", since.Format("2006-01-02")),
110		"--limit", "30",
111		"--json", "repository,number,title,createdAt,author",
112	}
113	args = append(args, ownerFlags(owners)...)
114	return runGHSearch(ctx, args, "pr")
115}
116
117// FetchMergedPRsSince returns PRs by user merged since the given date.
118// Splits the query by month to avoid GitHub Search API result limits.
119func FetchMergedPRsSince(ctx context.Context, username string, since time.Time, owners []string) ([]Item, error) {
120	return fetchByMonth(ctx, since, func(start, end string) ([]Item, error) {
121		args := []string{
122			"search", "prs",
123			"--author", username,
124			"--merged",
125			"--merged-at", start + ".." + end,
126			"--limit", "1000",
127			"--json", "repository,number,title,closedAt",
128		}
129		args = append(args, ownerFlags(owners)...)
130		return runGHSearch(ctx, args, "pr")
131	})
132}
133
134// FetchReviewsGivenSince fetches PRs reviewed by the user since a given date.
135// Splits the query by month to avoid GitHub Search API result limits.
136func FetchReviewsGivenSince(ctx context.Context, username string, since time.Time, owners []string) ([]Item, error) {
137	items, err := fetchByMonth(ctx, since, func(start, end string) ([]Item, error) {
138		args := []string{
139			"search", "prs",
140			"--reviewed-by", username,
141			"--merged",
142			"--merged-at", start + ".." + end,
143			"--limit", "1000",
144			"--json", "repository,number,title,author,createdAt,closedAt",
145		}
146		args = append(args, ownerFlags(owners)...)
147		return runGHSearch(ctx, args, "pr")
148	})
149	if err != nil {
150		return nil, err
151	}
152	// Filter out own PRs (already shown in merged section)
153	var reviews []Item
154	for _, item := range items {
155		if item.Author != username {
156			reviews = append(reviews, item)
157		}
158	}
159	return reviews, nil
160}
161
162// FetchIssuesCreatedSince returns issues created by user since the given date.
163// Splits the query by month to avoid GitHub Search API result limits.
164func FetchIssuesCreatedSince(ctx context.Context, username string, since time.Time, owners []string) ([]Item, error) {
165	return fetchByMonth(ctx, since, func(start, end string) ([]Item, error) {
166		args := []string{
167			"search", "issues",
168			"--author", username,
169			"--created", start + ".." + end,
170			"--limit", "1000",
171			"--json", "repository,number,title,createdAt,author",
172		}
173		args = append(args, ownerFlags(owners)...)
174		return runGHSearch(ctx, args, "issue")
175	})
176}
177
178// DiscussionItem represents a GitHub discussion or discussion comment.
179type DiscussionItem struct {
180	Repo      string
181	Title     string
182	URL       string
183	Category  string // discussion category (e.g. "General", "RFC")
184	Type      string // "discussion" or "discussion_comment"
185	CreatedAt time.Time
186}
187
188// CommentItem represents a comment on an issue or PR.
189type CommentItem struct {
190	Repo        string
191	IssueTitle  string
192	IssueNumber int
193	URL         string
194	CreatedAt   time.Time
195}
196
197// graphQL response types
198
199type gqlDiscussionResponse struct {
200	Data struct {
201		Viewer struct {
202			RepositoryDiscussions struct {
203				Nodes []struct {
204					Title     string `json:"title"`
205					URL       string `json:"url"`
206					CreatedAt string `json:"createdAt"`
207					Category  struct {
208						Name string `json:"name"`
209					} `json:"category"`
210					Repository struct {
211						NameWithOwner string `json:"nameWithOwner"`
212					} `json:"repository"`
213				} `json:"nodes"`
214			} `json:"repositoryDiscussions"`
215			RepositoryDiscussionComments struct {
216				Nodes []struct {
217					Discussion struct {
218						Title      string `json:"title"`
219						URL        string `json:"url"`
220						Repository struct {
221							NameWithOwner string `json:"nameWithOwner"`
222						} `json:"repository"`
223					} `json:"discussion"`
224					CreatedAt string `json:"createdAt"`
225					URL       string `json:"url"`
226				} `json:"nodes"`
227			} `json:"repositoryDiscussionComments"`
228		} `json:"viewer"`
229	} `json:"data"`
230}
231
232type gqlCommentsResponse struct {
233	Data struct {
234		Viewer struct {
235			IssueComments struct {
236				Nodes []struct {
237					URL       string `json:"url"`
238					CreatedAt string `json:"createdAt"`
239					Issue     struct {
240						Title      string `json:"title"`
241						Number     int    `json:"number"`
242						Repository struct {
243							NameWithOwner string `json:"nameWithOwner"`
244						} `json:"repository"`
245					} `json:"issue"`
246				} `json:"nodes"`
247			} `json:"issueComments"`
248		} `json:"viewer"`
249	} `json:"data"`
250}
251
252// FetchDiscussionsSince fetches discussions authored by or commented on by the user.
253func FetchDiscussionsSince(ctx context.Context, since time.Time) ([]DiscussionItem, error) {
254	query := `{
255  viewer {
256    repositoryDiscussions(first: 50, orderBy: {field: CREATED_AT, direction: DESC}) {
257      nodes {
258        title
259        url
260        createdAt
261        category { name }
262        repository { nameWithOwner }
263      }
264    }
265    repositoryDiscussionComments(first: 50) {
266      nodes {
267        discussion {
268          title
269          url
270          repository { nameWithOwner }
271        }
272        createdAt
273        url
274      }
275    }
276  }
277}`
278	cmd := exec.CommandContext(ctx, "gh", "api", "graphql", "-f", fmt.Sprintf("query=%s", query))
279	var stdout, stderr bytes.Buffer
280	cmd.Stdout = &stdout
281	cmd.Stderr = &stderr
282
283	if err := cmd.Run(); err != nil {
284		return nil, fmt.Errorf("gh api graphql (discussions): %s", stderr.String())
285	}
286
287	var resp gqlDiscussionResponse
288	if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
289		return nil, fmt.Errorf("json parse error: %w", err)
290	}
291
292	var items []DiscussionItem
293	seen := make(map[string]bool)
294
295	for _, d := range resp.Data.Viewer.RepositoryDiscussions.Nodes {
296		ts, _ := time.Parse(time.RFC3339, d.CreatedAt)
297		if ts.Before(since) || seen[d.URL] {
298			continue
299		}
300		seen[d.URL] = true
301		items = append(items, DiscussionItem{
302			Repo:      d.Repository.NameWithOwner,
303			Title:     d.Title,
304			URL:       d.URL,
305			Category:  d.Category.Name,
306			Type:      "discussion",
307			CreatedAt: ts,
308		})
309	}
310
311	for _, c := range resp.Data.Viewer.RepositoryDiscussionComments.Nodes {
312		ts, _ := time.Parse(time.RFC3339, c.CreatedAt)
313		if ts.Before(since) || seen[c.URL] {
314			continue
315		}
316		seen[c.URL] = true
317		items = append(items, DiscussionItem{
318			Repo:      c.Discussion.Repository.NameWithOwner,
319			Title:     fmt.Sprintf("Comment on: %s", c.Discussion.Title),
320			URL:       c.URL,
321			Category:  "",
322			Type:      "discussion_comment",
323			CreatedAt: ts,
324		})
325	}
326
327	return items, nil
328}
329
330// FetchCommentsSince fetches issue/PR comments by the user since a given date.
331func FetchCommentsSince(ctx context.Context, since time.Time) ([]CommentItem, error) {
332	query := `{
333  viewer {
334    issueComments(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) {
335      nodes {
336        url
337        createdAt
338        issue {
339          title
340          number
341          repository { nameWithOwner }
342        }
343      }
344    }
345  }
346}`
347	cmd := exec.CommandContext(ctx, "gh", "api", "graphql", "-f", fmt.Sprintf("query=%s", query))
348	var stdout, stderr bytes.Buffer
349	cmd.Stdout = &stdout
350	cmd.Stderr = &stderr
351
352	if err := cmd.Run(); err != nil {
353		return nil, fmt.Errorf("gh api graphql (comments): %s", stderr.String())
354	}
355
356	var resp gqlCommentsResponse
357	if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
358		return nil, fmt.Errorf("json parse error: %w", err)
359	}
360
361	var items []CommentItem
362	seen := make(map[string]bool)
363
364	for _, c := range resp.Data.Viewer.IssueComments.Nodes {
365		ts, _ := time.Parse(time.RFC3339, c.CreatedAt)
366		if ts.Before(since) || seen[c.URL] {
367			continue
368		}
369		seen[c.URL] = true
370		items = append(items, CommentItem{
371			Repo:        c.Issue.Repository.NameWithOwner,
372			IssueTitle:  c.Issue.Title,
373			IssueNumber: c.Issue.Number,
374			URL:         c.URL,
375			CreatedAt:   ts,
376		})
377	}
378
379	return items, nil
380}
381
382// FetchIssueSummary fetches title for a single GitHub issue.
383func FetchIssueSummary(ctx context.Context, repo string, number int) (string, error) {
384	cmd := exec.CommandContext(ctx, "gh", "issue", "view",
385		fmt.Sprintf("%d", number),
386		"--repo", repo,
387		"--json", "title",
388		"--jq", ".title",
389	)
390	var stdout bytes.Buffer
391	cmd.Stdout = &stdout
392	if err := cmd.Run(); err != nil {
393		return "", fmt.Errorf("failed to fetch %s#%d: %w", repo, number, err)
394	}
395	return strings.TrimSpace(stdout.String()), nil
396}
397
398// FilterBots removes items from bot authors.
399func FilterBots(items []Item, botPatterns []string) []Item {
400	var filtered []Item
401	for _, item := range items {
402		isBot := false
403		for _, pattern := range botPatterns {
404			if strings.Contains(strings.ToLower(item.Author), strings.ToLower(pattern)) {
405				isBot = true
406				break
407			}
408		}
409		if !isBot {
410			filtered = append(filtered, item)
411		}
412	}
413	return filtered
414}
415
416// SecurityAdvisory represents a GitHub security advisory (GHSA).
417type SecurityAdvisory struct {
418	GHSAID    string    `json:"ghsa_id"`
419	CVEID     string    `json:"cve_id"`
420	Summary   string    `json:"summary"`
421	Severity  string    `json:"severity"`
422	State     string    `json:"state"`
423	HTMLURL   string    `json:"html_url"`
424	Repo      string    `json:"-"` // filled in by caller
425	CreatedAt time.Time `json:"created_at"`
426	Credits   []string  `json:"-"` // credit logins, filled from raw response
427	Role      string    `json:"-"` // "credited", "collaborator", "author"
428}
429
430// FetchSecurityAdvisories fetches open/triage security advisories for specific repos.
431// Only queries repos explicitly listed to avoid excessive API calls.
432func FetchSecurityAdvisories(ctx context.Context, repos []string, states []string) ([]SecurityAdvisory, error) {
433	var all []SecurityAdvisory
434	for _, repo := range repos {
435		for _, state := range states {
436			advisories, err := fetchRepoAdvisories(ctx, repo, state)
437			if err != nil {
438				continue
439			}
440			for i := range advisories {
441				advisories[i].Repo = repo
442			}
443			all = append(all, advisories...)
444		}
445	}
446	return all, nil
447}
448
449// advisoryRaw is the raw JSON shape from the GitHub Security Advisories API,
450// including credits and collaborating_users for involvement filtering.
451type advisoryRaw struct {
452	GHSAID    string    `json:"ghsa_id"`
453	CVEID     string    `json:"cve_id"`
454	Summary   string    `json:"summary"`
455	Severity  string    `json:"severity"`
456	State     string    `json:"state"`
457	HTMLURL   string    `json:"html_url"`
458	CreatedAt time.Time `json:"created_at"`
459	Author    struct {
460		Login string `json:"login"`
461	} `json:"author"`
462	Credits []struct {
463		Login string `json:"login"`
464	} `json:"credits"`
465	CollaboratingUsers []struct {
466		Login string `json:"login"`
467	} `json:"collaborating_users"`
468}
469
470func fetchRepoAdvisories(ctx context.Context, repo, state string) ([]SecurityAdvisory, error) {
471	endpoint := fmt.Sprintf("repos/%s/security-advisories?state=%s&per_page=30", repo, state)
472	cmd := exec.CommandContext(ctx, "gh", "api", endpoint)
473	var stdout, stderr bytes.Buffer
474	cmd.Stdout = &stdout
475	cmd.Stderr = &stderr
476	if err := cmd.Run(); err != nil {
477		return nil, fmt.Errorf("gh api error: %s", stderr.String())
478	}
479
480	var raw []advisoryRaw
481	if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
482		return nil, err
483	}
484
485	advisories := make([]SecurityAdvisory, 0, len(raw))
486	for _, r := range raw {
487		var credits []string
488		for _, c := range r.Credits {
489			credits = append(credits, c.Login)
490		}
491		a := SecurityAdvisory{
492			GHSAID:    r.GHSAID,
493			CVEID:     r.CVEID,
494			Summary:   r.Summary,
495			Severity:  r.Severity,
496			State:     r.State,
497			HTMLURL:   r.HTMLURL,
498			CreatedAt: r.CreatedAt,
499			Credits:   credits,
500		}
501		advisories = append(advisories, a)
502	}
503	return advisories, nil
504}
505
506// userInvolved checks if a username appears in credits or as author.
507// We intentionally skip collaborating_users because that only indicates
508// access (e.g. via team membership like tekton-vmt), not active involvement.
509// Comments on advisories live in a private fork and are not accessible
510// via the REST API, so we cannot detect comment-based involvement.
511func userInvolved(raw advisoryRaw, username string) string {
512	if raw.Author.Login == username {
513		return "author"
514	}
515	for _, c := range raw.Credits {
516		if c.Login == username {
517			return "credited"
518		}
519	}
520	return ""
521}
522
523// DependabotAlert represents a Dependabot security alert.
524type DependabotAlert struct {
525	Number    int       `json:"number"`
526	State     string    `json:"state"`
527	Repo      string    `json:"-"` // filled in from response
528	HTMLURL   string    `json:"html_url"`
529	CreatedAt time.Time `json:"created_at"`
530	Severity  string    `json:"-"` // extracted from nested field
531	CVE       string    `json:"-"` // extracted from nested field
532	Summary   string    `json:"-"` // extracted from nested field
533	Package   string    `json:"-"` // extracted from nested field
534}
535
536type dependabotAlertRaw struct {
537	Number     int       `json:"number"`
538	State      string    `json:"state"`
539	HTMLURL    string    `json:"html_url"`
540	CreatedAt  time.Time `json:"created_at"`
541	Repository struct {
542		FullName string `json:"full_name"`
543	} `json:"repository"`
544	SecurityAdvisory struct {
545		CVEID    string `json:"cve_id"`
546		Summary  string `json:"summary"`
547		Severity string `json:"severity"`
548	} `json:"security_advisory"`
549	Dependency struct {
550		Package struct {
551			Name string `json:"name"`
552		} `json:"package"`
553	} `json:"dependency"`
554}
555
556// FetchDependabotAlerts fetches open dependabot alerts at org level.
557func FetchDependabotAlerts(ctx context.Context, owners []string, severity string) ([]DependabotAlert, error) {
558	var all []DependabotAlert
559	for _, org := range owners {
560		endpoint := fmt.Sprintf("orgs/%s/dependabot/alerts?state=open&sort=created&direction=desc&per_page=30", org)
561		if severity != "" {
562			endpoint += "&severity=" + severity
563		}
564		cmd := exec.CommandContext(ctx, "gh", "api", endpoint)
565		var stdout, stderr bytes.Buffer
566		cmd.Stdout = &stdout
567		cmd.Stderr = &stderr
568		if err := cmd.Run(); err != nil {
569			continue // Skip orgs we don't have access to
570		}
571
572		var raw []dependabotAlertRaw
573		if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
574			continue
575		}
576		for _, r := range raw {
577			all = append(all, DependabotAlert{
578				Number:    r.Number,
579				State:     r.State,
580				Repo:      r.Repository.FullName,
581				HTMLURL:   r.HTMLURL,
582				CreatedAt: r.CreatedAt,
583				Severity:  r.SecurityAdvisory.Severity,
584				CVE:       r.SecurityAdvisory.CVEID,
585				Summary:   r.SecurityAdvisory.Summary,
586				Package:   r.Dependency.Package.Name,
587			})
588		}
589	}
590	return all, nil
591}
592
593// fetchByMonth splits a date range into monthly chunks and runs the given
594// query function for each chunk, deduplicating results by repo+number.
595// This avoids the GitHub Search API's 1000-result-per-query limit.
596func fetchByMonth(ctx context.Context, since time.Time, queryFn func(start, end string) ([]Item, error)) ([]Item, error) {
597	now := time.Now()
598	var all []Item
599	seen := make(map[string]bool)
600
601	current := since
602	for current.Before(now) {
603		// End of this chunk: last day of the month or now, whichever is earlier
604		nextMonth := time.Date(current.Year(), current.Month()+1, 1, 0, 0, 0, 0, current.Location())
605		end := nextMonth.AddDate(0, 0, -1) // last day of current month
606		if end.After(now) {
607			end = now
608		}
609
610		startStr := current.Format("2006-01-02")
611		endStr := end.Format("2006-01-02")
612
613		items, err := queryFn(startStr, endStr)
614		if err != nil {
615			// Continue on error — partial data is better than none
616			current = nextMonth
617			continue
618		}
619
620		for _, item := range items {
621			key := fmt.Sprintf("%s#%d", item.Repo, item.Number)
622			if !seen[key] {
623				seen[key] = true
624				all = append(all, item)
625			}
626		}
627
628		current = nextMonth
629	}
630	return all, nil
631}
632
633// FetchSecurityAdvisoriesSince fetches security advisories created since the
634// given date where the specified user is involved (credited, collaborating, or author).
635func FetchSecurityAdvisoriesSince(ctx context.Context, repos []string, since time.Time, username string) ([]SecurityAdvisory, error) {
636	var all []SecurityAdvisory
637	for _, repo := range repos {
638		for _, state := range []string{"published", "closed", "draft", "triage"} {
639			// Fetch raw to check involvement
640			endpoint := fmt.Sprintf("repos/%s/security-advisories?state=%s&per_page=30", repo, state)
641			cmd := exec.CommandContext(ctx, "gh", "api", endpoint)
642			var stdout, stderr bytes.Buffer
643			cmd.Stdout = &stdout
644			cmd.Stderr = &stderr
645			if err := cmd.Run(); err != nil {
646				continue
647			}
648
649			var raw []advisoryRaw
650			if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
651				continue
652			}
653
654			for _, r := range raw {
655				if r.CreatedAt.Before(since) {
656					continue
657				}
658				role := userInvolved(r, username)
659				if role == "" {
660					continue
661				}
662				var credits []string
663				for _, c := range r.Credits {
664					credits = append(credits, c.Login)
665				}
666				all = append(all, SecurityAdvisory{
667					GHSAID:    r.GHSAID,
668					CVEID:     r.CVEID,
669					Summary:   r.Summary,
670					Severity:  r.Severity,
671					State:     r.State,
672					HTMLURL:   r.HTMLURL,
673					CreatedAt: r.CreatedAt,
674					Repo:      repo,
675					Credits:   credits,
676					Role:      role,
677				})
678			}
679		}
680	}
681	return all, nil
682}
683
684func ownerFlags(owners []string) []string {
685	var flags []string
686	for _, owner := range owners {
687		flags = append(flags, "--owner", owner)
688	}
689	return flags
690}
691
692func runGHSearch(ctx context.Context, args []string, kind string) ([]Item, error) {
693	cmd := exec.CommandContext(ctx, "gh", args...)
694	var stdout, stderr bytes.Buffer
695	cmd.Stdout = &stdout
696	cmd.Stderr = &stderr
697
698	if err := cmd.Run(); err != nil {
699		return nil, fmt.Errorf("gh error: %s", stderr.String())
700	}
701
702	var results []ghSearchResult
703	if err := json.Unmarshal(stdout.Bytes(), &results); err != nil {
704		return nil, fmt.Errorf("json parse error: %w", err)
705	}
706
707	items := make([]Item, 0, len(results))
708	for _, r := range results {
709		items = append(items, Item{
710			Repo:      r.Repository.NameWithOwner,
711			Number:    r.Number,
712			Title:     r.Title,
713			Author:    r.Author.Login,
714			CreatedAt: r.CreatedAt,
715			ClosedAt:  r.ClosedAt,
716			Kind:      kind,
717		})
718	}
719	return items, nil
720}