Redmine/docs/API.md
Gemini 2e3ed338b5 release: 2.0.0 — motor de templates de payload (produto ambiente-agnóstico)
- bump 2.0.0 (final) + CHANGELOG datado
- docs/TEMPLATES.md (guia do usuário) + seção no README
- docs/API.md: seção do motor de templates (componentes, precedência, descoberta)

Retrocompatível: sem template ativo, comportamento idêntico ao 1.x.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:18:25 -03:00

313 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Documentação de API — Plugin Redmine Integration
> Documento de desenvolvedor. Descreve **o que sai do GLPI** e **como entra no Redmine**:
> gatilhos, chamadas à API REST do Redmine, payloads, cabeçalhos e o mapeamento de campos.
> Base de código: `inc/redmineapi.class.php` (cliente HTTP) e `hook.php` (orquestração).
## Índice
1. [Visão geral](#1-visão-geral)
2. [Autenticação e cabeçalhos](#2-autenticação-e-cabeçalhos)
3. [Gatilhos no GLPI](#3-gatilhos-no-glpi)
4. [Resolução do projeto Redmine](#4-resolução-do-projeto-redmine)
5. [Endpoints do Redmine usados](#5-endpoints-do-redmine-usados)
6. [Mapeamento de campos GLPI → Redmine](#6-mapeamento-de-campos-glpi--redmine)
7. [Impersonation (autoria)](#7-impersonation-autoria)
8. [Idempotência e tabelas de estado](#8-idempotência-e-tabelas-de-estado)
9. [Tratamento de erros](#9-tratamento-de-erros)
10. [Exemplos completos](#10-exemplos-completos)
---
## 1. Visão geral
O plugin é um **cliente da API REST do Redmine**. Ele reage a eventos do GLPI
(hooks) e traduz esses eventos em chamadas HTTP ao Redmine.
```
hooks GLPI HTTP (REST/JSON)
GLPI (chamados) ───────────► Plugin (PHP/cURL) ──────────────────► Redmine
inc/redmineapi.class.php
```
- **Sentido único** (GLPI → Redmine) no fluxo automático. O GLPI é a fonte; o
Redmine recebe issues e lançamentos de tempo.
- A única exceção de leitura é a **Sincronização de Metadados** (Redmine → cache local).
---
## 2. Autenticação e cabeçalhos
Todas as chamadas usam a **chave de API de um administrador** do Redmine
(armazenada cifrada com `GLPIKey` em `glpi_plugin_redmine_configs.api_key`).
Cabeçalhos padrão:
| Cabeçalho | Valor | Quando |
|---|---|---|
| `X-Redmine-API-Key` | `<api_key do admin>` | sempre |
| `Content-Type` | `application/json` | POST/PUT |
| `X-Redmine-Switch-User` | `<login do usuário Redmine>` | impersonation (ver §7) |
- Timeout: **5s** por requisição (evita 504).
- Sucesso = HTTP `2xx`. O corpo é JSON.
---
## 3. Gatilhos no GLPI
| Evento GLPI (hook) | Função | Ação no Redmine |
|---|---|---|
| `item_add` / `item_update` em **TicketTask** | `plugin_redmine_tickettask_*` | cria a issue (1ª vez) + lança o tempo |
| `item_update` em **Ticket** | `plugin_redmine_ticket_update` | atualiza o **status** da issue vinculada |
| Aba **Redmine** em Projeto/Entidade (form) | `front/project.form.php`, `front/entity.form.php` | cria/vincula projeto, define categoria, sincroniza metadados |
**Gate do lançamento de tempo:** o processamento da TicketTask só ocorre quando
`task.state == Planning::DONE` (Feito) **e** `actiontime > 0`. Tarefas não
concluídas permanecem apenas no GLPI.
---
## 4. Resolução do projeto Redmine
Antes de criar a issue, o plugin descobre **em qual projeto Redmine** ela entra,
a partir do chamado:
```
Ticket
├─ vinculado a um Projeto GLPI? ──► glpi_plugin_redmine_projects ─► redmine_project_id
│ (projeto via glpi_projecttasks_tickets ou glpi_itils_projects)
└─ senão ──► entities_id do ticket ─► glpi_plugin_redmine_entities ─► redmine_project_id
(match EXATO da entidade, sem herança de árvore)
```
Sem vínculo (nem projeto nem entidade) → **nada é enviado** (log e retorno).
---
## 5. Endpoints do Redmine usados
### Leitura (Sincronizar Metadados)
| Método | Endpoint | Uso |
|---|---|---|
| GET | `/projects.json?limit=100` | lista de projetos (cache + dropdowns) |
| GET | `/trackers.json` | tipos de tarefa |
| GET | `/issue_statuses.json` | status (alvo do mapa de status) |
| GET | `/enumerations/issue_priorities.json` | prioridades |
| GET | `/enumerations/time_entry_activities.json` | atividades de tempo |
| GET | `/roles.json` | papéis (para auto-membership) |
| GET | `/users.json?limit=100&offset=N` | usuários (cache p/ mapeamento; paginado) |
| GET | `/projects/{id}/issue_categories.json` | categorias (por projeto) |
| GET | `/custom_fields.json` | definições de campos personalizados (filtra `customized_type=project`) |
### Escrita
| Método | Endpoint | Uso |
|---|---|---|
| POST | `/projects.json` | criar projeto Redmine (aba Projeto/Entidade) — inclui `custom_field_values` |
| POST | `/issues.json` | **criar a issue** a partir do chamado |
| PUT | `/issues/{id}.json` | sincronizar **status** da issue |
| POST | `/time_entries.json` | **lançar tempo** da TicketTask |
| POST | `/projects/{id}/memberships.json` | adicionar técnico como membro (auto-membership) |
---
## 6. Mapeamento de campos GLPI → Redmine
### Issue (`POST /issues.json`)
| Campo Redmine (`issue.*`) | Origem no GLPI |
|---|---|
| `project_id` | projeto Redmine resolvido (§4) |
| `subject` | `Ticket.name` (truncado a 255, HTML removido) |
| `description` | `Ticket.content` (HTML removido) |
| `tracker_id` | `config.default_tracker_id` |
| `priority_id` | `config.default_priority_id` |
| `status_id` | `config.status_map[Ticket.status]` |
| `category_id` | `default_category_id` do vínculo (projeto/entidade) |
| `watcher_user_ids` | `config.default_watcher_ids` (array) |
| **autor** | técnico **atribuído** ao chamado → `X-Redmine-Switch-User` (§7) |
### Lançamento de tempo (`POST /time_entries.json`)
| Campo Redmine (`time_entry.*`) | Origem no GLPI |
|---|---|
| `issue_id` | issue da issue do chamado |
| `hours` | `TicketTask.actiontime / 3600` (segundos → horas decimais) |
| `comments` | `TicketTask.content` (HTML removido) |
| `activity_id` | `config.default_activity_id` |
| **autor** | quem lançou a task (`TicketTask.users_id`) → `X-Redmine-Switch-User` |
### Status (`PUT /issues/{id}.json`)
| Campo | Origem |
|---|---|
| `status_id` | `config.status_map[Ticket.status]` (no `item_update` do Ticket) |
### Criação de projeto (`POST /projects.json` — abas Redmine de Projeto/Entidade)
| Campo Redmine (`project.*`) | Origem |
|---|---|
| `name`, `description`, `is_public` | formulário da aba |
| `identifier` | formulário, **normalizado para minúsculas** (regra do Redmine) |
| `parent_id` | dropdown "Subprojeto de" |
| `enabled_module_names` | multiselect "Módulos" |
| `custom_field_values` | `{ "<cf_id>": "<valor>", ... }` — campos personalizados de projeto |
Os campos personalizados são renderizados **dinamicamente** a partir de
`metadata_cache.project_custom_fields` (populado pelo "Sincronizar Metadados" via
`GET /custom_fields.json`). Em instâncias onde eles são **obrigatórios** (ex.:
produção Mindtek: "Tipo de Projeto" e "Tipo de Faturamento"), a criação falha com
422 se não forem enviados.
### Conversões importantes
- **Tempo:** GLPI grava `actiontime` em **segundos** (int); Redmine espera **horas decimais**. `5400 → 1.5`.
- **Subject:** limite de 255 chars no Redmine → truncado.
- **HTML:** descrições do GLPI (TinyMCE) passam por `RichText::getTextFromHtml`.
- **Identificador de projeto:** minúsculas/números/traços — normalizado em `createProjectFull()`.
### Regras que variam por instância/projeto do Redmine (validar em produção)
- **Custom fields de projeto** podem ser obrigatórios na criação (ver acima).
- **Atividade do time entry é por projeto**: a `default_activity_id` precisa estar
habilitada no projeto de destino (senão 422 "não está incluso na lista").
- **Comentário do time entry** pode ser obrigatório: task sem descrição usa o
fallback "Tempo lançado via GLPI (chamado #N)".
- **Tracker é por projeto**: default global vazio = Redmine usa o default do projeto.
- Issues só podem ser criadas em projetos **ativos** (o dropdown de vínculo já filtra).
---
## 7. Impersonation (autoria)
Por padrão, tudo criado via API nasce como o **admin** (dono da chave). Para
atribuir o autor correto, o plugin usa o cabeçalho **`X-Redmine-Switch-User: <login>`**.
**Pré-requisito:** o usuário impersonado precisa ser **membro do projeto Redmine**
com um papel — senão o Redmine retorna **403**. Por isso, antes de impersonar, o
plugin chama `ensureProjectMembership()` (adiciona o técnico com `config.default_role_id`).
**Mapeamento GLPI → usuário Redmine** (`resolveRedmineUser`):
1. **Override manual** (`glpi_plugin_redmine_usermap`), se houver.
2. **Auto-match por e-mail** (e-mail do usuário GLPI = `mail` do usuário Redmine, do cache).
Sem correspondência → cai no admin (fallback, §9).
---
## 8. Idempotência e tabelas de estado
| Tabela | Garante |
|---|---|
| `glpi_plugin_redmine_tickets` (`tickets_id` único) | **1 issue por chamado** (reuso) |
| `glpi_plugin_redmine_tasks` (`tickettasks_id` único) | tempo lançado **1× por task** (sem duplicar) |
| `glpi_plugin_redmine_projects` / `_entities` | vínculo GLPI ↔ projeto Redmine |
| `glpi_plugin_redmine_users` / `_usermap` | cache de usuários + overrides de mapeamento |
Editar horas de uma task já lançada **não** re-sincroniza. Reverter `Feito`→`A fazer`
**não** apaga o time entry.
---
## 9. Tratamento de erros
- **Impersonation falha** (403/412 — usuário sem par no Redmine ou não-membro):
o plugin **refaz a chamada como admin** (não perde a issue/tempo) e registra
aviso em `files/_log/redmine*`.
- **API inacessível / chave inválida:** `syncMetadata()` retorna `false`; criação
de issue/tempo é abortada com log.
- Todo erro HTTP `>= 400` é logado via `Toolbox::logInFile('redmine', ...)` (em `files/_log/redmine*`).
---
## 10. Exemplos completos
### Criar issue (com impersonation)
```http
POST /issues.json HTTP/1.1
X-Redmine-API-Key: <admin_key>
X-Redmine-Switch-User: joao.silva
Content-Type: application/json
```
Resposta `201`: `{ "issue": { "id": 42, "author": { "id": 9, "name": "João Silva" }, ... } }`
### Lançar tempo (com impersonation)
```http
POST /time_entries.json HTTP/1.1
X-Redmine-API-Key: <admin_key>
X-Redmine-Switch-User: joao.silva
Content-Type: application/json
```
### Sincronizar status da issue
```http
PUT /issues/42.json
X-Redmine-API-Key: <admin_key>
Content-Type: application/json
{ "issue": { "status_id": 5 } }
```
### Auto-membership (antes de impersonar)
```http
POST /projects/3/memberships.json
X-Redmine-API-Key: <admin_key>
Content-Type: application/json
{ "membership": { "user_id": 9, "role_ids": [3] } }
```
---
### Resumo do fluxo end-to-end (suporte recorrente / entidade)
1. Técnico marca a TicketTask como **Feito** no GLPI.
2. Hook resolve o projeto Redmine pela **entidade** do chamado.
3. (1ª vez) `POST /issues.json` cria a issue — autor = técnico atribuído (impersonation + auto-membership).
4. `POST /time_entries.json` lança as horas — autor = quem lançou a task.
5. Ao mudar o status do chamado, `PUT /issues/{id}.json` reflete no Redmine.
---
## Motor de Templates de Payload (2.0)
A partir da 2.0, o corpo de cada operação pode vir de um **template** editável em
vez de ser montado no código. Guia do usuário: [`TEMPLATES.md`](TEMPLATES.md).
### Componentes internos
- **`PluginRedmineTemplateengine`** (`inc/templateengine.class.php`): armazenamento
(`glpi_plugin_redmine_templates`), `defaultTemplate()`/`mergeDiscoveredCustomFields()`
(seeds), `variableCatalog()` e `fieldDictionary()` (paleta "DE" e dicionário "PARA"),
`buildContext()`/`buildFormContext()`/`sampleContext()` (contextos), e o renderizador
`render()` (tags mustache-like, tipo nativo p/ tag inteira, filtros, `prune` de nulos).
- **`PluginRedmineRedmineapi::createIssueFromPayload()` / `logTimeFromPayload()`**:
enviam um payload já renderizado (com fallback de impersonation → admin).
- **`PluginRedmineRedmineapi::rawRequest()`**: requisição crua (código+corpo) usada
pelo testador — o erro **é** a informação.
- **`ajax/template_test.php`**: `action=preview` (render com `sampleContext`) e
`action=dryrun` (cria+apaga objeto de teste, traduz 422 em dicas).
### Precedência (motor 2.0 × 1.x)
Em cada operação do hook/controllers:
1. Existe template **ativo** para a operação? Renderiza e envia esse payload.
2. Falha no **render** (JSON/tag inválidos)? Loga e **cai no comportamento 1.x**.
3. Sem template ativo? Comportamento **1.x** (idêntico às versões anteriores).
### Descoberta de campos
`GET /custom_fields.json` (admin) é cacheado por `syncMetadata()` — os campos
personalizados de `project`, `issue` e `time_entry` alimentam tanto o seed do
"Gerar template" (só os **obrigatórios**) quanto o dicionário "PARA" (todos).