Compare commits

..
4 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach d0e99b6483 Release version 1.16.0
Mark stable commit / mark-stable (push) Canceled after 0s
2026-06-28 16:54:16 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.8 13fc8fe885 feat(cli): add code-scanning command to download GitHub scan results
pkgmgr code-scanning fetches a repository's GitHub code scanning alerts (and analysis metadata) via the gh CLI and writes alerts.json, a readable summary.md digest, and analyses.json into a timestamped directory (default /tmp/<repo>/code-scanner/<YYYYMMDDHHMMSS>) for offline analysis. The repository is resolved from gh or --repo; authentication is delegated to gh; --state filters by alert state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:37:20 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.8 e8aed49a4c feat(release): lint and normalise the changelog message interactively
The release flow now sanitises the release message into the changelog house style and validates it before writing: a leading '#' heading becomes a bold line, inline `code` becomes *italic*, and the resulting entry is checked with markdownlint-cli2 (using the target repo's own config, i.e. the same rules it enforces) or a built-in fallback. In an interactive run the editor re-opens with the findings until the entry is clean; with --message it transforms then aborts on any finding. The editor's ignore-marker moved from '#' to ';' so heading lines survive as content.

debian/changelog and the RPM %changelog mirror the transformed body, keeping all three changelogs consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:37:19 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.8 87ee6c66c8 fix(release): preserve multi-line changelog bodies in package mirrors
update_debian_changelog and update_spec_changelog wrapped the whole release message in a single prefixed line, so only the first line of a multi-line (already-bulleted) message was indented/dashed while the rest stayed at column 0. That made dpkg-parsechangelog emit 'badly formatted heading line' and rpmspec report 'bad date', failing the package build. Each non-empty body line is now indented (Debian) or dash-prefixed (RPM) individually.

update_changelog no longer auto-prepends a '* ' bullet to the CHANGELOG.md entry: the message is inserted verbatim with blank-line separation, so a caller-provided markdown list stays markdown-lint-clean instead of gaining a stray leading bullet.

Adds regression tests for the multi-line debian/rpm mirroring and the no-injected-bullet behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:37:18 +02:00
21 changed files with 681 additions and 34 deletions

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## [1.16.0] - 2026-06-28
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
* The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
## [1.15.2] - 2026-05-28 ## [1.15.2] - 2026-05-28
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again. * Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
+1 -1
View File
@@ -32,7 +32,7 @@
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "1.15.2"; version = "1.16.0";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=1.15.2 pkgver=1.16.0
pkgrel=1 pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any') arch=('any')
+8
View File
@@ -1,3 +1,11 @@
package-manager (1.16.0-1) unstable; urgency=medium
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
* The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
* The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 28 Jun 2026 16:54:16 +0200
package-manager (1.15.2-1) unstable; urgency=medium package-manager (1.15.2-1) unstable; urgency=medium
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again. * Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
+6 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 1.15.2 Version: 1.16.0
Release: 1%{?dist} Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,11 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/ /usr/lib/package-manager/
%changelog %changelog
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
- * The Debian changelog and the RPM *%changelog* now mirror the full multi-line message correctly — every change line is indented or dash-prefixed — fixing a package build failure where un-indented bodies broke *dpkg-buildpackage* and *rpmspec*; the changelog entry no longer gains an auto-inserted leading bullet.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.2-1 * Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.2-1
- Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again. - Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "kpmx" name = "kpmx"
version = "1.15.2" version = "1.16.0"
description = "Kevin's package-manager tool (pkgmgr)" description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
@@ -0,0 +1,161 @@
"""Download GitHub code scanning results via the ``gh`` CLI.
Fetches all code scanning alerts (and the analysis metadata) for a
repository and writes them, plus a readable digest, into a timestamped
directory so they can be read and analysed offline. Authentication is
delegated to ``gh`` (credentials from its keyring/login).
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, List, Optional
class CodeScanningError(RuntimeError):
"""Raised when code scanning results cannot be downloaded."""
@dataclass
class CodeScanningResult:
repo: str
output_dir: str
alert_count: int
files: List[str] = field(default_factory=list)
def _gh(args: List[str]) -> "subprocess.CompletedProcess[str]":
return subprocess.run(["gh", *args], capture_output=True, text=True)
def _resolve_repo(repo: Optional[str]) -> str:
if repo:
return repo
proc = _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"])
name = proc.stdout.strip()
if proc.returncode != 0 or not name:
raise CodeScanningError(
"could not resolve the current repository; run inside a GitHub "
"repo or pass --repo OWNER/REPO "
f"({proc.stderr.strip()})"
)
return name
def _fetch_json(endpoint: str, params: Optional[List[str]] = None) -> Any:
args = ["api", endpoint, "--paginate"]
for param in params or []:
args += ["-f", param]
proc = _gh(args)
if proc.returncode != 0:
raise CodeScanningError(
f"gh api {endpoint} failed: {proc.stderr.strip() or 'unknown error'}"
)
body = proc.stdout.strip()
return json.loads(body) if body else []
def _alert_row(alert: dict) -> str:
rule = alert.get("rule") or {}
instance = alert.get("most_recent_instance") or {}
location = instance.get("location") or {}
message = (instance.get("message") or {}).get("text", "").strip().replace("\n", " ")
severity = rule.get("security_severity_level") or rule.get("severity") or "unknown"
path = location.get("path", "?")
line = location.get("start_line", "?")
state = alert.get("state", "?")
rule_id = rule.get("id", "?")
return f"- [{severity}] {rule_id} — {path}:{line} ({state})\n {message}"
def _build_summary(repo: str, generated_at: str, alerts: List[dict]) -> str:
by_severity: Counter = Counter()
by_state: Counter = Counter()
by_rule: Counter = Counter()
for alert in alerts:
rule = alert.get("rule") or {}
by_severity[
rule.get("security_severity_level") or rule.get("severity") or "unknown"
] += 1
by_state[alert.get("state", "unknown")] += 1
by_rule[rule.get("id", "unknown")] += 1
lines = [
f"# Code scanning summary — {repo}",
"",
f"- Generated: {generated_at}",
f"- Total alerts: {len(alerts)}",
"",
"## By severity",
"",
]
lines += [f"- {sev}: {count}" for sev, count in by_severity.most_common()] or [
"- none"
]
lines += ["", "## By state", ""]
lines += [f"- {state}: {count}" for state, count in by_state.most_common()] or [
"- none"
]
lines += ["", "## By rule", ""]
lines += [f"- {rule}: {count}" for rule, count in by_rule.most_common()] or [
"- none"
]
lines += ["", "## Alerts", ""]
lines += [_alert_row(alert) for alert in alerts] or ["- none"]
return "\n".join(lines) + "\n"
def download_code_scanning(
repo: Optional[str] = None,
output_dir: Optional[str] = None,
state: Optional[str] = None,
) -> CodeScanningResult:
if not shutil.which("gh"):
raise CodeScanningError("the GitHub CLI 'gh' is not installed or not on PATH")
repo = _resolve_repo(repo)
repo_name = repo.rstrip("/").split("/")[-1]
if output_dir is None:
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
output_dir = os.path.join("/tmp", repo_name, "code-scanner", timestamp)
output_dir = os.path.abspath(os.path.expanduser(output_dir))
os.makedirs(output_dir, exist_ok=True)
generated_at = datetime.now().isoformat(timespec="seconds")
alerts = _fetch_json(
f"repos/{repo}/code-scanning/alerts",
params=[f"state={state}"] if state else None,
)
files: List[str] = []
def _write(name: str, content: str) -> None:
path = os.path.join(output_dir, name)
with open(path, "w", encoding="utf-8") as handle:
handle.write(content)
files.append(path)
_write("alerts.json", json.dumps(alerts, indent=2, ensure_ascii=False) + "\n")
_write("summary.md", _build_summary(repo, generated_at, alerts))
try:
analyses = _fetch_json(f"repos/{repo}/code-scanning/analyses")
_write(
"analyses.json", json.dumps(analyses, indent=2, ensure_ascii=False) + "\n"
)
except CodeScanningError as exc:
_write("analyses.json", json.dumps({"error": str(exc)}, indent=2) + "\n")
return CodeScanningResult(
repo=repo,
output_dir=output_dir,
alert_count=len(alerts),
files=files,
)
@@ -0,0 +1,99 @@
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
_HEADING = re.compile(r"^\s*#{1,6}\s+(.*?)\s*#*\s*$")
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
_FINDING = re.compile(r"\bMD\d{3}\b")
_MULTI_BLANK = re.compile(r"\n{3,}")
class ChangelogLintError(RuntimeError):
"""Raised when a changelog entry cannot be made markdown-lint clean."""
def transform_changelog_message(text: str) -> str:
"""Normalise a free-form release message into the changelog house style.
A leading ``#`` heading becomes a bold line of its own (markdown
headings inside an entry body would collide with the ``## [version]``
structure), and inline ``code`` spans become ``*italic*`` so the entry
stays free of backticks.
"""
text = _INLINE_CODE.sub(r"*\1*", text)
out: list[str] = []
for line in text.split("\n"):
heading = _HEADING.match(line)
if heading:
if out and out[-1].strip():
out.append("")
out.append(f"**{heading.group(1).strip()}**")
out.append("")
else:
out.append(line)
joined = _MULTI_BLANK.sub("\n\n", "\n".join(out))
return joined.strip()
def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
"""Return markdown-lint findings for *entry* (empty list when clean).
The entry is checked inside a minimal ``# Changelog`` document so the
result reflects only the entry, not pre-existing issues in the target
file. ``markdownlint-cli2`` is used when available (picking up the
target repository's own config, i.e. the same rules the repo enforces);
otherwise a small built-in check runs.
"""
document = f"# Changelog\n\n{entry.strip()}\n"
if shutil.which("markdownlint-cli2"):
return _markdownlint(reference_path, document)
return _builtin_lint(document)
def _markdownlint(reference_path: str, document: str) -> list[str]:
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
fd, tmp_path = tempfile.mkstemp(
suffix=".md", prefix=".pkgmgr-changelog-", dir=directory
)
name = os.path.basename(tmp_path)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(document)
proc = subprocess.run(
["markdownlint-cli2", name],
cwd=directory,
capture_output=True,
text=True,
)
if proc.returncode == 0:
return []
output = f"{proc.stdout or ''}{proc.stderr or ''}"
findings = [
line.strip().replace(name, "changelog entry")
for line in output.splitlines()
if _FINDING.search(line)
]
return findings or [output.strip() or "markdown-lint reported an error"]
finally:
try:
os.remove(tmp_path)
except OSError:
pass
def _builtin_lint(document: str) -> list[str]:
errors: list[str] = []
for number, line in enumerate(document.split("\n"), start=1):
if line != line.rstrip():
errors.append(f"line {number}: trailing whitespace (MD009)")
if "`" in line:
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
if "\n\n\n" in document:
errors.append("multiple consecutive blank lines (MD012)")
return errors
@@ -2,9 +2,15 @@ from __future__ import annotations
import os import os
import re import re
import sys
from datetime import date from datetime import date
from typing import Optional from typing import Optional
from .changelog_lint import (
ChangelogLintError,
lint_changelog_entry,
transform_changelog_message,
)
from .editor import _open_editor_for_changelog from .editor import _open_editor_for_changelog
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE) H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
@@ -60,30 +66,49 @@ def update_changelog(
""" """
today = date.today().isoformat() today = date.today().isoformat()
if message is None: def _entry_for(raw: str) -> tuple[str, str]:
if preview: body = transform_changelog_message(raw).strip() or f"Release {new_version}"
message = "Automated release." return body, f"## [{new_version}] - {today}\n\n{body}\n\n"
else:
print( def _print_findings(findings: list[str]) -> None:
"\n[INFO] No release message provided, opening editor for changelog entry...\n" print("\n[ERROR] Changelog entry is not markdown-lint clean:")
for finding in findings:
print(f" - {finding}")
print()
if message is not None:
body, entry = _entry_for(message)
findings = lint_changelog_entry(changelog_path, entry)
if findings:
_print_findings(findings)
raise ChangelogLintError(
"Provided changelog message is not markdown-lint clean."
) )
editor_message = _open_editor_for_changelog() elif preview or not sys.stdin.isatty():
if not editor_message: body, entry = _entry_for(message or f"Release {new_version}")
message = "Automated release." else:
else: attempt: Optional[str] = None
message = editor_message while True:
print(
entry = f"## [{new_version}] - {today}\n\n* {message}\n\n" "\n[INFO] Provide the changelog entry — a leading '#' becomes "
"bold, `code` becomes italic.\n"
)
raw = _open_editor_for_changelog(attempt)
body, entry = _entry_for(raw or f"Release {new_version}")
findings = lint_changelog_entry(changelog_path, entry)
if not findings:
break
_print_findings(findings)
attempt = body
print("[INFO] Re-opening the editor so you can fix the entry...")
changelog = ""
if os.path.exists(changelog_path): if os.path.exists(changelog_path):
try: try:
with open(changelog_path, "r", encoding="utf-8") as f: with open(changelog_path, "r", encoding="utf-8") as f:
changelog = f.read() changelog = f.read()
except Exception as exc: except Exception as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}") print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
else:
changelog = ""
new_changelog = _insert_after_h1(changelog, entry) new_changelog = _insert_after_h1(changelog, entry)
@@ -93,10 +118,10 @@ def update_changelog(
if preview: if preview:
print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md") print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
return message return body
with open(changelog_path, "w", encoding="utf-8") as f: with open(changelog_path, "w", encoding="utf-8") as f:
f.write(new_changelog) f.write(new_changelog)
print(f"Updated CHANGELOG.md with version {new_version}") print(f"Updated CHANGELOG.md with version {new_version}")
return message return body
+9 -2
View File
@@ -47,10 +47,17 @@ def update_debian_changelog(
author_name, author_email = _get_debian_author() author_name, author_email = _get_debian_author()
first_line = f"{package_name} ({debian_version}) unstable; urgency=medium" first_line = f"{package_name} ({debian_version}) unstable; urgency=medium"
body_line = message.strip() if message else f"Automated release {new_version}." body = (
message.strip()
if message and message.strip()
else f"Automated release {new_version}."
)
indented_body = "\n".join(
f" {line}" if line.strip() else line for line in body.split("\n")
)
stanza = ( stanza = (
f"{first_line}\n\n" f"{first_line}\n\n"
f" * {body_line}\n\n" f"{indented_body}\n\n"
f" -- {author_name} <{author_email}> {date_str}\n\n" f" -- {author_name} <{author_email}> {date_str}\n\n"
) )
+5 -4
View File
@@ -16,9 +16,10 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
) as tmp: ) as tmp:
tmp_path = tmp.name tmp_path = tmp.name
tmp.write( tmp.write(
"# Write the changelog entry for this release.\n" "; Write the changelog entry for this release.\n"
"# Lines starting with '#' will be ignored.\n" "; Lines starting with ';' are ignored.\n"
"# Empty result will fall back to a generic message.\n\n" "; A leading '#' becomes bold; `code` becomes italic.\n"
"; Empty result will fall back to a generic message.\n\n"
) )
if initial_message: if initial_message:
tmp.write(initial_message.strip() + "\n") tmp.write(initial_message.strip() + "\n")
@@ -41,5 +42,5 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
except OSError: except OSError:
pass pass
lines = [line for line in content.splitlines() if not line.strip().startswith("#")] lines = [line for line in content.splitlines() if not line.strip().startswith(";")]
return "\n".join(lines).strip() return "\n".join(lines).strip()
@@ -30,11 +30,18 @@ def update_spec_changelog(
date_str = now.strftime("%a %b %d %Y") date_str = now.strftime("%a %b %d %Y")
author_name, author_email = _get_debian_author() author_name, author_email = _get_debian_author()
body_line = message.strip() if message else f"Automated release {new_version}." body = (
message.strip()
if message and message.strip()
else f"Automated release {new_version}."
)
dashed_body = "\n".join(
f"- {line}" if line.strip() else line for line in body.split("\n")
)
stanza = ( stanza = (
f"* {date_str} {author_name} <{author_email}> - {debian_version}\n" f"* {date_str} {author_name} <{author_email}> - {debian_version}\n"
f"- {body_line}\n\n" f"{dashed_body}\n\n"
) )
marker = "%changelog" marker = "%changelog"
+2 -3
View File
@@ -88,9 +88,8 @@ def _release_impl(
print("[INFO] No RPM spec file found. Skipping spec version update.") print("[INFO] No RPM spec file found. Skipping spec version update.")
effective_message: Optional[str] = message effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str): if isinstance(changelog_message, str) and changelog_message.strip():
if changelog_message.strip(): effective_message = changelog_message.strip()
effective_message = changelog_message.strip()
package_name = resolve_package_name(paths) package_name = resolve_package_name(paths)
+2
View File
@@ -1,4 +1,5 @@
from .archive import handle_archive from .archive import handle_archive
from .code_scanning import handle_code_scanning
from .repos import handle_repos_command from .repos import handle_repos_command
from .config import handle_config from .config import handle_config
from .tools import handle_tools_command from .tools import handle_tools_command
@@ -12,6 +13,7 @@ from .mirror import handle_mirror_command
__all__ = [ __all__ = [
"handle_archive", "handle_archive",
"handle_code_scanning",
"handle_repos_command", "handle_repos_command",
"handle_config", "handle_config",
"handle_tools_command", "handle_tools_command",
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import sys
from pkgmgr.actions.code_scanning import CodeScanningError, download_code_scanning
from pkgmgr.cli.context import CLIContext
def handle_code_scanning(args, ctx: CLIContext) -> None:
try:
result = download_code_scanning(
repo=getattr(args, "repo", None),
output_dir=getattr(args, "output", None),
state=getattr(args, "state", None),
)
except CodeScanningError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
sys.exit(1)
print(f"[code-scanning] Repository: {result.repo}")
print(f"[code-scanning] Alerts: {result.alert_count}")
print(f"[code-scanning] Output: {result.output_dir}")
for path in result.files:
print(f" - {path}")
+5
View File
@@ -11,6 +11,7 @@ from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.cli.commands import ( from pkgmgr.cli.commands import (
handle_archive, handle_archive,
handle_code_scanning,
handle_repos_command, handle_repos_command,
handle_tools_command, handle_tools_command,
handle_release, handle_release,
@@ -65,6 +66,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
handle_archive(args, ctx) handle_archive(args, ctx)
return return
if args.command == "code-scanning":
handle_code_scanning(args, ctx)
return
commands_with_selection = { commands_with_selection = {
"install", "install",
"update", "update",
+2
View File
@@ -7,6 +7,7 @@ from pkgmgr.cli.proxy import register_proxy_commands
from .archive_cmd import add_archive_subparser from .archive_cmd import add_archive_subparser
from .branch_cmd import add_branch_subparsers from .branch_cmd import add_branch_subparsers
from .changelog_cmd import add_changelog_subparser from .changelog_cmd import add_changelog_subparser
from .code_scanning_cmd import add_code_scanning_subparser
from .common import SortedSubParsersAction from .common import SortedSubParsersAction
from .config_cmd import add_config_subparsers from .config_cmd import add_config_subparsers
from .install_update import add_install_update_subparsers from .install_update import add_install_update_subparsers
@@ -67,6 +68,7 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
add_make_subparsers(subparsers) add_make_subparsers(subparsers)
add_mirror_subparsers(subparsers) add_mirror_subparsers(subparsers)
add_archive_subparser(subparsers) add_archive_subparser(subparsers)
add_code_scanning_subparser(subparsers)
register_proxy_commands(subparsers) register_proxy_commands(subparsers)
return parser return parser
@@ -0,0 +1,39 @@
from __future__ import annotations
import argparse
def add_code_scanning_subparser(subparsers: argparse._SubParsersAction) -> None:
"""Register the code-scanning subcommand.
Downloads a repository's GitHub code scanning alerts (and analysis
metadata) into a timestamped directory for offline analysis, using the
``gh`` CLI for authentication.
"""
parser = subparsers.add_parser(
"code-scanning",
help="Download GitHub code scanning results for offline analysis.",
description=(
"Fetch all code scanning alerts (plus analysis metadata) for a "
"repository via the gh CLI and write them, with a readable "
"summary, into a timestamped directory. Defaults to the current "
"repository and /tmp/<repo>/code-scanner/<YYYYMMDDHHMMSS>."
),
)
parser.add_argument(
"--repo",
default=None,
help="Target repository as OWNER/REPO (default: current repo via gh).",
)
parser.add_argument(
"--output",
"-o",
default=None,
help=("Output directory (default: /tmp/<repo>/code-scanner/<YYYYMMDDHHMMSS>)."),
)
parser.add_argument(
"--state",
default=None,
choices=["open", "closed", "dismissed", "fixed"],
help="Only download alerts in this state (default: all states).",
)
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
import os
import shutil
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
from pkgmgr.actions.code_scanning import (
CodeScanningError,
download_code_scanning,
)
_ALERTS = [
{
"number": 1,
"state": "open",
"html_url": "https://example/1",
"rule": {
"id": "py/sql-injection",
"security_severity_level": "high",
"severity": "error",
"description": "SQL injection",
},
"most_recent_instance": {
"location": {"path": "app.py", "start_line": 10},
"message": {"text": "Possible SQL injection"},
},
"tool": {"name": "CodeQL"},
}
]
def _cp(stdout: str = "", returncode: int = 0, stderr: str = "") -> SimpleNamespace:
return SimpleNamespace(stdout=stdout, returncode=returncode, stderr=stderr)
def _fake_gh(args):
if args[:2] == ["repo", "view"]:
return _cp(stdout="owner/pkgmgr-cs-test\n")
endpoint = args[1] if len(args) > 1 else ""
if endpoint.endswith("code-scanning/alerts"):
return _cp(stdout=json.dumps(_ALERTS))
if endpoint.endswith("code-scanning/analyses"):
return _cp(stdout=json.dumps([{"id": 42, "created_at": "2026-01-01"}]))
return _cp(returncode=1, stderr=f"unexpected: {args}")
class TestDownloadCodeScanning(unittest.TestCase):
def test_writes_alerts_summary_and_analyses(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
with (
mock.patch(
"pkgmgr.actions.code_scanning.shutil.which",
return_value="/usr/bin/gh",
),
mock.patch("pkgmgr.actions.code_scanning._gh", side_effect=_fake_gh),
):
result = download_code_scanning(output_dir=tmp)
self.assertEqual(result.repo, "owner/pkgmgr-cs-test")
self.assertEqual(result.alert_count, 1)
with open(os.path.join(tmp, "alerts.json"), encoding="utf-8") as f:
self.assertEqual(json.load(f), _ALERTS)
with open(os.path.join(tmp, "summary.md"), encoding="utf-8") as f:
summary = f.read()
self.assertIn("py/sql-injection", summary)
self.assertIn("app.py:10", summary)
self.assertIn("high", summary)
self.assertTrue(os.path.exists(os.path.join(tmp, "analyses.json")))
def test_default_output_dir_uses_repo_name(self) -> None:
with (
mock.patch(
"pkgmgr.actions.code_scanning.shutil.which", return_value="/usr/bin/gh"
),
mock.patch("pkgmgr.actions.code_scanning._gh", side_effect=_fake_gh),
):
result = download_code_scanning()
self.addCleanup(shutil.rmtree, "/tmp/pkgmgr-cs-test", ignore_errors=True)
self.assertTrue(
result.output_dir.startswith("/tmp/pkgmgr-cs-test/code-scanner/"),
msg=result.output_dir,
)
def test_errors_when_gh_missing(self) -> None:
with mock.patch("pkgmgr.actions.code_scanning.shutil.which", return_value=None):
with self.assertRaises(CodeScanningError):
download_code_scanning(output_dir="/tmp/should-not-be-created")
if __name__ == "__main__": # pragma: no cover
unittest.main()
@@ -0,0 +1,44 @@
from __future__ import annotations
import unittest
from pkgmgr.actions.release.files.changelog_lint import (
lint_changelog_entry,
transform_changelog_message,
)
class TestTransformChangelogMessage(unittest.TestCase):
def test_heading_becomes_bold(self) -> None:
out = transform_changelog_message("# Title\n\nbody")
self.assertIn("**Title**", out)
self.assertNotIn("# Title", out)
def test_multi_hash_heading_becomes_bold(self) -> None:
self.assertEqual(
transform_changelog_message("### Sub heading"), "**Sub heading**"
)
def test_inline_code_becomes_italic(self) -> None:
out = transform_changelog_message("use `pkgmgr release` now")
self.assertEqual(out, "use *pkgmgr release* now")
self.assertNotIn("`", out)
def test_plain_message_is_unchanged(self) -> None:
self.assertEqual(transform_changelog_message("just text"), "just text")
def test_no_heading_or_backtick_survives(self) -> None:
out = transform_changelog_message("# Heading\n\n* item with `code`")
self.assertNotIn("`", out)
for line in out.split("\n"):
self.assertFalse(line.lstrip().startswith("#"))
class TestLintChangelogEntry(unittest.TestCase):
def test_clean_entry_has_no_findings(self) -> None:
entry = "## [1.0.0] - 2026-01-01\n\n* a clean bullet\n\n"
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
if __name__ == "__main__": # pragma: no cover
unittest.main()
+118 -1
View File
@@ -310,6 +310,35 @@ class TestUpdateChangelog(unittest.TestCase):
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]")) self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
self.assertIn("## [0.1.0] - 2024-01-01", content) self.assertIn("## [0.1.0] - 2024-01-01", content)
def test_update_changelog_does_not_inject_a_leading_bullet(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
update_changelog(path, "1.2.3", message="* Provided bullet", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("\n\n* Provided bullet\n", content)
self.assertNotIn("* * Provided bullet", content)
def test_update_changelog_transforms_heading_and_inline_code(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
update_changelog(
path,
"1.2.3",
message="# Summary\n\n* uses `foo` tool",
preview=False,
)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("**Summary**", content)
self.assertIn("*foo*", content)
self.assertNotIn("# Summary", content)
self.assertNotIn("`foo`", content)
def test_update_changelog_preview_does_not_write(self) -> None: def test_update_changelog_preview_does_not_write(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md") path = os.path.join(tmpdir, "CHANGELOG.md")
@@ -349,7 +378,7 @@ class TestUpdateDebianChangelog(unittest.TestCase):
content = f.read() content = f.read()
self.assertIn("package-manager (1.2.3-1) unstable; urgency=medium", content) self.assertIn("package-manager (1.2.3-1) unstable; urgency=medium", content)
self.assertIn(" * Test debian entry", content) self.assertIn(" Test debian entry", content)
self.assertIn("Test Maintainer <test@example.com>", content) self.assertIn("Test Maintainer <test@example.com>", content)
self.assertIn("existing content", content) self.assertIn("existing content", content)
@@ -378,6 +407,44 @@ class TestUpdateDebianChangelog(unittest.TestCase):
self.assertEqual(content, original) self.assertEqual(content, original)
def test_update_debian_changelog_indents_every_line_of_multiline_message(
self,
) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "changelog")
with open(path, "w", encoding="utf-8") as f:
f.write("existing content\n")
with patch.dict(
os.environ,
{"DEBFULLNAME": "Test Maintainer", "DEBEMAIL": "test@example.com"},
clear=False,
):
update_debian_changelog(
debian_changelog_path=path,
package_name="package-manager",
new_version="1.2.3",
message="* First bullet\n\n* Second bullet",
preview=False,
)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn(" * First bullet", content)
self.assertIn(" * Second bullet", content)
stanza = content.split(" -- ", 1)[0]
body_lines = [
line
for line in stanza.splitlines()
if line.strip() and not line.startswith("package-manager (")
]
for line in body_lines:
self.assertTrue(
line.startswith(" "),
msg=f"un-indented debian change line: {line!r}",
)
class TestUpdateSpecChangelog(unittest.TestCase): class TestUpdateSpecChangelog(unittest.TestCase):
def test_update_spec_changelog_inserts_stanza_after_changelog_marker(self) -> None: def test_update_spec_changelog_inserts_stanza_after_changelog_marker(self) -> None:
@@ -464,6 +531,56 @@ class TestUpdateSpecChangelog(unittest.TestCase):
self.assertEqual(content, original) self.assertEqual(content, original)
def test_update_spec_changelog_dashes_every_line_of_multiline_message(
self,
) -> None:
original = (
textwrap.dedent(
"""
Name: package-manager
Version: 0.1.0
Release: 5%{?dist}
%changelog
* Mon Jan 01 2024 Old Maintainer <old@example.com> - 0.1.0-1
- Old entry
"""
).strip()
+ "\n"
)
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "package-manager.spec")
with open(path, "w", encoding="utf-8") as f:
f.write(original)
with patch.dict(
os.environ,
{"DEBFULLNAME": "Test Maintainer", "DEBEMAIL": "test@example.com"},
clear=False,
):
update_spec_changelog(
spec_path=path,
package_name="package-manager",
new_version="1.2.3",
message="* First bullet\n\n* Second bullet",
preview=False,
)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("- * First bullet", content)
self.assertIn("- * Second bullet", content)
new_section = content.split("- Old entry", 1)[0].split("%changelog", 1)[1]
for line in new_section.splitlines():
if not line.strip() or line.startswith("* "):
continue
self.assertTrue(
line.startswith("- "),
msg=f"rpm body line would be read as a new entry: {line!r}",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()