ctrlrelay 0.5.0__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.5.0 → ctrlrelay-0.7.0}/.github/workflows/build.yml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.github/workflows/pages.yml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.github/workflows/publish.yml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.github/workflows/test.yml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/CHANGELOG.md +92 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/PKG-INFO +2 -2
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/config/orchestrator.yaml.example +7 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/configuration.md +37 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/pyproject.toml +1 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/cli.py +17 -5
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/config.py +10 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/poller.py +74 -2
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/state.py +74 -0
- ctrlrelay-0.7.0/src/ctrlrelay/gh_protocol.py +47 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/install.py +11 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/manager.py +8 -1
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/secops.py +218 -2
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/setup.py +11 -7
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_config.py +96 -0
- ctrlrelay-0.7.0/tests/test_gh_protocol.py +65 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_install.py +39 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_personalization.py +27 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_poller.py +150 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_secops_pipeline.py +485 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_setup.py +140 -6
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_state.py +129 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_transport.py +4 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/.gitignore +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/LICENSE +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/README.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/SECURITY.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/cli.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/development.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/index.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/operations.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.5.0 → ctrlrelay-0.7.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,95 @@ 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
|
+
|
|
10
99
|
## [0.5.0] - 2026-05-11
|
|
11
100
|
|
|
12
101
|
Minor release. Three changes on the secops path; together they take the
|
|
@@ -1143,7 +1232,9 @@ pipeline).
|
|
|
1143
1232
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1144
1233
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1145
1234
|
|
|
1146
|
-
[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
|
|
1147
1238
|
[0.1.3]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.3
|
|
1148
1239
|
[0.1.1]: https://github.com/AInvirion/ctrlrelay/releases/tag/v0.1.1
|
|
1149
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
|
|
@@ -1055,6 +1055,9 @@ def poller_start(
|
|
|
1055
1055
|
include_labels_by_repo = {
|
|
1056
1056
|
r.name: list(r.automation.include_labels) for r in config.repos
|
|
1057
1057
|
}
|
|
1058
|
+
require_labels_by_repo = {
|
|
1059
|
+
r.name: list(r.automation.require_labels) for r in config.repos
|
|
1060
|
+
}
|
|
1058
1061
|
|
|
1059
1062
|
poller = IssuePoller(
|
|
1060
1063
|
github=github,
|
|
@@ -1064,6 +1067,7 @@ def poller_start(
|
|
|
1064
1067
|
accept_foreign_assignments=accept_foreign,
|
|
1065
1068
|
exclude_labels_by_repo=exclude_labels_by_repo,
|
|
1066
1069
|
include_labels_by_repo=include_labels_by_repo,
|
|
1070
|
+
require_labels_by_repo=require_labels_by_repo,
|
|
1067
1071
|
)
|
|
1068
1072
|
|
|
1069
1073
|
# NOTE: first-run seeding moved into `_main()` so the APScheduler
|
|
@@ -1631,6 +1635,7 @@ def poller_start(
|
|
|
1631
1635
|
if sweep_repo_cfg is not None
|
|
1632
1636
|
else None
|
|
1633
1637
|
),
|
|
1638
|
+
question=row.get("question"),
|
|
1634
1639
|
)
|
|
1635
1640
|
elif pipeline_name == "dev":
|
|
1636
1641
|
# Dev resume needs the repo's branch template
|
|
@@ -2486,6 +2491,14 @@ def setup(
|
|
|
2486
2491
|
)
|
|
2487
2492
|
if result.personalization_summary:
|
|
2488
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
|
+
)
|
|
2489
2502
|
if result.daemon_units:
|
|
2490
2503
|
console.print(" daemon unit files:")
|
|
2491
2504
|
for p in result.daemon_units:
|
|
@@ -2497,11 +2510,10 @@ def setup(
|
|
|
2497
2510
|
" systemctl --user daemon-reload && systemctl --user enable --now <unit>"
|
|
2498
2511
|
" # Linux"
|
|
2499
2512
|
)
|
|
2500
|
-
# Clone failures must surface as a
|
|
2501
|
-
# units were rendered — automation
|
|
2502
|
-
# mistake a partial setup for success.
|
|
2503
|
-
|
|
2504
|
-
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:
|
|
2505
2517
|
raise typer.Exit(1)
|
|
2506
2518
|
|
|
2507
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(
|
|
@@ -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
|
|
@@ -47,6 +47,7 @@ from dataclasses import dataclass
|
|
|
47
47
|
from pathlib import Path
|
|
48
48
|
|
|
49
49
|
from ctrlrelay.core.config import Config, Personalization, PersonalizationPath
|
|
50
|
+
from ctrlrelay.gh_protocol import detect_git_protocol, github_clone_url
|
|
50
51
|
from ctrlrelay.personalization.paths import (
|
|
51
52
|
TemplateContext,
|
|
52
53
|
project_slug,
|
|
@@ -120,7 +121,13 @@ class PersonalizationManager:
|
|
|
120
121
|
raise PersonalizationError("personalization_branch is unset")
|
|
121
122
|
self.working_branch: str = branch
|
|
122
123
|
self.main_branch: str = self.cfg.main_branch
|
|
123
|
-
|
|
124
|
+
# Respect the operator's configured git protocol (gh's
|
|
125
|
+
# git_protocol setting) rather than assuming HTTPS — an
|
|
126
|
+
# operator set up for SSH-only GitHub auth would otherwise
|
|
127
|
+
# hit "could not read Username" on the clone below (#137).
|
|
128
|
+
self.repo_url: str = github_clone_url(
|
|
129
|
+
self.cfg.repo, protocol=detect_git_protocol()
|
|
130
|
+
)
|
|
124
131
|
|
|
125
132
|
# ----- top-level commands ------------------------------------------------
|
|
126
133
|
|