diff --git a/.github/workflows/daily-upstream-check.yml b/.github/workflows/daily-upstream-check.yml
new file mode 100644
index 0000000..7f534d5
--- /dev/null
+++ b/.github/workflows/daily-upstream-check.yml
@@ -0,0 +1,34 @@
+name: Daily Upstream Plugin Check
+
+on:
+ schedule:
+ # Run daily at 05:00 UTC
+ - cron: '0 5 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ check-plugins:
+ name: Audit Upstream Plugins
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+
+ - name: Run upstream check
+ id: check
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python scripts/check_upstream_updates.py --report docs/upstream-audit-report.md
diff --git a/scripts/check_upstream_updates.py b/scripts/check_upstream_updates.py
new file mode 100644
index 0000000..6bf6b54
--- /dev/null
+++ b/scripts/check_upstream_updates.py
@@ -0,0 +1,362 @@
+#!/usr/bin/env python3
+"""Stage 1: Audit and check active plugins in manifest.json for upstream releases & Nu version bumps.
+
+The audit fails closed: any upstream API error marks the entry FETCH_ERROR and the
+script exits non-zero rather than reporting a possibly false "up to date" signal.
+
+Usage:
+ python scripts/check_upstream_updates.py [--manifest manifest.json] [--report report.md] [--json]
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+MANIFEST_PATH = REPO_ROOT / "manifest.json"
+USER_AGENT = "numan-plugins-checker/1.0"
+
+
+class FetchError(Exception):
+ """Raised when a GitHub API request fails.
+
+ Attributes:
+ status_code: HTTP status code when the failure is an HTTP error response,
+ otherwise None.
+ """
+
+ def __init__(self, message: str, status_code: int | None = None):
+ super().__init__(message)
+ self.status_code = status_code
+
+
+def fetch_github_json(endpoint: str) -> dict | list:
+ """Fetch JSON from GitHub API with optional auth token.
+
+ Raises:
+ FetchError: If the request fails or the response cannot be decoded.
+ """
+ url = f"https://api.github.com/{endpoint.lstrip('/')}"
+ req = urllib.request.Request(
+ url,
+ headers={
+ "User-Agent": USER_AGENT,
+ "Accept": "application/vnd.github.v3+json",
+ },
+ )
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+ if token:
+ req.add_header("Authorization", f"Bearer {token}")
+
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ raise FetchError(f"HTTP {e.code} from {url}", status_code=e.code) from e
+ except urllib.error.URLError as e:
+ raise FetchError(f"network error for {url}: {e.reason}") from e
+ except Exception as e:
+ raise FetchError(f"failed to fetch {url}: {e}") from e
+
+
+def fetch_cargo_toml_nu_dep(repo: str, ref: str = "HEAD") -> str | None:
+ """Fetch Cargo.toml and extract nu-plugin / nu-protocol dependency version.
+
+ A 404 (no Cargo.toml at the repository root) is treated as "unknown" rather
+ than a failure; all other API errors propagate.
+
+ Raises:
+ FetchError: If the request fails for a reason other than a missing file.
+ """
+ endpoint = f"repos/{repo}/contents/Cargo.toml"
+ if ref != "HEAD":
+ endpoint += f"?ref={ref}"
+ try:
+ data = fetch_github_json(endpoint)
+ except FetchError as e:
+ if e.status_code == 404:
+ return None
+ raise
+ if isinstance(data, dict) and "content" in data:
+ content = base64.b64decode(data["content"]).decode("utf-8", errors="replace")
+ # match nu-plugin = "0.115.0" or nu-plugin = { version = "0.115" }
+ m = re.search(r'nu-plugin\s*=\s*(?:\{[^}]*version\s*=\s*)?["\']([^"\']+)["\']', content)
+ if m:
+ return m.group(1)
+ # fallback to nu-protocol
+ m_proto = re.search(r'nu-protocol\s*=\s*(?:\{[^}]*version\s*=\s*)?["\']([^"\']+)["\']', content)
+ if m_proto:
+ return m_proto.group(1)
+ return None
+
+
+def fetch_latest_tags(repo: str) -> list[dict]:
+ """Fetch recent tags for repository with the commit each tag points at."""
+ data = fetch_github_json(f"repos/{repo}/tags?per_page=5")
+ tags = []
+ if isinstance(data, list):
+ for item in data:
+ if not isinstance(item, dict) or "name" not in item:
+ continue
+ commit_sha = None
+ commit = item.get("commit")
+ if isinstance(commit, dict) and isinstance(commit.get("sha"), str):
+ commit_sha = commit["sha"]
+ tags.append({"name": item["name"], "commit_sha": commit_sha})
+ return tags
+
+
+def resolve_tag_commit(repo: str, tag: str) -> str | None:
+ """Resolve a git tag name to the commit SHA it currently points at.
+
+ Returns:
+ The target commit SHA, or None when the tag no longer exists upstream
+ (HTTP 404).
+
+ Raises:
+ FetchError: If the resolution request fails for another reason.
+ """
+ quoted = urllib.parse.quote(tag, safe="")
+ try:
+ data = fetch_github_json(f"repos/{repo}/commits/{quoted}")
+ except FetchError as e:
+ if e.status_code == 404:
+ return None
+ raise
+ if isinstance(data, dict) and isinstance(data.get("sha"), str):
+ return data["sha"]
+ raise FetchError(f"unexpected response resolving {repo}@{tag}")
+
+
+def parse_version(version: str) -> tuple[int, ...] | None:
+ """Parse a dotted numeric version into a 3-component comparable tuple.
+
+ Missing components are padded with zeros so "0.115" == (0, 115, 0).
+ """
+ m = re.match(r"(\d+(?:\.\d+){0,3})", version.strip())
+ if not m:
+ return None
+ parts = [int(part) for part in m.group(1).split(".")]
+ return tuple((parts + [0, 0, 0])[:3])
+
+
+def manifest_nu_upper_bound(nu_version: str) -> tuple[int, ...] | None:
+ """Extract the exclusive upper bound (X.Y.Z) from a manifest nu_version range.
+
+ e.g. '>=0.114.0 <0.115.0' -> (0, 115, 0). Falls back to parsing the whole
+ value when the range has no '<' constraint.
+ """
+ for part in re.split(r"[\s,]+", nu_version):
+ m = re.match(r"<(\d+(?:\.\d+){0,3})", part.strip())
+ if m:
+ return parse_version(m.group(1))
+ return parse_version(nu_version)
+
+
+def nu_needs_bump(manifest_nu: str, upstream_nu: str) -> bool:
+ """Return True when upstream's Cargo.toml nu dep is at or beyond the manifest's supported range."""
+ bound = manifest_nu_upper_bound(manifest_nu)
+ upstream = parse_version(upstream_nu)
+ if bound is None or upstream is None:
+ return False
+ return upstream >= bound
+
+
+def _error_result(entry: dict, error: str) -> dict:
+ """Build an audit result that fails closed for an upstream API error."""
+ current_commit = entry.get("source_commit", "")
+ return {
+ "repo": entry["repo"],
+ "name": entry["name"],
+ "current_tag": entry.get("tag"),
+ "current_commit": current_commit[:7] if current_commit else "",
+ "current_nu": entry.get("nu_version", ""),
+ "latest_tags": [],
+ "newest_tag": None,
+ "cargo_nu_dep": "unknown",
+ "has_new_tag": False,
+ "nu_bump": False,
+ "error": error,
+ "status": "FETCH_ERROR",
+ }
+
+
+def audit_entry(entry: dict) -> dict:
+ """Audit single manifest entry against upstream repository."""
+ repo = entry["repo"]
+ name = entry["name"]
+ current_tag = entry.get("tag")
+ current_nu = entry.get("nu_version", "")
+ current_commit = entry.get("source_commit", "")
+
+ try:
+ tags = fetch_latest_tags(repo)
+ cargo_nu = fetch_cargo_toml_nu_dep(repo)
+ except FetchError as e:
+ return _error_result(entry, str(e))
+
+ newest_tag = tags[0]["name"] if tags else None
+
+ tag_moved = False
+ if current_tag:
+ try:
+ resolved = resolve_tag_commit(repo, current_tag)
+ except FetchError as e:
+ return _error_result(entry, str(e))
+ if resolved is None or (current_commit and resolved.lower() != current_commit.lower()):
+ tag_moved = True
+
+ has_new_tag = False
+ if newest_tag:
+ if not current_tag:
+ # commit-snapshot entries pin no tag; any upstream tag that does not
+ # already point at the snapshot commit is a re-intake candidate.
+ try:
+ resolved_newest = resolve_tag_commit(repo, newest_tag)
+ except FetchError as e:
+ return _error_result(entry, str(e))
+ snapshot_matches = bool(
+ resolved_newest and current_commit and resolved_newest.lower() == current_commit.lower()
+ )
+ has_new_tag = not snapshot_matches
+ elif newest_tag != current_tag:
+ has_new_tag = True
+
+ nu_bump = bool(cargo_nu) and nu_needs_bump(current_nu, cargo_nu)
+
+ if tag_moved:
+ status = "TAG_PROVENANCE_MISMATCH"
+ elif nu_bump and has_new_tag:
+ status = "READY_FOR_BUMP"
+ elif nu_bump:
+ status = "UPSTREAM_NU_BUMP_NO_TAG"
+ elif has_new_tag:
+ status = "NEW_TAG_AVAILABLE"
+ else:
+ status = "UP_TO_DATE"
+
+ return {
+ "repo": repo,
+ "name": name,
+ "current_tag": current_tag,
+ "current_commit": current_commit[:7] if current_commit else "",
+ "current_nu": current_nu,
+ "latest_tags": [t["name"] for t in tags[:3]],
+ "newest_tag": newest_tag,
+ "cargo_nu_dep": cargo_nu or "unknown",
+ "has_new_tag": has_new_tag,
+ "nu_bump": nu_bump,
+ "status": status,
+ }
+
+
+def _truncate(text: str, limit: int) -> str:
+ """Flatten and shorten a message for use inside a markdown table cell."""
+ text = text.replace("\n", " ").replace("|", "/")
+ return text if len(text) <= limit else text[: limit - 1] + "…"
+
+
+def generate_markdown_report(results: list[dict]) -> str:
+ lines = [
+ "# Upstream Plugin Audit Report",
+ "",
+ f"_Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%SZ')}_",
+ "",
+ "| Package | Upstream Repo | Current Tag | Cargo.toml nu-dep | Latest Upstream Tags | Status |",
+ "| :--- | :--- | :--- | :--- | :--- | :--- |",
+ ]
+
+ badges = {
+ "READY_FOR_BUMP": "🚀 **READY_FOR_BUMP**",
+ "UPSTREAM_NU_BUMP_NO_TAG": "⚡ _NU_BUMP (no tag)_",
+ "NEW_TAG_AVAILABLE": "🏷️ _NEW_TAG_",
+ "TAG_PROVENANCE_MISMATCH": "⚠️ **TAG_MOVED**",
+ "FETCH_ERROR": "❌ **FETCH_ERROR**",
+ }
+
+ for r in results:
+ tags_str = ", ".join(f"`{t}`" for t in r["latest_tags"]) if r["latest_tags"] else "—"
+ cur_tag = f"`{r['current_tag']}`" if r["current_tag"] else "—"
+ status_badge = badges.get(r["status"], r["status"])
+ if r.get("error"):
+ status_badge += f"
{_truncate(r['error'], 90)}"
+
+ lines.append(
+ f"| `{r['name']}` | [{r['repo']}](https://github.com/{r['repo']}) | {cur_tag} | `{r['cargo_nu_dep']}` | {tags_str} | {status_badge} |"
+ )
+
+ lines.append("")
+ return "\n".join(lines)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Check active plugins for upstream updates.")
+ parser.add_argument("--manifest", type=Path, default=MANIFEST_PATH, help="Path to manifest.json")
+ parser.add_argument("--report", type=Path, default=None, help="Path to output markdown report")
+ parser.add_argument("--json", action="store_true", help="Output JSON results to stdout")
+ args = parser.parse_args()
+
+ if not args.manifest.exists():
+ print(f"Error: manifest file {args.manifest} does not exist", file=sys.stderr)
+ return 1
+
+ manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
+ active = manifest.get("active", [])
+
+ print(f"Auditing {len(active)} active plugins from {args.manifest.name}...")
+ results = []
+ for entry in active:
+ res = audit_entry(entry)
+ results.append(res)
+ print(
+ f" • {res['name']}: {res['status']} "
+ f"(Cargo nu-dep: {res['cargo_nu_dep']}, newest tag: {res['newest_tag']})"
+ )
+ if res["status"] == "FETCH_ERROR":
+ print(f" {res['error']}", file=sys.stderr)
+
+ report_md = generate_markdown_report(results)
+
+ if args.report:
+ args.report.parent.mkdir(parents=True, exist_ok=True)
+ args.report.write_text(report_md, encoding="utf-8")
+ print(f"\nReport written to {args.report}")
+
+ gh_summary = os.environ.get("GITHUB_STEP_SUMMARY")
+ if gh_summary:
+ with open(gh_summary, "a", encoding="utf-8") as f:
+ f.write(report_md)
+
+ updates_found = any(r["status"] in ("READY_FOR_BUMP", "NEW_TAG_AVAILABLE") for r in results)
+ gh_output = os.environ.get("GITHUB_OUTPUT")
+ if gh_output:
+ with open(gh_output, "a", encoding="utf-8") as f:
+ f.write(f"updates_found={'true' if updates_found else 'false'}\n")
+
+ if args.json:
+ print(json.dumps(results, indent=2))
+
+ failures = sum(1 for r in results if r["status"] == "FETCH_ERROR")
+ if failures:
+ print(
+ f"ERROR: {failures} upstream fetch(es) failed; audit is incomplete and "
+ "cannot report readiness",
+ file=sys.stderr,
+ )
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/test_check_upstream_updates.py b/scripts/test_check_upstream_updates.py
new file mode 100644
index 0000000..db67d8f
--- /dev/null
+++ b/scripts/test_check_upstream_updates.py
@@ -0,0 +1,215 @@
+import unittest
+from unittest import mock
+
+from check_upstream_updates import (
+ FetchError,
+ audit_entry,
+ generate_markdown_report,
+ manifest_nu_upper_bound,
+ nu_needs_bump,
+ parse_version,
+)
+
+
+def make_entry(**overrides):
+ entry = {
+ "repo": "idanarye/nu_plugin_skim",
+ "name": "nu_plugin_skim",
+ "tag": "v0.29.1",
+ "source_commit": "b86f26023a166492a0c2aea1e2c8cf803c29f813",
+ "nu_version": ">=0.114.0 <0.115.0",
+ }
+ entry.update(overrides)
+ return entry
+
+
+class TestCheckUpstreamUpdates(unittest.TestCase):
+ def test_generate_markdown_report(self):
+ sample_results = [
+ {
+ "repo": "idanarye/nu_plugin_skim",
+ "name": "nu_plugin_skim",
+ "current_tag": "v0.29.1",
+ "current_commit": "abcdef1",
+ "current_nu": ">=0.114.0 <0.115.0",
+ "latest_tags": ["v0.29.1"],
+ "newest_tag": "v0.29.1",
+ "cargo_nu_dep": "0.115",
+ "has_new_tag": False,
+ "status": "UPSTREAM_NU_BUMP_NO_TAG",
+ }
+ ]
+ report = generate_markdown_report(sample_results)
+ self.assertIn("# Upstream Plugin Audit Report", report)
+ self.assertIn("nu_plugin_skim", report)
+ self.assertIn("idanarye/nu_plugin_skim", report)
+ self.assertIn("0.115", report)
+
+ def test_parse_version_pads_components(self):
+ self.assertEqual(parse_version("0.115"), (0, 115, 0))
+ self.assertEqual(parse_version("0.115.1"), (0, 115, 1))
+ self.assertEqual(parse_version("26.1140.0"), (26, 1140, 0))
+ self.assertIsNone(parse_version("not-a-version"))
+
+ def test_manifest_nu_upper_bound(self):
+ self.assertEqual(
+ manifest_nu_upper_bound(">=0.114.0 <0.115.0"), (0, 115, 0)
+ )
+ self.assertEqual(
+ manifest_nu_upper_bound(">=0.90.0 <0.91.0"), (0, 91, 0)
+ )
+ self.assertEqual(manifest_nu_upper_bound("0.114.1"), (0, 114, 1))
+
+ def test_nu_needs_bump(self):
+ self.assertTrue(nu_needs_bump(">=0.114.0 <0.115.0", "0.115"))
+ self.assertTrue(nu_needs_bump(">=0.114.0 <0.115.0", "0.116.0"))
+ self.assertFalse(nu_needs_bump(">=0.114.0 <0.115.0", "0.114.1"))
+ self.assertFalse(nu_needs_bump(">=0.114.0 <0.115.0", "garbage"))
+
+ def test_fetch_error_fails_closed(self):
+ entry = make_entry()
+ with mock.patch(
+ "check_upstream_updates.fetch_latest_tags",
+ side_effect=FetchError("rate limited"),
+ ):
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "FETCH_ERROR")
+ self.assertIn("rate limited", result["error"])
+
+ def test_tag_provenance_mismatch_flagged(self):
+ entry = make_entry()
+ with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch(
+ "check_upstream_updates.fetch_cargo_toml_nu_dep"
+ ) as cargo, mock.patch(
+ "check_upstream_updates.resolve_tag_commit"
+ ) as resolve:
+ tags.return_value = [{"name": "v0.29.1", "commit_sha": "0" * 40}]
+ cargo.return_value = "0.114.1"
+ resolve.return_value = "f" * 40
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "TAG_PROVENANCE_MISMATCH")
+
+ def test_tag_missing_upstream_flagged(self):
+ entry = make_entry()
+ with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch(
+ "check_upstream_updates.fetch_cargo_toml_nu_dep"
+ ) as cargo, mock.patch(
+ "check_upstream_updates.resolve_tag_commit"
+ ) as resolve:
+ tags.return_value = [{"name": "v0.29.1", "commit_sha": "0" * 40}]
+ cargo.return_value = "0.114.1"
+ resolve.return_value = None
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "TAG_PROVENANCE_MISMATCH")
+
+ def test_tagged_up_to_date(self):
+ entry = make_entry()
+ with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch(
+ "check_upstream_updates.fetch_cargo_toml_nu_dep"
+ ) as cargo, mock.patch(
+ "check_upstream_updates.resolve_tag_commit"
+ ) as resolve:
+ tags.return_value = [{"name": "v0.29.1", "commit_sha": "0" * 40}]
+ cargo.return_value = "0.114"
+ resolve.return_value = entry["source_commit"]
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "UP_TO_DATE")
+ self.assertFalse(result["has_new_tag"])
+
+ def test_new_tag_available(self):
+ entry = make_entry()
+ with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch(
+ "check_upstream_updates.fetch_cargo_toml_nu_dep"
+ ) as cargo, mock.patch(
+ "check_upstream_updates.resolve_tag_commit"
+ ) as resolve:
+ tags.return_value = [{"name": "v0.30.0", "commit_sha": "0" * 40}]
+ cargo.return_value = "0.114"
+ resolve.return_value = entry["source_commit"]
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "NEW_TAG_AVAILABLE")
+ self.assertTrue(result["has_new_tag"])
+
+ def test_nu_bump_with_new_tag_ready_for_bump(self):
+ entry = make_entry()
+ with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch(
+ "check_upstream_updates.fetch_cargo_toml_nu_dep"
+ ) as cargo, mock.patch(
+ "check_upstream_updates.resolve_tag_commit"
+ ) as resolve:
+ tags.return_value = [{"name": "v0.30.0", "commit_sha": "0" * 40}]
+ cargo.return_value = "0.116"
+ resolve.return_value = entry["source_commit"]
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "READY_FOR_BUMP")
+
+ def test_nu_bump_without_new_tag(self):
+ entry = make_entry()
+ with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch(
+ "check_upstream_updates.fetch_cargo_toml_nu_dep"
+ ) as cargo, mock.patch(
+ "check_upstream_updates.resolve_tag_commit"
+ ) as resolve:
+ tags.return_value = [{"name": "v0.29.1", "commit_sha": "0" * 40}]
+ cargo.return_value = "0.116"
+ resolve.return_value = entry["source_commit"]
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "UPSTREAM_NU_BUMP_NO_TAG")
+
+ def test_snapshot_with_newer_tag_flagged(self):
+ entry = make_entry(
+ tag=None,
+ intake_mode="commit-snapshot",
+ source_commit="5a1ca2a5ceba60108a4ca6d45ec18d213abb5227",
+ )
+ with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch(
+ "check_upstream_updates.fetch_cargo_toml_nu_dep"
+ ) as cargo, mock.patch(
+ "check_upstream_updates.resolve_tag_commit"
+ ) as resolve:
+ tags.return_value = [{"name": "v0.104.0", "commit_sha": "0" * 40}]
+ cargo.return_value = "0.105"
+ resolve.return_value = "f" * 40
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "NEW_TAG_AVAILABLE")
+
+ def test_snapshot_at_newest_tag_up_to_date(self):
+ entry = make_entry(
+ tag=None,
+ intake_mode="commit-snapshot",
+ source_commit="5a1ca2a5ceba60108a4ca6d45ec18d213abb5227",
+ )
+ with mock.patch("check_upstream_updates.fetch_latest_tags") as tags, mock.patch(
+ "check_upstream_updates.fetch_cargo_toml_nu_dep"
+ ) as cargo, mock.patch(
+ "check_upstream_updates.resolve_tag_commit"
+ ) as resolve:
+ tags.return_value = [{"name": "v0.104.0", "commit_sha": "0" * 40}]
+ cargo.return_value = "0.104"
+ resolve.return_value = entry["source_commit"]
+ result = audit_entry(entry)
+ self.assertEqual(result["status"], "UP_TO_DATE")
+
+ def test_fetch_error_reported_in_markdown(self):
+ results = [
+ {
+ "repo": "owner/repo",
+ "name": "nu_plugin_x",
+ "current_tag": None,
+ "current_commit": "",
+ "current_nu": "",
+ "latest_tags": [],
+ "newest_tag": None,
+ "cargo_nu_dep": "unknown",
+ "has_new_tag": False,
+ "error": "HTTP 403 from https://api.github.com/...",
+ "status": "FETCH_ERROR",
+ }
+ ]
+ report = generate_markdown_report(results)
+ self.assertIn("FETCH_ERROR", report)
+ self.assertIn("HTTP 403", report)
+
+
+if __name__ == "__main__":
+ unittest.main()