@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.
Files changed (303) hide show
  1. package/package.json +2 -2
  2. package/src/global.js +70 -10
  3. package/tools/skills/vds-scripts-skill/.openskills.json +6 -0
  4. package/tools/skills/vds-scripts-skill/QUALITY.md +44 -0
  5. package/tools/skills/vds-scripts-skill/SKILL.md +135 -0
  6. package/tools/skills/vds-scripts-skill/references/audit-commands.md +171 -0
  7. package/tools/skills/vds-scripts-skill/references/capability-index.md +34 -0
  8. package/tools/skills/vds-scripts-skill/references/development-commands.md +12 -0
  9. package/tools/skills/vds-scripts-skill/references/google-sheets.md +73 -0
  10. package/tools/skills/vds-scripts-skill/references/integration-commands.md +17 -0
  11. package/tools/skills/vds-scripts-skill/references/platform-bootstrap.md +31 -0
  12. package/tools/skills/vds-scripts-skill/references/specialist-routing.md +14 -0
  13. package/tools/skills/vds-scripts-skill/references/validation-commands.md +15 -0
  14. package/tools/skills/vsaf-build/SKILL.md +32 -2
  15. package/tools/skills/vsaf-ship/SKILL.md +41 -10
  16. package/tools/skills/vsaf-test/SKILL.md +8 -0
  17. package/tools/vds-scripts/.mcp.json +11 -0
  18. package/tools/vds-scripts/.secrets.baseline +133 -0
  19. package/tools/vds-scripts/AGENTS.md +152 -0
  20. package/tools/vds-scripts/CLAUDE.md +101 -0
  21. package/tools/vds-scripts/CLI_COMMAND_OPTIMIZATION.md +156 -0
  22. package/tools/vds-scripts/PACKAGE_P125B_IMPLEMENTATION_SUMMARY.md +131 -0
  23. package/tools/vds-scripts/PROJECT_COMPLETION_SUMMARY.md +45 -0
  24. package/tools/vds-scripts/README.md +97 -0
  25. package/tools/vds-scripts/bitbucket_manifest_mapping.toml +34 -0
  26. package/tools/vds-scripts/bitbucket_orchestrator/ARCHITECTURE_ANALYSIS.md +258 -0
  27. package/tools/vds-scripts/bitbucket_orchestrator/BITBUCKET_API_PRACTICES.md +393 -0
  28. package/tools/vds-scripts/bitbucket_orchestrator/EVALUATION_REPORT.md +61 -0
  29. package/tools/vds-scripts/bitbucket_orchestrator/FEATURES.md +908 -0
  30. package/tools/vds-scripts/bitbucket_orchestrator/README.md +687 -0
  31. package/tools/vds-scripts/bitbucket_orchestrator/pyproject.toml +40 -0
  32. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/__init__.py +20 -0
  33. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/async_client.py +657 -0
  34. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/cli.py +2108 -0
  35. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/client.py +2534 -0
  36. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/config.py +171 -0
  37. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/errors.py +67 -0
  38. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/factory.py +185 -0
  39. package/tools/vds-scripts/bitbucket_orchestrator/src/vds_bitbucket_orchestrator/protocols.py +244 -0
  40. package/tools/vds-scripts/bitbucket_orchestrator/tests/__init__.py +8 -0
  41. package/tools/vds-scripts/bitbucket_orchestrator/tests/conftest.py +65 -0
  42. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_advanced_search.py +151 -0
  43. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_async_client.py +546 -0
  44. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_branch_permissions.py +145 -0
  45. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_cli.py +115 -0
  46. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client.py +157 -0
  47. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_branch_conditions.py +79 -0
  48. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_code_advanced.py +163 -0
  49. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_code_file.py +32 -0
  50. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_deployment_environments.py +194 -0
  51. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_issues.py +164 -0
  52. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_pipelines_advanced.py +179 -0
  53. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_pr_blockers.py +119 -0
  54. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_client_repository_variables.py +156 -0
  55. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code.py +98 -0
  56. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code_advanced.py +282 -0
  57. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_code_insights.py +335 -0
  58. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_conditions.py +147 -0
  59. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_config.py +131 -0
  60. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_deployment_env.py +352 -0
  61. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_factory.py +371 -0
  62. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_fork_operations.py +204 -0
  63. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_issue_cli.py +261 -0
  64. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_pipeline_advanced.py +270 -0
  65. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_pr_blocker.py +204 -0
  66. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_protocols.py +334 -0
  67. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_repo_settings.py +343 -0
  68. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_repo_variables.py +270 -0
  69. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_webhooks.py +189 -0
  70. package/tools/vds-scripts/bitbucket_orchestrator/tests/test_workspace.py +233 -0
  71. package/tools/vds-scripts/bitbucket_orchestrator/uv.lock +742 -0
  72. package/tools/vds-scripts/confluence_orchestrator/Dockerfile +19 -0
  73. package/tools/vds-scripts/confluence_orchestrator/README.md +412 -0
  74. package/tools/vds-scripts/confluence_orchestrator/SYNC_SCRIPTS.md +127 -0
  75. package/tools/vds-scripts/confluence_orchestrator/SYNC_STANDARDIZATION.md +108 -0
  76. package/tools/vds-scripts/confluence_orchestrator/pyproject.toml +48 -0
  77. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/__init__.py +20 -0
  78. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/cli.py +2532 -0
  79. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/config.py +175 -0
  80. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/content.py +290 -0
  81. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/content_v2.py +94 -0
  82. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/crawl_tree.py +1835 -0
  83. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/errors.py +80 -0
  84. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/eventing.py +109 -0
  85. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/http.py +1114 -0
  86. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/orchestration.py +165 -0
  87. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/reporting.py +78 -0
  88. package/tools/vds-scripts/confluence_orchestrator/src/confluence_orchestrator/tree.py +121 -0
  89. package/tools/vds-scripts/confluence_orchestrator/sync_pdfs_from_markdown.py +213 -0
  90. package/tools/vds-scripts/confluence_orchestrator/sync_pdfs_to_confluence.py +305 -0
  91. package/tools/vds-scripts/confluence_orchestrator/sync_png_attachments.py +305 -0
  92. package/tools/vds-scripts/confluence_orchestrator/tests/__init__.py +0 -0
  93. package/tools/vds-scripts/confluence_orchestrator/tests/conftest.py +8 -0
  94. package/tools/vds-scripts/confluence_orchestrator/tests/test_advanced_content.py +224 -0
  95. package/tools/vds-scripts/confluence_orchestrator/tests/test_advanced_search.py +188 -0
  96. package/tools/vds-scripts/confluence_orchestrator/tests/test_cache_management.py +247 -0
  97. package/tools/vds-scripts/confluence_orchestrator/tests/test_cli.py +499 -0
  98. package/tools/vds-scripts/confluence_orchestrator/tests/test_config.py +83 -0
  99. package/tools/vds-scripts/confluence_orchestrator/tests/test_content.py +186 -0
  100. package/tools/vds-scripts/confluence_orchestrator/tests/test_content_flags.py +27 -0
  101. package/tools/vds-scripts/confluence_orchestrator/tests/test_crawl_tree.py +2250 -0
  102. package/tools/vds-scripts/confluence_orchestrator/tests/test_draft_management.py +223 -0
  103. package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing.py +71 -0
  104. package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_chaos.py +37 -0
  105. package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_rate_limit.py +44 -0
  106. package/tools/vds-scripts/confluence_orchestrator/tests/test_eventing_timeout.py +49 -0
  107. package/tools/vds-scripts/confluence_orchestrator/tests/test_export.py +230 -0
  108. package/tools/vds-scripts/confluence_orchestrator/tests/test_history.py +204 -0
  109. package/tools/vds-scripts/confluence_orchestrator/tests/test_http.py +117 -0
  110. package/tools/vds-scripts/confluence_orchestrator/tests/test_orchestration.py +91 -0
  111. package/tools/vds-scripts/confluence_orchestrator/tests/test_reporting.py +24 -0
  112. package/tools/vds-scripts/confluence_orchestrator/tests/test_search_cql.py +34 -0
  113. package/tools/vds-scripts/confluence_orchestrator/tests/test_space_management.py +237 -0
  114. package/tools/vds-scripts/confluence_orchestrator/tests/test_space_permissions.py +332 -0
  115. package/tools/vds-scripts/confluence_orchestrator/tests/test_user_group_management.py +388 -0
  116. package/tools/vds-scripts/confluence_orchestrator/uv.lock +1023 -0
  117. package/tools/vds-scripts/git_orchestrator/ENHANCEMENT_SUMMARY.md +119 -0
  118. package/tools/vds-scripts/git_orchestrator/README.md +280 -0
  119. package/tools/vds-scripts/git_orchestrator/VERIFICATION_REPORT.md +152 -0
  120. package/tools/vds-scripts/git_orchestrator/pyproject.toml +35 -0
  121. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/__init__.py +7 -0
  122. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/__main__.py +4 -0
  123. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/cli.py +847 -0
  124. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/logging_config.py +63 -0
  125. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/manifest.py +129 -0
  126. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/orchestrator.py +819 -0
  127. package/tools/vds-scripts/git_orchestrator/src/vds_git_orchestrator/reporting.py +53 -0
  128. package/tools/vds-scripts/git_orchestrator/tests/__init__.py +0 -0
  129. package/tools/vds-scripts/git_orchestrator/tests/test_cli_settings.py +21 -0
  130. package/tools/vds-scripts/git_orchestrator/tests/test_integration.py +74 -0
  131. package/tools/vds-scripts/git_orchestrator/tests/test_manifest.py +79 -0
  132. package/tools/vds-scripts/git_orchestrator/tests/test_orchestrator.py +204 -0
  133. package/tools/vds-scripts/git_orchestrator/tests/test_public_api.py +236 -0
  134. package/tools/vds-scripts/git_orchestrator/tests/test_resilience.py +345 -0
  135. package/tools/vds-scripts/git_orchestrator/uv.lock +271 -0
  136. package/tools/vds-scripts/jira_orchestrator/README.md +770 -0
  137. package/tools/vds-scripts/jira_orchestrator/pyproject.toml +39 -0
  138. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/__init__.py +1 -0
  139. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/adapter.py +1320 -0
  140. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/cli.py +2271 -0
  141. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/config.py +138 -0
  142. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/errors.py +67 -0
  143. package/tools/vds-scripts/jira_orchestrator/src/vds_jira_orchestrator/reporting.py +65 -0
  144. package/tools/vds-scripts/jira_orchestrator/tests/__init__.py +1 -0
  145. package/tools/vds-scripts/jira_orchestrator/tests/conftest.py +86 -0
  146. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_agile_list_payloads.py +54 -0
  147. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_bulk_operations.py +69 -0
  148. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_components.py +57 -0
  149. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_createmeta.py +45 -0
  150. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_dashboard.py +117 -0
  151. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_issue_properties.py +54 -0
  152. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_permissions_compat.py +42 -0
  153. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_reindex.py +42 -0
  154. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_remote_links.py +76 -0
  155. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_transitions.py +91 -0
  156. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_user_management.py +110 -0
  157. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_version_management.py +133 -0
  158. package/tools/vds-scripts/jira_orchestrator/tests/test_adapter_watchers.py +41 -0
  159. package/tools/vds-scripts/jira_orchestrator/tests/test_advanced_search.py +164 -0
  160. package/tools/vds-scripts/jira_orchestrator/tests/test_agile.py +256 -0
  161. package/tools/vds-scripts/jira_orchestrator/tests/test_application_properties.py +193 -0
  162. package/tools/vds-scripts/jira_orchestrator/tests/test_backlog.py +91 -0
  163. package/tools/vds-scripts/jira_orchestrator/tests/test_bulk_operations.py +277 -0
  164. package/tools/vds-scripts/jira_orchestrator/tests/test_cli.py +106 -0
  165. package/tools/vds-scripts/jira_orchestrator/tests/test_components.py +106 -0
  166. package/tools/vds-scripts/jira_orchestrator/tests/test_config.py +164 -0
  167. package/tools/vds-scripts/jira_orchestrator/tests/test_dashboard.py +122 -0
  168. package/tools/vds-scripts/jira_orchestrator/tests/test_discover_fields.py +207 -0
  169. package/tools/vds-scripts/jira_orchestrator/tests/test_filter_management.py +333 -0
  170. package/tools/vds-scripts/jira_orchestrator/tests/test_issue_archiving.py +164 -0
  171. package/tools/vds-scripts/jira_orchestrator/tests/test_issue_links.py +257 -0
  172. package/tools/vds-scripts/jira_orchestrator/tests/test_issue_properties.py +171 -0
  173. package/tools/vds-scripts/jira_orchestrator/tests/test_link_types.py +314 -0
  174. package/tools/vds-scripts/jira_orchestrator/tests/test_parse_set.py +37 -0
  175. package/tools/vds-scripts/jira_orchestrator/tests/test_permissions.py +273 -0
  176. package/tools/vds-scripts/jira_orchestrator/tests/test_reindex.py +81 -0
  177. package/tools/vds-scripts/jira_orchestrator/tests/test_remote_links.py +254 -0
  178. package/tools/vds-scripts/jira_orchestrator/tests/test_security_schemes.py +170 -0
  179. package/tools/vds-scripts/jira_orchestrator/tests/test_transitions_changelog.py +114 -0
  180. package/tools/vds-scripts/jira_orchestrator/tests/test_user_management.py +226 -0
  181. package/tools/vds-scripts/jira_orchestrator/tests/test_version_management.py +339 -0
  182. package/tools/vds-scripts/jira_orchestrator/tests/test_watchers.py +101 -0
  183. package/tools/vds-scripts/jira_orchestrator/tests/test_worklog.py +223 -0
  184. package/tools/vds-scripts/jira_orchestrator/uv.lock +738 -0
  185. package/tools/vds-scripts/mcp_server/Dockerfile +34 -0
  186. package/tools/vds-scripts/mcp_server/README.md +140 -0
  187. package/tools/vds-scripts/mcp_server/pyproject.toml +42 -0
  188. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/__init__.py +4 -0
  189. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/config.py +36 -0
  190. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/server.py +66 -0
  191. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/__init__.py +14 -0
  192. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/bitbucket_tools.py +47 -0
  193. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/confluence_tools.py +59 -0
  194. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/git_tools.py +71 -0
  195. package/tools/vds-scripts/mcp_server/src/vds_mcp_server/tools/jira_tools.py +63 -0
  196. package/tools/vds-scripts/mcp_server/tests/__init__.py +2 -0
  197. package/tools/vds-scripts/mcp_server/tests/conftest.py +29 -0
  198. package/tools/vds-scripts/mcp_server/tests/unit/__init__.py +2 -0
  199. package/tools/vds-scripts/mcp_server/tests/unit/test_bitbucket_tools.py +25 -0
  200. package/tools/vds-scripts/mcp_server/tests/unit/test_confluence_tools.py +25 -0
  201. package/tools/vds-scripts/mcp_server/tests/unit/test_git_tools.py +32 -0
  202. package/tools/vds-scripts/mcp_server/tests/unit/test_jira_tools.py +32 -0
  203. package/tools/vds-scripts/mcp_server/tests/verification/__init__.py +2 -0
  204. package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_confluence_tools.py +40 -0
  205. package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_jira_tools.py +37 -0
  206. package/tools/vds-scripts/mcp_server/tests/verification/test_mcp_tool_registration.py +47 -0
  207. package/tools/vds-scripts/mcp_server/uv.lock +1032 -0
  208. package/tools/vds-scripts/mypy.ini +5 -0
  209. package/tools/vds-scripts/pyproject.toml +29 -0
  210. package/tools/vds-scripts/repo-manifest.yaml +273 -0
  211. package/tools/vds-scripts/repo-manifest.yaml.example +25 -0
  212. package/tools/vds-scripts/scripts/BRD-Validation-API.postman_collection.json +706 -0
  213. package/tools/vds-scripts/scripts/BRD-Validation-README.md +308 -0
  214. package/tools/vds-scripts/scripts/README.md +162 -0
  215. package/tools/vds-scripts/scripts/bootstrap_uv.sh +30 -0
  216. package/tools/vds-scripts/scripts/brd-validation-environment.json +51 -0
  217. package/tools/vds-scripts/scripts/brd-validation-test-results.json +13023 -0
  218. package/tools/vds-scripts/scripts/brd_coverage_report.json +276 -0
  219. package/tools/vds-scripts/scripts/create_memory_session.py +35 -0
  220. package/tools/vds-scripts/scripts/deployment/load_docker_images_offline.sh +90 -0
  221. package/tools/vds-scripts/scripts/final_completion_report.md +139 -0
  222. package/tools/vds-scripts/scripts/folder_structure_report.json +321 -0
  223. package/tools/vds-scripts/scripts/generate_completion_report.py +125 -0
  224. package/tools/vds-scripts/scripts/generate_intellij_modules.py +150 -0
  225. package/tools/vds-scripts/scripts/link_integrity_report.json +807 -0
  226. package/tools/vds-scripts/scripts/move_audit_artifact_pages.py +255 -0
  227. package/tools/vds-scripts/scripts/move_audit_artifact_pages_rest.py +165 -0
  228. package/tools/vds-scripts/scripts/move_wrong_dept_pages.py +216 -0
  229. package/tools/vds-scripts/scripts/save_intellij_memories.py +120 -0
  230. package/tools/vds-scripts/scripts/save_memories_to_vds_ai.py +83 -0
  231. package/tools/vds-scripts/scripts/save_memories_vds_style.py +129 -0
  232. package/tools/vds-scripts/scripts/search_intellij_memories.py +50 -0
  233. package/tools/vds-scripts/scripts/setup_intellij_workspace.py +65 -0
  234. package/tools/vds-scripts/scripts/target-state-automation/README.md +89 -0
  235. package/tools/vds-scripts/scripts/target-state-automation/confluence_sync_coordinator.sh +27 -0
  236. package/tools/vds-scripts/scripts/target-state-automation/coordination.sh +114 -0
  237. package/tools/vds-scripts/scripts/target-state-automation/diagram_coordinator.sh +25 -0
  238. package/tools/vds-scripts/scripts/target-state-automation/docs_root.sh +22 -0
  239. package/tools/vds-scripts/scripts/target-state-automation/generate_diagrams.sh +22 -0
  240. package/tools/vds-scripts/scripts/target-state-automation/markdown_coordinator.sh +25 -0
  241. package/tools/vds-scripts/scripts/target-state-automation/progress_dashboard.sh +17 -0
  242. package/tools/vds-scripts/scripts/target-state-automation/schema_coordinator.sh +25 -0
  243. package/tools/vds-scripts/scripts/target-state-automation/sync_confluence.sh +30 -0
  244. package/tools/vds-scripts/scripts/target-state-automation/update_dependencies.sh +19 -0
  245. package/tools/vds-scripts/scripts/target-state-automation/validate_links.sh +86 -0
  246. package/tools/vds-scripts/scripts/target-state-automation/validate_markdown.sh +52 -0
  247. package/tools/vds-scripts/scripts/target-state-automation/validate_schemas.sh +26 -0
  248. package/tools/vds-scripts/scripts/target-state-automation/validate_structure.sh +98 -0
  249. package/tools/vds-scripts/scripts/update_modules_xml.py +190 -0
  250. package/tools/vds-scripts/scripts/uv-workspace-alignment-verification-2026-03-25.md +128 -0
  251. package/tools/vds-scripts/scripts/validate_brd_coverage.py +179 -0
  252. package/tools/vds-scripts/scripts/validate_folder_structure.py +240 -0
  253. package/tools/vds-scripts/scripts/validate_link_integrity.py +272 -0
  254. package/tools/vds-scripts/scripts/vds_sh_helpers.sh +180 -0
  255. package/tools/vds-scripts/scripts/verification/phase2_portable_paths_ubuntu_docker.sh +26 -0
  256. package/tools/vds-scripts/scripts/worktree_uv.sh +48 -0
  257. package/tools/vds-scripts/uv.lock +8 -0
  258. package/tools/vds-scripts/vds_cli/README.md +126 -0
  259. package/tools/vds-scripts/vds_cli/VERIFICATION_REPORT.md +41 -0
  260. package/tools/vds-scripts/vds_cli/pyproject.toml +38 -0
  261. package/tools/vds-scripts/vds_cli/src/vds_cli/__init__.py +3 -0
  262. package/tools/vds-scripts/vds_cli/src/vds_cli/cli.py +173 -0
  263. package/tools/vds-scripts/vds_cli/src/vds_cli/docs_sync.py +1203 -0
  264. package/tools/vds-scripts/vds_cli/src/vds_cli/env.py +41 -0
  265. package/tools/vds-scripts/vds_cli/src/vds_cli/google_sheets_orchestrator/__init__.py +3 -0
  266. package/tools/vds-scripts/vds_cli/src/vds_cli/google_sheets_orchestrator/google_sheets_orchestrator.py +198 -0
  267. package/tools/vds-scripts/vds_cli/src/vds_cli/router.py +93 -0
  268. package/tools/vds-scripts/vds_cli/src/vds_cli/sync_api.py +647 -0
  269. package/tools/vds-scripts/vds_cli/src/vds_cli/sync_service.py +266 -0
  270. package/tools/vds-scripts/vds_cli/tests/__init__.py +2 -0
  271. package/tools/vds-scripts/vds_cli/tests/conftest.py +49 -0
  272. package/tools/vds-scripts/vds_cli/tests/unit/__init__.py +2 -0
  273. package/tools/vds-scripts/vds_cli/tests/unit/test_cli.py +143 -0
  274. package/tools/vds-scripts/vds_cli/tests/unit/test_docs_sync.py +422 -0
  275. package/tools/vds-scripts/vds_cli/tests/unit/test_env.py +51 -0
  276. package/tools/vds-scripts/vds_cli/tests/unit/test_router.py +72 -0
  277. package/tools/vds-scripts/vds_cli/tests/unit/test_sync_api.py +357 -0
  278. package/tools/vds-scripts/vds_cli/tests/unit/test_sync_service.py +160 -0
  279. package/tools/vds-scripts/vds_cli/tests/verification/__init__.py +2 -0
  280. package/tools/vds-scripts/vds_cli/tests/verification/test_bitbucket_real.py +33 -0
  281. package/tools/vds-scripts/vds_cli/tests/verification/test_confluence_real.py +35 -0
  282. package/tools/vds-scripts/vds_cli/tests/verification/test_jira_real.py +41 -0
  283. package/tools/vds-scripts/vds_cli/uv.lock +524 -0
  284. package/tools/vds-scripts/vds_cli_common/README.md +190 -0
  285. package/tools/vds-scripts/vds_cli_common/pyproject.toml +92 -0
  286. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/__init__.py +34 -0
  287. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/completers.py +139 -0
  288. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/context.py +201 -0
  289. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/env.py +119 -0
  290. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/errors.py +318 -0
  291. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/output.py +284 -0
  292. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/paths.py +78 -0
  293. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/testing.py +213 -0
  294. package/tools/vds-scripts/vds_cli_common/src/vds_cli_common/version.py +85 -0
  295. package/tools/vds-scripts/vds_cli_common/tests/__init__.py +1 -0
  296. package/tools/vds-scripts/vds_cli_common/tests/test_completers.py +148 -0
  297. package/tools/vds-scripts/vds_cli_common/tests/test_context.py +192 -0
  298. package/tools/vds-scripts/vds_cli_common/tests/test_env.py +102 -0
  299. package/tools/vds-scripts/vds_cli_common/tests/test_errors.py +186 -0
  300. package/tools/vds-scripts/vds_cli_common/tests/test_output.py +229 -0
  301. package/tools/vds-scripts/vds_cli_common/tests/test_paths.py +61 -0
  302. package/tools/vds-scripts/vds_cli_common/tests/test_testing.py +138 -0
  303. package/tools/vds-scripts/vds_cli_common/tests/test_version.py +64 -0
@@ -0,0 +1,2271 @@
1
+ """Typer CLI for Jira using the SDK-only adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import structlog
14
+ import typer
15
+
16
+ from .adapter import JiraAdapter
17
+ from .config import JiraSettings
18
+ from .errors import JiraAdapterError, JiraAuthError
19
+ from .reporting import RunSummary, write_reports
20
+
21
+ app = typer.Typer(add_completion=False, help="Jira operations orchestrator (SDK-only)")
22
+ logger = logging.getLogger("jira_cli")
23
+
24
+ APP_NAME = "vds-jira-orchestrator"
25
+ APP_VERSION = "2025.11.0"
26
+
27
+ _REPORT_DIR = (Path(__file__).resolve().parents[3] / "reports" / "jira_runs").resolve()
28
+
29
+
30
+ def _emit_json(payload: Any) -> None:
31
+ text = json.dumps(payload, indent=2, sort_keys=True)
32
+ if sys.stdout.isatty():
33
+ typer.echo(text)
34
+ else:
35
+ sys.stdout.write(text + "\n")
36
+
37
+
38
+ def _parse_set(items: Sequence[str]) -> dict[str, Any]:
39
+ out: dict[str, Any] = {}
40
+ for raw in items:
41
+ if "=" not in raw:
42
+ raise typer.BadParameter(f"Invalid --set item '{raw}', expected key=value")
43
+ k, v = raw.split("=", 1)
44
+ k = k.strip()
45
+ v = v.strip()
46
+ if not k:
47
+ raise typer.BadParameter(f"Invalid key in --set '{raw}'")
48
+ out[k] = v
49
+ return out
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Remote link helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def _build_remote_link_payload(
57
+ *,
58
+ url: str | None,
59
+ title: str | None,
60
+ summary: str | None,
61
+ relationship: str | None,
62
+ global_id: str | None,
63
+ application_type: str | None,
64
+ application_name: str | None,
65
+ icon_url: str | None,
66
+ icon_title: str | None,
67
+ data_file: Path | None,
68
+ ) -> dict[str, Any]:
69
+ if data_file:
70
+ if not data_file.exists():
71
+ raise typer.BadParameter(f"Data file not found: {data_file}")
72
+ try:
73
+ with data_file.open() as f:
74
+ parsed = json.load(f)
75
+ except json.JSONDecodeError as exc:
76
+ raise typer.BadParameter(f"Invalid JSON in {data_file}: {exc}") from exc
77
+ if not isinstance(parsed, dict):
78
+ raise typer.BadParameter("Remote link data file must contain a JSON object")
79
+ return parsed
80
+
81
+ if not url:
82
+ raise typer.BadParameter("--url required when --data-file is not provided")
83
+
84
+ payload: dict[str, Any] = {"object": {"url": url}}
85
+
86
+ if title:
87
+ payload["object"]["title"] = title
88
+ if summary:
89
+ payload["object"]["summary"] = summary
90
+ if icon_url:
91
+ icon: dict[str, Any] = {"url16x16": icon_url}
92
+ if icon_title:
93
+ icon["title"] = icon_title
94
+ payload["object"]["icon"] = icon
95
+ if relationship:
96
+ payload["relationship"] = relationship
97
+ if global_id:
98
+ payload["globalId"] = global_id
99
+ if application_type or application_name:
100
+ if not (application_type and application_name):
101
+ raise typer.BadParameter("--application-type and --application-name must be provided together")
102
+ payload["application"] = {"type": application_type, "name": application_name}
103
+
104
+ return payload
105
+
106
+
107
+ # Global state for report directory and markdown flag
108
+ _report_dir_global: Path = _REPORT_DIR
109
+ _markdown_global: bool = True
110
+ _reports_global: bool = True
111
+
112
+
113
+ @app.callback(invoke_without_command=True)
114
+ def main(
115
+ report_dir: Path | None = typer.Option(None, help=f"Directory for run reports (default: {_REPORT_DIR})"),
116
+ markdown: bool = typer.Option(True, help="Emit Markdown summaries alongside JSON reports"),
117
+ reports: bool = typer.Option(True, "--reports/--no-reports", help="Persist run reports to disk"),
118
+ json_only: bool = typer.Option(
119
+ False,
120
+ "--json-only",
121
+ help="Emit JSON to stdout only (disables reports and markdown)",
122
+ ),
123
+ structured_logs: bool = typer.Option(
124
+ False, "--structured-logs/--no-structured-logs", help="Emit structured JSON logs to stderr"
125
+ ),
126
+ version: bool = typer.Option(
127
+ False,
128
+ "--version",
129
+ help="Show vds-jira-orchestrator version and exit",
130
+ is_eager=True,
131
+ ),
132
+ ) -> None:
133
+ if version:
134
+ typer.echo(f"{APP_NAME} {APP_VERSION}")
135
+ raise typer.Exit() from None
136
+ global _report_dir_global, _markdown_global, _reports_global # noqa: PLW0603
137
+ if json_only:
138
+ markdown = False
139
+ reports = False
140
+ _report_dir_global = report_dir.resolve() if report_dir else _REPORT_DIR
141
+ _markdown_global = markdown
142
+ _reports_global = reports
143
+ if structured_logs:
144
+ structlog.configure(
145
+ processors=[
146
+ structlog.processors.TimeStamper(fmt="iso"),
147
+ structlog.stdlib.add_log_level,
148
+ structlog.processors.StackInfoRenderer(),
149
+ structlog.processors.format_exc_info,
150
+ structlog.processors.JSONRenderer(),
151
+ ],
152
+ logger_factory=structlog.stdlib.LoggerFactory(),
153
+ wrapper_class=structlog.stdlib.BoundLogger,
154
+ cache_logger_on_first_use=True,
155
+ )
156
+ global logger # noqa: PLW0603
157
+ logger = structlog.get_logger("jira_cli") # type: ignore[assignment]
158
+ else:
159
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(levelname)s %(name)s: %(message)s")
160
+
161
+
162
+ def _record_summary(
163
+ *, command: str, args: list[str], exit_code: int, started_at: datetime, finished_at: datetime
164
+ ) -> None:
165
+ global _report_dir_global, _markdown_global # noqa: PLW0603
166
+ summary = RunSummary(
167
+ command=command,
168
+ args=args,
169
+ exit_code=exit_code,
170
+ duration_ms=int((finished_at - started_at).total_seconds() * 1000),
171
+ started_at=started_at,
172
+ finished_at=finished_at,
173
+ )
174
+ if _reports_global:
175
+ write_reports(summary, base_dir=_report_dir_global, include_markdown=_markdown_global)
176
+
177
+
178
+ @app.command("projects")
179
+ def cmd_projects(
180
+ search: str | None = typer.Option(
181
+ None,
182
+ "--search",
183
+ "-s",
184
+ help="Case-insensitive substring filter on project name or key",
185
+ ),
186
+ limit: int | None = typer.Option(
187
+ None,
188
+ "--limit",
189
+ "-l",
190
+ help="Maximum number of projects to return (applied after filtering)",
191
+ ),
192
+ ) -> None:
193
+ started_at = datetime.now(UTC)
194
+ code = 0
195
+ args_summary = []
196
+ if search:
197
+ args_summary.append(f"search={search}")
198
+ if limit is not None:
199
+ args_summary.append(f"limit={limit}")
200
+ try:
201
+ s = JiraSettings()
202
+ s.require_basic_or_token()
203
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
204
+ payload = adapter.list_projects()
205
+
206
+ if search:
207
+ needle = search.lower()
208
+ payload = [
209
+ p
210
+ for p in payload
211
+ if needle in p.get("name", "").lower() or needle in p.get("key", "").lower()
212
+ ]
213
+ if limit is not None:
214
+ payload = payload[:limit]
215
+
216
+ _emit_json({"count": len(payload), "projects": payload})
217
+ except (JiraAuthError, JiraAdapterError) as exc:
218
+ code = 1
219
+ logger.error("projects_failed %s", exc)
220
+ typer.echo(f"Error: {exc}", err=True)
221
+ finally:
222
+ finished_at = datetime.now(UTC)
223
+ _record_summary(
224
+ command="projects",
225
+ args=args_summary,
226
+ exit_code=code,
227
+ started_at=started_at,
228
+ finished_at=finished_at,
229
+ )
230
+ if code != 0:
231
+ raise typer.Exit(code=code) from None
232
+
233
+
234
+ @app.command("component")
235
+ def cmd_component(
236
+ action: str = typer.Argument(..., help="Action: list, get, create, update, delete"),
237
+ project_key: str | None = typer.Option(None, "--project-key", "-P", help="Project key (for list)"),
238
+ component_id: str | None = typer.Option(None, "--component-id", "-c", help="Component ID (for get/update/delete)"),
239
+ name: str | None = typer.Option(None, "--name", help="Component name (for create/update)"),
240
+ description: str | None = typer.Option(None, "--description", help="Component description"),
241
+ lead_account_id: str | None = typer.Option(None, "--lead-account-id", help="Account ID of component lead"),
242
+ assignee_type: str | None = typer.Option(None, "--assignee-type", help="Assignee type (e.g., PROJECT_LEAD, COMPONENT_LEAD)"),
243
+ is_assignee_type_valid: bool | None = typer.Option(None, "--assignee-valid/--no-assignee-valid", help="Assignee validity flag"),
244
+ data_file: Path | None = typer.Option(None, "--data-file", help="JSON file for component payload"),
245
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations (create/update/delete)"),
246
+ ) -> None:
247
+ """Manage project components."""
248
+ started_at = datetime.now(UTC)
249
+ code = 0
250
+ summary_args = [
251
+ action,
252
+ f"project_key={project_key}",
253
+ f"component_id={component_id}",
254
+ f"data_file={data_file}",
255
+ ]
256
+
257
+ def _build_payload() -> dict[str, Any]:
258
+ if data_file:
259
+ if not data_file.exists():
260
+ raise typer.BadParameter(f"Data file not found: {data_file}")
261
+ try:
262
+ parsed = json.loads(data_file.read_text())
263
+ except json.JSONDecodeError as exc:
264
+ raise typer.BadParameter(f"Invalid JSON in {data_file}: {exc}") from exc
265
+ if not isinstance(parsed, dict):
266
+ raise typer.BadParameter("--data-file must contain a JSON object")
267
+ return parsed
268
+ payload: dict[str, Any] = {}
269
+ if name:
270
+ payload["name"] = name
271
+ if description is not None:
272
+ payload["description"] = description
273
+ if lead_account_id:
274
+ payload["leadAccountId"] = lead_account_id
275
+ if assignee_type:
276
+ payload["assigneeType"] = assignee_type
277
+ if is_assignee_type_valid is not None:
278
+ payload["isAssigneeTypeValid"] = is_assignee_type_valid
279
+ if not payload:
280
+ raise typer.BadParameter("Provide --data-file or at least one field option for create/update")
281
+ return payload
282
+
283
+ try:
284
+ s = JiraSettings()
285
+ s.require_basic_or_token()
286
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
287
+
288
+ if action == "list":
289
+ if not project_key:
290
+ raise typer.BadParameter("--project-key required for list")
291
+ # Use search via JQL to list components indirectly is not ideal; instead, document-only.
292
+ # For parity with SDK surface, listing typically goes via project endpoint; defer to client usage by users.
293
+ # Here we emit a message to guide usage.
294
+ _emit_json({"message": "Listing components via project endpoint is not exposed here; use SDK or project API."})
295
+
296
+ elif action == "get":
297
+ if not component_id:
298
+ raise typer.BadParameter("--component-id required for get")
299
+ payload = adapter.get_component(component_id)
300
+ _emit_json(payload)
301
+
302
+ elif action == "create":
303
+ if not yes:
304
+ raise typer.BadParameter("--yes required for create")
305
+ payload = _build_payload()
306
+ if not project_key and "project" not in payload and "projectId" not in payload and "projectKey" not in payload:
307
+ raise typer.BadParameter("--project-key required (or include project/projectId in --data-file) for create")
308
+ if project_key and "project" not in payload and "projectId" not in payload and "projectKey" not in payload:
309
+ payload["projectKey"] = project_key
310
+ result = adapter.create_component(payload)
311
+ _emit_json(result)
312
+
313
+ elif action == "update":
314
+ if not yes:
315
+ raise typer.BadParameter("--yes required for update")
316
+ if not component_id:
317
+ raise typer.BadParameter("--component-id required for update")
318
+ payload = _build_payload()
319
+ result = adapter.update_component(component_id, payload)
320
+ _emit_json(result)
321
+
322
+ elif action == "delete":
323
+ if not yes:
324
+ raise typer.BadParameter("--yes required for delete")
325
+ if not component_id:
326
+ raise typer.BadParameter("--component-id required for delete")
327
+ adapter.delete_component(component_id)
328
+ _emit_json({"status": "deleted", "component_id": component_id})
329
+
330
+ else:
331
+ raise typer.BadParameter(f"Unknown action: {action}")
332
+
333
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
334
+ code = 1
335
+ logger.error("component_command_failed %s", exc)
336
+ typer.echo(f"Error: {exc}", err=True)
337
+ finally:
338
+ finished_at = datetime.now(UTC)
339
+ _record_summary(
340
+ command="component",
341
+ args=summary_args,
342
+ exit_code=code,
343
+ started_at=started_at,
344
+ finished_at=finished_at,
345
+ )
346
+ if code != 0:
347
+ raise typer.Exit(code=code) from None
348
+
349
+
350
+ @app.command("version")
351
+ def cmd_version(
352
+ action: str = typer.Argument(..., help="Action: list, create, update"),
353
+ project_key: str | None = typer.Option(None, "--project-key", "-P", help="Project key (for list/create)"),
354
+ project_id: str | None = typer.Option(None, "--project-id", help="Project ID (for create)"),
355
+ version_id: str | None = typer.Option(None, "--version-id", help="Version ID (for update)"),
356
+ name: str | None = typer.Option(None, "--name", help="Version name (for create/update)"),
357
+ description: str | None = typer.Option(None, "--description", help="Version description (for update)"),
358
+ start: int | None = typer.Option(None, "--start", help="Start index for pagination (for list)"),
359
+ limit: int | None = typer.Option(None, "--limit", help="Limit for pagination (for list)"),
360
+ order_by: str | None = typer.Option(None, "--order-by", help="Order by field: sequence, name, startDate, releaseDate (for list)"),
361
+ query: str | None = typer.Option(None, "--query", help="Query filter (for list)"),
362
+ status: str | None = typer.Option(None, "--status", help="Status filter (for list)"),
363
+ archived: bool | None = typer.Option(None, "--archived/--no-archived", help="Archived flag (for create/update)"),
364
+ released: bool | None = typer.Option(None, "--released/--no-released", help="Released flag (for create/update)"),
365
+ start_date: str | None = typer.Option(None, "--start-date", help="Start date (YYYY-MM-DD) (for update)"),
366
+ release_date: str | None = typer.Option(None, "--release-date", help="Release date (YYYY-MM-DD) (for update)"),
367
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations (create/update)"),
368
+ ) -> None:
369
+ """Manage project versions."""
370
+ started_at = datetime.now(UTC)
371
+ code = 0
372
+ summary_args = [
373
+ action,
374
+ f"project_key={project_key}",
375
+ f"project_id={project_id}",
376
+ f"version_id={version_id}",
377
+ f"name={name}",
378
+ ]
379
+
380
+ try:
381
+ s = JiraSettings()
382
+ s.require_basic_or_token()
383
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
384
+
385
+ if action == "list":
386
+ if not project_key:
387
+ raise typer.BadParameter("--project-key required for list")
388
+ payload = adapter.get_project_versions_paginated(
389
+ project_key=project_key,
390
+ start=start,
391
+ limit=limit,
392
+ order_by=order_by,
393
+ query=query,
394
+ status=status,
395
+ )
396
+ _emit_json(payload)
397
+
398
+ elif action == "create":
399
+ if not yes:
400
+ raise typer.BadParameter("Refusing to create version without --yes")
401
+ if not project_key:
402
+ raise typer.BadParameter("--project-key required for create")
403
+ if not project_id:
404
+ raise typer.BadParameter("--project-id required for create")
405
+ if not name:
406
+ raise typer.BadParameter("--name required for create")
407
+ result = adapter.add_version(
408
+ key=project_key,
409
+ project_id=project_id,
410
+ version=name,
411
+ is_archived=archived if archived is not None else False,
412
+ is_released=released if released is not None else False,
413
+ )
414
+ _emit_json(result)
415
+
416
+ elif action == "update":
417
+ if not yes:
418
+ raise typer.BadParameter("Refusing to update version without --yes")
419
+ if not version_id:
420
+ raise typer.BadParameter("--version-id required for update")
421
+ # At least one field must be provided for update
422
+ if not any([name, description is not None, archived is not None, released is not None, start_date, release_date]):
423
+ raise typer.BadParameter("At least one update field required (--name, --description, --archived, --released, --start-date, --release-date)")
424
+ result = adapter.update_version(
425
+ version=version_id,
426
+ name=name,
427
+ description=description,
428
+ is_archived=archived,
429
+ is_released=released,
430
+ start_date=start_date,
431
+ release_date=release_date,
432
+ )
433
+ _emit_json(result)
434
+
435
+ else:
436
+ raise typer.BadParameter(f"Unknown action: {action}")
437
+
438
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
439
+ code = 1
440
+ logger.error("version_command_failed %s", exc)
441
+ typer.echo(f"Error: {exc}", err=True)
442
+ finally:
443
+ finished_at = datetime.now(UTC)
444
+ _record_summary(
445
+ command="version",
446
+ args=summary_args,
447
+ exit_code=code,
448
+ started_at=started_at,
449
+ finished_at=finished_at,
450
+ )
451
+ if code != 0:
452
+ raise typer.Exit(code=code) from None
453
+
454
+
455
+ @app.command("search")
456
+ def cmd_search(
457
+ jql: str = typer.Argument(..., help="JQL query string"),
458
+ limit: int = typer.Option(25, "--limit", help="Number of results"),
459
+ fields: str | None = typer.Option(None, "--fields", help="Comma separated fields"),
460
+ start: int = typer.Option(0, "--start", help="Start index for pagination (advanced search)"),
461
+ advanced: bool = typer.Option(False, "--advanced", help="Use advanced search with pagination"),
462
+ ) -> None:
463
+ """Search issues using JQL. Use --advanced for pagination support."""
464
+ started_at = datetime.now(UTC)
465
+ code = 0
466
+ try:
467
+ s = JiraSettings()
468
+ s.require_basic_or_token()
469
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
470
+
471
+ fields_list = [f.strip() for f in fields.split(",")] if fields else None
472
+ if advanced or start > 0:
473
+ payload = adapter.search_issues_advanced(jql, start=start, limit=limit, fields=fields_list)
474
+ else:
475
+ payload = adapter.jql(jql, limit=limit, fields=fields_list)
476
+ _emit_json(payload)
477
+ except (JiraAuthError, JiraAdapterError) as exc:
478
+ code = 1
479
+ logger.error("search_failed %s", exc)
480
+ typer.echo(f"Error: {exc}", err=True)
481
+ finally:
482
+ finished_at = datetime.now(UTC)
483
+ args_list = [jql, f"limit={limit}"]
484
+ if start > 0:
485
+ args_list.append(f"start={start}")
486
+ if advanced:
487
+ args_list.append("advanced=True")
488
+ _record_summary(
489
+ command="search",
490
+ args=args_list,
491
+ exit_code=code,
492
+ started_at=started_at,
493
+ finished_at=finished_at,
494
+ )
495
+ if code != 0:
496
+ raise typer.Exit(code=code) from None
497
+
498
+
499
+ @app.command("search-by-filter")
500
+ def cmd_search_by_filter(
501
+ filter_id: int = typer.Argument(..., help="Filter ID"),
502
+ limit: int = typer.Option(50, "--limit", help="Number of results"),
503
+ fields: str | None = typer.Option(None, "--fields", help="Comma separated fields"),
504
+ start: int = typer.Option(0, "--start", help="Start index for pagination"),
505
+ ) -> None:
506
+ """Search issues using a saved filter."""
507
+ started_at = datetime.now(UTC)
508
+ code = 0
509
+ try:
510
+ s = JiraSettings()
511
+ s.require_basic_or_token()
512
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
513
+
514
+ fields_list = [f.strip() for f in fields.split(",")] if fields else None
515
+ payload = adapter.search_issues_by_filter(filter_id, start=start, limit=limit, fields=fields_list)
516
+ _emit_json(payload)
517
+ except (JiraAuthError, JiraAdapterError) as exc:
518
+ code = 1
519
+ logger.error("search_by_filter_failed %s", exc)
520
+ typer.echo(f"Error: {exc}", err=True)
521
+ finally:
522
+ finished_at = datetime.now(UTC)
523
+ args_list = [str(filter_id), f"limit={limit}"]
524
+ if start > 0:
525
+ args_list.append(f"start={start}")
526
+ _record_summary(
527
+ command="search-by-filter",
528
+ args=args_list,
529
+ exit_code=code,
530
+ started_at=started_at,
531
+ finished_at=finished_at,
532
+ )
533
+ if code != 0:
534
+ raise typer.Exit(code=code) from None
535
+
536
+
537
+ @app.command("issue")
538
+ def cmd_issue(
539
+ key: str = typer.Argument(..., help="Issue key (e.g., NTTC-123)"),
540
+ ) -> None:
541
+ started_at = datetime.now(UTC)
542
+ code = 0
543
+ try:
544
+ s = JiraSettings()
545
+ s.require_basic_or_token()
546
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
547
+ payload = adapter.get_issue(key)
548
+ _emit_json(payload)
549
+ except (JiraAuthError, JiraAdapterError) as exc:
550
+ code = 1
551
+ logger.error("issue_failed %s", exc)
552
+ typer.echo(f"Error: {exc}", err=True)
553
+ finally:
554
+ finished_at = datetime.now(UTC)
555
+ _record_summary(command="issue", args=[key], exit_code=code, started_at=started_at, finished_at=finished_at)
556
+ if code != 0:
557
+ raise typer.Exit(code=code) from None
558
+
559
+
560
+ @app.command("bulk-update")
561
+ def cmd_bulk_update(
562
+ issue_keys: str = typer.Option(..., "--issue-keys", help="Comma-separated issue keys (e.g., NTTC-1,NTTC-2)"),
563
+ fields: str | None = typer.Option(None, "--fields", help="JSON string or '*all' for all fields"),
564
+ fields_file: Path | None = typer.Option(None, "--fields-file", help="JSON file containing field updates"),
565
+ yes: bool = typer.Option(False, "--yes", help="Confirm bulk update operation"),
566
+ ) -> None:
567
+ """Bulk update multiple issues."""
568
+ started_at = datetime.now(UTC)
569
+ code = 0
570
+ summary_args = [f"issue_keys={issue_keys}", f"fields_file={fields_file}"]
571
+
572
+ def _load_fields() -> str:
573
+ if fields_file:
574
+ if not fields_file.exists():
575
+ raise typer.BadParameter(f"Fields file not found: {fields_file}")
576
+ try:
577
+ parsed = json.loads(fields_file.read_text())
578
+ return json.dumps(parsed)
579
+ except json.JSONDecodeError as exc:
580
+ raise typer.BadParameter(f"Invalid JSON in {fields_file}: {exc}") from exc
581
+ if fields:
582
+ # Validate JSON if provided
583
+ if fields != "*all":
584
+ try:
585
+ json.loads(fields)
586
+ except json.JSONDecodeError as exc:
587
+ raise typer.BadParameter(f"Invalid JSON in --fields: {exc}") from exc
588
+ return fields
589
+ return "*all"
590
+
591
+ try:
592
+ if not yes:
593
+ raise typer.BadParameter("Refusing to bulk update issues without --yes")
594
+ s = JiraSettings()
595
+ s.require_basic_or_token()
596
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
597
+
598
+ key_list = [k.strip() for k in issue_keys.split(",") if k.strip()]
599
+ if not key_list:
600
+ raise typer.BadParameter("At least one issue key required in --issue-keys")
601
+ fields_value = _load_fields()
602
+ result = adapter.bulk_update_issue_field(key_list=key_list, fields=fields_value)
603
+ _emit_json(result)
604
+
605
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
606
+ code = 1
607
+ logger.error("bulk_update_failed %s", exc)
608
+ typer.echo(f"Error: {exc}", err=True)
609
+ finally:
610
+ finished_at = datetime.now(UTC)
611
+ _record_summary(
612
+ command="bulk-update",
613
+ args=summary_args,
614
+ exit_code=code,
615
+ started_at=started_at,
616
+ finished_at=finished_at,
617
+ )
618
+ if code != 0:
619
+ raise typer.Exit(code=code) from None
620
+
621
+
622
+ @app.command("field-append")
623
+ def cmd_field_append(
624
+ issue_key: str = typer.Option(..., "--issue-key", "-k", help="Issue key (e.g., NTTC-123)"),
625
+ field: str = typer.Option(..., "--field", help="Field ID (e.g., customfield_10000)"),
626
+ value: str | None = typer.Option(None, "--value", help="JSON string value to append"),
627
+ value_file: Path | None = typer.Option(None, "--value-file", help="JSON file containing value to append"),
628
+ no_notify: bool = typer.Option(False, "--no-notify", help="Don't notify users"),
629
+ yes: bool = typer.Option(False, "--yes", help="Confirm field append operation"),
630
+ ) -> None:
631
+ """Append value to issue field (for multi-select fields)."""
632
+ started_at = datetime.now(UTC)
633
+ code = 0
634
+ summary_args = [f"issue_key={issue_key}", f"field={field}", f"value_file={value_file}"]
635
+
636
+ def _load_value() -> dict[str, Any]:
637
+ if value_file:
638
+ if not value_file.exists():
639
+ raise typer.BadParameter(f"Value file not found: {value_file}")
640
+ try:
641
+ parsed = json.loads(value_file.read_text())
642
+ if not isinstance(parsed, dict):
643
+ raise typer.BadParameter("--value-file must contain a JSON object")
644
+ return parsed
645
+ except json.JSONDecodeError as exc:
646
+ raise typer.BadParameter(f"Invalid JSON in {value_file}: {exc}") from exc
647
+ if not value:
648
+ raise typer.BadParameter("Provide --value or --value-file")
649
+ try:
650
+ parsed = json.loads(value)
651
+ if not isinstance(parsed, dict):
652
+ raise typer.BadParameter("--value must be a JSON object")
653
+ return parsed
654
+ except json.JSONDecodeError as exc:
655
+ raise typer.BadParameter(f"Invalid JSON in --value: {exc}") from exc
656
+
657
+ try:
658
+ if not yes:
659
+ raise typer.BadParameter("Refusing to append field value without --yes")
660
+ s = JiraSettings()
661
+ s.require_basic_or_token()
662
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
663
+
664
+ value_dict = _load_value()
665
+ result = adapter.issue_field_value_append(
666
+ issue_id_or_key=issue_key, field=field, value=value_dict, notify_users=not no_notify
667
+ )
668
+ _emit_json(result)
669
+
670
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
671
+ code = 1
672
+ logger.error("field_append_failed %s", exc)
673
+ typer.echo(f"Error: {exc}", err=True)
674
+ finally:
675
+ finished_at = datetime.now(UTC)
676
+ _record_summary(
677
+ command="field-append",
678
+ args=summary_args,
679
+ exit_code=code,
680
+ started_at=started_at,
681
+ finished_at=finished_at,
682
+ )
683
+ if code != 0:
684
+ raise typer.Exit(code=code) from None
685
+
686
+
687
+ @app.command("issue-archive")
688
+ def cmd_issue_archive(
689
+ issue_key: str = typer.Option(..., "--issue-key", "-k", help="Issue key (e.g., NTTC-123)"),
690
+ yes: bool = typer.Option(False, "--yes", help="Confirm archive operation"),
691
+ ) -> None:
692
+ """Archive an issue."""
693
+ started_at = datetime.now(UTC)
694
+ code = 0
695
+ summary_args = [f"issue_key={issue_key}"]
696
+
697
+ try:
698
+ if not yes:
699
+ raise typer.BadParameter("Refusing to archive issue without --yes")
700
+ s = JiraSettings()
701
+ s.require_basic_or_token()
702
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
703
+
704
+ adapter.issue_archive(issue_key)
705
+ _emit_json({"status": "archived", "issue_key": issue_key})
706
+
707
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
708
+ code = 1
709
+ logger.error("issue_archive_failed %s", exc)
710
+ typer.echo(f"Error: {exc}", err=True)
711
+ finally:
712
+ finished_at = datetime.now(UTC)
713
+ _record_summary(
714
+ command="issue-archive",
715
+ args=summary_args,
716
+ exit_code=code,
717
+ started_at=started_at,
718
+ finished_at=finished_at,
719
+ )
720
+ if code != 0:
721
+ raise typer.Exit(code=code) from None
722
+
723
+
724
+ @app.command("issue-restore")
725
+ def cmd_issue_restore(
726
+ issue_key: str = typer.Option(..., "--issue-key", "-k", help="Issue key (e.g., NTTC-123)"),
727
+ yes: bool = typer.Option(False, "--yes", help="Confirm restore operation"),
728
+ ) -> None:
729
+ """Restore an archived issue."""
730
+ started_at = datetime.now(UTC)
731
+ code = 0
732
+ summary_args = [f"issue_key={issue_key}"]
733
+
734
+ try:
735
+ if not yes:
736
+ raise typer.BadParameter("Refusing to restore issue without --yes")
737
+ s = JiraSettings()
738
+ s.require_basic_or_token()
739
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
740
+
741
+ adapter.issue_restore(issue_key)
742
+ _emit_json({"status": "restored", "issue_key": issue_key})
743
+
744
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
745
+ code = 1
746
+ logger.error("issue_restore_failed %s", exc)
747
+ typer.echo(f"Error: {exc}", err=True)
748
+ finally:
749
+ finished_at = datetime.now(UTC)
750
+ _record_summary(
751
+ command="issue-restore",
752
+ args=summary_args,
753
+ exit_code=code,
754
+ started_at=started_at,
755
+ finished_at=finished_at,
756
+ )
757
+ if code != 0:
758
+ raise typer.Exit(code=code) from None
759
+
760
+
761
+ @app.command("property")
762
+ def cmd_property(
763
+ action: str = typer.Argument(..., help="Action: get, set"),
764
+ property_id: str | None = typer.Option(None, "--property-id", help="Property ID (for set)"),
765
+ key: str | None = typer.Option(None, "--key", help="Property key (for get)"),
766
+ permission_level: str | None = typer.Option(None, "--permission-level", help="Permission level (for get)"),
767
+ key_filter: str | None = typer.Option(None, "--key-filter", help="Key filter pattern (for get)"),
768
+ value: str | None = typer.Option(None, "--value", help="Property value (for set)"),
769
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation (for set)"),
770
+ ) -> None:
771
+ """Manage JIRA application properties."""
772
+ started_at = datetime.now(UTC)
773
+ code = 0
774
+ summary_args = [action]
775
+
776
+ try:
777
+ s = JiraSettings()
778
+ s.require_basic_or_token()
779
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
780
+
781
+ if action == "get":
782
+ summary_args.extend([f"key={key}", f"permission_level={permission_level}", f"key_filter={key_filter}"])
783
+ payload = adapter.get_property(key=key, permission_level=permission_level, key_filter=key_filter)
784
+ _emit_json(payload)
785
+
786
+ elif action == "set":
787
+ if not yes:
788
+ raise typer.BadParameter("Refusing to set property without --yes")
789
+ if not property_id:
790
+ raise typer.BadParameter("--property-id required for set")
791
+ if value is None:
792
+ raise typer.BadParameter("--value required for set")
793
+ summary_args.extend([f"property_id={property_id}", f"value={value}"])
794
+ adapter.set_property(property_id, value)
795
+ _emit_json({"status": "set", "property_id": property_id, "value": value})
796
+
797
+ else:
798
+ raise typer.BadParameter(f"Unknown action: {action}")
799
+
800
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
801
+ code = 1
802
+ logger.error("property_failed %s", exc)
803
+ typer.echo(f"Error: {exc}", err=True)
804
+ finally:
805
+ finished_at = datetime.now(UTC)
806
+ _record_summary(
807
+ command="property",
808
+ args=summary_args,
809
+ exit_code=code,
810
+ started_at=started_at,
811
+ finished_at=finished_at,
812
+ )
813
+ if code != 0:
814
+ raise typer.Exit(code=code) from None
815
+
816
+
817
+ @app.command("settings")
818
+ def cmd_settings(
819
+ action: str = typer.Argument(..., help="Action: advanced"),
820
+ ) -> None:
821
+ """Manage JIRA settings."""
822
+ started_at = datetime.now(UTC)
823
+ code = 0
824
+ summary_args = [action]
825
+
826
+ try:
827
+ s = JiraSettings()
828
+ s.require_basic_or_token()
829
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
830
+
831
+ if action == "advanced":
832
+ payload = adapter.get_advanced_settings()
833
+ _emit_json(payload)
834
+ else:
835
+ raise typer.BadParameter(f"Unknown action: {action}")
836
+
837
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
838
+ code = 1
839
+ logger.error("settings_failed %s", exc)
840
+ typer.echo(f"Error: {exc}", err=True)
841
+ finally:
842
+ finished_at = datetime.now(UTC)
843
+ _record_summary(
844
+ command="settings",
845
+ args=summary_args,
846
+ exit_code=code,
847
+ started_at=started_at,
848
+ finished_at=finished_at,
849
+ )
850
+ if code != 0:
851
+ raise typer.Exit(code=code) from None
852
+
853
+
854
+ @app.command("create")
855
+ def cmd_create(
856
+ project: str = typer.Option(..., "--project", help="Project key"),
857
+ issuetype: str = typer.Option(..., "--issuetype", help="Issue type name (e.g., Task, Bug)"),
858
+ summary: str = typer.Option(..., "--summary", help="Summary"),
859
+ description: str | None = typer.Option(None, "--description", help="Description"),
860
+ assignee: str | None = typer.Option(None, "--assignee", help="Assignee username (Server/DC)"),
861
+ labels: str | None = typer.Option(None, "--labels", help="Comma-separated labels"),
862
+ set_field: list[str] = typer.Option(
863
+ [], "--set", help="Additional key=value field pairs (e.g., customfield_12345=10001)"
864
+ ),
865
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
866
+ dry_run: bool = typer.Option(False, "--dry-run", help="Validate payload without sending"),
867
+ ) -> None:
868
+ started_at = datetime.now(UTC)
869
+ code = 0
870
+ args = [f"project={project}", f"issuetype={issuetype}"]
871
+ try:
872
+ if not yes and not dry_run:
873
+ raise typer.BadParameter("Refusing to create without --yes (or use --dry-run)")
874
+ s = JiraSettings()
875
+ s.require_basic_or_token()
876
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
877
+ label_list = [x.strip() for x in labels.split(",")] if labels else None
878
+ extras = _parse_set(set_field) if set_field else {}
879
+ payload = {
880
+ "project": project,
881
+ "issuetype": issuetype,
882
+ "summary": summary,
883
+ "description": description,
884
+ "assignee": assignee,
885
+ "labels": label_list,
886
+ **extras,
887
+ }
888
+ if dry_run and not yes:
889
+ _emit_json({"dry_run": True, "request": payload})
890
+ else:
891
+ res = adapter.create_issue(
892
+ project_key=project,
893
+ summary=summary,
894
+ issuetype=issuetype,
895
+ description=description,
896
+ assignee=assignee,
897
+ labels=label_list or [],
898
+ extra_fields=extras,
899
+ )
900
+ _emit_json(res)
901
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
902
+ code = 1
903
+ logger.error("create_failed %s", exc)
904
+ typer.echo(f"Error: {exc}", err=True)
905
+ finally:
906
+ finished_at = datetime.now(UTC)
907
+ _record_summary(
908
+ command="create",
909
+ args=args + [f"dry_run={dry_run}", f"yes={yes}"],
910
+ exit_code=code,
911
+ started_at=started_at,
912
+ finished_at=finished_at,
913
+ )
914
+ if code != 0:
915
+ raise typer.Exit(code=code) from None
916
+
917
+
918
+ @app.command("update")
919
+ def cmd_update(
920
+ key: str = typer.Argument(..., help="Issue key"),
921
+ set_field: list[str] = typer.Option([], "--set", help="key=value pairs to update (repeatable)"),
922
+ add_label: list[str] = typer.Option([], "--add-label", help="Labels to add"),
923
+ remove_label: list[str] = typer.Option([], "--remove-label", help="Labels to remove"),
924
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
925
+ dry_run: bool = typer.Option(False, "--dry-run", help="Validate payload without sending"),
926
+ ) -> None:
927
+ started_at = datetime.now(UTC)
928
+ code = 0
929
+ try:
930
+ if not yes and not dry_run:
931
+ raise typer.BadParameter("Refusing to update without --yes (or use --dry-run)")
932
+ s = JiraSettings()
933
+ s.require_basic_or_token()
934
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
935
+ fields: dict[str, Any] = _parse_set(set_field)
936
+ if add_label or remove_label:
937
+ current = adapter.get_issue(key)
938
+ cur_labels = list(current.get("fields", {}).get("labels") or [])
939
+ if add_label:
940
+ cur_labels = sorted(set(cur_labels + add_label))
941
+ if remove_label:
942
+ cur_labels = [label for label in cur_labels if label not in set(remove_label)]
943
+ fields["labels"] = cur_labels
944
+ if dry_run and not yes:
945
+ _emit_json({"dry_run": True, "request": {"key": key, "fields": fields}})
946
+ else:
947
+ res = adapter.update_issue_fields(key, fields)
948
+ _emit_json(res)
949
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
950
+ code = 1
951
+ logger.error("update_failed %s", exc)
952
+ typer.echo(f"Error: {exc}", err=True)
953
+ finally:
954
+ finished_at = datetime.now(UTC)
955
+ _record_summary(
956
+ command="update",
957
+ args=[key]
958
+ + set_field
959
+ + [f"add_label={add_label}", f"remove_label={remove_label}", f"dry_run={dry_run}", f"yes={yes}"],
960
+ exit_code=code,
961
+ started_at=started_at,
962
+ finished_at=finished_at,
963
+ )
964
+ if code != 0:
965
+ raise typer.Exit(code=code) from None
966
+
967
+
968
+ @app.command("comment")
969
+ def cmd_comment(
970
+ key: str = typer.Argument(..., help="Issue key"),
971
+ message: str = typer.Option(..., "--message", help="Comment message"),
972
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
973
+ ) -> None:
974
+ started_at = datetime.now(UTC)
975
+ code = 0
976
+ try:
977
+ if not yes:
978
+ raise typer.BadParameter("Refusing to comment without --yes")
979
+ s = JiraSettings()
980
+ s.require_basic_or_token()
981
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
982
+ res = adapter.add_comment(key, message)
983
+ _emit_json(res)
984
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
985
+ code = 1
986
+ logger.error("comment_failed %s", exc)
987
+ typer.echo(f"Error: {exc}", err=True)
988
+ finally:
989
+ finished_at = datetime.now(UTC)
990
+ _record_summary(command="comment", args=[key], exit_code=code, started_at=started_at, finished_at=finished_at)
991
+ if code != 0:
992
+ raise typer.Exit(code=code) from None
993
+
994
+
995
+ @app.command("transition")
996
+ def cmd_transition(
997
+ key: str = typer.Argument(..., help="Issue key"),
998
+ to: str = typer.Option(..., "--to", help="Target status name"),
999
+ admin: bool = typer.Option(False, "--admin", help="Acknowledge administrative action"),
1000
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
1001
+ ) -> None:
1002
+ started_at = datetime.now(UTC)
1003
+ code = 0
1004
+ try:
1005
+ if not (admin and yes):
1006
+ raise typer.BadParameter("Refusing to transition without --admin and --yes")
1007
+ s = JiraSettings()
1008
+ s.require_basic_or_token()
1009
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1010
+ adapter.transition_issue(key, to)
1011
+ _emit_json({"key": key, "transitioned_to": to})
1012
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1013
+ code = 1
1014
+ logger.error("transition_failed %s", exc)
1015
+ typer.echo(f"Error: {exc}", err=True)
1016
+ finally:
1017
+ finished_at = datetime.now(UTC)
1018
+ _record_summary(
1019
+ command="transition", args=[key, f"to={to}"], exit_code=code, started_at=started_at, finished_at=finished_at
1020
+ )
1021
+ if code != 0:
1022
+ raise typer.Exit(code=code) from None
1023
+
1024
+
1025
+ @app.command("delete")
1026
+ def cmd_delete(
1027
+ key: str = typer.Argument(..., help="Issue key"),
1028
+ yes: bool = typer.Option(False, "--yes", help="Confirm deletion"),
1029
+ force: bool = typer.Option(False, "--force", help="Double confirm deletion"),
1030
+ ) -> None:
1031
+ started_at = datetime.now(UTC)
1032
+ code = 0
1033
+ try:
1034
+ if not (yes and force):
1035
+ raise typer.BadParameter("Refusing to delete without --yes and --force")
1036
+ s = JiraSettings()
1037
+ s.require_basic_or_token()
1038
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1039
+ adapter.delete_issue(key)
1040
+ _emit_json({"deleted": key})
1041
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1042
+ code = 1
1043
+ logger.error("delete_failed %s", exc)
1044
+ typer.echo(f"Error: {exc}", err=True)
1045
+ finally:
1046
+ finished_at = datetime.now(UTC)
1047
+ _record_summary(command="delete", args=[key], exit_code=code, started_at=started_at, finished_at=finished_at)
1048
+ if code != 0:
1049
+ raise typer.Exit(code=code) from None
1050
+
1051
+
1052
+ @app.command("createmeta")
1053
+ def cmd_createmeta(
1054
+ project: str = typer.Option(..., "--project", help="Project key"),
1055
+ issuetype: str = typer.Option(..., "--issuetype", help="Issue type name"),
1056
+ expand: str | None = typer.Option(None, "--expand", help="Expand options"),
1057
+ ) -> None:
1058
+ """Get create metadata for issue creation (field definitions, allowed values)."""
1059
+ started_at = datetime.now(UTC)
1060
+ code = 0
1061
+ try:
1062
+ s = JiraSettings()
1063
+ s.require_basic_or_token()
1064
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1065
+ payload = adapter.get_createmeta(project_key=project, issuetype=issuetype, expand=expand)
1066
+ _emit_json(payload)
1067
+ except (JiraAuthError, JiraAdapterError) as exc:
1068
+ code = 1
1069
+ logger.error("createmeta_failed %s", exc)
1070
+ typer.echo(f"Error: {exc}", err=True)
1071
+ finally:
1072
+ finished_at = datetime.now(UTC)
1073
+ _record_summary(
1074
+ command="createmeta",
1075
+ args=[f"project={project}", f"issuetype={issuetype}"],
1076
+ exit_code=code,
1077
+ started_at=started_at,
1078
+ finished_at=finished_at,
1079
+ )
1080
+ if code != 0:
1081
+ raise typer.Exit(code=code) from None
1082
+
1083
+
1084
+ @app.command("custom-fields")
1085
+ def cmd_custom_fields(
1086
+ search: str | None = typer.Option(None, "--search", help="Search filter"),
1087
+ start: int = typer.Option(1, "--start", help="Start index"),
1088
+ limit: int = typer.Option(50, "--limit", help="Number of results"),
1089
+ ) -> None:
1090
+ """Get existing custom fields or find by filter."""
1091
+ started_at = datetime.now(UTC)
1092
+ code = 0
1093
+ try:
1094
+ s = JiraSettings()
1095
+ s.require_basic_or_token()
1096
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1097
+ payload = adapter.get_custom_fields(search=search, start=start, limit=limit)
1098
+ _emit_json({"count": len(payload), "custom_fields": payload})
1099
+ except (JiraAuthError, JiraAdapterError) as exc:
1100
+ code = 1
1101
+ logger.error("custom_fields_failed %s", exc)
1102
+ typer.echo(f"Error: {exc}", err=True)
1103
+ finally:
1104
+ finished_at = datetime.now(UTC)
1105
+ _record_summary(
1106
+ command="custom-fields",
1107
+ args=[f"search={search}", f"start={start}", f"limit={limit}"],
1108
+ exit_code=code,
1109
+ started_at=started_at,
1110
+ finished_at=finished_at,
1111
+ )
1112
+ if code != 0:
1113
+ raise typer.Exit(code=code) from None
1114
+
1115
+
1116
+ @app.command("board")
1117
+ def cmd_board(
1118
+ action: str = typer.Argument(..., help="Action: list, get, create, delete, config, issues, properties, velocity"),
1119
+ board_id: int | None = typer.Option(None, "--board-id", help="Board ID"),
1120
+ filter_id: int | None = typer.Option(None, "--filter-id", help="Filter ID (for create/get-by-filter)"),
1121
+ name: str | None = typer.Option(None, "--name", help="Board name (for create)"),
1122
+ board_type: str | None = typer.Option(None, "--type", help="Board type: scrum, kanban (for create)"),
1123
+ project_key: str | None = typer.Option(None, "--project", help="Project key (for list)"),
1124
+ limit: int = typer.Option(50, "--limit", help="Number of results"),
1125
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
1126
+ ) -> None:
1127
+ """Agile board operations."""
1128
+ started_at = datetime.now(UTC)
1129
+ code = 0
1130
+ try:
1131
+ s = JiraSettings()
1132
+ s.require_basic_or_token()
1133
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1134
+
1135
+ if action == "list":
1136
+ payload = adapter.get_all_agile_boards(
1137
+ project_key=project_key, board_type=board_type, start=0, limit=limit
1138
+ )
1139
+ _emit_json({"count": len(payload), "boards": payload})
1140
+ elif action == "get":
1141
+ if not board_id and not filter_id:
1142
+ raise typer.BadParameter("Either --board-id or --filter-id required")
1143
+ if filter_id:
1144
+ payload = adapter.get_agile_board_by_filter_id(filter_id)
1145
+ else:
1146
+ payload = adapter.get_agile_board(board_id) # type: ignore[arg-type]
1147
+ _emit_json(payload)
1148
+ elif action == "create":
1149
+ if not yes:
1150
+ raise typer.BadParameter("Refusing to create without --yes")
1151
+ if not name or not board_type or not filter_id:
1152
+ raise typer.BadParameter("--name, --type, and --filter-id required for create")
1153
+ payload = adapter.create_agile_board(name, board_type, filter_id)
1154
+ _emit_json(payload)
1155
+ elif action == "delete":
1156
+ if not (yes and board_id):
1157
+ raise typer.BadParameter("Refusing to delete without --yes and --board-id")
1158
+ adapter.delete_agile_board(board_id) # type: ignore[arg-type]
1159
+ _emit_json({"deleted": board_id})
1160
+ elif action == "config":
1161
+ if not board_id:
1162
+ raise typer.BadParameter("--board-id required")
1163
+ payload = adapter.get_agile_board_configuration(board_id) # type: ignore[arg-type]
1164
+ _emit_json(payload)
1165
+ elif action == "issues":
1166
+ if not board_id:
1167
+ raise typer.BadParameter("--board-id required")
1168
+ payload = adapter.get_issues_for_board(board_id) # type: ignore[arg-type]
1169
+ _emit_json({"count": len(payload), "issues": payload})
1170
+ elif action == "properties":
1171
+ if not board_id:
1172
+ raise typer.BadParameter("--board-id required")
1173
+ payload = adapter.get_agile_board_properties(board_id) # type: ignore[arg-type]
1174
+ _emit_json({"count": len(payload), "properties": payload})
1175
+ elif action == "velocity":
1176
+ if not board_id:
1177
+ raise typer.BadParameter("--board-id required")
1178
+ payload = adapter.get_agile_board_refined_velocity(board_id) # type: ignore[arg-type]
1179
+ _emit_json(payload)
1180
+ else:
1181
+ raise typer.BadParameter(f"Unknown action: {action}")
1182
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1183
+ code = 1
1184
+ logger.error("board_failed %s", exc)
1185
+ typer.echo(f"Error: {exc}", err=True)
1186
+ finally:
1187
+ finished_at = datetime.now(UTC)
1188
+ _record_summary(
1189
+ command="board",
1190
+ args=[action, f"board_id={board_id}", f"limit={limit}"],
1191
+ exit_code=code,
1192
+ started_at=started_at,
1193
+ finished_at=finished_at,
1194
+ )
1195
+ if code != 0:
1196
+ raise typer.Exit(code=code) from None
1197
+
1198
+
1199
+ @app.command("epic")
1200
+ def cmd_epic(
1201
+ action: str = typer.Argument(..., help="Action: issues, list, issues-for-epic"),
1202
+ epic_key: str | None = typer.Option(None, "--epic-key", help="Epic key (for issues)"),
1203
+ board_id: int | None = typer.Option(None, "--board-id", help="Board ID (for list/issues-for-epic)"),
1204
+ epic_id: int | None = typer.Option(None, "--epic-id", help="Epic ID (for issues-for-epic)"),
1205
+ done: bool = typer.Option(False, "--done", help="Include done epics (for list)"),
1206
+ limit: int = typer.Option(50, "--limit", help="Number of results"),
1207
+ ) -> None:
1208
+ """Epic operations."""
1209
+ started_at = datetime.now(UTC)
1210
+ code = 0
1211
+ try:
1212
+ s = JiraSettings()
1213
+ s.require_basic_or_token()
1214
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1215
+
1216
+ if action == "issues":
1217
+ if not epic_key:
1218
+ raise typer.BadParameter("--epic-key required")
1219
+ payload = adapter.epic_issues(epic_key)
1220
+ _emit_json({"count": len(payload), "issues": payload})
1221
+ elif action == "list":
1222
+ if not board_id:
1223
+ raise typer.BadParameter("--board-id required")
1224
+ payload = adapter.get_epics(board_id, done=done, start=0, limit=limit) # type: ignore[arg-type]
1225
+ _emit_json({"count": len(payload), "epics": payload})
1226
+ elif action == "issues-for-epic":
1227
+ if not board_id or not epic_id:
1228
+ raise typer.BadParameter("--board-id and --epic-id required")
1229
+ payload = adapter.get_issues_for_epic(board_id, epic_id, jql="", validate_query="", fields="*all", expand="", start=0, limit=limit) # type: ignore[arg-type]
1230
+ _emit_json({"count": len(payload), "issues": payload})
1231
+ else:
1232
+ raise typer.BadParameter(f"Unknown action: {action}")
1233
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1234
+ code = 1
1235
+ logger.error("epic_failed %s", exc)
1236
+ typer.echo(f"Error: {exc}", err=True)
1237
+ finally:
1238
+ finished_at = datetime.now(UTC)
1239
+ _record_summary(
1240
+ command="epic",
1241
+ args=[action, f"epic_key={epic_key}", f"board_id={board_id}"],
1242
+ exit_code=code,
1243
+ started_at=started_at,
1244
+ finished_at=finished_at,
1245
+ )
1246
+ if code != 0:
1247
+ raise typer.Exit(code=code) from None
1248
+
1249
+
1250
+ @app.command("sprint")
1251
+ def cmd_sprint(
1252
+ action: str = typer.Argument(..., help="Action: list, issues, versions, create, rename, add-issues"),
1253
+ board_id: int | None = typer.Option(None, "--board-id", help="Board ID"),
1254
+ sprint_id: int | None = typer.Option(None, "--sprint-id", help="Sprint ID"),
1255
+ state: str | None = typer.Option(None, "--state", help="Sprint state filter"),
1256
+ sprint_name: str | None = typer.Option(None, "--name", help="Sprint name (for create/rename)"),
1257
+ start_date: str | None = typer.Option(None, "--start-date", help="Start date (for create/rename)"),
1258
+ end_date: str | None = typer.Option(None, "--end-date", help="End date (for create/rename)"),
1259
+ goal: str | None = typer.Option(None, "--goal", help="Sprint goal (for create)"),
1260
+ issues: str | None = typer.Option(None, "--issues", help="Comma-separated issue keys (for add-issues)"),
1261
+ limit: int = typer.Option(50, "--limit", help="Number of results"),
1262
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
1263
+ ) -> None:
1264
+ """Sprint operations."""
1265
+ started_at = datetime.now(UTC)
1266
+ code = 0
1267
+ try:
1268
+ s = JiraSettings()
1269
+ s.require_basic_or_token()
1270
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1271
+
1272
+ if action == "list":
1273
+ if not board_id:
1274
+ raise typer.BadParameter("--board-id required")
1275
+ payload = adapter.get_all_sprints_from_board(board_id, state=state, start=0, limit=limit) # type: ignore[arg-type]
1276
+ _emit_json({"count": len(payload), "sprints": payload})
1277
+ elif action == "issues":
1278
+ if not board_id:
1279
+ raise typer.BadParameter("--board-id required")
1280
+ payload = adapter.get_all_issues_for_sprint_in_board(board_id, state=state, start=0, limit=limit) # type: ignore[arg-type]
1281
+ _emit_json({"count": len(payload), "issues": payload})
1282
+ elif action == "versions":
1283
+ if not board_id:
1284
+ raise typer.BadParameter("--board-id required")
1285
+ payload = adapter.get_all_versions_from_board(board_id, start=0, limit=limit) # type: ignore[arg-type]
1286
+ _emit_json({"count": len(payload), "versions": payload})
1287
+ elif action == "create":
1288
+ if not yes:
1289
+ raise typer.BadParameter("Refusing to create without --yes")
1290
+ if not sprint_name or not board_id:
1291
+ raise typer.BadParameter("--name and --board-id required for create")
1292
+ payload = adapter.create_sprint(
1293
+ sprint_name, board_id, start_datetime=start_date, end_datetime=end_date, goal=goal # type: ignore[arg-type]
1294
+ )
1295
+ _emit_json(payload)
1296
+ elif action == "rename":
1297
+ if not yes:
1298
+ raise typer.BadParameter("Refusing to rename without --yes")
1299
+ if not sprint_id:
1300
+ raise typer.BadParameter("--sprint-id required")
1301
+ payload = adapter.rename_sprint(sprint_id, name=sprint_name, start_date=start_date, end_date=end_date) # type: ignore[arg-type]
1302
+ _emit_json(payload)
1303
+ elif action == "add-issues":
1304
+ if not yes:
1305
+ raise typer.BadParameter("Refusing to add issues without --yes")
1306
+ if not sprint_id or not issues:
1307
+ raise typer.BadParameter("--sprint-id and --issues required")
1308
+ issues_list = [i.strip() for i in issues.split(",")]
1309
+ adapter.add_issues_to_sprint(sprint_id, issues_list) # type: ignore[arg-type]
1310
+ _emit_json({"sprint_id": sprint_id, "issues_added": issues_list})
1311
+ else:
1312
+ raise typer.BadParameter(f"Unknown action: {action}")
1313
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1314
+ code = 1
1315
+ logger.error("sprint_failed %s", exc)
1316
+ typer.echo(f"Error: {exc}", err=True)
1317
+ finally:
1318
+ finished_at = datetime.now(UTC)
1319
+ _record_summary(
1320
+ command="sprint",
1321
+ args=[action, f"board_id={board_id}", f"sprint_id={sprint_id}"],
1322
+ exit_code=code,
1323
+ started_at=started_at,
1324
+ finished_at=finished_at,
1325
+ )
1326
+ if code != 0:
1327
+ raise typer.Exit(code=code) from None
1328
+
1329
+
1330
+ @app.command("backlog")
1331
+ def cmd_backlog(
1332
+ action: str = typer.Argument(..., help="Action: move, add"),
1333
+ issues: str = typer.Option(..., "--issues", help="Comma-separated issue keys"),
1334
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
1335
+ ) -> None:
1336
+ """Backlog operations."""
1337
+ started_at = datetime.now(UTC)
1338
+ code = 0
1339
+ try:
1340
+ if not yes:
1341
+ raise typer.BadParameter("Refusing to modify backlog without --yes")
1342
+ s = JiraSettings()
1343
+ s.require_basic_or_token()
1344
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1345
+ issues_list = [i.strip() for i in issues.split(",")]
1346
+
1347
+ if action == "move":
1348
+ adapter.move_issues_to_backlog(issues_list)
1349
+ _emit_json({"action": "moved_to_backlog", "issues": issues_list})
1350
+ elif action == "add":
1351
+ adapter.add_issues_to_backlog(issues_list)
1352
+ _emit_json({"action": "added_to_backlog", "issues": issues_list})
1353
+ else:
1354
+ raise typer.BadParameter(f"Unknown action: {action}")
1355
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1356
+ code = 1
1357
+ logger.error("backlog_failed %s", exc)
1358
+ typer.echo(f"Error: {exc}", err=True)
1359
+ finally:
1360
+ finished_at = datetime.now(UTC)
1361
+ _record_summary(
1362
+ command="backlog",
1363
+ args=[action, f"issues={issues}"],
1364
+ exit_code=code,
1365
+ started_at=started_at,
1366
+ finished_at=finished_at,
1367
+ )
1368
+ if code != 0:
1369
+ raise typer.Exit(code=code) from None
1370
+
1371
+
1372
+ @app.command("worklog")
1373
+ def cmd_worklog(
1374
+ action: str = typer.Argument(..., help="Action: get, add, updated, deleted"),
1375
+ issue_key: str | None = typer.Option(None, "--issue-key", help="Issue key (for get/add)"),
1376
+ time_spent: str | None = typer.Option(None, "--time-spent", help="Time spent (e.g., '2h 30m') (for add)"),
1377
+ comment: str | None = typer.Option(None, "--comment", help="Worklog comment (for add)"),
1378
+ started: str | None = typer.Option(None, "--started", help="Start datetime in ISO format (for add)"),
1379
+ time_sec: int | None = typer.Option(None, "--time-sec", help="Time in seconds (alternative to --time-spent)"),
1380
+ since: str | None = typer.Option(None, "--since", help="Timestamp for updated/deleted (YYYY-MM-DD HH:mm or ISO)"),
1381
+ expand: str | None = typer.Option(None, "--expand", help="Expand parameter (for updated)"),
1382
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
1383
+ ) -> None:
1384
+ """Worklog operations."""
1385
+ started_at = datetime.now(UTC)
1386
+ code = 0
1387
+ try:
1388
+ s = JiraSettings()
1389
+ s.require_basic_or_token()
1390
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1391
+
1392
+ if action == "get":
1393
+ if not issue_key:
1394
+ raise typer.BadParameter("--issue-key required for get")
1395
+ payload = adapter.get_worklogs(issue_key)
1396
+ _emit_json(payload)
1397
+ elif action == "add":
1398
+ if not yes:
1399
+ raise typer.BadParameter("Refusing to add worklog without --yes")
1400
+ if not issue_key:
1401
+ raise typer.BadParameter("--issue-key required for add")
1402
+ if not time_spent and time_sec is None:
1403
+ raise typer.BadParameter("--time-spent or --time-sec required for add")
1404
+ payload = adapter.issue_add_worklog(
1405
+ issue_key=issue_key,
1406
+ time_spent=time_spent or "",
1407
+ comment=comment,
1408
+ started=started,
1409
+ time_sec=time_sec,
1410
+ )
1411
+ _emit_json(payload)
1412
+ elif action == "updated":
1413
+ if not since:
1414
+ raise typer.BadParameter("--since required for updated")
1415
+ payload = adapter.get_updated_worklogs(since, expand=expand)
1416
+ _emit_json(payload)
1417
+ elif action == "deleted":
1418
+ if not since:
1419
+ raise typer.BadParameter("--since required for deleted")
1420
+ payload = adapter.get_deleted_worklogs(since)
1421
+ _emit_json(payload)
1422
+ else:
1423
+ raise typer.BadParameter(f"Unknown action: {action}")
1424
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1425
+ code = 1
1426
+ logger.error("worklog_failed %s", exc)
1427
+ typer.echo(f"Error: {exc}", err=True)
1428
+ finally:
1429
+ finished_at = datetime.now(UTC)
1430
+ _record_summary(
1431
+ command="worklog",
1432
+ args=[action, f"issue_key={issue_key}", f"since={since}"],
1433
+ exit_code=code,
1434
+ started_at=started_at,
1435
+ finished_at=finished_at,
1436
+ )
1437
+ if code != 0:
1438
+ raise typer.Exit(code=code) from None
1439
+
1440
+
1441
+ @app.command("transitions")
1442
+ def cmd_transitions(
1443
+ issue_key: str = typer.Argument(..., help="Issue key"),
1444
+ ) -> None:
1445
+ """Get available transitions for an issue."""
1446
+ started_at = datetime.now(UTC)
1447
+ code = 0
1448
+ try:
1449
+ s = JiraSettings()
1450
+ s.require_basic_or_token()
1451
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1452
+
1453
+ payload = adapter.get_issue_transitions(issue_key)
1454
+ _emit_json(payload)
1455
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1456
+ code = 1
1457
+ logger.error("transitions_failed %s", exc)
1458
+ typer.echo(f"Error: {exc}", err=True)
1459
+ finally:
1460
+ finished_at = datetime.now(UTC)
1461
+ _record_summary(
1462
+ command="transitions",
1463
+ args=[issue_key],
1464
+ exit_code=code,
1465
+ started_at=started_at,
1466
+ finished_at=finished_at,
1467
+ )
1468
+ if code != 0:
1469
+ raise typer.Exit(code=code) from None
1470
+
1471
+
1472
+ @app.command("changelog")
1473
+ def cmd_changelog(
1474
+ action: str = typer.Argument(..., help="Action: get, status"),
1475
+ issue_key: str = typer.Argument(..., help="Issue key"),
1476
+ start: int | None = typer.Option(None, "--start", help="Start index for pagination"),
1477
+ limit: int | None = typer.Option(None, "--limit", help="Limit for pagination"),
1478
+ ) -> None:
1479
+ """Get issue changelog (change history)."""
1480
+ started_at = datetime.now(UTC)
1481
+ code = 0
1482
+ try:
1483
+ s = JiraSettings()
1484
+ s.require_basic_or_token()
1485
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1486
+
1487
+ if action == "get":
1488
+ payload = adapter.get_issue_changelog(issue_key, start=start, limit=limit)
1489
+ _emit_json(payload)
1490
+ elif action == "status":
1491
+ payload = adapter.get_issue_status_changelog(issue_key)
1492
+ _emit_json(payload)
1493
+ else:
1494
+ raise typer.BadParameter(f"Unknown action: {action}")
1495
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1496
+ code = 1
1497
+ logger.error("changelog_failed %s", exc)
1498
+ typer.echo(f"Error: {exc}", err=True)
1499
+ finally:
1500
+ finished_at = datetime.now(UTC)
1501
+ _record_summary(
1502
+ command="changelog",
1503
+ args=[action, issue_key, f"start={start}", f"limit={limit}"],
1504
+ exit_code=code,
1505
+ started_at=started_at,
1506
+ finished_at=finished_at,
1507
+ )
1508
+ if code != 0:
1509
+ raise typer.Exit(code=code) from None
1510
+
1511
+
1512
+ @app.command("filter")
1513
+ def cmd_filter(
1514
+ action: str = typer.Argument(..., help="Action: list, get, create, update, delete"),
1515
+ filter_id: int | None = typer.Option(None, "--filter-id", help="Filter ID (for get, update, delete)"),
1516
+ owner: str | None = typer.Option(None, "--owner", help="Owner username (for list)"),
1517
+ name: str | None = typer.Option(None, "--name", help="Filter name (for create, update)"),
1518
+ jql: str | None = typer.Option(None, "--jql", help="JQL query (for create, update)"),
1519
+ description: str | None = typer.Option(None, "--description", help="Filter description (for create, update)"),
1520
+ favourite: bool | None = typer.Option(None, "--favourite/--no-favourite", help="Mark as favourite (for create, update)"),
1521
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation (for create, update, delete)"),
1522
+ ) -> None:
1523
+ """Filter management operations."""
1524
+ started_at = datetime.now(UTC)
1525
+ code = 0
1526
+ try:
1527
+ s = JiraSettings()
1528
+ s.require_basic_or_token()
1529
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1530
+
1531
+ if action == "list":
1532
+ payload = adapter.get_filters(owner=owner)
1533
+ _emit_json({"filters": payload, "count": len(payload)})
1534
+ elif action == "get":
1535
+ if not filter_id:
1536
+ raise typer.BadParameter("--filter-id required for get")
1537
+ payload = adapter.get_filter(filter_id)
1538
+ _emit_json(payload)
1539
+ elif action == "create":
1540
+ if not yes:
1541
+ raise typer.BadParameter("Refusing to create filter without --yes")
1542
+ if not name:
1543
+ raise typer.BadParameter("--name required for create")
1544
+ if not jql:
1545
+ raise typer.BadParameter("--jql required for create")
1546
+ payload = adapter.create_filter(
1547
+ name=name, jql=jql, description=description, favourite=favourite if favourite is not None else False
1548
+ )
1549
+ _emit_json(payload)
1550
+ elif action == "update":
1551
+ if not yes:
1552
+ raise typer.BadParameter("Refusing to update filter without --yes")
1553
+ if not filter_id:
1554
+ raise typer.BadParameter("--filter-id required for update")
1555
+ if not (name or jql or description is not None or favourite is not None):
1556
+ raise typer.BadParameter("At least one of --name, --jql, --description, or --favourite must be provided for update")
1557
+ payload = adapter.update_filter(
1558
+ filter_id=filter_id, name=name, jql=jql, description=description, favourite=favourite
1559
+ )
1560
+ _emit_json(payload)
1561
+ elif action == "delete":
1562
+ if not yes:
1563
+ raise typer.BadParameter("Refusing to delete filter without --yes")
1564
+ if not filter_id:
1565
+ raise typer.BadParameter("--filter-id required for delete")
1566
+ adapter.delete_filter(filter_id)
1567
+ _emit_json({"status": "success", "filter_id": filter_id, "message": "Filter deleted"})
1568
+ else:
1569
+ raise typer.BadParameter(f"Unknown action: {action}")
1570
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1571
+ code = 1
1572
+ logger.error("filter_failed %s", exc)
1573
+ typer.echo(f"Error: {exc}", err=True)
1574
+ finally:
1575
+ finished_at = datetime.now(UTC)
1576
+ args_list: list[str] = [action]
1577
+ if filter_id:
1578
+ args_list.append(f"filter_id={filter_id}")
1579
+ if owner:
1580
+ args_list.append(f"owner={owner}")
1581
+ if name:
1582
+ args_list.append(f"name={name}")
1583
+ if jql:
1584
+ args_list.append(f"jql={jql}")
1585
+ if description:
1586
+ args_list.append(f"description={description}")
1587
+ if favourite is not None:
1588
+ args_list.append(f"favourite={favourite}")
1589
+ _record_summary(
1590
+ command="filter",
1591
+ args=args_list,
1592
+ exit_code=code,
1593
+ started_at=started_at,
1594
+ finished_at=finished_at,
1595
+ )
1596
+ if code != 0:
1597
+ raise typer.Exit(code=code) from None
1598
+
1599
+
1600
+ @app.command("dashboard")
1601
+ def cmd_dashboard(
1602
+ action: str = typer.Argument(..., help="Action: get, list"),
1603
+ dashboard_id: str | None = typer.Option(None, "--dashboard-id", help="Dashboard ID (for get)"),
1604
+ filter_str: str | None = typer.Option(None, "--filter", help="Filter string (for list)"),
1605
+ start: int = typer.Option(0, "--start", help="Start index for pagination (for list)"),
1606
+ limit: int = typer.Option(10, "--limit", help="Limit for pagination (for list)"),
1607
+ ) -> None:
1608
+ """Dashboard operations."""
1609
+ started_at = datetime.now(UTC)
1610
+ code = 0
1611
+ try:
1612
+ s = JiraSettings()
1613
+ s.require_basic_or_token()
1614
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1615
+
1616
+ if action == "get":
1617
+ if not dashboard_id:
1618
+ raise typer.BadParameter("--dashboard-id required for get")
1619
+ # Try to parse as int, otherwise use as string
1620
+ try:
1621
+ dashboard_id_int = int(dashboard_id)
1622
+ payload = adapter.get_dashboard(dashboard_id_int)
1623
+ except ValueError:
1624
+ payload = adapter.get_dashboard(dashboard_id)
1625
+ _emit_json(payload)
1626
+ elif action == "list":
1627
+ payload = adapter.get_dashboards(filter=filter_str or "", start=start, limit=limit)
1628
+ _emit_json(payload)
1629
+ else:
1630
+ raise typer.BadParameter(f"Unknown action: {action}")
1631
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1632
+ code = 1
1633
+ logger.error("dashboard_failed %s", exc)
1634
+ typer.echo(f"Error: {exc}", err=True)
1635
+ finally:
1636
+ finished_at = datetime.now(UTC)
1637
+ _record_summary(
1638
+ command="dashboard",
1639
+ args=[action, f"dashboard_id={dashboard_id}", f"filter={filter_str}", f"start={start}", f"limit={limit}"],
1640
+ exit_code=code,
1641
+ started_at=started_at,
1642
+ finished_at=finished_at,
1643
+ )
1644
+ if code != 0:
1645
+ raise typer.Exit(code=code) from None
1646
+
1647
+
1648
+ @app.command("link-type")
1649
+ def cmd_link_type(
1650
+ action: str = typer.Argument(..., help="Action: list, get, create, update, delete"),
1651
+ link_type_id: str | None = typer.Option(None, "--link-type-id", help="Link type ID (for get, update, delete)"),
1652
+ name: str | None = typer.Option(None, "--name", help="Link type name (for create, update)"),
1653
+ inward: str | None = typer.Option(None, "--inward", help="Inward description (for create, update)"),
1654
+ outward: str | None = typer.Option(None, "--outward", help="Outward description (for create, update)"),
1655
+ data_file: Path | None = typer.Option(None, "--data-file", help="JSON file with link type data (for create, update)"),
1656
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation (for create, update, delete)"),
1657
+ ) -> None:
1658
+ """Issue link type operations."""
1659
+ started_at = datetime.now(UTC)
1660
+ code = 0
1661
+ try:
1662
+ s = JiraSettings()
1663
+ s.require_basic_or_token()
1664
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1665
+
1666
+ if action == "list":
1667
+ payload = adapter.get_issue_link_types()
1668
+ _emit_json({"count": len(payload), "link_types": payload})
1669
+ elif action == "get":
1670
+ if not link_type_id:
1671
+ raise typer.BadParameter("--link-type-id required for get")
1672
+ payload = adapter.get_issue_link_type(link_type_id)
1673
+ _emit_json(payload)
1674
+ elif action == "create":
1675
+ if not yes:
1676
+ raise typer.BadParameter("Refusing to create link type without --yes")
1677
+ if data_file:
1678
+ if not data_file.exists():
1679
+ raise typer.BadParameter(f"Data file not found: {data_file}")
1680
+ with data_file.open() as f:
1681
+ data = json.load(f)
1682
+ else:
1683
+ if not name or not inward or not outward:
1684
+ raise typer.BadParameter("--name, --inward, and --outward required for create (or use --data-file)")
1685
+ data = {"name": name, "inward": inward, "outward": outward}
1686
+ payload = adapter.create_issue_link_type(data)
1687
+ _emit_json(payload)
1688
+ elif action == "update":
1689
+ if not yes:
1690
+ raise typer.BadParameter("Refusing to update link type without --yes")
1691
+ if not link_type_id:
1692
+ raise typer.BadParameter("--link-type-id required for update")
1693
+ if data_file:
1694
+ if not data_file.exists():
1695
+ raise typer.BadParameter(f"Data file not found: {data_file}")
1696
+ with data_file.open() as f:
1697
+ data = json.load(f)
1698
+ else:
1699
+ if not name or not inward or not outward:
1700
+ raise typer.BadParameter("--name, --inward, and --outward required for update (or use --data-file)")
1701
+ data = {"name": name, "inward": inward, "outward": outward}
1702
+ payload = adapter.update_issue_link_type(link_type_id, data)
1703
+ _emit_json(payload)
1704
+ elif action == "delete":
1705
+ if not yes:
1706
+ raise typer.BadParameter("Refusing to delete link type without --yes")
1707
+ if not link_type_id:
1708
+ raise typer.BadParameter("--link-type-id required for delete")
1709
+ adapter.delete_issue_link_type(link_type_id)
1710
+ _emit_json({"status": "deleted", "link_type_id": link_type_id})
1711
+ else:
1712
+ raise typer.BadParameter(f"Unknown action: {action}")
1713
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1714
+ code = 1
1715
+ logger.error("link_type_failed %s", exc)
1716
+ typer.echo(f"Error: {exc}", err=True)
1717
+ finally:
1718
+ finished_at = datetime.now(UTC)
1719
+ _record_summary(
1720
+ command="link-type",
1721
+ args=[
1722
+ action,
1723
+ f"link_type_id={link_type_id}",
1724
+ f"name={name}",
1725
+ f"inward={inward}",
1726
+ f"outward={outward}",
1727
+ f"data_file={data_file}",
1728
+ f"yes={yes}",
1729
+ ],
1730
+ exit_code=code,
1731
+ started_at=started_at,
1732
+ finished_at=finished_at,
1733
+ )
1734
+ if code != 0:
1735
+ raise typer.Exit(code=code) from None
1736
+
1737
+
1738
+ @app.command("remote-link")
1739
+ def cmd_remote_link(
1740
+ action: str = typer.Argument(..., help="Action: list, get, create, update, delete"),
1741
+ issue_key: str = typer.Option(..., "--issue-key", "-k", help="Issue key (e.g., NTTC-123)"),
1742
+ remote_link_id: str | None = typer.Option(None, "--remote-link-id", "-r", help="Remote link ID (for get/update/delete)"),
1743
+ url: str | None = typer.Option(None, "--url", help="Remote link target URL (for create/update)"),
1744
+ title: str | None = typer.Option(None, "--title", help="Remote link title"),
1745
+ summary: str | None = typer.Option(None, "--summary", help="Remote link summary/description"),
1746
+ relationship: str | None = typer.Option(None, "--relationship", help="Relationship description"),
1747
+ global_id: str | None = typer.Option(None, "--global-id", help="Global ID for remote link"),
1748
+ application_type: str | None = typer.Option(None, "--application-type", help="Application type for remote link"),
1749
+ application_name: str | None = typer.Option(None, "--application-name", help="Application name for remote link"),
1750
+ icon_url: str | None = typer.Option(None, "--icon-url", help="Icon URL for the remote link object"),
1751
+ icon_title: str | None = typer.Option(None, "--icon-title", help="Icon title for the remote link object"),
1752
+ data_file: Path | None = typer.Option(None, "--data-file", help="JSON file describing the remote link payload"),
1753
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations (create/update/delete)"),
1754
+ ) -> None:
1755
+ """Manage Jira issue remote links."""
1756
+ started_at = datetime.now(UTC)
1757
+ code = 0
1758
+ summary_args: list[str] = [
1759
+ action,
1760
+ f"issue={issue_key}",
1761
+ f"remote_link_id={remote_link_id}",
1762
+ f"data_file={data_file}",
1763
+ ]
1764
+
1765
+ def _parse_remote_link_identifier(value: str | None) -> str | int | None:
1766
+ if value is None:
1767
+ return None
1768
+ try:
1769
+ return int(value)
1770
+ except ValueError:
1771
+ return value
1772
+
1773
+ try:
1774
+ s = JiraSettings()
1775
+ s.require_basic_or_token()
1776
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1777
+
1778
+ if action == "list":
1779
+ payload = adapter.get_issue_remote_links(issue_key)
1780
+ _emit_json({"count": len(payload), "remote_links": payload})
1781
+
1782
+ elif action == "get":
1783
+ remote_id = _parse_remote_link_identifier(remote_link_id)
1784
+ if remote_id is None:
1785
+ raise typer.BadParameter("--remote-link-id required for get")
1786
+ payload = adapter.get_issue_remote_link_by_id(issue_key, remote_id)
1787
+ _emit_json(payload)
1788
+
1789
+ elif action == "create":
1790
+ if not yes:
1791
+ raise typer.BadParameter("--yes required for create")
1792
+ payload_data = _build_remote_link_payload(
1793
+ url=url,
1794
+ title=title,
1795
+ summary=summary,
1796
+ relationship=relationship,
1797
+ global_id=global_id,
1798
+ application_type=application_type,
1799
+ application_name=application_name,
1800
+ icon_url=icon_url,
1801
+ icon_title=icon_title,
1802
+ data_file=data_file,
1803
+ )
1804
+ result = adapter.create_or_update_issue_remote_link(issue_key, payload_data)
1805
+ _emit_json(result)
1806
+
1807
+ elif action == "update":
1808
+ if not yes:
1809
+ raise typer.BadParameter("--yes required for update")
1810
+ remote_id = _parse_remote_link_identifier(remote_link_id)
1811
+ if remote_id is None:
1812
+ raise typer.BadParameter("--remote-link-id required for update")
1813
+ payload_data = _build_remote_link_payload(
1814
+ url=url,
1815
+ title=title,
1816
+ summary=summary,
1817
+ relationship=relationship,
1818
+ global_id=global_id,
1819
+ application_type=application_type,
1820
+ application_name=application_name,
1821
+ icon_url=icon_url,
1822
+ icon_title=icon_title,
1823
+ data_file=data_file,
1824
+ )
1825
+ result = adapter.update_issue_remote_link_by_id(issue_key, remote_id, payload_data)
1826
+ _emit_json(result)
1827
+
1828
+ elif action == "delete":
1829
+ if not yes:
1830
+ raise typer.BadParameter("--yes required for delete")
1831
+ remote_id = _parse_remote_link_identifier(remote_link_id)
1832
+ if remote_id is None:
1833
+ raise typer.BadParameter("--remote-link-id required for delete")
1834
+ adapter.delete_issue_remote_link_by_id(issue_key, remote_id)
1835
+ _emit_json({"status": "deleted", "issue": issue_key, "remote_link_id": remote_id})
1836
+
1837
+ else:
1838
+ raise typer.BadParameter(f"Unknown action: {action}")
1839
+
1840
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1841
+ code = 1
1842
+ logger.error("remote_link_command_failed %s", exc)
1843
+ typer.echo(f"Error: {exc}", err=True)
1844
+ finally:
1845
+ finished_at = datetime.now(UTC)
1846
+ _record_summary(
1847
+ command="remote-link",
1848
+ args=summary_args,
1849
+ exit_code=code,
1850
+ started_at=started_at,
1851
+ finished_at=finished_at,
1852
+ )
1853
+ if code != 0:
1854
+ raise typer.Exit(code=code) from None
1855
+
1856
+
1857
+ @app.command("issue-property")
1858
+ def cmd_issue_property(
1859
+ action: str = typer.Argument(..., help="Action: list, get, set, delete"),
1860
+ issue_key: str = typer.Option(..., "--issue-key", "-k", help="Issue key (e.g., NTTC-123)"),
1861
+ property_key: str | None = typer.Option(None, "--property-key", "-p", help="Property key (required for get/set/delete)"),
1862
+ data_file: Path | None = typer.Option(None, "--data-file", help="JSON file containing property value (for set)"),
1863
+ value: str | None = typer.Option(None, "--value", help="JSON string value (for set when no file provided)"),
1864
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations (set/delete)"),
1865
+ ) -> None:
1866
+ """Manage Jira issue properties."""
1867
+ started_at = datetime.now(UTC)
1868
+ code = 0
1869
+ summary_args = [
1870
+ action,
1871
+ f"issue={issue_key}",
1872
+ f"property_key={property_key}",
1873
+ f"data_file={data_file}",
1874
+ ]
1875
+
1876
+ def _load_value() -> dict[str, Any]:
1877
+ if data_file:
1878
+ if not data_file.exists():
1879
+ raise typer.BadParameter(f"Data file not found: {data_file}")
1880
+ try:
1881
+ return json.loads(data_file.read_text())
1882
+ except json.JSONDecodeError as exc:
1883
+ raise typer.BadParameter(f"Invalid JSON in {data_file}: {exc}") from exc
1884
+ if value is None:
1885
+ raise typer.BadParameter("Provide --value or --data-file for set action")
1886
+ try:
1887
+ parsed = json.loads(value)
1888
+ except json.JSONDecodeError as exc:
1889
+ raise typer.BadParameter(f"Invalid JSON for --value: {exc}") from exc
1890
+ if not isinstance(parsed, dict):
1891
+ raise typer.BadParameter("--value must decode to a JSON object")
1892
+ return parsed
1893
+
1894
+ try:
1895
+ s = JiraSettings()
1896
+ s.require_basic_or_token()
1897
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1898
+
1899
+ if action == "list":
1900
+ payload = adapter.get_issue_property_keys(issue_key)
1901
+ _emit_json(payload)
1902
+
1903
+ elif action == "get":
1904
+ if not property_key:
1905
+ raise typer.BadParameter("--property-key required for get")
1906
+ payload = adapter.get_issue_property(issue_key, property_key)
1907
+ _emit_json(payload)
1908
+
1909
+ elif action == "set":
1910
+ if not yes:
1911
+ raise typer.BadParameter("--yes required for set")
1912
+ if not property_key:
1913
+ raise typer.BadParameter("--property-key required for set")
1914
+ payload = _load_value()
1915
+ adapter.set_issue_property(issue_key, property_key, payload)
1916
+ _emit_json({"status": "updated", "issue": issue_key, "property_key": property_key})
1917
+
1918
+ elif action == "delete":
1919
+ if not yes:
1920
+ raise typer.BadParameter("--yes required for delete")
1921
+ if not property_key:
1922
+ raise typer.BadParameter("--property-key required for delete")
1923
+ adapter.delete_issue_property(issue_key, property_key)
1924
+ _emit_json({"status": "deleted", "issue": issue_key, "property_key": property_key})
1925
+
1926
+ else:
1927
+ raise typer.BadParameter(f"Unknown action: {action}")
1928
+
1929
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1930
+ code = 1
1931
+ logger.error("issue_property_command_failed %s", exc)
1932
+ typer.echo(f"Error: {exc}", err=True)
1933
+ finally:
1934
+ finished_at = datetime.now(UTC)
1935
+ _record_summary(
1936
+ command="issue-property",
1937
+ args=summary_args,
1938
+ exit_code=code,
1939
+ started_at=started_at,
1940
+ finished_at=finished_at,
1941
+ )
1942
+ if code != 0:
1943
+ raise typer.Exit(code=code) from None
1944
+
1945
+
1946
+ @app.command("watcher")
1947
+ def cmd_watcher(
1948
+ action: str = typer.Argument(..., help="Action: list, add, remove"),
1949
+ issue_key: str = typer.Option(..., "--issue-key", "-k", help="Issue key (e.g., NTTC-123)"),
1950
+ account: str | None = typer.Option(
1951
+ None,
1952
+ "--account",
1953
+ "-a",
1954
+ help="Username or accountId (required for add/remove)",
1955
+ ),
1956
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operations (add/remove)"),
1957
+ ) -> None:
1958
+ """Manage issue watchers."""
1959
+ started_at = datetime.now(UTC)
1960
+ code = 0
1961
+ summary_args = [action, f"issue={issue_key}", f"account={account}"]
1962
+
1963
+ try:
1964
+ s = JiraSettings()
1965
+ s.require_basic_or_token()
1966
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
1967
+
1968
+ if action == "list":
1969
+ payload = adapter.issue_get_watchers(issue_key)
1970
+ _emit_json(payload)
1971
+
1972
+ elif action == "add":
1973
+ if not yes:
1974
+ raise typer.BadParameter("--yes required for add")
1975
+ if not account:
1976
+ raise typer.BadParameter("--account required for add")
1977
+ adapter.issue_add_watcher(issue_key, account)
1978
+ _emit_json({"status": "added", "issue": issue_key, "account": account})
1979
+
1980
+ elif action == "remove":
1981
+ if not yes:
1982
+ raise typer.BadParameter("--yes required for remove")
1983
+ if not account:
1984
+ raise typer.BadParameter("--account required for remove")
1985
+ adapter.issue_delete_watcher(issue_key, account)
1986
+ _emit_json({"status": "removed", "issue": issue_key, "account": account})
1987
+
1988
+ else:
1989
+ raise typer.BadParameter(f"Unknown action: {action}")
1990
+
1991
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
1992
+ code = 1
1993
+ logger.error("watcher_command_failed %s", exc)
1994
+ typer.echo(f"Error: {exc}", err=True)
1995
+ finally:
1996
+ finished_at = datetime.now(UTC)
1997
+ _record_summary(
1998
+ command="watcher",
1999
+ args=summary_args,
2000
+ exit_code=code,
2001
+ started_at=started_at,
2002
+ finished_at=finished_at,
2003
+ )
2004
+ if code != 0:
2005
+ raise typer.Exit(code=code) from None
2006
+
2007
+
2008
+ @app.command("security-scheme")
2009
+ def cmd_security_scheme(
2010
+ action: str = typer.Argument(..., help="Action: list, get"),
2011
+ scheme_id: str | None = typer.Option(None, "--scheme-id", help="Scheme ID (for get)"),
2012
+ only_levels: bool = typer.Option(False, "--only-levels", help="Return only security levels (for get)"),
2013
+ ) -> None:
2014
+ """Issue security scheme operations."""
2015
+ started_at = datetime.now(UTC)
2016
+ code = 0
2017
+ try:
2018
+ s = JiraSettings()
2019
+ s.require_basic_or_token()
2020
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
2021
+
2022
+ if action == "list":
2023
+ payload = adapter.get_issue_security_schemes()
2024
+ _emit_json({"count": len(payload), "security_schemes": payload})
2025
+ elif action == "get":
2026
+ if not scheme_id:
2027
+ raise typer.BadParameter("--scheme-id required for get")
2028
+ payload = adapter.get_issue_security_scheme(scheme_id, only_levels=only_levels)
2029
+ _emit_json(payload)
2030
+ else:
2031
+ raise typer.BadParameter(f"Unknown action: {action}")
2032
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
2033
+ code = 1
2034
+ logger.error("security_scheme_failed %s", exc)
2035
+ typer.echo(f"Error: {exc}", err=True)
2036
+ finally:
2037
+ finished_at = datetime.now(UTC)
2038
+ _record_summary(
2039
+ command="security-scheme",
2040
+ args=[action, f"scheme_id={scheme_id}", f"only_levels={only_levels}"],
2041
+ exit_code=code,
2042
+ started_at=started_at,
2043
+ finished_at=finished_at,
2044
+ )
2045
+ if code != 0:
2046
+ raise typer.Exit(code=code) from None
2047
+
2048
+
2049
+ @app.command("permissions")
2050
+ def cmd_permissions(
2051
+ action: str = typer.Argument(..., help="Action: all, check"),
2052
+ permissions: str | None = typer.Option(None, "--permissions", help="Comma-separated permission names (for check)"),
2053
+ project_key: str | None = typer.Option(None, "--project-key", help="Project key (for check)"),
2054
+ issue_key: str | None = typer.Option(None, "--issue-key", help="Issue key (for check)"),
2055
+ ) -> None:
2056
+ """Permission operations."""
2057
+ started_at = datetime.now(UTC)
2058
+ code = 0
2059
+ try:
2060
+ s = JiraSettings()
2061
+ s.require_basic_or_token()
2062
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
2063
+
2064
+ if action == "all":
2065
+ payload = adapter.get_all_permissions()
2066
+ _emit_json({"count": len(payload), "permissions": payload})
2067
+ elif action == "check":
2068
+ if not permissions:
2069
+ raise typer.BadParameter("--permissions required for check")
2070
+ perms_list = [p.strip() for p in permissions.split(",")]
2071
+ payload = adapter.get_permissions(
2072
+ permissions=perms_list,
2073
+ project_key=project_key,
2074
+ issue_key=issue_key,
2075
+ )
2076
+ _emit_json(payload)
2077
+ else:
2078
+ raise typer.BadParameter(f"Unknown action: {action}")
2079
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
2080
+ code = 1
2081
+ logger.error("permissions_failed %s", exc)
2082
+ typer.echo(f"Error: {exc}", err=True)
2083
+ finally:
2084
+ finished_at = datetime.now(UTC)
2085
+ _record_summary(
2086
+ command="permissions",
2087
+ args=[action, f"permissions={permissions}", f"project_key={project_key}", f"issue_key={issue_key}"],
2088
+ exit_code=code,
2089
+ started_at=started_at,
2090
+ finished_at=finished_at,
2091
+ )
2092
+ if code != 0:
2093
+ raise typer.Exit(code=code) from None
2094
+
2095
+
2096
+ @app.command("permission-scheme")
2097
+ def cmd_permission_scheme(
2098
+ project_key: str = typer.Argument(..., help="Project key"),
2099
+ expand: str | None = typer.Option(None, "--expand", help="Expand options (comma-separated)"),
2100
+ ) -> None:
2101
+ """Get project permission scheme."""
2102
+ started_at = datetime.now(UTC)
2103
+ code = 0
2104
+ try:
2105
+ s = JiraSettings()
2106
+ s.require_basic_or_token()
2107
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
2108
+ payload = adapter.get_project_permission_scheme(project_key, expand=expand)
2109
+ _emit_json(payload)
2110
+ except (JiraAuthError, JiraAdapterError) as exc:
2111
+ code = 1
2112
+ logger.error("permission_scheme_failed %s", exc)
2113
+ typer.echo(f"Error: {exc}", err=True)
2114
+ finally:
2115
+ finished_at = datetime.now(UTC)
2116
+ _record_summary(
2117
+ command="permission-scheme",
2118
+ args=[project_key, f"expand={expand}"],
2119
+ exit_code=code,
2120
+ started_at=started_at,
2121
+ finished_at=finished_at,
2122
+ )
2123
+ if code != 0:
2124
+ raise typer.Exit(code=code) from None
2125
+
2126
+
2127
+ @app.command("issue-security-scheme")
2128
+ def cmd_issue_security_scheme(
2129
+ project_key: str = typer.Argument(..., help="Project key"),
2130
+ only_levels: bool = typer.Option(False, "--only-levels", help="Return only security levels"),
2131
+ ) -> None:
2132
+ """Get project issue security scheme."""
2133
+ started_at = datetime.now(UTC)
2134
+ code = 0
2135
+ try:
2136
+ s = JiraSettings()
2137
+ s.require_basic_or_token()
2138
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
2139
+ payload = adapter.get_project_issue_security_scheme(project_key, only_levels=only_levels)
2140
+ _emit_json(payload)
2141
+ except (JiraAuthError, JiraAdapterError) as exc:
2142
+ code = 1
2143
+ logger.error("issue_security_scheme_failed %s", exc)
2144
+ typer.echo(f"Error: {exc}", err=True)
2145
+ finally:
2146
+ finished_at = datetime.now(UTC)
2147
+ _record_summary(
2148
+ command="issue-security-scheme",
2149
+ args=[project_key, f"only_levels={only_levels}"],
2150
+ exit_code=code,
2151
+ started_at=started_at,
2152
+ finished_at=finished_at,
2153
+ )
2154
+ if code != 0:
2155
+ raise typer.Exit(code=code) from None
2156
+
2157
+
2158
+ @app.command("reindex")
2159
+ def cmd_reindex(
2160
+ action: str = typer.Argument(..., help="Action: start, status, with-type"),
2161
+ reindex_type: str | None = typer.Option(None, "--type", help="Reindex type (for with-type)"),
2162
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation"),
2163
+ ) -> None:
2164
+ """Reindex operations."""
2165
+ started_at = datetime.now(UTC)
2166
+ code = 0
2167
+ try:
2168
+ s = JiraSettings()
2169
+ s.require_basic_or_token()
2170
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
2171
+
2172
+ if action == "start":
2173
+ if not yes:
2174
+ raise typer.BadParameter("Refusing to start reindex without --yes")
2175
+ payload = adapter.reindex()
2176
+ _emit_json(payload)
2177
+ elif action == "status":
2178
+ payload = adapter.reindex_status()
2179
+ _emit_json(payload)
2180
+ elif action == "with-type":
2181
+ if not yes:
2182
+ raise typer.BadParameter("Refusing to reindex without --yes")
2183
+ if not reindex_type:
2184
+ raise typer.BadParameter("--type required for with-type")
2185
+ payload = adapter.reindex_with_type(reindex_type)
2186
+ _emit_json(payload)
2187
+ else:
2188
+ raise typer.BadParameter(f"Unknown action: {action}")
2189
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
2190
+ code = 1
2191
+ logger.error("reindex_failed %s", exc)
2192
+ typer.echo(f"Error: {exc}", err=True)
2193
+ finally:
2194
+ finished_at = datetime.now(UTC)
2195
+ _record_summary(
2196
+ command="reindex",
2197
+ args=[action, f"type={reindex_type}"],
2198
+ exit_code=code,
2199
+ started_at=started_at,
2200
+ finished_at=finished_at,
2201
+ )
2202
+ if code != 0:
2203
+ raise typer.Exit(code=code) from None
2204
+
2205
+
2206
+ @app.command("user")
2207
+ def cmd_user(
2208
+ action: str = typer.Argument(..., help="Action: search, groups, deactivate"),
2209
+ query: str | None = typer.Option(None, "--query", help="Search query (for search)"),
2210
+ account_id: str | None = typer.Option(None, "--account-id", help="Account ID (for groups)"),
2211
+ username: str | None = typer.Option(None, "--username", help="Username (for deactivate)"),
2212
+ start: int = typer.Option(0, "--start", help="Start index for pagination (for search)"),
2213
+ limit: int = typer.Option(50, "--limit", help="Limit for pagination (for search)"),
2214
+ include_inactive: bool = typer.Option(False, "--include-inactive", help="Include inactive users (for search)"),
2215
+ yes: bool = typer.Option(False, "--yes", help="Confirm write operation (for deactivate)"),
2216
+ ) -> None:
2217
+ """User management operations."""
2218
+ started_at = datetime.now(UTC)
2219
+ code = 0
2220
+ try:
2221
+ s = JiraSettings()
2222
+ s.require_basic_or_token()
2223
+ adapter = JiraAdapter(url=str(s.base_url), username=s.username, password=s.password, token=s.token)
2224
+
2225
+ if action == "search":
2226
+ if not query:
2227
+ raise typer.BadParameter("--query required for search")
2228
+ payload = adapter.find_users(query=query, start=start, limit=limit, include_inactive=include_inactive)
2229
+ _emit_json({"count": len(payload), "users": payload})
2230
+ elif action == "groups":
2231
+ if not account_id:
2232
+ raise typer.BadParameter("--account-id required for groups")
2233
+ payload = adapter.get_user_groups(account_id)
2234
+ _emit_json({"count": len(payload), "groups": payload})
2235
+ elif action == "deactivate":
2236
+ if not yes:
2237
+ raise typer.BadParameter("Refusing to deactivate user without --yes")
2238
+ if not username:
2239
+ raise typer.BadParameter("--username required for deactivate")
2240
+ adapter.deactivate_user(username)
2241
+ _emit_json({"status": "deactivated", "username": username})
2242
+ else:
2243
+ raise typer.BadParameter(f"Unknown action: {action}")
2244
+ except (JiraAuthError, JiraAdapterError, typer.BadParameter) as exc:
2245
+ code = 1
2246
+ logger.error("user_failed %s", exc)
2247
+ typer.echo(f"Error: {exc}", err=True)
2248
+ finally:
2249
+ finished_at = datetime.now(UTC)
2250
+ _record_summary(
2251
+ command="user",
2252
+ args=[
2253
+ action,
2254
+ f"query={query}",
2255
+ f"account_id={account_id}",
2256
+ f"username={username}",
2257
+ f"start={start}",
2258
+ f"limit={limit}",
2259
+ f"include_inactive={include_inactive}",
2260
+ f"yes={yes}",
2261
+ ],
2262
+ exit_code=code,
2263
+ started_at=started_at,
2264
+ finished_at=finished_at,
2265
+ )
2266
+ if code != 0:
2267
+ raise typer.Exit(code=code) from None
2268
+
2269
+
2270
+ if __name__ == "__main__":
2271
+ app()