From ad4a489f8d363ee55fa0401b497ee15f0770487d Mon Sep 17 00:00:00 2001 From: Ariel Deitcher <1149246+mntlty@users.noreply.github.com> Date: Fri, 9 Jun 2023 09:57:01 -0700 Subject: [PATCH] Introduce `gh project` commands (#7375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- go.mod | 10 +- go.sum | 20 +- pkg/cmd/project/close/close.go | 148 ++ pkg/cmd/project/close/close_test.go | 457 +++++++ pkg/cmd/project/copy/copy.go | 153 +++ pkg/cmd/project/copy/copy_test.go | 462 +++++++ pkg/cmd/project/create/create.go | 125 ++ pkg/cmd/project/create/create_test.go | 278 ++++ pkg/cmd/project/delete/delete.go | 136 ++ pkg/cmd/project/delete/delete_test.go | 348 +++++ pkg/cmd/project/edit/edit.go | 165 +++ pkg/cmd/project/edit/edit_test.go | 512 +++++++ pkg/cmd/project/field-create/field_create.go | 163 +++ .../project/field-create/field_create_test.go | 632 +++++++++ pkg/cmd/project/field-delete/field_delete.go | 105 ++ .../project/field-delete/field_delete_test.go | 117 ++ pkg/cmd/project/field-list/field_list.go | 132 ++ pkg/cmd/project/field-list/field_list_test.go | 425 ++++++ pkg/cmd/project/item-add/item_add.go | 144 ++ pkg/cmd/project/item-add/item_add_test.go | 426 ++++++ pkg/cmd/project/item-archive/item_archive.go | 171 +++ .../project/item-archive/item_archive_test.go | 639 +++++++++ pkg/cmd/project/item-create/item_create.go | 140 ++ .../project/item-create/item_create_test.go | 374 +++++ pkg/cmd/project/item-delete/item_delete.go | 131 ++ .../project/item-delete/item_delete_test.go | 359 +++++ pkg/cmd/project/item-edit/item_edit.go | 253 ++++ pkg/cmd/project/item-edit/item_edit_test.go | 476 +++++++ pkg/cmd/project/item-list/item_list.go | 136 ++ pkg/cmd/project/item-list/item_list_test.go | 402 ++++++ pkg/cmd/project/list/list.go | 186 +++ pkg/cmd/project/list/list_test.go | 737 ++++++++++ pkg/cmd/project/project.go | 61 + pkg/cmd/project/shared/client/client.go | 22 + pkg/cmd/project/shared/format/json.go | 365 +++++ pkg/cmd/project/shared/format/json_test.go | 287 ++++ pkg/cmd/project/shared/queries/queries.go | 1212 +++++++++++++++++ .../project/shared/queries/queries_test.go | 365 +++++ pkg/cmd/project/view/view.go | 203 +++ pkg/cmd/project/view/view_test.go | 545 ++++++++ pkg/cmd/root/root.go | 3 + 41 files changed, 12013 insertions(+), 12 deletions(-) create mode 100644 pkg/cmd/project/close/close.go create mode 100644 pkg/cmd/project/close/close_test.go create mode 100644 pkg/cmd/project/copy/copy.go create mode 100644 pkg/cmd/project/copy/copy_test.go create mode 100644 pkg/cmd/project/create/create.go create mode 100644 pkg/cmd/project/create/create_test.go create mode 100644 pkg/cmd/project/delete/delete.go create mode 100644 pkg/cmd/project/delete/delete_test.go create mode 100644 pkg/cmd/project/edit/edit.go create mode 100644 pkg/cmd/project/edit/edit_test.go create mode 100644 pkg/cmd/project/field-create/field_create.go create mode 100644 pkg/cmd/project/field-create/field_create_test.go create mode 100644 pkg/cmd/project/field-delete/field_delete.go create mode 100644 pkg/cmd/project/field-delete/field_delete_test.go create mode 100644 pkg/cmd/project/field-list/field_list.go create mode 100644 pkg/cmd/project/field-list/field_list_test.go create mode 100644 pkg/cmd/project/item-add/item_add.go create mode 100644 pkg/cmd/project/item-add/item_add_test.go create mode 100644 pkg/cmd/project/item-archive/item_archive.go create mode 100644 pkg/cmd/project/item-archive/item_archive_test.go create mode 100644 pkg/cmd/project/item-create/item_create.go create mode 100644 pkg/cmd/project/item-create/item_create_test.go create mode 100644 pkg/cmd/project/item-delete/item_delete.go create mode 100644 pkg/cmd/project/item-delete/item_delete_test.go create mode 100644 pkg/cmd/project/item-edit/item_edit.go create mode 100644 pkg/cmd/project/item-edit/item_edit_test.go create mode 100644 pkg/cmd/project/item-list/item_list.go create mode 100644 pkg/cmd/project/item-list/item_list_test.go create mode 100644 pkg/cmd/project/list/list.go create mode 100644 pkg/cmd/project/list/list_test.go create mode 100644 pkg/cmd/project/project.go create mode 100644 pkg/cmd/project/shared/client/client.go create mode 100644 pkg/cmd/project/shared/format/json.go create mode 100644 pkg/cmd/project/shared/format/json_test.go create mode 100644 pkg/cmd/project/shared/queries/queries.go create mode 100644 pkg/cmd/project/shared/queries/queries_test.go create mode 100644 pkg/cmd/project/view/view.go create mode 100644 pkg/cmd/project/view/view_test.go diff --git a/go.mod b/go.mod index 39c41efa6..a0b3781cf 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 github.com/opentracing/opentracing-go v1.1.0 github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d - github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00 + github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9 github.com/sourcegraph/jsonrpc2 v0.1.0 github.com/spf13/cobra v1.6.1 github.com/spf13/pflag v1.0.5 @@ -38,10 +38,11 @@ require ( github.com/zalando/go-keyring v0.2.3 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 golang.org/x/sync v0.1.0 - golang.org/x/term v0.6.0 - golang.org/x/text v0.8.0 + golang.org/x/term v0.7.0 + golang.org/x/text v0.9.0 google.golang.org/grpc v1.49.0 google.golang.org/protobuf v1.27.1 + gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -59,6 +60,7 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/gorilla/css v1.0.0 // indirect + github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/itchyny/gojq v0.12.13 // indirect @@ -77,7 +79,7 @@ require ( github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect github.com/yuin/goldmark v1.4.13 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect - golang.org/x/net v0.8.0 // indirect + golang.org/x/net v0.9.0 // indirect golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect golang.org/x/sys v0.8.0 // indirect google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect diff --git a/go.sum b/go.sum index 81d281bc5..1d3b83805 100644 --- a/go.sum +++ b/go.sum @@ -161,6 +161,7 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= @@ -223,6 +224,8 @@ github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo= github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= @@ -239,8 +242,8 @@ github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00 h1:fiFvD4lT0aWjuuAb64LlZ/67v87m+Kc9Qsu5cMFNK0w= -github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= +github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9 h1:nCBaIs5/R0HFP5+aPW/SzFUF8z0oKuCXmuDmHWaxzjY= +github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc= github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/sourcegraph/jsonrpc2 v0.1.0 h1:ohJHjZ+PcaLxDUjqk2NC3tIGsVa5bXThe1ZheSXOjuk= @@ -342,8 +345,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -409,8 +412,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -419,8 +422,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -558,6 +561,7 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/cmd/project/close/close.go b/pkg/cmd/project/close/close.go new file mode 100644 index 000000000..7f8006b6f --- /dev/null +++ b/pkg/cmd/project/close/close.go @@ -0,0 +1,148 @@ +package close + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type closeOpts struct { + number int32 + owner string + reopen bool + projectID string + format string +} + +type closeConfig struct { + io *iostreams.IOStreams + client *queries.Client + opts closeOpts +} + +// the close command relies on the updateProjectV2 mutation +type updateProjectMutation struct { + UpdateProjectV2 struct { + ProjectV2 queries.Project `graphql:"projectV2"` + } `graphql:"updateProjectV2(input:$input)"` +} + +func NewCmdClose(f *cmdutil.Factory, runF func(config closeConfig) error) *cobra.Command { + opts := closeOpts{} + closeCmd := &cobra.Command{ + Short: "Close a project", + Use: "close []", + Example: heredoc.Doc(` + # close project "1" owned by monalisa + gh project close 1 --owner monalisa + + # reopen closed project "1" owned by github + gh project close 1 --owner github --undo + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := closeConfig{ + io: f.IOStreams, + client: client, + opts: opts, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runClose(config) + }, + } + + closeCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + closeCmd.Flags().BoolVar(&opts.reopen, "undo", false, "Reopen a closed project") + closeCmd.Flags().StringVar(&opts.format, "format", "", "Output format, must be 'json'") + + return closeCmd +} + +func runClose(config closeConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.projectID = project.ID + + query, variables := closeArgs(config) + + err = config.client.Mutate("CloseProjectV2", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, *project) + } + + return printResults(config, query.UpdateProjectV2.ProjectV2) +} + +func closeArgs(config closeConfig) (*updateProjectMutation, map[string]interface{}) { + closed := !config.opts.reopen + return &updateProjectMutation{}, map[string]interface{}{ + "input": githubv4.UpdateProjectV2Input{ + ProjectID: githubv4.ID(config.opts.projectID), + Closed: githubv4.NewBoolean(githubv4.Boolean(closed)), + }, + "firstItems": githubv4.Int(0), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(0), + "afterFields": (*githubv4.String)(nil), + } +} + +func printResults(config closeConfig, project queries.Project) error { + if !config.io.IsStdoutTTY() { + return nil + } + var action string + if config.opts.reopen { + action = "Reopened" + } else { + action = "Closed" + } + _, err := fmt.Fprintf(config.io.Out, "%s project %s\n", action, project.URL) + return err +} + +func printJSON(config closeConfig, project queries.Project) error { + b, err := format.JSONProject(project) + if err != nil { + return err + } + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/close/close_test.go b/pkg/cmd/project/close/close_test.go new file mode 100644 index 000000000..3db3c3c68 --- /dev/null +++ b/pkg/cmd/project/close/close_test.go @@ -0,0 +1,457 @@ +package close + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdClose(t *testing.T) { + tests := []struct { + name string + cli string + wants closeOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "not-a-number", + cli: "x", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "number", + cli: "123", + wants: closeOpts{ + number: 123, + }, + }, + { + name: "owner", + cli: "--owner monalisa", + wants: closeOpts{ + owner: "monalisa", + }, + }, + { + name: "reopen", + cli: "--undo", + wants: closeOpts{ + reopen: true, + }, + }, + { + name: "json", + cli: "--format json", + wants: closeOpts{ + format: "json", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts closeOpts + cmd := NewCmdClose(f, func(config closeConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunClose_User(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get user project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // close project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CloseProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","closed":true}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := closeConfig{ + io: ios, + opts: closeOpts{ + number: 1, + owner: "monalisa", + }, + client: client, + } + + err := runClose(config) + assert.NoError(t, err) + assert.Equal( + t, + "Closed project http://a-url.com\n", + stdout.String()) +} + +func TestRunClose_Org(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get org project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // close project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CloseProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","closed":true}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := closeConfig{ + io: ios, + opts: closeOpts{ + number: 1, + owner: "github", + }, + client: client, + } + + err := runClose(config) + assert.NoError(t, err) + assert.Equal(t, "", stdout.String()) +} + +func TestRunClose_Me(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get viewer project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // close project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CloseProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","closed":true}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "me", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := closeConfig{ + io: ios, + opts: closeOpts{ + number: 1, + owner: "@me", + }, + client: client, + } + + err := runClose(config) + assert.NoError(t, err) + assert.Equal(t, "", stdout.String()) +} + +func TestRunClose_Reopen(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get user project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // close project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CloseProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","closed":false}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := closeConfig{ + io: ios, + opts: closeOpts{ + number: 1, + owner: "monalisa", + reopen: true, + }, + client: client, + } + + err := runClose(config) + assert.NoError(t, err) + assert.Equal( + t, + "Reopened project http://a-url.com\n", + stdout.String()) +} diff --git a/pkg/cmd/project/copy/copy.go b/pkg/cmd/project/copy/copy.go new file mode 100644 index 000000000..6dae73e40 --- /dev/null +++ b/pkg/cmd/project/copy/copy.go @@ -0,0 +1,153 @@ +package copy + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type copyOpts struct { + includeDraftIssues bool + number int32 + ownerID string + projectID string + sourceOwner string + targetOwner string + title string + format string +} + +type copyConfig struct { + io *iostreams.IOStreams + client *queries.Client + opts copyOpts +} + +type copyProjectMutation struct { + CopyProjectV2 struct { + ProjectV2 queries.Project `graphql:"projectV2"` + } `graphql:"copyProjectV2(input:$input)"` +} + +func NewCmdCopy(f *cmdutil.Factory, runF func(config copyConfig) error) *cobra.Command { + opts := copyOpts{} + copyCmd := &cobra.Command{ + Short: "Copy a project", + Use: "copy []", + Example: heredoc.Doc(` + # copy project "1" owned by monalisa to github + gh project copy 1 --source-owner monalisa --target-owner github --title "a new project" + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := copyConfig{ + io: f.IOStreams, + client: client, + opts: opts, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runCopy(config) + }, + } + + copyCmd.Flags().StringVar(&opts.sourceOwner, "source-owner", "", "Login of the source owner. Use \"@me\" for the current user.") + copyCmd.Flags().StringVar(&opts.targetOwner, "target-owner", "", "Login of the target owner. Use \"@me\" for the current user.") + copyCmd.Flags().StringVar(&opts.title, "title", "", "Title for the new project") + copyCmd.Flags().BoolVar(&opts.includeDraftIssues, "drafts", false, "Include draft issues when copying") + cmdutil.StringEnumFlag(copyCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + _ = copyCmd.MarkFlagRequired("title") + + return copyCmd +} + +func runCopy(config copyConfig) error { + canPrompt := config.io.CanPrompt() + sourceOwner, err := config.client.NewOwner(canPrompt, config.opts.sourceOwner) + if err != nil { + return err + } + + targetOwner, err := config.client.NewOwner(canPrompt, config.opts.targetOwner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, sourceOwner, config.opts.number, false) + if err != nil { + return err + } + + config.opts.projectID = project.ID + config.opts.ownerID = targetOwner.ID + + query, variables := copyArgs(config) + + err = config.client.Mutate("CopyProjectV2", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.CopyProjectV2.ProjectV2) + } + + return printResults(config, query.CopyProjectV2.ProjectV2) +} + +func copyArgs(config copyConfig) (*copyProjectMutation, map[string]interface{}) { + return ©ProjectMutation{}, map[string]interface{}{ + "input": githubv4.CopyProjectV2Input{ + OwnerID: githubv4.ID(config.opts.ownerID), + ProjectID: githubv4.ID(config.opts.projectID), + Title: githubv4.String(config.opts.title), + IncludeDraftIssues: githubv4.NewBoolean(githubv4.Boolean(config.opts.includeDraftIssues)), + }, + "firstItems": githubv4.Int(0), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(0), + "afterFields": (*githubv4.String)(nil), + } +} + +func printResults(config copyConfig, project queries.Project) error { + if !config.io.IsStdoutTTY() { + return nil + } + _, err := fmt.Fprintf(config.io.Out, "Copied project to %s\n", project.URL) + return err +} + +func printJSON(config copyConfig, project queries.Project) error { + b, err := format.JSONProject(project) + if err != nil { + return err + } + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/copy/copy_test.go b/pkg/cmd/project/copy/copy_test.go new file mode 100644 index 000000000..d731adaff --- /dev/null +++ b/pkg/cmd/project/copy/copy_test.go @@ -0,0 +1,462 @@ +package copy + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdCopy(t *testing.T) { + tests := []struct { + name string + cli string + wants copyOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "not-a-number", + cli: "x --title t", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "title", + cli: "--title t", + wants: copyOpts{ + title: "t", + }, + }, + { + name: "number", + cli: "123 --title t", + wants: copyOpts{ + number: 123, + title: "t", + }, + }, + { + name: "source-owner", + cli: "--source-owner monalisa --title t", + wants: copyOpts{ + sourceOwner: "monalisa", + title: "t", + }, + }, + { + name: "target-owner", + cli: "--target-owner monalisa --title t", + wants: copyOpts{ + targetOwner: "monalisa", + title: "t", + }, + }, + { + name: "drafts", + cli: "--drafts --title t", + wants: copyOpts{ + includeDraftIssues: true, + title: "t", + }, + }, + { + name: "json", + cli: "--format json --title t", + wants: copyOpts{ + format: "json", + title: "t", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts copyOpts + cmd := NewCmdCopy(f, func(config copyConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.sourceOwner, gotOpts.sourceOwner) + assert.Equal(t, tt.wants.targetOwner, gotOpts.targetOwner) + assert.Equal(t, tt.wants.title, gotOpts.title) + assert.Equal(t, tt.wants.includeDraftIssues, gotOpts.includeDraftIssues) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunCopy_User(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get user project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // get source user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]string{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + "login": "monalisa", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get target user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]string{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + "login": "monalisa", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // Copy project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CopyProjectV2.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","ownerId":"an ID","title":"a title","includeDraftIssues":false}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "copyProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + config := copyConfig{ + io: ios, + opts: copyOpts{ + title: "a title", + sourceOwner: "monalisa", + targetOwner: "monalisa", + number: 1, + }, + client: client, + } + + err := runCopy(config) + assert.NoError(t, err) + assert.Equal(t, "", stdout.String()) +} + +func TestRunCopy_Org(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get org project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + // get source org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]string{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + "login": "github", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get target source org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]string{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + "login": "github", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // Copy project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CopyProjectV2.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","ownerId":"an ID","title":"a title","includeDraftIssues":false}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "copyProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + config := copyConfig{ + io: ios, + opts: copyOpts{ + title: "a title", + sourceOwner: "github", + targetOwner: "github", + number: 1, + }, + client: client, + } + + err := runCopy(config) + assert.NoError(t, err) + assert.Equal(t, "", stdout.String()) +} + +func TestRunCopy_Me(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get viewer project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // get source viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + "login": "me", + }, + }, + }) + + // get target viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + "login": "me", + }, + }, + }) + + // Copy project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CopyProjectV2.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","ownerId":"an ID","title":"a title","includeDraftIssues":false}}}`).Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "copyProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "me", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := copyConfig{ + io: ios, + opts: copyOpts{ + title: "a title", + sourceOwner: "@me", + targetOwner: "@me", + number: 1, + }, + client: client, + } + + err := runCopy(config) + assert.NoError(t, err) + assert.Equal( + t, + "Copied project to http://a-url.com\n", + stdout.String()) +} diff --git a/pkg/cmd/project/create/create.go b/pkg/cmd/project/create/create.go new file mode 100644 index 000000000..0ecd4f5f3 --- /dev/null +++ b/pkg/cmd/project/create/create.go @@ -0,0 +1,125 @@ +package create + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type createOpts struct { + title string + owner string + ownerID string + format string +} + +type createConfig struct { + client *queries.Client + opts createOpts + io *iostreams.IOStreams +} + +type createProjectMutation struct { + CreateProjectV2 struct { + ProjectV2 queries.Project `graphql:"projectV2"` + } `graphql:"createProjectV2(input:$input)"` +} + +func NewCmdCreate(f *cmdutil.Factory, runF func(config createConfig) error) *cobra.Command { + opts := createOpts{} + createCmd := &cobra.Command{ + Short: "Create a project", + Use: "create", + Example: heredoc.Doc(` + # create a new project owned by login monalisa + gh project create --owner monalisa --title "a new project" + `), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + config := createConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runCreate(config) + }, + } + + createCmd.Flags().StringVar(&opts.title, "title", "", "Title for the project") + createCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + cmdutil.StringEnumFlag(createCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + _ = createCmd.MarkFlagRequired("title") + + return createCmd +} + +func runCreate(config createConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + config.opts.ownerID = owner.ID + query, variables := createArgs(config) + + err = config.client.Mutate("CreateProjectV2", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.CreateProjectV2.ProjectV2) + } + + return printResults(config, query.CreateProjectV2.ProjectV2) +} + +func createArgs(config createConfig) (*createProjectMutation, map[string]interface{}) { + return &createProjectMutation{}, map[string]interface{}{ + "input": githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID(config.opts.ownerID), + Title: githubv4.String(config.opts.title), + }, + "firstItems": githubv4.Int(0), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(0), + "afterFields": (*githubv4.String)(nil), + } +} + +func printResults(config createConfig, project queries.Project) error { + if !config.io.IsStdoutTTY() { + return nil + } + + _, err := fmt.Fprintf(config.io.Out, "Created project '%s'\n%s\n", project.Title, project.URL) + return err +} + +func printJSON(config createConfig, project queries.Project) error { + b, err := format.JSONProject(project) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/create/create_test.go b/pkg/cmd/project/create/create_test.go new file mode 100644 index 000000000..0a6cc9700 --- /dev/null +++ b/pkg/cmd/project/create/create_test.go @@ -0,0 +1,278 @@ +package create + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdCreate(t *testing.T) { + tests := []struct { + name string + cli string + wants createOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "title", + cli: "--title t", + wants: createOpts{ + title: "t", + }, + }, + { + name: "owner", + cli: "--title t --owner monalisa", + wants: createOpts{ + owner: "monalisa", + title: "t", + }, + }, + { + name: "json", + cli: "--title t --format json", + wants: createOpts{ + format: "json", + title: "t", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts createOpts + cmd := NewCmdCreate(f, func(config createConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.title, gotOpts.title) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunCreate_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]string{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + "login": "monalisa", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // create project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"ownerId":"an ID","title":"a title"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createConfig{ + opts: createOpts{ + title: "a title", + owner: "monalisa", + }, + client: client, + io: ios, + } + + err := runCreate(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created project 'a title'\nhttp://a-url.com\n", + stdout.String()) +} + +func TestRunCreate_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]string{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + "login": "github", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // create project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"ownerId":"an ID","title":"a title"}}}`).Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createConfig{ + opts: createOpts{ + title: "a title", + owner: "github", + }, + client: client, + io: ios, + } + + err := runCreate(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created project 'a title'\nhttp://a-url.com\n", + stdout.String()) +} + +func TestRunCreate_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + "login": "me", + }, + }, + }) + + // create project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"ownerId":"an ID","title":"a title"}}}`).Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "me", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createConfig{ + opts: createOpts{ + title: "a title", + owner: "@me", + }, + client: client, + io: ios, + } + + err := runCreate(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created project 'a title'\nhttp://a-url.com\n", + stdout.String()) +} diff --git a/pkg/cmd/project/delete/delete.go b/pkg/cmd/project/delete/delete.go new file mode 100644 index 000000000..0ae93277b --- /dev/null +++ b/pkg/cmd/project/delete/delete.go @@ -0,0 +1,136 @@ +package delete + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type deleteOpts struct { + owner string + number int32 + projectID string + format string +} + +type deleteConfig struct { + client *queries.Client + opts deleteOpts + io *iostreams.IOStreams +} + +type deleteProjectMutation struct { + DeleteProject struct { + Project queries.Project `graphql:"projectV2"` + } `graphql:"deleteProjectV2(input:$input)"` +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(config deleteConfig) error) *cobra.Command { + opts := deleteOpts{} + deleteCmd := &cobra.Command{ + Short: "Delete a project", + Use: "delete []", + Example: heredoc.Doc(` + # delete the current user's project "1" + gh project delete 1 --owner "@me" + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := deleteConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runDelete(config) + }, + } + + deleteCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + cmdutil.StringEnumFlag(deleteCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + return deleteCmd +} + +func runDelete(config deleteConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.projectID = project.ID + + query, variables := deleteItemArgs(config) + err = config.client.Mutate("DeleteProject", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, *project) + } + + return printResults(config) + +} + +func deleteItemArgs(config deleteConfig) (*deleteProjectMutation, map[string]interface{}) { + return &deleteProjectMutation{}, map[string]interface{}{ + "input": githubv4.DeleteProjectV2Input{ + ProjectID: githubv4.ID(config.opts.projectID), + }, + "firstItems": githubv4.Int(0), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(0), + "afterFields": (*githubv4.String)(nil), + } +} + +func printResults(config deleteConfig) error { + if !config.io.IsStdoutTTY() { + return nil + } + + _, err := fmt.Fprintf(config.io.Out, "Deleted project\n") + return err +} + +func printJSON(config deleteConfig, project queries.Project) error { + b, err := format.JSONProject(project) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/delete/delete_test.go b/pkg/cmd/project/delete/delete_test.go new file mode 100644 index 000000000..28f30ebc6 --- /dev/null +++ b/pkg/cmd/project/delete/delete_test.go @@ -0,0 +1,348 @@ +package delete + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + cli string + wants deleteOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "not-a-number", + cli: "x", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "number", + cli: "123", + wants: deleteOpts{ + number: 123, + }, + }, + { + name: "owner", + cli: "--owner monalisa", + wants: deleteOpts{ + owner: "monalisa", + }, + }, + { + name: "json", + cli: "--format json", + wants: deleteOpts{ + format: "json", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts deleteOpts + cmd := NewCmdDelete(f, func(config deleteConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunDelete_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // delete project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation DeleteProject.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "deleteProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "project ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := deleteConfig{ + opts: deleteOpts{ + owner: "monalisa", + number: 1, + }, + client: client, + io: ios, + } + + err := runDelete(config) + assert.NoError(t, err) + assert.Equal( + t, + "Deleted project\n", + stdout.String()) +} + +func TestRunDelete_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // delete project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation DeleteProject.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "deleteProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "project ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := deleteConfig{ + opts: deleteOpts{ + owner: "github", + number: 1, + }, + client: client, + io: ios, + } + + err := runDelete(config) + assert.NoError(t, err) + assert.Equal( + t, + "Deleted project\n", + stdout.String()) +} + +func TestRunDelete_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // delete project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation DeleteProject.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "deleteProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "project ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := deleteConfig{ + opts: deleteOpts{ + owner: "@me", + number: 1, + }, + client: client, + io: ios, + } + + err := runDelete(config) + assert.NoError(t, err) + assert.Equal( + t, + "Deleted project\n", + stdout.String()) +} diff --git a/pkg/cmd/project/edit/edit.go b/pkg/cmd/project/edit/edit.go new file mode 100644 index 000000000..64ee74d78 --- /dev/null +++ b/pkg/cmd/project/edit/edit.go @@ -0,0 +1,165 @@ +package edit + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type editOpts struct { + number int32 + owner string + title string + readme string + visibility string + shortDescription string + projectID string + format string +} + +type editConfig struct { + client *queries.Client + opts editOpts + io *iostreams.IOStreams +} + +type updateProjectMutation struct { + UpdateProjectV2 struct { + ProjectV2 queries.Project `graphql:"projectV2"` + } `graphql:"updateProjectV2(input:$input)"` +} + +const projectVisibilityPublic = "PUBLIC" +const projectVisibilityPrivate = "PRIVATE" + +func NewCmdEdit(f *cmdutil.Factory, runF func(config editConfig) error) *cobra.Command { + opts := editOpts{} + editCmd := &cobra.Command{ + Short: "Edit a project", + Use: "edit []", + Example: heredoc.Doc(` + # edit the title of monalisa's project "1" + gh project edit 1 --owner monalisa --title "New title" + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := editConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + if config.opts.title == "" && config.opts.shortDescription == "" && config.opts.readme == "" && config.opts.visibility == "" { + return fmt.Errorf("no fields to edit") + } + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runEdit(config) + }, + } + + editCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + cmdutil.StringEnumFlag(editCmd, &opts.visibility, "visibility", "", "", []string{projectVisibilityPublic, projectVisibilityPrivate}, "Change project visibility") + editCmd.Flags().StringVar(&opts.title, "title", "", "New title for the project") + editCmd.Flags().StringVar(&opts.readme, "readme", "", "New readme for the project") + editCmd.Flags().StringVarP(&opts.shortDescription, "description", "d", "", "New description of the project") + cmdutil.StringEnumFlag(editCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + return editCmd +} + +func runEdit(config editConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.projectID = project.ID + + query, variables := editArgs(config) + err = config.client.Mutate("UpdateProjectV2", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, *project) + } + + return printResults(config, query.UpdateProjectV2.ProjectV2) +} + +func editArgs(config editConfig) (*updateProjectMutation, map[string]interface{}) { + variables := githubv4.UpdateProjectV2Input{ProjectID: githubv4.ID(config.opts.projectID)} + if config.opts.title != "" { + variables.Title = githubv4.NewString(githubv4.String(config.opts.title)) + } + if config.opts.shortDescription != "" { + variables.ShortDescription = githubv4.NewString(githubv4.String(config.opts.shortDescription)) + } + if config.opts.readme != "" { + variables.Readme = githubv4.NewString(githubv4.String(config.opts.readme)) + } + if config.opts.visibility != "" { + if config.opts.visibility == projectVisibilityPublic { + variables.Public = githubv4.NewBoolean(githubv4.Boolean(true)) + } else if config.opts.visibility == projectVisibilityPrivate { + variables.Public = githubv4.NewBoolean(githubv4.Boolean(false)) + } + } + + return &updateProjectMutation{}, map[string]interface{}{ + "input": variables, + "firstItems": githubv4.Int(0), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(0), + "afterFields": (*githubv4.String)(nil), + } +} + +func printResults(config editConfig, project queries.Project) error { + if !config.io.IsStdoutTTY() { + return nil + } + + _, err := fmt.Fprintf(config.io.Out, "Updated project %s\n", project.URL) + return err +} + +func printJSON(config editConfig, project queries.Project) error { + b, err := format.JSONProject(project) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/edit/edit_test.go b/pkg/cmd/project/edit/edit_test.go new file mode 100644 index 000000000..45aff1390 --- /dev/null +++ b/pkg/cmd/project/edit/edit_test.go @@ -0,0 +1,512 @@ +package edit + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdEdit(t *testing.T) { + tests := []struct { + name string + cli string + wants editOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "not-a-number", + cli: "x", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "visibility-error", + cli: "--visibility v", + wantsErr: true, + wantsErrMsg: "invalid argument \"v\" for \"--visibility\" flag: valid values are {PUBLIC|PRIVATE}", + }, + { + name: "no-args", + cli: "", + wantsErr: true, + wantsErrMsg: "no fields to edit", + }, + { + name: "title", + cli: "--title t", + wants: editOpts{ + title: "t", + }, + }, + { + name: "number", + cli: "123 --title t", + wants: editOpts{ + number: 123, + title: "t", + }, + }, + { + name: "owner", + cli: "--owner monalisa --title t", + wants: editOpts{ + owner: "monalisa", + title: "t", + }, + }, + { + name: "readme", + cli: "--readme r", + wants: editOpts{ + readme: "r", + }, + }, + { + name: "description", + cli: "--description d", + wants: editOpts{ + shortDescription: "d", + }, + }, + { + name: "visibility", + cli: "--visibility PUBLIC", + wants: editOpts{ + visibility: "PUBLIC", + }, + }, + { + name: "json", + cli: "--format json --title t", + wants: editOpts{ + format: "json", + title: "t", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts editOpts + cmd := NewCmdEdit(f, func(config editConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.visibility, gotOpts.visibility) + assert.Equal(t, tt.wants.title, gotOpts.title) + assert.Equal(t, tt.wants.readme, gotOpts.readme) + assert.Equal(t, tt.wants.shortDescription, gotOpts.shortDescription) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunUpdate_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get user project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // edit project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","title":"a new title","shortDescription":"a new description","readme":"a new readme","public":true}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := editConfig{ + opts: editOpts{ + number: 1, + owner: "monalisa", + title: "a new title", + shortDescription: "a new description", + visibility: "PUBLIC", + readme: "a new readme", + }, + client: client, + io: ios, + } + + err := runEdit(config) + assert.NoError(t, err) + assert.Equal( + t, + "Updated project http://a-url.com\n", + stdout.String()) +} + +func TestRunUpdate_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get org project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // edit project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","title":"a new title","shortDescription":"a new description","readme":"a new readme","public":true}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := editConfig{ + opts: editOpts{ + number: 1, + owner: "github", + title: "a new title", + shortDescription: "a new description", + visibility: "PUBLIC", + readme: "a new readme", + }, + client: client, + io: ios, + } + + err := runEdit(config) + assert.NoError(t, err) + assert.Equal( + t, + "Updated project http://a-url.com\n", + stdout.String()) +} + +func TestRunUpdate_Me(t *testing.T) { + defer gock.Off() + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + gock.Observe(gock.DumpRequest) + // get viewer project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // edit project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","title":"a new title","shortDescription":"a new description","readme":"a new readme","public":false}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "me", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := editConfig{ + opts: editOpts{ + number: 1, + owner: "@me", + title: "a new title", + shortDescription: "a new description", + visibility: "PRIVATE", + readme: "a new readme", + }, + client: client, + io: ios, + } + + err := runEdit(config) + assert.NoError(t, err) + assert.Equal( + t, + "Updated project http://a-url.com\n", + stdout.String()) +} + +func TestRunUpdate_OmitParams(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get user project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]string{ + "id": "an ID", + }, + }, + }, + }) + + // Update project + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateProjectV2.*"variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID","title":"another title"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "title": "a title", + "url": "http://a-url.com", + "owner": map[string]string{ + "login": "monalisa", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := editConfig{ + opts: editOpts{ + number: 1, + owner: "monalisa", + title: "another title", + }, + client: client, + io: ios, + } + + err := runEdit(config) + assert.NoError(t, err) + assert.Equal( + t, + "Updated project http://a-url.com\n", + stdout.String()) +} diff --git a/pkg/cmd/project/field-create/field_create.go b/pkg/cmd/project/field-create/field_create.go new file mode 100644 index 000000000..d369148ba --- /dev/null +++ b/pkg/cmd/project/field-create/field_create.go @@ -0,0 +1,163 @@ +package fieldcreate + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type createFieldOpts struct { + name string + dataType string + owner string + singleSelectOptions []string + number int32 + projectID string + format string +} + +type createFieldConfig struct { + client *queries.Client + opts createFieldOpts + io *iostreams.IOStreams +} + +type createProjectV2FieldMutation struct { + CreateProjectV2Field struct { + Field queries.ProjectField `graphql:"projectV2Field"` + } `graphql:"createProjectV2Field(input:$input)"` +} + +func NewCmdCreateField(f *cmdutil.Factory, runF func(config createFieldConfig) error) *cobra.Command { + opts := createFieldOpts{} + createFieldCmd := &cobra.Command{ + Short: "Create a field in a project", + Use: "field-create []", + Example: heredoc.Doc(` + # create a field in the current user's project "1" + gh project field-create 1 --owner "@me" --name "new field" --data-type "text" + + # create a field with three options to select from for owner monalisa + gh project field-create 1 --owner monalisa --name "new field" --data-type "SINGLE_SELECT" --single-select-options "one,two,three" + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := createFieldConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + if config.opts.dataType == "SINGLE_SELECT" && len(config.opts.singleSelectOptions) == 0 { + return fmt.Errorf("passing `--single-select-options` is required for SINGLE_SELECT data type") + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runCreateField(config) + }, + } + + createFieldCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + createFieldCmd.Flags().StringVar(&opts.name, "name", "", "Name of the new field") + cmdutil.StringEnumFlag(createFieldCmd, &opts.dataType, "data-type", "", "", []string{"TEXT", "SINGLE_SELECT", "DATE", "NUMBER"}, "DataType of the new field.") + createFieldCmd.Flags().StringSliceVar(&opts.singleSelectOptions, "single-select-options", []string{}, "Options for SINGLE_SELECT data type") + cmdutil.StringEnumFlag(createFieldCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + _ = createFieldCmd.MarkFlagRequired("name") + _ = createFieldCmd.MarkFlagRequired("data-type") + + return createFieldCmd +} + +func runCreateField(config createFieldConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.projectID = project.ID + + query, variables := createFieldArgs(config) + + err = config.client.Mutate("CreateField", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.CreateProjectV2Field.Field) + } + + return printResults(config, query.CreateProjectV2Field.Field) +} + +func createFieldArgs(config createFieldConfig) (*createProjectV2FieldMutation, map[string]interface{}) { + input := githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID(config.opts.projectID), + DataType: githubv4.ProjectV2CustomFieldType(config.opts.dataType), + Name: githubv4.String(config.opts.name), + } + + if len(config.opts.singleSelectOptions) != 0 { + opts := make([]githubv4.ProjectV2SingleSelectFieldOptionInput, 0) + for _, opt := range config.opts.singleSelectOptions { + opts = append(opts, githubv4.ProjectV2SingleSelectFieldOptionInput{ + Name: githubv4.String(opt), + Color: githubv4.ProjectV2SingleSelectFieldOptionColor("GRAY"), + }) + } + input.SingleSelectOptions = &opts + } + + return &createProjectV2FieldMutation{}, map[string]interface{}{ + "input": input, + } +} + +func printResults(config createFieldConfig, field queries.ProjectField) error { + if !config.io.IsStdoutTTY() { + return nil + } + + _, err := fmt.Fprintf(config.io.Out, "Created field\n") + return err +} + +func printJSON(config createFieldConfig, field queries.ProjectField) error { + b, err := format.JSONProjectField(field) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/field-create/field_create_test.go b/pkg/cmd/project/field-create/field_create_test.go new file mode 100644 index 000000000..31328a04f --- /dev/null +++ b/pkg/cmd/project/field-create/field_create_test.go @@ -0,0 +1,632 @@ +package fieldcreate + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdCreateField(t *testing.T) { + tests := []struct { + name string + cli string + wants createFieldOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "missing-name-and-data-type", + cli: "", + wantsErr: true, + wantsErrMsg: "required flag(s) \"data-type\", \"name\" not set", + }, + { + name: "not-a-number", + cli: "x --name n --data-type TEXT", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "single-select-no-options", + cli: "123 --name n --data-type SINGLE_SELECT", + wantsErr: true, + wantsErrMsg: "passing `--single-select-options` is required for SINGLE_SELECT data type", + }, + { + name: "number", + cli: "123 --name n --data-type TEXT", + wants: createFieldOpts{ + number: 123, + name: "n", + dataType: "TEXT", + singleSelectOptions: []string{}, + }, + }, + { + name: "owner", + cli: "--owner monalisa --name n --data-type TEXT", + wants: createFieldOpts{ + owner: "monalisa", + name: "n", + dataType: "TEXT", + singleSelectOptions: []string{}, + }, + }, + { + name: "single-select-options", + cli: "--name n --data-type TEXT --single-select-options a,b", + wants: createFieldOpts{ + singleSelectOptions: []string{"a", "b"}, + name: "n", + dataType: "TEXT", + }, + }, + { + name: "json", + cli: "--format json --name n --data-type TEXT ", + wants: createFieldOpts{ + format: "json", + name: "n", + dataType: "TEXT", + singleSelectOptions: []string{}, + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts createFieldOpts + cmd := NewCmdCreateField(f, func(config createFieldConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.name, gotOpts.name) + assert.Equal(t, tt.wants.dataType, gotOpts.dataType) + assert.Equal(t, tt.wants.singleSelectOptions, gotOpts.singleSelectOptions) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunCreateField_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create Field + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateField.*","variables":{"input":{"projectId":"an ID","dataType":"TEXT","name":"a name"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2Field": map[string]interface{}{ + "projectV2Field": map[string]interface{}{ + "id": "Field ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createFieldConfig{ + opts: createFieldOpts{ + name: "a name", + owner: "monalisa", + number: 1, + dataType: "TEXT", + }, + client: client, + io: ios, + } + + err := runCreateField(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created field\n", + stdout.String()) +} + +func TestRunCreateField_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create Field + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateField.*","variables":{"input":{"projectId":"an ID","dataType":"TEXT","name":"a name"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2Field": map[string]interface{}{ + "projectV2Field": map[string]interface{}{ + "id": "Field ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createFieldConfig{ + opts: createFieldOpts{ + name: "a name", + owner: "github", + number: 1, + dataType: "TEXT", + }, + client: client, + io: ios, + } + + err := runCreateField(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created field\n", + stdout.String()) +} + +func TestRunCreateField_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create Field + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateField.*","variables":{"input":{"projectId":"an ID","dataType":"TEXT","name":"a name"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2Field": map[string]interface{}{ + "projectV2Field": map[string]interface{}{ + "id": "Field ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createFieldConfig{ + opts: createFieldOpts{ + owner: "@me", + number: 1, + name: "a name", + dataType: "TEXT", + }, + client: client, + io: ios, + } + + err := runCreateField(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created field\n", + stdout.String()) +} + +func TestRunCreateField_TEXT(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create Field + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateField.*","variables":{"input":{"projectId":"an ID","dataType":"TEXT","name":"a name"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2Field": map[string]interface{}{ + "projectV2Field": map[string]interface{}{ + "id": "Field ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createFieldConfig{ + opts: createFieldOpts{ + owner: "@me", + number: 1, + name: "a name", + dataType: "TEXT", + }, + client: client, + io: ios, + } + + err := runCreateField(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created field\n", + stdout.String()) +} + +func TestRunCreateField_DATE(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create Field + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateField.*","variables":{"input":{"projectId":"an ID","dataType":"DATE","name":"a name"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2Field": map[string]interface{}{ + "projectV2Field": map[string]interface{}{ + "id": "Field ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createFieldConfig{ + opts: createFieldOpts{ + owner: "@me", + number: 1, + name: "a name", + dataType: "DATE", + }, + client: client, + io: ios, + } + + err := runCreateField(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created field\n", + stdout.String()) +} + +func TestRunCreateField_NUMBER(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create Field + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateField.*","variables":{"input":{"projectId":"an ID","dataType":"NUMBER","name":"a name"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "createProjectV2Field": map[string]interface{}{ + "projectV2Field": map[string]interface{}{ + "id": "Field ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createFieldConfig{ + opts: createFieldOpts{ + owner: "@me", + number: 1, + name: "a name", + dataType: "NUMBER", + }, + client: client, + io: ios, + } + + err := runCreateField(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created field\n", + stdout.String()) +} diff --git a/pkg/cmd/project/field-delete/field_delete.go b/pkg/cmd/project/field-delete/field_delete.go new file mode 100644 index 000000000..3d3bb25ce --- /dev/null +++ b/pkg/cmd/project/field-delete/field_delete.go @@ -0,0 +1,105 @@ +package fielddelete + +import ( + "fmt" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type deleteFieldOpts struct { + fieldID string + format string +} + +type deleteFieldConfig struct { + client *queries.Client + opts deleteFieldOpts + io *iostreams.IOStreams +} + +type deleteProjectV2FieldMutation struct { + DeleteProjectV2Field struct { + Field queries.ProjectField `graphql:"projectV2Field"` + } `graphql:"deleteProjectV2Field(input:$input)"` +} + +func NewCmdDeleteField(f *cmdutil.Factory, runF func(config deleteFieldConfig) error) *cobra.Command { + opts := deleteFieldOpts{} + deleteFieldCmd := &cobra.Command{ + Short: "Delete a field in a project", + Use: "field-delete", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + config := deleteFieldConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runDeleteField(config) + }, + } + + deleteFieldCmd.Flags().StringVar(&opts.fieldID, "id", "", "ID of the field to delete") + cmdutil.StringEnumFlag(deleteFieldCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + _ = deleteFieldCmd.MarkFlagRequired("id") + + return deleteFieldCmd +} + +func runDeleteField(config deleteFieldConfig) error { + query, variables := deleteFieldArgs(config) + + err := config.client.Mutate("DeleteField", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.DeleteProjectV2Field.Field) + } + + return printResults(config, query.DeleteProjectV2Field.Field) +} + +func deleteFieldArgs(config deleteFieldConfig) (*deleteProjectV2FieldMutation, map[string]interface{}) { + return &deleteProjectV2FieldMutation{}, map[string]interface{}{ + "input": githubv4.DeleteProjectV2FieldInput{ + FieldID: githubv4.ID(config.opts.fieldID), + }, + } +} + +func printResults(config deleteFieldConfig, field queries.ProjectField) error { + if !config.io.IsStdoutTTY() { + return nil + } + + _, err := fmt.Fprintf(config.io.Out, "Deleted field\n") + return err +} + +func printJSON(config deleteFieldConfig, field queries.ProjectField) error { + b, err := format.JSONProjectField(field) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/field-delete/field_delete_test.go b/pkg/cmd/project/field-delete/field_delete_test.go new file mode 100644 index 000000000..61783d44d --- /dev/null +++ b/pkg/cmd/project/field-delete/field_delete_test.go @@ -0,0 +1,117 @@ +package fielddelete + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdDeleteField(t *testing.T) { + tests := []struct { + name string + cli string + wants deleteFieldOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "no id", + cli: "", + wantsErr: true, + wantsErrMsg: "required flag(s) \"id\" not set", + }, + { + name: "id", + cli: "--id 123", + wants: deleteFieldOpts{ + fieldID: "123", + }, + }, + { + name: "json", + cli: "--id 123 --format json", + wants: deleteFieldOpts{ + format: "json", + fieldID: "123", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts deleteFieldOpts + cmd := NewCmdDeleteField(f, func(config deleteFieldConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.fieldID, gotOpts.fieldID) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunDeleteField(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // delete Field + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation DeleteField.*","variables":{"input":{"fieldId":"an ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "deleteProjectV2Field": map[string]interface{}{ + "projectV2Field": map[string]interface{}{ + "id": "Field ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := deleteFieldConfig{ + opts: deleteFieldOpts{ + fieldID: "an ID", + }, + client: client, + io: ios, + } + + err := runDeleteField(config) + assert.NoError(t, err) + assert.Equal( + t, + "Deleted field\n", + stdout.String()) +} diff --git a/pkg/cmd/project/field-list/field_list.go b/pkg/cmd/project/field-list/field_list.go new file mode 100644 index 000000000..152d90fcd --- /dev/null +++ b/pkg/cmd/project/field-list/field_list.go @@ -0,0 +1,132 @@ +package fieldlist + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type listOpts struct { + limit int + owner string + number int32 + format string +} + +type listConfig struct { + io *iostreams.IOStreams + tp *tableprinter.TablePrinter + client *queries.Client + opts listOpts +} + +func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.Command { + opts := listOpts{} + listCmd := &cobra.Command{ + Short: "List the fields in a project", + Use: "field-list number", + Example: heredoc.Doc(` + # list fields in the current user's project "1" + gh project field-list 1 --owner "@me" + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + t := tableprinter.New(f.IOStreams) + config := listConfig{ + io: f.IOStreams, + tp: t, + client: client, + opts: opts, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runList(config) + }, + } + + listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + cmdutil.StringEnumFlag(listCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + listCmd.Flags().IntVarP(&opts.limit, "limit", "L", queries.LimitDefault, "Maximum number of fields to fetch") + + return listCmd +} + +func runList(config listConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + // no need to fetch the project if we already have the number + if config.opts.number == 0 { + canPrompt := config.io.CanPrompt() + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.number = project.Number + } + + project, err := config.client.ProjectFields(owner, config.opts.number, config.opts.limit) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, project) + } + + return printResults(config, project.Fields.Nodes, owner.Login) +} + +func printResults(config listConfig, fields []queries.ProjectField, login string) error { + if len(fields) == 0 { + return cmdutil.NewNoResultsError(fmt.Sprintf("Project %d for owner %s has no fields", config.opts.number, login)) + } + + config.tp.HeaderRow("Name", "Data type", "ID") + + for _, f := range fields { + config.tp.AddField(f.Name()) + config.tp.AddField(f.Type()) + config.tp.AddField(f.ID(), tableprinter.WithTruncate(nil)) + config.tp.EndRow() + } + + return config.tp.Render() +} + +func printJSON(config listConfig, project *queries.Project) error { + b, err := format.JSONProjectFields(project) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/field-list/field_list_test.go b/pkg/cmd/project/field-list/field_list_test.go new file mode 100644 index 000000000..60e61188d --- /dev/null +++ b/pkg/cmd/project/field-list/field_list_test.go @@ -0,0 +1,425 @@ +package fieldlist + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants listOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "not-a-number", + cli: "x", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "number", + cli: "123", + wants: listOpts{ + number: 123, + limit: 30, + }, + }, + { + name: "owner", + cli: "--owner monalisa", + wants: listOpts{ + owner: "monalisa", + limit: 30, + }, + }, + { + name: "json", + cli: "--format json", + wants: listOpts{ + format: "json", + limit: 30, + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts listOpts + cmd := NewCmdList(f, func(config listConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Equal(t, tt.wantsErrMsg, err.Error()) + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.limit, gotOpts.limit) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunList_User(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // list project fields + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": queries.LimitMax, + "afterItems": nil, + "firstFields": queries.LimitDefault, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "__typename": "ProjectV2Field", + "name": "FieldTitle", + "id": "field ID", + }, + { + "__typename": "ProjectV2SingleSelectField", + "name": "Status", + "id": "status ID", + }, + { + "__typename": "ProjectV2IterationField", + "name": "Iterations", + "id": "iteration ID", + }, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + number: 1, + owner: "monalisa", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "FieldTitle\tProjectV2Field\tfield ID\nStatus\tProjectV2SingleSelectField\tstatus ID\nIterations\tProjectV2IterationField\titeration ID\n", + stdout.String()) +} + +func TestRunList_Org(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // list project fields + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": queries.LimitMax, + "afterItems": nil, + "firstFields": queries.LimitDefault, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "__typename": "ProjectV2Field", + "name": "FieldTitle", + "id": "field ID", + }, + { + "__typename": "ProjectV2SingleSelectField", + "name": "Status", + "id": "status ID", + }, + { + "__typename": "ProjectV2IterationField", + "name": "Iterations", + "id": "iteration ID", + }, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + number: 1, + owner: "github", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "FieldTitle\tProjectV2Field\tfield ID\nStatus\tProjectV2SingleSelectField\tstatus ID\nIterations\tProjectV2IterationField\titeration ID\n", + stdout.String()) +} + +func TestRunList_Me(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // list project fields + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": queries.LimitMax, + "afterItems": nil, + "firstFields": queries.LimitDefault, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "__typename": "ProjectV2Field", + "name": "FieldTitle", + "id": "field ID", + }, + { + "__typename": "ProjectV2SingleSelectField", + "name": "Status", + "id": "status ID", + }, + { + "__typename": "ProjectV2IterationField", + "name": "Iterations", + "id": "iteration ID", + }, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + number: 1, + owner: "@me", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "FieldTitle\tProjectV2Field\tfield ID\nStatus\tProjectV2SingleSelectField\tstatus ID\nIterations\tProjectV2IterationField\titeration ID\n", + stdout.String()) +} + +func TestRunList_Empty(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // list project fields + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": queries.LimitMax, + "afterItems": nil, + "firstFields": queries.LimitDefault, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "fields": map[string]interface{}{ + "nodes": nil, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + number: 1, + owner: "@me", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.EqualError( + t, + err, + "Project 1 for owner @me has no fields") +} diff --git a/pkg/cmd/project/item-add/item_add.go b/pkg/cmd/project/item-add/item_add.go new file mode 100644 index 000000000..51c9ecfce --- /dev/null +++ b/pkg/cmd/project/item-add/item_add.go @@ -0,0 +1,144 @@ +package itemadd + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type addItemOpts struct { + owner string + number int32 + itemURL string + projectID string + itemID string + format string +} + +type addItemConfig struct { + client *queries.Client + opts addItemOpts + io *iostreams.IOStreams +} + +type addProjectItemMutation struct { + CreateProjectItem struct { + ProjectV2Item queries.ProjectItem `graphql:"item"` + } `graphql:"addProjectV2ItemById(input:$input)"` +} + +func NewCmdAddItem(f *cmdutil.Factory, runF func(config addItemConfig) error) *cobra.Command { + opts := addItemOpts{} + addItemCmd := &cobra.Command{ + Short: "Add a pull request or an issue to a project", + Use: "item-add []", + Example: heredoc.Doc(` + # add an item to monalisa's project "1" + gh project item-add 1 --owner monalisa --url https://github.com/monalisa/myproject/issues/23 + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := addItemConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runAddItem(config) + }, + } + + addItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + addItemCmd.Flags().StringVar(&opts.itemURL, "url", "", "URL of the issue or pull request to add to the project") + cmdutil.StringEnumFlag(addItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + _ = addItemCmd.MarkFlagRequired("url") + + return addItemCmd +} + +func runAddItem(config addItemConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.projectID = project.ID + + itemID, err := config.client.IssueOrPullRequestID(config.opts.itemURL) + if err != nil { + return err + } + config.opts.itemID = itemID + + query, variables := addItemArgs(config) + err = config.client.Mutate("AddItem", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.CreateProjectItem.ProjectV2Item) + } + + return printResults(config, query.CreateProjectItem.ProjectV2Item) + +} + +func addItemArgs(config addItemConfig) (*addProjectItemMutation, map[string]interface{}) { + return &addProjectItemMutation{}, map[string]interface{}{ + "input": githubv4.AddProjectV2ItemByIdInput{ + ProjectID: githubv4.ID(config.opts.projectID), + ContentID: githubv4.ID(config.opts.itemID), + }, + } +} + +func printResults(config addItemConfig, item queries.ProjectItem) error { + if !config.io.IsStdoutTTY() { + return nil + } + + _, err := fmt.Fprintf(config.io.Out, "Added item\n") + return err +} + +func printJSON(config addItemConfig, item queries.ProjectItem) error { + b, err := format.JSONProjectItem(item) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/item-add/item_add_test.go b/pkg/cmd/project/item-add/item_add_test.go new file mode 100644 index 000000000..4f1ebd4f6 --- /dev/null +++ b/pkg/cmd/project/item-add/item_add_test.go @@ -0,0 +1,426 @@ +package itemadd + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdaddItem(t *testing.T) { + tests := []struct { + name string + cli string + wants addItemOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "missing-url", + cli: "", + wantsErr: true, + wantsErrMsg: "required flag(s) \"url\" not set", + }, + { + name: "not-a-number", + cli: "x --url github.com/cli/cli", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "url", + cli: "--url github.com/cli/cli", + wants: addItemOpts{ + itemURL: "github.com/cli/cli", + }, + }, + { + name: "number", + cli: "123 --url github.com/cli/cli", + wants: addItemOpts{ + number: 123, + itemURL: "github.com/cli/cli", + }, + }, + { + name: "owner", + cli: "--owner monalisa --url github.com/cli/cli", + wants: addItemOpts{ + owner: "monalisa", + itemURL: "github.com/cli/cli", + }, + }, + { + name: "json", + cli: "--format json --url github.com/cli/cli", + wants: addItemOpts{ + format: "json", + itemURL: "github.com/cli/cli", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts addItemOpts + cmd := NewCmdAddItem(f, func(config addItemConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.itemURL, gotOpts.itemURL) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunAddItem_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // get item ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query GetIssueOrPullRequest.*", + "variables": map[string]interface{}{ + "url": "https://github.com/cli/go-gh/issues/1", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "id": "item ID", + "__typename": "Issue", + }, + }, + }) + + // create item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation AddItem.*","variables":{"input":{"projectId":"an ID","contentId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "addProjectV2ItemById": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := addItemConfig{ + opts: addItemOpts{ + owner: "monalisa", + number: 1, + itemURL: "https://github.com/cli/go-gh/issues/1", + }, + client: client, + io: ios, + } + + err := runAddItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Added item\n", + stdout.String()) +} + +func TestRunAddItem_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // get item ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query GetIssueOrPullRequest.*", + "variables": map[string]interface{}{ + "url": "https://github.com/cli/go-gh/issues/1", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "id": "item ID", + "__typename": "Issue", + }, + }, + }) + + // create item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation AddItem.*","variables":{"input":{"projectId":"an ID","contentId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "addProjectV2ItemById": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := addItemConfig{ + opts: addItemOpts{ + owner: "github", + number: 1, + itemURL: "https://github.com/cli/go-gh/issues/1", + }, + client: client, + io: ios, + } + + err := runAddItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Added item\n", + stdout.String()) +} + +func TestRunAddItem_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // get item ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query GetIssueOrPullRequest.*", + "variables": map[string]interface{}{ + "url": "https://github.com/cli/go-gh/pull/1", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "id": "item ID", + "__typename": "PullRequest", + }, + }, + }) + + // create item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation AddItem.*","variables":{"input":{"projectId":"an ID","contentId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "addProjectV2ItemById": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := addItemConfig{ + opts: addItemOpts{ + owner: "@me", + number: 1, + itemURL: "https://github.com/cli/go-gh/pull/1", + }, + client: client, + io: ios, + } + + err := runAddItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Added item\n", + stdout.String()) +} diff --git a/pkg/cmd/project/item-archive/item_archive.go b/pkg/cmd/project/item-archive/item_archive.go new file mode 100644 index 000000000..7ae336242 --- /dev/null +++ b/pkg/cmd/project/item-archive/item_archive.go @@ -0,0 +1,171 @@ +package itemarchive + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type archiveItemOpts struct { + owner string + number int32 + undo bool + itemID string + projectID string + format string +} + +type archiveItemConfig struct { + client *queries.Client + opts archiveItemOpts + io *iostreams.IOStreams +} + +type archiveProjectItemMutation struct { + ArchiveProjectItem struct { + ProjectV2Item queries.ProjectItem `graphql:"item"` + } `graphql:"archiveProjectV2Item(input:$input)"` +} + +type unarchiveProjectItemMutation struct { + UnarchiveProjectItem struct { + ProjectV2Item queries.ProjectItem `graphql:"item"` + } `graphql:"unarchiveProjectV2Item(input:$input)"` +} + +func NewCmdArchiveItem(f *cmdutil.Factory, runF func(config archiveItemConfig) error) *cobra.Command { + opts := archiveItemOpts{} + archiveItemCmd := &cobra.Command{ + Short: "Archive an item in a project", + Use: "item-archive []", + Example: heredoc.Doc(` + # archive an item in the current user's project "1" + gh project item-archive 1 --owner "@me" --id + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := archiveItemConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runArchiveItem(config) + }, + } + + archiveItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + archiveItemCmd.Flags().StringVar(&opts.itemID, "id", "", "ID of the item to archive") + archiveItemCmd.Flags().BoolVar(&opts.undo, "undo", false, "Unarchive an item") + cmdutil.StringEnumFlag(archiveItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + _ = archiveItemCmd.MarkFlagRequired("id") + + return archiveItemCmd +} + +func runArchiveItem(config archiveItemConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.projectID = project.ID + + if config.opts.undo { + query, variables := unarchiveItemArgs(config, config.opts.itemID) + err = config.client.Mutate("UnarchiveProjectItem", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.UnarchiveProjectItem.ProjectV2Item) + } + + return printResults(config, query.UnarchiveProjectItem.ProjectV2Item) + } + query, variables := archiveItemArgs(config) + err = config.client.Mutate("ArchiveProjectItem", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.ArchiveProjectItem.ProjectV2Item) + } + + return printResults(config, query.ArchiveProjectItem.ProjectV2Item) +} + +func archiveItemArgs(config archiveItemConfig) (*archiveProjectItemMutation, map[string]interface{}) { + return &archiveProjectItemMutation{}, map[string]interface{}{ + "input": githubv4.ArchiveProjectV2ItemInput{ + ProjectID: githubv4.ID(config.opts.projectID), + ItemID: githubv4.ID(config.opts.itemID), + }, + } +} + +func unarchiveItemArgs(config archiveItemConfig, itemID string) (*unarchiveProjectItemMutation, map[string]interface{}) { + return &unarchiveProjectItemMutation{}, map[string]interface{}{ + "input": githubv4.UnarchiveProjectV2ItemInput{ + ProjectID: githubv4.ID(config.opts.projectID), + ItemID: githubv4.ID(itemID), + }, + } +} + +func printResults(config archiveItemConfig, item queries.ProjectItem) error { + if !config.io.IsStdoutTTY() { + return nil + } + + if config.opts.undo { + _, err := fmt.Fprintf(config.io.Out, "Unarchived item\n") + return err + } + + _, err := fmt.Fprintf(config.io.Out, "Archived item\n") + return err +} + +func printJSON(config archiveItemConfig, item queries.ProjectItem) error { + b, err := format.JSONProjectItem(item) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/item-archive/item_archive_test.go b/pkg/cmd/project/item-archive/item_archive_test.go new file mode 100644 index 000000000..85a34af22 --- /dev/null +++ b/pkg/cmd/project/item-archive/item_archive_test.go @@ -0,0 +1,639 @@ +package itemarchive + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdarchiveItem(t *testing.T) { + tests := []struct { + name string + cli string + wants archiveItemOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "missing-id", + cli: "", + wantsErr: true, + wantsErrMsg: "required flag(s) \"id\" not set", + }, + { + name: "not-a-number", + cli: "x --id 123", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "id", + cli: "--id 123", + wants: archiveItemOpts{ + itemID: "123", + }, + }, + { + name: "number", + cli: "456 --id 123", + wants: archiveItemOpts{ + number: 456, + itemID: "123", + }, + }, + { + name: "owner", + cli: "--owner monalisa --id 123", + wants: archiveItemOpts{ + owner: "monalisa", + itemID: "123", + }, + }, + { + name: "undo", + cli: "--undo --id 123", + wants: archiveItemOpts{ + undo: true, + itemID: "123", + }, + }, + { + name: "json", + cli: "--format json --id 123", + wants: archiveItemOpts{ + format: "json", + itemID: "123", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts archiveItemOpts + cmd := NewCmdArchiveItem(f, func(config archiveItemConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.itemID, gotOpts.itemID) + assert.Equal(t, tt.wants.undo, gotOpts.undo) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunArchive_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // archive item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation ArchiveProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "archiveProjectV2Item": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := archiveItemConfig{ + opts: archiveItemOpts{ + owner: "monalisa", + number: 1, + itemID: "item ID", + }, + client: client, + io: ios, + } + + err := runArchiveItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Archived item\n", + stdout.String()) +} + +func TestRunArchive_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // archive item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation ArchiveProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "archiveProjectV2Item": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := archiveItemConfig{ + opts: archiveItemOpts{ + owner: "github", + number: 1, + itemID: "item ID", + }, + client: client, + io: ios, + } + + err := runArchiveItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Archived item\n", + stdout.String()) +} + +func TestRunArchive_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // archive item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation ArchiveProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "archiveProjectV2Item": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := archiveItemConfig{ + opts: archiveItemOpts{ + owner: "@me", + number: 1, + itemID: "item ID", + }, + client: client, + io: ios, + } + + err := runArchiveItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Archived item\n", + stdout.String()) +} + +func TestRunArchive_User_Undo(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // archive item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UnarchiveProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "unarchiveProjectV2Item": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := archiveItemConfig{ + opts: archiveItemOpts{ + owner: "monalisa", + number: 1, + itemID: "item ID", + undo: true, + }, + client: client, + io: ios, + } + + err := runArchiveItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Unarchived item\n", + stdout.String()) +} + +func TestRunArchive_Org_Undo(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // archive item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UnarchiveProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "unarchiveProjectV2Item": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := archiveItemConfig{ + opts: archiveItemOpts{ + owner: "github", + number: 1, + itemID: "item ID", + undo: true, + }, + client: client, + io: ios, + } + + err := runArchiveItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Unarchived item\n", + stdout.String()) +} + +func TestRunArchive_Me_Undo(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // archive item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UnarchiveProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "unarchiveProjectV2Item": map[string]interface{}{ + "item": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := archiveItemConfig{ + opts: archiveItemOpts{ + owner: "@me", + number: 1, + itemID: "item ID", + undo: true, + }, + client: client, + io: ios, + } + + err := runArchiveItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Unarchived item\n", + stdout.String()) +} diff --git a/pkg/cmd/project/item-create/item_create.go b/pkg/cmd/project/item-create/item_create.go new file mode 100644 index 000000000..6ee32e9f6 --- /dev/null +++ b/pkg/cmd/project/item-create/item_create.go @@ -0,0 +1,140 @@ +package itemcreate + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type createItemOpts struct { + title string + body string + owner string + number int32 + projectID string + format string +} + +type createItemConfig struct { + client *queries.Client + opts createItemOpts + io *iostreams.IOStreams +} + +type createProjectDraftItemMutation struct { + CreateProjectDraftItem struct { + ProjectV2Item queries.ProjectItem `graphql:"projectItem"` + } `graphql:"addProjectV2DraftIssue(input:$input)"` +} + +func NewCmdCreateItem(f *cmdutil.Factory, runF func(config createItemConfig) error) *cobra.Command { + opts := createItemOpts{} + createItemCmd := &cobra.Command{ + Short: "Create a draft issue item in a project", + Use: "item-create []", + Example: heredoc.Doc(` + # create a draft issue in the current user's project "1" + gh project item-create 1 --owner "@me" --title "new item" --body "new item body" + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := createItemConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runCreateItem(config) + }, + } + + createItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + createItemCmd.Flags().StringVar(&opts.title, "title", "", "Title for the draft issue") + createItemCmd.Flags().StringVar(&opts.body, "body", "", "Body for the draft issue") + cmdutil.StringEnumFlag(createItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + _ = createItemCmd.MarkFlagRequired("title") + + return createItemCmd +} + +func runCreateItem(config createItemConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.projectID = project.ID + + query, variables := createDraftIssueArgs(config) + + err = config.client.Mutate("CreateDraftItem", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.CreateProjectDraftItem.ProjectV2Item) + } + + return printResults(config, query.CreateProjectDraftItem.ProjectV2Item) +} + +func createDraftIssueArgs(config createItemConfig) (*createProjectDraftItemMutation, map[string]interface{}) { + return &createProjectDraftItemMutation{}, map[string]interface{}{ + "input": githubv4.AddProjectV2DraftIssueInput{ + Body: githubv4.NewString(githubv4.String(config.opts.body)), + ProjectID: githubv4.ID(config.opts.projectID), + Title: githubv4.String(config.opts.title), + }, + } +} + +func printResults(config createItemConfig, item queries.ProjectItem) error { + if !config.io.IsStdoutTTY() { + return nil + } + + _, err := fmt.Fprintf(config.io.Out, "Created item\n") + return err +} + +func printJSON(config createItemConfig, item queries.ProjectItem) error { + b, err := format.JSONProjectItem(item) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/item-create/item_create_test.go b/pkg/cmd/project/item-create/item_create_test.go new file mode 100644 index 000000000..d49144f3c --- /dev/null +++ b/pkg/cmd/project/item-create/item_create_test.go @@ -0,0 +1,374 @@ +package itemcreate + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdCreateItem(t *testing.T) { + tests := []struct { + name string + cli string + wants createItemOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "missing-title", + cli: "", + wantsErr: true, + wantsErrMsg: "required flag(s) \"title\" not set", + }, + { + name: "not-a-number", + cli: "x --title t", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "title", + cli: "--title t", + wants: createItemOpts{ + title: "t", + }, + }, + { + name: "number", + cli: "123 --title t", + wants: createItemOpts{ + number: 123, + title: "t", + }, + }, + { + name: "owner", + cli: "--owner monalisa --title t", + wants: createItemOpts{ + owner: "monalisa", + title: "t", + }, + }, + { + name: "body", + cli: "--body b --title t", + wants: createItemOpts{ + body: "b", + title: "t", + }, + }, + { + name: "json", + cli: "--format json --title t", + wants: createItemOpts{ + format: "json", + title: "t", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts createItemOpts + cmd := NewCmdCreateItem(f, func(config createItemConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.title, gotOpts.title) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunCreateItem_Draft_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateDraftItem.*","variables":{"input":{"projectId":"an ID","title":"a title","body":""}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "addProjectV2DraftIssue": map[string]interface{}{ + "projectItem": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createItemConfig{ + opts: createItemOpts{ + title: "a title", + owner: "monalisa", + number: 1, + }, + client: client, + io: ios, + } + + err := runCreateItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created item\n", + stdout.String()) +} + +func TestRunCreateItem_Draft_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateDraftItem.*","variables":{"input":{"projectId":"an ID","title":"a title","body":""}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "addProjectV2DraftIssue": map[string]interface{}{ + "projectItem": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createItemConfig{ + opts: createItemOpts{ + title: "a title", + owner: "github", + number: 1, + }, + client: client, + io: ios, + } + + err := runCreateItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created item\n", + stdout.String()) +} + +func TestRunCreateItem_Draft_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // create item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation CreateDraftItem.*","variables":{"input":{"projectId":"an ID","title":"a title","body":"a body"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "addProjectV2DraftIssue": map[string]interface{}{ + "projectItem": map[string]interface{}{ + "id": "item ID", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := createItemConfig{ + opts: createItemOpts{ + title: "a title", + owner: "@me", + number: 1, + body: "a body", + }, + client: client, + io: ios, + } + + err := runCreateItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Created item\n", + stdout.String()) +} diff --git a/pkg/cmd/project/item-delete/item_delete.go b/pkg/cmd/project/item-delete/item_delete.go new file mode 100644 index 000000000..2717afedc --- /dev/null +++ b/pkg/cmd/project/item-delete/item_delete.go @@ -0,0 +1,131 @@ +package itemdelete + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type deleteItemOpts struct { + owner string + number int32 + itemID string + projectID string + format string +} + +type deleteItemConfig struct { + client *queries.Client + opts deleteItemOpts + io *iostreams.IOStreams +} + +type deleteProjectItemMutation struct { + DeleteProjectItem struct { + DeletedItemId githubv4.ID `graphql:"deletedItemId"` + } `graphql:"deleteProjectV2Item(input:$input)"` +} + +func NewCmdDeleteItem(f *cmdutil.Factory, runF func(config deleteItemConfig) error) *cobra.Command { + opts := deleteItemOpts{} + deleteItemCmd := &cobra.Command{ + Short: "Delete an item from a project by ID", + Use: "item-delete []", + Example: heredoc.Doc(` + # delete an item in the current user's project "1" + gh project item-delete 1 --owner "@me" --id + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := deleteItemConfig{ + client: client, + opts: opts, + io: f.IOStreams, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runDeleteItem(config) + }, + } + + deleteItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + deleteItemCmd.Flags().StringVar(&opts.itemID, "id", "", "ID of the item to delete") + cmdutil.StringEnumFlag(deleteItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + _ = deleteItemCmd.MarkFlagRequired("id") + + return deleteItemCmd +} + +func runDeleteItem(config deleteItemConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.projectID = project.ID + + query, variables := deleteItemArgs(config) + err = config.client.Mutate("DeleteProjectItem", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, query.DeleteProjectItem.DeletedItemId) + } + + return printResults(config) + +} + +func deleteItemArgs(config deleteItemConfig) (*deleteProjectItemMutation, map[string]interface{}) { + return &deleteProjectItemMutation{}, map[string]interface{}{ + "input": githubv4.DeleteProjectV2ItemInput{ + ProjectID: githubv4.ID(config.opts.projectID), + ItemID: githubv4.ID(config.opts.itemID), + }, + } +} + +func printResults(config deleteItemConfig) error { + if !config.io.IsStdoutTTY() { + return nil + } + + _, err := fmt.Fprintf(config.io.Out, "Deleted item\n") + return err +} + +func printJSON(config deleteItemConfig, ID githubv4.ID) error { + _, err := config.io.Out.Write([]byte(fmt.Sprintf(`{"id": "%s"}`, ID))) + return err +} diff --git a/pkg/cmd/project/item-delete/item_delete_test.go b/pkg/cmd/project/item-delete/item_delete_test.go new file mode 100644 index 000000000..c5a3f01fd --- /dev/null +++ b/pkg/cmd/project/item-delete/item_delete_test.go @@ -0,0 +1,359 @@ +package itemdelete + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdDeleteItem(t *testing.T) { + tests := []struct { + name string + cli string + wants deleteItemOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "missing-id", + cli: "", + wantsErr: true, + wantsErrMsg: "required flag(s) \"id\" not set", + }, + { + name: "not-a-number", + cli: "x --id 123", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "item-id", + cli: "--id 123", + wants: deleteItemOpts{ + itemID: "123", + }, + }, + { + name: "number", + cli: "456 --id 123", + wants: deleteItemOpts{ + number: 456, + itemID: "123", + }, + }, + { + name: "owner", + cli: "--owner monalisa --id 123", + wants: deleteItemOpts{ + owner: "monalisa", + itemID: "123", + }, + }, + { + name: "json", + cli: "--format json --id 123", + wants: deleteItemOpts{ + format: "json", + itemID: "123", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts deleteItemOpts + cmd := NewCmdDeleteItem(f, func(config deleteItemConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.itemID, gotOpts.itemID) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunDelete_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // delete item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation DeleteProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "deleteProjectV2Item": map[string]interface{}{ + "deletedItemId": "item ID", + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := deleteItemConfig{ + opts: deleteItemOpts{ + owner: "monalisa", + number: 1, + itemID: "item ID", + }, + client: client, + io: ios, + } + + err := runDeleteItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Deleted item\n", + stdout.String()) +} + +func TestRunDelete_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query OrgProject.*", + "variables": map[string]interface{}{ + "login": "github", + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // delete item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation DeleteProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "deleteProjectV2Item": map[string]interface{}{ + "deletedItemId": "item ID", + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := deleteItemConfig{ + opts: deleteItemOpts{ + owner: "github", + number: 1, + itemID: "item ID", + }, + client: client, + io: ios, + } + + err := runDeleteItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Deleted item\n", + stdout.String()) +} + +func TestRunDelete_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // get project ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerProject.*", + "variables": map[string]interface{}{ + "number": 1, + "firstItems": 0, + "afterItems": nil, + "firstFields": 0, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "an ID", + }, + }, + }, + }) + + // delete item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation DeleteProjectItem.*","variables":{"input":{"projectId":"an ID","itemId":"item ID"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "deleteProjectV2Item": map[string]interface{}{ + "deletedItemId": "item ID", + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + config := deleteItemConfig{ + opts: deleteItemOpts{ + owner: "@me", + number: 1, + itemID: "item ID", + }, + client: client, + io: ios, + } + + err := runDeleteItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Deleted item\n", + stdout.String()) +} diff --git a/pkg/cmd/project/item-edit/item_edit.go b/pkg/cmd/project/item-edit/item_edit.go new file mode 100644 index 000000000..3bd479470 --- /dev/null +++ b/pkg/cmd/project/item-edit/item_edit.go @@ -0,0 +1,253 @@ +package itemedit + +import ( + "fmt" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type editItemOpts struct { + // updateDraftIssue + title string + body string + itemID string + // updateItem + fieldID string + projectID string + text string + number float32 + date string + singleSelectOptionID string + iterationID string + // format + format string +} + +type editItemConfig struct { + io *iostreams.IOStreams + client *queries.Client + opts editItemOpts +} + +type EditProjectDraftIssue struct { + UpdateProjectV2DraftIssue struct { + DraftIssue queries.DraftIssue `graphql:"draftIssue"` + } `graphql:"updateProjectV2DraftIssue(input:$input)"` +} + +type UpdateProjectV2FieldValue struct { + Update struct { + Item queries.ProjectItem `graphql:"projectV2Item"` + } `graphql:"updateProjectV2ItemFieldValue(input:$input)"` +} + +func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error) *cobra.Command { + opts := editItemOpts{} + editItemCmd := &cobra.Command{ + Use: "item-edit", + Short: "Edit an item in a project", + Long: heredoc.Doc(` + Edit either a draft issue or a project item. Both usages require the ID of the item to edit. + + For non-draft issues, the ID of the project is also required, and only a single field value can be updated per invocation. + `), + Example: heredoc.Doc(` + # edit an item's text field value + gh project item-edit --id --field-id --project-id --text "new text" + `), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.MutuallyExclusive( + "only one of `--text`, `--number`, `--date`, `--single-select-option-id` or `--iteration-id` may be used", + opts.text != "", + opts.number != 0, + opts.date != "", + opts.singleSelectOptionID != "", + opts.iterationID != "", + ); err != nil { + return err + } + + client, err := client.New(f) + if err != nil { + return err + } + + config := editItemConfig{ + io: f.IOStreams, + client: client, + opts: opts, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runEditItem(config) + }, + } + + editItemCmd.Flags().StringVar(&opts.itemID, "id", "", "ID of the item to edit") + cmdutil.StringEnumFlag(editItemCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + editItemCmd.Flags().StringVar(&opts.title, "title", "", "Title of the draft issue item") + editItemCmd.Flags().StringVar(&opts.body, "body", "", "Body of the draft issue item") + + editItemCmd.Flags().StringVar(&opts.fieldID, "field-id", "", "ID of the field to update") + editItemCmd.Flags().StringVar(&opts.projectID, "project-id", "", "ID of the project to which the field belongs to") + editItemCmd.Flags().StringVar(&opts.text, "text", "", "Text value for the field") + editItemCmd.Flags().Float32Var(&opts.number, "number", 0, "Number value for the field") + editItemCmd.Flags().StringVar(&opts.date, "date", "", "Date value for the field (YYYY-MM-DD)") + editItemCmd.Flags().StringVar(&opts.singleSelectOptionID, "single-select-option-id", "", "ID of the single select option value to set on the field") + editItemCmd.Flags().StringVar(&opts.iterationID, "iteration-id", "", "ID of the iteration value to set on the field") + + _ = editItemCmd.MarkFlagRequired("id") + + return editItemCmd +} + +func runEditItem(config editItemConfig) error { + // update draft issue + if config.opts.title != "" || config.opts.body != "" { + if !strings.HasPrefix(config.opts.itemID, "DI_") { + return cmdutil.FlagErrorf("ID must be the ID of the draft issue content which is prefixed with `DI_`") + } + + query, variables := buildEditDraftIssue(config) + + err := config.client.Mutate("EditDraftIssueItem", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printDraftIssueJSON(config, query.UpdateProjectV2DraftIssue.DraftIssue) + } + + return printDraftIssueResults(config, query.UpdateProjectV2DraftIssue.DraftIssue) + } + + // update item values + if config.opts.text != "" || config.opts.number != 0 || config.opts.date != "" || config.opts.singleSelectOptionID != "" || config.opts.iterationID != "" { + if config.opts.fieldID == "" { + return cmdutil.FlagErrorf("field-id must be provided") + } + if config.opts.projectID == "" { + // TODO: offer to fetch interactively + return cmdutil.FlagErrorf("project-id must be provided") + } + + var parsedDate time.Time + if config.opts.date != "" { + date, err := time.Parse("2006-01-02", config.opts.date) + if err != nil { + return err + } + parsedDate = date + } + + query, variables := buildUpdateItem(config, parsedDate) + err := config.client.Mutate("UpdateItemValues", query, variables) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printItemJSON(config, &query.Update.Item) + } + + return printItemResults(config, &query.Update.Item) + } + + if _, err := fmt.Fprintln(config.io.ErrOut, "error: no changes to make"); err != nil { + return err + } + return cmdutil.SilentError +} + +func buildEditDraftIssue(config editItemConfig) (*EditProjectDraftIssue, map[string]interface{}) { + return &EditProjectDraftIssue{}, map[string]interface{}{ + "input": githubv4.UpdateProjectV2DraftIssueInput{ + Body: githubv4.NewString(githubv4.String(config.opts.body)), + DraftIssueID: githubv4.ID(config.opts.itemID), + Title: githubv4.NewString(githubv4.String(config.opts.title)), + }, + } +} + +func buildUpdateItem(config editItemConfig, date time.Time) (*UpdateProjectV2FieldValue, map[string]interface{}) { + var value githubv4.ProjectV2FieldValue + if config.opts.text != "" { + value = githubv4.ProjectV2FieldValue{ + Text: githubv4.NewString(githubv4.String(config.opts.text)), + } + } else if config.opts.number != 0 { + value = githubv4.ProjectV2FieldValue{ + Number: githubv4.NewFloat(githubv4.Float(config.opts.number)), + } + } else if config.opts.date != "" { + value = githubv4.ProjectV2FieldValue{ + Date: githubv4.NewDate(githubv4.Date{Time: date}), + } + } else if config.opts.singleSelectOptionID != "" { + value = githubv4.ProjectV2FieldValue{ + SingleSelectOptionID: githubv4.NewString(githubv4.String(config.opts.singleSelectOptionID)), + } + } else if config.opts.iterationID != "" { + value = githubv4.ProjectV2FieldValue{ + IterationID: githubv4.NewString(githubv4.String(config.opts.iterationID)), + } + } + + return &UpdateProjectV2FieldValue{}, map[string]interface{}{ + "input": githubv4.UpdateProjectV2ItemFieldValueInput{ + ProjectID: githubv4.ID(config.opts.projectID), + ItemID: githubv4.ID(config.opts.itemID), + FieldID: githubv4.ID(config.opts.fieldID), + Value: value, + }, + } +} + +func printDraftIssueResults(config editItemConfig, item queries.DraftIssue) error { + if !config.io.IsStdoutTTY() { + return nil + } + _, err := fmt.Fprintf(config.io.Out, "Edited draft issue %q\n", item.Title) + return err +} + +func printDraftIssueJSON(config editItemConfig, item queries.DraftIssue) error { + b, err := format.JSONProjectDraftIssue(item) + if err != nil { + return err + } + _, err = config.io.Out.Write(b) + return err +} + +func printItemResults(config editItemConfig, item *queries.ProjectItem) error { + if !config.io.IsStdoutTTY() { + return nil + } + _, err := fmt.Fprintf(config.io.Out, "Edited item %q\n", item.Title()) + return err +} + +func printItemJSON(config editItemConfig, item *queries.ProjectItem) error { + b, err := format.JSONProjectItem(*item) + if err != nil { + return err + } + _, err = config.io.Out.Write(b) + return err + +} diff --git a/pkg/cmd/project/item-edit/item_edit_test.go b/pkg/cmd/project/item-edit/item_edit_test.go new file mode 100644 index 000000000..156871f32 --- /dev/null +++ b/pkg/cmd/project/item-edit/item_edit_test.go @@ -0,0 +1,476 @@ +package itemedit + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdeditItem(t *testing.T) { + tests := []struct { + name string + cli string + wants editItemOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "missing-id", + cli: "", + wantsErr: true, + wantsErrMsg: "required flag(s) \"id\" not set", + }, + { + name: "invalid-flags", + cli: "--id 123 --text t --date 2023-01-01", + wantsErr: true, + wantsErrMsg: "only one of `--text`, `--number`, `--date`, `--single-select-option-id` or `--iteration-id` may be used", + }, + { + name: "item-id", + cli: "--id 123", + wants: editItemOpts{ + itemID: "123", + }, + }, + { + name: "number", + cli: "--number 456 --id 123", + wants: editItemOpts{ + number: 456, + itemID: "123", + }, + }, + { + name: "field-id", + cli: "--field-id FIELD_ID --id 123", + wants: editItemOpts{ + fieldID: "FIELD_ID", + itemID: "123", + }, + }, + { + name: "project-id", + cli: "--project-id PROJECT_ID --id 123", + wants: editItemOpts{ + projectID: "PROJECT_ID", + itemID: "123", + }, + }, + { + name: "text", + cli: "--text t --id 123", + wants: editItemOpts{ + text: "t", + itemID: "123", + }, + }, + { + name: "date", + cli: "--date 2023-01-01 --id 123", + wants: editItemOpts{ + date: "2023-01-01", + itemID: "123", + }, + }, + { + name: "single-select-option-id", + cli: "--single-select-option-id OPTION_ID --id 123", + wants: editItemOpts{ + singleSelectOptionID: "OPTION_ID", + itemID: "123", + }, + }, + { + name: "iteration-id", + cli: "--iteration-id ITERATION_ID --id 123", + wants: editItemOpts{ + iterationID: "ITERATION_ID", + itemID: "123", + }, + }, + { + name: "json", + cli: "--format json --id 123", + wants: editItemOpts{ + format: "json", + itemID: "123", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts editItemOpts + cmd := NewCmdEditItem(f, func(config editItemConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.itemID, gotOpts.itemID) + assert.Equal(t, tt.wants.format, gotOpts.format) + assert.Equal(t, tt.wants.title, gotOpts.title) + assert.Equal(t, tt.wants.fieldID, gotOpts.fieldID) + assert.Equal(t, tt.wants.projectID, gotOpts.projectID) + assert.Equal(t, tt.wants.text, gotOpts.text) + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.date, gotOpts.date) + assert.Equal(t, tt.wants.singleSelectOptionID, gotOpts.singleSelectOptionID) + assert.Equal(t, tt.wants.iterationID, gotOpts.iterationID) + }) + } +} + +func TestRunItemEdit_Draft(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // edit item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation EditDraftIssueItem.*","variables":{"input":{"draftIssueId":"DI_item_id","title":"a title","body":"a new body"}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2DraftIssue": map[string]interface{}{ + "draftIssue": map[string]interface{}{ + "title": "a title", + "body": "a new body", + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + title: "a title", + body: "a new body", + itemID: "DI_item_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Edited draft issue \"a title\"\n", + stdout.String()) +} + +func TestRunItemEdit_Text(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // edit item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project_id","itemId":"item_id","fieldId":"field_id","value":{"text":"item text"}}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2ItemFieldValue": map[string]interface{}{ + "projectV2Item": map[string]interface{}{ + "ID": "item_id", + "content": map[string]interface{}{ + "__typename": "Issue", + "body": "body", + "title": "title", + "number": 1, + "repository": map[string]interface{}{ + "nameWithOwner": "my-repo", + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + text: "item text", + itemID: "item_id", + projectID: "project_id", + fieldID: "field_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal(t, "", stdout.String()) +} + +func TestRunItemEdit_Number(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // edit item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project_id","itemId":"item_id","fieldId":"field_id","value":{"number":2}}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2ItemFieldValue": map[string]interface{}{ + "projectV2Item": map[string]interface{}{ + "ID": "item_id", + "content": map[string]interface{}{ + "__typename": "Issue", + "body": "body", + "title": "title", + "number": 1, + "repository": map[string]interface{}{ + "nameWithOwner": "my-repo", + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + number: 2, + itemID: "item_id", + projectID: "project_id", + fieldID: "field_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Edited item \"title\"\n", + stdout.String()) +} + +func TestRunItemEdit_Date(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // edit item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project_id","itemId":"item_id","fieldId":"field_id","value":{"date":"2023-01-01T00:00:00Z"}}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2ItemFieldValue": map[string]interface{}{ + "projectV2Item": map[string]interface{}{ + "ID": "item_id", + "content": map[string]interface{}{ + "__typename": "Issue", + "body": "body", + "title": "title", + "number": 1, + "repository": map[string]interface{}{ + "nameWithOwner": "my-repo", + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + date: "2023-01-01", + itemID: "item_id", + projectID: "project_id", + fieldID: "field_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal(t, "", stdout.String()) +} + +func TestRunItemEdit_SingleSelect(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // edit item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project_id","itemId":"item_id","fieldId":"field_id","value":{"singleSelectOptionId":"option_id"}}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2ItemFieldValue": map[string]interface{}{ + "projectV2Item": map[string]interface{}{ + "ID": "item_id", + "content": map[string]interface{}{ + "__typename": "Issue", + "body": "body", + "title": "title", + "number": 1, + "repository": map[string]interface{}{ + "nameWithOwner": "my-repo", + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + singleSelectOptionID: "option_id", + itemID: "item_id", + projectID: "project_id", + fieldID: "field_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal(t, "", stdout.String()) +} + +func TestRunItemEdit_Iteration(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // edit item + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project_id","itemId":"item_id","fieldId":"field_id","value":{"iterationId":"option_id"}}}}`). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "updateProjectV2ItemFieldValue": map[string]interface{}{ + "projectV2Item": map[string]interface{}{ + "ID": "item_id", + "content": map[string]interface{}{ + "__typename": "Issue", + "body": "body", + "title": "title", + "number": 1, + "repository": map[string]interface{}{ + "nameWithOwner": "my-repo", + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + iterationID: "option_id", + itemID: "item_id", + projectID: "project_id", + fieldID: "field_id", + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal( + t, + "Edited item \"title\"\n", + stdout.String()) +} + +func TestRunItemEdit_NoChanges(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + client := queries.NewTestClient() + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{}, + client: client, + } + + err := runEditItem(config) + assert.Error(t, err, "SilentError") + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "error: no changes to make\n", stderr.String()) +} + +func TestRunItemEdit_InvalidID(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + client := queries.NewTestClient() + config := editItemConfig{ + opts: editItemOpts{ + title: "a title", + body: "a new body", + itemID: "item_id", + }, + client: client, + } + + err := runEditItem(config) + assert.Error(t, err, "ID must be the ID of the draft issue content which is prefixed with `DI_`") +} diff --git a/pkg/cmd/project/item-list/item_list.go b/pkg/cmd/project/item-list/item_list.go new file mode 100644 index 000000000..4acc95dcb --- /dev/null +++ b/pkg/cmd/project/item-list/item_list.go @@ -0,0 +1,136 @@ +package itemlist + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type listOpts struct { + limit int + owner string + number int32 + format string +} + +type listConfig struct { + io *iostreams.IOStreams + tp *tableprinter.TablePrinter + client *queries.Client + opts listOpts +} + +func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.Command { + opts := listOpts{} + listCmd := &cobra.Command{ + Short: "List the items in a project", + Use: "item-list []", + Example: heredoc.Doc(` + # list the items in the current users's project "1" + gh project item-list 1 --owner "@me" + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + t := tableprinter.New(f.IOStreams) + config := listConfig{ + io: f.IOStreams, + tp: t, + client: client, + opts: opts, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runList(config) + }, + } + + listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + cmdutil.StringEnumFlag(listCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + listCmd.Flags().IntVarP(&opts.limit, "limit", "L", queries.LimitDefault, "Maximum number of items to fetch") + + return listCmd +} + +func runList(config listConfig) error { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + // no need to fetch the project if we already have the number + if config.opts.number == 0 { + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false) + if err != nil { + return err + } + config.opts.number = project.Number + } + + project, err := config.client.ProjectItems(owner, config.opts.number, config.opts.limit) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, project) + } + + return printResults(config, project.Items.Nodes, owner.Login) +} + +func printResults(config listConfig, items []queries.ProjectItem, login string) error { + if len(items) == 0 { + return cmdutil.NewNoResultsError(fmt.Sprintf("Project %d for owner %s has no items", config.opts.number, login)) + } + + config.tp.HeaderRow("Type", "Title", "Number", "Repository", "ID") + + for _, i := range items { + config.tp.AddField(i.Type()) + config.tp.AddField(i.Title()) + if i.Number() == 0 { + config.tp.AddField("") + } else { + config.tp.AddField(strconv.Itoa(i.Number())) + } + config.tp.AddField(i.Repo()) + config.tp.AddField(i.ID(), tableprinter.WithTruncate(nil)) + config.tp.EndRow() + } + + return config.tp.Render() +} + +func printJSON(config listConfig, project *queries.Project) error { + b, err := format.JSONProjectDetailedItems(project) + if err != nil { + return err + } + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/item-list/item_list_test.go b/pkg/cmd/project/item-list/item_list_test.go new file mode 100644 index 000000000..d618451b1 --- /dev/null +++ b/pkg/cmd/project/item-list/item_list_test.go @@ -0,0 +1,402 @@ +package itemlist + +import ( + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants listOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "not-a-number", + cli: "x", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "number", + cli: "123", + wants: listOpts{ + number: 123, + limit: 30, + }, + }, + { + name: "owner", + cli: "--owner monalisa", + wants: listOpts{ + owner: "monalisa", + limit: 30, + }, + }, + { + name: "json", + cli: "--format json", + wants: listOpts{ + format: "json", + limit: 30, + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts listOpts + cmd := NewCmdList(f, func(config listConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.format, gotOpts.format) + assert.Equal(t, tt.wants.limit, gotOpts.limit) + }) + } +} + +func TestRunList_User(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // list project items + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query UserProjectWithItems.*", + "variables": map[string]interface{}{ + "firstItems": queries.LimitDefault, + "afterItems": nil, + "firstFields": queries.LimitMax, + "afterFields": nil, + "login": "monalisa", + "number": 1, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "items": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "issue ID", + "content": map[string]interface{}{ + "__typename": "Issue", + "title": "an issue", + "number": 1, + "repository": map[string]string{ + "nameWithOwner": "cli/go-gh", + }, + }, + }, + { + "id": "pull request ID", + "content": map[string]interface{}{ + "__typename": "PullRequest", + "title": "a pull request", + "number": 2, + "repository": map[string]string{ + "nameWithOwner": "cli/go-gh", + }, + }, + }, + { + "id": "draft issue ID", + "content": map[string]interface{}{ + "title": "draft issue", + "__typename": "DraftIssue", + }, + }, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + number: 1, + owner: "monalisa", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "Issue\tan issue\t1\tcli/go-gh\tissue ID\nPullRequest\ta pull request\t2\tcli/go-gh\tpull request ID\nDraftIssue\tdraft issue\t\t\tdraft issue ID\n", + stdout.String()) +} + +func TestRunList_Org(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + // list project items + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query OrgProjectWithItems.*", + "variables": map[string]interface{}{ + "firstItems": queries.LimitDefault, + "afterItems": nil, + "firstFields": queries.LimitMax, + "afterFields": nil, + "login": "github", + "number": 1, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "items": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "issue ID", + "content": map[string]interface{}{ + "__typename": "Issue", + "title": "an issue", + "number": 1, + "repository": map[string]string{ + "nameWithOwner": "cli/go-gh", + }, + }, + }, + { + "id": "pull request ID", + "content": map[string]interface{}{ + "__typename": "PullRequest", + "title": "a pull request", + "number": 2, + "repository": map[string]string{ + "nameWithOwner": "cli/go-gh", + }, + }, + }, + { + "id": "draft issue ID", + "content": map[string]interface{}{ + "title": "draft issue", + "__typename": "DraftIssue", + }, + }, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + number: 1, + owner: "github", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "Issue\tan issue\t1\tcli/go-gh\tissue ID\nPullRequest\ta pull request\t2\tcli/go-gh\tpull request ID\nDraftIssue\tdraft issue\t\t\tdraft issue ID\n", + stdout.String()) +} + +func TestRunList_Me(t *testing.T) { + defer gock.Off() + // gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + // list project items + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query ViewerProjectWithItems.*", + "variables": map[string]interface{}{ + "firstItems": queries.LimitDefault, + "afterItems": nil, + "firstFields": queries.LimitMax, + "afterFields": nil, + "number": 1, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "items": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "issue ID", + "content": map[string]interface{}{ + "__typename": "Issue", + "title": "an issue", + "number": 1, + "repository": map[string]string{ + "nameWithOwner": "cli/go-gh", + }, + }, + }, + { + "id": "pull request ID", + "content": map[string]interface{}{ + "__typename": "PullRequest", + "title": "a pull request", + "number": 2, + "repository": map[string]string{ + "nameWithOwner": "cli/go-gh", + }, + }, + }, + { + "id": "draft issue ID", + "content": map[string]interface{}{ + "title": "draft issue", + "__typename": "DraftIssue", + }, + }, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + number: 1, + owner: "@me", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "Issue\tan issue\t1\tcli/go-gh\tissue ID\nPullRequest\ta pull request\t2\tcli/go-gh\tpull request ID\nDraftIssue\tdraft issue\t\t\tdraft issue ID\n", + stdout.String()) +} diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go new file mode 100644 index 000000000..661cbcd10 --- /dev/null +++ b/pkg/cmd/project/list/list.go @@ -0,0 +1,186 @@ +package list + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type listOpts struct { + limit int + web bool + owner string + closed bool + format string +} + +type listConfig struct { + tp *tableprinter.TablePrinter + client *queries.Client + opts listOpts + URLOpener func(string) error + io *iostreams.IOStreams +} + +func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.Command { + opts := listOpts{} + listCmd := &cobra.Command{ + Short: "List the projects for an owner", + Use: "list", + Example: heredoc.Doc(` + # list the current user's projects + gh project list + + # list the projects for org github including closed projects + gh project list --owner github --closed + `), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + URLOpener := func(url string) error { + return f.Browser.Browse(url) + } + t := tableprinter.New(f.IOStreams) + config := listConfig{ + tp: t, + client: client, + opts: opts, + URLOpener: URLOpener, + io: f.IOStreams, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runList(config) + }, + } + + listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner") + listCmd.Flags().BoolVarP(&opts.closed, "closed", "", false, "Include closed projects") + listCmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open projects list in the browser") + cmdutil.StringEnumFlag(listCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + listCmd.Flags().IntVarP(&opts.limit, "limit", "L", queries.LimitDefault, "Maximum number of projects to fetch") + + return listCmd +} + +func runList(config listConfig) error { + if config.opts.web { + url, err := buildURL(config) + if err != nil { + return err + } + + if err := config.URLOpener(url); err != nil { + return err + } + return nil + } + + if config.opts.owner == "" { + config.opts.owner = "@me" + } + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + projects, totalCount, err := config.client.Projects(config.opts.owner, owner.Type, config.opts.limit, false) + if err != nil { + return err + } + projects = filterProjects(projects, config) + + if config.opts.format == "json" { + return printJSON(config, projects, totalCount) + } + + return printResults(config, projects, owner.Login) +} + +// TODO: support non-github.com hostnames +func buildURL(config listConfig) (string, error) { + var url string + if config.opts.owner == "@me" || config.opts.owner == "" { + owner, err := config.client.ViewerLoginName() + if err != nil { + return "", err + } + url = fmt.Sprintf("https://github.com/users/%s/projects", owner) + } else { + _, ownerType, err := config.client.OwnerIDAndType(config.opts.owner) + if err != nil { + return "", err + } + + if ownerType == queries.UserOwner { + url = fmt.Sprintf("https://github.com/users/%s/projects", config.opts.owner) + } else { + url = fmt.Sprintf("https://github.com/orgs/%s/projects", config.opts.owner) + } + } + + if config.opts.closed { + return url + "?query=is%3Aclosed", nil + } + return url, nil +} + +func filterProjects(nodes []queries.Project, config listConfig) []queries.Project { + projects := make([]queries.Project, 0, len(nodes)) + for _, p := range nodes { + if !config.opts.closed && p.Closed { + continue + } + projects = append(projects, p) + } + return projects +} + +func printResults(config listConfig, projects []queries.Project, owner string) error { + if len(projects) == 0 { + return cmdutil.NewNoResultsError(fmt.Sprintf("No projects found for %s", owner)) + } + + config.tp.HeaderRow("Number", "Title", "State", "ID") + + for _, p := range projects { + config.tp.AddField(strconv.Itoa(int(p.Number)), tableprinter.WithTruncate(nil)) + config.tp.AddField(p.Title) + var state string + if p.Closed { + state = "closed" + } else { + state = "open" + } + config.tp.AddField(state) + config.tp.AddField(p.ID, tableprinter.WithTruncate(nil)) + config.tp.EndRow() + } + + return config.tp.Render() +} + +func printJSON(config listConfig, projects []queries.Project, totalCount int) error { + b, err := format.JSONProjects(projects, totalCount) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/list/list_test.go b/pkg/cmd/project/list/list_test.go new file mode 100644 index 000000000..48fb689ed --- /dev/null +++ b/pkg/cmd/project/list/list_test.go @@ -0,0 +1,737 @@ +package list + +import ( + "bytes" + "os" + "testing" + + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdlist(t *testing.T) { + tests := []struct { + name string + cli string + wants listOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "owner", + cli: "--owner monalisa", + wants: listOpts{ + owner: "monalisa", + limit: 30, + }, + }, + { + name: "closed", + cli: "--closed", + wants: listOpts{ + closed: true, + limit: 30, + }, + }, + { + name: "web", + cli: "--web", + wants: listOpts{ + web: true, + limit: 30, + }, + }, + { + name: "json", + cli: "--format json", + wants: listOpts{ + format: "json", + limit: 30, + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts listOpts + cmd := NewCmdList(f, func(config listConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.closed, gotOpts.closed) + assert.Equal(t, tt.wants.web, gotOpts.web) + assert.Equal(t, tt.wants.limit, gotOpts.limit) + assert.Equal(t, tt.wants.format, gotOpts.format) + }) + } +} + +func TestRunList(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "login": "monalisa", + "projectsV2": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "title": "Project 1", + "shortDescription": "Short description 1", + "url": "url1", + "closed": false, + "ID": "id-1", + "number": 1, + }, + map[string]interface{}{ + "title": "Project 2", + "shortDescription": "", + "url": "url2", + "closed": true, + "ID": "id-2", + "number": 2, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + owner: "monalisa", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "1\tProject 1\topen\tid-1\n", + stdout.String()) +} + +func TestRunList_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "login": "monalisa", + "projectsV2": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "title": "Project 1", + "shortDescription": "Short description 1", + "url": "url1", + "closed": false, + "ID": "id-1", + "number": 1, + }, + map[string]interface{}{ + "title": "Project 2", + "shortDescription": "", + "url": "url2", + "closed": true, + "ID": "id-2", + "number": 2, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + owner: "@me", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "1\tProject 1\topen\tid-1\n", + stdout.String()) +} + +func TestRunListViewer(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "login": "monalisa", + "projectsV2": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "title": "Project 1", + "shortDescription": "Short description 1", + "url": "url1", + "closed": false, + "ID": "id-1", + "number": 1, + }, + map[string]interface{}{ + "title": "Project 2", + "shortDescription": "", + "url": "url2", + "closed": true, + "ID": "id-2", + "number": 2, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{}, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "1\tProject 1\topen\tid-1\n", + stdout.String()) +} + +func TestRunListOrg(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "login": "monalisa", + "projectsV2": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "title": "Project 1", + "shortDescription": "Short description 1", + "url": "url1", + "closed": false, + "ID": "id-1", + "number": 1, + }, + map[string]interface{}{ + "title": "Project 2", + "shortDescription": "", + "url": "url2", + "closed": true, + "ID": "id-2", + "number": 2, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + owner: "github", + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "1\tProject 1\topen\tid-1\n", + stdout.String()) +} + +func TestRunListEmpty(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query Viewer.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + "login": "theviewer", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "login": "monalisa", + "projectsV2": map[string]interface{}{ + "nodes": []interface{}{}, + }, + }, + }, + }) + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{}, + client: client, + io: ios, + } + + err := runList(config) + assert.EqualError( + t, + err, + "No projects found for @me") +} + +func TestRunListWithClosed(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "login": "monalisa", + "projectsV2": map[string]interface{}{ + "nodes": []interface{}{ + map[string]interface{}{ + "title": "Project 1", + "shortDescription": "Short description 1", + "url": "url1", + "closed": false, + "ID": "id-1", + "number": 1, + }, + map[string]interface{}{ + "title": "Project 2", + "shortDescription": "", + "url": "url2", + "closed": true, + "ID": "id-2", + "number": 2, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + config := listConfig{ + tp: tableprinter.New(ios), + opts: listOpts{ + owner: "monalisa", + closed: true, + }, + client: client, + io: ios, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal( + t, + "1\tProject 1\topen\tid-1\n2\tProject 2\tclosed\tid-2\n", + stdout.String()) +} + +func TestRunListWeb_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + client := queries.NewTestClient() + buf := bytes.Buffer{} + config := listConfig{ + opts: listOpts{ + owner: "monalisa", + web: true, + }, + URLOpener: func(url string) error { + buf.WriteString(url) + return nil + }, + client: client, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/users/monalisa/projects", buf.String()) +} + +func TestRunListWeb_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + client := queries.NewTestClient() + buf := bytes.Buffer{} + config := listConfig{ + opts: listOpts{ + owner: "github", + web: true, + }, + URLOpener: func(url string) error { + buf.WriteString(url) + return nil + }, + client: client, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/orgs/github/projects", buf.String()) +} + +func TestRunListWeb_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query Viewer.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + "login": "theviewer", + }, + }, + }) + + client := queries.NewTestClient() + buf := bytes.Buffer{} + config := listConfig{ + opts: listOpts{ + owner: "@me", + web: true, + }, + URLOpener: func(url string) error { + buf.WriteString(url) + return nil + }, + client: client, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/users/theviewer/projects", buf.String()) +} + +func TestRunListWeb_Empty(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query Viewer.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + "login": "theviewer", + }, + }, + }) + + client := queries.NewTestClient() + buf := bytes.Buffer{} + config := listConfig{ + opts: listOpts{ + web: true, + }, + URLOpener: func(url string) error { + buf.WriteString(url) + return nil + }, + client: client, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/users/theviewer/projects", buf.String()) +} + +func TestRunListWeb_Closed(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query Viewer.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + "login": "theviewer", + }, + }, + }) + + client := queries.NewTestClient() + buf := bytes.Buffer{} + config := listConfig{ + opts: listOpts{ + web: true, + closed: true, + }, + URLOpener: func(url string) error { + buf.WriteString(url) + return nil + }, + client: client, + } + + err := runList(config) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/users/theviewer/projects?query=is%3Aclosed", buf.String()) +} diff --git a/pkg/cmd/project/project.go b/pkg/cmd/project/project.go new file mode 100644 index 000000000..520463f64 --- /dev/null +++ b/pkg/cmd/project/project.go @@ -0,0 +1,61 @@ +package project + +import ( + "github.com/MakeNowJust/heredoc" + cmdClose "github.com/cli/cli/v2/pkg/cmd/project/close" + cmdCopy "github.com/cli/cli/v2/pkg/cmd/project/copy" + cmdCreate "github.com/cli/cli/v2/pkg/cmd/project/create" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/project/delete" + cmdEdit "github.com/cli/cli/v2/pkg/cmd/project/edit" + cmdFieldCreate "github.com/cli/cli/v2/pkg/cmd/project/field-create" + cmdFieldDelete "github.com/cli/cli/v2/pkg/cmd/project/field-delete" + cmdFieldList "github.com/cli/cli/v2/pkg/cmd/project/field-list" + cmdItemAdd "github.com/cli/cli/v2/pkg/cmd/project/item-add" + cmdItemArchive "github.com/cli/cli/v2/pkg/cmd/project/item-archive" + cmdItemCreate "github.com/cli/cli/v2/pkg/cmd/project/item-create" + cmdItemDelete "github.com/cli/cli/v2/pkg/cmd/project/item-delete" + cmdItemEdit "github.com/cli/cli/v2/pkg/cmd/project/item-edit" + cmdItemList "github.com/cli/cli/v2/pkg/cmd/project/item-list" + cmdList "github.com/cli/cli/v2/pkg/cmd/project/list" + cmdView "github.com/cli/cli/v2/pkg/cmd/project/view" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdProject(f *cmdutil.Factory) *cobra.Command { + var cmd = &cobra.Command{ + Use: "project [flags]", + Short: "Work with GitHub Projects.", + Long: "Work with GitHub Projects. Note that the token you are using must have 'project' scope, which is not set by default. You can verify your token scope by running 'gh auth status' and add the project scope by running 'gh auth refresh -s project'.", + Example: heredoc.Doc(` + $ gh project create --owner monalisa --title "Roadmap" + $ gh project view 1 --owner cli --web + $ gh project field-list 1 --owner cli + $ gh project item-list 1 --owner cli + `), + GroupID: "core", + } + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdCopy.NewCmdCopy(f, nil)) + cmd.AddCommand(cmdClose.NewCmdClose(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + cmd.AddCommand(cmdEdit.NewCmdEdit(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) + + // items + cmd.AddCommand(cmdItemList.NewCmdList(f, nil)) + cmd.AddCommand(cmdItemCreate.NewCmdCreateItem(f, nil)) + cmd.AddCommand(cmdItemAdd.NewCmdAddItem(f, nil)) + cmd.AddCommand(cmdItemEdit.NewCmdEditItem(f, nil)) + cmd.AddCommand(cmdItemArchive.NewCmdArchiveItem(f, nil)) + cmd.AddCommand(cmdItemDelete.NewCmdDeleteItem(f, nil)) + + // fields + cmd.AddCommand(cmdFieldList.NewCmdList(f, nil)) + cmd.AddCommand(cmdFieldCreate.NewCmdCreateField(f, nil)) + cmd.AddCommand(cmdFieldDelete.NewCmdDeleteField(f, nil)) + + return cmd +} diff --git a/pkg/cmd/project/shared/client/client.go b/pkg/cmd/project/shared/client/client.go new file mode 100644 index 000000000..1f5835a54 --- /dev/null +++ b/pkg/cmd/project/shared/client/client.go @@ -0,0 +1,22 @@ +package client + +import ( + "os" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" +) + +func New(f *cmdutil.Factory) (*queries.Client, error) { + if f.HttpClient == nil { + // This is for compatibility with tests that exercise Cobra command functionality. + // These tests do not define a `HttpClient` nor do they need to. + return nil, nil + } + + httpClient, err := f.HttpClient() + if err != nil { + return nil, err + } + return queries.NewClient(httpClient, os.Getenv("GH_HOST"), f.IOStreams), nil +} diff --git a/pkg/cmd/project/shared/format/json.go b/pkg/cmd/project/shared/format/json.go new file mode 100644 index 000000000..175485314 --- /dev/null +++ b/pkg/cmd/project/shared/format/json.go @@ -0,0 +1,365 @@ +package format + +import ( + "encoding/json" + "strings" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" +) + +// JSONProject serializes a Project to JSON. +func JSONProject(project queries.Project) ([]byte, error) { + return json.Marshal(projectJSON{ + Number: project.Number, + URL: project.URL, + ShortDescription: project.ShortDescription, + Public: project.Public, + Closed: project.Closed, + Title: project.Title, + ID: project.ID, + Readme: project.Readme, + Items: struct { + TotalCount int `json:"totalCount"` + }{ + TotalCount: project.Items.TotalCount, + }, + Fields: struct { + TotalCount int `json:"totalCount"` + }{ + TotalCount: project.Fields.TotalCount, + }, + Owner: struct { + Type string `json:"type"` + Login string `json:"login"` + }{ + Type: project.OwnerType(), + Login: project.OwnerLogin(), + }, + }) +} + +// JSONProjects serializes a slice of Projects to JSON. +// JSON fields are `totalCount` and `projects`. +func JSONProjects(projects []queries.Project, totalCount int) ([]byte, error) { + var result []projectJSON + for _, p := range projects { + result = append(result, projectJSON{ + Number: p.Number, + URL: p.URL, + ShortDescription: p.ShortDescription, + Public: p.Public, + Closed: p.Closed, + Title: p.Title, + ID: p.ID, + Readme: p.Readme, + Items: struct { + TotalCount int `json:"totalCount"` + }{ + TotalCount: p.Items.TotalCount, + }, + Fields: struct { + TotalCount int `json:"totalCount"` + }{ + TotalCount: p.Fields.TotalCount, + }, + Owner: struct { + Type string `json:"type"` + Login string `json:"login"` + }{ + Type: p.OwnerType(), + Login: p.OwnerLogin(), + }, + }) + } + + return json.Marshal(struct { + Projects []projectJSON `json:"projects"` + TotalCount int `json:"totalCount"` + }{ + Projects: result, + TotalCount: totalCount, + }) +} + +type projectJSON struct { + Number int32 `json:"number"` + URL string `json:"url"` + ShortDescription string `json:"shortDescription"` + Public bool `json:"public"` + Closed bool `json:"closed"` + Title string `json:"title"` + ID string `json:"id"` + Readme string `json:"readme"` + Items struct { + TotalCount int `json:"totalCount"` + } `graphql:"items(first: 100)" json:"items"` + Fields struct { + TotalCount int `json:"totalCount"` + } `graphql:"fields(first:100)" json:"fields"` + Owner struct { + Type string `json:"type"` + Login string `json:"login"` + } `json:"owner"` +} + +// JSONProjectField serializes a ProjectField to JSON. +func JSONProjectField(field queries.ProjectField) ([]byte, error) { + val := projectFieldJSON{ + ID: field.ID(), + Name: field.Name(), + Type: field.Type(), + } + for _, o := range field.Options() { + val.Options = append(val.Options, singleSelectOptionJSON{ + Name: o.Name, + ID: o.ID, + }) + } + + return json.Marshal(val) +} + +// JSONProjectFields serializes a slice of ProjectFields to JSON. +// JSON fields are `totalCount` and `fields`. +func JSONProjectFields(project *queries.Project) ([]byte, error) { + var result []projectFieldJSON + for _, f := range project.Fields.Nodes { + val := projectFieldJSON{ + ID: f.ID(), + Name: f.Name(), + Type: f.Type(), + } + for _, o := range f.Options() { + val.Options = append(val.Options, singleSelectOptionJSON{ + Name: o.Name, + ID: o.ID, + }) + } + + result = append(result, val) + } + + return json.Marshal(struct { + Fields []projectFieldJSON `json:"fields"` + TotalCount int `json:"totalCount"` + }{ + Fields: result, + TotalCount: project.Fields.TotalCount, + }) +} + +type projectFieldJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Options []singleSelectOptionJSON `json:"options,omitempty"` +} + +type singleSelectOptionJSON struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// JSONProjectItem serializes a ProjectItem to JSON. +func JSONProjectItem(item queries.ProjectItem) ([]byte, error) { + return json.Marshal(projectItemJSON{ + ID: item.ID(), + Title: item.Title(), + Body: item.Body(), + Type: item.Type(), + URL: item.URL(), + }) +} + +type projectItemJSON struct { + ID string `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Type string `json:"type"` + URL string `json:"url,omitempty"` +} + +// JSONProjectDraftIssue serializes a DraftIssue to JSON. +// This is needed because the field for +// https://docs.github.com/en/graphql/reference/mutations#updateprojectv2draftissue +// is a DraftIssue https://docs.github.com/en/graphql/reference/objects#draftissue +// and not a ProjectV2Item https://docs.github.com/en/graphql/reference/objects#projectv2item +func JSONProjectDraftIssue(item queries.DraftIssue) ([]byte, error) { + + return json.Marshal(draftIssueJSON{ + ID: item.ID, + Title: item.Title, + Body: item.Body, + Type: "DraftIssue", + }) +} + +type draftIssueJSON struct { + ID string `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Type string `json:"type"` +} + +func projectItemContent(p queries.ProjectItem) any { + switch p.Content.TypeName { + case "DraftIssue": + return struct { + Type string `json:"type"` + Body string `json:"body"` + Title string `json:"title"` + }{ + Type: p.Type(), + Body: p.Body(), + Title: p.Title(), + } + case "Issue": + return struct { + Type string `json:"type"` + Body string `json:"body"` + Title string `json:"title"` + Number int `json:"number"` + Repository string `json:"repository"` + URL string `json:"url"` + }{ + Type: p.Type(), + Body: p.Body(), + Title: p.Title(), + Number: p.Number(), + Repository: p.Repo(), + URL: p.URL(), + } + case "PullRequest": + return struct { + Type string `json:"type"` + Body string `json:"body"` + Title string `json:"title"` + Number int `json:"number"` + Repository string `json:"repository"` + URL string `json:"url"` + }{ + Type: p.Type(), + Body: p.Body(), + Title: p.Title(), + Number: p.Number(), + Repository: p.Repo(), + URL: p.URL(), + } + } + + return nil +} + +func projectFieldValueData(v queries.FieldValueNodes) any { + switch v.Type { + case "ProjectV2ItemFieldDateValue": + return v.ProjectV2ItemFieldDateValue.Date + case "ProjectV2ItemFieldIterationValue": + return struct { + StartDate string `json:"startDate"` + Duration int `json:"duration"` + }{ + StartDate: v.ProjectV2ItemFieldIterationValue.StartDate, + Duration: v.ProjectV2ItemFieldIterationValue.Duration, + } + case "ProjectV2ItemFieldNumberValue": + return v.ProjectV2ItemFieldNumberValue.Number + case "ProjectV2ItemFieldSingleSelectValue": + return v.ProjectV2ItemFieldSingleSelectValue.Name + case "ProjectV2ItemFieldTextValue": + return v.ProjectV2ItemFieldTextValue.Text + case "ProjectV2ItemFieldMilestoneValue": + return struct { + Description string `json:"description"` + DueOn string `json:"dueOn"` + }{ + Description: v.ProjectV2ItemFieldMilestoneValue.Milestone.Description, + DueOn: v.ProjectV2ItemFieldMilestoneValue.Milestone.DueOn, + } + case "ProjectV2ItemFieldLabelValue": + names := make([]string, 0) + for _, p := range v.ProjectV2ItemFieldLabelValue.Labels.Nodes { + names = append(names, p.Name) + } + return names + case "ProjectV2ItemFieldPullRequestValue": + urls := make([]string, 0) + for _, p := range v.ProjectV2ItemFieldPullRequestValue.PullRequests.Nodes { + urls = append(urls, p.Url) + } + return urls + case "ProjectV2ItemFieldRepositoryValue": + return v.ProjectV2ItemFieldRepositoryValue.Repository.Url + case "ProjectV2ItemFieldUserValue": + logins := make([]string, 0) + for _, p := range v.ProjectV2ItemFieldUserValue.Users.Nodes { + logins = append(logins, p.Login) + } + return logins + case "ProjectV2ItemFieldReviewerValue": + names := make([]string, 0) + for _, p := range v.ProjectV2ItemFieldReviewerValue.Reviewers.Nodes { + if p.Type == "Team" { + names = append(names, p.Team.Name) + } else if p.Type == "User" { + names = append(names, p.User.Login) + } + } + return names + + } + + return nil +} + +// serialize creates a map from field to field values +func serializeProjectWithItems(project *queries.Project) []map[string]any { + fields := make(map[string]string) + + // make a map of fields by ID + for _, f := range project.Fields.Nodes { + fields[f.ID()] = CamelCase(f.Name()) + } + itemsSlice := make([]map[string]any, 0) + + // for each value, look up the name by ID + // and set the value to the field value + for _, i := range project.Items.Nodes { + o := make(map[string]any) + o["id"] = i.Id + o["content"] = projectItemContent(i) + for _, v := range i.FieldValues.Nodes { + id := v.ID() + value := projectFieldValueData(v) + + o[fields[id]] = value + } + itemsSlice = append(itemsSlice, o) + } + return itemsSlice +} + +// JSONProjectWithItems returns a detailed JSON representation of project items. +// JSON fields are `totalCount` and `items`. +func JSONProjectDetailedItems(project *queries.Project) ([]byte, error) { + items := serializeProjectWithItems(project) + return json.Marshal(struct { + Items []map[string]any `json:"items"` + TotalCount int `json:"totalCount"` + }{ + Items: items, + TotalCount: project.Items.TotalCount, + }) +} + +// CamelCase converts a string to camelCase, which is useful for turning Go field names to JSON keys. +func CamelCase(s string) string { + if len(s) == 0 { + return "" + } + + if len(s) == 1 { + return strings.ToLower(s) + } + return strings.ToLower(s[0:1]) + s[1:] +} diff --git a/pkg/cmd/project/shared/format/json_test.go b/pkg/cmd/project/shared/format/json_test.go new file mode 100644 index 000000000..31bdcccc6 --- /dev/null +++ b/pkg/cmd/project/shared/format/json_test.go @@ -0,0 +1,287 @@ +package format + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + + "github.com/stretchr/testify/assert" +) + +func TestJSONProject_User(t *testing.T) { + project := queries.Project{ + ID: "123", + Number: 2, + URL: "a url", + ShortDescription: "short description", + Public: true, + Readme: "readme", + } + + project.Items.TotalCount = 1 + project.Fields.TotalCount = 2 + project.Owner.TypeName = "User" + project.Owner.User.Login = "monalisa" + b, err := JSONProject(project) + assert.NoError(t, err) + + assert.Equal(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}}`, string(b)) +} + +func TestJSONProject_Org(t *testing.T) { + project := queries.Project{ + ID: "123", + Number: 2, + URL: "a url", + ShortDescription: "short description", + Public: true, + Readme: "readme", + } + + project.Items.TotalCount = 1 + project.Fields.TotalCount = 2 + project.Owner.TypeName = "Organization" + project.Owner.Organization.Login = "github" + b, err := JSONProject(project) + assert.NoError(t, err) + + assert.Equal(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}`, string(b)) +} + +func TestJSONProjects(t *testing.T) { + userProject := queries.Project{ + ID: "123", + Number: 2, + URL: "a url", + ShortDescription: "short description", + Public: true, + Readme: "readme", + } + + userProject.Items.TotalCount = 1 + userProject.Fields.TotalCount = 2 + userProject.Owner.TypeName = "User" + userProject.Owner.User.Login = "monalisa" + + orgProject := queries.Project{ + ID: "123", + Number: 2, + URL: "a url", + ShortDescription: "short description", + Public: true, + Readme: "readme", + } + + orgProject.Items.TotalCount = 1 + orgProject.Fields.TotalCount = 2 + orgProject.Owner.TypeName = "Organization" + orgProject.Owner.Organization.Login = "github" + b, err := JSONProjects([]queries.Project{userProject, orgProject}, 2) + assert.NoError(t, err) + + assert.Equal( + t, + `{"projects":[{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}},{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}],"totalCount":2}`, + string(b)) +} + +func TestJSONProjectField_FieldType(t *testing.T) { + field := queries.ProjectField{} + field.TypeName = "ProjectV2Field" + field.Field.ID = "123" + field.Field.Name = "name" + + b, err := JSONProjectField(field) + assert.NoError(t, err) + + assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2Field"}`, string(b)) +} + +func TestJSONProjectField_SingleSelectType(t *testing.T) { + field := queries.ProjectField{} + field.TypeName = "ProjectV2SingleSelectField" + field.SingleSelectField.ID = "123" + field.SingleSelectField.Name = "name" + field.SingleSelectField.Options = []queries.SingleSelectFieldOptions{ + { + ID: "123", + Name: "name", + }, + { + ID: "456", + Name: "name2", + }, + } + + b, err := JSONProjectField(field) + assert.NoError(t, err) + + assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2SingleSelectField","options":[{"id":"123","name":"name"},{"id":"456","name":"name2"}]}`, string(b)) +} + +func TestJSONProjectField_ProjectV2IterationField(t *testing.T) { + field := queries.ProjectField{} + field.TypeName = "ProjectV2IterationField" + field.IterationField.ID = "123" + field.IterationField.Name = "name" + + b, err := JSONProjectField(field) + assert.NoError(t, err) + + assert.Equal(t, `{"id":"123","name":"name","type":"ProjectV2IterationField"}`, string(b)) +} + +func TestJSONProjectFields(t *testing.T) { + field := queries.ProjectField{} + field.TypeName = "ProjectV2Field" + field.Field.ID = "123" + field.Field.Name = "name" + + field2 := queries.ProjectField{} + field2.TypeName = "ProjectV2SingleSelectField" + field2.SingleSelectField.ID = "123" + field2.SingleSelectField.Name = "name" + field2.SingleSelectField.Options = []queries.SingleSelectFieldOptions{ + { + ID: "123", + Name: "name", + }, + { + ID: "456", + Name: "name2", + }, + } + + p := &queries.Project{ + Fields: struct { + TotalCount int + Nodes []queries.ProjectField + PageInfo queries.PageInfo + }{ + Nodes: []queries.ProjectField{field, field2}, + TotalCount: 5, + }, + } + b, err := JSONProjectFields(p) + assert.NoError(t, err) + + assert.Equal(t, `{"fields":[{"id":"123","name":"name","type":"ProjectV2Field"},{"id":"123","name":"name","type":"ProjectV2SingleSelectField","options":[{"id":"123","name":"name"},{"id":"456","name":"name2"}]}],"totalCount":5}`, string(b)) +} + +func TestJSONProjectItem_DraftIssue(t *testing.T) { + item := queries.ProjectItem{} + item.Content.TypeName = "DraftIssue" + item.Id = "123" + item.Content.DraftIssue.Title = "title" + item.Content.DraftIssue.Body = "a body" + + b, err := JSONProjectItem(item) + assert.NoError(t, err) + + assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b)) +} + +func TestJSONProjectItem_Issue(t *testing.T) { + item := queries.ProjectItem{} + item.Content.TypeName = "Issue" + item.Id = "123" + item.Content.Issue.Title = "title" + item.Content.Issue.Body = "a body" + item.Content.Issue.URL = "a-url" + + b, err := JSONProjectItem(item) + assert.NoError(t, err) + + assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"Issue","url":"a-url"}`, string(b)) +} + +func TestJSONProjectItem_PullRequest(t *testing.T) { + item := queries.ProjectItem{} + item.Content.TypeName = "PullRequest" + item.Id = "123" + item.Content.PullRequest.Title = "title" + item.Content.PullRequest.Body = "a body" + item.Content.PullRequest.URL = "a-url" + + b, err := JSONProjectItem(item) + assert.NoError(t, err) + + assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"PullRequest","url":"a-url"}`, string(b)) +} + +func TestJSONProjectDetailedItems(t *testing.T) { + p := &queries.Project{} + p.Items.TotalCount = 5 + p.Items.Nodes = []queries.ProjectItem{ + { + Id: "issueId", + Content: queries.ProjectItemContent{ + TypeName: "Issue", + Issue: queries.Issue{ + Title: "Issue title", + Body: "a body", + Number: 1, + URL: "issue-url", + Repository: struct { + NameWithOwner string + }{ + NameWithOwner: "cli/go-gh", + }, + }, + }, + }, + { + Id: "pullRequestId", + Content: queries.ProjectItemContent{ + TypeName: "PullRequest", + PullRequest: queries.PullRequest{ + Title: "Pull Request title", + Body: "a body", + Number: 2, + URL: "pr-url", + Repository: struct { + NameWithOwner string + }{ + NameWithOwner: "cli/go-gh", + }, + }, + }, + }, + { + Id: "draftIssueId", + Content: queries.ProjectItemContent{ + TypeName: "DraftIssue", + DraftIssue: queries.DraftIssue{ + Title: "Pull Request title", + Body: "a body", + }, + }, + }, + } + + out, err := JSONProjectDetailedItems(p) + assert.NoError(t, err) + assert.Equal( + t, + `{"items":[{"content":{"type":"Issue","body":"a body","title":"Issue title","number":1,"repository":"cli/go-gh","url":"issue-url"},"id":"issueId"},{"content":{"type":"PullRequest","body":"a body","title":"Pull Request title","number":2,"repository":"cli/go-gh","url":"pr-url"},"id":"pullRequestId"},{"content":{"type":"DraftIssue","body":"a body","title":"Pull Request title"},"id":"draftIssueId"}],"totalCount":5}`, + string(out)) +} + +func TestJSONProjectDraftIssue(t *testing.T) { + item := queries.DraftIssue{} + item.ID = "123" + item.Title = "title" + item.Body = "a body" + + b, err := JSONProjectDraftIssue(item) + assert.NoError(t, err) + + assert.Equal(t, `{"id":"123","title":"title","body":"a body","type":"DraftIssue"}`, string(b)) +} + +func TestCamelCase(t *testing.T) { + assert.Equal(t, "camelCase", CamelCase("camelCase")) + assert.Equal(t, "camelCase", CamelCase("CamelCase")) + assert.Equal(t, "c", CamelCase("C")) + assert.Equal(t, "", CamelCase("")) +} diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go new file mode 100644 index 000000000..d58dd1204 --- /dev/null +++ b/pkg/cmd/project/shared/queries/queries.go @@ -0,0 +1,1212 @@ +package queries + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/briandowns/spinner" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/set" + "github.com/shurcooL/githubv4" +) + +func NewClient(httpClient *http.Client, hostname string, ios *iostreams.IOStreams) *Client { + apiClient := &hostScopedClient{ + hostname: hostname, + Client: api.NewClientFromHTTP(httpClient), + } + return &Client{ + apiClient: apiClient, + spinner: ios.IsStdoutTTY() && ios.IsStderrTTY(), + prompter: prompter.New("", ios.In, ios.Out, ios.ErrOut), + } +} + +func NewTestClient() *Client { + apiClient := &hostScopedClient{ + hostname: "github.com", + Client: api.NewClientFromHTTP(http.DefaultClient), + } + return &Client{ + apiClient: apiClient, + spinner: false, + prompter: nil, + } +} + +type iprompter interface { + Select(string, string, []string) (int, error) +} + +type hostScopedClient struct { + *api.Client + hostname string +} + +func (c *hostScopedClient) Query(queryName string, query interface{}, variables map[string]interface{}) error { + return c.Client.Query(c.hostname, queryName, query, variables) +} + +func (c *hostScopedClient) Mutate(queryName string, query interface{}, variables map[string]interface{}) error { + return c.Client.Mutate(c.hostname, queryName, query, variables) +} + +type graphqlClient interface { + Query(queryName string, query interface{}, variables map[string]interface{}) error + Mutate(queryName string, query interface{}, variables map[string]interface{}) error +} + +type Client struct { + apiClient graphqlClient + spinner bool + prompter iprompter +} + +const ( + LimitDefault = 30 + LimitMax = 100 // https://docs.github.com/en/graphql/overview/resource-limitations#node-limit +) + +// doQuery wraps API calls with a visual spinner +func (c *Client) doQuery(name string, query interface{}, variables map[string]interface{}) error { + var sp *spinner.Spinner + if c.spinner { + // https://github.com/briandowns/spinner#available-character-sets + dotStyle := spinner.CharSets[11] + sp = spinner.New(dotStyle, 120*time.Millisecond, spinner.WithColor("fgCyan")) + sp.Start() + } + err := c.apiClient.Query(name, query, variables) + if sp != nil { + sp.Stop() + } + return handleError(err) +} + +// TODO: un-export this since it couples the caller heavily to api.GraphQLClient +func (c *Client) Mutate(operationName string, query interface{}, variables map[string]interface{}) error { + err := c.apiClient.Mutate(operationName, query, variables) + return handleError(err) +} + +// PageInfo is a PageInfo GraphQL object https://docs.github.com/en/graphql/reference/objects#pageinfo. +type PageInfo struct { + EndCursor githubv4.String + HasNextPage bool +} + +// Project is a ProjectV2 GraphQL object https://docs.github.com/en/graphql/reference/objects#projectv2. +type Project struct { + Number int32 + URL string + ShortDescription string + Public bool + Closed bool + Title string + ID string + Readme string + Items struct { + PageInfo PageInfo + TotalCount int + Nodes []ProjectItem + } `graphql:"items(first: $firstItems, after: $afterItems)"` + Fields struct { + TotalCount int + Nodes []ProjectField + PageInfo PageInfo + } `graphql:"fields(first: $firstFields, after: $afterFields)"` + Owner struct { + TypeName string `graphql:"__typename"` + User struct { + Login string + } `graphql:"... on User"` + Organization struct { + Login string + } `graphql:"... on Organization"` + } +} + +func (p Project) OwnerType() string { + return p.Owner.TypeName +} + +func (p Project) OwnerLogin() string { + if p.OwnerType() == "User" { + return p.Owner.User.Login + } + return p.Owner.Organization.Login +} + +// ProjectItem is a ProjectV2Item GraphQL object https://docs.github.com/en/graphql/reference/objects#projectv2item. +type ProjectItem struct { + Content ProjectItemContent + Id string + FieldValues struct { + Nodes []FieldValueNodes + } `graphql:"fieldValues(first: 100)"` // hardcoded to 100 for now on the assumption that this is a reasonable limit +} + +type ProjectItemContent struct { + TypeName string `graphql:"__typename"` + DraftIssue DraftIssue `graphql:"... on DraftIssue"` + PullRequest PullRequest `graphql:"... on PullRequest"` + Issue Issue `graphql:"... on Issue"` +} + +type FieldValueNodes struct { + Type string `graphql:"__typename"` + ProjectV2ItemFieldDateValue struct { + Date string + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldDateValue"` + ProjectV2ItemFieldIterationValue struct { + StartDate string + Duration int + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldIterationValue"` + ProjectV2ItemFieldLabelValue struct { + Labels struct { + Nodes []struct { + Name string + } + } `graphql:"labels(first: 10)"` // experienced issues with larger limits, 10 seems like enough for now + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldLabelValue"` + ProjectV2ItemFieldNumberValue struct { + Number float32 + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldNumberValue"` + ProjectV2ItemFieldSingleSelectValue struct { + Name string + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldSingleSelectValue"` + ProjectV2ItemFieldTextValue struct { + Text string + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldTextValue"` + ProjectV2ItemFieldMilestoneValue struct { + Milestone struct { + Description string + DueOn string + } + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldMilestoneValue"` + ProjectV2ItemFieldPullRequestValue struct { + PullRequests struct { + Nodes []struct { + Url string + } + } `graphql:"pullRequests(first:10)"` // experienced issues with larger limits, 10 seems like enough for now + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldPullRequestValue"` + ProjectV2ItemFieldRepositoryValue struct { + Repository struct { + Url string + } + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldRepositoryValue"` + ProjectV2ItemFieldUserValue struct { + Users struct { + Nodes []struct { + Login string + } + } `graphql:"users(first: 10)"` // experienced issues with larger limits, 10 seems like enough for now + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldUserValue"` + ProjectV2ItemFieldReviewerValue struct { + Reviewers struct { + Nodes []struct { + Type string `graphql:"__typename"` + Team struct { + Name string + } `graphql:"... on Team"` + User struct { + Login string + } `graphql:"... on User"` + } + } `graphql:"reviewers(first: 10)"` // experienced issues with larger limits, 10 seems like enough for now + Field ProjectField + } `graphql:"... on ProjectV2ItemFieldReviewerValue"` +} + +func (v FieldValueNodes) ID() string { + switch v.Type { + case "ProjectV2ItemFieldDateValue": + return v.ProjectV2ItemFieldDateValue.Field.ID() + case "ProjectV2ItemFieldIterationValue": + return v.ProjectV2ItemFieldIterationValue.Field.ID() + case "ProjectV2ItemFieldNumberValue": + return v.ProjectV2ItemFieldNumberValue.Field.ID() + case "ProjectV2ItemFieldSingleSelectValue": + return v.ProjectV2ItemFieldSingleSelectValue.Field.ID() + case "ProjectV2ItemFieldTextValue": + return v.ProjectV2ItemFieldTextValue.Field.ID() + case "ProjectV2ItemFieldMilestoneValue": + return v.ProjectV2ItemFieldMilestoneValue.Field.ID() + case "ProjectV2ItemFieldLabelValue": + return v.ProjectV2ItemFieldLabelValue.Field.ID() + case "ProjectV2ItemFieldPullRequestValue": + return v.ProjectV2ItemFieldPullRequestValue.Field.ID() + case "ProjectV2ItemFieldRepositoryValue": + return v.ProjectV2ItemFieldRepositoryValue.Field.ID() + case "ProjectV2ItemFieldUserValue": + return v.ProjectV2ItemFieldUserValue.Field.ID() + case "ProjectV2ItemFieldReviewerValue": + return v.ProjectV2ItemFieldReviewerValue.Field.ID() + } + + return "" +} + +type DraftIssue struct { + ID string + Body string + Title string +} + +type PullRequest struct { + Body string + Title string + Number int + URL string + Repository struct { + NameWithOwner string + } +} + +type Issue struct { + Body string + Title string + Number int + URL string + Repository struct { + NameWithOwner string + } +} + +// Type is the underlying type of the project item. +func (p ProjectItem) Type() string { + return p.Content.TypeName +} + +// Title is the title of the project item. +func (p ProjectItem) Title() string { + switch p.Content.TypeName { + case "Issue": + return p.Content.Issue.Title + case "PullRequest": + return p.Content.PullRequest.Title + case "DraftIssue": + return p.Content.DraftIssue.Title + } + return "" +} + +// Body is the body of the project item. +func (p ProjectItem) Body() string { + switch p.Content.TypeName { + case "Issue": + return p.Content.Issue.Body + case "PullRequest": + return p.Content.PullRequest.Body + case "DraftIssue": + return p.Content.DraftIssue.Body + } + return "" +} + +// Number is the number of the project item. It is only valid for issues and pull requests. +func (p ProjectItem) Number() int { + switch p.Content.TypeName { + case "Issue": + return p.Content.Issue.Number + case "PullRequest": + return p.Content.PullRequest.Number + } + + return 0 +} + +// ID is the id of the ProjectItem. +func (p ProjectItem) ID() string { + return p.Id +} + +// Repo is the repository of the project item. It is only valid for issues and pull requests. +func (p ProjectItem) Repo() string { + switch p.Content.TypeName { + case "Issue": + return p.Content.Issue.Repository.NameWithOwner + case "PullRequest": + return p.Content.PullRequest.Repository.NameWithOwner + } + return "" +} + +// URL is the URL of the project item. Note the draft issues do not have URLs +func (p ProjectItem) URL() string { + switch p.Content.TypeName { + case "Issue": + return p.Content.Issue.URL + case "PullRequest": + return p.Content.PullRequest.URL + } + return "" +} + +// ProjectItems returns the items of a project. If the OwnerType is VIEWER, no login is required. +// If limit is 0, the default limit is used. +func (c *Client) ProjectItems(o *Owner, number int32, limit int) (*Project, error) { + project := &Project{} + if limit == 0 { + limit = LimitDefault + } + + // set first to the min of limit and LimitMax + first := LimitMax + if limit < first { + first = limit + } + + variables := map[string]interface{}{ + "firstItems": githubv4.Int(first), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(LimitMax), + "afterFields": (*githubv4.String)(nil), + "number": githubv4.Int(number), + } + + var query pager[ProjectItem] + var queryName string + switch o.Type { + case UserOwner: + variables["login"] = githubv4.String(o.Login) + query = &userOwnerWithItems{} // must be a pointer to work with graphql queries + queryName = "UserProjectWithItems" + case OrgOwner: + variables["login"] = githubv4.String(o.Login) + query = &orgOwnerWithItems{} // must be a pointer to work with graphql queries + queryName = "OrgProjectWithItems" + case ViewerOwner: + query = &viewerOwnerWithItems{} // must be a pointer to work with graphql queries + queryName = "ViewerProjectWithItems" + } + err := c.doQuery(queryName, query, variables) + if err != nil { + return project, err + } + project = query.Project() + + items, err := paginateAttributes(c, query, variables, queryName, "firstItems", "afterItems", limit, query.Nodes()) + if err != nil { + return project, err + } + + project.Items.Nodes = items + return project, nil +} + +// pager is an interface for paginating over the attributes of a Project. +type pager[N projectAttribute] interface { + HasNextPage() bool + EndCursor() string + Nodes() []N + Project() *Project +} + +// userOwnerWithItems +func (q userOwnerWithItems) HasNextPage() bool { + return q.Owner.Project.Items.PageInfo.HasNextPage +} + +func (q userOwnerWithItems) EndCursor() string { + return string(q.Owner.Project.Items.PageInfo.EndCursor) +} + +func (q userOwnerWithItems) Nodes() []ProjectItem { + return q.Owner.Project.Items.Nodes +} + +func (q userOwnerWithItems) Project() *Project { + return &q.Owner.Project +} + +// orgOwnerWithItems +func (q orgOwnerWithItems) HasNextPage() bool { + return q.Owner.Project.Items.PageInfo.HasNextPage +} + +func (q orgOwnerWithItems) EndCursor() string { + return string(q.Owner.Project.Items.PageInfo.EndCursor) +} + +func (q orgOwnerWithItems) Nodes() []ProjectItem { + return q.Owner.Project.Items.Nodes +} + +func (q orgOwnerWithItems) Project() *Project { + return &q.Owner.Project +} + +// viewerOwnerWithItems +func (q viewerOwnerWithItems) HasNextPage() bool { + return q.Owner.Project.Items.PageInfo.HasNextPage +} + +func (q viewerOwnerWithItems) EndCursor() string { + return string(q.Owner.Project.Items.PageInfo.EndCursor) +} + +func (q viewerOwnerWithItems) Nodes() []ProjectItem { + return q.Owner.Project.Items.Nodes +} + +func (q viewerOwnerWithItems) Project() *Project { + return &q.Owner.Project +} + +// userOwnerWithFields +func (q userOwnerWithFields) HasNextPage() bool { + return q.Owner.Project.Fields.PageInfo.HasNextPage +} + +func (q userOwnerWithFields) EndCursor() string { + return string(q.Owner.Project.Fields.PageInfo.EndCursor) +} + +func (q userOwnerWithFields) Nodes() []ProjectField { + return q.Owner.Project.Fields.Nodes +} + +func (q userOwnerWithFields) Project() *Project { + return &q.Owner.Project +} + +// orgOwnerWithFields +func (q orgOwnerWithFields) HasNextPage() bool { + return q.Owner.Project.Fields.PageInfo.HasNextPage +} + +func (q orgOwnerWithFields) EndCursor() string { + return string(q.Owner.Project.Fields.PageInfo.EndCursor) +} + +func (q orgOwnerWithFields) Nodes() []ProjectField { + return q.Owner.Project.Fields.Nodes +} + +func (q orgOwnerWithFields) Project() *Project { + return &q.Owner.Project +} + +// viewerOwnerWithFields +func (q viewerOwnerWithFields) HasNextPage() bool { + return q.Owner.Project.Fields.PageInfo.HasNextPage +} + +func (q viewerOwnerWithFields) EndCursor() string { + return string(q.Owner.Project.Fields.PageInfo.EndCursor) +} + +func (q viewerOwnerWithFields) Nodes() []ProjectField { + return q.Owner.Project.Fields.Nodes +} + +func (q viewerOwnerWithFields) Project() *Project { + return &q.Owner.Project +} + +type projectAttribute interface { + ProjectItem | ProjectField +} + +// paginateAttributes is for paginating over the attributes of a project, such as items or fields +// +// firstKey and afterKey are the keys in the variables map that are used to set the first and after +// as these are set independently based on the attribute type, such as item or field. +// +// limit is the maximum number of attributes to return, or 0 for no limit. +// +// nodes is the list of attributes that have already been fetched. +// +// the return value is a slice of the newly fetched attributes appended to nodes. +func paginateAttributes[N projectAttribute](c *Client, p pager[N], variables map[string]any, queryName string, firstKey string, afterKey string, limit int, nodes []N) ([]N, error) { + hasNextPage := p.HasNextPage() + cursor := p.EndCursor() + for { + if !hasNextPage || len(nodes) >= limit { + return nodes, nil + } + + if len(nodes)+LimitMax > limit { + first := limit - len(nodes) + variables[firstKey] = githubv4.Int(first) + } + + // set the cursor to the end of the last page + variables[afterKey] = (*githubv4.String)(&cursor) + err := c.doQuery(queryName, p, variables) + if err != nil { + return nodes, err + } + + nodes = append(nodes, p.Nodes()...) + hasNextPage = p.HasNextPage() + cursor = p.EndCursor() + } +} + +// ProjectField is a ProjectV2FieldConfiguration GraphQL object https://docs.github.com/en/graphql/reference/unions#projectv2fieldconfiguration. +type ProjectField struct { + TypeName string `graphql:"__typename"` + Field struct { + ID string + Name string + DataType string + } `graphql:"... on ProjectV2Field"` + IterationField struct { + ID string + Name string + DataType string + } `graphql:"... on ProjectV2IterationField"` + SingleSelectField struct { + ID string + Name string + DataType string + Options []SingleSelectFieldOptions + } `graphql:"... on ProjectV2SingleSelectField"` +} + +// ID is the ID of the project field. +func (p ProjectField) ID() string { + if p.TypeName == "ProjectV2Field" { + return p.Field.ID + } else if p.TypeName == "ProjectV2IterationField" { + return p.IterationField.ID + } else if p.TypeName == "ProjectV2SingleSelectField" { + return p.SingleSelectField.ID + } + return "" +} + +// Name is the name of the project field. +func (p ProjectField) Name() string { + if p.TypeName == "ProjectV2Field" { + return p.Field.Name + } else if p.TypeName == "ProjectV2IterationField" { + return p.IterationField.Name + } else if p.TypeName == "ProjectV2SingleSelectField" { + return p.SingleSelectField.Name + } + return "" +} + +// Type is the typename of the project field. +func (p ProjectField) Type() string { + return p.TypeName +} + +type SingleSelectFieldOptions struct { + ID string + Name string +} + +func (p ProjectField) Options() []SingleSelectFieldOptions { + if p.TypeName == "ProjectV2SingleSelectField" { + var options []SingleSelectFieldOptions + for _, o := range p.SingleSelectField.Options { + options = append(options, SingleSelectFieldOptions{ + ID: o.ID, + Name: o.Name, + }) + } + return options + } + return nil +} + +// ProjectFields returns a project with fields. If the OwnerType is VIEWER, no login is required. +// If limit is 0, the default limit is used. +func (c *Client) ProjectFields(o *Owner, number int32, limit int) (*Project, error) { + project := &Project{} + if limit == 0 { + limit = LimitDefault + } + + // set first to the min of limit and LimitMax + first := LimitMax + if limit < first { + first = limit + } + variables := map[string]interface{}{ + "firstItems": githubv4.Int(LimitMax), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(first), + "afterFields": (*githubv4.String)(nil), + "number": githubv4.Int(number), + } + + var query pager[ProjectField] + var queryName string + switch o.Type { + case UserOwner: + variables["login"] = githubv4.String(o.Login) + query = &userOwnerWithFields{} // must be a pointer to work with graphql queries + queryName = "UserProjectWithFields" + case OrgOwner: + variables["login"] = githubv4.String(o.Login) + query = &orgOwnerWithFields{} // must be a pointer to work with graphql queries + queryName = "OrgProjectWithFields" + case ViewerOwner: + query = &viewerOwnerWithFields{} // must be a pointer to work with graphql queries + queryName = "ViewerProjectWithFields" + } + err := c.doQuery(queryName, query, variables) + if err != nil { + return project, err + } + project = query.Project() + + fields, err := paginateAttributes(c, query, variables, queryName, "firstFields", "afterFields", limit, query.Nodes()) + if err != nil { + return project, err + } + + project.Fields.Nodes = fields + return project, nil +} + +// viewerLogin is used to query the Login of the viewer. +type viewerLogin struct { + Viewer struct { + Login string + Id string + } +} + +type viewerLoginOrgs struct { + Viewer struct { + Login string + ID string + Organizations struct { + PageInfo PageInfo + Nodes []struct { + Login string + ViewerCanCreateProjects bool + ID string + } + } `graphql:"organizations(first: 100, after: $after)"` + } +} + +// userOwner is used to query the project of a user. +type userOwner struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + Login string + } `graphql:"user(login: $login)"` +} + +// userOwnerWithItems is used to query the project of a user with its items. +type userOwnerWithItems struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + } `graphql:"user(login: $login)"` +} + +// userOwnerWithFields is used to query the project of a user with its fields. +type userOwnerWithFields struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + } `graphql:"user(login: $login)"` +} + +// orgOwner is used to query the project of an organization. +type orgOwner struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + Login string + } `graphql:"organization(login: $login)"` +} + +// orgOwnerWithItems is used to query the project of an organization with its items. +type orgOwnerWithItems struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + } `graphql:"organization(login: $login)"` +} + +// orgOwnerWithFields is used to query the project of an organization with its fields. +type orgOwnerWithFields struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + } `graphql:"organization(login: $login)"` +} + +// viewerOwner is used to query the project of the viewer. +type viewerOwner struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + Login string + } `graphql:"viewer"` +} + +// viewerOwnerWithItems is used to query the project of the viewer with its items. +type viewerOwnerWithItems struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + } `graphql:"viewer"` +} + +// viewerOwnerWithFields is used to query the project of the viewer with its fields. +type viewerOwnerWithFields struct { + Owner struct { + Project Project `graphql:"projectV2(number: $number)"` + } `graphql:"viewer"` +} + +// OwnerType is the type of the owner of a project, which can be either a user or an organization. Viewer is the current user. +type OwnerType string + +const UserOwner OwnerType = "USER" +const OrgOwner OwnerType = "ORGANIZATION" +const ViewerOwner OwnerType = "VIEWER" + +// ViewerLoginName returns the login name of the viewer. +func (c *Client) ViewerLoginName() (string, error) { + var query viewerLogin + err := c.doQuery("Viewer", &query, map[string]interface{}{}) + if err != nil { + return "", err + } + return query.Viewer.Login, nil +} + +// OwnerIDAndType returns the ID and OwnerType. The special login "@me" or an empty string queries the current user. +func (c *Client) OwnerIDAndType(login string) (string, OwnerType, error) { + if login == "@me" || login == "" { + var query viewerLogin + err := c.doQuery("ViewerOwner", &query, nil) + if err != nil { + return "", "", err + } + return query.Viewer.Id, ViewerOwner, nil + } + + variables := map[string]interface{}{ + "login": githubv4.String(login), + } + var query struct { + User struct { + Login string + Id string + } `graphql:"user(login: $login)"` + Organization struct { + Login string + Id string + } `graphql:"organization(login: $login)"` + } + + err := c.doQuery("UserOrgOwner", &query, variables) + if err != nil { + // Due to the way the queries are structured, we don't know if a login belongs to a user + // or to an org, even though they are unique. To deal with this, we try both - if neither + // is found, we return the error. + var graphErr api.GraphQLError + if errors.As(err, &graphErr) { + if graphErr.Match("NOT_FOUND", "user") && graphErr.Match("NOT_FOUND", "organization") { + return "", "", err + } else if graphErr.Match("NOT_FOUND", "organization") { // org isn't found must be a user + return query.User.Id, UserOwner, nil + } else if graphErr.Match("NOT_FOUND", "user") { // user isn't found must be an org + return query.Organization.Id, OrgOwner, nil + } + } + } + + return "", "", errors.New("unknown owner type") +} + +// issueOrPullRequest is used to query the global id of an issue or pull request by its URL. +type issueOrPullRequest struct { + Resource struct { + Typename string `graphql:"__typename"` + Issue struct { + ID string + } `graphql:"... on Issue"` + PullRequest struct { + ID string + } `graphql:"... on PullRequest"` + } `graphql:"resource(url: $url)"` +} + +// IssueOrPullRequestID returns the ID of the issue or pull request from a URL. +func (c *Client) IssueOrPullRequestID(rawURL string) (string, error) { + uri, err := url.Parse(rawURL) + if err != nil { + return "", err + } + variables := map[string]interface{}{ + "url": githubv4.URI{URL: uri}, + } + var query issueOrPullRequest + err = c.doQuery("GetIssueOrPullRequest", &query, variables) + if err != nil { + return "", err + } + if query.Resource.Typename == "Issue" { + return query.Resource.Issue.ID, nil + } else if query.Resource.Typename == "PullRequest" { + return query.Resource.PullRequest.ID, nil + } + return "", errors.New("resource not found, please check the URL") +} + +// userProjects queries the $first projects of a user. +type userProjects struct { + Owner struct { + Projects struct { + TotalCount int + PageInfo PageInfo + Nodes []Project + } `graphql:"projectsV2(first: $first, after: $after)"` + Login string + } `graphql:"user(login: $login)"` +} + +// orgProjects queries the $first projects of an organization. +type orgProjects struct { + Owner struct { + Projects struct { + TotalCount int + PageInfo PageInfo + Nodes []Project + } `graphql:"projectsV2(first: $first, after: $after)"` + Login string + } `graphql:"organization(login: $login)"` +} + +// viewerProjects queries the $first projects of the viewer. +type viewerProjects struct { + Owner struct { + Projects struct { + TotalCount int + PageInfo PageInfo + Nodes []Project + } `graphql:"projectsV2(first: $first, after: $after)"` + Login string + } `graphql:"viewer"` +} + +type loginTypes struct { + Login string + Type OwnerType + ID string +} + +// userOrgLogins gets all the logins of the viewer and the organizations the viewer is a member of. +func (c *Client) userOrgLogins() ([]loginTypes, error) { + l := make([]loginTypes, 0) + var v viewerLoginOrgs + variables := map[string]interface{}{ + "after": (*githubv4.String)(nil), + } + + err := c.doQuery("ViewerLoginAndOrgs", &v, variables) + if err != nil { + return l, err + } + + // add the user + l = append(l, loginTypes{ + Login: v.Viewer.Login, + Type: ViewerOwner, + ID: v.Viewer.ID, + }) + + // add orgs where the user can create projects + for _, org := range v.Viewer.Organizations.Nodes { + if org.ViewerCanCreateProjects { + l = append(l, loginTypes{ + Login: org.Login, + Type: OrgOwner, + ID: org.ID, + }) + } + } + + // this seem unlikely, but if there are more org logins, paginate the rest + if v.Viewer.Organizations.PageInfo.HasNextPage { + return c.paginateOrgLogins(l, string(v.Viewer.Organizations.PageInfo.EndCursor)) + } + + return l, nil +} + +// paginateOrgLogins after cursor and append them to the list of logins. +func (c *Client) paginateOrgLogins(l []loginTypes, cursor string) ([]loginTypes, error) { + var v viewerLoginOrgs + variables := map[string]interface{}{ + "after": githubv4.String(cursor), + } + + err := c.doQuery("ViewerLoginAndOrgs", &v, variables) + if err != nil { + return l, err + } + + for _, org := range v.Viewer.Organizations.Nodes { + if org.ViewerCanCreateProjects { + l = append(l, loginTypes{ + Login: org.Login, + Type: OrgOwner, + ID: org.ID, + }) + } + } + + if v.Viewer.Organizations.PageInfo.HasNextPage { + return c.paginateOrgLogins(l, string(v.Viewer.Organizations.PageInfo.EndCursor)) + } + + return l, nil +} + +type Owner struct { + Login string + Type OwnerType + ID string +} + +// NewOwner creates a project Owner +// If canPrompt is false, login is required as we cannot prompt for it. +// If login is not empty, it is used to lookup the project owner. +// If login is empty empty, interative mode is used to select an owner. +// from the current viewer and their organizations +func (c *Client) NewOwner(canPrompt bool, login string) (*Owner, error) { + if login != "" { + id, ownerType, err := c.OwnerIDAndType(login) + if err != nil { + return nil, err + } + + return &Owner{ + Login: login, + Type: ownerType, + ID: id, + }, nil + } + + if !canPrompt { + return nil, fmt.Errorf("login is required when not running interactively") + } + + logins, err := c.userOrgLogins() + if err != nil { + return nil, err + } + + options := make([]string, 0, len(logins)) + for _, l := range logins { + options = append(options, l.Login) + } + + answerIndex, err := c.prompter.Select("Which owner would you like to use?", "", options) + if err != nil { + return nil, err + } + + l := logins[answerIndex] + return &Owner{ + Login: l.Login, + Type: l.Type, + ID: l.ID, + }, nil +} + +// NewProject creates a project based on the owner and project number +// if canPrompt is false, number is required as we cannot prompt for it +// if number is 0 it will prompt the user to select a project interactively +// otherwise it will make a request to get the project by number +// set `fields“ to true to get the project's field data +func (c *Client) NewProject(canPrompt bool, o *Owner, number int32, fields bool) (*Project, error) { + if number != 0 { + variables := map[string]interface{}{ + "number": githubv4.Int(number), + "firstItems": githubv4.Int(0), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(0), + "afterFields": (*githubv4.String)(nil), + } + + if fields { + variables["firstFields"] = githubv4.Int(LimitMax) + } + if o.Type == UserOwner { + var query userOwner + variables["login"] = githubv4.String(o.Login) + err := c.doQuery("UserProject", &query, variables) + return &query.Owner.Project, err + } else if o.Type == OrgOwner { + variables["login"] = githubv4.String(o.Login) + var query orgOwner + err := c.doQuery("OrgProject", &query, variables) + return &query.Owner.Project, err + } else if o.Type == ViewerOwner { + var query viewerOwner + err := c.doQuery("ViewerProject", &query, variables) + return &query.Owner.Project, err + } + return nil, errors.New("unknown owner type") + } + + if !canPrompt { + return nil, fmt.Errorf("project number is required when not running interactively") + } + + projects, _, err := c.Projects(o.Login, o.Type, 0, fields) + if err != nil { + return nil, err + } + + if len(projects) == 0 { + return nil, fmt.Errorf("no projects found for %s", o.Login) + } + + options := make([]string, 0, len(projects)) + for _, p := range projects { + title := fmt.Sprintf("%s (#%d)", p.Title, p.Number) + options = append(options, title) + } + + answerIndex, err := c.prompter.Select("Which project would you like to use?", "", options) + if err != nil { + return nil, err + } + + return &projects[answerIndex], nil +} + +// Projects returns all the projects for an Owner. If the OwnerType is VIEWER, no login is required. +// If limit is 0, the default limit is used. +func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) ([]Project, int, error) { + projects := make([]Project, 0) + cursor := (*githubv4.String)(nil) + hasNextPage := false + totalCount := 0 + + if limit == 0 { + limit = LimitDefault + } + + // set first to the min of limit and LimitMax + first := LimitMax + if limit < first { + first = limit + } + + variables := map[string]interface{}{ + "first": githubv4.Int(first), + "after": cursor, + "firstItems": githubv4.Int(0), + "afterItems": (*githubv4.String)(nil), + "firstFields": githubv4.Int(0), + "afterFields": (*githubv4.String)(nil), + } + + if fields { + variables["firstFields"] = githubv4.Int(LimitMax) + } + + if t != ViewerOwner { + variables["login"] = githubv4.String(login) + } + // loop until we get all the projects + for { + // the code below is very repetitive, the only real difference being the type of the query + // and the query variables. I couldn't figure out a way to make this cleaner that was worth + // the cost. + if t == UserOwner { + var query userProjects + if err := c.doQuery("UserProjects", &query, variables); err != nil { + return projects, 0, err + } + projects = append(projects, query.Owner.Projects.Nodes...) + hasNextPage = query.Owner.Projects.PageInfo.HasNextPage + cursor = &query.Owner.Projects.PageInfo.EndCursor + totalCount = query.Owner.Projects.TotalCount + } else if t == OrgOwner { + var query orgProjects + if err := c.doQuery("OrgProjects", &query, variables); err != nil { + return projects, 0, err + } + projects = append(projects, query.Owner.Projects.Nodes...) + hasNextPage = query.Owner.Projects.PageInfo.HasNextPage + cursor = &query.Owner.Projects.PageInfo.EndCursor + totalCount = query.Owner.Projects.TotalCount + } else if t == ViewerOwner { + var query viewerProjects + if err := c.doQuery("ViewerProjects", &query, variables); err != nil { + return projects, 0, err + } + projects = append(projects, query.Owner.Projects.Nodes...) + hasNextPage = query.Owner.Projects.PageInfo.HasNextPage + cursor = &query.Owner.Projects.PageInfo.EndCursor + totalCount = query.Owner.Projects.TotalCount + } + + if !hasNextPage || len(projects) >= limit { + return projects, totalCount, nil + } + + if len(projects)+LimitMax > limit { + first := limit - len(projects) + variables["first"] = githubv4.Int(first) + } + variables["after"] = cursor + } +} + +func handleError(err error) error { + var gerr api.GraphQLError + if errors.As(err, &gerr) { + missing := set.NewStringSet() + for _, e := range gerr.Errors { + if e.Type != "INSUFFICIENT_SCOPES" { + continue + } + missing.AddValues(requiredScopesFromServerMessage(e.Message)) + } + if missing.Len() > 0 { + s := missing.ToSlice() + // TODO: this duplicates parts of generateScopesSuggestion + return fmt.Errorf( + "error: your authentication token is missing required scopes %v\n"+ + "To request it, run: gh auth refresh -s %s", + s, + strings.Join(s, ",")) + } + } + return err +} + +var scopesRE = regexp.MustCompile(`one of the following scopes: \[(.+?)]`) + +func requiredScopesFromServerMessage(msg string) []string { + m := scopesRE.FindStringSubmatch(msg) + if m == nil { + return nil + } + var scopes []string + for _, mm := range strings.Split(m[1], ",") { + scopes = append(scopes, strings.Trim(mm, "' ")) + } + return scopes +} diff --git a/pkg/cmd/project/shared/queries/queries_test.go b/pkg/cmd/project/shared/queries/queries_test.go new file mode 100644 index 000000000..b5a8a1f76 --- /dev/null +++ b/pkg/cmd/project/shared/queries/queries_test.go @@ -0,0 +1,365 @@ +package queries + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestProjectItems_DefaultLimit(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // list project items + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query UserProjectWithItems.*", + "variables": map[string]interface{}{ + "firstItems": LimitMax, + "afterItems": nil, + "firstFields": LimitMax, + "afterFields": nil, + "login": "monalisa", + "number": 1, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "items": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "issue ID", + }, + { + "id": "pull request ID", + }, + { + "id": "draft issue ID", + }, + }, + }, + }, + }, + }, + }) + + client := NewTestClient() + + owner := &Owner{ + Type: "USER", + Login: "monalisa", + ID: "user ID", + } + project, err := client.ProjectItems(owner, 1, LimitMax) + assert.NoError(t, err) + assert.Len(t, project.Items.Nodes, 3) +} + +func TestProjectItems_LowerLimit(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // list project items + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query UserProjectWithItems.*", + "variables": map[string]interface{}{ + "firstItems": 2, + "afterItems": nil, + "firstFields": LimitMax, + "afterFields": nil, + "login": "monalisa", + "number": 1, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "items": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "issue ID", + }, + { + "id": "pull request ID", + }, + }, + }, + }, + }, + }, + }) + + client := NewTestClient() + + owner := &Owner{ + Type: "USER", + Login: "monalisa", + ID: "user ID", + } + project, err := client.ProjectItems(owner, 1, 2) + assert.NoError(t, err) + assert.Len(t, project.Items.Nodes, 2) +} + +func TestProjectItems_NoLimit(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // list project items + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query UserProjectWithItems.*", + "variables": map[string]interface{}{ + "firstItems": LimitDefault, + "afterItems": nil, + "firstFields": LimitMax, + "afterFields": nil, + "login": "monalisa", + "number": 1, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "items": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "issue ID", + }, + { + "id": "pull request ID", + }, + { + "id": "draft issue ID", + }, + }, + }, + }, + }, + }, + }) + + client := NewTestClient() + + owner := &Owner{ + Type: "USER", + Login: "monalisa", + ID: "user ID", + } + project, err := client.ProjectItems(owner, 1, 0) + assert.NoError(t, err) + assert.Len(t, project.Items.Nodes, 3) +} + +func TestProjectFields_LowerLimit(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // list project fields + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": LimitMax, + "afterItems": nil, + "firstFields": 2, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "field ID", + }, + { + "id": "status ID", + }, + }, + }, + }, + }, + }, + }) + + client := NewTestClient() + owner := &Owner{ + Type: "USER", + Login: "monalisa", + ID: "user ID", + } + project, err := client.ProjectFields(owner, 1, 2) + assert.NoError(t, err) + assert.Len(t, project.Fields.Nodes, 2) +} + +func TestProjectFields_DefaultLimit(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // list project fields + // list project fields + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": LimitMax, + "afterItems": nil, + "firstFields": LimitMax, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "field ID", + }, + { + "id": "status ID", + }, + { + "id": "iteration ID", + }, + }, + }, + }, + }, + }, + }) + + client := NewTestClient() + + owner := &Owner{ + Type: "USER", + Login: "monalisa", + ID: "user ID", + } + project, err := client.ProjectFields(owner, 1, LimitMax) + assert.NoError(t, err) + assert.Len(t, project.Fields.Nodes, 3) +} + +func TestProjectFields_NoLimit(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + + // list project fields + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query UserProject.*", + "variables": map[string]interface{}{ + "login": "monalisa", + "number": 1, + "firstItems": LimitMax, + "afterItems": nil, + "firstFields": LimitDefault, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "id": "field ID", + }, + { + "id": "status ID", + }, + { + "id": "iteration ID", + }, + }, + }, + }, + }, + }, + }) + + client := NewTestClient() + + owner := &Owner{ + Type: "USER", + Login: "monalisa", + ID: "user ID", + } + project, err := client.ProjectFields(owner, 1, 0) + assert.NoError(t, err) + assert.Len(t, project.Fields.Nodes, 3) +} + +func Test_requiredScopesFromServerMessage(t *testing.T) { + tests := []struct { + name string + msg string + want []string + }{ + { + name: "no scopes", + msg: "SERVER OOPSIE", + want: []string(nil), + }, + { + name: "one scope", + msg: "Your token has not been granted the required scopes to execute this query. The 'dataType' field requires one of the following scopes: ['read:project'], but your token has only been granted the: ['codespace', repo'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens.", + want: []string{"read:project"}, + }, + { + name: "multiple scopes", + msg: "Your token has not been granted the required scopes to execute this query. The 'dataType' field requires one of the following scopes: ['read:project', 'read:discussion', 'codespace'], but your token has only been granted the: [repo'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens.", + want: []string{"read:project", "read:discussion", "codespace"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := requiredScopesFromServerMessage(tt.msg); !reflect.DeepEqual(got, tt.want) { + t.Errorf("requiredScopesFromServerMessage() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewProject_nonTTY(t *testing.T) { + client := NewTestClient() + _, err := client.NewProject(false, &Owner{}, 0, false) + assert.EqualError(t, err, "project number is required when not running interactively") +} + +func TestNewOwner_nonTTY(t *testing.T) { + client := NewTestClient() + _, err := client.NewOwner(false, "") + assert.EqualError(t, err, "login is required when not running interactively") + +} diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go new file mode 100644 index 000000000..36eb148e3 --- /dev/null +++ b/pkg/cmd/project/view/view.go @@ -0,0 +1,203 @@ +package view + +import ( + "fmt" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmd/project/shared/client" + "github.com/cli/cli/v2/pkg/cmd/project/shared/format" + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + "github.com/spf13/cobra" +) + +type viewOpts struct { + web bool + owner string + number int32 + format string +} + +type viewConfig struct { + client *queries.Client + opts viewOpts + io *iostreams.IOStreams + URLOpener func(string) error +} + +func NewCmdView(f *cmdutil.Factory, runF func(config viewConfig) error) *cobra.Command { + opts := viewOpts{} + viewCmd := &cobra.Command{ + Short: "View a project", + Use: "view []", + Example: heredoc.Doc(` + # view the current user's project "1" + gh project view 1 + + # open user monalisa's project "1" in the browser + gh project view 1 --owner monalisa --web + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := client.New(f) + if err != nil { + return err + } + + URLOpener := func(url string) error { + return f.Browser.Browse(url) + } + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number = int32(num) + } + + config := viewConfig{ + client: client, + opts: opts, + io: f.IOStreams, + URLOpener: URLOpener, + } + + // allow testing of the command without actually running it + if runF != nil { + return runF(config) + } + return runView(config) + }, + } + + viewCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + viewCmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open a project in the browser") + cmdutil.StringEnumFlag(viewCmd, &opts.format, "format", "", "", []string{"json"}, "Output format") + + return viewCmd +} + +func runView(config viewConfig) error { + if config.opts.web { + url, err := buildURL(config) + if err != nil { + return err + } + + if err := config.URLOpener(url); err != nil { + return err + } + return nil + } + + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return err + } + + project, err := config.client.NewProject(canPrompt, owner, config.opts.number, true) + if err != nil { + return err + } + + if config.opts.format == "json" { + return printJSON(config, *project) + } + + return printResults(config, project) +} + +// TODO: support non-github.com hostnames +func buildURL(config viewConfig) (string, error) { + var url string + if config.opts.owner == "@me" { + owner, err := config.client.ViewerLoginName() + if err != nil { + return "", err + } + url = fmt.Sprintf("https://github.com/users/%s/projects/%d", owner, config.opts.number) + } else { + _, ownerType, err := config.client.OwnerIDAndType(config.opts.owner) + if err != nil { + return "", err + } + + if ownerType == queries.UserOwner { + url = fmt.Sprintf("https://github.com/users/%s/projects/%d", config.opts.owner, config.opts.number) + } else { + url = fmt.Sprintf("https://github.com/orgs/%s/projects/%d", config.opts.owner, config.opts.number) + } + } + + return url, nil +} + +func printResults(config viewConfig, project *queries.Project) error { + var sb strings.Builder + sb.WriteString("# Title\n") + sb.WriteString(project.Title) + sb.WriteString("\n") + + sb.WriteString("## Description\n") + if project.ShortDescription == "" { + sb.WriteString(" -- ") + } else { + sb.WriteString(project.ShortDescription) + } + sb.WriteString("\n") + + sb.WriteString("## Visibility\n") + if project.Public { + sb.WriteString("Public") + } else { + sb.WriteString("Private") + } + sb.WriteString("\n") + + sb.WriteString("## URL\n") + sb.WriteString(project.URL) + sb.WriteString("\n") + + sb.WriteString("## Item count\n") + sb.WriteString(fmt.Sprintf("%d", project.Items.TotalCount)) + sb.WriteString("\n") + + sb.WriteString("## Readme\n") + if project.Readme == "" { + sb.WriteString(" -- ") + } else { + sb.WriteString(project.Readme) + } + sb.WriteString("\n") + + sb.WriteString("## Field Name (Field Type)\n") + for _, f := range project.Fields.Nodes { + sb.WriteString(fmt.Sprintf("%s (%s)\n\n", f.Name(), f.Type())) + } + + out, err := markdown.Render(sb.String(), + markdown.WithTheme(config.io.TerminalTheme()), + markdown.WithWrap(config.io.TerminalWidth())) + + if err != nil { + return err + } + _, err = fmt.Fprint(config.io.Out, out) + return err +} + +func printJSON(config viewConfig, project queries.Project) error { + b, err := format.JSONProject(project) + if err != nil { + return err + } + + _, err = config.io.Out.Write(b) + return err +} diff --git a/pkg/cmd/project/view/view_test.go b/pkg/cmd/project/view/view_test.go new file mode 100644 index 000000000..5c76c1413 --- /dev/null +++ b/pkg/cmd/project/view/view_test.go @@ -0,0 +1,545 @@ +package view + +import ( + "bytes" + "os" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestNewCmdview(t *testing.T) { + tests := []struct { + name string + cli string + wants viewOpts + wantsErr bool + wantsErrMsg string + }{ + { + name: "not-a-number", + cli: "x", + wantsErr: true, + wantsErrMsg: "invalid number: x", + }, + { + name: "number", + cli: "123", + wants: viewOpts{ + number: 123, + }, + }, + { + name: "owner", + cli: "--owner monalisa", + wants: viewOpts{ + owner: "monalisa", + }, + }, + { + name: "web", + cli: "--web", + wants: viewOpts{ + web: true, + }, + }, + { + name: "json", + cli: "--format json", + wants: viewOpts{ + format: "json", + }, + }, + } + + os.Setenv("GH_TOKEN", "auth-token") + defer os.Unsetenv("GH_TOKEN") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts viewOpts + cmd := NewCmdView(f, func(config viewConfig) error { + gotOpts = config.opts + return nil + }) + + cmd.SetArgs(argv) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Equal(t, tt.wantsErrMsg, err.Error()) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.number, gotOpts.number) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.format, gotOpts.format) + assert.Equal(t, tt.wants.web, gotOpts.web) + }) + } +} + +func TestBuildURLViewer(t *testing.T) { + defer gock.Off() + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(` + {"data": + {"viewer": + { + "login":"theviewer" + } + } + } + `) + + client := queries.NewTestClient() + + url, err := buildURL(viewConfig{ + opts: viewOpts{ + number: 1, + owner: "@me", + }, + client: client, + }) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/users/theviewer/projects/1", url) +} + +func TestRunView_User(t *testing.T) { + defer gock.Off() + + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(` + {"data": + {"user": + { + "login":"monalisa", + "projectV2": { + "number": 1, + "items": { + "totalCount": 10 + }, + "readme": null, + "fields": { + "nodes": [ + { + "name": "Title" + } + ] + } + } + } + } + } + `) + + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + config := viewConfig{ + opts: viewOpts{ + owner: "monalisa", + number: 1, + }, + io: ios, + client: client, + } + + err := runView(config) + assert.NoError(t, err) + +} + +func TestRunView_Viewer(t *testing.T) { + defer gock.Off() + + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query ViewerOwner.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(` + {"data": + {"viewer": + { + "login":"monalisa", + "projectV2": { + "number": 1, + "items": { + "totalCount": 10 + }, + "readme": null, + "fields": { + "nodes": [ + { + "name": "Title" + } + ] + } + } + } + } + } + `) + + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + config := viewConfig{ + opts: viewOpts{ + owner: "@me", + number: 1, + }, + io: ios, + client: client, + } + + err := runView(config) + assert.NoError(t, err) +} + +func TestRunView_Org(t *testing.T) { + defer gock.Off() + + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(` + {"data": + {"organization": + { + "login":"monalisa", + "projectV2": { + "number": 1, + "items": { + "totalCount": 10 + }, + "readme": null, + "fields": { + "nodes": [ + { + "name": "Title" + } + ] + } + } + } + } + } + `) + + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + config := viewConfig{ + opts: viewOpts{ + owner: "github", + number: 1, + }, + io: ios, + client: client, + } + + err := runView(config) + assert.NoError(t, err) +} + +func TestRunViewWeb_User(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get user ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "monalisa", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(` + {"data": + {"user": + { + "login":"monalisa", + "projectV2": { + "number": 1, + "items": { + "totalCount": 10 + }, + "readme": null, + "fields": { + "nodes": [ + { + "name": "Title" + } + ] + } + } + } + } + } + `) + + client := queries.NewTestClient() + buf := bytes.Buffer{} + config := viewConfig{ + opts: viewOpts{ + owner: "monalisa", + web: true, + number: 8, + }, + URLOpener: func(url string) error { + buf.WriteString(url) + return nil + }, + client: client, + } + + err := runView(config) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/users/monalisa/projects/8", buf.String()) +} + +func TestRunViewWeb_Org(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get org ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query UserOrgOwner.*", + "variables": map[string]interface{}{ + "login": "github", + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "organization": map[string]interface{}{ + "id": "an ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"user"}, + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(` + {"data": + {"organization": + { + "login":"github", + "projectV2": { + "number": 1, + "items": { + "totalCount": 10 + }, + "readme": null, + "fields": { + "nodes": [ + { + "name": "Title" + } + ] + } + } + } + } + } + `) + + client := queries.NewTestClient() + buf := bytes.Buffer{} + config := viewConfig{ + opts: viewOpts{ + owner: "github", + web: true, + number: 8, + }, + URLOpener: func(url string) error { + buf.WriteString(url) + return nil + }, + client: client, + } + + err := runView(config) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/orgs/github/projects/8", buf.String()) +} + +func TestRunViewWeb_Me(t *testing.T) { + defer gock.Off() + gock.Observe(gock.DumpRequest) + // get viewer ID + gock.New("https://api.github.com"). + Post("/graphql"). + MatchType("json"). + JSON(map[string]interface{}{ + "query": "query Viewer.*", + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "viewer": map[string]interface{}{ + "id": "an ID", + "login": "theviewer", + }, + }, + }) + + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(` + {"data": + {"user": + { + "login":"github", + "projectV2": { + "number": 1, + "items": { + "totalCount": 10 + }, + "readme": null, + "fields": { + "nodes": [ + { + "name": "Title" + } + ] + } + } + } + } + } + `) + + client := queries.NewTestClient() + buf := bytes.Buffer{} + config := viewConfig{ + opts: viewOpts{ + owner: "@me", + web: true, + number: 8, + }, + URLOpener: func(url string) error { + buf.WriteString(url) + return nil + }, + client: client, + } + + err := runView(config) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/users/theviewer/projects/8", buf.String()) +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 437063d77..2929ca720 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -27,6 +27,7 @@ import ( labelCmd "github.com/cli/cli/v2/pkg/cmd/label" orgCmd "github.com/cli/cli/v2/pkg/cmd/org" prCmd "github.com/cli/cli/v2/pkg/cmd/pr" + projectCmd "github.com/cli/cli/v2/pkg/cmd/project" releaseCmd "github.com/cli/cli/v2/pkg/cmd/release" repoCmd "github.com/cli/cli/v2/pkg/cmd/repo" creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits" @@ -139,6 +140,8 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(apiCmd.NewCmdApi(&bareHTTPCmdFactory, nil)) + cmd.AddCommand(projectCmd.NewCmdProject(f)) + // below here at the commands that require the "intelligent" BaseRepo resolver repoResolvingCmdFactory := *f repoResolvingCmdFactory.BaseRepo = factory.SmartBaseRepoFunc(f)