alissa-tools-github-revloop 0.28.0__tar.gz → 0.29.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.
- {alissa_tools_github_revloop-0.28.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.29.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/__main__.py +100 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/alissa_client.py +135 -12
- alissa_tools_github_revloop-0.29.0/src/main/alissa/tools/github/revloop/bows.py +405 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/config.py +285 -7
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/loop.py +186 -2
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/state.py +69 -0
- alissa_tools_github_revloop-0.29.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/sources.py +14 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -3,18 +3,25 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import dataclasses
|
|
6
7
|
import logging
|
|
7
8
|
import re
|
|
8
9
|
import sys
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
12
|
+
from .alissa_client import AlissaClient, AlissaError
|
|
11
13
|
from .config import (
|
|
14
|
+
BOW_OWNERS_ENV,
|
|
15
|
+
BOWS_REFRESH_POLLS_ENV,
|
|
12
16
|
HUB_ADD,
|
|
13
17
|
HUB_SKIP,
|
|
14
18
|
LOOP_EVENTS_ENV,
|
|
15
19
|
ON_MISSING_CREATE,
|
|
16
20
|
ON_MISSING_SKIP,
|
|
17
21
|
ON_MISSING_SPAWN,
|
|
22
|
+
REPOS_BOWS,
|
|
23
|
+
REPOS_SOURCE_ENV,
|
|
24
|
+
REPOS_STATIC,
|
|
18
25
|
TASK_LIST_BOW_ENV,
|
|
19
26
|
Config,
|
|
20
27
|
load_config_file,
|
|
@@ -82,6 +89,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
82
89
|
metavar="OWNER/REPO",
|
|
83
90
|
help="only watch this repo; repeatable. Replaces the config list entirely.",
|
|
84
91
|
)
|
|
92
|
+
over.add_argument(
|
|
93
|
+
"--repos-source",
|
|
94
|
+
dest="repos_source",
|
|
95
|
+
choices=[REPOS_STATIC, REPOS_BOWS],
|
|
96
|
+
help="where the allowlist comes from: 'static' (the repos list alone, "
|
|
97
|
+
"the default) or 'bows' (that list unioned with the repos named by the "
|
|
98
|
+
"operator's active `autodev: <owner>/<repo>` bodies of work, "
|
|
99
|
+
"re-derived every --bows-refresh-polls passes). Under bows an EMPTY "
|
|
100
|
+
f"allowlist watches nothing. Overridden by ${REPOS_SOURCE_ENV}",
|
|
101
|
+
)
|
|
102
|
+
over.add_argument(
|
|
103
|
+
"--bows-refresh-polls",
|
|
104
|
+
dest="bows_refresh_polls",
|
|
105
|
+
type=int,
|
|
106
|
+
metavar="N",
|
|
107
|
+
help="bows mode: re-derive the allowlist every N poll passes (>= 1). "
|
|
108
|
+
f"Overridden by ${BOWS_REFRESH_POLLS_ENV}",
|
|
109
|
+
)
|
|
110
|
+
over.add_argument(
|
|
111
|
+
"--bow-owner",
|
|
112
|
+
dest="bow_owners",
|
|
113
|
+
action="append",
|
|
114
|
+
metavar="ACTOR_ID",
|
|
115
|
+
help="bows mode: an Alissa actor id whose bodies of work may enroll a "
|
|
116
|
+
"repo; repeatable, and one value may carry a `|`/`,`-separated list. "
|
|
117
|
+
"Replaces the config list entirely. Unset = this token's own actor, "
|
|
118
|
+
f"resolved at boot via GET /v1/ping. Overridden by ${BOW_OWNERS_ENV}",
|
|
119
|
+
)
|
|
85
120
|
over.add_argument(
|
|
86
121
|
"--author",
|
|
87
122
|
dest="authors",
|
|
@@ -248,6 +283,9 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
248
283
|
through. `repos` becomes a tuple so it matches the config-file form."""
|
|
249
284
|
return {
|
|
250
285
|
"repos": tuple(args.repos) if args.repos else None,
|
|
286
|
+
"repos_source": args.repos_source,
|
|
287
|
+
"bows_refresh_polls": args.bows_refresh_polls,
|
|
288
|
+
"bow_owners": tuple(args.bow_owners) if args.bow_owners else None,
|
|
251
289
|
"authors": tuple(args.authors) if args.authors else None,
|
|
252
290
|
"operators": tuple(args.operators) if args.operators else None,
|
|
253
291
|
"poll_interval": args.poll_interval,
|
|
@@ -284,6 +322,54 @@ def resolve_config(args: argparse.Namespace) -> Config:
|
|
|
284
322
|
return Config.build(workspace_root, file_data, overrides_from(args))
|
|
285
323
|
|
|
286
324
|
|
|
325
|
+
def resolve_feed_authority(
|
|
326
|
+
config: Config, client: "AlissaClient | None" = None
|
|
327
|
+
) -> Config:
|
|
328
|
+
"""Decide WHOSE bodies of work may enroll a repo, and say so out loud.
|
|
329
|
+
|
|
330
|
+
Two answers, one of them free:
|
|
331
|
+
|
|
332
|
+
* `bow_owners` **set** — the operator named the authority explicitly. That
|
|
333
|
+
is the multi-actor case: the feed containers belong to an actor this
|
|
334
|
+
token is not. Nothing is resolved, nothing is called.
|
|
335
|
+
* `bow_owners` **unset** — the authority is **self**. The token already
|
|
336
|
+
carries an identity, so `GET /v1/ping` answers the question the operator
|
|
337
|
+
would otherwise have hand-copied out of the API.
|
|
338
|
+
|
|
339
|
+
The whoami call is **fatal on failure**, deliberately. Carrying on with an
|
|
340
|
+
empty authority is technically safe (nothing would be trusted, so nothing
|
|
341
|
+
would enroll) but it is safe *silently*: the operator would see a daemon
|
|
342
|
+
that runs and watches only the static list, which is exactly what a
|
|
343
|
+
correctly configured daemon with no feeds yet looks like. Dying names the
|
|
344
|
+
reason while someone is watching. The property being protected is not
|
|
345
|
+
"self is trusted" but "only a KNOWN actor is trusted" — an unanswered
|
|
346
|
+
whoami leaves that unknown, and no default can fill it in. Never falling
|
|
347
|
+
back to trusting everything is the other half of the same rule.
|
|
348
|
+
|
|
349
|
+
Under `static` this is a no-op: that mode never reads a Body of Work, so
|
|
350
|
+
it never needs an identity, and a daemon that only watches its static
|
|
351
|
+
list must not fail to boot because an endpoint it does not use was
|
|
352
|
+
unreachable.
|
|
353
|
+
"""
|
|
354
|
+
if config.repos_source != REPOS_BOWS:
|
|
355
|
+
return config
|
|
356
|
+
|
|
357
|
+
if config.bow_owners:
|
|
358
|
+
log.info("bow feed authority: explicit (%s)", ", ".join(config.bow_owners))
|
|
359
|
+
return config
|
|
360
|
+
|
|
361
|
+
identity = (client or AlissaClient(base=config.alissa_endpoint)).ping()
|
|
362
|
+
log.info("bow feed authority: self (%s)", identity.actor_id)
|
|
363
|
+
if identity.display_name:
|
|
364
|
+
# Logged as context, never compared: a display name is renameable,
|
|
365
|
+
# which is why `bow_owners` refuses to hold one.
|
|
366
|
+
log.debug("resolved from this token's identity: %r", identity.display_name)
|
|
367
|
+
# The id came from the API, so it is the authority on its own shape and is
|
|
368
|
+
# NOT put back through the config's actor-id check -- that check exists to
|
|
369
|
+
# catch an operator typing a username, not to second-guess the server.
|
|
370
|
+
return dataclasses.replace(config, bow_owners=(identity.actor_id,))
|
|
371
|
+
|
|
372
|
+
|
|
287
373
|
def main(argv: list[str] | None = None) -> int:
|
|
288
374
|
args = build_parser().parse_args(argv)
|
|
289
375
|
|
|
@@ -306,6 +392,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
306
392
|
try:
|
|
307
393
|
config = resolve_config(args)
|
|
308
394
|
log.info("workspace: %s", config.workspace_root)
|
|
395
|
+
log.info("repos_source: %s", config.repos_source)
|
|
396
|
+
config = resolve_feed_authority(config)
|
|
309
397
|
|
|
310
398
|
watcher = ReviewWatcher(config)
|
|
311
399
|
for warning in watcher.preflight():
|
|
@@ -349,6 +437,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
349
437
|
except (FileNotFoundError, ValueError) as exc:
|
|
350
438
|
print(f"config error: {exc}", file=sys.stderr)
|
|
351
439
|
return 2
|
|
440
|
+
except AlissaError as exc:
|
|
441
|
+
# Only reachable from resolve_feed_authority: an unanswered whoami
|
|
442
|
+
# leaves the feed authority unknown, and starting with it unknown is
|
|
443
|
+
# the one failure this mode must not have (the poll loop's own
|
|
444
|
+
# feed reads never raise -- see bows.BowRepoSource.refresh).
|
|
445
|
+
print(
|
|
446
|
+
f"alissa error: {exc} — repos_source='bows' could not resolve the "
|
|
447
|
+
f"feed authority (GET /v1/ping). Check ALISSA_API_TOKEN and "
|
|
448
|
+
f"alissa_endpoint, or set bow_owners explicitly",
|
|
449
|
+
file=sys.stderr,
|
|
450
|
+
)
|
|
451
|
+
return 2
|
|
352
452
|
except CommandError as exc:
|
|
353
453
|
print(f"error: {exc}", file=sys.stderr)
|
|
354
454
|
return 1
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Alissa REST access —
|
|
1
|
+
"""Alissa REST access — loop telemetry's one write (issue #112) and the two
|
|
2
|
+
reads `repos_source: bows` needs (issue #119).
|
|
2
3
|
|
|
3
4
|
This is the SECOND Alissa adapter in the package, and the split is deliberate.
|
|
4
5
|
`alissa.py` shells out to the `alissa` CLI, which is the daemon's established
|
|
@@ -20,6 +21,23 @@ The caller here is a BEST-EFFORT emitter (`loop_events`): its answer to every
|
|
|
20
21
|
bucket is the same — warn once and let the pass complete — so the taxonomy
|
|
21
22
|
exists for the log line, which should say "your token is wrong" (permanent,
|
|
22
23
|
operator-fixable) differently from "the API blinked".
|
|
24
|
+
|
|
25
|
+
The two READS (issue #119) ride the same transport and the same taxonomy, and
|
|
26
|
+
they are the shape devloop's client already gives them, so the two daemons
|
|
27
|
+
agree about the wire:
|
|
28
|
+
|
|
29
|
+
``GET /v1/ping``
|
|
30
|
+
``{"pong", "timestamp", "userId", "actorId", "displayName"}`` — the
|
|
31
|
+
identity this token acts as. `actorId` is the same opaque id that appears
|
|
32
|
+
as `ownerActorId` on a Body of Work, which is what makes "trust my own
|
|
33
|
+
feeds" resolvable at boot without an operator hand-copying an id.
|
|
34
|
+
|
|
35
|
+
``GET /v1/bodies-of-work?includeShared=true``
|
|
36
|
+
``{"bodiesOfWork": [{"_id", "title", "status", "ownerActorId", ...}]}``.
|
|
37
|
+
`includeShared=true` is load-bearing: the endpoint defaults to OWNED-only,
|
|
38
|
+
and a feed container is the operator's with the daemon merely a
|
|
39
|
+
collaborator. It also widens the listing to whatever anyone shared, which
|
|
40
|
+
is why `bows.py` gates on the owner and never on the title alone.
|
|
23
41
|
"""
|
|
24
42
|
|
|
25
43
|
from __future__ import annotations
|
|
@@ -29,6 +47,7 @@ import logging
|
|
|
29
47
|
import os
|
|
30
48
|
import urllib.error
|
|
31
49
|
import urllib.request
|
|
50
|
+
from dataclasses import dataclass
|
|
32
51
|
|
|
33
52
|
log = logging.getLogger(__name__)
|
|
34
53
|
|
|
@@ -89,8 +108,38 @@ class AlissaTransient(AlissaError):
|
|
|
89
108
|
deterministic dedupe keys are what make it harmless."""
|
|
90
109
|
|
|
91
110
|
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Identity:
|
|
113
|
+
"""Who this token acts as (`GET /v1/ping`).
|
|
114
|
+
|
|
115
|
+
`actor_id` is the field the feed-authority gate compares against a Body
|
|
116
|
+
of Work's `ownerActorId`. The other two are context for the log line —
|
|
117
|
+
a display name is renameable, so nothing is ever compared against it."""
|
|
118
|
+
|
|
119
|
+
actor_id: str
|
|
120
|
+
user_id: str = ""
|
|
121
|
+
display_name: str = ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class BodyOfWork:
|
|
126
|
+
"""A Body of Work as the LIST endpoint reports it — only the four fields
|
|
127
|
+
the feed reads.
|
|
128
|
+
|
|
129
|
+
`owner_id` is the container's provenance and the only field here that is
|
|
130
|
+
not operator-authored text: the listing includes containers merely SHARED
|
|
131
|
+
with this actor, so the title alone cannot say whether a feed is one the
|
|
132
|
+
operator set up. Empty when the payload omits it, which the authority gate
|
|
133
|
+
treats as untrusted rather than unknown."""
|
|
134
|
+
|
|
135
|
+
id: str
|
|
136
|
+
title: str
|
|
137
|
+
status: str = ""
|
|
138
|
+
owner_id: str = ""
|
|
139
|
+
|
|
140
|
+
|
|
92
141
|
class AlissaClient:
|
|
93
|
-
"""
|
|
142
|
+
"""One write and two reads, with the transport hidden behind the taxonomy.
|
|
94
143
|
|
|
95
144
|
Reads `ALISSA_API_TOKEN` from the environment; `base` defaults to the
|
|
96
145
|
public API. Both are constructor arguments so a test never needs the
|
|
@@ -108,24 +157,31 @@ class AlissaClient:
|
|
|
108
157
|
self._token = token if token is not None else os.environ.get(ENV_TOKEN)
|
|
109
158
|
self._timeout = timeout
|
|
110
159
|
|
|
111
|
-
def _request(self, path: str, payload: dict) -> object:
|
|
112
|
-
"""One POST
|
|
113
|
-
|
|
160
|
+
def _request(self, path: str, payload: "dict | None" = None) -> object:
|
|
161
|
+
"""One call: a POST when `payload` is given, a GET otherwise. Every
|
|
162
|
+
failure leaves as a taxonomy exception — the caller never sees a raw
|
|
163
|
+
urllib error or an HTTP status. Both verbs share the redirect-refusing
|
|
164
|
+
opener: a 30x on a GET would carry the bearer token across hosts just
|
|
165
|
+
as it would on the POST."""
|
|
114
166
|
if not self._token:
|
|
115
167
|
# No token at all is an auth condition, not a transport one: the
|
|
116
168
|
# operator must set the env var. Fail the way a 401 would, so the
|
|
117
169
|
# emitter's warning reads as permanent rather than transient.
|
|
118
170
|
raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
|
|
119
171
|
|
|
172
|
+
headers = {
|
|
173
|
+
"Accept": "application/json",
|
|
174
|
+
"Authorization": f"Bearer {self._token}",
|
|
175
|
+
}
|
|
176
|
+
data = None
|
|
177
|
+
if payload is not None:
|
|
178
|
+
headers["Content-Type"] = "application/json"
|
|
179
|
+
data = json.dumps(payload).encode("utf-8")
|
|
120
180
|
req = urllib.request.Request(
|
|
121
181
|
f"{self.base}{path}",
|
|
122
|
-
method="POST",
|
|
123
|
-
data=
|
|
124
|
-
headers=
|
|
125
|
-
"Accept": "application/json",
|
|
126
|
-
"Content-Type": "application/json",
|
|
127
|
-
"Authorization": f"Bearer {self._token}",
|
|
128
|
-
},
|
|
182
|
+
method="POST" if payload is not None else "GET",
|
|
183
|
+
data=data,
|
|
184
|
+
headers=headers,
|
|
129
185
|
)
|
|
130
186
|
try:
|
|
131
187
|
with _opener.open(req, timeout=self._timeout) as resp:
|
|
@@ -189,3 +245,70 @@ class AlissaClient:
|
|
|
189
245
|
)
|
|
190
246
|
payload = self._request("/v1/loop-events", {"events": events})
|
|
191
247
|
return payload if isinstance(payload, dict) else {}
|
|
248
|
+
|
|
249
|
+
def ping(self) -> Identity:
|
|
250
|
+
"""The identity this token acts as (`GET /v1/ping`).
|
|
251
|
+
|
|
252
|
+
On the BOOT path of `repos_source: bows`, and fatal there — see
|
|
253
|
+
`__main__.resolve_feed_authority` for why an unanswered whoami must
|
|
254
|
+
stop the daemon rather than soften into an empty authority."""
|
|
255
|
+
payload = self._request("/v1/ping")
|
|
256
|
+
row = payload if isinstance(payload, dict) else {}
|
|
257
|
+
actor_id = row.get("actorId")
|
|
258
|
+
if not isinstance(actor_id, str) or not actor_id.strip():
|
|
259
|
+
# A 2xx with no actorId is a contract violation, not a missing
|
|
260
|
+
# resource: name it as such rather than letting an empty authority
|
|
261
|
+
# travel onwards looking like a configuration choice.
|
|
262
|
+
raise AlissaError(
|
|
263
|
+
200,
|
|
264
|
+
f"GET /v1/ping returned no actorId (got {payload!r}) — the "
|
|
265
|
+
f"token's acting identity could not be determined",
|
|
266
|
+
)
|
|
267
|
+
return Identity(
|
|
268
|
+
actor_id=actor_id.strip(),
|
|
269
|
+
user_id=str(row.get("userId") or ""),
|
|
270
|
+
display_name=str(row.get("displayName") or ""),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def list_bodies_of_work(self) -> "list[BodyOfWork]":
|
|
274
|
+
"""Every Body of Work this actor owns OR collaborates on — ONE call.
|
|
275
|
+
|
|
276
|
+
There is no server-side title or status filter, so the caller narrows.
|
|
277
|
+
Rows without an `_id` are dropped: a container that cannot be
|
|
278
|
+
addressed cannot be reported on either.
|
|
279
|
+
|
|
280
|
+
A payload with no `bodiesOfWork` LIST is a contract violation, not an
|
|
281
|
+
empty listing, and is raised as such -- `ping`'s missing-`actorId`
|
|
282
|
+
reasoning applied to the other read. Degrading it to `[]` would report
|
|
283
|
+
SUCCESS to the refresh, which would then derive nothing and drop every
|
|
284
|
+
feed-enrolled repo on a routine INFO line: a response-shape change or
|
|
285
|
+
an `alissa_endpoint` answering 200 with something else would silently
|
|
286
|
+
un-enroll everything, which is precisely what the never-shrink rule
|
|
287
|
+
promises cannot happen. Raising routes it to the refresh's failure
|
|
288
|
+
path instead, which keeps the last good set. `{"bodiesOfWork": []}`
|
|
289
|
+
is untouched -- present-and-empty is a legitimate empty listing, and
|
|
290
|
+
the documented way an operator un-enrolls the last repo."""
|
|
291
|
+
payload = self._request("/v1/bodies-of-work?includeShared=true")
|
|
292
|
+
rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
|
|
293
|
+
if not isinstance(rows, list):
|
|
294
|
+
raise AlissaError(
|
|
295
|
+
200,
|
|
296
|
+
f"GET /v1/bodies-of-work returned no bodiesOfWork list "
|
|
297
|
+
f"(got {payload!r})",
|
|
298
|
+
)
|
|
299
|
+
out: "list[BodyOfWork]" = []
|
|
300
|
+
for row in rows:
|
|
301
|
+
if not isinstance(row, dict):
|
|
302
|
+
continue
|
|
303
|
+
bow_id = row.get("_id")
|
|
304
|
+
if not isinstance(bow_id, str) or not bow_id:
|
|
305
|
+
continue
|
|
306
|
+
out.append(
|
|
307
|
+
BodyOfWork(
|
|
308
|
+
id=bow_id,
|
|
309
|
+
title=str(row.get("title") or ""),
|
|
310
|
+
status=str(row.get("status") or ""),
|
|
311
|
+
owner_id=str(row.get("ownerActorId") or ""),
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
return out
|