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:"-"` // filled in by caller
438 CreatedAt time.Time `json:"created_at"`
439 Credits []string `json:"-"` // credit logins, filled from raw response
440 Role string `json:"-"` // "credited", "collaborator", "author"
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 Author struct {
473 Login string `json:"login"`
474 } `json:"author"`
475 Credits []struct {
476 Login string `json:"login"`
477 } `json:"credits"`
478 CollaboratingUsers []struct {
479 Login string `json:"login"`
480 } `json:"collaborating_users"`
481}
482
483func fetchRepoAdvisories(ctx context.Context, repo, state string) ([]SecurityAdvisory, error) {
484 endpoint := fmt.Sprintf("repos/%s/security-advisories?state=%s&per_page=30", repo, state)
485 cmd := exec.CommandContext(ctx, "gh", "api", endpoint)
486 var stdout, stderr bytes.Buffer
487 cmd.Stdout = &stdout
488 cmd.Stderr = &stderr
489 if err := cmd.Run(); err != nil {
490 return nil, fmt.Errorf("gh api error: %s", stderr.String())
491 }
492
493 var raw []advisoryRaw
494 if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
495 return nil, err
496 }
497
498 advisories := make([]SecurityAdvisory, 0, len(raw))
499 for _, r := range raw {
500 var credits []string
501 for _, c := range r.Credits {
502 credits = append(credits, c.Login)
503 }
504 a := SecurityAdvisory{
505 GHSAID: r.GHSAID,
506 CVEID: r.CVEID,
507 Summary: r.Summary,
508 Severity: r.Severity,
509 State: r.State,
510 HTMLURL: r.HTMLURL,
511 CreatedAt: r.CreatedAt,
512 Credits: credits,
513 }
514 advisories = append(advisories, a)
515 }
516 return advisories, nil
517}
518
519// userInvolved checks if a username appears in credits or as author.
520// We intentionally skip collaborating_users because that only indicates
521// access (e.g. via team membership like tekton-vmt), not active involvement.
522// Comments on advisories live in a private fork and are not accessible
523// via the REST API, so we cannot detect comment-based involvement.
524func userInvolved(raw advisoryRaw, username string) string {
525 if raw.Author.Login == username {
526 return "author"
527 }
528 for _, c := range raw.Credits {
529 if c.Login == username {
530 return "credited"
531 }
532 }
533 return ""
534}
535
536// DependabotAlert represents a Dependabot security alert.
537type DependabotAlert struct {
538 Number int `json:"number"`
539 State string `json:"state"`
540 Repo string `json:"-"` // filled in from response
541 HTMLURL string `json:"html_url"`
542 CreatedAt time.Time `json:"created_at"`
543 Severity string `json:"-"` // extracted from nested field
544 CVE string `json:"-"` // extracted from nested field
545 Summary string `json:"-"` // extracted from nested field
546 Package string `json:"-"` // extracted from nested field
547}
548
549type dependabotAlertRaw struct {
550 Number int `json:"number"`
551 State string `json:"state"`
552 HTMLURL string `json:"html_url"`
553 CreatedAt time.Time `json:"created_at"`
554 Repository struct {
555 FullName string `json:"full_name"`
556 } `json:"repository"`
557 SecurityAdvisory struct {
558 CVEID string `json:"cve_id"`
559 Summary string `json:"summary"`
560 Severity string `json:"severity"`
561 } `json:"security_advisory"`
562 Dependency struct {
563 Package struct {
564 Name string `json:"name"`
565 } `json:"package"`
566 } `json:"dependency"`
567}
568
569// FetchDependabotAlerts fetches open dependabot alerts at org level.
570func FetchDependabotAlerts(ctx context.Context, owners []string, severity string) ([]DependabotAlert, error) {
571 var all []DependabotAlert
572 for _, org := range owners {
573 endpoint := fmt.Sprintf("orgs/%s/dependabot/alerts?state=open&sort=created&direction=desc&per_page=30", org)
574 if severity != "" {
575 endpoint += "&severity=" + severity
576 }
577 cmd := exec.CommandContext(ctx, "gh", "api", endpoint)
578 var stdout, stderr bytes.Buffer
579 cmd.Stdout = &stdout
580 cmd.Stderr = &stderr
581 if err := cmd.Run(); err != nil {
582 continue // Skip orgs we don't have access to
583 }
584
585 var raw []dependabotAlertRaw
586 if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
587 continue
588 }
589 for _, r := range raw {
590 all = append(all, DependabotAlert{
591 Number: r.Number,
592 State: r.State,
593 Repo: r.Repository.FullName,
594 HTMLURL: r.HTMLURL,
595 CreatedAt: r.CreatedAt,
596 Severity: r.SecurityAdvisory.Severity,
597 CVE: r.SecurityAdvisory.CVEID,
598 Summary: r.SecurityAdvisory.Summary,
599 Package: r.Dependency.Package.Name,
600 })
601 }
602 }
603 return all, nil
604}
605
606// fetchByMonth splits a date range into monthly chunks and runs the given
607// query function for each chunk, deduplicating results by repo+number.
608// This avoids the GitHub Search API's 1000-result-per-query limit.
609func fetchByMonth(ctx context.Context, since time.Time, queryFn func(start, end string) ([]Item, error)) ([]Item, error) {
610 now := time.Now()
611 var all []Item
612 seen := make(map[string]bool)
613
614 current := since
615 for current.Before(now) {
616 // End of this chunk: last day of the month or now, whichever is earlier
617 nextMonth := time.Date(current.Year(), current.Month()+1, 1, 0, 0, 0, 0, current.Location())
618 end := nextMonth.AddDate(0, 0, -1) // last day of current month
619 if end.After(now) {
620 end = now
621 }
622
623 startStr := current.Format("2006-01-02")
624 endStr := end.Format("2006-01-02")
625
626 items, err := queryFn(startStr, endStr)
627 if err != nil {
628 // Continue on error — partial data is better than none
629 current = nextMonth
630 continue
631 }
632
633 for _, item := range items {
634 key := fmt.Sprintf("%s#%d", item.Repo, item.Number)
635 if !seen[key] {
636 seen[key] = true
637 all = append(all, item)
638 }
639 }
640
641 current = nextMonth
642 }
643 return all, nil
644}
645
646// FetchSecurityAdvisoriesSince fetches security advisories created since the
647// given date where the specified user is involved (credited, collaborating, or author).
648func FetchSecurityAdvisoriesSince(ctx context.Context, repos []string, since time.Time, username string) ([]SecurityAdvisory, error) {
649 var all []SecurityAdvisory
650 for _, repo := range repos {
651 for _, state := range []string{"published", "closed", "draft", "triage"} {
652 // Fetch raw to check involvement
653 endpoint := fmt.Sprintf("repos/%s/security-advisories?state=%s&per_page=30", repo, state)
654 cmd := exec.CommandContext(ctx, "gh", "api", endpoint)
655 var stdout, stderr bytes.Buffer
656 cmd.Stdout = &stdout
657 cmd.Stderr = &stderr
658 if err := cmd.Run(); err != nil {
659 continue
660 }
661
662 var raw []advisoryRaw
663 if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
664 continue
665 }
666
667 for _, r := range raw {
668 if r.CreatedAt.Before(since) {
669 continue
670 }
671 role := userInvolved(r, username)
672 if role == "" {
673 continue
674 }
675 var credits []string
676 for _, c := range r.Credits {
677 credits = append(credits, c.Login)
678 }
679 all = append(all, SecurityAdvisory{
680 GHSAID: r.GHSAID,
681 CVEID: r.CVEID,
682 Summary: r.Summary,
683 Severity: r.Severity,
684 State: r.State,
685 HTMLURL: r.HTMLURL,
686 CreatedAt: r.CreatedAt,
687 Repo: repo,
688 Credits: credits,
689 Role: role,
690 })
691 }
692 }
693 }
694 return all, nil
695}
696
697func ownerFlags(owners []string) []string {
698 var flags []string
699 for _, owner := range owners {
700 flags = append(flags, "--owner", owner)
701 }
702 return flags
703}
704
705func runGHSearch(ctx context.Context, args []string, kind string) ([]Item, error) {
706 cmd := exec.CommandContext(ctx, "gh", args...)
707 var stdout, stderr bytes.Buffer
708 cmd.Stdout = &stdout
709 cmd.Stderr = &stderr
710
711 if err := cmd.Run(); err != nil {
712 return nil, fmt.Errorf("gh error: %s", stderr.String())
713 }
714
715 var results []ghSearchResult
716 if err := json.Unmarshal(stdout.Bytes(), &results); err != nil {
717 return nil, fmt.Errorf("json parse error: %w", err)
718 }
719
720 items := make([]Item, 0, len(results))
721 for _, r := range results {
722 items = append(items, Item{
723 Repo: r.Repository.NameWithOwner,
724 Number: r.Number,
725 Title: r.Title,
726 Author: r.Author.Login,
727 CreatedAt: r.CreatedAt,
728 ClosedAt: r.ClosedAt,
729 Kind: kind,
730 })
731 }
732 return items, nil
733}