ctrlrelay 0.5.0__tar.gz → 0.8.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.5.0 → ctrlrelay-0.8.0}/.github/workflows/build.yml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/pages.yml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/publish.yml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/test.yml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/CHANGELOG.md +108 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/PKG-INFO +2 -2
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/config/orchestrator.yaml.example +13 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/configuration.md +63 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/pyproject.toml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/cli.py +19 -5
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/config.py +17 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/poller.py +74 -2
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/state.py +74 -0
- ctrlrelay-0.8.0/src/ctrlrelay/gh_protocol.py +47 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/install.py +11 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/manager.py +8 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/dev.py +4 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/secops.py +218 -2
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/setup.py +11 -7
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_config.py +160 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_dev_pipeline.py +59 -0
- ctrlrelay-0.8.0/tests/test_gh_protocol.py +65 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_install.py +39 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_personalization.py +27 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_poller.py +150 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_secops_pipeline.py +485 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_setup.py +140 -6
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_state.py +129 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_transport.py +4 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/.gitignore +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/LICENSE +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/README.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/SECURITY.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/cli.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/development.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/index.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/operations.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.8.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-09-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`ci_wait_timeout_seconds`: per-repo cap on the dev pipeline's
|
|
15
|
+
`ctrlrelay ci wait` call.** The dev pipeline prompt tells Claude to run
|
|
16
|
+
`ctrlrelay ci wait --pr <PR> --repo <repo> --timeout 600` before
|
|
17
|
+
signaling DONE — a single blocking call, hardcoded to 10 minutes, that
|
|
18
|
+
generates zero tokens while it polls GitHub. Measured directly on a real
|
|
19
|
+
session: two `ci wait` calls accounted for 12 of a 26-minute run, versus
|
|
20
|
+
under 35K output tokens generated the whole time — the wall-clock cost
|
|
21
|
+
is CI runtime, not model work. `ci_wait_timeout_seconds` (default `600`,
|
|
22
|
+
unchanged behavior) lets a repo with a faster CI suite cap that single
|
|
23
|
+
call lower instead of always budgeting the full 10 minutes.
|
|
24
|
+
|
|
25
|
+
## [0.7.0] - 2026-09-04
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`require_labels`: an AND-gate between assignment and a label,
|
|
30
|
+
closing the gap `include_labels` (#80) couldn't cover.** `exclude_labels`
|
|
31
|
+
is a block-list and `include_labels` is an OR-alternate trigger, but
|
|
32
|
+
neither could express "only pick this up if it's assigned to me AND
|
|
33
|
+
carries a specific label." On a repo with a long history of
|
|
34
|
+
self-assigned issues used as personal reminders, that meant the poller
|
|
35
|
+
replayed the whole backlog as new work the first time it saw the repo —
|
|
36
|
+
including issues that weren't code tasks at all. `require_labels`
|
|
37
|
+
(default `[]`, no behavior change) tightens the plain-assignment path
|
|
38
|
+
only: when set, an issue needs both the assignment and a matching label.
|
|
39
|
+
`include_labels` keeps triggering on a label match alone regardless.
|
|
40
|
+
An assigned issue missing the required label is left unmarked so a
|
|
41
|
+
later label addition still surfaces it. See #139.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **`ctrlrelay setup --install-daemons` on a fresh SSH-only machine:
|
|
46
|
+
personalization clone ignored the configured git protocol, and
|
|
47
|
+
generated daemon units crash-looped.** The personalization repo
|
|
48
|
+
clone (setup pre-scan and `PersonalizationManager.init`) hardcoded
|
|
49
|
+
an HTTPS URL regardless of `gh`'s configured `git_protocol`,
|
|
50
|
+
so SSH-only operators hit `could not read Username` with the
|
|
51
|
+
failure buried in a single warning line while setup still exited
|
|
52
|
+
0. `ctrlrelay.gh_protocol` now reads `gh`'s configured protocol and
|
|
53
|
+
builds the matching clone URL; a personalization failure flips
|
|
54
|
+
`SetupResult.personalization_failed` so the CLI prints an ACTION
|
|
55
|
+
NEEDED banner and exits non-zero instead of silently continuing.
|
|
56
|
+
Separately, generated launchd/systemd units point
|
|
57
|
+
`StandardOutput`/`StandardError` at `~/.ctrlrelay/logs/`, which
|
|
58
|
+
setup never created — systemd refuses to start a unit with a
|
|
59
|
+
missing log dir (`EXIT_STDOUT`, 209), and with `Restart=always`
|
|
60
|
+
that's an immediate crash-loop. `write_units` now creates the log
|
|
61
|
+
dir up front and chmods unit files `0600` since they embed
|
|
62
|
+
`CTRLRELAY_TELEGRAM_TOKEN` in plaintext. See #137.
|
|
63
|
+
|
|
64
|
+
## [0.6.0] - 2026-06-10
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- **Secops sweeps now remember operator decisions across days.** The
|
|
69
|
+
`automation_decisions` table had a schema but no producer or consumer
|
|
70
|
+
— every 6am sweep re-asked about the same Dependabot PRs the operator
|
|
71
|
+
had already answered, drowning Telegram. Wired both halves:
|
|
72
|
+
1. When the BLOCKED loop in `run_secops_all` (or the out-of-band
|
|
73
|
+
`resume_secops_from_pending` sweeper) receives an answer, regex
|
|
74
|
+
extracts every PR# from the question and records one row per PR#
|
|
75
|
+
keyed on `(repo, "dependabot_pr", "#N")` with the operator's
|
|
76
|
+
verbatim answer. PR# regex handles both `PR #60` and bare `#60`
|
|
77
|
+
forms, dedupes, and tolerates questions with no PR# (e.g.
|
|
78
|
+
CodeQL-only) by writing nothing.
|
|
79
|
+
2. Before building each sweep's prompt, `run_secops_all` pulls the
|
|
80
|
+
last 30 days of decisions for that repo (namespaced to
|
|
81
|
+
`operation="dependabot_pr"` so e.g. CodeQL-suppression decisions
|
|
82
|
+
don't leak into the Dependabot prompt) and threads them via
|
|
83
|
+
`ctx.extra['prior_decisions']` into `_build_prompt`, which renders
|
|
84
|
+
a `## Prior operator decisions (last 30 days)` block listing each
|
|
85
|
+
decision verbatim alongside the original question snippet so the
|
|
86
|
+
agent can detect a force-pushed PR that swapped the version bump
|
|
87
|
+
under the same PR number. The block carries instructions to act
|
|
88
|
+
on prior answers unless circumstances have materially changed
|
|
89
|
+
(different version bump, CI state flipped). Without this the
|
|
90
|
+
persistence layer was dead weight.
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- **Secops handles "no CI configured" without inventing options.**
|
|
95
|
+
Repos with no `.github/workflows/*` previously confused the agent
|
|
96
|
+
into freelance suggestions ("Want me to batch into a review PR?")
|
|
97
|
+
because the "auto-merge with passing CI" gate couldn't fire. Prompt
|
|
98
|
+
now explicitly directs: treat ALL Dependabot PRs as ASK regardless
|
|
99
|
+
of tier, and signal BLOCKED ONCE with a single consolidated question
|
|
100
|
+
per repo (not one per PR).
|
|
101
|
+
- **Secops escalates pre-existing CI failures instead of stalling PRs.**
|
|
102
|
+
When CI is failing on a check unrelated to the PR's package (e.g.
|
|
103
|
+
`pip-audit` flagging a transitive CVE in `idna` while the PR bumps
|
|
104
|
+
`boto3`), patch PRs were sitting stuck for weeks because the agent
|
|
105
|
+
reported the failure and moved on. Prompt now requires the agent to
|
|
106
|
+
signal BLOCKED with a one-line question naming the underlying issue
|
|
107
|
+
and a root-cause hint from `gh run view --log-failed`.
|
|
108
|
+
- **Secops scope discipline.** The prompt now enforces "exactly three
|
|
109
|
+
legitimate exits" per open PR (merge / leave / BLOCKED) and
|
|
110
|
+
explicitly forbids batching PRs into review PRs, opening tracking
|
|
111
|
+
issues, or performing manual reviews. Cuts off the freelance
|
|
112
|
+
options observed in production sweeps.
|
|
113
|
+
|
|
10
114
|
## [0.5.0] - 2026-05-11
|
|
11
115
|
|
|
12
116
|
Minor release. Three changes on the secops path; together they take the
|
|
@@ -1143,7 +1247,10 @@ pipeline).
|
|
|
1143
1247
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1144
1248
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1145
1249
|
|
|
1146
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
1250
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.0...HEAD
|
|
1251
|
+
[0.8.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...v0.8.0
|
|
1252
|
+
[0.7.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.6.0...v0.7.0
|
|
1253
|
+
[0.6.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.5.0...v0.6.0
|
|
1147
1254
|
[0.1.3]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.3
|
|
1148
1255
|
[0.1.1]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.1
|
|
1149
1256
|
[0.1.0]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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/
|
|
@@ -139,3 +139,16 @@ repos: []
|
|
|
139
139
|
# # Matching is case-insensitive. Defaults to ["manual", "operator",
|
|
140
140
|
# # "instruction"]; override with an empty list to disable.
|
|
141
141
|
# exclude_labels: ["manual", "operator", "instruction"]
|
|
142
|
+
# # Gates the plain-assignment trigger: when set, assignment alone
|
|
143
|
+
# # is no longer enough on its own — an issue must ALSO carry at
|
|
144
|
+
# # least one of these labels to be picked up. Does not affect
|
|
145
|
+
# # include_labels, which still triggers on a label match alone.
|
|
146
|
+
# # Matching is case-insensitive. Defaults to [] (assignment alone
|
|
147
|
+
# # is sufficient, same as today).
|
|
148
|
+
# require_labels: []
|
|
149
|
+
# # Hard cap, in seconds, on the single `ctrlrelay ci wait` call the
|
|
150
|
+
# # dev pipeline prompt runs before signaling DONE. Lower this to
|
|
151
|
+
# # match how long this repo's CI actually takes — no tokens are
|
|
152
|
+
# # generated while blocked in this call, so a slow default here
|
|
153
|
+
# # just adds idle wall-clock time. Defaults to 600 (10 minutes).
|
|
154
|
+
# ci_wait_timeout_seconds: 600
|
|
@@ -200,6 +200,8 @@ and ask the operator), or `never` (skip).
|
|
|
200
200
|
| `accept_foreign_assignments` | `false` | When `true`, the poller also picks up issues assigned to you by someone else. Default (`false`) runs the dev pipeline only on issues you self-assigned. |
|
|
201
201
|
| `exclude_labels` | `["manual", "operator", "instruction"]` | Issue labels that tell the poller "this isn't for the agent". See [exclude_labels](#reposautomationexclude_labels) below. |
|
|
202
202
|
| `include_labels` | `[]` | Issue labels that opt an issue **into** the dev pipeline regardless of who is (or isn't) assigned. See [include_labels](#reposautomationinclude_labels) below. |
|
|
203
|
+
| `require_labels` | `[]` | Issue labels that gate the plain-assignment trigger: when set, assignment alone is no longer enough — the issue must also carry one of these labels. See [require_labels](#reposautomationrequire_labels) below. |
|
|
204
|
+
| `ci_wait_timeout_seconds` | `600` | Hard cap, in seconds, on the single `ctrlrelay ci wait` call the dev pipeline prompt tells Claude to run before signaling DONE. See [ci_wait_timeout_seconds](#reposautomationci_wait_timeout_seconds) below. |
|
|
203
205
|
|
|
204
206
|
The current secops and dev pipelines read these settings to bias their prompts
|
|
205
207
|
to Claude — they're not enforced by hard-coded checks.
|
|
@@ -308,6 +310,67 @@ triage access was already able to push branches and trigger CI, so allowing
|
|
|
308
310
|
them to opt an issue into the dev pipeline is a narrower extension, not a new
|
|
309
311
|
vector.
|
|
310
312
|
|
|
313
|
+
### repos[].automation.require_labels
|
|
314
|
+
|
|
315
|
+
Assignment-only ends up too loose on repos with a long history: issues
|
|
316
|
+
self-assigned as personal reminders or notes, unrelated to automation, get
|
|
317
|
+
replayed into the dev pipeline the first time the poller sees the repo (or
|
|
318
|
+
after any config change that clears `poller_state.json` for it). Some of
|
|
319
|
+
those issues aren't code tasks at all — "create an account with a
|
|
320
|
+
third-party service", "look into pricing" — and shouldn't go anywhere near
|
|
321
|
+
an unsupervised `claude -p ... --dangerously-skip-permissions` run.
|
|
322
|
+
|
|
323
|
+
`require_labels` closes that gap: when configured, bare assignment is no
|
|
324
|
+
longer sufficient by itself. An issue must be **both** assigned to the
|
|
325
|
+
operator **and** carry at least one of the configured labels to be picked
|
|
326
|
+
up via the assignment path.
|
|
327
|
+
|
|
328
|
+
```yaml
|
|
329
|
+
repos:
|
|
330
|
+
- name: "your-org/your-repo"
|
|
331
|
+
local_path: "~/Projects/your-repo"
|
|
332
|
+
automation:
|
|
333
|
+
require_labels: ["ctrlrelay:auto"]
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
- Default: `[]`. An empty list preserves today's behavior — assignment
|
|
337
|
+
alone is sufficient, no change for operators who haven't opted in.
|
|
338
|
+
- Matching is **case-insensitive**, same as `exclude_labels` / `include_labels`.
|
|
339
|
+
- This only tightens the **assignment** path. `include_labels` is
|
|
340
|
+
unaffected — a label match there still admits the issue regardless of
|
|
341
|
+
`require_labels`, since that path already treats the label as its own
|
|
342
|
+
trust signal.
|
|
343
|
+
- An issue that's assigned but missing the required label is left
|
|
344
|
+
**unmarked** (not added to `seen_issues`), so applying the label later
|
|
345
|
+
still surfaces it on a subsequent poll — nothing is lost, it's just not
|
|
346
|
+
picked up yet.
|
|
347
|
+
- `exclude_labels` is still checked first, same precedence as always.
|
|
348
|
+
|
|
349
|
+
### repos[].automation.ci_wait_timeout_seconds
|
|
350
|
+
|
|
351
|
+
The dev pipeline prompt tells Claude to run `ctrlrelay ci wait --pr <PR>
|
|
352
|
+
--repo <repo> --timeout <N>` before signaling DONE — a single blocking call
|
|
353
|
+
that polls GitHub every 15s until CI finishes, fails, or the timeout hits.
|
|
354
|
+
No tokens get generated while it's blocked; on a repo with a slow CI suite,
|
|
355
|
+
that one call can eat most of a session's wall-clock time.
|
|
356
|
+
|
|
357
|
+
```yaml
|
|
358
|
+
repos:
|
|
359
|
+
- name: "your-org/your-repo"
|
|
360
|
+
local_path: "~/Projects/your-repo"
|
|
361
|
+
automation:
|
|
362
|
+
ci_wait_timeout_seconds: 300
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
- Default: `600` (10 minutes).
|
|
366
|
+
- Lower it to match how long the repo's CI actually takes — a repo with a
|
|
367
|
+
2-minute CI suite gains nothing from a 10-minute cap.
|
|
368
|
+
- A timeout here isn't a failure: `ctrlrelay ci wait` exits 2 (not 1) when
|
|
369
|
+
the deadline hits with checks still pending, and the prompt tells Claude
|
|
370
|
+
to treat that as acceptable and hand off rather than loop.
|
|
371
|
+
- This does not change the overall session timeout (`agent.default_timeout_seconds`,
|
|
372
|
+
default 1800s) — it only bounds the one `ci wait` call.
|
|
373
|
+
|
|
311
374
|
## schedules
|
|
312
375
|
|
|
313
376
|
In-process cron jobs run by the poller daemon. All expressions are
|
|
@@ -748,6 +748,7 @@ def run_dev(
|
|
|
748
748
|
state_db=db,
|
|
749
749
|
transport=None,
|
|
750
750
|
contexts_dir=config.paths.contexts,
|
|
751
|
+
ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
|
|
751
752
|
)
|
|
752
753
|
|
|
753
754
|
try:
|
|
@@ -1055,6 +1056,9 @@ def poller_start(
|
|
|
1055
1056
|
include_labels_by_repo = {
|
|
1056
1057
|
r.name: list(r.automation.include_labels) for r in config.repos
|
|
1057
1058
|
}
|
|
1059
|
+
require_labels_by_repo = {
|
|
1060
|
+
r.name: list(r.automation.require_labels) for r in config.repos
|
|
1061
|
+
}
|
|
1058
1062
|
|
|
1059
1063
|
poller = IssuePoller(
|
|
1060
1064
|
github=github,
|
|
@@ -1064,6 +1068,7 @@ def poller_start(
|
|
|
1064
1068
|
accept_foreign_assignments=accept_foreign,
|
|
1065
1069
|
exclude_labels_by_repo=exclude_labels_by_repo,
|
|
1066
1070
|
include_labels_by_repo=include_labels_by_repo,
|
|
1071
|
+
require_labels_by_repo=require_labels_by_repo,
|
|
1067
1072
|
)
|
|
1068
1073
|
|
|
1069
1074
|
# NOTE: first-run seeding moved into `_main()` so the APScheduler
|
|
@@ -1269,6 +1274,7 @@ def poller_start(
|
|
|
1269
1274
|
state_db=state_db,
|
|
1270
1275
|
transport=connected_transport,
|
|
1271
1276
|
contexts_dir=config.paths.contexts,
|
|
1277
|
+
ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
|
|
1272
1278
|
)
|
|
1273
1279
|
|
|
1274
1280
|
# Lock-conflict retry hook. The poller marks issues seen
|
|
@@ -1631,6 +1637,7 @@ def poller_start(
|
|
|
1631
1637
|
if sweep_repo_cfg is not None
|
|
1632
1638
|
else None
|
|
1633
1639
|
),
|
|
1640
|
+
question=row.get("question"),
|
|
1634
1641
|
)
|
|
1635
1642
|
elif pipeline_name == "dev":
|
|
1636
1643
|
# Dev resume needs the repo's branch template
|
|
@@ -2486,6 +2493,14 @@ def setup(
|
|
|
2486
2493
|
)
|
|
2487
2494
|
if result.personalization_summary:
|
|
2488
2495
|
console.print(f" personalization: {result.personalization_summary}")
|
|
2496
|
+
if result.personalization_failed:
|
|
2497
|
+
console.print(
|
|
2498
|
+
"\n[bold red]ACTION NEEDED[/bold red] — personalization repo "
|
|
2499
|
+
"sync failed; the rest of setup completed but your "
|
|
2500
|
+
"personalization config/skills were not cloned. Check your "
|
|
2501
|
+
"git auth (`gh auth status`, `gh config get git_protocol`) "
|
|
2502
|
+
"and re-run `ctrlrelay personalization init` once it's fixed."
|
|
2503
|
+
)
|
|
2489
2504
|
if result.daemon_units:
|
|
2490
2505
|
console.print(" daemon unit files:")
|
|
2491
2506
|
for p in result.daemon_units:
|
|
@@ -2497,11 +2512,10 @@ def setup(
|
|
|
2497
2512
|
" systemctl --user daemon-reload && systemctl --user enable --now <unit>"
|
|
2498
2513
|
" # Linux"
|
|
2499
2514
|
)
|
|
2500
|
-
# Clone failures must surface as a
|
|
2501
|
-
# units were rendered — automation
|
|
2502
|
-
# mistake a partial setup for success.
|
|
2503
|
-
|
|
2504
|
-
if result.failed:
|
|
2515
|
+
# Clone and personalization-sync failures must surface as a
|
|
2516
|
+
# non-zero exit even when daemon units were rendered — automation
|
|
2517
|
+
# watching exit code shouldn't mistake a partial setup for success.
|
|
2518
|
+
if result.failed or result.personalization_failed:
|
|
2505
2519
|
raise typer.Exit(1)
|
|
2506
2520
|
|
|
2507
2521
|
|
|
@@ -183,6 +183,16 @@ class AutomationConfig(BaseModel):
|
|
|
183
183
|
# Matching is case-insensitive. An issue that is both labeled AND
|
|
184
184
|
# assigned is processed exactly once — not duplicated. See #80.
|
|
185
185
|
include_labels: list[str] = Field(default_factory=list)
|
|
186
|
+
# Labels that gate the plain-assignment trigger. Default ``[]``
|
|
187
|
+
# preserves today's behavior (assignment alone is sufficient).
|
|
188
|
+
# When non-empty, bare assignment is no longer enough on its own:
|
|
189
|
+
# an issue must be assigned to the operator AND carry at least one
|
|
190
|
+
# of these labels to be picked up. This only tightens the
|
|
191
|
+
# assignment path — ``include_labels`` keeps triggering on label
|
|
192
|
+
# match alone regardless of ``require_labels``, and
|
|
193
|
+
# ``exclude_labels`` is still checked first. Matching is
|
|
194
|
+
# case-insensitive. See #139.
|
|
195
|
+
require_labels: list[str] = Field(default_factory=list)
|
|
186
196
|
# Labels that route an issue to the task pipeline (run a command /
|
|
187
197
|
# investigate / report findings via issue comment) instead of the
|
|
188
198
|
# dev pipeline (branch + PR). Matching is case-insensitive; the
|
|
@@ -191,6 +201,13 @@ class AutomationConfig(BaseModel):
|
|
|
191
201
|
# precedence so "not for the agent at all" overrides "agent does
|
|
192
202
|
# this but differently".
|
|
193
203
|
task_labels: list[str] = Field(default_factory=lambda: ["task"])
|
|
204
|
+
# Hard cap, in seconds, on a single `ctrlrelay ci wait` call the dev
|
|
205
|
+
# pipeline prompt tells Claude to run before signaling DONE. A repo
|
|
206
|
+
# with slow CI can eat most of a session's wall-clock time sitting
|
|
207
|
+
# in this one blocking call generating zero tokens; a fast-CI repo
|
|
208
|
+
# doesn't need anywhere near the 600s default. Lower this per-repo
|
|
209
|
+
# to match how long that repo's CI actually takes.
|
|
210
|
+
ci_wait_timeout_seconds: int = 600
|
|
194
211
|
|
|
195
212
|
|
|
196
213
|
_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
|
@@ -84,6 +84,16 @@ class IssuePoller:
|
|
|
84
84
|
or ``seen_issues``). A repo with an empty / missing ``include_labels``
|
|
85
85
|
keeps the pre-#80 ``--assignee``-filtered query so we don't
|
|
86
86
|
over-fetch.
|
|
87
|
+
|
|
88
|
+
``require_labels_by_repo`` tightens the plain-assignment trigger
|
|
89
|
+
(see #139). When a repo has any require-labels configured, bare
|
|
90
|
+
assignment is no longer sufficient by itself — an issue must be
|
|
91
|
+
assigned to the operator AND carry at least one of the configured
|
|
92
|
+
labels to be admitted via the assignment path. ``include_labels``
|
|
93
|
+
is unaffected: a label_match there still admits the issue
|
|
94
|
+
regardless of ``require_labels``. An issue that's assigned but
|
|
95
|
+
missing the required label is left unmarked (not seen) so a later
|
|
96
|
+
label addition still surfaces it on a subsequent poll.
|
|
87
97
|
"""
|
|
88
98
|
|
|
89
99
|
github: GitHubCLI
|
|
@@ -94,6 +104,7 @@ class IssuePoller:
|
|
|
94
104
|
accept_foreign_assignments: set[str] = field(default_factory=set)
|
|
95
105
|
exclude_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
|
|
96
106
|
include_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
|
|
107
|
+
require_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
|
|
97
108
|
# Per-repo consecutive-skip counter; populated at runtime by poll() /
|
|
98
109
|
# seed_current(). Not persisted — intentionally resets on daemon
|
|
99
110
|
# restart so an operator fix is exercised before we re-escalate.
|
|
@@ -343,6 +354,10 @@ class IssuePoller:
|
|
|
343
354
|
label.lower()
|
|
344
355
|
for label in self.exclude_labels_by_repo.get(repo, [])
|
|
345
356
|
}
|
|
357
|
+
require_lowered = {
|
|
358
|
+
label.lower()
|
|
359
|
+
for label in self.require_labels_by_repo.get(repo, [])
|
|
360
|
+
}
|
|
346
361
|
for issue in issues:
|
|
347
362
|
# Per-issue guard so ONE malformed payload (missing 'number',
|
|
348
363
|
# wrong type, non-dict entry) doesn't poison the remaining
|
|
@@ -412,6 +427,18 @@ class IssuePoller:
|
|
|
412
427
|
is_assigned = self._issue_is_assigned_to(
|
|
413
428
|
issue, self.username,
|
|
414
429
|
)
|
|
430
|
+
if (
|
|
431
|
+
is_assigned
|
|
432
|
+
and require_lowered
|
|
433
|
+
and self._matched_require_label(
|
|
434
|
+
issue, require_lowered,
|
|
435
|
+
) is None
|
|
436
|
+
):
|
|
437
|
+
# Assigned but missing the required label
|
|
438
|
+
# (#139): the plain-assignment trigger doesn't
|
|
439
|
+
# count on this repo. label_match (below) can
|
|
440
|
+
# still admit the issue independently.
|
|
441
|
+
is_assigned = False
|
|
415
442
|
if label_match is None and not is_assigned:
|
|
416
443
|
# Defensive: a targeted query should never
|
|
417
444
|
# return an issue that matches neither trigger,
|
|
@@ -423,7 +450,13 @@ class IssuePoller:
|
|
|
423
450
|
else:
|
|
424
451
|
# Server-side ``--assignee`` already filtered; by
|
|
425
452
|
# construction every issue reaching here is
|
|
426
|
-
# assignment-triggered
|
|
453
|
+
# assignment-triggered — unless require_labels
|
|
454
|
+
# (#139) is configured, in which case the missing
|
|
455
|
+
# label leaves the issue unmarked for a later poll.
|
|
456
|
+
if require_lowered and self._matched_require_label(
|
|
457
|
+
issue, require_lowered,
|
|
458
|
+
) is None:
|
|
459
|
+
continue
|
|
427
460
|
is_assigned = True
|
|
428
461
|
|
|
429
462
|
if label_match is not None:
|
|
@@ -584,6 +617,27 @@ class IssuePoller:
|
|
|
584
617
|
return name
|
|
585
618
|
return None
|
|
586
619
|
|
|
620
|
+
@staticmethod
|
|
621
|
+
def _matched_require_label(
|
|
622
|
+
issue: dict[str, Any], require_lowered: set[str]
|
|
623
|
+
) -> str | None:
|
|
624
|
+
"""Return the first issue label that matches ``require_lowered``.
|
|
625
|
+
|
|
626
|
+
Mirrors ``_matched_exclude_label`` / ``_matched_include_label``.
|
|
627
|
+
Used to gate the plain-assignment trigger when a repo has
|
|
628
|
+
``require_labels`` configured. See #139.
|
|
629
|
+
"""
|
|
630
|
+
if not require_lowered:
|
|
631
|
+
return None
|
|
632
|
+
for label in issue.get("labels") or []:
|
|
633
|
+
if isinstance(label, dict):
|
|
634
|
+
name = label.get("name", "")
|
|
635
|
+
else:
|
|
636
|
+
name = str(label)
|
|
637
|
+
if name and name.lower() in require_lowered:
|
|
638
|
+
return name
|
|
639
|
+
return None
|
|
640
|
+
|
|
587
641
|
@staticmethod
|
|
588
642
|
def _issue_is_assigned_to(issue: dict[str, Any], username: str) -> bool:
|
|
589
643
|
"""Check whether ``username`` is listed in the issue's assignees.
|
|
@@ -726,6 +780,10 @@ class IssuePoller:
|
|
|
726
780
|
self._clear_repo_failure(repo)
|
|
727
781
|
seen_for_repo = self.seen_issues.setdefault(repo, set())
|
|
728
782
|
include_lowered = {label.lower() for label in include_labels}
|
|
783
|
+
require_lowered = {
|
|
784
|
+
label.lower()
|
|
785
|
+
for label in self.require_labels_by_repo.get(repo, [])
|
|
786
|
+
}
|
|
729
787
|
for issue in issues:
|
|
730
788
|
try:
|
|
731
789
|
number = int(issue["number"])
|
|
@@ -776,11 +834,25 @@ class IssuePoller:
|
|
|
776
834
|
seen_for_repo.add(number)
|
|
777
835
|
continue
|
|
778
836
|
if self_assigned:
|
|
837
|
+
if require_lowered and self._matched_require_label(
|
|
838
|
+
issue, require_lowered,
|
|
839
|
+
) is None:
|
|
840
|
+
# Assigned but missing the required label
|
|
841
|
+
# (#139): leave unseeded so a later
|
|
842
|
+
# label-add still surfaces it on the next
|
|
843
|
+
# poll.
|
|
844
|
+
continue
|
|
779
845
|
seen_for_repo.add(number)
|
|
780
846
|
else:
|
|
781
847
|
# Pre-#80 behavior: server-side --assignee already
|
|
782
848
|
# filtered, seed everything so first poll doesn't
|
|
783
|
-
# flood
|
|
849
|
+
# flood — unless require_labels (#139) is
|
|
850
|
+
# configured, in which case an issue missing the
|
|
851
|
+
# required label is left unseeded.
|
|
852
|
+
if require_lowered and self._matched_require_label(
|
|
853
|
+
issue, require_lowered,
|
|
854
|
+
) is None:
|
|
855
|
+
continue
|
|
784
856
|
seen_for_repo.add(number)
|
|
785
857
|
self._save_state_best_effort()
|
|
786
858
|
|
|
@@ -386,6 +386,80 @@ class StateDB:
|
|
|
386
386
|
self._conn.commit()
|
|
387
387
|
return cursor.rowcount > 0
|
|
388
388
|
|
|
389
|
+
# Automation decisions (operator answers persisted across sweeps)
|
|
390
|
+
|
|
391
|
+
def record_automation_decision(
|
|
392
|
+
self,
|
|
393
|
+
*,
|
|
394
|
+
repo: str,
|
|
395
|
+
operation: str,
|
|
396
|
+
item_id: str,
|
|
397
|
+
decision: str,
|
|
398
|
+
decided_by: str = "operator",
|
|
399
|
+
context: str | None = None,
|
|
400
|
+
policy: str = "",
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Record an operator decision so future sweeps can avoid
|
|
403
|
+
re-asking the same question.
|
|
404
|
+
|
|
405
|
+
``operation`` namespaces the decision domain
|
|
406
|
+
(e.g. ``"dependabot_pr"`` or ``"codeql_alert"``); ``item_id``
|
|
407
|
+
is the specific item the decision applies to (e.g. ``"#60"``
|
|
408
|
+
for PR #60). ``decision`` is the operator's verbatim answer —
|
|
409
|
+
the agent that reads this back interprets it; we don't try to
|
|
410
|
+
normalize to yes/no here because Telegram replies are free-form.
|
|
411
|
+
"""
|
|
412
|
+
self._conn.execute(
|
|
413
|
+
"""INSERT INTO automation_decisions
|
|
414
|
+
(repo, operation, policy, item_id, decision,
|
|
415
|
+
decided_by, decided_at, context)
|
|
416
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
417
|
+
(repo, operation, policy, item_id, decision,
|
|
418
|
+
decided_by, int(time.time()), context),
|
|
419
|
+
)
|
|
420
|
+
self._conn.commit()
|
|
421
|
+
|
|
422
|
+
def list_recent_automation_decisions(
|
|
423
|
+
self,
|
|
424
|
+
repo: str,
|
|
425
|
+
*,
|
|
426
|
+
operation: str | None = None,
|
|
427
|
+
since_ts: int | None = None,
|
|
428
|
+
limit: int = 100,
|
|
429
|
+
) -> list[dict[str, Any]]:
|
|
430
|
+
"""Recent operator decisions for this repo, newest first.
|
|
431
|
+
|
|
432
|
+
``operation`` namespaces the read so a Dependabot-PR consumer
|
|
433
|
+
only sees ``dependabot_pr`` rows, not e.g. ``codeql_alert``
|
|
434
|
+
decisions also tracked in the same repo. Without this filter,
|
|
435
|
+
a "suppress this CVE" answer for a CodeQL alert would render
|
|
436
|
+
into the Dependabot prompt as a prior-PR decision and the
|
|
437
|
+
agent could act on it. Defaults to ``None`` (all operations)
|
|
438
|
+
to preserve the original behaviour for callers that want a
|
|
439
|
+
full audit view.
|
|
440
|
+
|
|
441
|
+
``since_ts`` (unix seconds) filters to decisions made
|
|
442
|
+
at-or-after that point — used by the secops prompt builder to
|
|
443
|
+
inject a 30-day rolling window into the agent's context so the
|
|
444
|
+
agent can act on prior decisions instead of asking again.
|
|
445
|
+
"""
|
|
446
|
+
clauses = ["repo = ?"]
|
|
447
|
+
params: list[Any] = [repo]
|
|
448
|
+
if operation is not None:
|
|
449
|
+
clauses.append("operation = ?")
|
|
450
|
+
params.append(operation)
|
|
451
|
+
if since_ts is not None:
|
|
452
|
+
clauses.append("decided_at >= ?")
|
|
453
|
+
params.append(since_ts)
|
|
454
|
+
sql = (
|
|
455
|
+
"SELECT * FROM automation_decisions WHERE "
|
|
456
|
+
+ " AND ".join(clauses)
|
|
457
|
+
+ " ORDER BY decided_at DESC LIMIT ?"
|
|
458
|
+
)
|
|
459
|
+
params.append(limit)
|
|
460
|
+
rows = self._conn.execute(sql, tuple(params)).fetchall()
|
|
461
|
+
return [dict(row) for row in rows]
|
|
462
|
+
|
|
389
463
|
# PR watches (durable, cross-restart)
|
|
390
464
|
|
|
391
465
|
def add_pr_watch(
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Detect the git transport protocol the operator has configured for
|
|
2
|
+
GitHub via the ``gh`` CLI.
|
|
3
|
+
|
|
4
|
+
``ctrlrelay`` clones a handful of GitHub repos on the operator's
|
|
5
|
+
behalf (project repos, the personalization repo). Hardcoding HTTPS
|
|
6
|
+
for these clones breaks operators who've set ``gh config set
|
|
7
|
+
git_protocol ssh`` and have no HTTPS credential helper wired up —
|
|
8
|
+
``git`` then fails with "could not read Username" instead of using
|
|
9
|
+
the SSH key ``gh`` already knows about.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
_VALID_PROTOCOLS = ("ssh", "https")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_git_protocol(*, default: str = "https") -> str:
|
|
20
|
+
"""Return ``"ssh"`` or ``"https"``, per ``gh config get git_protocol``.
|
|
21
|
+
|
|
22
|
+
Falls back to ``default`` whenever ``gh`` isn't installed, isn't
|
|
23
|
+
configured, or returns something unexpected — this is a
|
|
24
|
+
convenience read, not a hard requirement, so any failure here
|
|
25
|
+
should degrade to the previous hardcoded-HTTPS behavior rather
|
|
26
|
+
than blocking a clone.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
proc = subprocess.run(
|
|
30
|
+
["gh", "config", "get", "git_protocol"],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=5,
|
|
34
|
+
)
|
|
35
|
+
except (OSError, subprocess.SubprocessError):
|
|
36
|
+
return default
|
|
37
|
+
value = proc.stdout.strip().lower()
|
|
38
|
+
if proc.returncode == 0 and value in _VALID_PROTOCOLS:
|
|
39
|
+
return value
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def github_clone_url(repo: str, *, protocol: str) -> str:
|
|
44
|
+
"""Build a clone URL for ``owner/repo`` under the given protocol."""
|
|
45
|
+
if protocol == "ssh":
|
|
46
|
+
return f"git@github.com:{repo}.git"
|
|
47
|
+
return f"https://github.com/{repo}.git"
|
|
@@ -178,7 +178,17 @@ def write_units(
|
|
|
178
178
|
silently overwriting an operator's customised plist would be a foot-gun.
|
|
179
179
|
Returns the list of paths actually written. Raises ``FileExistsError``
|
|
180
180
|
if any target exists and overwrite is False.
|
|
181
|
+
|
|
182
|
+
Also ensures ``~/.ctrlrelay/logs/`` exists — both the launchd and
|
|
183
|
+
systemd templates set StandardOutput/StandardError paths under it,
|
|
184
|
+
and systemd refuses to start a unit at all (EXIT_STDOUT, code 209)
|
|
185
|
+
when the directory is missing; combined with ``Restart=always``
|
|
186
|
+
that's an immediate crash-loop (#137).
|
|
187
|
+
|
|
188
|
+
Unit files are chmod'd ``0600`` after writing since they embed
|
|
189
|
+
``CTRLRELAY_TELEGRAM_TOKEN`` as plaintext.
|
|
181
190
|
"""
|
|
191
|
+
(Path.home() / ".ctrlrelay" / "logs").mkdir(parents=True, exist_ok=True)
|
|
182
192
|
written: list[Path] = []
|
|
183
193
|
for unit in units:
|
|
184
194
|
unit.target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -188,5 +198,6 @@ def write_units(
|
|
|
188
198
|
"pass --force to replace it"
|
|
189
199
|
)
|
|
190
200
|
unit.target_path.write_text(unit.content, encoding="utf-8")
|
|
201
|
+
unit.target_path.chmod(0o600)
|
|
191
202
|
written.append(unit.target_path)
|
|
192
203
|
return written
|