Build package / php-lint (8.2) (push) Successful in 58s
Build package / php-lint (8.3) (push) Successful in 41s
Build package / php-lint (8.4) (push) Successful in 36s
Build package / xml-lint (push) Successful in 12s
Build package / unit-tests (push) Successful in 38s
Build package / package (push) Successful in 58s
Job::start() calls setLastRun() before run() and clears `reserved_at` only afterwards through setExecutionTime(), which is not in a finally block. Any uncaught throwable therefore leaves the job reserved, and JobList::getNext() skips reserved jobs until the reservation is 12 hours stale -- so a single failing workflow takes the whole app offline for half a day, while last_run still shows the start of that failed attempt. Wrap both loops so a failure is logged with its exception and the remaining users and workflows still run.
210 lines
6.3 KiB
PHP
210 lines
6.3 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.
|
|
*
|
|
* Deck's permission checks read the *live* user session, not a value
|
|
* frozen at construction, and this runner has to evaluate rules for many
|
|
* different users within a single background-job process. So workflows
|
|
* are grouped by owner and, for each owner, we temporarily impersonate
|
|
* that user (push their IUser onto the session, restore afterwards) —
|
|
* the standard Nextcloud pattern for multi-user cron jobs.
|
|
*/
|
|
class WorkflowRunner {
|
|
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;
|
|
}
|
|
|
|
$this->impersonate($user);
|
|
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 {
|
|
$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();
|
|
|
|
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;
|
|
}
|
|
|
|
$this->moveAndNotify($user, $workflow, $card);
|
|
}
|
|
|
|
$workflow->setLastRun($this->timeFactory->getDateTime());
|
|
$this->workflowMapper->update($workflow);
|
|
}
|
|
|
|
private function moveAndNotify(IUser $user, Workflow $workflow, Card $card): void {
|
|
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;
|
|
}
|
|
|
|
if ($workflow->getNotifyEmail()) {
|
|
$this->mailer->sendCardMovedNotification($user, $workflow, $card);
|
|
}
|
|
}
|
|
|
|
public static function isOverdue(?int $daysUntilDue): bool {
|
|
return $daysUntilDue !== null && $daysUntilDue < 0;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
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',
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function clearImpersonation(): void {
|
|
$this->userSession->setUser($this->impersonationRestore);
|
|
$this->impersonationRestore = null;
|
|
}
|
|
}
|