ctrlrelay 0.4.1__tar.gz → 0.7.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.4.1 → ctrlrelay-0.7.0}/.github/workflows/build.yml +1 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/pages.yml +1 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/publish.yml +1 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/test.yml +1 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/CHANGELOG.md +162 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/PKG-INFO +2 -2
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/config/orchestrator.yaml.example +7 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/configuration.md +37 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/pyproject.toml +1 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/cli.py +47 -6
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/config.py +10 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/poller.py +74 -2
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/state.py +74 -0
- ctrlrelay-0.7.0/src/ctrlrelay/gh_protocol.py +47 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/install.py +11 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/manager.py +8 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/secops.py +372 -7
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/setup.py +11 -7
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/socket_client.py +11 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_config.py +96 -0
- ctrlrelay-0.7.0/tests/test_gh_protocol.py +65 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_install.py +39 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_personalization.py +27 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_poller.py +150 -0
- ctrlrelay-0.7.0/tests/test_secops_pipeline.py +1432 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_setup.py +140 -6
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_state.py +129 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_transport.py +101 -0
- ctrlrelay-0.4.1/tests/test_secops_pipeline.py +0 -555
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/.gitignore +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/LICENSE +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/README.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/SECURITY.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/cli.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/development.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/index.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/operations.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.7.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,165 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-09-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`require_labels`: an AND-gate between assignment and a label,
|
|
15
|
+
closing the gap `include_labels` (#80) couldn't cover.** `exclude_labels`
|
|
16
|
+
is a block-list and `include_labels` is an OR-alternate trigger, but
|
|
17
|
+
neither could express "only pick this up if it's assigned to me AND
|
|
18
|
+
carries a specific label." On a repo with a long history of
|
|
19
|
+
self-assigned issues used as personal reminders, that meant the poller
|
|
20
|
+
replayed the whole backlog as new work the first time it saw the repo —
|
|
21
|
+
including issues that weren't code tasks at all. `require_labels`
|
|
22
|
+
(default `[]`, no behavior change) tightens the plain-assignment path
|
|
23
|
+
only: when set, an issue needs both the assignment and a matching label.
|
|
24
|
+
`include_labels` keeps triggering on a label match alone regardless.
|
|
25
|
+
An assigned issue missing the required label is left unmarked so a
|
|
26
|
+
later label addition still surfaces it. See #139.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **`ctrlrelay setup --install-daemons` on a fresh SSH-only machine:
|
|
31
|
+
personalization clone ignored the configured git protocol, and
|
|
32
|
+
generated daemon units crash-looped.** The personalization repo
|
|
33
|
+
clone (setup pre-scan and `PersonalizationManager.init`) hardcoded
|
|
34
|
+
an HTTPS URL regardless of `gh`'s configured `git_protocol`,
|
|
35
|
+
so SSH-only operators hit `could not read Username` with the
|
|
36
|
+
failure buried in a single warning line while setup still exited
|
|
37
|
+
0. `ctrlrelay.gh_protocol` now reads `gh`'s configured protocol and
|
|
38
|
+
builds the matching clone URL; a personalization failure flips
|
|
39
|
+
`SetupResult.personalization_failed` so the CLI prints an ACTION
|
|
40
|
+
NEEDED banner and exits non-zero instead of silently continuing.
|
|
41
|
+
Separately, generated launchd/systemd units point
|
|
42
|
+
`StandardOutput`/`StandardError` at `~/.ctrlrelay/logs/`, which
|
|
43
|
+
setup never created — systemd refuses to start a unit with a
|
|
44
|
+
missing log dir (`EXIT_STDOUT`, 209), and with `Restart=always`
|
|
45
|
+
that's an immediate crash-loop. `write_units` now creates the log
|
|
46
|
+
dir up front and chmods unit files `0600` since they embed
|
|
47
|
+
`CTRLRELAY_TELEGRAM_TOKEN` in plaintext. See #137.
|
|
48
|
+
|
|
49
|
+
## [0.6.0] - 2026-06-10
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
|
|
53
|
+
- **Secops sweeps now remember operator decisions across days.** The
|
|
54
|
+
`automation_decisions` table had a schema but no producer or consumer
|
|
55
|
+
— every 6am sweep re-asked about the same Dependabot PRs the operator
|
|
56
|
+
had already answered, drowning Telegram. Wired both halves:
|
|
57
|
+
1. When the BLOCKED loop in `run_secops_all` (or the out-of-band
|
|
58
|
+
`resume_secops_from_pending` sweeper) receives an answer, regex
|
|
59
|
+
extracts every PR# from the question and records one row per PR#
|
|
60
|
+
keyed on `(repo, "dependabot_pr", "#N")` with the operator's
|
|
61
|
+
verbatim answer. PR# regex handles both `PR #60` and bare `#60`
|
|
62
|
+
forms, dedupes, and tolerates questions with no PR# (e.g.
|
|
63
|
+
CodeQL-only) by writing nothing.
|
|
64
|
+
2. Before building each sweep's prompt, `run_secops_all` pulls the
|
|
65
|
+
last 30 days of decisions for that repo (namespaced to
|
|
66
|
+
`operation="dependabot_pr"` so e.g. CodeQL-suppression decisions
|
|
67
|
+
don't leak into the Dependabot prompt) and threads them via
|
|
68
|
+
`ctx.extra['prior_decisions']` into `_build_prompt`, which renders
|
|
69
|
+
a `## Prior operator decisions (last 30 days)` block listing each
|
|
70
|
+
decision verbatim alongside the original question snippet so the
|
|
71
|
+
agent can detect a force-pushed PR that swapped the version bump
|
|
72
|
+
under the same PR number. The block carries instructions to act
|
|
73
|
+
on prior answers unless circumstances have materially changed
|
|
74
|
+
(different version bump, CI state flipped). Without this the
|
|
75
|
+
persistence layer was dead weight.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **Secops handles "no CI configured" without inventing options.**
|
|
80
|
+
Repos with no `.github/workflows/*` previously confused the agent
|
|
81
|
+
into freelance suggestions ("Want me to batch into a review PR?")
|
|
82
|
+
because the "auto-merge with passing CI" gate couldn't fire. Prompt
|
|
83
|
+
now explicitly directs: treat ALL Dependabot PRs as ASK regardless
|
|
84
|
+
of tier, and signal BLOCKED ONCE with a single consolidated question
|
|
85
|
+
per repo (not one per PR).
|
|
86
|
+
- **Secops escalates pre-existing CI failures instead of stalling PRs.**
|
|
87
|
+
When CI is failing on a check unrelated to the PR's package (e.g.
|
|
88
|
+
`pip-audit` flagging a transitive CVE in `idna` while the PR bumps
|
|
89
|
+
`boto3`), patch PRs were sitting stuck for weeks because the agent
|
|
90
|
+
reported the failure and moved on. Prompt now requires the agent to
|
|
91
|
+
signal BLOCKED with a one-line question naming the underlying issue
|
|
92
|
+
and a root-cause hint from `gh run view --log-failed`.
|
|
93
|
+
- **Secops scope discipline.** The prompt now enforces "exactly three
|
|
94
|
+
legitimate exits" per open PR (merge / leave / BLOCKED) and
|
|
95
|
+
explicitly forbids batching PRs into review PRs, opening tracking
|
|
96
|
+
issues, or performing manual reviews. Cuts off the freelance
|
|
97
|
+
options observed in production sweeps.
|
|
98
|
+
|
|
99
|
+
## [0.5.0] - 2026-05-11
|
|
100
|
+
|
|
101
|
+
Minor release. Three changes on the secops path; together they take the
|
|
102
|
+
secops sweep from "silently drops operator decisions" to "respects per-repo
|
|
103
|
+
policy and reaches the operator on every blocked decision".
|
|
104
|
+
|
|
105
|
+
### Fixed
|
|
106
|
+
|
|
107
|
+
- **Secops BLOCKED questions now reach the operator via Telegram.**
|
|
108
|
+
Three discrete bugs were stacking to drop every blocked secops session
|
|
109
|
+
silently (#131):
|
|
110
|
+
1. `run_secops_all` never called `transport.ask()` after a blocked
|
|
111
|
+
pipeline result. The DB row was marked `status='blocked'` and a
|
|
112
|
+
`pending_resumes` entry was inserted, but the question went nowhere.
|
|
113
|
+
Now mirrors the dev/task pipelines: post the question, await an
|
|
114
|
+
answer, resume — up to `DEFAULT_MAX_BLOCKED_ROUNDS=5` rounds. On
|
|
115
|
+
transport failure preserves `blocked=True` so the existing
|
|
116
|
+
persistence-on-blocked branch fires.
|
|
117
|
+
2. `ctrlrelay run secops` (manual CLI) was passing `transport=None`,
|
|
118
|
+
so even with the dispatch loop in place, the `is not None` gate
|
|
119
|
+
short-circuited and questions still went to pending_resumes
|
|
120
|
+
instead of Telegram. Now builds a `SocketTransport` when the
|
|
121
|
+
bridge socket is up, mirroring the scheduled-cron path.
|
|
122
|
+
3. `SocketTransport._receive_loop` resolved the per-request future on
|
|
123
|
+
the FIRST message matching `request_id`. The bridge sends two
|
|
124
|
+
messages for an ASK: an intermediate `ACK(status="pending")` then
|
|
125
|
+
a terminal `ANSWER`. Receiving the ACK fulfilled the future early
|
|
126
|
+
and `ask()` raised `TransportError("Unexpected response: BridgeOp.ACK")`.
|
|
127
|
+
Now skips `ACK(status="pending")` and waits for `ANSWER`/`ERROR`.
|
|
128
|
+
|
|
129
|
+
Real-world impact: an 86-repo secops sweep with 21 sessions hitting
|
|
130
|
+
BLOCKED produced zero Telegram messages pre-fix. Post-fix, every
|
|
131
|
+
blocked session reaches the operator with a structured question.
|
|
132
|
+
|
|
133
|
+
### Added
|
|
134
|
+
|
|
135
|
+
- **Auto-merge operator-authored `.github/dependabot.yml`-only PRs.**
|
|
136
|
+
The secops agent's policy treated all operator-authored PRs as needing
|
|
137
|
+
explicit approval, even ones that ONLY add an ecosystem entry to
|
|
138
|
+
`.github/dependabot.yml`. These are the prerequisite PRs the operator
|
|
139
|
+
files when bulk-enabling Dependabot across repos with branch protection,
|
|
140
|
+
and they sat indefinitely (#132). The carve-out is narrow and
|
|
141
|
+
multi-gated:
|
|
142
|
+
1. **Author check**: `author.login == $OPERATOR` (derived from
|
|
143
|
+
`gh api user --jq .login`) — collaborators, GitHub apps, or
|
|
144
|
+
external contributors are NOT eligible even for dependabot.yml-only.
|
|
145
|
+
2. **Diff check**: `gh pr diff` must be PURELY ADDITIVE — any line
|
|
146
|
+
beginning with `-` (other than `---` file headers) signals a
|
|
147
|
+
deletion or modification of existing config -> BLOCKED.
|
|
148
|
+
3. **CI check**: `gh pr checks` must all pass — a PR adding invalid
|
|
149
|
+
YAML can pass author+diff and still break Dependabot for the repo
|
|
150
|
+
if merged without this gate.
|
|
151
|
+
|
|
152
|
+
All three must pass before merge. Any one failing -> BLOCKED.
|
|
153
|
+
|
|
154
|
+
- **Per-repo `automation:` policy now drives the secops agent.**
|
|
155
|
+
`orchestrator.yaml`'s per-repo `automation` block (defining
|
|
156
|
+
`dependabot_patch`, `dependabot_minor`, `dependabot_major` as
|
|
157
|
+
auto/ask/never) was decorative — the secops prompt had hardcoded
|
|
158
|
+
prose that ignored it (#133). Now `_build_prompt` accepts an
|
|
159
|
+
`AutomationConfig` and renders per-tier directives like
|
|
160
|
+
"patch updates: AUTO-MERGE", "minor updates: ASK", "major updates: NEVER"
|
|
161
|
+
per repo. `run_secops_all` puts `repo_config.automation` in
|
|
162
|
+
`ctx.extra`; `resume_secops_from_pending` accepts an `automation`
|
|
163
|
+
kwarg; the pending-resume sweeper in `cli.py` looks up the per-repo
|
|
164
|
+
policy and threads it on resume.
|
|
165
|
+
|
|
166
|
+
Operators can now set a sensitive repo to `dependabot_minor: never`
|
|
167
|
+
and the agent will actually respect it.
|
|
168
|
+
|
|
10
169
|
## [0.4.1] - 2026-05-08
|
|
11
170
|
|
|
12
171
|
Patch release. Two follow-ups against the v0.4.0 setup flow surfaced
|
|
@@ -1073,7 +1232,9 @@ pipeline).
|
|
|
1073
1232
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1074
1233
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1075
1234
|
|
|
1076
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.
|
|
1235
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.7.0...HEAD
|
|
1236
|
+
[0.7.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.6.0...v0.7.0
|
|
1237
|
+
[0.6.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.5.0...v0.6.0
|
|
1077
1238
|
[0.1.3]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.3
|
|
1078
1239
|
[0.1.1]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.1
|
|
1079
1240
|
[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.7.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,10 @@ 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: []
|
|
@@ -200,6 +200,7 @@ 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. |
|
|
203
204
|
|
|
204
205
|
The current secops and dev pipelines read these settings to bias their prompts
|
|
205
206
|
to Claude — they're not enforced by hard-coded checks.
|
|
@@ -308,6 +309,42 @@ triage access was already able to push branches and trigger CI, so allowing
|
|
|
308
309
|
them to opt an issue into the dev pipeline is a narrower extension, not a new
|
|
309
310
|
vector.
|
|
310
311
|
|
|
312
|
+
### repos[].automation.require_labels
|
|
313
|
+
|
|
314
|
+
Assignment-only ends up too loose on repos with a long history: issues
|
|
315
|
+
self-assigned as personal reminders or notes, unrelated to automation, get
|
|
316
|
+
replayed into the dev pipeline the first time the poller sees the repo (or
|
|
317
|
+
after any config change that clears `poller_state.json` for it). Some of
|
|
318
|
+
those issues aren't code tasks at all — "create an account with a
|
|
319
|
+
third-party service", "look into pricing" — and shouldn't go anywhere near
|
|
320
|
+
an unsupervised `claude -p ... --dangerously-skip-permissions` run.
|
|
321
|
+
|
|
322
|
+
`require_labels` closes that gap: when configured, bare assignment is no
|
|
323
|
+
longer sufficient by itself. An issue must be **both** assigned to the
|
|
324
|
+
operator **and** carry at least one of the configured labels to be picked
|
|
325
|
+
up via the assignment path.
|
|
326
|
+
|
|
327
|
+
```yaml
|
|
328
|
+
repos:
|
|
329
|
+
- name: "your-org/your-repo"
|
|
330
|
+
local_path: "~/Projects/your-repo"
|
|
331
|
+
automation:
|
|
332
|
+
require_labels: ["ctrlrelay:auto"]
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- Default: `[]`. An empty list preserves today's behavior — assignment
|
|
336
|
+
alone is sufficient, no change for operators who haven't opted in.
|
|
337
|
+
- Matching is **case-insensitive**, same as `exclude_labels` / `include_labels`.
|
|
338
|
+
- This only tightens the **assignment** path. `include_labels` is
|
|
339
|
+
unaffected — a label match there still admits the issue regardless of
|
|
340
|
+
`require_labels`, since that path already treats the label as its own
|
|
341
|
+
trust signal.
|
|
342
|
+
- An issue that's assigned but missing the required label is left
|
|
343
|
+
**unmarked** (not added to `seen_issues`), so applying the label later
|
|
344
|
+
still surfaces it on a subsequent poll — nothing is lost, it's just not
|
|
345
|
+
picked up yet.
|
|
346
|
+
- `exclude_labels` is still checked first, same precedence as always.
|
|
347
|
+
|
|
311
348
|
## schedules
|
|
312
349
|
|
|
313
350
|
In-process cron jobs run by the poller daemon. All expressions are
|
|
@@ -593,6 +593,29 @@ def run_secops(
|
|
|
593
593
|
console.print(f"Running secops on {len(repos)} repo(s)...")
|
|
594
594
|
|
|
595
595
|
async def _run():
|
|
596
|
+
# Build a transport so secops blocked questions reach Telegram.
|
|
597
|
+
# Without this, agents that end BLOCKED_NEEDS_INPUT silently land
|
|
598
|
+
# in the DB+pending_resumes and the operator never sees the
|
|
599
|
+
# question (mirror of the wiring in the scheduled path).
|
|
600
|
+
transport = None
|
|
601
|
+
if (
|
|
602
|
+
config.transport.type.value == "telegram"
|
|
603
|
+
and config.transport.telegram
|
|
604
|
+
):
|
|
605
|
+
from ctrlrelay.transports import SocketTransport
|
|
606
|
+
sock = config.transport.telegram.socket_path.expanduser().resolve()
|
|
607
|
+
if sock.exists():
|
|
608
|
+
try:
|
|
609
|
+
candidate = SocketTransport(sock)
|
|
610
|
+
await candidate.connect()
|
|
611
|
+
transport = candidate
|
|
612
|
+
except Exception as e:
|
|
613
|
+
console.print(
|
|
614
|
+
f"[yellow]Bridge transport connect failed ({e}) — "
|
|
615
|
+
f"running without notifications. Blocked sessions "
|
|
616
|
+
f"will land in pending_resumes for later resume.[/yellow]"
|
|
617
|
+
)
|
|
618
|
+
|
|
596
619
|
return await run_secops_all(
|
|
597
620
|
repos=repos,
|
|
598
621
|
dispatcher=dispatcher,
|
|
@@ -600,7 +623,7 @@ def run_secops(
|
|
|
600
623
|
worktree=worktree,
|
|
601
624
|
dashboard=dashboard,
|
|
602
625
|
state_db=db,
|
|
603
|
-
transport=
|
|
626
|
+
transport=transport,
|
|
604
627
|
contexts_dir=config.paths.contexts,
|
|
605
628
|
)
|
|
606
629
|
|
|
@@ -1032,6 +1055,9 @@ def poller_start(
|
|
|
1032
1055
|
include_labels_by_repo = {
|
|
1033
1056
|
r.name: list(r.automation.include_labels) for r in config.repos
|
|
1034
1057
|
}
|
|
1058
|
+
require_labels_by_repo = {
|
|
1059
|
+
r.name: list(r.automation.require_labels) for r in config.repos
|
|
1060
|
+
}
|
|
1035
1061
|
|
|
1036
1062
|
poller = IssuePoller(
|
|
1037
1063
|
github=github,
|
|
@@ -1041,6 +1067,7 @@ def poller_start(
|
|
|
1041
1067
|
accept_foreign_assignments=accept_foreign,
|
|
1042
1068
|
exclude_labels_by_repo=exclude_labels_by_repo,
|
|
1043
1069
|
include_labels_by_repo=include_labels_by_repo,
|
|
1070
|
+
require_labels_by_repo=require_labels_by_repo,
|
|
1044
1071
|
)
|
|
1045
1072
|
|
|
1046
1073
|
# NOTE: first-run seeding moved into `_main()` so the APScheduler
|
|
@@ -1591,6 +1618,7 @@ def poller_start(
|
|
|
1591
1618
|
|
|
1592
1619
|
try:
|
|
1593
1620
|
if pipeline_name == "secops":
|
|
1621
|
+
sweep_repo_cfg = repo_cfg_by_name.get(repo)
|
|
1594
1622
|
result = await resume_secops_from_pending(
|
|
1595
1623
|
session_id=session_id,
|
|
1596
1624
|
repo=repo,
|
|
@@ -1602,6 +1630,12 @@ def poller_start(
|
|
|
1602
1630
|
state_db=state_db,
|
|
1603
1631
|
transport=sweeper_transport,
|
|
1604
1632
|
contexts_dir=config.paths.contexts,
|
|
1633
|
+
automation=(
|
|
1634
|
+
sweep_repo_cfg.automation
|
|
1635
|
+
if sweep_repo_cfg is not None
|
|
1636
|
+
else None
|
|
1637
|
+
),
|
|
1638
|
+
question=row.get("question"),
|
|
1605
1639
|
)
|
|
1606
1640
|
elif pipeline_name == "dev":
|
|
1607
1641
|
# Dev resume needs the repo's branch template
|
|
@@ -2457,6 +2491,14 @@ def setup(
|
|
|
2457
2491
|
)
|
|
2458
2492
|
if result.personalization_summary:
|
|
2459
2493
|
console.print(f" personalization: {result.personalization_summary}")
|
|
2494
|
+
if result.personalization_failed:
|
|
2495
|
+
console.print(
|
|
2496
|
+
"\n[bold red]ACTION NEEDED[/bold red] — personalization repo "
|
|
2497
|
+
"sync failed; the rest of setup completed but your "
|
|
2498
|
+
"personalization config/skills were not cloned. Check your "
|
|
2499
|
+
"git auth (`gh auth status`, `gh config get git_protocol`) "
|
|
2500
|
+
"and re-run `ctrlrelay personalization init` once it's fixed."
|
|
2501
|
+
)
|
|
2460
2502
|
if result.daemon_units:
|
|
2461
2503
|
console.print(" daemon unit files:")
|
|
2462
2504
|
for p in result.daemon_units:
|
|
@@ -2468,11 +2510,10 @@ def setup(
|
|
|
2468
2510
|
" systemctl --user daemon-reload && systemctl --user enable --now <unit>"
|
|
2469
2511
|
" # Linux"
|
|
2470
2512
|
)
|
|
2471
|
-
# Clone failures must surface as a
|
|
2472
|
-
# units were rendered — automation
|
|
2473
|
-
# mistake a partial setup for success.
|
|
2474
|
-
|
|
2475
|
-
if result.failed:
|
|
2513
|
+
# Clone and personalization-sync failures must surface as a
|
|
2514
|
+
# non-zero exit even when daemon units were rendered — automation
|
|
2515
|
+
# watching exit code shouldn't mistake a partial setup for success.
|
|
2516
|
+
if result.failed or result.personalization_failed:
|
|
2476
2517
|
raise typer.Exit(1)
|
|
2477
2518
|
|
|
2478
2519
|
|
|
@@ -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
|
|
@@ -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(
|