client = $client; } public static function fromCredentials(string $url, string $apiKeyOrUsername, ?string $password = null): self { return new self(new NativeCurlClient($url, $apiKeyOrUsername, $password)); } /** * List Redmine issues. * * @param array $params Standard Redmine issue list filters. * * @return array */ public function issues(array $params = []): array { return $this->listIssues($params); } /** * List Redmine issues. * * @param array $params Standard Redmine issue list filters. * * @return array */ public function listIssues(array $params = []): array { $response = $this->client->getApi('issue')->list($params); if (!is_array($response)) { throw new RuntimeException('Could not list issues: ' . $this->stringifyError($response)); } return $response; } /** * Alias for listIssues() that makes Redmine's built-in issue filters * explicit at call sites. * * Useful filters include project_id, tracker_id, status_id, * assigned_to_id, author_id, category_id, fixed_version_id, query_id, * created_on, updated_on, sort, offset, and limit. * * @param array $filters Standard Redmine issue list filters. * * @return array */ public function filterIssues(array $filters = []): array { return $this->listIssues($filters); } /** * Search Redmine using the built-in /search.json endpoint. * * Typical params include project_id, all_words, titles_only, scope, * open_issues, issues, projects, news, documents, changesets, wiki_pages, * messages, offset, and limit. * * @param array $params Standard Redmine search params. * * @return array */ public function search(string $query, array $params = []): array { $query = trim($query); if ($query === '') { throw new RuntimeException('Searching Redmine requires a non-empty query.'); } return $this->getJson('/search', ['q' => $query] + $params) ?? []; } /** * Search only issues using Redmine's built-in /search.json endpoint. * * @param array $params Additional Redmine search params. * * @return array */ public function searchIssues(string $query, array $params = []): array { return $this->search($query, ['issues' => '1'] + $params); } /** * List Redmine projects. * * @param array $params Standard Redmine project list params. * * @return array */ public function projects(array $params = []): array { return $this->listProjects($params); } /** * List Redmine projects. * * @param array $params Standard Redmine project list params. * * @return array */ public function listProjects(array $params = []): array { return $this->getJson('/projects', $params) ?? []; } /** * Fetch a Redmine project by numeric id or identifier. * * @param array $params Standard Redmine project show params. * * @return array */ public function project(int|string $projectId, array $params = []): array { $projectId = trim((string) $projectId); if ($projectId === '') { throw new RuntimeException('Fetching a project requires a project id or identifier.'); } $response = $this->getJson('/projects/' . rawurlencode($projectId), $params); if (!is_array($response)) { throw new RuntimeException('Could not fetch project ' . $projectId . '.'); } return $response['project'] ?? $response; } /** * Fetch a normal Redmine issue. * * @param array $issueIncludes Standard Redmine issue includes: * journals, attachments, children, * relations, changesets. * * @return array */ public function issue(int $issueId, array $issueIncludes = ['journals', 'attachments']): array { $issueResponse = $this->client->getApi('issue')->show($issueId, ['include' => $issueIncludes]); if (!is_array($issueResponse)) { throw new RuntimeException('Could not fetch issue #' . $issueId . ': ' . $this->stringifyError($issueResponse)); } return $issueResponse['issue'] ?? $issueResponse; } /** * Create a Redmine issue. * * Typical fields include project_id, subject, description, tracker_id, * status_id, priority_id, assigned_to_id, category_id, due_date, and * start_date. * * @param array $fields * * @return array */ public function createIssue(array $fields): array { if (!isset($fields['project_id']) || !isset($fields['subject'])) { throw new RuntimeException('Creating an issue requires at least project_id and subject.'); } $issueApi = $this->client->getApi('issue'); $response = $issueApi->create($fields); $this->assertLastApiResponseSucceeded($issueApi, 'create issue'); return $this->xmlResponseToArray($response); } /** * Update a Redmine issue. * * By default this uses the normal Redmine issue REST API and does not send * a Helpdesk email. To send a customer-visible Helpdesk response, pass * ['send_helpdesk_email' => true] with a notes field, or call * sendHelpdeskIssueResponse() directly. * * Typical fields include notes, subject, status_id, priority_id, * assigned_to_id, private_notes, due_date, and tracker_id. * * @param array $fields */ public function updateIssue(int $issueId, array $fields, array $options = []): bool { if ($fields === []) { throw new RuntimeException('Updating an issue requires at least one field.'); } if (!empty($options['send_helpdesk_email'])) { if (!isset($fields['notes']) || trim((string) $fields['notes']) === '') { throw new RuntimeException('Sending a Helpdesk email requires a non-empty notes field.'); } if (!empty($fields['private_notes'])) { throw new RuntimeException('A private note cannot be sent as a Helpdesk email.'); } $issueFields = $fields; unset($issueFields['notes'], $issueFields['private_notes']); if ($issueFields !== []) { $this->updateIssue($issueId, $issueFields); } $this->sendHelpdeskIssueResponse($issueId, (string) $fields['notes'], $options); return true; } $issueApi = $this->client->getApi('issue'); $issueApi->update($issueId, $fields); $this->assertLastApiResponseSucceeded($issueApi, 'update issue #' . $issueId); return true; } /** * Send a Helpdesk email response for an existing Helpdesk-backed issue. * * This uses the RedmineUP Helpdesk API endpoint that corresponds to sending * a note with the Helpdesk UI's "Send note" checkbox enabled. * * @param array $options Optional to_address, cc_address, * bcc_address, and status_id fields. * * @return array */ public function sendHelpdeskIssueResponse(int $issueId, string $content, array $options = []): array { if (trim($content) === '') { throw new RuntimeException('Sending a Helpdesk response requires non-empty content.'); } $message = [ 'issue_id' => $issueId, 'content' => $content, ]; if (isset($options['status_id'])) { $message['status_id'] = $options['status_id']; } $payload = ['message' => $message]; foreach (['to_address', 'cc_address', 'bcc_address'] as $key) { if (isset($options[$key])) { $payload[$key] = $options[$key]; } } $response = $this->postJson('/helpdesk/email_note', $payload); if (!isset($response['message']) || !is_array($response['message'])) { throw new RuntimeException('Helpdesk email response returned unexpected JSON.'); } return $response['message']; } /** * Delete a Redmine issue. */ public function deleteIssue(int $issueId): bool { $issueApi = $this->client->getApi('issue'); $issueApi->remove($issueId); $this->assertLastApiResponseSucceeded($issueApi, 'delete issue #' . $issueId); return true; } /** * Alias for deleteIssue(). */ public function removeIssue(int $issueId): bool { return $this->deleteIssue($issueId); } /** * @param array $issueIncludes Standard Redmine issue includes. * * @return array */ public function issueWithHelpdesk(int $issueId, int $messageLimit = 100, array $issueIncludes = ['journals', 'attachments']): array { return $this->getIssueWithHelpdeskContext($issueId, $messageLimit, $issueIncludes); } /** * @param array $issueIncludes Standard Redmine issue includes. * * @return array */ public function getIssueWithHelpdeskContext( int $issueId, int $messageLimit = 100, array $issueIncludes = ['journals', 'attachments'] ): array { $issue = $this->issue($issueId, $issueIncludes); $ticket = $this->helpdeskTicketByIssue($issueId); $messages = $this->helpdeskMessagesByIssue($issueId, $messageLimit); return [ 'issue' => $issue, 'helpdesk' => [ 'available' => $ticket !== null || $messages !== [], 'ticket' => $ticket, 'journal_messages' => $messages, ], 'meta' => [ 'issue_id' => $issueId, 'issue_includes' => array_values($issueIncludes), 'helpdesk_sources' => [ 'ticket' => '/helpdesk_search/issues/' . $issueId . '/ticket', 'messages' => '/helpdesk_search/issues/' . $issueId . '/messages', ], ], ]; } /** * @return array|null */ public function helpdeskTicketByIssue(int $issueId): ?array { return $this->getHelpdeskTicketByIssue($issueId); } /** * @return array|null */ public function getHelpdeskTicketByIssue(int $issueId): ?array { $response = $this->getJson('/helpdesk_search/issues/' . rawurlencode((string) $issueId) . '/ticket', [], [403, 404]); if (!is_array($response) || !isset($response['helpdesk_ticket']) || !is_array($response['helpdesk_ticket'])) { return null; } return $response['helpdesk_ticket']; } /** * @return array */ public function helpdeskMessagesByIssue(int $issueId, int $limit = 100): array { return $this->getHelpdeskMessagesByIssue($issueId, $limit); } /** * @return array */ public function getHelpdeskMessagesByIssue(int $issueId, int $limit = 100): array { $response = $this->getJson( '/helpdesk_search/issues/' . rawurlencode((string) $issueId) . '/messages', ['limit' => max(1, min($limit, 200))], [403, 404] ); if (!is_array($response) || !isset($response['journal_messages']) || !is_array($response['journal_messages'])) { return []; } return $response['journal_messages']; } /** * @param array $params * * @return array|null */ private function getJson(string $path, array $params = [], array $nullStatuses = []): ?array { if (!$this->client instanceof HttpClient) { throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.'); } $requestPath = $this->buildPath($path, $params); $response = $this->client->request(HttpFactory::makeJsonRequest('GET', $requestPath)); $status = $response->getStatusCode(); if (in_array($status, $nullStatuses, true)) { return null; } if ($status >= 400) { throw new RuntimeException('Redmine GET ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); } $body = $response->getContent(); if ($body === '') { return []; } try { $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (Throwable $exception) { throw new RuntimeException('Redmine GET ' . $requestPath . ' returned invalid JSON.', 0, $exception); } if (!is_array($decoded)) { throw new RuntimeException('Redmine GET ' . $requestPath . ' returned invalid JSON.'); } return $decoded; } /** * @param array $payload * * @return array */ private function postJson(string $path, array $payload): array { if (!$this->client instanceof HttpClient) { throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.'); } $requestPath = $this->buildPath($path, []); $encoded = json_encode($payload); if ($encoded === false) { throw new RuntimeException('Could not encode Redmine POST payload.'); } $response = $this->client->request(HttpFactory::makeJsonRequest('POST', $requestPath, $encoded)); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); } $body = $response->getContent(); if ($body === '') { return []; } try { $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (Throwable $exception) { throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.', 0, $exception); } if (!is_array($decoded)) { throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.'); } return $decoded; } /** * @param array $params */ private function buildPath(string $path, array $params): string { return PathSerializer::create($path . '.json', $params)->getPath(); } /** * @param mixed $api */ private function assertLastApiResponseSucceeded($api, string $action): void { if (!method_exists($api, 'getLastResponse')) { return; } $response = $api->getLastResponse(); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Could not ' . $action . ': HTTP ' . $status . ': ' . $response->getContent()); } } /** * @param mixed $response * * @return array */ private function xmlResponseToArray($response): array { if ($response instanceof SimpleXMLElement) { $encoded = json_encode($response); if ($encoded === false) { throw new RuntimeException('Could not encode Redmine XML response.'); } $decoded = json_decode($encoded, true); if (is_array($decoded)) { return $decoded; } } if ($response === '') { return []; } throw new RuntimeException('Unexpected Redmine response: ' . $this->stringifyError($response)); } /** * @param mixed $value */ private function stringifyError($value): string { if ($value === false) { return 'empty response'; } if (is_scalar($value)) { return (string) $value; } return 'unexpected response'; } }