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}