diff --git a/README.md b/README.md index ae003bd..f6c531f 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ taiga api PATCH userstories/123 --field comment="Deployed to staging" --auto-ver |---|---| | `taiga story list [--ref N] [--status S] [--assignee USER\|me] [--epic REF] [--swimlane L\|--no-swimlane] [--tag T]... [--search TEXT] [--closed[=false]]` | Every matching story, without manual paging. Repeated `--tag` must all match | | `taiga story get REF` / `taiga story get --id ID` | One story, with its web `url`; JSON output also carries `custom_attributes` (values with their own `version`) | -| `taiga story create --subject S [--description-file F\|-] [--status S] [--tag T]... [--swimlane L] [--assignee USER]... [--epic REF]` | Create a story; `--epic` links it to an epic after creating it | +| `taiga story create --subject S [--description-file F\|-] [--status S] [--tag T]... [--swimlane L] [--assignee USER]... [--owner-assignee USER] [--epic REF]` | Create a story; `--epic` links it to an epic after creating it | | `taiga story update REF [--subject S] [--description-file F\|-] [--append-description TEXT] [--status S] [--tag T]... [--add-tag T]... [--remove-tag T]... [--milestone M] [--swimlane L\|--clear-swimlane] [--add-assignee USER]... [--remove-assignee USER]... [--owner-assignee USER\|--clear-owner-assignee] [--block NOTE\|--unblock] [--epic REF\|--replace-epic REF --confirm-delete]` | Send only the fields that change, with the story `version`; then add the epic link, or make it the only one | | `taiga story close REF [--status S]` | Move to a closed status; never archives or deletes | | `taiga story field list REF` | The story's custom field values next to their definitions | @@ -75,6 +75,7 @@ taiga api PATCH userstories/123 --field comment="Deployed to staging" --auto-ver - Statuses, milestones and swimlanes take a name or an id of the project; users take an exact username, an id or `me`, and must be project members. A name used twice is `ambiguous_name`. - `--tag` replaces the tags; `--add-tag`/`--remove-tag` merge with the current ones. Taiga stores tags in lower case, so the CLI sends them that way. +- On `story create`, `--assignee` only fills the assignees (`assigned_users`) and leaves the main assignee (`assigned_to`, the one the board card shows) empty; `--owner-assignee` sets it and adds that user to the assignees too. - `--add-assignee`/`--remove-assignee` merge with the current assignees (`assigned_users`) and never change the main assignee (`assigned_to`); that takes `--owner-assignee` or `--clear-owner-assignee`. Taiga always shows the main assignee among the assignees, so removing them needs one of those two flags in the same command. Changing the main assignee keeps the others. - `--remove-assignee` also takes users who left the project. A write that changes assignees is not retried after a version conflict (exit 4; run it again, or use `--force-version`). - Taiga's concurrency check does not cover the main assignee (`assigned_to`). The CLI re-reads the assignees right before writing (a change since the first read is `version_conflict`) and checks the story after writing; a mismatch is `assignees_postcondition_failed` (exit 4): the write **was applied**, so check the story instead of re-running. A concurrent change in the short window between that re-read and the write is detected, not prevented. See [docs/api-notes.md](docs/api-notes.md). diff --git a/internal/cli/assignees_test.go b/internal/cli/assignees_test.go index 576378b..48c84b0 100644 --- a/internal/cli/assignees_test.go +++ b/internal/cli/assignees_test.go @@ -30,6 +30,8 @@ func TestStoryAssigneeFlagsRejectedBeforeNetwork(t *testing.T) { {"story", "create", "--subject", "s", "--assignee", ""}, {"story", "create", "--subject", "s", "--block", "x"}, {"story", "create", "--subject", "s", "--add-assignee", "svc"}, + {"story", "create", "--subject", "s", "--owner-assignee", " "}, + {"story", "create", "--subject", "s", "--clear-owner-assignee"}, } { _, stderr, code := runIn(t, env, "", args...) if code != 2 || !strings.Contains(stderr, "usage") { @@ -110,6 +112,7 @@ func TestStoryAssigneeOutsideProjectIsNotFound(t *testing.T) { {"story", "update", "246", "--owner-assignee", "outsider"}, {"story", "update", "246", "--add-assignee", "svc", "--block", "x", "--add-assignee", "outsider"}, {"story", "create", "--subject", "s", "--assignee", "outsider"}, + {"story", "create", "--subject", "s", "--owner-assignee", "outsider"}, } { _, stderr, code := runIn(t, f.env(), "", args...) if code != 5 || !strings.Contains(stderr, "not_found") { @@ -156,6 +159,32 @@ func TestStoryCreateWithAssignees(t *testing.T) { } } +// --owner-assignee on create sets assigned_to and also stores the owner in assigned_users, so a +// later change of assigned_to does not drop them from the assignees. +func TestStoryCreateWithOwnerAssignee(t *testing.T) { + f, calls := newStoryFake(t) + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"--owner-assignee", "svc"}, `{"assigned_to":6,"assigned_users":[6]}`}, + {[]string{"--assignee", "me", "--owner-assignee", "svc"}, `{"assigned_to":6,"assigned_users":[5,6]}`}, + {[]string{"--assignee", "svc", "--assignee", "me", "--owner-assignee", "6"}, `{"assigned_to":6,"assigned_users":[6,5]}`}, + } { + before := len(writes(calls)) + _, stderr, code := runIn(t, f.env(), "", append([]string{"story", "create", "--subject", "Nova"}, tc.args...)...) + w := writes(calls) + if code != 0 || len(w) != before+1 { + t.Fatalf("%v: %d %s", tc.args, code, stderr) + } + body := w[len(w)-1].body + got := bodyJSON(t, map[string]any{"assigned_to": body["assigned_to"], "assigned_users": body["assigned_users"]}) + if got != tc.want { + t.Errorf("%v: %s", tc.args, got) + } + } +} + // A concurrent change of a merged field between our read and our PATCH is a conflict with no // second PATCH; a concurrent change of an unrelated field allows the single guarded retry. func TestStoryAssigneeAndBlockConcurrentChanges(t *testing.T) { diff --git a/internal/cli/story.go b/internal/cli/story.go index a5ddee5..ce9d0e5 100644 --- a/internal/cli/story.go +++ b/internal/cli/story.go @@ -180,11 +180,12 @@ func (a *App) resolveStoryValue(cmd *cobra.Command, service *app.Service, field, } // assigneeFlags are the assignee and block flags: create takes the initial assignees, update -// merges into the current ones and changes the main assignee only when asked. +// merges into the current ones; both change the main assignee only when asked. func assigneeFlags(cmd *cobra.Command, update bool) { f := cmd.Flags() if !update { - f.StringArray("assignee", nil, "initial assignee (project username, id or me), repeatable") + f.StringArray("assignee", nil, "initial assignee (project username, id or me), repeatable; does not set the main assignee") + f.String("owner-assignee", "", "main assignee (assigned_to), also added to the assignees: project username, id or me") return } f.StringArray("add-assignee", nil, "add an assignee (project username, id or me), repeatable") @@ -220,13 +221,13 @@ func checkAssigneeFlags(cmd *cobra.Command, update bool) error { } } } - if !update { - return nil - } owner, _ := f.GetString("owner-assignee") if f.Changed("owner-assignee") && strings.TrimSpace(owner) == "" { return app.Usage("--owner-assignee cannot be blank") } + if !update { + return nil + } clearOwner, _ := f.GetBool("clear-owner-assignee") if f.Changed("owner-assignee") && clearOwner { return app.Usage("choose --owner-assignee or --clear-owner-assignee") @@ -270,11 +271,23 @@ func resolveAssignees(cmd *cobra.Command, service *app.Service, patch *app.Patch return app.MergeIDs(nil, ids, nil), nil } if !update { - if f.Changed("assignee") { - ids, err := resolve("assignee") + ids, err := resolve("assignee") + if err != nil { + return err + } + // The main assignee also goes into the stored list: one that is only in assigned_to + // drops out of the assignees when a later write changes assigned_to (docs/api-notes.md). + if f.Changed("owner-assignee") { + value, _ := f.GetString("owner-assignee") + user, err := service.Member(cmd.Context(), value) if err != nil { return err } + id := app.ID(user["id"]) + patch.Set["assigned_to"] = id + ids = app.MergeIDs(ids, []int64{id}, nil) + } + if f.Changed("assignee") || f.Changed("owner-assignee") { patch.Set["assigned_users"] = ids } return nil diff --git a/internal/cli/story_integration_test.go b/internal/cli/story_integration_test.go index c10868d..57ad542 100644 --- a/internal/cli/story_integration_test.go +++ b/internal/cli/story_integration_test.go @@ -201,6 +201,10 @@ func TestIntegrationStoryAssigneesAndBlock(t *testing.T) { if users(created) != admin || created["assigned_to"] != nil { t.Fatalf("create: %v %v", created["assigned_users"], created["assigned_to"]) } + owned := storyJSON(t, env, "", "story", "create", "--subject", "owner "+fmt.Sprint(time.Now().UnixNano()), "--assignee", testtaiga.ServiceUser, "--owner-assignee", "me") + if users(owned) != both || fmt.Sprint(owned["assigned_to"]) != admin { + t.Fatalf("create owner: %v %v", owned["assigned_users"], owned["assigned_to"]) + } update := []string{"story", "update", ref, "--add-assignee", testtaiga.ServiceUser, "--owner-assignee", "me", "--block", "aguardando \"B6\"\nção"} plan := storyJSON(t, env, "", append(update, "--dry-run")...) if plan["dry_run"] != true || storyJSON(t, env, "", "story", "get", ref)["version"] != created["version"] { diff --git a/skills/taiga-cli/SKILL.md b/skills/taiga-cli/SKILL.md index 4a04bca..930791c 100644 --- a/skills/taiga-cli/SKILL.md +++ b/skills/taiga-cli/SKILL.md @@ -51,7 +51,7 @@ Status, milestone, swimlane, campo e usuário aceitam nome ou id; usuário aceit ``` taiga story list [--status S] [--assignee me] [--epic REF] [--search TEXT]; taiga story get REF -taiga story create --subject S [--status S] [--epic REF] ... +taiga story create --subject S [--status S] [--owner-assignee U] [--epic REF] ... taiga story update REF --status S | --add-tag T | --add-assignee U | --milestone M taiga story comment REF --body TEXTO # ler: taiga story comments REF taiga task create --story REF --subject S; taiga task close REF diff --git a/skills/taiga-cli/references/comandos.md b/skills/taiga-cli/references/comandos.md index 0c280a5..21bf145 100644 --- a/skills/taiga-cli/references/comandos.md +++ b/skills/taiga-cli/references/comandos.md @@ -3,8 +3,8 @@ | tarefa | comando | |---|---| | ler stories | `taiga story list [--status S] [--assignee me] [--epic REF] [--tag T] [--search TEXT] [--closed=false]`, `taiga story get REF` | -| criar story | `taiga story create --subject S [--description-file F] [--status S] [--tag T] [--swimlane L] [--assignee U] [--epic REF]` | -| mudar status, tags, responsáveis, sprint, bloqueio | `taiga story update REF --status S`, `--add-tag T`, `--add-assignee U`, `--milestone M`, `--block NOTA`/`--unblock`, `--append-description TEXTO` | +| criar story | `taiga story create --subject S [--description-file F] [--status S] [--tag T] [--swimlane L] [--assignee U] [--owner-assignee U] [--epic REF]` (`--assignee` só preenche a lista de responsáveis; o responsável principal, que aparece no card, é `--owner-assignee`) | +| mudar status, tags, responsáveis, sprint, bloqueio | `taiga story update REF --status S`, `--add-tag T`, `--add-assignee U`, `--owner-assignee U`, `--milestone M`, `--block NOTA`/`--unblock`, `--append-description TEXTO` | | fechar | `taiga story close REF [--status S]` (só muda o status; não arquiva nem apaga) | | swimlane | `taiga swimlane list`; `taiga story update REF --swimlane L` ou `--clear-swimlane` | | comentário | `taiga story comment REF --body TEXTO` ou `--body-file F`; ler com `taiga story comments REF` |