Build package / package (push) Successful in 55s
Build package / php-lint (8.2) (push) Successful in 41s
Build package / php-lint (8.3) (push) Successful in 34s
Build package / php-lint (8.4) (push) Successful in 40s
Build package / xml-lint (push) Successful in 11s
Build package / unit-tests (push) Successful in 42s
A filter whose entries have all been deleted from the board was the one failure mode of this app that was completely invisible: the workflow kept running, kept updating last_run and matched nothing, forever. It happens with deleted labels and just as easily when a filtered user loses board access, because Deck's BoardService::deleteAcl() calls assignedUsersMapper->deleteByParticipantOnBoard() and wipes that user's card assignments. Two low-stakes signals, no new column and no migration: - WorkflowRunner::warnAboutDeadFilters() logs a warning on every run. - workflowIssues in PersonalSettings.vue marks the row red. Deliberately no mail and no `enabled = false`. One deleted label out of three is harmless, and even a fully dead filter can be one board edit away from being live again. Both obey the rule the disable path already follows: only positive knowledge. DeckIntegrationService::findFilterOptions() therefore throws instead of degrading to [], unlike listLabels()/listParticipants(), where an empty list only means an empty dropdown. That also fixes an existing false positive of the same family: loadStacksFor() stored [] when the request failed, so a single failed fetch reported "Quell-Stapel und Ziel-Stapel nicht mehr vorhanden" for an untouched board. Failed requests now store null and are read as "unknown". The comparison itself lives in WorkflowRunner::findDeadFilters(), static and side-effect free like cardMatchesFilters(), with unit tests -- notably that an empty filter is never dead, which would otherwise flag every unfiltered workflow. Separately, the moved-card mail now names the board and both stacks. DeckIntegrationService::describeTargets() resolves the titles at most once per workflow run, and only when a card actually moved and the workflow wants a mail; unreadable titles degrade to #<id> rather than costing the user their notification.
401 lines
13 KiB
PHP
401 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\WorkflowDeckAutomation\Service;
|
|
|
|
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 overdue cards.
|
|
*
|
|
* 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;
|
|
|
|
public function __construct(
|
|
private WorkflowMapper $workflowMapper,
|
|
private DeckIntegrationService $deckService,
|
|
private NotificationMailer $mailer,
|
|
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;
|
|
}
|
|
|
|
$byUser = [];
|
|
foreach ($workflows as $workflow) {
|
|
$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,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
|
|
$filterUserIds = $workflow->getFilterUserIdsArray();
|
|
$filterLabelIds = $workflow->getFilterLabelIdsArray();
|
|
$this->warnAboutDeadFilters($workflow, $filterUserIds, $filterLabelIds);
|
|
|
|
// Resolved at most once per run, and only if a card actually moves
|
|
// and the workflow wants a mail — this is decoration, not a check.
|
|
$targets = null;
|
|
|
|
foreach ($cards as $card) {
|
|
if (!self::isOverdue($card->getDaysUntilDue())) {
|
|
continue;
|
|
}
|
|
|
|
$assignedUserIds = $this->deckService->getCardAssignedUserIds($card);
|
|
$labelIds = $this->deckService->getCardLabelIds($card);
|
|
if (!self::cardMatchesFilters($assignedUserIds, $labelIds, $filterUserIds, $filterLabelIds)) {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->moveCard($workflow, $card)) {
|
|
continue;
|
|
}
|
|
|
|
if ($workflow->getNotifyEmail()) {
|
|
$targets ??= $this->deckService->describeTargets(
|
|
$workflow->getBoardId(),
|
|
$workflow->getSourceStackId(),
|
|
$workflow->getTargetStackId(),
|
|
);
|
|
$this->mailer->sendCardMovedNotification($user, $workflow, $card, $targets);
|
|
}
|
|
}
|
|
|
|
$workflow->setLastRun($this->timeFactory->getDateTime());
|
|
$this->workflowMapper->update($workflow);
|
|
}
|
|
|
|
/**
|
|
* 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 regardless of the workflow's notifyEmail setting: that flag is
|
|
// about moved cards, this is a one-off notice that the automation the
|
|
// user configured has been switched off. It stays one-off because a
|
|
// disabled workflow is not picked up again.
|
|
$this->mailer->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;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* A filter whose every entry has been deleted from the board is not an
|
|
* error — the workflow still runs, it just cannot match a single card any
|
|
* more, forever, without anything in the UI or the log saying so. That is
|
|
* the one failure mode of this app that is completely invisible, so it
|
|
* gets a log line on every run.
|
|
*
|
|
* Explicitly *not* a reason to disable the workflow or mail the user: a
|
|
* filter with three labels of which one was deleted still works as
|
|
* intended, and even a fully dead filter may be one board edit away from
|
|
* being live again. The settings page shows the same condition in red.
|
|
*
|
|
* @param string[] $filterUserIds
|
|
* @param int[] $filterLabelIds
|
|
*/
|
|
private function warnAboutDeadFilters(Workflow $workflow, array $filterUserIds, array $filterLabelIds): void {
|
|
if ($filterUserIds === [] && $filterLabelIds === []) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$options = $this->deckService->findFilterOptions($workflow->getBoardId());
|
|
} catch (DeckUnavailableException $e) {
|
|
// Same rule as everywhere else: no answer is not a bad answer.
|
|
$this->logger->debug('Could not check the filters of workflow {id}: ' . $e->getMessage(), [
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
]);
|
|
return;
|
|
}
|
|
|
|
$dead = self::findDeadFilters($filterUserIds, $filterLabelIds, $options['participantUids'], $options['labelIds']);
|
|
|
|
if ($dead !== []) {
|
|
$this->logger->warning(
|
|
'Workflow {id} can no longer match any card: none of its ' . implode('/', $dead)
|
|
. ' filter entries exists on board {board} any more',
|
|
[
|
|
'app' => 'workflow_deck_automation',
|
|
'id' => $workflow->getId(),
|
|
'board' => $workflow->getBoardId(),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
public static function isOverdue(?int $daysUntilDue): bool {
|
|
return $daysUntilDue !== null && $daysUntilDue < 0;
|
|
}
|
|
|
|
/**
|
|
* Which filters cannot match anything any more, because not one of their
|
|
* entries is still offered by the board. An empty filter is not dead, it
|
|
* is "no restriction on this dimension" — the same reading as in
|
|
* cardMatchesFilters(), and getting it backwards would report every
|
|
* unfiltered workflow as broken.
|
|
*
|
|
* Static and side-effect free for the same reason as the matching logic:
|
|
* testable without a Deck installation.
|
|
*
|
|
* @param string[] $filterUserIds
|
|
* @param int[] $filterLabelIds
|
|
* @param string[] $boardUserIds everyone who can hold a card on the board
|
|
* @param int[] $boardLabelIds the board's remaining labels
|
|
* @return string[] any of 'label', 'user', in that order
|
|
*/
|
|
public static function findDeadFilters(
|
|
array $filterUserIds,
|
|
array $filterLabelIds,
|
|
array $boardUserIds,
|
|
array $boardLabelIds,
|
|
): array {
|
|
$dead = [];
|
|
if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $boardLabelIds) === []) {
|
|
$dead[] = 'label';
|
|
}
|
|
if ($filterUserIds !== [] && array_intersect($filterUserIds, $boardUserIds) === []) {
|
|
$dead[] = 'user';
|
|
}
|
|
return $dead;
|
|
}
|
|
|
|
/**
|
|
* Pure filter-matching logic (kept static/side-effect free so it can
|
|
* be unit tested without real Deck objects). Both filters are OR
|
|
* inside themselves and AND between each other: an empty filter list
|
|
* means "no restriction on this dimension".
|
|
*
|
|
* @param string[] $cardAssignedUserIds
|
|
* @param int[] $cardLabelIds
|
|
* @param string[] $filterUserIds
|
|
* @param int[] $filterLabelIds
|
|
*/
|
|
public static function cardMatchesFilters(
|
|
array $cardAssignedUserIds,
|
|
array $cardLabelIds,
|
|
array $filterUserIds,
|
|
array $filterLabelIds,
|
|
): bool {
|
|
if ($filterUserIds !== [] && array_intersect($filterUserIds, $cardAssignedUserIds) === []) {
|
|
return false;
|
|
}
|
|
if ($filterLabelIds !== [] && array_intersect($filterLabelIds, $cardLabelIds) === []) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|