Refuse workflows that move cards in a loop
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().
This commit is contained in:
Patrick Niebeling committed 2026-10-01 13:47:32 +02:00
1 parent 8268f1068d
commit 9e1a15bab0
8 files changed
+251 -5

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
/**
* Finds workflows that would hand cards around in a circle — pure logic, no
* Nextcloud or Deck dependencies, so it is unit-testable like
* NotificationPolicy.
*
* Every enabled workflow is an edge "source stack → target stack". Two
* workflows S1 → S2 and S2 → S1 move a card back and forth on every run,
* and since the runner reads each source stack afresh, even within a single
* run — with a notice each time. The trigger date is deliberately ignored:
* with S1 → S2 by start date and S2 → S1 by due date, every card whose two
* dates have both passed still circles. A workflow whose source is its own
* target is the shortest such loop.
*
* Deck's stack ids are unique across boards, so edges from several boards
* can be passed in together without mixing anything up.
*/
class LoopDetector {
/**
* The edges for findPath() and findLoopingWorkflows(). Typed only in the
* docblock, so the tests need no Nextcloud classes.
*
* @param iterable<\OCA\WorkflowDeckAutomation\Db\Workflow> $workflows
* @return array<int, array{0: int, 1: int}>
*/
public static function edgesOf(iterable $workflows): array {
$edges = [];
foreach ($workflows as $workflow) {
$edges[(int)$workflow->getId()] = [(int)$workflow->getSourceStackId(), (int)$workflow->getTargetStackId()];
}
return $edges;
}
/**
* The workflows leading from stack $from to stack $to, in order, or
* `null` if there is no way. `[]` if $from already is $to.
*
* @param array<int, array{0: int, 1: int}> $edges workflow id => [source stack id, target stack id]
* @return list<int>|null
*/
public static function findPath(array $edges, int $from, int $to): ?array {
if ($from === $to) {
return [];
}
// Breadth-first, remembering how each stack was reached.
$reachedBy = [$from => null];
$queue = [$from];
while ($queue !== []) {
$stack = array_shift($queue);
foreach ($edges as $id => [$source, $target]) {
if ($source !== $stack || array_key_exists($target, $reachedBy)) {
continue;
}
$reachedBy[$target] = [$id, $stack];
if ($target === $to) {
$path = [];
for ($at = $to; $reachedBy[$at] !== null; $at = $reachedBy[$at][1]) {
array_unshift($path, $reachedBy[$at][0]);
}
return $path;
}
$queue[] = $target;
}
}
return null;
}
/**
* Ids of the workflows that lie on a loop: those whose target leads back
* to their own source.
*
* @param array<int, array{0: int, 1: int}> $edges workflow id => [source stack id, target stack id]
* @return list<int>
*/
public static function findLoopingWorkflows(array $edges): array {
$looping = [];
foreach ($edges as $id => [$source, $target]) {
if (self::findPath($edges, $target, $source) !== null) {
$looping[] = $id;
}
}
return $looping;
}
}
+14
View File
@@ -67,11 +67,25 @@ class WorkflowRunner {
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;
}