ctrlrelay 0.16.3__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/CHANGELOG.md +63 -1
  2. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/configuration.md +31 -13
  4. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/operations.md +24 -0
  5. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/pyproject.toml +1 -1
  6. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/cli.py +134 -0
  7. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/archived.py +16 -0
  8. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/config.py +1 -1
  9. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/github.py +106 -0
  10. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/poller.py +304 -47
  11. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/pr_watcher.py +3 -1
  12. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/worktree.py +185 -12
  13. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/dev.py +126 -4
  14. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/post_merge.py +2 -2
  15. ctrlrelay-0.17.0/tests/_poller_fakes.py +38 -0
  16. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/conftest.py +24 -0
  17. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_agent_no_keys.py +205 -0
  18. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_archived.py +17 -8
  19. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_poller.py +13 -260
  20. ctrlrelay-0.17.0/tests/test_poller_search.py +291 -0
  21. ctrlrelay-0.17.0/tests/test_publish_failure_retry.py +438 -0
  22. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_server.py +1 -1
  23. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_worktree.py +206 -0
  24. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  26. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/dependabot.yml +0 -0
  28. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/build.yml +0 -0
  29. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/cla.yml +0 -0
  30. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/pages.yml +0 -0
  31. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/publish.yml +0 -0
  32. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/test.yml +0 -0
  33. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.gitignore +0 -0
  34. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/CODE_OF_CONDUCT.md +0 -0
  35. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/CONTRIBUTING.md +0 -0
  36. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/LICENSE +0 -0
  37. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/README.md +0 -0
  38. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/SECURITY.md +0 -0
  39. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/config/orchestrator.yaml.example +0 -0
  40. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/Gemfile +0 -0
  41. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/_config.yml +0 -0
  42. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/adding-a-machine.md +0 -0
  43. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/architecture.md +0 -0
  44. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/bridge.md +0 -0
  45. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/cli.md +0 -0
  46. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/development.md +0 -0
  47. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/feedback-loop.md +0 -0
  48. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/getting-started.md +0 -0
  49. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/index.md +0 -0
  50. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/personalization.md +0 -0
  51. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  52. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  53. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/__init__.py +0 -0
  54. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  55. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  56. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/factory.py +0 -0
  57. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/handler.py +0 -0
  58. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  59. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
  60. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  61. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/server.py +0 -0
  62. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  63. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/__init__.py +0 -0
  64. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/agent_env.py +0 -0
  65. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/audit.py +0 -0
  66. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/binaries.py +0 -0
  67. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  68. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/claims.py +0 -0
  69. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/code_review.py +0 -0
  70. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  71. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  72. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/events.py +0 -0
  73. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/github_budget.py +0 -0
  74. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/heartbeat.py +0 -0
  75. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/job_claims.py +0 -0
  76. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/network.py +0 -0
  77. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/node_check.py +0 -0
  78. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/obs.py +0 -0
  79. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  80. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/publish.py +0 -0
  81. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/question_expiry.py +0 -0
  82. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/redact.py +0 -0
  83. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/scheduler.py +0 -0
  84. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/state.py +0 -0
  85. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/usage.py +0 -0
  86. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  87. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/dashboard/client.py +0 -0
  88. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/gh_protocol.py +0 -0
  89. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/install.py +0 -0
  90. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  91. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/manager.py +0 -0
  92. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/paths.py +0 -0
  93. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  94. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/base.py +0 -0
  95. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  96. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/task.py +0 -0
  97. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/setup.py +0 -0
  98. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/__init__.py +0 -0
  99. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  100. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  101. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  102. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  103. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  104. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  105. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  106. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  107. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/__init__.py +0 -0
  108. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/base.py +0 -0
  109. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  110. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  111. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/__init__.py +0 -0
  112. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/fleet.py +0 -0
  113. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/health.py +0 -0
  114. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/links.py +0 -0
  115. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/server.py +0 -0
  116. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/static/index.html +0 -0
  117. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/status.py +0 -0
  118. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/transcript.py +0 -0
  119. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/waiting.py +0 -0
  120. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/__init__.py +0 -0
  121. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/_publish_fakes.py +0 -0
  122. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/fixtures/gh_search_issues.json +0 -0
  123. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  124. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/fixtures/transcript_sample.jsonl +0 -0
  125. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_agent_sandbox.py +0 -0
  126. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_audit.py +0 -0
  127. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_binary_resolution.py +0 -0
  128. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_bridge_protocol.py +0 -0
  129. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_bridge_server.py +0 -0
  130. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_bridge_start_command.py +0 -0
  131. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_chat_machines.py +0 -0
  132. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_checkpoint.py +0 -0
  133. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_claim_reconciliation.py +0 -0
  134. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_ci_wait.py +0 -0
  135. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_dev.py +0 -0
  136. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_repos.py +0 -0
  137. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_secops.py +0 -0
  138. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_start.py +0 -0
  139. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_version.py +0 -0
  140. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_code_review.py +0 -0
  141. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_config.py +0 -0
  142. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dashboard_client.py +0 -0
  143. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dev_integration.py +0 -0
  144. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dev_pipeline.py +0 -0
  145. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dispatch_health.py +0 -0
  146. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dispatch_outcome_hook.py +0 -0
  147. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dispatcher.py +0 -0
  148. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_docs_site.py +0 -0
  149. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_event_emission.py +0 -0
  150. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_events.py +0 -0
  151. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_fleet.py +0 -0
  152. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_gh_protocol.py +0 -0
  153. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_github.py +0 -0
  154. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_github_budget.py +0 -0
  155. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_install.py +0 -0
  156. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_issue_outcome_announcement.py +0 -0
  157. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_job_claims.py +0 -0
  158. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_mattermost_handler.py +0 -0
  159. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_network.py +0 -0
  160. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_node_check.py +0 -0
  161. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_node_claims.py +0 -0
  162. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_notify_transport.py +0 -0
  163. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_obs.py +0 -0
  164. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_personalization.py +0 -0
  165. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_pipeline_base.py +0 -0
  166. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_post_merge.py +0 -0
  167. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_pr_verifier.py +0 -0
  168. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_pr_watcher.py +0 -0
  169. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_pre_dispatch_retry.py +0 -0
  170. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_question_backoff.py +0 -0
  171. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_question_expiry.py +0 -0
  172. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_redact.py +0 -0
  173. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_relay_alert_off_lock.py +0 -0
  174. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_resume_prompt_contract.py +0 -0
  175. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_scheduler.py +0 -0
  176. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_secops_integration.py +0 -0
  177. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_secops_pipeline.py +0 -0
  178. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_setup.py +0 -0
  179. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_state.py +0 -0
  180. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_task_pipeline.py +0 -0
  181. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_telegram_handler.py +0 -0
  182. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_transport.py +0 -0
  183. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_transport_resolution.py +0 -0
  184. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  185. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_usage.py +0 -0
  186. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_health.py +0 -0
  187. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_links.py +0 -0
  188. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_status.py +0 -0
  189. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_transcript.py +0 -0
  190. {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_waiting.py +0 -0
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.0] - 2026-11-01
11
+
12
+ Polling costs one GitHub search instead of one call per repo. **No config
13
+ edit is required.** Check one thing if you use `include_labels`: a trigger
14
+ label now starts a run only when the configured user added it.
15
+
16
+ ### Changed
17
+
18
+ - Each poll asks GitHub's search once for open issues assigned to the user
19
+ across all configured repos, plus once per distinct `include_labels`
20
+ label, instead of one `gh issue list` per repo and one "is it archived"
21
+ probe per repo. Measured on 87 repos: 1 call per poll, down from about
22
+ 174. A repo is probed for "archived" only when it has an issue not yet
23
+ handled, and a "not archived" answer is kept for 6 hours (#283, closes
24
+ #282)
25
+ - A search GitHub marks incomplete, or that returns fewer rows than its
26
+ total, fails the poll as "could not look": nothing is marked seen or
27
+ treated as new. A startup seed that could not look is finished before
28
+ the next poll, so issues already there are never read as new (#283)
29
+ - A trigger label (`include_labels`) starts a run only when the configured
30
+ user added it, whatever `accept_foreign_assignments` says; the issue is
31
+ still taken if the user also assigned it to themselves. Before, anyone
32
+ who could label an issue could start a run (#283)
33
+ - The post-merge PR watcher and the status page's GitHub check
34
+ (`web.github_check_seconds`) default to 300 s, from 60 s (#283)
35
+ - GitHub logins compare without regard to case in the assignment and label
36
+ checks (#283)
37
+
38
+ ## [0.16.4] - 2026-11-01
39
+
40
+ Fixes for runs whose work could not be published. **No config edit is
41
+ required.** `poller_state.json` gains a `publish_failures` count, added
42
+ automatically.
43
+
44
+ ### Fixed
45
+
46
+ - A failed publish no longer strands the issue. `poll()` marks an issue seen
47
+ before handing it to the handler, so a run whose agent finished but whose
48
+ branch the relay could not publish stayed in `seen_issues` forever: the
49
+ claim was retracted, the automation label was still on it, and the relay
50
+ never came back. The issue is now un-marked for the next poll, up to 3
51
+ runs, counted in `publish_failures` in `poller_state.json` (saved together
52
+ with the un-mark) so a restart does not reset the limit. After the third
53
+ the relay says on the issue that it has stopped, with the error scrubbed,
54
+ and says so in chat if that comment cannot be posted. A *refused* push is
55
+ not retried at all: the relay reaches the same refusal every time (#278,
56
+ closes #268)
57
+ - The retraction comment no longer advises a retry that cannot work. It said
58
+ to re-assign the issue or re-apply its automation label, but the skip
59
+ decision reads the issue number alone, so neither did anything. It now
60
+ says that while the poller is running the relay tries again a limited
61
+ number of times and says so if it stops, and otherwise names the one route
62
+ that works (#278, closes #268)
63
+ - A failed push says why. The rejected ref and git's reason (a stale lease,
64
+ a non-fast-forward, a protection rule) lead the error, the remote's own
65
+ `remote:` lines follow, and the reason reaches `dev.publish.failed` as its
66
+ own `push_rejection` field. Before, only git's summary line naming the URL
67
+ survived. Every failing git command now reports both of its output
68
+ streams, scrubbed of credential shapes (#277, closes #269)
69
+
10
70
  ## [0.16.3] - 2026-11-01
11
71
 
12
72
  A security fix: **upgrade every machine.** No config edit is required.
@@ -2016,7 +2076,9 @@ pipeline).
2016
2076
  per-phase implementation plans (Phase 0 through Phase 4).
2017
2077
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2018
2078
 
2019
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...HEAD
2079
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.0...HEAD
2080
+ [0.17.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...v0.17.0
2081
+ [0.16.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...v0.16.4
2020
2082
  [0.16.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.2...v0.16.3
2021
2083
  [0.16.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...v0.16.2
2022
2084
  [0.16.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.0...v0.16.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.16.3
3
+ Version: 0.17.0
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -133,7 +133,21 @@ private git dir with hooks off and only ctrlrelay's own git config, pushes the
133
133
  commit it checked rather than the ref name, and uses `--force-with-lease`
134
134
  against the head this session last pushed (recorded in the state DB). An
135
135
  open PR found for the branch is reused only if its head repo is this repo and
136
- its base is the default branch. Text the agent wrote is posted with `@`-mentions and
136
+ its base is the default branch.
137
+
138
+ A push git rejects for a ref is a different thing, logged as
139
+ `publish.push.rejected` with the per-ref reason git gave. Some are decided by
140
+ git itself before the remote is asked (a stale lease, a non-fast-forward:
141
+ `[rejected]`), others by the remote (a branch-protection or repository-rule
142
+ refusal: `[remote rejected]`). That reason
143
+ comes from the `!` line of `git push --porcelain`, which git writes to
144
+ **stdout**; stderr carries only `error: failed to push some refs to '<url>'`.
145
+ Both streams of any failing git command now reach the error it raises (stderr
146
+ whole, stdout capped, both scrubbed of credential shapes), and the reason
147
+ reaches `dev.publish.failed` as its own `push_rejection` field, so a
148
+ rejection is not reported as a URL and nothing else.
149
+
150
+ Text the agent wrote is posted with `@`-mentions and
137
151
  issue-closing keywords disabled and its length capped (20,000 characters);
138
152
  the PR's own `Closes #N` is added by ctrlrelay.
139
153
 
@@ -390,7 +404,7 @@ web:
390
404
  port: 8787
391
405
  history_days: 7
392
406
  refresh_seconds: 5
393
- github_check_seconds: 60
407
+ github_check_seconds: 300
394
408
  stale_after_minutes: 15
395
409
  allowed_hosts: [] # e.g. ["mybox", "mybox.lan"]
396
410
  ```
@@ -401,7 +415,7 @@ web:
401
415
  | `port` | int (1-65535) | `8787` | Port to listen on. |
402
416
  | `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. |
403
417
  | `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
404
- | `github_check_seconds` | int (>= 15) | `60` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). |
418
+ | `github_check_seconds` | int (>= 15) | `300` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). Every check spends from the GitHub account's budget, which machines on the account share. |
405
419
  | `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
406
420
  | `allowed_hosts` | list of strings | `[]` | Extra names the page may be opened by. A request is served only when its `Host` header is `localhost`, `127.0.0.1`, `[::1]`, `host` (unless it is `0.0.0.0`/`::`), this machine's hostname, the hostname plus `.local`, or one of these, alone or followed by `:port`. Anything else, or no `Host`, gets `421`. Matching ignores case and a trailing dot. This stops a web page on another site from pointing its own name at this server (DNS rebinding) and reading it. |
407
421
 
@@ -609,21 +623,25 @@ repos:
609
623
  - An issue is accepted when **either** (a) it's assigned to the configured
610
624
  user (subject to the self-assignment filter from #79 and
611
625
  `accept_foreign_assignments`) **or** (b) it carries any label in
612
- `include_labels`. A label match **skips** the self-assignment check — the
613
- operator's config choice is the trust boundary.
626
+ `include_labels` **that the configured user added** (the latest
627
+ `labeled` event for that label names that account; `accept_foreign_assignments`
628
+ does not widen this). A label someone else added does not start a run;
629
+ the issue is still taken if the user also assigned it to themselves.
614
630
  - Dedup: an issue that is **both** labeled and assigned is picked up exactly
615
631
  once per poll cycle — no duplicate entries in `seen_issues` and no double
616
632
  pipeline spawn.
617
633
  - `exclude_labels` always wins over `include_labels` on the same issue: an
618
634
  explicit "not for the agent" opt-OUT beats the generic label opt-IN.
619
- - When a repo configures `include_labels`, the poller runs **targeted**
620
- queries per cycle: the existing `gh issue list --assignee <user>` plus
621
- one `gh issue list --label <L>` call per configured label. Results merge
622
- by issue number. This keeps the label path scale-safe on busy repos
623
- where an unfiltered fetch would silently cap at gh's `--limit` and miss
624
- labeled issues on later pages. Repos without `include_labels` run only
625
- the cheap `--assignee` query, so enabling the feature on one repo does
626
- not add API calls on the others.
635
+ - Each poll asks GitHub's search **once** for open issues assigned to the
636
+ user across all configured repos, plus **once per distinct
637
+ `include_labels` label** across the repos using it, whatever the number
638
+ of repos (split by owner only if the owner list is too long for one
639
+ query). A search GitHub marks incomplete, or that returns fewer rows
640
+ than its total, fails the poll as "could not look": nothing is marked
641
+ seen and nothing is treated as new. After that, only issues not yet
642
+ handled cost a call each (who assigned it, or who added the label), and
643
+ a repo is asked whether it is archived only when it has such an issue,
644
+ at most once every 6 hours.
627
645
  - The event log entry for a label-triggered acceptance is
628
646
  `poll.issue.included_by_label`, alongside the existing
629
647
  `poll.issue.excluded_by_label` for exclusions.
@@ -727,6 +727,30 @@ file, so a restart does not reset it. Each retry posts a fresh claim
727
727
  comment and, when it fails, a fresh "nothing is working on this" comment
728
728
  on the issue.
729
729
 
730
+ A run whose **agent finished but whose branch the relay could not
731
+ publish** is retried the same way, for the same reason: the issue is
732
+ already in `seen_issues`, so without this nothing would ever bring it
733
+ back. Up to 3 runs in all, counted in `publish_failures` in the same
734
+ file, and the retraction comment on the issue says the relay will
735
+ return rather than asking you to re-assign it. After the third run the
736
+ issue stays in `seen_issues` and the relay says so **on the issue** —
737
+ the commits are still on the run branch in the local checkout (on
738
+ GitHub too, if only opening the pull request failed), and the fix is to
739
+ clear the cause and delete the number as above. If that comment cannot
740
+ be posted, the chat line says so.
741
+
742
+ A push the relay **refuses** (a protected or default branch, a base it
743
+ did not expect, somebody else's commits on the branch) is not retried
744
+ at all: the relay reaches the same refusal every time, so another run
745
+ would spend a whole agent to arrive at it. The issue stays in
746
+ `seen_issues` and the comment names what to do.
747
+
748
+ Re-assigning an issue, or re-applying its automation label, does **not**
749
+ make the poller look at it again. The skip decision reads the issue
750
+ number only — no assignee and no labels — so the number has to leave
751
+ `seen_issues` (and the relay has to be restarted, since the running
752
+ poller holds the set in memory and rewrites the file from it).
753
+
730
754
  ### A repo whose history was replaced
731
755
 
732
756
  If the local copy's default branch can no longer fast-forward to GitHub's
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.16.3"
3
+ version = "0.17.0"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1183,6 +1183,123 @@ def settle_pre_dispatch_failure(poller, repo: str, issue_number: int, result):
1183
1183
  )
1184
1184
 
1185
1185
 
1186
+ # Runs of one issue whose branch the relay may fail to publish before
1187
+ # the poller stops retrying it (#268). Same shape and same reason as
1188
+ # PRE_DISPATCH_ATTEMPTS: a blip deserves another poll, a permanent cause
1189
+ # must not spend one full agent run per poll forever.
1190
+ PUBLISH_ATTEMPTS = 3
1191
+
1192
+
1193
+ def publish_failure_message(
1194
+ repo: str, issue_number: int, error: str, *, attempts: int, retrying: bool,
1195
+ ) -> str:
1196
+ """The chat line for a run whose branch the relay could not publish."""
1197
+ why = (error.strip().splitlines() or ["no error recorded"])[0][:200]
1198
+ if retrying:
1199
+ return (
1200
+ f"\u23f3 #{issue_number} in {repo}: the agent finished but the "
1201
+ f"relay could not publish the branch (try {attempts} of "
1202
+ f"{PUBLISH_ATTEMPTS}): {why}. Will retry on the next poll."
1203
+ )
1204
+ return (
1205
+ f"\u274c #{issue_number} in {repo}: could not publish the branch "
1206
+ f"{attempts} times; not retrying. Last error: {why}. The commits "
1207
+ "are on the run branch locally; fix the cause, then remove the "
1208
+ "issue number from seen_issues in poller_state.json to try again."
1209
+ )
1210
+
1211
+
1212
+ async def settle_publish_failure(
1213
+ github, poller, repo: str, issue_number: int, result,
1214
+ ):
1215
+ """Decide what a failed publish means for retrying its issue (#268).
1216
+
1217
+ `poll()` marks an issue seen BEFORE handing it to the handler, so a
1218
+ publish failure nobody un-marks drops the issue forever: the claim
1219
+ is retracted, the automation label is still on it, and the relay
1220
+ never comes back. Nothing in the logs says why, because from the
1221
+ relay's own point of view nothing is wrong.
1222
+
1223
+ So: the agent finished and only the publish failed, and the push was
1224
+ not refused -> count it, and below ``PUBLISH_ATTEMPTS`` un-mark the
1225
+ issue so the next poll runs it again. At the limit the issue stays
1226
+ seen, the count is dropped, and the relay says on the ISSUE that it
1227
+ has stopped — the retraction already there promised a retry, and a
1228
+ promise silently abandoned is the original defect one step on.
1229
+
1230
+ A refused push is not retried at all: the relay re-reaches that
1231
+ refusal every time, so another attempt costs a whole agent run to
1232
+ arrive at the same answer. Returns the chat line, or ``None`` for
1233
+ any result this is not about.
1234
+
1235
+ Writing the issue comment is best-effort: this runs after a run that
1236
+ has already failed, and a `gh` error here must not replace the run's
1237
+ real failure with a different one.
1238
+ """
1239
+ from ctrlrelay.pipelines.dev import (
1240
+ publish_failure_is_retryable,
1241
+ publish_retries_exhausted_comment,
1242
+ )
1243
+
1244
+ outputs = result.outputs or {}
1245
+ if result.success or result.blocked or not outputs.get("publish_failed"):
1246
+ poller.clear_publish_failures(repo, issue_number)
1247
+ return None
1248
+ if not publish_failure_is_retryable(result):
1249
+ # Refused, not failed. Left seen deliberately; the pipeline's
1250
+ # retraction on the issue already names the operator action.
1251
+ poller.clear_publish_failures(repo, issue_number)
1252
+ log_event(
1253
+ _logger, "publish.refused_not_retried", repo=repo,
1254
+ issue_number=issue_number, error=(result.error or "")[:200],
1255
+ )
1256
+ return (
1257
+ f"\u26d4 #{issue_number} in {repo}: the relay refused to push "
1258
+ f"the branch, so it is not retrying. {result.error or ''}".strip()
1259
+ )
1260
+
1261
+ # The count and the un-mark are saved together, in one write.
1262
+ attempts = poller.publish_failure_count(repo, issue_number) + 1
1263
+ retrying = attempts < PUBLISH_ATTEMPTS
1264
+ poller.count_publish_failure(repo, issue_number, unmark=retrying)
1265
+ notice_failed = False
1266
+ if not retrying:
1267
+ poller.clear_publish_failures(repo, issue_number)
1268
+ try:
1269
+ await github.comment_on_issue(
1270
+ repo=repo,
1271
+ issue_number=issue_number,
1272
+ body=publish_retries_exhausted_comment(
1273
+ result.session_id,
1274
+ repo=repo,
1275
+ issue_number=issue_number,
1276
+ attempts=attempts,
1277
+ error=result.error or "",
1278
+ ),
1279
+ )
1280
+ except asyncio.CancelledError:
1281
+ raise
1282
+ except Exception as e:
1283
+ log_event(
1284
+ _logger, "publish.exhausted_notice_failed", repo=repo,
1285
+ issue_number=issue_number,
1286
+ error=f"{type(e).__name__}: {str(e)[:200]}",
1287
+ )
1288
+ notice_failed = True
1289
+ message = publish_failure_message(
1290
+ repo, issue_number, result.error or "",
1291
+ attempts=attempts, retrying=retrying,
1292
+ )
1293
+ if notice_failed:
1294
+ # The issue's last word is still the retraction promising a
1295
+ # retry; chat is now the only place that says it stopped.
1296
+ message += (
1297
+ " The note saying so could not be posted on the issue, which "
1298
+ "still promises a retry."
1299
+ )
1300
+ return message
1301
+
1302
+
1186
1303
  def reclone_message(repo: str, set_aside: Path) -> str:
1187
1304
  """The chat line for a clone that was set aside and cloned again
1188
1305
  (#234). Names where the old copy went, because nothing deletes it."""
@@ -3168,6 +3285,23 @@ def poller_start(
3168
3285
  pass
3169
3286
  return
3170
3287
 
3288
+ # The agent finished and the relay could not publish the
3289
+ # branch (#268). Un-marked so the next poll runs it
3290
+ # again, bounded, because the issue is already marked
3291
+ # seen and nothing else would ever bring it back. No
3292
+ # early return: a fix round that failed to push still
3293
+ # has a pull request below that needs its merge watcher.
3294
+ publish_note = await settle_publish_failure(
3295
+ github, poller, repo, issue_number, result,
3296
+ )
3297
+ if publish_note is not None:
3298
+ console.print(f"[yellow]{publish_note}[/yellow]")
3299
+ if connected_transport is not None:
3300
+ try:
3301
+ await connected_transport.send(publish_note)
3302
+ except Exception:
3303
+ pass
3304
+
3171
3305
  # Lock-contended DURING PR verification (issue #29): the
3172
3306
  # PR already exists AND verification decided it wasn't
3173
3307
  # ready (_verify_and_fix_pr only returns this error from
@@ -25,6 +25,8 @@ and an unarchived repo recovers on its own.
25
25
  from __future__ import annotations
26
26
 
27
27
  import asyncio
28
+ import time
29
+ from collections.abc import Callable
28
30
  from dataclasses import dataclass, field
29
31
  from typing import Any
30
32
 
@@ -37,6 +39,10 @@ _logger = get_logger("core.archived")
37
39
  # cycle, and a hung lookup would delay every repo behind it. Timing out
38
40
  # just means "unknown" — the repo is polled as normal.
39
41
  ARCHIVED_PROBE_TIMEOUT_SECONDS = 20
42
+ # How long a confirmed "not archived" answer is kept (#282). Each probe is a
43
+ # GitHub call on an account several machines share, and a repo is archived
44
+ # rarely; a repo archived meanwhile is noticed within this long.
45
+ ACTIVE_ANSWER_SECONDS = 6 * 3600
40
46
 
41
47
 
42
48
  @dataclass
@@ -62,6 +68,10 @@ class ArchivedRepoTracker:
62
68
  # the cache miss before either awaits, so the "one probe, one log"
63
69
  # contract in the docstring above is only true single-threaded.
64
70
  _locks: dict[str, asyncio.Lock] = field(default_factory=dict, repr=False)
71
+ # Repo -> monotonic time until which a confirmed "not archived" answer
72
+ # stands (#282). Only a definite answer is kept; an error never is.
73
+ _active_until: dict[str, float] = field(default_factory=dict, repr=False)
74
+ clock: Callable[[], float] = field(default=time.monotonic, repr=False)
65
75
 
66
76
  async def is_archived(self, repo: str) -> bool:
67
77
  """Return True only for a confirmed-archived repo.
@@ -73,6 +83,8 @@ class ArchivedRepoTracker:
73
83
  """
74
84
  if repo in self.archived_repos:
75
85
  return True
86
+ if self._active_until.get(repo, 0.0) > self.clock():
87
+ return False
76
88
 
77
89
  lock = self._locks.setdefault(repo, asyncio.Lock())
78
90
  async with lock:
@@ -108,6 +120,10 @@ class ArchivedRepoTracker:
108
120
  # `is True` on purpose: a truthy non-boolean (a Mock, a "true"
109
121
  # string from a future gh format change) must not skip a repo.
110
122
  if result is not True:
123
+ if result is False:
124
+ # A definite "not archived": kept for a while (#282). A
125
+ # truthy non-boolean is not definite and is not kept.
126
+ self._active_until[repo] = self.clock() + ACTIVE_ANSWER_SECONDS
111
127
  return False
112
128
 
113
129
  self.archived_repos.add(repo)
@@ -349,7 +349,7 @@ class WebConfig(BaseModel):
349
349
  refresh_seconds: int = Field(default=5, ge=1)
350
350
  # How often the waiting line asks GitHub. One search per check, plus
351
351
  # one query per include label; below 15 s that spends the rate limit.
352
- github_check_seconds: int = Field(default=60, ge=15)
352
+ github_check_seconds: int = Field(default=300, ge=15)
353
353
  # A running card with no new agent step for this long, and no CI wait
354
354
  # or review open, is marked "looks stuck".
355
355
  stale_after_minutes: int = Field(default=15, ge=1)
@@ -6,6 +6,7 @@ import asyncio
6
6
  import json
7
7
  import re
8
8
  import shutil
9
+ from collections.abc import Sequence
9
10
  from dataclasses import dataclass, field
10
11
  from typing import Any
11
12
 
@@ -95,6 +96,21 @@ def _find_gh() -> str:
95
96
  return "gh"
96
97
 
97
98
 
99
+
100
+ # GitHub refuses search queries longer than 256 characters; kept below it.
101
+ _SEARCH_QUERY_MAX = 240
102
+
103
+ # One page of ``search/issues`` as one JSON line, with the rows in
104
+ # ``gh issue list``'s field names (#282).
105
+ _SEARCH_PAGE_JQ = (
106
+ "{incomplete: .incomplete_results, total: .total_count, items: [.items[] | "
107
+ "{number, title, state, body, labels: [.labels[] | {name}], "
108
+ "assignees: [.assignees[] | {login}], createdAt: .created_at, "
109
+ "updatedAt: .updated_at, "
110
+ "repo: (.repository_url | sub(\"^https://api.github.com/repos/\"; \"\"))}]}"
111
+ " | tojson"
112
+ )
113
+
98
114
  @dataclass
99
115
  class GitHubCLI:
100
116
  """Async wrapper around the gh CLI."""
@@ -365,6 +381,96 @@ class GitHubCLI:
365
381
  issue["repo"] = (issue.get("repository") or {}).get("nameWithOwner", "")
366
382
  return issues
367
383
 
384
+ async def search_open_issues(
385
+ self,
386
+ *,
387
+ repos: Sequence[str],
388
+ assignee: str | None = None,
389
+ label: str | None = None,
390
+ ) -> dict[str, list[dict[str, Any]]]:
391
+ """Open issues in ``repos`` assigned to ``assignee`` or carrying
392
+ ``label``, in ONE search rather than one call per repo (#282): the
393
+ poller's intake, whatever the number of repos.
394
+
395
+ GitHub's search API directly (``gh api search/issues``), because
396
+ it says when an answer is partial: a page marked
397
+ ``incomplete_results`` (the search timed out on GitHub's side), or
398
+ fewer rows than ``total_count`` (more than search returns), raises
399
+ ``GitHubError`` rather than pass for complete, and so do empty or
400
+ unreadable output. Scoped to the owners of ``repos``; rows are kept
401
+ only for ``repos`` (matched without regard to case) and keyed by
402
+ the configured name. Every configured repo is a key, with ``[]``
403
+ when nothing matched, so "looked, found nothing" differs from "did
404
+ not look", which raises. Rows use ``gh issue list``'s field names.
405
+ """
406
+ if (assignee is None) == (label is None):
407
+ raise ValueError("pass exactly one of assignee or label")
408
+ by_name = {r.lower(): r for r in repos}
409
+ owners = sorted({r.split("/", 1)[0] for r in repos if "/" in r})
410
+ base = ["is:issue", "is:open"]
411
+ if assignee is not None:
412
+ base.append(f"assignee:{assignee}")
413
+ else:
414
+ base.append('label:"' + label.replace('"', "") + '"')
415
+ # GitHub caps a search query's length, so owners are split across
416
+ # as few queries as fit: the count grows with the number of
417
+ # owners, never with the number of repos.
418
+ queries: list[list[str]] = [[]]
419
+ for owner in owners:
420
+ term = f"user:{owner}"
421
+ if queries[-1] and len(" ".join(base + queries[-1] + [term])) > _SEARCH_QUERY_MAX:
422
+ queries.append([])
423
+ queries[-1].append(term)
424
+ rows: list[dict[str, Any]] = []
425
+ for owner_terms in queries:
426
+ rows += await self._search_issue_pages(" ".join(base + owner_terms))
427
+ out: dict[str, list[dict[str, Any]]] = {r: [] for r in repos}
428
+ for row in rows:
429
+ repo = by_name.get(str(row.get("repo", "")).lower())
430
+ if repo is not None:
431
+ out[repo].append(row)
432
+ return out
433
+
434
+ async def _search_issue_pages(self, query: str) -> list[dict[str, Any]]:
435
+ """Every row of one search, or ``GitHubError`` when the answer
436
+ could be partial or could not be read."""
437
+ output = await self._run_gh(
438
+ "api", "-X", "GET", "search/issues", "--paginate",
439
+ "-f", "q=" + query, "-f", "per_page=100",
440
+ "--jq", _SEARCH_PAGE_JQ,
441
+ )
442
+ pages = [json.loads(line) for line in output.splitlines() if line.strip()]
443
+ if not pages or not all(isinstance(p, dict) and "items" in p for p in pages):
444
+ raise GitHubError("gh api search/issues gave no readable answer")
445
+ if any(p.get("incomplete") for p in pages):
446
+ raise GitHubError("GitHub marked the search incomplete")
447
+ rows = [row for p in pages for row in p["items"]]
448
+ totals = {p.get("total") for p in pages}
449
+ # Every page must agree on the total: one that changed mid-way means
450
+ # the result set moved while it was being read.
451
+ total = totals.pop() if len(totals) == 1 else None
452
+ if not isinstance(total, int) or len(rows) < total:
453
+ raise GitHubError(
454
+ f"the search returned {len(rows)} of {total} issues, so the "
455
+ "answer may be incomplete"
456
+ )
457
+ return rows
458
+
459
+ async def list_label_events(
460
+ self,
461
+ repo: str,
462
+ issue_number: int,
463
+ ) -> list[dict[str, Any]]:
464
+ """``labeled`` events for an issue, oldest first, every page. On a
465
+ ``labeled`` event ``actor`` is the account that added the label
466
+ (unlike an ``assigned`` event, where it is the assignee, #274)."""
467
+ output = await self._run_gh(
468
+ "api", "--paginate",
469
+ f"/repos/{repo}/issues/{issue_number}/events",
470
+ "--jq", '.[] | select(.event=="labeled")',
471
+ )
472
+ return [json.loads(line) for line in output.splitlines() if line.strip()]
473
+
368
474
  async def current_login(self) -> str:
369
475
  """The login ``gh`` is authenticated as (``gh api user``).
370
476