diff --git a/api/client.go b/api/client.go index f3100815d..c056680c9 100644 --- a/api/client.go +++ b/api/client.go @@ -7,6 +7,7 @@ import ( "io" "io/ioutil" "net/http" + "regexp" "strings" ) @@ -35,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 }} @@ -193,3 +207,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/api/queries_issue.go b/api/queries_issue.go index 39a998199..50fcbd434 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 @@ -233,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 @@ -272,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 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/command/issue_test.go b/command/issue_test.go index e9f616f68..9cf4156b4 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -203,7 +203,7 @@ func TestIssueView(t *testing.T) { http := initFakeHTTP() http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "issue": { + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 123, "url": "https://github.com/OWNER/REPO/issues/123" } } } } @@ -236,7 +236,7 @@ func TestIssueView_preview(t *testing.T) { http := initFakeHTTP() http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "issue": { + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 123, "body": "**bold story**", "title": "ix of coins", @@ -303,12 +303,29 @@ 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": { "issue": { + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 123, "url": "https://github.com/OWNER/REPO/issues/123" } } } } diff --git a/command/root.go b/command/root.go index f2bfab638..c945d02e8 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 } @@ -112,7 +112,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 } 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.

+
` diff --git a/utils/utils.go b/utils/utils.go index 2c8c5ac0a..f60321907 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,27 @@ func Pluralize(num int, thing string) string { return fmt.Sprintf("%d %ss", num, thing) } } + +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 fmtDuration(int(ago.Minutes()), "minute") + } + if ago < 24*time.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 fmtDuration(int(ago.Hours()/24/365), "year") +} diff --git a/utils/utils_test.go b/utils/utils_test.go new file mode 100644 index 000000000..0891c2a39 --- /dev/null +++ b/utils/utils_test.go @@ -0,0 +1,38 @@ +package utils + +import ( + "testing" + "time" +) + +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", + "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 { + 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) + } + } +}