From 967c649489e2a39b78bbb4dcc50ad20ac83569ad Mon Sep 17 00:00:00 2001 From: geopanther Date: Wed, 29 Apr 2026 00:18:22 +0200 Subject: [PATCH 1/6] refactor!: inherit mdfluence CLI parser, rename env vars, add auth model BREAKING CHANGE: Environment variable rename: - CONFLUENCE_PROD_HOST -> CONFLUENCE_HOST - CONFLUENCE_PROD_TOKEN -> CONFLUENCE_TOKEN Replace ~150 lines of duplicated argparse definitions with mdfluence's get_parser() as base. gitfluence inherits all mdfluence args, overrides 6 defaults (only_changed, strip_top_header, collapse_single_pages, skip_empty, skip_subtrees_wo_markdown, enable_relative_links), removes incompatible args (file_list, output, prefix nargs, preface/postface nargs), and re-adds them with gitfluence semantics. New features: - Username/password auth (matching mdfluence: token > user+password) - --host-int, --token-int, --username-int, --password-int for integration - --debug alias for --verbose (mdfluence compat) - -n alias for --dry-run - --content-type choices enforced (page/blogpost) - --insecure flag passed to MinimalConfluence verify= - --page-id rejected with user-friendly error Config resolution: CLI > env > interactive prompt > dry-run dummy. Int mode without int host: full fallback to prod config. Int mode with int host: enforce int-specific auth (no prod fallback). --- gitfluence/__main__.py | 296 ++++++++++++++-------------- gitfluence/config.py | 196 ++++++++++++++++--- gitfluence/confluence.py | 34 +++- tests/test_cli.py | 268 ++++++++++++++++++++++--- tests/test_config.py | 402 +++++++++++++++++++++++++++----------- tests/test_confluence.py | 3 + tests/test_integration.py | 4 +- 7 files changed, 882 insertions(+), 321 deletions(-) diff --git a/gitfluence/__main__.py b/gitfluence/__main__.py index ce0e4f2..92c0b96 100644 --- a/gitfluence/__main__.py +++ b/gitfluence/__main__.py @@ -9,6 +9,7 @@ from pathlib import Path import mdfluence.document +from mdfluence.__main__ import get_parser from gitfluence.config import GitfluenceContext, GitfluenceSettings from gitfluence.confluence import run_sync @@ -21,103 +22,114 @@ _PACKAGE_FILES = resources.files("gitfluence") -def main( - argv: list[str] | None = None, -) -> None: - parser = argparse.ArgumentParser( - prog="gitfluence", - description="Sync markdown files from a git repo to Confluence.", - ) - parser.add_argument( - "repo_path", - type=Path, - help="Root directory of the git working tree to sync.", - ) - parser.add_argument( - "-n", - "--dry-run", - action="store_true", - help="Print what would be done without calling the Confluence API.", - ) - parser.add_argument( - "--space", - type=str, - default=None, - help="Override the Confluence space key (default: from CONFLUENCE_SPACE env var).", +def _remove_action(parser: argparse.ArgumentParser, dest: str) -> None: + """Remove an action from a parser by its dest name.""" + for action in parser._actions[:]: + if action.dest == dest: + parser._actions.remove(action) + # Remove from option_string_actions mapping + for opt in action.option_strings: + parser._option_string_actions.pop(opt, None) + for group in parser._action_groups: + if action in group._group_actions: + group._group_actions.remove(action) + # Remove from mutually exclusive groups; prune empty ones + for mutex in parser._mutually_exclusive_groups[:]: + if action in mutex._group_actions: + mutex._group_actions.remove(action) + if not mutex._group_actions: + parser._mutually_exclusive_groups.remove(mutex) + break + + +def _find_action(parser: argparse.ArgumentParser, dest: str) -> argparse.Action | None: + """Find an action by its dest name.""" + for action in parser._actions: + if action.dest == dest: + return action + return None + + +def _build_parser() -> argparse.ArgumentParser: + """Build gitfluence CLI parser inheriting mdfluence's parser.""" + parser = get_parser() + parser.prog = "gitfluence" + parser.description = "Sync markdown files from a git repo to Confluence." + + # ── Remove mdfluence-only args ──────────────────────────────────── + _remove_action(parser, "file_list") + _remove_action(parser, "output") + + # Remove preface/postface (nargs="?" conflict) — will re-add below + for dest in ( + "preface_markdown", + "preface_file", + "postface_markdown", + "postface_file", + ): + _remove_action(parser, dest) + + # Remove mdfluence --prefix (different semantics) — will re-add below + _remove_action(parser, "prefix") + + # ── Null out mdfluence env var defaults (gitfluence uses pydantic-settings) ── + parser.set_defaults( + host=None, + token=None, + username=None, + password=None, + space=None, ) - parser.add_argument( - "--prefix", - type=str, - default=None, - help="Override auto-detected prefix (default: branch name on non-prod).", + + # ── Override mdfluence defaults (sync-oriented) ─────────────────── + parser.set_defaults( + only_changed=True, + strip_top_header=True, + collapse_single_pages=True, + skip_empty=True, + skip_subtrees_wo_markdown=True, + enable_relative_links=True, ) + + # ── Repurpose --debug as alias for --verbose ────────────────────── + _remove_action(parser, "debug") parser.add_argument( "-v", "--verbose", + "--debug", action="store_true", help="Enable debug logging.", ) + + # ── Add -n alias to --dry-run ───────────────────────────────────── + dry_run_action = _find_action(parser, "dry_run") + if dry_run_action and "-n" not in dry_run_action.option_strings: + dry_run_action.option_strings = ["-n", *dry_run_action.option_strings] + parser._option_string_actions["-n"] = dry_run_action + + # ── Add positional repo_path ────────────────────────────────────── parser.add_argument( - "--only-changed", - action="store_true", - default=True, - help="Only update pages whose content has changed (default: True).", + "repo_path", + type=Path, + help="Root directory of the git working tree to sync.", ) + + # ── Add gitfluence-specific args ────────────────────────────────── parser.add_argument( - "--max-retries", - type=int, - default=3, - help="Maximum number of retries for Confluence API calls (default: 3).", - ) - # ── Page information arguments ──────────────────────────────────── - page_group = parser.add_argument_group("page information arguments") - page_group.add_argument( - "-t", "--title", type=str, default=None, help="Set the page title." - ) - page_group.add_argument( - "-c", - "--content-type", + "--prefix", type=str, - default="page", - help="Content type (default: page).", - ) - page_group.add_argument( - "-m", "--message", type=str, default=None, help="Version message for the page." - ) - page_group.add_argument( - "--minor-edit", - action="store_true", - help="Mark the edit as a minor edit.", - ) - page_group.add_argument( - "--strip-top-header", - action="store_true", - default=True, - help="Strip the top-level header from pages (default: True).", - ) - page_group.add_argument( - "--remove-text-newlines", - action="store_true", - help="Remove newlines from text nodes.", - ) - page_group.add_argument( - "--replace-all-labels", - action="store_true", - help="Replace all existing labels on the page.", + default=None, + help="Override auto-detected prefix (default: branch name on non-prod).", ) - parent_group = page_group.add_mutually_exclusive_group() - parent_group.add_argument( - "-a", "--parent-title", type=str, default=None, help="Parent page title." - ) - parent_group.add_argument( - "-A", "--parent-id", type=str, default=None, help="Parent page ID." - ) - parent_group.add_argument( - "--top-level", - action="store_true", - help="Create pages as top-level children of the space.", - ) + # ── Preface group (re-added with gitfluence semantics) ──────────── + page_group = None + for group in parser._action_groups: + if group.title == "page information arguments": + page_group = group + break + if page_group is None: + page_group = parser.add_argument_group("page information arguments") preface_group = page_group.add_mutually_exclusive_group() preface_group.add_argument( @@ -131,8 +143,7 @@ def main( "--preface-file", type=Path, default=None, - help="Markdown template file to prepend to every page. " - "Supports {branch_name}, {repo_origin}, {username}, {hostname}, {timestamp} placeholders.", + help="Markdown template file to prepend to every page.", ) preface_group.add_argument( "--no-preface", @@ -145,15 +156,13 @@ def main( "--postface-markdown", type=str, default=None, - help="Markdown template string to append to every page. " - "Supports {branch_name}, {repo_origin}, {username}, {hostname}, {timestamp} placeholders.", + help="Markdown template string to append to every page.", ) postface_group.add_argument( "--postface-file", type=Path, default=None, - help="Markdown template file to append to every page. " - "Supports {branch_name}, {repo_origin}, {username}, {hostname}, {timestamp} placeholders.", + help="Markdown template file to append to every page.", ) postface_group.add_argument( "--no-postface", @@ -161,76 +170,50 @@ def main( help="Disable the default postface (metadata footer).", ) - # ── Directory arguments ─────────────────────────────────────────── - dir_group = parser.add_argument_group("directory arguments") - dir_group.add_argument( - "--collapse-single-pages", - action="store_true", - default=True, - help="Collapse directories with a single page (default: True).", - ) - dir_group.add_argument( - "--no-gitignore", - action="store_true", - help="Do not use .gitignore to filter files.", - ) - dir_group.add_argument( - "--skip-subtrees-wo-markdown", - action="store_true", - default=True, - help="Skip directory subtrees without markdown files (default: True).", - ) - - dir_title_group = dir_group.add_mutually_exclusive_group() - dir_title_group.add_argument( - "--beautify-folders", - action="store_true", - help="Beautify folder names (capitalize, replace dashes/underscores).", + # ── Integration target args ─────────────────────────────────────── + int_group = parser.add_argument_group("integration target arguments") + int_group.add_argument( + "--host-int", + type=str, + default=None, + help="Integration Confluence host (env: CONFLUENCE_INT_HOST).", ) - dir_title_group.add_argument( - "--use-pages-file", - action="store_true", - help="Use .pages files for directory titles and ordering.", + int_group.add_argument( + "--token-int", + type=str, + default=None, + help="Integration Confluence token (env: CONFLUENCE_INT_TOKEN).", ) - - empty_group = dir_group.add_mutually_exclusive_group() - empty_group.add_argument( - "--collapse-empty", - action="store_true", - help="Collapse empty directories.", + int_group.add_argument( + "--username-int", + type=str, + default=None, + help="Integration Confluence username (env: CONFLUENCE_INT_USERNAME).", ) - empty_group.add_argument( - "--skip-empty", - action="store_true", - default=True, - help="Skip empty directories (default: True).", + int_group.add_argument( + "--password-int", + type=str, + default=None, + help="Integration Confluence password (env: CONFLUENCE_INT_PASSWORD).", ) - # ── Relative links arguments ────────────────────────────────────── - links_group = parser.add_argument_group("relative links arguments") - links_group.add_argument( - "--enable-relative-links", - action="store_true", - default=True, - help="Enable relative link resolution (default: True).", - ) - links_group.add_argument( - "--ignore-relative-link-errors", - action="store_true", - help="Ignore errors from unresolvable relative links.", - ) + return parser - # ── Anchor arguments ────────────────────────────────────────────── - anchor_group = parser.add_argument_group("anchor arguments") - anchor_group.add_argument( - "--convert-anchors", - action=argparse.BooleanOptionalAction, - default=True, - help="Convert markdown anchors to Confluence format (default: True).", - ) +def main( + argv: list[str] | None = None, +) -> None: + parser = _build_parser() args = parser.parse_args(argv) + # ── Post-parse: reject --page-id ────────────────────────────────── + if getattr(args, "page_id", None): + parser.error( + "--page-id is not supported by gitfluence. " + "Pages are managed by directory hierarchy. " + "Use --parent-id to anchor pages under a specific parent." + ) + logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(levelname)-8s %(name)s: %(message)s", @@ -273,11 +256,18 @@ def main( use_prod=use_prod, branch_name=branch_name, dry_run=args.dry_run, + cli_host=args.host, + cli_token=args.token, + cli_username=args.username, + cli_password=args.password, + cli_host_int=args.host_int, + cli_token_int=args.token_int, + cli_username_int=args.username_int, + cli_password_int=args.password_int, + cli_space=args.space, + cli_insecure=args.insecure, ) - if args.space: - ctx.space = args.space - # ── Preface / postface markup ───────────────────────────────────── preface_markup = "" if not args.no_preface: diff --git a/gitfluence/config.py b/gitfluence/config.py index 8a764d3..d5d9172 100644 --- a/gitfluence/config.py +++ b/gitfluence/config.py @@ -1,11 +1,14 @@ """Configuration for gitfluence using pydantic-settings. All values read from environment variables. +Env var naming matches mdfluence for prod (CONFLUENCE_HOST, CONFLUENCE_TOKEN, etc.) +and adds CONFLUENCE_INT_* variants for integration target. """ from __future__ import annotations import getpass +import logging import os import sys from pathlib import Path @@ -14,6 +17,8 @@ from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict +log = logging.getLogger(__name__) + DEFAULT_DUMMY_HOST = "https://dummy.example.com/api" DEFAULT_DUMMY_SECRET = "dummy" # nosec B105 - not a real secret, placeholder for dry-run DEFAULT_DUMMY_SPACE = "DRY_RUN" @@ -24,20 +29,27 @@ class GitfluenceSettings(BaseSettings): model_config = SettingsConfigDict() - # ── Confluence hosts & tokens ────────────────────────────────────── - confluence_prod_host: Optional[str] = None - confluence_prod_token: Optional[SecretStr] = None + # ── Prod connection (matches mdfluence naming) ───────────────────── + confluence_host: Optional[str] = None + confluence_token: Optional[SecretStr] = None + confluence_username: Optional[str] = None + confluence_password: Optional[SecretStr] = None + # ── Integration connection ───────────────────────────────────────── confluence_int_host: Optional[str] = None confluence_int_token: Optional[SecretStr] = None + confluence_int_username: Optional[str] = None + confluence_int_password: Optional[SecretStr] = None + # ── Space ────────────────────────────────────────────────────────── confluence_space: Optional[str] = None class GitfluenceContext: """Runtime context assembled from settings + git state + CLI args. - Passed as single parameter to every function that needs configuration. + Config priority: CLI arg > env var > interactive prompt > dry-run dummy. + Auth decision (per resolved target): token > username+password. """ def __init__( @@ -48,44 +60,172 @@ def __init__( use_prod: bool, branch_name: str, dry_run: bool = False, + # CLI overrides (highest priority) + cli_host: Optional[str] = None, + cli_token: Optional[str] = None, + cli_username: Optional[str] = None, + cli_password: Optional[str] = None, + cli_host_int: Optional[str] = None, + cli_token_int: Optional[str] = None, + cli_username_int: Optional[str] = None, + cli_password_int: Optional[str] = None, + cli_space: Optional[str] = None, + cli_insecure: bool = False, ) -> None: self.settings = settings self.repo_path = repo_path self.dry_run = dry_run + self.insecure = cli_insecure - # ── INT defaults to PROD host when not explicitly set ───────── - int_host = settings.confluence_int_host or settings.confluence_prod_host - prod_token = settings.confluence_prod_token - - # ── Effective write target ───────────────────────────────────── if use_prod: - self.write_host: str = self._require_host( - settings.confluence_prod_host, - "CONFLUENCE_PROD_HOST", - dry_run=dry_run, + self._resolve_prod( + settings, dry_run, cli_host, cli_token, cli_username, cli_password ) - self.write_token = self._require_secret( - prod_token, - "CONFLUENCE_PROD_TOKEN", + self.prefix: Optional[str] = None + else: + self._resolve_int( + settings, + dry_run, + cli_host, + cli_token, + cli_username, + cli_password, + cli_host_int, + cli_token_int, + cli_username_int, + cli_password_int, + ) + self.prefix = branch_name + + self.space = self._resolve_space( + cli_space or settings.confluence_space, dry_run=dry_run + ) + + # ── Prod resolution ─────────────────────────────────────────────── + + def _resolve_prod( + self, + s: GitfluenceSettings, + dry_run: bool, + cli_host: Optional[str], + cli_token: Optional[str], + cli_username: Optional[str], + cli_password: Optional[str], + ) -> None: + self.write_host: str = self._require_host( + cli_host or s.confluence_host, + "CONFLUENCE_HOST", + dry_run=dry_run, + ) + self._resolve_auth( + env_token=s.confluence_token, + env_username=s.confluence_username, + env_password=s.confluence_password, + cli_token=cli_token, + cli_username=cli_username, + cli_password=cli_password, + token_env_name="CONFLUENCE_TOKEN", # nosec B106 - env var name, not password + dry_run=dry_run, + ) + + # ── Int resolution ──────────────────────────────────────────────── + + def _resolve_int( + self, + s: GitfluenceSettings, + dry_run: bool, + cli_host: Optional[str], + cli_token: Optional[str], + cli_username: Optional[str], + cli_password: Optional[str], + cli_host_int: Optional[str], + cli_token_int: Optional[str], + cli_username_int: Optional[str], + cli_password_int: Optional[str], + ) -> None: + # Determine if a separate int host is configured + int_host = cli_host_int or s.confluence_int_host + + if int_host: + # Separate int target → enforce int-specific auth + log.info("Int mode: using separate int host %s", int_host) + self.write_host = int_host + self._resolve_auth( + env_token=s.confluence_int_token, + env_username=s.confluence_int_username, + env_password=s.confluence_int_password, + cli_token=cli_token_int, + cli_username=cli_username_int, + cli_password=cli_password_int, + token_env_name="CONFLUENCE_INT_TOKEN", # nosec B106 - env var name dry_run=dry_run, ) - self.prefix: Optional[str] = None else: + # No separate int host → full fallback to prod config + log.info("Int mode: no int host configured, falling back to prod config") self.write_host = self._require_host( - int_host, - "CONFLUENCE_INT_HOST / CONFLUENCE_PROD_HOST", + cli_host or s.confluence_host, + "CONFLUENCE_HOST", dry_run=dry_run, ) - self.write_token = self._require_secret( - settings.confluence_int_token, - "CONFLUENCE_INT_TOKEN", + self._resolve_auth( + env_token=s.confluence_token, + env_username=s.confluence_username, + env_password=s.confluence_password, + cli_token=cli_token, + cli_username=cli_username, + cli_password=cli_password, + token_env_name="CONFLUENCE_TOKEN", # nosec B106 - env var name dry_run=dry_run, ) - self.prefix = branch_name - self.space = self._require_space(settings.confluence_space, dry_run=dry_run) + # ── Auth decision (matching mdfluence): token > user+password ───── + + def _resolve_auth( + self, + *, + env_token: Optional[SecretStr], + env_username: Optional[str], + env_password: Optional[SecretStr], + cli_token: Optional[str], + cli_username: Optional[str], + cli_password: Optional[str], + token_env_name: str, + dry_run: bool, + ) -> None: + # Effective values: CLI > env + token = SecretStr(cli_token) if cli_token else env_token + username = cli_username or env_username + password = SecretStr(cli_password) if cli_password else env_password + + if token: + # Token auth wins + self.write_token: Optional[SecretStr] = token + self.write_username: Optional[str] = None + self.write_password: Optional[SecretStr] = None + elif username and password: + # Basic auth fallback + self.write_token = None + self.write_username = username + self.write_password = password + elif username and not password: + # Username given but no password — prompt for it + pw_env = token_env_name.replace("_TOKEN", "_PASSWORD") + self.write_token = None + self.write_username = username + self.write_password = self._require_secret(None, pw_env, dry_run=dry_run) + elif dry_run: + self.write_token = SecretStr(DEFAULT_DUMMY_SECRET) + self.write_username = None + self.write_password = None + else: + # Try prompting for token + self.write_token = self._require_secret(None, token_env_name, dry_run=False) + self.write_username = None + self.write_password = None # ── helpers ──────────────────────────────────────────────────────── + @staticmethod def _prompt_text(env_name: str) -> str: return f"{env_name} (or set before run): " @@ -100,10 +240,10 @@ def _require_host(host: Optional[str], env_name: str, *, dry_run: bool) -> str: raise SystemExit( f"ERROR: {env_name} is not set and stdin is not a terminal." ) - value = input(GitfluenceContext._prompt_text("CONFLUENCE_PROD_HOST")).strip() + value = input(GitfluenceContext._prompt_text(env_name)).strip() if not value: - raise SystemExit("ERROR: CONFLUENCE_PROD_HOST cannot be empty.") - os.environ["CONFLUENCE_PROD_HOST"] = value + raise SystemExit(f"ERROR: {env_name} cannot be empty.") + os.environ[env_name] = value return value @staticmethod @@ -126,7 +266,7 @@ def _require_secret( return SecretStr(value) @staticmethod - def _require_space(space: Optional[str], *, dry_run: bool) -> str: + def _resolve_space(space: Optional[str], *, dry_run: bool) -> str: if space: return space if dry_run: diff --git a/gitfluence/confluence.py b/gitfluence/confluence.py index 5db3427..7d862f1 100644 --- a/gitfluence/confluence.py +++ b/gitfluence/confluence.py @@ -47,14 +47,21 @@ def run_sync( if ctx.dry_run: # In dry-run mode we skip Confluence API calls entirely for page in pages: - _preprocess_page(page, ctx, preface_markup, postface_markup, None) + _preprocess_page( + page, ctx, preface_markup, postface_markup, None, args=args + ) log.info("[dry-run] Would upsert: %s", page.title) return + token_val = ctx.write_token.get_secret_value() if ctx.write_token else None + password_val = ctx.write_password.get_secret_value() if ctx.write_password else None confluence = MinimalConfluence( host=ctx.write_host, - token=ctx.write_token.get_secret_value(), - max_retries=getattr(args, "max_retries", 3) if args else 3, + token=token_val, + username=ctx.write_username, + password=password_val, + verify=not ctx.insecure, + max_retries=getattr(args, "max_retries", 4) if args else 4, ) log.info("Connecting to Confluence at %s (space: %s)", ctx.write_host, ctx.space) space_info = confluence.get_space(ctx.space, additional_expansions=["homepage"]) @@ -85,6 +92,7 @@ def run_sync( space_info, integration_root=integration_root, branch_page=branch_page, + args=args, ) try: @@ -150,7 +158,7 @@ def _collect_pages(repo_path: Path, *, args=None) -> list[Page]: ), strip_header=(getattr(args, "strip_top_header", True) if args else True), use_pages_file=(getattr(args, "use_pages_file", False) if args else False), - use_gitignore=(not getattr(args, "no_gitignore", False) if args else True), + use_gitignore=(getattr(args, "use_gitignore", True) if args else True), enable_relative_links=( getattr(args, "enable_relative_links", True) if args else True ), @@ -269,10 +277,23 @@ def _preprocess_page( *, integration_root=None, branch_page=None, + args=None, ) -> None: page.original_title = page.title page.space = ctx.space - page.content_type = "page" + page.content_type = getattr(args, "content_type", "page") if args else "page" + + # CLI parent overrides + if args: + parent_title = getattr(args, "parent_title", None) + parent_id = getattr(args, "parent_id", None) + top_level = getattr(args, "top_level", False) + if parent_title: + page.parent_title = parent_title + elif parent_id: + page.parent_id = parent_id + elif top_level: + pass # parent_title/parent_id already None from Page init # Top-level pages → child of branch page / integration root / space homepage if page.parent_title is None and page.parent_id is None: @@ -290,7 +311,8 @@ def _preprocess_page( page.body = page.body + postface_markup # Anchor conversion - page.body = rewrite_page_anchors(page.body, page.title or "") + if getattr(args, "convert_anchors", True) if args else True: + page.body = rewrite_page_anchors(page.body, page.title or "") def _resolve_relative_links( diff --git a/tests/test_cli.py b/tests/test_cli.py index 5bd7a6b..657d33a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,11 +2,234 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import patch import pytest -from gitfluence.__main__ import main +from gitfluence.__main__ import _build_parser, main + + +# ── Helper: parse args via _build_parser ────────────────────────────────── + + +def _parse(argv: list[str]): + """Parse argv through gitfluence's parser and return the namespace.""" + parser = _build_parser() + return parser.parse_args(argv) + + +# ── 1a. mdfluence parser args inherited correctly ───────────────────────── + + +class TestInheritedArgs: + def test_content_type_choices_enforced(self): + with pytest.raises(SystemExit): + _parse(["--content-type", "foo", "."]) + args = _parse(["--content-type", "blogpost", "."]) + assert args.content_type == "blogpost" + + def test_mdfluence_flags_parse(self): + args = _parse( + [ + "--beautify-folders", + "--collapse-empty", + "--ignore-relative-link-errors", + "--insecure", + ".", + ] + ) + assert args.beautify_folders is True + assert args.collapse_empty is True + assert args.ignore_relative_link_errors is True + assert args.insecure is True + + def test_mutual_exclusion_parent(self): + with pytest.raises(SystemExit): + _parse(["--parent-title", "X", "--parent-id", "Y", "."]) + + def test_mutual_exclusion_dir_titles(self): + with pytest.raises(SystemExit): + _parse(["--beautify-folders", "--use-pages-file", "."]) + + def test_mutual_exclusion_empty(self): + with pytest.raises(SystemExit): + _parse(["--collapse-empty", "--skip-empty", "."]) + + +# ── 1b. gitfluence default overrides ────────────────────────────────────── + + +class TestDefaultOverrides: + def test_default_only_changed_true(self): + args = _parse(["."]) + assert args.only_changed is True + + def test_default_strip_top_header_true(self): + args = _parse(["."]) + assert args.strip_top_header is True + + def test_default_collapse_single_pages_true(self): + args = _parse(["."]) + assert args.collapse_single_pages is True + + def test_default_skip_empty_true(self): + args = _parse(["."]) + assert args.skip_empty is True + + def test_default_skip_subtrees_wo_markdown_true(self): + args = _parse(["."]) + assert args.skip_subtrees_wo_markdown is True + + def test_default_enable_relative_links_true(self): + args = _parse(["."]) + assert args.enable_relative_links is True + + def test_default_max_retries_unchanged(self): + args = _parse(["."]) + assert args.max_retries == 4 + + def test_default_convert_anchors_true(self): + args = _parse(["."]) + assert args.convert_anchors is True + + +# ── 1c. gitfluence-specific args ───────────────────────────────────────── + + +class TestGitfluenceArgs: + def test_verbose_flag(self): + args = _parse(["-v", "."]) + assert args.verbose is True + + def test_verbose_long(self): + args = _parse(["--verbose", "."]) + assert args.verbose is True + + def test_debug_alias_for_verbose(self): + args = _parse(["--debug", "."]) + assert args.verbose is True + + def test_no_preface_flag(self): + args = _parse(["--no-preface", "."]) + assert args.no_preface is True + + def test_no_postface_flag(self): + args = _parse(["--no-postface", "."]) + assert args.no_postface is True + + def test_repo_path_positional(self): + args = _parse(["/some/path"]) + assert isinstance(args.repo_path, Path) + assert str(args.repo_path) == "/some/path" + + def test_dry_run_shorthand_n(self): + args = _parse(["-n", "."]) + assert args.dry_run is True + + def test_host_int_arg(self): + args = _parse(["--host-int", "https://int.example.com", "."]) + assert args.host_int == "https://int.example.com" + + def test_token_int_arg(self): + args = _parse(["--token-int", "tok-int", "."]) + assert args.token_int == "tok-int" + + def test_username_int_arg(self): + args = _parse(["--username-int", "user-int", "."]) + assert args.username_int == "user-int" + + def test_password_int_arg(self): + args = _parse(["--password-int", "pw-int", "."]) + assert args.password_int == "pw-int" + + +# ── 1d. mdfluence-only args removed ────────────────────────────────────── + + +class TestRemovedArgs: + def test_no_output_arg(self): + with pytest.raises(SystemExit): + _parse(["--output", "json", "."]) + + def test_no_file_list_positional(self): + """repo_path is single Path, not nargs=* file_list.""" + args = _parse(["."]) + assert hasattr(args, "repo_path") + assert not hasattr(args, "file_list") + + +# ── 1e. Auth args work ─────────────────────────────────────────────────── + + +class TestAuthArgs: + def test_host_arg_parses(self): + args = _parse(["--host", "https://prod.example.com", "."]) + assert args.host == "https://prod.example.com" + + def test_host_short_o(self): + args = _parse(["-o", "https://prod.example.com", "."]) + assert args.host == "https://prod.example.com" + + def test_token_arg_parses(self): + args = _parse(["--token", "my-token", "."]) + assert args.token == "my-token" + + def test_username_arg_parses(self): + args = _parse(["--username", "user", "."]) + assert args.username == "user" + + def test_username_short_u(self): + args = _parse(["-u", "user", "."]) + assert args.username == "user" + + def test_password_arg_parses(self): + args = _parse(["--password", "pw", "."]) + assert args.password == "pw" + + def test_password_short_p(self): + args = _parse(["-p", "pw", "."]) + assert args.password == "pw" + + def test_insecure_arg_parses(self): + args = _parse(["--insecure", "."]) + assert args.insecure is True + + def test_page_id_rejected(self, tmp_repo, monkeypatch, capsys): + monkeypatch.setenv("CONFLUENCE_HOST", "https://prod.example.com/api") + monkeypatch.delenv("CONFLUENCE_TOKEN", raising=False) + monkeypatch.delenv("CONFLUENCE_INT_TOKEN", raising=False) + monkeypatch.delenv("CONFLUENCE_INT_HOST", raising=False) + with pytest.raises(SystemExit): + main(["--dry-run", "--page-id", "123", str(tmp_repo)]) + err = capsys.readouterr().err + assert "--page-id" in err + assert "--parent-id" in err + + +# ── 1j. page-id rejected ───────────────────────────────────────────────── + + +class TestPageIdRejected: + def test_page_id_rejected_with_message(self, tmp_repo, monkeypatch, capsys): + monkeypatch.setenv("CONFLUENCE_HOST", "https://prod.example.com/api") + monkeypatch.delenv("CONFLUENCE_TOKEN", raising=False) + monkeypatch.delenv("CONFLUENCE_INT_TOKEN", raising=False) + monkeypatch.delenv("CONFLUENCE_INT_HOST", raising=False) + with pytest.raises(SystemExit): + main(["--dry-run", "--page-id", "123", str(tmp_repo)]) + err = capsys.readouterr().err + assert "--parent-id" in err + + +# ── 1k. Preface/postface (existing tests adapted) ──────────────────────── + + +def _env_for_dry_run(monkeypatch): + """Set minimal env for dry-run CLI tests.""" + monkeypatch.setenv("CONFLUENCE_HOST", "https://prod.example.com/api") + for v in ["CONFLUENCE_TOKEN", "CONFLUENCE_INT_TOKEN", "CONFLUENCE_INT_HOST"]: + monkeypatch.delenv(v, raising=False) class TestCLI: @@ -21,10 +244,7 @@ def test_nonexistent_path_exits(self, tmp_path): main([str(bad)]) def test_dry_run_no_api_calls(self, tmp_repo, monkeypatch): - monkeypatch.setenv("CONFLUENCE_PROD_HOST", "https://prod.example.com/api") - monkeypatch.delenv("CONFLUENCE_PROD_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_HOST", raising=False) + _env_for_dry_run(monkeypatch) with patch("gitfluence.__main__.run_sync") as mock_sync: main(["--dry-run", str(tmp_repo)]) mock_sync.assert_called_once() @@ -35,10 +255,7 @@ def test_dry_run_no_api_calls(self, tmp_repo, monkeypatch): class TestNoPreface: def test_no_preface_disables_default(self, tmp_repo, monkeypatch): - monkeypatch.setenv("CONFLUENCE_PROD_HOST", "https://prod.example.com/api") - monkeypatch.delenv("CONFLUENCE_PROD_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_HOST", raising=False) + _env_for_dry_run(monkeypatch) with patch("gitfluence.__main__.run_sync") as mock_sync: main(["--dry-run", "--no-preface", str(tmp_repo)]) mock_sync.assert_called_once() @@ -46,10 +263,7 @@ def test_no_preface_disables_default(self, tmp_repo, monkeypatch): assert preface_markup == "" def test_no_postface_disables_default(self, tmp_repo, monkeypatch): - monkeypatch.setenv("CONFLUENCE_PROD_HOST", "https://prod.example.com/api") - monkeypatch.delenv("CONFLUENCE_PROD_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_HOST", raising=False) + _env_for_dry_run(monkeypatch) with patch("gitfluence.__main__.run_sync") as mock_sync: main(["--dry-run", "--no-postface", str(tmp_repo)]) mock_sync.assert_called_once() @@ -57,11 +271,7 @@ def test_no_postface_disables_default(self, tmp_repo, monkeypatch): assert postface_markup == "" def test_default_preface_not_empty(self, tmp_repo, monkeypatch): - """Without --no-preface, default bundled preface is applied.""" - monkeypatch.setenv("CONFLUENCE_PROD_HOST", "https://prod.example.com/api") - monkeypatch.delenv("CONFLUENCE_PROD_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_HOST", raising=False) + _env_for_dry_run(monkeypatch) with patch("gitfluence.__main__.run_sync") as mock_sync: main(["--dry-run", str(tmp_repo)]) mock_sync.assert_called_once() @@ -69,13 +279,25 @@ def test_default_preface_not_empty(self, tmp_repo, monkeypatch): assert preface_markup != "" def test_default_postface_not_empty(self, tmp_repo, monkeypatch): - """Without --no-postface, default bundled postface is applied.""" - monkeypatch.setenv("CONFLUENCE_PROD_HOST", "https://prod.example.com/api") - monkeypatch.delenv("CONFLUENCE_PROD_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_TOKEN", raising=False) - monkeypatch.delenv("CONFLUENCE_INT_HOST", raising=False) + _env_for_dry_run(monkeypatch) with patch("gitfluence.__main__.run_sync") as mock_sync: main(["--dry-run", str(tmp_repo)]) mock_sync.assert_called_once() postface_markup = mock_sync.call_args[0][2] assert postface_markup != "" + + +class TestPrefixOverride: + def test_prefix_override_sets_context(self, tmp_repo, monkeypatch): + _env_for_dry_run(monkeypatch) + with patch("gitfluence.__main__.run_sync") as mock_sync: + main(["--dry-run", "--prefix", "custom-branch", str(tmp_repo)]) + ctx = mock_sync.call_args[0][0] + assert ctx.prefix == "custom-branch" + + def test_empty_prefix_forces_prod(self, tmp_repo, monkeypatch): + _env_for_dry_run(monkeypatch) + with patch("gitfluence.__main__.run_sync") as mock_sync: + main(["--dry-run", "--prefix", "", str(tmp_repo)]) + ctx = mock_sync.call_args[0][0] + assert ctx.prefix is None diff --git a/tests/test_config.py b/tests/test_config.py index 5e27f6d..78ca12d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from pathlib import Path import pytest @@ -17,75 +16,155 @@ ) +# ── GitfluenceSettings env var tests ────────────────────────────────────── + + class TestGitfluenceSettings: - def test_nothing_from_env(self, monkeypatch): + @staticmethod + def _clear_env(monkeypatch): for name in [ - "CONFLUENCE_PROD_HOST", - "CONFLUENCE_PROD_TOKEN", + "CONFLUENCE_HOST", + "CONFLUENCE_TOKEN", + "CONFLUENCE_USERNAME", + "CONFLUENCE_PASSWORD", "CONFLUENCE_INT_HOST", "CONFLUENCE_INT_TOKEN", + "CONFLUENCE_INT_USERNAME", + "CONFLUENCE_INT_PASSWORD", "CONFLUENCE_SPACE", + # also clear old names in case they leak + "CONFLUENCE_PROD_HOST", + "CONFLUENCE_PROD_TOKEN", ]: monkeypatch.delenv(name, raising=False) + def test_nothing_from_env(self, monkeypatch): + self._clear_env(monkeypatch) s = GitfluenceSettings() - - assert s.confluence_prod_host is None - assert s.confluence_prod_token is None + assert s.confluence_host is None + assert s.confluence_token is None + assert s.confluence_username is None + assert s.confluence_password is None assert s.confluence_int_host is None assert s.confluence_int_token is None + assert s.confluence_int_username is None + assert s.confluence_int_password is None assert s.confluence_space is None def test_defaults_from_env(self, monkeypatch): - monkeypatch.setenv("CONFLUENCE_PROD_HOST", "https://prod.example.com/api") - monkeypatch.setenv("CONFLUENCE_PROD_TOKEN", "tok-prod") + monkeypatch.setenv("CONFLUENCE_HOST", "https://prod.example.com/api") + monkeypatch.setenv("CONFLUENCE_TOKEN", "tok-prod") + monkeypatch.setenv("CONFLUENCE_USERNAME", "user-prod") + monkeypatch.setenv("CONFLUENCE_PASSWORD", "pw-prod") monkeypatch.setenv("CONFLUENCE_SPACE", "MYSPACE") monkeypatch.setenv("CONFLUENCE_INT_HOST", "https://int.example.com/api") monkeypatch.setenv("CONFLUENCE_INT_TOKEN", "tok-int") + monkeypatch.setenv("CONFLUENCE_INT_USERNAME", "user-int") + monkeypatch.setenv("CONFLUENCE_INT_PASSWORD", "pw-int") s = GitfluenceSettings() - assert s.confluence_prod_host == "https://prod.example.com/api" - assert s.confluence_prod_token.get_secret_value() == "tok-prod" + assert s.confluence_host == "https://prod.example.com/api" + assert s.confluence_token.get_secret_value() == "tok-prod" + assert s.confluence_username == "user-prod" + assert s.confluence_password.get_secret_value() == "pw-prod" assert s.confluence_int_host == "https://int.example.com/api" assert s.confluence_int_token.get_secret_value() == "tok-int" + assert s.confluence_int_username == "user-int" + assert s.confluence_int_password.get_secret_value() == "pw-int" assert s.confluence_space == "MYSPACE" +# ── GitfluenceContext tests ─────────────────────────────────────────────── + + class TestGitfluenceContext: @staticmethod def _make_settings(**overrides): defaults = { - "confluence_prod_host": "https://prod.example.com/api", - "confluence_prod_token": SecretStr("tok-prod"), + "confluence_host": "https://prod.example.com/api", + "confluence_token": SecretStr("tok-prod"), + "confluence_username": None, + "confluence_password": None, "confluence_int_host": None, "confluence_int_token": None, + "confluence_int_username": None, + "confluence_int_password": None, "confluence_space": "SP", } defaults.update(overrides) return GitfluenceSettings(**defaults) - def test_prod_mode(self): + # ── 1f. Prod-update-mode config resolution ──────────────────────── + + def test_prod_host_from_env(self): s = self._make_settings() ctx = GitfluenceContext( s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" ) assert ctx.write_host == "https://prod.example.com/api" + + def test_prod_host_cli_overrides_env(self): + s = self._make_settings() + ctx = GitfluenceContext( + s, + repo_path=Path("/tmp"), + use_prod=True, + branch_name="main", + cli_host="https://cli-host.example.com", + ) + assert ctx.write_host == "https://cli-host.example.com" + + def test_prod_token_from_env(self): + s = self._make_settings() + ctx = GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" + ) assert ctx.write_token.get_secret_value() == "tok-prod" - assert ctx.prefix is None - def test_prod_mode_raises(self): + def test_prod_token_cli_overrides_env(self): s = self._make_settings() - s.confluence_prod_host + ctx = GitfluenceContext( + s, + repo_path=Path("/tmp"), + use_prod=True, + branch_name="main", + cli_token="tok-cli", + ) + assert ctx.write_token.get_secret_value() == "tok-cli" - def test_prod_mode_dry_run_defaults(self): - s = GitfluenceSettings() + def test_prod_username_password_from_env(self): + s = self._make_settings( + confluence_token=None, + confluence_username="user", + confluence_password=SecretStr("pw"), + ) ctx = GitfluenceContext( - s, repo_path=Path("/tmp"), use_prod=True, branch_name="main", dry_run=True + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" ) - assert ctx.write_host == DEFAULT_DUMMY_HOST - assert ctx.write_token.get_secret_value() == DEFAULT_DUMMY_SECRET - assert ctx.space == DEFAULT_DUMMY_SPACE + assert ctx.write_username == "user" + assert ctx.write_password.get_secret_value() == "pw" + assert ctx.write_token is None - def test_int_mode_falls_back_to_prod(self): + def test_space_from_env(self): + s = self._make_settings() + ctx = GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" + ) + assert ctx.space == "SP" + + def test_space_cli_overrides_env(self): + s = self._make_settings() + ctx = GitfluenceContext( + s, + repo_path=Path("/tmp"), + use_prod=True, + branch_name="main", + cli_space="OVERRIDE", + ) + assert ctx.space == "OVERRIDE" + + # ── 1g. Int-update-mode, NO int host → full prod fallback ───────── + + def test_int_no_int_host_uses_prod_host(self): s = self._make_settings() ctx = GitfluenceContext( s, @@ -94,12 +173,57 @@ def test_int_mode_falls_back_to_prod(self): branch_name="feat/x", dry_run=True, ) - # INT host falls back to PROD in dry-run; token becomes dummy. assert ctx.write_host == "https://prod.example.com/api" - assert ctx.write_token.get_secret_value() == "dummy" - assert ctx.prefix == "feat/x" - def test_int_mode_explicit(self): + def test_int_no_int_host_uses_prod_token(self): + s = self._make_settings() + ctx = GitfluenceContext( + s, + repo_path=Path("/tmp"), + use_prod=False, + branch_name="feat/x", + ) + assert ctx.write_token.get_secret_value() == "tok-prod" + + def test_int_no_int_host_uses_prod_username(self): + s = self._make_settings( + confluence_token=None, + confluence_username="user-prod", + confluence_password=SecretStr("pw-prod"), + ) + ctx = GitfluenceContext( + s, + repo_path=Path("/tmp"), + use_prod=False, + branch_name="feat/x", + ) + assert ctx.write_username == "user-prod" + + def test_int_no_int_host_cli_host_falls_back(self): + s = self._make_settings(confluence_host=None) + ctx = GitfluenceContext( + s, + repo_path=Path("/tmp"), + use_prod=False, + branch_name="feat/x", + cli_host="https://cli.example.com", + ) + assert ctx.write_host == "https://cli.example.com" + + def test_int_no_int_host_cli_token_falls_back(self): + s = self._make_settings(confluence_token=None) + ctx = GitfluenceContext( + s, + repo_path=Path("/tmp"), + use_prod=False, + branch_name="feat/x", + cli_token="tok-cli", + ) + assert ctx.write_token.get_secret_value() == "tok-cli" + + # ── 1h. Int-update-mode, int host configured → enforce int auth ─── + + def test_int_with_int_host_uses_int_host(self): s = self._make_settings( confluence_int_host="https://int.example.com/api", confluence_int_token=SecretStr("tok-int"), @@ -109,28 +233,104 @@ def test_int_mode_explicit(self): ) assert ctx.write_host == "https://int.example.com/api" assert ctx.write_token.get_secret_value() == "tok-int" - assert ctx.prefix == "dev" - def test_missing_prod_host_non_interactive(self, monkeypatch): + def test_int_with_int_host_enforces_int_auth(self, monkeypatch): monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: False})()) - s = self._make_settings(confluence_prod_host=None) - with pytest.raises(SystemExit, match="CONFLUENCE_PROD_HOST"): + s = self._make_settings( + confluence_int_host="https://int.example.com/api", + confluence_int_token=None, + confluence_int_username=None, + confluence_int_password=None, + ) + with pytest.raises(SystemExit): GitfluenceContext( - s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" + s, repo_path=Path("/tmp"), use_prod=False, branch_name="dev" ) - def test_missing_token_non_interactive(self, monkeypatch): + def test_int_with_int_host_uses_int_token(self): + s = self._make_settings( + confluence_int_host="https://int.example.com/api", + confluence_int_token=SecretStr("tok-int"), + ) + ctx = GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=False, branch_name="dev" + ) + assert ctx.write_token.get_secret_value() == "tok-int" + + def test_int_with_int_host_uses_int_username_password(self): + s = self._make_settings( + confluence_int_host="https://int.example.com/api", + confluence_int_token=None, + confluence_int_username="user-int", + confluence_int_password=SecretStr("pw-int"), + ) + ctx = GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=False, branch_name="dev" + ) + assert ctx.write_username == "user-int" + assert ctx.write_password.get_secret_value() == "pw-int" + + def test_int_host_int_cli_overrides_env(self): + s = self._make_settings( + confluence_int_host="https://int-env.example.com/api", + confluence_int_token=SecretStr("tok-int"), + ) + ctx = GitfluenceContext( + s, + repo_path=Path("/tmp"), + use_prod=False, + branch_name="dev", + cli_host_int="https://int-cli.example.com/api", + cli_token_int="tok-int-cli", + ) + assert ctx.write_host == "https://int-cli.example.com/api" + assert ctx.write_token.get_secret_value() == "tok-int-cli" + + # ── 1i. Auth decision logic ─────────────────────────────────────── + + def test_token_auth_preferred_over_password(self): + s = self._make_settings( + confluence_username="user", + confluence_password=SecretStr("pw"), + ) + ctx = GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" + ) + # Token takes precedence + assert ctx.write_token.get_secret_value() == "tok-prod" + assert ctx.write_username is None + assert ctx.write_password is None + + def test_basic_auth_when_no_token(self): + s = self._make_settings( + confluence_token=None, + confluence_username="user", + confluence_password=SecretStr("pw"), + ) + ctx = GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" + ) + assert ctx.write_token is None + assert ctx.write_username == "user" + assert ctx.write_password.get_secret_value() == "pw" + + def test_username_without_password_non_interactive(self, monkeypatch): monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: False})()) - s = self._make_settings(confluence_prod_token=None) - with pytest.raises(SystemExit, match="CONFLUENCE_PROD_TOKEN"): + s = self._make_settings( + confluence_token=None, + confluence_username="user", + confluence_password=None, + ) + with pytest.raises(SystemExit): GitfluenceContext( s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" ) - def test_missing_token_dry_run_uses_dummy(self): + def test_username_without_password_dry_run(self): s = self._make_settings( - confluence_prod_token=None, - confluence_int_token=None, + confluence_token=None, + confluence_username="user", + confluence_password=None, ) ctx = GitfluenceContext( s, @@ -139,80 +339,83 @@ def test_missing_token_dry_run_uses_dummy(self): branch_name="main", dry_run=True, ) - assert ctx.write_token.get_secret_value() == "dummy" + assert ctx.write_username == "user" + assert ctx.write_password.get_secret_value() == "dummy" - def test_prompt_prod_host_exports(self, monkeypatch): - prompts = [] + # ── Existing tests (updated for new env var names) ──────────────── - monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: True})()) - monkeypatch.setattr( - "builtins.input", - lambda prompt: prompts.append(prompt) or "https://prod.example.com/api", + def test_prod_mode(self): + s = self._make_settings() + ctx = GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" + ) + assert ctx.write_host == "https://prod.example.com/api" + assert ctx.write_token.get_secret_value() == "tok-prod" + assert ctx.prefix is None + + def test_prod_mode_dry_run_defaults(self): + s = GitfluenceSettings() + ctx = GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main", dry_run=True ) - monkeypatch.delenv("CONFLUENCE_PROD_HOST", raising=False) - s = self._make_settings(confluence_prod_host=None) + assert ctx.write_host == DEFAULT_DUMMY_HOST + assert ctx.write_token.get_secret_value() == DEFAULT_DUMMY_SECRET + assert ctx.space == DEFAULT_DUMMY_SPACE + + def test_int_mode_falls_back_to_prod(self): + s = self._make_settings() ctx = GitfluenceContext( s, repo_path=Path("/tmp"), - use_prod=True, - branch_name="main", - dry_run=False, + use_prod=False, + branch_name="feat/x", ) + # No int host → uses prod host and token assert ctx.write_host == "https://prod.example.com/api" - assert os.environ["CONFLUENCE_PROD_HOST"] == "https://prod.example.com/api" - assert prompts == ["CONFLUENCE_PROD_HOST (or set before run): "] - - def test_prompt_prod_token_exports(self, monkeypatch): - prompts = [] + assert ctx.write_token.get_secret_value() == "tok-prod" + assert ctx.prefix == "feat/x" - monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: True})()) - monkeypatch.setattr( - "gitfluence.config.getpass.getpass", - lambda prompt: prompts.append(prompt) or "tok-prompt", + def test_int_mode_explicit(self): + s = self._make_settings( + confluence_int_host="https://int.example.com/api", + confluence_int_token=SecretStr("tok-int"), ) - monkeypatch.delenv("CONFLUENCE_PROD_TOKEN", raising=False) - s = self._make_settings(confluence_prod_token=None) ctx = GitfluenceContext( - s, - repo_path=Path("/tmp"), - use_prod=True, - branch_name="main", - dry_run=False, + s, repo_path=Path("/tmp"), use_prod=False, branch_name="dev" ) - assert ctx.write_token.get_secret_value() == "tok-prompt" - assert os.environ["CONFLUENCE_PROD_TOKEN"] == "tok-prompt" - assert prompts == ["CONFLUENCE_PROD_TOKEN (or set before run): "] + assert ctx.write_host == "https://int.example.com/api" + assert ctx.write_token.get_secret_value() == "tok-int" + assert ctx.prefix == "dev" - def test_missing_int_token_non_interactive(self, monkeypatch): + def test_missing_host_non_interactive(self, monkeypatch): monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: False})()) - s = self._make_settings(confluence_int_token=None) - with pytest.raises(SystemExit, match="CONFLUENCE_INT_TOKEN"): + s = self._make_settings(confluence_host=None) + with pytest.raises(SystemExit, match="CONFLUENCE_HOST"): GitfluenceContext( - s, - repo_path=Path("/tmp"), - use_prod=False, - branch_name="feature/x", + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" ) - def test_prompt_int_token_exports(self, monkeypatch): - prompts = [] + def test_missing_token_non_interactive(self, monkeypatch): + monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: False})()) + s = self._make_settings(confluence_token=None) + with pytest.raises(SystemExit): + GitfluenceContext( + s, repo_path=Path("/tmp"), use_prod=True, branch_name="main" + ) - monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: True})()) - monkeypatch.setattr( - "gitfluence.config.getpass.getpass", - lambda prompt: prompts.append(prompt) or "tok-int", + def test_missing_token_dry_run_uses_dummy(self): + s = self._make_settings( + confluence_token=None, + confluence_int_token=None, ) - monkeypatch.delenv("CONFLUENCE_INT_TOKEN", raising=False) - s = self._make_settings(confluence_int_token=None) ctx = GitfluenceContext( s, repo_path=Path("/tmp"), - use_prod=False, - branch_name="feature/x", + use_prod=True, + branch_name="main", + dry_run=True, ) - assert ctx.write_token.get_secret_value() == "tok-int" - assert os.environ["CONFLUENCE_INT_TOKEN"] == "tok-int" - assert prompts == ["CONFLUENCE_INT_TOKEN (or set before run): "] + assert ctx.write_token.get_secret_value() == "dummy" def test_missing_space_non_interactive(self, monkeypatch): monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: False})()) @@ -233,25 +436,6 @@ def test_missing_space_dry_run_uses_default(self): ) assert ctx.space == "DRY_RUN" - def test_prompt_space_exports(self, monkeypatch): - prompts = [] - - monkeypatch.setattr("sys.stdin", type("F", (), {"isatty": lambda s: True})()) - monkeypatch.setattr( - "builtins.input", lambda prompt: prompts.append(prompt) or "MYSPACE" - ) - monkeypatch.delenv("CONFLUENCE_SPACE", raising=False) - s = self._make_settings(confluence_space=None) - ctx = GitfluenceContext( - s, - repo_path=Path("/tmp"), - use_prod=True, - branch_name="main", - ) - assert ctx.space == "MYSPACE" - assert os.environ["CONFLUENCE_SPACE"] == "MYSPACE" - assert prompts == ["CONFLUENCE_SPACE (or set before run): "] - def test_dry_run_flag(self): s = self._make_settings() ctx = GitfluenceContext( diff --git a/tests/test_confluence.py b/tests/test_confluence.py index eb16ef4..a6d7edb 100644 --- a/tests/test_confluence.py +++ b/tests/test_confluence.py @@ -36,6 +36,9 @@ def _make_ctx( ctx.dry_run = dry_run ctx.write_host = write_host ctx.write_token = SecretStr(write_token) + ctx.write_username = None + ctx.write_password = None + ctx.insecure = False ctx.repo_path = Path("/tmp") return ctx diff --git a/tests/test_integration.py b/tests/test_integration.py index 21894cd..95731c4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -55,8 +55,8 @@ def test_repo(tmp_path: Path, unique_prefix: str) -> Path: def _make_settings() -> GitfluenceSettings: return GitfluenceSettings( - confluence_prod_host="http://mock.example.com/api", - confluence_prod_token=SecretStr("mock-token"), + confluence_host="http://mock.example.com/api", + confluence_token=SecretStr("mock-token"), confluence_int_token=SecretStr("mock-int-token"), confluence_space="TEST", ) From 64feadacf40b3e845a18b464f8352b1ec9e54969 Mon Sep 17 00:00:00 2001 From: geopanther Date: Wed, 29 Apr 2026 00:24:59 +0200 Subject: [PATCH 2/6] docs: update env var names, CLI options, and action inputs Update documentation for CONFLUENCE_PROD_HOST -> CONFLUENCE_HOST and CONFLUENCE_PROD_TOKEN -> CONFLUENCE_TOKEN rename. Add documentation for new features: - Username/password auth (token > user+password priority) - Integration target CLI args (--host-int, --token-int, etc.) - --debug alias for --verbose, -n alias for --dry-run - --page-id rejection note - --content-type choices (page/blogpost) - --insecure flag - Login args section for mdfluence pass-through --- README.md | 75 +++++++++++++++++++++++++++------------------- action.yml | 12 ++++---- setenv.example.ps1 | 10 +++---- setenv.example.sh | 10 +++---- 4 files changed, 61 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 58a9255..ad7daf5 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,22 @@ gitfluence --beautify-folders . # pass mdfluence options ## Environment Variables -| Variable | Required | Description | -| ----------------------- | -------- | ----------------------------------------------------------- | -| `CONFLUENCE_PROD_HOST` | Yes | Production Confluence REST API base URL | -| `CONFLUENCE_PROD_TOKEN` | Yes\* | PAT for production writes | -| `CONFLUENCE_INT_HOST` | No | Integration Confluence REST API base URL (defaults to prod) | -| `CONFLUENCE_INT_TOKEN` | No\* | PAT for integration writes | -| `CONFLUENCE_SPACE` | Yes\* | Confluence space key | +| Variable | Required | Description | +| ------------------------- | -------- | ----------------------------------------------------------- | +| `CONFLUENCE_HOST` | Yes | Confluence REST API base URL | +| `CONFLUENCE_TOKEN` | Yes\* | PAT for Confluence (token > username/password) | +| `CONFLUENCE_USERNAME` | No | Username for basic auth | +| `CONFLUENCE_PASSWORD` | No | Password for basic auth | +| `CONFLUENCE_INT_HOST` | No | Integration Confluence REST API base URL (defaults to prod) | +| `CONFLUENCE_INT_TOKEN` | No\* | PAT for integration writes | +| `CONFLUENCE_INT_USERNAME` | No | Username for integration basic auth | +| `CONFLUENCE_INT_PASSWORD` | No | Password for integration basic auth | +| `CONFLUENCE_SPACE` | Yes\* | Confluence space key | \* On `--dry-run`, missing host defaults to `https://dummy.example.com/api`, missing tokens default to `dummy` and missing space defaults to `DRY_RUN`. In interactive mode, missing values are prompted. +Auth decision (per target): token > username+password > prompt > dry-run dummy. +
macOS / Linux @@ -113,25 +119,25 @@ jobs: with: repo_path: "." extra_args: "--beautify-folders" - confluence_prod_host: ${{ secrets.CONFLUENCE_PROD_HOST }} - confluence_prod_token: ${{ secrets.CONFLUENCE_PROD_TOKEN }} + confluence_host: ${{ secrets.CONFLUENCE_HOST }} + confluence_token: ${{ secrets.CONFLUENCE_TOKEN }} confluence_space: ${{ secrets.CONFLUENCE_SPACE }} ``` ### Action inputs -| Input | Default | Description | -| ----------------------- | ---------- | -------------------------------- | -| `repo_path` | `"."` | Root directory to sync | -| `dry_run` | `"false"` | Preview mode | -| `gitfluence_version` | `"latest"` | Version to install | -| `python_version` | `"3.12"` | Python version | -| `extra_args` | `""` | Additional CLI args | -| `confluence_prod_host` | — | Confluence production host URL | -| `confluence_prod_token` | — | Confluence production API token | -| `confluence_int_host` | `""` | Confluence integration host URL | -| `confluence_int_token` | `""` | Confluence integration API token | -| `confluence_space` | — | Confluence space key | +| Input | Default | Description | +| ---------------------- | ---------- | -------------------------------- | +| `repo_path` | `"."` | Root directory to sync | +| `dry_run` | `"false"` | Preview mode | +| `gitfluence_version` | `"latest"` | Version to install | +| `python_version` | `"3.12"` | Python version | +| `extra_args` | `""` | Additional CLI args | +| `confluence_host` | — | Confluence host URL | +| `confluence_token` | — | Confluence API token | +| `confluence_int_host` | `""` | Confluence integration host URL | +| `confluence_int_token` | `""` | Confluence integration API token | +| `confluence_space` | — | Confluence space key | ## CLI Options @@ -139,14 +145,21 @@ jobs: These options are **not** available in mdfluence: -| Option | Description | -| ------------------ | ----------------------------------------------------------- | -| `repo_path` | Root directory of the git working tree to sync (positional) | -| `--space` | Override Confluence space key | -| `--prefix` | Override auto-detected page title prefix | -| `-v` / `--verbose` | Enable debug logging | -| `--no-preface` | Disable the default preface (DO-NOT-EDIT banner) | -| `--no-postface` | Disable the default postface (metadata footer) | +| Option | Description | +| ------------------------------ | ----------------------------------------------------------- | +| `repo_path` | Root directory of the git working tree to sync (positional) | +| `--space` | Override Confluence space key | +| `--prefix` | Override auto-detected page title prefix | +| `-v` / `--verbose` / `--debug` | Enable debug logging | +| `-n` (alias for `--dry-run`) | Print what would be done without calling API | +| `--no-preface` | Disable the default preface (DO-NOT-EDIT banner) | +| `--no-postface` | Disable the default postface (metadata footer) | +| `--host-int` | Integration Confluence host (env: CONFLUENCE_INT_HOST) | +| `--token-int` | Integration Confluence token (env: CONFLUENCE_INT_TOKEN) | +| `--username-int` | Integration Confluence username | +| `--password-int` | Integration Confluence password | + +> **Note:** `--page-id` is not supported. Pages are managed by directory hierarchy. Use `--parent-id` to anchor pages under a specific parent. ### Differences from mdfluence defaults @@ -170,7 +183,9 @@ Preface and postface behave differently from mdfluence: All remaining mdfluence options are passed through unchanged: -**Page information:** `--title`, `--content-type`, `--message`, `--minor-edit`, `--strip-top-header`, `--remove-text-newlines`, `--replace-all-labels` +**Login:** `--host` / `-o`, `--token`, `--username` / `-u`, `--password` / `-p`, `--insecure` + +**Page information:** `--title`, `--content-type` (choices: page/blogpost), `--message`, `--minor-edit`, `--strip-top-header`, `--remove-text-newlines`, `--replace-all-labels` **Parent selection** (mutually exclusive): `--parent-title` / `--parent-id` / `--top-level` diff --git a/action.yml b/action.yml index 8cae7fd..1d05ec8 100644 --- a/action.yml +++ b/action.yml @@ -23,11 +23,11 @@ inputs: description: "Additional CLI arguments passed to gitfluence (e.g. '--skip-empty --beautify-folders')" required: false default: "" - confluence_prod_host: - description: "Confluence production host URL" + confluence_host: + description: "Confluence host URL" required: true - confluence_prod_token: - description: "Confluence production API token" + confluence_token: + description: "Confluence API token" required: true confluence_int_host: description: "Confluence integration host URL" @@ -56,8 +56,8 @@ runs: - name: Sync to Confluence shell: bash env: - CONFLUENCE_PROD_HOST: ${{ inputs.confluence_prod_host }} - CONFLUENCE_PROD_TOKEN: ${{ inputs.confluence_prod_token }} + CONFLUENCE_HOST: ${{ inputs.confluence_host }} + CONFLUENCE_TOKEN: ${{ inputs.confluence_token }} CONFLUENCE_INT_HOST: ${{ inputs.confluence_int_host }} CONFLUENCE_INT_TOKEN: ${{ inputs.confluence_int_token }} CONFLUENCE_SPACE: ${{ inputs.confluence_space }} diff --git a/setenv.example.ps1 b/setenv.example.ps1 index 16a4566..77be7c5 100644 --- a/setenv.example.ps1 +++ b/setenv.example.ps1 @@ -4,13 +4,13 @@ if ($MyInvocation.InvocationName -ne '.') { exit 1 } -# Required: Confluence REST API base URL for production writes -$env:CONFLUENCE_PROD_HOST = "https://your-confluence.example.com/confluence/rest/api" +# Required: Confluence REST API base URL +$env:CONFLUENCE_HOST = "https://your-confluence.example.com/confluence/rest/api" -# Required: Personal Access Token for production Confluence -$env:CONFLUENCE_PROD_TOKEN = "your-prod-token" +# Required: Personal Access Token for Confluence +$env:CONFLUENCE_TOKEN = "your-token" -# Optional: Separate integration Confluence instance (defaults to CONFLUENCE_PROD_HOST) +# Optional: Separate integration Confluence instance (defaults to CONFLUENCE_HOST) # $env:CONFLUENCE_INT_HOST = "https://your-confluence-int.example.com/confluence/rest/api" # Optional: Token for integration instance (prompted interactively if unset) diff --git a/setenv.example.sh b/setenv.example.sh index 502a1f0..59adaab 100644 --- a/setenv.example.sh +++ b/setenv.example.sh @@ -8,13 +8,13 @@ else case "$0" in */setenv.sh) echo "ERROR: source this file, don't execute it: . ./setenv.sh" >&2; exit 1 ;; esac fi -# Required: Confluence REST API base URL for production writes -export CONFLUENCE_PROD_HOST="https://your-confluence.example.com/confluence/rest/api" +# Required: Confluence REST API base URL +export CONFLUENCE_HOST="https://your-confluence.example.com/confluence/rest/api" -# Required: Personal Access Token for production Confluence -export CONFLUENCE_PROD_TOKEN="your-prod-token" +# Required: Personal Access Token for Confluence +export CONFLUENCE_TOKEN="your-token" -# Optional: Separate integration Confluence instance (defaults to CONFLUENCE_PROD_HOST) +# Optional: Separate integration Confluence instance (defaults to CONFLUENCE_HOST) # export CONFLUENCE_INT_HOST="https://your-confluence-int.example.com/confluence/rest/api" # Optional: Token for integration instance (prompted interactively if unset) From 97ebe85fba1ddc985fd856745a31ca6808cc109a Mon Sep 17 00:00:00 2001 From: geopanther Date: Tue, 5 May 2026 21:14:59 +0200 Subject: [PATCH 3/6] fix(sec): Fixed gitpython security findings Finding: gitpython@3.1.46 has the following known vulnerabilities: GHSA-rpm5-65cw-6hj4: GitPython has Command Injection via Git options bypass Severity: '8.8'; Minimal Fix Version: '3.1.47'; GHSA-x2qx-6953-8485: GitPython: Unsafe option check validates multi_options before shlex.split... Severity: '8.1'; Minimal Fix Version: '3.1.47'; Fix: tie gitpython to >=3.1.47 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 09faa43..7c8f7b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] dependencies = [ "mdfluence>=0.2.1", - "gitpython>=3.1.46", + "gitpython>=3.1.47", "pydantic>=2.0", "pydantic-settings>=2.0", "identify>=2.6.19", diff --git a/uv.lock b/uv.lock index 03fe09e..e453f4d 100644 --- a/uv.lock +++ b/uv.lock @@ -263,7 +263,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "gitpython", specifier = ">=3.1.46" }, + { name = "gitpython", specifier = ">=3.1.47" }, { name = "identify", specifier = ">=2.6.19" }, { name = "mdfluence", specifier = ">=0.2.1" }, { name = "pydantic", specifier = ">=2.0" }, @@ -292,14 +292,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/91/ae/d983db08d9c5f3dd5 [[package]] name = "gitpython" -version = "3.1.46" +version = "3.1.49" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/63/210aaa302d6a0a78daa67c5c15bbac2cad361722841278b0209b6da20855/gitpython-3.1.49.tar.gz", hash = "sha256:42f9399c9eb33fc581014bedd76049dfbaf6375aa2a5754575966387280315e1", size = 219367, upload-time = "2026-04-29T00:31:20.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/b842bfa6f21d6f87c57f9abf7194225e55279d96d869775e19e9f7236fc5/gitpython-3.1.49-py3-none-any.whl", hash = "sha256:024b0422d7f84d15cd794844e029ffebd4c5d42a7eb9b936b458697ef550a02c", size = 212190, upload-time = "2026-04-29T00:31:18.412Z" }, ] [[package]] From 29b339c5c20d8f389f7ae87ea5219f29f24e69d1 Mon Sep 17 00:00:00 2001 From: geopanther Date: Thu, 7 May 2026 10:56:43 +0200 Subject: [PATCH 4/6] fix(confluence): scope --parent-id/--parent-title to root-level pages only CLI parent overrides were applied to all pages, flattening the directory hierarchy. Now only pages without a mdfluence-assigned parent receive the override; child pages retain their structure. --- gitfluence/confluence.py | 8 ++++-- tests/test_confluence.py | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/gitfluence/confluence.py b/gitfluence/confluence.py index 7d862f1..f98066d 100644 --- a/gitfluence/confluence.py +++ b/gitfluence/confluence.py @@ -283,8 +283,10 @@ def _preprocess_page( page.space = ctx.space page.content_type = getattr(args, "content_type", "page") if args else "page" - # CLI parent overrides - if args: + # CLI parent overrides — only for root-level pages (no parent from directory structure). + # Child pages already have parent_title set by mdfluence's get_pages_from_directory. + is_root_page = page.parent_title is None and page.parent_id is None + if args and is_root_page: parent_title = getattr(args, "parent_title", None) parent_id = getattr(args, "parent_id", None) top_level = getattr(args, "top_level", False) @@ -295,7 +297,7 @@ def _preprocess_page( elif top_level: pass # parent_title/parent_id already None from Page init - # Top-level pages → child of branch page / integration root / space homepage + # Root pages without an explicit parent → child of branch page / integration root / homepage if page.parent_title is None and page.parent_id is None: if branch_page is not None: page.parent_id = branch_page.id diff --git a/tests/test_confluence.py b/tests/test_confluence.py index a6d7edb..8e38e36 100644 --- a/tests/test_confluence.py +++ b/tests/test_confluence.py @@ -159,6 +159,66 @@ def test_postface_appended(self): _preprocess_page(page, ctx, "", "

postface

", space_info) assert page.body.endswith("

postface

") + # ── --parent-id / --parent-title propagation ────────────────────── + + def test_parent_id_applies_to_root_page(self): + page = _make_page(parent_title=None, parent_id=None) + ctx = _make_ctx() + space_info = SimpleNamespace(homepage=SimpleNamespace(id="1")) + args = SimpleNamespace( + parent_id="999", + parent_title=None, + top_level=False, + content_type="page", + convert_anchors=True, + ) + _preprocess_page(page, ctx, "", "", space_info, args=args) + assert page.parent_id == "999" + + def test_parent_id_does_not_apply_to_child_page(self): + page = _make_page(parent_title="DirPage", parent_id=None) + ctx = _make_ctx() + space_info = SimpleNamespace(homepage=SimpleNamespace(id="1")) + args = SimpleNamespace( + parent_id="999", + parent_title=None, + top_level=False, + content_type="page", + convert_anchors=True, + ) + _preprocess_page(page, ctx, "", "", space_info, args=args) + # Child keeps its directory-based parent, not CLI --parent-id + assert page.parent_title == "DirPage" + assert page.parent_id is None + + def test_parent_title_applies_to_root_page(self): + page = _make_page(parent_title=None, parent_id=None) + ctx = _make_ctx() + space_info = SimpleNamespace(homepage=SimpleNamespace(id="1")) + args = SimpleNamespace( + parent_id=None, + parent_title="MyParent", + top_level=False, + content_type="page", + convert_anchors=True, + ) + _preprocess_page(page, ctx, "", "", space_info, args=args) + assert page.parent_title == "MyParent" + + def test_parent_title_does_not_apply_to_child_page(self): + page = _make_page(parent_title="DirPage", parent_id=None) + ctx = _make_ctx() + space_info = SimpleNamespace(homepage=SimpleNamespace(id="1")) + args = SimpleNamespace( + parent_id=None, + parent_title="MyParent", + top_level=False, + content_type="page", + convert_anchors=True, + ) + _preprocess_page(page, ctx, "", "", space_info, args=args) + assert page.parent_title == "DirPage" + class TestBuildPathMap: def test_maps_file_paths(self, tmp_path): From 1cc705c1e45dbffcd28de2784bca52fee31e45c4 Mon Sep 17 00:00:00 2001 From: geopanther Date: Tue, 12 May 2026 21:50:45 +0200 Subject: [PATCH 5/6] fix(deps): bump dependency floors and pin urllib3>=2.7.0 for security - Bump mdfluence minimum from 0.2.1 to 0.4.0 - Bump gitpython minimum from 3.1.47 to 3.1.50 - Pin urllib3>=2.7.0 to fix GHSA-mf9v-mfxr-j63j and GHSA-qccp-gfcp-xxvc --- pyproject.toml | 5 +++-- uv.lock | 31 +++++++++++++++++-------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7c8f7b3..51467a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,11 +16,12 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "mdfluence>=0.2.1", - "gitpython>=3.1.47", + "mdfluence>=0.4.0", + "gitpython>=3.1.50", "pydantic>=2.0", "pydantic-settings>=2.0", "identify>=2.6.19", + "urllib3>=2.7.0", ] [project.urls] diff --git a/uv.lock b/uv.lock index e453f4d..0042916 100644 --- a/uv.lock +++ b/uv.lock @@ -245,6 +245,7 @@ dependencies = [ { name = "mdfluence" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "urllib3" }, ] [package.dev-dependencies] @@ -263,11 +264,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "gitpython", specifier = ">=3.1.47" }, + { name = "gitpython", specifier = ">=3.1.50" }, { name = "identify", specifier = ">=2.6.19" }, - { name = "mdfluence", specifier = ">=0.2.1" }, + { name = "mdfluence", specifier = ">=0.4.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.0" }, + { name = "urllib3", specifier = ">=2.7.0" }, ] [package.metadata.requires-dev] @@ -292,14 +294,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/91/ae/d983db08d9c5f3dd5 [[package]] name = "gitpython" -version = "3.1.49" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/63/210aaa302d6a0a78daa67c5c15bbac2cad361722841278b0209b6da20855/gitpython-3.1.49.tar.gz", hash = "sha256:42f9399c9eb33fc581014bedd76049dfbaf6375aa2a5754575966387280315e1", size = 219367, upload-time = "2026-04-29T00:31:20.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/6f/b842bfa6f21d6f87c57f9abf7194225e55279d96d869775e19e9f7236fc5/gitpython-3.1.49-py3-none-any.whl", hash = "sha256:024b0422d7f84d15cd794844e029ffebd4c5d42a7eb9b936b458697ef550a02c", size = 212190, upload-time = "2026-04-29T00:31:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] [[package]] @@ -380,7 +382,7 @@ wheels = [ [[package]] name = "mdfluence" -version = "0.2.1" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, @@ -390,10 +392,11 @@ dependencies = [ { name = "requests" }, { name = "rich" }, { name = "rich-argparse" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/c7/a328cbd6b446f28c38b52f6c68eb065b3151ea0990588187e01fc95c2678/mdfluence-0.2.1.tar.gz", hash = "sha256:9f663b0423312b8dfb38ca00cc71d7d4570188d94442ca0d9a6a32f81ea7bec9", size = 30659, upload-time = "2026-04-16T15:47:20.287Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/dc/659ed4a2484a2f1eda6f213352cd43bb234d6954de736b1192743c6cd19d/mdfluence-0.4.0.tar.gz", hash = "sha256:a46b70fc6f05d2a543e67b43ba4a52dfe6bceb58469b9ba69ad4518d7aed1a6a", size = 109683, upload-time = "2026-05-12T19:30:53.189Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/db/61e70f2b774956dcf6e4bbc2083f1c26517cdf0b83ebe2760f474a1fe22c/mdfluence-0.2.1-py3-none-any.whl", hash = "sha256:2969be1f1c0e0012abefcec7188d7d4b43b2b666ae0bc0038994b80fef580623", size = 27999, upload-time = "2026-04-16T15:47:19.229Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0f/037e3c81f41cbf0dacb99e6d9bbbec73c5a053d430f8e9a42598cad7f12c/mdfluence-0.4.0-py3-none-any.whl", hash = "sha256:17145ed2677a94374d017d56e63fb08a79da8c592d92ec903154f140ff083926", size = 50298, upload-time = "2026-05-12T19:30:51.42Z" }, ] [[package]] @@ -407,11 +410,11 @@ wheels = [ [[package]] name = "mistune" -version = "0.8.4" +version = "3.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/a4/509f6e7783ddd35482feda27bc7f72e65b5e7dc910eca4ab2164daf9c577/mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", size = 58322, upload-time = "2018-10-11T06:59:27.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/ec/4b43dae793655b7d8a25f76119624350b4d65eb663459eb9603d7f1f0345/mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4", size = 16220, upload-time = "2018-10-11T06:59:26.044Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, ] [[package]] @@ -906,11 +909,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From ec8b9b92db5b272d68287a1130413c679f470d05 Mon Sep 17 00:00:00 2001 From: geopanther Date: Tue, 12 May 2026 22:04:56 +0200 Subject: [PATCH 6/6] feat(confluence): align with all mdfluence CLI options - Remove broken rewrite_page_anchors import; delegate anchor conversion, emoji processing, and diagram rendering to mdfluence's get_pages_from_directory via convert_anchors, enable_emoji, render_diagrams, mmdc_path, plantuml_path params - Add --title override support in _preprocess_page - Prefix page titles and child parent_titles in integration mode (matching mdfluence's prefix behavior) - Add --top-level homepage anchoring for page moves - Fix test_cli: convert_anchors -> disable_anchor_convert - Update test_confluence and test_integration for prefix behavior --- gitfluence/confluence.py | 35 ++++++++++++++++++++++++++++++----- tests/test_cli.py | 4 ++-- tests/test_confluence.py | 19 +++++++++++++------ tests/test_integration.py | 9 +++++---- 4 files changed, 50 insertions(+), 17 deletions(-) diff --git a/gitfluence/confluence.py b/gitfluence/confluence.py index f98066d..cffcdb5 100644 --- a/gitfluence/confluence.py +++ b/gitfluence/confluence.py @@ -6,7 +6,6 @@ from pathlib import Path import mdfluence.document -from mdfluence.anchor import rewrite_page_anchors from mdfluence.api import MinimalConfluence from mdfluence.document import Page from mdfluence.upsert import upsert_attachment, upsert_page @@ -165,6 +164,15 @@ def _collect_pages(repo_path: Path, *, args=None) -> list[Page]: skip_subtrees_wo_markdown=( getattr(args, "skip_subtrees_wo_markdown", True) if args else True ), + enable_emoji=(not getattr(args, "disable_emoji", False) if args else True), + convert_anchors=( + not getattr(args, "disable_anchor_convert", False) if args else False + ), + render_diagrams=( + getattr(args, "render_diagrams", False) if args else False + ), + mmdc_path=getattr(args, "mmdc_path", None) if args else None, + plantuml_path=getattr(args, "plantuml_path", None) if args else None, ) ) @@ -283,6 +291,11 @@ def _preprocess_page( page.space = ctx.space page.content_type = getattr(args, "content_type", "page") if args else "page" + # CLI --title override (single-page only — validated at call site) + title_override = getattr(args, "title", None) if args else None + if title_override and page.title: + page.title = title_override + # CLI parent overrides — only for root-level pages (no parent from directory structure). # Child pages already have parent_title set by mdfluence's get_pages_from_directory. is_root_page = page.parent_title is None and page.parent_id is None @@ -296,6 +309,9 @@ def _preprocess_page( page.parent_id = parent_id elif top_level: pass # parent_title/parent_id already None from Page init + elif page.parent_title is not None and ctx.prefix: + # Child pages: prefix parent_title so they find the prefixed parent + page.parent_title = f"{ctx.prefix} - {page.parent_title}" # Root pages without an explicit parent → child of branch page / integration root / homepage if page.parent_title is None and page.parent_id is None: @@ -306,16 +322,25 @@ def _preprocess_page( elif space_info is not None: page.parent_id = space_info.homepage.id + # --top-level: anchor to homepage so pages can be moved back to top + if ( + getattr(args, "top_level", False) + and page.parent_title is None + and page.parent_id is None + and space_info is not None + ): + page.parent_id = space_info.homepage.id + + # Prefix page titles (integration mode) + if ctx.prefix: + page.title = f"{ctx.prefix} - {page.title}" + # Preface / postface if preface_markup: page.body = preface_markup + page.body if postface_markup: page.body = page.body + postface_markup - # Anchor conversion - if getattr(args, "convert_anchors", True) if args else True: - page.body = rewrite_page_anchors(page.body, page.title or "") - def _resolve_relative_links( confluence: MinimalConfluence, diff --git a/tests/test_cli.py b/tests/test_cli.py index 657d33a..2c6eef2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -89,9 +89,9 @@ def test_default_max_retries_unchanged(self): args = _parse(["."]) assert args.max_retries == 4 - def test_default_convert_anchors_true(self): + def test_default_disable_anchor_convert_false(self): args = _parse(["."]) - assert args.convert_anchors is True + assert args.disable_anchor_convert is False # ── 1c. gitfluence-specific args ───────────────────────────────────────── diff --git a/tests/test_confluence.py b/tests/test_confluence.py index 8e38e36..e4e8202 100644 --- a/tests/test_confluence.py +++ b/tests/test_confluence.py @@ -110,24 +110,31 @@ def test_child_page_parent_title_unchanged(self): branch_page=branch_page, ) assert page.parent_id is None - assert page.parent_title == "Parent" + assert page.parent_title == "feat/x - Parent" - def test_no_prefix_applied_to_title_when_branch_page_present(self): + def test_prefix_applied_to_title_when_prefix_set(self): page = _make_page(title="README") ctx = _make_ctx(prefix="feat/x") space_info = SimpleNamespace(homepage=SimpleNamespace(id="1")) branch_page = SimpleNamespace(id="200") _preprocess_page(page, ctx, "", "", space_info, branch_page=branch_page) - assert page.title == "README" + assert page.title == "feat/x - README" - def test_no_prefix_applied_to_parent_title(self): + def test_prefix_applied_to_parent_title(self): page = _make_page(title="Child", parent_title="Parent") ctx = _make_ctx(prefix="dev") space_info = SimpleNamespace(homepage=SimpleNamespace(id="1")) branch_page = SimpleNamespace(id="200") _preprocess_page(page, ctx, "", "", space_info, branch_page=branch_page) - assert page.parent_title == "Parent" - assert page.title == "Child" + assert page.parent_title == "dev - Parent" + assert page.title == "dev - Child" + + def test_no_prefix_on_prod_title(self): + page = _make_page(title="README") + ctx = _make_ctx(prefix=None) + space_info = SimpleNamespace(homepage=SimpleNamespace(id="1")) + _preprocess_page(page, ctx, "", "", space_info) + assert page.title == "README" def test_no_prefix_on_prod(self): page = _make_page(title="README") diff --git a/tests/test_integration.py b/tests/test_integration.py index 95731c4..a830530 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -135,14 +135,15 @@ def test_relative_links_resolved(self, mock_confluence, test_repo, unique_prefix class TestIntegrationPrefix: - def test_no_prefix_in_titles(self, mock_confluence, test_repo, unique_prefix): + def test_prefix_in_titles(self, mock_confluence, test_repo, unique_prefix): prefix = "feat/my-branch" _run_sync_with_mock(mock_confluence, test_repo, prefix=prefix) - # Content pages should have clean titles (no branch prefix) - page = mock_confluence.get_page_by_title(f"{unique_prefix} Root") + # Content pages should have prefixed titles in integration mode + page = mock_confluence.get_page_by_title(f"{prefix} - {unique_prefix} Root") assert page is not None, ( - f"Page '{unique_prefix} Root' not found — titles should not carry prefix" + f"Page '{prefix} - {unique_prefix} Root' not found — " + "titles should carry prefix in integration mode" ) def test_integration_root_created(self, mock_confluence, test_repo, unique_prefix):