Files
redmine/redMCP/app/RedmineClient.php
T
2026-04-24 22:09:37 +00:00

337 lines
9.9 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;
}
/**
* 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.
*
* 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): bool
{
if ($fields === []) {
throw new RuntimeException('Updating an issue requires at least one field.');
}
$issueApi = $this->client->getApi('issue');
$issueApi->update($issueId, $fields);
$this->assertLastApiResponseSucceeded($issueApi, 'update issue #' . $issueId);
return true;
}
/**
* 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> $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';
}
}