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
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(task test:*)",
"Bash(task composer:*)",
"Bash(task lint:*)"
]
}
}
26 changes: 23 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
on: pull_request

name: Review

# The checkout belongs to the runner, so the container writes to the bind mount
# as `runner` rather than as the image's `deploy` user.
env:
COMPOSE_USER: runner

jobs:
changelog:
runs-on: ubuntu-latest
name: Changelog should be updated
strategy:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Git fetch
run: git fetch

- name: Check that changelog has been updated.
run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0

test:
runs-on: ubuntu-latest
name: Unit tests
steps:
- name: Checkout
uses: actions/checkout@v7

# Tests run in the project's own compose stack, so the PHP version comes
# from compose.yml rather than being repeated here.
- name: Install dependencies
run: docker compose run --rm --no-deps phpfpm composer install --no-interaction --prefer-dist --no-progress

- name: Run tests
run: docker compose run --rm --no-deps phpfpm vendor/bin/phpunit
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Build and create release
env:
Expand All @@ -34,7 +34,7 @@ jobs:
release_name="release-${tag_name}"
fi

docker compose run --user root --rm php bin/create-release "$tag_name"
docker compose run --user root --rm php-release bin/create-release "$tag_name"

# Delete release if it already exists.
gh release view "$release_name" > /dev/null 2>&1 && gh release delete "$release_name" --yes
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
vendor
.DS_Store
*.cache
composer.lock

# Release artifacts
release/
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## [Unreleased]

* [PR-18](https://github.com/ITK-Leantime/data-api/pull/18)
* Allowed null values in API models, so entries referencing deleted users or deleted tickets no longer fail the whole request.
* Added userId to timesheets, so hours logged by a deleted user stay attributable.
* Stopped resolving ticket status against the session's project when a ticket has no project.
* Allowed a missing worker name, and stopped returning a whitespace-only name for a worker without one.
* Looked up ticket status labels once per project instead of once per ticket.
* Pinned the development dependencies to the Leantime release the plugin targets.
* Added PHPUnit test setup and a Taskfile for running it, and ran the tests in the project's Docker Compose stack on pull requests.

## [0.1.2] - 2026-03-06

* [PR-12](https://github.com/ITK-Leantime/data-api/pull/12)
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FROM itkdev/php8.3-fpm:latest

USER root

# Add rsync
# Add rsync, which `bin/create-release` needs and the base image does not carry.
RUN apt-get update && apt-get --yes install rsync

# Clean up
Expand Down
6 changes: 3 additions & 3 deletions Model/DeletedData.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

use Carbon\CarbonInterface;

class DeletedData
readonly class DeletedData
{
public function __construct(
public int $id,
public CarbonInterface $deletedDate,
public ?int $id,
public ?CarbonInterface $deletedDate,
) {}
}
4 changes: 2 additions & 2 deletions Model/MilestoneData.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
{
public function __construct(
public int $id,
public int $projectId,
public string $name,
public ?int $projectId,
public ?string $name,
public ?CarbonInterface $modified,
) {}
}
2 changes: 1 addition & 1 deletion Model/ProjectData.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{
public function __construct(
public int $id,
public string $name,
public ?string $name,
public ?CarbonInterface $modified,
) {}
}
4 changes: 2 additions & 2 deletions Model/TicketData.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
{
public function __construct(
public int $id,
public int $projectId,
public string $name,
public ?int $projectId,
public ?string $name,
public ?string $status,
public ?int $milestoneId,
public array $tags,
Expand Down
9 changes: 5 additions & 4 deletions Model/TimesheetData.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
{
public function __construct(
public int $id,
public int $ticketId,
public int $projectId,
public ?int $ticketId,
public ?int $projectId,
public ?string $description,
public float $hours,
public string $username,
public ?int $userId,
public ?string $username,
public ?string $kind,
public ?CarbonInterface $workDate = null,
public ?CarbonInterface $modified = null,
public string $kind,
) {}
}
12 changes: 4 additions & 8 deletions Model/WorkerData.php
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
<?php


namespace Leantime\Plugins\APIData\Model;


readonly class WorkerData
{
public function __construct(
public int $id,
public string $email,
public string $name,
)
{
}
public int $id,
public ?string $email,
public ?string $name,
) {}
}
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,31 @@ curl https://leantime.local.itkdev.dk/apidata/api/deleted
-d '{"deleted":1759906882,"types":["projects","milestones","tickets","timesheets"]}'
```

## Development

The plugin has no long-running stack, so everything runs in a one-off
`itkdev/php8.3-fpm` container. Install [Task](https://taskfile.dev), then:

```shell
task setup
task test
task lint
```

Run `task --list-all` to see the remaining commands.

The `Dockerfile` exists only for releases: `bin/create-release` needs `rsync`,
which the base image does not carry. It backs the `php-release` compose service
and is not used for tests or linting.

Leantime core is not a Composer dependency of this plugin, so the development
dependencies stand in for it. `illuminate/database` and `nesbot/carbon` are
pinned to the exact versions the targeted Leantime release locks, so the tests
run against the code Leantime itself runs — currently v3.9.7, which runs PHP 8.3
and locks `laravel/framework v11.45.1` and `nesbot/carbon 3.10.1`. Bump those
pins and re-check `tests/Stub/` against Leantime's own `composer.lock` when
upgrading Leantime.

## API Key

To use the plugin you need an API key for leantime.
Expand Down
11 changes: 7 additions & 4 deletions Repositories/ApiDataRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function getProjects(int $startId, int $limit, ?int $modifiedAfter = null
->toArray();
}

public function getMilestones(int $startId, int $limit, int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array
public function getMilestones(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array
{
return $this->query()
->select(["id", "headline", "projectId", "modified"])
Expand All @@ -44,7 +44,7 @@ public function getMilestones(int $startId, int $limit, int $modifiedAfter = nul
->toArray();
}

public function getTickets(int $startId, int $limit, int $modifiedAfter = null, array $ids = null, ?array $projectIds = null): array
public function getTickets(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array
{
return $this->query()
->select(["ticket.id", "ticket.headline", "ticket.projectId", "ticket.status", "ticket.planHours", "ticket.hourRemaining", "ticket.tags", "ticket.dateToFinish", "ticket.editTo", "ticket.milestoneid", "ticket.modified", "user.username"])
Expand All @@ -65,7 +65,7 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu
{
return $this->query()
->from("zp_timesheets", "timesheet")
->select(["timesheet.id", "timesheet.description", "timesheet.hours", "timesheet.workDate", "timesheet.modified", "timesheet.ticketId", "timesheet.kind", "user.username", "ticket.projectId"])
->select(["timesheet.id", "timesheet.description", "timesheet.hours", "timesheet.workDate", "timesheet.modified", "timesheet.ticketId", "timesheet.userId", "timesheet.kind", "user.username", "ticket.projectId"])
->where("timesheet.id", ">=", $startId)
->whereNotNull("timesheet.hours")
->leftJoin('zp_user as user', "user.id", "=", "timesheet.userId")
Expand All @@ -83,7 +83,10 @@ public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null,
{
return $this->query()
->from("zp_user", "worker")
->select(["worker.id", "worker.username", DB::raw("CONCAT(worker.firstname, ' ', worker.lastname) as name")])
// CONCAT_WS skips a missing name part, so a worker with only a
// firstname keeps a usable name. NULLIF turns an all-blank name into
// null rather than a string of whitespace.
->select(["worker.id", "worker.username", DB::raw("NULLIF(TRIM(CONCAT_WS(' ', worker.firstname, worker.lastname)), '') as name")])
->where("worker.id", ">=", $startId)
->where("worker.source", "<>", "api")
->when($modifiedAfter !== null, fn ($query) => $query->where("worker.modified", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT)))
Expand Down
40 changes: 27 additions & 13 deletions Services/APIData.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ public function getProjects(int $startId, int $limit, ?int $modifiedAfter = null
}, $values);
}

public function getMilestones(int $startId, int $limit, int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array
public function getMilestones(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array
{
$values = $this->apiDataRepository->getMilestones($startId, $limit, $modifiedAfter, $ids, $projectIds);

Expand All @@ -139,12 +139,22 @@ public function getMilestones(int $startId, int $limit, int $modifiedAfter = nul
}, $values);
}

public function getTickets(int $startId, int $limit, int $modifiedAfter = null, array $ids = null, ?array $projectIds = null): array
public function getTickets(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array
{
$values = $this->apiDataRepository->getTickets($startId, $limit, $modifiedAfter, $ids, $projectIds);

return array_map(function ($value) {
$projectStatuses = $this->ticketRepository->getStateLabels($value->projectId);
// Tickets arrive in batches from the same handful of projects, so the
// labels are looked up once per project instead of once per ticket. Kept
// local to the call, since labels can change between requests.
$statusesByProject = [];

return array_map(function ($value) use (&$statusesByProject) {
// Asked for labels without a project id, Leantime falls back to
// session('currentProject'), which would resolve the status against
// an unrelated project.
$projectStatuses = $value->projectId !== null
? $statusesByProject[$value->projectId] ??= $this->ticketRepository->getStateLabels($value->projectId)
: [];

return new TicketData(
$value->id,
Expand All @@ -168,16 +178,20 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu
$values = $this->apiDataRepository->getTimesheets($startId, $limit, $modifiedAfter, $ids, $projectIds);

return array_map(function ($value) {
// Named arguments: CarbonImmutable has a __toString(), so a
// mis-ordered date would be coerced into one of the string
// parameters instead of raising a TypeError.
return new TimesheetData(
$value->id,
$value->ticketId,
$value->projectId,
$value->description,
$value->hours,
$value->username,
$this->getCarbonFromDatabaseValue($value->workDate),
$this->getCarbonFromDatabaseValue($value->modified),
$value->kind,
id: $value->id,
ticketId: $value->ticketId,
projectId: $value->projectId,
description: $value->description,
hours: $value->hours,
userId: $value->userId,
username: $value->username,
kind: $value->kind,
workDate: $this->getCarbonFromDatabaseValue($value->workDate),
modified: $this->getCarbonFromDatabaseValue($value->modified),
);
}, $values);
}
Expand Down
61 changes: 61 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# https://taskfile.dev — install with `brew install go-task` (or see docs).
# Run `task` (or `task --list-all`) to see all available commands.
#
# This plugin has no long-running stack, so everything runs in a one-off
# itkdev/php8.3-fpm container.

version: "3"

vars:
# https://taskfile.dev/reference/templating/
DOCKER_COMPOSE: '{{ .TASK_DOCKER_COMPOSE | default "docker compose" }}'

tasks:
default:
desc: List all tasks
cmds:
- task --list-all
silent: true

# -------------------------------------------------------------- Wrappers ---

compose:
desc: "Run a docker compose command. Example: task compose -- config."
cmds:
- "{{ .DOCKER_COMPOSE }} {{ .CLI_ARGS }}"

php:
desc: "Run a command in a one-off php container. Example: task php -- php --version."
cmds:
- task compose -- run --rm --no-deps phpfpm {{ .CLI_ARGS }}
silent: true

composer:
desc: "Run a composer command. Example: task composer -- install."
cmds:
- task php -- composer {{ .CLI_ARGS }}
silent: true

# ------------------------------------------------------------ Lifecycle ---

setup:
desc: Install dev dependencies.
cmds:
- task composer -- install

# ------------------------------------------------------------ PHP tests ---

test:
desc: "Run the PHP test suite. Example: task test -- --filter TimesheetData."
cmds:
- task php -- vendor/bin/phpunit {{ .CLI_ARGS }}
silent: true

lint:
desc: Syntax-check all PHP files.
cmds:
- >-
task php -- sh -c
'find Controllers Model Repositories Services -name "*.php" -print0
| xargs -0 -n1 -- php -l'
silent: true
Loading