alissa-tools-github-orcloop 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_orcloop-0.3.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.1}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/__main__.py +4 -3
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/config.py +140 -4
- alissa_tools_github_orcloop-0.3.1/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.3.0/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/loop.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/state.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -84,9 +84,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
84
84
|
dest="repos",
|
|
85
85
|
action="append",
|
|
86
86
|
metavar="OWNER/REPO",
|
|
87
|
-
help="watch this repo; repeatable
|
|
88
|
-
"
|
|
89
|
-
"
|
|
87
|
+
help="watch this repo; repeatable, and one flag may carry several "
|
|
88
|
+
"entries separated by `|` (the daemon-family convention) or `,`. When "
|
|
89
|
+
"given, REPLACES the config `repos` list. The allowlist is fail-closed: "
|
|
90
|
+
"an empty allowlist is a fatal startup error, not a no-op.",
|
|
90
91
|
)
|
|
91
92
|
over.add_argument("--label", metavar="LABEL", help="issue label released work carries")
|
|
92
93
|
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
@@ -21,6 +21,20 @@ watch nothing, but boot anyway), an empty orcloop allowlist is **fatal at
|
|
|
21
21
|
startup** — the orchestrator releases work into `alissa:develop` issues and
|
|
22
22
|
closes the loop on merge, so it never runs against an unbounded repo set.
|
|
23
23
|
|
|
24
|
+
REPOS SEPARATOR (O7). A repo list written as one string — `ALISSA_ORC_REPOS`,
|
|
25
|
+
a `--repo a/b|c/d` flag, or a single-element list in the config file — is split
|
|
26
|
+
on **both `|` and `,`**, mixed freely. `|` is the daemon **family convention**
|
|
27
|
+
(devloop's `ALISSA_DEV_REPOS` has always been pipe-separated); comma stays
|
|
28
|
+
accepted for backward compatibility with every orcloop release up to 0.3.0.
|
|
29
|
+
The convention matters because the operator points several Railway services at
|
|
30
|
+
ONE list through a variable reference — `ALISSA_ORC_REPOS=${{dev.ALISSA_DEV_REPOS}}`
|
|
31
|
+
— which only works if both daemons read the same separator. Entries are
|
|
32
|
+
whitespace-stripped, empties from splitting are dropped (so a trailing
|
|
33
|
+
separator is harmless), duplicates collapse, and each surviving entry must look
|
|
34
|
+
like `owner/repo` (a bare word or a pasted URL is a fatal startup error naming
|
|
35
|
+
the offending entry). An allowlist that is empty *after* parsing stays fatal —
|
|
36
|
+
fail-closed is unchanged.
|
|
37
|
+
|
|
24
38
|
The marker contract (`Autonomous-Dev` / `Autonomous-Scope` issue-body
|
|
25
39
|
trailers) is parsed in `markers.py`; the keys that name those trailers live
|
|
26
40
|
here (`marker_key`, `scope_key`) so an operator can rename them per-deployment.
|
|
@@ -51,6 +65,7 @@ from __future__ import annotations
|
|
|
51
65
|
|
|
52
66
|
import json
|
|
53
67
|
import os
|
|
68
|
+
import re
|
|
54
69
|
from dataclasses import dataclass, field
|
|
55
70
|
from pathlib import Path
|
|
56
71
|
from typing import Any, Mapping
|
|
@@ -91,9 +106,37 @@ RELEASE_FEEDS = (FEED_SCAN, FEED_BOW)
|
|
|
91
106
|
# Keys carrying a boolean; env strings for these need real parsing (bool("false")
|
|
92
107
|
# is True, which would silently invert the setting).
|
|
93
108
|
_BOOL_KEYS = frozenset({"dry_run", "close_loop_enabled"})
|
|
94
|
-
# Keys carrying a list of strings
|
|
109
|
+
# Keys carrying a list of strings. In an env value they are separator-delimited;
|
|
110
|
+
# `repos` accepts the family's `|` as well as `,` (see REPO_SEPARATORS), every
|
|
111
|
+
# other list key is comma-separated.
|
|
95
112
|
_LIST_KEYS = frozenset({"repos", "bow_owners"})
|
|
96
113
|
|
|
114
|
+
# The separators a one-string repo list may use, in preference order. `|` is the
|
|
115
|
+
# daemon-family convention (devloop's ALISSA_DEV_REPOS), which is what lets one
|
|
116
|
+
# Railway variable reference feed both daemons; `,` is what orcloop shipped
|
|
117
|
+
# through 0.3.0 and stays accepted. Mixing them in one value is fine.
|
|
118
|
+
REPO_SEPARATORS = ("|", ",")
|
|
119
|
+
_REPO_SPLIT_RE = re.compile("[" + re.escape("".join(REPO_SEPARATORS)) + "]")
|
|
120
|
+
|
|
121
|
+
# What an allowlist entry must look like once split: exactly one `/`, a GitHub
|
|
122
|
+
# owner (alphanumerics and hyphens, not leading/trailing) and a repo name
|
|
123
|
+
# (alphanumerics, `.`, `_`, `-`) that is neither all dots nor `.git`-suffixed
|
|
124
|
+
# (in any casing, matching the hint branch below — spelled as a character class
|
|
125
|
+
# rather than re.IGNORECASE, which would also relax the two strict classes) —
|
|
126
|
+
# both are names GitHub itself refuses. Deliberately strict enough to reject
|
|
127
|
+
# the mistakes a separator change invites — a bare word (`widgets`, which names
|
|
128
|
+
# no owner) and a pasted URL or git remote (`https://github.com/acme/widgets`,
|
|
129
|
+
# `git@github.com:acme/widgets.git`), each of which would otherwise become an
|
|
130
|
+
# allowlist row the daemon silently never matches.
|
|
131
|
+
#
|
|
132
|
+
# Every exclusion here is one spelling GitHub refuses that the character class
|
|
133
|
+
# would otherwise accept, so this is the line new ones get added to; the hint
|
|
134
|
+
# table in `_validate_repo_entry` is where each earns its own message.
|
|
135
|
+
_REPO_NAME_RE = re.compile(
|
|
136
|
+
r"^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
|
|
137
|
+
r"/(?!\.+$)(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$"
|
|
138
|
+
)
|
|
139
|
+
|
|
97
140
|
MIN_POLL_INTERVAL = 10 # the GitHub search API allows 30 req/min
|
|
98
141
|
|
|
99
142
|
DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
|
|
@@ -134,6 +177,86 @@ def _string_list(value: Any, key: str) -> tuple[str, ...]:
|
|
|
134
177
|
return tuple(value)
|
|
135
178
|
|
|
136
179
|
|
|
180
|
+
def split_repo_entries(raw: str) -> list[str]:
|
|
181
|
+
"""Split one repo-list string into entries on `|` and/or `,`.
|
|
182
|
+
|
|
183
|
+
Whitespace around an entry is stripped and empties are dropped, so a
|
|
184
|
+
trailing separator, a doubled one, and `a/b | c/d` all behave. This is the
|
|
185
|
+
ONE place the separator set is applied: the library's env layer, the CLI
|
|
186
|
+
flag, the config file, and the container's `render_config.py` all route
|
|
187
|
+
through it, so no surface can end up accepting a different syntax.
|
|
188
|
+
|
|
189
|
+
Splitting only — shape validation is `normalize_repos`, because the config
|
|
190
|
+
file may also hand over an already-split JSON list.
|
|
191
|
+
"""
|
|
192
|
+
return [entry for part in _REPO_SPLIT_RE.split(raw) if (entry := part.strip())]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def normalize_repos(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
196
|
+
"""Turn the raw `repos` layer value into the validated allowlist.
|
|
197
|
+
|
|
198
|
+
Every element is itself split (an entry may carry a whole separated list —
|
|
199
|
+
that is exactly what `ALISSA_ORC_REPOS` and a `--repo a/b|c/d` flag look
|
|
200
|
+
like), duplicates collapse case-insensitively keeping the operator's first
|
|
201
|
+
spelling (GitHub names are case-insensitive, and `watches` matches them
|
|
202
|
+
that way — a repeat is a duplicated GitHub search term, not a second lane),
|
|
203
|
+
and each survivor is validated as `owner/repo`.
|
|
204
|
+
|
|
205
|
+
Emptiness is NOT judged here: `Config.build` owns the fail-closed guard so
|
|
206
|
+
the operator gets its one canonical message.
|
|
207
|
+
"""
|
|
208
|
+
out: list[str] = []
|
|
209
|
+
seen: set[str] = set()
|
|
210
|
+
for value in values:
|
|
211
|
+
for entry in split_repo_entries(value):
|
|
212
|
+
_validate_repo_entry(entry)
|
|
213
|
+
if (key := entry.casefold()) not in seen:
|
|
214
|
+
seen.add(key)
|
|
215
|
+
out.append(entry)
|
|
216
|
+
return tuple(out)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _validate_repo_entry(entry: str) -> None:
|
|
220
|
+
"""Reject anything that is not an `owner/repo` name, naming the entry.
|
|
221
|
+
|
|
222
|
+
Fail loudly rather than carry it: an unmatchable allowlist entry is not
|
|
223
|
+
inert, it is a repo the operator believes is watched and is not.
|
|
224
|
+
"""
|
|
225
|
+
if _REPO_NAME_RE.match(entry):
|
|
226
|
+
return
|
|
227
|
+
if (
|
|
228
|
+
"://" in entry
|
|
229
|
+
or "@" in entry
|
|
230
|
+
or ":" in entry
|
|
231
|
+
or entry.casefold().startswith(("github.com/", "www."))
|
|
232
|
+
):
|
|
233
|
+
# Covers a pasted browser URL, a scheme-less `github.com/...`, and an
|
|
234
|
+
# SSH remote (`git@github.com:acme/widgets.git`) — which has no `://`
|
|
235
|
+
# and only one `/`, so without the `@`/`:` tests it would fall through
|
|
236
|
+
# to the bare message.
|
|
237
|
+
hint = " — that looks like a URL or a git remote; use only the " \
|
|
238
|
+
"`owner/repo` part"
|
|
239
|
+
elif entry.casefold().endswith(".git") and "/" in entry:
|
|
240
|
+
# Ordered AFTER the URL/remote branch so the two messages COMPOSE: a
|
|
241
|
+
# full remote keeps "use only the `owner/repo` part", and the
|
|
242
|
+
# `acme/widgets.git` that following that advice yields gets the
|
|
243
|
+
# remaining half of the fix here. Without this branch, the first
|
|
244
|
+
# message walks the operator from a loud abort into a silent no-match.
|
|
245
|
+
hint = (" — drop the trailing `.git`; that is the clone URL's suffix, "
|
|
246
|
+
"not part of the repo name")
|
|
247
|
+
elif "/" not in entry:
|
|
248
|
+
hint = " — no `/`, so it names no owner"
|
|
249
|
+
elif entry.count("/") > 1:
|
|
250
|
+
hint = " — too many `/`; an entry is one owner and one repo"
|
|
251
|
+
else:
|
|
252
|
+
hint = ""
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"repos entry {entry!r} is not an owner/repo name{hint}. Entries look "
|
|
255
|
+
f"like `acme/widgets` and are separated by `|` (the daemon-family "
|
|
256
|
+
f"convention) or `,`, e.g. \"acme/widgets|acme/gadgets\"."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
137
260
|
def _env_bool(env_name: str, raw: str) -> bool:
|
|
138
261
|
value = raw.strip().lower()
|
|
139
262
|
if value in _ENV_TRUE:
|
|
@@ -164,6 +287,11 @@ def env_overrides(environ: Mapping[str, str] | None = None) -> dict[str, Any]:
|
|
|
164
287
|
raw = env[env_name]
|
|
165
288
|
if key in _BOOL_KEYS:
|
|
166
289
|
out[key] = _env_bool(env_name, raw)
|
|
290
|
+
elif key == "repos":
|
|
291
|
+
# Separator-tolerant (`|` and `,`); shape-validated in `build`
|
|
292
|
+
# alongside the file and CLI layers, so one bad entry reports the
|
|
293
|
+
# same way wherever it came from.
|
|
294
|
+
out[key] = tuple(split_repo_entries(raw))
|
|
167
295
|
elif key in _LIST_KEYS:
|
|
168
296
|
out[key] = tuple(
|
|
169
297
|
item for part in raw.split(",") if (item := part.strip())
|
|
@@ -181,7 +309,9 @@ class Config:
|
|
|
181
309
|
# Fail-closed allowlist of owner/repo. Empty is REJECTED at build (see
|
|
182
310
|
# build's guard) — the orchestrator never runs against an unbounded set.
|
|
183
311
|
# The dataclass default is () only to mark "unset"; a real Config always
|
|
184
|
-
# carries at least one repo.
|
|
312
|
+
# carries at least one repo. Entries reach `build` as `|`- and/or
|
|
313
|
+
# `,`-separated text from any layer and land here split, validated,
|
|
314
|
+
# de-duplicated, and in the operator's own casing (see `normalize_repos`).
|
|
185
315
|
repos: tuple[str, ...] = ()
|
|
186
316
|
|
|
187
317
|
label: str = "alissa:develop"
|
|
@@ -313,7 +443,12 @@ class Config:
|
|
|
313
443
|
# The env layer wins over everything, including a value set on the CLI.
|
|
314
444
|
raw.update(env_overrides(environ))
|
|
315
445
|
|
|
316
|
-
|
|
446
|
+
# Split-then-validate: whichever layer won, an entry may still be a
|
|
447
|
+
# whole `a/b|c/d` list (that is what one Railway variable reference
|
|
448
|
+
# looks like), so normalize before the fail-closed check — a value of
|
|
449
|
+
# only separators parses to nothing and must read as "empty", not as
|
|
450
|
+
# one bogus repo.
|
|
451
|
+
repos = normalize_repos(_string_list(raw.get("repos", cls.repos), "repos"))
|
|
317
452
|
if not repos:
|
|
318
453
|
# Fail-closed: the orchestrator spawns develop work and closes the
|
|
319
454
|
# loop on merge. An empty allowlist would let anyone who can label
|
|
@@ -321,7 +456,8 @@ class Config:
|
|
|
321
456
|
raise ValueError(
|
|
322
457
|
"repos allowlist is empty — the orchestrator is fail-closed and "
|
|
323
458
|
"refuses to start with no repos. Set `repos` in the config file, "
|
|
324
|
-
"pass one or more --repo flags, or set ALISSA_ORC_REPOS
|
|
459
|
+
"pass one or more --repo flags, or set ALISSA_ORC_REPOS "
|
|
460
|
+
"(entries separated by `|`, or `,`)."
|
|
325
461
|
)
|
|
326
462
|
|
|
327
463
|
interval = int(raw.get("poll_interval", cls.poll_interval))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|