From 217ca6fa0e0dba16403279c36e6fae46c2757312 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Wed, 21 Oct 2026 08:39:12 -0400 Subject: [PATCH 1/2] feat(manifest): add GitHub release download totals Fetch paginated GitHub Release asset counters through the workflow token. Emit per-release and per-plugin totals in manifests, and refresh them weekly. --- .github/pluginctl/src/pluginctl/core/gh.py | 43 +++++++++ .../pluginctl/src/pluginctl/feature_flags.py | 1 + .../src/pluginctl/publish/manifest.py | 54 ++++++++--- .github/pluginctl/tests/test_manifest.py | 91 +++++++++++++++++++ .github/workflows/publish-plugins.yml | 2 + docs/maintainer-operations.md | 2 +- 6 files changed, 181 insertions(+), 12 deletions(-) diff --git a/.github/pluginctl/src/pluginctl/core/gh.py b/.github/pluginctl/src/pluginctl/core/gh.py index bb9cea69..35cbaa0d 100644 --- a/.github/pluginctl/src/pluginctl/core/gh.py +++ b/.github/pluginctl/src/pluginctl/core/gh.py @@ -128,6 +128,49 @@ def release_list_tags(repo: str, limit: int = 500) -> list[str]: return [entry.get("tagName", "") for entry in data if entry.get("tagName")] +def release_download_counts(repo: str) -> dict[str, int]: + """Return GitHub Release asset download totals keyed by release tag. + + ``gh api --paginate --slurp`` produces one JSON array containing all REST + response pages. Failures deliberately produce an empty mapping so manifest + generation retains the existing permissive GitHub API behavior. + """ + proc = _run([ + "api", f"repos/{repo}/releases?per_page=100", "--paginate", "--slurp", + ]) + if proc.returncode != 0: + return {} + try: + pages = json.loads(proc.stdout) + except json.JSONDecodeError: + return {} + if not isinstance(pages, list): + return {} + + counts: dict[str, int] = {} + for page in pages: + if not isinstance(page, list): + continue + for release in page: + if not isinstance(release, dict): + continue + tag = release.get("tag_name") + if not isinstance(tag, str) or not tag: + continue + total = 0 + assets = release.get("assets") + if isinstance(assets, list): + for asset in assets: + if not isinstance(asset, dict): + continue + try: + total += max(0, int(asset.get("download_count", 0))) + except (TypeError, ValueError): + continue + counts[tag] = total + return counts + + def release_create(tag: str, repo: str, title: str, notes: str, asset: str) -> int: return _run(["release", "create", tag, "--repo", repo, "--title", title, "--notes", notes, asset]).returncode diff --git a/.github/pluginctl/src/pluginctl/feature_flags.py b/.github/pluginctl/src/pluginctl/feature_flags.py index 4218753e..2df5c993 100644 --- a/.github/pluginctl/src/pluginctl/feature_flags.py +++ b/.github/pluginctl/src/pluginctl/feature_flags.py @@ -3,6 +3,7 @@ CAPABILITY_CONTRACT_DETECTION = False SANDBOX_BYPASS_DETECTION = False SPLIT_MANIFEST_BASE_URLS = False +MANIFEST_DOWNLOAD_TOTALS = True # Experimental disclosure support. Keep disabled until its long-term policy is # decided; this may be removed entirely in a future release. diff --git a/.github/pluginctl/src/pluginctl/publish/manifest.py b/.github/pluginctl/src/pluginctl/publish/manifest.py index 1838c81f..cea6d7dd 100644 --- a/.github/pluginctl/src/pluginctl/publish/manifest.py +++ b/.github/pluginctl/src/pluginctl/publish/manifest.py @@ -44,7 +44,8 @@ def _metadata_fields(metadata: dict, min_da, max_da) -> dict: } -def build_version_entry(metadata: dict, url: str, size, canonical_version: str) -> dict: +def build_version_entry(metadata: dict, url: str, size, canonical_version: str, + download_count: Optional[int] = None) -> dict: """A ``versions[]`` entry. With metadata present, all fields are populated (nulls dropped). With empty @@ -58,8 +59,14 @@ def build_version_entry(metadata: dict, url: str, size, canonical_version: str) metadata.get("max_dispatcharr_version")), "url": url, "size": size, + "download_count": download_count, }) - return {"version": canonical_version, "url": url, "size": size} + return drop_none({ + "version": canonical_version, + "url": url, + "size": size, + "download_count": download_count, + }) def override_current_min_max(versioned_zips: list[dict], current_version: str, @@ -80,7 +87,9 @@ def override_current_min_max(versioned_zips: list[dict], current_version: str, def build_plugin_entry(plugin_raw: dict, plugin_name: str, latest_url: str, registry_url: str, registry_name: str, versioned_zips: list[dict], latest_metadata: dict, - latest_size_kb, ai_assisted_disclosed: bool = False) -> dict: + latest_size_kb, ai_assisted_disclosed: bool = False, + download_count: Optional[int] = None, + latest_download_count: Optional[int] = None) -> dict: """Per-plugin manifest ``.manifest`` payload (metadata//manifest.json).""" def g(key): return plugin_raw.get(key) @@ -94,6 +103,7 @@ def g(key): "latest_url": latest_url, "url": versioned_zips[0]["url"] if versioned_zips else None, "size": latest_size_kb, + "download_count": latest_download_count, }) return drop_none({ @@ -112,6 +122,7 @@ def g(key): "registry_url": registry_url, "registry_name": registry_name, "last_updated": latest_metadata.get("last_updated") if latest_metadata else None, + "download_count": download_count, "latest": latest, "versions": versioned_zips, }) @@ -123,9 +134,10 @@ def trim_description(desc: str) -> str: def build_root_entry(plugin_raw: dict, plugin_name: str, latest_metadata: dict, - latest_size_kb, min_da: str, max_da: str, - latest_url: str, manifest_url: str, - ai_assisted_disclosed: bool = False) -> dict: + latest_size_kb, min_da: str, max_da: str, + latest_url: str, manifest_url: str, + ai_assisted_disclosed: bool = False, + download_count: Optional[int] = None) -> dict: """Compact entry for the root manifest's ``plugins[]``.""" license_id = plugin_raw.get("license") or "" return drop_none({ @@ -138,6 +150,7 @@ def build_root_entry(plugin_raw: dict, plugin_name: str, latest_metadata: dict, "ai_assisted": True if ai_assisted_disclosed else None, "deprecated": True if plugin_raw.get("deprecated") is True else None, "last_updated": latest_metadata.get("last_updated") if latest_metadata else None, + "download_count": download_count, "latest_version": latest_metadata.get("version") if latest_metadata else None, "latest_md5": latest_metadata.get("checksum_md5") if latest_metadata else None, "latest_sha256": latest_metadata.get("checksum_sha256") if latest_metadata else None, @@ -340,6 +353,10 @@ def sign_if_needed(path: str, written: bool) -> None: maybe_sign(path) all_tags = gh.release_list_tags(repository) + release_download_counts = ( + gh.release_download_counts(repository) + if feature_flags.MANIFEST_DOWNLOAD_TOTALS else {} + ) root_entries: list[dict] = [] plugin_count = 0 @@ -382,11 +399,19 @@ def sign_if_needed(path: str, written: bool) -> None: latest_zip_version = "" latest_size_kb = 0 latest_size_set = False + plugin_download_count = 0 + latest_download_count = None for release_tag in tags: zip_version = release_tag[len(prefix):] zip_url = f"{plugin_name}-{zip_version}/{plugin_name}-{zip_version}.zip" canonical_version = _canonical(zip_version) + download_count = ( + release_download_counts.get(release_tag, 0) + if feature_flags.MANIFEST_DOWNLOAD_TOTALS else None + ) + if download_count is not None: + plugin_download_count += download_count metadata: dict = {} fresh = os.path.join(build_meta_dir, plugin_key, f"{plugin_key}-{canonical_version}.json") @@ -406,10 +431,12 @@ def sign_if_needed(path: str, written: bool) -> None: latest_size_kb = zip_size_kb latest_size_set = True - versioned_zips.append(build_version_entry(metadata, zip_url, zip_size_kb, canonical_version)) + versioned_zips.append(build_version_entry( + metadata, zip_url, zip_size_kb, canonical_version, download_count)) if metadata and not latest_metadata: latest_metadata = metadata latest_zip_version = zip_version + latest_download_count = download_count latest_url = "" if latest_zip_version: @@ -419,7 +446,10 @@ def sign_if_needed(path: str, written: bool) -> None: plugin_entry = build_plugin_entry(raw, plugin_name, latest_url, registry_url, registry_name, versioned_zips, latest_metadata, - latest_size_kb, ai_assisted_disclosed) + latest_size_kb, ai_assisted_disclosed, + (plugin_download_count + if feature_flags.MANIFEST_DOWNLOAD_TOTALS else None), + latest_download_count) written = write_manifest_if_changed(existing_manifest_file, plugin_entry, generated_at) sign_if_needed(existing_manifest_file, written) @@ -430,9 +460,11 @@ def sign_if_needed(path: str, written: bool) -> None: if feature_flags.SPLIT_MANIFEST_BASE_URLS else f"{raw_releases_url}/metadata/{plugin_name}/manifest.json") root_entries.append(build_root_entry(raw, plugin_name, latest_metadata, - latest_size_kb, min_da, max_da, - latest_url, manifest_url, - ai_assisted_disclosed)) + latest_size_kb, min_da, max_da, + latest_url, manifest_url, + ai_assisted_disclosed, + (plugin_download_count + if feature_flags.MANIFEST_DOWNLOAD_TOTALS else None))) plugin_count += 1 inner_root = build_root_manifest( diff --git a/.github/pluginctl/tests/test_manifest.py b/.github/pluginctl/tests/test_manifest.py index 6c659891..5469bc86 100644 --- a/.github/pluginctl/tests/test_manifest.py +++ b/.github/pluginctl/tests/test_manifest.py @@ -45,6 +45,40 @@ def test_version_entry_empty_metadata(): assert entry == {"version": "1.2.0", "url": "u", "size": 5} +def test_release_download_counts_sums_assets_across_pages(monkeypatch): + class Result: + returncode = 0 + stdout = json.dumps([ + [{"tag_name": "demo-1.2.0", "assets": [ + {"download_count": 5}, {"download_count": "3"}, {"download_count": -1}, + ]}], + [{"tag_name": "demo-1.1.0", "assets": []}], + ]) + + monkeypatch.setattr(gh, "_run", lambda args: Result()) + + assert gh.release_download_counts("org/repo") == { + "demo-1.2.0": 8, + "demo-1.1.0": 0, + } + + +def test_release_download_counts_ignores_api_and_json_failures(monkeypatch): + class Failed: + returncode = 1 + stdout = "" + + monkeypatch.setattr(gh, "_run", lambda args: Failed()) + assert gh.release_download_counts("org/repo") == {} + + class Invalid: + returncode = 0 + stdout = "not json" + + monkeypatch.setattr(gh, "_run", lambda args: Invalid()) + assert gh.release_download_counts("org/repo") == {} + + def test_override_current_min_max(): zips = [{"version": "1.2.0", "url": "u", "size": 5}] out = m.override_current_min_max(zips, "1.2.0", "v0.9.0", "") @@ -95,6 +129,25 @@ def test_root_entry_key_order(): ] # max_dispatcharr_version "" -> dropped +def test_manifest_download_counts_are_emitted_at_release_and_plugin_levels(): + zips = [ + m.build_version_entry(META, "demo-1.2.0/demo-1.2.0.zip", 10, "1.2.0", 8), + m.build_version_entry({"version": "1.1.0"}, "demo-1.1.0/demo-1.1.0.zip", 9, "1.1.0", 3), + ] + plugin = m.build_plugin_entry( + PLUGIN_RAW, "demo", "demo-1.2.0/demo-1.2.0.zip", "registry", "registry", + zips, META, 10, download_count=11, latest_download_count=8) + root = m.build_root_entry( + PLUGIN_RAW, "demo", META, 10, "", "", "u", "manifest", download_count=11) + + assert [entry["download_count"] for entry in plugin["versions"]] == [8, 3] + assert plugin["latest"]["download_count"] == 8 + assert plugin["download_count"] == 11 + assert root["download_count"] == 11 + assert list(plugin).index("download_count") == list(plugin).index("last_updated") + 1 + assert list(root).index("download_count") == list(root).index("last_updated") + 1 + + def test_trim_description(): assert m.trim_description("x" * 200) == "x" * 200 long = m.trim_description("x" * 250) @@ -271,6 +324,44 @@ def test_generate_emits_and_strips_ai_assisted_with_feature_flag(tmp_path, monke assert "ai_assisted" not in json.loads((tmp_path / "manifest.json").read_text(encoding="utf-8"))["manifest"]["plugins"][0] +def test_generate_emits_and_removes_download_totals_with_feature_flag(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + plugin_dir = tmp_path / "plugins" / "demo" + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.json").write_text( + json.dumps({"name": "Demo", "version": "1.2.0", "description": "d"}), + encoding="utf-8") + build_meta_dir = tmp_path / "build-meta" + metadata_dir = build_meta_dir / "demo" + metadata_dir.mkdir(parents=True) + (metadata_dir / "demo-1.2.0.json").write_text(json.dumps(META), encoding="utf-8") + (metadata_dir / "demo-1.1.0.json").write_text( + json.dumps({**META, "version": "1.1.0"}), encoding="utf-8") + monkeypatch.setattr(gh, "release_list_tags", lambda repo: ["demo-1.2.0", "demo-1.1.0"]) + monkeypatch.setattr(gh, "release_download_counts", lambda repo: { + "demo-1.2.0": 8, + "demo-1.1.0": 3, + }) + monkeypatch.setattr(feature_flags, "MANIFEST_DOWNLOAD_TOTALS", True) + + assert m.generate("main", "releases", "org/repo", str(build_meta_dir)) == 0 + plugin = json.loads((tmp_path / "metadata/demo/manifest.json").read_text(encoding="utf-8"))["manifest"] + root = json.loads((tmp_path / "manifest.json").read_text(encoding="utf-8"))["manifest"] + assert plugin["download_count"] == 11 + assert plugin["latest"]["download_count"] == 8 + assert [entry["download_count"] for entry in plugin["versions"]] == [8, 3] + assert root["plugins"][0]["download_count"] == 11 + + monkeypatch.setattr(feature_flags, "MANIFEST_DOWNLOAD_TOTALS", False) + assert m.generate("main", "releases", "org/repo", str(build_meta_dir)) == 0 + plugin = json.loads((tmp_path / "metadata/demo/manifest.json").read_text(encoding="utf-8"))["manifest"] + root = json.loads((tmp_path / "manifest.json").read_text(encoding="utf-8"))["manifest"] + assert "download_count" not in plugin + assert "download_count" not in plugin["latest"] + assert all("download_count" not in entry for entry in plugin["versions"]) + assert "download_count" not in root["plugins"][0] + + def test_generate_emits_ai_assisted_for_history_evidence(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) plugin_dir = tmp_path / "plugins" / "demo" diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index a036f7b7..d3439e93 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -8,6 +8,8 @@ env: PYTHONPATH: .github/pluginctl/src on: + schedule: + - cron: '0 0 * * 0' push: branches: - main diff --git a/docs/maintainer-operations.md b/docs/maintainer-operations.md index d8d78c81..43985125 100644 --- a/docs/maintainer-operations.md +++ b/docs/maintainer-operations.md @@ -58,7 +58,7 @@ Every skipped candidate is marked for manual review. Successful automatic merges | Workflow | When to use it | Notes | | --- | --- | --- | -| **Publish Plugins** | Publishes plugin releases after changes reach `main`, or rebuilds releases manually. | A full rebuild is destructive. Manual rebuilds require `force_rebuild_confirm` to be `CONFIRM`. Target one plugin with `force_rebuild_plugin` whenever possible. | +| **Publish Plugins** | Publishes plugin releases after changes reach `main`, rebuilds releases manually, and refreshes generated manifests every Sunday at 00:00 UTC. | A full rebuild is destructive. Manual rebuilds require `force_rebuild_confirm` to be `CONFIRM`. Target one plugin with `force_rebuild_plugin` whenever possible. The scheduled run can publish updated GitHub Release download totals when that manifest feature flag is enabled. | | **Yank Plugin Version** | Removes a published version following a Version Removal Request. | Requires the plugin slug, version, and authorizing issue number. It updates releases and closes the request. | | **Run Migrations** | Performs maintainer-directed repository or release-branch migrations. | Review workflow inputs and logs before running it. | | **Update External README** | Synchronizes documentation to the configured external repository. | It is inactive until the optional external-README settings are configured. | From 6eb04c4ce3eb6a89877aee0c6e99bf87d895d755 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Wed, 21 Oct 2026 08:56:54 -0400 Subject: [PATCH 2/2] feat(manifest): add changelog references Record associated pull request numbers while publishing releases and backfill changelog references from stored release commits. --- .../pluginctl/src/pluginctl/publish/manifest.py | 11 +++++++++++ .github/pluginctl/src/pluginctl/publish/zips.py | 15 ++++++++++----- .github/pluginctl/tests/test_manifest.py | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/.github/pluginctl/src/pluginctl/publish/manifest.py b/.github/pluginctl/src/pluginctl/publish/manifest.py index cea6d7dd..ab5d4103 100644 --- a/.github/pluginctl/src/pluginctl/publish/manifest.py +++ b/.github/pluginctl/src/pluginctl/publish/manifest.py @@ -34,6 +34,7 @@ def _metadata_fields(metadata: dict, min_da, max_da) -> dict: "version": metadata.get("version"), "commit_sha": metadata.get("commit_sha"), "commit_sha_short": metadata.get("commit_sha_short"), + "changelog": metadata.get("changelog"), "build_timestamp": metadata.get("build_timestamp"), "last_updated": metadata.get("last_updated"), "checksum_md5": metadata.get("checksum_md5"), @@ -152,6 +153,7 @@ def build_root_entry(plugin_raw: dict, plugin_name: str, latest_metadata: dict, "last_updated": latest_metadata.get("last_updated") if latest_metadata else None, "download_count": download_count, "latest_version": latest_metadata.get("version") if latest_metadata else None, + "latest_changelog": latest_metadata.get("changelog") if latest_metadata else None, "latest_md5": latest_metadata.get("checksum_md5") if latest_metadata else None, "latest_sha256": latest_metadata.get("checksum_sha256") if latest_metadata else None, "latest_url": latest_url, @@ -359,6 +361,7 @@ def sign_if_needed(path: str, written: bool) -> None: ) root_entries: list[dict] = [] plugin_count = 0 + changelog_numbers: dict[str, Optional[str]] = {} for plugin_dir in sorted(glob.glob("plugins/*/")): plugin_file = os.path.join(plugin_dir, "plugin.json") @@ -424,6 +427,14 @@ def sign_if_needed(path: str, written: bool) -> None: metadata = entry break + commit_sha = str(metadata.get("commit_sha") or "") + if metadata and "changelog" not in metadata and commit_sha: + if commit_sha not in changelog_numbers: + changelog_numbers[commit_sha] = gh.commit_pull_number(repository, commit_sha)[0] + changelog = changelog_numbers[commit_sha] + if changelog: + metadata = {**metadata, "changelog": changelog} + zip_size_kb = 0 if metadata: zip_size_kb = metadata.get("size_kb", metadata.get("size", 0)) or 0 diff --git a/.github/pluginctl/src/pluginctl/publish/zips.py b/.github/pluginctl/src/pluginctl/publish/zips.py index 13a5db95..4e8f573b 100644 --- a/.github/pluginctl/src/pluginctl/publish/zips.py +++ b/.github/pluginctl/src/pluginctl/publish/zips.py @@ -76,6 +76,8 @@ def run(source_branch: str, repository: str, build_meta_dir: str) -> int: source_type = raw.get("source_type") or "local" build_timestamp = now_iso() commit_sha, commit_sha_short, commit_date = _commit_info(source_branch, plugin_dir) + pull_request_number, pull_request_url = ( + gh.commit_pull_number(repository, commit_sha) if commit_sha else (None, None)) last_updated = commit_date or now_iso() source_url_resolved = "" @@ -103,6 +105,7 @@ def run(source_branch: str, repository: str, build_meta_dir: str) -> int: "version": version, "commit_sha": commit_sha or None, "commit_sha_short": commit_sha_short or None, + "changelog": pull_request_number, "build_timestamp": build_timestamp, "last_updated": last_updated, "checksum_md5": checksum_md5, @@ -117,7 +120,9 @@ def run(source_branch: str, repository: str, build_meta_dir: str) -> int: "w", encoding="utf-8") as fh: fh.write(jsonio.dumps(meta)) - notes = _release_notes(repository, plugin_name, commit_sha, commit_sha_short) + notes = _release_notes( + repository, plugin_name, commit_sha, commit_sha_short, + pull_request_number, pull_request_url) actions.log(f" {plugin_name} v{version} - uploading to GitHub Releases") gh.release_create(release_tag, repository, f"{plugin_name} v{version}", notes, zip_path) @@ -129,15 +134,15 @@ def run(source_branch: str, repository: str, build_meta_dir: str) -> int: def _release_notes(repository: str, plugin_name: str, commit_sha: str, - commit_sha_short: str) -> str: + commit_sha_short: str, pull_request_number: Optional[str] = None, + pull_request_url: Optional[str] = None) -> str: readme_url = f"https://github.com/{repository}/blob/releases/metadata/{plugin_name}/README.md" notes = "" if commit_sha: commit_url = f"https://github.com/{repository}/commit/{commit_sha}" notes = f"**Commit:** [`{commit_sha_short}`]({commit_url})" - pr_number, pr_url = gh.commit_pull_number(repository, commit_sha) - if pr_number: - notes += f"\n**PR:** [#{pr_number}]({pr_url})" + if pull_request_number: + notes += f"\n**PR:** [#{pull_request_number}]({pull_request_url})" notes += "\n" notes += f"**README:** [Plugin README]({readme_url})" return notes diff --git a/.github/pluginctl/tests/test_manifest.py b/.github/pluginctl/tests/test_manifest.py index 5469bc86..4a3091bf 100644 --- a/.github/pluginctl/tests/test_manifest.py +++ b/.github/pluginctl/tests/test_manifest.py @@ -129,6 +129,18 @@ def test_root_entry_key_order(): ] # max_dispatcharr_version "" -> dropped +def test_manifest_emits_changelog_numbers_when_available(): + metadata = {**META, "changelog": "42"} + version = m.build_version_entry(metadata, "u", 10, "1.2.0") + plugin = m.build_plugin_entry( + PLUGIN_RAW, "demo", "u", "registry", "registry", [version], metadata, 10) + root = m.build_root_entry(PLUGIN_RAW, "demo", metadata, 10, "", "", "u", "manifest") + + assert version["changelog"] == "42" + assert plugin["latest"]["changelog"] == "42" + assert root["latest_changelog"] == "42" + + def test_manifest_download_counts_are_emitted_at_release_and_plugin_levels(): zips = [ m.build_version_entry(META, "demo-1.2.0/demo-1.2.0.zip", 10, "1.2.0", 8), @@ -342,6 +354,7 @@ def test_generate_emits_and_removes_download_totals_with_feature_flag(tmp_path, "demo-1.2.0": 8, "demo-1.1.0": 3, }) + monkeypatch.setattr(gh, "commit_pull_number", lambda repo, sha: ("42", "https://example.test/pr/42")) monkeypatch.setattr(feature_flags, "MANIFEST_DOWNLOAD_TOTALS", True) assert m.generate("main", "releases", "org/repo", str(build_meta_dir)) == 0 @@ -351,6 +364,9 @@ def test_generate_emits_and_removes_download_totals_with_feature_flag(tmp_path, assert plugin["latest"]["download_count"] == 8 assert [entry["download_count"] for entry in plugin["versions"]] == [8, 3] assert root["plugins"][0]["download_count"] == 11 + assert [entry["changelog"] for entry in plugin["versions"]] == ["42", "42"] + assert plugin["latest"]["changelog"] == "42" + assert root["plugins"][0]["latest_changelog"] == "42" monkeypatch.setattr(feature_flags, "MANIFEST_DOWNLOAD_TOTALS", False) assert m.generate("main", "releases", "org/repo", str(build_meta_dir)) == 0 @@ -360,6 +376,7 @@ def test_generate_emits_and_removes_download_totals_with_feature_flag(tmp_path, assert "download_count" not in plugin["latest"] assert all("download_count" not in entry for entry in plugin["versions"]) assert "download_count" not in root["plugins"][0] + assert root["plugins"][0]["latest_changelog"] == "42" def test_generate_emits_ai_assisted_for_history_evidence(tmp_path, monkeypatch):