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.
- {wup-0.2.33/wup.egg-info → wup-0.2.35}/PKG-INFO +6 -6
- {wup-0.2.33 → wup-0.2.35}/README.md +5 -5
- {wup-0.2.33 → wup-0.2.35}/pyproject.toml +1 -1
- {wup-0.2.33 → wup-0.2.35}/tests/test_testql_watcher.py +112 -0
- {wup-0.2.33 → wup-0.2.35}/tests/test_wup.py +44 -0
- {wup-0.2.33 → wup-0.2.35}/wup/__init__.py +1 -1
- {wup-0.2.33 → wup-0.2.35}/wup/config.py +31 -0
- {wup-0.2.33 → wup-0.2.35}/wup/core.py +18 -0
- {wup-0.2.33 → wup-0.2.35}/wup/models/config.py +18 -0
- wup-0.2.35/wup/planfile_reporter.py +203 -0
- {wup-0.2.33 → wup-0.2.35}/wup/testql_watcher.py +11 -0
- {wup-0.2.33 → wup-0.2.35/wup.egg-info}/PKG-INFO +6 -6
- {wup-0.2.33 → wup-0.2.35}/wup.egg-info/SOURCES.txt +1 -0
- {wup-0.2.33 → wup-0.2.35}/LICENSE +0 -0
- {wup-0.2.33 → wup-0.2.35}/setup.cfg +0 -0
- {wup-0.2.33 → wup-0.2.35}/tests/test_e2e.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/tests/test_monitoring_manifest.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/tests/test_testql_monitor.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/tests/test_web_client.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/_ast_detector.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/_hash_detector.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/_yaml_detector.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/anomaly_detector.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/anomaly_models.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/assistant.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/cli.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/dependency_mapper.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/models/__init__.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/monitoring_manifest.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/testql_discovery.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/testql_monitor.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/visual_diff.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup/web_client.py +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup.egg-info/dependency_links.txt +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup.egg-info/entry_points.txt +0 -0
- {wup-0.2.33 → wup-0.2.35}/wup.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
-
    
|
|
35
|
+
  
|
|
36
36
|
|
|
37
|
-
- 🤖 **LLM usage:** $2.
|
|
38
|
-
- 👤 **Human dev:** ~$
|
|
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
|
-
    
|
|
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
|
-
    
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $2.
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
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
|
-
    
|
|
17
17
|
|
|
18
18
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
19
19
|
|
|
@@ -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.
|
|
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.
|
|
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
|
-
    
|
|
35
|
+
  
|
|
36
36
|
|
|
37
|
-
- 🤖 **LLM usage:** $2.
|
|
38
|
-
- 👤 **Human dev:** ~$
|
|
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
|
-
    
|
|
45
45
|
|
|
46
46
|
**WUP (What's Up)** - Intelligent file watcher for regression testing in large projects.
|
|
47
47
|
|
|
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
|
|
File without changes
|
|
File without changes
|