Files
nextcloud-workflow-deck-aut…/lib/Service/LoopDetector.php
T
Patrick Niebeling 9e1a15bab0
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
Refuse workflows that move cards in a loop
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().
2026-10-01 13:47:32 +02:00

92 lines
2.8 KiB
PHP

<?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;
}
}