Redmine/inc/redmineapi.class.php
Gemini 913fd82272 fix: recria issue se a vinculada foi apagada no Redmine (vínculo órfão) (1.5.5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:32:52 -03:00

410 lines
14 KiB
PHP

<?php
class PluginRedmineRedmineapi {
/**
* Get Redmine configuration
* @return array|false
*/
private static function getConfig() {
global $DB;
$iterator = $DB->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;
}
/**
* 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'] : [];
}
/**
* 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'] : [];
}
/**
* 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;
}
}