wup 0.2.33__tar.gz → 0.2.35__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.33/wup.egg-info → wup-0.2.35}/PKG-INFO +6 -6
  2. {wup-0.2.33 → wup-0.2.35}/README.md +5 -5
  3. {wup-0.2.33 → wup-0.2.35}/pyproject.toml +1 -1
  4. {wup-0.2.33 → wup-0.2.35}/tests/test_testql_watcher.py +112 -0
  5. {wup-0.2.33 → wup-0.2.35}/tests/test_wup.py +44 -0
  6. {wup-0.2.33 → wup-0.2.35}/wup/__init__.py +1 -1
  7. {wup-0.2.33 → wup-0.2.35}/wup/config.py +31 -0
  8. {wup-0.2.33 → wup-0.2.35}/wup/core.py +18 -0
  9. {wup-0.2.33 → wup-0.2.35}/wup/models/config.py +18 -0
  10. wup-0.2.35/wup/planfile_reporter.py +203 -0
  11. {wup-0.2.33 → wup-0.2.35}/wup/testql_watcher.py +11 -0
  12. {wup-0.2.33 → wup-0.2.35/wup.egg-info}/PKG-INFO +6 -6
  13. {wup-0.2.33 → wup-0.2.35}/wup.egg-info/SOURCES.txt +1 -0
  14. {wup-0.2.33 → wup-0.2.35}/LICENSE +0 -0
  15. {wup-0.2.33 → wup-0.2.35}/setup.cfg +0 -0
  16. {wup-0.2.33 → wup-0.2.35}/tests/test_e2e.py +0 -0
  17. {wup-0.2.33 → wup-0.2.35}/tests/test_monitoring_manifest.py +0 -0
  18. {wup-0.2.33 → wup-0.2.35}/tests/test_testql_monitor.py +0 -0
  19. {wup-0.2.33 → wup-0.2.35}/tests/test_web_client.py +0 -0
  20. {wup-0.2.33 → wup-0.2.35}/wup/_ast_detector.py +0 -0
  21. {wup-0.2.33 → wup-0.2.35}/wup/_hash_detector.py +0 -0
  22. {wup-0.2.33 → wup-0.2.35}/wup/_yaml_detector.py +0 -0
  23. {wup-0.2.33 → wup-0.2.35}/wup/anomaly_detector.py +0 -0
  24. {wup-0.2.33 → wup-0.2.35}/wup/anomaly_models.py +0 -0
  25. {wup-0.2.33 → wup-0.2.35}/wup/assistant.py +0 -0
  26. {wup-0.2.33 → wup-0.2.35}/wup/cli.py +0 -0
  27. {wup-0.2.33 → wup-0.2.35}/wup/dependency_mapper.py +0 -0
  28. {wup-0.2.33 → wup-0.2.35}/wup/models/__init__.py +0 -0
  29. {wup-0.2.33 → wup-0.2.35}/wup/monitoring_manifest.py +0 -0
  30. {wup-0.2.33 → wup-0.2.35}/wup/testql_discovery.py +0 -0
  31. {wup-0.2.33 → wup-0.2.35}/wup/testql_monitor.py +0 -0
  32. {wup-0.2.33 → wup-0.2.35}/wup/visual_diff.py +0 -0
  33. {wup-0.2.33 → wup-0.2.35}/wup/web_client.py +0 -0
  34. {wup-0.2.33 → wup-0.2.35}/wup.egg-info/dependency_links.txt +0 -0
  35. {wup-0.2.33 → wup-0.2.35}/wup.egg-info/entry_points.txt +0 -0
  36. {wup-0.2.33 → wup-0.2.35}/wup.egg-info/requires.txt +0 -0
  37. {wup-0.2.33 → wup-0.2.35}/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.33
3
+ Version: 0.2.35
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.33-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.56-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.35-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.71-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-19.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $2.5582 (43 commits)
38
- - 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $2.7060 (45 commits)
38
+ - 👤 **Human dev:** ~$1917 (19.2h @ $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.33-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.35-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.33-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.56-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.35-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.71-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-19.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $2.5582 (43 commits)
10
- - 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $2.7060 (45 commits)
10
+ - 👤 **Human dev:** ~$1917 (19.2h @ $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.33-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.35-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.33"
7
+ version = "0.2.35"
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)
@@ -20,6 +20,7 @@ from wup.models.config import (
20
20
  NotifyConfig,
21
21
  ServiceTestConfig,
22
22
  ProjectConfig,
23
+ PlanfileConfig,
23
24
  VisualDiffConfig,
24
25
  )
25
26
  from wup.testql_watcher import TestQLWatcher
@@ -1449,6 +1450,49 @@ visual_diff:
1449
1450
  assert vd.pages_from_endpoints is True
1450
1451
  assert vd.max_pages == 200
1451
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
+
1452
1496
  def test_load_dotenv_sets_env_var(self):
1453
1497
  """_load_dotenv should load .wup.env into os.environ."""
1454
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.33"
10
+ __version__ = "0.2.35"
11
11
  __author__ = "Tom Sapletta"
12
12
 
13
13
  from .config import load_config, save_config, get_default_config
@@ -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
 
@@ -24,6 +24,7 @@ from watchdog.observers.polling import PollingObserver
24
24
  from .config import load_config
25
25
  from .dependency_mapper import DependencyMapper
26
26
  from .models.config import WupConfig, ServiceConfig
27
+ from .planfile_reporter import PlanfileReporter
27
28
 
28
29
 
29
30
  class WupWatcher:
@@ -91,6 +92,11 @@ class WupWatcher:
91
92
  self.test_queue: deque = deque()
92
93
  self.last_test_times: Dict[str, float] = defaultdict(float)
93
94
  self.console = Console()
95
+ self.planfile_reporter = PlanfileReporter(
96
+ project_root=self.project_root,
97
+ config=self.config.planfile,
98
+ console=self.console,
99
+ )
94
100
 
95
101
  # Load or build dependency map
96
102
  if Path(deps_file).exists():
@@ -334,6 +340,12 @@ class WupWatcher:
334
340
  self.console.print(f"[green]✓ Quick test passed for {service}[/green]")
335
341
  else:
336
342
  self.console.print(f"[red]✗ Quick test failed for {service}[/red]")
343
+ self.planfile_reporter.report_failure(
344
+ service=service,
345
+ status="down",
346
+ stage="quick",
347
+ message="Quick HTTP smoke test failed",
348
+ )
337
349
 
338
350
  return passed
339
351
 
@@ -389,6 +401,12 @@ class WupWatcher:
389
401
  except Exception:
390
402
  pass
391
403
  self.console.print(f"[red]✗ Detail test found {results['failed']} regression(s)[/red]")
404
+ self.planfile_reporter.report_failure(
405
+ service=service,
406
+ status="down",
407
+ stage="detail",
408
+ message=json.dumps(results, ensure_ascii=False),
409
+ )
392
410
  else:
393
411
  self.console.print(f"[green]✓ Detail test passed for {service}[/green]")
394
412
 
@@ -112,6 +112,23 @@ class WebConfig:
112
112
  api_key: str = "" # optional bearer token
113
113
 
114
114
 
115
+ @dataclass
116
+ class PlanfileConfig:
117
+ """Configuration for creating planfile tickets from WUP failures."""
118
+ enabled: bool = False
119
+ command: str = "planfile"
120
+ sprint: str = "current"
121
+ priority: str = "normal"
122
+ source: str = "wup"
123
+ dedupe_file: str = ".wup/planfile-tickets.json"
124
+ labels: List[str] = field(default_factory=lambda: [
125
+ "koru",
126
+ "llm-ready",
127
+ "wup",
128
+ "auto-diag",
129
+ ])
130
+
131
+
115
132
  @dataclass
116
133
  class AnomalyDetectionConfig:
117
134
  """Configuration for fast anomaly detection without Playwright."""
@@ -144,4 +161,5 @@ class WupConfig:
144
161
  testql: TestQLConfig = field(default_factory=TestQLConfig)
145
162
  visual_diff: VisualDiffConfig = field(default_factory=VisualDiffConfig)
146
163
  web: WebConfig = field(default_factory=WebConfig)
164
+ planfile: PlanfileConfig = field(default_factory=PlanfileConfig)
147
165
  anomaly_detection: AnomalyDetectionConfig = field(default_factory=AnomalyDetectionConfig)
@@ -0,0 +1,203 @@
1
+ """Planfile ticket sink for WUP failure signals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Optional
12
+
13
+ import yaml
14
+ from rich.console import Console
15
+
16
+ from .models.config import PlanfileConfig
17
+
18
+
19
+ class PlanfileReporter:
20
+ """Create deduplicated planfile tickets for WUP-detected failures."""
21
+
22
+ def __init__(self, project_root: Path, config: PlanfileConfig, console: Optional[Console] = None):
23
+ self.project_root = Path(project_root)
24
+ self.config = config
25
+ self.console = console or Console()
26
+ self.dedupe_path = self.project_root / config.dedupe_file
27
+
28
+ @property
29
+ def enabled(self) -> bool:
30
+ return bool(self.config.enabled)
31
+
32
+ def report_failure(
33
+ self,
34
+ *,
35
+ service: str,
36
+ status: str,
37
+ stage: str,
38
+ message: str,
39
+ track_file: str = "",
40
+ ) -> Optional[str]:
41
+ """Create a ticket for a failure transition, returning its id when created."""
42
+ if not self.enabled:
43
+ return None
44
+
45
+ fingerprint = self._fingerprint(service=service, status=status, stage=stage, message=message)
46
+ dedupe = self._load_dedupe()
47
+ existing = dedupe.get(fingerprint)
48
+ if existing:
49
+ return existing.get("ticket_id")
50
+
51
+ name = self._ticket_name(service=service, stage=stage, status=status)
52
+ description = self._ticket_description(
53
+ service=service,
54
+ status=status,
55
+ stage=stage,
56
+ message=message,
57
+ track_file=track_file,
58
+ )
59
+ result = self._create_ticket(name=name, description=description, track_file=track_file)
60
+ if result is None:
61
+ return None
62
+
63
+ ticket_id, stdout = result
64
+ dedupe[fingerprint] = {
65
+ "ticket_id": ticket_id,
66
+ "service": service,
67
+ "status": status,
68
+ "stage": stage,
69
+ "track_file": track_file,
70
+ "stdout": stdout,
71
+ }
72
+ self._save_dedupe(dedupe)
73
+ self.console.print(f"[yellow]🧾 WUP created planfile ticket {ticket_id}: {name}[/yellow]")
74
+ return ticket_id
75
+
76
+ def clear_service_stage(self, *, service: str, stage: str) -> None:
77
+ """Allow a future recurrence to create a fresh ticket after recovery."""
78
+ if not self.enabled or not self.dedupe_path.exists():
79
+ return
80
+ dedupe = self._load_dedupe()
81
+ remaining = {
82
+ key: value for key, value in dedupe.items()
83
+ if value.get("service") != service or value.get("stage") != stage
84
+ }
85
+ if len(remaining) == len(dedupe):
86
+ return
87
+ self._save_dedupe(remaining)
88
+
89
+ def _create_ticket(self, *, name: str, description: str, track_file: str = "") -> Optional[tuple[str, str]]:
90
+ if not self._wait_for_planfile_store_ready():
91
+ return None
92
+
93
+ cmd = [
94
+ self.config.command,
95
+ "ticket",
96
+ "create",
97
+ name,
98
+ "--priority",
99
+ self.config.priority,
100
+ "--sprint",
101
+ self.config.sprint,
102
+ "--source",
103
+ self.config.source,
104
+ "--description",
105
+ description,
106
+ ]
107
+ for label in self.config.labels:
108
+ cmd.extend(["--label", label])
109
+ if track_file:
110
+ cmd.extend(["--files", track_file])
111
+
112
+ try:
113
+ result = subprocess.run(
114
+ cmd,
115
+ cwd=str(self.project_root),
116
+ capture_output=True,
117
+ text=True,
118
+ timeout=30,
119
+ )
120
+ except (OSError, subprocess.TimeoutExpired) as exc:
121
+ self.console.print(f"[yellow]planfile ticket creation skipped: {exc}[/yellow]")
122
+ return None
123
+
124
+ stdout = (result.stdout or "").strip()
125
+ stderr = (result.stderr or "").strip()
126
+ if result.returncode != 0:
127
+ detail = stderr or stdout or f"rc={result.returncode}"
128
+ self.console.print(f"[yellow]planfile ticket creation failed: {detail}[/yellow]")
129
+ return None
130
+
131
+ if not self._wait_for_planfile_store_ready(timeout_s=10.0):
132
+ self.console.print("[yellow]planfile ticket created, but sprint YAML did not become readable[/yellow]")
133
+ return None
134
+
135
+ ticket_id = self._parse_ticket_id(stdout) or self._parse_ticket_id(stderr) or "unknown"
136
+ return ticket_id, stdout
137
+
138
+ def _wait_for_planfile_store_ready(self, timeout_s: float = 30.0) -> bool:
139
+ """Wait until the current sprint YAML is readable and not mid-write."""
140
+ sprint_path = self.project_root / ".planfile" / "sprints" / f"{self.config.sprint}.yaml"
141
+ if not sprint_path.exists():
142
+ return True
143
+
144
+ deadline = time.time() + timeout_s
145
+ last_signature: tuple[int, int] | None = None
146
+ while time.time() < deadline:
147
+ try:
148
+ stat = sprint_path.stat()
149
+ signature = (stat.st_size, stat.st_mtime_ns)
150
+ yaml.safe_load(sprint_path.read_text(encoding="utf-8")) or {}
151
+ except (OSError, yaml.YAMLError):
152
+ time.sleep(0.25)
153
+ last_signature = None
154
+ continue
155
+
156
+ if signature == last_signature:
157
+ return True
158
+ last_signature = signature
159
+ time.sleep(0.25)
160
+
161
+ self.console.print(
162
+ f"[yellow]planfile ticket creation skipped: {sprint_path} is not stable/readable[/yellow]"
163
+ )
164
+ return False
165
+
166
+ def _load_dedupe(self) -> dict[str, dict[str, Any]]:
167
+ if not self.dedupe_path.exists():
168
+ return {}
169
+ try:
170
+ payload = json.loads(self.dedupe_path.read_text(encoding="utf-8"))
171
+ except (OSError, json.JSONDecodeError):
172
+ return {}
173
+ return payload if isinstance(payload, dict) else {}
174
+
175
+ def _save_dedupe(self, payload: dict[str, dict[str, Any]]) -> None:
176
+ self.dedupe_path.parent.mkdir(parents=True, exist_ok=True)
177
+ self.dedupe_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
178
+
179
+ @staticmethod
180
+ def _fingerprint(*, service: str, status: str, stage: str, message: str) -> str:
181
+ normalized_message = " ".join(message.split())[:500]
182
+ raw = "\0".join([service, status, stage, normalized_message])
183
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
184
+
185
+ @staticmethod
186
+ def _parse_ticket_id(text: str) -> Optional[str]:
187
+ match = re.search(r"\bPLF-\d+\b", text)
188
+ return match.group(0) if match else None
189
+
190
+ @staticmethod
191
+ def _ticket_name(*, service: str, stage: str, status: str) -> str:
192
+ return f"[AUTO-DIAG] wup-{service} {stage} {status}"
193
+
194
+ @staticmethod
195
+ def _ticket_description(*, service: str, status: str, stage: str, message: str, track_file: str) -> str:
196
+ track_line = f"\nTrack file: `{track_file}`" if track_file else ""
197
+ return (
198
+ f"WUP detected a `{status}` state for service `{service}` during `{stage}`.\n\n"
199
+ f"Failure summary:\n{message[:4000] or 'No diagnostic message was provided.'}"
200
+ f"{track_line}\n\n"
201
+ "Investigate and fix the failing probe, TestQL scenario, visual diff, or stale diagnostic gate. "
202
+ "After the fix, rerun the relevant WUP/TestQL check and mark this Planfile ticket done."
203
+ )
@@ -178,6 +178,17 @@ class TestQLWatcher(WupWatcher):
178
178
  with self.health_events_path.open("a", encoding="utf-8") as handle:
179
179
  handle.write(json.dumps(event) + "\n")
180
180
 
181
+ if status in {"down", "degraded"}:
182
+ self.planfile_reporter.report_failure(
183
+ service=service,
184
+ status=status,
185
+ stage=stage,
186
+ message=message,
187
+ track_file=track_file or "",
188
+ )
189
+ elif status == "up":
190
+ self.planfile_reporter.clear_service_stage(service=service, stage=stage)
191
+
181
192
  self.browser_notifier.notify(
182
193
  {
183
194
  "type": "wup_service_health_change",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wup
3
- Version: 0.2.33
3
+ Version: 0.2.35
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.33-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.56-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.35-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.71-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-19.2h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
36
36
 
37
- - 🤖 **LLM usage:** $2.5582 (43 commits)
38
- - 👤 **Human dev:** ~$1832 (18.3h @ $100/h, 30min dedup)
37
+ - 🤖 **LLM usage:** $2.7060 (45 commits)
38
+ - 👤 **Human dev:** ~$1917 (19.2h @ $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.33-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.35-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
 
@@ -19,6 +19,7 @@ wup/config.py
19
19
  wup/core.py
20
20
  wup/dependency_mapper.py
21
21
  wup/monitoring_manifest.py
22
+ wup/planfile_reporter.py
22
23
  wup/testql_discovery.py
23
24
  wup/testql_monitor.py
24
25
  wup/testql_watcher.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes