request([ 'FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1] ]); if (count($iterator) > 0) { $config = $iterator->current(); // The API key is stored encrypted with GLPIKey; decrypt for use. if (!empty($config['api_key'])) { $config['api_key'] = (string) (new \GLPIKey())->decrypt($config['api_key']); } return $config; } return false; } /** * Perform HTTP POST request to Redmine API * @param string $endpoint * @param array $payload * @return array|false */ private static function post($endpoint, $payload, $switchUser = null) { $config = self::getConfig(); if (!$config || empty($config['redmine_url']) || empty($config['api_key'])) { Toolbox::logInFile('redmine', "URL ou chave de API não configurada.\n"); return false; } $url = rtrim($config['redmine_url'], '/') . $endpoint; $headers = [ 'Content-Type: application/json', 'X-Redmine-API-Key: ' . $config['api_key'] ]; // Impersonation: create the object as the given Redmine user (admin only). if (!empty($switchUser)) { $headers[] = 'X-Redmine-Switch-User: ' . $switchUser; } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 5); // Prevent 504 gateway timeout $response = curl_exec($ch); $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpcode >= 200 && $httpcode < 300) { return json_decode($response, true); } else { Toolbox::logInFile('redmine', "API Error: [$httpcode] $response\n"); return false; } } /** * Perform HTTP GET request to Redmine API * @param string $endpoint * @return array|false */ private static function get($endpoint) { $config = self::getConfig(); if (!$config || empty($config['redmine_url']) || empty($config['api_key'])) { return false; } $url = rtrim($config['redmine_url'], '/') . $endpoint; $headers = [ 'Content-Type: application/json', 'X-Redmine-API-Key: ' . $config['api_key'] ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 5); // Prevent 504 gateway timeout $response = curl_exec($ch); $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpcode >= 200 && $httpcode < 300) { return json_decode($response, true); } return false; } /** * Create a Redmine Project * @param string $name * @param string $identifier * @return int|false Returns project ID on success */ public static function createProject($name, $identifier) { // Redmine identifier max length is 100, must be lower case, a-z, 0-9 and dashes. $identifier = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '-', $identifier)); $payload = [ 'project' => [ 'name' => $name, 'identifier' => $identifier ] ]; $result = self::post('/projects.json', $payload); if ($result && isset($result['project']['id'])) { return $result['project']['id']; } return false; } /** * Create an Issue in Redmine * @param int $projectId * @param string $subject * @param string $description * @return int|false Returns issue ID on success */ public static function createIssue($projectId, $subject, $description, array $extra = [], $switchUser = null) { // Redmine subject is limited to 255 chars. $issue = array_merge([ 'project_id' => $projectId, 'subject' => mb_substr((string) $subject, 0, 255), 'description' => $description, ], $extra); $result = self::post('/issues.json', ['issue' => $issue], $switchUser); // Fallback: if impersonation failed (user not member / inactive), retry as admin. if (!$result && !empty($switchUser)) { Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao criar issue; usando admin.\n"); $result = self::post('/issues.json', ['issue' => $issue]); } if ($result && isset($result['issue']['id'])) { return $result['issue']['id']; } return false; } /** * Create an Issue from a fully-built payload (template engine 2.0). * @param array $issue corpo do objeto issue já renderizado * @return int|false */ public static function createIssueFromPayload(array $issue, $switchUser = null) { $result = self::post('/issues.json', ['issue' => $issue], $switchUser); if (!$result && !empty($switchUser)) { Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao criar issue (template); usando admin.\n"); $result = self::post('/issues.json', ['issue' => $issue]); } return ($result && isset($result['issue']['id'])) ? $result['issue']['id'] : false; } /** * Log time from a fully-built payload (template engine 2.0). * @param array $entry corpo do time_entry já renderizado * @return int|false */ public static function logTimeFromPayload(array $entry, $switchUser = null) { $result = self::post('/time_entries.json', ['time_entry' => $entry], $switchUser); if (!$result && !empty($switchUser)) { Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao lançar tempo (template); usando admin.\n"); $result = self::post('/time_entries.json', ['time_entry' => $entry]); } return ($result && isset($result['time_entry']['id'])) ? $result['time_entry']['id'] : false; } /** * Log time to an Issue * @param int $issueId * @param float $hours * @param string $comments * @return int|false Returns the created time entry ID on success */ public static function logTime($issueId, $hours, $comments, $activityId = null, $switchUser = null) { $entry = [ 'issue_id' => $issueId, 'hours' => $hours, 'comments' => $comments ]; if (!empty($activityId)) { $entry['activity_id'] = (int) $activityId; } $payload = ['time_entry' => $entry]; $result = self::post('/time_entries.json', $payload, $switchUser); // Fallback: if impersonation failed, log the time as admin (never lose it). if (!$result && !empty($switchUser)) { Toolbox::logInFile('redmine', "Impersonation falhou para '$switchUser' ao lançar tempo; usando admin.\n"); $result = self::post('/time_entries.json', $payload); } if ($result && isset($result['time_entry']['id'])) { return $result['time_entry']['id']; } return false; } /** * Find a project by identifier */ /** * Get all Redmine projects * @return array|false */ public static function getProjects() { $result = self::get("/projects.json?limit=100"); if ($result && isset($result['projects'])) { return $result['projects']; } return false; } /** * Create a Redmine Project with full custom payload * @param array $projectData * @return int|false Returns project ID on success */ public static function createProjectFull($projectData) { // Redmine identifiers must be lowercase (a-z, 0-9, dashes/underscores). if (isset($projectData['identifier'])) { $projectData['identifier'] = strtolower(preg_replace('/[^a-zA-Z0-9\-_]/', '-', $projectData['identifier'])); } $payload = [ 'project' => $projectData ]; $result = self::post('/projects.json', $payload); if ($result && isset($result['project']['id'])) { return $result['project']['id']; } return false; } /** * Get all Redmine trackers * @return array */ public static function getTrackers() { $result = self::get("/trackers.json"); return ($result && isset($result['trackers'])) ? $result['trackers'] : []; } /** * Get all Redmine issue statuses * @return array */ public static function getStatuses() { $result = self::get("/issue_statuses.json"); return ($result && isset($result['issue_statuses'])) ? $result['issue_statuses'] : []; } /** * Get all Redmine issue priorities * @return array */ public static function getPriorities() { $result = self::get("/enumerations/issue_priorities.json"); return ($result && isset($result['issue_priorities'])) ? $result['issue_priorities'] : []; } /** * Get all Redmine time entry activities * @return array */ public static function getActivities() { $result = self::get("/enumerations/time_entry_activities.json"); return ($result && isset($result['time_entry_activities'])) ? $result['time_entry_activities'] : []; } /** * Get the issue categories of a given Redmine project (categories are per-project) * @param int $redmineProjectId * @return array */ public static function getProjectCategories($redmineProjectId) { $result = self::get("/projects/{$redmineProjectId}/issue_categories.json"); return ($result && isset($result['issue_categories'])) ? $result['issue_categories'] : []; } /** * Whether a Redmine issue still exists (returns false if it was deleted). * @param int $issueId * @return bool */ public static function issueExists($issueId) { $result = self::get("/issues/{$issueId}.json"); return $result && isset($result['issue']['id']); } /** * Get all Redmine users (paginated). Requires admin API key. * @return array */ public static function getUsers() { $users = []; $offset = 0; $limit = 100; do { $result = self::get("/users.json?limit={$limit}&offset={$offset}"); if (!$result || !isset($result['users'])) { break; } $users = array_merge($users, $result['users']); $total = (int) ($result['total_count'] ?? count($users)); $offset += $limit; } while ($offset < $total); return $users; } /** * Get all Redmine roles. * @return array */ public static function getRoles() { $result = self::get("/roles.json"); return ($result && isset($result['roles'])) ? $result['roles'] : []; } /** * Get the custom field definitions that apply to PROJECTS (admin only). * Needed because some Redmine setups make them mandatory on creation. * @return array */ public static function getProjectCustomFields() { $result = self::get("/custom_fields.json"); $out = []; foreach (($result['custom_fields'] ?? []) as $f) { if (($f['customized_type'] ?? '') === 'project') { $out[] = $f; } } return $out; } /** * Requisição crua para o testador de templates: devolve código HTTP e corpo * (o post()/get() normais escondem o erro; aqui o erro É a informação). * @return array ['code'=>int,'body'=>array|null,'raw'=>string] */ public static function rawRequest($method, $endpoint, ?array $payload = null) { $config = self::getConfig(); if (!$config || empty($config['redmine_url']) || empty($config['api_key'])) { return ['code' => 0, 'body' => null, 'raw' => 'plugin não configurado']; } $ch = curl_init(rtrim($config['redmine_url'], '/') . $endpoint); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Redmine-API-Key: ' . $config['api_key'], ], CURLOPT_TIMEOUT => 8, ]; if ($payload !== null) { $opts[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_UNESCAPED_UNICODE); } curl_setopt_array($ch, $opts); $raw = (string) curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return ['code' => $code, 'body' => json_decode($raw, true), 'raw' => $raw]; } /** * All custom field definitions (project, issue, time_entry...). Admin only. * @return array */ public static function getAllCustomFields() { $result = self::get("/custom_fields.json"); return $result['custom_fields'] ?? []; } /** * Ensure a Redmine user is a member of a project (idempotent). * Impersonation requires the user to be a project member with a role. * @param int $redmineProjectId * @param int $redmineUserId * @param int[] $roleIds * @return bool */ public static function ensureProjectMembership($redmineProjectId, $redmineUserId, array $roleIds) { if (empty($redmineUserId) || empty($roleIds)) { return false; } // Already a member? $members = self::get("/projects/{$redmineProjectId}/memberships.json?limit=100"); foreach (($members['memberships'] ?? []) as $m) { if (isset($m['user']['id']) && (int) $m['user']['id'] === (int) $redmineUserId) { return true; } } $result = self::post("/projects/{$redmineProjectId}/memberships.json", [ 'membership' => [ 'user_id' => (int) $redmineUserId, 'role_ids' => array_values(array_map('intval', $roleIds)), ] ]); return $result !== false; } /** * Update an existing issue (used to keep the Redmine status in sync with GLPI). * @param int $issueId * @param array $issueData fields to update (e.g. ['status_id' => 3]) * @return bool */ public static function updateIssue($issueId, array $issueData) { $config = self::getConfig(); if (!$config || empty($config['redmine_url']) || empty($config['api_key'])) { return false; } $url = rtrim($config['redmine_url'], '/') . "/issues/{$issueId}.json"; $headers = [ 'Content-Type: application/json', 'X-Redmine-API-Key: ' . $config['api_key'] ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['issue' => $issueData])); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_exec($ch); $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return ($httpcode >= 200 && $httpcode < 300); } /** * Fetch all metadata used by the config dropdowns and cache it. * @return array|false The metadata array on success, false if API is unreachable */ public static function syncMetadata() { $projects = self::getProjects(); if ($projects === false) { return false; // API unreachable / not configured } return [ 'projects' => $projects, 'trackers' => self::getTrackers(), 'statuses' => self::getStatuses(), 'priorities' => self::getPriorities(), 'activities' => self::getActivities(), 'roles' => self::getRoles(), 'project_custom_fields' => self::getProjectCustomFields(), 'custom_fields' => self::getAllCustomFields(), ]; } /** * Find a single Redmine user by e-mail, live (admin only). * Used as self-healing fallback when the local cache misses. * @param string $email * @return array|null the Redmine user, or null */ public static function findUserByEmail($email) { if (empty($email)) { return null; } $result = self::get("/users.json?name=" . urlencode($email) . "&limit=10"); foreach (($result['users'] ?? []) as $u) { if (strcasecmp($u['mail'] ?? '', $email) === 0) { return $u; } } return null; } /** * Refresh the local cache of Redmine users (glpi_plugin_redmine_users). * Upsert per user — never truncates the table, so a mid-sync failure * can't leave the cache empty (which silently broke impersonation). * @return int number of users cached, or -1 on API failure */ public static function syncUsers() { global $DB; $users = self::getUsers(); if (empty($users)) { // A working admin key always sees at least itself: [] = API/permission // failure. Keep the existing cache untouched. Toolbox::logInFile('redmine', "syncUsers: API não retornou usuários (chave sem admin? indisponível?). Cache mantido.\n"); return -1; } $seen = []; foreach ($users as $u) { $rid = (int) $u['id']; $seen[] = $rid; $row = [ 'login' => $u['login'] ?? null, 'mail' => $u['mail'] ?? null, 'name' => trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')), ]; $exists = $DB->request(['FROM' => 'glpi_plugin_redmine_users', 'WHERE' => ['redmine_id' => $rid]]); if (count($exists) > 0) { $DB->update('glpi_plugin_redmine_users', $row, ['redmine_id' => $rid]); } else { $DB->insert('glpi_plugin_redmine_users', $row + ['redmine_id' => $rid]); } } // Remove somente quem não existe mais no Redmine. foreach ($DB->request(['FROM' => 'glpi_plugin_redmine_users']) as $row) { if (!in_array((int) $row['redmine_id'], $seen, true)) { $DB->delete('glpi_plugin_redmine_users', ['id' => $row['id']]); } } return count($users); } /** * Find a project by identifier */ public static function getProjectByIdentifier($identifier) { $identifier = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '-', $identifier)); $result = self::get("/projects/{$identifier}.json"); if ($result && isset($result['project']['id'])) { return $result['project']['id']; } return false; } }