Compare commits

...
8 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
Kevin Veen-Birkenbach f6228988e1 Release version 1.15.2
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
Mark stable commit / mark-stable (push) Canceled after 0s
2026-05-28 11:06:43 +02:00
Kevin Veen-Birkenbach 5c7171acd9 fix(config): add alias: infinito for infinito-nexus/core
`pkgmgr install infinito` (and `pkgmgr path infinito`, `pkgmgr version
infinito`, etc.) failed with:

    Identifier 'infinito' did not match any repository in config.

The infinito-nexus/core entry in defaults.yaml had no alias, so the
resolver could only match it via the full id `github.com/infinito-nexus/core`
or the bare repository name `core`. Downstream consumers (the
infinito-nexus-core Dockerfile, roles/sys-cli, roles/web-app-navigator,
and the test-install-pkgmgr CI job) all invoke the short identifier
`infinito` and broke once kpmx 1.15.x was rolled out via the floating
`pkgmgr-<distro>:stable` images.

Registering `alias: infinito` on the entry restores the short identifier
without renaming the repository or touching the consumer side.
2026-05-28 11:05:47 +02:00
Kevin Veen-Birkenbach 06cc5b6725 Release version 1.15.1
Mark stable commit / mark-stable (push) Canceled after 0s
2026-05-28 08:18:23 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.7 ece575cc73 fix(release/changelog): insert new entry under H1 instead of above it
update_changelog used to blindly prepend the new ## [version] entry to
the entire file body. When CHANGELOG.md leads with a # Changelog H1
the result was:

  ## [new] - YYYY-MM-DD
  ...
  # Changelog
  ## [old] - ...

which trips markdownlint MD041 (first-line-h1) and MD012
(no-multiple-blanks), and reads as if the document were two stacked
changelogs.

The new _insert_after_h1 helper:

* Synthesises # Changelog when the file is empty.
* Inserts the entry between the existing H1 (plus any intro prose)
  and the first existing ## release entry.
* For legacy headerless files (file starts with ##) prepends the
  synthesised H1 + entry, so the resulting document is also
  MD041-clean.

Tests cover the three layouts (empty, H1+existing-entries,
legacy-headerless). All 61 release-unit tests stay green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 08:17:35 +02:00
22 changed files with 787 additions and 45 deletions

No files matched your search

+16
View File
@@ -1,3 +1,19 @@
# 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
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
## [1.15.1] - 2026-05-28
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
## [1.15.0] - 2026-05-28
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
+1 -1
View File
@@ -32,7 +32,7 @@
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "1.15.0";
version = "1.16.0";
# Use the git repo as source
src = ./.;
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=1.15.0
pkgver=1.16.0
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')
+20
View File
@@ -1,3 +1,23 @@
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
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 11:06:43 +0200
package-manager (1.15.1-1) unstable; urgency=medium
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 08:18:23 +0200
package-manager (1.15.0-1) unstable; urgency=medium
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
+12 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager
Version: 1.15.0
Version: 1.16.0
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,17 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%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
- Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.1-1
- Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.0-1
- Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kpmx"
version = "1.15.0"
version = "1.16.0"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
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
@@ -1,11 +1,56 @@
from __future__ import annotations
import os
import re
import sys
from datetime import date
from typing import Optional
from .changelog_lint import (
ChangelogLintError,
lint_changelog_entry,
transform_changelog_message,
)
from .editor import _open_editor_for_changelog
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
H2_RE = re.compile(r"^##\s+\S", re.MULTILINE)
def _insert_after_h1(existing: str, entry: str) -> str:
"""Place *entry* after the H1 (and any intro prose), above the first H2.
If the file has no H1 we synthesise ``# Changelog`` so the resulting
document is markdown-lint-clean (MD041 first-line-h1).
If the file has no H2 yet we append *entry* after the H1 block.
Existing behaviour for legacy headerless files (file starts with
``## ``) is preserved: *entry* is prepended unchanged.
"""
if not existing.strip():
return f"# Changelog\n\n{entry}"
if not H1_RE.search(existing):
# Legacy layout: file starts with `## [version]` and has no H1.
# Synthesise the H1 so the merged file is lint-clean.
return f"# Changelog\n\n{entry}{existing.lstrip()}"
# File has an H1. Find the first H2 (existing release section).
h2_match = H2_RE.search(existing)
if h2_match is None:
# H1 + optional intro but no release entries yet — append entry
# after a single blank line.
suffix = (
""
if existing.endswith("\n\n")
else ("\n" if existing.endswith("\n") else "\n\n")
)
return f"{existing}{suffix}{entry}"
# Insert new entry just before the first H2.
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
tail = existing[h2_match.start() :]
return f"{head}{entry}{tail}"
def update_changelog(
changelog_path: str,
@@ -13,50 +58,70 @@ def update_changelog(
message: Optional[str] = None,
preview: bool = False,
) -> str:
"""
Prepend a new release section to CHANGELOG.md with the new version,
current date, and a message.
"""Insert a new release entry into CHANGELOG.md.
The entry is placed after the documents H1 heading (creating one if
missing) and above any existing release entries, so the result stays
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks).
"""
today = date.today().isoformat()
if message is None:
if preview:
message = "Automated release."
else:
print(
"\n[INFO] No release message provided, opening editor for changelog entry...\n"
def _entry_for(raw: str) -> tuple[str, str]:
body = transform_changelog_message(raw).strip() or f"Release {new_version}"
return body, f"## [{new_version}] - {today}\n\n{body}\n\n"
def _print_findings(findings: list[str]) -> None:
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()
if not editor_message:
message = "Automated release."
else:
message = editor_message
header = f"## [{new_version}] - {today}\n"
header += f"\n* {message}\n\n"
elif preview or not sys.stdin.isatty():
body, entry = _entry_for(message or f"Release {new_version}")
else:
attempt: Optional[str] = None
while True:
print(
"\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):
try:
with open(changelog_path, "r", encoding="utf-8") as f:
changelog = f.read()
except Exception as exc:
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
changelog = ""
else:
changelog = ""
new_changelog = header + "\n" + changelog if changelog else header
new_changelog = _insert_after_h1(changelog, entry)
print("\n================ CHANGELOG ENTRY ================")
print(header.rstrip())
print(entry.rstrip())
print("=================================================\n")
if preview:
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
return message
print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
return body
with open(changelog_path, "w", encoding="utf-8") as f:
f.write(new_changelog)
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()
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 = (
f"{first_line}\n\n"
f" * {body_line}\n\n"
f"{indented_body}\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:
tmp_path = tmp.name
tmp.write(
"# Write the changelog entry for this release.\n"
"# Lines starting with '#' will be ignored.\n"
"# Empty result will fall back to a generic message.\n\n"
"; Write the changelog entry for this release.\n"
"; Lines starting with ';' are ignored.\n"
"; A leading '#' becomes bold; `code` becomes italic.\n"
"; Empty result will fall back to a generic message.\n\n"
)
if initial_message:
tmp.write(initial_message.strip() + "\n")
@@ -41,5 +42,5 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
except OSError:
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()
@@ -30,11 +30,18 @@ def update_spec_changelog(
date_str = now.strftime("%a %b %d %Y")
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 = (
f"* {date_str} {author_name} <{author_email}> - {debian_version}\n"
f"- {body_line}\n\n"
f"{dashed_body}\n\n"
)
marker = "%changelog"
+2 -3
View File
@@ -88,9 +88,8 @@ def _release_impl(
print("[INFO] No RPM spec file found. Skipping spec version update.")
effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str):
if changelog_message.strip():
effective_message = changelog_message.strip()
if isinstance(changelog_message, str) and changelog_message.strip():
effective_message = changelog_message.strip()
package_name = resolve_package_name(paths)
+2
View File
@@ -1,4 +1,5 @@
from .archive import handle_archive
from .code_scanning import handle_code_scanning
from .repos import handle_repos_command
from .config import handle_config
from .tools import handle_tools_command
@@ -12,6 +13,7 @@ from .mirror import handle_mirror_command
__all__ = [
"handle_archive",
"handle_code_scanning",
"handle_repos_command",
"handle_config",
"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 (
handle_archive,
handle_code_scanning,
handle_repos_command,
handle_tools_command,
handle_release,
@@ -65,6 +66,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
handle_archive(args, ctx)
return
if args.command == "code-scanning":
handle_code_scanning(args, ctx)
return
commands_with_selection = {
"install",
"update",
+2
View File
@@ -7,6 +7,7 @@ from pkgmgr.cli.proxy import register_proxy_commands
from .archive_cmd import add_archive_subparser
from .branch_cmd import add_branch_subparsers
from .changelog_cmd import add_changelog_subparser
from .code_scanning_cmd import add_code_scanning_subparser
from .common import SortedSubParsersAction
from .config_cmd import add_config_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_mirror_subparsers(subparsers)
add_archive_subparser(subparsers)
add_code_scanning_subparser(subparsers)
register_proxy_commands(subparsers)
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).",
)
+1
View File
@@ -265,6 +265,7 @@ repositories:
- 44D8F11FD62F878E
- B5690EEEBB952194
- account: infinito-nexus
alias: infinito
provider: github.com
description: Infinito.nexus streamlines Linux-based system setups and Docker image administration, perfect for servers and PCs. It offers extensive solutions for system initialization, admin tools, backups, monitoring, updates, driver management, security, and VPNs.
homepage: https://infinito.nexus
@@ -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()
+148 -5
View File
@@ -256,7 +256,7 @@ class TestUpdateSpecVersion(unittest.TestCase):
class TestUpdateChangelog(unittest.TestCase):
def test_update_changelog_creates_file_if_missing(self) -> None:
def test_update_changelog_creates_file_with_h1_if_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
self.assertFalse(os.path.exists(path))
@@ -267,10 +267,35 @@ class TestUpdateChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("## [1.2.3]", content)
# New file must lead with an H1 so markdownlint MD041 is happy.
self.assertTrue(content.startswith("# Changelog\n\n## [1.2.3]"))
self.assertIn("First release", content)
def test_update_changelog_prepends_entry_to_existing_content(self) -> None:
def test_update_changelog_inserts_below_existing_h1(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
existing = "# Changelog\n\n## [0.1.0] - 2024-01-01\n\n* Initial content\n"
with open(path, "w", encoding="utf-8") as f:
f.write(existing)
update_changelog(path, "1.0.0", message="Second release", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# H1 still on top, new entry above the existing one.
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
# Exactly one H1.
self.assertEqual(
content.count("\n# Changelog\n") + content.startswith("# Changelog\n"), 1
)
# Old entry still present, after the new one.
self.assertLess(
content.index("## [1.0.0]"),
content.index("## [0.1.0]"),
)
def test_update_changelog_legacy_headerless_gets_h1_synthesized(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
with open(path, "w", encoding="utf-8") as f:
@@ -281,9 +306,39 @@ class TestUpdateChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertTrue(content.startswith("## [1.0.0]"))
# An H1 is added so MD041 is satisfied even for legacy files.
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
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:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
@@ -323,7 +378,7 @@ class TestUpdateDebianChangelog(unittest.TestCase):
content = f.read()
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("existing content", content)
@@ -352,6 +407,44 @@ class TestUpdateDebianChangelog(unittest.TestCase):
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):
def test_update_spec_changelog_inserts_stanza_after_changelog_marker(self) -> None:
@@ -438,6 +531,56 @@ class TestUpdateSpecChangelog(unittest.TestCase):
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__":
unittest.main()