diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..f008343 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,256 @@ +# 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` | `` | sempre | +| `Content-Type` | `application/json` | POST/PUT | +| `X-Redmine-Switch-User` | `` | 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) | + +### Escrita +| Método | Endpoint | Uso | +|---|---|---| +| POST | `/projects.json` | criar projeto Redmine (aba Projeto/Entidade) | +| 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) | + +### 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`. + +--- + +## 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: `**. + +**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::logError`. + +--- + +## 10. Exemplos completos + +### Criar issue (com impersonation) +```http +POST /issues.json HTTP/1.1 +X-Redmine-API-Key: +X-Redmine-Switch-User: joao.silva +Content-Type: application/json + +{ + "issue": { + "project_id": 3, + "subject": "Erro no servidor de e-mail", + "description": "Cliente relata falha no envio.", + "tracker_id": 1, + "priority_id": 2, + "status_id": 1, + "category_id": 7, + "watcher_user_ids": [5] + } +} +``` +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: +X-Redmine-Switch-User: joao.silva +Content-Type: application/json + +{ + "time_entry": { + "issue_id": 42, + "hours": 1.5, + "activity_id": 1, + "comments": "Reinício do serviço e validação." + } +} +``` + +### Sincronizar status da issue +```http +PUT /issues/42.json +X-Redmine-API-Key: +Content-Type: application/json + +{ "issue": { "status_id": 5 } } +``` + +### Auto-membership (antes de impersonar) +```http +POST /projects/3/memberships.json +X-Redmine-API-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.