From 5e7fdff36fb4c1ffa6a50cc9c51e63595639ad2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 17:04:11 +0000 Subject: [PATCH 1/2] Add Slack webhook reporting via --slack flag Agent-Logs-Url: https://github.com/FertigLab/ontrack/sessions/73331a2c-fb89-4105-842b-f20a8ce48e68 Co-authored-by: dimalvovs <1246862+dimalvovs@users.noreply.github.com> --- ontrack.py | 149 +++++++++++++++++++++++++++------------- tests/test_ontrack.py | 153 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 45 deletions(-) diff --git a/ontrack.py b/ontrack.py index 49e9f49..a1f9155 100644 --- a/ontrack.py +++ b/ontrack.py @@ -44,15 +44,20 @@ """ import argparse +import contextlib import fnmatch import functools import grp +import io +import json import logging import os import pathlib import pwd import subprocess import sys +import urllib.error +import urllib.request import yaml from tqdm import tqdm @@ -60,6 +65,7 @@ logger = logging.getLogger(__name__) _CONFIG_ENV_VAR = "ONTRACK_CONFIG" +_SLACK_WEBHOOK_ENV_VAR = "ONTRACK_SLACK_WEBHOOK" @functools.lru_cache(maxsize=None) @@ -693,6 +699,27 @@ def load_config(config_path: str) -> dict: return yaml.safe_load(fh) +def send_slack_message(text: str, webhook_url: str) -> None: + """Send *text* to a Slack incoming webhook URL. + + Args: + text: Message body to send. + webhook_url: Slack incoming webhook URL. + """ + payload = {"text": f"```\n{text}\n```"} + request = urllib.request.Request( + webhook_url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request): + pass + except (urllib.error.URLError, OSError) as exc: + logger.warning("Could not send Slack message: %s", exc) + + def _is_ignored(name: str, patterns: list[str]) -> bool: """Return ``True`` if *name* matches any of the given shell-style patterns. @@ -717,6 +744,7 @@ def main( output: str | None = None, report: bool = False, find: str | None = None, + slack: bool = False, ) -> None: """Run ontrack with the given options. @@ -733,6 +761,8 @@ def main( file; otherwise they are printed to stdout. find: Optional exact-match filter. Only entries containing at least one output field whose value exactly matches this string are kept. + slack: When ``True``, send the human-readable stdout output to Slack + via an incoming webhook URL. """ config = load_config(config_path) paths: list[str] = config.get("paths", []) @@ -783,54 +813,72 @@ def main( else paths_to_process ) - if report: - entries = [] - for path in iterator: - entry = _build_directory_entry( - path, - groups=groups, - light=True, - show_progress=progress, - ignore_patterns=ignore_patterns, - valid_tracks=valid_tracks, - ) - if entry is not None and _entry_matches_find(entry, find): - entries.append(entry) - report_data = compute_report(entries) - if output is not None: + def _run_output() -> None: + if report: + entries = [] + for path in iterator: + entry = _build_directory_entry( + path, + groups=groups, + light=True, + show_progress=progress, + ignore_patterns=ignore_patterns, + valid_tracks=valid_tracks, + ) + if entry is not None and _entry_matches_find(entry, find): + entries.append(entry) + report_data = compute_report(entries) + if output is not None: + with open(output, "w") as fh: + yaml.dump(report_data, fh, default_flow_style=False, allow_unicode=True) + logger.info("Report written to %s", output) + else: + print_report(report_data) + elif output is not None: + results = [] + for path in iterator: + entry = _build_directory_entry( + path, + groups=groups, + light=light, + show_progress=progress, + ignore_patterns=ignore_patterns, + valid_tracks=valid_tracks, + ) + if entry is not None and _entry_matches_find(entry, find): + results.append(entry) with open(output, "w") as fh: - yaml.dump(report_data, fh, default_flow_style=False, allow_unicode=True) + yaml.dump(results, fh, default_flow_style=False, allow_unicode=True) logger.info("Report written to %s", output) else: - print_report(report_data) - elif output is not None: - results = [] - for path in iterator: - entry = _build_directory_entry( - path, - groups=groups, - light=light, - show_progress=progress, - ignore_patterns=ignore_patterns, - valid_tracks=valid_tracks, - ) - if entry is not None and _entry_matches_find(entry, find): - results.append(entry) - with open(output, "w") as fh: - yaml.dump(results, fh, default_flow_style=False, allow_unicode=True) - logger.info("Report written to %s", output) - else: - for path in iterator: - entry = _build_directory_entry( - path, - groups=groups, - light=light, - show_progress=progress, - ignore_patterns=ignore_patterns, - valid_tracks=valid_tracks, - ) - if entry is not None and _entry_matches_find(entry, find): - _print_directory_entry(entry) + for path in iterator: + entry = _build_directory_entry( + path, + groups=groups, + light=light, + show_progress=progress, + ignore_patterns=ignore_patterns, + valid_tracks=valid_tracks, + ) + if entry is not None and _entry_matches_find(entry, find): + _print_directory_entry(entry) + + if not slack: + _run_output() + return + + webhook_url = os.environ.get(_SLACK_WEBHOOK_ENV_VAR) or config.get("slack_webhook") + if not webhook_url: + print("WARNING: Slack webhook URL not configured – skipping Slack send.", file=sys.stderr) + _run_output() + return + + stdout_buffer = io.StringIO() + with contextlib.redirect_stdout(stdout_buffer): + _run_output() + stdout_text = stdout_buffer.getvalue() + print(stdout_text, end="") + send_slack_message(stdout_text, webhook_url) def _resolve_config_path(cli_config: str | None) -> str: @@ -878,6 +926,7 @@ def cli() -> None: " %(prog)s --config ontrack.config --output report.yaml\n" " %(prog)s --config ontrack.config --find alice\n" " %(prog)s --config ontrack.config --progress\n" + " %(prog)s --config ontrack.config --slack\n" ), ) parser.add_argument( @@ -937,6 +986,15 @@ def cli() -> None: "(e.g. username, track name, Yes/No on-track status)." ), ) + parser.add_argument( + "--slack", + action="store_true", + default=False, + help=( + "Send the report to Slack using the webhook URL from " + "ONTRACK_SLACK_WEBHOOK or 'slack_webhook' in the config file." + ), + ) args = parser.parse_args() if not sys.argv[1:]: parser.print_help() @@ -950,6 +1008,7 @@ def cli() -> None: output=args.output, report=args.report, find=args.find, + slack=args.slack, ) diff --git a/tests/test_ontrack.py b/tests/test_ontrack.py index 6884925..e47fb30 100644 --- a/tests/test_ontrack.py +++ b/tests/test_ontrack.py @@ -1,12 +1,14 @@ """Tests for ontrack.py""" import grp +import json import logging import os import pwd import sys import tempfile import textwrap +import urllib.error import pytest import yaml @@ -34,6 +36,7 @@ main, print_report, report_directory, + send_slack_message, ) @@ -940,6 +943,155 @@ def test_main_output_light_mode(tmp_path): assert "total_size_human" not in entry +# --------------------------------------------------------------------------- +# Slack output (--slack) +# --------------------------------------------------------------------------- + + +def test_send_slack_message_posts_json_payload(monkeypatch): + """send_slack_message posts a JSON payload to the provided webhook URL.""" + captured: dict = {} + + class _DummyResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def _fake_urlopen(request): + captured["url"] = request.full_url + headers = {k.lower(): v for k, v in request.header_items()} + captured["content_type"] = headers.get("content-type") + captured["payload"] = json.loads(request.data.decode("utf-8")) + return _DummyResponse() + + import ontrack as rd + + monkeypatch.setattr(rd.urllib.request, "urlopen", _fake_urlopen) + send_slack_message("hello", "https://example.com/webhook") + + assert captured["url"] == "https://example.com/webhook" + assert captured["content_type"] == "application/json" + assert captured["payload"] == {"text": "```\nhello\n```"} + + +def test_send_slack_message_logs_warning_on_network_error(monkeypatch, caplog): + """send_slack_message logs a warning when webhook delivery fails.""" + import ontrack as rd + + def _raise_url_error(_request): + raise urllib.error.URLError("network down") + + monkeypatch.setattr(rd.urllib.request, "urlopen", _raise_url_error) + + with caplog.at_level(logging.WARNING): + send_slack_message("hello", "https://example.com/webhook") + + assert "Could not send Slack message" in caplog.text + + +def test_main_slack_uses_env_webhook_and_sends_stdout(tmp_path, monkeypatch, capsys): + """main with slack uses env webhook URL and sends captured stdout text.""" + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "file.txt").write_text("hello") + + config_file = tmp_path / "config.yaml" + config_file.write_text(f"paths:\n - {data_dir}\n") + + calls: list[tuple[str, str]] = [] + import ontrack as rd + + monkeypatch.setenv("ONTRACK_SLACK_WEBHOOK", "https://env.example/webhook") + monkeypatch.setattr(rd, "send_slack_message", lambda text, url: calls.append((text, url))) + + main(str(config_file), slack=True) + captured = capsys.readouterr() + + assert len(calls) == 1 + assert calls[0][1] == "https://env.example/webhook" + assert str(data_dir) in calls[0][0] + assert "Directory :" in calls[0][0] + assert str(data_dir) in captured.out + + +def test_main_slack_uses_config_webhook_when_env_missing(tmp_path, monkeypatch): + """main with slack falls back to the config webhook URL when env var is absent.""" + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "file.txt").write_text("hello") + + config_file = tmp_path / "config.yaml" + config_file.write_text( + f"paths:\n - {data_dir}\n" + "slack_webhook: https://config.example/webhook\n" + ) + + calls: list[tuple[str, str]] = [] + import ontrack as rd + + monkeypatch.delenv("ONTRACK_SLACK_WEBHOOK", raising=False) + monkeypatch.setattr(rd, "send_slack_message", lambda text, url: calls.append((text, url))) + + main(str(config_file), slack=True) + + assert len(calls) == 1 + assert calls[0][1] == "https://config.example/webhook" + assert str(data_dir) in calls[0][0] + + +def test_main_slack_warns_when_webhook_missing(tmp_path, monkeypatch, capsys): + """main with slack warns and skips sending when no webhook URL is configured.""" + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "file.txt").write_text("hello") + + config_file = tmp_path / "config.yaml" + config_file.write_text(f"paths:\n - {data_dir}\n") + + calls: list[tuple[str, str]] = [] + import ontrack as rd + + monkeypatch.delenv("ONTRACK_SLACK_WEBHOOK", raising=False) + monkeypatch.setattr(rd, "send_slack_message", lambda text, url: calls.append((text, url))) + + main(str(config_file), slack=True) + captured = capsys.readouterr() + + assert calls == [] + assert "Slack webhook URL not configured" in captured.err + + +def test_main_output_and_slack_writes_yaml_and_sends_stdout(tmp_path, monkeypatch, capsys): + """main with output and slack writes YAML and sends the captured stdout text.""" + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "file.txt").write_text("hello") + + config_file = tmp_path / "config.yaml" + config_file.write_text(f"paths:\n - {data_dir}\n") + output_file = tmp_path / "report.yaml" + + calls: list[tuple[str, str]] = [] + import ontrack as rd + + monkeypatch.setenv("ONTRACK_SLACK_WEBHOOK", "https://env.example/webhook") + monkeypatch.setattr(rd, "send_slack_message", lambda text, url: calls.append((text, url))) + + main(str(config_file), output=str(output_file), slack=True) + captured = capsys.readouterr() + + with open(output_file) as fh: + report = yaml.safe_load(fh) + + assert isinstance(report, list) + assert report[0]["directory"] == str(data_dir) + assert captured.out == "" + assert len(calls) == 1 + assert calls[0] == ("", "https://env.example/webhook") + + # --------------------------------------------------------------------------- # Filtering (--find) # --------------------------------------------------------------------------- @@ -2181,6 +2333,7 @@ def test_main_entrypoint_help_flag_prints_help(capsys, monkeypatch): captured = capsys.readouterr() assert "usage:" in captured.out.lower() assert "--config" in captured.out + assert "--slack" in captured.out def test_main_entrypoint_long_help_flag_prints_help(capsys, monkeypatch): From e43053ac7c2235c7862ff0d852032f95c3fa2a40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 19:25:13 +0000 Subject: [PATCH 2/2] chore: bump version to 0.2.0 Agent-Logs-Url: https://github.com/FertigLab/ontrack/sessions/2dc42561-f2cf-4255-a256-a88b6a2e3c4d Co-authored-by: dimalvovs <1246862+dimalvovs@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8a53f0d..f2721d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ontrack" -version = "0.1.0" +version = "0.2.0" description = "Scan directory trees and report file statistics from YAML configuration." readme = "README.md" requires-python = ">=3.10"