Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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).
Expand Down
29 changes: 29 additions & 0 deletions internal/cli/assignees_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand Down
27 changes: 20 additions & 7 deletions internal/cli/story.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/story_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
2 changes: 1 addition & 1 deletion skills/taiga-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions skills/taiga-cli/references/comandos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading