diff --git a/pkg/cmd/project/item-edit/item_edit.go b/pkg/cmd/project/item-edit/item_edit.go index 6eb44caf4e3..7819c37a98c 100644 --- a/pkg/cmd/project/item-edit/item_edit.go +++ b/pkg/cmd/project/item-edit/item_edit.go @@ -2,6 +2,7 @@ package itemedit import ( "fmt" + "strconv" "strings" "time" @@ -31,6 +32,13 @@ type editItemOpts struct { singleSelectOptionID string iterationID string clear bool + // name-based resolution + owner string + number32 int32 + url string + field string + value string + valueChanged bool // format exporter cmdutil.Exporter } @@ -68,45 +76,110 @@ type ClearProjectV2FieldValue struct { func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error) *cobra.Command { opts := editItemOpts{} editItemCmd := &cobra.Command{ - Use: "item-edit", + Use: "item-edit []", Short: "Edit an item in a project", Long: heredoc.Docf(` - Edit either a draft issue or a project item. Both usages require the ID of the item to edit. + Edit a draft issue or a project item. - For non-draft issues, the ID of the project is also required, and only a single field value can be updated per invocation. + There are two ways to select the item and field to edit: - Remove project item field value using %[1]s--clear%[1]s flag. + - By ID: pass %[1]s--id%[1]s, %[1]s--field-id%[1]s and %[1]s--project-id%[1]s directly. + - By name: pass %[1]snumber%[1]s plus %[1]s--owner%[1]s, point at the item with its + issue or pull request %[1]s--url%[1]s, and name the field with %[1]s--field%[1]s. + For single-select fields, %[1]s--value%[1]s is the option name. + + In either case, the project is always selected by number and %[1]s--owner%[1]s. + + Note that %[1]s--url%[1]s is the issue or pull request URL, not a project URL, so its + owner may differ from the project's; %[1]s--owner%[1]s selects the project. + + For non-draft issues, only a single field value can be updated per invocation. + + Remove a project item field value with %[1]s--clear%[1]s. `, "`"), Example: heredoc.Doc(` - # Edit an item's text field value + # Edit an item's text field value by node ID $ gh project item-edit --id --field-id --project-id --text "new text" + # Set the "Status" field to "In Progress" for an issue on monalisa's project 1 + $ gh project item-edit 1 --owner monalisa --url https://github.com/monalisa/myproject/issues/23 --field "Status" --value "In Progress" + # Clear an item's field value $ gh project item-edit --id --field-id --project-id --clear `), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.numberChanged = cmd.Flags().Changed("number") opts.titleChanged = cmd.Flags().Changed("title") opts.bodyChanged = cmd.Flags().Changed("body") + opts.valueChanged = cmd.Flags().Changed("value") + + if len(args) == 1 { + num, err := strconv.ParseInt(args[0], 10, 32) + if err != nil { + return cmdutil.FlagErrorf("invalid number: %v", args[0]) + } + opts.number32 = int32(num) + } + if err := cmdutil.MutuallyExclusive( - "only one of `--text`, `--number`, `--date`, `--single-select-option-id` or `--iteration-id` may be used", + "only one of `--text`, `--number`, `--date`, `--single-select-option-id`, `--iteration-id` or `--value` may be used", opts.text != "", opts.numberChanged, opts.date != "", opts.singleSelectOptionID != "", opts.iterationID != "", + opts.valueChanged, ); err != nil { return err } if err := cmdutil.MutuallyExclusive( - "cannot use `--text`, `--number`, `--date`, `--single-select-option-id` or `--iteration-id` in conjunction with `--clear`", - opts.text != "" || opts.numberChanged || opts.date != "" || opts.singleSelectOptionID != "" || opts.iterationID != "", + "only one of `--field` or `--field-id` may be used", + opts.field != "", + opts.fieldID != "", + ); err != nil { + return err + } + + if err := cmdutil.MutuallyExclusive( + "only one of `--url` or `--id` may be used", + opts.url != "", + opts.itemID != "", + ); err != nil { + return err + } + + if err := cmdutil.MutuallyExclusive( + "cannot use `--text`, `--number`, `--date`, `--single-select-option-id`, `--iteration-id` or `--value` in conjunction with `--clear`", + opts.text != "" || opts.numberChanged || opts.date != "" || opts.singleSelectOptionID != "" || opts.iterationID != "" || opts.valueChanged, opts.clear, ); err != nil { return err } + if opts.valueChanged && opts.fieldID != "" { + return cmdutil.FlagErrorf("`--value` cannot be used with `--field-id`; name the field with `--field` to use `--value`") + } + + if opts.field != "" && opts.itemID != "" { + return cmdutil.FlagErrorf("`--field` cannot be used with `--id`; use `--url` to address the item when editing by name") + } + + if opts.valueChanged && opts.field == "" { + return cmdutil.FlagErrorf("`--value` requires `--field`") + } + + if opts.itemID == "" && opts.url == "" { + return cmdutil.FlagErrorf("specify the item to edit with `--id` or `--url`") + } + + // Name-based flags resolve the field and item within a specific + // project, so they require the project number as an argument. + if (opts.url != "" || opts.field != "" || opts.valueChanged) && len(args) == 0 { + return cmdutil.FlagErrorf("provide the project number as an argument when using `--url`, `--field`, or `--value`") + } + client, err := client.New(f) if err != nil { return err @@ -129,6 +202,11 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error) editItemCmd.Flags().StringVar(&opts.itemID, "id", "", "ID of the item to edit") cmdutil.AddFormatFlags(editItemCmd, &opts.exporter) + editItemCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user.") + editItemCmd.Flags().StringVar(&opts.url, "url", "", "URL of the issue or pull request whose project item to edit") + editItemCmd.Flags().StringVar(&opts.field, "field", "", "Name of the field to update") + editItemCmd.Flags().StringVar(&opts.value, "value", "", "Value to set on the field named by `--field`") + editItemCmd.Flags().StringVar(&opts.title, "title", "", "Title of the draft issue item") editItemCmd.Flags().StringVar(&opts.body, "body", "", "Body of the draft issue item") @@ -141,12 +219,20 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error) editItemCmd.Flags().StringVar(&opts.iterationID, "iteration-id", "", "ID of the iteration value to set on the field") editItemCmd.Flags().BoolVar(&opts.clear, "clear", false, "Remove field value") - _ = editItemCmd.MarkFlagRequired("id") - return editItemCmd } func runEditItem(config editItemConfig) error { + // resolve name-based flags (--owner/number/--url/--field/--value) into the + // ID-typed fields the mutations use, before any write happens. + if config.opts.url != "" || config.opts.field != "" { + resolved, err := resolveItemEditNames(config) + if err != nil { + return err + } + config = resolved + } + // when clear flag is used, remove value set to the corresponding field ID if config.opts.clear { return clearItemFieldValue(config) @@ -168,6 +254,77 @@ func runEditItem(config editItemConfig) error { return cmdutil.SilentError } +// resolveItemEditNames turns the name-based addressing flags into the node IDs +// used by the field-value mutations: it resolves the project from +// owner + number, the item from --url, the field from --field, and (for +// single-select fields) --value from an option name to an option ID. Resolution +// happens before the mutation so a bad name fails loudly instead of writing a +// malformed value. +func resolveItemEditNames(config editItemConfig) (editItemConfig, error) { + canPrompt := config.io.CanPrompt() + owner, err := config.client.NewOwner(canPrompt, config.opts.owner) + if err != nil { + return config, err + } + + project, err := config.client.ProjectFields(owner, config.opts.number32, queries.LimitMax) + if err != nil { + return config, err + } + config.opts.projectID = project.ID + + // Resolve the field first so a bad field name fails before the item lookup. + var field queries.ProjectField + if config.opts.field != "" { + field, err = queries.ResolveFieldByName(project.Fields.Nodes, config.opts.field) + if err != nil { + return config, err + } + config.opts.fieldID = field.ID() + } + + if config.opts.url == "" { + return config, cmdutil.FlagErrorf("`--url` is required to resolve the item by issue or pull request URL") + } + itemID, err := config.client.ProjectItemIDByURL(config.opts.url, project.ID, config.opts.number32) + if err != nil { + return config, err + } + config.opts.itemID = itemID + + if config.opts.field == "" || !config.opts.valueChanged { + return config, nil + } + + // Dispatch --value by the resolved field's data type. New enum-like field + // types (e.g. MultiSelect) can be added here without reworking callers. + switch field.DataType() { + case "SINGLE_SELECT": + optionID, err := queries.ResolveSingleSelectOptionID(field, config.opts.value) + if err != nil { + return config, err + } + config.opts.singleSelectOptionID = optionID + case "TEXT": + config.opts.text = config.opts.value + case "NUMBER": + n, err := strconv.ParseFloat(config.opts.value, 64) + if err != nil { + return config, cmdutil.FlagErrorf("invalid number value %q for field %q", config.opts.value, field.Name()) + } + config.opts.number = n + config.opts.numberChanged = true + case "DATE": + config.opts.date = config.opts.value + case "ITERATION": + return config, cmdutil.FlagErrorf("setting an iteration field by name is not supported; use `--iteration-id`") + default: + return config, cmdutil.FlagErrorf("field %q has data type %q which is not supported with `--value`", field.Name(), field.DataType()) + } + + return config, nil +} + func fetchDraftIssueByID(config editItemConfig, draftIssueID string) (*queries.DraftIssue, error) { var query DraftIssueQuery variables := map[string]interface{}{ diff --git a/pkg/cmd/project/item-edit/item_edit_test.go b/pkg/cmd/project/item-edit/item_edit_test.go index 64852bad555..2ff5c4407b5 100644 --- a/pkg/cmd/project/item-edit/item_edit_test.go +++ b/pkg/cmd/project/item-edit/item_edit_test.go @@ -21,16 +21,70 @@ func TestNewCmdeditItem(t *testing.T) { wantsExporter bool }{ { - name: "missing-id", + name: "no item selector flags", cli: "", wantsErr: true, - wantsErrMsg: "required flag(s) \"id\" not set", + wantsErrMsg: "specify the item to edit with `--id` or `--url`", }, { 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", + wantsErrMsg: "only one of `--text`, `--number`, `--date`, `--single-select-option-id`, `--iteration-id` or `--value` may be used", + }, + { + name: "field and field-id conflict", + cli: "--url https://github.com/o/r/issues/1 --field Status --field-id FIELD_ID", + wantsErr: true, + wantsErrMsg: "only one of `--field` or `--field-id` may be used", + }, + { + name: "url and id conflict", + cli: "--id 123 --url https://github.com/o/r/issues/1", + wantsErr: true, + wantsErrMsg: "only one of `--url` or `--id` may be used", + }, + { + name: "value and single-select-option-id conflict", + cli: "--url https://github.com/o/r/issues/1 --field Status --value Todo --single-select-option-id OPTION_ID", + wantsErr: true, + wantsErrMsg: "only one of `--text`, `--number`, `--date`, `--single-select-option-id`, `--iteration-id` or `--value` may be used", + }, + { + name: "value requires field", + cli: "1 --url https://github.com/o/r/issues/1 --value Todo", + wantsErr: true, + wantsErrMsg: "`--value` requires `--field`", + }, + { + name: "value and field-id conflict", + cli: "1 --owner monalisa --url https://github.com/o/r/issues/1 --field-id FIELD_ID --value Todo", + wantsErr: true, + wantsErrMsg: "`--value` cannot be used with `--field-id`; name the field with `--field` to use `--value`", + }, + { + name: "field and id conflict", + cli: "1 --owner monalisa --id ITEM_ID --field Status", + wantsErr: true, + wantsErrMsg: "`--field` cannot be used with `--id`; use `--url` to address the item when editing by name", + }, + { + name: "name-based flags require project number", + cli: "--owner monalisa --url https://github.com/o/r/issues/1 --field Status --value Todo", + wantsErr: true, + wantsErrMsg: "provide the project number as an argument when using `--url`, `--field`, or `--value`", + }, + { + name: "name-based flags", + cli: "1 --owner monalisa --url https://github.com/o/r/issues/1 --field Status --value Todo", + wants: editItemOpts{ + number32: 1, + owner: "monalisa", + url: "https://github.com/o/r/issues/1", + field: "Status", + value: "Todo", + valueChanged: true, + }, }, { name: "item-id", @@ -182,6 +236,12 @@ func TestNewCmdeditItem(t *testing.T) { assert.Equal(t, tt.wants.titleChanged, gotOpts.titleChanged) assert.Equal(t, tt.wants.bodyChanged, gotOpts.bodyChanged) assert.Equal(t, tt.wants.body, gotOpts.body) + assert.Equal(t, tt.wants.owner, gotOpts.owner) + assert.Equal(t, tt.wants.number32, gotOpts.number32) + assert.Equal(t, tt.wants.url, gotOpts.url) + assert.Equal(t, tt.wants.field, gotOpts.field) + assert.Equal(t, tt.wants.value, gotOpts.value) + assert.Equal(t, tt.wants.valueChanged, gotOpts.valueChanged) }) } } @@ -810,3 +870,669 @@ func TestRunItemEdit_JSON(t *testing.T) { `{"id":"DI_item_id","title":"a title","body":"a new body","type":"DraftIssue"}`, stdout.String()) } + +func TestRunItemEdit_ByName_SingleSelect(t *testing.T) { + defer gock.Off() + + // resolve owner + gock.New("https://api.github.com"). + Post("/graphql"). + 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": "user ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // resolve 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.LimitMax, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "project ID", + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "__typename": "ProjectV2SingleSelectField", + "id": "status ID", + "name": "Status", + "dataType": "SINGLE_SELECT", + "options": []map[string]interface{}{ + {"id": "opt_todo", "name": "Todo"}, + {"id": "opt_done", "name": "Done"}, + }, + }, + }, + }, + }, + }, + }, + }) + + // resolve item by URL + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query GetProjectItemByURL.*", + "variables": map[string]interface{}{ + "url": "https://github.com/monalisa/repo/issues/1", + "firstItems": queries.LimitMax, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "__typename": "Issue", + "projectItems": map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": "item ID", "project": map[string]interface{}{"id": "project ID"}}, + }, + }, + }, + }, + }) + + // mutation uses the resolved option ID + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project ID","itemId":"item ID","fieldId":"status ID","value":{"singleSelectOptionId":"opt_done"}}}}`). + 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", + "title": "an issue", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + owner: "monalisa", + number32: 1, + url: "https://github.com/monalisa/repo/issues/1", + field: "Status", + value: "Done", + valueChanged: true, + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal(t, "Edited item \"an issue\"\n", stdout.String()) +} + +// TestRunItemEdit_ByName_ValueDispatch covers how --value is dispatched by the +// resolved field's data type for the non single-select field types. The error +// cases exercise the guard branches that reject a value before any write: since +// those errors are only produced after the owner/project/item lookups succeed, +// the exact error assertion proves the field-value mutation never fired. +func TestRunItemEdit_ByName_ValueDispatch(t *testing.T) { + tests := []struct { + name string + fieldNode map[string]interface{} + value string + mutationBody string // expected mutation body; empty when no write should happen + wantErr string // expected error; empty for happy paths + }{ + { + name: "text field", + fieldNode: map[string]interface{}{ + "__typename": "ProjectV2Field", + "id": "text ID", + "name": "Text", + "dataType": "TEXT", + }, + value: "hello", + mutationBody: `{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project ID","itemId":"item ID","fieldId":"text ID","value":{"text":"hello"}}}}`, + }, + { + name: "number field", + fieldNode: map[string]interface{}{ + "__typename": "ProjectV2Field", + "id": "number ID", + "name": "Estimate", + "dataType": "NUMBER", + }, + value: "123.45", + mutationBody: `{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project ID","itemId":"item ID","fieldId":"number ID","value":{"number":123.45}}}}`, + }, + { + name: "date field", + fieldNode: map[string]interface{}{ + "__typename": "ProjectV2Field", + "id": "date ID", + "name": "Due", + "dataType": "DATE", + }, + value: "2023-01-01", + mutationBody: `{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project ID","itemId":"item ID","fieldId":"date ID","value":{"date":"2023-01-01T00:00:00Z"}}}}`, + }, + { + name: "invalid number value", + fieldNode: map[string]interface{}{ + "__typename": "ProjectV2Field", + "id": "number ID", + "name": "Estimate", + "dataType": "NUMBER", + }, + value: "not-a-number", + wantErr: `invalid number value "not-a-number" for field "Estimate"`, + }, + { + name: "iteration field rejected", + fieldNode: map[string]interface{}{ + "__typename": "ProjectV2IterationField", + "id": "iteration ID", + "name": "Sprint", + "dataType": "ITERATION", + }, + value: "Sprint 1", + wantErr: "setting an iteration field by name is not supported; use `--iteration-id`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer gock.Off() + + // resolve owner + gock.New("https://api.github.com"). + Post("/graphql"). + 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": "user ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // resolve 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.LimitMax, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "project ID", + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{tt.fieldNode}, + }, + }, + }, + }, + }) + + // resolve item by URL + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query GetProjectItemByURL.*", + "variables": map[string]interface{}{ + "url": "https://github.com/monalisa/repo/issues/1", + "firstItems": queries.LimitMax, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "__typename": "Issue", + "projectItems": map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": "item ID", "project": map[string]interface{}{"id": "project ID"}}, + }, + }, + }, + }, + }) + + if tt.mutationBody != "" { + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(tt.mutationBody). + 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", + "title": "an issue", + }, + }, + }, + }, + }) + } + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + owner: "monalisa", + number32: 1, + url: "https://github.com/monalisa/repo/issues/1", + field: tt.fieldNode["name"].(string), + value: tt.value, + valueChanged: true, + }, + client: client, + } + + err := runEditItem(config) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + assert.True(t, gock.IsDone(), "the item should be resolved and no write attempted") + return + } + assert.NoError(t, err) + assert.True(t, gock.IsDone()) + assert.Equal(t, "Edited item \"an issue\"\n", stdout.String()) + }) + } +} + +func TestRunItemEdit_ByName_CaseInsensitive(t *testing.T) { + defer gock.Off() + + // resolve owner + gock.New("https://api.github.com"). + Post("/graphql"). + 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": "user ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // resolve 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.LimitMax, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "project ID", + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "__typename": "ProjectV2SingleSelectField", + "id": "status ID", + "name": "Status", + "dataType": "SINGLE_SELECT", + "options": []map[string]interface{}{ + {"id": "opt_todo", "name": "Todo"}, + {"id": "opt_inprog", "name": "In Progress"}, + }, + }, + }, + }, + }, + }, + }, + }) + + // resolve item by URL + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query GetProjectItemByURL.*", + "variables": map[string]interface{}{ + "url": "https://github.com/monalisa/repo/issues/1", + "firstItems": queries.LimitMax, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "__typename": "Issue", + "projectItems": map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": "item ID", "project": map[string]interface{}{"id": "project ID"}}, + }, + }, + }, + }, + }) + + // mutation resolves the lowercase field/value inputs to their canonical IDs + gock.New("https://api.github.com"). + Post("/graphql"). + BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project ID","itemId":"item ID","fieldId":"status ID","value":{"singleSelectOptionId":"opt_inprog"}}}}`). + 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", + "title": "an issue", + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + owner: "monalisa", + number32: 1, + url: "https://github.com/monalisa/repo/issues/1", + field: "status", + value: "in progress", + valueChanged: true, + }, + client: client, + } + + err := runEditItem(config) + assert.NoError(t, err) + assert.Equal(t, "Edited item \"an issue\"\n", stdout.String()) + assert.True(t, gock.IsDone()) +} + +func TestRunItemEdit_ByName_FieldNotFound(t *testing.T) { + defer gock.Off() + + gock.New("https://api.github.com"). + Post("/graphql"). + 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": "user ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + 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.LimitMax, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "project ID", + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "__typename": "ProjectV2Field", + "id": "title ID", + "name": "Title", + "dataType": "TITLE", + }, + }, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + owner: "monalisa", + number32: 1, + url: "https://github.com/monalisa/repo/issues/1", + field: "Status", + value: "Done", + valueChanged: true, + }, + client: client, + } + + // The field resolves against the project fields, so no item lookup or + // mutation should fire; the error must list candidate field names. + err := runEditItem(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), `field "Status" not found`) + assert.Contains(t, err.Error(), "available fields: Title") + assert.True(t, gock.IsDone()) +} + +func TestRunItemEdit_ByName_WrongFieldType(t *testing.T) { + defer gock.Off() + + // resolve owner + gock.New("https://api.github.com"). + Post("/graphql"). + 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": "user ID", + }, + }, + "errors": []interface{}{ + map[string]interface{}{ + "type": "NOT_FOUND", + "path": []string{"organization"}, + }, + }, + }) + + // resolve project + fields: the project has a built-in Title field, which + // updateProjectV2ItemFieldValue does not support with --value. + 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.LimitMax, + "afterFields": nil, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "user": map[string]interface{}{ + "projectV2": map[string]interface{}{ + "id": "project ID", + "fields": map[string]interface{}{ + "nodes": []map[string]interface{}{ + { + "__typename": "ProjectV2Field", + "id": "title ID", + "name": "Title", + "dataType": "TITLE", + }, + }, + }, + }, + }, + }, + }) + + // resolve item by URL: this fires before the value dispatch, but no + // mutation should follow. + gock.New("https://api.github.com"). + Post("/graphql"). + JSON(map[string]interface{}{ + "query": "query GetProjectItemByURL.*", + "variables": map[string]interface{}{ + "url": "https://github.com/monalisa/repo/issues/1", + "firstItems": queries.LimitMax, + }, + }). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "__typename": "Issue", + "projectItems": map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": "item ID", "project": map[string]interface{}{"id": "project ID"}}, + }, + }, + }, + }, + }) + + client := queries.NewTestClient() + + ios, _, _, _ := iostreams.Test() + + config := editItemConfig{ + io: ios, + opts: editItemOpts{ + owner: "monalisa", + number32: 1, + url: "https://github.com/monalisa/repo/issues/1", + field: "Title", + value: "a new title", + valueChanged: true, + }, + client: client, + } + + // The Title field resolves but its data type is not writable with --value, + // so a clean error is returned and no mutation fires. + err := runEditItem(config) + assert.Error(t, err) + assert.Contains(t, err.Error(), `field "Title" has data type "TITLE"`) + assert.Contains(t, err.Error(), "not supported with `--value`") + assert.True(t, gock.IsDone()) +} diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index 9a3bd490902..a9abbd8858d 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -971,6 +971,20 @@ func (p ProjectField) Type() string { return p.TypeName } +// DataType is the data type of the project field, e.g. TEXT, NUMBER, DATE, +// SINGLE_SELECT, or ITERATION. It returns an empty string for unknown field types. +func (p ProjectField) DataType() string { + switch p.TypeName { + case "ProjectV2Field": + return p.Field.DataType + case "ProjectV2IterationField": + return p.IterationField.DataType + case "ProjectV2SingleSelectField": + return p.SingleSelectField.DataType + } + return "" +} + type SingleSelectFieldOptions struct { ID string Name string diff --git a/pkg/cmd/project/shared/queries/resolve.go b/pkg/cmd/project/shared/queries/resolve.go new file mode 100644 index 00000000000..3713200c43e --- /dev/null +++ b/pkg/cmd/project/shared/queries/resolve.go @@ -0,0 +1,191 @@ +package queries + +import ( + "fmt" + "net/url" + "strings" + + "github.com/shurcooL/githubv4" +) + +// This file holds name-based resolution for ProjectV2 fields, single-select +// options, and project items. It lets commands accept human-readable names +// (e.g. --field "Status" --value "In Progress") instead of node IDs. +// +// The resolvers mirror the semantics of the github-mcp-server Projects resolver +// so the CLI and MCP surfaces behave identically, with two deliberate +// differences documented on each type: +// - errors are human-readable (gh convention) rather than structured JSON, +// but still list candidate names so the message stays self-correctable; +// - resolution yields GraphQL node IDs (PVTF_/PVTSSF_/PVTI_), not the numeric +// databaseIDs the MCP REST path needs. +// +// Option resolution dispatches on the field's data type so that additional +// enum-like field types (MultiSelect, issue field values) can be added later +// without reworking the shared path. + +// OptionNotFoundError is returned when no single-select option matches the given +// name. Candidates lists the available option names. +type OptionNotFoundError struct { + FieldName string + Name string + Candidates []string +} + +func (e *OptionNotFoundError) Error() string { + if len(e.Candidates) == 0 { + return fmt.Sprintf("option %q not found on field %q; the field has no options", e.Name, e.FieldName) + } + return fmt.Sprintf("option %q not found on field %q; available options: %s", e.Name, e.FieldName, strings.Join(e.Candidates, ", ")) +} + +// OptionAmbiguousError is returned when more than one single-select option +// shares the given name. Candidates lists the option IDs of the matches. +type OptionAmbiguousError struct { + FieldName string + Name string + Candidates []string +} + +func (e *OptionAmbiguousError) Error() string { + return fmt.Sprintf("option %q is ambiguous on field %q; multiple options share this name (use --single-select-option-id with one of: %s)", e.Name, e.FieldName, strings.Join(e.Candidates, ", ")) +} + +// WrongFieldTypeError is returned when a field resolves by name but its data +// type does not support the requested operation, e.g. resolving an option name +// on a field that is not a single-select field. +type WrongFieldTypeError struct { + FieldName string + DataType string + Expected string +} + +func (e *WrongFieldTypeError) Error() string { + return fmt.Sprintf("field %q has data type %q, but %q was expected", e.FieldName, e.DataType, e.Expected) +} + +// ItemNotInProjectError is returned when an issue or pull request exists but is +// not an item on the target project. +type ItemNotInProjectError struct { + URL string + ProjectNumber int32 +} + +func (e *ItemNotInProjectError) Error() string { + return fmt.Sprintf("%s is not an item in project %d; add it first with `gh project item-add`", e.URL, e.ProjectNumber) +} + +// ResolveSingleSelectOptionID resolves optionName to its option ID on a +// single-select field. It returns a *WrongFieldTypeError if the field is not a +// single-select field, a *OptionNotFoundError if no option matches, and a +// *OptionAmbiguousError if more than one option shares the name. +func ResolveSingleSelectOptionID(field ProjectField, optionName string) (string, error) { + if field.Type() != "ProjectV2SingleSelectField" { + return "", &WrongFieldTypeError{ + FieldName: field.Name(), + DataType: field.DataType(), + Expected: "SINGLE_SELECT", + } + } + + var matches []string + for _, o := range field.Options() { + // Matching is case-insensitive to mirror the Projects v2 platform API, + // whose options(names:) argument downcases before matching. Unlike field + // names, option names are not guaranteed case-insensitively unique, so + // EqualFold may match more than one option; that still fires the + // OptionAmbiguousError path below. + if strings.EqualFold(o.Name, optionName) { + matches = append(matches, o.ID) + } + } + + switch len(matches) { + case 1: + return matches[0], nil + case 0: + names := make([]string, 0, len(field.Options())) + for _, o := range field.Options() { + names = append(names, o.Name) + } + return "", &OptionNotFoundError{FieldName: field.Name(), Name: optionName, Candidates: names} + default: + return "", &OptionAmbiguousError{FieldName: field.Name(), Name: optionName, Candidates: matches} + } +} + +// projectItemByURL resolves an issue or pull request URL to the ID of its +// corresponding item on the project identified by projectID. It is used to let +// item-edit address an item by issue/PR URL instead of a project item ID. +type projectItemByURL struct { + Resource struct { + Typename string `graphql:"__typename"` + Issue struct { + ProjectItems struct { + Nodes []struct { + ID string + Project struct { + ID string + } + } + } `graphql:"projectItems(first: $firstItems)"` + } `graphql:"... on Issue"` + PullRequest struct { + ProjectItems struct { + Nodes []struct { + ID string + Project struct { + ID string + } + } + } `graphql:"projectItems(first: $firstItems)"` + } `graphql:"... on PullRequest"` + } `graphql:"resource(url: $url)"` +} + +// ProjectItemIDByURL returns the project item ID for the issue or pull request +// at rawURL on the project identified by projectID. projectNumber is used only +// to build a helpful error message. It returns a *ItemNotInProjectError when the +// resource exists but is not an item on the project. +// +// The projectItems connection is read with a bounded page size; very large +// boards may exceed it. +func (c *Client) ProjectItemIDByURL(rawURL, projectID string, projectNumber int32) (string, error) { + uri, err := url.Parse(rawURL) + if err != nil { + return "", err + } + + variables := map[string]interface{}{ + "url": githubv4.URI{URL: uri}, + "firstItems": githubv4.Int(LimitMax), + } + + var query projectItemByURL + if err := c.doQueryWithProgressIndicator("GetProjectItemByURL", &query, variables); err != nil { + return "", err + } + + var nodes []struct { + ID string + Project struct { + ID string + } + } + switch query.Resource.Typename { + case "Issue": + nodes = query.Resource.Issue.ProjectItems.Nodes + case "PullRequest": + nodes = query.Resource.PullRequest.ProjectItems.Nodes + default: + return "", fmt.Errorf("resource not found, please check the URL: %s", rawURL) + } + + for _, n := range nodes { + if n.Project.ID == projectID { + return n.ID, nil + } + } + + return "", &ItemNotInProjectError{URL: rawURL, ProjectNumber: projectNumber} +} diff --git a/pkg/cmd/project/shared/queries/resolve_fields.go b/pkg/cmd/project/shared/queries/resolve_fields.go new file mode 100644 index 00000000000..1efe782cdc6 --- /dev/null +++ b/pkg/cmd/project/shared/queries/resolve_fields.go @@ -0,0 +1,108 @@ +package queries + +import ( + "fmt" + "sort" + "strings" +) + +// This file holds the ProjectV2 field-by-name and field-by-ID resolvers plus +// their error types. It is intentionally self-contained (only fmt/sort/strings +// and ProjectField.Name()/ID()) and is shared verbatim by the item-edit and +// item-list commands, which ship in separate pull requests. Keeping this file +// byte-identical in both lets Git auto-merge it cleanly (an "added by both" +// identical file dedupes to a single copy), so the two PRs can land in any +// order without a duplicate-symbol conflict. + +// FieldIDNotFoundError is returned when no project field matches the given node +// ID. Candidates lists the available fields as "name (id)" pairs so the caller +// can self-correct, keeping the ID path as actionable as the name path. +type FieldIDNotFoundError struct { + ID string + Candidates []string +} + +func (e *FieldIDNotFoundError) Error() string { + if len(e.Candidates) == 0 { + return fmt.Sprintf("field with ID %q not found in project; the project has no fields", e.ID) + } + return fmt.Sprintf("field with ID %q not found in project; available fields: %s", e.ID, strings.Join(e.Candidates, ", ")) +} + +// FieldNotFoundError is returned when no project field matches the given name. +// Candidates lists the available field names so the caller can self-correct. +type FieldNotFoundError struct { + Name string + Candidates []string +} + +func (e *FieldNotFoundError) Error() string { + if len(e.Candidates) == 0 { + return fmt.Sprintf("field %q not found in project; the project has no fields", e.Name) + } + return fmt.Sprintf("field %q not found in project; available fields: %s", e.Name, strings.Join(e.Candidates, ", ")) +} + +// FieldAmbiguousError is returned when more than one field shares the given +// name. Candidates lists the node IDs of the matching fields. +type FieldAmbiguousError struct { + Name string + Candidates []string +} + +func (e *FieldAmbiguousError) Error() string { + return fmt.Sprintf("field %q is ambiguous; multiple fields share this name (use --field-id with one of: %s)", e.Name, strings.Join(e.Candidates, ", ")) +} + +// ResolveFieldByName finds the single project field whose name matches name, +// case-insensitively. Matching is case-insensitive to mirror the Projects v2 +// platform API, which normalizes field names via a name_slug +// (name.downcase.gsub(" ","-")) and so guarantees field names are +// case-insensitively unique; EqualFold can therefore match at most one field. +// It returns a *FieldNotFoundError when nothing matches and a +// *FieldAmbiguousError when more than one field shares the name, each carrying +// candidates so the error is self-correctable. +func ResolveFieldByName(fields []ProjectField, name string) (ProjectField, error) { + var matches []ProjectField + for _, f := range fields { + if strings.EqualFold(f.Name(), name) { + matches = append(matches, f) + } + } + + switch len(matches) { + case 1: + return matches[0], nil + case 0: + names := make([]string, 0, len(fields)) + for _, f := range fields { + names = append(names, f.Name()) + } + sort.Strings(names) + return ProjectField{}, &FieldNotFoundError{Name: name, Candidates: names} + default: + ids := make([]string, 0, len(matches)) + for _, f := range matches { + ids = append(ids, f.ID()) + } + return ProjectField{}, &FieldAmbiguousError{Name: name, Candidates: ids} + } +} + +// ResolveFieldByID finds the project field whose node ID matches id, validating +// it against the fields returned with the project so an unknown ID fails with a +// candidate-carrying *FieldIDNotFoundError instead of silently rendering an +// empty column. Node IDs are matched exactly (they are case-sensitive). +func ResolveFieldByID(fields []ProjectField, id string) (ProjectField, error) { + for _, f := range fields { + if f.ID() == id { + return f, nil + } + } + candidates := make([]string, 0, len(fields)) + for _, f := range fields { + candidates = append(candidates, fmt.Sprintf("%s (%s)", f.Name(), f.ID())) + } + sort.Strings(candidates) + return ProjectField{}, &FieldIDNotFoundError{ID: id, Candidates: candidates} +} diff --git a/pkg/cmd/project/shared/queries/resolve_fields_test.go b/pkg/cmd/project/shared/queries/resolve_fields_test.go new file mode 100644 index 00000000000..d52645abffe --- /dev/null +++ b/pkg/cmd/project/shared/queries/resolve_fields_test.go @@ -0,0 +1,97 @@ +package queries + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// textField builds a ProjectV2Field (TEXT) fixture with the given id and name. +func textField(id, name string) ProjectField { + f := ProjectField{TypeName: "ProjectV2Field"} + f.Field.ID = id + f.Field.Name = name + f.Field.DataType = "TEXT" + return f +} + +// singleSelectField builds a ProjectV2SingleSelectField fixture with the given +// id, name, and options (alternating id/name pairs are passed as a slice). +func singleSelectField(id, name string, options []SingleSelectFieldOptions) ProjectField { + f := ProjectField{TypeName: "ProjectV2SingleSelectField"} + f.SingleSelectField.ID = id + f.SingleSelectField.Name = name + f.SingleSelectField.DataType = "SINGLE_SELECT" + f.SingleSelectField.Options = options + return f +} + +func TestResolveFieldByName(t *testing.T) { + fields := []ProjectField{ + textField("PVTF_title", "Title"), + singleSelectField("PVTSSF_status", "Status", []SingleSelectFieldOptions{ + {ID: "opt_todo", Name: "Todo"}, + {ID: "opt_inprog", Name: "In Progress"}, + }), + textField("PVTF_prio", "Priority"), + } + + t.Run("resolves an exact match", func(t *testing.T) { + got, err := ResolveFieldByName(fields, "Status") + require.NoError(t, err) + assert.Equal(t, "PVTSSF_status", got.ID()) + }) + + t.Run("resolves case-insensitively", func(t *testing.T) { + got, err := ResolveFieldByName(fields, "status") + require.NoError(t, err) + assert.Equal(t, "PVTSSF_status", got.ID()) + }) + + t.Run("not found lists candidate field names", func(t *testing.T) { + _, err := ResolveFieldByName(fields, "Statuss") + require.Error(t, err) + var nf *FieldNotFoundError + require.True(t, errors.As(err, &nf)) + assert.Equal(t, "Statuss", nf.Name) + assert.Equal(t, []string{"Priority", "Status", "Title"}, nf.Candidates) + assert.Contains(t, err.Error(), "available fields: Priority, Status, Title") + }) + + t.Run("ambiguous name lists candidate ids", func(t *testing.T) { + dup := append([]ProjectField{}, fields...) + dup = append(dup, textField("PVTF_status2", "Status")) + _, err := ResolveFieldByName(dup, "Status") + require.Error(t, err) + var amb *FieldAmbiguousError + require.True(t, errors.As(err, &amb)) + assert.ElementsMatch(t, []string{"PVTSSF_status", "PVTF_status2"}, amb.Candidates) + assert.Contains(t, err.Error(), "is ambiguous") + }) +} + +func TestResolveFieldByID(t *testing.T) { + fields := []ProjectField{ + textField("PVTF_title", "Title"), + singleSelectField("PVTSSF_status", "Status", nil), + textField("PVTF_prio", "Priority"), + } + + t.Run("resolves an exact id match", func(t *testing.T) { + got, err := ResolveFieldByID(fields, "PVTSSF_status") + require.NoError(t, err) + assert.Equal(t, "Status", got.Name()) + }) + + t.Run("not found lists candidate name (id) pairs", func(t *testing.T) { + _, err := ResolveFieldByID(fields, "PVTF_missing") + require.Error(t, err) + var nf *FieldIDNotFoundError + require.True(t, errors.As(err, &nf)) + assert.Equal(t, "PVTF_missing", nf.ID) + assert.Equal(t, []string{"Priority (PVTF_prio)", "Status (PVTSSF_status)", "Title (PVTF_title)"}, nf.Candidates) + assert.Contains(t, err.Error(), "available fields: Priority (PVTF_prio), Status (PVTSSF_status), Title (PVTF_title)") + }) +} diff --git a/pkg/cmd/project/shared/queries/resolve_test.go b/pkg/cmd/project/shared/queries/resolve_test.go new file mode 100644 index 00000000000..d3d341e4b85 --- /dev/null +++ b/pkg/cmd/project/shared/queries/resolve_test.go @@ -0,0 +1,126 @@ +package queries + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/h2non/gock.v1" +) + +func TestResolveSingleSelectOptionID(t *testing.T) { + status := singleSelectField("PVTSSF_status", "Status", []SingleSelectFieldOptions{ + {ID: "opt_todo", Name: "Todo"}, + {ID: "opt_inprog", Name: "In Progress"}, + {ID: "opt_done", Name: "Done"}, + }) + + t.Run("resolves an option name to its id", func(t *testing.T) { + id, err := ResolveSingleSelectOptionID(status, "In Progress") + require.NoError(t, err) + assert.Equal(t, "opt_inprog", id) + }) + + t.Run("resolves an option name case-insensitively", func(t *testing.T) { + id, err := ResolveSingleSelectOptionID(status, "in progress") + require.NoError(t, err) + assert.Equal(t, "opt_inprog", id) + }) + + t.Run("case-insensitive match that hits multiple options is ambiguous", func(t *testing.T) { + dup := singleSelectField("PVTSSF_status", "Status", []SingleSelectFieldOptions{ + {ID: "opt_lower", Name: "done"}, + {ID: "opt_upper", Name: "Done"}, + }) + _, err := ResolveSingleSelectOptionID(dup, "DONE") + require.Error(t, err) + var amb *OptionAmbiguousError + require.True(t, errors.As(err, &amb)) + assert.ElementsMatch(t, []string{"opt_lower", "opt_upper"}, amb.Candidates) + }) + + t.Run("not found lists candidate option names", func(t *testing.T) { + _, err := ResolveSingleSelectOptionID(status, "In progres") + require.Error(t, err) + var nf *OptionNotFoundError + require.True(t, errors.As(err, &nf)) + assert.Equal(t, "Status", nf.FieldName) + assert.Equal(t, []string{"Todo", "In Progress", "Done"}, nf.Candidates) + assert.Contains(t, err.Error(), "available options: Todo, In Progress, Done") + }) + + t.Run("ambiguous option lists candidate ids", func(t *testing.T) { + dup := singleSelectField("PVTSSF_status", "Status", []SingleSelectFieldOptions{ + {ID: "opt_a", Name: "Done"}, + {ID: "opt_b", Name: "Done"}, + }) + _, err := ResolveSingleSelectOptionID(dup, "Done") + require.Error(t, err) + var amb *OptionAmbiguousError + require.True(t, errors.As(err, &amb)) + assert.ElementsMatch(t, []string{"opt_a", "opt_b"}, amb.Candidates) + }) + + t.Run("wrong field type is rejected", func(t *testing.T) { + _, err := ResolveSingleSelectOptionID(textField("PVTF_title", "Title"), "In Progress") + require.Error(t, err) + var wt *WrongFieldTypeError + require.True(t, errors.As(err, &wt)) + assert.Equal(t, "TEXT", wt.DataType) + assert.Equal(t, "SINGLE_SELECT", wt.Expected) + }) +} + +func TestProjectItemIDByURL(t *testing.T) { + t.Run("returns the item id for the matching project", func(t *testing.T) { + defer gock.Off() + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "__typename": "Issue", + "projectItems": map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": "PVTI_other", "project": map[string]interface{}{"id": "PVT_other"}}, + {"id": "PVTI_match", "project": map[string]interface{}{"id": "PVT_target"}}, + }, + }, + }, + }, + }) + + client := NewTestClient() + id, err := client.ProjectItemIDByURL("https://github.com/monalisa/repo/issues/1", "PVT_target", 5) + require.NoError(t, err) + assert.Equal(t, "PVTI_match", id) + }) + + t.Run("errors when the resource is not an item on the project", func(t *testing.T) { + defer gock.Off() + gock.New("https://api.github.com"). + Post("/graphql"). + Reply(200). + JSON(map[string]interface{}{ + "data": map[string]interface{}{ + "resource": map[string]interface{}{ + "__typename": "Issue", + "projectItems": map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": "PVTI_other", "project": map[string]interface{}{"id": "PVT_other"}}, + }, + }, + }, + }, + }) + + client := NewTestClient() + _, err := client.ProjectItemIDByURL("https://github.com/monalisa/repo/issues/1", "PVT_target", 5) + require.Error(t, err) + var nip *ItemNotInProjectError + require.True(t, errors.As(err, &nip)) + assert.Contains(t, err.Error(), "is not an item in project 5") + }) +} diff --git a/pkg/cmd/search/code/code.go b/pkg/cmd/search/code/code.go index b34691d83dc..734ba8ff869 100644 --- a/pkg/cmd/search/code/code.go +++ b/pkg/cmd/search/code/code.go @@ -103,7 +103,7 @@ func NewCmdCode(f *cmdutil.Factory, runF func(*CodeOptions) error) *cobra.Comman cmd.Flags().StringVar(&opts.Query.Qualifiers.Filename, "filename", "", "Filter on filename") cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.In, "match", "", nil, []string{"file", "path"}, "Restrict search to file contents or file path") cmd.Flags().StringVarP(&opts.Query.Qualifiers.Language, "language", "", "", "Filter results by language") - cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository") + cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository, in `OWNER/REPO` format") cmd.Flags().StringVar(&opts.Query.Qualifiers.Size, "size", "", "Filter on size range, in kilobytes") cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on owner") diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go index 14af7c4256f..f07a9077683 100644 --- a/pkg/cmd/search/commits/commits.go +++ b/pkg/cmd/search/commits/commits.go @@ -117,7 +117,7 @@ func NewCmdCommits(f *cmdutil.Factory, runF func(*CommitsOptions) error) *cobra. cmd.Flags().StringVar(&opts.Query.Qualifiers.Hash, "hash", "", "Filter by commit hash") cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Merge, "merge", "", "Filter on merge commits") cmd.Flags().StringVar(&opts.Query.Qualifiers.Parent, "parent", "", "Filter by parent hash") - cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository") + cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository, in `OWNER/REPO` format") cmd.Flags().StringVar(&opts.Query.Qualifiers.Tree, "tree", "", "Filter by tree hash") cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.User, "owner", nil, "Filter on repository owner") cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility") diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go index cc4e7f78e5e..409cbf09b35 100644 --- a/pkg/cmd/search/issues/issues.go +++ b/pkg/cmd/search/issues/issues.go @@ -170,7 +170,7 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project") cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `owner/number`") cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions") - cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository") + cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository, in `OWNER/REPO` format") cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state") cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions") cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`") diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go index 07ea9155312..5f193165216 100644 --- a/pkg/cmd/search/prs/prs.go +++ b/pkg/cmd/search/prs/prs.go @@ -181,7 +181,7 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr cmd.Flags().BoolVar(&noProject, "no-project", false, "Filter on missing project") cmd.Flags().StringVar(&opts.Query.Qualifiers.Project, "project", "", "Filter on project board `owner/number`") cmd.Flags().StringVar(&opts.Query.Qualifiers.Reactions, "reactions", "", "Filter on `number` of reactions") - cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository") + cmd.Flags().StringSliceVarP(&opts.Query.Qualifiers.Repo, "repo", "R", nil, "Filter on repository, in `OWNER/REPO` format") cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.State, "state", "", "", []string{"open", "closed"}, "Filter based on state") cmd.Flags().StringVar(&opts.Query.Qualifiers.Team, "team-mentions", "", "Filter based on team mentions") cmd.Flags().StringVar(&opts.Query.Qualifiers.Updated, "updated", "", "Filter on last updated at `date`")