orbi-cli 0.7.5__tar.gz → 0.7.6__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 (186) hide show
  1. {orbi_cli-0.7.5/src/orbi_cli.egg-info → orbi_cli-0.7.6}/PKG-INFO +1 -1
  2. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/pyproject.toml +1 -1
  3. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/__init__.py +1 -1
  4. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/failure.py +156 -40
  5. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/failure_report.py +4 -0
  6. orbi_cli-0.7.6/src/orbi/gh_response.py +220 -0
  7. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/github.py +24 -30
  8. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/gitops.py +51 -8
  9. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/journal.py +42 -2
  10. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/language.py +21 -14
  11. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/release.py +25 -14
  12. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/release_comments.py +5 -1
  13. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/runner.py +1 -0
  14. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/suggest.py +10 -1
  15. {orbi_cli-0.7.5 → orbi_cli-0.7.6/src/orbi_cli.egg-info}/PKG-INFO +1 -1
  16. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi_cli.egg-info/SOURCES.txt +2 -0
  17. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_base_branch_failure.py +169 -18
  18. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_adapters.py +10 -4
  19. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_delivery.py +1 -0
  20. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_release.py +117 -36
  21. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_failure.py +240 -35
  22. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_gh_read_retry.py +218 -61
  23. orbi_cli-0.7.6/tests/test_gh_response.py +243 -0
  24. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_git_network_retry.py +53 -0
  25. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_github.py +41 -32
  26. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_gitops.py +80 -1
  27. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_gitops_fakes.py +20 -2
  28. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_journal.py +84 -0
  29. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_suggest.py +54 -0
  30. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/LICENSE +0 -0
  31. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/README.md +0 -0
  32. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/docs/licenses/sustainable-use-license.md +0 -0
  33. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/setup.cfg +0 -0
  34. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/blocked_comment.py +0 -0
  35. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/branch_reclaim.py +0 -0
  36. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/checks.py +0 -0
  37. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/claim.py +0 -0
  38. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/clarify.py +0 -0
  39. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/cli.py +0 -0
  40. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/cli_source.py +0 -0
  41. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/comment_kind.py +0 -0
  42. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/config.py +0 -0
  43. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/delivery_comment.py +0 -0
  44. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/delivery_labels.py +0 -0
  45. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/delivery_scene.py +0 -0
  46. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/engine_source.py +0 -0
  47. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/example_config.toml +0 -0
  48. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/git_identity.py +0 -0
  49. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/git_transport.py +0 -0
  50. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/human_review.py +0 -0
  51. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/language_terms.py +0 -0
  52. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/launchd_deploy.py +0 -0
  53. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/merge_gate.py +0 -0
  54. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/merge_handoff.py +0 -0
  55. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/milestone.py +0 -0
  56. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/milestone_command.py +0 -0
  57. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/notify.py +0 -0
  58. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_activity.py +0 -0
  59. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_command.py +0 -0
  60. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_process.py +0 -0
  61. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_recovery.py +0 -0
  62. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_session.py +0 -0
  63. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pilot_setup.py +0 -0
  64. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pilot_slots.py +0 -0
  65. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/progress.py +0 -0
  66. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/progress_display.py +0 -0
  67. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/progress_outcome.py +0 -0
  68. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/progress_summary.py +0 -0
  69. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/release_git.py +0 -0
  70. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/release_notes.py +0 -0
  71. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/repo_config.py +0 -0
  72. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/resume_scan.py +0 -0
  73. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/review_merge.py +0 -0
  74. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/review_rounds.py +0 -0
  75. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/run_state.py +0 -0
  76. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/runner_health.py +0 -0
  77. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/scene.py +0 -0
  78. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/scheduler.py +0 -0
  79. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/scheduler_units.py +0 -0
  80. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/session_command.py +0 -0
  81. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/source_pool.py +0 -0
  82. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/status_observe.py +0 -0
  83. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/systemd_deploy.py +0 -0
  84. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/ticket_command.py +0 -0
  85. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi_cli.egg-info/dependency_links.txt +0 -0
  86. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi_cli.egg-info/entry_points.txt +0 -0
  87. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi_cli.egg-info/top_level.txt +0 -0
  88. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_agents_md.py +0 -0
  89. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_apply_label_patch.py +0 -0
  90. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_blocked_comment.py +0 -0
  91. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_pi.py +0 -0
  92. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_preflight.py +0 -0
  93. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_runner.py +0 -0
  94. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_branch_reclaim.py +0 -0
  95. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ci_failure_issue_workflow.py +0 -0
  96. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ci_failure_triage.py +0 -0
  97. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ci_macos_workflow.py +0 -0
  98. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ci_workflow.py +0 -0
  99. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_clarify.py +0 -0
  100. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_claude_plugin.py +0 -0
  101. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_cli_install.py +0 -0
  102. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_cli_milestone.py +0 -0
  103. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_cli_packaging.py +0 -0
  104. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_cli_source.py +0 -0
  105. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_comment_identity.py +0 -0
  106. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_comment_readers_contract.py +0 -0
  107. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_concurrency_e2e.py +0 -0
  108. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_constitution.py +0 -0
  109. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_coverage_gate.py +0 -0
  110. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_deliver_pr_fakes.py +0 -0
  111. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_delivery_branch_delete.py +0 -0
  112. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_delivery_labels.py +0 -0
  113. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_delivery_scene.py +0 -0
  114. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docker_docs.py +0 -0
  115. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docker_entrypoint.py +0 -0
  116. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docker_image_workflow.py +0 -0
  117. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_i18n.py +0 -0
  118. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_issue_shapes.py +0 -0
  119. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_labels.py +0 -0
  120. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_mermaid.py +0 -0
  121. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_releases.py +0 -0
  122. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_single_source.py +0 -0
  123. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_site.py +0 -0
  124. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_engine_source.py +0 -0
  125. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_failure_report.py +0 -0
  126. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_fix_needed_loop.py +0 -0
  127. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_git_base_smoke.py +0 -0
  128. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_git_merge_smoke.py +0 -0
  129. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_git_transport.py +0 -0
  130. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_github_fakes.py +0 -0
  131. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_human_review.py +0 -0
  132. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_import_graph.py +0 -0
  133. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_install_sh.py +0 -0
  134. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_issue_1367.py +0 -0
  135. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_issue_461.py +0 -0
  136. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_issue_template_config.py +0 -0
  137. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_journal_events.py +0 -0
  138. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_launchd_deploy.py +0 -0
  139. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_license.py +0 -0
  140. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_message_language.py +0 -0
  141. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_milestone_command.py +0 -0
  142. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_notify.py +0 -0
  143. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ops_e2e.py +0 -0
  144. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_orbi.py +0 -0
  145. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_orbi_dashboard.py +0 -0
  146. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_orbi_exporter.py +0 -0
  147. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_patch_ratchet.py +0 -0
  148. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_activity.py +0 -0
  149. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_command.py +0 -0
  150. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_extensions.py +0 -0
  151. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_provider_templates.py +0 -0
  152. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_recovery.py +0 -0
  153. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_session.py +0 -0
  154. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pilot_setup.py +0 -0
  155. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pilot_slots.py +0 -0
  156. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pr_issue_link_workflow.py +0 -0
  157. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_prerequisite_check.py +0 -0
  158. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_progress.py +0 -0
  159. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_progress_wiring.py +0 -0
  160. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_prompt_contract.py +0 -0
  161. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_public_contracts.py +0 -0
  162. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_publish_workflow.py +0 -0
  163. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_release_docs_workflow.py +0 -0
  164. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_release_ticket_templates.py +0 -0
  165. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_repo_config.py +0 -0
  166. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_resume_continue_e2e.py +0 -0
  167. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_resume_e2e.py +0 -0
  168. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_resume_pr.py +0 -0
  169. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_review_merge.py +0 -0
  170. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_run_artifacts.py +0 -0
  171. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_run_id_e2e.py +0 -0
  172. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_runner_config.py +0 -0
  173. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_runner_health.py +0 -0
  174. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_runner_source_freshness.py +0 -0
  175. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_scene.py +0 -0
  176. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_scheduler.py +0 -0
  177. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_size_ratchet.py +0 -0
  178. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_src_layout.py +0 -0
  179. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_status_observe.py +0 -0
  180. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_steering_review.py +0 -0
  181. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_suite_hygiene.py +0 -0
  182. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_systemd_deploy.py +0 -0
  183. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_systemd_timer.py +0 -0
  184. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ticket_command.py +0 -0
  185. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_user_acceptance_contract.py +0 -0
  186. {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_worktree_reclaim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orbi-cli
3
- Version: 0.7.5
3
+ Version: 0.7.6
4
4
  Summary: Orbi — local AI development worker: claims GitHub Issues, runs Pi in isolated worktrees, and delivers PRs (GitHub Issues + labels are the only state store).
5
5
  Author: xqliu
6
6
  License-Expression: AGPL-3.0-only OR LicenseRef-Sustainable-Use-1.0
@@ -44,7 +44,7 @@ build-backend = "setuptools.build_meta"
44
44
 
45
45
  [project]
46
46
  name = "orbi-cli"
47
- version = "0.7.5"
47
+ version = "0.7.6"
48
48
  description = "Orbi — local AI development worker: claims GitHub Issues, runs Pi in isolated worktrees, and delivers PRs (GitHub Issues + labels are the only state store)."
49
49
  readme = "README.md"
50
50
  requires-python = ">=3.14"
@@ -18,4 +18,4 @@ the package would shadow the installed package for every process with
18
18
  the checkout root on sys.path. The direct-execution compatibility entry
19
19
  is `python3 -m orbi.cli` (development path only).
20
20
  """
21
- __version__ = "0.7.5"
21
+ __version__ = "0.7.6"
@@ -16,14 +16,17 @@ ignored (`parse` returns `None`) instead of failing the resume path.
16
16
  new code is a deliberate contract change here and in `docs/workflow.mdx`
17
17
  (EN + ZH); `classify` maps an exception to them and an unknown one to
18
18
  `unclassified`, never to a missing block, so the prose cannot drift.
19
+ A failure that knows its reason at the source carries the record itself
20
+ (`BaseBranchMissingError`); the reporter uses it as it is.
19
21
  """
20
22
  from __future__ import annotations
21
23
 
22
24
  import dataclasses
23
25
  import json
24
26
  import re
27
+ import subprocess
25
28
 
26
- from orbi import language
29
+ from orbi import gh_response, language
27
30
 
28
31
  SCHEMA_VERSION = 1
29
32
 
@@ -45,7 +48,10 @@ REASON_CODES = frozenset({
45
48
  # A transient GitHub API/server failure (5xx, secondary rate limit,
46
49
  # `GraphQL: Something went wrong`): the same operation may succeed.
47
50
  "github_transient",
48
- # The delivery's own tests failed and the code must change.
51
+ # Legacy only (Issue #1759): nothing in production raises it any
52
+ # more. The runner runs no tests of its own, and a red CI check is
53
+ # routed by the `GateCIFailure` type. It stays in the closed set so
54
+ # an already-written comment still parses; `classify` never produces it.
49
55
  "tests_failed",
50
56
  # The bounded review/fix budget is spent — a human decision.
51
57
  "review_budget_exhausted",
@@ -91,11 +97,42 @@ class Failure:
91
97
  outcome: str = "blocked"
92
98
  schema: int = SCHEMA_VERSION
93
99
 
100
+ def with_outcome(self, outcome: str) -> Failure:
101
+ """The same record for another delivery outcome.
102
+
103
+ A failure that decided its reason at the source (Issue #1756)
104
+ carries the record already; the caller only supplies the
105
+ outcome, never a re-derivation from the message.
106
+ """
107
+ return dataclasses.replace(self, outcome=outcome)
108
+
94
109
 
95
110
  class FailureError(ValueError):
96
111
  """A failure block is present in the body but cannot be parsed."""
97
112
 
98
113
 
114
+ class ReleaseGateWaitTimeout(RuntimeError):
115
+ """A release gate spent its bounded wait budget without a decision.
116
+
117
+ The release state machine reports this wait as `blocked`: the budget
118
+ is bounded on purpose, so the next step is a human's, never the
119
+ automatic requeue a transient GitHub failure gets (Issue #1760). The
120
+ classification reads THIS type, never the words of the message.
121
+ """
122
+
123
+
124
+ class GitHubPermissionDenied(RuntimeError):
125
+ """A GitHub 403 with quota left: the credential lacks the permission.
126
+
127
+ The release gate receives that status from `gh_response` but reports
128
+ it with its own reader-facing sentence (the one naming the missing
129
+ `Checks:read` scope), so the meaning travels on the TYPE: `classify`
130
+ reads the type, never the sentence — which is why the broad
131
+ `credential` word could be dropped from the text markers without
132
+ losing the permission repair (Issue #1760).
133
+ """
134
+
135
+
99
136
  def render(record: Failure) -> str:
100
137
  """Render the single hidden v1 block for one failure.
101
138
 
@@ -206,12 +243,20 @@ _PROVIDER_QUOTA_RE = re.compile(
206
243
  r"\b429\b|quota|resource_exhausted|retry in|usage limit has been reached",
207
244
  re.IGNORECASE,
208
245
  )
209
- # The word markers of a provider authentication failure. `pi_process`'s
210
- # early-exit classifier (`_classify_startup_exit`) matches this SAME
211
- # tuple (plus the 401/403 status codes), so the two classifications
212
- # cannot drift into two lists (Issue #1465).
246
+ # The word markers `pi_process`'s early-exit classifier
247
+ # (`_classify_startup_exit`) matches, plus the 401/403 status codes: the
248
+ # provider's own words stay the Pi startup classification's (Issue #1465),
249
+ # and `failure` reads them from the one regex below.
213
250
  PROVIDER_AUTH_WORDS = ("unauthorized", "forbidden", "api key")
214
251
 
252
+ # The text-only credential markers. GitHub failures no longer read this
253
+ # one: they carry the response status (`gh_response`), which is exact,
254
+ # while `forbidden` and `credential` were broad enough to match a branch
255
+ # or check name inside the command's argv (Issue #1760). The remaining
256
+ # markers serve the Pi / model-provider detail, which only gives text
257
+ # (Issue #1465): its 401 shapes, its rejected-key sentences and
258
+ # `unauthorized`; `forbidden` stays in `PROVIDER_AUTH_WORDS` for
259
+ # `pi_process`'s own startup classifier, which is not this regex.
215
260
  _CREDENTIAL_MISSING_RE = re.compile(
216
261
  r"bad credentials"
217
262
  r"|(?:http|status)\s+401\b"
@@ -221,8 +266,7 @@ _CREDENTIAL_MISSING_RE = re.compile(
221
266
  r"|(?:^|[\s\"'(])401:"
222
267
  r"|authentication (?:failed|required|fails)"
223
268
  r"|incorrect api key"
224
- r"|" + "|".join(re.escape(word) for word in PROVIDER_AUTH_WORDS)
225
- + r"|credential",
269
+ r"|unauthorized",
226
270
  re.IGNORECASE,
227
271
  )
228
272
  # The explicit GitHub rejection when a push creates or updates an
@@ -244,23 +288,34 @@ _WORKFLOW_PERMISSION_RE = re.compile(
244
288
  # terminal progress scene and the short milestone; only the last
245
289
  # sentence differs by whether a delivery PR already exists.
246
290
  _WORKFLOW_PERMISSION_CAUSE_KEY = "failure.workflow_cause"
247
- _TESTS_FAILED_RE = re.compile(
248
- r"\btests? failed\b|\d+ failed", re.IGNORECASE,
249
- )
250
- # Git's answer to `git fetch origin <branch>` when the remote has no such
251
- # branch (Issue #1749): the two wordings real Git emits, and nothing else —
252
- # a generic fetch failure keeps its existing classification.
253
- _BASE_BRANCH_MISSING_RE = re.compile(
254
- r"could ?n['o]t find remote ref", re.IGNORECASE,
255
- )
291
+ # The remaining text-only transient markers: network-level failures and
292
+ # GitHub's own server sentences. This set names no timeout — a GitHub
293
+ # request reports its status, and a real transport timeout IS a
294
+ # `subprocess.TimeoutExpired` (Issue #1760).
256
295
  _GITHUB_TRANSIENT_RE = re.compile(
257
296
  r"http 5\d\d|http 429|rate limit|"
258
297
  r"something went wrong while executing your query|"
259
298
  r"internal server error|connection reset|connection refused|"
260
- r"temporary failure in name resolution|network is unreachable|"
261
- r"timed out|timeout",
299
+ r"temporary failure in name resolution|network is unreachable",
262
300
  re.IGNORECASE,
263
301
  )
302
+ # A non-GitHub subprocess — a Pi session, a Git fetch/push — reports a
303
+ # timeout in its own words and gives neither a status nor a timeout type:
304
+ # there the wording is all that exists (the same text-only carve-out the
305
+ # provider failure reasons keep, Issue #1465/#1760).
306
+ _TEXT_TIMEOUT_RE = re.compile(r"timed out|timeout", re.IGNORECASE)
307
+
308
+
309
+ def _timeout_is_text_only(error: BaseException | None) -> bool:
310
+ """Whether only the failure's text can say it was a timeout.
311
+
312
+ `subprocess.TimeoutExpired` IS a timeout (decided by type) and a
313
+ GitHub request reports its status, so neither reads the wording; a
314
+ non-gh subprocess (Pi, `git fetch`/`git push`) has only its output.
315
+ """
316
+ command = getattr(error, "cmd", None)
317
+ return isinstance(command, list) and command[:1] != ["gh"]
318
+
264
319
 
265
320
  # reason_code -> (action_code, retry_safe). `retry_safe` is whether a
266
321
  # plain re-queue can succeed WITHOUT a human repair: a transient GitHub
@@ -278,36 +333,87 @@ DISPOSITIONS: dict[str, tuple[str, bool]] = {
278
333
  }
279
334
 
280
335
 
336
+ class BaseBranchMissingError(RuntimeError):
337
+ """The configured base branch does not exist on the remote (Issue #1756).
338
+
339
+ Raised by the first delivery step `freeze_base` → `fetch_base_ref`
340
+ only after the fetch failed AND the remote itself answered
341
+ `git ls-remote --heads` with nothing: the fact is decided at the
342
+ source, so no consumer has to read Git's error text — Git localizes
343
+ it (`LANG` / `LC_MESSAGES`), and a text match would classify the
344
+ same failure differently on a non-English host.
345
+
346
+ `base_branch` is the CONFIGURED branch and `failure_record` is the
347
+ record the failure report uses as it is; the text classifier never
348
+ runs for this failure. `stderr` keeps the failed fetch's own output
349
+ as evidence: `_failure_detail` reads it into the run details, so the
350
+ reader still sees what Git said without it deciding anything
351
+ (Issues #1749, #1753).
352
+ """
353
+
354
+ def __init__(self, base_branch: str, *, stderr: str | None = None):
355
+ super().__init__(
356
+ f"base branch {base_branch!r} does not exist on origin",
357
+ )
358
+ self.base_branch = base_branch
359
+ self.stderr = stderr
360
+ action_code, retry_safe = DISPOSITIONS["base_branch_missing"]
361
+ self.failure_record = Failure(
362
+ reason_code="base_branch_missing",
363
+ action_code=action_code,
364
+ retry_safe=retry_safe,
365
+ )
366
+
367
+
281
368
  def classify(detail: str, *, outcome: str = "blocked",
282
369
  provider_quota: bool = False, review_budget: bool = False,
283
370
  human_decision: bool = False,
284
- unrecoverable: bool = False) -> Failure:
371
+ unrecoverable: bool = False,
372
+ error: BaseException | None = None) -> Failure:
285
373
  """Map one delivery exception to the machine-readable failure record.
286
374
 
287
375
  The caller supplies the type-derived flags (the runner owns the
288
- exception classes); an explicit type wins over the message markers,
289
- and every marker that is not recognized falls through to
290
- `unclassified`, so the block is never omitted. The mapping is the
291
- single source both the comment block and the reader-facing
292
- Action/Reason rendering use.
376
+ exception classes) and, when it has one, the failing exception itself
377
+ (`error`). Its TYPE and the HTTP response status it carries decide
378
+ BEFORE every text marker — a 401, a 429/5xx, a rate-limited 403, a
379
+ permission 403, a transport timeout and a bounded gate wait are read
380
+ from the failure itself, never from words that the command's argv
381
+ could also contain (Issue #1760). Every marker that is not recognized
382
+ falls through to `unclassified`, so the block is never omitted. The
383
+ mapping is the single source both the comment block and the
384
+ reader-facing Action/Reason rendering use.
293
385
  """
294
386
  if provider_quota:
295
387
  reason_code = "provider_quota"
296
388
  elif review_budget:
297
389
  reason_code = "review_budget_exhausted"
298
- elif human_decision:
390
+ elif human_decision or isinstance(error, ReleaseGateWaitTimeout):
391
+ # A bounded wait that ran out is a human's decision, never a
392
+ # transient failure to requeue (Issue #1760).
299
393
  reason_code = "human_decision_required"
394
+ elif isinstance(error, subprocess.TimeoutExpired):
395
+ # A transport timeout is transient by type, whatever it says.
396
+ reason_code = "github_transient"
397
+ elif gh_response.authentication_required(error):
398
+ reason_code = "credential_missing"
399
+ elif gh_response.transient(error):
400
+ reason_code = "github_transient"
401
+ elif gh_response.status_of(error) == 403 or isinstance(
402
+ error, GitHubPermissionDenied):
403
+ # A 403 with quota left is a permission failure: the same repair
404
+ # as a rejected credential. A caller that re-wraps that status in
405
+ # its own sentence (the release gate's missing Checks:read) keeps
406
+ # it on the type.
407
+ reason_code = "credential_missing"
300
408
  elif _WORKFLOW_PERMISSION_RE.search(detail):
301
409
  # The explicit App/workflows refusal is an authorization the AI
302
410
  # cannot grant: the existing `credential_missing` /
303
411
  # `check_credentials` category, a repair required before retry.
304
412
  reason_code = "credential_missing"
305
- elif _BASE_BRANCH_MISSING_RE.search(detail):
306
- reason_code = "base_branch_missing"
307
413
  elif _CREDENTIAL_MISSING_RE.search(detail):
308
414
  reason_code = "credential_missing"
309
- elif _TESTS_FAILED_RE.search(detail):
310
- reason_code = "tests_failed"
415
+ elif _timeout_is_text_only(error) and _TEXT_TIMEOUT_RE.search(detail):
416
+ reason_code = "github_transient"
311
417
  elif _GITHUB_TRANSIENT_RE.search(detail):
312
418
  reason_code = "github_transient"
313
419
  elif _PROVIDER_QUOTA_RE.search(detail):
@@ -370,19 +476,23 @@ def workflow_permission_action(
370
476
 
371
477
 
372
478
  def terminal_action(record: Failure,
373
- language_code: str) -> str:
479
+ language_code: str, *,
480
+ base_branch: str | None = None) -> str:
374
481
  """The reader action for the implement-phase terminal handler.
375
482
 
376
483
  An unrecognized failure says the cause could not be determined
377
484
  instead of implying the Issue text is the problem (Issue #1651); a
378
- missing base branch names its one repair (Issue #1749); every other
379
- recognized terminal failure keeps the existing generic action.
485
+ missing base branch names the branch and both repairs — create it, or
486
+ pick an existing one where the repository was set up — plus how to
487
+ restart the Issue (Issue #1749, #1753); every other recognized
488
+ terminal failure keeps the existing generic action.
380
489
  """
381
490
  if record.reason_code == "unclassified":
382
491
  return language.text(language_code, "failure.terminal_unclassified")
383
492
  if record.reason_code == "base_branch_missing":
384
493
  return language.text(
385
494
  language_code, "failure.terminal_base_branch_missing",
495
+ base_branch=base_branch or "-",
386
496
  )
387
497
  return language.text(language_code, "failure.terminal_generic")
388
498
 
@@ -391,15 +501,16 @@ def terminal_reason(detail: str, record: Failure, *,
391
501
  base_branch: str | None, language_code: str) -> str:
392
502
  """The reader-facing reason of the implement-phase terminal handler.
393
503
 
394
- The missing-base-branch failure names the CONFIGURED branch: Git's own
395
- `couldn't find remote ref <value>` line is the evidence, never the
396
- setting a human has to repair (Issue #1749). Every other terminal
504
+ The missing-base-branch failure names the CONFIGURED branch and keeps
505
+ Git's raw answer out of the sentence: the remote's own `git ls-remote`
506
+ probe is the evidence the run details carry, never the setting a human
507
+ has to repair (Issues #1749, #1753, #1756). Every other terminal
397
508
  failure keeps the generic stopped-delivery sentence.
398
509
  """
399
510
  if record.reason_code == "base_branch_missing":
400
511
  return language.text(
401
512
  language_code, "failure.base_branch_missing_reason",
402
- base_branch=base_branch or "-", detail=detail,
513
+ base_branch=base_branch or "-",
403
514
  )
404
515
  return language.text(
405
516
  language_code, "runner.delivery_stopped_reason", detail=detail,
@@ -465,8 +576,11 @@ def _failure_summary(reason: str,
465
576
  summary = re.sub(r"CalledProcessError\([^)]*\)",
466
577
  command_failed, summary)
467
578
  summary = _redact_local_paths(summary)
468
- summary = re.sub(r"\s+", " ", summary).strip(" .;:")
469
- if not summary:
579
+ # Trailing dots are kept: a catalog sentence ("... start from.")
580
+ # reaches the reader as written (Issue #1753), while a trailing
581
+ # separator from exception text is noise.
582
+ summary = re.sub(r"\s+", " ", summary).strip(" ;:")
583
+ if not summary.strip("."):
470
584
  summary = command_failed
471
585
  if stderr:
472
586
  # Setup tools often write informational lines before the provider's
@@ -480,7 +594,9 @@ def _failure_summary(reason: str,
480
594
  else stderr.rstrip().rsplit("\n", 1)[-1].strip()
481
595
  )
482
596
  if stderr_summary and stderr_summary not in summary:
483
- summary = f"{summary}: {stderr_summary}"
597
+ # The sentence separator never doubles the punctuation the
598
+ # summary already ends with.
599
+ summary = f"{summary.rstrip(' .;:')}: {stderr_summary}"
484
600
  return summary[:500]
485
601
 
486
602
 
@@ -135,12 +135,16 @@ def is_unrecoverable_failure(exc: BaseException) -> bool:
135
135
  # markers live in `orbi.failure` (Issue #1229 keeps this module small).
136
136
  def _classify_failure(exc: BaseException, *, outcome: str) -> failure.Failure:
137
137
  """Map one delivery exception to the machine-readable failure record."""
138
+ carried = getattr(exc, "failure_record", None)
139
+ if isinstance(carried, failure.Failure): # decided at the source (#1756)
140
+ return carried.with_outcome(outcome)
138
141
  return failure.classify(
139
142
  _failure_detail(exc),
140
143
  provider_quota=isinstance(exc, RateLimitExhaustedError),
141
144
  review_budget=isinstance(exc, ReviewRoundsExhausted),
142
145
  human_decision=isinstance(exc, HumanDecisionRequired),
143
146
  unrecoverable=isinstance(exc, UnrecoverableDeliveryError),
147
+ error=exc,
144
148
  outcome=outcome,
145
149
  )
146
150
 
@@ -0,0 +1,220 @@
1
+ """The response a failed `gh` request received, read from gh's own output.
2
+
3
+ A GitHub failure is classified from the HTTP status the response carries,
4
+ never from the error text: the detail of a `CalledProcessError` also holds
5
+ the command's argv, so a branch, label or check named `credential` or
6
+ `forbidden` could be read as a credential problem (Issue #1760).
7
+
8
+ `gh api --include` (documented: "Include HTTP response status line and
9
+ headers in the output") prints the status line and the response headers in
10
+ front of the body — for a failed request too, verified against gh 2.100.0:
11
+ a 403 prints `HTTP/2.0 403 Forbidden`, its headers and the error body on
12
+ stdout, writes one line to stderr and exits 1. `X-Ratelimit-Remaining` is
13
+ one of those headers, and a 403 with `0` remaining is GitHub's rate limit,
14
+ while a 403 with quota left is a permission failure. The status is
15
+ therefore read from the response itself.
16
+
17
+ The other structural source is gh's documented exit code: `gh help
18
+ exit-codes` says "If a command requires authentication, the exit code will
19
+ be 4", which covers the gh subcommands that print no response headers.
20
+
21
+ JSON output escapes newlines inside string values, so a line that starts
22
+ with a status line can only be gh's own header block — a body that merely
23
+ QUOTES an HTTP example keeps it inside one JSON string.
24
+
25
+ An empty body prints no line at all: gh's output is then the status line
26
+ and the headers, and the stripped stdout ends after the last header. That
27
+ block is still complete — the body it carries is the empty string.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ import subprocess
33
+
34
+ # gh's documented exit code for "requires authentication".
35
+ GITHUB_AUTH_EXIT_CODE = 4
36
+ INCLUDE_FLAG = "--include"
37
+
38
+ # gh subcommands other than `gh api` print no response status line, so
39
+ # their server-side failures keep GitHub's own sentences. Read only when
40
+ # the request carried NO status at all: a response status always wins.
41
+ _GH_SERVER_FAILURE_RE = re.compile(
42
+ r"http 5\d\d|http 429|rate limit|"
43
+ r"something went wrong while executing your query|internal server error",
44
+ re.IGNORECASE,
45
+ )
46
+ _STATUS_LINE_RE = re.compile(r"HTTP/\d+(?:\.\d+)? (\d{3})\b")
47
+ # RFC 9110 header field name followed by ":". Deliberately strict: a block
48
+ # only counts when every line up to the blank line is a header, so body
49
+ # text cannot be mistaken for response metadata.
50
+ _HEADER_LINE_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+.^_`|~-]+:")
51
+ _RATELIMIT_RE = re.compile(r"^x-ratelimit-remaining:\s*(\d+)\s*$", re.IGNORECASE)
52
+
53
+
54
+ class GitHubRequestFailed(subprocess.CalledProcessError):
55
+ """A failed gh request carrying the HTTP response it received.
56
+
57
+ `status` is the status the response itself reported, or None when the
58
+ request produced no response (a connection failure). A
59
+ `CalledProcessError` subclass, so every existing
60
+ `except subprocess.CalledProcessError` handler keeps its behaviour.
61
+ """
62
+
63
+ def __init__(self, returncode: int, cmd: list[str],
64
+ output: str | None = None, stderr: str | None = None, *,
65
+ status: int | None = None,
66
+ ratelimit_remaining: int | None = None) -> None:
67
+ super().__init__(returncode, cmd, output=output, stderr=stderr)
68
+ self.status = status
69
+ self.ratelimit_remaining = ratelimit_remaining
70
+
71
+ @property
72
+ def rate_limited(self) -> bool:
73
+ """GitHub's rate limit is a 403 with no quota left."""
74
+ return self.status == 403 and self.ratelimit_remaining == 0
75
+
76
+ @property
77
+ def authentication_required(self) -> bool:
78
+ """A rejected credential: an HTTP 401, or gh's auth exit code."""
79
+ return self.status == 401 or self.returncode == GITHUB_AUTH_EXIT_CODE
80
+
81
+
82
+ def include_headers(command: list[str]) -> list[str]:
83
+ """Return `command` asking `gh api` to print the response status line.
84
+
85
+ Only `gh api` prints response headers; every other gh subcommand keeps
86
+ its argv (its authentication failure is gh's own exit code).
87
+ """
88
+ if command[:2] != ["gh", "api"]:
89
+ return command
90
+ if INCLUDE_FLAG in command or "-i" in command:
91
+ return command
92
+ return [*command, INCLUDE_FLAG]
93
+
94
+
95
+ def _header_block_end(lines: list[str], start: int) -> int | None:
96
+ """Index of the first line after a header block, or None if not one.
97
+
98
+ A block also ends at the end of the output: gh prints no body line at
99
+ all when the response body is empty (a `--jq` filter that selects
100
+ nothing, a `204`), and the caller's stripped stdout then ends right
101
+ after the last header — without the trailing blank line. A status
102
+ line followed by header lines and nothing else is still gh's header
103
+ block, never body text (Issue #1760).
104
+ """
105
+ index = start + 1
106
+ if index >= len(lines) or not (
107
+ _HEADER_LINE_RE.match(lines[index]) or lines[index][:1] in " \t"
108
+ ):
109
+ return None
110
+ while index < len(lines):
111
+ line = lines[index]
112
+ if not line:
113
+ return index + 1
114
+ if not (_HEADER_LINE_RE.match(line) or line[:1] in " \t"):
115
+ return None
116
+ index += 1
117
+ return len(lines)
118
+
119
+
120
+ def response_parts(output: str | None) -> tuple[int | None, int | None, str]:
121
+ """Split gh's output into (status, x-ratelimit-remaining, body).
122
+
123
+ Every header block is removed from the body, including the ones gh
124
+ prints between the pages of a `--paginate` read (the JSON separator
125
+ shares the line with the next page's status line). The LAST block wins:
126
+ a failed paginated request ends on the response that failed.
127
+ """
128
+ lines = str(output or "").splitlines()
129
+ statuses: list[int] = []
130
+ remaining: int | None = None
131
+ body: list[str] = []
132
+ index = 0
133
+ while index < len(lines):
134
+ line = lines[index]
135
+ prefix = ""
136
+ candidate = line
137
+ if candidate[:1] in "[,":
138
+ prefix, candidate = candidate[:1], candidate[1:]
139
+ match = _STATUS_LINE_RE.match(candidate)
140
+ end = _header_block_end(lines, index) if match else None
141
+ if match is not None and end is not None:
142
+ statuses.append(int(match.group(1)))
143
+ for header in lines[index + 1:end]:
144
+ header_match = _RATELIMIT_RE.match(header)
145
+ if header_match:
146
+ remaining = int(header_match.group(1))
147
+ body.append(prefix)
148
+ index = end
149
+ continue
150
+ body.append(line)
151
+ index += 1
152
+ return (
153
+ statuses[-1] if statuses else None,
154
+ remaining,
155
+ "\n".join(body).strip(),
156
+ )
157
+
158
+
159
+ def failed_request(
160
+ exc: subprocess.CalledProcessError,
161
+ ) -> GitHubRequestFailed:
162
+ """Return `exc` carrying the status of the response it received.
163
+
164
+ An already structured error is returned unchanged, so the wrapper can
165
+ re-raise without losing the status.
166
+ """
167
+ if isinstance(exc, GitHubRequestFailed):
168
+ return exc
169
+ status, remaining, _ = response_parts(exc.stdout)
170
+ return GitHubRequestFailed(
171
+ exc.returncode, exc.cmd,
172
+ output=exc.output, stderr=exc.stderr,
173
+ # A 2xx status next to a non-zero exit is an incomplete read, not a
174
+ # failed request: it carries no failure status.
175
+ status=status if status is not None and status >= 400 else None,
176
+ ratelimit_remaining=remaining,
177
+ )
178
+
179
+
180
+ def transient_command(failure: GitHubRequestFailed) -> bool:
181
+ """Whether a failed gh command is worth retrying.
182
+
183
+ The response status decides whenever gh printed one — a permission
184
+ failure is never retried. A `gh` subcommand other than `gh api`
185
+ prints no response headers, so it keeps GitHub's own server
186
+ sentences: there is no status to read, and dropping that signal would
187
+ turn a GitHub-side 5xx on a plain scan read back into the crashed
188
+ tick the bounded retry exists to prevent (Issue #738). A retried
189
+ mutation stays guarded by the caller's `already_applied` check.
190
+ """
191
+ if failure.status is not None or failure.authentication_required:
192
+ return transient(failure)
193
+ detail = " ".join(
194
+ str(part or "") for part in (failure.stderr, failure.stdout))
195
+ return _GH_SERVER_FAILURE_RE.search(detail) is not None
196
+
197
+
198
+ def status_of(exc: BaseException | None) -> int | None:
199
+ """The HTTP status a failed gh request carried, or None."""
200
+ return exc.status if isinstance(exc, GitHubRequestFailed) else None
201
+
202
+
203
+ def authentication_required(exc: BaseException) -> bool:
204
+ """Whether the failed request was refused for its credential."""
205
+ return isinstance(exc, GitHubRequestFailed) and exc.authentication_required
206
+
207
+
208
+ def transient(exc: BaseException) -> bool:
209
+ """Whether the failed request is worth retrying.
210
+
211
+ Transient: a rate limit (403 with no quota left), 429 and every 5xx.
212
+ A rejected credential (401 / gh's auth exit code) and every other
213
+ client failure are not — retrying them cannot succeed.
214
+ """
215
+ if not isinstance(exc, GitHubRequestFailed):
216
+ return False
217
+ if exc.rate_limited:
218
+ return True
219
+ status = exc.status
220
+ return status is not None and (status == 429 or 500 <= status <= 599)