Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e99b6483 | ||
|
|
13fc8fe885 | ||
|
|
e8aed49a4c | ||
|
|
87ee6c66c8 | ||
|
|
f6228988e1 | ||
|
|
5c7171acd9 | ||
|
|
06cc5b6725 | ||
|
|
ece575cc73 | ||
|
|
a4099717be | ||
|
|
a37b9ed8a7 |
No files matched your search
@@ -1,3 +1,24 @@
|
|||||||
|
# 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.14.0] - 2026-05-27
|
## [1.14.0] - 2026-05-27
|
||||||
|
|
||||||
* Added
|
* Added
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "1.14.0";
|
version = "1.16.0";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
@@ -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.14.0
|
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')
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 07:56:07 +0200
|
||||||
|
|
||||||
package-manager (1.14.0-1) unstable; urgency=medium
|
package-manager (1.14.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* Added
|
* Added
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 1.14.0
|
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,20 @@ 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
|
||||||
|
- 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.
|
||||||
|
|
||||||
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
|
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
|
||||||
- Added
|
- Added
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "1.14.0"
|
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,31 @@
|
|||||||
|
"""Archive fully-checked Markdown files into a README index, then delete them.
|
||||||
|
|
||||||
|
The archive action walks a directory for numbered ``NNN-topic.md`` files
|
||||||
|
(default pattern ``^\\d{3}-[^/]+\\.md$``), promotes every file with zero
|
||||||
|
unchecked ``- [ ]`` task-list markers into a ``## Archive`` index inside
|
||||||
|
the directory README, and deletes the per-file source. Useful for
|
||||||
|
keeping ``docs/requirements/`` (or any other task-tracked spec folder)
|
||||||
|
short and focused on open work.
|
||||||
|
|
||||||
|
The module was extracted from
|
||||||
|
``cli/contributing/requirements/archive`` in infinito-nexus-core so
|
||||||
|
every kpmx-managed repository can rely on the same archival convention
|
||||||
|
without copy-pasting the helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .discovery import iter_archivable_files
|
||||||
|
from .inspect import count_unchecked_items, extract_h1
|
||||||
|
from .readme import existing_archive_entries, merge_archive_section
|
||||||
|
from .workflow import ArchivePlan, run_archive
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ArchivePlan",
|
||||||
|
"count_unchecked_items",
|
||||||
|
"existing_archive_entries",
|
||||||
|
"extract_h1",
|
||||||
|
"iter_archivable_files",
|
||||||
|
"merge_archive_section",
|
||||||
|
"run_archive",
|
||||||
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Locate archivable Markdown files under a target directory."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
|
||||||
|
TEMPLATE_FILENAME = "000-template.md"
|
||||||
|
|
||||||
|
|
||||||
|
def iter_archivable_files(
|
||||||
|
directory: Path,
|
||||||
|
*,
|
||||||
|
include_template: bool = False,
|
||||||
|
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
|
||||||
|
template_filename: str = TEMPLATE_FILENAME,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Return all files in *directory* whose name matches *pattern*, sorted.
|
||||||
|
|
||||||
|
``000-template.md`` (or whatever *template_filename* matches) is
|
||||||
|
excluded unless *include_template* is true. The check is filename
|
||||||
|
based; nested directories are not traversed.
|
||||||
|
"""
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
files: list[Path] = []
|
||||||
|
for path in sorted(directory.iterdir()):
|
||||||
|
if not path.is_file() or not pattern.match(path.name):
|
||||||
|
continue
|
||||||
|
if not include_template and path.name == template_filename:
|
||||||
|
continue
|
||||||
|
files.append(path)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def filter_archivable_files(
|
||||||
|
paths: Iterable[Path],
|
||||||
|
*,
|
||||||
|
include_template: bool = False,
|
||||||
|
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
|
||||||
|
template_filename: str = TEMPLATE_FILENAME,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Same predicate as :func:`iter_archivable_files`, applied to an iterable."""
|
||||||
|
result: list[Path] = []
|
||||||
|
for path in paths:
|
||||||
|
if not path.is_file() or not pattern.match(path.name):
|
||||||
|
continue
|
||||||
|
if not include_template and path.name == template_filename:
|
||||||
|
continue
|
||||||
|
result.append(path)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Parse a single Markdown file: H1 heading and task-list completeness."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
H1_RE = re.compile(r"^#\s+(?P<title>\S.*?)\s*$")
|
||||||
|
UNCHECKED_TASK_RE = re.compile(r"^\s*[-*+]\s+\[\s\]\s")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_h1(path: Path) -> str | None:
|
||||||
|
"""Return the first H1 title in *path* or ``None`` if there is none."""
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
match = H1_RE.match(line.rstrip("\n"))
|
||||||
|
if match:
|
||||||
|
return match.group("title")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def count_unchecked_items(path: Path) -> int:
|
||||||
|
"""Return the number of ``- [ ]`` task-list markers anywhere in *path*.
|
||||||
|
|
||||||
|
A non-zero count means the file is not yet fully complete and MUST
|
||||||
|
NOT be archived.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with path.open(encoding="utf-8") as fh:
|
||||||
|
return sum(1 for line in fh if UNCHECKED_TASK_RE.match(line))
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Read and update the ``## Archive`` section of a directory README."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
ARCHIVE_HEADING = "## Archive"
|
||||||
|
LIST_ITEM_RE = re.compile(r"^\s*-\s+(?P<body>\S.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def existing_archive_entries(readme_text: str) -> set[str]:
|
||||||
|
"""Return the deduplicated set of list-item bodies under ``## Archive``."""
|
||||||
|
lines = readme_text.splitlines()
|
||||||
|
in_archive = False
|
||||||
|
entries: set[str] = set()
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.rstrip()
|
||||||
|
if stripped == ARCHIVE_HEADING:
|
||||||
|
in_archive = True
|
||||||
|
continue
|
||||||
|
if in_archive and stripped.startswith("## "):
|
||||||
|
break
|
||||||
|
if not in_archive:
|
||||||
|
continue
|
||||||
|
match = LIST_ITEM_RE.match(stripped)
|
||||||
|
if match:
|
||||||
|
entries.add(match.group("body").strip())
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def merge_archive_section(readme_text: str, new_entries: list[str]) -> str:
|
||||||
|
"""Return ``readme_text`` with *new_entries* appended under ``## Archive``.
|
||||||
|
|
||||||
|
Existing entries are preserved verbatim. If the section is missing it
|
||||||
|
is created at the end of the document.
|
||||||
|
"""
|
||||||
|
if not new_entries:
|
||||||
|
return readme_text
|
||||||
|
|
||||||
|
lines = readme_text.splitlines()
|
||||||
|
archive_index = next(
|
||||||
|
(i for i, line in enumerate(lines) if line.rstrip() == ARCHIVE_HEADING),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if archive_index is None:
|
||||||
|
suffix = [""] if (lines and lines[-1] != "") else []
|
||||||
|
suffix.append(ARCHIVE_HEADING)
|
||||||
|
suffix.append("")
|
||||||
|
suffix.extend(f"- {entry}" for entry in new_entries)
|
||||||
|
merged = lines + suffix
|
||||||
|
return "\n".join(merged) + "\n"
|
||||||
|
|
||||||
|
section_end = next(
|
||||||
|
(i for i in range(archive_index + 1, len(lines)) if lines[i].startswith("## ")),
|
||||||
|
len(lines),
|
||||||
|
)
|
||||||
|
|
||||||
|
body_start = archive_index + 1
|
||||||
|
while body_start < section_end and not lines[body_start].strip():
|
||||||
|
body_start += 1
|
||||||
|
|
||||||
|
last_item = body_start - 1
|
||||||
|
for i in range(body_start, section_end):
|
||||||
|
if LIST_ITEM_RE.match(lines[i]):
|
||||||
|
last_item = i
|
||||||
|
|
||||||
|
insertion_point = (last_item + 1) if last_item >= body_start else body_start
|
||||||
|
if insertion_point == body_start and body_start == archive_index + 1:
|
||||||
|
new_block = ["", *[f"- {entry}" for entry in new_entries]]
|
||||||
|
else:
|
||||||
|
new_block = [f"- {entry}" for entry in new_entries]
|
||||||
|
|
||||||
|
merged = lines[:insertion_point] + new_block + lines[insertion_point:]
|
||||||
|
trailing = "\n" if readme_text.endswith("\n") else ""
|
||||||
|
return "\n".join(merged) + trailing
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Orchestrator for archiving fully-checked Markdown files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .discovery import iter_archivable_files
|
||||||
|
from .inspect import count_unchecked_items, extract_h1
|
||||||
|
from .readme import existing_archive_entries, merge_archive_section
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArchivePlan:
|
||||||
|
"""Outcome of an archive analysis run.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
archived: ``(source_path, title)`` for every file that was (or
|
||||||
|
would be) archived. Order matches the original directory
|
||||||
|
listing.
|
||||||
|
skipped_incomplete: ``(source_path, unchecked_count)`` for files
|
||||||
|
that still hold ``- [ ]`` markers.
|
||||||
|
skipped_without_h1: files that had no H1 heading to use as title.
|
||||||
|
new_entries: titles that will be appended to the README index.
|
||||||
|
existing_entries: titles already present in the README index.
|
||||||
|
"""
|
||||||
|
|
||||||
|
archived: list[tuple[Path, str]]
|
||||||
|
skipped_incomplete: list[tuple[Path, int]]
|
||||||
|
skipped_without_h1: list[Path]
|
||||||
|
new_entries: list[str]
|
||||||
|
existing_entries: set[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_files(
|
||||||
|
files: list[Path],
|
||||||
|
) -> tuple[
|
||||||
|
list[tuple[Path, str]],
|
||||||
|
list[tuple[Path, int]],
|
||||||
|
list[Path],
|
||||||
|
]:
|
||||||
|
plan: list[tuple[Path, str]] = []
|
||||||
|
skipped_incomplete: list[tuple[Path, int]] = []
|
||||||
|
skipped_without_h1: list[Path] = []
|
||||||
|
for path in files:
|
||||||
|
unchecked = count_unchecked_items(path)
|
||||||
|
if unchecked > 0:
|
||||||
|
skipped_incomplete.append((path, unchecked))
|
||||||
|
continue
|
||||||
|
title = extract_h1(path)
|
||||||
|
if title is None:
|
||||||
|
skipped_without_h1.append(path)
|
||||||
|
continue
|
||||||
|
plan.append((path, title))
|
||||||
|
return plan, skipped_incomplete, skipped_without_h1
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_titles(
|
||||||
|
plan: list[tuple[Path, str]], already_archived: set[str]
|
||||||
|
) -> list[str]:
|
||||||
|
new_entries: list[str] = []
|
||||||
|
for _path, title in plan:
|
||||||
|
if title in already_archived or title in new_entries:
|
||||||
|
continue
|
||||||
|
new_entries.append(title)
|
||||||
|
return new_entries
|
||||||
|
|
||||||
|
|
||||||
|
def run_archive(
|
||||||
|
directory: Path,
|
||||||
|
readme_path: Path,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
include_template: bool = False,
|
||||||
|
) -> ArchivePlan:
|
||||||
|
"""Walk *directory* and archive every fully-checked file into *readme_path*.
|
||||||
|
|
||||||
|
Returns an :class:`ArchivePlan` describing the outcome. When
|
||||||
|
``dry_run`` is true no files are deleted and the README is not
|
||||||
|
rewritten — the plan still reflects what *would* happen.
|
||||||
|
|
||||||
|
Raises ``FileNotFoundError`` if *directory* or *readme_path* does
|
||||||
|
not exist.
|
||||||
|
"""
|
||||||
|
if not directory.is_dir():
|
||||||
|
raise FileNotFoundError(f"Archive directory not found: {directory}")
|
||||||
|
if not readme_path.is_file():
|
||||||
|
raise FileNotFoundError(f"README not found: {readme_path}")
|
||||||
|
|
||||||
|
files = iter_archivable_files(directory, include_template=include_template)
|
||||||
|
readme_text = readme_path.read_text(encoding="utf-8")
|
||||||
|
already_archived = existing_archive_entries(readme_text)
|
||||||
|
|
||||||
|
archived, skipped_incomplete, skipped_without_h1 = _bucket_files(files)
|
||||||
|
new_entries = _dedupe_titles(archived, already_archived)
|
||||||
|
|
||||||
|
if not dry_run and new_entries:
|
||||||
|
merged_text = merge_archive_section(readme_text, new_entries)
|
||||||
|
if merged_text != readme_text:
|
||||||
|
readme_path.write_text(merged_text, encoding="utf-8")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
for path, _title in archived:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ArchivePlan(
|
||||||
|
archived=archived,
|
||||||
|
skipped_incomplete=skipped_incomplete,
|
||||||
|
skipped_without_h1=skipped_without_h1,
|
||||||
|
new_entries=new_entries,
|
||||||
|
existing_entries=already_archived,
|
||||||
|
)
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
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)
|
||||||
|
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(
|
def update_changelog(
|
||||||
changelog_path: str,
|
changelog_path: str,
|
||||||
@@ -13,50 +58,70 @@ def update_changelog(
|
|||||||
message: Optional[str] = None,
|
message: Optional[str] = None,
|
||||||
preview: bool = False,
|
preview: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Insert a new release entry into CHANGELOG.md.
|
||||||
Prepend a new release section to CHANGELOG.md with the new version,
|
|
||||||
current date, and a message.
|
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()
|
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:
|
||||||
message = editor_message
|
attempt: Optional[str] = None
|
||||||
|
while True:
|
||||||
header = f"## [{new_version}] - {today}\n"
|
print(
|
||||||
header += f"\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 = header + "\n" + changelog if changelog else header
|
new_changelog = _insert_after_h1(changelog, entry)
|
||||||
|
|
||||||
print("\n================ CHANGELOG ENTRY ================")
|
print("\n================ CHANGELOG ENTRY ================")
|
||||||
print(header.rstrip())
|
print(entry.rstrip())
|
||||||
print("=================================================\n")
|
print("=================================================\n")
|
||||||
|
|
||||||
if preview:
|
if preview:
|
||||||
print(f"[PREVIEW] Would prepend new entry for {new_version} to 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
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ 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)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -10,6 +12,8 @@ from .branch import handle_branch
|
|||||||
from .mirror import handle_mirror_command
|
from .mirror import handle_mirror_command
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"handle_archive",
|
||||||
|
"handle_code_scanning",
|
||||||
"handle_repos_command",
|
"handle_repos_command",
|
||||||
"handle_config",
|
"handle_config",
|
||||||
"handle_tools_command",
|
"handle_tools_command",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive import ArchivePlan, run_archive
|
||||||
|
from pkgmgr.cli.context import CLIContext
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(
|
||||||
|
directory: Path, readme: Path, plan: ArchivePlan, dry_run: bool
|
||||||
|
) -> None:
|
||||||
|
print(f"[archive] Directory: {directory}")
|
||||||
|
print(f"[archive] README: {readme}")
|
||||||
|
print(f"[archive] Files to process: {len(plan.archived)}")
|
||||||
|
print(f"[archive] Skipped (incomplete): {len(plan.skipped_incomplete)}")
|
||||||
|
print(f"[archive] New archive entries: {len(plan.new_entries)}")
|
||||||
|
print(f"[archive] Dry-run: {dry_run}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_skips(plan: ArchivePlan, cwd: Path) -> None:
|
||||||
|
if plan.skipped_incomplete:
|
||||||
|
print(
|
||||||
|
"[archive] SKIP: files with unchecked `- [ ]` items "
|
||||||
|
"(not archived, not deleted):"
|
||||||
|
)
|
||||||
|
for path, count in plan.skipped_incomplete:
|
||||||
|
suffix = "s" if count != 1 else ""
|
||||||
|
rel = _rel_or_abs(path, cwd)
|
||||||
|
print(f" - {rel} ({count} unchecked item{suffix})")
|
||||||
|
if plan.skipped_without_h1:
|
||||||
|
print("[archive] WARN: skipped files without an H1 heading:")
|
||||||
|
for path in plan.skipped_without_h1:
|
||||||
|
print(f" - {_rel_or_abs(path, cwd)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_actions(plan: ArchivePlan, cwd: Path, dry_run: bool) -> None:
|
||||||
|
verb = "would archive" if dry_run else "archived"
|
||||||
|
rm_verb = "would delete" if dry_run else "deleted"
|
||||||
|
for path, title in plan.archived:
|
||||||
|
rel = _rel_or_abs(path, cwd)
|
||||||
|
print(f"[archive] {verb}: {rel} -> '{title}'")
|
||||||
|
if not dry_run:
|
||||||
|
print(f"[archive] {rm_verb}: {rel}")
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_or_abs(path: Path, cwd: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.resolve().relative_to(cwd).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_archive(args, _ctx: CLIContext) -> None:
|
||||||
|
directory = Path(args.directory).resolve()
|
||||||
|
readme = (
|
||||||
|
Path(args.readme).resolve()
|
||||||
|
if args.readme
|
||||||
|
else (directory / "README.md").resolve()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = run_archive(
|
||||||
|
directory=directory,
|
||||||
|
readme_path=readme,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
include_template=args.include_template,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
print(f"[archive] ERROR: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cwd = Path.cwd().resolve()
|
||||||
|
_print_summary(directory, readme, plan, args.dry_run)
|
||||||
|
_print_skips(plan, cwd)
|
||||||
|
_print_actions(plan, cwd, args.dry_run)
|
||||||
@@ -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}")
|
||||||
@@ -10,6 +10,8 @@ from pkgmgr.core.repository.selected import get_selected_repos
|
|||||||
from pkgmgr.core.repository.dir import get_repo_dir
|
from pkgmgr.core.repository.dir import get_repo_dir
|
||||||
|
|
||||||
from pkgmgr.cli.commands import (
|
from pkgmgr.cli.commands import (
|
||||||
|
handle_archive,
|
||||||
|
handle_code_scanning,
|
||||||
handle_repos_command,
|
handle_repos_command,
|
||||||
handle_tools_command,
|
handle_tools_command,
|
||||||
handle_release,
|
handle_release,
|
||||||
@@ -60,6 +62,14 @@ def dispatch_command(args, ctx: CLIContext) -> None:
|
|||||||
if maybe_handle_proxy(args, ctx):
|
if maybe_handle_proxy(args, ctx):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.command == "archive":
|
||||||
|
handle_archive(args, ctx)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "code-scanning":
|
||||||
|
handle_code_scanning(args, ctx)
|
||||||
|
return
|
||||||
|
|
||||||
commands_with_selection = {
|
commands_with_selection = {
|
||||||
"install",
|
"install",
|
||||||
"update",
|
"update",
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import argparse
|
|||||||
|
|
||||||
from pkgmgr.cli.proxy import register_proxy_commands
|
from pkgmgr.cli.proxy import register_proxy_commands
|
||||||
|
|
||||||
|
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
|
||||||
@@ -65,6 +67,8 @@ 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_code_scanning_subparser(subparsers)
|
||||||
|
|
||||||
register_proxy_commands(subparsers)
|
register_proxy_commands(subparsers)
|
||||||
return parser
|
return parser
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def add_archive_subparser(subparsers: argparse._SubParsersAction) -> None:
|
||||||
|
"""Register the archive subcommand.
|
||||||
|
|
||||||
|
Walks a directory of numbered ``NNN-topic.md`` files, promotes every
|
||||||
|
file whose ``- [ ]`` checklist is fully checked into the directorys
|
||||||
|
README ``## Archive`` section, then deletes the source file.
|
||||||
|
"""
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
"archive",
|
||||||
|
help=(
|
||||||
|
"Archive fully-checked Markdown spec files (NNN-topic.md) "
|
||||||
|
"into the directorys README and delete them."
|
||||||
|
),
|
||||||
|
description=(
|
||||||
|
"Walk DIR for files that match the numbered "
|
||||||
|
"NNN-topic.md naming, promote every file with zero "
|
||||||
|
"unchecked `- [ ]` items into READMEs ## Archive section, "
|
||||||
|
"then delete the source file. Files with unchecked items are "
|
||||||
|
"skipped. Use --dry-run to preview without writing."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"directory",
|
||||||
|
nargs="?",
|
||||||
|
default="docs/requirements",
|
||||||
|
help=(
|
||||||
|
"Directory to scan for archivable Markdown files. "
|
||||||
|
"Defaults to docs/requirements (relative to the current "
|
||||||
|
"working directory)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--readme",
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Path to the README that holds the ## Archive index. "
|
||||||
|
"Defaults to <directory>/README.md."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Print planned changes without modifying or deleting anything.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-template",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Also archive and delete 000-template.md. Off by default "
|
||||||
|
"because contributor guides typically reference it."
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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).",
|
||||||
|
)
|
||||||
@@ -265,6 +265,7 @@ repositories:
|
|||||||
- 44D8F11FD62F878E
|
- 44D8F11FD62F878E
|
||||||
- B5690EEEBB952194
|
- B5690EEEBB952194
|
||||||
- account: infinito-nexus
|
- account: infinito-nexus
|
||||||
|
alias: infinito
|
||||||
provider: github.com
|
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.
|
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
|
homepage: https://infinito.nexus
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1,49 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.archive.discovery`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive.discovery import iter_archivable_files
|
||||||
|
|
||||||
|
|
||||||
|
class TestIterArchivableFiles(unittest.TestCase):
|
||||||
|
def test_only_numbered_markdown_files_are_returned_and_sorted(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
(d / "001-alpha.md").write_text("# 001 - A")
|
||||||
|
(d / "002-beta.md").write_text("# 002 - B")
|
||||||
|
(d / "README.md").write_text("# X")
|
||||||
|
(d / "notes.md").write_text("# X")
|
||||||
|
(d / "001-alpha.txt").write_text("x")
|
||||||
|
|
||||||
|
result = [p.name for p in iter_archivable_files(d, include_template=True)]
|
||||||
|
self.assertEqual(result, ["001-alpha.md", "002-beta.md"])
|
||||||
|
|
||||||
|
def test_template_is_skipped_unless_opted_in(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
(d / "000-template.md").write_text("# 000 - Template")
|
||||||
|
(d / "001-alpha.md").write_text("# 001 - A")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[p.name for p in iter_archivable_files(d, include_template=False)],
|
||||||
|
["001-alpha.md"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.name for p in iter_archivable_files(d, include_template=True)],
|
||||||
|
["000-template.md", "001-alpha.md"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_directory_returns_empty(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self.assertEqual(
|
||||||
|
iter_archivable_files(Path(tmp) / "missing", include_template=False),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.archive.inspect`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive.inspect import count_unchecked_items, extract_h1
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractH1(unittest.TestCase):
|
||||||
|
def test_returns_first_h1_title(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text("\n# 001 - Title\n\n## Subsection\n# Later H1\n")
|
||||||
|
self.assertEqual(extract_h1(path), "001 - Title")
|
||||||
|
|
||||||
|
def test_returns_none_when_no_h1(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text("no heading here\n## h2 only\n")
|
||||||
|
self.assertIsNone(extract_h1(path))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCountUncheckedItems(unittest.TestCase):
|
||||||
|
def test_zero_when_all_checked(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text(
|
||||||
|
"# 001 - Done\n\n## Acceptance Criteria\n\n- [x] A\n- [x] B\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(count_unchecked_items(path), 0)
|
||||||
|
|
||||||
|
def test_counts_unchecked_anywhere_in_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text(
|
||||||
|
"# 001 - In progress\n\n"
|
||||||
|
"## Acceptance Criteria\n\n"
|
||||||
|
"- [x] A\n"
|
||||||
|
"- [ ] B\n\n"
|
||||||
|
"## Notes\n\n"
|
||||||
|
"- [ ] still tracking this one too\n"
|
||||||
|
" - [ ] nested unchecked\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(count_unchecked_items(path), 3)
|
||||||
|
|
||||||
|
def test_ignores_non_task_dashes(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "001-x.md"
|
||||||
|
path.write_text(
|
||||||
|
"# 001 - X\n\n"
|
||||||
|
"- plain list item\n"
|
||||||
|
"- [x] checked\n"
|
||||||
|
"- not [ ] not a task marker\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(count_unchecked_items(path), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.archive.readme`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive.readme import (
|
||||||
|
existing_archive_entries,
|
||||||
|
merge_archive_section,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExistingArchiveEntries(unittest.TestCase):
|
||||||
|
def test_parses_only_archive_section_items(self) -> None:
|
||||||
|
readme = (
|
||||||
|
"# Requirements\n\n"
|
||||||
|
"Intro paragraph.\n\n"
|
||||||
|
"## Other\n\n"
|
||||||
|
"- not an archive entry\n\n"
|
||||||
|
"## Archive\n\n"
|
||||||
|
"- 001 - One\n"
|
||||||
|
"- 002 - Two\n\n"
|
||||||
|
"## Trailing\n\n"
|
||||||
|
"- ignored too\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
existing_archive_entries(readme),
|
||||||
|
{"001 - One", "002 - Two"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_empty_when_section_missing(self) -> None:
|
||||||
|
self.assertEqual(existing_archive_entries("# Title\n\nBody.\n"), set())
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeArchiveSection(unittest.TestCase):
|
||||||
|
def test_creates_section_when_missing(self) -> None:
|
||||||
|
readme = "# Requirements\n\nIntro.\n"
|
||||||
|
merged = merge_archive_section(readme, ["001 - One", "002 - Two"])
|
||||||
|
self.assertEqual(
|
||||||
|
merged,
|
||||||
|
"# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n- 002 - Two\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_appends_to_existing_section_preserving_entries(self) -> None:
|
||||||
|
readme = "# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n"
|
||||||
|
merged = merge_archive_section(readme, ["002 - Two", "003 - Three"])
|
||||||
|
self.assertEqual(
|
||||||
|
merged,
|
||||||
|
(
|
||||||
|
"# Requirements\n\n"
|
||||||
|
"Intro.\n\n"
|
||||||
|
"## Archive\n\n"
|
||||||
|
"- 001 - One\n"
|
||||||
|
"- 002 - Two\n"
|
||||||
|
"- 003 - Three\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_appends_before_trailing_section(self) -> None:
|
||||||
|
readme = "## Archive\n\n- 001 - One\n\n## Trailing\n\nfooter\n"
|
||||||
|
merged = merge_archive_section(readme, ["002 - Two"])
|
||||||
|
self.assertEqual(
|
||||||
|
merged,
|
||||||
|
"## Archive\n\n- 001 - One\n- 002 - Two\n\n## Trailing\n\nfooter\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_entries_returns_unchanged(self) -> None:
|
||||||
|
readme = "# X\n\nBody\n"
|
||||||
|
self.assertEqual(merge_archive_section(readme, []), readme)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Unit tests for `pkgmgr.actions.archive.workflow.run_archive`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pkgmgr.actions.archive.workflow import run_archive
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunArchive(unittest.TestCase):
|
||||||
|
def test_dry_run_does_not_touch_files_or_readme(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
done = d / "001-done.md"
|
||||||
|
done.write_text("# 001 - Done\n\n- [x] ok\n")
|
||||||
|
readme = d / "README.md"
|
||||||
|
readme.write_text("# Specs\n\n## Archive\n")
|
||||||
|
|
||||||
|
plan = run_archive(d, readme, dry_run=True)
|
||||||
|
|
||||||
|
self.assertTrue(done.exists())
|
||||||
|
self.assertEqual(readme.read_text(), "# Specs\n\n## Archive\n")
|
||||||
|
self.assertEqual(plan.new_entries, ["001 - Done"])
|
||||||
|
self.assertEqual(plan.archived, [(done, "001 - Done")])
|
||||||
|
|
||||||
|
def test_real_run_archives_and_deletes_completed_file(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
done = d / "001-done.md"
|
||||||
|
done.write_text("# 001 - Done\n\n- [x] ok\n")
|
||||||
|
readme = d / "README.md"
|
||||||
|
readme.write_text("# Specs\n\n## Archive\n")
|
||||||
|
|
||||||
|
plan = run_archive(d, readme)
|
||||||
|
|
||||||
|
self.assertFalse(done.exists())
|
||||||
|
self.assertIn("- 001 - Done", readme.read_text())
|
||||||
|
self.assertEqual(plan.new_entries, ["001 - Done"])
|
||||||
|
|
||||||
|
def test_incomplete_files_are_skipped(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
wip = d / "002-wip.md"
|
||||||
|
wip.write_text("# 002 - WIP\n\n- [ ] open\n")
|
||||||
|
readme = d / "README.md"
|
||||||
|
readme.write_text("# Specs\n\n## Archive\n")
|
||||||
|
|
||||||
|
plan = run_archive(d, readme)
|
||||||
|
|
||||||
|
self.assertTrue(wip.exists())
|
||||||
|
self.assertEqual(plan.archived, [])
|
||||||
|
self.assertEqual(plan.skipped_incomplete, [(wip, 1)])
|
||||||
|
|
||||||
|
def test_already_archived_title_is_not_duplicated(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
done = d / "001-done.md"
|
||||||
|
done.write_text("# 001 - Done\n\n- [x] ok\n")
|
||||||
|
readme = d / "README.md"
|
||||||
|
readme.write_text("# Specs\n\n## Archive\n\n- 001 - Done\n")
|
||||||
|
|
||||||
|
plan = run_archive(d, readme)
|
||||||
|
|
||||||
|
self.assertEqual(plan.new_entries, [])
|
||||||
|
self.assertEqual(plan.archived, [(done, "001 - Done")])
|
||||||
|
# File still deleted; README unchanged.
|
||||||
|
self.assertFalse(done.exists())
|
||||||
|
self.assertEqual(
|
||||||
|
readme.read_text(),
|
||||||
|
"# Specs\n\n## Archive\n\n- 001 - Done\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_directory_raises_filenotfound(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
readme = Path(tmp) / "README.md"
|
||||||
|
readme.write_text("# X\n## Archive\n")
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
run_archive(Path(tmp) / "no-such-dir", readme)
|
||||||
|
|
||||||
|
def test_missing_readme_raises_filenotfound(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
d = Path(tmp)
|
||||||
|
(d / "001-done.md").write_text("# 001 - Done\n- [x] ok\n")
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
run_archive(d, d / "missing.md")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -256,7 +256,7 @@ class TestUpdateSpecVersion(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestUpdateChangelog(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:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
path = os.path.join(tmpdir, "CHANGELOG.md")
|
path = os.path.join(tmpdir, "CHANGELOG.md")
|
||||||
self.assertFalse(os.path.exists(path))
|
self.assertFalse(os.path.exists(path))
|
||||||
@@ -267,10 +267,35 @@ class TestUpdateChangelog(unittest.TestCase):
|
|||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
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)
|
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:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
path = os.path.join(tmpdir, "CHANGELOG.md")
|
path = os.path.join(tmpdir, "CHANGELOG.md")
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
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:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
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)
|
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")
|
||||||
@@ -323,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)
|
||||||
|
|
||||||
@@ -352,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:
|
||||||
@@ -438,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()
|
||||||
@@ -112,6 +112,7 @@ class TestReleaseCommand(unittest.TestCase):
|
|||||||
preview=False,
|
preview=False,
|
||||||
force=True,
|
force=True,
|
||||||
close=True,
|
close=True,
|
||||||
|
retry=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("pkgmgr.cli.commands.release.os.path.isdir", return_value=True)
|
@patch("pkgmgr.cli.commands.release.os.path.isdir", return_value=True)
|
||||||
@@ -160,6 +161,7 @@ class TestReleaseCommand(unittest.TestCase):
|
|||||||
preview=True,
|
preview=True,
|
||||||
force=False,
|
force=False,
|
||||||
close=False,
|
close=False,
|
||||||
|
retry=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("pkgmgr.cli.commands.release.run_release")
|
@patch("pkgmgr.cli.commands.release.run_release")
|
||||||
|
|||||||
Reference in new issue
Block a user