feat(archive): add pkgmgr archive subcommand for task-tracked spec dirs
Walks a directory of numbered NNN-topic.md files, promotes every file with zero unchecked task-list items into the directorys README under the ## Archive section, then deletes the source file. Keeps spec directories (typically docs/requirements) short and focused on open work. The action ships as pkgmgr.actions.archive with four leaf modules (discovery, inspect, readme, workflow) and is wired into the CLI as pkgmgr archive [DIR] [--readme PATH] [--dry-run] [--include-template]. Extracted verbatim from cli/contributing/requirements/archive in infinito-nexus-core so every kpmx-managed repository can rely on the same archival convention without copy-pasting helpers. Twenty unit tests cover discovery, inspection, README merge, and end-to-end workflow paths. Also: realign tests/unit/pkgmgr/cli/commands/test_release.py with the new run_release(retry=False) signature shipped in v1.14.0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
31
src/pkgmgr/actions/archive/__init__.py
Normal file
31
src/pkgmgr/actions/archive/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
53
src/pkgmgr/actions/archive/discovery.py
Normal file
53
src/pkgmgr/actions/archive/discovery.py
Normal file
@@ -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
|
||||||
35
src/pkgmgr/actions/archive/inspect.py
Normal file
35
src/pkgmgr/actions/archive/inspect.py
Normal file
@@ -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
|
||||||
76
src/pkgmgr/actions/archive/readme.py
Normal file
76
src/pkgmgr/actions/archive/readme.py
Normal file
@@ -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
|
||||||
115
src/pkgmgr/actions/archive/workflow.py
Normal file
115
src/pkgmgr/actions/archive/workflow.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .archive import handle_archive
|
||||||
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 +11,7 @@ from .branch import handle_branch
|
|||||||
from .mirror import handle_mirror_command
|
from .mirror import handle_mirror_command
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"handle_archive",
|
||||||
"handle_repos_command",
|
"handle_repos_command",
|
||||||
"handle_config",
|
"handle_config",
|
||||||
"handle_tools_command",
|
"handle_tools_command",
|
||||||
|
|||||||
76
src/pkgmgr/cli/commands/archive.py
Normal file
76
src/pkgmgr/cli/commands/archive.py
Normal file
@@ -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)
|
||||||
@@ -10,6 +10,7 @@ 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_repos_command,
|
handle_repos_command,
|
||||||
handle_tools_command,
|
handle_tools_command,
|
||||||
handle_release,
|
handle_release,
|
||||||
@@ -60,6 +61,10 @@ 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
|
||||||
|
|
||||||
commands_with_selection = {
|
commands_with_selection = {
|
||||||
"install",
|
"install",
|
||||||
"update",
|
"update",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 .common import SortedSubParsersAction
|
from .common import SortedSubParsersAction
|
||||||
@@ -65,6 +66,7 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
add_make_subparsers(subparsers)
|
add_make_subparsers(subparsers)
|
||||||
add_mirror_subparsers(subparsers)
|
add_mirror_subparsers(subparsers)
|
||||||
|
add_archive_subparser(subparsers)
|
||||||
|
|
||||||
register_proxy_commands(subparsers)
|
register_proxy_commands(subparsers)
|
||||||
return parser
|
return parser
|
||||||
|
|||||||
57
src/pkgmgr/cli/parser/archive_cmd.py
Normal file
57
src/pkgmgr/cli/parser/archive_cmd.py
Normal file
@@ -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
tests/unit/pkgmgr/actions/archive/__init__.py
Normal file
0
tests/unit/pkgmgr/actions/archive/__init__.py
Normal file
49
tests/unit/pkgmgr/actions/archive/test_discovery.py
Normal file
49
tests/unit/pkgmgr/actions/archive/test_discovery.py
Normal file
@@ -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()
|
||||||
62
tests/unit/pkgmgr/actions/archive/test_inspect.py
Normal file
62
tests/unit/pkgmgr/actions/archive/test_inspect.py
Normal file
@@ -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()
|
||||||
73
tests/unit/pkgmgr/actions/archive/test_readme.py
Normal file
73
tests/unit/pkgmgr/actions/archive/test_readme.py
Normal file
@@ -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()
|
||||||
91
tests/unit/pkgmgr/actions/archive/test_workflow.py
Normal file
91
tests/unit/pkgmgr/actions/archive/test_workflow.py
Normal file
@@ -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()
|
||||||
@@ -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