From bfdf89b579875aa5efe2279c203e03b05c7758c2 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 17 Jan 2020 15:38:01 -0600 Subject: [PATCH 1/9] updated based sorting and fuzzy time display on issue status --- api/queries_issue.go | 21 ++++++++++++--------- command/issue.go | 10 +++++++++- utils/utils.go | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index 39a998199..7ba12f6ad 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "time" ) type IssuesPayload struct { @@ -16,12 +17,13 @@ type IssuesAndTotalCount struct { } type Issue struct { - Number int - Title string - URL string - State string - Body string - Comments struct { + Number int + Title string + URL string + State string + Body string + UpdatedAt time.Time + Comments struct { TotalCount int } Author struct { @@ -44,6 +46,7 @@ const fragments = ` title url state + updatedAt labels(first: 3) { nodes { name @@ -111,19 +114,19 @@ func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPa query($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { repository(owner: $owner, name: $repo) { hasIssuesEnabled - assigned: issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + assigned: issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } - mentioned: issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + mentioned: issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue } } - authored: issues(filterBy: {createdBy: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + authored: issues(filterBy: {createdBy: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { ...issue diff --git a/command/issue.go b/command/issue.go index 63974cb20..139ab3337 100644 --- a/command/issue.go +++ b/command/issue.go @@ -7,6 +7,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/github/gh-cli/api" "github.com/github/gh-cli/context" @@ -385,7 +386,14 @@ func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) if coloredLabels != "" { coloredLabels = utils.Gray(fmt.Sprintf(" (%s)", coloredLabels)) } - fmt.Fprintf(w, "%s%s %s%s\n", prefix, number, truncate(70, replaceExcessiveWhitespace(issue.Title)), coloredLabels) + + now := time.Now() + ago := now.Sub(issue.UpdatedAt) + + fmt.Fprintf(w, "%s%s %s%s %s\n", prefix, number, + truncate(70, replaceExcessiveWhitespace(issue.Title)), + coloredLabels, + utils.Gray(utils.FuzzyAgo(ago))) } remaining := totalCount - len(issues) if remaining > 0 { diff --git a/utils/utils.go b/utils/utils.go index 2c8c5ac0a..aa3f6da1d 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "runtime" + "time" "github.com/kballard/go-shellquote" md "github.com/vilmibm/go-termd" @@ -90,3 +91,17 @@ func Pluralize(num int, thing string) string { return fmt.Sprintf("%d %ss", num, thing) } } + +func FuzzyAgo(ago time.Duration) string { + if ago < time.Minute { + return "less than a minute ago" + } + if ago < time.Hour { + return fmt.Sprintf("about %s ago", Pluralize(int(ago.Minutes()), "minute")) + } + if ago < 24*time.Hour { + return fmt.Sprintf("about %s ago", Pluralize(int(ago.Hours()), "hour")) + } + + return fmt.Sprintf("about %s ago", Pluralize(int(ago.Hours()/24), "day")) +} From a29715363719b785f22ef06fcd9e28052032ce0b Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 17 Jan 2020 15:56:52 -0600 Subject: [PATCH 2/9] test for utils.FuzzyAgo --- utils/utils_test.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 utils/utils_test.go diff --git a/utils/utils_test.go b/utils/utils_test.go new file mode 100644 index 000000000..f9586b523 --- /dev/null +++ b/utils/utils_test.go @@ -0,0 +1,35 @@ +package utils + +import ( + "testing" + "time" +) + +// TODO test FuzzyAgo + +func TestFuzzyAgo(t *testing.T) { + + cases := map[string]string{ + "1s": "less than a minute ago", + "30s": "less than a minute ago", + "1m08s": "about 1 minute ago", + "15m0s": "about 15 minutes ago", + "59m10s": "about 59 minutes ago", + "1h10m02s": "about 1 hour ago", + "15h0m01s": "about 15 hours ago", + "30h10m": "about 1 day ago", + "50h": "about 2 days ago", + } + + for duration, expected := range cases { + d, e := time.ParseDuration(duration) + if e != nil { + t.Errorf("failed to create a duration: %s", e) + } + + fuzzy := FuzzyAgo(d) + if fuzzy != expected { + t.Errorf("unexpected fuzzy duration value: %s for %s", fuzzy, duration) + } + } +} From a4b97014d11aee9d029fd5b4f517b0cee9bace6f Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 17 Jan 2020 16:07:35 -0600 Subject: [PATCH 3/9] stray TODO --- utils/utils_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/utils/utils_test.go b/utils/utils_test.go index f9586b523..4e0838dac 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -5,8 +5,6 @@ import ( "time" ) -// TODO test FuzzyAgo - func TestFuzzyAgo(t *testing.T) { cases := map[string]string{ From d7d575fccb02cce2ba247fc6ca9eae91dc355f0a Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 21 Jan 2020 14:25:38 -0600 Subject: [PATCH 4/9] support more time units --- utils/utils.go | 16 +++++++++++++--- utils/utils_test.go | 23 ++++++++++++++--------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/utils/utils.go b/utils/utils.go index aa3f6da1d..f60321907 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -92,16 +92,26 @@ func Pluralize(num int, thing string) string { } } +func fmtDuration(amount int, unit string) string { + return fmt.Sprintf("about %s ago", Pluralize(amount, unit)) +} + func FuzzyAgo(ago time.Duration) string { if ago < time.Minute { return "less than a minute ago" } if ago < time.Hour { - return fmt.Sprintf("about %s ago", Pluralize(int(ago.Minutes()), "minute")) + return fmtDuration(int(ago.Minutes()), "minute") } if ago < 24*time.Hour { - return fmt.Sprintf("about %s ago", Pluralize(int(ago.Hours()), "hour")) + return fmtDuration(int(ago.Hours()), "hour") + } + if ago < 30*24*time.Hour { + return fmtDuration(int(ago.Hours())/24, "day") + } + if ago < 365*24*time.Hour { + return fmtDuration(int(ago.Hours())/24/30, "month") } - return fmt.Sprintf("about %s ago", Pluralize(int(ago.Hours()/24), "day")) + return fmtDuration(int(ago.Hours()/24/365), "year") } diff --git a/utils/utils_test.go b/utils/utils_test.go index 4e0838dac..0891c2a39 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -8,15 +8,20 @@ import ( func TestFuzzyAgo(t *testing.T) { cases := map[string]string{ - "1s": "less than a minute ago", - "30s": "less than a minute ago", - "1m08s": "about 1 minute ago", - "15m0s": "about 15 minutes ago", - "59m10s": "about 59 minutes ago", - "1h10m02s": "about 1 hour ago", - "15h0m01s": "about 15 hours ago", - "30h10m": "about 1 day ago", - "50h": "about 2 days ago", + "1s": "less than a minute ago", + "30s": "less than a minute ago", + "1m08s": "about 1 minute ago", + "15m0s": "about 15 minutes ago", + "59m10s": "about 59 minutes ago", + "1h10m02s": "about 1 hour ago", + "15h0m01s": "about 15 hours ago", + "30h10m": "about 1 day ago", + "50h": "about 2 days ago", + "720h05m": "about 1 month ago", + "3000h10m": "about 4 months ago", + "8760h59m": "about 1 year ago", + "17601h59m": "about 2 years ago", + "262800h19m": "about 30 years ago", } for duration, expected := range cases { From fc25a4e9ed860d9dc52d3243b522256cebabc553 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 21 Jan 2020 15:37:42 -0600 Subject: [PATCH 5/9] check for disabled issues in issue view command --- api/queries_issue.go | 31 +++++++++++++++++++++++++++++++ command/issue.go | 8 ++++++++ 2 files changed, 39 insertions(+) diff --git a/api/queries_issue.go b/api/queries_issue.go index 7ba12f6ad..c1ffb5253 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -91,6 +91,37 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} return &result.CreateIssue.Issue, nil } +func HasIssuesEnabled(client *Client, ghRepo Repo) (bool, error) { + type response struct { + Repository struct { + HasIssuesEnabled bool + } + } + + query := ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + hasIssuesEnabled + } + }` + + owner := ghRepo.RepoOwner() + repo := ghRepo.RepoName() + variables := map[string]interface{}{ + "owner": owner, + "repo": repo, + } + + var resp response + err := client.GraphQL(query, variables, &resp) + if err != nil { + return false, err + } + + return resp.Repository.HasIssuesEnabled, nil + +} + func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload, error) { type response struct { Repository struct { diff --git a/command/issue.go b/command/issue.go index 139ab3337..49bf66a7e 100644 --- a/command/issue.go +++ b/command/issue.go @@ -215,6 +215,14 @@ func issueView(cmd *cobra.Command, args []string) error { return err } + issuesEnabled, err := api.HasIssuesEnabled(apiClient, baseRepo) + if err != nil { + return err + } + if !issuesEnabled { + return fmt.Errorf("the '%s/%s' repository has disabled issues", baseRepo.RepoOwner(), baseRepo.RepoName()) + } + issue, err := issueFromArg(apiClient, baseRepo, args[0]) if err != nil { return err From f09b05e628ea8a0af0c9c501154a1c3b40c64128 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 21 Jan 2020 15:47:36 -0600 Subject: [PATCH 6/9] test for disabled issues --- command/issue_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/command/issue_test.go b/command/issue_test.go index e9f616f68..532c3cc35 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -202,6 +202,13 @@ func TestIssueView(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "issue": { "number": 123, @@ -235,6 +242,13 @@ func TestIssueView_preview(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "issue": { "number": 123, @@ -280,6 +294,13 @@ func TestIssueView_notFound(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` { "errors": [ { "message": "Could not resolve to an Issue with the number of 9999." } @@ -303,10 +324,34 @@ func TestIssueView_notFound(t *testing.T) { } } +func TestIssueView_disabledIssues(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": false + } } } + `)) + + _, err := RunCommand(issueViewCmd, `issue view 6666`) + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue view`: %v", err) + } +} + func TestIssueView_urlArg(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "issue": { "number": 123, From 0b0fd42ef302dec175c3af3505fb6f3b4c219d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 22 Jan 2020 19:35:39 +0100 Subject: [PATCH 7/9] Dump HTTP request/response bodies when `DEBUG=api` --- api/client.go | 23 ++++++++++++++++++++++- command/root.go | 4 ++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/api/client.go b/api/client.go index 2aa82d1ea..040527053 100644 --- a/api/client.go +++ b/api/client.go @@ -7,6 +7,8 @@ import ( "io" "io/ioutil" "net/http" + "regexp" + "strings" ) // ClientOption represents an argument to NewClient @@ -34,13 +36,26 @@ func AddHeader(name, value string) ClientOption { } // VerboseLog enables request/response logging within a RoundTripper -func VerboseLog(out io.Writer) ClientOption { +func VerboseLog(out io.Writer, logBodies bool) ClientOption { return func(tr http.RoundTripper) http.RoundTripper { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { fmt.Fprintf(out, "> %s %s\n", req.Method, req.URL.RequestURI()) + if logBodies && req.Body != nil && inspectableMIMEType(req.Header.Get("Content-type")) { + newBody := &bytes.Buffer{} + io.Copy(out, io.TeeReader(req.Body, newBody)) + fmt.Fprintln(out) + req.Body = ioutil.NopCloser(newBody) + } res, err := tr.RoundTrip(req) if err == nil { fmt.Fprintf(out, "< HTTP %s\n", res.Status) + if logBodies && res.Body != nil && inspectableMIMEType(res.Header.Get("Content-type")) { + newBody := &bytes.Buffer{} + // TODO: pretty-print response JSON + io.Copy(out, io.TeeReader(res.Body, newBody)) + fmt.Fprintln(out) + res.Body = ioutil.NopCloser(newBody) + } } return res, err }} @@ -179,3 +194,9 @@ func handleHTTPError(resp *http.Response) error { return fmt.Errorf("http error, '%s' failed (%d): '%s'", resp.Request.URL, resp.StatusCode, message) } + +var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) + +func inspectableMIMEType(t string) bool { + return strings.HasPrefix(t, "text/") || jsonTypeRE.MatchString(t) +} diff --git a/command/root.go b/command/root.go index 5703f30c2..e443d391e 100644 --- a/command/root.go +++ b/command/root.go @@ -84,7 +84,7 @@ func BasicClient() (*api.Client, error) { opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", c.Token))) } if verbose := os.Getenv("DEBUG"); verbose != "" { - opts = append(opts, api.VerboseLog(os.Stderr)) + opts = append(opts, api.VerboseLog(os.Stderr, false)) } return api.NewClient(opts...), nil } @@ -113,7 +113,7 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) { api.AddHeader("GraphQL-Features", "pe_mobile"), } if verbose := os.Getenv("DEBUG"); verbose != "" { - opts = append(opts, api.VerboseLog(os.Stderr)) + opts = append(opts, api.VerboseLog(os.Stderr, strings.Contains(verbose, "api"))) } return api.NewClient(opts...), nil } From 8c84fe3e3c44f8f411fed83c89a33c261e8f80d2 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 22 Jan 2020 12:37:00 -0600 Subject: [PATCH 8/9] just augment existing queries --- api/queries_issue.go | 39 +++++++-------------------------------- command/issue.go | 8 -------- command/issue_test.go | 34 +++------------------------------- 3 files changed, 10 insertions(+), 71 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index c1ffb5253..50fcbd434 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -91,37 +91,6 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} return &result.CreateIssue.Issue, nil } -func HasIssuesEnabled(client *Client, ghRepo Repo) (bool, error) { - type response struct { - Repository struct { - HasIssuesEnabled bool - } - } - - query := ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - hasIssuesEnabled - } - }` - - owner := ghRepo.RepoOwner() - repo := ghRepo.RepoName() - variables := map[string]interface{}{ - "owner": owner, - "repo": repo, - } - - var resp response - err := client.GraphQL(query, variables, &resp) - if err != nil { - return false, err - } - - return resp.Repository.HasIssuesEnabled, nil - -} - func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload, error) { type response struct { Repository struct { @@ -267,13 +236,15 @@ func IssueList(client *Client, ghRepo Repo, state string, labels []string, assig func IssueByNumber(client *Client, ghRepo Repo, number int) (*Issue, error) { type response struct { Repository struct { - Issue Issue + Issue Issue + HasIssuesEnabled bool } } query := ` query($owner: String!, $repo: String!, $issue_number: Int!) { repository(owner: $owner, name: $repo) { + hasIssuesEnabled issue(number: $issue_number) { title body @@ -306,5 +277,9 @@ func IssueByNumber(client *Client, ghRepo Repo, number int) (*Issue, error) { return nil, err } + if !resp.Repository.HasIssuesEnabled { + return nil, fmt.Errorf("the '%s/%s' repository has disabled issues", ghRepo.RepoOwner(), ghRepo.RepoName()) + } + return &resp.Repository.Issue, nil } diff --git a/command/issue.go b/command/issue.go index 49bf66a7e..139ab3337 100644 --- a/command/issue.go +++ b/command/issue.go @@ -215,14 +215,6 @@ func issueView(cmd *cobra.Command, args []string) error { return err } - issuesEnabled, err := api.HasIssuesEnabled(apiClient, baseRepo) - if err != nil { - return err - } - if !issuesEnabled { - return fmt.Errorf("the '%s/%s' repository has disabled issues", baseRepo.RepoOwner(), baseRepo.RepoName()) - } - issue, err := issueFromArg(apiClient, baseRepo, args[0]) if err != nil { return err diff --git a/command/issue_test.go b/command/issue_test.go index 532c3cc35..9cf4156b4 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -203,14 +203,7 @@ func TestIssueView(t *testing.T) { http := initFakeHTTP() http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "issue": { + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 123, "url": "https://github.com/OWNER/REPO/issues/123" } } } } @@ -243,14 +236,7 @@ func TestIssueView_preview(t *testing.T) { http := initFakeHTTP() http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "issue": { + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 123, "body": "**bold story**", "title": "ix of coins", @@ -294,13 +280,6 @@ func TestIssueView_notFound(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` { "errors": [ { "message": "Could not resolve to an Issue with the number of 9999." } @@ -346,14 +325,7 @@ func TestIssueView_urlArg(t *testing.T) { http := initFakeHTTP() http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "issue": { + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 123, "url": "https://github.com/OWNER/REPO/issues/123" } } } } From ea09883b07bb930f2093d680d207184f69718327 Mon Sep 17 00:00:00 2001 From: Amanda Pinsker Date: Wed, 22 Jan 2020 14:45:31 -0800 Subject: [PATCH 9/9] Restyle auth page --- context/config_success.go | 44 ++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/context/config_success.go b/context/config_success.go index 3a6fe978b..1919fc1a4 100644 --- a/context/config_success.go +++ b/context/config_success.go @@ -6,27 +6,37 @@ const oauthSuccessPage = ` Success: GitHub CLI - -

Authentication successful.

-

- You have completed logging into GitHub CLI.
- You may now close this tab and return to the terminal. -

- + +
+

Successfully authenticated GitHub CLI

+

You may now close this tab and return to the terminal.

+
`