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::logError("Redmine Plugin: URL or API Key not configured."); 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::logError("Redmine Plugin API Error: [$httpcode] $response"); 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; } /** * 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) { $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'] : []; } /** * 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'] : []; } /** * 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(), ]; } /** * Refresh the local cache of Redmine users (glpi_plugin_redmine_users). * @return int number of users cached, or -1 on API failure */ public static function syncUsers() { global $DB; $users = self::getUsers(); if (empty($users)) { // Distinguish "no access" from "zero users": getUsers returns [] for both, // but a working admin key always sees at least itself. Treat [] as failure-safe no-op. return -1; } $DB->delete('glpi_plugin_redmine_users', [1]); foreach ($users as $u) { $DB->insert('glpi_plugin_redmine_users', [ 'redmine_id' => (int) $u['id'], 'login' => $u['login'] ?? null, 'mail' => $u['mail'] ?? null, 'name' => trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')), ]); } 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; } }