Redmine/hook.php
Gemini 7ac1f8ac44 feat(2.0): Fase 1 — motor de templates de payload (design + engine + fallback 1.x)
- docs/DESIGN-2.0.md: decisões fechadas (tags PT, editor linha-a-linha,
  escopo global, dry-run com preview) e arquitetura Motor x Template
- PluginRedmineTemplateengine: render mustache-like ({{ var | filtro }}),
  regra de tipos (tag inteira = tipo nativo), lookup indexado
  (map.status[chamado.status]), filtros (truncar, sem_html, minusculo,
  identificador, padrao), prune de nulls, seeds 1.x-equivalentes e
  descoberta de custom fields obrigatórios
- tabela glpi_plugin_redmine_templates (operation único, is_active)
- wiring com precedência de template + fallback no render em
  create_issue, log_time e update_status
- validado: 12/12 unit + E2E (template ativo cria issue/tempo; template
  quebrado cai no motor 1.x sem perder lançamento)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:25:32 -03:00

524 lines
21 KiB
PHP

<?php
/**
* Install hook
*
* @return boolean
*/
function plugin_redmine_install() {
global $DB;
$migration = new \Migration(PLUGIN_REDMINE_VERSION);
// Config table for storing Redmine URL and API Key
if (!$DB->tableExists("glpi_plugin_redmine_configs")) {
$query = "CREATE TABLE `glpi_plugin_redmine_configs` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`redmine_url` varchar(255) DEFAULT NULL,
`api_key` varchar(255) DEFAULT NULL,
`metadata_cache` LONGTEXT DEFAULT NULL,
`default_tracker_id` int(11) NOT NULL DEFAULT 0,
`default_priority_id` int(11) NOT NULL DEFAULT 0,
`default_activity_id` int(11) NOT NULL DEFAULT 0,
`default_role_id` int(11) NOT NULL DEFAULT 0,
`default_watcher_ids` TEXT DEFAULT NULL,
`status_map` TEXT DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
$migration->addPostQuery("INSERT INTO `glpi_plugin_redmine_configs` (`id`, `redmine_url`, `api_key`) VALUES (1, '', '')");
} else {
if (!$DB->fieldExists("glpi_plugin_redmine_configs", "metadata_cache")) {
$migration->addField('glpi_plugin_redmine_configs', 'metadata_cache', 'LONGTEXT');
}
// Default values for Redmine issue creation (formconfig).
$new_fields = [
'default_tracker_id' => "int NOT NULL DEFAULT '0'",
'default_priority_id' => "int NOT NULL DEFAULT '0'",
'default_activity_id' => "int NOT NULL DEFAULT '0'",
'default_role_id' => "int NOT NULL DEFAULT '0'",
'default_watcher_ids' => "text",
'status_map' => "text",
];
foreach ($new_fields as $field => $type) {
if (!$DB->fieldExists('glpi_plugin_redmine_configs', $field)) {
$migration->addField('glpi_plugin_redmine_configs', $field, $type);
}
}
}
// Mapping table for GLPI Projects <-> Redmine Projects
if (!$DB->tableExists("glpi_plugin_redmine_projects")) {
$query = "CREATE TABLE `glpi_plugin_redmine_projects` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`projects_id` int(11) NOT NULL,
`redmine_project_id` int(11) NOT NULL,
`default_category_id` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `projects_id` (`projects_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
} else {
if (!$DB->fieldExists('glpi_plugin_redmine_projects', 'default_category_id')) {
$migration->addField('glpi_plugin_redmine_projects', 'default_category_id', "int NOT NULL DEFAULT '0'");
}
}
// Mapping table for GLPI Tickets <-> Redmine Issues (one issue per ticket).
// Prevents creating a duplicate issue every time a TicketTask is saved.
if (!$DB->tableExists("glpi_plugin_redmine_tickets")) {
$query = "CREATE TABLE `glpi_plugin_redmine_tickets` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`tickets_id` int(11) NOT NULL,
`redmine_issue_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `tickets_id` (`tickets_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
}
// Mapping table for GLPI TicketTasks <-> Redmine time entries.
// Guarantees a task logs its time only once (no double-count on update).
if (!$DB->tableExists("glpi_plugin_redmine_tasks")) {
$query = "CREATE TABLE `glpi_plugin_redmine_tasks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`tickettasks_id` int(11) NOT NULL,
`redmine_time_entry_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `tickettasks_id` (`tickettasks_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
}
// Mapping table for GLPI Entities <-> Redmine Projects (1:1, exact match).
// For recurring-support clients whose tickets are born in an entity, with no project.
if (!$DB->tableExists("glpi_plugin_redmine_entities")) {
$query = "CREATE TABLE `glpi_plugin_redmine_entities` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`entities_id` int(11) NOT NULL,
`redmine_project_id` int(11) NOT NULL,
`default_category_id` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `entities_id` (`entities_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
}
// Payload templates (motor 2.0 — ver docs/DESIGN-2.0.md). Sem template
// ativo, o plugin usa o comportamento 1.x.
if (!$DB->tableExists("glpi_plugin_redmine_templates")) {
$query = "CREATE TABLE `glpi_plugin_redmine_templates` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`operation` varchar(50) NOT NULL,
`template` MEDIUMTEXT DEFAULT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `operation` (`operation`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
}
// Cache of Redmine users (populated by "Sincronizar Metadados").
if (!$DB->tableExists("glpi_plugin_redmine_users")) {
$query = "CREATE TABLE `glpi_plugin_redmine_users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`redmine_id` int(11) NOT NULL,
`login` varchar(255) DEFAULT NULL,
`mail` varchar(255) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `redmine_id` (`redmine_id`),
KEY `mail` (`mail`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
}
// Manual overrides: GLPI user -> Redmine user (used when e-mail auto-match
// is not enough). Email auto-match is resolved at runtime against the cache.
if (!$DB->tableExists("glpi_plugin_redmine_usermap")) {
$query = "CREATE TABLE `glpi_plugin_redmine_usermap` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`users_id` int(11) NOT NULL,
`redmine_user_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `users_id` (`users_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$migration->addPostQuery($query);
}
$migration->executeMigration();
// One-time migration: encrypt any API key still stored in plain text.
// GLPIKey::decrypt() returns '' for a value that was not encrypted by it,
// so a non-empty key that decrypts to empty is plain text and must be cifered.
if ($DB->tableExists("glpi_plugin_redmine_configs")) {
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
if (count($it) > 0) {
$row = $it->current();
$stored = (string) ($row['api_key'] ?? '');
if ($stored !== '') {
$key = new \GLPIKey();
$decoded = (string) $key->decrypt($stored);
if ($decoded === '') {
$DB->update('glpi_plugin_redmine_configs', [
'api_key' => $key->encrypt($stored)
], ['id' => 1]);
}
}
}
}
return true;
}
/**
* Uninstall hook
*
* @return boolean
*/
function plugin_redmine_uninstall() {
global $DB;
$tables = [
"glpi_plugin_redmine_configs",
"glpi_plugin_redmine_projects",
"glpi_plugin_redmine_tickets",
"glpi_plugin_redmine_tasks",
"glpi_plugin_redmine_users",
"glpi_plugin_redmine_usermap",
"glpi_plugin_redmine_entities",
"glpi_plugin_redmine_templates"
];
foreach ($tables as $table) {
if ($DB->tableExists($table)) {
$DB->dropTable($table);
}
}
return true;
}
/**
* Hook for TicketTask creation
*/
function plugin_redmine_tickettask_add(TicketTask $task) {
_plugin_redmine_process_tickettask($task);
}
/**
* Hook for TicketTask update
*/
function plugin_redmine_tickettask_update(TicketTask $task) {
_plugin_redmine_process_tickettask($task);
}
/**
* Hook for Ticket update: keep the linked Redmine issue status in sync.
* Only acts if an issue already exists for this ticket (created on first done task).
*/
function plugin_redmine_ticket_update(Ticket $ticket) {
global $DB;
$ticketId = (int) $ticket->getID();
$issueMap = $DB->request([
'FROM' => 'glpi_plugin_redmine_tickets',
'WHERE' => ['tickets_id' => $ticketId]
]);
if (count($issueMap) === 0) {
return; // No Redmine issue yet for this ticket
}
$issueId = (int) $issueMap->current()['redmine_issue_id'];
// Motor 2.0: template ativo tem precedência; erro de render cai no 1.x.
$tpl = PluginRedmineTemplateengine::getActive('update_status');
if ($tpl) {
try {
$ctx = PluginRedmineTemplateengine::buildContext($ticket, null, null, null, $issueId);
$payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
if (!empty($payload['issue'])) {
PluginRedmineRedmineapi::updateIssue($issueId, $payload['issue']);
}
return;
} catch (\Throwable $e) {
Toolbox::logInFile('redmine', "Template update_status falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
}
}
$mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']);
if ($mappedStatus) {
PluginRedmineRedmineapi::updateIssue($issueId, ['status_id' => $mappedStatus]);
}
}
/**
* Return the first assigned technician (users_id) of a ticket, or 0.
*/
function _plugin_redmine_assigned_tech($ticketId) {
global $DB;
$it = $DB->request([
'SELECT' => 'users_id',
'FROM' => 'glpi_tickets_users',
'WHERE' => [
'tickets_id' => (int) $ticketId,
'type' => \CommonITILActor::ASSIGN,
['users_id' => ['>', 0]],
],
'LIMIT' => 1,
]);
return count($it) > 0 ? (int) $it->current()['users_id'] : 0;
}
/**
* Resolve a GLPI user to a Redmine login for impersonation, ensuring the user
* is a member of the Redmine project (so impersonation is allowed).
* @return string|null the Redmine login, or null if no mapping
*/
function _plugin_redmine_impersonation_login($glpi_users_id, $redmineProjectId) {
if (empty($glpi_users_id)) {
return null;
}
$ru = PluginRedmineConfig::resolveRedmineUser($glpi_users_id);
if (!$ru || empty($ru['login'])) {
// Sem par no Redmine: o lançamento sairá como admin. Loga para não ser silencioso.
Toolbox::logInFile('redmine', "Sem par Redmine para o usuário GLPI #{$glpi_users_id} ("
. getUserName($glpi_users_id) . ") — lançando como admin. Verifique o e-mail do usuário e o cache (Sincronizar Metadados).\n");
return null;
}
$roleId = PluginRedmineConfig::getDefaultRoleId();
if ($roleId) {
PluginRedmineRedmineapi::ensureProjectMembership($redmineProjectId, (int) $ru['redmine_id'], [$roleId]);
}
return $ru['login'];
}
/**
* Internal logic for processing TicketTasks and sending time to Redmine
*/
function _plugin_redmine_process_tickettask(TicketTask $task) {
// Time is only sent to Redmine once the task is concluded (state = Done).
// While not done, the task lives only in GLPI.
if ((int) ($task->fields['state'] ?? 0) !== \Planning::DONE) {
return;
}
if (!isset($task->fields['actiontime']) || $task->fields['actiontime'] <= 0) {
return; // No time logged
}
global $DB;
$taskId = (int) $task->getID();
$ticketId = (int) $task->fields['tickets_id'];
// Time entry is logged only once per task: if we already recorded it,
// a later update must not create another Redmine time entry.
$logged = $DB->request([
'FROM' => 'glpi_plugin_redmine_tasks',
'WHERE' => ['tickettasks_id' => $taskId]
]);
if (count($logged) > 0) {
return;
}
$ticket = new Ticket();
if (!$ticket->getFromDB($ticketId)) {
return;
}
// Resolve the GLPI project linked to this ticket.
// GLPI 11 links tickets to projects either through a project task
// (glpi_projecttasks_tickets) or directly (glpi_projects_tickets).
$iterator = $DB->request([
'FROM' => 'glpi_projecttasks_tickets',
'WHERE' => ['tickets_id' => $ticketId]
]);
$projectId = null;
if (count($iterator) > 0) {
$link = $iterator->current();
$pt = new ProjectTask();
if ($pt->getFromDB($link['projecttasks_id'])) {
$projectId = (int) $pt->fields['projects_id'];
}
} else {
// Direct link Project <-> Ticket (GLPI 11 stores it polymorphically
// in glpi_itils_projects with itemtype = 'Ticket').
$iterator = $DB->request([
'FROM' => 'glpi_itils_projects',
'WHERE' => ['itemtype' => 'Ticket', 'items_id' => $ticketId]
]);
if (count($iterator) > 0) {
$link = $iterator->current();
$projectId = (int) $link['projects_id'];
}
}
// Resolve the Redmine project. Priority: the linked GLPI project; if none,
// fall back to the ticket's ENTITY (1:1, exact match, no tree inheritance).
// $linkRow holds the mapping row (carries default_category_id) in either case.
$redmineProjectId = null;
$linkRow = null;
if ($projectId) {
$mapping = $DB->request([
'FROM' => 'glpi_plugin_redmine_projects',
'WHERE' => ['projects_id' => $projectId]
]);
if (count($mapping) > 0) {
$linkRow = $mapping->current();
$redmineProjectId = (int) $linkRow['redmine_project_id'];
} else {
// Fallback: resolve by the conventional identifier.
$redmineProjectId = PluginRedmineRedmineapi::getProjectByIdentifier("glpi-proj-" . $projectId);
}
}
// Entity-based clients (recurring support): ticket born in a linked entity, no project.
if (!$redmineProjectId) {
$entMap = $DB->request([
'FROM' => 'glpi_plugin_redmine_entities',
'WHERE' => ['entities_id' => (int) $ticket->fields['entities_id']]
]);
if (count($entMap) > 0) {
$linkRow = $entMap->current();
$redmineProjectId = (int) $linkRow['redmine_project_id'];
}
}
if (!$redmineProjectId) {
Toolbox::logInFile('redmine', "No Redmine link for ticket #$ticketId (no project nor entity link).\n");
return;
}
// Reuse a single Redmine issue per GLPI ticket.
$issueId = null;
$issueMap = $DB->request([
'FROM' => 'glpi_plugin_redmine_tickets',
'WHERE' => ['tickets_id' => $ticketId]
]);
if (count($issueMap) > 0) {
$candidate = (int) $issueMap->current()['redmine_issue_id'];
if (PluginRedmineRedmineapi::issueExists($candidate)) {
$issueId = $candidate;
} else {
// A issue foi apagada no Redmine: remove o vínculo órfão e recria.
Toolbox::logInFile('redmine', "Issue #{$candidate} do ticket #{$ticketId} não existe mais; recriando.\n");
$DB->delete('glpi_plugin_redmine_tickets', ['tickets_id' => $ticketId]);
}
}
if (!$issueId) {
// Author = the GLPI assigned technician, via impersonation.
$techId = _plugin_redmine_assigned_tech($ticketId);
$switchUser = _plugin_redmine_impersonation_login($techId, $redmineProjectId);
// Motor 2.0: template ativo tem precedência. Erro de RENDER cai no 1.x;
// template válido é a palavra final (sem retry pela via legada).
$tpl = PluginRedmineTemplateengine::getActive('create_issue');
if ($tpl) {
try {
$ctx = PluginRedmineTemplateengine::buildContext($ticket, $task, $linkRow, $redmineProjectId);
$payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
if (!empty($payload['issue'])) {
$issueId = PluginRedmineRedmineapi::createIssueFromPayload($payload['issue'], $switchUser);
}
} catch (\Throwable $e) {
Toolbox::logInFile('redmine', "Template create_issue falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
$tpl = null; // render quebrado: deixa o 1.x assumir
}
}
if (!$tpl) {
// Motor 1.x: issue a partir do chamado + defaults configurados.
$cfg = PluginRedmineConfig::getConfigRow();
$extra = [];
if (!empty($cfg['default_tracker_id'])) { $extra['tracker_id'] = (int) $cfg['default_tracker_id']; }
if (!empty($cfg['default_priority_id'])) { $extra['priority_id'] = (int) $cfg['default_priority_id']; }
// Category is per link (project or entity).
if ($linkRow && !empty($linkRow['default_category_id'])) {
$extra['category_id'] = (int) $linkRow['default_category_id'];
}
// Issue status mirrors the GLPI ticket status (mapped).
$mappedStatus = PluginRedmineConfig::mapTicketStatus((int) $ticket->fields['status']);
if ($mappedStatus) { $extra['status_id'] = $mappedStatus; }
// Default watchers come from the config (Redmine user ids).
$watchers = PluginRedmineConfig::getDefaultWatcherIds();
if (!empty($watchers)) { $extra['watcher_user_ids'] = $watchers; }
// Subject = ticket title; description stripped of HTML.
$description = strip_tags(html_entity_decode((string) $ticket->fields['content']));
$issueId = PluginRedmineRedmineapi::createIssue(
$redmineProjectId,
$ticket->fields['name'],
$description,
$extra,
$switchUser
);
}
if ($issueId) {
$DB->insert('glpi_plugin_redmine_tickets', [
'tickets_id' => $ticketId,
'redmine_issue_id' => $issueId
]);
}
}
if (!$issueId) {
Toolbox::logInFile('redmine', "Failed to resolve Redmine issue for ticket #$ticketId\n");
return;
}
// Time entry author = the GLPI user who logged the task, via impersonation.
$taskUserId = (int) ($task->fields['users_id'] ?? 0);
$timeSwitchUser = _plugin_redmine_impersonation_login($taskUserId, $redmineProjectId);
$timeEntryId = null;
// Motor 2.0: template ativo tem precedência; erro de render cai no 1.x.
$tpl = PluginRedmineTemplateengine::getActive('log_time');
if ($tpl) {
try {
$ctx = PluginRedmineTemplateengine::buildContext($ticket, $task, $linkRow, $redmineProjectId, $issueId);
$payload = PluginRedmineTemplateengine::render($tpl['template'], $ctx);
if (!empty($payload['time_entry'])) {
$timeEntryId = PluginRedmineRedmineapi::logTimeFromPayload($payload['time_entry'], $timeSwitchUser);
}
} catch (\Throwable $e) {
Toolbox::logInFile('redmine', "Template log_time falhou no render (" . $e->getMessage() . ") — usando motor 1.x.\n");
$tpl = null;
}
}
if (!$tpl) {
// Motor 1.x
$cfg = PluginRedmineConfig::getConfigRow();
$hours = round($task->fields['actiontime'] / HOUR_TIMESTAMP, 2);
// Comments are mandatory on time entries in some Redmine setups — never send empty.
$comments = trim(strip_tags(html_entity_decode((string) ($task->fields['content'] ?? ''))));
if ($comments === '') {
$comments = "Tempo lançado via GLPI (chamado #{$ticketId})";
}
$timeEntryId = PluginRedmineRedmineapi::logTime(
$issueId,
$hours,
$comments,
!empty($cfg['default_activity_id']) ? (int) $cfg['default_activity_id'] : null,
$timeSwitchUser
);
}
if ($timeEntryId) {
$DB->insert('glpi_plugin_redmine_tasks', [
'tickettasks_id' => $taskId,
'redmine_time_entry_id' => $timeEntryId
]);
}
}