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}