- form das abas Redmine exibe/envia custom_field_values (Tipo de Projeto etc.) - createProjectFull normaliza o identificador (minúsculas) - sync da aba de projeto usa syncMetadata completo (não apaga mais o cache) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
431 lines
15 KiB
PHP
431 lines
15 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) {
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|