orbi-cli 0.7.5__tar.gz → 0.7.7__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.7}/PKG-INFO +1 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/pyproject.toml +1 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/__init__.py +1 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/failure.py +176 -41
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/failure_report.py +4 -0
- orbi_cli-0.7.7/src/orbi/gh_response.py +220 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/github.py +24 -30
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/gitops.py +51 -8
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/journal.py +42 -2
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/language.py +21 -14
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/release.py +31 -14
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/release_comments.py +5 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/runner.py +1 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/suggest.py +10 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.7/src/orbi_cli.egg-info}/PKG-INFO +1 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi_cli.egg-info/SOURCES.txt +2 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_base_branch_failure.py +169 -18
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_bootstrap_adapters.py +10 -4
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_bootstrap_delivery.py +1 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_bootstrap_release.py +133 -36
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_failure.py +280 -35
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_gh_read_retry.py +218 -61
- orbi_cli-0.7.7/tests/test_gh_response.py +243 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_git_network_retry.py +53 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_github.py +41 -32
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_gitops.py +80 -1
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_gitops_fakes.py +20 -2
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_journal.py +84 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_suggest.py +54 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/LICENSE +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/README.md +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/docs/licenses/sustainable-use-license.md +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/setup.cfg +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/blocked_comment.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/branch_reclaim.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/checks.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/claim.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/clarify.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/cli.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/cli_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/comment_kind.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/delivery_comment.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/delivery_labels.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/delivery_scene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/engine_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/example_config.toml +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/git_identity.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/git_transport.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/human_review.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/language_terms.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/launchd_deploy.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/merge_gate.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/merge_handoff.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/milestone.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/milestone_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/notify.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/pi_activity.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/pi_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/pi_process.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/pi_recovery.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/pi_session.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/pilot_setup.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/pilot_slots.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/progress.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/progress_display.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/progress_outcome.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/progress_summary.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/release_git.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/release_notes.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/repo_config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/resume_scan.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/review_merge.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/review_rounds.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/run_state.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/runner_health.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/scene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/scheduler.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/scheduler_units.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/session_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/source_pool.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/status_observe.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/systemd_deploy.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi/ticket_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi_cli.egg-info/dependency_links.txt +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi_cli.egg-info/entry_points.txt +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/src/orbi_cli.egg-info/top_level.txt +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_agents_md.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_apply_label_patch.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_blocked_comment.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_bootstrap_pi.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_bootstrap_preflight.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_bootstrap_runner.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_branch_reclaim.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_ci_failure_issue_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_ci_failure_triage.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_ci_macos_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_ci_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_clarify.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_claude_plugin.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_cli_install.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_cli_milestone.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_cli_packaging.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_cli_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_comment_identity.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_comment_readers_contract.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_concurrency_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_constitution.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_coverage_gate.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_deliver_pr_fakes.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_delivery_branch_delete.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_delivery_labels.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_delivery_scene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docker_docs.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docker_entrypoint.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docker_image_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docs_i18n.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docs_issue_shapes.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docs_labels.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docs_mermaid.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docs_releases.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docs_single_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_docs_site.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_engine_source.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_failure_report.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_fix_needed_loop.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_git_base_smoke.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_git_merge_smoke.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_git_transport.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_github_fakes.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_human_review.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_import_graph.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_install_sh.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_issue_1367.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_issue_461.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_issue_template_config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_journal_events.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_launchd_deploy.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_license.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_message_language.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_milestone_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_notify.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_ops_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_orbi.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_orbi_dashboard.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_orbi_exporter.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_patch_ratchet.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pi_activity.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pi_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pi_extensions.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pi_provider_templates.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pi_recovery.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pi_session.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pilot_setup.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pilot_slots.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_pr_issue_link_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_prerequisite_check.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_progress.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_progress_wiring.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_prompt_contract.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_public_contracts.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_publish_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_release_docs_workflow.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_release_ticket_templates.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_repo_config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_resume_continue_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_resume_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_resume_pr.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_review_merge.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_run_artifacts.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_run_id_e2e.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_runner_config.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_runner_health.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_runner_source_freshness.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_scene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_scheduler.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_size_ratchet.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_src_layout.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_status_observe.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_steering_review.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_suite_hygiene.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_systemd_deploy.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_systemd_timer.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_ticket_command.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/tests/test_user_acceptance_contract.py +0 -0
- {orbi_cli-0.7.5 → orbi_cli-0.7.7}/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.7
|
|
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.7"
|
|
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,25 @@ _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.
|
|
260
|
+
# `api key` is the provider's own missing/rejected-key sentence ("No API
|
|
261
|
+
# key found for provider …"), the marker `PROVIDER_AUTH_WORDS` still
|
|
262
|
+
# lists; only `incorrect api key` survived #1762, so the plain sentence
|
|
263
|
+
# regressed to `unclassified` (Issue #1768). An argv word cannot carry
|
|
264
|
+
# it — the space keeps it out of a branch, label or check name.
|
|
215
265
|
_CREDENTIAL_MISSING_RE = re.compile(
|
|
216
266
|
r"bad credentials"
|
|
217
267
|
r"|(?:http|status)\s+401\b"
|
|
@@ -220,9 +270,8 @@ _CREDENTIAL_MISSING_RE = re.compile(
|
|
|
220
270
|
# relabelled as a credential problem (Issue #1465).
|
|
221
271
|
r"|(?:^|[\s\"'(])401:"
|
|
222
272
|
r"|authentication (?:failed|required|fails)"
|
|
223
|
-
r"|
|
|
224
|
-
r"|"
|
|
225
|
-
+ r"|credential",
|
|
273
|
+
r"|api key"
|
|
274
|
+
r"|unauthorized",
|
|
226
275
|
re.IGNORECASE,
|
|
227
276
|
)
|
|
228
277
|
# The explicit GitHub rejection when a push creates or updates an
|
|
@@ -244,23 +293,48 @@ _WORKFLOW_PERMISSION_RE = re.compile(
|
|
|
244
293
|
# terminal progress scene and the short milestone; only the last
|
|
245
294
|
# sentence differs by whether a delivery PR already exists.
|
|
246
295
|
_WORKFLOW_PERMISSION_CAUSE_KEY = "failure.workflow_cause"
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
_BASE_BRANCH_MISSING_RE = re.compile(
|
|
254
|
-
r"could ?n['o]t find remote ref", re.IGNORECASE,
|
|
255
|
-
)
|
|
296
|
+
# The remaining text-only transient markers: network-level failures and
|
|
297
|
+
# GitHub's own server sentences. This set names no timeout: a timeout is
|
|
298
|
+
# decided by `subprocess.TimeoutExpired` or, for a source that gives no
|
|
299
|
+
# status and no type, by `_TEXT_TIMEOUT_RE` below. A GitHub request that
|
|
300
|
+
# received a response reports its status and never reads that wording; a
|
|
301
|
+
# status-less gh transport failure is the one exception (Issue #1768).
|
|
256
302
|
_GITHUB_TRANSIENT_RE = re.compile(
|
|
257
303
|
r"http 5\d\d|http 429|rate limit|"
|
|
258
304
|
r"something went wrong while executing your query|"
|
|
259
305
|
r"internal server error|connection reset|connection refused|"
|
|
260
|
-
r"temporary failure in name resolution|network is unreachable
|
|
261
|
-
r"timed out|timeout",
|
|
306
|
+
r"temporary failure in name resolution|network is unreachable",
|
|
262
307
|
re.IGNORECASE,
|
|
263
308
|
)
|
|
309
|
+
# A non-GitHub subprocess — a Pi session, a Git fetch/push — reports a
|
|
310
|
+
# timeout in its own words and gives neither a status nor a timeout type:
|
|
311
|
+
# there the wording is all that exists (the same text-only carve-out the
|
|
312
|
+
# provider failure reasons keep, Issue #1465/#1760).
|
|
313
|
+
_TEXT_TIMEOUT_RE = re.compile(r"timed out|timeout", re.IGNORECASE)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _timeout_is_text_only(error: BaseException | None) -> bool:
|
|
317
|
+
"""Whether only the failure's text can say it was a timeout.
|
|
318
|
+
|
|
319
|
+
`subprocess.TimeoutExpired` IS a timeout (decided by type) and a
|
|
320
|
+
GitHub request that received a response reports its status, so
|
|
321
|
+
neither reads the wording; a non-gh subprocess (Pi, `git
|
|
322
|
+
fetch`/`git push`) has only its output. gh's own transport can fail
|
|
323
|
+
before any response exists — a TLS handshake timeout exits 1 with no
|
|
324
|
+
status and no `TimeoutExpired` (cli/cli#1761) — so a structured gh
|
|
325
|
+
failure that reports NO status keeps the wording as its only signal
|
|
326
|
+
(Issue #1768). A response status, when present, still wins.
|
|
327
|
+
"""
|
|
328
|
+
command = getattr(error, "cmd", None)
|
|
329
|
+
if not isinstance(command, list):
|
|
330
|
+
return False
|
|
331
|
+
if command[:1] != ["gh"]:
|
|
332
|
+
return True
|
|
333
|
+
return (
|
|
334
|
+
isinstance(error, gh_response.GitHubRequestFailed)
|
|
335
|
+
and error.status is None
|
|
336
|
+
)
|
|
337
|
+
|
|
264
338
|
|
|
265
339
|
# reason_code -> (action_code, retry_safe). `retry_safe` is whether a
|
|
266
340
|
# plain re-queue can succeed WITHOUT a human repair: a transient GitHub
|
|
@@ -278,36 +352,87 @@ DISPOSITIONS: dict[str, tuple[str, bool]] = {
|
|
|
278
352
|
}
|
|
279
353
|
|
|
280
354
|
|
|
355
|
+
class BaseBranchMissingError(RuntimeError):
|
|
356
|
+
"""The configured base branch does not exist on the remote (Issue #1756).
|
|
357
|
+
|
|
358
|
+
Raised by the first delivery step `freeze_base` → `fetch_base_ref`
|
|
359
|
+
only after the fetch failed AND the remote itself answered
|
|
360
|
+
`git ls-remote --heads` with nothing: the fact is decided at the
|
|
361
|
+
source, so no consumer has to read Git's error text — Git localizes
|
|
362
|
+
it (`LANG` / `LC_MESSAGES`), and a text match would classify the
|
|
363
|
+
same failure differently on a non-English host.
|
|
364
|
+
|
|
365
|
+
`base_branch` is the CONFIGURED branch and `failure_record` is the
|
|
366
|
+
record the failure report uses as it is; the text classifier never
|
|
367
|
+
runs for this failure. `stderr` keeps the failed fetch's own output
|
|
368
|
+
as evidence: `_failure_detail` reads it into the run details, so the
|
|
369
|
+
reader still sees what Git said without it deciding anything
|
|
370
|
+
(Issues #1749, #1753).
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def __init__(self, base_branch: str, *, stderr: str | None = None):
|
|
374
|
+
super().__init__(
|
|
375
|
+
f"base branch {base_branch!r} does not exist on origin",
|
|
376
|
+
)
|
|
377
|
+
self.base_branch = base_branch
|
|
378
|
+
self.stderr = stderr
|
|
379
|
+
action_code, retry_safe = DISPOSITIONS["base_branch_missing"]
|
|
380
|
+
self.failure_record = Failure(
|
|
381
|
+
reason_code="base_branch_missing",
|
|
382
|
+
action_code=action_code,
|
|
383
|
+
retry_safe=retry_safe,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
281
387
|
def classify(detail: str, *, outcome: str = "blocked",
|
|
282
388
|
provider_quota: bool = False, review_budget: bool = False,
|
|
283
389
|
human_decision: bool = False,
|
|
284
|
-
unrecoverable: bool = False
|
|
390
|
+
unrecoverable: bool = False,
|
|
391
|
+
error: BaseException | None = None) -> Failure:
|
|
285
392
|
"""Map one delivery exception to the machine-readable failure record.
|
|
286
393
|
|
|
287
394
|
The caller supplies the type-derived flags (the runner owns the
|
|
288
|
-
exception classes)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
395
|
+
exception classes) and, when it has one, the failing exception itself
|
|
396
|
+
(`error`). Its TYPE and the HTTP response status it carries decide
|
|
397
|
+
BEFORE every text marker — a 401, a 429/5xx, a rate-limited 403, a
|
|
398
|
+
permission 403, a transport timeout and a bounded gate wait are read
|
|
399
|
+
from the failure itself, never from words that the command's argv
|
|
400
|
+
could also contain (Issue #1760). Every marker that is not recognized
|
|
401
|
+
falls through to `unclassified`, so the block is never omitted. The
|
|
402
|
+
mapping is the single source both the comment block and the
|
|
403
|
+
reader-facing Action/Reason rendering use.
|
|
293
404
|
"""
|
|
294
405
|
if provider_quota:
|
|
295
406
|
reason_code = "provider_quota"
|
|
296
407
|
elif review_budget:
|
|
297
408
|
reason_code = "review_budget_exhausted"
|
|
298
|
-
elif human_decision:
|
|
409
|
+
elif human_decision or isinstance(error, ReleaseGateWaitTimeout):
|
|
410
|
+
# A bounded wait that ran out is a human's decision, never a
|
|
411
|
+
# transient failure to requeue (Issue #1760).
|
|
299
412
|
reason_code = "human_decision_required"
|
|
413
|
+
elif isinstance(error, subprocess.TimeoutExpired):
|
|
414
|
+
# A transport timeout is transient by type, whatever it says.
|
|
415
|
+
reason_code = "github_transient"
|
|
416
|
+
elif gh_response.authentication_required(error):
|
|
417
|
+
reason_code = "credential_missing"
|
|
418
|
+
elif gh_response.transient(error):
|
|
419
|
+
reason_code = "github_transient"
|
|
420
|
+
elif gh_response.status_of(error) == 403 or isinstance(
|
|
421
|
+
error, GitHubPermissionDenied):
|
|
422
|
+
# A 403 with quota left is a permission failure: the same repair
|
|
423
|
+
# as a rejected credential. A caller that re-wraps that status in
|
|
424
|
+
# its own sentence (the release gate's missing Checks:read) keeps
|
|
425
|
+
# it on the type.
|
|
426
|
+
reason_code = "credential_missing"
|
|
300
427
|
elif _WORKFLOW_PERMISSION_RE.search(detail):
|
|
301
428
|
# The explicit App/workflows refusal is an authorization the AI
|
|
302
429
|
# cannot grant: the existing `credential_missing` /
|
|
303
430
|
# `check_credentials` category, a repair required before retry.
|
|
304
431
|
reason_code = "credential_missing"
|
|
305
|
-
elif _BASE_BRANCH_MISSING_RE.search(detail):
|
|
306
|
-
reason_code = "base_branch_missing"
|
|
307
432
|
elif _CREDENTIAL_MISSING_RE.search(detail):
|
|
308
433
|
reason_code = "credential_missing"
|
|
309
|
-
elif
|
|
310
|
-
reason_code = "
|
|
434
|
+
elif _timeout_is_text_only(error) and _TEXT_TIMEOUT_RE.search(detail):
|
|
435
|
+
reason_code = "github_transient"
|
|
311
436
|
elif _GITHUB_TRANSIENT_RE.search(detail):
|
|
312
437
|
reason_code = "github_transient"
|
|
313
438
|
elif _PROVIDER_QUOTA_RE.search(detail):
|
|
@@ -370,19 +495,23 @@ def workflow_permission_action(
|
|
|
370
495
|
|
|
371
496
|
|
|
372
497
|
def terminal_action(record: Failure,
|
|
373
|
-
language_code: str
|
|
498
|
+
language_code: str, *,
|
|
499
|
+
base_branch: str | None = None) -> str:
|
|
374
500
|
"""The reader action for the implement-phase terminal handler.
|
|
375
501
|
|
|
376
502
|
An unrecognized failure says the cause could not be determined
|
|
377
503
|
instead of implying the Issue text is the problem (Issue #1651); a
|
|
378
|
-
missing base branch names
|
|
379
|
-
|
|
504
|
+
missing base branch names the branch and both repairs — create it, or
|
|
505
|
+
pick an existing one where the repository was set up — plus how to
|
|
506
|
+
restart the Issue (Issue #1749, #1753); every other recognized
|
|
507
|
+
terminal failure keeps the existing generic action.
|
|
380
508
|
"""
|
|
381
509
|
if record.reason_code == "unclassified":
|
|
382
510
|
return language.text(language_code, "failure.terminal_unclassified")
|
|
383
511
|
if record.reason_code == "base_branch_missing":
|
|
384
512
|
return language.text(
|
|
385
513
|
language_code, "failure.terminal_base_branch_missing",
|
|
514
|
+
base_branch=base_branch or "-",
|
|
386
515
|
)
|
|
387
516
|
return language.text(language_code, "failure.terminal_generic")
|
|
388
517
|
|
|
@@ -391,15 +520,16 @@ def terminal_reason(detail: str, record: Failure, *,
|
|
|
391
520
|
base_branch: str | None, language_code: str) -> str:
|
|
392
521
|
"""The reader-facing reason of the implement-phase terminal handler.
|
|
393
522
|
|
|
394
|
-
The missing-base-branch failure names the CONFIGURED branch
|
|
395
|
-
|
|
396
|
-
|
|
523
|
+
The missing-base-branch failure names the CONFIGURED branch and keeps
|
|
524
|
+
Git's raw answer out of the sentence: the remote's own `git ls-remote`
|
|
525
|
+
probe is the evidence the run details carry, never the setting a human
|
|
526
|
+
has to repair (Issues #1749, #1753, #1756). Every other terminal
|
|
397
527
|
failure keeps the generic stopped-delivery sentence.
|
|
398
528
|
"""
|
|
399
529
|
if record.reason_code == "base_branch_missing":
|
|
400
530
|
return language.text(
|
|
401
531
|
language_code, "failure.base_branch_missing_reason",
|
|
402
|
-
base_branch=base_branch or "-",
|
|
532
|
+
base_branch=base_branch or "-",
|
|
403
533
|
)
|
|
404
534
|
return language.text(
|
|
405
535
|
language_code, "runner.delivery_stopped_reason", detail=detail,
|
|
@@ -465,8 +595,11 @@ def _failure_summary(reason: str,
|
|
|
465
595
|
summary = re.sub(r"CalledProcessError\([^)]*\)",
|
|
466
596
|
command_failed, summary)
|
|
467
597
|
summary = _redact_local_paths(summary)
|
|
468
|
-
|
|
469
|
-
|
|
598
|
+
# Trailing dots are kept: a catalog sentence ("... start from.")
|
|
599
|
+
# reaches the reader as written (Issue #1753), while a trailing
|
|
600
|
+
# separator from exception text is noise.
|
|
601
|
+
summary = re.sub(r"\s+", " ", summary).strip(" ;:")
|
|
602
|
+
if not summary.strip("."):
|
|
470
603
|
summary = command_failed
|
|
471
604
|
if stderr:
|
|
472
605
|
# Setup tools often write informational lines before the provider's
|
|
@@ -480,7 +613,9 @@ def _failure_summary(reason: str,
|
|
|
480
613
|
else stderr.rstrip().rsplit("\n", 1)[-1].strip()
|
|
481
614
|
)
|
|
482
615
|
if stderr_summary and stderr_summary not in summary:
|
|
483
|
-
|
|
616
|
+
# The sentence separator never doubles the punctuation the
|
|
617
|
+
# summary already ends with.
|
|
618
|
+
summary = f"{summary.rstrip(' .;:')}: {stderr_summary}"
|
|
484
619
|
return summary[:500]
|
|
485
620
|
|
|
486
621
|
|
|
@@ -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)
|