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
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:
1 parent
8268f1068d
commit
9e1a15bab0
8 files changed
+251
-5
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user