From 3400f28d79b2ed3125d24778941a7d303b5e3dee Mon Sep 17 00:00:00 2001 From: Original Gary <276612211+OpenGaryBot@users.noreply.github.com> Date: Fri, 1 May 2026 23:48:17 +1000 Subject: [PATCH 1/3] =?UTF-8?q?[worktree:scai-watchdog-fix]=20test:=20add?= =?UTF-8?q?=20pipeline=5Fwatchdog=20tests=20=E2=80=94=20mutation-first,=20?= =?UTF-8?q?failure=20suppression,=20drift-repair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/tests/test_pipeline_watchdog.py | 299 ++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 scripts/tests/test_pipeline_watchdog.py diff --git a/scripts/tests/test_pipeline_watchdog.py b/scripts/tests/test_pipeline_watchdog.py new file mode 100644 index 0000000..9922cde --- /dev/null +++ b/scripts/tests/test_pipeline_watchdog.py @@ -0,0 +1,299 @@ +"""Tests for pipeline_watchdog.py. + +The watchdog strips the status:done label from GitHub issues that were marked +done without a closing PR or commit evidence. This suite encodes the three +fixed behaviours introduced by the bug fix: + +1. Label mutation happens before the strip-comment is posted. +2. The strip-comment is suppressed when the label mutation fails. +3. The strip-comment is posted when the label mutation succeeds. +4. Failed mutations are written to the orchestrator log as structured entries. +5. The drift-repair pass re-attempts label removal on any issue that already + has the strip-comment but still carries status:done. + +Each test names the rule it encodes and specifies the mutation that kills it. +""" + +from __future__ import annotations + +import json +import os +import stat +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Load module under test +# --------------------------------------------------------------------------- + +import importlib.util + +_watchdog_path = Path(__file__).parent.parent / "pipeline_watchdog.py" +_spec = importlib.util.spec_from_file_location("pipeline_watchdog", _watchdog_path) +_watchdog = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_watchdog) + +strip_status_done = _watchdog.strip_status_done +drift_repair_pass = _watchdog.drift_repair_pass + + +# --------------------------------------------------------------------------- +# Mock gh binary helpers +# --------------------------------------------------------------------------- + +def _write_mock_gh(path: Path, exit_code_for_edit: int, labels: list[str], comments: list[str]) -> None: + """Write a mock gh binary that records calls and returns configurable exit codes. + + The mock records every invocation (argv) into a JSON-lines call log at + .calls. Responses vary by sub-command: + + - `gh issue edit ... --remove-label ...` → exit_code_for_edit + - `gh issue comment ...` → exit 0 (always) + - `gh issue view ... --json labels,comments` → stdout JSON, exit 0 + """ + labels_json = json.dumps(labels) + comments_json = json.dumps(comments) + + # Build the mock script content + script = textwrap.dedent(f"""\ + #!/usr/bin/env python3 + import json, sys, os + + argv = sys.argv[1:] + log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gh.calls") + with open(log_path, "a") as f: + f.write(json.dumps(argv) + "\\n") + + if "issue" in argv and "edit" in argv and "--remove-label" in argv: + sys.exit({exit_code_for_edit}) + elif "issue" in argv and "comment" in argv: + sys.exit(0) + elif "issue" in argv and "view" in argv and "--json" in argv: + labels = {labels_json} + comments = {comments_json} + print(json.dumps({{"labels": labels, "comments": comments}})) + sys.exit(0) + else: + sys.exit(0) + """) + + path.write_text(script, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + +def _read_calls(mock_gh_path: Path) -> list[list[str]]: + """Return all recorded call argument lists.""" + calls_file = mock_gh_path.parent / "gh.calls" + if not calls_file.exists(): + return [] + lines = calls_file.read_text(encoding="utf-8").strip().splitlines() + return [json.loads(line) for line in lines if line.strip()] + + +# --------------------------------------------------------------------------- +# Test 1 — Mutation-first behaviour +# +# Rule: gh issue edit --remove-label is called before gh issue comment. +# Mutation: swap call order in strip_status_done — mutation causes this test +# to fail because comment call appears before edit call in the log. +# --------------------------------------------------------------------------- + +def test_mutation_first_behaviour(tmp_path: Path) -> None: + mock_gh = tmp_path / "gh" + _write_mock_gh(mock_gh, exit_code_for_edit=0, labels=["status:done"], comments=[]) + log_path = tmp_path / "orchestrator.log" + + env = {**os.environ, "PATH": str(tmp_path) + os.pathsep + os.environ.get("PATH", "")} + strip_status_done( + repo="Open-Paws/structured-coding-with-ai", + issue_number=46, + log_path=log_path, + gh_path=str(mock_gh), + ) + + calls = _read_calls(mock_gh) + # Find positions of edit and comment calls + edit_positions = [i for i, c in enumerate(calls) if "edit" in c and "--remove-label" in c] + comment_positions = [i for i, c in enumerate(calls) if "comment" in c] + + assert edit_positions, "gh issue edit --remove-label was not called" + assert comment_positions, "gh issue comment was not called" + # The mutation (label removal) must precede the comment + assert edit_positions[0] < comment_positions[0], ( + "label mutation must happen before strip-comment is posted" + ) + + +# --------------------------------------------------------------------------- +# Test 2 — Comment suppressed on mutation failure +# +# Rule: when gh issue edit exits non-zero, gh issue comment is never called. +# Mutation: remove the exit-code guard in strip_status_done — mutation causes +# this test to fail because comment call appears in the log. +# --------------------------------------------------------------------------- + +def test_comment_suppressed_on_mutation_failure(tmp_path: Path) -> None: + mock_gh = tmp_path / "gh" + _write_mock_gh(mock_gh, exit_code_for_edit=1, labels=["status:done"], comments=[]) + log_path = tmp_path / "orchestrator.log" + + strip_status_done( + repo="Open-Paws/structured-coding-with-ai", + issue_number=46, + log_path=log_path, + gh_path=str(mock_gh), + ) + + calls = _read_calls(mock_gh) + comment_calls = [c for c in calls if "comment" in c] + assert not comment_calls, ( + "strip-comment must not be posted when label mutation fails" + ) + + +# --------------------------------------------------------------------------- +# Test 3 — Comment posted on mutation success +# +# Rule: when gh issue edit exits 0, gh issue comment IS called. +# Mutation: invert the success guard (post comment only on failure) — mutation +# causes this test to fail because comment call is absent in the log. +# --------------------------------------------------------------------------- + +def test_comment_posted_on_mutation_success(tmp_path: Path) -> None: + mock_gh = tmp_path / "gh" + _write_mock_gh(mock_gh, exit_code_for_edit=0, labels=["status:done"], comments=[]) + log_path = tmp_path / "orchestrator.log" + + strip_status_done( + repo="Open-Paws/structured-coding-with-ai", + issue_number=46, + log_path=log_path, + gh_path=str(mock_gh), + ) + + calls = _read_calls(mock_gh) + comment_calls = [c for c in calls if "comment" in c] + assert comment_calls, ( + "strip-comment must be posted when label mutation succeeds" + ) + + +# --------------------------------------------------------------------------- +# Test 4 — Failure logged on non-zero exit +# +# Rule: when gh issue edit exits non-zero, a structured error entry is written +# to the orchestrator log containing repo, issue number, and exit code. +# Mutation: remove the log-write call from the failure branch — mutation causes +# this test to fail because no log entry is present. +# --------------------------------------------------------------------------- + +def test_failure_logged_on_nonzero_exit(tmp_path: Path) -> None: + mock_gh = tmp_path / "gh" + _write_mock_gh(mock_gh, exit_code_for_edit=1, labels=["status:done"], comments=[]) + log_path = tmp_path / "orchestrator.log" + + strip_status_done( + repo="Open-Paws/context", + issue_number=82, + log_path=log_path, + gh_path=str(mock_gh), + ) + + assert log_path.exists(), "orchestrator log must be written on failure" + log_content = log_path.read_text(encoding="utf-8") + + # Parse each line as a JSON entry + entries = [] + for line in log_content.strip().splitlines(): + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + pass + + assert entries, "log must contain at least one structured JSON entry" + entry = entries[-1] + + # The entry must encode the three required fields + assert "repo" in entry and "Open-Paws/context" in str(entry["repo"]), ( + "log entry must contain repo field" + ) + assert "issue" in entry and entry["issue"] == 82, ( + "log entry must contain issue number" + ) + assert "exit_code" in entry and entry["exit_code"] != 0, ( + "log entry must contain non-zero exit code" + ) + + +# --------------------------------------------------------------------------- +# Test 5 — Drift-repair pass fires on stale comment +# +# Rule: when an issue already has the strip-comment AND still has status:done, +# drift_repair_pass must call gh issue edit --remove-label for that issue. +# Mutation: remove the drift-repair scan from drift_repair_pass — mutation +# causes this test to fail because no edit call appears in the log. +# --------------------------------------------------------------------------- + +STRIP_COMMENT_BODY = "[pipeline-watchdog] Stripped `status:done`" + + +def test_drift_repair_fires_on_stale_comment(tmp_path: Path) -> None: + mock_gh = tmp_path / "gh" + stale_comments = [{"body": STRIP_COMMENT_BODY}] + _write_mock_gh( + mock_gh, + exit_code_for_edit=0, + labels=["status:done"], + comments=stale_comments, + ) + log_path = tmp_path / "orchestrator.log" + + drift_repair_pass( + repos_and_issues=[("Open-Paws/context", 82)], + log_path=log_path, + gh_path=str(mock_gh), + ) + + calls = _read_calls(mock_gh) + edit_calls = [c for c in calls if "edit" in c and "--remove-label" in c] + assert edit_calls, ( + "drift-repair pass must call gh issue edit --remove-label on stale issues" + ) + + +# --------------------------------------------------------------------------- +# Test 6 — Drift-repair is a no-op when label already removed +# +# Rule: when an issue has the strip-comment but does NOT have status:done, +# drift_repair_pass must NOT call gh issue edit --remove-label (already clean). +# Mutation: remove the label-presence check — mutation causes this test to +# fail because an edit call appears even for already-clean issues. +# --------------------------------------------------------------------------- + +def test_drift_repair_noop_when_label_absent(tmp_path: Path) -> None: + mock_gh = tmp_path / "gh" + # Issue has strip-comment but status:done is already gone + stale_comments = [{"body": STRIP_COMMENT_BODY}] + _write_mock_gh( + mock_gh, + exit_code_for_edit=0, + labels=[], # status:done already removed + comments=stale_comments, + ) + log_path = tmp_path / "orchestrator.log" + + drift_repair_pass( + repos_and_issues=[("Open-Paws/context", 82)], + log_path=log_path, + gh_path=str(mock_gh), + ) + + calls = _read_calls(mock_gh) + edit_calls = [c for c in calls if "edit" in c and "--remove-label" in c] + assert not edit_calls, ( + "drift-repair pass must not call gh issue edit when status:done is already removed" + ) From a9b0252cff71495a83b4b7df393aadabb2af9254 Mon Sep 17 00:00:00 2001 From: Original Gary <276612211+OpenGaryBot@users.noreply.github.com> Date: Fri, 1 May 2026 23:50:25 +1000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20invert=20watchdog=20op=20order=20?= =?UTF-8?q?=E2=80=94=20mutation=20before=20comment=20strip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the bug where the pipeline-watchdog posted the strip-announcement comment before attempting the label mutation. When the mutation failed silently the comment was the only durable trace, leaving status:done on the issue. Three changes: 1. Mutation-first: gh issue edit --remove-label runs before gh issue comment; comment is only posted when mutation exits 0. 2. Failure logging: non-zero mutation exit writes a structured JSON entry to the orchestrator log (repo, issue, exit_code, stderr). 3. Drift-repair pass: scans a list of (repo, issue) pairs; for each issue that still has the strip-comment AND status:done, re-attempts removal. Closes #46 --- scripts/pipeline_watchdog.py | 253 ++++++++++++++++++++++++ scripts/tests/test_pipeline_watchdog.py | 61 ++++-- 2 files changed, 294 insertions(+), 20 deletions(-) create mode 100644 scripts/pipeline_watchdog.py diff --git a/scripts/pipeline_watchdog.py b/scripts/pipeline_watchdog.py new file mode 100644 index 0000000..d9b3f53 --- /dev/null +++ b/scripts/pipeline_watchdog.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Pipeline watchdog — strips status:done labels from GitHub issues applied +without commit or PR evidence. + +Operation ordering (the fix): + 1. Attempt label mutation first: ``gh issue edit --remove-label status:done`` + 2. Only post the strip-announcement comment if mutation exits 0. + 3. On non-zero exit, write a structured JSON entry to the orchestrator log. + +The previous (buggy) ordering posted the comment first, then attempted the +mutation. When the mutation failed silently the comment became the only +durable trace, leaving the label in place. + +Drift-repair pass: + Re-scans a list of known (repo, issue) pairs. For each issue that still + carries the strip-comment AND the status:done label, re-attempts label + removal. This heals issues left in the broken state by the original bug. + +Usage (programmatic): + from pipeline_watchdog import strip_status_done, drift_repair_pass + + strip_status_done( + repo="Open-Paws/context", + issue_number=82, + log_path=Path("pipeline/watchdog.log"), + ) + + drift_repair_pass( + repos_and_issues=[("Open-Paws/context", 82)], + log_path=Path("pipeline/watchdog.log"), + ) + +Usage (CLI): + python scripts/pipeline_watchdog.py strip --repo Open-Paws/context \\ + --issue 82 --log pipeline/watchdog.log + + python scripts/pipeline_watchdog.py drift-repair \\ + --log pipeline/watchdog.log \\ + Open-Paws/context:82 Open-Paws/platform:87 +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Sequence + +# The exact prefix that the watchdog embeds in strip-announcement comments. +# Used by the drift-repair pass to detect stale comment+label combinations. +STRIP_COMMENT_MARKER = "[pipeline-watchdog] Stripped `status:done`" + +_DEFAULT_GH = "gh" + + +# --------------------------------------------------------------------------- +# Core operations +# --------------------------------------------------------------------------- + + +def strip_status_done( + repo: str, + issue_number: int, + log_path: Path, + gh_path: str = _DEFAULT_GH, +) -> bool: + """Attempt to remove the status:done label, then post the announcement. + + Returns True when the label mutation succeeded and the comment was posted, + False when the mutation failed (comment suppressed, failure logged). + """ + # Step 1 — mutate label FIRST + result = subprocess.run( + [gh_path, "issue", "edit", str(issue_number), + "--repo", repo, + "--remove-label", "status:done"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + # Mutation failed — log the failure, suppress the comment + _log_failure( + log_path=log_path, + repo=repo, + issue=issue_number, + exit_code=result.returncode, + stderr=result.stderr.strip(), + ) + return False + + # Step 2 — mutation succeeded, now post the announcement + subprocess.run( + [gh_path, "issue", "comment", str(issue_number), + "--repo", repo, + "--body", + f"{STRIP_COMMENT_MARKER} — applied without commit/PR evidence " + "(no closing PR, no `Closes #N` reference on default branch). " + "Re-walking through triage/plan/impl on next /run."], + capture_output=True, + text=True, + ) + return True + + +def drift_repair_pass( + repos_and_issues: Sequence[tuple[str, int]], + log_path: Path, + gh_path: str = _DEFAULT_GH, +) -> int: + """Re-attempt label removal for issues in a known-broken state. + + A broken state means: the issue has the strip-comment AND still carries + status:done. This is the signature of the original bug. + + Returns the count of issues where repair was attempted. + """ + repaired = 0 + for repo, issue_number in repos_and_issues: + if _is_stale(repo, issue_number, gh_path): + strip_status_done( + repo=repo, + issue_number=issue_number, + log_path=log_path, + gh_path=gh_path, + ) + repaired += 1 + return repaired + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _is_stale(repo: str, issue_number: int, gh_path: str) -> bool: + """Return True if the issue has the strip-comment AND status:done label.""" + result = subprocess.run( + [gh_path, "issue", "view", str(issue_number), + "--repo", repo, + "--json", "labels,comments"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return False + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + return False + + labels = [lbl if isinstance(lbl, str) else lbl.get("name", "") for lbl in data.get("labels", [])] + has_done_label = "status:done" in labels + + comments = data.get("comments", []) + has_strip_comment = any( + STRIP_COMMENT_MARKER in (c.get("body", "") if isinstance(c, dict) else str(c)) + for c in comments + ) + + return has_done_label and has_strip_comment + + +def _log_failure( + log_path: Path, + repo: str, + issue: int, + exit_code: int, + stderr: str, +) -> None: + """Append a structured JSON entry to the orchestrator log.""" + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "event": "watchdog_label_mutation_failed", + "repo": repo, + "issue": issue, + "exit_code": exit_code, + "stderr": stderr, + } + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="pipeline_watchdog", + description="Pipeline watchdog — strips status:done labels applied without evidence.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + strip_cmd = sub.add_parser("strip", help="Strip status:done from a single issue.") + strip_cmd.add_argument("--repo", required=True, help="owner/repo") + strip_cmd.add_argument("--issue", type=int, required=True, help="issue number") + strip_cmd.add_argument("--log", required=True, help="path to orchestrator log file") + strip_cmd.add_argument("--gh", default=_DEFAULT_GH, help="path to gh binary") + + repair_cmd = sub.add_parser("drift-repair", help="Re-attempt removal for stale issues.") + repair_cmd.add_argument("--log", required=True, help="path to orchestrator log file") + repair_cmd.add_argument("--gh", default=_DEFAULT_GH, help="path to gh binary") + repair_cmd.add_argument( + "issues", + nargs="+", + metavar="OWNER/REPO:NUMBER", + help="repo:issue pairs to scan, e.g. Open-Paws/context:82", + ) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + if args.command == "strip": + ok = strip_status_done( + repo=args.repo, + issue_number=args.issue, + log_path=Path(args.log), + gh_path=args.gh, + ) + return 0 if ok else 1 + + if args.command == "drift-repair": + pairs: list[tuple[str, int]] = [] + for item in args.issues: + repo, _, num = item.rpartition(":") + if not repo or not num.isdigit(): + print(f"Invalid format (expected OWNER/REPO:NUMBER): {item}", file=sys.stderr) + return 2 + pairs.append((repo, int(num))) + count = drift_repair_pass( + repos_and_issues=pairs, + log_path=Path(args.log), + gh_path=args.gh, + ) + print(f"drift-repair: {count} issue(s) re-attempted") + return 0 + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_pipeline_watchdog.py b/scripts/tests/test_pipeline_watchdog.py index 9922cde..d72ca78 100644 --- a/scripts/tests/test_pipeline_watchdog.py +++ b/scripts/tests/test_pipeline_watchdog.py @@ -17,9 +17,6 @@ from __future__ import annotations import json -import os -import stat -import subprocess import sys import textwrap from pathlib import Path @@ -46,21 +43,25 @@ # --------------------------------------------------------------------------- def _write_mock_gh(path: Path, exit_code_for_edit: int, labels: list[str], comments: list[str]) -> None: - """Write a mock gh binary that records calls and returns configurable exit codes. + """Write a mock gh script that records calls and returns configurable exit codes. + + On Windows the mock is a .py file invoked via ``python .py``; on + Unix it is a shebang executable. The ``gh_path`` passed to functions under + test is a wrapper that works on both platforms. The mock records every invocation (argv) into a JSON-lines call log at - .calls. Responses vary by sub-command: + /gh.calls. Responses vary by sub-command: - - `gh issue edit ... --remove-label ...` → exit_code_for_edit - - `gh issue comment ...` → exit 0 (always) - - `gh issue view ... --json labels,comments` → stdout JSON, exit 0 + - ``gh issue edit ... --remove-label ...`` → exit_code_for_edit + - ``gh issue comment ...`` → exit 0 (always) + - ``gh issue view ... --json labels,comments`` → stdout JSON, exit 0 """ labels_json = json.dumps(labels) comments_json = json.dumps(comments) - # Build the mock script content + # Write the Python implementation (works on all platforms) + py_path = path.with_suffix(".py") script = textwrap.dedent(f"""\ - #!/usr/bin/env python3 import json, sys, os argv = sys.argv[1:] @@ -80,9 +81,30 @@ def _write_mock_gh(path: Path, exit_code_for_edit: int, labels: list[str], comme else: sys.exit(0) """) - - path.write_text(script, encoding="utf-8") - path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + py_path.write_text(script, encoding="utf-8") + + # Write a thin wrapper that invokes the .py via the same Python interpreter + # that runs pytest. On Windows this is a .cmd file; on Unix a shebang script. + if sys.platform == "win32": + cmd_path = path.with_suffix(".cmd") + cmd_path.write_text( + f'@"{sys.executable}" "{py_path}" %*\r\n', + encoding="utf-8", + ) + # Store the .cmd path in a sentinel so _gh_path() finds it + path.write_bytes(cmd_path.read_bytes()) + else: + wrapper = f'#!/bin/sh\nexec "{sys.executable}" "{py_path}" "$@"\n' + path.write_text(wrapper, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + +def _gh_path(base: Path) -> str: + """Return the platform-appropriate path for the mock gh binary.""" + if sys.platform == "win32": + cmd = base.with_suffix(".cmd") + return str(cmd) + return str(base) def _read_calls(mock_gh_path: Path) -> list[list[str]]: @@ -107,12 +129,11 @@ def test_mutation_first_behaviour(tmp_path: Path) -> None: _write_mock_gh(mock_gh, exit_code_for_edit=0, labels=["status:done"], comments=[]) log_path = tmp_path / "orchestrator.log" - env = {**os.environ, "PATH": str(tmp_path) + os.pathsep + os.environ.get("PATH", "")} strip_status_done( repo="Open-Paws/structured-coding-with-ai", issue_number=46, log_path=log_path, - gh_path=str(mock_gh), + gh_path=_gh_path(mock_gh), ) calls = _read_calls(mock_gh) @@ -145,7 +166,7 @@ def test_comment_suppressed_on_mutation_failure(tmp_path: Path) -> None: repo="Open-Paws/structured-coding-with-ai", issue_number=46, log_path=log_path, - gh_path=str(mock_gh), + gh_path=_gh_path(mock_gh), ) calls = _read_calls(mock_gh) @@ -172,7 +193,7 @@ def test_comment_posted_on_mutation_success(tmp_path: Path) -> None: repo="Open-Paws/structured-coding-with-ai", issue_number=46, log_path=log_path, - gh_path=str(mock_gh), + gh_path=_gh_path(mock_gh), ) calls = _read_calls(mock_gh) @@ -200,7 +221,7 @@ def test_failure_logged_on_nonzero_exit(tmp_path: Path) -> None: repo="Open-Paws/context", issue_number=82, log_path=log_path, - gh_path=str(mock_gh), + gh_path=_gh_path(mock_gh), ) assert log_path.exists(), "orchestrator log must be written on failure" @@ -255,7 +276,7 @@ def test_drift_repair_fires_on_stale_comment(tmp_path: Path) -> None: drift_repair_pass( repos_and_issues=[("Open-Paws/context", 82)], log_path=log_path, - gh_path=str(mock_gh), + gh_path=_gh_path(mock_gh), ) calls = _read_calls(mock_gh) @@ -289,7 +310,7 @@ def test_drift_repair_noop_when_label_absent(tmp_path: Path) -> None: drift_repair_pass( repos_and_issues=[("Open-Paws/context", 82)], log_path=log_path, - gh_path=str(mock_gh), + gh_path=_gh_path(mock_gh), ) calls = _read_calls(mock_gh) From d7a9db54902ff798e4309a8b3000cc2ecbb93752 Mon Sep 17 00:00:00 2001 From: Original Gary <276612211+OpenGaryBot@users.noreply.github.com> Date: Fri, 1 May 2026 23:53:22 +1000 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20fix=20import=20cleanup=20=E2=80=94?= =?UTF-8?q?=20add=20stat,=20remove=20unused=20pytest=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/tests/test_pipeline_watchdog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/tests/test_pipeline_watchdog.py b/scripts/tests/test_pipeline_watchdog.py index d72ca78..af97112 100644 --- a/scripts/tests/test_pipeline_watchdog.py +++ b/scripts/tests/test_pipeline_watchdog.py @@ -17,12 +17,11 @@ from __future__ import annotations import json +import stat import sys import textwrap from pathlib import Path -import pytest - # --------------------------------------------------------------------------- # Load module under test # ---------------------------------------------------------------------------