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.
- {orbi_cli-0.7.5/src/orbi_cli.egg-info → orbi_cli-0.7.6}/PKG-INFO +1 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/pyproject.toml +1 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/__init__.py +1 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/failure.py +156 -40
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/failure_report.py +4 -0
- orbi_cli-0.7.6/src/orbi/gh_response.py +220 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/github.py +24 -30
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/gitops.py +51 -8
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/journal.py +42 -2
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/language.py +21 -14
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/release.py +25 -14
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/release_comments.py +5 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/runner.py +1 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/suggest.py +10 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.6/src/orbi_cli.egg-info}/PKG-INFO +1 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi_cli.egg-info/SOURCES.txt +2 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_base_branch_failure.py +169 -18
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_adapters.py +10 -4
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_delivery.py +1 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_release.py +117 -36
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_failure.py +240 -35
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_gh_read_retry.py +218 -61
- orbi_cli-0.7.6/tests/test_gh_response.py +243 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_git_network_retry.py +53 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_github.py +41 -32
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_gitops.py +80 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_gitops_fakes.py +20 -2
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_journal.py +84 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_suggest.py +54 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/LICENSE +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/README.md +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/docs/licenses/sustainable-use-license.md +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/setup.cfg +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/blocked_comment.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/branch_reclaim.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/checks.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/claim.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/clarify.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/cli.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/cli_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/comment_kind.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/delivery_comment.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/delivery_labels.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/delivery_scene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/engine_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/example_config.toml +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/git_identity.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/git_transport.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/human_review.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/language_terms.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/launchd_deploy.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/merge_gate.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/merge_handoff.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/milestone.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/milestone_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/notify.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_activity.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_process.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_recovery.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pi_session.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pilot_setup.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/pilot_slots.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/progress.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/progress_display.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/progress_outcome.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/progress_summary.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/release_git.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/release_notes.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/repo_config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/resume_scan.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/review_merge.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/review_rounds.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/run_state.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/runner_health.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/scene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/scheduler.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/scheduler_units.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/session_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/source_pool.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/status_observe.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/systemd_deploy.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi/ticket_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi_cli.egg-info/dependency_links.txt +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi_cli.egg-info/entry_points.txt +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/src/orbi_cli.egg-info/top_level.txt +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_agents_md.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_apply_label_patch.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_blocked_comment.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_pi.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_preflight.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_bootstrap_runner.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_branch_reclaim.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ci_failure_issue_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ci_failure_triage.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ci_macos_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ci_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_clarify.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_claude_plugin.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_cli_install.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_cli_milestone.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_cli_packaging.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_cli_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_comment_identity.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_comment_readers_contract.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_concurrency_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_constitution.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_coverage_gate.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_deliver_pr_fakes.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_delivery_branch_delete.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_delivery_labels.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_delivery_scene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docker_docs.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docker_entrypoint.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docker_image_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_i18n.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_issue_shapes.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_labels.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_mermaid.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_releases.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_single_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_docs_site.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_engine_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_failure_report.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_fix_needed_loop.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_git_base_smoke.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_git_merge_smoke.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_git_transport.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_github_fakes.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_human_review.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_import_graph.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_install_sh.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_issue_1367.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_issue_461.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_issue_template_config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_journal_events.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_launchd_deploy.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_license.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_message_language.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_milestone_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_notify.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ops_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_orbi.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_orbi_dashboard.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_orbi_exporter.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_patch_ratchet.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_activity.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_extensions.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_provider_templates.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_recovery.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pi_session.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pilot_setup.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pilot_slots.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_pr_issue_link_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_prerequisite_check.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_progress.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_progress_wiring.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_prompt_contract.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_public_contracts.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_publish_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_release_docs_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_release_ticket_templates.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_repo_config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_resume_continue_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_resume_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_resume_pr.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_review_merge.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_run_artifacts.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_run_id_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_runner_config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_runner_health.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_runner_source_freshness.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_scene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_scheduler.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_size_ratchet.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_src_layout.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_status_observe.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_steering_review.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_suite_hygiene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_systemd_deploy.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_systemd_timer.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_ticket_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.6}/tests/test_user_acceptance_contract.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
#
|
|
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
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
#
|
|
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"|"
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
#
|
|
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
|
|
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)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
310
|
-
reason_code = "
|
|
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
|
|
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
|
|
379
|
-
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
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 "-",
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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)
|