boomtick-cli 0.7.0__tar.gz → 0.8.0__tar.gz

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 (145) hide show
  1. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/PKG-INFO +1 -1
  2. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/boomtick_cli.egg-info/PKG-INFO +1 -1
  3. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/boomtick_cli.egg-info/SOURCES.txt +2 -0
  4. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/cli.py +14 -5
  5. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/orchestrator.py +87 -29
  6. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/pr_overlap.py +4 -4
  7. boomtick_cli-0.8.0/dev_tools/resources/__pycache__/__init__.cpython-312.pyc +0 -0
  8. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/resources/__pycache__/build-repo-context.cpython-312.pyc +0 -0
  9. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/ai_service.py +5 -4
  10. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/dependency_graph.py +14 -14
  11. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/utils/__init__.py +24 -2
  12. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/pyproject.toml +1 -1
  13. boomtick_cli-0.8.0/tests/test_detect_conflicts.py +83 -0
  14. boomtick_cli-0.8.0/tests/test_issue_view.py +75 -0
  15. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_labels.py +10 -0
  16. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_latency_mitigation.py +3 -1
  17. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_search_issues.py +1 -1
  18. boomtick_cli-0.7.0/dev_tools/resources/__pycache__/__init__.cpython-312.pyc +0 -0
  19. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/MANIFEST.in +0 -0
  20. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/README.md +0 -0
  21. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/boomtick_cli.egg-info/dependency_links.txt +0 -0
  22. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/boomtick_cli.egg-info/entry_points.txt +0 -0
  23. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/boomtick_cli.egg-info/requires.txt +0 -0
  24. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/boomtick_cli.egg-info/top_level.txt +0 -0
  25. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/__init__.py +0 -0
  26. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/cli-schema.json +0 -0
  27. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/config.py +0 -0
  28. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/constants.py +0 -0
  29. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/daemon.py +0 -0
  30. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/config.js +0 -0
  31. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/evals/run-evals.js +0 -0
  32. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/index.js +0 -0
  33. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/lib/error_utils.js +0 -0
  34. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/lib/git.js +0 -0
  35. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/lib/result.js +0 -0
  36. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/lib/shell.js +0 -0
  37. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/lib/shell.test.js +0 -0
  38. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/lib/td-cli.js +0 -0
  39. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/lib/test-utils.js +0 -0
  40. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/mcp/definitions.js +0 -0
  41. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/mcp/server.js +0 -0
  42. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/mcp/tools.js +0 -0
  43. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/contract.js +0 -0
  44. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/ddgs.search.js +0 -0
  45. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/ddgs.search.test.js +0 -0
  46. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/ddgs_search.py +0 -0
  47. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.checkout_branch.js +0 -0
  48. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.comment_triage_summary.js +0 -0
  49. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.create_issue.js +0 -0
  50. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.create_issue.test.js +0 -0
  51. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.create_pull_request.js +0 -0
  52. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.get_merge_conflict_files.js +0 -0
  53. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.get_pr.js +0 -0
  54. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.get_pr_diff.js +0 -0
  55. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.issue_comment.js +0 -0
  56. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.issue_update.js +0 -0
  57. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.issue_view.js +0 -0
  58. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.open_replacement_pr.js +0 -0
  59. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.search_open_prs.js +0 -0
  60. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/github.search_open_prs.test.js +0 -0
  61. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/cancel-session.js +0 -0
  62. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/create-session.js +0 -0
  63. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/create-session.test.js +0 -0
  64. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/get-messages.js +0 -0
  65. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/get-messages.test.js +0 -0
  66. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/get-pr.js +0 -0
  67. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/get-pr.test.js +0 -0
  68. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/get-session.js +0 -0
  69. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/list-sessions.js +0 -0
  70. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/send-message.js +0 -0
  71. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/send-message.test.js +0 -0
  72. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/shared.js +0 -0
  73. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/trigger-feedback.js +0 -0
  74. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/jules/trigger-feedback.test.js +0 -0
  75. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.commit_patch.js +0 -0
  76. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.create_branch.js +0 -0
  77. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.create_branch.test.js +0 -0
  78. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.create_repair_branch.js +0 -0
  79. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.get_changed_files.js +0 -0
  80. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.get_command_schema.js +0 -0
  81. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.get_package_scripts.js +0 -0
  82. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.get_package_scripts.test.js +0 -0
  83. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.get_route_map.js +0 -0
  84. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.logs.js +0 -0
  85. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.read_agent_context.js +0 -0
  86. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.read_ci_logs.js +0 -0
  87. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.run_lighthouse.js +0 -0
  88. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.run_playwright.js +0 -0
  89. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/repo.run_tests.js +0 -0
  90. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/dist/tools/types.js +0 -0
  91. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/get_ai_context.py +0 -0
  92. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/handlers/__init__.py +0 -0
  93. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/handlers/command_handler.py +0 -0
  94. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/mcp_server.py +0 -0
  95. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/models.py +0 -0
  96. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/resources/__init__.py +0 -0
  97. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/resources/build-repo-context.py +0 -0
  98. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/resources/prompt_constants.json +0 -0
  99. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/resources/review_template.md +0 -0
  100. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/resources/ux-audit.config.json +0 -0
  101. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/resources/visual_guidelines.json +0 -0
  102. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/review_read_pass.py +0 -0
  103. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/schema_gen.py +0 -0
  104. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/schema_utils.py +0 -0
  105. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/__init__.py +0 -0
  106. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/context_builder.py +0 -0
  107. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/github.py +0 -0
  108. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/jules.py +0 -0
  109. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/repair_service.py +0 -0
  110. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/vector_store.py +0 -0
  111. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/services/vision_service.py +0 -0
  112. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/td_cli.py +0 -0
  113. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/utils/git.py +0 -0
  114. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/ux_report.py +0 -0
  115. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/verify_infra.py +0 -0
  116. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/verify_versions.py +0 -0
  117. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/version_utils.py +0 -0
  118. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/workflows/__init__.py +0 -0
  119. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/workflows/context.py +0 -0
  120. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/workflows/graph.py +0 -0
  121. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/workflows/node.py +0 -0
  122. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/workflows/nodes.py +0 -0
  123. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/dev_tools/workflows/runner.py +0 -0
  124. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/setup.cfg +0 -0
  125. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_build_repo_context.py +0 -0
  126. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_config.py +0 -0
  127. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_dependency_graph.py +0 -0
  128. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_fail_fast.py +0 -0
  129. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_github_diff_parser.py +0 -0
  130. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_github_no_gh.py +0 -0
  131. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_github_parser.py +0 -0
  132. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_install_workflows.py +0 -0
  133. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_issue_validation_local.py +0 -0
  134. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_jules_batch_send.py +0 -0
  135. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_opentelemetry_check.py +0 -0
  136. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_orchestrator_aggregation.py +0 -0
  137. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_orchestrator_heuristics.py +0 -0
  138. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_parse_comment.py +0 -0
  139. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_plan_issue_audit.py +0 -0
  140. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_pr_overlap.py +0 -0
  141. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_review_template_validation.py +0 -0
  142. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_schema_utils.py +0 -0
  143. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_verify_ci_metrics.py +0 -0
  144. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_workflow_audit.py +0 -0
  145. {boomtick_cli-0.7.0 → boomtick_cli-0.8.0}/tests/test_workflow_orchestration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boomtick-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: BoomTick CLI — agentic dev-tools for GitHub + AI workflows
5
5
  Author: Ariel Anders
6
6
  Project-URL: Homepage, https://github.com/arii/boomtick
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boomtick-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: BoomTick CLI — agentic dev-tools for GitHub + AI workflows
5
5
  Author: Ariel Anders
6
6
  Project-URL: Homepage, https://github.com/arii/boomtick
@@ -117,12 +117,14 @@ dev_tools/workflows/runner.py
117
117
  tests/test_build_repo_context.py
118
118
  tests/test_config.py
119
119
  tests/test_dependency_graph.py
120
+ tests/test_detect_conflicts.py
120
121
  tests/test_fail_fast.py
121
122
  tests/test_github_diff_parser.py
122
123
  tests/test_github_no_gh.py
123
124
  tests/test_github_parser.py
124
125
  tests/test_install_workflows.py
125
126
  tests/test_issue_validation_local.py
127
+ tests/test_issue_view.py
126
128
  tests/test_jules_batch_send.py
127
129
  tests/test_labels.py
128
130
  tests/test_latency_mitigation.py
@@ -296,6 +296,7 @@ def search_prs(ctx, state, limit, include_drafts, labels):
296
296
  @click.option("--labels")
297
297
  @click.pass_context
298
298
  def search_issues(ctx, state, limit, labels):
299
+ """Search for GitHub issues (excluding pull requests) in the repository."""
299
300
  orch = ctx.obj["ORCHESTRATOR"]
300
301
  label_list = [l.strip() for l in labels.split(",")] if labels else None
301
302
 
@@ -424,10 +425,15 @@ def create_issue(ctx, title, file, body):
424
425
 
425
426
 
426
427
  @gh.command()
427
- @click.option("--issue-number", type=int, required=True, help="The GitHub issue number to view")
428
+ @click.argument("issue_number_arg", required=False, type=int)
429
+ @click.option("--issue-number", "issue_number_opt", type=int, help="The GitHub issue number to view")
428
430
  @click.pass_context
429
- def issue_view(ctx, issue_number):
431
+ def issue_view(ctx, issue_number_arg, issue_number_opt):
430
432
  """View details of a GitHub issue."""
433
+ issue_number = issue_number_arg if issue_number_arg is not None else issue_number_opt
434
+ if issue_number is None:
435
+ err(ctx, "Provide --issue-number or a positional issue number")
436
+
431
437
  orch = ctx.obj["ORCHESTRATOR"]
432
438
  issue = orch.get_issue_details(issue_number)
433
439
  msg = f"Issue #{issue.get('number')}: {issue.get('title')}\nState: {issue.get('state')}\n\n{issue.get('body')}"
@@ -686,11 +692,14 @@ def verify_versions(ctx, diff_input):
686
692
 
687
693
 
688
694
  @gh.command()
689
- @click.option("--pr", type=int)
695
+ @click.option("--pr", type=int, help="PR number to detect conflicts for")
696
+ @click.option("--all", "all_prs", is_flag=True, help="Detect conflicts for all open PRs")
690
697
  @click.pass_context
691
- def detect_conflicts(ctx, pr):
698
+ def detect_conflicts(ctx, pr, all_prs):
699
+ if pr is not None and all_prs:
700
+ err(ctx, "Provide either --pr or --all, not both")
692
701
  orch = ctx.obj["ORCHESTRATOR"]
693
- conflicts = orch.handle_detect_conflicts(pr_num=pr)
702
+ conflicts = orch.handle_detect_conflicts(pr_num=pr, all_prs=all_prs)
694
703
  _render_conflicts(ctx, conflicts)
695
704
 
696
705
 
@@ -19,6 +19,8 @@ from dev_tools.ux_report import generate_report
19
19
  if TYPE_CHECKING:
20
20
  from dev_tools.services.ai_service import AIClient
21
21
  from dev_tools.services.vision_service import VisionService
22
+ from dev_tools.workflows.graph import WorkflowGraph
23
+ from dev_tools.workflows.context import WorkflowContext
22
24
 
23
25
  from dev_tools.config import get_config
24
26
  from dev_tools.handlers.command_handler import CommandHandler
@@ -477,6 +479,9 @@ class Orchestrator:
477
479
  if res is None:
478
480
  raise CLIError("Nothing to update. Provide body, labels, or state.")
479
481
 
482
+ if isinstance(res, list) or not isinstance(res, dict) or not all(k in res for k in ["number", "title", "html_url", "state"]):
483
+ res = self.github.fetch_issue_details(issueNumber)
484
+
480
485
  return {"status": "success", "issue": IssueSummary(**res).model_dump()}
481
486
 
482
487
  def post_comment(self, entity_number: int, body: Optional[str]) -> Dict[str, Any]:
@@ -593,8 +598,11 @@ class Orchestrator:
593
598
  "total_findings": total_findings,
594
599
  }
595
600
 
596
- def handle_detect_conflicts(self, pr_num: Optional[int] = None) -> List[Dict[str, Any]]:
597
- conflicts = self.detect_conflicts(pr_num)
601
+ def handle_detect_conflicts(self, pr_num: Optional[int] = None, all_prs: bool = False) -> List[Dict[str, Any]]:
602
+ if pr_num is not None and all_prs:
603
+ raise CLIError("Provide either pr_num or all_prs, not both")
604
+ target_pr = pr_num if not all_prs else None
605
+ conflicts = self.detect_conflicts(target_pr)
598
606
  formatted = []
599
607
  for pr_pair, files in conflicts.items():
600
608
  formatted.append({"prs": list(pr_pair), "files": files})
@@ -732,7 +740,7 @@ class Orchestrator:
732
740
  review_dir = get_or_create_log_dir("reviews")
733
741
  ctx_path = os.path.join(review_dir, f"pr-context-{prNumber}.md")
734
742
  rev_path = os.path.join(review_dir, f"pr-review-{prNumber}.md")
735
- res = {"pr": prNumber, "files": {}}
743
+ res: Dict[str, Any] = {"pr": prNumber, "files": {}}
736
744
  if fetch:
737
745
  repo = get_github_client().get_repo(get_repo_name())
738
746
  pr = repo.get_pull(prNumber)
@@ -991,7 +999,7 @@ class Orchestrator:
991
999
  try:
992
1000
  import importlib_metadata # type: ignore
993
1001
  except ImportError:
994
- importlib_metadata = None
1002
+ importlib_metadata = None # type: ignore[assignment]
995
1003
 
996
1004
  if importlib_metadata:
997
1005
  otel_issues = []
@@ -1926,18 +1934,42 @@ Follow the "Audit comment template" in `docs/agent/issue-audit-rules.md` to post
1926
1934
  checks = self.github.fetch_check_runs(head_sha)
1927
1935
  failed_checks = [c for c in checks if c.get("conclusion") == "failure"]
1928
1936
 
1929
- logs = {}
1937
+ logs: dict[str, str] = {}
1930
1938
  # Get check suites to find workflow runs
1931
1939
  check_suites = self.github.fetch_check_suites(head_sha)
1932
1940
 
1933
- for suite in check_suites:
1934
- runs = self.github.fetch_check_runs_for_suite(suite["id"])
1935
- for run in runs:
1936
- if include_all or run.get("conclusion") == "failure":
1937
- run_id = run.get("id")
1938
- if isinstance(run_id, int):
1939
- log_content = self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
1940
- logs[run["name"]] = log_content[:10000]
1941
+ all_runs = []
1942
+ import concurrent.futures
1943
+ if check_suites:
1944
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(check_suites))) as executor:
1945
+ suite_futures = {executor.submit(self.github.fetch_check_runs_for_suite, suite["id"]): suite for suite in check_suites}
1946
+ for future in concurrent.futures.as_completed(suite_futures):
1947
+ try:
1948
+ runs = future.result()
1949
+ all_runs.extend(runs)
1950
+ except Exception as e:
1951
+ suite = suite_futures[future]
1952
+ log_warn(f"Failed to fetch runs for suite {suite.get('id')}: {e}")
1953
+
1954
+ runs_to_fetch = [run for run in all_runs if include_all or run.get("conclusion") == "failure"]
1955
+
1956
+ def fetch_log(run):
1957
+ run_id = run.get("id")
1958
+ if isinstance(run_id, int):
1959
+ return run["name"], self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
1960
+ return None, None
1961
+
1962
+ if runs_to_fetch:
1963
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(runs_to_fetch))) as executor:
1964
+ log_futures = {executor.submit(fetch_log, run): run for run in runs_to_fetch}
1965
+ for future in concurrent.futures.as_completed(log_futures):
1966
+ try:
1967
+ name, log_content = future.result()
1968
+ if name and isinstance(name, str) and log_content and isinstance(log_content, str):
1969
+ logs[name] = log_content[:10000]
1970
+ except Exception as e:
1971
+ run = log_futures[future]
1972
+ log_warn(f"Failed to fetch log for run {run.get('id')}: {e}")
1941
1973
 
1942
1974
  return {"checks": checks, "failedChecks": failed_checks, "logs": logs}
1943
1975
 
@@ -1957,8 +1989,11 @@ Follow the "Audit comment template" in `docs/agent/issue-audit-rules.md` to post
1957
1989
  # Limit to latest 20 jobs to avoid extreme memory usage
1958
1990
  for run in check_runs[:20]:
1959
1991
  # Fetch logs via API to avoid terminal paging/buffering issues
1960
- log_content = self.github.fetch_check_run_logs(run.get("id"), external_id=run.get("external_id"))
1961
- header = f"--- LOGS FOR JOB: {run['name']} (ID: {run['id']}) ---"
1992
+ run_id = run.get("id")
1993
+ if not isinstance(run_id, int):
1994
+ continue
1995
+ log_content = self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
1996
+ header = f"--- LOGS FOR JOB: {run['name']} (ID: {run_id}) ---"
1962
1997
  all_logs.append(header)
1963
1998
  # Truncate each log to 20k chars to balance detail vs memory
1964
1999
  all_logs.append(log_content[-20000:])
@@ -2232,9 +2267,21 @@ Follow the "Audit comment template" in `docs/agent/issue-audit-rules.md` to post
2232
2267
  aggregate_body = ""
2233
2268
  successfully_merged = []
2234
2269
 
2270
+ import concurrent.futures
2271
+ pr_details_map = {}
2272
+ if prNumbers:
2273
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(prNumbers))) as executor:
2274
+ future_to_pr = {executor.submit(self.github.fetch_pr_details, pr_num): pr_num for pr_num in prNumbers}
2275
+ for future in concurrent.futures.as_completed(future_to_pr):
2276
+ pr_num = future_to_pr[future]
2277
+ try:
2278
+ pr_details_map[pr_num] = future.result()
2279
+ except Exception as e:
2280
+ raise CLIError(f"Failed to fetch PR #{pr_num}: {e}")
2281
+
2235
2282
  for pr_num in prNumbers:
2236
2283
  # 2. Sequential Extraction & Deterministic Sequence
2237
- pr_data = self.github.fetch_pr_details(pr_num)
2284
+ pr_data = pr_details_map.get(pr_num) or {}
2238
2285
  head_ref = pr_data.get("head", {}).get("ref")
2239
2286
  title = pr_data.get("title")
2240
2287
  body = pr_data.get("body") or ""
@@ -2370,15 +2417,15 @@ Follow the "Audit comment template" in `docs/agent/issue-audit-rules.md` to post
2370
2417
 
2371
2418
  if os.path.exists(impact_script):
2372
2419
  # Use check=False to swallow errors from impact analysis in the planning phase
2373
- res = run_command(
2420
+ impact_res = run_command(
2374
2421
  ["npx", "tsx", impact_script],
2375
2422
  check=False,
2376
2423
  log_on_error=False,
2377
2424
  )
2378
- if isinstance(res, subprocess.CompletedProcess):
2379
- impact_output = (res.stdout or "") + (res.stderr or "")
2380
- if res.returncode != 0:
2381
- impact_output = f"Impact analysis failed (exit {res.returncode}):\n{impact_output}"
2425
+ if isinstance(impact_res, subprocess.CompletedProcess):
2426
+ impact_output = (impact_res.stdout or "") + (impact_res.stderr or "")
2427
+ if impact_res.returncode != 0:
2428
+ impact_output = f"Impact analysis failed (exit {impact_res.returncode}):\n{impact_output}"
2382
2429
 
2383
2430
  # 6. Existing Review Data
2384
2431
  gemini_review = "None."
@@ -2809,17 +2856,28 @@ Overlapping functionality identified and resolved.
2809
2856
  file_to_prs: Dict[str, set[int]] = defaultdict(set)
2810
2857
  pr_hunks = {}
2811
2858
 
2812
- for pr_num in prNumbers:
2859
+ def fetch_pr_info(pr_num: int):
2813
2860
  details = self.github.fetch_pr_details(pr_num)
2814
- pr_details[pr_num] = details
2815
2861
  files = self.github.fetch_pr_files(pr_num)
2816
2862
  diff = self.github.fetch_pr_diff(pr_num)
2817
- pr_hunks[pr_num] = self._extract_diff_hunks(diff)
2818
-
2819
- # Explicitly cast to str to satisfy type checkers
2820
- pr_files = {str(f.get("filename")) for f in files if f.get("filename")}
2821
- for filename_str in pr_files:
2822
- file_to_prs[filename_str].add(pr_num)
2863
+ hunks = self._extract_diff_hunks(diff)
2864
+ return pr_num, details, files, hunks
2865
+
2866
+ import concurrent.futures
2867
+ if prNumbers:
2868
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(prNumbers))) as executor:
2869
+ future_to_pr = {executor.submit(fetch_pr_info, pr_num): pr_num for pr_num in prNumbers}
2870
+ for future in concurrent.futures.as_completed(future_to_pr):
2871
+ pr_num = future_to_pr[future]
2872
+ try:
2873
+ pr_num, details, files, hunks = future.result()
2874
+ pr_details[pr_num] = details
2875
+ pr_hunks[pr_num] = hunks
2876
+ pr_files = {str(f.get("filename")) for f in files if f.get("filename")}
2877
+ for filename_str in pr_files:
2878
+ file_to_prs[filename_str].add(pr_num)
2879
+ except Exception as e:
2880
+ raise CLIError(f"Failed to fetch info for PR #{pr_num}: {e}")
2823
2881
 
2824
2882
  overlapping_files: Dict[str, List[int]] = {
2825
2883
  f: sorted(list(prs)) for f, prs in file_to_prs.items() if len(prs) > 1
@@ -1,7 +1,7 @@
1
1
  # pylint: disable=invalid-name,line-too-long,missing-docstring,no-value-for-parameter
2
2
  import sys
3
3
  from collections import defaultdict
4
- from typing import Any, Dict, List
4
+ from typing import Any, Dict, List, Set, Tuple
5
5
 
6
6
  import click
7
7
  from dev_tools.services.github import GitHubClient
@@ -57,17 +57,17 @@ def get_pr_overlaps(github: GitHubClient, limit: int) -> List[Dict[str, Any]]:
57
57
  # We use a simple disjoint-set or just group by overlap.
58
58
  # For PR consolidation, we want to see pairs/groups that have overlaps.
59
59
 
60
- overlaps: Dict[tuple, set] = defaultdict(set)
60
+ overlaps: Dict[Tuple[int, ...], Set[str]] = defaultdict(set)
61
61
  for filename, pr_set in file_to_prs.items():
62
62
  if len(pr_set) > 1:
63
63
  pr_tuple_key = tuple(sorted(list(pr_set)))
64
64
  overlaps[pr_tuple_key].add(filename)
65
65
 
66
66
  clusters: List[Dict[str, Any]] = []
67
- for pr_tuple_key, files in overlaps.items():
67
+ for pr_tuple_key, overlapping_files in overlaps.items():
68
68
  clusters.append({
69
69
  "prs": list(pr_tuple_key),
70
- "files": sorted(list(files)),
70
+ "files": sorted(list(overlapping_files)),
71
71
  "metadata": {num: pr_metadata[num] for num in pr_tuple_key}
72
72
  })
73
73
 
@@ -147,7 +147,7 @@ class AIClient:
147
147
  url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent"
148
148
  headers = {"Content-Type": "application/json", "x-goog-api-key": self.gemini_api_key}
149
149
 
150
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
150
+ payload: Dict[str, Any] = {"contents": [{"parts": [{"text": prompt}]}]}
151
151
 
152
152
  if schema:
153
153
  payload["generationConfig"] = {
@@ -366,8 +366,8 @@ class AIClient:
366
366
  needs_specialist = bool(triage_data.get("needsSpecialistReview", True))
367
367
  fast_feedback_text = triage_data.get("fastFeedback", "")
368
368
  log_info(f"[AI Triage Success] Triage complete. Needs specialist: {needs_specialist}. Model: {triage_model_used}")
369
- except Exception as err:
370
- log_warn(f"⚠️ Triage Agent failed, falling back to Specialist review: {err}")
369
+ except Exception as e:
370
+ log_warn(f"⚠️ Triage Agent failed, falling back to Specialist review: {e}")
371
371
 
372
372
  if not needs_specialist and fast_feedback_text:
373
373
  log_info("[AI Triage] Fast triage review sufficient. Bypassing specialist review.")
@@ -500,7 +500,8 @@ class AIClient:
500
500
  # CI guard: never approve if checks are failing
501
501
  if has_ci_failures and final.get("recommendation") == "Approved":
502
502
  final["recommendation"] = "Not Approved"
503
- final["reviewComment"] = f"CI checks are failing ({failing_names}). Recommendation downgraded.\n\n" + (final.get("reviewComment") or "")
503
+ final_comment = final.get("reviewComment") or ""
504
+ final["reviewComment"] = f"CI checks are failing ({failing_names}). Recommendation downgraded.\n\n{final_comment}"
504
505
 
505
506
  self._write_review_file(pr_num, pr, final, chunks, file_reviews)
506
507
  return final
@@ -3,17 +3,16 @@ from collections import deque
3
3
  import json
4
4
  import os
5
5
  import subprocess
6
- from typing import Dict, List, Set
6
+ from typing import Any, Dict, List, Set
7
7
 
8
8
  from dev_tools.utils import CLIError, log_error, log_warn
9
9
 
10
-
11
10
  def validate_and_sanitize_graph_data(data: dict) -> dict:
12
11
  """Validates and sanitizes the dependency graph data structure."""
13
12
  if not isinstance(data, dict):
14
13
  raise ValueError("Graph data must be a dictionary")
15
14
 
16
- sanitized = {}
15
+ sanitized: Dict[str, Any] = {}
17
16
  if "modules" in data:
18
17
  if not isinstance(data["modules"], list):
19
18
  raise ValueError("modules must be a list")
@@ -21,7 +20,7 @@ def validate_and_sanitize_graph_data(data: dict) -> dict:
21
20
  for mod in data["modules"]:
22
21
  if not isinstance(mod, dict):
23
22
  raise ValueError("Module must be a dictionary")
24
- sanitized_mod = {}
23
+ sanitized_mod: Dict[str, Any] = {}
25
24
  if "source" in mod:
26
25
  if not isinstance(mod["source"], str):
27
26
  raise ValueError("source must be a string")
@@ -33,7 +32,7 @@ def validate_and_sanitize_graph_data(data: dict) -> dict:
33
32
  for dep in mod["dependencies"]:
34
33
  if not isinstance(dep, dict):
35
34
  raise ValueError("Dependency must be a dictionary")
36
- sanitized_dep = {}
35
+ sanitized_dep: Dict[str, Any] = {}
37
36
  if "resolved" in dep:
38
37
  if not isinstance(dep["resolved"], str):
39
38
  raise ValueError("resolved must be a string")
@@ -125,20 +124,21 @@ class DependencyGraph:
125
124
  def _parse_modules(self, modules: List[Dict]):
126
125
  for mod in modules:
127
126
  source = mod.get("source")
128
- deps = mod.get("dependencies", [])
129
- self.graph[source] = deps
127
+ if isinstance(source, str):
128
+ deps = mod.get("dependencies", [])
129
+ self.graph[source] = deps
130
130
 
131
- for dep in deps:
132
- resolved = dep.get("resolved")
133
- if resolved:
134
- if resolved not in self.reverse_graph:
135
- self.reverse_graph[resolved] = []
136
- self.reverse_graph[resolved].append(source)
131
+ for dep in deps:
132
+ resolved = dep.get("resolved")
133
+ if isinstance(resolved, str):
134
+ if resolved not in self.reverse_graph:
135
+ self.reverse_graph[resolved] = []
136
+ self.reverse_graph[resolved].append(source)
137
137
 
138
138
  def get_dependencies(self, filepath: str) -> List[str]:
139
139
  """Returns files that the given file depends on."""
140
140
  deps = self.graph.get(filepath, [])
141
- return [d.get("resolved") for d in deps if d.get("resolved")]
141
+ return [str(d.get("resolved")) for d in deps if isinstance(d.get("resolved"), str)]
142
142
 
143
143
  def get_dependents(self, filepath: str) -> List[str]:
144
144
  """Returns files that depend on the given file."""
@@ -8,7 +8,7 @@ import sys
8
8
  import time
9
9
  import urllib.parse
10
10
  from pathlib import Path
11
- from typing import Any, Dict, List, Optional, Union
11
+ from typing import Any, Dict, List, Optional, Union, Literal, overload
12
12
 
13
13
  import requests # type: ignore[import-untyped]
14
14
 
@@ -60,7 +60,7 @@ def run_git_commands(
60
60
  commands: List[List[str]], cwd: Optional[str] = None
61
61
  ) -> List[Union[str, subprocess.CompletedProcess[str]]]:
62
62
  """Executes a sequence of git commands."""
63
- results = []
63
+ results: List[Union[str, subprocess.CompletedProcess[str]]] = []
64
64
  for cmd in commands:
65
65
  results.append(run_command(cmd, cwd=cwd))
66
66
  return results
@@ -712,6 +712,28 @@ def call_ai_service(
712
712
  return None
713
713
 
714
714
 
715
+ @overload
716
+ def run_command(
717
+ cmd: Union[str, List[str]],
718
+ shell: bool = False,
719
+ check: Literal[True] = True,
720
+ input_str: Optional[str] = None,
721
+ log_on_error: bool = True,
722
+ **kwargs: Any,
723
+ ) -> str: ...
724
+
725
+
726
+ @overload
727
+ def run_command(
728
+ cmd: Union[str, List[str]],
729
+ shell: bool = False,
730
+ check: Literal[False] = False,
731
+ input_str: Optional[str] = None,
732
+ log_on_error: bool = True,
733
+ **kwargs: Any,
734
+ ) -> subprocess.CompletedProcess[str]: ...
735
+
736
+
715
737
  def run_command(
716
738
  cmd: Union[str, List[str]],
717
739
  shell: bool = False,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "boomtick-cli"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "BoomTick CLI — agentic dev-tools for GitHub + AI workflows"
9
9
  authors = [{name = "Ariel Anders"}]
10
10
  readme = "README.md"
@@ -0,0 +1,83 @@
1
+ # pylint: disable=missing-docstring,protected-access,redefined-outer-name
2
+ import json
3
+ from unittest.mock import patch, MagicMock
4
+
5
+ import pytest
6
+ from click.testing import CliRunner
7
+ from dev_tools.cli import cli
8
+ from dev_tools.orchestrator import Orchestrator
9
+
10
+
11
+ @pytest.fixture
12
+ def orchestrator():
13
+ with patch("dev_tools.orchestrator.GitHubClient") as mock_gh:
14
+ orch = Orchestrator()
15
+ orch._github = mock_gh.return_value
16
+ yield orch
17
+
18
+
19
+ def test_orchestrator_detect_conflicts_specific_pr(orchestrator):
20
+ with patch.object(orchestrator, "detect_conflicts") as mock_detect:
21
+ mock_detect.return_value = {(1, 2): ["shared.py"]}
22
+ res = orchestrator.handle_detect_conflicts(pr_num=1)
23
+ mock_detect.assert_called_once_with(1)
24
+ assert len(res) == 1
25
+ assert res[0]["prs"] == [1, 2]
26
+ assert res[0]["files"] == ["shared.py"]
27
+
28
+
29
+ def test_orchestrator_detect_conflicts_all_prs(orchestrator):
30
+ with patch.object(orchestrator, "detect_conflicts") as mock_detect:
31
+ mock_detect.return_value = {(1, 2): ["shared.py"], (3, 4): ["other.py"]}
32
+ res = orchestrator.handle_detect_conflicts(all_prs=True)
33
+ mock_detect.assert_called_once_with(None)
34
+ assert len(res) == 2
35
+
36
+
37
+ def test_cli_detect_conflicts_all():
38
+ runner = CliRunner()
39
+
40
+ with patch("dev_tools.cli.LazyOrchestrator") as mock_lazy:
41
+ mock_orch = MagicMock()
42
+ mock_lazy.return_value = mock_orch
43
+
44
+ # Mock handle_detect_conflicts response
45
+ mock_orch.handle_detect_conflicts.return_value = [
46
+ {"prs": [1, 2], "files": ["shared.py"]}
47
+ ]
48
+
49
+ # Invoke CLI command in JSON mode (default)
50
+ result = runner.invoke(cli, ["gh", "detect-conflicts", "--all"])
51
+
52
+ assert result.exit_code == 0
53
+ data = json.loads(result.output)
54
+ assert data["status"] == "success"
55
+ assert len(data["conflicts"]) == 1
56
+ assert data["conflicts"][0]["prs"] == [1, 2]
57
+ mock_orch.handle_detect_conflicts.assert_called_once_with(pr_num=None, all_prs=True)
58
+
59
+
60
+ def test_cli_detect_conflicts_specific_pr():
61
+ runner = CliRunner()
62
+
63
+ with patch("dev_tools.cli.LazyOrchestrator") as mock_lazy:
64
+ mock_orch = MagicMock()
65
+ mock_lazy.return_value = mock_orch
66
+
67
+ mock_orch.handle_detect_conflicts.return_value = []
68
+
69
+ result = runner.invoke(cli, ["gh", "detect-conflicts", "--pr", "123"])
70
+
71
+ assert result.exit_code == 0
72
+ data = json.loads(result.output)
73
+ assert data["status"] == "success"
74
+ mock_orch.handle_detect_conflicts.assert_called_once_with(pr_num=123, all_prs=False)
75
+
76
+
77
+ def test_cli_detect_conflicts_mutually_exclusive():
78
+ runner = CliRunner()
79
+
80
+ result = runner.invoke(cli, ["gh", "detect-conflicts", "--pr", "123", "--all"])
81
+
82
+ assert result.exit_code != 0
83
+ assert "Provide either --pr or --all, not both" in result.output
@@ -0,0 +1,75 @@
1
+ # pylint: disable=missing-docstring,protected-access,redefined-outer-name
2
+ import json
3
+ from unittest.mock import patch, MagicMock
4
+
5
+ from click.testing import CliRunner
6
+ from dev_tools.cli import cli
7
+
8
+
9
+ def test_cli_issue_view_positional():
10
+ runner = CliRunner()
11
+
12
+ with patch("dev_tools.cli.LazyOrchestrator") as mock_lazy:
13
+ mock_orch = MagicMock()
14
+ mock_lazy.return_value = mock_orch
15
+
16
+ # Mock get_issue_details response
17
+ mock_orch.get_issue_details.return_value = {
18
+ "number": 123,
19
+ "title": "Bug in issue-view",
20
+ "html_url": "https://github.com/owner/repo/issues/123",
21
+ "state": "open",
22
+ "body": "This is a bug report body.",
23
+ }
24
+
25
+ # Invoke CLI with positional argument
26
+ result = runner.invoke(cli, ["gh", "issue-view", "123"])
27
+
28
+ assert result.exit_code == 0
29
+ data = json.loads(result.output)
30
+ assert data["status"] == "success"
31
+ assert data["issue"]["number"] == 123
32
+ assert data["issue"]["title"] == "Bug in issue-view"
33
+ mock_orch.get_issue_details.assert_called_once_with(123)
34
+
35
+
36
+ def test_cli_issue_view_flag():
37
+ runner = CliRunner()
38
+
39
+ with patch("dev_tools.cli.LazyOrchestrator") as mock_lazy:
40
+ mock_orch = MagicMock()
41
+ mock_lazy.return_value = mock_orch
42
+
43
+ mock_orch.get_issue_details.return_value = {
44
+ "number": 123,
45
+ "title": "Bug in issue-view",
46
+ "html_url": "https://github.com/owner/repo/issues/123",
47
+ "state": "open",
48
+ "body": "This is a bug report body.",
49
+ }
50
+
51
+ # Invoke CLI with --issue-number flag
52
+ result = runner.invoke(cli, ["gh", "issue-view", "--issue-number", "123"])
53
+
54
+ assert result.exit_code == 0
55
+ data = json.loads(result.output)
56
+ assert data["status"] == "success"
57
+ assert data["issue"]["number"] == 123
58
+ mock_orch.get_issue_details.assert_called_once_with(123)
59
+
60
+
61
+ def test_cli_issue_view_missing():
62
+ runner = CliRunner()
63
+
64
+ with patch("dev_tools.cli.LazyOrchestrator") as mock_lazy:
65
+ mock_orch = MagicMock()
66
+ mock_lazy.return_value = mock_orch
67
+
68
+ # Invoke CLI with no arguments
69
+ result = runner.invoke(cli, ["gh", "issue-view"])
70
+
71
+ assert result.exit_code != 0
72
+ data = json.loads(result.output)
73
+ assert data["status"] == "error"
74
+ assert "Provide --issue-number or a positional issue number" in data["message"]
75
+ mock_orch.get_issue_details.assert_not_called()
@@ -83,6 +83,16 @@ class TestLabels(unittest.TestCase):
83
83
  self.orch.github.add_labels.assert_called_once_with(123, ["l1"])
84
84
  self.orch.github.update_issue.assert_called_once_with(123, body="new body", state=None)
85
85
 
86
+ def test_orchestrator_update_issue_add_labels_returns_list(self):
87
+ # Reset setup mocks and make add_labels return a list of labels (as GitHub API actually does)
88
+ self.orch.github.add_labels.return_value = [{"name": "l1"}]
89
+ # When update_issue detects a list or non-IssueSummary dictionary, it fetches full issue details
90
+ res = self.orch.update_issue(123, add_labels=["l1"])
91
+ self.orch.github.add_labels.assert_called_once_with(123, ["l1"])
92
+ self.orch.github.fetch_issue_details.assert_called_once_with(123)
93
+ self.assertEqual(res["status"], "success")
94
+ self.assertEqual(res["issue"]["number"], 123)
95
+
86
96
 
87
97
  if __name__ == "__main__":
88
98
  unittest.main()
@@ -19,8 +19,10 @@ def test_disk_cache_basic(tmp_path):
19
19
  assert cache.get(key) == val
20
20
 
21
21
  # Test expiration
22
- cache.set(key, val, ttl=0.1)
22
+ cache.set(key, val, ttl=5.0)
23
23
  assert cache.get(key) == val
24
+
25
+ cache.set(key, val, ttl=0.1)
24
26
  time.sleep(0.2)
25
27
  assert cache.get(key) is None
26
28
 
@@ -1,4 +1,5 @@
1
1
  # pylint: disable=missing-docstring,protected-access,redefined-outer-name
2
+ import json
2
3
  from unittest.mock import patch, MagicMock
3
4
 
4
5
  import pytest
@@ -67,7 +68,6 @@ def test_cli_search_issues():
67
68
  result = runner.invoke(cli, ["gh", "search-issues", "--state", "open", "--limit", "10", "--labels", "bug,high"])
68
69
 
69
70
  assert result.exit_code == 0
70
- import json
71
71
  data = json.loads(result.output)
72
72
  assert data["status"] == "success"
73
73
  assert len(data["issues"]) == 1
File without changes
File without changes
File without changes