492 lines
15 KiB
PHP
492 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace RedMCP;
|
|
|
|
use Redmine\Client\NativeCurlClient;
|
|
use Redmine\Http\HttpClient;
|
|
use Redmine\Http\HttpFactory;
|
|
use Redmine\Serializer\PathSerializer;
|
|
use RuntimeException;
|
|
use SimpleXMLElement;
|
|
use Throwable;
|
|
|
|
final class RedmineClient
|
|
{
|
|
private NativeCurlClient $client;
|
|
|
|
public function __construct(NativeCurlClient $client)
|
|
{
|
|
$this->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<string,mixed> $params Standard Redmine issue list filters.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function issues(array $params = []): array
|
|
{
|
|
return $this->listIssues($params);
|
|
}
|
|
|
|
/**
|
|
* List Redmine issues.
|
|
*
|
|
* @param array<string,mixed> $params Standard Redmine issue list filters.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $filters Standard Redmine issue list filters.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $params Standard Redmine search params.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $params Additional Redmine search params.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function searchIssues(string $query, array $params = []): array
|
|
{
|
|
return $this->search($query, ['issues' => '1'] + $params);
|
|
}
|
|
|
|
/**
|
|
* Fetch a normal Redmine issue.
|
|
*
|
|
* @param array<int,string> $issueIncludes Standard Redmine issue includes:
|
|
* journals, attachments, children,
|
|
* relations, changesets.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $fields
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $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<string,mixed> $options Optional to_address, cc_address,
|
|
* bcc_address, and status_id fields.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<int,string> $issueIncludes Standard Redmine issue includes.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function issueWithHelpdesk(int $issueId, int $messageLimit = 100, array $issueIncludes = ['journals', 'attachments']): array
|
|
{
|
|
return $this->getIssueWithHelpdeskContext($issueId, $messageLimit, $issueIncludes);
|
|
}
|
|
|
|
/**
|
|
* @param array<int,string> $issueIncludes Standard Redmine issue includes.
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed>|null
|
|
*/
|
|
public function helpdeskTicketByIssue(int $issueId): ?array
|
|
{
|
|
return $this->getHelpdeskTicketByIssue($issueId);
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>|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<int,mixed>
|
|
*/
|
|
public function helpdeskMessagesByIssue(int $issueId, int $limit = 100): array
|
|
{
|
|
return $this->getHelpdeskMessagesByIssue($issueId, $limit);
|
|
}
|
|
|
|
/**
|
|
* @return array<int,mixed>
|
|
*/
|
|
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<string,mixed> $params
|
|
*
|
|
* @return array<string,mixed>|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<string,mixed> $payload
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $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<string,mixed>
|
|
*/
|
|
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';
|
|
}
|
|
}
|