@ngocsangairvds/vsaf 3.1.27 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/global.js +70 -10
- package/tools/skills/vds-scripts-skill/.openskills.json +6 -0
- package/tools/skills/vds-scripts-skill/QUALITY.md +44 -0
- package/tools/skills/vds-scripts-skill/SKILL.md +135 -0
- package/tools/skills/vds-scripts-skill/references/audit-commands.md +171 -0
- package/tools/skills/vds-scripts-skill/references/capability-index.md +34 -0
- package/tools/skills/vds-scripts-skill/references/development-commands.md +12 -0
- package/tools/skills/vds-scripts-skill/references/google-sheets.md +73 -0
- package/tools/skills/vds-scripts-skill/references/integration-commands.md +17 -0
- package/tools/skills/vds-scripts-skill/references/platform-bootstrap.md +31 -0
- package/tools/skills/vds-scripts-skill/references/specialist-routing.md +14 -0
- package/tools/skills/vds-scripts-skill/references/validation-commands.md +15 -0
- package/tools/skills/vsaf-build/SKILL.md +32 -2
- package/tools/skills/vsaf-ship/SKILL.md +41 -10
- package/tools/skills/vsaf-test/SKILL.md +8 -0
- package/tools/vds-scripts/.mcp.json +11 -0
- package/tools/vds-scripts/.secrets.baseline +133 -0
- package/tools/vds-scripts/AGENTS.md +152 -0
- package/tools/vds-scripts/CLAUDE.md +101 -0
- package/tools/vds-scripts/CLI_COMMAND_OPTIMIZATION.md +156 -0
- package/tools/vds-scripts/PACKAGE_P125B_IMPLEMENTATION_SUMMARY.md +131 -0
- package/tools/vds-scripts/PROJECT_COMPLETION_SUMMARY.md +45 -0
- package/tools/vds-scripts/README.md +97 -0
- package/tools/vds-scripts/bitbucket_manifest_mapping.toml +34 -0
- package/tools/vds-scripts/bitbucket_orchestrator/ARCHITECTURE_ANALYSIS.md +258 -0
- package/tools/vds-scripts/bitbucket_orchestrator/BITBUCKET_API_PRACTICES.md +393 -0
- package/tools/vds-scripts/bitbucket_orchestrator/EVALUATION_REPORT.md +61 -0
- package/tools/vds-scripts/bitbucket_orchestrator/FEATURES.md +908 -0
- package/tools/vds-scripts/bitbucket_orchestrator/README.md +687 -0
- package/tools/vds-scripts/bitbucket_orchestrator/pyproject.toml +40 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/__init__.py +20 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/async_client.py +657 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/cli.py +2108 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/client.py +2534 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/config.py +171 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/errors.py +67 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/factory.py +185 -0
- package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/protocols.py +244 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/__init__.py +8 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/conftest.py +65 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_advanced_search.py +151 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_async_client.py +546 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_branch_permissions.py +145 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_cli.py +115 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client.py +157 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_branch_conditions.py +79 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_code_advanced.py +163 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_code_file.py +32 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_deployment_environments.py +194 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_issues.py +164 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_pipelines_advanced.py +179 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_pr_blockers.py +119 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_repository_variables.py +156 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code.py +98 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code_advanced.py +282 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code_insights.py +335 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_conditions.py +147 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_config.py +131 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_deployment_env.py +352 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_factory.py +371 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_fork_operations.py +204 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_issue_cli.py +261 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_pipeline_advanced.py +270 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_pr_blocker.py +204 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_protocols.py +334 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_repo_settings.py +343 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_repo_variables.py +270 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_webhooks.py +189 -0
- package/tools/vds-scripts/bitbucket_orchestrator/tests/test_workspace.py +233 -0
- package/tools/vds-scripts/bitbucket_orchestrator/uv.lock +742 -0
- package/tools/vds-scripts/confluence_orchestrator/Dockerfile +19 -0
- package/tools/vds-scripts/confluence_orchestrator/README.md +412 -0
- package/tools/vds-scripts/confluence_orchestrator/SYNC_SCRIPTS.md +127 -0
- package/tools/vds-scripts/confluence_orchestrator/SYNC_STANDARDIZATION.md +108 -0
- package/tools/vds-scripts/confluence_orchestrator/pyproject.toml +48 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/__init__.py +20 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/cli.py +2532 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/config.py +175 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/content.py +290 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/content_v2.py +94 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/crawl_tree.py +1835 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/errors.py +80 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/eventing.py +109 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/http.py +1114 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/orchestration.py +165 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/reporting.py +78 -0
- package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/tree.py +121 -0
- package/tools/vds-scripts/confluence_orchestrator/sync_pdfs_from_markdown.py +213 -0
- package/tools/vds-scripts/confluence_orchestrator/sync_pdfs_to_confluence.py +305 -0
- package/tools/vds-scripts/confluence_orchestrator/sync_png_attachments.py +305 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/__init__.py +0 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/conftest.py +8 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_advanced_content.py +224 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_advanced_search.py +188 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_cache_management.py +247 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_cli.py +499 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_config.py +83 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_content.py +186 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_content_flags.py +27 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_crawl_tree.py +2250 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_draft_management.py +223 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing.py +71 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_chaos.py +37 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_rate_limit.py +44 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_timeout.py +49 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_export.py +230 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_history.py +204 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_http.py +117 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_orchestration.py +91 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_reporting.py +24 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_search_cql.py +34 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_space_management.py +237 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_space_permissions.py +332 -0
- package/tools/vds-scripts/confluence_orchestrator/tests/test_user_group_management.py +388 -0
- package/tools/vds-scripts/confluence_orchestrator/uv.lock +1023 -0
- package/tools/vds-scripts/git_orchestrator/ENHANCEMENT_SUMMARY.md +119 -0
- package/tools/vds-scripts/git_orchestrator/README.md +280 -0
- package/tools/vds-scripts/git_orchestrator/VERIFICATION_REPORT.md +152 -0
- package/tools/vds-scripts/git_orchestrator/pyproject.toml +35 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/__init__.py +7 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/__main__.py +4 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/cli.py +847 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/logging_config.py +63 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/manifest.py +129 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/orchestrator.py +819 -0
- package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/reporting.py +53 -0
- package/tools/vds-scripts/git_orchestrator/tests/__init__.py +0 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_cli_settings.py +21 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_integration.py +74 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_manifest.py +79 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_orchestrator.py +204 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_public_api.py +236 -0
- package/tools/vds-scripts/git_orchestrator/tests/test_resilience.py +345 -0
- package/tools/vds-scripts/git_orchestrator/uv.lock +271 -0
- package/tools/vds-scripts/jira_orchestrator/README.md +770 -0
- package/tools/vds-scripts/jira_orchestrator/pyproject.toml +39 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/__init__.py +1 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/adapter.py +1320 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/cli.py +2271 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/config.py +138 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/errors.py +67 -0
- package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/reporting.py +65 -0
- package/tools/vds-scripts/jira_orchestrator/tests/__init__.py +1 -0
- package/tools/vds-scripts/jira_orchestrator/tests/conftest.py +86 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_agile_list_payloads.py +54 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_bulk_operations.py +69 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_components.py +57 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_createmeta.py +45 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_dashboard.py +117 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_issue_properties.py +54 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_permissions_compat.py +42 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_reindex.py +42 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_remote_links.py +76 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_transitions.py +91 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_user_management.py +110 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_version_management.py +133 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_watchers.py +41 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_advanced_search.py +164 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_agile.py +256 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_application_properties.py +193 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_backlog.py +91 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_bulk_operations.py +277 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_cli.py +106 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_components.py +106 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_config.py +164 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_dashboard.py +122 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_discover_fields.py +207 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_filter_management.py +333 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_issue_archiving.py +164 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_issue_links.py +257 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_issue_properties.py +171 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_link_types.py +314 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_parse_set.py +37 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_permissions.py +273 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_reindex.py +81 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_remote_links.py +254 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_security_schemes.py +170 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_transitions_changelog.py +114 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_user_management.py +226 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_version_management.py +339 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_watchers.py +101 -0
- package/tools/vds-scripts/jira_orchestrator/tests/test_worklog.py +223 -0
- package/tools/vds-scripts/jira_orchestrator/uv.lock +738 -0
- package/tools/vds-scripts/mcp_server/Dockerfile +34 -0
- package/tools/vds-scripts/mcp_server/README.md +140 -0
- package/tools/vds-scripts/mcp_server/pyproject.toml +42 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/__init__.py +4 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/config.py +36 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/server.py +66 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/__init__.py +14 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/bitbucket_tools.py +47 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/confluence_tools.py +59 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/git_tools.py +71 -0
- package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/jira_tools.py +63 -0
- package/tools/vds-scripts/mcp_server/tests/__init__.py +2 -0
- package/tools/vds-scripts/mcp_server/tests/conftest.py +29 -0
- package/tools/vds-scripts/mcp_server/tests/unit/__init__.py +2 -0
- package/tools/vds-scripts/mcp_server/tests/unit/test_bitbucket_tools.py +25 -0
- package/tools/vds-scripts/mcp_server/tests/unit/test_confluence_tools.py +25 -0
- package/tools/vds-scripts/mcp_server/tests/unit/test_git_tools.py +32 -0
- package/tools/vds-scripts/mcp_server/tests/unit/test_jira_tools.py +32 -0
- package/tools/vds-scripts/mcp_server/tests/verification/__init__.py +2 -0
- package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_confluence_tools.py +40 -0
- package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_jira_tools.py +37 -0
- package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_tool_registration.py +47 -0
- package/tools/vds-scripts/mcp_server/uv.lock +1032 -0
- package/tools/vds-scripts/mypy.ini +5 -0
- package/tools/vds-scripts/pyproject.toml +29 -0
- package/tools/vds-scripts/repo-manifest.yaml +273 -0
- package/tools/vds-scripts/repo-manifest.yaml.example +25 -0
- package/tools/vds-scripts/scripts/BRD-Validation-API.postman_collection.json +706 -0
- package/tools/vds-scripts/scripts/BRD-Validation-README.md +308 -0
- package/tools/vds-scripts/scripts/README.md +162 -0
- package/tools/vds-scripts/scripts/bootstrap_uv.sh +30 -0
- package/tools/vds-scripts/scripts/brd-validation-environment.json +51 -0
- package/tools/vds-scripts/scripts/brd-validation-test-results.json +13023 -0
- package/tools/vds-scripts/scripts/brd_coverage_report.json +276 -0
- package/tools/vds-scripts/scripts/create_memory_session.py +35 -0
- package/tools/vds-scripts/scripts/deployment/load_docker_images_offline.sh +90 -0
- package/tools/vds-scripts/scripts/final_completion_report.md +139 -0
- package/tools/vds-scripts/scripts/folder_structure_report.json +321 -0
- package/tools/vds-scripts/scripts/generate_completion_report.py +125 -0
- package/tools/vds-scripts/scripts/generate_intellij_modules.py +150 -0
- package/tools/vds-scripts/scripts/link_integrity_report.json +807 -0
- package/tools/vds-scripts/scripts/move_audit_artifact_pages.py +255 -0
- package/tools/vds-scripts/scripts/move_audit_artifact_pages_rest.py +165 -0
- package/tools/vds-scripts/scripts/move_wrong_dept_pages.py +216 -0
- package/tools/vds-scripts/scripts/save_intellij_memories.py +120 -0
- package/tools/vds-scripts/scripts/save_memories_to_vds_ai.py +83 -0
- package/tools/vds-scripts/scripts/save_memories_vds_style.py +129 -0
- package/tools/vds-scripts/scripts/search_intellij_memories.py +50 -0
- package/tools/vds-scripts/scripts/setup_intellij_workspace.py +65 -0
- package/tools/vds-scripts/scripts/target-state-automation/README.md +89 -0
- package/tools/vds-scripts/scripts/target-state-automation/confluence_sync_coordinator.sh +27 -0
- package/tools/vds-scripts/scripts/target-state-automation/coordination.sh +114 -0
- package/tools/vds-scripts/scripts/target-state-automation/diagram_coordinator.sh +25 -0
- package/tools/vds-scripts/scripts/target-state-automation/docs_root.sh +22 -0
- package/tools/vds-scripts/scripts/target-state-automation/generate_diagrams.sh +22 -0
- package/tools/vds-scripts/scripts/target-state-automation/markdown_coordinator.sh +25 -0
- package/tools/vds-scripts/scripts/target-state-automation/progress_dashboard.sh +17 -0
- package/tools/vds-scripts/scripts/target-state-automation/schema_coordinator.sh +25 -0
- package/tools/vds-scripts/scripts/target-state-automation/sync_confluence.sh +30 -0
- package/tools/vds-scripts/scripts/target-state-automation/update_dependencies.sh +19 -0
- package/tools/vds-scripts/scripts/target-state-automation/validate_links.sh +86 -0
- package/tools/vds-scripts/scripts/target-state-automation/validate_markdown.sh +52 -0
- package/tools/vds-scripts/scripts/target-state-automation/validate_schemas.sh +26 -0
- package/tools/vds-scripts/scripts/target-state-automation/validate_structure.sh +98 -0
- package/tools/vds-scripts/scripts/update_modules_xml.py +190 -0
- package/tools/vds-scripts/scripts/uv-workspace-alignment-verification-2026-03-25.md +128 -0
- package/tools/vds-scripts/scripts/validate_brd_coverage.py +179 -0
- package/tools/vds-scripts/scripts/validate_folder_structure.py +240 -0
- package/tools/vds-scripts/scripts/validate_link_integrity.py +272 -0
- package/tools/vds-scripts/scripts/vds_sh_helpers.sh +180 -0
- package/tools/vds-scripts/scripts/verification/phase2_portable_paths_ubuntu_docker.sh +26 -0
- package/tools/vds-scripts/scripts/worktree_uv.sh +48 -0
- package/tools/vds-scripts/uv.lock +8 -0
- package/tools/vds-scripts/vds_cli/README.md +126 -0
- package/tools/vds-scripts/vds_cli/VERIFICATION_REPORT.md +41 -0
- package/tools/vds-scripts/vds_cli/pyproject.toml +38 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/__init__.py +3 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/cli.py +173 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/docs_sync.py +1203 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/env.py +41 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/google_sheets_orchestrator/__init__.py +3 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/google_sheets_orchestrator/google_sheets_orchestrator.py +198 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/router.py +93 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/sync_api.py +647 -0
- package/tools/vds-scripts/vds_cli/src/vds_cli/sync_service.py +266 -0
- package/tools/vds-scripts/vds_cli/tests/__init__.py +2 -0
- package/tools/vds-scripts/vds_cli/tests/conftest.py +49 -0
- package/tools/vds-scripts/vds_cli/tests/unit/__init__.py +2 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_cli.py +143 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_docs_sync.py +422 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_env.py +51 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_router.py +72 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_sync_api.py +357 -0
- package/tools/vds-scripts/vds_cli/tests/unit/test_sync_service.py +160 -0
- package/tools/vds-scripts/vds_cli/tests/verification/__init__.py +2 -0
- package/tools/vds-scripts/vds_cli/tests/verification/test_bitbucket_real.py +33 -0
- package/tools/vds-scripts/vds_cli/tests/verification/test_confluence_real.py +35 -0
- package/tools/vds-scripts/vds_cli/tests/verification/test_jira_real.py +41 -0
- package/tools/vds-scripts/vds_cli/uv.lock +524 -0
- package/tools/vds-scripts/vds_cli_common/README.md +190 -0
- package/tools/vds-scripts/vds_cli_common/pyproject.toml +92 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/__init__.py +34 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/completers.py +139 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/context.py +201 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/env.py +119 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/errors.py +318 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/output.py +284 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/paths.py +78 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/testing.py +213 -0
- package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/version.py +85 -0
- package/tools/vds-scripts/vds_cli_common/tests/__init__.py +1 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_completers.py +148 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_context.py +192 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_env.py +102 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_errors.py +186 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_output.py +229 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_paths.py +61 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_testing.py +138 -0
- package/tools/vds-scripts/vds_cli_common/tests/test_version.py +64 -0
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
"""Confluence client wrapper using atlassian-python-api for consistency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
try: # Prefer new-style Atlassian clients when available
|
|
14
|
+
from atlassian.confluence import ConfluenceCloud as AtlassianConfluenceCloud
|
|
15
|
+
from atlassian.confluence import ConfluenceServer as AtlassianConfluenceServer
|
|
16
|
+
except ImportError: # Fall back to legacy class
|
|
17
|
+
from atlassian import Confluence as AtlassianConfluenceCloud # type: ignore
|
|
18
|
+
AtlassianConfluenceServer = AtlassianConfluenceCloud
|
|
19
|
+
|
|
20
|
+
from .config import ConfluenceSettings
|
|
21
|
+
from .errors import (
|
|
22
|
+
ConfluenceAuthError,
|
|
23
|
+
ConfluenceClientError,
|
|
24
|
+
ConfluenceConflictError,
|
|
25
|
+
ConfluencePermissionError,
|
|
26
|
+
ConfluenceRateLimitedError,
|
|
27
|
+
ConfluenceResponseError,
|
|
28
|
+
ConfluenceTransportError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class RetryConfig:
|
|
34
|
+
max_attempts: int = 3
|
|
35
|
+
backoff_factor: float = 0.5
|
|
36
|
+
max_wait: float = 5.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConfluenceHttpClient:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
settings: ConfluenceSettings,
|
|
43
|
+
*,
|
|
44
|
+
server: str = "internal",
|
|
45
|
+
client: httpx.Client | None = None,
|
|
46
|
+
retry_config: RetryConfig | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
token = settings.token_for(server)
|
|
49
|
+
if not token:
|
|
50
|
+
raise ConfluenceAuthError(
|
|
51
|
+
f"Token for server '{server}' is missing.",
|
|
52
|
+
context={"server": server},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self._settings = settings
|
|
56
|
+
self._server = server
|
|
57
|
+
self._token = token
|
|
58
|
+
self._retry = retry_config or RetryConfig()
|
|
59
|
+
if client is None:
|
|
60
|
+
base_url = str(settings.url_for(server))
|
|
61
|
+
client = httpx.Client(
|
|
62
|
+
base_url=base_url,
|
|
63
|
+
headers={
|
|
64
|
+
"Authorization": f"Bearer {token}",
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
},
|
|
67
|
+
timeout=30,
|
|
68
|
+
)
|
|
69
|
+
self._client = client
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self._client.close()
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> ConfluenceHttpClient:
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
|
|
78
|
+
self.close()
|
|
79
|
+
|
|
80
|
+
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
81
|
+
attempt = 0
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
response = self._client.request(method, path, **kwargs)
|
|
85
|
+
except httpx.HTTPError as exc: # pragma: no cover - network errors
|
|
86
|
+
raise ConfluenceTransportError(f"HTTP transport error: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
if response.status_code < 400:
|
|
89
|
+
return response
|
|
90
|
+
|
|
91
|
+
if response.status_code in {401, 403}:
|
|
92
|
+
raise ConfluenceAuthError(
|
|
93
|
+
"Authentication failed",
|
|
94
|
+
context={"status_code": response.status_code},
|
|
95
|
+
)
|
|
96
|
+
if response.status_code == 409:
|
|
97
|
+
raise ConfluenceConflictError(
|
|
98
|
+
"Request conflicted with existing state",
|
|
99
|
+
context={"status_code": response.status_code},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
should_retry = response.status_code in {429, 500, 502, 503, 504}
|
|
103
|
+
if should_retry and attempt < self._retry.max_attempts - 1:
|
|
104
|
+
retry_after = response.headers.get("Retry-After")
|
|
105
|
+
if retry_after:
|
|
106
|
+
try:
|
|
107
|
+
wait = min(float(retry_after), self._retry.max_wait)
|
|
108
|
+
except ValueError:
|
|
109
|
+
wait = self._retry.backoff_factor * (2**attempt)
|
|
110
|
+
else:
|
|
111
|
+
wait = self._retry.backoff_factor * (2**attempt)
|
|
112
|
+
wait = min(wait, self._retry.max_wait)
|
|
113
|
+
time.sleep(wait)
|
|
114
|
+
attempt += 1
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
raise ConfluenceResponseError(
|
|
118
|
+
f"Unexpected status {response.status_code}",
|
|
119
|
+
status_code=response.status_code,
|
|
120
|
+
context={"status_code": response.status_code, "body": response.text[:256]},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ConfluenceClient:
|
|
125
|
+
"""Confluence client wrapper using atlassian-python-api SDK."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
settings: ConfluenceSettings,
|
|
130
|
+
*,
|
|
131
|
+
server: str | None = None,
|
|
132
|
+
timeout: int | None = 30,
|
|
133
|
+
) -> None:
|
|
134
|
+
self._settings = settings
|
|
135
|
+
self._server = server or settings.default_server
|
|
136
|
+
self._timeout = timeout or 30
|
|
137
|
+
|
|
138
|
+
base_url = str(settings.url_for(self._server))
|
|
139
|
+
token = settings.token_for(self._server)
|
|
140
|
+
|
|
141
|
+
# Decide whether this endpoint should be treated as Cloud (v2) or Server (v1)
|
|
142
|
+
self._is_cloud = self._looks_like_cloud(base_url)
|
|
143
|
+
client_cls = AtlassianConfluenceCloud if self._is_cloud else AtlassianConfluenceServer
|
|
144
|
+
|
|
145
|
+
# Configure authentication based on available credentials
|
|
146
|
+
auth_kwargs: dict[str, Any] = {"url": base_url, "timeout": self._timeout}
|
|
147
|
+
if self._is_cloud:
|
|
148
|
+
if not token:
|
|
149
|
+
raise ConfluenceAuthError(
|
|
150
|
+
f"No API token available for cloud server '{self._server}'.",
|
|
151
|
+
context={"server": self._server},
|
|
152
|
+
)
|
|
153
|
+
auth_kwargs["token"] = token
|
|
154
|
+
else:
|
|
155
|
+
if settings.username and settings.password:
|
|
156
|
+
auth_kwargs["username"] = settings.username
|
|
157
|
+
auth_kwargs["password"] = settings.password
|
|
158
|
+
elif token:
|
|
159
|
+
# Recent ConfluenceServer class also supports PATs
|
|
160
|
+
auth_kwargs["token"] = token
|
|
161
|
+
else:
|
|
162
|
+
raise ConfluenceAuthError(
|
|
163
|
+
f"No credentials available for server '{self._server}'. "
|
|
164
|
+
"Provide VDS_USERNAME+VDS_PASSWORD or a CONFLUENCE token.",
|
|
165
|
+
context={"server": self._server},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
self._client = client_cls(**auth_kwargs)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
raise ConfluenceTransportError(
|
|
172
|
+
f"Failed to initialize Confluence client: {exc}", context={"server": self._server, "base_url": base_url}
|
|
173
|
+
) from exc
|
|
174
|
+
|
|
175
|
+
self._log = structlog.get_logger(__name__).bind(server=self._server, base_url=base_url)
|
|
176
|
+
|
|
177
|
+
# Store retry settings
|
|
178
|
+
self._max_retries = settings.max_retries
|
|
179
|
+
self._retry_backoff_factor = settings.retry_backoff_factor
|
|
180
|
+
self._create_params = set(self._safe_signature_params(self._client.create_page))
|
|
181
|
+
self._update_params = set(self._safe_signature_params(self._client.update_page))
|
|
182
|
+
|
|
183
|
+
def supports_api_version(self, version: str) -> bool:
|
|
184
|
+
version = (version or "v1").lower()
|
|
185
|
+
if version == "v1":
|
|
186
|
+
return True
|
|
187
|
+
if version == "v2":
|
|
188
|
+
return self._is_cloud
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _looks_like_cloud(base_url: str) -> bool:
|
|
193
|
+
lowered = base_url.lower()
|
|
194
|
+
return any(indicator in lowered for indicator in ("atlassian.net", "atlassian.com"))
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _safe_signature_params(method) -> list[str]:
|
|
198
|
+
try:
|
|
199
|
+
return list(inspect.signature(method).parameters.keys())
|
|
200
|
+
except (TypeError, ValueError):
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
def _handle_api_error(self, error: Exception, operation: str) -> None:
|
|
204
|
+
"""Convert atlassian-python-api errors to our standardized errors."""
|
|
205
|
+
error_str = str(error).lower()
|
|
206
|
+
|
|
207
|
+
if "unauthorized" in error_str or "authentication" in error_str or "401" in error_str:
|
|
208
|
+
raise ConfluenceAuthError(f"Authentication failed during {operation}", context={"error": str(error)})
|
|
209
|
+
elif "forbidden" in error_str or "403" in error_str:
|
|
210
|
+
raise ConfluenceAuthError(f"Access forbidden during {operation}", context={"error": str(error)})
|
|
211
|
+
elif "rate limit" in error_str or "429" in error_str:
|
|
212
|
+
raise ConfluenceRateLimitedError(f"Rate limited during {operation}", context={"error": str(error)})
|
|
213
|
+
elif "conflict" in error_str or "409" in error_str:
|
|
214
|
+
raise ConfluenceConflictError(f"Conflict during {operation}", context={"error": str(error)})
|
|
215
|
+
elif "server error" in error_str or "500" in error_str:
|
|
216
|
+
raise ConfluenceResponseError(f"Server error during {operation}", context={"error": str(error)})
|
|
217
|
+
else:
|
|
218
|
+
raise ConfluenceClientError(f"API error during {operation}: {error}", context={"error": str(error)})
|
|
219
|
+
|
|
220
|
+
def _with_retry(self, operation, operation_name: str):
|
|
221
|
+
"""Execute an operation with retry logic using instance settings."""
|
|
222
|
+
for attempt in range(self._max_retries + 1):
|
|
223
|
+
try:
|
|
224
|
+
result = operation()
|
|
225
|
+
return result
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
try:
|
|
228
|
+
self._handle_api_error(exc, operation_name)
|
|
229
|
+
except (ConfluenceTransportError, ConfluenceRateLimitedError) as retry_exc:
|
|
230
|
+
if attempt == self._max_retries:
|
|
231
|
+
self._log.error(
|
|
232
|
+
"retry_attempts_exhausted",
|
|
233
|
+
operation=operation_name,
|
|
234
|
+
attempts=self._max_retries,
|
|
235
|
+
final_error=str(retry_exc),
|
|
236
|
+
)
|
|
237
|
+
from .errors import ConfluenceRetryError
|
|
238
|
+
|
|
239
|
+
raise ConfluenceRetryError(
|
|
240
|
+
f"Retry attempts exhausted: {retry_exc}",
|
|
241
|
+
attempts=self._max_retries,
|
|
242
|
+
context={"original_error": str(retry_exc), "operation": operation_name},
|
|
243
|
+
) from retry_exc
|
|
244
|
+
|
|
245
|
+
wait_time = self._retry_backoff_factor * (2**attempt)
|
|
246
|
+
self._log.warning(
|
|
247
|
+
"retry_attempt",
|
|
248
|
+
operation=operation_name,
|
|
249
|
+
attempt=attempt + 1,
|
|
250
|
+
max_retries=self._max_retries,
|
|
251
|
+
wait_time=wait_time,
|
|
252
|
+
error=str(retry_exc),
|
|
253
|
+
)
|
|
254
|
+
time.sleep(wait_time)
|
|
255
|
+
except ConfluenceClientError:
|
|
256
|
+
# Don't retry on client errors (auth, not found, etc.)
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
def get_page(self, page_id: str, expand: list[str] | None = None) -> dict[str, Any]:
|
|
260
|
+
"""Get a page by ID."""
|
|
261
|
+
|
|
262
|
+
def operation():
|
|
263
|
+
params = {}
|
|
264
|
+
if expand:
|
|
265
|
+
params["expand"] = ",".join(expand)
|
|
266
|
+
result = self._client.get_page_by_id(page_id, expand=",".join(expand) if expand else None)
|
|
267
|
+
self._log.debug("page_retrieved", page_id=page_id)
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
return self._with_retry(operation, f"get_page {page_id}")
|
|
271
|
+
|
|
272
|
+
def search_cql(
|
|
273
|
+
self, cql: str, limit: int = 25, expand: list[str] | None = None, **kwargs: Any
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
"""Search using CQL."""
|
|
276
|
+
|
|
277
|
+
def operation():
|
|
278
|
+
result = self._client.cql(cql, limit=limit, expand=",".join(expand) if expand else None, **kwargs)
|
|
279
|
+
self._log.debug("cql_search_completed", cql=cql, limit=limit)
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
return self._with_retry(operation, f"cql_search {cql}")
|
|
283
|
+
|
|
284
|
+
def cql_advanced(
|
|
285
|
+
self,
|
|
286
|
+
cql: str,
|
|
287
|
+
*,
|
|
288
|
+
limit: int = 25,
|
|
289
|
+
start: int = 0,
|
|
290
|
+
expand: list[str] | None = None,
|
|
291
|
+
excerpt: str | None = None,
|
|
292
|
+
**kwargs: Any,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
"""Advanced CQL search with additional options.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
cql: CQL query string
|
|
298
|
+
limit: Maximum number of results (default: 25)
|
|
299
|
+
start: Start index for pagination (default: 0)
|
|
300
|
+
expand: Optional list of fields to expand
|
|
301
|
+
excerpt: Optional excerpt strategy (e.g., "highlighted", "indexed")
|
|
302
|
+
**kwargs: Additional options passed to SDK cql() method
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def operation():
|
|
306
|
+
expand_str = ",".join(expand) if expand else None
|
|
307
|
+
# SDK cql() method supports start parameter via kwargs
|
|
308
|
+
cql_kwargs = {"limit": limit, "start": start, **kwargs}
|
|
309
|
+
if expand_str:
|
|
310
|
+
cql_kwargs["expand"] = expand_str
|
|
311
|
+
if excerpt:
|
|
312
|
+
cql_kwargs["excerpt"] = excerpt
|
|
313
|
+
|
|
314
|
+
result = self._client.cql(cql, **cql_kwargs) # type: ignore[call-arg]
|
|
315
|
+
self._log.debug("cql_advanced_search_completed", cql=cql, limit=limit, start=start)
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
return self._with_retry(operation, f"cql_advanced_search {cql}")
|
|
319
|
+
|
|
320
|
+
def search_by_space_and_type(
|
|
321
|
+
self,
|
|
322
|
+
space_key: str | None = None,
|
|
323
|
+
content_type: str | None = None,
|
|
324
|
+
*,
|
|
325
|
+
limit: int = 25,
|
|
326
|
+
start: int = 0,
|
|
327
|
+
expand: list[str] | None = None,
|
|
328
|
+
**kwargs: Any,
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
"""Search by space and content type (helper method that builds CQL).
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
space_key: Optional space key to filter by
|
|
334
|
+
content_type: Optional content type (e.g., "page", "blogpost", "comment")
|
|
335
|
+
limit: Maximum number of results (default: 25)
|
|
336
|
+
start: Start index for pagination (default: 0)
|
|
337
|
+
expand: Optional list of fields to expand
|
|
338
|
+
**kwargs: Additional options passed to CQL search
|
|
339
|
+
"""
|
|
340
|
+
# Build CQL query
|
|
341
|
+
cql_parts: list[str] = []
|
|
342
|
+
if space_key:
|
|
343
|
+
cql_parts.append(f'space = "{space_key}"')
|
|
344
|
+
if content_type:
|
|
345
|
+
cql_parts.append(f'type = "{content_type}"')
|
|
346
|
+
|
|
347
|
+
if not cql_parts:
|
|
348
|
+
raise ConfluenceClientError(
|
|
349
|
+
"At least one of space_key or content_type must be provided",
|
|
350
|
+
context={},
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
cql = " AND ".join(cql_parts)
|
|
354
|
+
return self.cql_advanced(cql, limit=limit, start=start, expand=expand, **kwargs)
|
|
355
|
+
|
|
356
|
+
def create_page(
|
|
357
|
+
self, space_key: str, title: str, body: str, parent_id: str | None = None, **kwargs: Any
|
|
358
|
+
) -> dict[str, Any]:
|
|
359
|
+
"""Create a new page."""
|
|
360
|
+
|
|
361
|
+
def operation():
|
|
362
|
+
call_kwargs: dict[str, Any] = {}
|
|
363
|
+
if parent_id is not None:
|
|
364
|
+
call_kwargs["parent_id"] = parent_id
|
|
365
|
+
for key, value in kwargs.items():
|
|
366
|
+
if key in self._create_params and value is not None:
|
|
367
|
+
call_kwargs[key] = value
|
|
368
|
+
result = self._client.create_page(space=space_key, title=title, body=body, **call_kwargs)
|
|
369
|
+
self._log.info("page_created", space_key=space_key, title=title, page_id=result.get("id"))
|
|
370
|
+
return result
|
|
371
|
+
|
|
372
|
+
return self._with_retry(operation, f"create_page {title}")
|
|
373
|
+
|
|
374
|
+
def update_page(
|
|
375
|
+
self, page_id: str, title: str, body: str | None = None, version: int | None = None, **kwargs: Any
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
"""Update an existing page."""
|
|
378
|
+
|
|
379
|
+
def operation():
|
|
380
|
+
call_kwargs: dict[str, Any] = {}
|
|
381
|
+
for key, value in kwargs.items():
|
|
382
|
+
if key in self._update_params and value is not None:
|
|
383
|
+
call_kwargs[key] = value
|
|
384
|
+
if version is not None and "version" in self._update_params:
|
|
385
|
+
call_kwargs["version"] = version
|
|
386
|
+
result = self._client.update_page(page_id, title, body, **call_kwargs)
|
|
387
|
+
self._log.info("page_updated", page_id=page_id, title=title)
|
|
388
|
+
return result
|
|
389
|
+
|
|
390
|
+
return self._with_retry(operation, f"update_page {page_id}")
|
|
391
|
+
|
|
392
|
+
def delete_page(self, page_id: str) -> None:
|
|
393
|
+
"""Delete a page."""
|
|
394
|
+
try:
|
|
395
|
+
# Use remove_page method from atlassian library
|
|
396
|
+
self._client.remove_page(page_id=page_id)
|
|
397
|
+
self._log.info("page_deleted", page_id=page_id)
|
|
398
|
+
|
|
399
|
+
except Exception as exc:
|
|
400
|
+
# Check if it's a permission issue
|
|
401
|
+
if "Unable to trash content" in str(exc) or "Unable to purge content" in str(exc):
|
|
402
|
+
self._log.warning("page_delete_permission_denied", page_id=page_id, error=str(exc))
|
|
403
|
+
raise ConfluencePermissionError(
|
|
404
|
+
f"Permission denied when deleting page {page_id}. The page may be restricted or you may not have delete permissions.",
|
|
405
|
+
context={"page_id": page_id, "original_error": str(exc)},
|
|
406
|
+
) from exc
|
|
407
|
+
else:
|
|
408
|
+
self._handle_api_error(exc, f"delete_page {page_id}")
|
|
409
|
+
|
|
410
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
411
|
+
"""Make a raw HTTP request using the atlassian client."""
|
|
412
|
+
try:
|
|
413
|
+
# Use the underlying request method from atlassian library
|
|
414
|
+
response = self._client.request(method=method, path=path, **kwargs)
|
|
415
|
+
self._log.debug("http_request", method=method, path=path)
|
|
416
|
+
return response
|
|
417
|
+
|
|
418
|
+
except Exception as exc:
|
|
419
|
+
self._handle_api_error(exc, f"request {method} {path}")
|
|
420
|
+
|
|
421
|
+
def add_attachment(
|
|
422
|
+
self,
|
|
423
|
+
page_id: str,
|
|
424
|
+
filename: str,
|
|
425
|
+
content: bytes,
|
|
426
|
+
content_type: str | None = None,
|
|
427
|
+
*,
|
|
428
|
+
comment: str | None = None,
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
"""Add an attachment to a page.
|
|
431
|
+
|
|
432
|
+
According to atlassian-python-api docs, attach_content automatically updates
|
|
433
|
+
if the file exists, versioning the new file and keeping the old one.
|
|
434
|
+
See: https://atlassian-python-api.readthedocs.io/confluence.html#
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
def operation():
|
|
438
|
+
# Use attach_content from the official SDK (handles create vs update internally)
|
|
439
|
+
result = self._client.attach_content(
|
|
440
|
+
content=content,
|
|
441
|
+
name=filename,
|
|
442
|
+
content_type=content_type or "application/octet-stream",
|
|
443
|
+
page_id=page_id,
|
|
444
|
+
comment=comment,
|
|
445
|
+
)
|
|
446
|
+
self._log.info("attachment_added", page_id=page_id, filename=filename)
|
|
447
|
+
return result
|
|
448
|
+
|
|
449
|
+
return self._with_retry(operation, f"add_attachment {filename}")
|
|
450
|
+
|
|
451
|
+
def get_space(self, space_key: str) -> dict[str, Any]:
|
|
452
|
+
"""Get space information."""
|
|
453
|
+
try:
|
|
454
|
+
result = self._client.get_space(space_key)
|
|
455
|
+
self._log.debug("space_retrieved", space_key=space_key)
|
|
456
|
+
return result
|
|
457
|
+
|
|
458
|
+
except Exception as exc:
|
|
459
|
+
self._handle_api_error(exc, f"get_space {space_key}")
|
|
460
|
+
|
|
461
|
+
def get_space_permissions(self, space_key: str) -> dict[str, Any]:
|
|
462
|
+
"""Get all permissions configured for a space."""
|
|
463
|
+
|
|
464
|
+
def operation():
|
|
465
|
+
result = self._client.get_space_permissions(space_key)
|
|
466
|
+
count = len(result) if isinstance(result, (list, tuple)) else len(result or {})
|
|
467
|
+
self._log.debug("space_permissions_retrieved", space_key=space_key, count=count)
|
|
468
|
+
return result
|
|
469
|
+
|
|
470
|
+
return self._with_retry(operation, f"get_space_permissions {space_key}")
|
|
471
|
+
|
|
472
|
+
def set_permissions_to_user_for_space(
|
|
473
|
+
self,
|
|
474
|
+
space_key: str,
|
|
475
|
+
user_key: str,
|
|
476
|
+
*,
|
|
477
|
+
operations: list[str] | None = None,
|
|
478
|
+
) -> dict[str, Any]:
|
|
479
|
+
"""Grant permissions to a specific user for a space."""
|
|
480
|
+
|
|
481
|
+
def operation():
|
|
482
|
+
result = self._client.set_permissions_to_user_for_space(
|
|
483
|
+
space_key,
|
|
484
|
+
user_key,
|
|
485
|
+
operations=operations,
|
|
486
|
+
)
|
|
487
|
+
self._log.info(
|
|
488
|
+
"space_permissions_user_set",
|
|
489
|
+
space_key=space_key,
|
|
490
|
+
user_key=user_key,
|
|
491
|
+
operations=operations or [],
|
|
492
|
+
)
|
|
493
|
+
return result
|
|
494
|
+
|
|
495
|
+
return self._with_retry(operation, f"set_permissions_to_user_for_space {space_key} {user_key}")
|
|
496
|
+
|
|
497
|
+
def set_permissions_to_group_for_space(
|
|
498
|
+
self,
|
|
499
|
+
space_key: str,
|
|
500
|
+
group_name: str,
|
|
501
|
+
*,
|
|
502
|
+
operations: list[str] | None = None,
|
|
503
|
+
) -> dict[str, Any]:
|
|
504
|
+
"""Grant permissions to a group for a space."""
|
|
505
|
+
|
|
506
|
+
def operation():
|
|
507
|
+
result = self._client.set_permissions_to_group_for_space(
|
|
508
|
+
space_key,
|
|
509
|
+
group_name,
|
|
510
|
+
operations=operations,
|
|
511
|
+
)
|
|
512
|
+
self._log.info(
|
|
513
|
+
"space_permissions_group_set",
|
|
514
|
+
space_key=space_key,
|
|
515
|
+
group_name=group_name,
|
|
516
|
+
operations=operations or [],
|
|
517
|
+
)
|
|
518
|
+
return result
|
|
519
|
+
|
|
520
|
+
return self._with_retry(operation, f"set_permissions_to_group_for_space {space_key} {group_name}")
|
|
521
|
+
|
|
522
|
+
def set_permissions_to_anonymous_for_space(
|
|
523
|
+
self,
|
|
524
|
+
space_key: str,
|
|
525
|
+
*,
|
|
526
|
+
operations: list[str] | None = None,
|
|
527
|
+
) -> dict[str, Any]:
|
|
528
|
+
"""Grant permissions to anonymous users for a space."""
|
|
529
|
+
|
|
530
|
+
def operation():
|
|
531
|
+
result = self._client.set_permissions_to_anonymous_for_space(space_key, operations=operations)
|
|
532
|
+
self._log.info(
|
|
533
|
+
"space_permissions_anonymous_set",
|
|
534
|
+
space_key=space_key,
|
|
535
|
+
operations=operations or [],
|
|
536
|
+
)
|
|
537
|
+
return result
|
|
538
|
+
|
|
539
|
+
return self._with_retry(operation, f"set_permissions_to_anonymous_for_space {space_key}")
|
|
540
|
+
|
|
541
|
+
def set_permissions_to_multiple_items_for_space(
|
|
542
|
+
self,
|
|
543
|
+
space_key: str,
|
|
544
|
+
items: list[dict[str, Any]],
|
|
545
|
+
) -> dict[str, Any]:
|
|
546
|
+
"""Grant permissions to multiple users/groups in a single request."""
|
|
547
|
+
|
|
548
|
+
def operation():
|
|
549
|
+
result = self._client.set_permissions_to_multiple_items_for_space(space_key, items)
|
|
550
|
+
self._log.info(
|
|
551
|
+
"space_permissions_bulk_set",
|
|
552
|
+
space_key=space_key,
|
|
553
|
+
items=len(items),
|
|
554
|
+
)
|
|
555
|
+
return result
|
|
556
|
+
|
|
557
|
+
return self._with_retry(operation, f"set_permissions_to_multiple_items_for_space {space_key}")
|
|
558
|
+
|
|
559
|
+
def remove_permissions_from_user_for_space(self, space_key: str, user_key: str) -> dict[str, Any]:
|
|
560
|
+
"""Remove all permissions granted to a user for a space."""
|
|
561
|
+
|
|
562
|
+
def operation():
|
|
563
|
+
result = self._client.remove_permissions_from_user_for_space(space_key, user_key)
|
|
564
|
+
self._log.info("space_permissions_user_removed", space_key=space_key, user_key=user_key)
|
|
565
|
+
return result
|
|
566
|
+
|
|
567
|
+
return self._with_retry(operation, f"remove_permissions_from_user_for_space {space_key} {user_key}")
|
|
568
|
+
|
|
569
|
+
def remove_permissions_from_group_for_space(self, space_key: str, group_name: str) -> dict[str, Any]:
|
|
570
|
+
"""Remove all permissions granted to a group for a space."""
|
|
571
|
+
|
|
572
|
+
def operation():
|
|
573
|
+
result = self._client.remove_permissions_from_group_for_space(space_key, group_name)
|
|
574
|
+
self._log.info("space_permissions_group_removed", space_key=space_key, group_name=group_name)
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
return self._with_retry(operation, f"remove_permissions_from_group_for_space {space_key} {group_name}")
|
|
578
|
+
|
|
579
|
+
def remove_permissions_from_anonymous_for_space(self, space_key: str) -> dict[str, Any]:
|
|
580
|
+
"""Remove permissions granted to anonymous users for a space."""
|
|
581
|
+
|
|
582
|
+
def operation():
|
|
583
|
+
result = self._client.remove_permissions_from_anonymous_for_space(space_key)
|
|
584
|
+
self._log.info("space_permissions_anonymous_removed", space_key=space_key)
|
|
585
|
+
return result
|
|
586
|
+
|
|
587
|
+
return self._with_retry(operation, f"remove_permissions_from_anonymous_for_space {space_key}")
|
|
588
|
+
|
|
589
|
+
# --- User & Group Management ---
|
|
590
|
+
|
|
591
|
+
def get_all_groups(self, start: int = 0, limit: int = 1000) -> list[dict[str, Any]]:
|
|
592
|
+
"""Get all groups in Confluence."""
|
|
593
|
+
|
|
594
|
+
def operation():
|
|
595
|
+
result = self._client.get_all_groups(start=start, limit=limit)
|
|
596
|
+
self._log.debug("groups_retrieved", count=len(result) if isinstance(result, list) else 0, start=start, limit=limit)
|
|
597
|
+
return result if isinstance(result, list) else []
|
|
598
|
+
|
|
599
|
+
return self._with_retry(operation, f"get_all_groups start={start} limit={limit}")
|
|
600
|
+
|
|
601
|
+
def get_group_members(self, group_name: str, start: int = 0, limit: int = 1000) -> list[dict[str, Any]]:
|
|
602
|
+
"""Get members of a group."""
|
|
603
|
+
|
|
604
|
+
def operation():
|
|
605
|
+
result = self._client.get_group_members(group_name, start=start, limit=limit)
|
|
606
|
+
self._log.debug("group_members_retrieved", group_name=group_name, count=len(result) if isinstance(result, list) else 0)
|
|
607
|
+
return result if isinstance(result, list) else []
|
|
608
|
+
|
|
609
|
+
return self._with_retry(operation, f"get_group_members {group_name} start={start} limit={limit}")
|
|
610
|
+
|
|
611
|
+
def get_user_details_by_username(self, username: str, expand: str | None = None) -> dict[str, Any]:
|
|
612
|
+
"""Get user details by username."""
|
|
613
|
+
|
|
614
|
+
def operation():
|
|
615
|
+
result = self._client.get_user_details_by_username(username, expand=expand)
|
|
616
|
+
self._log.debug("user_retrieved_by_username", username=username)
|
|
617
|
+
return result
|
|
618
|
+
|
|
619
|
+
return self._with_retry(operation, f"get_user_details_by_username {username}")
|
|
620
|
+
|
|
621
|
+
def get_user_details_by_userkey(self, userkey: str, expand: str | None = None) -> dict[str, Any]:
|
|
622
|
+
"""Get user details by user key."""
|
|
623
|
+
|
|
624
|
+
def operation():
|
|
625
|
+
result = self._client.get_user_details_by_userkey(userkey, expand=expand)
|
|
626
|
+
self._log.debug("user_retrieved_by_userkey", userkey=userkey)
|
|
627
|
+
return result
|
|
628
|
+
|
|
629
|
+
return self._with_retry(operation, f"get_user_details_by_userkey {userkey}")
|
|
630
|
+
|
|
631
|
+
def change_user_password(self, username: str, password: str) -> None:
|
|
632
|
+
"""Change user password."""
|
|
633
|
+
|
|
634
|
+
def operation():
|
|
635
|
+
self._client.change_user_password(username, password)
|
|
636
|
+
self._log.info("user_password_changed", username=username)
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
return self._with_retry(operation, f"change_user_password {username}")
|
|
640
|
+
|
|
641
|
+
def add_user_to_group(self, username: str, group_name: str) -> None:
|
|
642
|
+
"""Add user to group."""
|
|
643
|
+
|
|
644
|
+
def operation():
|
|
645
|
+
self._client.add_user_to_group(username, group_name)
|
|
646
|
+
self._log.info("user_added_to_group", username=username, group_name=group_name)
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
return self._with_retry(operation, f"add_user_to_group {username} {group_name}")
|
|
650
|
+
|
|
651
|
+
def remove_user_from_group(self, username: str, group_name: str) -> None:
|
|
652
|
+
"""Remove user from group."""
|
|
653
|
+
|
|
654
|
+
def operation():
|
|
655
|
+
self._client.remove_user_from_group(username, group_name)
|
|
656
|
+
self._log.info("user_removed_from_group", username=username, group_name=group_name)
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
return self._with_retry(operation, f"remove_user_from_group {username} {group_name}")
|
|
660
|
+
|
|
661
|
+
# --- Export Operations ---
|
|
662
|
+
|
|
663
|
+
def export_page(self, page_id: str, api_version: str | None = None) -> bytes:
|
|
664
|
+
"""Export page as PDF.
|
|
665
|
+
|
|
666
|
+
Note: The SDK auto-detects Cloud/Server mode based on client initialization.
|
|
667
|
+
The api_version parameter is accepted for CLI compatibility but not passed to SDK.
|
|
668
|
+
"""
|
|
669
|
+
|
|
670
|
+
def operation():
|
|
671
|
+
# SDK auto-detects Cloud/Server mode, so we don't pass api_version
|
|
672
|
+
# The parameter is kept for CLI compatibility but ignored here
|
|
673
|
+
result = self._client.export_page(page_id) # type: ignore[call-arg]
|
|
674
|
+
detected_mode = "cloud" if self._is_cloud else "server"
|
|
675
|
+
self._log.info("page_exported", page_id=page_id, detected_mode=detected_mode)
|
|
676
|
+
# SDK returns bytes or file path; ensure we return bytes
|
|
677
|
+
if isinstance(result, bytes):
|
|
678
|
+
return result
|
|
679
|
+
elif isinstance(result, str):
|
|
680
|
+
# If it's a file path, read it
|
|
681
|
+
with open(result, "rb") as f:
|
|
682
|
+
return f.read()
|
|
683
|
+
else:
|
|
684
|
+
raise ConfluenceClientError(f"Unexpected export_page return type: {type(result)}", context={"page_id": page_id})
|
|
685
|
+
|
|
686
|
+
return self._with_retry(operation, f"export_page {page_id}")
|
|
687
|
+
|
|
688
|
+
def get_space_export(self, space_key: str, export_type: str) -> str:
|
|
689
|
+
"""Get space export download URL."""
|
|
690
|
+
|
|
691
|
+
def operation():
|
|
692
|
+
result = self._client.get_space_export(space_key, export_type)
|
|
693
|
+
self._log.info("space_export_url_retrieved", space_key=space_key, export_type=export_type)
|
|
694
|
+
return result if isinstance(result, str) else str(result)
|
|
695
|
+
|
|
696
|
+
return self._with_retry(operation, f"get_space_export {space_key} {export_type}")
|
|
697
|
+
|
|
698
|
+
# --- Draft Management (Server-only) ---
|
|
699
|
+
|
|
700
|
+
def get_draft_page_by_id(self, page_id: str) -> dict[str, Any]:
|
|
701
|
+
"""Get draft page by ID (Server-only)."""
|
|
702
|
+
if self._is_cloud:
|
|
703
|
+
raise ConfluenceClientError(
|
|
704
|
+
"get_draft_page_by_id is only available in Confluence Server/Data Center mode",
|
|
705
|
+
context={"page_id": page_id},
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
def operation():
|
|
709
|
+
result = self._client.get_draft_page_by_id(page_id) # type: ignore[attr-defined]
|
|
710
|
+
self._log.debug("draft_page_retrieved", page_id=page_id)
|
|
711
|
+
return result
|
|
712
|
+
|
|
713
|
+
return self._with_retry(operation, f"get_draft_page_by_id {page_id}")
|
|
714
|
+
|
|
715
|
+
def get_all_draft_pages_from_space(self, space_key: str, limit: int = 25) -> list[dict[str, Any]]:
|
|
716
|
+
"""Get all draft pages from a space (Server-only)."""
|
|
717
|
+
if self._is_cloud:
|
|
718
|
+
raise ConfluenceClientError(
|
|
719
|
+
"get_all_draft_pages_from_space is only available in Confluence Server/Data Center mode",
|
|
720
|
+
context={"space_key": space_key},
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
def operation():
|
|
724
|
+
result = self._client.get_all_draft_pages_from_space(space_key, limit=limit) # type: ignore[attr-defined]
|
|
725
|
+
self._log.debug("draft_pages_retrieved", space_key=space_key, count=len(result) if isinstance(result, list) else 0)
|
|
726
|
+
return result if isinstance(result, list) else []
|
|
727
|
+
|
|
728
|
+
return self._with_retry(operation, f"get_all_draft_pages_from_space {space_key}")
|
|
729
|
+
|
|
730
|
+
def remove_page_as_draft(self, page_id: str) -> None:
|
|
731
|
+
"""Remove page as draft (Server-only)."""
|
|
732
|
+
if self._is_cloud:
|
|
733
|
+
raise ConfluenceClientError(
|
|
734
|
+
"remove_page_as_draft is only available in Confluence Server/Data Center mode",
|
|
735
|
+
context={"page_id": page_id},
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
def operation():
|
|
739
|
+
self._client.remove_page_as_draft(page_id) # type: ignore[attr-defined]
|
|
740
|
+
self._log.info("draft_page_removed", page_id=page_id)
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
return self._with_retry(operation, f"remove_page_as_draft {page_id}")
|
|
744
|
+
|
|
745
|
+
# --- Cache Management (Server-only) ---
|
|
746
|
+
|
|
747
|
+
def get_cache_statistics(self) -> dict[str, Any]:
|
|
748
|
+
"""Get cache statistics (Server-only)."""
|
|
749
|
+
if self._is_cloud:
|
|
750
|
+
raise ConfluenceClientError(
|
|
751
|
+
"get_cache_statistics is only available in Confluence Server/Data Center mode",
|
|
752
|
+
context={},
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
def operation():
|
|
756
|
+
# SDK doesn't have get_cache_statistics, use REST API directly
|
|
757
|
+
# Cache statistics endpoint: /rest/cache/1.0/stats
|
|
758
|
+
response = self.request(method="GET", path="/rest/cache/1.0/stats")
|
|
759
|
+
# SDK request method returns response object, extract JSON
|
|
760
|
+
if hasattr(response, 'json'):
|
|
761
|
+
result = response.json()
|
|
762
|
+
else:
|
|
763
|
+
result = response # Already JSON dict
|
|
764
|
+
self._log.debug("cache_statistics_retrieved")
|
|
765
|
+
return result
|
|
766
|
+
|
|
767
|
+
return self._with_retry(operation, "get_cache_statistics")
|
|
768
|
+
|
|
769
|
+
def flush_cache(self, cache_name: str | None = None) -> None:
|
|
770
|
+
"""Flush cache (Server-only).
|
|
771
|
+
|
|
772
|
+
Args:
|
|
773
|
+
cache_name: Optional cache name to flush specific cache. If None, flushes all caches.
|
|
774
|
+
"""
|
|
775
|
+
if self._is_cloud:
|
|
776
|
+
raise ConfluenceClientError(
|
|
777
|
+
"flush_cache is only available in Confluence Server/Data Center mode",
|
|
778
|
+
context={"cache_name": cache_name},
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
def operation():
|
|
782
|
+
if cache_name:
|
|
783
|
+
# Flush specific cache package
|
|
784
|
+
self._client.clean_package_cache(cache_name=cache_name) # type: ignore[attr-defined]
|
|
785
|
+
self._log.info("cache_flushed", cache_name=cache_name)
|
|
786
|
+
else:
|
|
787
|
+
# Flush all caches
|
|
788
|
+
self._client.clean_all_caches() # type: ignore[attr-defined]
|
|
789
|
+
self._log.info("all_caches_flushed")
|
|
790
|
+
|
|
791
|
+
return self._with_retry(operation, f"flush_cache {cache_name or 'all'}")
|
|
792
|
+
|
|
793
|
+
def get_cache_size(self) -> dict[str, Any]:
|
|
794
|
+
"""Get cache size information (Server-only)."""
|
|
795
|
+
if self._is_cloud:
|
|
796
|
+
raise ConfluenceClientError(
|
|
797
|
+
"get_cache_size is only available in Confluence Server/Data Center mode",
|
|
798
|
+
context={},
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
def operation():
|
|
802
|
+
# SDK doesn't have get_cache_size, use REST API directly
|
|
803
|
+
# Cache size endpoint: /rest/cache/1.0/size
|
|
804
|
+
response = self.request(method="GET", path="/rest/cache/1.0/size")
|
|
805
|
+
# SDK request method returns response object, extract JSON
|
|
806
|
+
if hasattr(response, 'json'):
|
|
807
|
+
result = response.json()
|
|
808
|
+
else:
|
|
809
|
+
result = response # Already JSON dict
|
|
810
|
+
self._log.debug("cache_size_retrieved")
|
|
811
|
+
return result
|
|
812
|
+
|
|
813
|
+
return self._with_retry(operation, "get_cache_size")
|
|
814
|
+
|
|
815
|
+
# --- Advanced Content Operations ---
|
|
816
|
+
|
|
817
|
+
def get_page_ancestors(self, page_id: str) -> list[dict[str, Any]]:
|
|
818
|
+
"""Get page ancestors (parent pages)."""
|
|
819
|
+
|
|
820
|
+
def operation():
|
|
821
|
+
result = self._client.get_page_ancestors(page_id) # type: ignore[attr-defined]
|
|
822
|
+
self._log.debug("page_ancestors_retrieved", page_id=page_id, count=len(result) if isinstance(result, list) else 0)
|
|
823
|
+
return result if isinstance(result, list) else []
|
|
824
|
+
|
|
825
|
+
return self._with_retry(operation, f"get_page_ancestors {page_id}")
|
|
826
|
+
|
|
827
|
+
def move_page(self, page_id: str, target_title: str, position: str = "append") -> dict[str, Any]:
|
|
828
|
+
"""Move page to a new location."""
|
|
829
|
+
|
|
830
|
+
def operation():
|
|
831
|
+
result = self._client.move_page(page_id, target_title, position=position) # type: ignore[attr-defined]
|
|
832
|
+
self._log.info("page_moved", page_id=page_id, target_title=target_title, position=position)
|
|
833
|
+
return result
|
|
834
|
+
|
|
835
|
+
return self._with_retry(operation, f"move_page {page_id} {target_title}")
|
|
836
|
+
|
|
837
|
+
def get_tables_from_page(self, page_id: str) -> list[dict[str, Any]]:
|
|
838
|
+
"""Extract tables from page."""
|
|
839
|
+
|
|
840
|
+
def operation():
|
|
841
|
+
result = self._client.get_tables_from_page(page_id) # type: ignore[attr-defined]
|
|
842
|
+
self._log.debug("tables_extracted", page_id=page_id, count=len(result) if isinstance(result, list) else 0)
|
|
843
|
+
return result if isinstance(result, list) else []
|
|
844
|
+
|
|
845
|
+
return self._with_retry(operation, f"get_tables_from_page {page_id}")
|
|
846
|
+
|
|
847
|
+
def scrap_regex_from_page(self, page_id: str, regex: str) -> list[dict[str, Any]]:
|
|
848
|
+
"""Extract regex matches from page."""
|
|
849
|
+
|
|
850
|
+
def operation():
|
|
851
|
+
result = self._client.scrap_regex_from_page(page_id, regex) # type: ignore[attr-defined]
|
|
852
|
+
self._log.debug("regex_matches_extracted", page_id=page_id, pattern=regex, count=len(result) if isinstance(result, list) else 0)
|
|
853
|
+
return result if isinstance(result, list) else []
|
|
854
|
+
|
|
855
|
+
return self._with_retry(operation, f"scrap_regex_from_page {page_id} {regex}")
|
|
856
|
+
|
|
857
|
+
def get_all_restrictions_for_content(self, content_id: str) -> dict[str, Any]:
|
|
858
|
+
"""Get all restrictions for content."""
|
|
859
|
+
|
|
860
|
+
def operation():
|
|
861
|
+
result = self._client.get_all_restrictions_for_content(content_id) # type: ignore[attr-defined]
|
|
862
|
+
self._log.debug("content_restrictions_retrieved", content_id=content_id)
|
|
863
|
+
return result
|
|
864
|
+
|
|
865
|
+
return self._with_retry(operation, f"get_all_restrictions_for_content {content_id}")
|
|
866
|
+
|
|
867
|
+
# --- History & Versioning ---
|
|
868
|
+
|
|
869
|
+
def history(self, page_id: str) -> dict[str, Any]:
|
|
870
|
+
"""Get page history."""
|
|
871
|
+
|
|
872
|
+
def operation():
|
|
873
|
+
result = self._client.history(page_id) # type: ignore[attr-defined]
|
|
874
|
+
self._log.debug("page_history_retrieved", page_id=page_id)
|
|
875
|
+
return result
|
|
876
|
+
|
|
877
|
+
return self._with_retry(operation, f"history {page_id}")
|
|
878
|
+
|
|
879
|
+
def get_content_history_by_version_number(self, page_id: str, version: int) -> dict[str, Any]:
|
|
880
|
+
"""Get specific version of content."""
|
|
881
|
+
|
|
882
|
+
def operation():
|
|
883
|
+
result = self._client.get_content_history_by_version_number(page_id, version) # type: ignore[attr-defined]
|
|
884
|
+
self._log.debug("content_version_retrieved", page_id=page_id, version=version)
|
|
885
|
+
return result
|
|
886
|
+
|
|
887
|
+
return self._with_retry(operation, f"get_content_history_by_version_number {page_id} {version}")
|
|
888
|
+
|
|
889
|
+
def remove_content_history(self, page_id: str, version: int) -> None:
|
|
890
|
+
"""Remove content history (experimental)."""
|
|
891
|
+
|
|
892
|
+
def operation():
|
|
893
|
+
self._client.remove_content_history(page_id, version) # type: ignore[attr-defined]
|
|
894
|
+
self._log.info("content_history_removed", page_id=page_id, version=version)
|
|
895
|
+
return None
|
|
896
|
+
|
|
897
|
+
return self._with_retry(operation, f"remove_content_history {page_id} {version}")
|
|
898
|
+
|
|
899
|
+
def archive_space(self, space_key: str) -> dict[str, Any]:
|
|
900
|
+
"""Archive a Confluence space."""
|
|
901
|
+
|
|
902
|
+
def operation():
|
|
903
|
+
result = self._client.archive_space(space_key)
|
|
904
|
+
self._log.info("space_archived", space_key=space_key)
|
|
905
|
+
return result or {"status": "archived"}
|
|
906
|
+
|
|
907
|
+
return self._with_retry(operation, f"archive_space {space_key}")
|
|
908
|
+
|
|
909
|
+
def get_trashed_contents_by_space(
|
|
910
|
+
self,
|
|
911
|
+
space_key: str,
|
|
912
|
+
*,
|
|
913
|
+
cursor: str | None = None,
|
|
914
|
+
expand: str | None = None,
|
|
915
|
+
limit: int = 100,
|
|
916
|
+
) -> dict[str, Any]:
|
|
917
|
+
"""List trashed content for a space."""
|
|
918
|
+
|
|
919
|
+
def operation():
|
|
920
|
+
result = self._client.get_trashed_contents_by_space(
|
|
921
|
+
space_key,
|
|
922
|
+
cursor=cursor,
|
|
923
|
+
expand=expand,
|
|
924
|
+
limit=limit,
|
|
925
|
+
)
|
|
926
|
+
count = len(result.get("results", [])) if isinstance(result, dict) else 0
|
|
927
|
+
self._log.debug(
|
|
928
|
+
"space_trash_listed",
|
|
929
|
+
space_key=space_key,
|
|
930
|
+
count=count,
|
|
931
|
+
cursor=cursor,
|
|
932
|
+
limit=limit,
|
|
933
|
+
)
|
|
934
|
+
return result
|
|
935
|
+
|
|
936
|
+
return self._with_retry(operation, f"get_trashed_contents_by_space {space_key}")
|
|
937
|
+
|
|
938
|
+
def remove_trashed_contents_by_space(self, space_key: str) -> dict[str, Any]:
|
|
939
|
+
"""Remove trashed content for a space."""
|
|
940
|
+
|
|
941
|
+
def operation():
|
|
942
|
+
result = self._client.remove_trashed_contents_by_space(space_key)
|
|
943
|
+
self._log.info("space_trash_cleared", space_key=space_key)
|
|
944
|
+
return result or {"status": "removed"}
|
|
945
|
+
|
|
946
|
+
return self._with_retry(operation, f"remove_trashed_contents_by_space {space_key}")
|
|
947
|
+
|
|
948
|
+
def get_child_pages(self, page_id: str) -> list[dict[str, Any]]:
|
|
949
|
+
"""Get child pages of a page."""
|
|
950
|
+
try:
|
|
951
|
+
result = self._client.get_child_pages(page_id)
|
|
952
|
+
# Convert generator to list if needed
|
|
953
|
+
if hasattr(result, '__iter__') and not isinstance(result, (list, tuple, dict)):
|
|
954
|
+
result = list(result)
|
|
955
|
+
self._log.debug("child_pages_retrieved", page_id=page_id, count=len(result) if result else 0)
|
|
956
|
+
return result or []
|
|
957
|
+
|
|
958
|
+
except Exception as exc:
|
|
959
|
+
self._handle_api_error(exc, f"get_child_pages {page_id}")
|
|
960
|
+
|
|
961
|
+
def get_page_by_title(self, space_key: str, title: str) -> dict[str, Any] | None:
|
|
962
|
+
"""Get a page by space and title."""
|
|
963
|
+
try:
|
|
964
|
+
result = self._client.get_page_by_title(space_key, title)
|
|
965
|
+
self._log.debug("page_retrieved_by_title", space_key=space_key, title=title)
|
|
966
|
+
return result
|
|
967
|
+
|
|
968
|
+
except Exception as exc:
|
|
969
|
+
self._handle_api_error(exc, f"get_page_by_title {space_key}/{title}")
|
|
970
|
+
|
|
971
|
+
def get_attachments(self, page_id: str) -> list[dict[str, Any]]:
|
|
972
|
+
"""Get attachments for a page."""
|
|
973
|
+
try:
|
|
974
|
+
result = self._client.get_attachments_from_content(page_id)
|
|
975
|
+
self._log.debug("attachments_retrieved", page_id=page_id, count=len(result) if result else 0)
|
|
976
|
+
return result or []
|
|
977
|
+
|
|
978
|
+
except Exception as exc:
|
|
979
|
+
self._handle_api_error(exc, f"get_attachments {page_id}")
|
|
980
|
+
|
|
981
|
+
def get_attachment_content(self, attachment_id: str) -> bytes:
|
|
982
|
+
"""Get attachment content by ID."""
|
|
983
|
+
try:
|
|
984
|
+
result = self._client.get_attachment_data(attachment_id)
|
|
985
|
+
self._log.debug("attachment_content_retrieved", attachment_id=attachment_id)
|
|
986
|
+
return result
|
|
987
|
+
|
|
988
|
+
except Exception as exc:
|
|
989
|
+
self._handle_api_error(exc, f"get_attachment_content {attachment_id}")
|
|
990
|
+
|
|
991
|
+
def update_attachment_data(
|
|
992
|
+
self,
|
|
993
|
+
page_id: str,
|
|
994
|
+
attachment_id: str,
|
|
995
|
+
filename: str,
|
|
996
|
+
content: bytes,
|
|
997
|
+
content_type: str | None = None,
|
|
998
|
+
*,
|
|
999
|
+
comment: str | None = None,
|
|
1000
|
+
) -> dict[str, Any]:
|
|
1001
|
+
"""Update an existing attachment.
|
|
1002
|
+
|
|
1003
|
+
According to atlassian-python-api docs, attach_content automatically updates
|
|
1004
|
+
if the file exists, versioning the new file and keeping the old one.
|
|
1005
|
+
"""
|
|
1006
|
+
def operation():
|
|
1007
|
+
# Use attach_content - it automatically updates if file exists
|
|
1008
|
+
# See: https://atlassian-python-api.readthedocs.io/confluence.html#
|
|
1009
|
+
result = self._client.attach_content(
|
|
1010
|
+
content=content,
|
|
1011
|
+
name=filename,
|
|
1012
|
+
content_type=content_type or "application/octet-stream",
|
|
1013
|
+
page_id=page_id,
|
|
1014
|
+
comment=comment,
|
|
1015
|
+
)
|
|
1016
|
+
self._log.info("attachment_updated", page_id=page_id, attachment_id=attachment_id, filename=filename)
|
|
1017
|
+
return result
|
|
1018
|
+
|
|
1019
|
+
return self._with_retry(operation, f"update_attachment {attachment_id}")
|
|
1020
|
+
|
|
1021
|
+
def delete_attachment_by_id(self, page_id: str, attachment_id: str, version: int | None = None) -> None:
|
|
1022
|
+
"""Delete an attachment by ID (historic versions only).
|
|
1023
|
+
|
|
1024
|
+
The Atlassian SDK's ``delete_attachment_by_id`` endpoint only removes versions
|
|
1025
|
+
strictly lower than the current one, so callers should provide ``version`` when
|
|
1026
|
+
targeting a specific revision. Cloud docs:
|
|
1027
|
+
https://atlassian-python-api.readthedocs.io/confluence.html#confluence.Confluence.delete_attachment_by_id
|
|
1028
|
+
"""
|
|
1029
|
+
try:
|
|
1030
|
+
versions_payload = self._client.get_attachment_history(attachment_id) or []
|
|
1031
|
+
except Exception: # pragma: no cover - defensive
|
|
1032
|
+
versions_payload = []
|
|
1033
|
+
|
|
1034
|
+
versions = []
|
|
1035
|
+
for item in versions_payload:
|
|
1036
|
+
if isinstance(item, dict):
|
|
1037
|
+
number = item.get("number")
|
|
1038
|
+
if isinstance(number, int):
|
|
1039
|
+
versions.append(number)
|
|
1040
|
+
|
|
1041
|
+
if version is not None:
|
|
1042
|
+
versions = [ver for ver in versions if ver == version]
|
|
1043
|
+
|
|
1044
|
+
if not versions:
|
|
1045
|
+
if version is not None:
|
|
1046
|
+
versions = [version]
|
|
1047
|
+
else:
|
|
1048
|
+
raise ConfluenceClientError(
|
|
1049
|
+
f"No attachment versions found for {attachment_id}. Specify --version explicitly.",
|
|
1050
|
+
context={"attachment_id": attachment_id, "page_id": page_id},
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
for ver in sorted(set(versions), reverse=True):
|
|
1054
|
+
try:
|
|
1055
|
+
self._client.delete_attachment_by_id(attachment_id, ver)
|
|
1056
|
+
self._log.info(
|
|
1057
|
+
"attachment_version_deleted",
|
|
1058
|
+
page_id=page_id,
|
|
1059
|
+
attachment_id=attachment_id,
|
|
1060
|
+
version=ver,
|
|
1061
|
+
)
|
|
1062
|
+
except Exception as exc:
|
|
1063
|
+
self._handle_api_error(exc, f"delete_attachment {attachment_id} (version {ver})")
|
|
1064
|
+
|
|
1065
|
+
def delete_attachment(self, page_id: str, filename: str, version: int | None = None) -> None:
|
|
1066
|
+
"""Delete the latest version of an attachment by filename.
|
|
1067
|
+
|
|
1068
|
+
Mirrors :py:meth:`Confluence.delete_attachment` from the Atlassian SDK.
|
|
1069
|
+
"""
|
|
1070
|
+
|
|
1071
|
+
try:
|
|
1072
|
+
self._client.delete_attachment(page_id, filename, version=version)
|
|
1073
|
+
self._log.info(
|
|
1074
|
+
"attachment_deleted",
|
|
1075
|
+
page_id=page_id,
|
|
1076
|
+
filename=filename,
|
|
1077
|
+
version=version,
|
|
1078
|
+
)
|
|
1079
|
+
except Exception as exc:
|
|
1080
|
+
self._handle_api_error(exc, f"delete_attachment {filename}")
|
|
1081
|
+
|
|
1082
|
+
def add_comment(self, page_id: str, body: str) -> dict[str, Any]:
|
|
1083
|
+
"""Add a comment to a page."""
|
|
1084
|
+
try:
|
|
1085
|
+
result = self._client.add_comment(page_id, body)
|
|
1086
|
+
self._log.info("comment_added", page_id=page_id)
|
|
1087
|
+
return result
|
|
1088
|
+
|
|
1089
|
+
except Exception as exc:
|
|
1090
|
+
self._handle_api_error(exc, f"add_comment {page_id}")
|
|
1091
|
+
|
|
1092
|
+
def get_page_history(self, page_id: str) -> dict[str, Any]:
|
|
1093
|
+
"""Get page version history."""
|
|
1094
|
+
try:
|
|
1095
|
+
result = self._client.get_history(page_id)
|
|
1096
|
+
self._log.debug("page_history_retrieved", page_id=page_id)
|
|
1097
|
+
return result
|
|
1098
|
+
|
|
1099
|
+
except Exception as exc:
|
|
1100
|
+
self._handle_api_error(exc, f"get_page_history {page_id}")
|
|
1101
|
+
|
|
1102
|
+
def close(self) -> None:
|
|
1103
|
+
"""Close the client (cleanup method for compatibility)."""
|
|
1104
|
+
# atlassian-python-api doesn't require explicit cleanup
|
|
1105
|
+
pass
|
|
1106
|
+
|
|
1107
|
+
def __enter__(self) -> ConfluenceClient:
|
|
1108
|
+
return self
|
|
1109
|
+
|
|
1110
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
1111
|
+
self.close()
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
__all__ = ["ConfluenceClient"]
|