diff --git a/go.mod b/go.mod index e04ff252e..aebcd1a5e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.18.1 github.com/charmbracelet/glamour v0.4.0 + github.com/charmbracelet/lipgloss v0.5.0 github.com/cli/browser v1.1.0 github.com/cli/oauth v0.9.0 github.com/cli/safeexec v1.0.0 @@ -27,7 +28,7 @@ require ( github.com/mattn/go-isatty v0.0.14 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muesli/reflow v0.3.0 - github.com/muesli/termenv v0.9.0 + github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 github.com/opentracing/opentracing-go v1.1.0 github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b diff --git a/go.sum b/go.sum index 3c76c15a1..b0e941da5 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/pp github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k= github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -181,6 +183,7 @@ github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -189,10 +192,12 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 h1:STjmj0uFfRryL9fzRA/OupNppeAID6QJYPMavTL7jtY= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo= github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index f77d17b55..2fd80ae1b 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -28,6 +28,7 @@ import ( searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" + statusCmd "github.com/cli/cli/v2/pkg/cmd/status" versionCmd "github.com/cli/cli/v2/pkg/cmd/version" workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow" "github.com/cli/cli/v2/pkg/cmdutil" @@ -83,6 +84,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(searchCmd.NewCmdSearch(f)) cmd.AddCommand(secretCmd.NewCmdSecret(f)) cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f)) + cmd.AddCommand(statusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(newCodespaceCmd(f)) // the `api` command should not inherit any extra HTTP headers diff --git a/pkg/cmd/status/fixtures/events.json b/pkg/cmd/status/fixtures/events.json new file mode 100644 index 000000000..84631269f --- /dev/null +++ b/pkg/cmd/status/fixtures/events.json @@ -0,0 +1,188 @@ +[ + { + "type": "PullRequestEvent", + "repo": { + "name": "rpd/todo" + }, + "payload": { + "action": "opened", + "number": 5326, + "pull_request": { + "number": 5326, + "title": "Only write UTF-8 BOM on Windows where it is needed" + } + }, + "created_at": "2022-03-17T15:42:28Z", + "org": { + "login": "rpd" + } + }, + { + "type": "IssueCommentEvent", + "repo": { + "name": "vilmibm/testing" + }, + "payload": { + "action": "created", + "issue": { + "number": 5325, + "title": "Ability to search \"not\" thing" + }, + "comment": { + "html_url": "https://github.com/vilmibm/testing/issues/5325#issuecomment-1070868636", + "body": "We are working on dedicated `search` functionality and might be exploring some syntax for denoting \"NOT\" qualifiers. However, until something like this is added, you can always make \"NOT\" searches by using the `--search` flag:\r\n```\r\ngh pr list --search \"-author:app/dependabot\"\r\n```\r\n/cc @samcoe " + } + }, + "created_at": "2022-03-17T12:36:31Z" + }, + { + "type": "DeleteEvent", + "repo": { + "name": "cli/cli" + }, + "payload": { + "ref": "dependabot/go_modules/github.com/stretchr/testify-1.7.1", + "ref_type": "branch", + "pusher_type": "user" + }, + "created_at": "2022-03-16T14:41:30Z", + "org": { + "login": "cli", + "avatar_url": "https://avatars.githubusercontent.com/u/59704711?" + } + }, + { + "type": "CreateEvent", + "repo": { + "name": "cli/cli" + }, + "payload": { + "ref": "dependabot/go_modules/github.com/stretchr/testify-1.7.1", + "ref_type": "branch", + "master_branch": "trunk", + "description": "GitHub’s official command line tool", + "pusher_type": "user" + }, + "created_at": "2022-03-16T14:25:19Z", + "org": { + "login": "cli", + "avatar_url": "https://avatars.githubusercontent.com/u/59704711?" + } + }, + { + "type": "PullRequestReviewCommentEvent", + "repo": { + "name": "cli/cli" + }, + "payload": { + "action": "created", + "comment": { + "body": "Wondering if we shouldn't name this something more generic and use it everywhere else to DRY things up a bit?", + "html_url": "https://github.com/cli/cli/pull/5319#discussion_r827935007" + }, + "pull_request": { + "number": 5319, + "title": "[Codespaces] Disallow some operations on codespaces that have a pending operation" + } + }, + "created_at": "2022-03-16T12:08:02Z", + "org": { + "login": "cli" + } + }, + { + "type": "CommitCommentEvent", + "repo": { + "name": "cli/cli" + }, + "payload": { + "comment": { + "html_url": "https://github.com/cli/cli/commit/1b50852b2dfecd17ca5d3a2a12eb5c16df9fe46b#r68794505", + "body": "spam" + } + }, + "created_at": "2022-03-16T03:32:59Z", + "org": { + "login": "cli" + } + }, + { + "type": "PushEvent", + "repo": { + "name": "cli/cli" + }, + "payload": { + "push_id": 9359833428, + "size": 1, + "distinct_size": 1, + "ref": "refs/heads/jg/event-handling", + "head": "7d07249150fe1a24b2b49b3ff7c55e2152446a5e", + "before": "06f1f6eb527a34af1222ca03807a9205a17d6e90", + "commits": [ + { + "sha": "7d07249150fe1a24b2b49b3ff7c55e2152446a5e", + "author": { + "email": "68619889+bchuecos@users.noreply.github.com", + "name": "Bernardo" + }, + "message": "review suggestions", + "distinct": true, + "url": "https://api.github.com/repos/cli/cli/commits/7d07249150fe1a24b2b49b3ff7c55e2152446a5e" + } + ] + }, + "created_at": "2022-03-15T18:22:41Z", + "org": { + "login": "cli" + } + }, + { + "type": "WatchEvent", + "repo": { + "name": "fengari-lua/fengari", + "url": "https://api.github.com/repos/fengari-lua/fengari" + }, + "payload": { + "action": "started" + }, + "created_at": "2022-03-14T13:03:51Z", + "org": { + "login": "fengari-lua", + "avatar_url": "https://avatars.githubusercontent.com/u/28658472?" + } + }, + { + "type": "IssuesEvent", + "repo": { + "name": "cli/cli" + }, + "payload": { + "action": "closed", + "issue": { + "number": 5301, + "title": "wheee" + } + }, + "created_at": "2022-03-14T12:21:59Z", + "org": { + "login": "cli" + } + }, + { + "type": "IssuesEvent", + "repo": { + "name": "cli/cli" + }, + "payload": { + "action": "opened", + "issue": { + "number": 5300, + "title": "Terminal bell when a running task is done" + } + }, + "created_at": "2022-03-14T12:21:59Z", + "org": { + "login": "cli" + } + } +] diff --git a/pkg/cmd/status/fixtures/notifications.json b/pkg/cmd/status/fixtures/notifications.json new file mode 100644 index 000000000..707078b70 --- /dev/null +++ b/pkg/cmd/status/fixtures/notifications.json @@ -0,0 +1,138 @@ +[ + { + "reason": "team_mention", + "subject": { + "title": "Tracking: Close an issue as either resolved or unresolved", + "url": "https://api.github.com/repos/vilmibm/testing/issues/594", + "latest_comment_url": "https://api.github.com/repos/vilmibm/testing/issues/comments/1070788734", + "type": "Issue" + }, + "repository": { + "full_name": "vilmibm/testing", + "owner": { + "login": "vilmibm" + } + } + }, + { + "reason": "comment", + "subject": { + "title": "Unclear message about base repository when creating a PR", + "url": "https://api.github.com/repos/cli/cli/issues/2090", + "latest_comment_url": "https://api.github.com/repos/cli/cli/issues/comments/1069309546", + "type": "Issue" + }, + "repository": { + "full_name": "cli/cli", + "owner": { + "login": "cli" + } + } + }, + { + "reason": "mention", + "subject": { + "title": "Good", + "url": "https://api.github.com/repos/rpd/todo/issues/110", + "latest_comment_url": "https://api.github.com/repos/rpd/todo/issues/110", + "type": "Issue" + }, + "repository": { + "full_name": "rpd/todo", + "owner": { + "login": "rpd" + } + } + }, + { + "reason": "mention", + "subject": { + "title": "Fire Irons", + "url": "https://api.github.com/repos/rpd/todo/issues/4113", + "latest_comment_url": "https://api.github.com/repos/rpd/todo/issues/4113", + "type": "Issue" + }, + "repository": { + "full_name": "rpd/todo", + "owner": { + "login": "rpd" + } + } + }, + { + "reason": "mention", + "subject": { + "title": "PR merge should switch to the base branch instead of the default branch.", + "url": "https://api.github.com/repos/cli/cli/issues/1096", + "latest_comment_url": "https://api.github.com/repos/cli/cli/issues/1096", + "type": "Issue" + }, + "repository": { + "full_name": "cli/cli", + "owner": { + "login": "cli" + } + } + }, + { + "reason": "mention", + "subject": { + "title": "Find bolt cutter", + "url": "https://api.github.com/repos/rpd/todo/issues/116", + "latest_comment_url": "https://api.github.com/repos/rpd/todo/issues/comments/1065", + "type": "Issue" + }, + "repository": { + "full_name": "rpd/todo", + "owner": { + "login": "rpd" + } + } + }, + { + "reason": "mention", + "subject": { + "title": "use the action to precompile", + "url": "https://api.github.com/repos/vilmibm/gh-screensaver/issues/15", + "latest_comment_url": "https://api.github.com/repos/vilmibm/gh-screensaver/issues/comments/10", + "type": "Issue" + }, + "repository": { + "full_name": "vilmibm/gh-screensaver", + "owner": { + "login": "vilmibm" + }, + "url": "https://api.github.com/repos/vilmibm/gh-screensaver" + } + }, + { + "reason": "author", + "subject": { + "title": "asodijasodji", + "url": "https://api.github.com/repos/vilmibm/testing/issues/150", + "latest_comment_url": "https://api.github.com/repos/vilmibm/testing/issues/comments/1064534165", + "type": "Issue" + }, + "repository": { + "full_name": "vilmibm/testing", + "owner": { + "login": "vilmibm" + } + } + }, + { + "reason": "review_requested", + "subject": { + "title": "Update LICENSE", + "url": "https://api.github.com/repos/cli/cli/pulls/5277", + "latest_comment_url": null, + "type": "PullRequest" + }, + "repository": { + "full_name": "cli/cli", + "owner": { + "login": "cli" + } + } + } +] diff --git a/pkg/cmd/status/fixtures/search.json b/pkg/cmd/status/fixtures/search.json new file mode 100644 index 000000000..e0367bdca --- /dev/null +++ b/pkg/cmd/status/fixtures/search.json @@ -0,0 +1,188 @@ +{ + "data": { + "assignments": { + "edges": [ + { + "node": { + "updatedAt": "2022-03-15T17:10:25Z", + "__typename": "PullRequest", + "title": "Pin extensions", + "number": 5272, + "repository": { + "nameWithOwner": "cli/cli" + } + } + }, + { + "node": { + "updatedAt": "2022-03-01T17:10:25Z", + "__typename": "PullRequest", + "title": "Board up RPD windows", + "number": 73, + "repository": { + "nameWithOwner": "rpd/todo" + } + } + }, + { + "node": { + "__typename": "Issue", + "updatedAt": "2022-03-10T21:33:57Z", + "title": "yolo", + "number": 157, + "repository": { + "nameWithOwner": "vilmibm/testing" + } + } + }, + { + "node": { + "updatedAt": "2022-01-06T05:05:12Z", + "__typename": "PullRequest", + "title": "Issue Frecency", + "number": 4768, + "repository": { + "nameWithOwner": "cli/cli" + } + } + }, + { + "node": { + "__typename": "Issue", + "updatedAt": "2021-08-05T20:03:07Z", + "title": "Reducing zombie threat in Raccoon City", + "number": 514, + "repository": { + "nameWithOwner": "rpd/todo" + } + } + }, + { + "node": { + "__typename": "Issue", + "updatedAt": "2021-10-16T23:20:35Z", + "title": "Repo garden for Windows", + "number": 3223, + "repository": { + "nameWithOwner": "cli/cli" + } + } + }, + { + "node": { + "__typename": "Issue", + "updatedAt": "2020-11-10T23:07:37Z", + "title": "welp", + "number": 74, + "repository": { + "nameWithOwner": "vilmibm/testing" + } + } + }, + { + "node": { + "__typename": "Issue", + "updatedAt": "2019-07-20T01:30:01Z", + "title": "Ability to send emails from helpdesk UI", + "number": 42, + "repository": { + "nameWithOwner": "tildetown/tildetown-admin" + } + } + }, + { + "node": { + "__typename": "Issue", + "updatedAt": "2019-07-22T19:37:45Z", + "title": "complete move to class based views", + "number": 22, + "repository": { + "nameWithOwner": "adreyer/arkestrator" + } + } + }, + { + "node": { + "__typename": "Issue", + "updatedAt": "2019-02-20T17:54:24Z", + "title": "`(every)` macro", + "number": 145, + "repository": { + "nameWithOwner": "vilmibm/tildemush" + } + } + }, + { + "node": { + "__typename": "Issue", + "updatedAt": "2019-02-20T18:10:53Z", + "title": "audit move rules", + "number": 79, + "repository": { + "nameWithOwner": "vilmibm/tildemush" + } + } + } + ] + }, + "reviewRequested": { + "edges": [ + { + "node": { + "updatedAt": "2022-03-15T17:10:25Z", + "__typename": "PullRequest", + "title": "Pin extensions", + "number": 5272, + "repository": { + "nameWithOwner": "cli/cli" + } + } + }, + { + "node": { + "updatedAt": "2022-02-18T19:38:20Z", + "__typename": "PullRequest", + "title": "Foobar", + "number": 1234, + "repository": { + "nameWithOwner": "vilmibm/testing" + } + } + }, + { + "node": { + "updatedAt": "2022-02-04T06:56:48Z", + "__typename": "PullRequest", + "title": "Welcome party for Leon", + "number": 50, + "repository": { + "nameWithOwner": "rpd/todo" + } + } + }, + { + "node": { + "updatedAt": "2021-12-20T10:30:39Z", + "__typename": "PullRequest", + "title": "Haircut for Leon", + "number": 49, + "repository": { + "nameWithOwner": "rpd/todo" + } + } + }, + { + "node": { + "updatedAt": "2021-12-20T16:43:15Z", + "__typename": "PullRequest", + "title": "This pull request adds support for json output the to `gh pr checks` …", + "number": 4671, + "repository": { + "nameWithOwner": "cli/cli" + } + } + } + ] + } + } +} diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go new file mode 100644 index 000000000..87c8d55ee --- /dev/null +++ b/pkg/cmd/status/status.go @@ -0,0 +1,618 @@ +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/internal/ghinstance" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/utils" + "github.com/spf13/cobra" +) + +type StatusOptions struct { + HttpClient func() (*http.Client, error) + 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/prs, comments) + + This data can be limited to a single org with -o; specific repositories can be excluded from this list with -e. + `), + Example: heredoc.Doc(` + Exclude some repositories: + + gh status -e"cli/cli,cli/go-gh" + + Limit information to a single organization's repositories: + + gh status -ocli + `), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + + return statusRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Org, "org", "o", "", "Report status within an organization") + cmd.Flags().StringVarP(&opts.Exclude, "exclude", "e", "", "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 + Org string + Exclude string + AssignedPRs []StatusItem + AssignedIssues []StatusItem + Mentions []StatusItem + ReviewRequests []StatusItem + RepoActivity []StatusItem +} + +func NewStatusGetter(client *http.Client, opts *StatusOptions) *StatusGetter { + return &StatusGetter{ + Client: client, + Org: opts.Org, + Exclude: opts.Exclude, + cachedClient: opts.CachedClient, + } +} + +func (s *StatusGetter) CachedClient(ttl time.Duration) *http.Client { + return s.cachedClient(s.Client, ttl) +} + +func (s *StatusGetter) ShouldExclude(repo string) bool { + return strings.Contains(s.Exclude, repo) +} + +func (s *StatusGetter) CurrentUsername() (string, error) { + cachedClient := s.CachedClient(time.Hour * 48) + cachingAPIClient := api.NewClientFromHTTP(cachedClient) + currentUsername, err := api.CurrentLoginName(cachingAPIClient, ghinstance.Default()) + 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(ghinstance.Default(), "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 := 3 + for page := 1; page <= pages; page++ { + query.Add("page", fmt.Sprintf("%d", page)) + p := fmt.Sprintf("notifications?%s", query.Encode()) + err := c.REST(ghinstance.Default(), "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 len(resp) == 0 || len(resp) < perPage { + break + } + } + + 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 := "" + if s.Exclude != "" { + for _, repo := range strings.Split(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(ghinstance.Default(), 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 := 2 + for page := 1; page <= pages; page++ { + query.Add("page", fmt.Sprintf("%d", page)) + p := fmt.Sprintf("users/%s/received_events?%s", currentUsername, query.Encode()) + err := c.REST(ghinstance.Default(), "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 len(resp) == 0 || len(resp) < perPage { + break + } + } + + 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) + } + + sg := NewStatusGetter(client, opts) + errc := make(chan error) + + // TODO break out sections into individual subcommands + + opts.IO.StartProgressIndicator() + go func() { + err := sg.LoadNotifications() + if err != nil { + err = fmt.Errorf("could not load notifications: %w", err) + } + errc <- err + }() + + go func() { + err := sg.LoadEvents() + if err != nil { + err = fmt.Errorf("could not load events: %w", err) + } + errc <- err + }() + + go func() { + err := sg.LoadSearchResults() + if err != nil { + err = fmt.Errorf("failed to search: %w", err) + } + errc <- err + }() + + for i := 0; i < 3; i++ { + if err := <-errc; 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).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 PRs", sg.AssignedPRs, halfWidth, 5) + if err != nil { + return fmt.Errorf("failed to render 'Assigned PRs': %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 +} diff --git a/pkg/cmd/status/status_test.go b/pkg/cmd/status/status_test.go new file mode 100644 index 000000000..8ae3e4f5d --- /dev/null +++ b/pkg/cmd/status/status_test.go @@ -0,0 +1,332 @@ +package status + +import ( + "bytes" + "net/http" + "testing" + "time" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdStatus(t *testing.T) { + tests := []struct { + name string + cli string + wants StatusOptions + }{ + { + name: "defaults", + }, + { + name: "org", + cli: "-o cli", + wants: StatusOptions{ + Org: "cli", + }, + }, + { + name: "exclude", + cli: "-e cli/cli,cli/go-gh", + wants: StatusOptions{ + Exclude: "cli/cli,cli/go-gh", + }, + }, + } + + for _, tt := range tests { + io, _, _, _ := iostreams.Test() + io.SetStdinTTY(true) + io.SetStdoutTTY(true) + + f := &cmdutil.Factory{ + IOStreams: io, + } + t.Run(tt.name, func(t *testing.T) { + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *StatusOptions + cmd := NewCmdStatus(f, func(opts *StatusOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Org, gotOpts.Org) + assert.Equal(t, tt.wants.Exclude, gotOpts.Exclude) + }) + } +} + +func TestStatusRun(t *testing.T) { + tests := []struct { + name string + httpStubs func(*httpmock.Registry) + opts *StatusOptions + wantOut string + wantErrMsg string + }{ + { + name: "nothing", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("AssignedSearch"), + httpmock.StringResponse(`{"data": { "assignments": {"edges": [] }, "reviewRequested": {"edges": []}}}`)) + reg.Register( + httpmock.REST("GET", "notifications"), + httpmock.StringResponse(`[]`)) + reg.Register( + httpmock.REST("GET", "users/jillvalentine/received_events"), + httpmock.StringResponse(`[]`)) + }, + opts: &StatusOptions{}, + wantOut: "Assigned Issues │Assigned PRs \nNothing here ^_^ │Nothing here ^_^ \n │ \nReview Requests │Mentions \nNothing here ^_^ │Nothing here ^_^ \n │ \nRepository Activity\nNothing here ^_^\n\n", + }, + { + name: "something", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/110"), + httpmock.StringResponse(`{"body":"hello @jillvalentine how are you"}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/4113"), + httpmock.StringResponse(`{"body":"this is a comment"}`)) + reg.Register( + httpmock.REST("GET", "repos/cli/cli/issues/1096"), + httpmock.StringResponse(`{"body":"@jillvalentine hi"}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/comments/1065"), + httpmock.StringResponse(`{"body":"not a real mention"}`)) + reg.Register( + httpmock.REST("GET", "repos/vilmibm/gh-screensaver/issues/comments/10"), + httpmock.StringResponse(`{"body":"a message for @jillvalentine"}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("AssignedSearch"), + httpmock.FileResponse("./fixtures/search.json")) + reg.Register( + httpmock.REST("GET", "notifications"), + httpmock.FileResponse("./fixtures/notifications.json")) + reg.Register( + httpmock.REST("GET", "users/jillvalentine/received_events"), + httpmock.FileResponse("./fixtures/events.json")) + }, + opts: &StatusOptions{}, + wantOut: "Assigned Issues │Assigned PRs \nvilmibm/testing#157 yolo │cli/cli#5272 Pin extensions \ncli/cli#3223 Repo garden...│rpd/todo#73 Board up RPD windows \nrpd/todo#514 Reducing zo...│cli/cli#4768 Issue Frecency \nvilmibm/testing#74 welp │ \nadreyer/arkestrator#22 complete mo...│ \n │ \nReview Requests │Mentions \ncli/cli#5272 Pin extensions │rpd/todo#110 hello @j...\nvilmibm/testing#1234 Foobar │cli/cli#1096 @jillval...\nrpd/todo#50 Welcome party...│vilmibm/gh-screensaver#15 a messag...\ncli/cli#4671 This pull req...│ \nrpd/todo#49 Haircut for Leon│ \n │ \nRepository Activity\nrpd/todo#5326 new PR Only write UTF-8 BOM on W...\nvilmibm/testing#5325 comment on Ability to sea... We are working on dedicat...\ncli/cli#5319 comment on [Codespaces] D... Wondering if we shouldn't...\ncli/cli#5300 new issue Terminal bell when a runn...\n\n", + }, + { + name: "exclude a repository", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/110"), + httpmock.StringResponse(`{"body":"hello @jillvalentine how are you"}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/4113"), + httpmock.StringResponse(`{"body":"this is a comment"}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/comments/1065"), + httpmock.StringResponse(`{"body":"not a real mention"}`)) + reg.Register( + httpmock.REST("GET", "repos/vilmibm/gh-screensaver/issues/comments/10"), + httpmock.StringResponse(`{"body":"a message for @jillvalentine"}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("AssignedSearch"), + httpmock.FileResponse("./fixtures/search.json")) + reg.Register( + httpmock.REST("GET", "notifications"), + httpmock.FileResponse("./fixtures/notifications.json")) + reg.Register( + httpmock.REST("GET", "users/jillvalentine/received_events"), + httpmock.FileResponse("./fixtures/events.json")) + }, + opts: &StatusOptions{ + Exclude: "cli/cli", + }, + // NOTA BENE: you'll see cli/cli in search results because that happens + // server side and the fixture doesn't account for that + wantOut: "Assigned Issues │Assigned PRs \nvilmibm/testing#157 yolo │cli/cli#5272 Pin extensions \ncli/cli#3223 Repo garden...│rpd/todo#73 Board up RPD windows \nrpd/todo#514 Reducing zo...│cli/cli#4768 Issue Frecency \nvilmibm/testing#74 welp │ \nadreyer/arkestrator#22 complete mo...│ \n │ \nReview Requests │Mentions \ncli/cli#5272 Pin extensions │rpd/todo#110 hello @j...\nvilmibm/testing#1234 Foobar │vilmibm/gh-screensaver#15 a messag...\nrpd/todo#50 Welcome party...│ \ncli/cli#4671 This pull req...│ \nrpd/todo#49 Haircut for Leon│ \n │ \nRepository Activity\nrpd/todo#5326 new PR Only write UTF-8 BOM on W...\nvilmibm/testing#5325 comment on Ability to sea... We are working on dedicat...\n\n", + }, + { + name: "exclude repositories", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.REST("GET", "repos/vilmibm/gh-screensaver/issues/comments/10"), + httpmock.StringResponse(`{"body":"a message for @jillvalentine"}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("AssignedSearch"), + httpmock.FileResponse("./fixtures/search.json")) + reg.Register( + httpmock.REST("GET", "notifications"), + httpmock.FileResponse("./fixtures/notifications.json")) + reg.Register( + httpmock.REST("GET", "users/jillvalentine/received_events"), + httpmock.FileResponse("./fixtures/events.json")) + }, + opts: &StatusOptions{ + Exclude: "cli/cli,rpd/todo", + }, + // NOTA BENE: you'll see cli/cli in search results because that happens + // server side and the fixture doesn't account for that + wantOut: "Assigned Issues │Assigned PRs \nvilmibm/testing#157 yolo │cli/cli#5272 Pin extensions \ncli/cli#3223 Repo garden...│rpd/todo#73 Board up RPD windows \nrpd/todo#514 Reducing zo...│cli/cli#4768 Issue Frecency \nvilmibm/testing#74 welp │ \nadreyer/arkestrator#22 complete mo...│ \n │ \nReview Requests │Mentions \ncli/cli#5272 Pin extensions │vilmibm/gh-screensaver#15 a messag...\nvilmibm/testing#1234 Foobar │ \nrpd/todo#50 Welcome party...│ \ncli/cli#4671 This pull req...│ \nrpd/todo#49 Haircut for Leon│ \n │ \nRepository Activity\nvilmibm/testing#5325 comment on Ability to sea... We are working on dedicat...\n\n", + }, + { + name: "filter to an org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/110"), + httpmock.StringResponse(`{"body":"hello @jillvalentine how are you"}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/4113"), + httpmock.StringResponse(`{"body":"this is a comment"}`)) + reg.Register( + httpmock.REST("GET", "repos/rpd/todo/issues/comments/1065"), + httpmock.StringResponse(`{"body":"not a real mention"}`)) + reg.Register( + httpmock.GraphQL("UserCurrent"), + httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`)) + reg.Register( + httpmock.GraphQL("AssignedSearch"), + httpmock.FileResponse("./fixtures/search.json")) + reg.Register( + httpmock.REST("GET", "notifications"), + httpmock.FileResponse("./fixtures/notifications.json")) + reg.Register( + httpmock.REST("GET", "users/jillvalentine/received_events"), + httpmock.FileResponse("./fixtures/events.json")) + }, + opts: &StatusOptions{ + Org: "rpd", + }, + wantOut: "Assigned Issues │Assigned PRs \nvilmibm/testing#157 yolo │cli/cli#5272 Pin extensions \ncli/cli#3223 Repo garden...│rpd/todo#73 Board up RPD windows \nrpd/todo#514 Reducing zo...│cli/cli#4768 Issue Frecency \nvilmibm/testing#74 welp │ \nadreyer/arkestrator#22 complete mo...│ \n │ \nReview Requests │Mentions \ncli/cli#5272 Pin extensions │rpd/todo#110 hello @jillvalentine ...\nvilmibm/testing#1234 Foobar │ \nrpd/todo#50 Welcome party...│ \ncli/cli#4671 This pull req...│ \nrpd/todo#49 Haircut for Leon│ \n │ \nRepository Activity\nrpd/todo#5326 new PR Only write UTF-8 BOM on Windows where it is needed\n\n", + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + tt.httpStubs(reg) + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.CachedClient = func(c *http.Client, _ time.Duration) *http.Client { + return c + } + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(true) + tt.opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + err := statusRun(tt.opts) + if tt.wantErrMsg != "" { + assert.Equal(t, tt.wantErrMsg, err.Error()) + return + } + + assert.NoError(t, err) + + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} + +func Test_buildSearchQuery(t *testing.T) { + tests := []struct { + name string + sg StatusGetter + wantReviewQ string + wantAssignedQ string + }{ + { + name: "nothing", + wantReviewQ: "first: 25, type: ISSUE, query:\"state:open review-requested:@me", + wantAssignedQ: "assignee:@me state:open", + }, + { + name: "exclude one", + sg: StatusGetter{ + Exclude: "cli/cli", + }, + wantReviewQ: "first: 25, type: ISSUE, query:\"state:open review-requested:@me -repo:cli/cli", + wantAssignedQ: "assignee:@me state:open", + }, + { + name: "exclude several", + sg: StatusGetter{ + Exclude: "cli/cli,vilmibm/testing", + }, + wantReviewQ: "first: 25, type: ISSUE, query:\"state:open review-requested:@me -repo:cli/cli -repo:vilmibm/testing", + wantAssignedQ: "assignee:@me state:open", + }, + { + name: "org filter", + sg: StatusGetter{ + Org: "cli", + }, + wantReviewQ: "first: 25, type: ISSUE, query:\"state:open review-requested:@me org:cli", + wantAssignedQ: "assignee:@me state:open org:cli", + }, + } + + for _, tt := range tests { + assert.Contains(t, tt.sg.buildSearchQuery(), tt.wantReviewQ) + assert.Contains(t, tt.sg.buildSearchQuery(), tt.wantAssignedQ) + } +} diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 8970e8f49..be281fc0f 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -30,10 +30,7 @@ func REST(method, p string) Matcher { if !strings.EqualFold(req.Method, method) { return false } - if req.URL.Path != "/"+p { - return false - } - return true + return req.URL.Path == "/"+p } } diff --git a/utils/table_printer.go b/utils/table_printer.go index 6c937f35d..9a40cdbbc 100644 --- a/utils/table_printer.go +++ b/utils/table_printer.go @@ -18,7 +18,9 @@ type TablePrinter interface { } type TablePrinterOptions struct { - IsTTY bool + IsTTY bool + MaxWidth int + Out io.Writer } func NewTablePrinter(io *iostreams.IOStreams) TablePrinter { @@ -27,21 +29,29 @@ func NewTablePrinter(io *iostreams.IOStreams) TablePrinter { }) } -func NewTablePrinterWithOptions(io *iostreams.IOStreams, opts TablePrinterOptions) TablePrinter { +func NewTablePrinterWithOptions(ios *iostreams.IOStreams, opts TablePrinterOptions) TablePrinter { + var out io.Writer + if opts.Out != nil { + out = opts.Out + } else { + out = ios.Out + } if opts.IsTTY { var maxWidth int - if io.IsStdoutTTY() { - maxWidth = io.TerminalWidth() + if opts.MaxWidth > 0 { + maxWidth = opts.MaxWidth + } else if ios.IsStdoutTTY() { + maxWidth = ios.TerminalWidth() } else { - maxWidth = io.ProcessTerminalWidth() + maxWidth = ios.ProcessTerminalWidth() } return &ttyTablePrinter{ - out: io.Out, + out: out, maxWidth: maxWidth, } } return &tsvTablePrinter{ - out: io.Out, + out: out, } }