wup 0.2.32__tar.gz → 0.2.34__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {wup-0.2.32/wup.egg-info → wup-0.2.34}/PKG-INFO +6 -6
  2. {wup-0.2.32 → wup-0.2.34}/README.md +5 -5
  3. {wup-0.2.32 → wup-0.2.34}/pyproject.toml +1 -1
  4. {wup-0.2.32 → wup-0.2.34}/tests/test_testql_watcher.py +112 -0
  5. {wup-0.2.32 → wup-0.2.34}/tests/test_wup.py +90 -0
  6. {wup-0.2.32 → wup-0.2.34}/wup/__init__.py +1 -1
  7. {wup-0.2.32 → wup-0.2.34}/wup/cli.py +115 -53
  8. {wup-0.2.32 → wup-0.2.34}/wup/config.py +31 -0
  9. {wup-0.2.32 → wup-0.2.34}/wup/core.py +47 -14
  10. {wup-0.2.32 → wup-0.2.34}/wup/models/config.py +18 -0
  11. {wup-0.2.32 → wup-0.2.34}/wup/monitoring_manifest.py +93 -59
  12. wup-0.2.34/wup/planfile_reporter.py +166 -0
  13. {wup-0.2.32 → wup-0.2.34}/wup/testql_monitor.py +126 -67
  14. {wup-0.2.32 → wup-0.2.34}/wup/testql_watcher.py +49 -19
  15. {wup-0.2.32 → wup-0.2.34}/wup/visual_diff.py +71 -48
  16. {wup-0.2.32 → wup-0.2.34/wup.egg-info}/PKG-INFO +6 -6
  17. {wup-0.2.32 → wup-0.2.34}/wup.egg-info/SOURCES.txt +1 -0
  18. {wup-0.2.32 → wup-0.2.34}/LICENSE +0 -0
  19. {wup-0.2.32 → wup-0.2.34}/setup.cfg +0 -0
  20. {wup-0.2.32 → wup-0.2.34}/tests/test_e2e.py +0 -0
  21. {wup-0.2.32 → wup-0.2.34}/tests/test_monitoring_manifest.py +0 -0
  22. {wup-0.2.32 → wup-0.2.34}/tests/test_testql_monitor.py +0 -0
  23. {wup-0.2.32 → wup-0.2.34}/tests/test_web_client.py +0 -0
  24. {wup-0.2.32 → wup-0.2.34}/wup/_ast_detector.py +0 -0
  25. {wup-0.2.32 → wup-0.2.34}/wup/_hash_detector.py +0 -0
  26. {wup-0.2.32 → wup-0.2.34}/wup/_yaml_detector.py +0 -0
  27. {wup-0.2.32 → wup-0.2.34}/wup/anomaly_detector.py +0 -0
  28. {wup-0.2.32 → wup-0.2.34}/wup/anomaly_models.py +0 -0
  29. {wup-0.2.32 → wup-0.2.34}/wup/assistant.py +0 -0
  30. {wup-0.2.32 → wup-0.2.34}/wup/dependency_mapper.py +0 -0
  31. {wup-0.2.32 → wup-0.2.34}/wup/models/__init__.py +0 -0
  32. {wup-0.2.32 → wup-0.2.34}/wup/testql_discovery.py +0 -0
  33. {wup-0.2.32 → wup-0.2.34}/wup/web_client.py +0 -0
  34. {wup-0.2.32 → wup-0.2.34}/wup.egg-info/dependency_links.txt +0 -0
  35. {wup-0.2.32 → wup-0.2.34}/wup.egg-info/entry_points.txt +0 -0
  36. {wup-0.2.32 → wup-0.2.34}/wup.egg-info/requires.txt +0 -0
  37. {wup-0.2.32 → wup-0.2.34}/wup.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.32
3
+ Version: 0.2.34
4
4
  Summary: WUP (What's Up) - Intelligent file watcher for regression testing in large projects
5
5
  Author-email: Tom Sapletta <tom@sapletta.com>
6
6
  License-Expression: Apache-2.0
@@ -31,17 +31,17 @@ Dynamic: license-file
31
31
 
32
32
  ## AI Cost Tracking
33
33
 
34
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.32-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
34
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.34-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
35
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.68-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $2.5178 (42 commits)
38
- - 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $2.6840 (44 commits)
38
+ - 👤 **Human dev:** ~$1867 (18.7h @ $100/h, 30min dedup)
39
39
 
40
40
  Generated on 2026-05-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
41
41
 
42
42
  ---
43
43
 
44
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.32-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
44
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.34-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
45
 
46
46
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
47
47
 
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.32-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.52-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.3h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.34-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$2.68-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-18.7h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $2.5178 (42 commits)
10
- - 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $2.6840 (44 commits)
10
+ - 👤 **Human dev:** ~$1867 (18.7h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-05-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.32-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-wup-blue) ![Version](https://img.shields.io/badge/version-0.2.34-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  **WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wup"
7
- version = "0.2.32"
7
+ version = "0.2.34"
8
8
  description = "WUP (What's Up) - Intelligent file watcher for regression testing in large projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -4,9 +4,11 @@ import os
4
4
  import tempfile
5
5
  from pathlib import Path
6
6
  from subprocess import CompletedProcess
7
+ from unittest.mock import Mock
7
8
 
8
9
  from wup.testql_watcher import TestQLWatcher
9
10
  from wup.models.config import (
11
+ PlanfileConfig,
10
12
  ProjectConfig,
11
13
  ServiceConfig,
12
14
  TestStrategyConfig,
@@ -15,6 +17,7 @@ from wup.models.config import (
15
17
  WatchConfig,
16
18
  WupConfig,
17
19
  )
20
+ from wup.planfile_reporter import PlanfileReporter
18
21
 
19
22
 
20
23
  def test_process_changed_file_creates_track_on_failure():
@@ -215,6 +218,115 @@ def test_service_health_transitions_are_persisted():
215
218
  assert "up" in statuses
216
219
 
217
220
 
221
+ def test_planfile_reporter_creates_deduped_ticket(monkeypatch):
222
+ with tempfile.TemporaryDirectory() as tmpdir:
223
+ root = Path(tmpdir)
224
+ calls = []
225
+
226
+ def fake_run(cmd, **kwargs):
227
+ calls.append(cmd)
228
+ return CompletedProcess(cmd, returncode=0, stdout="Created PLF-999\n", stderr="")
229
+
230
+ monkeypatch.setattr("wup.planfile_reporter.subprocess.run", fake_run)
231
+ reporter = PlanfileReporter(
232
+ root,
233
+ PlanfileConfig(enabled=True, labels=["koru", "llm-ready", "wup"]),
234
+ )
235
+
236
+ first = reporter.report_failure(
237
+ service="frontend",
238
+ status="down",
239
+ stage="quick",
240
+ message="broken page",
241
+ track_file=".wup/tracks/one.json",
242
+ )
243
+ second = reporter.report_failure(
244
+ service="frontend",
245
+ status="down",
246
+ stage="quick",
247
+ message="broken page",
248
+ track_file=".wup/tracks/one.json",
249
+ )
250
+
251
+ assert first == "PLF-999"
252
+ assert second == "PLF-999"
253
+ assert len(calls) == 1
254
+ assert calls[0][:3] == ["planfile", "ticket", "create"]
255
+ assert "--label" in calls[0]
256
+ assert "llm-ready" in calls[0]
257
+ assert "--files" in calls[0]
258
+ assert ".wup/tracks/one.json" in calls[0]
259
+
260
+
261
+ def test_planfile_reporter_clears_dedupe_after_recovery(monkeypatch):
262
+ with tempfile.TemporaryDirectory() as tmpdir:
263
+ root = Path(tmpdir)
264
+ calls = []
265
+
266
+ def fake_run(cmd, **kwargs):
267
+ calls.append(cmd)
268
+ ticket_id = f"PLF-{999 + len(calls)}"
269
+ return CompletedProcess(cmd, returncode=0, stdout=f"Created {ticket_id}\n", stderr="")
270
+
271
+ monkeypatch.setattr("wup.planfile_reporter.subprocess.run", fake_run)
272
+ reporter = PlanfileReporter(root, PlanfileConfig(enabled=True))
273
+
274
+ first = reporter.report_failure(
275
+ service="frontend",
276
+ status="down",
277
+ stage="quick",
278
+ message="broken page",
279
+ )
280
+ reporter.clear_service_stage(service="frontend", stage="quick")
281
+ second = reporter.report_failure(
282
+ service="frontend",
283
+ status="down",
284
+ stage="quick",
285
+ message="broken page",
286
+ )
287
+
288
+ assert first == "PLF-1000"
289
+ assert second == "PLF-1001"
290
+ assert len(calls) == 2
291
+
292
+
293
+ def test_health_transition_creates_planfile_ticket(monkeypatch):
294
+ with tempfile.TemporaryDirectory() as tmpdir:
295
+ root = Path(tmpdir)
296
+ cfg = WupConfig(
297
+ project=ProjectConfig(name="demo"),
298
+ services=[ServiceConfig(name="frontend", paths=["frontend/**"])],
299
+ watch=WatchConfig(),
300
+ testql=TestQLConfig(),
301
+ planfile=PlanfileConfig(enabled=True),
302
+ )
303
+ watcher = TestQLWatcher(
304
+ project_root=str(root),
305
+ deps_file=str(root / "deps.json"),
306
+ scenarios_dir="testql-scenarios",
307
+ track_dir=".wup/tracks",
308
+ config=cfg,
309
+ )
310
+ report_failure = Mock(return_value="PLF-100")
311
+ watcher.planfile_reporter.report_failure = report_failure
312
+
313
+ watcher._record_health_transition(
314
+ service="frontend",
315
+ status="down",
316
+ stage="visual",
317
+ message="error_container_detected:.error-container",
318
+ track_file=".wup/visual-diffs/frontend.jsonl",
319
+ )
320
+
321
+ report_failure.assert_called_once_with(
322
+ service="frontend",
323
+ status="down",
324
+ stage="visual",
325
+ message="error_container_detected:.error-container",
326
+ track_file=".wup/visual-diffs/frontend.jsonl",
327
+ )
328
+
329
+
218
330
  def test_normalize_fleet_health_entry_down_to_degraded():
219
331
  with tempfile.TemporaryDirectory() as tmpdir:
220
332
  root = Path(tmpdir)
@@ -1,7 +1,9 @@
1
1
  """Tests for WUP (What's Up) - Intelligent file watcher for regression testing."""
2
2
 
3
+ import errno
3
4
  import json
4
5
  import tempfile
6
+ from unittest.mock import MagicMock, patch
5
7
  from pathlib import Path
6
8
 
7
9
  import pytest
@@ -18,6 +20,7 @@ from wup.models.config import (
18
20
  NotifyConfig,
19
21
  ServiceTestConfig,
20
22
  ProjectConfig,
23
+ PlanfileConfig,
21
24
  VisualDiffConfig,
22
25
  )
23
26
  from wup.testql_watcher import TestQLWatcher
@@ -609,6 +612,50 @@ class TestWupWatcher:
609
612
 
610
613
  # No filtering should occur
611
614
 
615
+ def test_create_and_start_observer_fallback_on_enospc(self):
616
+ """Fallback to PollingObserver when inotify watch limit is reached."""
617
+ with tempfile.TemporaryDirectory() as tmpdir:
618
+ watcher = WupWatcher(tmpdir)
619
+ event_handler = MagicMock()
620
+ fake_observer = MagicMock()
621
+ fake_observer.start.side_effect = OSError(errno.ENOSPC, "inotify watch limit reached")
622
+
623
+ with patch("wup.core.Observer", return_value=fake_observer):
624
+ with patch("wup.core.PollingObserver") as mock_polling:
625
+ fake_polling = MagicMock()
626
+ mock_polling.return_value = fake_polling
627
+ result = watcher._create_and_start_observer(event_handler, [tmpdir])
628
+ assert result is fake_polling
629
+ fake_polling.start.assert_called_once()
630
+
631
+ def test_create_and_start_observer_fallback_on_emfile(self):
632
+ """Fallback to PollingObserver when inotify instance limit is reached."""
633
+ with tempfile.TemporaryDirectory() as tmpdir:
634
+ watcher = WupWatcher(tmpdir)
635
+ event_handler = MagicMock()
636
+ fake_observer = MagicMock()
637
+ fake_observer.start.side_effect = OSError(errno.EMFILE, "inotify instance limit reached")
638
+
639
+ with patch("wup.core.Observer", return_value=fake_observer):
640
+ with patch("wup.core.PollingObserver") as mock_polling:
641
+ fake_polling = MagicMock()
642
+ mock_polling.return_value = fake_polling
643
+ result = watcher._create_and_start_observer(event_handler, [tmpdir])
644
+ assert result is fake_polling
645
+ fake_polling.start.assert_called_once()
646
+
647
+ def test_create_and_start_observer_reraises_other_oserror(self):
648
+ """Re-raise OSError that is not ENOSPC or EMFILE."""
649
+ with tempfile.TemporaryDirectory() as tmpdir:
650
+ watcher = WupWatcher(tmpdir)
651
+ event_handler = MagicMock()
652
+ fake_observer = MagicMock()
653
+ fake_observer.start.side_effect = OSError(errno.EACCES, "permission denied")
654
+
655
+ with patch("wup.core.Observer", return_value=fake_observer):
656
+ with pytest.raises(OSError, match="permission denied"):
657
+ watcher._create_and_start_observer(event_handler, [tmpdir])
658
+
612
659
 
613
660
  class TestIntegrationWorkflow:
614
661
  """Integration tests for complete workflows."""
@@ -1403,6 +1450,49 @@ visual_diff:
1403
1450
  assert vd.pages_from_endpoints is True
1404
1451
  assert vd.max_pages == 200
1405
1452
 
1453
+ def test_save_and_load_planfile_config(self):
1454
+ """Test that planfile section is correctly saved and reloaded."""
1455
+ with tempfile.TemporaryDirectory() as tmpdir:
1456
+ config = WupConfig(
1457
+ project=ProjectConfig(name="pf-test"),
1458
+ planfile=PlanfileConfig(
1459
+ enabled=True,
1460
+ command=".venv/bin/planfile",
1461
+ sprint="runtime",
1462
+ priority="high",
1463
+ source="wup-watch",
1464
+ dedupe_file=".wup/pf.json",
1465
+ labels=["koru", "llm-ready", "wup", "visual"],
1466
+ ),
1467
+ )
1468
+ config_path = Path(tmpdir) / "wup.yaml"
1469
+ save_config(config, config_path)
1470
+
1471
+ loaded = load_config(Path(tmpdir), config_path)
1472
+ pf = loaded.planfile
1473
+ assert pf.enabled is True
1474
+ assert pf.command == ".venv/bin/planfile"
1475
+ assert pf.sprint == "runtime"
1476
+ assert pf.priority == "high"
1477
+ assert pf.source == "wup-watch"
1478
+ assert pf.dedupe_file == ".wup/pf.json"
1479
+ assert pf.labels == ["koru", "llm-ready", "wup", "visual"]
1480
+
1481
+ def test_load_config_planfile_env_override(self, monkeypatch):
1482
+ """Env can enable planfile ticket creation without editing wup.yaml."""
1483
+ monkeypatch.setenv("WUP_PLANFILE_ENABLED", "true")
1484
+ with tempfile.TemporaryDirectory() as tmpdir:
1485
+ config_path = Path(tmpdir) / "wup.yaml"
1486
+ config_path.write_text(
1487
+ "project:\n"
1488
+ " name: x\n"
1489
+ "planfile:\n"
1490
+ " enabled: false\n",
1491
+ encoding="utf-8",
1492
+ )
1493
+ config = load_config(Path(tmpdir), config_path)
1494
+ assert config.planfile.enabled is True
1495
+
1406
1496
  def test_load_dotenv_sets_env_var(self):
1407
1497
  """_load_dotenv should load .wup.env into os.environ."""
1408
1498
  import os
@@ -7,7 +7,7 @@ WUP monitors file changes and runs intelligent regression tests using a 3-layer
7
7
  3. Detail Layer: Full tests with blame reports (only on failure)
8
8
  """
9
9
 
10
- __version__ = "0.2.32"
10
+ __version__ = "0.2.34"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -23,6 +23,100 @@ app = typer.Typer(
23
23
  console = Console()
24
24
 
25
25
 
26
+ def _load_watch_config(
27
+ project_path: Path,
28
+ config_path: Optional[Path],
29
+ probe_interval: Optional[int],
30
+ mode: str,
31
+ ) -> WupConfig:
32
+ """Load wup.yaml config and apply CLI probe_interval override."""
33
+ wup_config = load_config(project_path, config_path)
34
+ if probe_interval is not None:
35
+ wup_config.testql.probe_interval_s = int(probe_interval)
36
+ elif mode.lower() == "testql" and not wup_config.testql.probe_interval_s:
37
+ wup_config.testql.probe_interval_s = 60
38
+ return wup_config
39
+
40
+
41
+ def _print_watch_header(
42
+ wup_config: WupConfig,
43
+ cpu_throttle: float,
44
+ debounce: int,
45
+ cooldown: int,
46
+ config_path: Optional[Path],
47
+ ) -> None:
48
+ """Print watcher startup banner."""
49
+ console.print(f"[bold cyan]🚀 WUP Watcher[/bold cyan]")
50
+ console.print(f"[dim]Project: {wup_config.project.name}[/dim]")
51
+ console.print(f"[dim]Description: {wup_config.project.description}[/dim]")
52
+ console.print(f"[dim]CPU Throttle: {cpu_throttle * 100}%[/dim]")
53
+ console.print(f"[dim]Debounce: {debounce}s[/dim]")
54
+ console.print(f"[dim]Cooldown: {cooldown}s[/dim]")
55
+ if wup_config.testql.probe_interval_s:
56
+ console.print(f"[dim]Live probes: every {wup_config.testql.probe_interval_s}s[/dim]")
57
+ console.print(f"[dim]Config: {config_path or 'auto-detected'}[/dim]")
58
+ console.print()
59
+
60
+
61
+ def _refresh_monitoring_manifest(
62
+ project_path: Path,
63
+ wup_config: WupConfig,
64
+ cfg_path: Optional[Path],
65
+ ) -> None:
66
+ """Rebuild and patch monitoring manifest into wup.yaml when possible."""
67
+ if not cfg_path:
68
+ return
69
+ from .monitoring_manifest import build_monitoring_manifest, patch_wup_yaml_monitoring
70
+ try:
71
+ manifest = build_monitoring_manifest(project_path, wup_config)
72
+ patch_wup_yaml_monitoring(cfg_path, manifest)
73
+ console.print("[dim]Refreshed monitoring manifest in wup.yaml[/dim]")
74
+ except OSError as exc:
75
+ console.print(f"[yellow]Could not refresh monitoring manifest: {exc}[/yellow]")
76
+
77
+
78
+ def _create_watcher(
79
+ mode: str,
80
+ project_path: Path,
81
+ deps_file: str,
82
+ cpu_throttle: float,
83
+ debounce: int,
84
+ cooldown: int,
85
+ scenarios_dir: Optional[str],
86
+ testql_bin: str,
87
+ browser_service_url: Optional[str],
88
+ track_dir: str,
89
+ quick_limit: int,
90
+ config: WupConfig,
91
+ ) -> WupWatcher:
92
+ """Instantiate the correct watcher class for the chosen mode."""
93
+ if mode.lower() == "testql":
94
+ watcher = TestQLWatcher(
95
+ project_root=str(project_path),
96
+ deps_file=deps_file,
97
+ cpu_throttle=cpu_throttle,
98
+ debounce_seconds=debounce,
99
+ test_cooldown_seconds=cooldown,
100
+ scenarios_dir=scenarios_dir,
101
+ testql_bin=testql_bin,
102
+ browser_service_url=browser_service_url,
103
+ track_dir=track_dir,
104
+ quick_limit=quick_limit,
105
+ config=config,
106
+ )
107
+ console.print("[green]TestQL mode enabled[/green]")
108
+ return watcher
109
+
110
+ return WupWatcher(
111
+ project_root=str(project_path),
112
+ deps_file=deps_file,
113
+ cpu_throttle=cpu_throttle,
114
+ debounce_seconds=debounce,
115
+ test_cooldown_seconds=cooldown,
116
+ config=config,
117
+ )
118
+
119
+
26
120
  @app.command()
27
121
  def watch(
28
122
  project: str = typer.Argument(".", help="Path to the project root directory"),
@@ -60,67 +154,35 @@ def watch(
60
154
  ``--mode default`` for the legacy HTTP-only watcher without TestQL.
61
155
  """
62
156
  project_path = Path(project).resolve()
63
-
157
+
64
158
  if not project_path.exists():
65
159
  console.print(f"[red]Error: Project path '{project}' does not exist[/red]")
66
160
  raise typer.Exit(1)
67
-
68
- # Load configuration
69
- config_path = Path(config) if config else None
70
- wup_config = load_config(project_path, config_path)
71
- if probe_interval is not None:
72
- wup_config.testql.probe_interval_s = int(probe_interval)
73
- elif mode.lower() == "testql" and not wup_config.testql.probe_interval_s:
74
- wup_config.testql.probe_interval_s = 60
75
161
 
162
+ config_path = Path(config) if config else None
163
+ wup_config = _load_watch_config(project_path, config_path, probe_interval, mode)
76
164
  effective_scenarios_dir = scenarios_dir or wup_config.testql.scenario_dir
77
165
 
78
- console.print(f"[bold cyan]🚀 WUP Watcher[/bold cyan]")
79
- console.print(f"[dim]Project: {wup_config.project.name}[/dim]")
80
- console.print(f"[dim]Description: {wup_config.project.description}[/dim]")
81
- console.print(f"[dim]CPU Throttle: {cpu_throttle * 100}%[/dim]")
82
- console.print(f"[dim]Debounce: {debounce}s[/dim]")
83
- console.print(f"[dim]Cooldown: {cooldown}s[/dim]")
84
- if wup_config.testql.probe_interval_s:
85
- console.print(f"[dim]Live probes: every {wup_config.testql.probe_interval_s}s[/dim]")
86
- console.print(f"[dim]Config: {config_path or 'auto-detected'}[/dim]")
87
- console.print()
88
-
166
+ _print_watch_header(wup_config, cpu_throttle, debounce, cooldown, config_path)
167
+
89
168
  cfg_path = config_path if config_path and config_path.exists() else find_config_file(project_path)
90
- if cfg_path:
91
- from .monitoring_manifest import build_monitoring_manifest, patch_wup_yaml_monitoring
92
- try:
93
- manifest = build_monitoring_manifest(project_path, wup_config)
94
- patch_wup_yaml_monitoring(cfg_path, manifest)
95
- console.print("[dim]Refreshed monitoring manifest in wup.yaml[/dim]")
96
- except OSError as exc:
97
- console.print(f"[yellow]Could not refresh monitoring manifest: {exc}[/yellow]")
169
+ _refresh_monitoring_manifest(project_path, wup_config, cfg_path)
170
+
171
+ watcher = _create_watcher(
172
+ mode=mode,
173
+ project_path=project_path,
174
+ deps_file=deps_file,
175
+ cpu_throttle=cpu_throttle,
176
+ debounce=debounce,
177
+ cooldown=cooldown,
178
+ scenarios_dir=effective_scenarios_dir,
179
+ testql_bin=testql_bin,
180
+ browser_service_url=browser_service_url,
181
+ track_dir=track_dir,
182
+ quick_limit=quick_limit,
183
+ config=wup_config,
184
+ )
98
185
 
99
- if mode.lower() == "testql":
100
- watcher = TestQLWatcher(
101
- project_root=str(project_path),
102
- deps_file=deps_file,
103
- cpu_throttle=cpu_throttle,
104
- debounce_seconds=debounce,
105
- test_cooldown_seconds=cooldown,
106
- scenarios_dir=effective_scenarios_dir,
107
- testql_bin=testql_bin,
108
- browser_service_url=browser_service_url,
109
- track_dir=track_dir,
110
- quick_limit=quick_limit,
111
- config=wup_config,
112
- )
113
- console.print("[green]TestQL mode enabled[/green]")
114
- else:
115
- watcher = WupWatcher(
116
- project_root=str(project_path),
117
- deps_file=deps_file,
118
- cpu_throttle=cpu_throttle,
119
- debounce_seconds=debounce,
120
- test_cooldown_seconds=cooldown,
121
- config=wup_config,
122
- )
123
-
124
186
  if dashboard:
125
187
  console.print("[green]Starting watcher with live dashboard...[/green]")
126
188
  asyncio.run(watcher.run_with_dashboard())
@@ -18,6 +18,7 @@ from .models.config import (
18
18
  TestQLConfig,
19
19
  ProjectConfig,
20
20
  NotifyConfig,
21
+ PlanfileConfig,
21
22
  ServiceTestConfig,
22
23
  VisualDiffConfig,
23
24
  WebConfig,
@@ -258,6 +259,26 @@ def validate_config(raw: dict) -> WupConfig:
258
259
  api_key=web_raw.get("api_key", ""),
259
260
  )
260
261
 
262
+ # Parse planfile config (ticket sink for Koru/Planfile workflows)
263
+ planfile_raw = raw.get("planfile", {})
264
+ env_planfile_enabled = os.environ.get("WUP_PLANFILE_ENABLED")
265
+ if env_planfile_enabled is None:
266
+ planfile_enabled = bool(planfile_raw.get("enabled", False))
267
+ else:
268
+ planfile_enabled = env_planfile_enabled.strip().lower() in {"1", "true", "yes", "on"}
269
+
270
+ labels_raw = planfile_raw.get("labels", ["koru", "llm-ready", "wup", "auto-diag"])
271
+ labels = [str(label) for label in labels_raw] if isinstance(labels_raw, list) else []
272
+ planfile = PlanfileConfig(
273
+ enabled=planfile_enabled,
274
+ command=planfile_raw.get("command", "planfile"),
275
+ sprint=planfile_raw.get("sprint", "current"),
276
+ priority=planfile_raw.get("priority", "normal"),
277
+ source=planfile_raw.get("source", "wup"),
278
+ dedupe_file=planfile_raw.get("dedupe_file", ".wup/planfile-tickets.json"),
279
+ labels=labels or ["koru", "llm-ready", "wup", "auto-diag"],
280
+ )
281
+
261
282
  return WupConfig(
262
283
  project=project,
263
284
  watch=watch,
@@ -266,6 +287,7 @@ def validate_config(raw: dict) -> WupConfig:
266
287
  testql=testql,
267
288
  visual_diff=visual_diff,
268
289
  web=web,
290
+ planfile=planfile,
269
291
  )
270
292
 
271
293
 
@@ -385,6 +407,15 @@ def save_config(config: WupConfig, output_path: Path):
385
407
  "endpoint_env": config.web.endpoint_env,
386
408
  "timeout_s": config.web.timeout_s,
387
409
  "api_key": config.web.api_key,
410
+ },
411
+ "planfile": {
412
+ "enabled": config.planfile.enabled,
413
+ "command": config.planfile.command,
414
+ "sprint": config.planfile.sprint,
415
+ "priority": config.planfile.priority,
416
+ "source": config.planfile.source,
417
+ "dedupe_file": config.planfile.dedupe_file,
418
+ "labels": config.planfile.labels,
388
419
  }
389
420
  }
390
421