Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/pluginctl/src/pluginctl/core/gh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/pluginctl/src/pluginctl/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 54 additions & 11 deletions .github/pluginctl/src/pluginctl/publish/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -44,7 +45,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
Expand All @@ -58,8 +60,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,
Expand All @@ -80,7 +88,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/<slug>/manifest.json)."""
def g(key):
return plugin_raw.get(key)
Expand All @@ -94,6 +104,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({
Expand All @@ -112,6 +123,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,
})
Expand All @@ -123,9 +135,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({
Expand All @@ -138,7 +151,9 @@ 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_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,
Expand Down Expand Up @@ -340,8 +355,13 @@ 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
changelog_numbers: dict[str, Optional[str]] = {}

for plugin_dir in sorted(glob.glob("plugins/*/")):
plugin_file = os.path.join(plugin_dir, "plugin.json")
Expand Down Expand Up @@ -382,11 +402,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")
Expand All @@ -399,17 +427,27 @@ 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
if not latest_size_set:
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:
Expand All @@ -419,7 +457,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)

Expand All @@ -430,9 +471,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(
Expand Down
15 changes: 10 additions & 5 deletions .github/pluginctl/src/pluginctl/publish/zips.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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
Loading
Loading