WIP work on youtrack rest api plugin.

yt-rest-api
Lio Novelli 2023-07-16 13:34:51 +02:00
parent 4e3477c4a0
commit 519c0b074a
21 changed files with 542 additions and 50 deletions

View File

@ -17,7 +17,7 @@
** Install/Getting started
Get ~.phar~ file from r, Run configuration wizzard (planned for version 1.0).
Get ~.phar~ file from r, Run configruration wizzard (planned for version 1.0).
Before version 1.0 is released you have to navigate into ~/app~ directory
and call command through ~.rprt.php~ file.
@ -75,6 +75,11 @@ curl 'https://drunomics.myjetbrains.com/youtrack/api/reports?$top=-1&fields=id,n
5 days of development.
- remove errors from reports
* Support the work
If you find this Free Software useful you can consider donating to my
[[https://liberapay.com/tehnoklistir/][@tehno-klistir liberapay account.]]
* Development
** Testing

View File

@ -1,5 +1,6 @@
<?php
use RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackRestApiClient;
use function DI\create;
use function DI\get;
use function DI\factory;
@ -60,6 +61,8 @@ return [
get('config.service'),
get('mpdf')
),
YoutrackRestApiClient::class => create()->constructor(get('config.service')),
'youtrack_rest_api.client' => get(YoutrackRestApiClient::class),
'pdf_export.service' => get(PdfExportInterface::class),
// 'locale' => get('config.service')->method('get', 'en'),
// Translator::class => create()->constructor('sl')->method('addLoader', 'po', new PoFileLoader),

View File

@ -21,4 +21,5 @@ $reportCommand = $container->get(ReportCommand::class);
$application->add($reportCommand);
// eval(\Psy\sh());
$application->run();

View File

@ -51,6 +51,9 @@ class InvoiceCommand extends Command
protected PdfExportInterface $pdfExport;
/**
* Mailer service.
*/
protected MailerInterface $mailer;
protected const TYPE_WORK = 1;
@ -177,9 +180,12 @@ class InvoiceCommand extends Command
}
$output->writeln("report: <info>{$report_name}</info>");
$data = $this->csv->getInvoiceData($file);
if (! empty($expenses)) {
if (!empty($expenses)) {
$data = array_merge($data, $expenses);
}
if (!empty($custom)) {
$data = array_merge($data, $custom);
}
$table = $this->getTable($output, $data);
$table->render();
if ($input->getOption('pdf')) {

View File

@ -145,7 +145,7 @@ class ReportCsv implements ReportCsvInterface
$rows[] = [
null,
null,
'Kosten',
'Extra', // Kosten
'EUR',
];
// Don't make next line bold. See RprtCli\PdfExport\PdfExportService::parsedDataToHtml.
@ -161,6 +161,7 @@ class ReportCsv implements ReportCsvInterface
];
$totalPrice += $invoice_element->getValue();
}
// @todo Add Extra time as well!
}
if ($add_separator) {
$rows[] = ReportCsvInterface::SEPARATOR_MEDIUM;

View File

@ -14,35 +14,63 @@ use Attribute;
#[Attribute]
class EntityDefinition {
/**
* @param string $id
* @param string $provider
* @param string $name
* @param array $fields
* @param array $filters
* @param array $resources
*/
public function __construct(
private string $id,
private string $provider,
private string $name,
private array $fields,
private array $filters
private array $filters,
private array $resources
) { }
public function getId() {
public function getId() :string {
return $this->id;
}
public function getProvider() {
public function getProvider() :string {
return $this->provider;
}
public function getName() {
public function getName() :string {
return $this->name;
}
public function getFields() {
public function getFields() :array {
return $this->fields;
}
public function getFilters() {
public function getFilters() :array {
return $this->filters;
}
public function getDefinition() {
/**
* Should be of type ResourceInterface.
*/
public function getResources() :array {
return $this->resources;
}
public function getResource(string $key) :?Resource {
return $this->resources[$key] ?? NULL;
}
/**
* Get definition as array.
*
* @todo I'm not really sure why I implemented this method. Definition
* is a definition already.
*
* @return array
*/
public function getDefinition() :array {
return [
'id' => $this->id,
'provider' => $this->provider,

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices;
use RprtCli\Utils\TimeTrackingServices\EntityDefinition;
use RprtCli\Utils\TimeTrackingServices\EntityInterface;
/**
@ -14,6 +13,8 @@ interface EntityManagerInterface {
/**
* List supported entity types.
*
* @return array
*/
public function list(): array;

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices;
/**
* Defines resources for entity type.
*
* This class could be readonly after php82.
*/
final class Resource {
public const GET = 'GET';
public const POST = 'POST';
public function __construct(
private string $id,
private string $method,
private string $path,
private string $description = ''
) { }
public function getId() :string {
return $this->id;
}
public function getPath() :string {
return $this->path;
}
public function getMethod() :string {
return $this->method;
}
public function getDescription() :string {
return $this->description;
}
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi;
use ReflectionAttribute;
use RprtCli\Utils\TimeTrackingServices\EntityManagerInterface;
use RprtCli\Utils\TimeTrackingServices\EntityDefinition;
use RprtCli\Utils\TimeTrackingServices\EntityInterface;
@ -30,8 +31,11 @@ class EntityManager implements EntityManagerInterface {
/**
* Returns all the entity definitions.
*
* @return array
* List of entity definitions keyed with their id.
*/
public function listEntityDefinitions() {
public function listEntityDefinitions() :array {
if (!$this->entityDefinitions) {
$this->discoverEntities();
}
@ -40,8 +44,10 @@ class EntityManager implements EntityManagerInterface {
/**
* Discovers entity definitions.
*
* @return array
*/
private function discoverEntities() {
private function discoverEntities() :?array {
if (!empty($this->entityDefinitions)) {
return $this->entityDefinitions;
}
@ -49,8 +55,8 @@ class EntityManager implements EntityManagerInterface {
$definitions = [];
// @todo create a proxy service for finder.
$this->finder->files()->in($path);
/** @var SplFileInfo $file */
foreach ($this->finder as $file) {
/** @var SplFileInfo $file */
$class = self::ENTITIES_NAMESPACE . '\\' . self::ENTITIES_DIR . '\\' . $file->getBasename('.php');
$reflection = new \ReflectionClass($class);
$attribute = $this->getAttributeOfInstance($reflection, EntityDefinition::class);
@ -61,8 +67,10 @@ class EntityManager implements EntityManagerInterface {
}
$instance = $attribute->newInstance();
$id = $instance->getId();
$content = $instance->getDefinition();
$content['class'] = $class;
$content = [
'definition' => $instance,
'class' => $class,
];
$definitions[$id] = $content;
}
$this->entityDefinitions = $definitions;
@ -77,19 +85,11 @@ class EntityManager implements EntityManagerInterface {
* @param string $instance
* The instance the attribute should be of.
*
* @return
* @return ?ReflectionAttribute
* The attribute instance.
*/
protected function getAttributeOfInstance(\ReflectionClass $reflection, string $instance) {
$t = $reflection->getAttributes();
var_dump($t);
var_dump($t[0]->getName());
var_dump($t[0]->getArguments());
var_dump($t[0]->newInstance());
$s = $reflection->getAttributes(EntityDefinition::class, \ReflectionAttribute::IS_INSTANCEOF);
var_dump($s);
protected function getAttributeOfInstance(\ReflectionClass $reflection, string $instance) :?ReflectionAttribute {
$attributes = $reflection->getAttributes($instance, \ReflectionAttribute::IS_INSTANCEOF);
var_dump($attributes);
if (empty($attributes)) {
return NULL;
}
@ -97,6 +97,15 @@ class EntityManager implements EntityManagerInterface {
return reset($attributes);
}
/**
* Get entity definition by id.
*
* @param string $id
* Id of an entity definition (work_item, issue, project, comment).
*
* @return ?EntityDefinition
* Entity definition (maybe even EntityDefinition object).
*/
public function getDefinition(string $id) :?array {
if (!isset($this->entityDefinitions)) {
$this->list();
@ -108,6 +117,20 @@ class EntityManager implements EntityManagerInterface {
return $this->entityDefinitions[$id];
}
/**
* Create entity instance.
*
* This method should be used for transfering the data between report cli
* tools and the youtrack rest api.
*
* @param string $id
* Instance id (issue, project, work_item, comment ...).
* @param array $values
* Time, description ...
*
* @return ?EntityInterface
*
*/
public function createInstance(string $id, array $values) :?EntityInterface {
if (!isset($this->entityDefinitions)) {
$this->list();
@ -117,8 +140,10 @@ class EntityManager implements EntityManagerInterface {
return NULL;
}
$definition = $this->getDefinition($id);
// This is not ok. definition is of type EntityDefinition but we want
// EntityInterface. This is where $class would come in handy.
$reflection = new \ReflectionClass($definition['class']);
return new $reflection->newInstanceArgs($values);
return $reflection->newInstanceArgs($values);
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices;
use Attribute;
/**
* Define filter properties.
*/
#[Attribute]
class FilterAttribute {
/**
* @param string $id
* @param string $provider
* @param string $name
* @param array $allowedFields
* @param array $allowedEntities
* @param array $resources
*/
public function __construct(
private string $id,
private string $provider,
private string $name,
private array $allowedFields = [],
private array $allowedEntities = [],
) { }
public function getId() :string {
return $this->id;
}
public function getProvider() :string {
return $this->provider;
}
public function getName() :string {
return $this->name;
}
public function getAllowedFields() :array {
return $this->allowedFields;
}
public function getAllowedEntities() :array {
return $this->allowedEntities;
}
/**
* Get definition as array.
*
* @todo I'm not really sure why I implemented this method. Definition
* is a definition already.
*
* @return array
*/
public function getDefinition() :array {
return [
'id' => $this->id,
'provider' => $this->provider,
'name' => $this->name,
'allowedFields' => $this->allowedFields,
'allowedEntities' => $this->allowedEntities,
];
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\Filters;
/**
* Defines a filter attribute and some constants.
*/
interface YoutrackFilterInterface {
const OPTIONAL = 0;
const REQUIRED = 1;
}

View File

@ -4,11 +4,22 @@ declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi;
use RprtCli\Utils\TimeTrackingServices\EntityDefinition;
use RprtCli\Utils\TimeTrackingServices\EntityInterface;
/**
* Abstract class for youtrack entities.
*/
abstract class YoutrackEntity implements EntityInterface {
protected function getDefintion() :?EntityDefinition {
$reflection = new \ReflectionClass(self::class);
$attributes = $reflection->getAttributes(EntityDefinition::class, \ReflectionAttribute::IS_INSTANCEOF);
if (empty($attributes)) {
return NULL;
}
return reset($attributes);
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackEntityTypes;
use RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackEntity;
use RprtCli\Utils\TimeTrackingServices\EntityDefinition;
use RprtCli\Utils\TimeTrackingServices\Resource;
use RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\Filters\YoutarckFilterInterface;
/**
* https://www.jetbrains.com/help/youtrack/devportal/api-entity-IssueComment.html
* https://www.jetbrains.com/help/youtrack/devportal/resource-api-issues-issueID-comments.html
*/
#[EntityDefinition(
id: 'issue_comment',
provider: 'youtrack_rest_api',
name: 'IssueComment',
fields: [
'id',
'text',
'textPreview',
'created',
'author' => [
'id',
'fullName',
'email',
],
'issue' => [
'id',
'idReadable',
'project' => [
'id',
'shortName',
],
'summary',
],
],
filters: [
'issue' => [
'required' => YoutrackFilterInterface::REQUIRED,
],
],
resources: [
'list' => new Resource(
id: 'list',
method: Resource::GET,
path: '/api/issues/{issueID}/comments',
description: 'Lists comments of issue.'
),
]
)]
class YoutrackComment extends YoutrackEntity {
}

View File

@ -6,16 +6,38 @@ namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackEntityTypes
use RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackEntity;
use RprtCli\Utils\TimeTrackingServices\EntityDefinition;
use RprtCli\Utils\TimeTrackingServices\Resource;
/**
* https://www.jetbrains.com/help/youtrack/devportal/api-entity-Issue.html
* https://www.jetbrains.com/help/youtrack/devportal/resource-api-issues.html
*/
#[EntityDefinition(
'issue',
'youtrack_rest_api',
'Issue',
[],
[]
id: 'issue',
provider: 'youtrack_rest_api',
name: 'Issue',
fields: [
'id',
'idReadable',
'project' => [
'id',
'shortName',
],
'summary',
],
filters: [
'state',
'project',
'assignee',
],
resources: [
'list' => new Resource(
id: 'list',
method: Resource::GET,
path: '/api/issues',
description: 'Lists issues.'
),
]
)]
class YoutrackIssue extends YoutrackEntity {
const ID = 'issue';

View File

@ -4,16 +4,39 @@ declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackEntityTypes;
use RprtCli\Utils\TimeTrackingServices\Resource;
use RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackEntity;
use RprtCli\Utils\TimeTrackingServices\EntityDefinition;
/**
* https://www.jetbrains.com/help/youtrack/devportal/resource-api-admin-projects.html
*/
#[EntityDefinition(
'project',
'youtrack_rest_api',
'Youtrack Project',
[],
[]
id: 'project',
provider: 'youtrack_rest_api',
name: 'Youtrack Project',
fields: [
'id',
'shortName',
'description',
'leader' => ['id', 'fullName'],
],
filters: [],
resources: [
'list' => new Resource(
id: 'list',
method: Resource::GET,
path: '/api/admin/projects',
description: 'List projects'
),
'read' => new Resource(
id: 'read',
method: Resource::GET,
path: '/api/admin/projects/{projectId}',
description: 'Sprecific project'
),
]
)]
class YoutrackProject extends YoutrackEntity {
}

View File

@ -4,29 +4,59 @@ declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackEntityTypes;
use RprtCli\Utils\TimeTrackingServices\Resource;
use RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackEntity;
use RprtCli\Utils\TimeTrackingServices\EntityDefinition;
/**
* https://www.jetbrains.com/help/youtrack/devportal/api-entity-IssueWorkItem.html
* https://www.jetbrains.com/help/youtrack/devportal/resource-api-workItems.html
*/
#[EntityDefinition(
'work_item',
'youtrack_rest_api',
'Issue Work Item',
[
id: 'work_item',
provider: 'youtrack_rest_api',
name: 'Issue Work Item',
fields: [
'id',
'author',
'author' => [
'id',
'fullName',
'email'
],
'text',
'type',
'duration',
'duration' => [
'id',
'minutes',
'presentation'
],
'date',
'issue',
'created',
'issue' => [
'id',
'idReadable',
'project' => [
'id',
'shortName'
],
'summary',
],
],
[
filters: [
'project',
'issue',
'user',
'date',
],
resources: [
'list' => new Resource(
id: 'list',
method: Resource::GET,
path: '/api/workItems',
description: 'List workItems'
),
]
)]
class YoutrackWorkItem extends YoutrackEntity {
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi;
use Cog\YouTrack\Rest\Client\YouTrackClient;
use Cog\YouTrack\Rest\Authorizer\TokenAuthorizer;
use Cog\YouTrack\Rest\HttpClient\GuzzleHttpClient;
use RprtCli\Utils\Configuration\ConfigurationInterface;
/**
* YouTrack php sdk abstraction service.
*
* Returns the client that takes care of authentication.
*/
class YoutrackRestApiClient {
protected ConfigurationInterface $config;
protected YouTrackClient $client;
// config
public function __construct(ConfigurationInterface $config) {
$this->config = $config;
$this->client = $this->createYoutrackClient();
}
protected function createYoutrackClient() :YouTrackClient {
// Could this all go into __construct method?
$apiBaseUri = $this->config->get('tracking_service.youtrack.base_url', false);
$apiToken = $this->config->get('tracking_service.youtrack.auth_token', false);
// Instantiate PSR-7 HTTP Client
$psrHttpClient = new \GuzzleHttp\Client([
'base_uri' => $apiBaseUri,
'debug' => true,
]);
// Instantiate YouTrack API HTTP Client
$httpClient = new GuzzleHttpClient($psrHttpClient);
// Instantiate YouTrack API Token Authorizer
$authorizer = new TokenAuthorizer($apiToken);
// Instantiate YouTrack API Client
$client = new YouTrackClient($httpClient, $authorizer);
return $client;
}
public function getClient() :YouTrackClient {
return $this->client;
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace RprtCli\Utils\TimeTrackingServices\YoutrackRestApi;
use RprtCli\Utils\TimeTrackingServices\EntityManagerInterface;
use RprtCli\Utils\TimeTrackingServices\YoutrackRestApi\YoutrackRestApiClient;
class YoutrackRestApiRequestBuilder {
protected EntityManagerInterface $entityManager;
protected YoutrackRestApiClient $clientFactory;
public function __construct(EntityManagerInterface $entity_manager, YoutrackRestApiClient $clientFactory) {
$this->entityManager = $entity_manager;
$this->clientFactory = $clientFactory;
// EntityMangerInterface
}
/**
* Method that creates path to youtrack entity resource.
*
* Examples:
* list issue => GET '/api/issues?fields=id,idReadable,summary,..'
* create issue => POST '/api/issues'
* but create action is only allowed on worktItems.
* list work_item => GET '/api/workItem?fields=...'
*
* First impression seems simple. We need path and method.
* What bothers me whether it would be better to create a new attribute
* wehere these "mappings" would live: allowed actions and then method and path.
*
* @TODO Add filter system?
*
* @param string $action
* Name of the action: list, create, read, update, delete.
* @param string $entity_id
* Name of the entity id (project, issue, comment, work_item).
*
* @return ?string
* Path to the resource.
*/
public function buildPath(string $action, string $entity_id) :?string {
$defintion = $this->entityManager->getDefinition($entity_id);
if (!isset($defintion['definition'])) {
// Missing entity definition!
return NULL;
}
else {
$defintion = $defintion['definition'];
}
$fields = $definition->getFields();
$fieldsQuery = $this->fieldsQuery($fields);
/** @var RprtCli\Utils\TimeTrackingServices\Resource $resource */
$resource = $defintion->getResource($action);
if (!$resource) {
// Missing resource!
return NULL;
}
$path = $resource->getPath();
// @TODO add query, create client request.
// @see https://github.com/cybercog/youtrack-rest-php/blob/eb0315133d1d3d161da23d26537201afb253dec9/src/Client/YouTrackClient.php#L89
if ($fieldsQuery) {
$path .= '?' . $fieldsQuery;
}
// @TODO create request and handle request.
}
protected function fieldsQuery(array $fields) :?string {
$query = implode(',', $this->stringifyFields($fields));
if (!empty($query)) {
return "fields=${query}";
}
return $query;
}
protected function stringifyFields(array $fields) :array {
foreach ($fields as $key => &$field) {
if (is_array($field)) {
$field = $key . '(' . implode(',', $this->stringifyFields($field)) . ')';
}
}
return $fields;
}
}

View File

@ -6,6 +6,8 @@ namespace RprtCli\Tests\Unit\YoutrackRestApi;
use DI\ContainerBuilder;
use PHPUnit\Framework\TestCase;
use RprtCli\Utils\TimeTrackingServices\EntityDefinition;
use RprtCli\Utils\TimeTrackingServices\EntityInterface;
/**
* Test the entity manager service and system.
@ -24,15 +26,26 @@ class EntityManagerTest extends TestCase {
// Instance of EntityMangerInterface;
// $this->assertInstanceOf('EntityManagerInterface', $entityManagerService);
$definitions = $entityManagerService->list();
var_dump($definitions);
// var_dump($definitions);
$this->assertCount(3, $definitions);
$entities = ['project', 'issue', 'work_item'];
foreach ($entities as $entity) {
$this->assertArrayHasKey($entity, $definitions, 'Check that key exists in entity types definitions.');
foreach ($entities as $entity_id) {
$this->assertArrayHasKey($entity_id, $definitions, 'Check that key exists in entity types definitions.');
var_dump($definitions[$entity_id]);
$this->assertInstanceOf(EntityDefinition::class, $definitions[$entity_id]['definition'], 'Check that definiton is of type entity definition');
$definition = $entityManagerService->getDefinition($entity_id);
$entity = $entityManagerService->createInstance($entity_id, []);
$this->assertArrayHasKey('definition', $definition);
$this->assertArrayHasKey('class', $definition);
$this->assertInstanceOf(EntityDefinition::class, $definition['definition']);
$this->assertInstanceOf(EntityInterface::class, $entity);
}
// list method returns definitions: project, issue, worktItem
// Check one definition, compare.
// Test creating the instance
}
}

BIN
box.phar 100755

Binary file not shown.

View File

@ -15,6 +15,13 @@ TODO:
- invoice command improvements
- pdf and output option could be combined
TODO: (start of 2023)
- youtrack rest api:
- resources class and parameter in EntityDefinition
- compose fields method
- create filter system
- connect filter system to youtrack rest api command
TODO (end of 2022):
- DONE fix project list output (first is missing)
- DONE report selection trait