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.
Files changed (23) hide show
  1. {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
  2. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/__main__.py +4 -3
  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
  4. alissa_tools_github_orcloop-0.3.1/src/main/alissa/tools/github/orcloop/version +1 -0
  5. {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
  6. alissa_tools_github_orcloop-0.3.0/src/main/alissa/tools/github/orcloop/version +0 -1
  7. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/MANIFEST.in +0 -0
  8. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/README.md +0 -0
  9. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/requirements.txt +0 -0
  10. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/setup.cfg +0 -0
  11. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/setup.py +0 -0
  12. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  13. {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
  14. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/loop.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/state.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {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
  21. {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
  22. {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
  23. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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. When given, REPLACES the config "
88
- "`repos` list. The allowlist is fail-closed: an empty allowlist is a "
89
- "fatal startup error, not a no-op.",
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; an env value is comma-separated.
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
- repos = _string_list(raw.get("repos", cls.repos), "repos")
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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera