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