cli/pkg/cmd/status/status.go
Mislav Marohnić 7ec2b2251d status: respect host configuration
Respect the default hostname determined from `hosts.yml` or GH_HOST
instead of always using `github.com`.
2022-04-08 14:54:40 +02:00

641 lines
15 KiB
Go

package status
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/charmbracelet/lipgloss"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
)
type hostConfig interface {
DefaultHost() (string, error)
}
type StatusOptions struct {
HttpClient func() (*http.Client, error)
HostConfig hostConfig
CachedClient func(*http.Client, time.Duration) *http.Client
IO *iostreams.IOStreams
Org string
Exclude []string
}
func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
opts := &StatusOptions{
CachedClient: func(c *http.Client, ttl time.Duration) *http.Client {
return api.NewCachedClient(c, ttl)
},
}
opts.HttpClient = f.HttpClient
opts.IO = f.IOStreams
cmd := &cobra.Command{
Use: "status",
Short: "Print information about relevant issues, pull requests, and notifications across repositories",
Long: heredoc.Doc(`
The status command prints information about your work on GitHub across all the repositories you're subscribed to, including:
- Assigned Issues
- Assigned Pull Requests
- Review Requests
- Mentions
- Repository Activity (new issues/pull requests, comments)
`),
Example: heredoc.Doc(`
$ gh status -e cli/cli -e cli/go-gh # Exclude multiple repositories
$ gh status -o cli # Limit results to a single organization
`),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
opts.HostConfig = cfg
if runF != nil {
return runF(opts)
}
return statusRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Org, "org", "o", "", "Report status within an organization")
cmd.Flags().StringSliceVarP(&opts.Exclude, "exclude", "e", []string{}, "Comma separated list of repos to exclude in owner/name format")
return cmd
}
type Notification struct {
Reason string
Subject struct {
Title string
LatestCommentURL string `json:"latest_comment_url"`
URL string
Type string
}
Repository struct {
Owner struct {
Login string
}
FullName string `json:"full_name"`
}
}
type StatusItem struct {
Repository string // owner/repo
Identifier string // eg cli/cli#1234 or just 1234
preview string // eg This is the truncated body of something...
Reason string // only used in repo activity
}
func (s StatusItem) Preview() string {
return strings.ReplaceAll(strings.ReplaceAll(s.preview, "\r", ""), "\n", " ")
}
type IssueOrPR struct {
Number int
Title string
}
type Event struct {
Type string
Org struct {
Login string
}
CreatedAt time.Time `json:"created_at"`
Repo struct {
Name string // owner/repo
}
Payload struct {
Action string
Issue IssueOrPR
PullRequest IssueOrPR `json:"pull_request"`
Comment struct {
Body string
HTMLURL string `json:"html_url"`
}
}
}
type SearchResult struct {
Type string `json:"__typename"`
UpdatedAt time.Time
Title string
Number int
Repository struct {
NameWithOwner string
}
}
type Results []SearchResult
func (rs Results) Len() int {
return len(rs)
}
func (rs Results) Less(i, j int) bool {
return rs[i].UpdatedAt.After(rs[j].UpdatedAt)
}
func (rs Results) Swap(i, j int) {
rs[i], rs[j] = rs[j], rs[i]
}
type StatusGetter struct {
Client *http.Client
cachedClient func(*http.Client, time.Duration) *http.Client
host string
Org string
Exclude []string
AssignedPRs []StatusItem
AssignedIssues []StatusItem
Mentions []StatusItem
ReviewRequests []StatusItem
RepoActivity []StatusItem
}
func NewStatusGetter(client *http.Client, hostname string, opts *StatusOptions) *StatusGetter {
return &StatusGetter{
Client: client,
Org: opts.Org,
Exclude: opts.Exclude,
cachedClient: opts.CachedClient,
host: hostname,
}
}
func (s *StatusGetter) hostname() string {
return s.host
}
func (s *StatusGetter) CachedClient(ttl time.Duration) *http.Client {
return s.cachedClient(s.Client, ttl)
}
func (s *StatusGetter) ShouldExclude(repo string) bool {
for _, exclude := range s.Exclude {
if repo == exclude {
return true
}
}
return false
}
func (s *StatusGetter) CurrentUsername() (string, error) {
cachedClient := s.CachedClient(time.Hour * 48)
cachingAPIClient := api.NewClientFromHTTP(cachedClient)
currentUsername, err := api.CurrentLoginName(cachingAPIClient, s.hostname())
if err != nil {
return "", fmt.Errorf("failed to get current username: %w", err)
}
return currentUsername, nil
}
func (s *StatusGetter) ActualMention(n Notification) (string, error) {
currentUsername, err := s.CurrentUsername()
if err != nil {
return "", err
}
// long cache period since once a comment is looked up, it never needs to be
// consulted again.
cachedClient := s.CachedClient(time.Hour * 24 * 30)
c := api.NewClientFromHTTP(cachedClient)
resp := struct {
Body string
}{}
if err := c.REST(s.hostname(), "GET", n.Subject.LatestCommentURL, nil, &resp); err != nil {
return "", err
}
var ret string
if strings.Contains(resp.Body, "@"+currentUsername) {
ret = resp.Body
}
return ret, nil
}
// These are split up by endpoint since it is along that boundary we parallelize
// work
// Populate .Mentions
func (s *StatusGetter) LoadNotifications() error {
perPage := 100
c := api.NewClientFromHTTP(s.Client)
query := url.Values{}
query.Add("per_page", fmt.Sprintf("%d", perPage))
query.Add("participating", "true")
query.Add("all", "true")
// this sucks, having to fetch so much :/ but it was the only way in my
// testing to really get enough mentions. I would love to be able to just
// filter for mentions but it does not seem like the notifications API can
// do that. I'd switch to the GraphQL version, but to my knowledge that does
// not work with PATs right now.
var ns []Notification
var resp []Notification
pages := 0
p := fmt.Sprintf("notifications?%s", query.Encode())
for pages < 3 {
next, err := c.RESTWithNext(s.hostname(), "GET", p, nil, &resp)
if err != nil {
var httpErr api.HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 {
return fmt.Errorf("could not get notifications: %w", err)
}
}
ns = append(ns, resp...)
if next == "" || len(resp) < perPage {
break
}
pages++
p = next
}
s.Mentions = []StatusItem{}
for _, n := range ns {
if n.Reason != "mention" {
continue
}
if s.Org != "" && n.Repository.Owner.Login != s.Org {
continue
}
if s.ShouldExclude(n.Repository.FullName) {
continue
}
if actual, err := s.ActualMention(n); actual != "" && err == nil {
// I'm so sorry
split := strings.Split(n.Subject.URL, "/")
s.Mentions = append(s.Mentions, StatusItem{
Repository: n.Repository.FullName,
Identifier: fmt.Sprintf("%s#%s", n.Repository.FullName, split[len(split)-1]),
preview: actual,
})
} else if err != nil {
return fmt.Errorf("could not fetch comment: %w", err)
}
}
return nil
}
func (s *StatusGetter) buildSearchQuery() string {
q := `
query AssignedSearch {
assignments: search(first: 25, type: ISSUE, query:"%s") {
edges {
node {
...on Issue {
__typename
updatedAt
title
number
repository {
nameWithOwner
}
}
...on PullRequest {
updatedAt
__typename
title
number
repository {
nameWithOwner
}
}
}
}
}
reviewRequested: search(first: 25, type: ISSUE, query:"%s") {
edges {
node {
...on PullRequest {
updatedAt
__typename
title
number
repository {
nameWithOwner
}
}
}
}
}
}`
assignmentsQ := `assignee:@me state:open%s%s`
requestedQ := `state:open review-requested:@me%s%s`
orgFilter := ""
if s.Org != "" {
orgFilter = " org:" + s.Org
}
excludeFilter := ""
for _, repo := range s.Exclude {
excludeFilter += " -repo:" + repo
}
assignmentsQ = fmt.Sprintf(assignmentsQ, orgFilter, excludeFilter)
requestedQ = fmt.Sprintf(requestedQ, orgFilter, excludeFilter)
return fmt.Sprintf(q, assignmentsQ, requestedQ)
}
// Populate .AssignedPRs, .AssignedIssues, .ReviewRequests
func (s *StatusGetter) LoadSearchResults() error {
q := s.buildSearchQuery()
c := api.NewClientFromHTTP(s.Client)
var resp struct {
Assignments struct {
Edges []struct {
Node SearchResult
}
}
ReviewRequested struct {
Edges []struct {
Node SearchResult
}
}
}
err := c.GraphQL(s.hostname(), q, nil, &resp)
if err != nil {
return fmt.Errorf("could not search for assignments: %w", err)
}
prs := []SearchResult{}
issues := []SearchResult{}
reviewRequested := []SearchResult{}
for _, e := range resp.Assignments.Edges {
if e.Node.Type == "Issue" {
issues = append(issues, e.Node)
} else if e.Node.Type == "PullRequest" {
prs = append(prs, e.Node)
} else {
panic("you shouldn't be here")
}
}
for _, e := range resp.ReviewRequested.Edges {
reviewRequested = append(reviewRequested, e.Node)
}
sort.Sort(Results(issues))
sort.Sort(Results(prs))
sort.Sort(Results(reviewRequested))
s.AssignedIssues = []StatusItem{}
s.AssignedPRs = []StatusItem{}
s.ReviewRequests = []StatusItem{}
for _, i := range issues {
s.AssignedIssues = append(s.AssignedIssues, StatusItem{
Repository: i.Repository.NameWithOwner,
Identifier: fmt.Sprintf("%s#%d", i.Repository.NameWithOwner, i.Number),
preview: i.Title,
})
}
for _, pr := range prs {
s.AssignedPRs = append(s.AssignedPRs, StatusItem{
Repository: pr.Repository.NameWithOwner,
Identifier: fmt.Sprintf("%s#%d", pr.Repository.NameWithOwner, pr.Number),
preview: pr.Title,
})
}
for _, r := range reviewRequested {
s.ReviewRequests = append(s.ReviewRequests, StatusItem{
Repository: r.Repository.NameWithOwner,
Identifier: fmt.Sprintf("%s#%d", r.Repository.NameWithOwner, r.Number),
preview: r.Title,
})
}
return nil
}
// Populate .RepoActivity
func (s *StatusGetter) LoadEvents() error {
perPage := 100
c := api.NewClientFromHTTP(s.Client)
query := url.Values{}
query.Add("per_page", fmt.Sprintf("%d", perPage))
currentUsername, err := s.CurrentUsername()
if err != nil {
return err
}
var events []Event
var resp []Event
pages := 0
p := fmt.Sprintf("users/%s/received_events?%s", currentUsername, query.Encode())
for pages < 2 {
next, err := c.RESTWithNext(s.hostname(), "GET", p, nil, &resp)
if err != nil {
var httpErr api.HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 {
return fmt.Errorf("could not get events: %w", err)
}
}
events = append(events, resp...)
if next == "" || len(resp) < perPage {
break
}
pages++
p = next
}
s.RepoActivity = []StatusItem{}
for _, e := range events {
if s.Org != "" && e.Org.Login != s.Org {
continue
}
if s.ShouldExclude(e.Repo.Name) {
continue
}
si := StatusItem{}
var number int
switch e.Type {
case "IssuesEvent":
if e.Payload.Action != "opened" {
continue
}
si.Reason = "new issue"
si.preview = e.Payload.Issue.Title
number = e.Payload.Issue.Number
case "PullRequestEvent":
if e.Payload.Action != "opened" {
continue
}
si.Reason = "new PR"
si.preview = e.Payload.PullRequest.Title
number = e.Payload.PullRequest.Number
case "PullRequestReviewCommentEvent":
si.Reason = "comment on " + e.Payload.PullRequest.Title
si.preview = e.Payload.Comment.Body
number = e.Payload.PullRequest.Number
case "IssueCommentEvent":
si.Reason = "comment on " + e.Payload.Issue.Title
si.preview = e.Payload.Comment.Body
number = e.Payload.Issue.Number
default:
continue
}
si.Repository = e.Repo.Name
si.Identifier = fmt.Sprintf("%s#%d", e.Repo.Name, number)
s.RepoActivity = append(s.RepoActivity, si)
}
return nil
}
func statusRun(opts *StatusOptions) error {
client, err := opts.HttpClient()
if err != nil {
return fmt.Errorf("could not create client: %w", err)
}
hostname, err := opts.HostConfig.DefaultHost()
if err != nil {
return err
}
sg := NewStatusGetter(client, hostname, opts)
// TODO break out sections into individual subcommands
g := new(errgroup.Group)
opts.IO.StartProgressIndicator()
g.Go(func() error {
err := sg.LoadNotifications()
if err != nil {
err = fmt.Errorf("could not load notifications: %w", err)
}
return err
})
g.Go(func() error {
err := sg.LoadEvents()
if err != nil {
err = fmt.Errorf("could not load events: %w", err)
}
return err
})
g.Go(func() error {
err := sg.LoadSearchResults()
if err != nil {
err = fmt.Errorf("failed to search: %w", err)
}
return err
})
err = g.Wait()
if err != nil {
return err
}
opts.IO.StopProgressIndicator()
cs := opts.IO.ColorScheme()
out := opts.IO.Out
fullWidth := opts.IO.TerminalWidth()
halfWidth := (fullWidth / 2) - 2
idStyle := cs.Cyan
leftHalfStyle := lipgloss.NewStyle().Width(halfWidth).Padding(0).MarginRight(1).BorderRight(true).BorderStyle(lipgloss.NormalBorder())
rightHalfStyle := lipgloss.NewStyle().Width(halfWidth).Padding(0)
section := func(header string, items []StatusItem, width, rowLimit int) (string, error) {
tableOut := &bytes.Buffer{}
fmt.Fprintln(tableOut, cs.Bold(header))
tp := utils.NewTablePrinterWithOptions(opts.IO, utils.TablePrinterOptions{
IsTTY: opts.IO.IsStdoutTTY(),
MaxWidth: width,
Out: tableOut,
})
if len(items) == 0 {
tp.AddField("Nothing here ^_^", nil, nil)
tp.EndRow()
} else {
for i, si := range items {
if i == rowLimit {
break
}
tp.AddField(si.Identifier, nil, idStyle)
if si.Reason != "" {
tp.AddField(si.Reason, nil, nil)
}
tp.AddField(si.Preview(), nil, nil)
tp.EndRow()
}
}
err := tp.Render()
if err != nil {
return "", err
}
return tableOut.String(), nil
}
mSection, err := section("Mentions", sg.Mentions, halfWidth, 5)
if err != nil {
return fmt.Errorf("failed to render 'Mentions': %w", err)
}
mSection = rightHalfStyle.Render(mSection)
rrSection, err := section("Review Requests", sg.ReviewRequests, halfWidth, 5)
if err != nil {
return fmt.Errorf("failed to render 'Review Requests': %w", err)
}
rrSection = leftHalfStyle.Render(rrSection)
prSection, err := section("Assigned Pull Requests", sg.AssignedPRs, halfWidth, 5)
if err != nil {
return fmt.Errorf("failed to render 'Assigned Pull Requests': %w", err)
}
prSection = rightHalfStyle.Render(prSection)
issueSection, err := section("Assigned Issues", sg.AssignedIssues, halfWidth, 5)
if err != nil {
return fmt.Errorf("failed to render 'Assigned Issues': %w", err)
}
issueSection = leftHalfStyle.Render(issueSection)
raSection, err := section("Repository Activity", sg.RepoActivity, fullWidth, 10)
if err != nil {
return fmt.Errorf("failed to render 'Repository Activity': %w", err)
}
fmt.Fprintln(out, lipgloss.JoinHorizontal(lipgloss.Top, issueSection, prSection))
fmt.Fprintln(out, lipgloss.JoinHorizontal(lipgloss.Top, rrSection, mSection))
fmt.Fprintln(out, raSection)
return nil
}