alissa-tools-github-orcloop 0.3.1__tar.gz → 0.3.3__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.1/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.3}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/__main__.py +108 -9
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/alissa_client.py +58 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/config.py +141 -31
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/ghclient.py +91 -2
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/loop.py +547 -25
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/state.py +43 -3
- alissa_tools_github_orcloop-0.3.3/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.3.1/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.3.1 → alissa_tools_github_orcloop-0.3.3}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
O1 wired the config surface and the argument parser; O3 wires the decision
|
|
4
4
|
loop behind it; O6 adds the release-feed selector (`--release-feed bow` and its
|
|
5
|
-
`--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs)
|
|
5
|
+
`--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs); O8 makes the
|
|
6
|
+
feed's authority resolve itself (see `resolve_feed_authority`).
|
|
6
7
|
|
|
7
8
|
The CLI resolves and validates the full configuration
|
|
8
9
|
(four-layer precedence, fail-closed `repos` guard), reports the effective
|
|
@@ -10,6 +11,12 @@ settings, then runs the orchestrator: `--once` for a single sweep (pair it
|
|
|
10
11
|
with `--dry-run -v` to print every decision and its reasons without touching
|
|
11
12
|
GitHub or the task graph), otherwise `run_forever`. Config errors still surface
|
|
12
13
|
here, at startup, with a clean exit code.
|
|
14
|
+
|
|
15
|
+
Startup is the ONE place that may do I/O before the loop begins, and it does
|
|
16
|
+
exactly one call: under `release_feed: bow` with no explicit `bow_owners`, the
|
|
17
|
+
daemon asks Alissa who its token is and makes itself the feed authority. That
|
|
18
|
+
call failing is fatal — the daemon exits rather than starting up unable to tell
|
|
19
|
+
its own containers from a stranger's.
|
|
13
20
|
"""
|
|
14
21
|
|
|
15
22
|
from __future__ import annotations
|
|
@@ -17,9 +24,10 @@ from __future__ import annotations
|
|
|
17
24
|
import argparse
|
|
18
25
|
import logging
|
|
19
26
|
import sys
|
|
27
|
+
from dataclasses import replace
|
|
20
28
|
from pathlib import Path
|
|
21
29
|
|
|
22
|
-
from .alissa_client import AlissaError
|
|
30
|
+
from .alissa_client import AlissaClient, AlissaError
|
|
23
31
|
from .config import (
|
|
24
32
|
FEED_BOW,
|
|
25
33
|
RELEASE_FEEDS,
|
|
@@ -119,10 +127,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
119
127
|
metavar="ACTOR_ID",
|
|
120
128
|
help="bow feed only: trust bodies of work OWNED by this Alissa actor "
|
|
121
129
|
"as release feeds; repeatable. When given, REPLACES the config "
|
|
122
|
-
"`bow_owners` list.
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
130
|
+
"`bow_owners` list. OPTIONAL — left unset, the daemon resolves its own "
|
|
131
|
+
"token's actor at boot and trusts only its own containers; give this "
|
|
132
|
+
"flag only when the containers belong to someone else. Being SHARED a "
|
|
133
|
+
"body of work is never a claim to drive this daemon either way. Must be "
|
|
134
|
+
"an actor ID (the `ownerActorId` on the container's row in "
|
|
135
|
+
"GET /v1/bodies-of-work) — a username or display name is refused.",
|
|
126
136
|
)
|
|
127
137
|
over.add_argument(
|
|
128
138
|
"--scan-fallback-interval",
|
|
@@ -197,6 +207,57 @@ def resolve_config(args: argparse.Namespace) -> Config:
|
|
|
197
207
|
return Config.build(workspace_root, file_data, overrides_from(args))
|
|
198
208
|
|
|
199
209
|
|
|
210
|
+
def resolve_feed_authority(config: Config, alissa: AlissaClient) -> Config:
|
|
211
|
+
"""Decide WHOSE bodies of work drive this daemon, and say so out loud.
|
|
212
|
+
|
|
213
|
+
Two answers, one of them free:
|
|
214
|
+
|
|
215
|
+
* `bow_owners` **set** — the operator named the authority explicitly. That
|
|
216
|
+
is the multi-operator case: the containers belong to an actor this token
|
|
217
|
+
is not. Nothing is resolved, nothing is called.
|
|
218
|
+
* `bow_owners` **unset** — the authority is **self**. The token already
|
|
219
|
+
carries an identity, so `GET /v1/ping` answers the question the operator
|
|
220
|
+
would otherwise have hand-copied out of the API, and the daemon trusts
|
|
221
|
+
exactly its own containers.
|
|
222
|
+
|
|
223
|
+
The whoami call is **fatal on failure**, deliberately, and this is the whole
|
|
224
|
+
fail-closed argument in one place. The alternative — carry on with an empty
|
|
225
|
+
authority — is technically safe (nothing would be trusted, so nothing would
|
|
226
|
+
release) but it is safe *silently*: the operator sees a daemon that runs and
|
|
227
|
+
releases nothing, which is exactly what a correctly configured daemon with
|
|
228
|
+
an empty queue looks like. Dying names the reason while someone is watching.
|
|
229
|
+
Note the property being protected is not "self is trusted" but "only a
|
|
230
|
+
KNOWN actor is trusted" — an unanswered whoami leaves that unknown, and no
|
|
231
|
+
default can fill it in.
|
|
232
|
+
|
|
233
|
+
Under `scan` this is a no-op: that feed never reads a Body of Work, so it
|
|
234
|
+
never needs an identity, and a daemon that only scans must not fail to boot
|
|
235
|
+
because an endpoint it does not use was unreachable.
|
|
236
|
+
"""
|
|
237
|
+
if config.release_feed != FEED_BOW:
|
|
238
|
+
return config
|
|
239
|
+
|
|
240
|
+
if config.bow_owners:
|
|
241
|
+
log.info(
|
|
242
|
+
"bow feed authority: explicit (%s)", ", ".join(config.bow_owners)
|
|
243
|
+
)
|
|
244
|
+
return config
|
|
245
|
+
|
|
246
|
+
identity = alissa.whoami() # fatal on failure — see main()
|
|
247
|
+
log.info("bow feed authority: self (%s)", identity.actor_id)
|
|
248
|
+
if identity.display_name:
|
|
249
|
+
# Logged as context, never compared: a display name is renameable, which
|
|
250
|
+
# is why `config.bow_owners` refuses to hold one.
|
|
251
|
+
log.debug(
|
|
252
|
+
"resolved from this token's identity: %r", identity.display_name
|
|
253
|
+
)
|
|
254
|
+
# The id comes from the API, so it is the authority on its own shape and is
|
|
255
|
+
# NOT put through `config`'s actor-id check — that check exists to catch a
|
|
256
|
+
# human typing a username, and applying it here would let a future id format
|
|
257
|
+
# break a daemon that had been given no configuration to be wrong about.
|
|
258
|
+
return replace(config, bow_owners=(identity.actor_id,))
|
|
259
|
+
|
|
260
|
+
|
|
200
261
|
def log_effective_config(config: Config) -> None:
|
|
201
262
|
"""The resolved settings, defaults and all. INFO carries the decisions an
|
|
202
263
|
operator needs; -v (DEBUG) shows the full surface."""
|
|
@@ -241,14 +302,52 @@ def main(argv: "list[str] | None" = None) -> int:
|
|
|
241
302
|
try:
|
|
242
303
|
config = resolve_config(args)
|
|
243
304
|
log.info("workspace: %s", config.workspace_root)
|
|
244
|
-
|
|
245
|
-
|
|
305
|
+
# One client, shared with the orchestrator: the boot-time whoami and
|
|
306
|
+
# every later task-graph call must speak as the same actor, or the
|
|
307
|
+
# authority resolved here would not be the one doing the reading.
|
|
308
|
+
alissa = AlissaClient(base=config.alissa_endpoint)
|
|
309
|
+
except (FileNotFoundError, ValueError, AlissaError) as exc:
|
|
310
|
+
# `AlissaError` is here to keep main()'s exit contract whole, not because
|
|
311
|
+
# anything in this block raises one today (the client's constructor only
|
|
312
|
+
# reads ALISSA_BASE / ALISSA_API_TOKEN). Exit 2 means "never started" and
|
|
313
|
+
# every other way of never starting prints one line; a lone path that
|
|
314
|
+
# answers with a traceback and exit 1 — the code that means "started,
|
|
315
|
+
# then a pass failed" — would misreport itself the day the constructor
|
|
316
|
+
# grows any validation. Plain text, deliberately: the bow-specific advice
|
|
317
|
+
# belongs only to the call that can actually earn it, below.
|
|
246
318
|
print(f"config error: {exc}", file=sys.stderr)
|
|
247
319
|
return 2
|
|
248
320
|
except KeyboardInterrupt:
|
|
249
321
|
return 0
|
|
250
322
|
|
|
251
|
-
|
|
323
|
+
# Deliberately its OWN try, holding exactly one call. The handler below
|
|
324
|
+
# names the bow feed, and `resolve_feed_authority` is the only thing here
|
|
325
|
+
# that can fail for that reason — under `scan` it returns before making any
|
|
326
|
+
# call at all. A wider block would let some unrelated Alissa failure be
|
|
327
|
+
# reported as an authority problem, pointing a scan-only operator at a knob
|
|
328
|
+
# that does nothing in their feed mode.
|
|
329
|
+
try:
|
|
330
|
+
config = resolve_feed_authority(config, alissa)
|
|
331
|
+
except AlissaError as exc:
|
|
332
|
+
# The bow feed could not learn who this token is. Fatal by design (see
|
|
333
|
+
# resolve_feed_authority): booting on an unknown identity would mean a
|
|
334
|
+
# daemon that looks healthy and silently releases nothing. Same exit
|
|
335
|
+
# code as a config error — it is the same class of failure, a daemon
|
|
336
|
+
# that never started, and the operator's fix is a token or an explicit
|
|
337
|
+
# bow_owners rather than anything the loop could retry.
|
|
338
|
+
print(
|
|
339
|
+
f"config error: could not resolve the bow feed authority — {exc}. "
|
|
340
|
+
f"Check ALISSA_API_TOKEN and the endpoint, or set bow_owners "
|
|
341
|
+
f"explicitly to name the actor(s) that own the feed bodies of work.",
|
|
342
|
+
file=sys.stderr,
|
|
343
|
+
)
|
|
344
|
+
return 2
|
|
345
|
+
except KeyboardInterrupt:
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
log_effective_config(config)
|
|
349
|
+
|
|
350
|
+
orchestrator = Orchestrator(config, alissa=alissa)
|
|
252
351
|
try:
|
|
253
352
|
if args.once:
|
|
254
353
|
orchestrator.poll_once()
|
|
@@ -94,6 +94,18 @@ Both BOW endpoints require the daemon actor to be a **collaborator** on the
|
|
|
94
94
|
Body of Work (otherwise 403 ``FORBIDDEN`` on attach/detach) -- an operator setup
|
|
95
95
|
step, documented in the README. orcloop never attaches: attaching is the
|
|
96
96
|
operator's trigger, detaching is orcloop's terminal release act.
|
|
97
|
+
|
|
98
|
+
WHO AM I (O8)
|
|
99
|
+
=============
|
|
100
|
+
``GET /v1/ping`` -- ``{"pong", "timestamp", "userId", "actorId",
|
|
101
|
+
"displayName"}`` -- is the token's identity endpoint, and it is the ONLY one:
|
|
102
|
+
``/v1/me``, ``/v1/whoami``, ``/v1/actors/me``, ``/v1/auth/me``, ``/v1/user(s)/me``,
|
|
103
|
+
``/v1/identity``, ``/v1/actor``, ``/v1/session``, ``/v1/profile`` all answer 404
|
|
104
|
+
``"The requested endpoint does not exist."``. Its OpenAPI summary states the
|
|
105
|
+
contract outright: *"Verify the token and return the acting identity."* That is
|
|
106
|
+
what lets ``bow_owners`` default to the daemon's own actor instead of making the
|
|
107
|
+
operator hand-copy an id -- see ``whoami`` and the boot resolution in
|
|
108
|
+
``__main__.resolve_feed_authority``.
|
|
97
109
|
"""
|
|
98
110
|
|
|
99
111
|
from __future__ import annotations
|
|
@@ -189,6 +201,21 @@ class BowMember:
|
|
|
189
201
|
status: str
|
|
190
202
|
|
|
191
203
|
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class Identity:
|
|
206
|
+
"""Who this token acts as, from ``GET /v1/ping``.
|
|
207
|
+
|
|
208
|
+
`actor_id` is the load-bearing field: it is the same opaque id that appears
|
|
209
|
+
as `ownerActorId` on a Body of Work, so it can be compared directly against
|
|
210
|
+
a container's owner. `user_id` and `display_name` are carried for logging
|
|
211
|
+
only -- a display name is renameable and must never reach an authority
|
|
212
|
+
check (see `config.bow_owners`)."""
|
|
213
|
+
|
|
214
|
+
actor_id: str
|
|
215
|
+
user_id: str = ""
|
|
216
|
+
display_name: str = ""
|
|
217
|
+
|
|
218
|
+
|
|
192
219
|
@dataclass(frozen=True)
|
|
193
220
|
class Criterion:
|
|
194
221
|
id: str
|
|
@@ -317,6 +344,37 @@ class AlissaClient:
|
|
|
317
344
|
|
|
318
345
|
# --- adapter surface ---------------------------------------------------
|
|
319
346
|
|
|
347
|
+
def whoami(self) -> Identity:
|
|
348
|
+
"""The identity this token acts as (`GET /v1/ping`).
|
|
349
|
+
|
|
350
|
+
The `bow` feed's authority defaults to this actor, so the call is on the
|
|
351
|
+
BOOT path and its failure is fatal there -- never softened into a
|
|
352
|
+
warning. A daemon that could not learn who it is cannot tell its own
|
|
353
|
+
containers from a stranger's, and the only safe answers are "refuse to
|
|
354
|
+
start" or "trust nobody"; refusing is the loud one, so that is what
|
|
355
|
+
`__main__.resolve_feed_authority` does with what this raises.
|
|
356
|
+
|
|
357
|
+
A 2xx with no `actorId` is a contract violation rather than a missing
|
|
358
|
+
resource, so it surfaces as the base `AlissaError` -- the same way
|
|
359
|
+
`get_task` treats a response with no task object. `AlissaAuthError`
|
|
360
|
+
(a bad or unset token) and `AlissaTransient` (the endpoint was
|
|
361
|
+
unreachable) come through the shared taxonomy unchanged.
|
|
362
|
+
"""
|
|
363
|
+
payload = self._request("GET", "/v1/ping")
|
|
364
|
+
row = payload if isinstance(payload, dict) else {}
|
|
365
|
+
actor_id = row.get("actorId")
|
|
366
|
+
if not isinstance(actor_id, str) or not actor_id.strip():
|
|
367
|
+
raise AlissaError(
|
|
368
|
+
200,
|
|
369
|
+
f"GET /v1/ping returned no actorId (got {payload!r}) — the "
|
|
370
|
+
f"token's acting identity could not be determined",
|
|
371
|
+
)
|
|
372
|
+
return Identity(
|
|
373
|
+
actor_id=actor_id.strip(),
|
|
374
|
+
user_id=str(row.get("userId") or ""),
|
|
375
|
+
display_name=str(row.get("displayName") or ""),
|
|
376
|
+
)
|
|
377
|
+
|
|
320
378
|
def list_marked_committed_tasks(self, marker: str = RELEASE_MARKER) -> list[MarkedTask]:
|
|
321
379
|
"""Committed tasks carrying the release `marker`, with descriptions.
|
|
322
380
|
|
|
@@ -50,15 +50,28 @@ RELEASE FEED (O6). `release_feed` selects where release candidates come from:
|
|
|
50
50
|
O(total BOW membership). `scan_fallback_interval` keeps a low-frequency full
|
|
51
51
|
trailer scan running underneath as a visibility safety net.
|
|
52
52
|
|
|
53
|
-
The `bow` feed has a **second**
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
The `bow` feed has a **second** allowlist, `bow_owners`, and the two answer
|
|
54
|
+
different questions. A container's title says which repo it targets, so `repos`
|
|
55
|
+
bounds *where* a release lands. Nothing in a title says who created the
|
|
56
56
|
container — and the daemon lists Bodies of Work it merely **collaborates** on,
|
|
57
57
|
which any actor in the tenant can arrange without an acceptance step on this
|
|
58
58
|
side. `bow_owners` names the actor id(s) whose containers may drive the daemon,
|
|
59
|
-
so "whose work reaches an autonomous pipeline" is
|
|
60
|
-
than a consequence of being shared something.
|
|
61
|
-
|
|
59
|
+
so "whose work reaches an autonomous pipeline" is a decided question rather
|
|
60
|
+
than a consequence of being shared something.
|
|
61
|
+
|
|
62
|
+
FEED AUTHORITY (O8). `bow_owners` is an OPTIONAL OVERRIDE, not required setup.
|
|
63
|
+
Left unset it resolves at boot to the **token's own actor** — the daemon asks
|
|
64
|
+
`GET /v1/ping` who it is and trusts only its own containers (see
|
|
65
|
+
`__main__.resolve_feed_authority`, which is also where a failed whoami is
|
|
66
|
+
fatal). Set it only for a multi-operator deployment, where the containers are
|
|
67
|
+
owned by someone other than the actor the daemon's token acts as. That the
|
|
68
|
+
default is self does not soften the gate: an unresolvable identity refuses to
|
|
69
|
+
boot rather than trusting everything, so the fail-closed property `bow_owners`
|
|
70
|
+
was introduced for is unchanged — only the hand-copying is gone.
|
|
71
|
+
|
|
72
|
+
Entries are actor **ids** and nothing else; a username or display name is
|
|
73
|
+
rejected here, naming it (see `_validate_actor_id` for why a name is both a
|
|
74
|
+
spoof surface and a silent one).
|
|
62
75
|
"""
|
|
63
76
|
|
|
64
77
|
from __future__ import annotations
|
|
@@ -137,6 +150,21 @@ _REPO_NAME_RE = re.compile(
|
|
|
137
150
|
r"/(?!\.+$)(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$"
|
|
138
151
|
)
|
|
139
152
|
|
|
153
|
+
# What an Alissa actor id looks like: a 32-character opaque handle of lower-case
|
|
154
|
+
# letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id the API mints
|
|
155
|
+
# — actors, users, tasks, bodies of work — has this shape, and `bow_owners` is
|
|
156
|
+
# checked against it so a username or display name cannot be mistaken for one.
|
|
157
|
+
# The check is SHAPE only: it says "this could be an id", never "this actor
|
|
158
|
+
# exists" (nothing short of a lookup could, and a wrong-but-well-formed id
|
|
159
|
+
# already fails loudly as a feed nobody owns). If the API ever mints a different
|
|
160
|
+
# shape, this constant and the pattern below are the two lines to widen.
|
|
161
|
+
ACTOR_ID_LENGTH = 32
|
|
162
|
+
_ACTOR_ID_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789")
|
|
163
|
+
# `\Z`, not `$`: `$` also matches just before a trailing newline, so
|
|
164
|
+
# `"<id>\n"` would pass. Callers strip, but a gate that only holds because its
|
|
165
|
+
# caller normalised is not a gate.
|
|
166
|
+
_ACTOR_ID_RE = re.compile(rf"^[a-z0-9]{{{ACTOR_ID_LENGTH}}}\Z")
|
|
167
|
+
|
|
140
168
|
MIN_POLL_INTERVAL = 10 # the GitHub search API allows 30 req/min
|
|
141
169
|
|
|
142
170
|
DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
|
|
@@ -257,6 +285,88 @@ def _validate_repo_entry(entry: str) -> None:
|
|
|
257
285
|
)
|
|
258
286
|
|
|
259
287
|
|
|
288
|
+
def normalize_bow_owners(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
289
|
+
"""Turn the raw `bow_owners` layer value into the validated authority list.
|
|
290
|
+
|
|
291
|
+
Entries are whitespace-stripped, empties dropped (so a trailing comma is
|
|
292
|
+
harmless, matching `repos`), duplicates collapsed — EXACTLY, with no
|
|
293
|
+
casefolding, because actor ids are opaque: two spellings that differ in case
|
|
294
|
+
are two different strings on the wire, and folding them would silently widen
|
|
295
|
+
the match. Every survivor must look like an actor id.
|
|
296
|
+
|
|
297
|
+
Emptiness is fine here and means "not overridden": the boot path resolves
|
|
298
|
+
the authority to this token's own actor (see the module docstring). There is
|
|
299
|
+
no fail-closed guard left in `build` for it — an empty list still admits
|
|
300
|
+
nothing (`trusts_feed_owner` returns False for every owner), so the property
|
|
301
|
+
is preserved by the comparison itself rather than by refusing to start.
|
|
302
|
+
"""
|
|
303
|
+
out: list[str] = []
|
|
304
|
+
seen: set[str] = set()
|
|
305
|
+
for value in values:
|
|
306
|
+
entry = value.strip()
|
|
307
|
+
if not entry:
|
|
308
|
+
continue
|
|
309
|
+
_validate_actor_id(entry)
|
|
310
|
+
if entry not in seen:
|
|
311
|
+
seen.add(entry)
|
|
312
|
+
out.append(entry)
|
|
313
|
+
return tuple(out)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _validate_actor_id(entry: str) -> None:
|
|
317
|
+
"""Reject anything that is not an Alissa actor id, naming the entry.
|
|
318
|
+
|
|
319
|
+
Ownership is checked against a container's `ownerActorId`, which is opaque
|
|
320
|
+
and immutable. A username or display name is neither: it is operator-facing
|
|
321
|
+
text the owning account can CHANGE, so an allowlist written in those terms
|
|
322
|
+
grants authority to whoever holds the name when the check runs rather than
|
|
323
|
+
to the actor the operator meant — the reason ids are the only accepted
|
|
324
|
+
spelling.
|
|
325
|
+
|
|
326
|
+
Worse, getting it wrong would not even be loud. Nothing on the wire ever
|
|
327
|
+
equals a name, so a name in this list simply matches no container, and the
|
|
328
|
+
`bow` feed's symptom is an empty queue — indistinguishable from "no work
|
|
329
|
+
attached yet". Failing here, at boot, with the offending entry named, is the
|
|
330
|
+
only point where the mistake is visible.
|
|
331
|
+
"""
|
|
332
|
+
if _ACTOR_ID_RE.match(entry):
|
|
333
|
+
return
|
|
334
|
+
lowered = entry.lower()
|
|
335
|
+
if _ACTOR_ID_RE.match(lowered):
|
|
336
|
+
# The one near-miss with a mechanical fix, so it gets its own message
|
|
337
|
+
# instead of being described. NOT lowered silently: ids are opaque, and
|
|
338
|
+
# quietly rewriting one makes a config stop meaning what it says.
|
|
339
|
+
hint = f" — actor ids are lower-case; did you mean {lowered!r}?"
|
|
340
|
+
else:
|
|
341
|
+
# LENGTH FIRST. Both problems are reported when both are present, but a
|
|
342
|
+
# short name is a length problem before it is an alphabet one: telling
|
|
343
|
+
# someone their 8-character username "contains 'ADHMORTZ'" buries the
|
|
344
|
+
# useful observation under a rendering of its own character set.
|
|
345
|
+
problems = []
|
|
346
|
+
if len(entry) != ACTOR_ID_LENGTH:
|
|
347
|
+
problems.append(
|
|
348
|
+
f"it is {len(entry)} characters, not {ACTOR_ID_LENGTH}"
|
|
349
|
+
)
|
|
350
|
+
offending = "".join(sorted(set(entry) - _ACTOR_ID_ALPHABET))
|
|
351
|
+
if offending:
|
|
352
|
+
problems.append(
|
|
353
|
+
f"it contains {offending!r}, which no actor id does"
|
|
354
|
+
)
|
|
355
|
+
hint = " — " + " and ".join(problems)
|
|
356
|
+
if offending:
|
|
357
|
+
hint += "; that looks like a username or a display name"
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"bow_owners entry {entry!r} is not an Alissa actor id{hint}. Use the "
|
|
360
|
+
f"opaque id — the `ownerActorId` on the container's row in "
|
|
361
|
+
f"GET /v1/bodies-of-work, or `actorId` from GET /v1/ping — never a "
|
|
362
|
+
f"username or display name: names are renameable, so an ownership "
|
|
363
|
+
f"check against one is a spoof surface, and since no id on the wire "
|
|
364
|
+
f"ever equals a name it would fail as a silently empty feed rather "
|
|
365
|
+
f"than an error. Leave bow_owners unset to trust this token's own "
|
|
366
|
+
f"actor, which needs no id at all."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
260
370
|
def _env_bool(env_name: str, raw: str) -> bool:
|
|
261
371
|
value = raw.strip().lower()
|
|
262
372
|
if value in _ENV_TRUE:
|
|
@@ -352,13 +462,16 @@ class Config:
|
|
|
352
462
|
bow_prefix: str = "autodev: "
|
|
353
463
|
|
|
354
464
|
# WHOSE Bodies of Work may act as a release feed: the Alissa actor id(s)
|
|
355
|
-
# allowed to OWN a feed container.
|
|
356
|
-
#
|
|
357
|
-
#
|
|
358
|
-
#
|
|
359
|
-
# collaborates on, which anyone in the tenant can arrange unilaterally.
|
|
360
|
-
# So provenance is an operator decision, made here. Ids are opaque and
|
|
465
|
+
# allowed to OWN a feed container. The title tells the daemon which repo a
|
|
466
|
+
# container targets; it says nothing about who created it, and the daemon
|
|
467
|
+
# lists containers it merely collaborates on, which anyone in the tenant can
|
|
468
|
+
# arrange unilaterally. So provenance is decided here. Ids are opaque and
|
|
361
469
|
# compared EXACTLY (unlike `repos`, which casefolds GitHub names).
|
|
470
|
+
#
|
|
471
|
+
# Empty means "not overridden", NOT "trust everyone": the boot path resolves
|
|
472
|
+
# it to this token's own actor and dies if it cannot (O8), and until it does
|
|
473
|
+
# an empty list matches no owner at all. An explicit list is the
|
|
474
|
+
# multi-operator case — containers owned by an actor this token is not.
|
|
362
475
|
bow_owners: tuple[str, ...] = ()
|
|
363
476
|
|
|
364
477
|
# The `bow`-feed safety net: every this-many seconds, ALSO run the full
|
|
@@ -400,6 +513,13 @@ class Config:
|
|
|
400
513
|
payload does not state cannot be attributed, and an unattributable feed
|
|
401
514
|
is exactly what `bow_owners` exists to refuse. Exact comparison — Alissa
|
|
402
515
|
actor ids are opaque, so normalising them would only widen the match.
|
|
516
|
+
|
|
517
|
+
Fail-closed on an EMPTY authority as well, which is what makes the O8
|
|
518
|
+
default safe to resolve outside this class: before boot fills the list
|
|
519
|
+
in (or when something skipped that step), every owner is untrusted and
|
|
520
|
+
nothing is a feed. The failure mode of a missing authority is therefore
|
|
521
|
+
an inert daemon, never an open one — boot's job is only to make that
|
|
522
|
+
state loud instead of quiet.
|
|
403
523
|
"""
|
|
404
524
|
return bool(actor_id) and actor_id in self.bow_owners
|
|
405
525
|
|
|
@@ -501,25 +621,15 @@ class Config:
|
|
|
501
621
|
"turn every Body of Work into a release feed"
|
|
502
622
|
)
|
|
503
623
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
# `bow` feed: `scan` never reads a Body of Work at all.
|
|
514
|
-
raise ValueError(
|
|
515
|
-
"bow_owners is empty and release_feed is 'bow' — the release "
|
|
516
|
-
"feed is fail-closed and refuses to start without the actor "
|
|
517
|
-
"id(s) allowed to own a feed Body of Work (being shared one is "
|
|
518
|
-
"not a claim to drive this daemon). Set `bow_owners` in the "
|
|
519
|
-
"config file, pass one or more --bow-owner flags, or set "
|
|
520
|
-
"ALISSA_ORC_BOW_OWNERS. An actor id is the `ownerActorId` on "
|
|
521
|
-
"the container's row in GET /v1/bodies-of-work."
|
|
522
|
-
)
|
|
624
|
+
# Shape-validated in EVERY feed mode, not just `bow`. The value is inert
|
|
625
|
+
# under `scan`, but a malformed id that boots quietly today is a daemon
|
|
626
|
+
# that refuses to start on the unrelated day the operator flips the feed
|
|
627
|
+
# over — and this is the layer that knows the entry came from a human.
|
|
628
|
+
# Empty stays legal: the boot path resolves the authority to this
|
|
629
|
+
# token's own actor (O8), so `bow_owners` is an override, not setup.
|
|
630
|
+
bow_owners = normalize_bow_owners(
|
|
631
|
+
_string_list(raw.get("bow_owners", cls.bow_owners), "bow_owners")
|
|
632
|
+
)
|
|
523
633
|
|
|
524
634
|
fallback = int(raw.get("scan_fallback_interval", cls.scan_fallback_interval))
|
|
525
635
|
if fallback < 0:
|
|
@@ -74,13 +74,26 @@ def _closes_re(number: int) -> re.Pattern[str]:
|
|
|
74
74
|
return pattern
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def parse_task_trailer(body: str) -> int | None:
|
|
78
|
+
"""Origin Alissa task number from a line-anchored `Alissa-Task: TASK-<n>`
|
|
79
|
+
TRAILER only -- never the loose `TASK-<n>` scan `parse_task_ref` falls back
|
|
80
|
+
to. This is the strict read, for the one caller that is about to MUTATE the
|
|
81
|
+
matched issue: the release edge's pre-create dedupe applies the trigger
|
|
82
|
+
label to whatever it matches, so "the body mentions TASK-<n> somewhere" is
|
|
83
|
+
not a good enough claim. A passing mention in prose must not adopt."""
|
|
84
|
+
match = TASK_TRAILER_RE.search(body or "")
|
|
85
|
+
return int(match.group(1)) if match else None
|
|
86
|
+
|
|
87
|
+
|
|
77
88
|
def parse_task_ref(body: str) -> int | None:
|
|
78
89
|
"""Origin Alissa task number from an issue body, or None. A line-anchored
|
|
79
90
|
`Alissa-Task: TASK-<n>` trailer wins wherever it sits; only absent that does
|
|
80
91
|
the loose first-`TASK-<n>` scan run. Mirrors devloop's parser so both
|
|
81
92
|
daemons read the trailer orcloop writes identically."""
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
trailer = parse_task_trailer(body)
|
|
94
|
+
if trailer is not None:
|
|
95
|
+
return trailer
|
|
96
|
+
match = TASK_REF_RE.search(body or "")
|
|
84
97
|
return int(match.group(1)) if match else None
|
|
85
98
|
|
|
86
99
|
|
|
@@ -245,6 +258,82 @@ class GitHub:
|
|
|
245
258
|
out.append((parts[-2], parts[-1], number))
|
|
246
259
|
return out
|
|
247
260
|
|
|
261
|
+
def issues_with_task_trailer(
|
|
262
|
+
self, owner: str, repo: str, task_number: int
|
|
263
|
+
) -> list[Issue]:
|
|
264
|
+
"""Every issue in ONE repo whose body carries the `Alissa-Task:
|
|
265
|
+
TASK-<task_number>` trailer -- OPEN and CLOSED alike, lowest issue
|
|
266
|
+
number first (earliest filed first).
|
|
267
|
+
|
|
268
|
+
This is the trailer-authoritative dedupe read, and it exists because
|
|
269
|
+
`search_issues` above is not one: that query is `is:issue is:open
|
|
270
|
+
label:"<label>"`, so a pre-existing issue carrying the trailer but NOT
|
|
271
|
+
the label is invisible to it, and the release edge filed a duplicate
|
|
272
|
+
issue for a task a hand-staged issue already covered. The label answers
|
|
273
|
+
"what is in flight"; only the trailer answers "does an issue for this
|
|
274
|
+
task already exist".
|
|
275
|
+
|
|
276
|
+
No `is:open` qualifier: the caller decides what a CLOSED match means
|
|
277
|
+
(that rule cannot be applied to results the query never returned).
|
|
278
|
+
|
|
279
|
+
The search index is a HINT, never the verdict. GitHub tokenizes the
|
|
280
|
+
phrase, so a hit can be an issue that merely mentions the number in
|
|
281
|
+
prose; every hit is re-checked against the strict trailer regex on the
|
|
282
|
+
body the API returned, and anything not carrying THIS task's trailer is
|
|
283
|
+
dropped. In the other direction the index can be stale for a
|
|
284
|
+
just-created issue -- which is exactly what the release ledger covers,
|
|
285
|
+
and a miss here degrades to filing an issue (today's behaviour), never
|
|
286
|
+
to mutating the wrong one.
|
|
287
|
+
|
|
288
|
+
Fails closed on a blank owner/repo, the same posture as
|
|
289
|
+
`search_issues` on an empty allowlist: a missing `repo:` qualifier
|
|
290
|
+
would run the query against all of GitHub.
|
|
291
|
+
"""
|
|
292
|
+
owner, repo = owner.strip(), repo.strip()
|
|
293
|
+
if not owner or not repo:
|
|
294
|
+
log.warning(
|
|
295
|
+
"issues_with_task_trailer: blank owner/repo (%r/%r) -- an "
|
|
296
|
+
"unscoped search would match all of GitHub, so this fails "
|
|
297
|
+
"closed and reports no match",
|
|
298
|
+
owner, repo,
|
|
299
|
+
)
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
query = (
|
|
303
|
+
f'is:issue repo:{owner}/{repo} in:body '
|
|
304
|
+
f'"Alissa-Task: TASK-{task_number}"'
|
|
305
|
+
)
|
|
306
|
+
payload = self._api(
|
|
307
|
+
"-X", "GET", "search/issues", "-f", f"q={query}", "-f",
|
|
308
|
+
f"per_page={self.SEARCH_PER_PAGE}",
|
|
309
|
+
)
|
|
310
|
+
items = payload.get("items", []) if isinstance(payload, dict) else []
|
|
311
|
+
if len(items) == self.SEARCH_PER_PAGE:
|
|
312
|
+
log.warning(
|
|
313
|
+
"issues_with_task_trailer: got exactly %d items for TASK-%d in "
|
|
314
|
+
"%s/%s -- results are likely truncated; the earliest matching "
|
|
315
|
+
"issue may not be among them",
|
|
316
|
+
self.SEARCH_PER_PAGE, task_number, owner, repo,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
out: list[Issue] = []
|
|
320
|
+
for item in items:
|
|
321
|
+
if not isinstance(item, dict) or item.get("pull_request"):
|
|
322
|
+
continue # `is:issue` should have done this; belt and braces
|
|
323
|
+
issue = self._issue_from_payload(owner, repo, item)
|
|
324
|
+
if not issue.number:
|
|
325
|
+
log.warning("could not parse issue number from %r", item.get("number"))
|
|
326
|
+
continue
|
|
327
|
+
if parse_task_trailer(issue.body) != task_number:
|
|
328
|
+
log.debug(
|
|
329
|
+
"%s matched the TASK-%d search but carries no such trailer "
|
|
330
|
+
"-- dropped (the index matches tokens, the trailer decides)",
|
|
331
|
+
issue.issue_slug, task_number,
|
|
332
|
+
)
|
|
333
|
+
continue
|
|
334
|
+
out.append(issue)
|
|
335
|
+
return sorted(out, key=lambda found: found.number)
|
|
336
|
+
|
|
248
337
|
def comment(self, owner: str, repo: str, number: int, body: str) -> None:
|
|
249
338
|
self._api(
|
|
250
339
|
f"repos/{owner}/{repo}/issues/{number}/comments", "-f", f"body={body}"
|