diff --git a/README.md b/README.md index dd070cc..a88a23f 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ taiga api PATCH userstories/123 --field comment="Deployed to staging" --auto-ver - Swimlanes: `--swimlane` moves the story to a swimlane, `--clear-swimlane` takes it out of any; `story list --swimlane L` and `--no-swimlane` filter by it (always checked locally: Taiga ignores the documented `swimlane` parameter). A story created without `--swimlane` stays without one, even when the project has a default swimlane. Creating, renaming and reordering swimlanes stay in the web UI: the first swimlane of a project takes every story, and the order has no `version`. - `close` without `--status` uses the project's only closed status; with several (the default template has `Done` and `Archived`) it asks for one. - Every write accepts `--dry-run` (prints method, path and body) and, except `create`, `--force-version`. -- `comment` sends the text exactly as given (quotes, accents, line breaks) and never touches the description. A blank comment is refused. Taiga accepts any past `version` for a comment, so the version protects nothing there and there is no `--force-version`: the comment is sent once. If the answer is lost (network error or 5xx), the CLI looks for it in the story history: found, the command succeeds; otherwise it exits with `comment_unconfirmed` (exit 1), because a request still running on the server can land later. Check `story comments` before publishing again. Exit 7 (`network_error`) means the connection never opened, so nothing was sent. +- A write whose answer is inconclusive after the request left (network error, 5xx, or a redirect, which the CLI never follows: a proxy may have passed the request on) never exits 7. `update`, `close` and `field set` re-read the story: the change is there, success; otherwise `story_update_unconfirmed` (exit 1). `create` always exits with `story_create_unconfirmed` (exit 1), naming the new stories of this account with the same subject, if any (another process of the same account may have created them); it never links `--epic`. Do **not** re-run blindly: check with `taiga story get REF` or `taiga story list` first. Exit 7 is left for reads and for a connection that never opened. +- `comment` sends the text exactly as given (quotes, accents, line breaks) and never touches the description. A blank comment is refused. Taiga accepts any past `version` for a comment, so the version protects nothing there and there is no `--force-version`: the comment is sent once. If the answer is lost (network error, 5xx or a redirect, which the CLI never follows), the command always exits with `comment_unconfirmed` (exit 1), naming the new comments of this account with the same text found in the history, if any: none of them proves it came from this command (another process of the same account may have published the same text), and a request still running on the server can land later. Check `story comments` before publishing again. Exit 7 (`network_error`) means the connection never opened, so nothing was sent. - `comments` lists every comment, including edited and deleted ones (`edit_comment_date`, `delete_comment_date`). Comments written by Taiga's own GitLab integration (its inactive `gitlab-` user, with the push hook templates "This user story has been mentioned by …" and "… changed the status from [GitLab commit]…") are hidden unless `--include-system`; every other author, service accounts included, is always shown. Each entry carries `is_system`, `story_ref` and the story `url`. - `--epic` and `--replace-epic`: see "Linking stories to epics" below. @@ -154,7 +155,7 @@ taiga story update 246 --replace-epic 15 --confirm-delete | `taiga attachment download REF ATTACHMENT_ID [--task] [--to PATH\|-] [--overwrite] [--timeout 10m]` | Save an attachment, checked against its size and sha1 | - `upload` is idempotent by content: if the story or task already has an attachment with the same name and sha1, nothing is sent and that attachment comes back with `"created": false`. The same content under another name is a new attachment. -- The upload is sent once and never repeated. Its answer is checked (sha1, size, object); a mismatch is `attachment_postcondition_failed` (exit 4): the file **was stored**. If the answer is lost, the CLI looks for a new attachment with that name and sha1: found, success; otherwise `attachment_unconfirmed` (exit 1). Check `attachment list` before uploading again. +- The upload is sent once and never repeated. Its answer is checked (sha1, size, object); a mismatch is `attachment_postcondition_failed` (exit 4): the file **was stored**. If the answer is lost after the request left (network error, 5xx or a redirect), the command always exits with `attachment_unconfirmed` (exit 1), naming any new attachment with that name and sha1 (another process of the same account may have attached it). A lost body after a 2xx is applied: the new attachment is the answer. Check `attachment list` before uploading again. - File names Taiga would store differently (control characters, a backslash, an HTML entity such as `&`, with or without `;`, or any `&#`) are refused: rename the file. The idempotence compares the stored name. - The CLI has no size limit. The proxy in front of Taiga has one (50 MB at Basis): above it the upload fails with `payload_too_large` (exit 2). Empty files are refused, as Taiga refuses them. - `--dry-run` prints the form fields and the file's name, size and sha1, never its content; every value of the plan (the description, the file name) is printed with URL queries and `token=` values hidden. The real upload sends them as given. @@ -179,7 +180,7 @@ taiga attachment download 18 3122 --task --to - | less | `taiga field create --kind story\|task --name NAME --type text\|date\|checkbox [--description TEXT] [--dry-run]` | Create a definition unless one with that name exists | | `taiga story field list REF` / `taiga story field set REF ["Name=value"]... [--unset NAME]... [--dry-run] [--force-version]` | Read and merge a story's values | -- `field create` is idempotent: an existing definition with the same name (case-sensitive) and type is returned unchanged; one with another type, or another description when `--description` is given, is `field_definition_conflict` (exit 2). Definitions are never changed or deleted. +- `field create` is idempotent: an existing definition with the same name (case-sensitive) and type is returned unchanged; one with another type, or another description when `--description` is given, is `field_definition_conflict` (exit 2). Definitions are never changed or deleted. An inconclusive answer (network error after the request left, 5xx, redirect) is `field_create_unconfirmed` (exit 1), naming a definition with that name if one is there; Taiga refuses a second definition with the same name, so it is never created twice. - Only `text`, `date` and `checkbox` are supported so far. Values: text as is (`null` is text), checkbox `true`/`false`, date `YYYY-MM-DD`. The name ends at the first `=`. - `--unset NAME` (repeatable, name or id) clears a `checkbox` or `date` field: Taiga stores `null` under its key, which stays even when it is the last field (Taiga refuses an empty dictionary). A field without a value, or already `null`, writes nothing. Text fields cannot be unset (usage error); set them to empty with `Name=`. JSON output shows the cleared value as `null`; text output shows it empty, like a field without value. The same merge, check and `--dry-run` apply. - `story field set` reads the values, merges the named fields and writes the whole dictionary with the `version` of the values resource, not the story's. Unchanged values write nothing. Values of fields without a definition are kept. diff --git a/docs/api-notes.md b/docs/api-notes.md index 9cdcb2a..27ef98d 100644 --- a/docs/api-notes.md +++ b/docs/api-notes.md @@ -225,8 +225,8 @@ Consequência na CLI: `field create` é idempotente pela leitura do catálogo an mesma definição entre a leitura e o `POST`, o 400 em `name` faz a CLI reler e aceitar a definição só se for compatível (mesmo tipo e, se `--description` foi dado, mesma descrição). O `POST` nunca é repetido. Limite não sondado: se os dois `POST` passarem juntos pelo validador, quem recusa é o `unique_together` do banco, o que pode -virar 500 (`server_error`, exit 7) em vez do 400; a unicidade continua garantida e repetir o comando encontra a -definição. A CLI também recusa `=` no nome, porque `story field set` corta a atribuição no primeiro `=`. Definições +virar 500 em vez do 400, que a CLI trata como resultado incerto (`field_create_unconfirmed`, exit 1, US #274); a +unicidade continua garantida e repetir o comando encontra a definição. A CLI também recusa `=` no nome, porque `story field set` corta a atribuição no primeiro `=`. Definições existentes nunca são alteradas nem apagadas. ### Valores @@ -339,13 +339,14 @@ Consequências na CLI (`story comment`): ocultada); - resposta 2xx ilegível (`UnreadableBodyError`, corpo truncado) segue o caminho de releitura das outras escritas: o status já confirma a gravação; se a releitura falhar, `write_applied`; -- erro sem resposta conclusiva (rede depois de aberta a conexão, timeout ou 5xx): antes do `PATCH` a CLI guarda os - ids dos comentários do próprio usuário (`users/me`) com exatamente o mesmo texto; depois da falha relê o histórico. - Um id novo com o texto = publicado (sucesso, story relida). Nenhum, ou histórico ilegível = `comment_unconfirmed` - (exit 1): a ausência não prova que o `PATCH` falhou, porque um gateway pode responder 503 enquanto o servidor ainda - grava, e o comentário aparece depois da conferência (achado da revisão do PR #7: um script que repetia o exit 7 - criou duas cópias). O erro de rede original (exit 7, repetível) só sai quando a conexão nem abriu (DNS, conexão - recusada). +- erro sem resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx, que a CLI nunca + segue): antes do `PATCH` a CLI guarda os ids dos comentários do próprio usuário (`users/me`) com exatamente o + mesmo texto; depois da falha relê o histórico e sai **sempre** com `comment_unconfirmed` (exit 1), nomeando os ids + novos com o texto, se houver. Um id novo não prova que veio deste `PATCH` (outro processo da mesma conta pode ter + publicado o mesmo texto; decisão de 2026-10-03, opção B, estendida ao comentário na US #274), e a ausência não + prova que o `PATCH` falhou, porque um gateway pode responder 503 enquanto o servidor ainda grava, e o comentário + aparece depois da conferência (achado da revisão do PR #7: um script que repetia o exit 7 criou duas cópias). O + erro de rede original (exit 7, repetível) só sai quando a conexão nem abriu (DNS, conexão recusada). ### Leitura: `GET history/userstory/` @@ -452,8 +453,9 @@ mesmo padrão dos responsáveis (US #247). Constante `statusOrderCheckedWrite = - **antes:** relê o catálogo logo antes do bulk; se algum status apareceu, sumiu, mudou de nome, de id ou de `order` desde a leitura do plano (mais os criados pelo próprio apply), recusa com `project_changed` (exit 4) sem enviar; - **depois:** relê e exige exatamente a ordem pretendida. Se não bate, `status_order_postcondition_failed` (exit 4), - dizendo que a escrita **foi aplicada** (ou teve resultado incerto, em rede/5xx) e para não repetir às cegas. 4xx do - bulk é recusa: o erro dele, sem conferência. Resposta perdida depois do 2xx: vale a releitura; + dizendo que a escrita **foi aplicada** (ou teve resultado incerto, em rede/5xx/3xx) e para não repetir às cegas. 4xx do + bulk é recusa: o erro dele, sem conferência. Resposta perdida depois do 2xx: vale a releitura. Resultado incerto e + releitura que falha: `status_order_unconfirmed` (exit 1); - nunca há repetição automática do bulk. Como o bulk grava posições absolutas, rodar o apply de novo depois de conferir é seguro: ele replaneja e confere outra vez; - limite: uma mudança que cai entre a releitura e o bulk é sobrescrita; só é detectada se deixar outra ordem na diff --git a/docs/errors.md b/docs/errors.md index db1a8dc..b2480ba 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -17,7 +17,9 @@ Todo erro sai no stderr como `{"error":{"code","source","stage","cause","recover ## Códigos -Um exit code agrupa vários `code`. Automação deve decidir pelo `code`, nunca só pelo exit: o exit 4 vale tanto para `version_conflict` (nada foi gravado, ou a gravação foi recusada; reler e repetir é seguro) quanto para `assignees_postcondition_failed` e `field_values_postcondition_failed` (a gravação **foi aplicada**; repetir às cegas não é seguro) e para `project_changed` (parte do plano pode ter sido aplicada; o resultado no stdout diz o quê), `status_order_postcondition_failed` (a ordem foi gravada), `attachment_postcondition_failed` (o anexo foi gravado) e `epic_links_postcondition_failed` (o vínculo novo pode ter sido gravado: `cause` diz). No exit 1, `write_applied`, `comment_unconfirmed` e `attachment_unconfirmed` também são "gravado, ou talvez gravado: não repetir às cegas"; `epic_link_unconfirmed` e `epic_replace_incomplete` são a exceção: o vínculo é idempotente e rodar **o mesmo comando** de novo converge; `story_created_link_failed` diz que a story **foi criada** e que repetir a criação a duplicaria, e `story_updated_link_failed` que os campos foram gravados e que repetir o `update` os repetiria. +Um exit code agrupa vários `code`. Automação deve decidir pelo `code`, nunca só pelo exit: o exit 4 vale tanto para `version_conflict` (nada foi gravado, ou a gravação foi recusada; reler e repetir é seguro) quanto para `assignees_postcondition_failed` e `field_values_postcondition_failed` (a gravação **foi aplicada**; repetir às cegas não é seguro) e para `project_changed` (parte do plano pode ter sido aplicada; o resultado no stdout diz o quê), `status_order_postcondition_failed` (a ordem foi gravada), `attachment_postcondition_failed` (o anexo foi gravado) e `epic_links_postcondition_failed` (o vínculo novo pode ter sido gravado: `cause` diz). No exit 1, `write_applied` e todo `*_unconfirmed` (`story_create_unconfirmed`, `story_update_unconfirmed`, `task_create_unconfirmed`, `task_update_unconfirmed`, `comment_unconfirmed`, `attachment_unconfirmed`, `field_create_unconfirmed`, `status_create_unconfirmed`, `status_order_unconfirmed`) e `project_apply_interrupted` também são "gravado, ou talvez gravado: não repetir às cegas"; `epic_link_unconfirmed` e `epic_replace_incomplete` são a exceção: o vínculo é idempotente e rodar **o mesmo comando** de novo converge; `story_created_link_failed` diz que a story **foi criada** e que repetir a criação a duplicaria, e `story_updated_link_failed` que os campos foram gravados e que repetir o `update` os repetiria. + +Escrita enviada sem resposta conclusiva — rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx (a CLI nunca segue `Location`; um proxy pode ter repassado o pedido) — nunca sai com exit 7 num comando curado (US #274): a alteração é conferida por releitura (`*_update_unconfirmed` quando não se confirma) e a criação sai **sempre** com `*_create_unconfirmed`, `comment_unconfirmed` ou `attachment_unconfirmed`, nomeando as candidatas (decisão de 2026-10-03, opção B). Um `PATCH` recusado por conflito de `version` cuja releitura para a nova tentativa falha sai com o erro dessa leitura: nada foi gravado. O corpo de um 3xx nunca é copiado na `cause` (ele diz para onde o redirect aponta). Exit 7 fica para leitura pura, para conexão que nem abriu (DNS, conexão recusada: nada chegou ao Taiga), para o login (`auth`) e para o `taiga api`, que não tem como conferir. | code | exit | source | quando ocorre | recuperação | |---|---|---|---|---| @@ -65,12 +67,17 @@ Um exit code agrupa vários `code`. Automação deve decidir pelo `code`, nunca | `epic_links_postcondition_failed` | 4 | `api` | vínculo de épico: a escrita foi feita, mas a releitura não mostra o resultado pedido (o épico não está entre os da story; na troca, sobrou outro épico), ou, na troca, um épico que não estava na primeira leitura apareceu, ou o épico novo sumiu, entre o `POST` e os `DELETE` (nada foi apagado). `cause` diz se o vínculo novo está gravado ("is saved") ou não ("is not among them"). O vínculo não tem `version` e o Taiga não acusa a corrida; `cause` traz os épicos encontrados | **não** repetir às cegas: conferir com `taiga story get` e decidir | | `attachment_postcondition_failed` | 4 | `api` | `attachment upload`: o anexo **foi gravado**, mas a resposta mostra `sha1`, `size` ou objeto diferentes do arquivo enviado (por exemplo, o arquivo mudou entre o cálculo do hash e o envio); `cause` traz o id e os valores dos dois lados | **não** repetir às cegas: conferir com `taiga attachment list` e decidir | | `project_changed` | 4 | — | `project apply`: o projeto mudou durante a execução — um status ou campo com o mesmo nome apareceu com outros valores, ou um com nome igual a menos de maiúsculas apareceu, entre o plano e a criação (o catálogo é relido antes de cada criação), ou a releitura final ainda encontra ações ou drift (inclusive dois nomes iguais a menos de maiúsculas); na reordenação, a ordem relida logo antes do `bulk_update_order` não é a do plano (nada é enviado). O que já foi aplicado está em `applied`; nada é desfeito nem repetido | rodar `taiga project plan` de novo e revisar antes de aplicar | -| `status_order_postcondition_failed` | 4 | `api` | `project apply` com reordenação: o `bulk_update_order` foi **aplicado** (ou teve resultado incerto: rede ou 5xx), mas a releitura logo depois não mostra a ordem pretendida — outra mudança caiu junto. O Taiga não tem controle de concorrência na ordem dos status (ver `docs/api-notes.md`); a conferência detecta, não impede. `cause` traz a ordem encontrada e a pretendida. Nunca há repetição automática | **não** repetir às cegas: conferir com `taiga status list`, rodar `taiga project plan` e decidir | -| `write_applied` | 1 | a da releitura (`api` ou `network`) | a escrita (`POST`/`PATCH`/`PUT`) foi **confirmada** pelo status HTTP, mas nem a resposta dela decodificou (ou chegou inteira: conexão caída no meio do corpo depois do 2xx) nem a releitura funcionou; `cause` traz o status da escrita e a falha da releitura. Em `taiga api` e na criação (`story create`, `task create`, `field create`), que não têm como reler sem a resposta, o corpo truncado depois do 2xx já dá `write_applied`. O cliente nunca repete essa escrita. Sai com 1, e não com 7, para que scripts que repetem erros de rede não repitam a escrita | **não** repetir o comando: a alteração já está gravada; conferir com `taiga story get` ou `taiga story list` (numa task, a recuperação cita `taiga task get REF` ou, na criação, `taiga task list --story REF`) | -| `comment_unconfirmed` | 1 | a do `PATCH` (`api` ou `network`) | `story comment`/`task comment`: o `PATCH` não teve resposta conclusiva (rede depois de aberta a conexão, timeout ou 5xx; na task, também redirect 3xx) e o histórico não mostra o comentário novo, ou não pôde ser lido. O comentário **pode** ter sido publicado: um gateway pode responder 5xx enquanto o `PATCH` ainda roda no servidor e grava depois da conferência. Nunca há repetição automática. Sai com 1, e não com 7, para que scripts que repetem erros de rede não publiquem duas vezes. Quando o histórico mostra o comentário novo, o comando termina com sucesso; `network_error` (exit 7) só sai quando a conexão nem abriu (DNS, conexão recusada), ou seja, nada chegou ao Taiga | esperar, conferir com `taiga story comments REF` (ou `taiga task comments REF`) e publicar de novo só se ainda faltar | +| `status_order_postcondition_failed` | 4 | `api` | `project apply` com reordenação: o `bulk_update_order` foi **aplicado** (ou teve resultado incerto: rede, 5xx ou redirect 3xx), mas a releitura logo depois não mostra a ordem pretendida — outra mudança caiu junto. O Taiga não tem controle de concorrência na ordem dos status (ver `docs/api-notes.md`); a conferência detecta, não impede. `cause` traz a ordem encontrada e a pretendida. Nunca há repetição automática | **não** repetir às cegas: conferir com `taiga status list`, rodar `taiga project plan` e decidir | +| `write_applied` | 1 | a da releitura (`api` ou `network`) | a escrita (`POST`/`PATCH`/`PUT`) foi **confirmada** pelo status HTTP, mas nem a resposta dela decodificou (ou chegou inteira: conexão caída no meio do corpo depois do 2xx) nem a releitura funcionou; `cause` traz o status da escrita e a falha da releitura. Em `taiga api` e na criação (`story create`, `task create`, `field create`), que não têm como reler sem a resposta, o corpo truncado depois do 2xx já dá `write_applied`. No status do `project apply`, o corpo perdido depois do 201 é conferido no catálogo: se a leitura falha (rede, 5xx, 3xx), sai `write_applied`, e não o erro repetível da leitura, mesmo sendo a primeira ação do plano (a recuperação cita `taiga project plan`). O cliente nunca repete essa escrita. Sai com 1, e não com 7, para que scripts que repetem erros de rede não repitam a escrita | **não** repetir o comando: a alteração já está gravada; conferir com `taiga story get` ou `taiga story list` (numa task, a recuperação cita `taiga task get REF` ou, na criação, `taiga task list --story REF`) | +| `comment_unconfirmed` | 1 | a do `PATCH` (`api` ou `network`) | `story comment`/`task comment`: o `PATCH` não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx). É **sempre** este erro, nunca sucesso, mesmo que o comentário esteja no histórico (opção B, estendida ao comentário na US #274): outro processo com a mesma conta pode ter publicado o mesmo texto. A `cause` lista os ids das entradas novas desta conta com exatamente o texto enviado, ou diz que nenhuma foi encontrada, ou que o histórico não pôde ser lido; ausência não prova nada, porque um gateway pode responder 5xx enquanto o `PATCH` ainda roda no servidor e grava depois da conferência. O comentário **pode** ter sido publicado. Nunca há repetição automática. Sai com 1, e não com 7, para que scripts que repetem erros de rede não publiquem duas vezes; `network_error` (exit 7) só sai quando a conexão nem abriu (DNS, conexão recusada), ou seja, nada chegou ao Taiga | **não** repetir às cegas (publicaria de novo, e um comentário ainda não encontrado pode gravar depois): esperar e conferir com `taiga story comments REF` (ou `taiga task comments REF`) | +| `story_create_unconfirmed` | 1 | a do `POST` (`api` ou `network`) | `story create` (com ou sem `--epic`): o `POST userstories` não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx). É **sempre** este erro, nunca sucesso, pelo mesmo motivo do `task_create_unconfirmed`. A `cause` lista ref e id das candidatas (stories novas do projeto desde a leitura anterior ao `POST`, desta conta, com o mesmo `subject`), ou diz que nenhuma foi encontrada, ou que a lista não pôde ser lida. A story **pode** ter sido criada; o vínculo do `--epic` não é tentado. Nunca há repetição automática; sai com 1, e não com 7, porque repetir criaria outra story | **não** repetir o comando às cegas (criaria outra story, e uma ainda não encontrada pode ser gravada depois): inspecionar as candidatas com `taiga story get REF` e o projeto com `taiga story list`; com `--epic`, vincular a escolhida com `taiga epic link EPIC REF` | | `task_create_unconfirmed` | 1 | a do `POST` (`api` ou `network`) | `task create`: o `POST tasks` não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx). É **sempre** este erro, nunca sucesso, mesmo que a task esteja lá (decisão de 2026-10-03): nenhuma comparação de estado prova que uma task veio deste `POST`, porque outro processo com a mesma conta pode ter criado a mesma task e campos como valores customizados e watchers não entram na comparação. A `cause` lista ref e id das candidatas (tasks novas desde a leitura anterior ao `POST`, na mesma story, desta conta, com o mesmo `subject`), ou diz que nenhuma foi encontrada, ou que a lista não pôde ser lida; lista vazia ou ilegível não prova que a task não existe, porque o pedido pode gravar depois. A task **pode** ter sido criada. Nunca há repetição automática; sai com 1, e não com 7, porque repetir criaria outra task | **não** repetir o comando às cegas (criaria outra task, e uma task ainda não encontrada pode ser gravada depois): inspecionar as candidatas com `taiga task get REF` e a story com `taiga task list --story REF` | -| `task_update_unconfirmed` | 1 | a do `PATCH` (`api` ou `network`) | `task update`/`task close`/`task field set`: o `PATCH` não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx) e a releitura da task (ou dos valores, no `field set`) não mostra todos os campos enviados com o valor pedido, ou não pôde ser feita; no `field set`, também quando os valores batem mas a `version` não é a seguinte à da leitura (outra escrita caiu junto e pode ter sido sobrescrita: esse recurso não tem OCC); com `--force-version`, essa conferência de versão é pulada, como no caminho normal, e só os valores são comparados. A alteração **pode** ter sido aplicada. Quando a releitura mostra os campos, o comando termina com sucesso. Sai com 1, e não com 7: repetir aplicaria de novo um `--append-description` | **não** repetir o comando às cegas: esperar, conferir com `taiga task get REF` (ou `taiga task field list REF`) e repetir só o que ainda faltar | -| `attachment_unconfirmed` | 1 | a do `POST` (`api` ou `network`) | `attachment upload`: o `POST` não teve resposta conclusiva (rede depois de aberta a conexão, timeout ou 5xx) e a lista de anexos não mostra um id novo com o mesmo nome e `sha1`, ou não pôde ser lida. O anexo **pode** ter sido gravado (o servidor pode terminar depois da conferência). Nunca há repetição automática; sai com 1, e não com 7, pelo mesmo motivo do `comment_unconfirmed`. Quando a lista mostra o anexo novo, o comando termina com sucesso; `network_error` (exit 7) só quando a conexão nem abriu | esperar, conferir com `taiga attachment list REF` e enviar de novo só se ainda faltar | +| `story_update_unconfirmed`, `task_update_unconfirmed` | 1 | a do `PATCH` (`api` ou `network`) | `story update`/`story close`/`story field set` (inclusive bloqueio, swimlane e responsáveis) e `task update`/`task close`/`task field set`: o `PATCH` não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx) e a releitura da story ou da task (ou dos valores, no `field set`) não mostra todos os campos enviados com o valor pedido, ou não pôde ser feita; no `field set`, também quando os valores batem mas a `version` não é a seguinte à da leitura (outra escrita caiu junto e pode ter sido sobrescrita: esse recurso não tem OCC); na story com responsáveis, também quando a releitura não passa nas conferências do `assignees_postcondition_failed` (quem devia entrar, sair, o responsável principal, a `version` seguinte à da releitura antes do `PATCH`); com `--force-version`, essa conferência de versão é pulada, como no caminho normal, e só os valores são comparados. A alteração **pode** ter sido aplicada. Quando a releitura mostra os campos, o comando termina com sucesso. Sai com 1, e não com 7: repetir aplicaria de novo um `--append-description` | **não** repetir o comando às cegas: esperar, conferir com `taiga story get REF`/`taiga task get REF` (ou `field list REF`) e repetir só o que ainda faltar | +| `attachment_unconfirmed` | 1 | a do `POST` (`api` ou `network`) | `attachment upload`: o `POST` não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx). É **sempre** este erro, nunca sucesso (opção B, estendida ao anexo na US #274): a `cause` lista os ids novos com o mesmo nome e `sha1` (outro processo com a mesma conta pode tê-los enviado), ou diz que a lista não os mostra, ou que não pôde ser lida. O anexo **pode** ter sido gravado (o servidor pode terminar depois da conferência). Nunca há repetição automática; sai com 1, e não com 7, pelo mesmo motivo do `comment_unconfirmed`. Corpo perdido depois de um 2xx não é este caso: a gravação está confirmada e o anexo novo é a resposta; `network_error` (exit 7) só quando a conexão nem abriu | **não** enviar de novo às cegas (anexaria duas vezes): esperar e conferir com `taiga attachment list REF` | +| `field_create_unconfirmed` | 1 | a do `POST` (`api` ou `network`) | `field create` (e o campo de um `project apply`): o `POST` da definição não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx — inclusive o 500 de dois `POST` simultâneos, ver `docs/api-notes.md` — ou redirect 3xx). É **sempre** este erro (opção B): a `cause` nomeia a definição com o mesmo nome encontrada depois (id e tipo), ou diz que nenhuma foi encontrada, ou que o catálogo não pôde ser lido | não supor que falta: esperar e conferir com `taiga field list --kind KIND`. O Taiga recusa segunda definição com o mesmo nome, então rodar de novo nunca a duplica (e devolve a existente se for compatível) | +| `status_create_unconfirmed` | 1 | a do `POST` (`api` ou `network`) | `project apply`: o `POST userstory-statuses` não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx ou redirect 3xx). É **sempre** este erro (opção B): a `cause` nomeia o status com o mesmo nome encontrado depois (id, cor, `closed`), ou diz que nenhum foi encontrado, ou que o catálogo não pôde ser lido. O que já foi aplicado está em `applied` | rodar `taiga project plan` para ver o que falta; o Taiga recusa segundo status com o mesmo nome, então um novo `apply` nunca o duplica | +| `status_order_unconfirmed` | 1 | a do `POST` (`api` ou `network`) | `project apply` com reordenação: o `bulk_update_order` não teve resposta conclusiva (rede depois de aberta a conexão, 5xx ou redirect 3xx) e a releitura dos status, que decidiria, falhou | **não** repetir às cegas: conferir `taiga status list`, rodar `taiga project plan` e decidir | +| `project_apply_interrupted` | 1 | a do erro original | `project apply`: uma ou mais ações **foram aplicadas** (estão em `applied`) e depois uma falha que sozinha seria exit 7 (por exemplo a leitura do catálogo para a próxima ação ou a releitura final, com rede ou 5xx) interrompeu o resto. `cause` traz o código original entre colchetes | não supor que nada foi gravado: rodar `taiga project plan` para ver o que falta e aplicar de novo (o `apply` replaneja e nunca cria um nome duas vezes) | | `epic_link_unconfirmed` | 1 | a do `POST` (`api` ou `network`) | `epic link`, `--epic` ou `--replace-epic`: o `POST epics//related_userstories` não teve resposta conclusiva (rede depois de aberta a conexão, timeout, 5xx, redirect 3xx — a CLI não segue e um proxy pode ter repassado o pedido —, resposta ilegível) e a releitura da story não mostra o épico, ou não pôde ser lida. O vínculo **pode** ter sido criado; nenhum vínculo antigo foi apagado. Nunca há repetição automática. Quando a releitura mostra o vínculo, o comando segue; `network_error` (exit 7) só quando a conexão nem abriu | rodar **o mesmo comando** de novo: o Taiga recusa vínculo duplicado (400), que a CLI confere pela releitura | | `epic_replace_incomplete` | 1 | `api` | troca de épico (`--replace`/`--replace-epic`): o vínculo novo **foi gravado**, mas um `DELETE` de vínculo antigo falhou (rede, 5xx, 403) ou a story não pôde ser relida antes de apagar. `cause` traz o estado: `linked`, `removed` e `remaining` (o primeiro de `remaining` pode ter sido apagado se a resposta se perdeu). Cada `DELETE` é enviado uma vez só | rodar **o mesmo comando** de novo: ele relê a story e termina a troca (`DELETE` repetido que dá 404 conta como feito). Quando o Taiga **recusou** o `DELETE` (4xx, como 403 sem `modify_epic`), repetir não adianta e o `recovery` diz isso: conferir a permissão com `taiga auth status --diagnose` ou remover o vínculo restante pela interface web | | `story_updated_link_failed` | 1 | a do erro do vínculo | `story update` com outros campos e `--epic`/`--replace-epic`: o `PATCH` dos campos **foi gravado**, mas o vínculo falhou depois (qualquer erro: rede, `forbidden`, conflito nos épicos, resultado incerto, troca incompleta). `cause` traz o código original do vínculo entre colchetes (`[version_conflict]`, `[epic_link_unconfirmed]`, `[network_error]`…) e o estado. Nunca sai com 7 nem com o código original, que poderia ser lido como "seguro repetir" | **não** repetir o `update` (repetiria as outras mudanças, como `--append-description`). O `recovery` compõe esse aviso com a orientação do erro do vínculo: resultado incerto ou troca interrompida → `taiga epic link EPIC REF` (com `--replace --confirm-delete` na troca), que converge; épicos mudados por outra pessoa (`version_conflict`, `epic_links_postcondition_failed`) → conferir com `taiga story get REF` e decidir antes de nova troca; `DELETE` recusado ou `forbidden` → conferir `modify_epic` (`taiga auth status --diagnose`) e só então concluir | @@ -78,9 +85,9 @@ Um exit code agrupa vários `code`. Automação deve decidir pelo `code`, nunca | `attachment_url_untrusted` | 1 | `api` | `attachment download`: o `url` assinado devolvido pelo Taiga não é da mesma origem (esquema, host e porta) da URL em uso; a CLI não segue o token para outro lugar e não faz requisição | não repetir: conferir o `MEDIA_URL` da instância (`TAIGA_SITES_DOMAIN`) e a `--url` em uso | | `not_found` | 5 | `api` ou — | a API respondeu 404; ou nome, ref de épico ou usuário inexistente no projeto (usuário fora de `memberships` também); ou `epic list`/`epic get`/`epic link` (e `--epic`/`--replace-epic` em story) num projeto com o módulo de épicos desligado ("the epics module is disabled in this project") | conferir o caminho, a ref ou o nome; para épicos, ligar o módulo nas configurações do projeto ou ler por `taiga api` | | `forbidden` | 6 | `api` | a API respondeu 403; ou `project apply` (também com `--dry-run`) sem `admin_project_values` no projeto, conferido antes de qualquer escrita | a conta não tem permissão; em `project apply`, usar a conta de um admin do projeto | -| `network_error` | 7 | `network` | falha de rede, DNS, TLS ou timeout de 30 s (em `attachment upload/download`, o `--timeout` da transferência), sem status 2xx de escrita (esse caso é `write_applied`, ou segue para releitura e pós-condição) | conferir a conectividade (agentes em sandbox precisam de rede) | +| `network_error` | 7 | `network` | falha de rede, DNS, TLS ou timeout de 30 s (em `attachment upload/download`, o `--timeout` da transferência), numa leitura, ou numa escrita cuja conexão nem abriu (DNS, conexão recusada: nada foi enviado). Escrita curada enviada sem resposta segue para releitura (`*_unconfirmed`, exit 1) ou, com status 2xx, `write_applied`; só `taiga api` e o login ainda dão 7 depois de enviar | conferir a conectividade (agentes em sandbox precisam de rede) | | `local_write_failed` | 1 | `file` | `attachment download`: o sistema de arquivos local falhou (criar o temporário, gravar, fechar, ajustar a permissão ou dar o nome final: disco cheio, diretório sem escrita) ou o leitor do stdout fechou. Nenhum arquivo é salvo e o download não é repetido; não é erro de rede. Com `--to -`, parte do arquivo pode já ter saído no stdout | liberar espaço ou conferir permissões do destino e baixar de novo; com `--to -`, descartar o que foi lido | | `attachment_download_mismatch` | 7 | `network` | `attachment download`: os bytes recebidos não batem com o `size` e o `sha1` do anexo. Com arquivo, o temporário é apagado e nada é salvo; com `--to -`, o que já saiu no stdout deve ser descartado. É leitura: repetir é seguro | baixar de novo | -| `server_error` | 7 | `api` | 5xx, ou resposta inesperada do login/refresh | tentar de novo mais tarde | -| `unexpected_redirect` | 7 | `api` | a API respondeu 3xx; a CLI nunca segue redirects | usar a URL canônica `https://` | +| `server_error` | 7 | `api` | 5xx numa leitura, no `taiga api` ou no login/refresh (resposta inesperada); numa escrita curada, o 5xx é resultado incerto (`*_unconfirmed`, exit 1, ou releitura) | tentar de novo mais tarde | +| `unexpected_redirect` | 7 | `api` | a API respondeu 3xx a uma leitura, ao `taiga api` ou ao login; a CLI nunca segue redirects. Numa escrita curada, o 3xx é resultado incerto (`*_unconfirmed`, exit 1, ou releitura; US #274) | usar a URL canônica `https://` | | `unexpected` | 1 | — | erro não previsto | abrir issue com o comando e o stderr | diff --git a/docs/guia.md b/docs/guia.md index e2c5b95..b68c9a0 100644 --- a/docs/guia.md +++ b/docs/guia.md @@ -57,7 +57,8 @@ taiga story comments 246 --output text - O controle de concorrência do Taiga não cobre o responsável principal (`assigned_to`). Por isso a CLI relê os responsáveis logo antes de gravar e confere a story depois. Se o resultado não bate com o pedido, sai `assignees_postcondition_failed` (exit 4): a alteração **foi gravada**, então confira com `taiga story get` em vez de repetir o comando. Uma troca concorrente na janela curta entre essa releitura e a gravação é detectada, mas não evitada (detalhes em `docs/api-notes.md`). - Bloqueio: `--block "nota"` bloqueia com a nota (obrigatória); `--unblock` desbloqueia e limpa a nota. - `close` só muda o status para um status fechado: não arquiva nem exclui. No template padrão há dois status fechados (`Done` e `Archived`), então informe `--status`. -- Comentário: `story comment` publica o texto exatamente como veio (`--body`, ou `--body-file` com arquivo ou `-` para stdin), sem mexer na descrição; texto em branco é recusado. O Taiga aceita qualquer `version` antigo para comentário, então não há `--force-version` e o comentário é enviado uma vez só, nunca repetido. Se a resposta se perde (rede ou 5xx), a CLI procura o comentário no histórico: achou, sucesso; não achou ou não conseguiu ler, `comment_unconfirmed` (exit 1), porque o servidor pode gravar depois. Nesse caso, espere e confira com `taiga story comments` antes de publicar de novo. Exit 7 (`network_error`) só quando a conexão nem abriu: nada foi enviado. +- Resposta inconclusiva depois do envio (rede, 5xx ou redirect, que a CLI nunca segue: um proxy pode ter repassado o pedido) nunca sai com exit 7. O `update`, o `close` e o `field set` releem a story: a alteração está lá, sucesso; senão `story_update_unconfirmed` (exit 1). O `create` sai **sempre** com `story_create_unconfirmed` (exit 1), com as stories novas desta conta com o mesmo subject, se houver (outro processo com a mesma conta pode tê-las criado), e não faz o vínculo do `--epic`. **Não** repita às cegas: confira com `taiga story get REF` ou `taiga story list`. Exit 7 fica para leitura e para conexão que nem abriu. +- Comentário: `story comment` publica o texto exatamente como veio (`--body`, ou `--body-file` com arquivo ou `-` para stdin), sem mexer na descrição; texto em branco é recusado. O Taiga aceita qualquer `version` antigo para comentário, então não há `--force-version` e o comentário é enviado uma vez só, nunca repetido. Se a resposta se perde (rede, 5xx ou redirect, que a CLI nunca segue), sai **sempre** `comment_unconfirmed` (exit 1), com os comentários novos desta conta com o mesmo texto que o histórico mostrar: nenhum prova que veio deste comando (outro processo com a mesma conta pode ter publicado o mesmo texto), e o servidor pode gravar depois. Nesse caso, espere e confira com `taiga story comments` antes de publicar de novo. Exit 7 (`network_error`) só quando a conexão nem abriu: nada foi enviado. - `story comments` lista do mais novo para o mais antigo, inclusive comentários editados e apagados (`edit_comment_date`, `delete_comment_date`). Os comentários automáticos da integração GitLab do Taiga (usuário de sistema `gitlab-`, inativo, com os textos do push hook) ficam ocultos; `--include-system` os mostra. Comentários da conta de integração dos agentes são humanos e sempre aparecem. - Swimlanes: `taiga swimlane list` mostra as swimlanes do projeto na ordem do board, com `is_default` na padrão. `--swimlane` (nome ou id) move a story; `--clear-swimlane` tira a story de qualquer swimlane; `story list --swimlane A` e `story list --no-swimlane` filtram por elas. Story criada sem `--swimlane` fica sem swimlane, mesmo com uma padrão no projeto. Criar, renomear e reordenar swimlanes continua na interface web: exige admin, a primeira swimlane do projeto puxa todas as stories e a ordem não tem `version`. - Épico: `--epic REF` no `create` e no `update` **acrescenta** o vínculo (a story pode ter vários épicos); `--replace-epic REF --confirm-delete` no `update` **troca**: deixa só esse épico. Detalhes na seção "Vincular stories a épicos". @@ -143,7 +144,7 @@ taiga attachment download 18 3122 --task --to - | less - Valem para story e, com `--task`, para task. Não há edição nem exclusão de anexo pela CLI. - Idempotência pelo conteúdo: se a story (ou task) já tem um anexo com o mesmo nome e o mesmo `sha1`, `upload` não envia nada e devolve esse anexo com `"created": false`. Rodar o mesmo comando duas vezes não duplica o anexo; o mesmo conteúdo com outro nome é um anexo novo. -- O upload é enviado uma vez só, nunca repetido. A resposta é conferida (`sha1`, tamanho, objeto); se não bate, sai `attachment_postcondition_failed` (exit 4) e o anexo **foi gravado**. Se a resposta se perde (rede ou 5xx), a CLI procura na lista um anexo novo com o mesmo nome e `sha1`: achou, sucesso; não achou, `attachment_unconfirmed` (exit 1). Confira com `taiga attachment list` antes de enviar de novo. +- O upload é enviado uma vez só, nunca repetido. A resposta é conferida (`sha1`, tamanho, objeto); se não bate, sai `attachment_postcondition_failed` (exit 4) e o anexo **foi gravado**. Se a resposta se perde depois do envio (rede, 5xx ou redirect), sai **sempre** `attachment_unconfirmed` (exit 1), com os anexos novos de mesmo nome e `sha1` que a lista mostrar (outro processo com a mesma conta pode tê-los enviado). Corpo perdido depois de um 2xx é gravação confirmada: o anexo novo é a resposta. Confira com `taiga attachment list` antes de enviar de novo. - Nomes que o Taiga gravaria diferente (caracteres de controle, barra invertida, entidade HTML como `&`, com ou sem `;`, ou qualquer `&#`) são recusados: renomeie o arquivo. A idempotência compara o nome gravado. - A CLI não limita o tamanho. O proxy da Basis aceita até **50 MB** (`client_max_body_size 50M`); acima disso sai `payload_too_large` (exit 2). Arquivo vazio é recusado, porque o Taiga recusa. - Por que o `url` não aparece: o Taiga devolve no anexo um `url` com token assinado que abre o arquivo **sem autenticação** por alguns minutos. A CLI nunca o imprime, nem no JSON. O caminho para o arquivo é `attachment download`, que lê um `url` novo, só baixa da mesma origem da URL do Taiga, sem `Authorization` e sem seguir redirect. @@ -164,7 +165,7 @@ taiga story field set 246 "Testado em staging=true" "Data de entrega=2026-10-15" taiga story field set 246 --unset "Testado em staging" --unset "Data de entrega" ``` -- `field create` pode ser repetido: se já existe definição com o mesmo nome (sensível a maiúsculas) e o mesmo tipo, ela é devolvida sem alteração; com outro tipo, ou outra descrição quando `--description` é informado, sai `field_definition_conflict` (exit 2). A CLI nunca altera nem apaga definições. +- `field create` pode ser repetido: se já existe definição com o mesmo nome (sensível a maiúsculas) e o mesmo tipo, ela é devolvida sem alteração; com outro tipo, ou outra descrição quando `--description` é informado, sai `field_definition_conflict` (exit 2). A CLI nunca altera nem apaga definições. Resposta inconclusiva (rede depois do envio, 5xx, redirect) é `field_create_unconfirmed` (exit 1), com a definição de mesmo nome, se houver; o Taiga recusa segundo campo com o mesmo nome, então ele nunca é criado duas vezes. - Tipos aceitos por enquanto: `text`, `date` (`AAAA-MM-DD`) e `checkbox` (`true`/`false`). O nome vai até o primeiro `=`; o texto `null` é gravado como texto. - `--unset NOME` (repetível, nome ou id) limpa um campo `checkbox` ou `date`: o Taiga guarda `null` na chave do campo, que continua no dicionário mesmo quando é o último (o Taiga recusa dicionário vazio). Campo sem valor, ou já `null`, não gera escrita. Campo `text` não se limpa (erro de uso); para esvaziar, `"Notas="`. No JSON o valor limpo aparece como `null`; no texto, vazio, como campo sem valor. - `story field set` junta os campos informados aos atuais e grava o dicionário inteiro com o `version` do recurso de valores, que é outro, independente do `version` da story. `story get` mostra os dois: o da story no topo e o dos valores em `custom_attributes`. diff --git a/internal/app/attachments.go b/internal/app/attachments.go index 088612a..1cbfeab 100644 --- a/internal/app/attachments.go +++ b/internal/app/attachments.go @@ -203,7 +203,7 @@ func (s *Service) Upload(ctx context.Context, kind string, owner Object, path, d return created(answer, true), nil case errors.As(err, &ue): return s.findUpload(ctx, endpoint, owner, file, known, ue) - case taiga.NotSent(err) || !unknownOutcome(err): + case !uncertain(err): return nil, taiga.ToOutput(err) } return s.findUpload(ctx, endpoint, owner, file, known, err) @@ -255,36 +255,50 @@ func created(o Object, isNew bool) Object { } // findUpload looks for the attachment of a POST without a conclusive answer: a new id with the -// name and sha1 sent. A 2xx whose body was lost is applied, so not finding it is write_applied; -// an unknown outcome stays unknown (attachment_unconfirmed, exit 1), because the POST may still -// be running on the server. +// name and sha1 sent. A 2xx whose body was lost is applied, so the new one is the answer and not +// finding it is write_applied. An unknown outcome (network after the connection opened, 5xx, +// 3xx) is always attachment_unconfirmed, exit 1, naming the new ones: another process of the +// same account may have attached the same file, so none of them is proof (decision of +// 2026-10-03, option B), and none found proves no absence, because the POST may still be +// running on the server. func (s *Service) findUpload(ctx context.Context, endpoint string, owner Object, file UploadFile, known map[string]bool, sendErr error) (any, error) { // The upload may have failed because its context is done (--timeout, interrupt): the check // gets a short deadline of its own, or it could never confirm a stored file. check, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) defer cancel() after, err := s.attachments(check, endpoint, owner) + found := []Object{} if err == nil { for _, o := range after { if !known[fmt.Sprint(o["id"])] && sameFile(o, file) { - return created(o, true), nil + found = append(found, o) } } } var ue *taiga.UnreadableBodyError if errors.As(sendErr, &ue) { + if len(found) > 0 { + return created(found[0], true), nil + } if err == nil { err = fmt.Errorf("the new attachment is not in the list of %v", owner["id"]) } return nil, WriteApplied("POST", endpoint, ue.Status, err) } checked := "the list does not show it yet, but the request may still be running on the server" - if err != nil { + switch { + case err != nil: checked = "the list could not be read to check: " + output.AsError(err).Error() + case len(found) > 0: + ids := []string{} + for _, o := range found { + ids = append(ids, fmt.Sprint(o["id"])) + } + checked = fmt.Sprintf("the list shows %d new attachment(s) with this name and sha1 (id %s), but nothing proves this command attached them", len(found), strings.Join(ids, ", ")) } return nil, &output.Error{Code: "attachment_unconfirmed", Source: output.AsError(taiga.ToOutput(sendErr)).Source, Stage: "POST " + endpoint, Cause: fmt.Sprintf("the file may have been attached: POST %s failed (%v) and %s", endpoint, sendErr, checked), - Recovery: fmt.Sprintf("do not upload again blindly: wait, check with `taiga attachment list %v` and upload again only if the file is still missing", owner["ref"]), + Recovery: fmt.Sprintf("do not upload again blindly: it would attach the file twice, and an attachment not found yet may still be saved; check with `taiga attachment list %v`", owner["ref"]), Exit: output.ExitUnexpected} } diff --git a/internal/app/attachments_test.go b/internal/app/attachments_test.go index 50f9914..a927f1a 100644 --- a/internal/app/attachments_test.go +++ b/internal/app/attachments_test.go @@ -223,10 +223,13 @@ func TestUploadUnreadableAnswerAndRereadFails(t *testing.T) { } } -func TestUploadLostAnswerFoundInList(t *testing.T) { +// An uncertain POST (network after the connection opened, 5xx, 3xx) whose file is in the list is +// still attachment_unconfirmed (option B): the new id is named, never adopted. +func TestUploadLostAnswerFoundInListIsNamedNotAdopted(t *testing.T) { for _, lost := range []error{ &taiga.NetworkError{Method: "POST", Path: "userstories/attachments", Err: &net.OpError{Op: "read", Err: errors.New("reset")}}, &taiga.APIError{Status: 504, Method: "POST", Path: "userstories/attachments"}, + &taiga.APIError{Status: 302, Method: "POST", Path: "userstories/attachments"}, } { f, s := newAttachAPI(t) f.upload = func(a *attachAPI, path string, fields map[string]string, name string, content []byte) (*taiga.Response, error) { @@ -234,12 +237,27 @@ func TestUploadLostAnswerFoundInList(t *testing.T) { return nil, lost } got, err := s.Upload(context.Background(), "story", story(t, s), writeFile(t, "r.txt", "content"), "", false) - if err != nil || got.(Object)["id"] != json.Number("101") || f.uploads != 1 { + e := output.AsError(err) + if got != nil || e.Code != "attachment_unconfirmed" || e.Exit != output.ExitUnexpected || !strings.Contains(e.Cause, "(id 101)") || + strings.Contains(e.Recovery, "upload again only") || f.uploads != 1 { t.Fatalf("%v: %v %v", lost, got, err) } } } +// A redirect answers a POST that was sent and is never followed: not applied, it is +// attachment_unconfirmed, never a repeatable exit 7. +func TestUploadRedirectNotAppliedIsUnconfirmed(t *testing.T) { + f, s := newAttachAPI(t) + f.upload = func(*attachAPI, string, map[string]string, string, []byte) (*taiga.Response, error) { + return nil, &taiga.APIError{Status: 307, Method: "POST", Path: "userstories/attachments"} + } + _, err := s.Upload(context.Background(), "story", story(t, s), writeFile(t, "r.txt", "content"), "", false) + if e := output.AsError(err); e.Code != "attachment_unconfirmed" || e.Exit != output.ExitUnexpected || !strings.Contains(e.Cause, "does not show it") || f.uploads != 1 { + t.Fatalf("%v", err) + } +} + func TestUploadLostAnswerNotFound(t *testing.T) { f, s := newAttachAPI(t) // An older copy with the same name and content does not count: only a new id does. @@ -545,9 +563,9 @@ func TestUploadLostAnswerAfterDeadlineIsChecked(t *testing.T) { return nil, &taiga.NetworkError{Method: "POST", Path: path, Err: context.Canceled} } f.ctxCheck = true - got, err := s.Upload(ctx, "story", owner, writeFile(t, "r.txt", "content"), "", false) - if err != nil || got.(Object)["created"] != true { - t.Fatalf("%v %v", got, err) + _, err := s.Upload(ctx, "story", owner, writeFile(t, "r.txt", "content"), "", false) + if e := output.AsError(err); e.Code != "attachment_unconfirmed" || !strings.Contains(e.Cause, "(id 101)") { + t.Fatalf("%v", err) } } diff --git a/internal/app/comments.go b/internal/app/comments.go index c255dd9..59a8285 100644 --- a/internal/app/comments.go +++ b/internal/app/comments.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "regexp" + "sort" "strings" "unicode/utf8" @@ -107,10 +108,9 @@ func (s *Service) Comments(ctx context.Context, name, ref string, includeSystem // Comment publishes body on a story or task (name) with PATCH {comment, version} and returns it // read again. Taiga's OCC never refuses a comment (it is not a field, so an old version is // accepted; docs/api-notes.md), so the version protects nothing and the PATCH is sent once, -// never repeated. When its outcome is unknown (network error after the connection opened, or -// 5xx; for a task also a 3xx), the CLI compares our comments with this exact text in the -// history before and after: a new one means published; otherwise the outcome stays unknown -// (comment_unconfirmed, exit 1). +// never repeated. When its outcome is unknown (network error after the connection opened, 5xx +// or 3xx), the result is always comment_unconfirmed, exit 1, naming the new comments of ours +// with this exact text that the history shows (findComment). func (s *Service) Comment(ctx context.Context, name, ref, body string, dry bool) (any, error) { k, err := kindOf(name) if err != nil { @@ -140,25 +140,14 @@ func (s *Service) Comment(ctx context.Context, name, ref, body string, dry bool) } resp, err := confirmed(s.API.Do(ctx, taiga.Request{Method: "PATCH", Path: path, Body: map[string]any{"comment": body, "version": item["version"]}})) if err != nil { - if !k.unsure(err) { + if !uncertain(err) { var ae *taiga.APIError if errors.As(err, &ae) && ae.IsVersionConflict() { err = &taiga.ConflictError{Method: "PATCH", Path: path, Fields: []string{"comment"}} } return nil, taiga.ToOutput(err) } - if err := s.findComment(ctx, k, item, me, body, before, path, err); err != nil { - return nil, err - } - after, rerr := Read(ctx, s.API, path, nil) - if rerr != nil { - read := output.AsError(rerr) - return nil, &output.Error{Code: "write_applied", Source: read.Source, Stage: "PATCH " + path, - Cause: fmt.Sprintf("the comment was published (PATCH %s failed with %v, but the history shows it), and the %s could not be read: %s", path, err, k.name, read.Error()), - Recovery: fmt.Sprintf("do not re-run the command: the comment is already saved; check it with `taiga %s comments %v`", k.name, item["ref"]), - Exit: output.ExitUnexpected} - } - return s.view(k, after) + return nil, s.findComment(ctx, k, item, me, body, before, path, err) } after, err := reread(ctx, s.API, "PATCH", path, path, resp) if err != nil { @@ -191,27 +180,38 @@ func (s *Service) ownComments(ctx context.Context, k kind, item, me Object, body return ids, nil } -// findComment looks in the history for the comment of a PATCH whose outcome is unknown. Found: -// nil, the comment is published. Otherwise comment_unconfirmed, exit 1: a missing comment does -// not prove the PATCH failed, because a request still running on the server (a gateway timeout, -// a client timeout) can land after the check. A repeatable exit 7 would let scripts that retry -// network errors publish twice. +// findComment decides nothing: it names, for inspection, the comments of me with exactly body +// that the history shows and before did not. Even one is not proof that it came from this PATCH +// (another process of the same account may have published the same text), so the result is +// always comment_unconfirmed, exit 1 (decision of 2026-10-03, option B). None found proves no +// absence either: a request still running on the server (a gateway timeout, a client timeout) +// can land after the check. A repeatable exit 7 would let scripts that retry network errors +// publish twice. func (s *Service) findComment(ctx context.Context, k kind, item, me Object, body string, before map[string]bool, path string, sendErr error) error { - after, err := s.ownComments(ctx, k, item, me, body) + check, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) + defer cancel() + after, err := s.ownComments(check, k, item, me, body) var checked string if err != nil { read := output.AsError(err) checked = "the history could not be read to check: " + read.Error() } else { + found := []string{} for id := range after { if !before[id] { - return nil + found = append(found, id) } } - checked = "the comment is not in the history yet, but the request may still be running on the server" + sort.Strings(found) + switch len(found) { + case 0: + checked = "the comment is not in the history yet, but the request may still be running on the server" + default: + checked = fmt.Sprintf("the history shows %d new comment(s) of this account with this text (%s), but nothing proves this command published them", len(found), strings.Join(found, ", ")) + } } return &output.Error{Code: "comment_unconfirmed", Source: output.AsError(taiga.ToOutput(sendErr)).Source, Stage: "PATCH " + path, Cause: fmt.Sprintf("the comment may have been published: PATCH %s failed (%v) and %s", path, sendErr, checked), - Recovery: fmt.Sprintf("do not re-run the command blindly: wait, check with `taiga %s comments %v` and publish again only if the comment is still missing", k.name, item["ref"]), + Recovery: fmt.Sprintf("do not re-run the command blindly: it would publish the comment again, and a comment not found yet may still be saved; check with `taiga %s comments %v`", k.name, item["ref"]), Exit: output.ExitUnexpected} } diff --git a/internal/app/epic_links.go b/internal/app/epic_links.go index d8ecd22..4bb2203 100644 --- a/internal/app/epic_links.go +++ b/internal/app/epic_links.go @@ -322,6 +322,12 @@ func afterWrite(err error, code, saved, doNot, finish string, ref any) error { // invitation to run the command again (that would create a second story). func (s *Service) CreateStoryWithEpic(ctx context.Context, body Object, epic Object, dry bool) (any, error) { created, err := s.CreateStory(ctx, body, dry) + if e, ok := err.(*output.Error); ok && e.Code == "story_create_unconfirmed" { + // The story may exist without its epic: the link is never made for a story not confirmed. + out := *e + out.Recovery += fmt.Sprintf("; the epic was not linked: once you pick the story, link it with `taiga epic link %v REF`", epic["ref"]) + return nil, &out + } if err != nil { return nil, err } diff --git a/internal/app/epic_links_test.go b/internal/app/epic_links_test.go index ceaaa88..0bb11b0 100644 --- a/internal/app/epic_links_test.go +++ b/internal/app/epic_links_test.go @@ -54,7 +54,8 @@ func newLinkAPI(t *testing.T, links ...int64) (*linkAPI, *Service) { delete(f.links, epic) return &taiga.Response{Status: 204}, nil } - f.lists = map[string]string{"epics": `[{"id":11,"ref":1,"project":37},{"id":12,"ref":2,"project":37},{"id":13,"ref":3,"project":37},{"id":99,"ref":4,"project":38}]`} + f.lists = map[string]string{"epics": `[{"id":11,"ref":1,"project":37},{"id":12,"ref":2,"project":37},{"id":13,"ref":3,"project":37},{"id":99,"ref":4,"project":38}]`, "userstories": `[]`} + f.objects = map[string]string{"users/me": `{"id":5}`} s := service(t, &f.fakeAPI) s.API = f return f, s diff --git a/internal/app/field_values.go b/internal/app/field_values.go index 4419e95..c65c95e 100644 --- a/internal/app/field_values.go +++ b/internal/app/field_values.go @@ -198,7 +198,7 @@ func (s *Service) SetFieldValues(ctx context.Context, kind string, id int64, ent func (s *Service) writeValues(ctx context.Context, k kind, path string, before, merged Object, check string, force bool) (Object, error) { patch := Object{"attributes_values": merged} resp, err := confirmed(s.API.Do(ctx, taiga.Request{Method: "PATCH", Path: path, Body: map[string]any{"attributes_values": merged, "version": before["version"]}})) - if err != nil && k.confirmUncertain && uncertain(err) { + if err != nil && uncertain(err) { next := ID(before["version"]) + 1 if force { next = 0 // --force-version skips the version check; the values are still compared diff --git a/internal/app/fields.go b/internal/app/fields.go index 69ff4ce..81516c3 100644 --- a/internal/app/fields.go +++ b/internal/app/fields.go @@ -87,6 +87,9 @@ func (s *Service) CreateField(ctx context.Context, kind, name, typ string, descr return existing, rerr } } + if uncertain(err) { + return nil, s.fieldUnconfirmed(ctx, kind, name, path, err) + } return nil, taiga.ToOutput(err) } created, err := Decode(r.Body) @@ -99,6 +102,36 @@ func (s *Service) CreateField(ctx context.Context, kind, name, typ string, descr return reread(ctx, s.API, "POST", path, fmt.Sprintf("%s/%d", path, ID(created["id"])), r) } +// fieldUnconfirmed is field_create_unconfirmed, exit 1, for a POST whose outcome is unknown +// (network after the connection opened, 5xx, 3xx). A definition with the name found afterwards +// is named, never adopted: nothing proves this POST created it (decision of 2026-10-03, option +// B), and none found proves no absence, because the POST may still be running on the server. +// Taiga refuses a second definition with the same name, so a later run can never duplicate it. +func (s *Service) fieldUnconfirmed(ctx context.Context, kind, name, path string, sendErr error) error { + check, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) + defer cancel() + delete(s.catalogs, path) + fields, err := s.Fields(check, kind) + checked := "the project has no field with this name yet, but the request may still be running on the server" + if err != nil { + checked = "the fields could not be read to look for it: " + output.AsError(err).Error() + } else { + found := []string{} + for _, f := range fields { + if f["name"] == name { + found = append(found, fmt.Sprintf("id %d, type %v", ID(f["id"]), f["type"])) + } + } + if len(found) > 0 { + checked = fmt.Sprintf("the project has a field with this name (%s), but nothing proves this command created it", strings.Join(found, "; ")) + } + } + return &output.Error{Code: "field_create_unconfirmed", Source: taiga.ToOutput(sendErr).Source, Stage: "POST " + path, + Cause: fmt.Sprintf("the field %q may have been created: POST %s failed (%v) and %s", name, path, sendErr, checked), + Recovery: fmt.Sprintf("do not assume it is missing: wait and check with `taiga field list --kind %s`; Taiga refuses a second field with the same name, so it is never created twice", kind), + Exit: output.ExitUnexpected} +} + // existingField returns the definition named name, nil when there is none, or an error when // it is ambiguous or differs from the request. func (s *Service) existingField(ctx context.Context, kind, name, typ string, description *string) (Object, error) { diff --git a/internal/app/project_apply_test.go b/internal/app/project_apply_test.go index 24f6654..03d70d6 100644 --- a/internal/app/project_apply_test.go +++ b/internal/app/project_apply_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/BasisTI/taiga-cli/internal/output" "github.com/BasisTI/taiga-cli/internal/taiga" ) @@ -202,6 +203,7 @@ type statusAPI struct { fieldPosts []taiga.Request postErr error created bool // postErr comes after the status was created + listErr error } func (a *statusAPI) Do(_ context.Context, r taiga.Request) (*taiga.Response, error) { @@ -270,6 +272,9 @@ func (a *statusAPI) Do(_ context.Context, r taiga.Request) (*taiga.Response, err } func (a *statusAPI) GetAll(_ context.Context, path string, _ url.Values) ([]json.RawMessage, error) { + if a.listErr != nil { + return nil, a.listErr + } items := a.fields if path == "userstory-statuses" { items = a.statuses @@ -351,10 +356,17 @@ func TestStatusWriterCreateStatusNeverRepeatsThePost(t *testing.T) { if err := statusWriter(a).CreateStatus(context.Background(), desired); !strings.Contains(fmt.Sprint(err), "write_applied") || len(a.posts) != 1 { t.Fatalf("unreadable, missing: %v %d", err, len(a.posts)) } - // A 5xx is not retried. - a = &statusAPI{postErr: &taiga.APIError{Status: 502, Method: "POST", Path: "userstory-statuses"}} - if err := statusWriter(a).CreateStatus(context.Background(), desired); exitOf(err) != 7 || len(a.posts) != 1 { - t.Fatalf("5xx: %v %d", err, len(a.posts)) + // A 5xx or a 3xx is never retried nor a repeatable exit 7: status_create_unconfirmed, naming + // the status found afterwards, never adopting it (option B). + for _, status := range []int{502, 302} { + for _, applied := range []bool{true, false} { + a = &statusAPI{postErr: &taiga.APIError{Status: status, Method: "POST", Path: "userstory-statuses"}, created: applied} + err := statusWriter(a).CreateStatus(context.Background(), desired) + e := output.AsError(err) + if e.Code != "status_create_unconfirmed" || e.Exit != output.ExitUnexpected || strings.Contains(e.Cause, "has a status with this name") != applied || len(a.posts) != 1 { + t.Fatalf("%d applied=%v: %v %d", status, applied, err, len(a.posts)) + } + } } } @@ -419,10 +431,32 @@ func TestStatusWriterReorderPostcondition(t *testing.T) { if err := loaded(t, a).Reorder(context.Background(), reordered); err != nil { t.Fatalf("lost answer, applied: %v", err) } - // A 5xx that did not apply: the order differs, reported without retry. - a = &statusAPI{bulkErr: &taiga.APIError{Status: 502, Method: "POST", Path: "userstory-statuses/bulk_update_order"}} - if err := loaded(t, a).Reorder(context.Background(), reordered); exitOf(err) != 4 || len(a.bulks) != 1 { - t.Fatalf("5xx: %v %d", err, len(a.bulks)) + // A 5xx or a 3xx: the re-read decides. Applied and matching is a success; not applied, the + // order differs, reported without retry; never a repeatable exit 7. + for _, status := range []int{502, 302} { + a = &statusAPI{bulkErr: &taiga.APIError{Status: status, Method: "POST", Path: "userstory-statuses/bulk_update_order"}} + if err := loaded(t, a).Reorder(context.Background(), reordered); exitOf(err) != 4 || len(a.bulks) != 1 { + t.Fatalf("%d: %v %d", status, err, len(a.bulks)) + } + a = &statusAPI{bulkErr: &taiga.APIError{Status: status, Method: "POST", Path: "userstory-statuses/bulk_update_order"}, bulkApplies: true} + if err := loaded(t, a).Reorder(context.Background(), reordered); err != nil || len(a.bulks) != 1 { + t.Fatalf("%d applied: %v %d", status, err, len(a.bulks)) + } + } +} + +// An uncertain bulk whose re-read fails says nothing about the order: status_order_unconfirmed, +// exit 1, never the repeatable exit 7 of the send error. +func TestStatusWriterReorderUncertainWithoutReread(t *testing.T) { + for _, status := range []int{502, 302} { + a := &statusAPI{bulkErr: &taiga.APIError{Status: status, Method: "POST", Path: "userstory-statuses/bulk_update_order"}, + afterBulk: func(a *statusAPI) { + a.listErr = &taiga.APIError{Status: 503, Method: "GET", Path: "userstory-statuses"} + }} + err := loaded(t, a).Reorder(context.Background(), reordered) + if e := output.AsError(err); e.Code != "status_order_unconfirmed" || e.Exit != output.ExitUnexpected || len(a.bulks) != 1 { + t.Fatalf("%d: %v", status, err) + } } } @@ -509,3 +543,64 @@ func TestApplyIsNotCompleteWithACaseCollision(t *testing.T) { t.Fatalf("%+v %v", got, err) } } + +// qaApplyAPI fails every catalog read once a POST was answered: the write is applied, the reads +// after it fail. +type qaApplyAPI struct{ *statusAPI } + +func (q qaApplyAPI) Do(ctx context.Context, r taiga.Request) (*taiga.Response, error) { + resp, err := q.statusAPI.Do(ctx, r) + if r.Method == "POST" && err == nil { + q.listErr = &taiga.APIError{Status: 502, Method: "GET", Path: "userstory-statuses"} + } + return resp, err +} + +// After an applied action, a failed read is never a repeatable exit 7 (adversarial review). +func TestApplyProjectReadFailureAfterAppliedWriteIsNotExit7(t *testing.T) { + a := &statusAPI{fields: remoteFields()} + statusWriter(a) + s := &Service{API: qaApplyAPI{a}, Project: Object{"id": 37, "slug": "infra"}, catalogs: map[string][]Object{}} + spec := ProjectSpec{StoryStatus: []StatusSpec{{Name: "In revision", Color: "#8E44AD", After: "Done"}}} + got, err := s.ApplyProject(context.Background(), spec, false) + e := output.AsError(err) + if len(got.Applied) != 1 || e.Code != "project_apply_interrupted" || e.Exit != output.ExitUnexpected || !strings.Contains(e.Cause, "[server_error]") || !strings.Contains(e.Recovery, "taiga project plan") { + t.Fatalf("applied %d: %v %q", len(got.Applied), err, e.Recovery) + } +} + +// postThenFailReads fails every catalog read with readErr once a POST was sent. +type postThenFailReads struct { + *statusAPI + readErr error +} + +func (q postThenFailReads) Do(ctx context.Context, r taiga.Request) (*taiga.Response, error) { + resp, err := q.statusAPI.Do(ctx, r) + if r.Method == "POST" { + q.listErr = q.readErr + } + return resp, err +} + +// The first action of the plan: Taiga answered 201 but the body was lost, and the read that +// would settle it fails (3xx, 5xx, network). The status is saved: write_applied, exit 1, never +// the repeatable exit 7 of the read (review round 1, Codex). +func TestApplyFirstStatusLostBodyWithFailedRecoveryIsWriteApplied(t *testing.T) { + for _, readErr := range []error{ + &taiga.APIError{Status: 302, Method: "GET", Path: "userstory-statuses"}, + &taiga.APIError{Status: 503, Method: "GET", Path: "userstory-statuses"}, + &taiga.NetworkError{Method: "GET", Path: "userstory-statuses", Err: errors.New("connection reset")}, + } { + a := &statusAPI{fields: remoteFields(), created: true, + postErr: &taiga.UnreadableBodyError{Method: "POST", Path: "userstory-statuses", Status: 201, Err: errors.New("unexpected EOF")}} + statusWriter(a) + s := &Service{API: postThenFailReads{a, readErr}, Project: Object{"id": 37, "slug": "infra"}, catalogs: map[string][]Object{}} + spec := ProjectSpec{StoryStatus: []StatusSpec{{Name: "ReviewStatus", Color: "#000000"}}} + got, err := s.ApplyProject(context.Background(), spec, false) + e := output.AsError(err) + if e.Code != "write_applied" || e.Exit != output.ExitUnexpected || !strings.Contains(e.Cause, "HTTP 201") || !strings.Contains(e.Recovery, "taiga project plan") || len(a.posts) != 1 || len(got.Applied) != 0 { + t.Errorf("%v: applied %d posts %d: %v", readErr, len(got.Applied), len(a.posts), err) + } + } +} diff --git a/internal/app/project_status_writer.go b/internal/app/project_status_writer.go index 88b456d..a049857 100644 --- a/internal/app/project_status_writer.go +++ b/internal/app/project_status_writer.go @@ -135,15 +135,34 @@ func (w *statusHTTPWriter) CreateStatus(ctx context.Context, desired Object) err const path = "userstory-statuses" r, err := w.service.API.Do(ctx, taiga.Request{Method: "POST", Path: path, Body: body}) if err != nil { - // 400 on "name": another run created it first (Taiga refuses the same name). A lost - // answer: the status may exist. Both are settled by reading, never by a second POST. + // 400 on "name": refused, another run created it first (Taiga refuses the same name), so + // a failed read is just that read's error. A 2xx with its body lost: the status is + // saved, so a failed read must keep that (write_applied), never a repeatable error. Both + // are settled by reading, never by a second POST. var ae *taiga.APIError var ue *taiga.UnreadableBodyError - if (errors.As(err, &ae) && ae.Status == 400 && hasKey(ae.Body, "name")) || errors.As(err, &ue) { + if errors.As(err, &ae) && ae.Status == 400 && hasKey(ae.Body, "name") { if existing, _, rerr := w.existingStatus(ctx, desired); existing != nil || rerr != nil { return rerr } } + if errors.As(err, &ue) { + existing, _, rerr := w.existingStatus(ctx, desired) + var oe *output.Error + switch { + case existing != nil: + return nil + case errors.As(rerr, &oe) && oe.Code == "project_changed": + return rerr // found, with other values: exit 4, never a repeatable one + case rerr != nil: + applied := *output.AsError(WriteApplied("POST", path, ue.Status, rerr)) + applied.Recovery = "do not assume it is missing: the status is saved; run `taiga project plan` to see what is left before applying again" + return &applied + } + } + if uncertain(err) { + return w.statusUnconfirmed(ctx, fmt.Sprint(desired["name"]), path, err) + } return taiga.ToOutput(err) } created, err := Decode(r.Body) @@ -166,6 +185,30 @@ func (w *statusHTTPWriter) CreateStatus(ctx context.Context, desired Object) err return nil } +// statusUnconfirmed is status_create_unconfirmed, exit 1, for a POST whose outcome is unknown +// (network after the connection opened, 5xx, 3xx), like fieldUnconfirmed: a status with the name +// found afterwards is named, never adopted (option B). Taiga refuses a second status with the +// same name, so a later apply never duplicates it. +func (w *statusHTTPWriter) statusUnconfirmed(ctx context.Context, name, path string, sendErr error) error { + check, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) + defer cancel() + statuses, err := w.loadStatuses(check) + checked := "the project has no status with this name yet, but the request may still be running on the server" + if err != nil { + checked = "the statuses could not be read to look for it: " + output.AsError(err).Error() + } else { + for _, st := range statuses { + if st["name"] == name { + checked = fmt.Sprintf("the project has a status with this name (id %d, color %v, closed %v), but nothing proves this command created it", ID(st["id"]), st["color"], st["is_closed"]) + } + } + } + return &output.Error{Code: "status_create_unconfirmed", Source: taiga.ToOutput(sendErr).Source, Stage: "POST " + path, + Cause: fmt.Sprintf("the status %q may have been created: POST %s failed (%v) and %s", name, path, sendErr, checked), + Recovery: "do not assume it is missing: wait and run `taiga project plan` to see what is left; Taiga refuses a second status with the same name, so it is never created twice", + Exit: output.ExitUnexpected} +} + // CreateField re-reads the story fields, bypassing the run cache, and refuses a definition that // appeared since the plan with another type or description, or with the name in another case. // An equal one is a no-op; otherwise field create does the POST, never repeated. @@ -225,15 +268,21 @@ func (w *statusHTTPWriter) Reorder(ctx context.Context, names []string) error { } _, werr := w.service.API.Do(ctx, taiga.Request{Method: "POST", Path: statusOrderPath, Body: Object{"project": w.service.Project["id"], "bulk_userstory_statuses": pairs}}) - var ae *taiga.APIError - if errors.As(werr, &ae) && ae.Status < 500 { - return taiga.ToOutput(werr) // refused: nothing was written + var ue *taiga.UnreadableBodyError + lost := errors.As(werr, &ue) // applied, answer lost + if werr != nil && !lost && !uncertain(werr) { + return taiga.ToOutput(werr) // refused, or never sent: nothing was written } after, rerr := w.loadStatuses(ctx) if rerr != nil { - if werr != nil { + if lost { return taiga.ToOutput(werr) } + if werr != nil { + return &output.Error{Code: "status_order_unconfirmed", Source: taiga.ToOutput(werr).Source, Stage: "POST " + statusOrderPath, + Cause: fmt.Sprintf("the order may have been applied: POST %s failed (%v) and the statuses could not be read to check: %s", statusOrderPath, werr, output.AsError(rerr).Error()), + Recovery: "do not re-run blindly: wait, check `taiga status list`, then run `taiga project plan` and decide", Exit: output.ExitUnexpected} + } return WriteApplied("POST", statusOrderPath, 204, rerr) } got := []string{} @@ -266,7 +315,16 @@ func (s *Service) ProjectPlan(ctx context.Context, spec ProjectSpec) (ProjectPla func (s *Service) ApplyProject(ctx context.Context, spec ProjectSpec, dry bool) (ApplyResult, error) { result, err := Apply(ctx, &statusHTTPWriter{service: s}, spec, dry) if err != nil { - return result, taiga.ToOutput(err) + e := taiga.ToOutput(err) + // Part of the plan is saved: a later failure (a read, mostly) must not look like a + // repeatable network error. + if len(result.Applied) > 0 && e.Exit == output.ExitNetwork { + return result, &output.Error{Code: "project_apply_interrupted", Source: e.Source, Stage: e.Stage, + Cause: fmt.Sprintf("%d action(s) were applied (see applied), then [%s] %s", len(result.Applied), e.Code, e.Cause), + Recovery: "do not assume nothing was saved: run `taiga project plan` to see what is left, then apply again (it re-plans and never creates a name twice)", + Exit: output.ExitUnexpected} + } + return result, e } return result, nil } diff --git a/internal/app/service.go b/internal/app/service.go index 684bc83..b843908 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -192,7 +192,7 @@ func (s *Service) Write(ctx context.Context, k kind, path string, before, patch return WritePlan{true, "PATCH", path, body}, nil } resp, err := s.send(ctx, k, path, before, patch, force) - if err != nil && k.confirmUncertain && uncertain(err) { + if err != nil && uncertain(err) { return s.confirmPatch(ctx, k, path, patch, 0, err, fmt.Sprintf("`taiga %s get %v`", k.name, before["ref"])) } if err != nil { @@ -217,20 +217,12 @@ func (s *Service) send(ctx context.Context, k kind, path string, before, patch O // uncertain is a failed write that Taiga may have applied: the request left (the connection // opened) and no answer said what happened: a network error, a 5xx, or a 3xx, which the client -// never follows and a proxy may have answered after passing the request on. +// never follows and a proxy may have answered after passing the request on. A read that failed +// after Taiga refused the write is not one: nothing was written. func uncertain(err error) bool { var ae *taiga.APIError - return !taiga.NotSent(err) && (unknownOutcome(err) || errors.As(err, &ae) && ae.Status >= 300 && ae.Status < 400) -} - -// unsure is a failed write whose outcome the kind decides by checking: for a task, any uncertain -// one; for a story, a network error after the connection opened or a 5xx (a 3xx stays an error -// until US #274). -func (k kind) unsure(err error) bool { - if k.confirmUncertain { - return uncertain(err) - } - return !taiga.NotSent(err) && unknownOutcome(err) + var refused *taiga.RefusedWriteError + return !errors.As(err, &refused) && !taiga.NotSent(err) && (unknownOutcome(err) || errors.As(err, &ae) && ae.Status >= 300 && ae.Status < 400) } // confirmPatch decides a PATCH whose outcome is unknown by re-reading the resource: when every diff --git a/internal/app/stories.go b/internal/app/stories.go index 22fb874..971e7f8 100644 --- a/internal/app/stories.go +++ b/internal/app/stories.go @@ -2,8 +2,10 @@ package app import ( "context" + "errors" "fmt" "net/url" + "slices" "strconv" "strings" @@ -197,7 +199,10 @@ func contains(list any, match func(any) bool) bool { return false } -// CreateStory posts body to the project. There is no previous version: none is sent. +// CreateStory posts body to the project, once and without version. When the answer says nothing +// conclusive (network after the connection opened, 5xx, 3xx), the result is always +// story_create_unconfirmed, naming the candidates found in the project (unconfirmedCreate). A +// re-run is never offered: it would create a second story. func (s *Service) CreateStory(ctx context.Context, body Object, dry bool) (any, error) { subject, _ := body["subject"].(string) if strings.TrimSpace(subject) == "" { @@ -207,7 +212,22 @@ func (s *Service) CreateStory(ctx context.Context, body Object, dry bool) (any, if dry { return WritePlan{true, "POST", "userstories", body}, nil } + me, err := Read(ctx, s.API, "users/me", nil) + if err != nil { + return nil, err + } + known, err := s.ownStories(ctx, me) + if err != nil { + return nil, err + } r, err := s.API.Do(ctx, taiga.Request{Method: "POST", Path: "userstories", Body: body}) + var ue *taiga.UnreadableBodyError + if err != nil && !errors.As(err, &ue) && uncertain(err) { + check, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) + defer cancel() + items, lerr := s.list(check, "userstories", url.Values{"owner": {fmt.Sprint(me["id"])}}) + return nil, unconfirmedCreate(storyKind, "the project", "`taiga story list`", items, lerr, known, me, body, err) + } if err != nil { return nil, taiga.ToOutput(err) } @@ -228,6 +248,22 @@ func (s *Service) CreateStory(ctx context.Context, body Object, dry bool) (any, return s.StoryView(raw) } +// ownStories returns the ids of the stories of the project that me created. The owner filter only +// shortens the list; the owner is checked here too. +func (s *Service) ownStories(ctx context.Context, me Object) (map[int64]bool, error) { + items, err := s.list(ctx, "userstories", url.Values{"owner": {fmt.Sprint(me["id"])}}) + if err != nil { + return nil, err + } + ids := map[int64]bool{} + for _, o := range items { + if ID(o["owner"]) == ID(me["id"]) { + ids[ID(o["id"])] = true + } + } + return ids, nil +} + func (s *Service) UpdateStory(ctx context.Context, ref string, p Patch, dry, force bool) (any, error) { before, err := s.Story(ctx, ref, 0) if err != nil { @@ -321,6 +357,9 @@ func (s *Service) writeAssignees(ctx context.Context, path string, before Object base = current } resp, err := s.send(ctx, storyKind, path, before, patch, force) + if err != nil && uncertain(err) { + return s.confirmAssignees(ctx, path, base, p, patch, err, force) + } if err != nil { return nil, taiga.ToOutput(err) } @@ -347,6 +386,39 @@ func (s *Service) writeAssignees(ctx context.Context, path string, before Object return s.StoryView(after) } +// confirmAssignees decides an assignee PATCH whose outcome is unknown by re-reading the story, +// with the checks of the answered write: the other fields sent show their value, the assignees +// match the request and, unless force, the version is the next one of base. Anything else is +// story_update_unconfirmed, exit 1: never a repeatable exit 7. +func (s *Service) confirmAssignees(ctx context.Context, path string, base Object, p Patch, patch Object, sendErr error, force bool) (any, error) { + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) + defer cancel() + now, err := Read(rctx, s.API, path, nil) + checked := "" + if err != nil { + checked = "the re-read to check failed: " + output.AsError(err).Error() + } else { + others := Object{} + for k, v := range patch { + if !slices.Contains(opaqueKeys, k) { + others[k] = v + } + } + problems := append(differences(now, others), assigneeProblems(base, now, p)...) + if !force && ID(now["version"]) != ID(base["version"])+1 { + problems = append(problems, fmt.Sprintf("the version is %v, not %d", now["version"], ID(base["version"])+1)) + } + if len(problems) == 0 { + return s.StoryView(now) + } + checked = "the re-read does not match the request (" + strings.Join(problems, "; ") + "), but the request may still be running on the server or another write landed next to it" + } + return nil, &output.Error{Code: "story_update_unconfirmed", Source: taiga.ToOutput(sendErr).Source, Stage: "PATCH " + path, + Cause: fmt.Sprintf("the change may have been applied: PATCH %s failed (%v) and %s", path, sendErr, checked), + Recovery: fmt.Sprintf("do not re-run the command blindly: wait, check with `taiga story get %v` and repeat only what is still missing", base["ref"]), + Exit: output.ExitUnexpected} +} + func assigneesMismatch(path string, status int, after Object, problems []string) error { users, _ := assignees(after["assigned_users"]) cause := fmt.Sprintf("the change was applied (PATCH %s returned HTTP %d), but the story does not match the request: %s; found assigned_to=%s assigned_users=%v (version %v)", diff --git a/internal/app/tasks.go b/internal/app/tasks.go index cbbae48..8787ae9 100644 --- a/internal/app/tasks.go +++ b/internal/app/tasks.go @@ -106,27 +106,40 @@ func (s *Service) storyTasks(ctx context.Context, story Object) (map[int64]bool, return ids, nil } -// findTask decides nothing: it names the candidates of a POST whose outcome is unknown, for -// inspection. A candidate is a task of story, owned by me, with the subject sent, that was not in -// known (read right before the POST). Even one with every field asked for is not proof that it -// came from this POST (another process of the same account may have created it; custom fields, -// watchers and the like are not comparable), so the result is always task_create_unconfirmed -// (decision of 2026-10-03, option B). An empty list, or one that cannot be read, proves no -// absence either: the request may still land. +// findTask names the candidates of a POST tasks whose outcome is unknown: the tasks of story not +// in known (read right before the POST), see unconfirmedCreate. func (s *Service) findTask(ctx context.Context, story, me, body Object, known map[int64]bool, sendErr error) (Object, error) { check, cancel := context.WithTimeout(context.WithoutCancel(ctx), checkTimeout) defer cancel() items, err := s.list(check, "tasks", url.Values{"user_story": {fmt.Sprint(story["id"])}}) - inspect := fmt.Sprintf("`taiga task list --story %v`", story["ref"]) + mine := []Object{} + for _, o := range items { + if ID(o["user_story"]) == ID(story["id"]) { + mine = append(mine, o) + } + } + return nil, unconfirmedCreate(taskKind, "the story", fmt.Sprintf("`taiga task list --story %v`", story["ref"]), mine, err, known, me, body, sendErr) +} + +// unconfirmedCreate decides nothing: it is _create_unconfirmed for a POST whose outcome is +// unknown (network after the connection opened, 5xx, 3xx), naming for inspection the candidates +// among items (listErr when they could not be read): owned by me, with the subject sent, not in +// known (read right before the POST). Even one with every field asked for is not proof that it +// came from this POST (another process of the same account may have created it; custom fields, +// watchers and the like are not comparable), so it is never a success (decision of 2026-10-03, +// option B). An empty list, or one that cannot be read, proves no absence either: the request +// may still land. where names the scope searched; list is the command that shows it. +func unconfirmedCreate(k kind, where, list string, items []Object, listErr error, known map[int64]bool, me, body Object, sendErr error) error { + inspect := list var checked string - if err != nil { - checked = "the tasks of the story could not be read to look for it: " + output.AsError(err).Error() + if listErr != nil { + checked = fmt.Sprintf("the %s of %s could not be read to look for it: %s", k.plural(), where, output.AsError(listErr).Error()) } else { found, gets := []string{}, []string{} for _, o := range items { - if ID(o["user_story"]) == ID(story["id"]) && !known[ID(o["id"])] && o["subject"] == body["subject"] && ID(o["owner"]) > 0 && ID(o["owner"]) == ID(me["id"]) { + if !known[ID(o["id"])] && o["subject"] == body["subject"] && ID(o["owner"]) > 0 && ID(o["owner"]) == ID(me["id"]) { found = append(found, fmt.Sprintf("#%v (id %d)", o["ref"], ID(o["id"]))) - gets = append(gets, fmt.Sprintf("`taiga task get %v`", o["ref"])) + gets = append(gets, fmt.Sprintf("`taiga %s get %v`", k.name, o["ref"])) } } if len(gets) > 0 { @@ -134,15 +147,23 @@ func (s *Service) findTask(ctx context.Context, story, me, body Object, known ma } switch len(found) { case 0: - checked = "the story has no new task of this account with this subject yet, but the request may still be running on the server" + checked = fmt.Sprintf("%s has no new %s of this account with this subject yet, but the request may still be running on the server", where, k.name) case 1: - checked = "the story has one new task of this account with this subject, " + found[0] + ", but nothing proves it is the one this command created" + checked = fmt.Sprintf("%s has one new %s of this account with this subject, %s, but nothing proves it is the one this command created", where, k.name, found[0]) default: - checked = fmt.Sprintf("the story has %d new tasks of this account with this subject: %s; nothing proves which one, if any, this command created", len(found), strings.Join(found, ", ")) + checked = fmt.Sprintf("%s has %d new %s of this account with this subject: %s; nothing proves which one, if any, this command created", where, len(found), k.plural(), strings.Join(found, ", ")) } } - return nil, &output.Error{Code: "task_create_unconfirmed", Source: taiga.ToOutput(sendErr).Source, Stage: "POST tasks", - Cause: fmt.Sprintf("the task may have been created: POST tasks failed (%v) and %s", sendErr, checked), - Recovery: "do not re-run the command blindly: it would create another task, and a task not found yet may still be saved; inspect with " + inspect, + return &output.Error{Code: k.name + "_create_unconfirmed", Source: taiga.ToOutput(sendErr).Source, Stage: "POST " + k.base, + Cause: fmt.Sprintf("the %s may have been created: POST %s failed (%v) and %s", k.name, k.base, sendErr, checked), + Recovery: fmt.Sprintf("do not re-run the command blindly: it would create another %s, and a %s not found yet may still be saved; inspect with %s", k.name, k.name, inspect), Exit: output.ExitUnexpected} } + +// plural is the name of many stories or tasks, for messages. +func (k kind) plural() string { + if k.name == "story" { + return "stories" + } + return k.name + "s" +} diff --git a/internal/app/tasks_ref.go b/internal/app/tasks_ref.go index 21cbd04..27b4ca7 100644 --- a/internal/app/tasks_ref.go +++ b/internal/app/tasks_ref.go @@ -17,15 +17,11 @@ type kind struct { // story write that changes assignees is fenced (writeAssignees); a task's assigned_to is in // its history diff and the per-field OCC protects it. fenceAssignees bool - // confirmUncertain: a PATCH whose outcome is unknown (network after the connection opened, - // 5xx or 3xx) is decided by a re-read, never a repeatable exit 7. Stories keep the phase 2 - // behaviour until US #274. - confirmUncertain bool } var ( storyKind = kind{name: "story", base: "userstories", web: "us", statuses: "userstory-statuses", history: "userstory", fenceAssignees: true} - taskKind = kind{name: "task", base: "tasks", web: "task", statuses: "task-statuses", history: "task", confirmUncertain: true} + taskKind = kind{name: "task", base: "tasks", web: "task", statuses: "task-statuses", history: "task"} ) func kindOf(name string) (kind, error) { diff --git a/internal/cli/attachment_integration_test.go b/internal/cli/attachment_integration_test.go index c372e42..b8094fb 100644 --- a/internal/cli/attachment_integration_test.go +++ b/internal/cli/attachment_integration_test.go @@ -164,12 +164,14 @@ func TestIntegrationUploadLostAnswer(t *testing.T) { return true }) proxied := map[string]string{"TAIGA_URL": url, "TAIGA_TOKEN": env["TAIGA_TOKEN"], "TAIGA_PROJECT": env["TAIGA_PROJECT"]} - got := storyJSON(t, proxied, "", "attachment", "upload", ref, localFile(t, "lost.txt", []byte("lost"))) - if got["created"] != true || posts != 1 { - t.Fatalf("%v posts=%d", got, posts) + // Applied, answer lost: named, never adopted (option B). + out, errOut, code := runIn(t, proxied, "", "attachment", "upload", ref, localFile(t, "lost.txt", []byte("lost"))) + saved := attachmentList(t, env, ref) + if code != 1 || out != "" || posts != 1 || len(saved) != 1 || !strings.Contains(errOut, "attachment_unconfirmed") || !strings.Contains(errOut, fmt.Sprintf("(id %v)", saved[0]["id"])) { + t.Fatalf("%d %q %s posts=%d", code, out, errOut, posts) } forwardPost = false - _, errOut, code := runIn(t, proxied, "", "attachment", "upload", ref, localFile(t, "never.txt", []byte("never"))) + _, errOut, code = runIn(t, proxied, "", "attachment", "upload", ref, localFile(t, "never.txt", []byte("never"))) if code != 1 || !strings.Contains(errOut, "attachment_unconfirmed") || posts != 2 { t.Fatalf("%d %s posts=%d", code, errOut, posts) } diff --git a/internal/cli/comments_test.go b/internal/cli/comments_test.go index 0ba276c..7d70382 100644 --- a/internal/cli/comments_test.go +++ b/internal/cli/comments_test.go @@ -257,12 +257,12 @@ func TestStoryCommentServerErrorIsNeverRepeated(t *testing.T) { t.Fatalf("repeated: %+v", writes(calls)) } - // Applied before the 5xx: the new entry is found (the older one with the same text, already - // in the fixture, does not count) and the command succeeds without a second PATCH. + // Applied before the 5xx: the new entry is named (the older one with the same text, already + // in the fixture, is not), never adopted (option B), and the PATCH is not repeated. f, calls = commentFake(t) f.commentStatus, f.commentApplied, f.commentAnswer = 502, true, `bad gateway` out, stderr, code := runIn(t, f.env(), "", "story", "comment", "246", "--body", "primeiro \"aspas\" acentuação\nlinha 2 **md**") - if code != 0 || !strings.Contains(out, `"version": 8`) || len(writes(calls)) != 1 { + if code != 1 || out != "" || !strings.Contains(stderr, `"comment_unconfirmed"`) || !strings.Contains(stderr, "1 new comment(s) of this account with this text (entry-") || len(writes(calls)) != 1 { t.Fatalf("%d %s %s %+v", code, out, stderr, writes(calls)) } } diff --git a/internal/cli/present_sweep_test.go b/internal/cli/present_sweep_test.go index f7a51fb..5c33a7b 100644 --- a/internal/cli/present_sweep_test.go +++ b/internal/cli/present_sweep_test.go @@ -116,6 +116,13 @@ func TestEveryCuratedCommandHidesTokens(t *testing.T) { {failing("PATCH tasks/9100", 302), []string{"task", "close", "250"}}, {failing("PATCH tasks/9100", 502), []string{"task", "comment", "250", "--body", "x"}}, {failing("PATCH tasks/custom-attributes-values/9100", 502), []string{"task", "field", "set", "250", "Horas=y"}}, + // Redirected writes of US #274: every *_unconfirmed code echoes the send error. + {failing("PATCH userstories/6808", 302), []string{"story", "update", "246", "--subject", "s"}}, + {failing("PATCH userstories/6808", 307), []string{"story", "update", "246", "--add-assignee", "svc"}}, + {failing("PATCH userstories/6808", 302), []string{"story", "comment", "246", "--body", "x"}}, + {failing("PATCH userstories/custom-attributes-values/6808", 302), []string{"story", "field", "set", "246", "Notas=y"}}, + {failing("POST userstories", 302), []string{"story", "create", "--subject", "s " + sweepURL}}, + {failing("POST userstory-custom-attributes", 308), []string{"field", "create", "--kind", "story", "--name", "n", "--type", "text"}}, } { cases = append(cases, struct { env func() map[string]string diff --git a/internal/cli/project_integration_test.go b/internal/cli/project_integration_test.go index 7e11c71..d4469ef 100644 --- a/internal/cli/project_integration_test.go +++ b/internal/cli/project_integration_test.go @@ -190,7 +190,7 @@ func TestIntegrationProjectApplyStopsAndResumes(t *testing.T) { return false } posts++ - if posts == 2 { // the second creation fails before reaching Taiga + if posts == 2 { // the second creation never reaches Taiga, but a 502 does not say so w.WriteHeader(502) return true } @@ -199,7 +199,8 @@ func TestIntegrationProjectApplyStopsAndResumes(t *testing.T) { proxied := map[string]string{"TAIGA_URL": url, "TAIGA_TOKEN": env["TAIGA_TOKEN"], "TAIGA_PROJECT": env["TAIGA_PROJECT"]} out, errOut, code := runIn(t, proxied, toml, "project", "apply", "-f", "-") r := applyResult(t, out) - if code != 7 || posts != 2 || r["complete"] != false || len(r["applied"].([]any)) != 1 || len(r["remaining"].([]any)) != 2 || !strings.Contains(errOut, "server_error") { + if code != 1 || posts != 2 || r["complete"] != false || len(r["applied"].([]any)) != 1 || len(r["remaining"].([]any)) != 2 || + !strings.Contains(errOut, "status_create_unconfirmed") || !strings.Contains(errOut, "no status with this name yet") { t.Fatalf("partial: exit %d, %d POST: %s %s", code, posts, errOut, out) } // A new run re-plans: A is not created twice. diff --git a/internal/cli/redirect_integration_test.go b/internal/cli/redirect_integration_test.go new file mode 100644 index 0000000..826f103 --- /dev/null +++ b/internal/cli/redirect_integration_test.go @@ -0,0 +1,289 @@ +//go:build integration + +package cli + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/BasisTI/taiga-cli/internal/testtaiga" +) + +// redirectAfter is a proxy to the local Taiga that answers each request with method and path +// (under /api/v1/) with a 302 instead of Taiga's answer: after forwarding it to Taiga when apply, +// without forwarding it otherwise. The Location points to trap, which counts any request that +// follows it. sent counts the redirected requests. +func redirectAfter(t *testing.T, env map[string]string, trap, method, path string, apply bool) (map[string]string, *int) { + t.Helper() + sent := 0 + url := proxy(t, func(w http.ResponseWriter, r *http.Request, body []byte) bool { + if r.Method != method || r.URL.Path != "/api/v1/"+path { + return false + } + sent++ + if apply { + if got := forward(t, r, body); got.status >= 300 { + t.Errorf("forward %s %s: %d %s", method, path, got.status, got.body) + } + } + w.Header().Set("Location", trap+"/api/v1/"+path) + w.WriteHeader(302) + return true + }) + out := map[string]string{} + for k, v := range env { + out[k] = v + } + out["TAIGA_URL"] = url + return out, &sent +} + +// Every curated write against the real Taiga, answered with a redirect, applied and not applied +// (US #274): sent once, never exit 7, Location never followed; an update is decided by the +// re-read, a creation is never a success and names what it finds. +func TestIntegrationCuratedWritesRedirectIsUncertain(t *testing.T) { + suffix := fmt.Sprint(time.Now().UnixNano()) + env, _, pid := freshProject(t, "cli-test-redirect-"+suffix) + var followed atomic.Int32 + trapSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + followed.Add(1) + w.WriteHeader(418) + })) + t.Cleanup(trapSrv.Close) + t.Cleanup(func() { + if n := followed.Load(); n != 0 { + t.Errorf("the Location was followed %d times", n) + } + }) + run := func(label, method, path string, apply bool, stdin string, args ...string) (string, string, int) { + t.Helper() + proxied, sent := redirectAfter(t, env, trapSrv.URL, method, path, apply) + out, errOut, code := runIn(t, proxied, stdin, args...) + if *sent != 1 || code == 7 { + t.Errorf("%s (applied=%v): %s %s sent %d times, exit %d: %s", label, apply, method, path, *sent, code, errOut) + } + return out, errOut, code + } + expect := func(label string, apply bool, code int, errOut string, want int, wantText ...string) { + t.Helper() + if code != want { + t.Errorf("%s (applied=%v): exit %d, want %d: %s", label, apply, code, want, errOut) + return + } + for _, w := range wantText { + if !strings.Contains(errOut, w) { + t.Errorf("%s (applied=%v): %q missing: %s", label, apply, w, errOut) + } + } + } + + storyJSON(t, env, "", "api", "PATCH", "projects/"+pid, "-F", "is_epics_activated=true") + epic := storyJSON(t, env, "", "api", "POST", "epics", "-F", "project="+pid, "-f", "subject=redirect epic") + story := storyJSON(t, env, "", "story", "create", "--subject", "redirect "+suffix) + ref, path := fmt.Sprint(story["ref"]), fmt.Sprintf("userstories/%v", story["id"]) + get := func() map[string]any { return storyJSON(t, env, "", "story", "get", ref) } + + // Story PATCH: the re-read decides. + for _, apply := range []bool{false, true} { + _, errOut, code := run("story update", "PATCH", path, apply, "", "story", "update", ref, "--subject", fmt.Sprintf("subject %v", apply), "--append-description", fmt.Sprintf("line %v", apply)) + if apply { + expect("story update", apply, code, errOut, 0) + } else { + expect("story update", apply, code, errOut, 1, "story_update_unconfirmed", "taiga story get "+ref) + } + } + if s := get(); s["subject"] != "subject true" || s["description"] != "line true" { + t.Errorf("story after update: %v %q", s["subject"], s["description"]) + } + for _, apply := range []bool{false, true} { + _, errOut, code := run("story block", "PATCH", path, apply, "", "story", "update", ref, "--block", "esperando") + expect("story block", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "story_update_unconfirmed"}[apply]) + } + storyJSON(t, env, "", "api", "POST", "swimlanes", "-F", "project="+pid, "-f", "name=A") // takes every story + storyJSON(t, env, "", "api", "POST", "swimlanes", "-F", "project="+pid, "-f", "name=B") + for _, apply := range []bool{false, true} { + _, errOut, code := run("story swimlane", "PATCH", path, apply, "", "story", "update", ref, "--swimlane", "B") + expect("story swimlane", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "story_update_unconfirmed"}[apply]) + } + for _, apply := range []bool{false, true} { + _, errOut, code := run("story assignees", "PATCH", path, apply, "", "story", "update", ref, "--add-assignee", testtaiga.ServiceUser) + expect("story assignees", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "story_update_unconfirmed"}[apply]) + } + if s := get(); s["is_blocked"] != true || len(s["assigned_users"].([]any)) != 1 { + t.Errorf("story after block/assignees: %v %v", s["is_blocked"], s["assigned_users"]) + } + storyJSON(t, env, "", "field", "create", "--kind", "story", "--name", "Notas", "--type", "text") + values := fmt.Sprintf("userstories/custom-attributes-values/%v", story["id"]) + for _, apply := range []bool{false, true} { + _, errOut, code := run("story field set", "PATCH", values, apply, "", "story", "field", "set", ref, fmt.Sprintf("Notas=%v", apply)) + expect("story field set", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "story_update_unconfirmed"}[apply]) + } + for _, apply := range []bool{false, true} { + _, errOut, code := run("story close", "PATCH", path, apply, "", "story", "close", ref, "--status", "Done") + expect("story close", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "story_update_unconfirmed"}[apply]) + } + if s := get(); s["is_closed"] != true || fmt.Sprint(s["swimlane"]) == "" { + t.Errorf("story after close: %v %v", s["is_closed"], s["swimlane"]) + } + + // Story comment: never a success (option B); the new comment is named when it landed. + for _, apply := range []bool{false, true} { + _, errOut, code := run("story comment", "PATCH", path, apply, "", "story", "comment", ref, "--body", "redirected "+suffix) + expect("story comment", apply, code, errOut, 1, "comment_unconfirmed", map[bool]string{true: "1 new comment(s)", false: "not in the history"}[apply]) + } + if n := len(comments(t, env, ref)); n != 1 { + t.Errorf("story comments: %d", n) + } + + // Story create, with and without --epic: never a success; the story found is named and + // never linked. + for _, epicFlag := range [][]string{nil, {"--epic", fmt.Sprint(epic["ref"])}} { + for _, apply := range []bool{false, true} { + subject := fmt.Sprintf("created %v %d %s", apply, len(epicFlag), suffix) + out, errOut, code := run("story create", "POST", "userstories", apply, "", append([]string{"story", "create", "--subject", subject}, epicFlag...)...) + found := storyRefs(t, env, "--search", subject) + if !apply { + expect("story create", apply, code, errOut, 1, "story_create_unconfirmed", "no new story") + if found != "" { + t.Errorf("story create not applied: created %q", found) + } + continue + } + expect("story create", apply, code, errOut, 1, "story_create_unconfirmed", "#"+found+" (id ", "taiga story get "+found) + if out != "" || found == "" || strings.Contains(found, ",") || storyEpicRefs(t, env, found) != "" { + t.Errorf("story create applied: %q %q", out, found) + } + } + } + + // Epic link: idempotent, decided by the re-read. + linked := fmt.Sprint(storyJSON(t, env, "", "story", "create", "--subject", "link "+suffix)["ref"]) + for _, apply := range []bool{false, true} { + _, errOut, code := run("epic link", "POST", fmt.Sprintf("epics/%v/related_userstories", epic["id"]), apply, "", "epic", "link", fmt.Sprint(epic["ref"]), linked) + expect("epic link", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "epic_link_unconfirmed"}[apply]) + } + if got := storyEpicRefs(t, env, linked); got != fmt.Sprint(epic["ref"]) { + t.Errorf("epic link: %q", got) + } + + // Field create: never a success; Taiga refuses a second one with the name, so a re-run + // returns the one there. + for _, apply := range []bool{false, true} { + _, errOut, code := run("field create", "POST", "userstory-custom-attributes", apply, "", "field", "create", "--kind", "story", "--name", "Redir", "--type", "text") + expect("field create", apply, code, errOut, 1, "field_create_unconfirmed", map[bool]string{true: "has a field with this name", false: "no field with this name"}[apply]) + } + if again := storyJSON(t, env, "", "field", "create", "--kind", "story", "--name", "Redir", "--type", "text"); again["name"] != "Redir" || countNamed(t, env, "story", "Redir") != 1 { + t.Errorf("field create re-run: %v", again) + } + + // Attachment upload: never a success; the new one is named. + for _, apply := range []bool{false, true} { + _, errOut, code := run("attachment upload", "POST", "userstories/attachments", apply, "", "attachment", "upload", ref, localFile(t, fmt.Sprintf("r%v.txt", apply), []byte("redirect"))) + expect("attachment upload", apply, code, errOut, 1, "attachment_unconfirmed", map[bool]string{true: "(id ", false: "does not show it"}[apply]) + } + if n := len(attachmentList(t, env, ref)); n != 1 { + t.Errorf("attachments: %d", n) + } + + // Project apply: a status creation is never a success; a re-run converges. The reorder is + // decided by the re-read. + const statusTOML = "[[story_status]]\nname = \"Redir\"\ncolor = \"#000000\"\n" + for _, apply := range []bool{false, true} { + _, errOut, code := run("status create", "POST", "userstory-statuses", apply, statusTOML, "project", "apply", "-f", "-") + expect("status create", apply, code, errOut, 1, "status_create_unconfirmed", map[bool]string{true: "has a status with this name", false: "no status with this name"}[apply]) + } + if out, errOut, code := runIn(t, env, statusTOML, "project", "apply", "-f", "-"); code != 0 || applyResult(t, out)["complete"] != true { + t.Errorf("status re-run: %d %s", code, errOut) + } + const orderTOML = "[[story_status]]\nname = \"Redir\"\ncolor = \"#000000\"\nafter = \"New\"\n" + for _, apply := range []bool{false, true} { + _, errOut, code := run("reorder", "POST", "userstory-statuses/bulk_update_order", apply, orderTOML, "project", "apply", "-f", "-") + expect("reorder", apply, code, errOut, map[bool]int{true: 0, false: 4}[apply], map[bool]string{true: "", false: "status_order_postcondition_failed"}[apply]) + } + if got := statusOrder(t, env); !strings.HasPrefix(got, "New,Redir,") { + t.Errorf("order: %s", got) + } + + // Tasks: the update is decided by the re-read, the creation and the comment never succeed. + open := fmt.Sprint(storyJSON(t, env, "", "story", "create", "--subject", "tasks "+suffix)["ref"]) + task := storyJSON(t, env, "", "task", "create", "--story", open, "--subject", "task "+suffix) + tref, tpath := fmt.Sprint(task["ref"]), fmt.Sprintf("tasks/%v", task["id"]) + for _, apply := range []bool{false, true} { + _, errOut, code := run("task update", "PATCH", tpath, apply, "", "task", "update", tref, "--append-description", fmt.Sprintf("line %v", apply)) + expect("task update", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "task_update_unconfirmed"}[apply]) + } + storyJSON(t, env, "", "field", "create", "--kind", "task", "--name", "Horas", "--type", "text") + for _, apply := range []bool{false, true} { + _, errOut, code := run("task field set", "PATCH", fmt.Sprintf("tasks/custom-attributes-values/%v", task["id"]), apply, "", "task", "field", "set", tref, fmt.Sprintf("Horas=%v", apply)) + expect("task field set", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "task_update_unconfirmed"}[apply]) + } + for _, apply := range []bool{false, true} { + _, errOut, code := run("task comment", "PATCH", tpath, apply, "", "task", "comment", tref, "--body", "redirected "+suffix) + expect("task comment", apply, code, errOut, 1, "comment_unconfirmed", map[bool]string{true: "1 new comment(s)", false: "not in the history"}[apply]) + } + for _, apply := range []bool{false, true} { + _, errOut, code := run("task close", "PATCH", tpath, apply, "", "task", "close", tref) + expect("task close", apply, code, errOut, map[bool]int{true: 0, false: 1}[apply], map[bool]string{true: "", false: "task_update_unconfirmed"}[apply]) + } + if got := storyJSON(t, env, "", "task", "get", tref); got["description"] != "line true" || got["is_closed"] != true { + t.Errorf("task: %q %v", got["description"], got["is_closed"]) + } + for _, apply := range []bool{false, true} { + subject := fmt.Sprintf("new task %v %s", apply, suffix) + _, errOut, code := run("task create", "POST", "tasks", apply, "", "task", "create", "--story", open, "--subject", subject) + expect("task create", apply, code, errOut, 1, "task_create_unconfirmed", map[bool]string{true: "taiga task get ", false: "no new task"}[apply]) + } + if got := taskRefs(t, env, "--story", open); strings.Count(got, ",") != 1 { + t.Errorf("tasks of the story: %q", got) + } +} + +// The first action of an apply: Taiga creates the status (201), the proxy cuts the body, and the +// catalog read that would settle it fails with a redirect, a 5xx or a dropped connection. The +// status is saved, so the error is write_applied (exit 1), never a repeatable exit 7; a run +// without the proxy converges without a second status (review round 1, Codex). +func TestIntegrationApplyLostStatusBodyWithFailedRecovery(t *testing.T) { + for _, fail := range []string{"302", "503", "drop"} { + env, _, _ := freshProject(t, fmt.Sprintf("cli-test-lost-status-%s-%d", fail, time.Now().UnixNano())) + posted := false + url := proxy(t, func(w http.ResponseWriter, r *http.Request, body []byte) bool { + switch { + case r.Method == "POST" && r.URL.Path == "/api/v1/userstory-statuses" && !posted: + posted = true + if got := forward(t, r, body); got.status != 201 { + t.Errorf("forward: %d %s", got.status, got.body) + } + cut(w, 201) + return true + case r.Method == "GET" && r.URL.Path == "/api/v1/userstory-statuses" && posted: + switch fail { + case "302": + w.Header().Set("Location", "https://elsewhere.example/") + w.WriteHeader(302) + case "503": + w.WriteHeader(503) + default: + conn, _, _ := w.(http.Hijacker).Hijack() + _ = conn.Close() + } + return true + } + return false + }) + proxied := map[string]string{"TAIGA_URL": url, "TAIGA_TOKEN": env["TAIGA_TOKEN"], "TAIGA_PROJECT": env["TAIGA_PROJECT"]} + toml := "[[story_status]]\nname = \"ReviewStatus\"\ncolor = \"#000000\"\n" + _, errOut, code := runIn(t, proxied, toml, "project", "apply", "-f", "-") + if code != 1 || !strings.Contains(errOut, "write_applied") || !strings.Contains(errOut, "HTTP 201") || !strings.Contains(errOut, "taiga project plan") { + t.Errorf("%s: exit %d %s", fail, code, errOut) + } + out, errOut, code := runIn(t, env, toml, "project", "apply", "-f", "-") + if code != 0 || applyResult(t, out)["complete"] != true || strings.Count(statusNames(t, env), "ReviewStatus/") != 1 { + t.Errorf("%s: re-run %d %s %s", fail, code, errOut, statusNames(t, env)) + } + } +} diff --git a/internal/cli/redirect_test.go b/internal/cli/redirect_test.go new file mode 100644 index 0000000..e73f494 --- /dev/null +++ b/internal/cli/redirect_test.go @@ -0,0 +1,157 @@ +package cli + +import ( + "strings" + "testing" +) + +// Every curated write answered with a redirect (3xx) is an uncertain outcome (US #274): the +// client never follows it, and a proxy may have passed the request on. Applied or not, the write +// is sent once, the command never exits 7 (scripts repeat it) and never follows the Location. +// An update is confirmed by a re-read (exit 0 when applied); a creation is never a success +// (option B of 2026-10-03): it names what it finds. +func TestEveryCuratedWriteRedirectIsUncertain(t *testing.T) { + for _, tc := range []struct { + name string + request string + args []string + setup func(*storyFake) + // applied and missing are the exit and the code expected when the write landed or not + // (code "" with exit 0: success); found is in the error of an applied creation. + appliedExit, missingExit int + appliedCode, missingCode string + found string + }{ + {name: "story update", request: "PATCH userstories/6808", args: []string{"story", "update", "246", "--subject", "novo"}, + missingExit: 1, missingCode: "story_update_unconfirmed"}, + {name: "story update block", request: "PATCH userstories/6808", args: []string{"story", "update", "246", "--block", "esperando"}, + missingExit: 1, missingCode: "story_update_unconfirmed"}, + {name: "story update swimlane", request: "PATCH userstories/6808", args: []string{"story", "update", "246", "--swimlane", "Lane"}, + setup: func(f *storyFake) { + f.swimlanes = []map[string]any{{"id": 21, "name": "Lane", "order": 1, "project": 37}} + }, + missingExit: 1, missingCode: "story_update_unconfirmed"}, + {name: "story update assignees", request: "PATCH userstories/6808", args: []string{"story", "update", "246", "--add-assignee", "svc"}, + missingExit: 1, missingCode: "story_update_unconfirmed"}, + {name: "story close", request: "PATCH userstories/6808", args: []string{"story", "close", "246", "--status", "Done"}, + missingExit: 1, missingCode: "story_update_unconfirmed"}, + {name: "story field set", request: "PATCH userstories/custom-attributes-values/6808", args: []string{"story", "field", "set", "246", "Notas=x"}, + setup: func(f *storyFake) { g, _ := fieldFake(f.t); f.defs = g.defs }, + missingExit: 1, missingCode: "story_update_unconfirmed"}, + {name: "story comment", request: "PATCH userstories/6809", args: []string{"story", "comment", "247", "--body", "feito"}, + setup: func(f *storyFake) { f.history = map[int64][]map[string]any{6809: {}} }, + appliedExit: 1, appliedCode: "comment_unconfirmed", missingExit: 1, missingCode: "comment_unconfirmed", found: "1 new comment(s)"}, + {name: "story create", request: "POST userstories", args: []string{"story", "create", "--subject", "nova"}, + appliedExit: 1, appliedCode: "story_create_unconfirmed", missingExit: 1, missingCode: "story_create_unconfirmed", found: "taiga story get 300"}, + {name: "story create --epic", request: "POST userstories", args: []string{"story", "create", "--subject", "nova", "--epic", "91"}, + appliedExit: 1, appliedCode: "story_create_unconfirmed", missingExit: 1, missingCode: "story_create_unconfirmed", found: "taiga story get 300"}, + {name: "epic link", request: "POST epics/9/related_userstories", args: []string{"epic", "link", "91", "246"}, + missingExit: 1, missingCode: "epic_link_unconfirmed"}, + {name: "field create", request: "POST userstory-custom-attributes", args: []string{"field", "create", "--kind", "story", "--name", "Nova", "--type", "text"}, + setup: func(f *storyFake) { g, _ := fieldFake(f.t); f.defs = g.defs }, + appliedExit: 1, appliedCode: "field_create_unconfirmed", missingExit: 1, missingCode: "field_create_unconfirmed", found: "has a field with this name"}, + {name: "task create", request: "POST tasks", args: []string{"task", "create", "--story", "246", "--subject", "nova"}, + appliedExit: 1, appliedCode: "task_create_unconfirmed", missingExit: 1, missingCode: "task_create_unconfirmed", found: "taiga task get 301"}, + {name: "task update", request: "PATCH tasks/9100", args: []string{"task", "update", "250", "--subject", "novo"}, + missingExit: 1, missingCode: "task_update_unconfirmed"}, + {name: "task field set", request: "PATCH tasks/custom-attributes-values/9100", args: []string{"task", "field", "set", "250", "Horas=8h"}, + setup: func(f *storyFake) { g, _ := fieldFake(f.t); f.defs = g.defs }, + missingExit: 1, missingCode: "task_update_unconfirmed"}, + {name: "task comment", request: "PATCH tasks/9100", args: []string{"task", "comment", "250", "--body", "feito"}, + setup: func(f *storyFake) { f.history = map[int64][]map[string]any{9100: {}} }, + appliedExit: 1, appliedCode: "comment_unconfirmed", missingExit: 1, missingCode: "comment_unconfirmed", found: "1 new comment(s)"}, + } { + for _, status := range []int{301, 302, 303, 307, 308} { + for _, applied := range []bool{true, false} { + f, calls := newStoryFake(t) + if tc.setup != nil { + tc.setup(f) + } + if applied { + f.answer = map[string]int{tc.request: status} + } else { + f.fail[tc.request] = status + f.failBody = `

The document has moved here.

` + } + out, stderr, code := runIn(t, f.env(), "", tc.args...) + wantExit, wantCode := tc.missingExit, tc.missingCode + if applied { + wantExit, wantCode = tc.appliedExit, tc.appliedCode + } + sent := 0 + for _, c := range writes(calls) { + if c.method+" "+strings.TrimPrefix(c.path, "/api/v1/") == tc.request { + sent++ + } + } + label := map[bool]string{true: "applied", false: "not applied"}[applied] + switch { + case code == 7: + t.Errorf("%s %d %s: exit 7 after the write was sent: %s", tc.name, status, label, stderr) + case code != wantExit || wantCode != "" && !strings.Contains(stderr, `"`+wantCode+`"`): + t.Errorf("%s %d %s: exit %d, want %d %s: %s", tc.name, status, label, code, wantExit, wantCode, stderr) + case sent != 1: + t.Errorf("%s %d %s: %s sent %d times", tc.name, status, label, tc.request, sent) + case code != 0 && out != "": + t.Errorf("%s %d %s: stdout on failure: %s", tc.name, status, label, out) + case applied && tc.found != "" && !strings.Contains(stderr, tc.found): + t.Errorf("%s %d %s: %q not named: %s", tc.name, status, label, tc.found, stderr) + case strings.Contains(stderr, "elsewhere.example"): + t.Errorf("%s %d %s: Location echoed: %s", tc.name, status, label, stderr) + } + if tc.name == "story create --epic" && !strings.Contains(stderr, "taiga epic link 91 REF") { + t.Errorf("%s %d %s: the recovery does not finish the link: %s", tc.name, status, label, stderr) + } + if tc.name == "story create --epic" && !strings.Contains(stderr, "taiga epic link 91 REF") { + t.Errorf("%s %d %s: the recovery does not finish the link: %s", tc.name, status, label, stderr) + } + for _, c := range writes(calls) { + if tc.name == "story create --epic" && strings.Contains(c.path, "related_userstories") { + t.Errorf("%s %d %s: linked an unconfirmed story", tc.name, status, label) + } + } + } + } + } +} + +// An assignee PATCH answered with a redirect, applied, but with another write landing next to it +// (the re-read shows a version past the next one): Taiga cannot detect a concurrent change of +// assigned_to, so it is story_update_unconfirmed, not a success; --force-version skips the +// version check, as on the answered path. +func TestStoryAssigneesRedirectNextToAnotherWrite(t *testing.T) { + for _, force := range []bool{false, true} { + f, calls := newStoryFake(t) + f.answer = map[string]int{"PATCH userstories/6808": 302} + reads := 0 + f.onRead = func(s map[string]any) { + if s["id"] == 6808 { + if reads++; reads == 2 && !force || reads == 1 && force { // the re-read after the PATCH + s["version"] = s["version"].(int) + 1 + } + } + } + args := []string{"story", "update", "246", "--add-assignee", "svc"} + if force { + args = append(args, "--force-version") + } + _, stderr, code := runIn(t, f.env(), "", args...) + if force && code != 0 || !force && (code != 1 || !strings.Contains(stderr, "story_update_unconfirmed") || !strings.Contains(stderr, "the version is 9, not 8")) || len(writes(calls)) != 1 { + t.Fatalf("force=%v: %d %s", force, code, stderr) + } + } +} + +// A PATCH refused for its version (nothing written) whose re-read for the guarded retry fails is +// that read's error, never "may have been applied" (adversarial review): repeating is safe. +func TestRefusedPatchWithFailedRereadIsTheReadError(t *testing.T) { + for _, status := range []int{503, 302} { + f, calls := newStoryFake(t) + f.fail["GET userstories/6808"] = status + f.onPatch = func(s map[string]any) { s["version"] = 8 } // someone else wrote: our version 7 is refused + _, stderr, code := runIn(t, f.env(), "", "story", "update", "246", "--subject", "x") + if code != 7 || strings.Contains(stderr, "unconfirmed") || strings.Contains(stderr, "may have been applied") || len(writes(calls)) != 1 { + t.Errorf("%d: exit %d %s", status, code, stderr) + } + } +} diff --git a/internal/cli/story_test.go b/internal/cli/story_test.go index 11df5fd..3ce7e9b 100644 --- a/internal/cli/story_test.go +++ b/internal/cli/story_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "net/url" "os" "path/filepath" @@ -137,6 +138,9 @@ type storyFake struct { afterComment func() // afterHistory runs after the first page of each history read of a story is answered. afterHistory func(id int64) + // answer applies a request ("METHOD path") as usual and then answers it with this status + // and a Location instead (a redirect after the write: the outcome is uncertain). + answer map[string]int // swimlanes is the swimlane catalog; defaultSwimlane is the project's default_swimlane. swimlanes []map[string]any defaultSwimlane any @@ -234,8 +238,10 @@ func (f *storyFake) handle(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() path := strings.TrimPrefix(r.URL.Path, "/api/v1/") - q := r.URL.Query() if status, ok := f.fail[r.Method+" "+path]; ok { + if status >= 300 && status < 400 { + w.Header().Set("Location", "https://elsewhere.example/api/v1/"+path) + } w.WriteHeader(status) body := `{"_error_message":"injected"}` if f.failBody != "" { @@ -244,6 +250,19 @@ func (f *storyFake) handle(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprint(w, body) return } + if status, ok := f.answer[r.Method+" "+path]; ok { + f.serve(httptest.NewRecorder(), r, path) // applied; the answer is replaced + w.Header().Set("Location", "https://elsewhere.example/api/v1/"+path) + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `

The document has moved here.

`, path) + return + } + f.serve(w, r, path) +} + +// serve handles a request that is not an injected failure. +func (f *storyFake) serve(w http.ResponseWriter, r *http.Request, path string) { + q := r.URL.Query() project := func() bool { if q.Get("project") != "37" { f.t.Errorf("%s %s without project=37: %s", r.Method, path, r.URL.RawQuery) @@ -347,7 +366,7 @@ func (f *storyFake) handle(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) f.nextID++ - s := map[string]any{"id": f.nextID, "ref": 300, "project": 37, "version": 1, "tags": []any{}, "status": 1} + s := map[string]any{"id": f.nextID, "ref": 300, "project": 37, "version": 1, "tags": []any{}, "status": 1, "owner": 5} if path == "tasks" { // the defaults the probe observed for a minimal POST for k, v := range map[string]any{"ref": 301, "status": 11, "owner": 5, "is_closed": false, "description": "", "due_date": nil, "assigned_to": nil, "is_blocked": false, "blocked_note": "", "milestone": nil, "attachments": []any{}} { @@ -729,7 +748,8 @@ func TestStoryHTTPErrorsMapToExitCodes(t *testing.T) { {"GET userstories/by_ref", 403, 6}, {"GET projects/by_slug", 404, 5}, {"PATCH userstories/6808", 409, 4}, - {"PATCH userstories/6808", 503, 7}, + {"PATCH userstories/6808", 503, 1}, // sent, not applied: story_update_unconfirmed, never a repeatable 7 + {"PATCH userstories/6808", 302, 1}, } { f, calls := newStoryFake(t) f.fail[tc.fail] = tc.status @@ -740,8 +760,10 @@ func TestStoryHTTPErrorsMapToExitCodes(t *testing.T) { if strings.Contains(stderr, "tok") { t.Errorf("token leaked: %s", stderr) } - if n := len(writes(calls)); tc.status == 503 && n != 1 { - t.Errorf("PATCH repeated after 503: %d", n) + if n := len(writes(calls)); tc.status >= 300 && tc.status < 400 || tc.status >= 500 { + if n != 1 || !strings.Contains(stderr, "story_update_unconfirmed") { + t.Errorf("PATCH after %d: %d writes %s", tc.status, n, stderr) + } } } } diff --git a/internal/cli/task_fields_test.go b/internal/cli/task_fields_test.go index 9e10bc9..19db46e 100644 --- a/internal/cli/task_fields_test.go +++ b/internal/cli/task_fields_test.go @@ -89,19 +89,21 @@ func TestTaskCommentAndComments(t *testing.T) { } } -func TestTaskCommentLostAnswer(t *testing.T) { - for _, status := range []int{502, 302} { - for _, applied := range []bool{true, false} { - f, calls := commentFake(t) - f.history[9100] = []map[string]any{} - f.commentStatus, f.commentApplied = status, applied - _, stderr, code := runIn(t, f.env(), "", "task", "comment", "250", "--body", "feito") - want := 0 - if !applied { - want = 1 - } - if code != want || len(writes(calls)) != 1 || !applied && (!strings.Contains(stderr, "comment_unconfirmed") || !strings.Contains(stderr, "taiga task comments 250")) { - t.Fatalf("%d applied=%v: %d %s", status, applied, code, stderr) +// A comment PATCH without a conclusive answer is always comment_unconfirmed (option B), on a story +// and on a task, applied or not, with a 5xx or a 3xx: never exit 0, never a repeatable exit 7. +func TestCommentLostAnswerIsAlwaysUnconfirmed(t *testing.T) { + for _, item := range []struct{ kind, ref string }{{"story", "247"}, {"task", "250"}} { + for _, status := range []int{502, 302} { + for _, applied := range []bool{true, false} { + f, calls := commentFake(t) + f.history[9100] = []map[string]any{} + f.commentStatus, f.commentApplied = status, applied + out, stderr, code := runIn(t, f.env(), "", item.kind, "comment", item.ref, "--body", "feito") + named := strings.Contains(stderr, "1 new comment(s)") + if code != 1 || out != "" || len(writes(calls)) != 1 || !strings.Contains(stderr, "comment_unconfirmed") || named != applied || + !strings.Contains(stderr, "taiga "+item.kind+" comments "+item.ref) { + t.Fatalf("%s %d applied=%v: %d %s", item.kind, status, applied, code, stderr) + } } } } diff --git a/internal/taiga/errors.go b/internal/taiga/errors.go index 7a16d01..96c9ffe 100644 --- a/internal/taiga/errors.go +++ b/internal/taiga/errors.go @@ -19,7 +19,16 @@ type APIError struct { } func (e *APIError) Error() string { - return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, stagePath(e.Path), e.Status, truncate(e.Body)) + return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, stagePath(e.Path), e.Status, e.cause()) +} + +// cause is the body Taiga answered, truncated. A redirect's body is left out: it names where the +// redirect points, which the CLI never follows and must not echo. +func (e *APIError) cause() string { + if e.Status >= 300 && e.Status < 400 { + return "redirect not followed" + } + return truncate(e.Body) } // IsVersionConflict reports Taiga's optimistic-concurrency rejection: 409 or 412 (precondition @@ -65,6 +74,13 @@ func (e *UnreadableBodyError) Error() string { } func (e *UnreadableBodyError) Unwrap() error { return e.Err } +// RefusedWriteError is a read that failed after Taiga refused the write (a version conflict): +// nothing was written, so the read's error is the outcome, never an uncertain write. +type RefusedWriteError struct{ Err error } + +func (e *RefusedWriteError) Error() string { return e.Err.Error() } +func (e *RefusedWriteError) Unwrap() error { return e.Err } + // ConflictError means another writer changed the fields we are updating. type ConflictError struct { Method, Path string @@ -138,7 +154,7 @@ func ToOutput(err error) *output.Error { if !errors.As(err, &ae) { return output.AsError(err) } - e := &output.Error{Source: "api", Stage: ae.Method + " " + stagePath(ae.Path), Cause: truncate(ae.Body)} + e := &output.Error{Source: "api", Stage: ae.Method + " " + stagePath(ae.Path), Cause: ae.cause()} switch { case ae.Status == 401: e.Code, e.Exit, e.Recovery = "auth_rejected", output.ExitAuth, "run `taiga auth status --diagnose`" diff --git a/internal/taiga/versioned.go b/internal/taiga/versioned.go index a7d5853..f33a0b9 100644 --- a/internal/taiga/versioned.go +++ b/internal/taiga/versioned.go @@ -70,7 +70,7 @@ func (c *Client) WriteVersionedFrom(ctx context.Context, method, path string, pa } second, err := c.getObject(ctx, stagePath(path)) if err != nil { - return nil, err + return nil, &RefusedWriteError{Err: err} } if !force { if changed := changedKeys(patch, first, second); len(changed) > 0 {