Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ 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: `LeantimeApiService` and the sync message
handlers now catch `\Throwable`, and the upsert dispatch moved inside the same `try`. A skipped row logs
`Skipping <class> id <id>: <reason>` and the sync continues.
* 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)`, and rows with no `ticketId`/`projectId` are skipped.
* 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.
* Added `LeantimeApiServiceTest::testUpdateWithNullValues()`, covering the nullable payload plus probes that a
single unmappable row is logged and skipped rather than stopping the sync.
* [PR-324](https://github.com/itk-dev/economics/pull/324)
Added game center with snake
* [PR-303](https://github.com/itk-dev/economics/pull/303)
Expand Down
4 changes: 2 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ dotenv: [".env.local", ".env"]
vars:
# https://taskfile.dev/reference/templating/
BASE_URL: "{{.TASK_BASE_URL | default .COMPOSE_SERVER_DOMAIN | default .COMPOSE_DOMAIN }}"
DOCKER_COMPOSE: '{{ .TASK_DOCKER_COMPOSE | default "itkdev-docker-compose" }}'
DOCKER_COMPOSE: '{{ .TASK_DOCKER_COMPOSE | default "docker compose" }}'

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to the sync fix. Flips the default docker wrapper to docker compose for every developer, and line 150 switches fixtures from hautelook to doctrine — no CHANGELOG entry, shipping under a PR titled as a sync fix.

Own PR.


tasks:
default:
Expand Down Expand Up @@ -147,7 +147,7 @@ tasks:
prompt: "This will reset fixture data. Continue?"
desc: Load data fixtures.
cmds:
- task composer -- fixtures:load
- task phpfpm -- bin/console doctrine:fixtures:load --no-interaction

# ----------------------------------------------------------- Messenger ---

Expand Down
26 changes: 10 additions & 16 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -2056,7 +2056,7 @@ parameters:
-
message: '#^Access to an undefined property object\:\:\$name\.$#'
identifier: property.notFound
count: 4
count: 1
path: src/Service/LeantimeApiService.php

-
Expand Down Expand Up @@ -2113,12 +2113,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 +2926,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: 17
path: tests/Integration/Service/LeantimeApiServiceTest.php

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

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

-
Expand Down Expand Up @@ -3028,31 +3022,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: 3
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: 3
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: 12
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: 3
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: 3
path: tests/Integration/Service/LeantimeApiServiceTest.php

-
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ 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 (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
2 changes: 1 addition & 1 deletion src/MessageHandler/LeantimeDeleteHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function __invoke(LeantimeDeleteMessage $message): void
$message->asyncJobQueue,
$message->deletedAfter,
);
} catch (\Exception $e) {
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
2 changes: 1 addition & 1 deletion src/MessageHandler/LeantimeUpdateHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function __invoke(LeantimeUpdateMessage $message): void
$message->modifiedAfter,
$message->disableModifiedAtCheck,
);
} catch (\Exception $e) {
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
2 changes: 1 addition & 1 deletion src/MessageHandler/UpsertIssueHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function __invoke(UpsertIssueMessage $message): void
try {
$this->logger->info('Upserting issue: '.$message->issueData->name);
$this->dataProviderService->upsertIssue($message->issueData);
} catch (\Exception $e) {
} catch (\Throwable $e) {

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eight handlers carry a byte-identical catch body, so \Exception\Throwable took eight identical diffs — and a handler added later can silently diverge.

Not blocking. Follow-up: a Messenger middleware or a shared trait.

$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
2 changes: 1 addition & 1 deletion src/MessageHandler/UpsertProjectHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function __invoke(UpsertProjectMessage $message): void
try {
$this->logger->info('Upserting project: '.$message->projectData->name);
$this->dataProviderService->upsertProject($message->projectData);
} catch (\Exception $e) {
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
2 changes: 1 addition & 1 deletion src/MessageHandler/UpsertVersionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function __invoke(UpsertVersionMessage $message): void
try {
$this->logger->info('Upserting version: '.$message->versionData->name);
$this->dataProviderService->upsertVersion($message->versionData);
} catch (\Exception $e) {
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
2 changes: 1 addition & 1 deletion src/MessageHandler/UpsertWorkerHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function __invoke(UpsertWorkerMessage $message): void
try {
$this->logger->info('Upserting worker: '.$message->workerData->email);
$this->dataProviderService->upsertWorker($message->workerData);
} catch (\Exception $e) {
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new UnrecoverableMessageHandlingException($e->getMessage());
}
Expand Down
2 changes: 1 addition & 1 deletion src/MessageHandler/UpsertWorklogHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function __invoke(UpsertWorklogMessage $message): void
try {
$this->logger->info('Upserting worklog: '.$message->worklogData->projectTrackerId);
$this->dataProviderService->upsertWorklog($message->worklogData);
} catch (\Exception $e) {
} catch (\Throwable $e) {
$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
2 changes: 1 addition & 1 deletion src/Model/DataProvider/DataProviderWorklogData.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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,
Expand Down
2 changes: 1 addition & 1 deletion src/Service/DataProviderService.php
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ public function upsertWorklog(DataProviderWorklogData $upsertWorklogData): void
$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
59 changes: 43 additions & 16 deletions src/Service/LeantimeApiService.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class LeantimeApiService implements DataProviderInterface
public const TIMESHEETS = 'timesheets';
public const WORKERS = 'workers';
private const LIMIT = 100;
// Placeholder for a null name; the name columns are not nullable, and dropping the row would
// lose real data — for issues it would make their worklogs unstorable.
private const NAME_MISSING = '(no name)';
private const QUEUE_ASYNC = 'async';
private const QUEUE_SYNC = 'sync';

Expand Down Expand Up @@ -116,7 +119,9 @@ public function deleteAsJob(int $dataProviderId, bool $asyncJobQueue = false, ?\

$params = [
'types' => $types,
'deletedAfter' => $deletedAfter?->getTimestamp(),
// The plugin reads 'deleted'; anything else is discarded and the whole deletion
// history is returned, which /deleted does not paginate.
'deleted' => $deletedAfter?->getTimestamp(),

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — and the only change here without a test. The client is mocked with ->method('request')->willReturn(...), so the request body is never asserted: re-introducing the typo keeps the suite green while every delete-sync re-downloads the full unpaginated history.

Pin it with ->with() on the params passed to post().

];

// Get data from Leantime.
Expand All @@ -137,6 +142,13 @@ public function deleteAsJob(int $dataProviderId, bool $asyncJobQueue = false, ?\
};

foreach ($results->{$type} as $result) {

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop got the null-id guard but no try/catch. SyncDeletedCommand passes asyncJobQueue=false, so the handler runs inline and its UnrecoverableMessageHandlingException escapes here — one bad entry drops every remaining deletion in the run.

It used to self-heal because deletedAfter was ignored and each run re-pulled everything. With deleted honoured (good fix), a skipped deletion is permanent.

Same per-row try/catch as dispatchUpsertMessage().

// Nothing identifies the entity to remove, and this loop has no other guard.
if (null === $result->id) {
$this->logger->warning(sprintf('Skipping deleted %s entry with no id', $type));

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warning() here vs error() at line 245 for the same event — a row that cannot be mapped and is dropped. After data-api#18, routine null rows will page anyone alerting on error while skipped deletions stay invisible.

One level for both paths.


continue;
}

$projectTrackerId = $result->id;
$deletedDate = $this->getLeanDateTime($result->deletedDate);

Expand Down Expand Up @@ -208,6 +220,11 @@ public function updateAsJob(string $className, int $startId, int $limit, int $da

private function dispatchUpsertMessage(string $className, object $data, int $dataProviderId, \DateTimeInterface $fetchDate, bool $asyncJobQueue = false, ?string $dataProviderUrl = null, bool $disableModifiedAtCheck = false): void
{
// Catch \Throwable, not \Exception: a nullable source field mapped onto a non-nullable
// constructor argument raises a TypeError, which extends Error. Uncaught, it escapes the
// row loop in updateAsJob() before the next page is queued, halting the sync silently.
// The dispatch belongs inside the try for the same reason: on the sync transport the
// handler runs inline here, so its failures surface as part of this call.
try {
$message = match ($className) {
Project::class => new UpsertProjectMessage($this->getProjectUpsertFromResult($data, $dataProviderId, $fetchDate, $dataProviderUrl, $disableModifiedAtCheck)),
Expand All @@ -217,17 +234,15 @@ private function dispatchUpsertMessage(string $className, object $data, int $dat
Worker::class => new UpsertWorkerMessage($this->getWorkerUpsertFromResult($data, $dataProviderId, $fetchDate)),
default => null,
};
} catch (\Exception $e) {
$this->logger->error($e->getMessage());

return;
}

if (null !== $message) {
$this->messageBus->dispatch(
$message,
[new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)],
);
if (null !== $message) {
$this->messageBus->dispatch(
$message,
[new TransportNamesStamp($asyncJobQueue ? $this::QUEUE_ASYNC : $this::QUEUE_SYNC)],
);
}
} catch (\Throwable $e) {

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

\Throwable here does not separate "bad row" from "database went away". If an upsert flush closes the EntityManager, this logs Skipping ..., line 214 still queues the next page, and every remaining row fails the same way — app:data-providers:sync exits SUCCESS having imported nothing.

Catch \TypeError/NotAcceptableException around the mapping instead, and let infrastructure failures propagate.

$this->logger->error(sprintf('Skipping %s id %s: %s', $className, $data->id ?? '?', $e->getMessage()));
}
}

Expand All @@ -237,7 +252,7 @@ private function getProjectUpsertFromResult(object $result, int $dataProviderId,

return new DataProviderProjectData(
$dataProviderId,
$result->name,
$result->name ?? self::NAME_MISSING,

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two nameless projects both become (no name) (same at line 290 for issues). BillableUnbilledHoursReportService and CybersecurityReportService key their aggregates on the name string, so unrelated projects merge into one row with summed hours.

Storing instead of failing is right — make it unique: sprintf('(no name) %s', $projectTrackerId).

$projectTrackerId,
$this->linkToProject($projectTrackerId, $dataProviderUrl),
$fetchDate,
Expand All @@ -248,9 +263,14 @@ private function getProjectUpsertFromResult(object $result, int $dataProviderId,

private function getVersionUpsertFromResult(object $result, int $dataProviderId, \DateTimeInterface $fetchDate, bool $disableModifiedAtCheck = false): DataProviderVersionData
{
// A version cannot exist without a project; Version::$project is not nullable.
if (null === $result->projectId) {
throw new NotAcceptableException('Version upsert not acceptable: projectId is null');
}

return new DataProviderVersionData(
$dataProviderId,
$result->name,
$result->name ?? self::NAME_MISSING,
(string) $result->id,
(string) $result->projectId,
$fetchDate,
Expand All @@ -267,7 +287,7 @@ private function getIssueUpsertFromResult(object $result, int $dataProviderId, \
$projectTrackerId,
$dataProviderId,
(string) $result->projectId,

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getVersionUpsertFromResult() now throws on a null projectId (line 267); this still casts it to ''. getProject('') returns null, Issue::$project is nullable, so the issue is stored with no project — and an existing association gets wiped. CybersecurityReportService.php:60-61 then fatals on getProject()->getId().

The worklog fixture (id 101) already has "projectId": null. Mirror the version guard.

$result->name,
$result->name ?? self::NAME_MISSING,
$result->tags,
$result->plannedHours,
$result->remainingHours,
Expand All @@ -291,13 +311,20 @@ private function getWorklogUpsertFromResult(object $result, int $dataProviderId,
throw new NotAcceptableException('Worklog upsert not acceptable: startedDate is null');
}

// A worklog cannot exist without an issue; Worklog::$issue is not nullable.
if (null === $result->ticketId) {
throw new NotAcceptableException('Worklog upsert not acceptable: ticketId is null');
}

return new DataProviderWorklogData(
$result->id,
$dataProviderId,
(string) $result->ticketId,
$result->description,
$startedDate,
$result->username,
// A null username means the join found no user row, as Leantime never stores a null
// one. The hours are still real, so keep the worklog and name the departed user.
$result->username ?? 'deleted-user-'.($result->userId ?? 'unknown'),

@turegjorup turegjorup Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This overwrites a correct value. upsertWorklog() calls setWorker('deleted-user-42') (DataProviderService.php:248), replacing a stored jane@example.com — her hours leave every worker-scoped report, and the rolling modifiedAfter window means it never comes back. With userId also null, different people merge into one deleted-user-unknown.

Only apply the fallback when worker is not already set.

$result->hours,
$result->kind,
$fetchDate,
Expand Down Expand Up @@ -343,7 +370,7 @@ private function getLeanDateTime(?string $dateString): ?\DateTimeInterface
return new \DateTime($dateString, new \DateTimeZone('UTC'));
}

private function convertStatusToEnum(string $statusString): IssueStatusEnum
private function convertStatusToEnum(?string $statusString): IssueStatusEnum
{
return match ($statusString) {
'NEW' => IssueStatusEnum::NEW,
Expand Down
Loading
Loading