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