Build package / php-lint (8.2) (push) Successful in 46s
Build package / php-lint (8.3) (push) Successful in 40s
Build package / php-lint (8.4) (push) Successful in 35s
Build package / xml-lint (push) Successful in 13s
Build package / unit-tests (push) Successful in 46s
Build package / package (push) Successful in 1m3s
- Boards: BoardService::getUserBoards() defaults to $includeArchived = true,
and that flag also gates the `deleted_at = 0` condition, so archived and
trashed boards showed up in the dropdown. Request the filtered query
instead, with a fallback to the no-arg call if the signature ever changes.
Stacks need nothing: StackMapper::findAll() always filters deleted_at.
- Each stack dropdown now hides whatever the other one holds, so source and
target can no longer be set to the same stack. The save-time check stays
as the backstop for rows stored before this rule.
- RunWorkflowsJob reads its interval from config.php
('workflow_deck_automation.interval', seconds, default 300, clamped to a
60s minimum). TimedJob re-reads the interval on every cron pass, so a
changed value takes effect without any occ command.
352 lines
10 KiB
PHP
352 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\WorkflowDeckAutomation\Service;
|
|
|
|
use OCA\Deck\Db\Card;
|
|
use OCA\Deck\Db\CardMapper;
|
|
use OCA\Deck\Service\BoardService;
|
|
use OCA\Deck\Service\CardService;
|
|
use OCA\Deck\Service\StackService;
|
|
use OCP\App\IAppManager;
|
|
use OCP\IGroupManager;
|
|
use OCP\IUser;
|
|
use OCP\IUserManager;
|
|
use OCP\Server;
|
|
use Psr\Log\LoggerInterface;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Single choke point for everything this app does with Deck.
|
|
*
|
|
* Deck's OCA\Deck\* classes are not a documented/stable public API (no
|
|
* @since markers, no <public> declaration in its info.xml). We use them
|
|
* directly anyway, per explicit product requirement (no HTTP/OCS calls
|
|
* against Deck are allowed), but every resolution and call is guarded so a
|
|
* Deck-side change degrades a single workflow instead of crashing the
|
|
* whole background run.
|
|
*/
|
|
class DeckIntegrationService {
|
|
private const DECK_APP_ID = 'deck';
|
|
|
|
/**
|
|
* Deck's Acl::PERMISSION_TYPE_* values, inlined so this class keeps
|
|
* working (degrading to "treat it as a user") if Deck ever moves or
|
|
* renames the constants.
|
|
*/
|
|
private const ACL_TYPE_USER = 0;
|
|
private const ACL_TYPE_GROUP = 1;
|
|
|
|
public function __construct(
|
|
private IAppManager $appManager,
|
|
private IUserManager $userManager,
|
|
private IGroupManager $groupManager,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
public function assertDeckAvailable(IUser $user): void {
|
|
if (!$this->appManager->isEnabledForUser(self::DECK_APP_ID, $user)) {
|
|
throw new DeckUnavailableException('Deck is not enabled for user ' . $user->getUID());
|
|
}
|
|
if (!class_exists(CardService::class) || !class_exists(BoardService::class) || !class_exists(StackService::class)) {
|
|
throw new DeckUnavailableException('Deck internal classes are not available');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string}>
|
|
*/
|
|
public function listBoardsForCurrentUser(): array {
|
|
$boards = $this->call(function () {
|
|
$boardService = $this->resolve(BoardService::class);
|
|
try {
|
|
// getUserBoards(?int $since, bool $includeArchived, …): with
|
|
// the default $includeArchived = true, Deck skips the
|
|
// `archived = false AND deleted_at = 0` conditions entirely,
|
|
// so archived *and* trashed boards come back. Ask for the
|
|
// filtered query instead of sorting them out afterwards.
|
|
return $boardService->getUserBoards(null, false);
|
|
} catch (Throwable $e) {
|
|
// Older/newer Deck with a different signature: fall back to
|
|
// the unfiltered call, isBoardHidden() below still filters.
|
|
return $boardService->getUserBoards();
|
|
}
|
|
}, []);
|
|
|
|
// Deck merges own/group/circle boards, so the same board can come
|
|
// back more than once; and boards in the trash or archived ones are
|
|
// no useful automation target. Keyed by id => deduplicated.
|
|
$result = [];
|
|
foreach ($boards as $board) {
|
|
$id = (int)$board->getId();
|
|
if (isset($result[$id]) || $this->isBoardHidden($board)) {
|
|
continue;
|
|
}
|
|
$title = $board->getTitle();
|
|
$result[$id] = [
|
|
'id' => $id,
|
|
'title' => (is_string($title) && $title !== '') ? $title : ('#' . $id),
|
|
];
|
|
}
|
|
|
|
return array_values($result);
|
|
}
|
|
|
|
private function isBoardHidden(mixed $board): bool {
|
|
if (method_exists($board, 'getDeletedAt') && (int)$board->getDeletedAt() > 0) {
|
|
return true;
|
|
}
|
|
return method_exists($board, 'getArchived') && $board->getArchived() === true;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string}>
|
|
*/
|
|
public function listStacks(int $boardId): array {
|
|
$stacks = $this->call(function () use ($boardId) {
|
|
return $this->resolve(StackService::class)->findAll($boardId);
|
|
}, []);
|
|
|
|
return array_map(
|
|
static fn ($stack) => ['id' => $stack->getId(), 'title' => $stack->getTitle()],
|
|
$stacks,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{id: int, title: string, color: string|null}>
|
|
*/
|
|
public function listLabels(int $boardId): array {
|
|
$labels = $this->call(function () use ($boardId) {
|
|
$board = $this->resolve(BoardService::class)->find($boardId, true);
|
|
return $board->getLabels() ?? [];
|
|
}, []);
|
|
|
|
return array_map(
|
|
static fn ($label) => ['id' => $label->getId(), 'title' => $label->getTitle(), 'color' => $label->getColor()],
|
|
$labels,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Everyone who can hold a card on this board: the board owner (who is
|
|
* *not* part of the ACL — a private board has an empty ACL) plus every
|
|
* ACL entry, with group shares expanded to their members.
|
|
*
|
|
* @return array<int, array{uid: string, displayName: string}>
|
|
*/
|
|
public function listParticipants(int $boardId): array {
|
|
$uids = $this->call(function () use ($boardId) {
|
|
$board = $this->resolve(BoardService::class)->find($boardId, true);
|
|
|
|
$uids = [];
|
|
$owner = $this->unwrapUid($board->getOwner());
|
|
if ($owner !== null) {
|
|
$uids[] = $owner;
|
|
}
|
|
|
|
foreach ($board->getAcl() ?? [] as $entry) {
|
|
$principal = $this->extractParticipantUid($entry);
|
|
if ($principal === null) {
|
|
continue;
|
|
}
|
|
$type = method_exists($entry, 'getType') ? (int)$entry->getType() : self::ACL_TYPE_USER;
|
|
if ($type === self::ACL_TYPE_GROUP) {
|
|
array_push($uids, ...$this->groupMemberUids($principal));
|
|
} elseif ($type === self::ACL_TYPE_USER) {
|
|
$uids[] = $principal;
|
|
}
|
|
// Circles and federated shares are skipped: their members
|
|
// cannot be resolved to plain uids here.
|
|
}
|
|
|
|
return $uids;
|
|
}, []);
|
|
|
|
$participants = [];
|
|
foreach ($uids as $uid) {
|
|
if (isset($participants[$uid])) {
|
|
continue;
|
|
}
|
|
$participants[$uid] = [
|
|
'uid' => $uid,
|
|
'displayName' => $this->userManager->get($uid)?->getDisplayName() ?? $uid,
|
|
];
|
|
}
|
|
|
|
return array_values($participants);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
private function groupMemberUids(string $groupId): array {
|
|
$group = $this->groupManager->get($groupId);
|
|
if ($group === null) {
|
|
return [];
|
|
}
|
|
return array_map(static fn (IUser $user) => $user->getUID(), $group->getUsers());
|
|
}
|
|
|
|
/**
|
|
* Cards in the given stack that are neither archived nor marked done,
|
|
* enriched so getAssignedUsers()/getLabels() are populated.
|
|
*
|
|
* @return Card[]
|
|
*/
|
|
public function getActiveCardsInStack(int $stackId): array {
|
|
return $this->call(function () use ($stackId) {
|
|
$cardMapper = $this->resolve(CardMapper::class);
|
|
$cardService = $this->resolve(CardService::class);
|
|
|
|
$cards = $cardMapper->findAllByStack($stackId);
|
|
$cards = $cardService->enrichCards($cards);
|
|
|
|
return array_values(array_filter($cards, static function (Card $card) {
|
|
return !$card->getArchived() && $card->getDone() === null;
|
|
}));
|
|
}, []);
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function getCardAssignedUserIds(Card $card): array {
|
|
$assigned = $card->getAssignedUsers() ?? [];
|
|
$uids = [];
|
|
foreach ($assigned as $entry) {
|
|
$uid = $this->extractParticipantUid($entry);
|
|
if ($uid !== null) {
|
|
$uids[] = $uid;
|
|
}
|
|
}
|
|
return $uids;
|
|
}
|
|
|
|
/**
|
|
* @return int[]
|
|
*/
|
|
public function getCardLabelIds(Card $card): array {
|
|
$labels = $card->getLabels() ?? [];
|
|
$ids = [];
|
|
foreach ($labels as $label) {
|
|
if (method_exists($label, 'getId')) {
|
|
$ids[] = (int)$label->getId();
|
|
}
|
|
}
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* Moves a card to another stack using Deck's own reorder logic
|
|
* (the exact same code path behind Deck's "move card" action).
|
|
*/
|
|
public function moveCard(int $cardId, int $targetStackId): void {
|
|
$this->call(function () use ($cardId, $targetStackId) {
|
|
$this->resolve(CardService::class)->reorder($cardId, $targetStackId, 0);
|
|
return null;
|
|
}, null, true);
|
|
}
|
|
|
|
/**
|
|
* @template T
|
|
* @param callable(): T $callback
|
|
* @param T $fallback
|
|
* @return T
|
|
*/
|
|
private function call(callable $callback, mixed $fallback, bool $rethrow = false) {
|
|
try {
|
|
return $callback();
|
|
} catch (DeckUnavailableException $e) {
|
|
throw $e;
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Deck integration call failed: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'exception' => $e,
|
|
]);
|
|
if ($rethrow) {
|
|
throw new DeckUnavailableException('Deck call failed: ' . $e->getMessage(), 0, $e);
|
|
}
|
|
return $fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @template T of object
|
|
* @param class-string<T> $class
|
|
* @return T
|
|
*/
|
|
private function resolve(string $class) {
|
|
if (!class_exists($class)) {
|
|
throw new DeckUnavailableException("Deck class {$class} does not exist");
|
|
}
|
|
return Server::get($class);
|
|
}
|
|
|
|
/**
|
|
* Deck's assignment/ACL entries are internal, undocumented value
|
|
* objects whose exact accessor shape has changed across versions, so
|
|
* we probe the common accessor names defensively instead of relying
|
|
* on one fixed method signature.
|
|
*/
|
|
private function extractParticipantUid(mixed $entry): ?string {
|
|
$uid = $this->unwrapUid($entry);
|
|
if ($uid !== null) {
|
|
return $uid;
|
|
}
|
|
if (!is_object($entry)) {
|
|
return null;
|
|
}
|
|
foreach (['getParticipant', 'getUid', 'getParticipantUid', 'getUserId'] as $method) {
|
|
if (method_exists($entry, $method)) {
|
|
$uid = $this->unwrapUid($entry->$method());
|
|
if ($uid !== null) {
|
|
return $uid;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Turns whatever Deck hands out for a "participant"/"owner" field into
|
|
* a plain uid.
|
|
*
|
|
* Enriched Deck entities do *not* return the raw uid string: their
|
|
* RelationalEntity base swaps resolved relations for a
|
|
* OCA\Deck\Db\RelationalObject wrapping a User/Group object, and its
|
|
* primary key is the uid we want. Unenriched entities still return the
|
|
* bare string, so both shapes are handled.
|
|
*/
|
|
private function unwrapUid(mixed $value): ?string {
|
|
if (is_string($value)) {
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
if (!is_object($value)) {
|
|
return null;
|
|
}
|
|
foreach (['getPrimaryKey', 'getUID', 'getUid', 'getId'] as $method) {
|
|
if (method_exists($value, $method)) {
|
|
$inner = $value->$method();
|
|
if (is_string($inner) && $inner !== '') {
|
|
return $inner;
|
|
}
|
|
}
|
|
}
|
|
if (method_exists($value, 'getObject')) {
|
|
$inner = $value->getObject();
|
|
foreach (['getUID', 'getUid', 'getId'] as $method) {
|
|
if (is_object($inner) && method_exists($inner, $method)) {
|
|
$uid = $inner->$method();
|
|
if (is_string($uid) && $uid !== '') {
|
|
return $uid;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|