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}