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.
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/CHANGELOG.md +63 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/PKG-INFO +1 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/configuration.md +31 -13
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/operations.md +24 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/pyproject.toml +1 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/cli.py +134 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/archived.py +16 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/config.py +1 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/github.py +106 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/poller.py +304 -47
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/pr_watcher.py +3 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/worktree.py +185 -12
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/dev.py +126 -4
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/post_merge.py +2 -2
- ctrlrelay-0.17.0/tests/_poller_fakes.py +38 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/conftest.py +24 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_agent_no_keys.py +205 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_archived.py +17 -8
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_poller.py +13 -260
- ctrlrelay-0.17.0/tests/test_poller_search.py +291 -0
- ctrlrelay-0.17.0/tests/test_publish_failure_retry.py +438 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_server.py +1 -1
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_worktree.py +206 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/.gitignore +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/LICENSE +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/README.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/SECURITY.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/cli.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/development.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/index.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.16.3 → ctrlrelay-0.17.0}/tests/test_web_transcript.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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:
|
|
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) | `
|
|
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
|
|
613
|
-
|
|
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
|
-
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
by
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
|
@@ -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=
|
|
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
|
|