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
@@ -25,7 +25,7 @@ Alle Lesezugriffe (Boards, Stacks, Karten) und das Verschieben laufen über Deck
|
||||
|
||||
**Regel:** Bevor ein weiterer `OCA\Deck\*`-Dienst im Runner genutzt wird, prüfen, ob er eine eingefrorene `$userId` trägt – der Fehler ist still und nutzerübergreifend. Bevorzugt Mapper (QBMapper ohne Nutzerzustand) oder Methoden mit explizitem uid-Parameter verwenden. Deshalb wird `BoardService::getUserBoards()` nicht verwendet.
|
||||
|
||||
Der Datumsvergleich `WorkflowRunner::hasPassed()`, `TriggerDate` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten.
|
||||
Der Datumsvergleich `WorkflowRunner::hasPassed()`, `TriggerDate`, `LoopDetector` und die Empfängerregel `NotificationPolicy::shouldNotify()` sind bewusst `static` und seiteneffektfrei, also ohne Deck unit-testbar (`tests/Unit/Service/`). Das sind die einzigen getesteten Teile der App. Neue reine Logik in dieser Form halten.
|
||||
|
||||
## Workflows werden deaktiviert, wenn Board/Stack verschwinden
|
||||
|
||||
@@ -79,6 +79,14 @@ Die App wird nur intern genutzt und spricht ausschließlich Deutsch. Oberfläche
|
||||
- **Texte:** `TriggerDate::describeReason()` liefert den Satzteil („ist überfällig“ / „hat ihr Startdatum erreicht“) für Mail und Notifier. `NotificationPusher` speichert `triggerDate` in den Subject-Parametern. Ältere Benachrichtigungen ohne den Schlüssel gelten als `due`.
|
||||
- **Empfänger:** Auch Startdatums-Workflows nutzen bei `notify_mode = board` Decks `notify-due`-Einstellung. Deck hat keine eigene für das Startdatum.
|
||||
|
||||
## Keine Schleifen, kein Quelle = Ziel
|
||||
|
||||
Jeder aktive Workflow ist eine Kante „Quell-Stack → Ziel-Stack“. `LoopDetector` (rein, getestet) findet Kreise. „Quelle = Ziel“ ist ein Kreis der Länge 1. Das Auslöse-Datum wird bewusst ignoriert: Bei S1→S2 per Start und S2→S1 per Fälligkeit pendelt jede Karte, bei der beide Daten erreicht sind. Ohne Prüfung würde der Runner sie sogar im selben Lauf hin- und herschieben (jeder Workflow liest seinen Quell-Stack neu), mit einer Benachrichtigung pro Bewegung. Ketten sind erlaubt.
|
||||
|
||||
- **Controller:** `validate()` → `assertNoLoop()` lehnt einen *aktiven* Workflow ab, dessen Ziel über die anderen aktiven Workflows des Boards zu seiner Quelle zurückführt, und nennt sie. Deaktivierte Workflows zählen nicht. Beim Aktivieren läuft dieselbe Prüfung, weil auch das über `update()` geht.
|
||||
- **Runner und Liste:** Altbestand kann trotzdem Kreise bilden. `run()` überspringt alle Workflows auf einem Kreis (nur Log, keine Deaktivierung, keine Mail), und `index()` liefert `loop: true`, das die Oberfläche rot markiert.
|
||||
- Stack-IDs sind in Deck boardübergreifend eindeutig, deshalb darf der Runner alle Workflows der Instanz in einen Graphen werfen.
|
||||
|
||||
## Fälligkeit wird als UTC-Zeitpunkt verglichen
|
||||
|
||||
Decks `duedate` und `startdate` sind `DATETIME`-Spalten ohne Zeitzone mit UTC-Uhrzeit (Frontend sendet `toISOString()`). `Entity` baut daraus `new \DateTime($string)` in PHPs Standardzone, die Nextcloud auf UTC setzt. `WorkflowRunner::asStoredUtc()` legt die Uhrzeit trotzdem ausdrücklich auf UTC fest, damit das Ergebnis nicht von dieser Standardzone abhängt. Decks eigene Pfade `Card::getDaysUntilDue()` (tagesgenau) und `CardMapper::findOverdue()` (vergleicht mit dem `NOW()` der Datenbank, also Ortszeit) sind dafür ungeeignet und werden nicht verwendet.
|
||||
|
||||
@@ -57,6 +57,7 @@ composer run test:unit # PHPUnit
|
||||
Die Unit-Tests decken nur reine Logik ab, ohne Deck-Installation:
|
||||
- Datumsvergleich für Fälligkeits- und Startdatum (`WorkflowRunner::hasPassed()`)
|
||||
- Auslöser und Begründungstext (`TriggerDate`)
|
||||
- Schleifenerkennung (`LoopDetector`)
|
||||
- Empfängerregel (`NotificationPolicy::shouldNotify()`)
|
||||
- Benachrichtigungsweg (`NotificationPolicy::sendsMail()`/`sendsNotification()`)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ Die Einstellungen liegen unter **Persönliche Einstellungen → Deck Workflow-Au
|
||||
- **Board:** Zur Auswahl stehen nur Boards, deren **Eigentümer** du bist. Geteilte, archivierte und gelöschte Boards erscheinen nicht.
|
||||
- **Quell- und Ziel-Stapel:** Das müssen zwei verschiedene Stapel desselben Boards sein.
|
||||
- **Karte verschieben, wenn:** das Fälligkeitsdatum überschritten oder das Startdatum erreicht ist.
|
||||
- **Keine Schleifen:** Ein aktiver Workflow darf mit den anderen aktiven Workflows des Boards keinen Kreis bilden, etwa „A → B“ und „B → A“. Sonst würden Karten bei jedem Durchlauf hin- und hergeschoben. Beim Speichern wird das abgelehnt, mit den Namen der beteiligten Workflows. Das gilt unabhängig davon, ob die Workflows nach Fälligkeits- oder Startdatum verschieben. Ketten wie „A → B“ und „B → C“ sind erlaubt.
|
||||
- **Benachrichtigung bei verschobenen Karten** und **Benachrichtigen per:** siehe unten.
|
||||
- **Workflow aktiv:** Deaktivierte Workflows bleiben gespeichert, verschieben aber nichts.
|
||||
|
||||
@@ -100,6 +101,7 @@ Deck schickt zusätzlich seine eigene Erinnerung, wenn eine Karte fällig wird.
|
||||
|
||||
- **In der Übersicht** wird der Workflow rot markiert, zum Beispiel „Board nicht mehr vorhanden“ oder „Quell-Stapel nicht mehr vorhanden“.
|
||||
- **Beim nächsten Durchlauf** wird der Workflow automatisch deaktiviert. Der Eigentümer bekommt einmalig eine E-Mail und, wenn die App *Benachrichtigungen* aktiv ist, eine Nextcloud-Benachrichtigung. Das gilt unabhängig davon, was für verschobene Karten eingestellt ist. Danach kommt keine weitere Erinnerung.
|
||||
- **Schleifen aus älteren Workflows** (gespeichert vor der Schleifenprüfung) werden in der Übersicht rot markiert und beim Durchlauf übersprungen, bis du einen der Workflows änderst oder deaktivierst.
|
||||
- **Archivierte Boards** deaktivieren den Workflow nicht. Sie lassen sich nur nicht mehr neu auswählen.
|
||||
- **Ist Deck vorübergehend nicht erreichbar**, passiert nichts: Der Workflow wird übersprungen und beim nächsten Durchlauf erneut versucht.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use OCA\WorkflowDeckAutomation\Db\Workflow;
|
||||
use OCA\WorkflowDeckAutomation\Db\WorkflowMapper;
|
||||
use OCA\WorkflowDeckAutomation\Service\DeckIntegrationService;
|
||||
use OCA\WorkflowDeckAutomation\Service\DeckUnavailableException;
|
||||
use OCA\WorkflowDeckAutomation\Service\LoopDetector;
|
||||
use OCA\WorkflowDeckAutomation\Service\NotificationPolicy;
|
||||
use OCA\WorkflowDeckAutomation\Service\TriggerDate;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
@@ -48,7 +49,17 @@ class WorkflowController extends OCSController {
|
||||
#[NoAdminRequired]
|
||||
public function index(): DataResponse {
|
||||
$workflows = $this->workflowMapper->findAllForBoards($this->ownedBoardIds());
|
||||
return new DataResponse(array_map(static fn (Workflow $w) => $w->jsonSerialize(), $workflows));
|
||||
|
||||
// validate() keeps new loops out, but workflows stored before that
|
||||
// check can still form one; the runner skips them, the list says why.
|
||||
$looping = LoopDetector::findLoopingWorkflows(LoopDetector::edgesOf(
|
||||
array_filter($workflows, static fn (Workflow $w) => (bool)$w->getEnabled()),
|
||||
));
|
||||
|
||||
return new DataResponse(array_map(
|
||||
static fn (Workflow $w) => $w->jsonSerialize() + ['loop' => in_array((int)$w->getId(), $looping, true)],
|
||||
$workflows,
|
||||
));
|
||||
}
|
||||
|
||||
#[NoAdminRequired]
|
||||
@@ -62,7 +73,7 @@ class WorkflowController extends OCSController {
|
||||
string $notifyChannel = NotificationPolicy::CHANNEL_MAIL,
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel);
|
||||
$this->validate(null, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
|
||||
|
||||
$workflow = new Workflow();
|
||||
$workflow->setUserId($this->currentUserId());
|
||||
@@ -85,7 +96,7 @@ class WorkflowController extends OCSController {
|
||||
bool $enabled = true,
|
||||
): DataResponse {
|
||||
$workflow = $this->findOwnWorkflow($id);
|
||||
$this->validate($title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel);
|
||||
$this->validate($id, $title, $boardId, $sourceStackId, $targetStackId, $triggerDate, $notifyMode, $notifyChannel, $enabled);
|
||||
|
||||
// Kept in step with the board owner so the job runs as, and mails,
|
||||
// the right user; validate() made sure the editor owns the board.
|
||||
@@ -161,7 +172,10 @@ class WorkflowController extends OCSController {
|
||||
}
|
||||
}
|
||||
|
||||
private function validate(string $title, int $boardId, int $sourceStackId, int $targetStackId, string $triggerDate, string $notifyMode, string $notifyChannel): void {
|
||||
/**
|
||||
* @param ?int $id the workflow being changed, `null` for a new one
|
||||
*/
|
||||
private function validate(?int $id, string $title, int $boardId, int $sourceStackId, int $targetStackId, string $triggerDate, string $notifyMode, string $notifyChannel, bool $enabled): void {
|
||||
if (trim($title) === '') {
|
||||
throw new OCSBadRequestException('Bitte einen Titel angeben.');
|
||||
}
|
||||
@@ -208,6 +222,37 @@ class WorkflowController extends OCSController {
|
||||
if (!in_array($sourceStackId, $stackIds, true) || !in_array($targetStackId, $stackIds, true)) {
|
||||
throw new OCSBadRequestException('Der gewählte Stapel existiert auf diesem Board nicht.');
|
||||
}
|
||||
|
||||
// A disabled workflow moves nothing and can close no loop; it is
|
||||
// checked again when it gets switched on, which also goes through here.
|
||||
if ($enabled) {
|
||||
$this->assertNoLoop($id, $boardId, $sourceStackId, $targetStackId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a workflow whose target stack leads back to its source through
|
||||
* the board's other enabled workflows — cards would circle forever. See
|
||||
* LoopDetector.
|
||||
*/
|
||||
private function assertNoLoop(?int $id, int $boardId, int $sourceStackId, int $targetStackId): void {
|
||||
$others = [];
|
||||
foreach ($this->workflowMapper->findAllForBoards([$boardId]) as $other) {
|
||||
if ($other->getId() !== $id && $other->getEnabled()) {
|
||||
$others[(int)$other->getId()] = $other;
|
||||
}
|
||||
}
|
||||
|
||||
$path = LoopDetector::findPath(LoopDetector::edgesOf($others), $targetStackId, $sourceStackId);
|
||||
if ($path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$titles = array_map(static fn (int $otherId) => '„' . $others[$otherId]->getTitle() . '“', $path);
|
||||
throw new OCSBadRequestException(sprintf(
|
||||
'Zusammen mit %s würde dieser Workflow eine Schleife bilden, in der Karten immer wieder hin- und hergeschoben werden. Wähle einen anderen Ziel-Stapel oder deaktiviere einen der Workflows.',
|
||||
implode(', ', $titles),
|
||||
));
|
||||
}
|
||||
|
||||
private function applyFields(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -278,8 +278,17 @@ const workflowIssues = computed(() => {
|
||||
}
|
||||
if (missing.length) {
|
||||
issues[workflow.id] = `${missing.join(' und ')} nicht mehr vorhanden`
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Set by the server for workflows stored before loops were refused;
|
||||
// the background job skips them.
|
||||
if (workflow.loop) {
|
||||
issues[workflow.id] = workflow.sourceStackId === workflow.targetStackId
|
||||
? 'Quell- und Ziel-Stapel sind gleich, wird übersprungen'
|
||||
: 'Bildet mit anderen Workflows eine Schleife, wird übersprungen'
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\WorkflowDeckAutomation\Tests\Unit\Service;
|
||||
|
||||
use OCA\WorkflowDeckAutomation\Service\LoopDetector;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class LoopDetectorTest extends TestCase {
|
||||
public function testNoWorkflowsHaveNoLoop(): void {
|
||||
$this->assertSame([], LoopDetector::findLoopingWorkflows([]));
|
||||
}
|
||||
|
||||
public function testSameSourceAndTargetIsALoop(): void {
|
||||
$this->assertSame([1], LoopDetector::findLoopingWorkflows([1 => [10, 10]]));
|
||||
}
|
||||
|
||||
public function testTwoWorkflowsBackAndForthAreALoop(): void {
|
||||
$this->assertSame([1, 2], LoopDetector::findLoopingWorkflows([
|
||||
1 => [10, 20],
|
||||
2 => [20, 10],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testLongerCircleIsALoop(): void {
|
||||
$this->assertSame([1, 2, 3], LoopDetector::findLoopingWorkflows([
|
||||
1 => [10, 20],
|
||||
2 => [20, 30],
|
||||
3 => [30, 10],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testChainIsNoLoop(): void {
|
||||
$this->assertSame([], LoopDetector::findLoopingWorkflows([
|
||||
1 => [10, 20],
|
||||
2 => [20, 30],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testOnlyWorkflowsOnTheCircleAreReported(): void {
|
||||
// 1 feeds into the circle 2 → 3 → 2 and 4 leads out of it; neither
|
||||
// lies on it.
|
||||
$this->assertSame([2, 3], LoopDetector::findLoopingWorkflows([
|
||||
1 => [10, 20],
|
||||
2 => [20, 30],
|
||||
3 => [30, 20],
|
||||
4 => [30, 40],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testTwoWorkflowsFromTheSameSourceAreNoLoop(): void {
|
||||
$this->assertSame([], LoopDetector::findLoopingWorkflows([
|
||||
1 => [10, 20],
|
||||
2 => [10, 30],
|
||||
]));
|
||||
}
|
||||
|
||||
public function testFindPathListsTheWorkflowsInOrder(): void {
|
||||
$edges = [
|
||||
7 => [30, 10],
|
||||
5 => [10, 20],
|
||||
6 => [20, 30],
|
||||
];
|
||||
$this->assertSame([5, 6], LoopDetector::findPath($edges, 10, 30));
|
||||
$this->assertSame([6, 7], LoopDetector::findPath($edges, 20, 10));
|
||||
}
|
||||
|
||||
public function testFindPathWithoutConnectionIsNull(): void {
|
||||
$this->assertNull(LoopDetector::findPath([1 => [10, 20]], 20, 10));
|
||||
}
|
||||
|
||||
public function testFindPathToItselfIsEmpty(): void {
|
||||
$this->assertSame([], LoopDetector::findPath([], 10, 10));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user