Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-325](https://github.com/itk-dev/economics/pull/325)
* Fixed the Leantime sync halting silently on a single bad row. A row that cannot be mapped, or that a handler
rejects, logs `Skipping <class> id <id>: <reason>` and the sync moves on. The catches are deliberately narrow —
a `TypeError` from a null field and a handler's `UnrecoverableMessageHandlingException` are skippable, while a
dead database or an unreachable Leantime still halts the run loudly instead of being logged away as a bad row.
* Made the Leantime result mappers null-safe ahead of
[data-api#18](https://github.com/ITK-Leantime/data-api/pull/18): a deleted user is attributed to
`deleted-user-<userId>`, a missing name becomes `(no name) <id>`, and rows with no `ticketId`/`projectId`/`id`
are skipped. The tracker id is part of the name placeholder because names are used as lookup keys elsewhere —
`ProjectBillingService` resolves a client by version name.
* A `deleted-user-<userId>` attribution no longer overwrites a worker name an earlier sync already stored.
* Fixed the `/deleted` request sending its timestamp as `deletedAfter` rather than `deleted`, which made every
delete-sync pull the entire unpaginated deletion history. Deletion entries with no id are now skipped and logged,
and a single failing entry no longer drops every deletion after it — with the timestamp now applied, a dropped
entry would never come round again.
* Added `LeantimeApiServiceTest::testUpdateWithNullValues()` and `::testDeletedUserFallbackKeepsStoredWorker()`,
and pinned the `/deleted` request body so the parameter name cannot regress.

## [3.7.0] - 2026-06-26

* [PR-324](https://github.com/itk-dev/economics/pull/324)
Expand Down
45 changes: 16 additions & 29 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1530,7 +1530,6 @@ parameters:
count: 1
path: src/Repository/ProjectVersionBudgetRepository.php


-
message: '#^Method App\\Repository\\ServiceAgreementRepository\:\:getFilteredPagination\(\) return type has no value type specified in iterable type Knp\\Component\\Pager\\Pagination\\PaginationInterface\.$#'
identifier: missingType.iterableValue
Expand Down Expand Up @@ -2032,7 +2031,7 @@ parameters:
-
message: '#^Access to an undefined property object\:\:\$id\.$#'
identifier: property.notFound
count: 5
count: 7
path: src/Service/LeantimeApiService.php

-
Expand All @@ -2053,12 +2052,6 @@ parameters:
count: 4
path: src/Service/LeantimeApiService.php

-
message: '#^Access to an undefined property object\:\:\$name\.$#'
identifier: property.notFound
count: 4
path: src/Service/LeantimeApiService.php

-
message: '#^Access to an undefined property object\:\:\$plannedHours\.$#'
identifier: property.notFound
Expand Down Expand Up @@ -2113,12 +2106,6 @@ parameters:
count: 1
path: src/Service/LeantimeApiService.php

-
message: '#^Access to an undefined property object\:\:\$username\.$#'
identifier: property.notFound
count: 1
path: src/Service/LeantimeApiService.php

-
message: '#^Access to an undefined property object\:\:\$workDate\.$#'
identifier: property.notFound
Expand Down Expand Up @@ -2932,25 +2919,25 @@ parameters:
-
message: '#^Call to an undefined method object\:\:findAll\(\)\.$#'
identifier: method.notFound
count: 24
count: 32
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Call to an undefined method object\:\:findOneBy\(\)\.$#'
identifier: method.notFound
count: 8
count: 19
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Call to an undefined method object\:\:flush\(\)\.$#'
identifier: method.notFound
count: 3
count: 5
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Call to an undefined method object\:\:persist\(\)\.$#'
identifier: method.notFound
count: 12
count: 17
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
Expand Down Expand Up @@ -2986,13 +2973,13 @@ parameters:
-
message: '#^Parameter \#1 \$projectTrackerId of method App\\Entity\\Issue\:\:setProjectTrackerId\(\) expects string, int given\.$#'
identifier: argument.type
count: 2
count: 3
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Parameter \#1 \$projectTrackerId of method App\\Entity\\Project\:\:setProjectTrackerId\(\) expects string\|null, int given\.$#'
identifier: argument.type
count: 2
count: 3
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
Expand All @@ -3004,19 +2991,19 @@ parameters:
-
message: '#^Parameter \#1 \$projectTrackerIssueId of method App\\Entity\\Worklog\:\:setProjectTrackerIssueId\(\) expects string, int given\.$#'
identifier: argument.type
count: 2
count: 3
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Parameter \#1 \$projectTrackerKey of method App\\Entity\\Issue\:\:setProjectTrackerKey\(\) expects string, int given\.$#'
identifier: argument.type
count: 2
count: 3
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Parameter \#1 \$projectTrackerKey of method App\\Entity\\Project\:\:setProjectTrackerKey\(\) expects string\|null, int given\.$#'
identifier: argument.type
count: 2
count: 3
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
Expand All @@ -3028,31 +3015,31 @@ parameters:
-
message: '#^Parameter \#2 \$messageBus of class App\\Service\\LeantimeApiService constructor expects Symfony\\Component\\Messenger\\MessageBusInterface, object given\.$#'
identifier: argument.type
count: 2
count: 4
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Parameter \#3 \$dataProviderRepository of class App\\Service\\LeantimeApiService constructor expects App\\Repository\\DataProviderRepository, object given\.$#'
identifier: argument.type
count: 2
count: 4
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Parameter \#4 \$dataProviderId of method App\\Service\\LeantimeApiService\:\:updateAsJob\(\) expects int, int\|null given\.$#'
identifier: argument.type
count: 8
count: 13
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Parameter \#4 \$entityManager of class App\\Service\\LeantimeApiService constructor expects Doctrine\\ORM\\EntityManagerInterface, object given\.$#'
identifier: argument.type
count: 2
count: 4
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
message: '#^Parameter \#5 \$projectRepository of class App\\Service\\LeantimeApiService constructor expects App\\Repository\\ProjectRepository, object given\.$#'
identifier: argument.type
count: 2
count: 4
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
Expand Down Expand Up @@ -3154,7 +3141,7 @@ parameters:
-
message: '#^Call to an undefined method App\\Service\\DataProviderService\:\:method\(\)\.$#'
identifier: method.notFound
count: 1
count: 2
path: tests/Unit/MessageHandler/EntityRemovedFromDataProviderHandlerTest.php

-
Expand Down
4 changes: 3 additions & 1 deletion src/MessageHandler/EntityRemovedFromDataProviderHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Entity\Project;
use App\Entity\Version;
use App\Entity\Worklog;
use App\Exception\NotFoundException;
use App\Exception\NotSupportedException;
use App\Message\EntityRemovedFromDataProviderMessage;
use App\Service\DataProviderService;
Expand Down Expand Up @@ -34,7 +35,8 @@ public function __invoke(EntityRemovedFromDataProviderMessage $message): void
Worklog::class => $this->dataProviderService->worklogRemovedFromDataProvider($message->dataProviderId, (int) $message->projectTrackerId, $message->deletedDate),
default => throw new NotSupportedException('classname not supported'),
};
} catch (\Exception $e) {
} catch (NotFoundException|NotSupportedException|\TypeError $e) {
// Narrow on purpose: see UpsertIssueHandler. Infrastructure failures must propagate.
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
4 changes: 3 additions & 1 deletion src/MessageHandler/LeantimeDeleteHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\MessageHandler;

use App\Exception\NotFoundException;
use App\Message\LeantimeDeleteMessage;
use App\Service\LeantimeApiService;
use Psr\Log\LoggerInterface;
Expand All @@ -27,7 +28,8 @@ public function __invoke(LeantimeDeleteMessage $message): void
$message->asyncJobQueue,
$message->deletedAfter,
);
} catch (\Exception $e) {
} catch (NotFoundException|\TypeError $e) {
// Narrow on purpose: see UpsertIssueHandler. Infrastructure failures must propagate.
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
5 changes: 4 additions & 1 deletion src/MessageHandler/LeantimeUpdateHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\MessageHandler;

use App\Exception\NotFoundException;
use App\Message\LeantimeUpdateMessage;
use App\Service\LeantimeApiService;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -32,7 +33,9 @@ public function __invoke(LeantimeUpdateMessage $message): void
$message->modifiedAfter,
$message->disableModifiedAtCheck,
);
} catch (\Exception $e) {
} catch (NotFoundException|\TypeError $e) {
// Narrow on purpose: see UpsertIssueHandler. A page that fails because Leantime or the
// database is unavailable must be retried, not dropped along with the pages after it.
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
6 changes: 5 additions & 1 deletion src/MessageHandler/UpsertIssueHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\MessageHandler;

use App\Exception\NotFoundException;
use App\Message\UpsertIssueMessage;
use App\Service\DataProviderService;
use Psr\Log\LoggerInterface;
Expand All @@ -22,7 +23,10 @@ public function __invoke(UpsertIssueMessage $message): void
try {
$this->logger->info('Upserting issue: '.$message->issueData->name);
$this->dataProviderService->upsertIssue($message->issueData);
} catch (\Exception $e) {
} catch (NotFoundException|\TypeError $e) {
// Narrow on purpose: only a failure describing this one row is unrecoverable. TypeError
// is here because a null source field mapped onto a non-nullable property raises an
// Error, not an Exception. Anything else propagates, so it is retried, not dropped.
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
4 changes: 3 additions & 1 deletion src/MessageHandler/UpsertProjectHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\MessageHandler;

use App\Exception\NotFoundException;
use App\Message\UpsertProjectMessage;
use App\Service\DataProviderService;
use Psr\Log\LoggerInterface;
Expand All @@ -22,7 +23,8 @@ public function __invoke(UpsertProjectMessage $message): void
try {
$this->logger->info('Upserting project: '.$message->projectData->name);
$this->dataProviderService->upsertProject($message->projectData);
} catch (\Exception $e) {
} catch (NotFoundException|\TypeError $e) {
// Narrow on purpose: see UpsertIssueHandler. Infrastructure failures must propagate.
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
4 changes: 3 additions & 1 deletion src/MessageHandler/UpsertVersionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\MessageHandler;

use App\Exception\NotFoundException;
use App\Message\UpsertVersionMessage;
use App\Service\DataProviderService;
use Psr\Log\LoggerInterface;
Expand All @@ -22,7 +23,8 @@ public function __invoke(UpsertVersionMessage $message): void
try {
$this->logger->info('Upserting version: '.$message->versionData->name);
$this->dataProviderService->upsertVersion($message->versionData);
} catch (\Exception $e) {
} catch (NotFoundException|\TypeError $e) {
// Narrow on purpose: see UpsertIssueHandler. Infrastructure failures must propagate.
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
4 changes: 3 additions & 1 deletion src/MessageHandler/UpsertWorkerHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\MessageHandler;

use App\Exception\NotFoundException;
use App\Message\UpsertWorkerMessage;
use App\Service\DataProviderService;
use Psr\Log\LoggerInterface;
Expand All @@ -22,7 +23,8 @@ public function __invoke(UpsertWorkerMessage $message): void
try {
$this->logger->info('Upserting worker: '.$message->workerData->email);
$this->dataProviderService->upsertWorker($message->workerData);
} catch (\Exception $e) {
} catch (NotFoundException|\TypeError $e) {
// Narrow on purpose: see UpsertIssueHandler. Infrastructure failures must propagate.
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
4 changes: 3 additions & 1 deletion src/MessageHandler/UpsertWorklogHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\MessageHandler;

use App\Exception\NotFoundException;
use App\Message\UpsertWorklogMessage;
use App\Service\DataProviderService;
use Psr\Log\LoggerInterface;
Expand All @@ -22,7 +23,8 @@ public function __invoke(UpsertWorklogMessage $message): void
try {
$this->logger->info('Upserting worklog: '.$message->worklogData->projectTrackerId);
$this->dataProviderService->upsertWorklog($message->worklogData);
} catch (\Exception $e) {
} catch (NotFoundException|\TypeError $e) {
// Narrow on purpose: see UpsertIssueHandler. Infrastructure failures must propagate.
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
4 changes: 2 additions & 2 deletions src/Model/DataProvider/DataProviderIssueData.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ public function __construct(
public string $projectTrackerProjectId,
public string $name,
public array $epics,
public float $plannedHours,
public float $remainingHours,
public ?float $plannedHours,
public ?float $remainingHours,
public ?string $worker,
public IssueStatusEnum $status,
public ?\DateTimeInterface $dueDate,
Expand Down
5 changes: 4 additions & 1 deletion src/Model/DataProvider/DataProviderWorklogData.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ public function __construct(
public \DateTimeInterface $startedDate,
public string $username,
public float $hours,
public string $kind,
public ?string $kind,
public ?\DateTimeInterface $fetchTime,
public ?\DateTimeInterface $sourceModifiedDate,
public bool $disableModifiedAtCheck = false,
// True when $username is a stand-in the data provider invented because the source had
// none, so it must not overwrite a real name already recorded for this worklog.
public bool $usernameIsPlaceholder = false,
) {
}
}
10 changes: 8 additions & 2 deletions src/Service/DataProviderService.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,17 @@ public function upsertWorklog(DataProviderWorklogData $upsertWorklogData): void

$worklog->setWorklogId($upsertWorklogData->projectTrackerId);
$worklog->setDescription($upsertWorklogData->description);
$worklog->setWorker($upsertWorklogData->username);

// A stand-in username describes a user the data provider could no longer resolve. It is
// better than losing the worklog, but not better than the name already on record.
if (!$upsertWorklogData->usernameIsPlaceholder || null === $worklog->getWorker()) {
$worklog->setWorker($upsertWorklogData->username);
}

$worklog->setStarted($upsertWorklogData->startedDate);
$worklog->setProjectTrackerIssueId($upsertWorklogData->projectTrackerIssueId);
$worklog->setTimeSpentSeconds($upsertWorklogData->hours * $this::SECONDS_IN_HOUR);
$worklog->setKind(BillableKindsEnum::tryFrom($upsertWorklogData->kind));
$worklog->setKind(null !== $upsertWorklogData->kind ? BillableKindsEnum::tryFrom($upsertWorklogData->kind) : null);
$worklog->setProject($issue->getProject());
$worklog->setIssue($issue);
$worklog->setFetchDate($upsertWorklogData->fetchTime);
Expand Down
Loading
Loading