Merge pull request #5369 from cli/gh-status

This commit is contained in:
Nate Smith 2022-03-29 11:32:28 -05:00 committed by GitHub
commit e2fd7ceff0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1518 additions and 20 deletions

View file

@ -316,41 +316,55 @@ func graphQLClient(h *http.Client, hostname string) *graphql.Client {
// REST performs a REST request and parses the response.
func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error {
_, err := c.RESTWithNext(hostname, method, p, body, data)
return err
}
func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) {
req, err := http.NewRequest(method, restURL(hostname, p), body)
if err != nil {
return err
return "", err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return err
return "", err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return HandleHTTPError(resp)
return "", HandleHTTPError(resp)
}
if resp.StatusCode == http.StatusNoContent {
return nil
return "", nil
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
return "", err
}
err = json.Unmarshal(b, &data)
if err != nil {
return err
return "", err
}
return nil
var next string
for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) {
if len(m) > 2 && m[2] == "next" {
next = m[1]
}
}
return next, nil
}
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
func restURL(hostname string, pathOrURL string) string {
if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") {
return pathOrURL

3
go.mod
View file

@ -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

7
go.sum
View file

@ -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=
@ -182,6 +184,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=
@ -190,10 +193,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=

View file

@ -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

View file

@ -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": "GitHubs 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"
}
}
]

View file

@ -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"
}
}
}
]

View file

@ -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"
}
}
}
]
}
}
}

619
pkg/cmd/status/status.go Normal file
View file

@ -0,0 +1,619 @@
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"
"golang.org/x/sync/errgroup"
)
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/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 {
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
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 {
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, 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 := 0
p := fmt.Sprintf("notifications?%s", query.Encode())
for pages < 3 {
next, err := c.RESTWithNext(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 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(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 := 0
p := fmt.Sprintf("users/%s/received_events?%s", currentUsername, query.Encode())
for pages < 2 {
next, err := c.RESTWithNext(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 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)
}
sg := NewStatusGetter(client, 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
}

View file

@ -0,0 +1,336 @@
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: []string{"cli/cli", "cli/go-gh"},
},
},
}
for _, tt := range tests {
io, _, _, _ := iostreams.Test()
io.SetStdinTTY(true)
io.SetStdoutTTY(true)
if tt.wants.Exclude == nil {
tt.wants.Exclude = []string{}
}
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 Pull Requests \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 Pull Requests \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: []string{"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 Pull Requests \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: []string{"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 Pull Requests \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 Pull Requests \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: []string{"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: []string{"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)
}
}

View file

@ -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
}
}

View file

@ -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,
}
}