Build package / xml-lint (push) Successful in 1m4s
Build package / php-lint (8.5) (push) Successful in 4m0s
Build package / unit-tests (push) Successful in 4m13s
Build package / php-lint (8.4) (push) Successful in 4m34s
Build package / package (push) Successful in 1m38s
Every enabled workflow is an edge "source stack -> target stack"; LoopDetector finds circles in that graph, a workflow whose source is its own target being the shortest. Without a check, two workflows A -> B and B -> A move the same cards back and forth on every run, even within a single run, with a notice for each move. The trigger date is ignored on purpose: by start date one way and by due date the other, a card with both dates passed still circles. Chains stay allowed. The controller rejects saving an enabled workflow that would close a loop and names the workflows involved. Workflows stored before this check are skipped by the runner (logged, not disabled) and flagged in the settings list via a new `loop` field from index().
526 lines
18 KiB
PHP
526 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\WorkflowDeckAutomation\Service;
|
|
|
|
use DateTimeImmutable;
|
|
use DateTimeInterface;
|
|
use DateTimeZone;
|
|
use OCA\Deck\Db\Card;
|
|
use OCA\WorkflowDeckAutomation\Db\Workflow;
|
|
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
|
use OCP\AppFramework\Utility\ITimeFactory;
|
|
use OCP\Files\IRootFolder;
|
|
use OCP\IUser;
|
|
use OCP\IUserManager;
|
|
use OCP\IUserSession;
|
|
use Psr\Log\LoggerInterface;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Evaluates all enabled workflows and moves every card whose due date or
|
|
* start date — whichever the workflow uses, see TriggerDate — has passed.
|
|
*
|
|
* This runner has to evaluate rules for many different users within a
|
|
* single background-job process, so workflows are grouped by owner and
|
|
* each owner is impersonated in turn. Impersonation here means two
|
|
* things, and the second one is the load-bearing half: pushing the IUser
|
|
* onto the session (the standard Nextcloud cron pattern), *and* pinning
|
|
* Deck itself to that user via
|
|
* DeckIntegrationService::beginUserContext(), because Deck's permission
|
|
* checks never consult the session — they read a uid frozen once per
|
|
* process. Both are undone in a finally.
|
|
*/
|
|
class WorkflowRunner {
|
|
public const TARGET_BOARD = 'board';
|
|
public const TARGET_SOURCE_STACK = 'sourceStack';
|
|
public const TARGET_TARGET_STACK = 'targetStack';
|
|
|
|
private ?IUser $impersonationRestore = null;
|
|
|
|
/**
|
|
* Moved cards per recipient uid, collected over the whole run. Each
|
|
* entry's workflow says whether it goes out by mail, as a Nextcloud
|
|
* notification, or both.
|
|
*
|
|
* @var array<string, list<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}>>
|
|
*/
|
|
private array $pendingNotices = [];
|
|
|
|
public function __construct(
|
|
private WorkflowMapper $workflowMapper,
|
|
private DeckIntegrationService $deckService,
|
|
private NotificationMailer $mailer,
|
|
private NotificationPusher $pusher,
|
|
private IUserManager $userManager,
|
|
private IUserSession $userSession,
|
|
private IRootFolder $rootFolder,
|
|
private ITimeFactory $timeFactory,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
public function run(): void {
|
|
$workflows = $this->workflowMapper->findAllEnabled();
|
|
if ($workflows === []) {
|
|
return;
|
|
}
|
|
|
|
// The controller refuses to store a loop, but workflows saved before
|
|
// that check — including ones whose source is their own target — can
|
|
// still form one. They would move the same cards on every run, with a
|
|
// notice each time, so they are left out until the user fixes them;
|
|
// the settings page marks them.
|
|
$looping = LoopDetector::findLoopingWorkflows(LoopDetector::edgesOf($workflows));
|
|
|
|
$this->pendingNotices = [];
|
|
$byUser = [];
|
|
$owners = [];
|
|
foreach ($workflows as $workflow) {
|
|
$this->followBoardOwner($workflow, $owners);
|
|
if (in_array((int)$workflow->getId(), $looping, true)) {
|
|
$this->logger->warning('Skipping workflow {id}: it moves cards in a loop together with other workflows', [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
continue;
|
|
}
|
|
$byUser[$workflow->getUserId()][] = $workflow;
|
|
}
|
|
|
|
foreach ($byUser as $userId => $userWorkflows) {
|
|
// Nothing may escape this loop. Job::start() calls setLastRun()
|
|
// *before* run() and only clears `reserved_at` afterwards via
|
|
// setExecutionTime(), which is not in a finally — so a single
|
|
// uncaught throwable leaves the job reserved, and JobList::getNext()
|
|
// then skips it until the reservation is 12 hours stale. One bad
|
|
// workflow would silently take the whole app offline for half a day.
|
|
try {
|
|
$this->runForUser((string)$userId, $userWorkflows);
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Workflow run failed for {user}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'user' => $userId,
|
|
'exception' => $e,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$this->sendPendingNotices();
|
|
}
|
|
|
|
/**
|
|
* A workflow belongs to its board's owner, not to whoever created it: if
|
|
* Deck transferred the board, the workflow moves along to the new owner —
|
|
* it then runs as them, and they get its mails.
|
|
*
|
|
* Only a known owner rewrites `user_id`. A board that is gone keeps its
|
|
* last owner, so findBrokenTarget() can disable the workflow and mail
|
|
* them; a Deck error keeps it too, and the run just goes ahead as before.
|
|
*
|
|
* @param array<int, ?string> $owners per-run cache, board id => owner
|
|
*/
|
|
private function followBoardOwner(Workflow $workflow, array &$owners): void {
|
|
$boardId = $workflow->getBoardId();
|
|
if (!array_key_exists($boardId, $owners)) {
|
|
try {
|
|
$owners[$boardId] = $this->deckService->findBoardOwner($boardId);
|
|
} catch (Throwable $e) {
|
|
$this->logger->warning('Could not determine the owner of board {board}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'board' => $boardId,
|
|
]);
|
|
$owners[$boardId] = null;
|
|
}
|
|
}
|
|
|
|
$owner = $owners[$boardId];
|
|
if ($owner === null || $owner === $workflow->getUserId()) {
|
|
return;
|
|
}
|
|
|
|
$this->logger->info('Workflow {id} follows board {board} to its new owner {owner}.', [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
'board' => $boardId,
|
|
'owner' => $owner,
|
|
]);
|
|
$workflow->setUserId($owner);
|
|
try {
|
|
$this->workflowMapper->update($workflow);
|
|
} catch (Throwable $e) {
|
|
// Not persisted, but this run still goes to the right user; the
|
|
// next one tries again.
|
|
$this->logger->warning('Could not store the new owner of workflow {id}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param Workflow[] $workflows
|
|
*/
|
|
private function runForUser(string $userId, array $workflows): void {
|
|
$user = $this->userManager->get($userId);
|
|
if ($user === null || !$user->isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->deckService->assertDeckAvailable($user);
|
|
} catch (DeckUnavailableException $e) {
|
|
$this->logger->info('Skipping workflows for {user}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'user' => $userId,
|
|
]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->impersonate($user);
|
|
} catch (DeckUnavailableException $e) {
|
|
// Deck could not be pinned to this user, so every permission
|
|
// check would be answered for somebody else. Skipping costs one
|
|
// run; continuing would move the wrong cards or, worse, read a
|
|
// denial as "the board is gone" and disable a working workflow.
|
|
$this->logger->error('Could not impersonate {user} towards Deck: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'user' => $userId,
|
|
]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
foreach ($workflows as $workflow) {
|
|
// Same reasoning as in run(): one broken workflow must not
|
|
// stop the user's remaining ones.
|
|
try {
|
|
$this->runWorkflow($user, $workflow);
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Workflow {id} failed: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
'exception' => $e,
|
|
]);
|
|
}
|
|
}
|
|
} finally {
|
|
$this->clearImpersonation();
|
|
}
|
|
}
|
|
|
|
private function runWorkflow(IUser $user, Workflow $workflow): void {
|
|
try {
|
|
$brokenTarget = $this->findBrokenTarget($user, $workflow);
|
|
} catch (DeckUnavailableException $e) {
|
|
// Could not determine it either way — leave the workflow alone
|
|
// rather than disabling it over a temporary Deck problem.
|
|
$this->logger->warning('Could not verify targets of workflow {id}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if ($brokenTarget !== null) {
|
|
$this->disableBrokenWorkflow($user, $workflow, $brokenTarget);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$cards = $this->deckService->getActiveCardsInStack($workflow->getSourceStackId());
|
|
} catch (DeckUnavailableException $e) {
|
|
$this->logger->warning('Could not read stack for workflow {id}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$trigger = (string)$workflow->getTriggerDate();
|
|
if ($trigger === TriggerDate::START && !$this->deckService->supportsStartdate()) {
|
|
// Not disabled: the workflow is intact, Deck is just too old for
|
|
// it, and comes back to life with a Deck update.
|
|
$this->logger->warning('Skipping workflow {id}: this Deck version has no start date', [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$moved = [];
|
|
$now = $this->timeFactory->getDateTime();
|
|
|
|
foreach ($cards as $card) {
|
|
// Anything but `start` counts as `due`, the column default.
|
|
$date = $trigger === TriggerDate::START ? $card->getStartdate() : $card->getDuedate();
|
|
if (!self::hasPassed($date, $now)) {
|
|
continue;
|
|
}
|
|
|
|
$this->logger->info('Card {card} ("{title}") matches workflow {id}; moving to stack {target}.', [
|
|
'app' => 'workflow_deck_automation',
|
|
'card' => $card->getId(),
|
|
'title' => $card->getTitle(),
|
|
'id' => $workflow->getId(),
|
|
'target' => $workflow->getTargetStackId(),
|
|
]);
|
|
|
|
if ($this->moveCard($workflow, $card)) {
|
|
$moved[] = $card;
|
|
}
|
|
}
|
|
|
|
$workflow->setLastRun($this->timeFactory->getDateTime());
|
|
$this->workflowMapper->update($workflow);
|
|
|
|
if ($moved !== []) {
|
|
$this->queueNotices($user, $workflow, $moved);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Works out who hears about the moved cards and parks them in
|
|
* $pendingNotices; sendPendingNotices() delivers them at the end of the
|
|
* run. The recipients are the same for mail and notification.
|
|
*
|
|
* Notices are decoration, not part of the move: nothing in here may fail
|
|
* the workflow, and if Deck cannot say who wants one, nobody gets one
|
|
* rather than people who asked Deck for silence.
|
|
*
|
|
* @param Card[] $cards
|
|
*/
|
|
private function queueNotices(IUser $owner, Workflow $workflow, array $cards): void {
|
|
$cardIds = array_map(static fn (Card $card): int => (int)$card->getId(), $cards);
|
|
|
|
switch ($workflow->getNotifyMode()) {
|
|
case NotificationPolicy::MODE_OWNER:
|
|
$recipients = array_fill_keys($cardIds, [$owner->getUID()]);
|
|
break;
|
|
case NotificationPolicy::MODE_BOARD:
|
|
try {
|
|
$recipients = $this->deckService->findNotificationRecipients($workflow->getBoardId(), $cardIds);
|
|
} catch (DeckUnavailableException $e) {
|
|
$this->logger->warning('No mails or notifications for workflow {id}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
return;
|
|
}
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
$targets = $this->deckService->describeTargets(
|
|
$workflow->getBoardId(),
|
|
$workflow->getSourceStackId(),
|
|
$workflow->getTargetStackId(),
|
|
);
|
|
|
|
foreach ($cards as $card) {
|
|
foreach ($recipients[(int)$card->getId()] ?? [] as $uid) {
|
|
$this->pendingNotices[$uid][] = [
|
|
'workflow' => $workflow,
|
|
'card' => $card,
|
|
'targets' => $targets,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One mail per recipient and run, however many cards and workflows it
|
|
* covers — a board with twenty overdue cards and fifteen members would
|
|
* otherwise send three hundred mails at once. Nextcloud notifications
|
|
* go out per card instead: they are grouped in the bell anyway, and each
|
|
* one links its card.
|
|
*
|
|
* Runs after the per-user loop, so no impersonation is active any more.
|
|
*/
|
|
private function sendPendingNotices(): void {
|
|
$pending = $this->pendingNotices;
|
|
$this->pendingNotices = [];
|
|
|
|
foreach ($pending as $uid => $entries) {
|
|
// Same reasoning as in run(): nothing may escape.
|
|
try {
|
|
$recipient = $this->userManager->get((string)$uid);
|
|
if ($recipient === null || !$recipient->isEnabled()) {
|
|
continue;
|
|
}
|
|
|
|
$mailEntries = array_values(array_filter(
|
|
$entries,
|
|
static fn (array $entry): bool => NotificationPolicy::sendsMail((string)$entry['workflow']->getNotifyChannel()),
|
|
));
|
|
$notificationEntries = array_values(array_filter(
|
|
$entries,
|
|
static fn (array $entry): bool => NotificationPolicy::sendsNotification((string)$entry['workflow']->getNotifyChannel()),
|
|
));
|
|
|
|
if ($mailEntries !== []) {
|
|
$this->mailer->sendCardsMovedNotification($recipient, $mailEntries);
|
|
}
|
|
if ($notificationEntries !== []) {
|
|
$this->pusher->sendCardsMovedNotification($recipient, $notificationEntries);
|
|
}
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Could not notify {user} about moved cards: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'user' => $uid,
|
|
'exception' => $e,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which of the workflow's Deck references no longer exists, if any.
|
|
*
|
|
* Deleting a board or stack leaves the workflow row untouched — there is
|
|
* no foreign key — and Deck keeps the orphaned cards readable until its
|
|
* own DeleteCron purges them, so the workflow would otherwise keep
|
|
* failing on every single run.
|
|
*
|
|
* @return self::TARGET_*|null
|
|
* @throws DeckUnavailableException if Deck could not be asked
|
|
*/
|
|
private function findBrokenTarget(IUser $user, Workflow $workflow): ?string {
|
|
$stackIds = $this->deckService->findStackIds($workflow->getBoardId(), $user->getUID());
|
|
if ($stackIds === null) {
|
|
return self::TARGET_BOARD;
|
|
}
|
|
|
|
if (!in_array($workflow->getSourceStackId(), $stackIds, true)) {
|
|
return self::TARGET_SOURCE_STACK;
|
|
}
|
|
if (!in_array($workflow->getTargetStackId(), $stackIds, true)) {
|
|
return self::TARGET_TARGET_STACK;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param self::TARGET_* $brokenTarget
|
|
*/
|
|
private function disableBrokenWorkflow(IUser $user, Workflow $workflow, string $brokenTarget): void {
|
|
$workflow->setEnabled(false);
|
|
$workflow->setLastRun($this->timeFactory->getDateTime());
|
|
$this->workflowMapper->update($workflow);
|
|
|
|
$this->logger->warning('Disabled workflow {id}: its {target} no longer exists', [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
'target' => $brokenTarget,
|
|
]);
|
|
|
|
// Sent to the owner regardless of the workflow's notifyMode and
|
|
// notifyChannel: those are about moved cards, this is a one-off notice
|
|
// that the automation the user configured has been switched off — by
|
|
// mail as always, and in the bell too if the notifications app is
|
|
// there. It stays one-off because a disabled workflow is not picked
|
|
// up again.
|
|
$this->mailer->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget);
|
|
$this->pusher->sendWorkflowDisabledNotification($user, $workflow, $brokenTarget);
|
|
}
|
|
|
|
private function moveCard(Workflow $workflow, Card $card): bool {
|
|
try {
|
|
$this->deckService->moveCard($card->getId(), $workflow->getTargetStackId());
|
|
} catch (Throwable $e) {
|
|
$this->logger->error('Failed to move card {card} for workflow {id}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'card' => $card->getId(),
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
$this->logger->info('Moved card {card} ("{title}") to stack {target} for workflow {id}.', [
|
|
'app' => 'workflow_deck_automation',
|
|
'card' => $card->getId(),
|
|
'title' => $card->getTitle(),
|
|
'target' => $workflow->getTargetStackId(),
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Whether the card's trigger date (due or start date, see TriggerDate)
|
|
* lies before $now.
|
|
*
|
|
* Compares the card's actual timestamp against $now instead of Deck's own
|
|
* Card::getDaysUntilDue(), which truncates both sides to midnight before diffing
|
|
* (DateInterval's %a is a whole-day count) and therefore reports 0 - not overdue -
|
|
* for any card whose due time has already passed today. That silently held back
|
|
* every card for up to 24h after it actually fell due; comparing the raw datetime
|
|
* instead matches what Deck's own UI shows (e.g. "vor 2 Stunden") and has no
|
|
* day-rounding step to get wrong.
|
|
*
|
|
* Both sides are compared as absolute instants, and the card's date is
|
|
* first pinned to UTC via asStoredUtc() — see there for why.
|
|
*/
|
|
public static function hasPassed(?DateTimeInterface $date, DateTimeInterface $now): bool {
|
|
return $date !== null && self::asStoredUtc($date) < $now;
|
|
}
|
|
|
|
/**
|
|
* Deck's `duedate` and `startdate` are DATETIME columns without a zone
|
|
* that hold UTC wall-clock time: Deck's frontend sends `toISOString()`,
|
|
* and the value is written as plain `Y-m-d H:i:s`. Entity hydrates it
|
|
* with `new \DateTime($string)`, i.e. in PHP's *default* timezone —
|
|
* correct only because Nextcloud sets that default to UTC during
|
|
* bootstrap. Reading the wall clock and pinning it to UTC explicitly
|
|
* makes the comparison independent of that default, so a server or CLI
|
|
* running with a local default zone cannot shift every date by its UTC
|
|
* offset.
|
|
*
|
|
* Deck itself has two timezone-sensitive paths this deliberately avoids:
|
|
* Card::getDaysUntilDue() (day-truncated, see hasPassed()) and
|
|
* CardMapper::findOverdue(), which compares the UTC column against the
|
|
* database's `NOW()` — the DB session's local time.
|
|
*/
|
|
private static function asStoredUtc(DateTimeInterface $date): DateTimeImmutable {
|
|
return new DateTimeImmutable($date->format('Y-m-d H:i:s.u'), new DateTimeZone('UTC'));
|
|
}
|
|
|
|
/**
|
|
* @throws DeckUnavailableException if Deck cannot be pinned to $user
|
|
*/
|
|
private function impersonate(IUser $user): void {
|
|
$this->impersonationRestore = $this->userSession->getUser();
|
|
$this->userSession->setUser($user);
|
|
try {
|
|
// Best-effort filesystem setup; some Deck-internal helpers
|
|
// (attachments, activity) expect an initialised user FS.
|
|
$this->rootFolder->getUserFolder($user->getUID());
|
|
} catch (Throwable $e) {
|
|
$this->logger->debug('Filesystem setup failed for ' . $user->getUID() . ': ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
]);
|
|
}
|
|
|
|
try {
|
|
// The session switch above is necessary but nowhere near
|
|
// sufficient — Deck's permission checks never look at it. See
|
|
// DeckIntegrationService::beginUserContext().
|
|
$this->deckService->beginUserContext($user);
|
|
} catch (DeckUnavailableException $e) {
|
|
$this->clearImpersonation();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
private function clearImpersonation(): void {
|
|
$this->deckService->endUserContext();
|
|
$this->userSession->setUser($this->impersonationRestore);
|
|
$this->impersonationRestore = null;
|
|
}
|
|
}
|