Azure DevOps Boards com Nada Além de curl e jq

Eu precisava que um fluxo de trabalho lesse um work item, criasse Stories e Tasks, movesse cartões pelo board e anexasse links de PR. Os caminhos óbvios são o CLI az ou um SDK de linguagem. Eu não queria nenhum dos dois: quem chama é um script bash, precisa funcionar headless, e adicionar uma dependência Python a uma toolchain de shell para fazer quatro chamadas HTTP é uma troca ruim.

A API REST é tranquila de usar diretamente. curl para transporte, jq tanto para parsing quanto para construir requisições, cerca de 200 linhas. O que segue são principalmente as quatro coisas que deram errado, porque são as partes que você não lê na documentação.

A forma

Um script, um subcomando por operação:

azure-workitem.sh fetch 6758                    # JSON compacto: tipo, estado, título, AC, repro
azure-workitem.sh create "User Story" "titulo" --parent 6760 --description "<p>…</p>"
azure-workitem.sh transition 6758 "Code Review"
azure-workitem.sh link 6758 "<url-do-pr>" "PR #42"
azure-workitem.sh parent 6793 6792
azure-workitem.sh wiql 'SELECT [System.Id] FROM WorkItems WHERE …'

Duas decisões estruturais se pagaram. Toda escrita passa por uma única função, então o --dry-run é imposto em um lugar em vez de por subcomando. E corpos de requisição são construídos com jq, nunca por interpolação de string — o formato JSON Patch que o Azure quer é chato, e títulos contêm aspas e travessões:

body="$(jq -n --arg title "$title" --arg assign "$assign" '
  [ {op:"add", path:"/fields/System.Title", value:$title} ]
  + (if $assign != "" then [{op:"add", path:"/fields/System.AssignedTo", value:$assign}] else [] end)')"

Nomes de projeto contêm espaços, então codifique-os em vez de escapar à mão: jq -rn --arg s "$PROJECT" '$s|@uri'. Isso permite que o arquivo de configuração guarde R10 Score Development como está escrito.

Falha 1: um problema de permissão chega como HTTP 500

Meu primeiro create retornou isto, sem corpo:

curl: (22) The requested URL returned error: 500

Um 500 te manda procurar uma requisição malformada. Passei um tempo verificando o documento de patch. A mensagem real estava lá quando removi o curl -f, que suprime o corpo da resposta em status de erro:

VS403410: You don't have suppress notifications permission.

Eu vinha anexando suppressNotifications=true em toda escrita, na teoria razoável de que um script espelhando progresso não deveria mandar e-mail ao time em cada transição. Esse parâmetro exige uma permissão de nível de coleção que minha conta não tem — e a resposta do Azure para usá-lo sem permissão não é um 403 no parâmetro. Ele derruba a escrita inteira, como um 500.

Duas coisas a levar disso. Remova o -f durante o desenvolvimento, ou você joga fora a única parte útil de uma resposta de erro. E desconfie de um 500 numa requisição que você nunca fez com sucesso: pode ser um problema de permissão vestido de erro de servidor.

O parâmetro sumiu. Notificações seguem as regras normais do projeto, que é o custo de não ser admin da coleção.

Falha 2: pais se marcam como seu trabalho

Construí uma consulta para "work items que toquei hoje" para alimentar uma planilha de horas:

SELECT [System.Id] FROM WorkItems
WHERE [System.ChangedBy] = 'eu@exemplo.com'
  AND [System.ChangedDate] >= '2026-08-04'

ChangedBy em vez de AssignedTo, deliberadamente — um cartão que outra pessoa move não é meu dia de trabalho.

A consulta retornou onze itens. Quatro eram Features e um era um Epic que eu nunca havia aberto. Estavam lá porque criar um filho atualiza o ChangedDate do pai, e o ChangedBy do pai se torna quem criou o filho. Eu havia criado cartões de Story e Task sob eles naquela manhã, então todo contêiner acima aparecia como trabalho que eu pessoalmente havia feito.

A consulta agora exclui tipos contêineres de forma explícita:

AND [System.WorkItemType] NOT IN ('Task','Feature','Epic','Iniciativa')

Task é excluído por um motivo diferente — um dia de trabalho orientado a spec toca uma dúzia deles sob uma única Story, o que é granular demais para uma planilha que um gestor lê. Isso levou o dia de 26 atividades para 7.

Falha 3: um valor de flag vazio que órfã em silêncio

Criei dois work items com --parent "$FEAT", e os dois voltaram com aparência perfeita: tipo certo, responsável certo, tags certas, estado certo. Os dois eram órfãos.

$FEAT estava vazio — definido em uma invocação anterior do shell, e estado de shell não persiste entre elas. Então a chamada era --parent "", e meu tratamento de argumentos fazia isto:

--parent) parent="${2:-}"; shift 2 ;;

Vazio é um valor válido, então nenhum erro. E adiante, a relação de pai só era anexada quando o valor era não vazio — uma guarda estilo // empty que transformou "você não me deu nada" em "você não pediu um pai".

A resposta do create não deu pista alguma, porque da perspectiva da API nada estava errado. Só descobri listando os filhos do pai e recebendo zero.

--parent) parent="${2:-}"; [ -n "$parent" ] || die "--parent recebeu valor vazio"; shift 2 ;;

A regra geral que eu escreveria na parede: uma flag opcional que foi explicitamente passada com valor vazio é um bug, não uma omissão. Distinga "ausente" de "presente mas vazio" sempre que a diferença for silenciosa.

Também adicionei um subcomando parent <id> <idDoPai>, já que reparar os dois órfãos de outra forma significava construir um patch /relations/- à mão.

Falha 4: dois tokens, mesmo tamanho, um revogado

As credenciais vinham de uma variável de ambiente primeiro, depois do cofre do sistema operacional. Ordem sensata — env para CI, keyring para uso interativo.

Toda chamada começou a retornar 401. O token no ambiente e o token no keyring tinham ambos 84 caracteres. Só um funcionava:

keyring  len=84  http=200  ✓
env      len=84  http=401  ✗
valores DIFEREM

Um token velho num perfil de shell havia sobrevivido ao bom que estava no keyring. Como o env era verificado primeiro, o morto sombreava o que funcionava, e a falha parecia exatamente um problema de permissão.

A ordem agora é cofre primeiro, ambiente como fallback:

PAT="$(secret-tool lookup service azure-devops-pat 2>/dev/null || true)"
PAT="${PAT:-${AZDO_PAT:-${AZURE_DEVOPS_EXT_PAT:-}}}"

Execuções headless não têm keyring, então o env ainda ganha onde é necessário. E uma entrada velha de perfil não consegue mais sombrear a credencial que você mantém ativamente.

Coisas pequenas que ajudaram

Valide nomes de estado contra o tipo antes de patchar. Nosso board tem 19 estados para uma User Story e um conjunto diferente para uma Task. Um System.State inválido retorna um 400 que se lê como falha de autenticação, então o script busca os estados permitidos do tipo primeiro e, em caso de erro, os imprime:

azure-workitem: 'Estado Inexistente' não é um estado de User Story. Válidos:
  New
  Business Refinement
  …

Remova HTML na leitura. System.Description e AcceptanceCriteria são rich text. Um filtro jq que remove tags e decodifica as entidades comuns os torna usáveis como entrada de spec.

--dry-run em toda escrita, canalizado por uma única função. Essa é a que eu exigiria, e também é onde me queimei: uma adição posterior chamou o cliente da API diretamente em vez de passar por esse canal, o que significa que a flag de dry-run não se aplicava a ela. Criou três cartões reais num board compartilhado durante um teste. Uma flag de segurança imposta por local de chamada é uma convenção, não uma garantia — deveria existir exatamente um lugar de onde uma requisição pode sair.