diff --git a/src/pkgmgr/actions/archive/__init__.py b/src/pkgmgr/actions/archive/__init__.py new file mode 100644 index 0000000..2d3f841 --- /dev/null +++ b/src/pkgmgr/actions/archive/__init__.py @@ -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", +] diff --git a/src/pkgmgr/actions/archive/discovery.py b/src/pkgmgr/actions/archive/discovery.py new file mode 100644 index 0000000..dc5cf40 --- /dev/null +++ b/src/pkgmgr/actions/archive/discovery.py @@ -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 diff --git a/src/pkgmgr/actions/archive/inspect.py b/src/pkgmgr/actions/archive/inspect.py new file mode 100644 index 0000000..c8e1bbe --- /dev/null +++ b/src/pkgmgr/actions/archive/inspect.py @@ -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\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 diff --git a/src/pkgmgr/actions/archive/readme.py b/src/pkgmgr/actions/archive/readme.py new file mode 100644 index 0000000..9b73826 --- /dev/null +++ b/src/pkgmgr/actions/archive/readme.py @@ -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 diff --git a/src/pkgmgr/actions/archive/workflow.py b/src/pkgmgr/actions/archive/workflow.py new file mode 100644 index 0000000..0c1b72b --- /dev/null +++ b/src/pkgmgr/actions/archive/workflow.py @@ -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, + ) diff --git a/src/pkgmgr/cli/commands/__init__.py b/src/pkgmgr/cli/commands/__init__.py index b8a723a..faf8cde 100644 --- a/src/pkgmgr/cli/commands/__init__.py +++ b/src/pkgmgr/cli/commands/__init__.py @@ -1,3 +1,4 @@ +from .archive import handle_archive from .repos import handle_repos_command from .config import handle_config from .tools import handle_tools_command @@ -10,6 +11,7 @@ from .branch import handle_branch from .mirror import handle_mirror_command __all__ = [ + "handle_archive", "handle_repos_command", "handle_config", "handle_tools_command", diff --git a/src/pkgmgr/cli/commands/archive.py b/src/pkgmgr/cli/commands/archive.py new file mode 100644 index 0000000..3273335 --- /dev/null +++ b/src/pkgmgr/cli/commands/archive.py @@ -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) diff --git a/src/pkgmgr/cli/dispatch.py b/src/pkgmgr/cli/dispatch.py index 2f92308..94f54de 100644 --- a/src/pkgmgr/cli/dispatch.py +++ b/src/pkgmgr/cli/dispatch.py @@ -10,6 +10,7 @@ from pkgmgr.core.repository.selected import get_selected_repos from pkgmgr.core.repository.dir import get_repo_dir from pkgmgr.cli.commands import ( + handle_archive, handle_repos_command, handle_tools_command, handle_release, @@ -60,6 +61,10 @@ def dispatch_command(args, ctx: CLIContext) -> None: if maybe_handle_proxy(args, ctx): return + if args.command == "archive": + handle_archive(args, ctx) + return + commands_with_selection = { "install", "update", diff --git a/src/pkgmgr/cli/parser/__init__.py b/src/pkgmgr/cli/parser/__init__.py index 2c9917c..63307b5 100644 --- a/src/pkgmgr/cli/parser/__init__.py +++ b/src/pkgmgr/cli/parser/__init__.py @@ -4,6 +4,7 @@ import argparse from pkgmgr.cli.proxy import register_proxy_commands +from .archive_cmd import add_archive_subparser from .branch_cmd import add_branch_subparsers from .changelog_cmd import add_changelog_subparser from .common import SortedSubParsersAction @@ -65,6 +66,7 @@ def create_parser(description_text: str) -> argparse.ArgumentParser: add_make_subparsers(subparsers) add_mirror_subparsers(subparsers) + add_archive_subparser(subparsers) register_proxy_commands(subparsers) return parser diff --git a/src/pkgmgr/cli/parser/archive_cmd.py b/src/pkgmgr/cli/parser/archive_cmd.py new file mode 100644 index 0000000..039a2a1 --- /dev/null +++ b/src/pkgmgr/cli/parser/archive_cmd.py @@ -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." + ), + ) diff --git a/tests/unit/pkgmgr/actions/archive/__init__.py b/tests/unit/pkgmgr/actions/archive/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/actions/archive/test_discovery.py b/tests/unit/pkgmgr/actions/archive/test_discovery.py new file mode 100644 index 0000000..3a06b65 --- /dev/null +++ b/tests/unit/pkgmgr/actions/archive/test_discovery.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/archive/test_inspect.py b/tests/unit/pkgmgr/actions/archive/test_inspect.py new file mode 100644 index 0000000..fced13b --- /dev/null +++ b/tests/unit/pkgmgr/actions/archive/test_inspect.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/archive/test_readme.py b/tests/unit/pkgmgr/actions/archive/test_readme.py new file mode 100644 index 0000000..2ccdf66 --- /dev/null +++ b/tests/unit/pkgmgr/actions/archive/test_readme.py @@ -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() diff --git a/tests/unit/pkgmgr/actions/archive/test_workflow.py b/tests/unit/pkgmgr/actions/archive/test_workflow.py new file mode 100644 index 0000000..27f0e86 --- /dev/null +++ b/tests/unit/pkgmgr/actions/archive/test_workflow.py @@ -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() diff --git a/tests/unit/pkgmgr/cli/commands/test_release.py b/tests/unit/pkgmgr/cli/commands/test_release.py index 0882c84..111a00b 100644 --- a/tests/unit/pkgmgr/cli/commands/test_release.py +++ b/tests/unit/pkgmgr/cli/commands/test_release.py @@ -112,6 +112,7 @@ class TestReleaseCommand(unittest.TestCase): preview=False, force=True, close=True, + retry=False, ) @patch("pkgmgr.cli.commands.release.os.path.isdir", return_value=True) @@ -160,6 +161,7 @@ class TestReleaseCommand(unittest.TestCase): preview=True, force=False, close=False, + retry=False, ) @patch("pkgmgr.cli.commands.release.run_release")