Isolate issue commands
This commit is contained in:
parent
20e30453d3
commit
c00fe73d5a
26 changed files with 2178 additions and 1664 deletions
694
command/issue.go
694
command/issue.go
|
|
@ -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 <command>",
|
||||
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 {<number> | <url>}",
|
||||
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 {<number> | <url>}",
|
||||
Short: "Close issue",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: issueClose,
|
||||
}
|
||||
var issueReopenCmd = &cobra.Command{
|
||||
Use: "reopen {<number> | <url>}",
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
80
pkg/cmd/issue/close/close.go
Normal file
80
pkg/cmd/issue/close/close.go
Normal file
|
|
@ -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 {<number> | <url>}",
|
||||
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
|
||||
}
|
||||
119
pkg/cmd/issue/close/close_test.go
Normal file
119
pkg/cmd/issue/close/close_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
228
pkg/cmd/issue/create/create.go
Normal file
228
pkg/cmd/issue/create/create.go
Normal file
|
|
@ -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
|
||||
}
|
||||
280
pkg/cmd/issue/create/create_test.go
Normal file
280
pkg/cmd/issue/create/create_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
54
pkg/cmd/issue/issue.go
Normal file
54
pkg/cmd/issue/issue.go
Normal file
|
|
@ -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 <command>",
|
||||
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
|
||||
}
|
||||
127
pkg/cmd/issue/list/list.go
Normal file
127
pkg/cmd/issue/list/list.go
Normal file
|
|
@ -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
|
||||
}
|
||||
223
pkg/cmd/issue/list/list_test.go
Normal file
223
pkg/cmd/issue/list/list_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
80
pkg/cmd/issue/reopen/reopen.go
Normal file
80
pkg/cmd/issue/reopen/reopen.go
Normal file
|
|
@ -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 {<number> | <url>}",
|
||||
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
|
||||
}
|
||||
119
pkg/cmd/issue/reopen/reopen_test.go
Normal file
119
pkg/cmd/issue/reopen/reopen_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
65
pkg/cmd/issue/shared/display.go
Normal file
65
pkg/cmd/issue/shared/display.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
103
pkg/cmd/issue/status/status.go
Normal file
103
pkg/cmd/issue/status/status.go
Normal file
|
|
@ -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
|
||||
}
|
||||
146
pkg/cmd/issue/status/status_test.go
Normal file
146
pkg/cmd/issue/status/status_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
207
pkg/cmd/issue/view/view.go
Normal file
207
pkg/cmd/issue/view/view.go
Normal file
|
|
@ -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 {<number> | <url>}",
|
||||
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
|
||||
}
|
||||
340
pkg/cmd/issue/view/view_test.go
Normal file
340
pkg/cmd/issue/view/view_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue