diff --git a/command/issue.go b/command/issue.go deleted file mode 100644 index 41dd30b7f..000000000 --- a/command/issue.go +++ /dev/null @@ -1,694 +0,0 @@ -package command - -import ( - "fmt" - "io" - "strconv" - "strings" - "time" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/api" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/cmd/pr/shared" - "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/githubtemplate" - "github.com/cli/cli/pkg/iostreams" - "github.com/cli/cli/pkg/text" - "github.com/cli/cli/utils" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -func init() { - issueCmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format") - - RootCmd.AddCommand(issueCmd) - issueCmd.AddCommand(issueStatusCmd) - - issueCmd.AddCommand(issueCreateCmd) - issueCreateCmd.Flags().StringP("title", "t", "", - "Supply a title. Will prompt for one otherwise.") - issueCreateCmd.Flags().StringP("body", "b", "", - "Supply a body. Will prompt for one otherwise.") - issueCreateCmd.Flags().BoolP("web", "w", false, "Open the browser to create an issue") - issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their `login`") - issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by `name`") - issueCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the issue to projects by `name`") - issueCreateCmd.Flags().StringP("milestone", "m", "", "Add the issue to a milestone by `name`") - - issueCmd.AddCommand(issueListCmd) - issueListCmd.Flags().BoolP("web", "w", false, "Open the browser to list the issue(s)") - issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") - issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by labels") - issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: {open|closed|all}") - issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of issues to fetch") - issueListCmd.Flags().StringP("author", "A", "", "Filter by author") - issueListCmd.Flags().String("mention", "", "Filter by mention") - issueListCmd.Flags().StringP("milestone", "m", "", "Filter by milestone `name`") - - issueCmd.AddCommand(issueViewCmd) - issueViewCmd.Flags().BoolP("web", "w", false, "Open an issue in the browser") - - issueCmd.AddCommand(issueCloseCmd) - issueCmd.AddCommand(issueReopenCmd) -} - -var issueCmd = &cobra.Command{ - Use: "issue ", - Short: "Create and view issues", - Long: `Work with GitHub issues`, - Example: heredoc.Doc(` - $ gh issue list - $ gh issue create --label bug - $ gh issue view --web - `), - Annotations: map[string]string{ - "IsCore": "true", - "help:arguments": `An issue can be supplied as argument in any of the following formats: -- by number, e.g. "123"; or -- by URL, e.g. "https://github.com/OWNER/REPO/issues/123".`}, -} -var issueCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create a new issue", - Args: cmdutil.NoArgsQuoteReminder, - RunE: issueCreate, - Example: heredoc.Doc(` - $ gh issue create --title "I found a bug" --body "Nothing works" - $ gh issue create --label "bug,help wanted" - $ gh issue create --label bug --label "help wanted" - $ gh issue create --assignee monalisa,hubot - $ gh issue create --project "Roadmap" - `), -} -var issueListCmd = &cobra.Command{ - Use: "list", - Short: "List and filter issues in this repository", - Example: heredoc.Doc(` - $ gh issue list -l "help wanted" - $ gh issue list -A monalisa - $ gh issue list --web - `), - Args: cmdutil.NoArgsQuoteReminder, - RunE: issueList, -} -var issueStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show status of relevant issues", - Args: cmdutil.NoArgsQuoteReminder, - RunE: issueStatus, -} -var issueViewCmd = &cobra.Command{ - Use: "view { | }", - Short: "View an issue", - Args: cobra.ExactArgs(1), - Long: `Display the title, body, and other information about an issue. - -With '--web', open the issue in a web browser instead.`, - RunE: issueView, -} -var issueCloseCmd = &cobra.Command{ - Use: "close { | }", - Short: "Close issue", - Args: cobra.ExactArgs(1), - RunE: issueClose, -} -var issueReopenCmd = &cobra.Command{ - Use: "reopen { | }", - Short: "Reopen issue", - Args: cobra.ExactArgs(1), - RunE: issueReopen, -} - -func issueList(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - state, err := cmd.Flags().GetString("state") - if err != nil { - return err - } - - labels, err := cmd.Flags().GetStringSlice("label") - if err != nil { - return err - } - - assignee, err := cmd.Flags().GetString("assignee") - if err != nil { - return err - } - - limit, err := cmd.Flags().GetInt("limit") - if err != nil { - return err - } - if limit <= 0 { - return fmt.Errorf("invalid limit: %v", limit) - } - - author, err := cmd.Flags().GetString("author") - if err != nil { - return err - } - - mention, err := cmd.Flags().GetString("mention") - if err != nil { - return err - } - - milestone, err := cmd.Flags().GetString("milestone") - if err != nil { - return err - } - - if web { - issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") - openURL, err := shared.ListURLWithQuery(issueListURL, shared.FilterOptions{ - Entity: "issue", - State: state, - Assignee: assignee, - Labels: labels, - Author: author, - Mention: mention, - Milestone: milestone, - }) - if err != nil { - return err - } - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) - return utils.OpenInBrowser(openURL) - } - - listResult, err := api.IssueList(apiClient, baseRepo, state, labels, assignee, limit, author, mention, milestone) - if err != nil { - return err - } - - hasFilters := false - cmd.Flags().Visit(func(f *pflag.Flag) { - switch f.Name { - case "state", "label", "assignee", "author", "mention", "milestone": - hasFilters = true - } - }) - - title := shared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters) - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title) - } - - out := cmd.OutOrStdout() - - printIssues(out, "", len(listResult.Issues), listResult.Issues) - - return nil -} - -func issueStatus(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - currentUser, err := api.CurrentLoginName(apiClient, baseRepo.RepoHost()) - if err != nil { - return err - } - - issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser) - if err != nil { - return err - } - - out := colorableOut(cmd) - - fmt.Fprintln(out, "") - fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo)) - fmt.Fprintln(out, "") - - shared.PrintHeader(out, "Issues assigned to you") - if issuePayload.Assigned.TotalCount > 0 { - printIssues(out, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues) - } else { - message := " There are no issues assigned to you" - shared.PrintMessage(out, message) - } - fmt.Fprintln(out) - - shared.PrintHeader(out, "Issues mentioning you") - if issuePayload.Mentioned.TotalCount > 0 { - printIssues(out, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues) - } else { - shared.PrintMessage(out, " There are no issues mentioning you") - } - fmt.Fprintln(out) - - shared.PrintHeader(out, "Issues opened by you") - if issuePayload.Authored.TotalCount > 0 { - printIssues(out, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues) - } else { - shared.PrintMessage(out, " There are no issues opened by you") - } - fmt.Fprintln(out) - - return nil -} - -func issueView(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - issue, _, err := issueFromArg(ctx, apiClient, cmd, args[0]) - if err != nil { - return err - } - openURL := issue.URL - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - if web { - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL) - return utils.OpenInBrowser(openURL) - } - if connectedToTerminal(cmd) { - return printHumanIssuePreview(colorableOut(cmd), issue) - } - - return printRawIssuePreview(cmd.OutOrStdout(), issue) -} - -func issueStateTitleWithColor(state string) string { - colorFunc := shared.ColorFuncForState(state) - return colorFunc(strings.Title(strings.ToLower(state))) -} - -func printRawIssuePreview(out io.Writer, issue *api.Issue) error { - assignees := issueAssigneeList(*issue) - labels := issueLabelList(*issue) - projects := issueProjectList(*issue) - - // Print empty strings for empty values so the number of metadata lines is consistent when - // processing many issues with head and grep. - fmt.Fprintf(out, "title:\t%s\n", issue.Title) - fmt.Fprintf(out, "state:\t%s\n", issue.State) - fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login) - fmt.Fprintf(out, "labels:\t%s\n", labels) - fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) - fmt.Fprintf(out, "assignees:\t%s\n", assignees) - fmt.Fprintf(out, "projects:\t%s\n", projects) - fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title) - - fmt.Fprintln(out, "--") - fmt.Fprintln(out, issue.Body) - return nil -} - -func printHumanIssuePreview(out io.Writer, issue *api.Issue) error { - now := time.Now() - ago := now.Sub(issue.CreatedAt) - - // Header (Title and State) - fmt.Fprintln(out, utils.Bold(issue.Title)) - fmt.Fprint(out, issueStateTitleWithColor(issue.State)) - fmt.Fprintln(out, utils.Gray(fmt.Sprintf( - " • %s opened %s • %s", - issue.Author.Login, - utils.FuzzyAgo(ago), - utils.Pluralize(issue.Comments.TotalCount, "comment"), - ))) - - // Metadata - fmt.Fprintln(out) - if assignees := issueAssigneeList(*issue); assignees != "" { - fmt.Fprint(out, utils.Bold("Assignees: ")) - fmt.Fprintln(out, assignees) - } - if labels := issueLabelList(*issue); labels != "" { - fmt.Fprint(out, utils.Bold("Labels: ")) - fmt.Fprintln(out, labels) - } - if projects := issueProjectList(*issue); projects != "" { - fmt.Fprint(out, utils.Bold("Projects: ")) - fmt.Fprintln(out, projects) - } - if issue.Milestone.Title != "" { - fmt.Fprint(out, utils.Bold("Milestone: ")) - fmt.Fprintln(out, issue.Milestone.Title) - } - - // Body - if issue.Body != "" { - fmt.Fprintln(out) - md, err := utils.RenderMarkdown(issue.Body) - if err != nil { - return err - } - fmt.Fprintln(out, md) - } - fmt.Fprintln(out) - - // Footer - fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL) - return nil -} - -func issueCreate(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - // NB no auto forking like over in pr create - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - baseOverride, err := cmd.Flags().GetString("repo") - if err != nil { - return err - } - - var nonLegacyTemplateFiles []string - if baseOverride == "" { - if rootDir, err := git.ToplevelDir(); err == nil { - // TODO: figure out how to stub this in tests - nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE") - } - } - - title, err := cmd.Flags().GetString("title") - if err != nil { - return fmt.Errorf("could not parse title: %w", err) - } - body, err := cmd.Flags().GetString("body") - if err != nil { - return fmt.Errorf("could not parse body: %w", err) - } - - assignees, err := cmd.Flags().GetStringSlice("assignee") - if err != nil { - return fmt.Errorf("could not parse assignees: %w", err) - } - labelNames, err := cmd.Flags().GetStringSlice("label") - if err != nil { - return fmt.Errorf("could not parse labels: %w", err) - } - projectNames, err := cmd.Flags().GetStringSlice("project") - if err != nil { - return fmt.Errorf("could not parse projects: %w", err) - } - var milestoneTitles []string - if milestoneTitle, err := cmd.Flags().GetString("milestone"); err != nil { - return fmt.Errorf("could not parse milestone: %w", err) - } else if milestoneTitle != "" { - milestoneTitles = append(milestoneTitles, milestoneTitle) - } - - if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb { - openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") - if title != "" || body != "" { - openURL, err = shared.WithPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestoneTitles) - if err != nil { - return err - } - } else if len(nonLegacyTemplateFiles) > 1 { - openURL += "/choose" - } - if connectedToTerminal(cmd) { - cmd.Printf("Opening %s in your browser.\n", utils.DisplayURL(openURL)) - } - return utils.OpenInBrowser(openURL) - } - - fmt.Fprintf(colorableErr(cmd), "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo)) - - repo, err := api.GitHubRepo(apiClient, baseRepo) - if err != nil { - return err - } - if !repo.HasIssuesEnabled { - return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) - } - - action := shared.SubmitAction - tb := shared.IssueMetadataState{ - Type: shared.IssueMetadata, - Assignees: assignees, - Labels: labelNames, - Projects: projectNames, - Milestones: milestoneTitles, - } - - interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) - - if interactive && !connectedToTerminal(cmd) { - return fmt.Errorf("must provide --title and --body when not attached to a terminal") - } - - if interactive { - var legacyTemplateFile *string - if baseOverride == "" { - if rootDir, err := git.ToplevelDir(); err == nil { - // TODO: figure out how to stub this in tests - legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE") - } - } - - editorCommand, err := cmdutil.DetermineEditor(ctx.Config) - if err != nil { - return err - } - - err = shared.TitleBodySurvey(defaultStreams, editorCommand, &tb, apiClient, baseRepo, title, body, shared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage()) - if err != nil { - return fmt.Errorf("could not collect title and/or body: %w", err) - } - - action = tb.Action - - if tb.Action == shared.CancelAction { - fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.") - - return nil - } - - if title == "" { - title = tb.Title - } - if body == "" { - body = tb.Body - } - } else { - if title == "" { - return fmt.Errorf("title can't be blank") - } - } - - if action == shared.PreviewAction { - openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") - openURL, err = shared.WithPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestoneTitles) - if err != nil { - return err - } - // TODO could exceed max url length for explorer - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) - return utils.OpenInBrowser(openURL) - } else if action == shared.SubmitAction { - params := map[string]interface{}{ - "title": title, - "body": body, - } - - err = shared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) - if err != nil { - return err - } - - newIssue, err := api.IssueCreate(apiClient, repo, params) - if err != nil { - return err - } - - fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL) - } else { - panic("Unreachable state") - } - - return nil -} - -func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) { - io := &iostreams.IOStreams{Out: w} - io.SetStdoutTTY(utils.IsTerminal(w)) - table := utils.NewTablePrinter(io) - for _, issue := range issues { - issueNum := strconv.Itoa(issue.Number) - if table.IsTTY() { - issueNum = "#" + issueNum - } - issueNum = prefix + issueNum - labels := issueLabelList(issue) - if labels != "" && table.IsTTY() { - labels = fmt.Sprintf("(%s)", labels) - } - now := time.Now() - ago := now.Sub(issue.UpdatedAt) - table.AddField(issueNum, nil, shared.ColorFuncForState(issue.State)) - if !table.IsTTY() { - table.AddField(issue.State, nil, nil) - } - table.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil) - table.AddField(labels, nil, utils.Gray) - if table.IsTTY() { - table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray) - } else { - table.AddField(issue.UpdatedAt.String(), nil, nil) - } - table.EndRow() - } - _ = table.Render() - remaining := totalCount - len(issues) - if remaining > 0 { - fmt.Fprintf(w, utils.Gray("%sAnd %d more\n"), prefix, remaining) - } -} - -func issueAssigneeList(issue api.Issue) string { - if len(issue.Assignees.Nodes) == 0 { - return "" - } - - AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes)) - for _, assignee := range issue.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) - } - - list := strings.Join(AssigneeNames, ", ") - if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) { - list += ", …" - } - return list -} - -func issueLabelList(issue api.Issue) string { - if len(issue.Labels.Nodes) == 0 { - return "" - } - - labelNames := make([]string, 0, len(issue.Labels.Nodes)) - for _, label := range issue.Labels.Nodes { - labelNames = append(labelNames, label.Name) - } - - list := strings.Join(labelNames, ", ") - if issue.Labels.TotalCount > len(issue.Labels.Nodes) { - list += ", …" - } - return list -} - -func issueProjectList(issue api.Issue) string { - if len(issue.ProjectCards.Nodes) == 0 { - return "" - } - - projectNames := make([]string, 0, len(issue.ProjectCards.Nodes)) - for _, project := range issue.ProjectCards.Nodes { - colName := project.Column.Name - if colName == "" { - colName = "Awaiting triage" - } - projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) - } - - list := strings.Join(projectNames, ", ") - if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) { - list += ", …" - } - return list -} - -func issueClose(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - issue, baseRepo, err := issueFromArg(ctx, apiClient, cmd, args[0]) - if err != nil { - return err - } - - if issue.Closed { - fmt.Fprintf(colorableErr(cmd), "%s Issue #%d (%s) is already closed\n", utils.Yellow("!"), issue.Number, issue.Title) - return nil - } - - err = api.IssueClose(apiClient, baseRepo, *issue) - if err != nil { - return err - } - - fmt.Fprintf(colorableErr(cmd), "%s Closed issue #%d (%s)\n", utils.Red("✔"), issue.Number, issue.Title) - - return nil -} - -func issueReopen(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - issue, baseRepo, err := issueFromArg(ctx, apiClient, cmd, args[0]) - if err != nil { - return err - } - - if !issue.Closed { - fmt.Fprintf(colorableErr(cmd), "%s Issue #%d (%s) is already open\n", utils.Yellow("!"), issue.Number, issue.Title) - return nil - } - - err = api.IssueReopen(apiClient, baseRepo, *issue) - if err != nil { - return err - } - - fmt.Fprintf(colorableErr(cmd), "%s Reopened issue #%d (%s)\n", utils.Green("✔"), issue.Number, issue.Title) - - return nil -} diff --git a/command/issue_test.go b/command/issue_test.go deleted file mode 100644 index 16d613d72..000000000 --- a/command/issue_test.go +++ /dev/null @@ -1,962 +0,0 @@ -package command - -import ( - "bytes" - "encoding/json" - "io/ioutil" - "os/exec" - "regexp" - "strings" - "testing" - - "github.com/cli/cli/internal/run" - "github.com/cli/cli/pkg/httpmock" - "github.com/cli/cli/test" - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/assert" -) - -func TestIssueStatus(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) - http.Register( - httpmock.GraphQL(`query IssueStatus\b`), - httpmock.FileResponse("../test/fixtures/issueStatus.json")) - - output, err := RunCommand("issue status") - if err != nil { - t.Errorf("error running command `issue status`: %v", err) - } - - expectedIssues := []*regexp.Regexp{ - regexp.MustCompile(`(?m)8.*carrots.*about.*ago`), - regexp.MustCompile(`(?m)9.*squash.*about.*ago`), - regexp.MustCompile(`(?m)10.*broccoli.*about.*ago`), - regexp.MustCompile(`(?m)11.*swiss chard.*about.*ago`), - } - - for _, r := range expectedIssues { - if !r.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) - return - } - } -} - -func TestIssueStatus_blankSlate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) - http.Register( - httpmock.GraphQL(`query IssueStatus\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "assigned": { "nodes": [] }, - "mentioned": { "nodes": [] }, - "authored": { "nodes": [] } - } } }`)) - - output, err := RunCommand("issue status") - if err != nil { - t.Errorf("error running command `issue status`: %v", err) - } - - expectedOutput := ` -Relevant issues in OWNER/REPO - -Issues assigned to you - There are no issues assigned to you - -Issues mentioning you - There are no issues mentioning you - -Issues opened by you - There are no issues opened by you - -` - if output.String() != expectedOutput { - t.Errorf("expected %q, got %q", expectedOutput, output) - } -} - -func TestIssueStatus_disabledIssues(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) - http.Register( - httpmock.GraphQL(`query IssueStatus\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } }`)) - - _, err := RunCommand("issue status") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Errorf("error running command `issue status`: %v", err) - } -} - -func TestIssueList_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.Register( - httpmock.GraphQL(`query IssueList\b`), - httpmock.FileResponse("../test/fixtures/issueList.json")) - - output, err := RunCommand("issue list") - if err != nil { - t.Errorf("error running command `issue list`: %v", err) - } - - eq(t, output.Stderr(), "") - test.ExpectLines(t, output.String(), - `1[\t]+number won[\t]+label[\t]+\d+`, - `2[\t]+number too[\t]+label[\t]+\d+`, - `4[\t]+number fore[\t]+label[\t]+\d+`) -} - -func TestIssueList_tty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query IssueList\b`), - httpmock.FileResponse("../test/fixtures/issueList.json")) - - output, err := RunCommand("issue list") - if err != nil { - t.Errorf("error running command `issue list`: %v", err) - } - - eq(t, output.Stderr(), ` -Showing 3 of 3 open issues in OWNER/REPO - -`) - - test.ExpectLines(t, output.String(), - "number won", - "number too", - "number fore") -} - -func TestIssueList_tty_withFlags(t *testing.T) { - defer stubTerminal(true)() - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query IssueList\b`), - httpmock.GraphQLQuery(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { "nodes": [] } - } } }`, func(_ string, params map[string]interface{}) { - assert.Equal(t, "probablyCher", params["assignee"].(string)) - assert.Equal(t, "foo", params["author"].(string)) - assert.Equal(t, "me", params["mention"].(string)) - assert.Equal(t, "1.x", params["milestone"].(string)) - assert.Equal(t, []interface{}{"web", "bug"}, params["labels"].([]interface{})) - assert.Equal(t, []interface{}{"OPEN"}, params["states"].([]interface{})) - })) - - output, err := RunCommand("issue list -a probablyCher -l web,bug -s open -A foo --mention me --milestone 1.x") - if err != nil { - t.Errorf("error running command `issue list`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), ` -No issues match your search in OWNER/REPO - -`) -} - -func TestIssueList_withInvalidLimitFlag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - _, err := RunCommand("issue list --limit=0") - - if err == nil || err.Error() != "invalid limit: 0" { - t.Errorf("error running command `issue list`: %v", err) - } -} - -func TestIssueList_nullAssigneeLabels(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { "nodes": [] } - } } } - `)) - - _, err := RunCommand("issue list") - if err != nil { - t.Errorf("error running command `issue list`: %v", err) - } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables map[string]interface{} - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - _, assigneeDeclared := reqBody.Variables["assignee"] - _, labelsDeclared := reqBody.Variables["labels"] - eq(t, assigneeDeclared, false) - eq(t, labelsDeclared, false) -} - -func TestIssueList_disabledIssues(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand("issue list") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Errorf("error running command `issue list`: %v", err) - } -} - -func TestIssueList_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("issue list --web -a peter -A john -l bug -l docs -L 10 -s all --mention frank --milestone v1.1") - if err != nil { - t.Errorf("error running command `issue list` with `--web` flag: %v", err) - } - - expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1" - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, expectedURL) -} - -func TestIssueView_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("issue view -w 123") - if err != nil { - t.Errorf("error running command `issue view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") -} - -func TestIssueView_web_numberArgWithHash(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("issue view -w \"#123\"") - if err != nil { - t.Errorf("error running command `issue view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") -} - -func TestIssueView_nontty_Preview(t *testing.T) { - defer stubTerminal(false)() - tests := map[string]struct { - ownerRepo string - command string - fixture string - expectedOutputs []string - }{ - "Open issue without metadata": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_preview.json", - expectedOutputs: []string{ - `title:\tix of coins`, - `state:\tOPEN`, - `comments:\t9`, - `author:\tmarseilles`, - `assignees:`, - `\*\*bold story\*\*`, - }, - }, - "Open issue with metadata": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewWithMetadata.json", - expectedOutputs: []string{ - `title:\tix of coins`, - `assignees:\tmarseilles, monaco`, - `author:\tmarseilles`, - `state:\tOPEN`, - `comments:\t9`, - `labels:\tone, two, three, four, five`, - `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, - `milestone:\tuluru\n`, - `\*\*bold story\*\*`, - }, - }, - "Open issue with empty body": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewWithEmptyBody.json", - expectedOutputs: []string{ - `title:\tix of coins`, - `state:\tOPEN`, - `author:\tmarseilles`, - `labels:\ttarot`, - }, - }, - "Closed issue": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewClosedState.json", - expectedOutputs: []string{ - `title:\tix of coins`, - `state:\tCLOSED`, - `\*\*bold story\*\*`, - `author:\tmarseilles`, - `labels:\ttarot`, - `\*\*bold story\*\*`, - }, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - initBlankContext("", "OWNER/REPO", tc.ownerRepo) - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) - - output, err := RunCommand(tc.command) - if err != nil { - t.Errorf("error running command `%v`: %v", tc.command, err) - } - - eq(t, output.Stderr(), "") - - test.ExpectLines(t, output.String(), tc.expectedOutputs...) - }) - } -} - -func TestIssueView_tty_Preview(t *testing.T) { - defer stubTerminal(true)() - tests := map[string]struct { - ownerRepo string - command string - fixture string - expectedOutputs []string - }{ - "Open issue without metadata": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_preview.json", - expectedOutputs: []string{ - `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, - `bold story`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - "Open issue with metadata": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewWithMetadata.json", - expectedOutputs: []string{ - `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, - `Assignees:.*marseilles, monaco\n`, - `Labels:.*one, two, three, four, five\n`, - `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, - `Milestone:.*uluru\n`, - `bold story`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - "Open issue with empty body": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewWithEmptyBody.json", - expectedOutputs: []string{ - `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - "Closed issue": { - ownerRepo: "master", - command: "issue view 123", - fixture: "../test/fixtures/issueView_previewClosedState.json", - expectedOutputs: []string{ - `ix of coins`, - `Closed.*marseilles opened about 292 years ago.*9 comments`, - `bold story`, - `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, - }, - }, - } - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - initBlankContext("", "OWNER/REPO", tc.ownerRepo) - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) - - output, err := RunCommand(tc.command) - if err != nil { - t.Errorf("error running command `%v`: %v", tc.command, err) - } - - eq(t, output.Stderr(), "") - - test.ExpectLines(t, output.String(), tc.expectedOutputs...) - }) - } -} - -func TestIssueView_web_notFound(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "errors": [ - { "message": "Could not resolve to an Issue with the number of 9999." } - ] } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - _, err := RunCommand("issue view -w 9999") - if err == nil || err.Error() != "GraphQL error: Could not resolve to an Issue with the number of 9999." { - t.Errorf("error running command `issue view`: %v", err) - } - - if seenCmd != nil { - t.Fatal("did not expect any command to run") - } -} - -func TestIssueView_disabledIssues(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand(`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_web_urlArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "hasIssuesEnabled": true, "issue": { - "number": 123, - "url": "https://github.com/OWNER/REPO/issues/123" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("issue view -w https://github.com/OWNER/REPO/issues/123") - if err != nil { - t.Errorf("error running command `issue view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/123") -} - -func TestIssueCreate_nontty_error(t *testing.T) { - defer stubTerminal(false)() - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - - _, err := RunCommand(`issue create -t hello`) - if err == nil { - t.Fatal("expected error running command `issue create`") - } - - assert.Equal(t, "must provide --title and --body when not attached to a terminal", err.Error()) - -} - -func TestIssueCreate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createIssue": { "issue": { - "URL": "https://github.com/OWNER/REPO/issues/12" - } } } } - `)) - - output, err := RunCommand(`issue create -t hello -b "cash rules everything around me"`) - if err != nil { - t.Errorf("error running command `issue create`: %v", err) - } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "hello") - eq(t, reqBody.Variables.Input.Body, "cash rules everything around me") - - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") -} - -func TestIssueCreate_metadata(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query RepositoryNetwork\b`), - httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true, - "viewerPermission": "WRITE" - } } } - `)) - http.Register( - httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), - httpmock.StringResponse(` - { "data": { - "u000": { "login": "MonaLisa", "id": "MONAID" }, - "repository": { - "l000": { "name": "bug", "id": "BUGID" }, - "l001": { "name": "TODO", "id": "TODOID" } - } - } } - `)) - http.Register( - httpmock.GraphQL(`query RepositoryMilestoneList\b`), - httpmock.StringResponse(` - { "data": { "repository": { "milestones": { - "nodes": [ - { "title": "GA", "id": "GAID" }, - { "title": "Big One.oh", "id": "BIGONEID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - http.Register( - httpmock.GraphQL(`query RepositoryProjectList\b`), - httpmock.StringResponse(` - { "data": { "repository": { "projects": { - "nodes": [ - { "name": "Cleanup", "id": "CLEANUPID" }, - { "name": "Roadmap", "id": "ROADMAPID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - http.Register( - httpmock.GraphQL(`query OrganizationProjectList\b`), - httpmock.StringResponse(` - { "data": { "organization": null }, - "errors": [{ - "type": "NOT_FOUND", - "path": [ "organization" ], - "message": "Could not resolve to an Organization with the login of 'OWNER'." - }] - } - `)) - http.Register( - httpmock.GraphQL(`mutation IssueCreate\b`), - httpmock.GraphQLMutation(` - { "data": { "createIssue": { "issue": { - "URL": "https://github.com/OWNER/REPO/issues/12" - } } } } - `, func(inputs map[string]interface{}) { - eq(t, inputs["title"], "TITLE") - eq(t, inputs["body"], "BODY") - eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) - eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) - eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) - eq(t, inputs["milestoneId"], "BIGONEID") - if v, ok := inputs["userIds"]; ok { - t.Errorf("did not expect userIds: %v", v) - } - if v, ok := inputs["teamIds"]; ok { - t.Errorf("did not expect teamIds: %v", v) - } - })) - - output, err := RunCommand(`issue create -t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`) - if err != nil { - t.Errorf("error running command `issue create`: %v", err) - } - - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") -} - -func TestIssueCreate_disabledIssues(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand(`issue create -t heres -b johnny`) - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Errorf("error running command `issue create`: %v", err) - } -} - -func TestIssueCreate_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - defer stubTerminal(true)() - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand(`issue create --web`) - if err != nil { - t.Errorf("error running command `issue create`: %v", err) - } - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/issues/new") - eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") - eq(t, output.Stderr(), "") -} - -func TestIssueCreate_webTitleBody(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - defer stubTerminal(true)() - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand(`issue create -w -t mytitle -b mybody`) - if err != nil { - t.Errorf("error running command `issue create`: %v", err) - } - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") - eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle") - eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") -} - -func TestIssueStateTitleWithColor(t *testing.T) { - tests := map[string]struct { - state string - want string - }{ - "Open state": {state: "OPEN", want: "Open"}, - "Closed state": {state: "CLOSED", want: "Closed"}, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - got := issueStateTitleWithColor(tc.state) - diff := cmp.Diff(tc.want, got) - if diff != "" { - t.Fatalf(diff) - } - }) - } -} - -func TestIssueClose(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 13, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("issue close 13") - if err != nil { - t.Fatalf("error running command `issue close`: %v", err) - } - - r := regexp.MustCompile(`Closed issue #13 \(The title of the issue\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueClose_alreadyClosed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 13, "title": "The title of the issue", "closed": true} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("issue close 13") - if err != nil { - t.Fatalf("error running command `issue close`: %v", err) - } - - r := regexp.MustCompile(`Issue #13 \(The title of the issue\) is already closed`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueClose_issuesDisabled(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand("issue close 13") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Fatalf("got error: %v", err) - } -} - -func TestIssueReopen(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 2, "closed": true, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("issue reopen 2") - if err != nil { - t.Fatalf("error running command `issue reopen`: %v", err) - } - - r := regexp.MustCompile(`Reopened issue #2 \(The title of the issue\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueReopen_alreadyOpen(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 2, "closed": false, "title": "The title of the issue"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("issue reopen 2") - if err != nil { - t.Fatalf("error running command `issue reopen`: %v", err) - } - - r := regexp.MustCompile(`Issue #2 \(The title of the issue\) is already open`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueReopen_issuesDisabled(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) - - _, err := RunCommand("issue reopen 2") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Fatalf("got error: %v", err) - } -} diff --git a/command/root.go b/command/root.go index ad64676f3..19206b648 100644 --- a/command/root.go +++ b/command/root.go @@ -23,6 +23,7 @@ import ( "github.com/cli/cli/internal/run" apiCmd "github.com/cli/cli/pkg/cmd/api" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" + issueCmd "github.com/cli/cli/pkg/cmd/issue" prCmd "github.com/cli/cli/pkg/cmd/pr" repoCmd "github.com/cli/cli/pkg/cmd/repo" repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone" @@ -169,6 +170,7 @@ func init() { repoCmd.Cmd.AddCommand(creditsCmd.NewCmdRepoCredits(&repoResolvingCmdFactory, nil)) RootCmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory)) + RootCmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory)) RootCmd.AddCommand(creditsCmd.NewCmdCredits(cmdFactory, nil)) } diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go new file mode 100644 index 000000000..66314a86d --- /dev/null +++ b/pkg/cmd/issue/close/close.go @@ -0,0 +1,80 @@ +package close + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/issue/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type CloseOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command { + opts := &CloseOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "close { | }", + Short: "Close issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return closeRun(opts) + }, + } + + return cmd +} + +func closeRun(opts *CloseOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + if err != nil { + return err + } + + if issue.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already closed\n", utils.Yellow("!"), issue.Number, issue.Title) + return nil + } + + err = api.IssueClose(apiClient, baseRepo, *issue) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Closed issue #%d (%s)\n", utils.Red("✔"), issue.Number, issue.Title) + + return nil +} diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go new file mode 100644 index 000000000..eadf7f440 --- /dev/null +++ b/pkg/cmd/issue/close/close_test.go @@ -0,0 +1,119 @@ +package close + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdClose(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueClose(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13, "title": "The title of the issue"} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "13") + if err != nil { + t.Fatalf("error running command `issue close`: %v", err) + } + + r := regexp.MustCompile(`Closed issue #13 \(The title of the issue\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueClose_alreadyClosed(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13, "title": "The title of the issue", "closed": true} + } } } + `)) + + output, err := runCommand(http, true, "13") + if err != nil { + t.Fatalf("error running command `issue close`: %v", err) + } + + r := regexp.MustCompile(`Issue #13 \(The title of the issue\) is already closed`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueClose_issuesDisabled(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, "13") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Fatalf("got error: %v", err) + } +} diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go new file mode 100644 index 000000000..8d47e4e27 --- /dev/null +++ b/pkg/cmd/issue/create/create.go @@ -0,0 +1,228 @@ +package create + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/githubtemplate" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type CreateOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RepoOverride string + WebMode bool + + Title string + TitleProvided bool + Body string + BodyProvided bool + + Assignees []string + Labels []string + Projects []string + Milestone string +} + +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new issue", + Example: heredoc.Doc(` + $ gh issue create --title "I found a bug" --body "Nothing works" + $ gh issue create --label "bug,help wanted" + $ gh issue create --label bug --label "help wanted" + $ gh issue create --assignee monalisa,hubot + $ gh issue create --project "Roadmap" + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.TitleProvided = cmd.Flags().Changed("title") + opts.BodyProvided = cmd.Flags().Changed("body") + opts.RepoOverride, _ = cmd.Flags().GetString("repo") + + if runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue") + cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") + cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`") + cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") + + return cmd +} + +func createRun(opts *CreateOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + var nonLegacyTemplateFiles []string + if opts.RepoOverride == "" { + if rootDir, err := git.ToplevelDir(); err == nil { + // TODO: figure out how to stub this in tests + nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE") + } + } + + isTerminal := opts.IO.IsStdoutTTY() + + var milestones []string + if opts.Milestone != "" { + milestones = []string{opts.Milestone} + } + + if opts.WebMode { + openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") + if opts.Title != "" || opts.Body != "" { + openURL, err = prShared.WithPrAndIssueQueryParams(openURL, opts.Title, opts.Body, opts.Assignees, opts.Labels, opts.Projects, milestones) + if err != nil { + return err + } + } else if len(nonLegacyTemplateFiles) > 1 { + openURL += "/choose" + } + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo)) + } + + repo, err := api.GitHubRepo(apiClient, baseRepo) + if err != nil { + return err + } + if !repo.HasIssuesEnabled { + return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) + } + + action := prShared.SubmitAction + tb := prShared.IssueMetadataState{ + Type: prShared.IssueMetadata, + Assignees: opts.Assignees, + Labels: opts.Labels, + Projects: opts.Projects, + Milestones: milestones, + } + + title := opts.Title + body := opts.Body + + interactive := !(opts.TitleProvided && opts.BodyProvided) + + if interactive && !isTerminal { + return fmt.Errorf("must provide --title and --body when not attached to a terminal") + } + + if interactive { + var legacyTemplateFile *string + if opts.RepoOverride == "" { + if rootDir, err := git.ToplevelDir(); err == nil { + // TODO: figure out how to stub this in tests + legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE") + } + } + + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + err = prShared.TitleBodySurvey(opts.IO, editorCommand, &tb, apiClient, baseRepo, title, body, prShared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage()) + if err != nil { + return fmt.Errorf("could not collect title and/or body: %w", err) + } + + action = tb.Action + + if tb.Action == prShared.CancelAction { + fmt.Fprintln(opts.IO.ErrOut, "Discarding.") + + return nil + } + + if title == "" { + title = tb.Title + } + if body == "" { + body = tb.Body + } + } else { + if title == "" { + return fmt.Errorf("title can't be blank") + } + } + + if action == prShared.PreviewAction { + openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") + openURL, err = prShared.WithPrAndIssueQueryParams(openURL, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones) + if err != nil { + return err + } + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } else if action == prShared.SubmitAction { + params := map[string]interface{}{ + "title": title, + "body": body, + } + + err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) + if err != nil { + return err + } + + newIssue, err := api.IssueCreate(apiClient, repo, params) + if err != nil { + return err + } + + fmt.Fprintln(opts.IO.Out, newIssue.URL) + } else { + panic("Unreachable state") + } + + return nil +} diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go new file mode 100644 index 000000000..b483d090b --- /dev/null +++ b/pkg/cmd/issue/create/create_test.go @@ -0,0 +1,280 @@ +package create + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "strings" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdCreate(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueCreate_nontty_error(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + + _, err := runCommand(http, false, `-t hello`) + if err == nil { + t.Fatal("expected error running command `issue create`") + } + + assert.Equal(t, "must provide --title and --body when not attached to a terminal", err.Error()) + +} + +func TestIssueCreate(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `)) + + output, err := runCommand(http, true, `-t hello -b "cash rules everything around me"`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + reqBody := struct { + Variables struct { + Input struct { + RepositoryID string + Title string + Body string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") + eq(t, reqBody.Variables.Input.Title, "hello") + eq(t, reqBody.Variables.Input.Body, "cash rules everything around me") + + eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") +} + +func TestIssueCreate_metadata(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true, + "viewerPermission": "WRITE" + } } } + `)) + http.Register( + httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), + httpmock.StringResponse(` + { "data": { + "u000": { "login": "MonaLisa", "id": "MONAID" }, + "repository": { + "l000": { "name": "bug", "id": "BUGID" }, + "l001": { "name": "TODO", "id": "TODOID" } + } + } } + `)) + http.Register( + httpmock.GraphQL(`query RepositoryMilestoneList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [ + { "name": "Cleanup", "id": "CLEANUPID" }, + { "name": "Roadmap", "id": "ROADMAPID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` + { "data": { "organization": null }, + "errors": [{ + "type": "NOT_FOUND", + "path": [ "organization" ], + "message": "Could not resolve to an Organization with the login of 'OWNER'." + }] + } + `)) + http.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `, func(inputs map[string]interface{}) { + eq(t, inputs["title"], "TITLE") + eq(t, inputs["body"], "BODY") + eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) + eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) + eq(t, inputs["milestoneId"], "BIGONEID") + if v, ok := inputs["userIds"]; ok { + t.Errorf("did not expect userIds: %v", v) + } + if v, ok := inputs["teamIds"]; ok { + t.Errorf("did not expect teamIds: %v", v) + } + })) + + output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") +} + +func TestIssueCreate_disabledIssues(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, `-t heres -b johnny`) + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue create`: %v", err) + } +} + +func TestIssueCreate_web(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, `--web`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/issues/new") + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") +} + +func TestIssueCreate_webTitleBody(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, `-w -t mytitle -b mybody`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") + eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle") + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") +} diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go new file mode 100644 index 000000000..91ed04b22 --- /dev/null +++ b/pkg/cmd/issue/issue.go @@ -0,0 +1,54 @@ +package issue + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" + cmdClose "github.com/cli/cli/pkg/cmd/issue/close" + cmdCreate "github.com/cli/cli/pkg/cmd/issue/create" + cmdList "github.com/cli/cli/pkg/cmd/issue/list" + cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen" + cmdStatus "github.com/cli/cli/pkg/cmd/issue/status" + cmdView "github.com/cli/cli/pkg/cmd/issue/view" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdIssue(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "issue ", + Short: "Manage issues", + Long: `Work with GitHub issues`, + Example: heredoc.Doc(` + $ gh issue list + $ gh issue create --label bug + $ gh issue view --web + `), + Annotations: map[string]string{ + "IsCore": "true", + "help:arguments": heredoc.Doc(` + An issue can be supplied as argument in any of the following formats: + - by number, e.g. "123"; or + - by URL, e.g. "https://github.com/OWNER/REPO/issues/123". + `), + }, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if repo, _ := cmd.Flags().GetString("repo"); repo != "" { + // NOTE: this mutates the factory + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName(repo) + } + } + }, + } + + cmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format") + + cmd.AddCommand(cmdClose.NewCmdClose(f, nil)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil)) + cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) + + return cmd +} diff --git a/test/fixtures/issueList.json b/pkg/cmd/issue/list/fixtures/issueList.json similarity index 100% rename from test/fixtures/issueList.json rename to pkg/cmd/issue/list/fixtures/issueList.json diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go new file mode 100644 index 000000000..d599da975 --- /dev/null +++ b/pkg/cmd/issue/list/list.go @@ -0,0 +1,127 @@ +package list + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + issueShared "github.com/cli/cli/pkg/cmd/issue/shared" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + WebMode bool + + Assignee string + Labels []string + State string + LimitResults int + Author string + Mention string + Milestone string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List and filter issues in this repository", + Example: heredoc.Doc(` + $ gh issue list -l "help wanted" + $ gh issue list -A monalisa + $ gh issue list --web + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if opts.LimitResults < 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.LimitResults)} + } + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to list the issue(s)") + cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by labels") + cmd.Flags().StringVarP(&opts.State, "state", "s", "open", "Filter by state: {open|closed|all}") + cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of issues to fetch") + cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author") + cmd.Flags().StringVar(&opts.Mention, "mention", "", "Filter by mention") + cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Filter by milestone `name`") + + return cmd +} + +func listRun(opts *ListOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + isTerminal := opts.IO.IsStdoutTTY() + + if opts.WebMode { + issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") + openURL, err := prShared.ListURLWithQuery(issueListURL, prShared.FilterOptions{ + Entity: "issue", + State: opts.State, + Assignee: opts.Assignee, + Labels: opts.Labels, + Author: opts.Author, + Mention: opts.Mention, + Milestone: opts.Milestone, + }) + if err != nil { + return err + } + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + + listResult, err := api.IssueList(apiClient, baseRepo, opts.State, opts.Labels, opts.Assignee, opts.LimitResults, opts.Author, opts.Mention, opts.Milestone) + if err != nil { + return err + } + + if isTerminal { + hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.Assignee != "" || opts.Author != "" || opts.Mention != "" || opts.Milestone != "" + title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters) + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title) + } + + issueShared.PrintIssues(opts.IO, "", len(listResult.Issues), listResult.Issues) + + return nil +} diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go new file mode 100644 index 000000000..7a42eb286 --- /dev/null +++ b/pkg/cmd/issue/list/list_test.go @@ -0,0 +1,223 @@ +package list + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdList(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} +func TestIssueList_nontty(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.FileResponse("./fixtures/issueList.json")) + + output, err := runCommand(http, false, "") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + eq(t, output.Stderr(), "") + test.ExpectLines(t, output.String(), + `1[\t]+number won[\t]+label[\t]+\d+`, + `2[\t]+number too[\t]+label[\t]+\d+`, + `4[\t]+number fore[\t]+label[\t]+\d+`) +} + +func TestIssueList_tty(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.FileResponse("./fixtures/issueList.json")) + + output, err := runCommand(http, true, "") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + eq(t, output.Stderr(), ` +Showing 3 of 3 open issues in OWNER/REPO + +`) + + test.ExpectLines(t, output.String(), + "number won", + "number too", + "number fore") +} + +func TestIssueList_tty_withFlags(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.GraphQLQuery(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { "nodes": [] } + } } }`, func(_ string, params map[string]interface{}) { + assert.Equal(t, "probablyCher", params["assignee"].(string)) + assert.Equal(t, "foo", params["author"].(string)) + assert.Equal(t, "me", params["mention"].(string)) + assert.Equal(t, "1.x", params["milestone"].(string)) + assert.Equal(t, []interface{}{"web", "bug"}, params["labels"].([]interface{})) + assert.Equal(t, []interface{}{"OPEN"}, params["states"].([]interface{})) + })) + + output, err := runCommand(http, true, "-a probablyCher -l web,bug -s open -A foo --mention me --milestone 1.x") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), ` +No issues match your search in OWNER/REPO + +`) +} + +func TestIssueList_withInvalidLimitFlag(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + _, err := runCommand(http, true, "--limit=0") + + if err == nil || err.Error() != "invalid limit: 0" { + t.Errorf("error running command `issue list`: %v", err) + } +} + +func TestIssueList_nullAssigneeLabels(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { "nodes": [] } + } } } + `)) + + _, err := runCommand(http, true, "") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) + reqBody := struct { + Variables map[string]interface{} + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + _, assigneeDeclared := reqBody.Variables["assignee"] + _, labelsDeclared := reqBody.Variables["labels"] + eq(t, assigneeDeclared, false) + eq(t, labelsDeclared, false) +} + +func TestIssueList_disabledIssues(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, "") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue list`: %v", err) + } +} + +func TestIssueList_web(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "--web -a peter -A john -l bug -l docs -L 10 -s all --mention frank --milestone v1.1") + if err != nil { + t.Errorf("error running command `issue list` with `--web` flag: %v", err) + } + + expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1" + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, expectedURL) +} diff --git a/pkg/cmd/issue/reopen/reopen.go b/pkg/cmd/issue/reopen/reopen.go new file mode 100644 index 000000000..72a052503 --- /dev/null +++ b/pkg/cmd/issue/reopen/reopen.go @@ -0,0 +1,80 @@ +package reopen + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/issue/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ReopenOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Command { + opts := &ReopenOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "reopen { | }", + Short: "Reopen issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return reopenRun(opts) + }, + } + + return cmd +} + +func reopenRun(opts *ReopenOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + if err != nil { + return err + } + + if !issue.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already open\n", utils.Yellow("!"), issue.Number, issue.Title) + return nil + } + + err = api.IssueReopen(apiClient, baseRepo, *issue) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Reopened issue #%d (%s)\n", utils.Green("✔"), issue.Number, issue.Title) + + return nil +} diff --git a/pkg/cmd/issue/reopen/reopen_test.go b/pkg/cmd/issue/reopen/reopen_test.go new file mode 100644 index 000000000..df8e8ecd7 --- /dev/null +++ b/pkg/cmd/issue/reopen/reopen_test.go @@ -0,0 +1,119 @@ +package reopen + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdReopen(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueReopen(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 2, "closed": true, "title": "The title of the issue"} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "2") + if err != nil { + t.Fatalf("error running command `issue reopen`: %v", err) + } + + r := regexp.MustCompile(`Reopened issue #2 \(The title of the issue\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueReopen_alreadyOpen(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 2, "closed": false, "title": "The title of the issue"} + } } } + `)) + + output, err := runCommand(http, true, "2") + if err != nil { + t.Fatalf("error running command `issue reopen`: %v", err) + } + + r := regexp.MustCompile(`Issue #2 \(The title of the issue\) is already open`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueReopen_issuesDisabled(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, "2") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Fatalf("got error: %v", err) + } +} diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go new file mode 100644 index 000000000..983052f19 --- /dev/null +++ b/pkg/cmd/issue/shared/display.go @@ -0,0 +1,65 @@ +package shared + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/cli/cli/api" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/text" + "github.com/cli/cli/utils" +) + +func PrintIssues(io *iostreams.IOStreams, prefix string, totalCount int, issues []api.Issue) { + table := utils.NewTablePrinter(io) + for _, issue := range issues { + issueNum := strconv.Itoa(issue.Number) + if table.IsTTY() { + issueNum = "#" + issueNum + } + issueNum = prefix + issueNum + labels := IssueLabelList(issue) + if labels != "" && table.IsTTY() { + labels = fmt.Sprintf("(%s)", labels) + } + now := time.Now() + ago := now.Sub(issue.UpdatedAt) + table.AddField(issueNum, nil, prShared.ColorFuncForState(issue.State)) + if !table.IsTTY() { + table.AddField(issue.State, nil, nil) + } + table.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil) + table.AddField(labels, nil, utils.Gray) + if table.IsTTY() { + table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray) + } else { + table.AddField(issue.UpdatedAt.String(), nil, nil) + } + table.EndRow() + } + _ = table.Render() + remaining := totalCount - len(issues) + if remaining > 0 { + fmt.Fprintf(io.Out, utils.Gray("%sAnd %d more\n"), prefix, remaining) + } +} + +func IssueLabelList(issue api.Issue) string { + if len(issue.Labels.Nodes) == 0 { + return "" + } + + labelNames := make([]string, 0, len(issue.Labels.Nodes)) + for _, label := range issue.Labels.Nodes { + labelNames = append(labelNames, label.Name) + } + + list := strings.Join(labelNames, ", ") + if issue.Labels.TotalCount > len(issue.Labels.Nodes) { + list += ", …" + } + return list +} diff --git a/command/issue_lookup.go b/pkg/cmd/issue/shared/lookup.go similarity index 83% rename from command/issue_lookup.go rename to pkg/cmd/issue/shared/lookup.go index aa8e0b12c..90a729599 100644 --- a/command/issue_lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -1,4 +1,4 @@ -package command +package shared import ( "fmt" @@ -8,12 +8,10 @@ import ( "strings" "github.com/cli/cli/api" - "github.com/cli/cli/context" "github.com/cli/cli/internal/ghrepo" - "github.com/spf13/cobra" ) -func issueFromArg(ctx context.Context, apiClient *api.Client, cmd *cobra.Command, arg string) (*api.Issue, ghrepo.Interface, error) { +func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) { issue, baseRepo, err := issueFromURL(apiClient, arg) if err != nil { return nil, nil, err @@ -22,7 +20,7 @@ func issueFromArg(ctx context.Context, apiClient *api.Client, cmd *cobra.Command return issue, baseRepo, nil } - baseRepo, err = determineBaseRepo(apiClient, cmd, ctx) + baseRepo, err = baseRepoFn() if err != nil { return nil, nil, fmt.Errorf("could not determine base repo: %w", err) } diff --git a/test/fixtures/issueStatus.json b/pkg/cmd/issue/status/fixtures/issueStatus.json similarity index 100% rename from test/fixtures/issueStatus.json rename to pkg/cmd/issue/status/fixtures/issueStatus.json diff --git a/pkg/cmd/issue/status/status.go b/pkg/cmd/issue/status/status.go new file mode 100644 index 000000000..d1b68bc0d --- /dev/null +++ b/pkg/cmd/issue/status/status.go @@ -0,0 +1,103 @@ +package status + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + issueShared "github.com/cli/cli/pkg/cmd/issue/shared" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type StatusOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) +} + +func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "status", + Short: "Show status of relevant issues", + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if runF != nil { + return runF(opts) + } + return statusRun(opts) + }, + } + + return cmd +} + +func statusRun(opts *StatusOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + currentUser, err := api.CurrentLoginName(apiClient, baseRepo.RepoHost()) + if err != nil { + return err + } + + issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser) + if err != nil { + return err + } + + out := opts.IO.Out + + fmt.Fprintln(out, "") + fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo)) + fmt.Fprintln(out, "") + + prShared.PrintHeader(out, "Issues assigned to you") + if issuePayload.Assigned.TotalCount > 0 { + issueShared.PrintIssues(opts.IO, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues) + } else { + message := " There are no issues assigned to you" + prShared.PrintMessage(out, message) + } + fmt.Fprintln(out) + + prShared.PrintHeader(out, "Issues mentioning you") + if issuePayload.Mentioned.TotalCount > 0 { + issueShared.PrintIssues(opts.IO, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues) + } else { + prShared.PrintMessage(out, " There are no issues mentioning you") + } + fmt.Fprintln(out) + + prShared.PrintHeader(out, "Issues opened by you") + if issuePayload.Authored.TotalCount > 0 { + issueShared.PrintIssues(opts.IO, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues) + } else { + prShared.PrintMessage(out, " There are no issues opened by you") + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/cmd/issue/status/status_test.go b/pkg/cmd/issue/status/status_test.go new file mode 100644 index 000000000..6087abe1a --- /dev/null +++ b/pkg/cmd/issue/status/status_test.go @@ -0,0 +1,146 @@ +package status + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdStatus(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueStatus(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + http.Register( + httpmock.GraphQL(`query IssueStatus\b`), + httpmock.FileResponse("./fixtures/issueStatus.json")) + + output, err := runCommand(http, true, "") + if err != nil { + t.Errorf("error running command `issue status`: %v", err) + } + + expectedIssues := []*regexp.Regexp{ + regexp.MustCompile(`(?m)8.*carrots.*about.*ago`), + regexp.MustCompile(`(?m)9.*squash.*about.*ago`), + regexp.MustCompile(`(?m)10.*broccoli.*about.*ago`), + regexp.MustCompile(`(?m)11.*swiss chard.*about.*ago`), + } + + for _, r := range expectedIssues { + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestIssueStatus_blankSlate(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + http.Register( + httpmock.GraphQL(`query IssueStatus\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "assigned": { "nodes": [] }, + "mentioned": { "nodes": [] }, + "authored": { "nodes": [] } + } } }`)) + + output, err := runCommand(http, true, "") + if err != nil { + t.Errorf("error running command `issue status`: %v", err) + } + + expectedOutput := ` +Relevant issues in OWNER/REPO + +Issues assigned to you + There are no issues assigned to you + +Issues mentioning you + There are no issues mentioning you + +Issues opened by you + There are no issues opened by you + +` + if output.String() != expectedOutput { + t.Errorf("expected %q, got %q", expectedOutput, output) + } +} + +func TestIssueStatus_disabledIssues(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + http.Register( + httpmock.GraphQL(`query IssueStatus\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`)) + + _, err := runCommand(http, true, "") + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue status`: %v", err) + } +} diff --git a/test/fixtures/issueView_preview.json b/pkg/cmd/issue/view/fixtures/issueView_preview.json similarity index 100% rename from test/fixtures/issueView_preview.json rename to pkg/cmd/issue/view/fixtures/issueView_preview.json diff --git a/test/fixtures/issueView_previewClosedState.json b/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json similarity index 100% rename from test/fixtures/issueView_previewClosedState.json rename to pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json diff --git a/test/fixtures/issueView_previewWithEmptyBody.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json similarity index 100% rename from test/fixtures/issueView_previewWithEmptyBody.json rename to pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json diff --git a/test/fixtures/issueView_previewWithMetadata.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json similarity index 100% rename from test/fixtures/issueView_previewWithMetadata.json rename to pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go new file mode 100644 index 000000000..6d2b0eeaa --- /dev/null +++ b/pkg/cmd/issue/view/view.go @@ -0,0 +1,207 @@ +package view + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/issue/shared" + issueShared "github.com/cli/cli/pkg/cmd/issue/shared" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string + WebMode bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "view { | }", + Short: "View an issue", + Long: heredoc.Doc(` + Display the title, body, and other information about an issue. + + With '--web', open the issue in a web browser instead. + `), + Example: heredoc.Doc(` + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, _, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + if err != nil { + return err + } + + openURL := issue.URL + + if opts.WebMode { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL) + return utils.OpenInBrowser(openURL) + } + if opts.IO.IsStdoutTTY() { + return printHumanIssuePreview(opts.IO.Out, issue) + } + + return printRawIssuePreview(opts.IO.Out, issue) +} + +func printRawIssuePreview(out io.Writer, issue *api.Issue) error { + assignees := issueAssigneeList(*issue) + labels := shared.IssueLabelList(*issue) + projects := issueProjectList(*issue) + + // Print empty strings for empty values so the number of metadata lines is consistent when + // processing many issues with head and grep. + fmt.Fprintf(out, "title:\t%s\n", issue.Title) + fmt.Fprintf(out, "state:\t%s\n", issue.State) + fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login) + fmt.Fprintf(out, "labels:\t%s\n", labels) + fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount) + fmt.Fprintf(out, "assignees:\t%s\n", assignees) + fmt.Fprintf(out, "projects:\t%s\n", projects) + fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title) + + fmt.Fprintln(out, "--") + fmt.Fprintln(out, issue.Body) + return nil +} + +func printHumanIssuePreview(out io.Writer, issue *api.Issue) error { + now := time.Now() + ago := now.Sub(issue.CreatedAt) + + // Header (Title and State) + fmt.Fprintln(out, utils.Bold(issue.Title)) + fmt.Fprint(out, issueStateTitleWithColor(issue.State)) + fmt.Fprintln(out, utils.Gray(fmt.Sprintf( + " • %s opened %s • %s", + issue.Author.Login, + utils.FuzzyAgo(ago), + utils.Pluralize(issue.Comments.TotalCount, "comment"), + ))) + + // Metadata + fmt.Fprintln(out) + if assignees := issueAssigneeList(*issue); assignees != "" { + fmt.Fprint(out, utils.Bold("Assignees: ")) + fmt.Fprintln(out, assignees) + } + if labels := shared.IssueLabelList(*issue); labels != "" { + fmt.Fprint(out, utils.Bold("Labels: ")) + fmt.Fprintln(out, labels) + } + if projects := issueProjectList(*issue); projects != "" { + fmt.Fprint(out, utils.Bold("Projects: ")) + fmt.Fprintln(out, projects) + } + if issue.Milestone.Title != "" { + fmt.Fprint(out, utils.Bold("Milestone: ")) + fmt.Fprintln(out, issue.Milestone.Title) + } + + // Body + if issue.Body != "" { + fmt.Fprintln(out) + md, err := utils.RenderMarkdown(issue.Body) + if err != nil { + return err + } + fmt.Fprintln(out, md) + } + fmt.Fprintln(out) + + // Footer + fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL) + return nil +} + +func issueStateTitleWithColor(state string) string { + colorFunc := prShared.ColorFuncForState(state) + return colorFunc(strings.Title(strings.ToLower(state))) +} + +func issueAssigneeList(issue api.Issue) string { + if len(issue.Assignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes)) + for _, assignee := range issue.Assignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) { + list += ", …" + } + return list +} + +func issueProjectList(issue api.Issue) string { + if len(issue.ProjectCards.Nodes) == 0 { + return "" + } + + projectNames := make([]string, 0, len(issue.ProjectCards.Nodes)) + for _, project := range issue.ProjectCards.Nodes { + colName := project.Column.Name + if colName == "" { + colName = "Awaiting triage" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) + } + + list := strings.Join(projectNames, ", ") + if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) { + list += ", …" + } + return list +} diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go new file mode 100644 index 000000000..e27505f92 --- /dev/null +++ b/pkg/cmd/issue/view/view_test.go @@ -0,0 +1,340 @@ +package view + +import ( + "bytes" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdView(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestIssueView_web(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "-w 123") + if err != nil { + t.Errorf("error running command `issue view`: %v", err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/issues/123") +} + +func TestIssueView_web_numberArgWithHash(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "-w \"#123\"") + if err != nil { + t.Errorf("error running command `issue view`: %v", err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/issues/123") +} + +func TestIssueView_nontty_Preview(t *testing.T) { + tests := map[string]struct { + fixture string + expectedOutputs []string + }{ + "Open issue without metadata": { + fixture: "./fixtures/issueView_preview.json", + expectedOutputs: []string{ + `title:\tix of coins`, + `state:\tOPEN`, + `comments:\t9`, + `author:\tmarseilles`, + `assignees:`, + `\*\*bold story\*\*`, + }, + }, + "Open issue with metadata": { + fixture: "./fixtures/issueView_previewWithMetadata.json", + expectedOutputs: []string{ + `title:\tix of coins`, + `assignees:\tmarseilles, monaco`, + `author:\tmarseilles`, + `state:\tOPEN`, + `comments:\t9`, + `labels:\tone, two, three, four, five`, + `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `milestone:\tuluru\n`, + `\*\*bold story\*\*`, + }, + }, + "Open issue with empty body": { + fixture: "./fixtures/issueView_previewWithEmptyBody.json", + expectedOutputs: []string{ + `title:\tix of coins`, + `state:\tOPEN`, + `author:\tmarseilles`, + `labels:\ttarot`, + }, + }, + "Closed issue": { + fixture: "./fixtures/issueView_previewClosedState.json", + expectedOutputs: []string{ + `title:\tix of coins`, + `state:\tCLOSED`, + `\*\*bold story\*\*`, + `author:\tmarseilles`, + `labels:\ttarot`, + `\*\*bold story\*\*`, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + + output, err := runCommand(http, false, "123") + if err != nil { + t.Errorf("error running `issue view`: %v", err) + } + + eq(t, output.Stderr(), "") + + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestIssueView_tty_Preview(t *testing.T) { + tests := map[string]struct { + fixture string + expectedOutputs []string + }{ + "Open issue without metadata": { + fixture: "./fixtures/issueView_preview.json", + expectedOutputs: []string{ + `ix of coins`, + `Open.*marseilles opened about 292 years ago.*9 comments`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "Open issue with metadata": { + fixture: "./fixtures/issueView_previewWithMetadata.json", + expectedOutputs: []string{ + `ix of coins`, + `Open.*marseilles opened about 292 years ago.*9 comments`, + `Assignees:.*marseilles, monaco\n`, + `Labels:.*one, two, three, four, five\n`, + `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `Milestone:.*uluru\n`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "Open issue with empty body": { + fixture: "./fixtures/issueView_previewWithEmptyBody.json", + expectedOutputs: []string{ + `ix of coins`, + `Open.*marseilles opened about 292 years ago.*9 comments`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "Closed issue": { + fixture: "./fixtures/issueView_previewClosedState.json", + expectedOutputs: []string{ + `ix of coins`, + `Closed.*marseilles opened about 292 years ago.*9 comments`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) + + output, err := runCommand(http, true, "123") + if err != nil { + t.Errorf("error running `issue view`: %v", err) + } + + eq(t, output.Stderr(), "") + + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestIssueView_web_notFound(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "errors": [ + { "message": "Could not resolve to an Issue with the number of 9999." } + ] } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + _, err := runCommand(http, true, "-w 9999") + if err == nil || err.Error() != "GraphQL error: Could not resolve to an Issue with the number of 9999." { + t.Errorf("error running command `issue view`: %v", err) + } + + if seenCmd != nil { + t.Fatal("did not expect any command to run") + } +} + +func TestIssueView_disabledIssues(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": false + } } } + `)) + + _, err := runCommand(http, true, `6666`) + if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { + t.Errorf("error running command `issue view`: %v", err) + } +} + +func TestIssueView_web_urlArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "-w https://github.com/OWNER/REPO/issues/123") + if err != nil { + t.Errorf("error running command `issue view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/issues/123") +} diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 2fba67e06..a7d175624 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -77,7 +77,7 @@ func closeRun(opts *CloseOptions) error { return fmt.Errorf("API call failed: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", utils.Green("✔"), pr.Number, pr.Title) + fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", utils.Red("✔"), pr.Number, pr.Title) return nil } diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go index 599b01758..4a50f8356 100644 --- a/pkg/cmd/pr/pr.go +++ b/pkg/cmd/pr/pr.go @@ -1,6 +1,7 @@ package pr import ( + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/ghrepo" cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout" cmdClose "github.com/cli/cli/pkg/cmd/pr/close" @@ -13,8 +14,6 @@ import ( cmdReview "github.com/cli/cli/pkg/cmd/pr/review" cmdStatus "github.com/cli/cli/pkg/cmd/pr/status" cmdView "github.com/cli/cli/pkg/cmd/pr/view" - - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" )