ctrlrelay 0.16.4__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.4 → ctrlrelay-0.17.0}/CHANGELOG.md +30 -1
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/PKG-INFO +1 -1
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/configuration.md +16 -12
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/pyproject.toml +1 -1
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/archived.py +16 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/config.py +1 -1
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/github.py +106 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/poller.py +244 -40
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/pr_watcher.py +3 -1
- {ctrlrelay-0.16.4 → 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.4 → ctrlrelay-0.17.0}/tests/conftest.py +24 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_archived.py +17 -8
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_poller.py +13 -260
- ctrlrelay-0.17.0/tests/test_poller_search.py +291 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_server.py +1 -1
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/.gitignore +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/LICENSE +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/README.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/SECURITY.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/adding-a-machine.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/cli.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/development.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/index.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/operations.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/superpowers/plans/2026-10-30-status-page.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/docs/superpowers/specs/2026-10-30-status-page-design.md +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/node_tag.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/agent_env.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/binaries.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/claims.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/events.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/github_budget.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/heartbeat.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/job_claims.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/node_check.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/publish.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/redact.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/usage.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/templates/systemd/web.service.template +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/fleet.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/health.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/links.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/server.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/static/index.html +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/status.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/transcript.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/src/ctrlrelay/web/waiting.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/_publish_fakes.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/fixtures/gh_search_issues.json +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/fixtures/stream_json_rate_limit.jsonl +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/fixtures/transcript_sample.jsonl +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_agent_no_keys.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_agent_sandbox.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_binary_resolution.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_bridge_start_command.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_chat_machines.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_claim_reconciliation.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dispatch_health.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dispatch_outcome_hook.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_event_emission.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_events.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_fleet.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_github_budget.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_issue_outcome_announcement.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_job_claims.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_network.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_node_check.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_node_claims.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_notify_transport.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_pre_dispatch_retry.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_publish_failure_retry.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_question_backoff.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_redact.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_relay_alert_off_lock.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_resume_prompt_contract.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_transport.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_unreviewed_pr_announcement.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_usage.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_health.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_links.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_status.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_transcript.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_web_waiting.py +0 -0
- {ctrlrelay-0.16.4 → ctrlrelay-0.17.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,34 @@ 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
|
+
|
|
10
38
|
## [0.16.4] - 2026-11-01
|
|
11
39
|
|
|
12
40
|
Fixes for runs whose work could not be published. **No config edit is
|
|
@@ -2048,7 +2076,8 @@ pipeline).
|
|
|
2048
2076
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
2049
2077
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
2050
2078
|
|
|
2051
|
-
[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
|
|
2052
2081
|
[0.16.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.3...v0.16.4
|
|
2053
2082
|
[0.16.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.16.2...v0.16.3
|
|
2054
2083
|
[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.
|
|
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/
|
|
@@ -404,7 +404,7 @@ web:
|
|
|
404
404
|
port: 8787
|
|
405
405
|
history_days: 7
|
|
406
406
|
refresh_seconds: 5
|
|
407
|
-
github_check_seconds:
|
|
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) | `
|
|
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
|
|
627
|
-
|
|
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
|
-
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
by
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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.
|
|
@@ -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
|
|