ctrlrelay 0.16.4__tar.gz → 0.17.1__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 (192) hide show
  1. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/CHANGELOG.md +64 -1
  2. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/PKG-INFO +1 -1
  3. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/cli.md +1 -1
  4. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/configuration.md +16 -12
  5. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/operations.md +21 -5
  6. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/pyproject.toml +1 -1
  7. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/cli.py +65 -2
  8. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/archived.py +16 -0
  9. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/config.py +1 -1
  10. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/github.py +106 -0
  11. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/poller.py +244 -40
  12. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/pr_watcher.py +3 -1
  13. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/worktree.py +117 -20
  14. ctrlrelay-0.17.1/src/ctrlrelay/install.py +323 -0
  15. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/pipelines/post_merge.py +2 -2
  16. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/setup.py +34 -8
  17. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +9 -2
  18. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/launchd/poller.plist.template +9 -2
  19. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/systemd/bridge.service.template +7 -1
  20. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/systemd/poller.service.template +7 -1
  21. ctrlrelay-0.17.1/tests/_poller_fakes.py +38 -0
  22. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/conftest.py +24 -0
  23. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_archived.py +17 -8
  24. ctrlrelay-0.17.1/tests/test_install.py +870 -0
  25. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_poller.py +13 -260
  26. ctrlrelay-0.17.1/tests/test_poller_search.py +291 -0
  27. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_setup.py +115 -0
  28. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_web_server.py +1 -1
  29. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_worktree.py +216 -13
  30. ctrlrelay-0.16.4/src/ctrlrelay/install.py +0 -204
  31. ctrlrelay-0.16.4/tests/test_install.py +0 -365
  32. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  33. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  34. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  35. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/dependabot.yml +0 -0
  36. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/workflows/build.yml +0 -0
  37. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/workflows/cla.yml +0 -0
  38. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/workflows/pages.yml +0 -0
  39. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/workflows/publish.yml +0 -0
  40. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.github/workflows/test.yml +0 -0
  41. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/.gitignore +0 -0
  42. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/CODE_OF_CONDUCT.md +0 -0
  43. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/CONTRIBUTING.md +0 -0
  44. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/LICENSE +0 -0
  45. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/README.md +0 -0
  46. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/SECURITY.md +0 -0
  47. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/config/orchestrator.yaml.example +0 -0
  48. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/Gemfile +0 -0
  49. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/_config.yml +0 -0
  50. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/adding-a-machine.md +0 -0
  51. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/architecture.md +0 -0
  52. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/bridge.md +0 -0
  53. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/development.md +0 -0
  54. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/feedback-loop.md +0 -0
  55. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/getting-started.md +0 -0
  56. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/index.md +0 -0
  57. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/personalization.md +0 -0
  58. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
  59. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
  60. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/__init__.py +0 -0
  61. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  62. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  63. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/factory.py +0 -0
  64. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/handler.py +0 -0
  65. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  66. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/node_tag.py +0 -0
  67. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  68. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/server.py +0 -0
  69. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  70. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/__init__.py +0 -0
  71. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/agent_env.py +0 -0
  72. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/audit.py +0 -0
  73. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/binaries.py +0 -0
  74. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  75. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/claims.py +0 -0
  76. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/code_review.py +0 -0
  77. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/dispatch_health.py +0 -0
  78. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  79. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/events.py +0 -0
  80. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/github_budget.py +0 -0
  81. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/heartbeat.py +0 -0
  82. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/job_claims.py +0 -0
  83. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/network.py +0 -0
  84. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/node_check.py +0 -0
  85. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/obs.py +0 -0
  86. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  87. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/publish.py +0 -0
  88. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/question_expiry.py +0 -0
  89. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/redact.py +0 -0
  90. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/scheduler.py +0 -0
  91. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/state.py +0 -0
  92. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/core/usage.py +0 -0
  93. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  94. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/dashboard/client.py +0 -0
  95. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/gh_protocol.py +0 -0
  96. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  97. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/personalization/manager.py +0 -0
  98. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/personalization/paths.py +0 -0
  99. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  100. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/pipelines/base.py +0 -0
  101. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/pipelines/dev.py +0 -0
  102. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/pipelines/secops.py +0 -0
  103. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/pipelines/task.py +0 -0
  104. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/__init__.py +0 -0
  105. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  106. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  107. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  108. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
  109. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/transports/__init__.py +0 -0
  110. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/transports/base.py +0 -0
  111. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  112. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  113. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/__init__.py +0 -0
  114. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/fleet.py +0 -0
  115. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/health.py +0 -0
  116. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/links.py +0 -0
  117. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/server.py +0 -0
  118. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/static/index.html +0 -0
  119. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/status.py +0 -0
  120. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/transcript.py +0 -0
  121. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/src/ctrlrelay/web/waiting.py +0 -0
  122. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/__init__.py +0 -0
  123. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/_publish_fakes.py +0 -0
  124. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/fixtures/gh_search_issues.json +0 -0
  125. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
  126. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/fixtures/transcript_sample.jsonl +0 -0
  127. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_agent_no_keys.py +0 -0
  128. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_agent_sandbox.py +0 -0
  129. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_audit.py +0 -0
  130. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_binary_resolution.py +0 -0
  131. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_bridge_protocol.py +0 -0
  132. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_bridge_server.py +0 -0
  133. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_bridge_start_command.py +0 -0
  134. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_chat_machines.py +0 -0
  135. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_checkpoint.py +0 -0
  136. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_claim_reconciliation.py +0 -0
  137. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_cli_ci_wait.py +0 -0
  138. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_cli_dev.py +0 -0
  139. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_cli_repos.py +0 -0
  140. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_cli_secops.py +0 -0
  141. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_cli_start.py +0 -0
  142. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_cli_version.py +0 -0
  143. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_code_review.py +0 -0
  144. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_config.py +0 -0
  145. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_dashboard_client.py +0 -0
  146. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_dev_integration.py +0 -0
  147. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_dev_pipeline.py +0 -0
  148. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_dispatch_health.py +0 -0
  149. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_dispatch_outcome_hook.py +0 -0
  150. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_dispatcher.py +0 -0
  151. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_docs_site.py +0 -0
  152. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_event_emission.py +0 -0
  153. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_events.py +0 -0
  154. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_fleet.py +0 -0
  155. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_gh_protocol.py +0 -0
  156. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_github.py +0 -0
  157. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_github_budget.py +0 -0
  158. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_issue_outcome_announcement.py +0 -0
  159. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_job_claims.py +0 -0
  160. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_mattermost_handler.py +0 -0
  161. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_network.py +0 -0
  162. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_node_check.py +0 -0
  163. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_node_claims.py +0 -0
  164. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_notify_transport.py +0 -0
  165. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_obs.py +0 -0
  166. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_personalization.py +0 -0
  167. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_pipeline_base.py +0 -0
  168. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_post_merge.py +0 -0
  169. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_pr_verifier.py +0 -0
  170. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_pr_watcher.py +0 -0
  171. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_pre_dispatch_retry.py +0 -0
  172. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_publish_failure_retry.py +0 -0
  173. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_question_backoff.py +0 -0
  174. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_question_expiry.py +0 -0
  175. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_redact.py +0 -0
  176. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_relay_alert_off_lock.py +0 -0
  177. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_resume_prompt_contract.py +0 -0
  178. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_scheduler.py +0 -0
  179. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_secops_integration.py +0 -0
  180. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_secops_pipeline.py +0 -0
  181. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_state.py +0 -0
  182. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_task_pipeline.py +0 -0
  183. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_telegram_handler.py +0 -0
  184. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_transport.py +0 -0
  185. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_transport_resolution.py +0 -0
  186. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_unreviewed_pr_announcement.py +0 -0
  187. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_usage.py +0 -0
  188. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_web_health.py +0 -0
  189. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_web_links.py +0 -0
  190. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_web_status.py +0 -0
  191. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_web_transcript.py +0 -0
  192. {ctrlrelay-0.16.4 → ctrlrelay-0.17.1}/tests/test_web_waiting.py +0 -0
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.1] - 2026-11-01
11
+
12
+ ### Fixed
13
+
14
+ - Installed bridge and poller units carry the token variable the configured
15
+ transport names, not a fixed Telegram one. `ctrlrelay install
16
+ launchd|systemd` and `ctrlrelay setup --install-daemons` read
17
+ `transport.<type>.bot_token_env` and render `Environment=<that
18
+ name>=<value>` (systemd) or the matching plist entry (launchd), with the
19
+ value taken from that variable in the installing shell. A Mattermost
20
+ install previously got a unit naming `CTRLRELAY_TELEGRAM_TOKEN`, which it
21
+ never sets, so the bridge could not log in once systemd or launchd started
22
+ it — the server here only worked because the line had been added by hand.
23
+ The unresolved-variable hint names the same variable, and the install
24
+ commands take `--config` to say which `orchestrator.yaml` to read
25
+ (closes #267)
26
+ - Updating a repository's clone no longer deletes local branches GitHub does
27
+ not have. The fetch paired `--prune` with `refs/heads/*:refs/heads/*`, which
28
+ tells git to delete every local branch missing from origin: a branch made in
29
+ the clone went with `- [deleted] (none) -> held`. The worktree stayed,
30
+ but its branch ref was gone, so a blocked session whose branch was never
31
+ pushed lost the branch the next time any run touched that repo, and its
32
+ commits could later be garbage-collected. The fetch is now two: the
33
+ `dependabot/*` branches, which never carry local work and are the refs that
34
+ actually pile up, are still pruned; every other branch is fetched without
35
+ `--prune`. The cost is that a branch GitHub deleted now lingers in the
36
+ clone, since a prune cannot tell it from one GitHub never had — the
37
+ branch-reuse path already recognises a local-only ref and decides whether
38
+ its commits are merged. Because a renamed default branch is no longer
39
+ pruned either, each update also asks GitHub which branch is the default
40
+ and points the clone at it when it changed and the branch here is at origin's
41
+ commit (#243).
42
+
43
+ ## [0.17.0] - 2026-11-01
44
+
45
+ Polling costs one GitHub search instead of one call per repo. **No config
46
+ edit is required.** Check one thing if you use `include_labels`: a trigger
47
+ label now starts a run only when the configured user added it.
48
+
49
+ ### Changed
50
+
51
+ - Each poll asks GitHub's search once for open issues assigned to the user
52
+ across all configured repos, plus once per distinct `include_labels`
53
+ label, instead of one `gh issue list` per repo and one "is it archived"
54
+ probe per repo. Measured on 87 repos: 1 call per poll, down from about
55
+ 174. A repo is probed for "archived" only when it has an issue not yet
56
+ handled, and a "not archived" answer is kept for 6 hours (#283, closes
57
+ #282)
58
+ - A search GitHub marks incomplete, or that returns fewer rows than its
59
+ total, fails the poll as "could not look": nothing is marked seen or
60
+ treated as new. A startup seed that could not look is finished before
61
+ the next poll, so issues already there are never read as new (#283)
62
+ - A trigger label (`include_labels`) starts a run only when the configured
63
+ user added it, whatever `accept_foreign_assignments` says; the issue is
64
+ still taken if the user also assigned it to themselves. Before, anyone
65
+ who could label an issue could start a run (#283)
66
+ - The post-merge PR watcher and the status page's GitHub check
67
+ (`web.github_check_seconds`) default to 300 s, from 60 s (#283)
68
+ - GitHub logins compare without regard to case in the assignment and label
69
+ checks (#283)
70
+
10
71
  ## [0.16.4] - 2026-11-01
11
72
 
12
73
  Fixes for runs whose work could not be published. **No config edit is
@@ -2048,7 +2109,9 @@ pipeline).
2048
2109
  per-phase implementation plans (Phase 0 through Phase 4).
2049
2110
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
2050
2111
 
2051
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...HEAD
2112
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.1...HEAD
2113
+ [0.17.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.17.0...v0.17.1
2114
+ [0.17.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.4...v0.17.0
2052
2115
  [0.16.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...v0.16.4
2053
2116
  [0.16.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.2...v0.16.3
2054
2117
  [0.16.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.1...v0.16.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.16.4
3
+ Version: 0.17.1
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/
@@ -68,7 +68,7 @@ Key flags:
68
68
  | `--telegram-chat-id ID` | none | Required for `--transport=telegram`. |
69
69
  | `--personalization-repo OWNER/REPO` | none | Adds a personalization block. |
70
70
  | `--no-personalization` | off | Skip the personalization prompt entirely. |
71
- | `--install-daemons` | (prompted) | Render and write launchd/systemd unit files. Reads `$CTRLRELAY_TELEGRAM_TOKEN` so the rendered plist isn't a placeholder. |
71
+ | `--install-daemons` | (prompted) | Render and write launchd/systemd unit files. Reads the token variable the chosen transport names (`$CTRLRELAY_TELEGRAM_TOKEN`, `$CTRLRELAY_MATTERMOST_TOKEN`) so the rendered unit isn't a placeholder. |
72
72
  | `--skip-archived/--include-archived` | skip | gh repo list filter. |
73
73
  | `--skip-forks/--include-forks` | skip | gh repo list filter. |
74
74
  | `--yes` / `-y` | off | Accept every default; never prompt. |
@@ -404,7 +404,7 @@ web:
404
404
  port: 8787
405
405
  history_days: 7
406
406
  refresh_seconds: 5
407
- github_check_seconds: 60
407
+ github_check_seconds: 300
408
408
  stale_after_minutes: 15
409
409
  allowed_hosts: [] # e.g. ["mybox", "mybox.lan"]
410
410
  ```
@@ -415,7 +415,7 @@ web:
415
415
  | `port` | int (1-65535) | `8787` | Port to listen on. |
416
416
  | `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. |
417
417
  | `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
418
- | `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. |
419
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. |
420
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. |
421
421
 
@@ -623,21 +623,25 @@ repos:
623
623
  - An issue is accepted when **either** (a) it's assigned to the configured
624
624
  user (subject to the self-assignment filter from #79 and
625
625
  `accept_foreign_assignments`) **or** (b) it carries any label in
626
- `include_labels`. A label match **skips** the self-assignment check — the
627
- 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.
628
630
  - Dedup: an issue that is **both** labeled and assigned is picked up exactly
629
631
  once per poll cycle — no duplicate entries in `seen_issues` and no double
630
632
  pipeline spawn.
631
633
  - `exclude_labels` always wins over `include_labels` on the same issue: an
632
634
  explicit "not for the agent" opt-OUT beats the generic label opt-IN.
633
- - When a repo configures `include_labels`, the poller runs **targeted**
634
- queries per cycle: the existing `gh issue list --assignee <user>` plus
635
- one `gh issue list --label <L>` call per configured label. Results merge
636
- by issue number. This keeps the label path scale-safe on busy repos
637
- where an unfiltered fetch would silently cap at gh's `--limit` and miss
638
- labeled issues on later pages. Repos without `include_labels` run only
639
- the cheap `--assignee` query, so enabling the feature on one repo does
640
- 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.
641
645
  - The event log entry for a label-triggered acceptance is
642
646
  `poll.issue.included_by_label`, alongside the existing
643
647
  `poll.issue.excluded_by_label` for exclusions.
@@ -34,12 +34,19 @@ and restart on failure — the templates below already do this.
34
34
 
35
35
  Recommended: let `ctrlrelay install launchd` render the plists for you.
36
36
  It substitutes `${USER}`, `${HOME}`, the absolute `ctrlrelay` path,
37
- your working directory, and (when exported) `CTRLRELAY_TELEGRAM_TOKEN`
38
- into the in-package templates and writes them to
39
- `~/Library/LaunchAgents/`.
37
+ your working directory, and (when exported) your chat token into the
38
+ in-package templates and writes them to `~/Library/LaunchAgents/`.
39
+
40
+ The token goes into the plist under the variable your transport names
41
+ in `transport.<type>.bot_token_env` — `CTRLRELAY_TELEGRAM_TOKEN` for
42
+ Telegram, `CTRLRELAY_MATTERMOST_TOKEN` for Mattermost, or whatever you
43
+ renamed that setting to. The command reads your `orchestrator.yaml` to
44
+ find out which; pass `--config` if it is somewhere the usual search
45
+ does not look. Export that variable before running, or the plist keeps
46
+ a placeholder and the command says which variable to export.
40
47
 
41
48
  ```bash
42
- export CTRLRELAY_TELEGRAM_TOKEN=your-bot-token
49
+ export CTRLRELAY_MATTERMOST_TOKEN=your-bot-token # or _TELEGRAM_, per your config
43
50
  ctrlrelay install launchd \
44
51
  --workdir ~/Projects/ctrlrelay \
45
52
  --label-prefix com.yourname \
@@ -158,8 +165,13 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.ctrlrelay-po
158
165
  Recommended: `ctrlrelay install systemd` renders the user unit files
159
166
  for you (no root needed) and writes them to `~/.config/systemd/user/`.
160
167
 
168
+ As on macOS, the bridge and poller units carry the token under the
169
+ variable `transport.<type>.bot_token_env` names, read from your
170
+ `orchestrator.yaml` (`--config` to point at a different one). Export
171
+ that variable before running.
172
+
161
173
  ```bash
162
- export CTRLRELAY_TELEGRAM_TOKEN=your-bot-token
174
+ export CTRLRELAY_MATTERMOST_TOKEN=your-bot-token # or _TELEGRAM_, per your config
163
175
  ctrlrelay install systemd \
164
176
  --workdir ~/Projects/ctrlrelay \
165
177
  --poller-interval 300
@@ -190,6 +202,8 @@ After=network-online.target
190
202
  [Service]
191
203
  Type=simple
192
204
  WorkingDirectory=%h/Projects/ctrlrelay
205
+ # The variable your transport names in bot_token_env — use
206
+ # CTRLRELAY_MATTERMOST_TOKEN on a Mattermost config.
193
207
  Environment=CTRLRELAY_TELEGRAM_TOKEN=your-bot-token-here
194
208
  ExecStart=%h/.local/bin/ctrlrelay bridge start --foreground
195
209
  Restart=always
@@ -211,6 +225,8 @@ After=network-online.target ctrlrelay-bridge.service
211
225
  [Service]
212
226
  Type=simple
213
227
  WorkingDirectory=%h/Projects/ctrlrelay
228
+ # The variable your transport names in bot_token_env — use
229
+ # CTRLRELAY_MATTERMOST_TOKEN on a Mattermost config.
214
230
  Environment=CTRLRELAY_TELEGRAM_TOKEN=your-bot-token-here
215
231
  ExecStart=%h/.local/bin/ctrlrelay poller start --foreground --interval 300
216
232
  Restart=always
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.16.4"
3
+ version = "0.17.1"
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"
@@ -5052,6 +5052,12 @@ def setup(
5052
5052
  console.print(" daemon unit files:")
5053
5053
  for p in result.daemon_units:
5054
5054
  console.print(f" - {p}")
5055
+ if result.daemon_unresolved:
5056
+ console.print(
5057
+ " [yellow]warning:[/yellow] the units still carry "
5058
+ f"{', '.join('${'+v+'}' for v in result.daemon_unresolved)}; "
5059
+ "export it and re-run with --force, or edit the files by hand."
5060
+ )
5055
5061
  console.print(
5056
5062
  "\n[bold]Next:[/bold] bootstrap the daemons via:\n"
5057
5063
  " launchctl bootstrap gui/$(id -u) <plist>"
@@ -5463,13 +5469,50 @@ def _print_install_summary(
5463
5469
  f" [yellow]warning:[/yellow] unresolved template variables: "
5464
5470
  f"{', '.join('${'+v+'}' for v in unit.unresolved)}"
5465
5471
  )
5466
- if "CTRLRELAY_TELEGRAM_TOKEN" in unit.unresolved:
5472
+ # Name the variable this install actually needs. It is
5473
+ # whatever `transport.<type>.bot_token_env` says, so hinting
5474
+ # a fixed CTRLRELAY_TELEGRAM_TOKEN sent a Mattermost
5475
+ # operator to export a variable nothing reads (#267).
5476
+ if unit.token_env_var in unit.unresolved:
5467
5477
  console.print(
5468
- " [dim]hint: export CTRLRELAY_TELEGRAM_TOKEN before "
5478
+ f" [dim]hint: export {unit.token_env_var} before "
5469
5479
  "re-running, or edit the file by hand.[/dim]"
5470
5480
  )
5471
5481
 
5472
5482
 
5483
+ def _install_token_env_var(config_path: str | None) -> str:
5484
+ """Which env var the rendered units should carry the chat token in.
5485
+
5486
+ Reads the active transport's ``bot_token_env`` so a Mattermost box
5487
+ gets its own variable (#267). Exits non-zero on a config that was
5488
+ asked for and cannot be loaded: rendering the Telegram default on
5489
+ the strength of a path the operator got wrong would write a unit for
5490
+ a transport they are not running. Finding no config at all is a
5491
+ different case — nothing names a transport, so the historical
5492
+ default stands and the summary flags it as unresolved.
5493
+ """
5494
+ from ctrlrelay.install import DEFAULT_TOKEN_ENV_VAR, token_env_var_from_config
5495
+
5496
+ if config_path is None:
5497
+ try:
5498
+ path = resolve_config_path(None)
5499
+ except ConfigError:
5500
+ console.print(
5501
+ f"[yellow]No orchestrator.yaml found, so nothing names the chat "
5502
+ f"transport: the units carry ${DEFAULT_TOKEN_ENV_VAR}. Pass "
5503
+ "--config to use your transport's token variable.[/yellow]"
5504
+ )
5505
+ return DEFAULT_TOKEN_ENV_VAR
5506
+ else:
5507
+ path = resolve_config_path(config_path)
5508
+ try:
5509
+ config = load_config(path)
5510
+ except ConfigError as e:
5511
+ console.print(f"[red]Error loading config:[/red] {e}")
5512
+ raise typer.Exit(1)
5513
+ return token_env_var_from_config(config)
5514
+
5515
+
5473
5516
  @install_app.command("launchd")
5474
5517
  def install_launchd(
5475
5518
  workdir: Path = typer.Option(
@@ -5499,9 +5542,18 @@ def install_launchd(
5499
5542
  "--dry-run",
5500
5543
  help="Print what would be written without touching the filesystem.",
5501
5544
  ),
5545
+ config_path: str | None = typer.Option(
5546
+ None,
5547
+ "--config",
5548
+ "-c",
5549
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
5550
+ ),
5502
5551
  ) -> None:
5503
5552
  """Render and install bridge + poller plists for macOS launchd.
5504
5553
 
5554
+ The plists carry the chat token in the variable the configured
5555
+ transport names, so export that variable before running.
5556
+
5505
5557
  After running, load the agents with:
5506
5558
  launchctl bootstrap gui/$(id -u) <plist-path>
5507
5559
  """
@@ -5512,6 +5564,7 @@ def install_launchd(
5512
5564
  label_prefix=label_prefix,
5513
5565
  poller_interval=poller_interval,
5514
5566
  target_dir=target_dir,
5567
+ token_env_var=_install_token_env_var(config_path),
5515
5568
  )
5516
5569
  written: list[Path] = []
5517
5570
  if not dry_run:
@@ -5547,9 +5600,18 @@ def install_systemd(
5547
5600
  "--dry-run",
5548
5601
  help="Print what would be written without touching the filesystem.",
5549
5602
  ),
5603
+ config_path: str | None = typer.Option(
5604
+ None,
5605
+ "--config",
5606
+ "-c",
5607
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
5608
+ ),
5550
5609
  ) -> None:
5551
5610
  """Render and install bridge, poller and status-page systemd user units (Linux).
5552
5611
 
5612
+ The bridge and poller units carry the chat token in the variable the
5613
+ configured transport names, so export that variable before running.
5614
+
5553
5615
  After running, enable with:
5554
5616
  systemctl --user daemon-reload
5555
5617
  systemctl --user enable --now ctrlrelay-bridge.service
@@ -5562,6 +5624,7 @@ def install_systemd(
5562
5624
  workdir=workdir,
5563
5625
  poller_interval=poller_interval,
5564
5626
  target_dir=target_dir,
5627
+ token_env_var=_install_token_env_var(config_path),
5565
5628
  )
5566
5629
  written: list[Path] = []
5567
5630
  if not dry_run:
@@ -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