alissa-tools-github-devloop 0.8.7__tar.gz → 0.8.9__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_devloop-0.8.7/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.9}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/__main__.py +115 -3
- alissa_tools_github_devloop-0.8.9/src/main/alissa/tools/github/devloop/alissa_client.py +270 -0
- alissa_tools_github_devloop-0.8.9/src/main/alissa/tools/github/devloop/bows.py +376 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/config.py +231 -4
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/loop.py +147 -4
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/state.py +25 -0
- alissa_tools_github_devloop-0.8.9/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +2 -0
- alissa_tools_github_devloop-0.8.7/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.7 → alissa_tools_github_devloop-0.8.9}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -8,12 +8,15 @@ import logging
|
|
|
8
8
|
import sys
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
+
from .alissa_client import AlissaClient, AlissaError
|
|
11
12
|
from .config import (
|
|
12
13
|
HUB_ADD,
|
|
13
14
|
HUB_SKIP,
|
|
14
15
|
ON_MISSING_SKIP,
|
|
15
16
|
ON_MISSING_SPAWN,
|
|
16
17
|
ON_MISSING_WARN,
|
|
18
|
+
REPOS_BOWS,
|
|
19
|
+
REPOS_STATIC,
|
|
17
20
|
Config,
|
|
18
21
|
load_config_file,
|
|
19
22
|
resolve_config_path,
|
|
@@ -34,7 +37,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
34
37
|
"fresh developer sessions — the developer-side counterpart of "
|
|
35
38
|
"alissa-reviewloop.",
|
|
36
39
|
epilog="Every setting below can also live in the config file; CLI "
|
|
37
|
-
"arguments win, and ALISSA_DEV_REVIEWERS wins over --reviewer
|
|
40
|
+
"arguments win, and ALISSA_DEV_REVIEWERS wins over --reviewer "
|
|
41
|
+
"(ALISSA_DEV_REPOS_SOURCE likewise over --repos-source). "
|
|
38
42
|
"workspace_root is CLI-only, so one config can drive several daemons "
|
|
39
43
|
"over different workspaces.",
|
|
40
44
|
)
|
|
@@ -76,6 +80,41 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
76
80
|
"applies. Emptying the allowlist (which turns the daemon off — an "
|
|
77
81
|
"empty allowlist watches NOTHING) is done in the config file.",
|
|
78
82
|
)
|
|
83
|
+
over.add_argument(
|
|
84
|
+
"--repos-source",
|
|
85
|
+
choices=[REPOS_STATIC, REPOS_BOWS],
|
|
86
|
+
help="where the watch allowlist comes from: 'static' (the default) is "
|
|
87
|
+
"the `repos` list alone; 'bows' UNIONS it with the repos named by the "
|
|
88
|
+
"operator's active `autodev: <owner>/<repo>` bodies of work, so "
|
|
89
|
+
"enrolling a repo needs no redeploy. ALISSA_DEV_REPOS_SOURCE wins "
|
|
90
|
+
"over even this flag",
|
|
91
|
+
)
|
|
92
|
+
over.add_argument(
|
|
93
|
+
"--bows-refresh-polls",
|
|
94
|
+
type=int,
|
|
95
|
+
metavar="N",
|
|
96
|
+
help="bows mode: re-derive the allowlist every N poll passes "
|
|
97
|
+
"(default 5; 1 = every pass)",
|
|
98
|
+
)
|
|
99
|
+
over.add_argument(
|
|
100
|
+
"--bow-owner",
|
|
101
|
+
dest="bow_owners",
|
|
102
|
+
action="append",
|
|
103
|
+
metavar="ACTOR_ID",
|
|
104
|
+
help="bows mode: trust bodies of work OWNED by this Alissa actor as "
|
|
105
|
+
"repo feeds; repeatable, and one flag may carry several ids separated "
|
|
106
|
+
"by `|` or `,`. When given, REPLACES the config `bow_owners` list. "
|
|
107
|
+
"OPTIONAL — left unset, the daemon resolves its own token's actor at "
|
|
108
|
+
"boot and trusts only its own containers. Must be an actor ID (the "
|
|
109
|
+
"`ownerActorId` on the container's row); a username or display name "
|
|
110
|
+
"is refused",
|
|
111
|
+
)
|
|
112
|
+
over.add_argument(
|
|
113
|
+
"--alissa-endpoint",
|
|
114
|
+
metavar="URL",
|
|
115
|
+
help="bows mode: the Alissa REST API the feed listing is read from "
|
|
116
|
+
"(default https://api.alissa.app)",
|
|
117
|
+
)
|
|
79
118
|
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
80
119
|
over.add_argument("--label", metavar="LABEL", help="issue label that marks dev-ready work")
|
|
81
120
|
over.add_argument("--hub-template", metavar="TEMPLATE")
|
|
@@ -321,6 +360,10 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
321
360
|
through. `repos`/`reviewers` become tuples so they match the file form."""
|
|
322
361
|
return {
|
|
323
362
|
"repos": tuple(args.repos) if args.repos else None,
|
|
363
|
+
"repos_source": args.repos_source,
|
|
364
|
+
"bows_refresh_polls": args.bows_refresh_polls,
|
|
365
|
+
"bow_owners": tuple(args.bow_owners) if args.bow_owners else None,
|
|
366
|
+
"alissa_endpoint": args.alissa_endpoint,
|
|
324
367
|
"poll_interval": args.poll_interval,
|
|
325
368
|
"label": args.label,
|
|
326
369
|
"hub_template": args.hub_template,
|
|
@@ -367,16 +410,70 @@ def resolve_config(args: argparse.Namespace) -> Config:
|
|
|
367
410
|
)
|
|
368
411
|
|
|
369
412
|
|
|
413
|
+
def resolve_feed_authority(
|
|
414
|
+
config: Config, client: "AlissaClient | None" = None
|
|
415
|
+
) -> Config:
|
|
416
|
+
"""Decide WHOSE bodies of work may enroll a repo, and say so out loud.
|
|
417
|
+
|
|
418
|
+
Two answers, one of them free:
|
|
419
|
+
|
|
420
|
+
* `bow_owners` **set** — the operator named the authority explicitly. That
|
|
421
|
+
is the multi-operator case: the feed containers belong to an actor this
|
|
422
|
+
token is not. Nothing is resolved, nothing is called.
|
|
423
|
+
* `bow_owners` **unset** — the authority is **self**. The token already
|
|
424
|
+
carries an identity, so `GET /v1/ping` answers the question the operator
|
|
425
|
+
would otherwise have hand-copied out of the API.
|
|
426
|
+
|
|
427
|
+
The whoami call is **fatal on failure**, deliberately, and that is the
|
|
428
|
+
whole fail-closed argument in one place. Carrying on with an empty
|
|
429
|
+
authority is technically safe (nothing would be trusted, so nothing would
|
|
430
|
+
enroll) but it is safe *silently*: the operator would see a daemon that
|
|
431
|
+
runs and watches only the static list, which is exactly what a correctly
|
|
432
|
+
configured daemon with no feeds yet looks like. Dying names the reason
|
|
433
|
+
while someone is watching. The property being protected is not "self is
|
|
434
|
+
trusted" but "only a KNOWN actor is trusted" — an unanswered whoami leaves
|
|
435
|
+
that unknown, and no default can fill it in.
|
|
436
|
+
|
|
437
|
+
Under `static` this is a no-op: that mode never reads a Body of Work, so
|
|
438
|
+
it never needs an identity, and a daemon that only watches its static list
|
|
439
|
+
must not fail to boot because an endpoint it does not use was unreachable.
|
|
440
|
+
"""
|
|
441
|
+
if config.repos_source != REPOS_BOWS:
|
|
442
|
+
return config
|
|
443
|
+
|
|
444
|
+
if config.bow_owners:
|
|
445
|
+
log.info("bow feed authority: explicit (%s)", ", ".join(config.bow_owners))
|
|
446
|
+
return config
|
|
447
|
+
|
|
448
|
+
identity = (client or AlissaClient(base=config.alissa_endpoint)).whoami()
|
|
449
|
+
log.info("bow feed authority: self (%s)", identity.actor_id)
|
|
450
|
+
if identity.display_name:
|
|
451
|
+
# Logged as context, never compared: a display name is renameable,
|
|
452
|
+
# which is why `bow_owners` refuses to hold one.
|
|
453
|
+
log.debug("resolved from this token's identity: %r", identity.display_name)
|
|
454
|
+
# The id came from the API, so it is the authority on its own shape and is
|
|
455
|
+
# NOT put back through the config's actor-id check -- that check exists to
|
|
456
|
+
# catch an operator typing a username, not to second-guess the server.
|
|
457
|
+
return dataclasses.replace(config, bow_owners=(identity.actor_id,))
|
|
458
|
+
|
|
459
|
+
|
|
370
460
|
def log_effective_config(config: Config, login: str) -> None:
|
|
371
461
|
"""The resolved settings, defaults and all. INFO carries the decisions an
|
|
372
462
|
operator needs; -v (DEBUG) shows the full surface."""
|
|
373
463
|
log.info("developing as GitHub user %s (from the gh token)", login)
|
|
464
|
+
bows = config.repos_source == REPOS_BOWS
|
|
374
465
|
log.info(
|
|
375
|
-
"watching %s for label %r",
|
|
466
|
+
"watching %s for label %r (repos_source=%s%s)",
|
|
376
467
|
", ".join(config.repos) if config.repos
|
|
377
468
|
else "NO repos (empty allowlist — set `repos` or pass --repo)",
|
|
378
469
|
config.label,
|
|
470
|
+
config.repos_source,
|
|
471
|
+
f", refreshed every {config.bows_refresh_polls} poll(s) from "
|
|
472
|
+
f"`autodev: ` bodies of work" if bows else "",
|
|
379
473
|
)
|
|
474
|
+
if bows:
|
|
475
|
+
log.debug("bow_owners: %s", ", ".join(config.bow_owners) or "none (INERT)")
|
|
476
|
+
log.debug("alissa_endpoint: %s", config.alissa_endpoint)
|
|
380
477
|
log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
|
|
381
478
|
log.info(
|
|
382
479
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
@@ -427,11 +524,15 @@ def main(argv: "list[str] | None" = None) -> int:
|
|
|
427
524
|
try:
|
|
428
525
|
config = resolve_config(args)
|
|
429
526
|
log.info("workspace: %s", config.workspace_root)
|
|
527
|
+
# Startup's one piece of Alissa I/O, and only under `bows`.
|
|
528
|
+
config = resolve_feed_authority(config)
|
|
430
529
|
|
|
431
530
|
watcher = DevWatcher(config)
|
|
432
531
|
for warning in watcher.preflight():
|
|
433
532
|
log.warning(warning)
|
|
434
|
-
|
|
533
|
+
# `watcher.config`, not `config`: preflight ran the first feed refresh,
|
|
534
|
+
# so the allowlist the operator reads here is the DERIVED one.
|
|
535
|
+
log_effective_config(watcher.config, watcher.github.login)
|
|
435
536
|
|
|
436
537
|
if args.once:
|
|
437
538
|
watcher.poll_once()
|
|
@@ -443,6 +544,17 @@ def main(argv: "list[str] | None" = None) -> int:
|
|
|
443
544
|
except (FileNotFoundError, ValueError) as exc:
|
|
444
545
|
print(f"config error: {exc}", file=sys.stderr)
|
|
445
546
|
return 2
|
|
547
|
+
except AlissaError as exc:
|
|
548
|
+
# Only reachable from resolve_feed_authority: an unanswered whoami
|
|
549
|
+
# leaves the feed authority unknown, and starting with it unknown is
|
|
550
|
+
# the one failure this mode must not have.
|
|
551
|
+
print(
|
|
552
|
+
f"alissa error: {exc} — repos_source='bows' could not resolve the "
|
|
553
|
+
f"feed authority (GET /v1/ping). Check ALISSA_API_TOKEN and "
|
|
554
|
+
f"alissa_endpoint, or set bow_owners explicitly",
|
|
555
|
+
file=sys.stderr,
|
|
556
|
+
)
|
|
557
|
+
return 2
|
|
446
558
|
except RateLimited as exc:
|
|
447
559
|
# Only --once can get here: run_forever absorbs rate limits by
|
|
448
560
|
# backing off, but a single pass has no next pass to wait for.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Alissa REST access — the two reads the BOW-derived allowlist needs.
|
|
2
|
+
|
|
3
|
+
This is the SECOND Alissa adapter in the package, and the split is deliberate.
|
|
4
|
+
`alissa.py` shells out to the `alissa` CLI, which is the daemon's established
|
|
5
|
+
Alissa idiom for everything it does today (the tmux queue, the worker probe,
|
|
6
|
+
`alissa code workspace add`). `repos_source: "bows"` needs two things that
|
|
7
|
+
idiom cannot supply:
|
|
8
|
+
|
|
9
|
+
* the **owner** of each Body of Work. `alissa bow list --json` does carry
|
|
10
|
+
`ownerActorId`, so this half is reachable from the CLI.
|
|
11
|
+
* the **token's own actor id**, which the feed-authority gate defaults to.
|
|
12
|
+
The CLI has no whoami that prints an id: `alissa auth status` prints a
|
|
13
|
+
DISPLAY NAME, and a display name is exactly what an ownership check must
|
|
14
|
+
never compare against (names are renameable, so an allowlist written in
|
|
15
|
+
them grants authority to whoever holds the name when the check runs).
|
|
16
|
+
|
|
17
|
+
So the authority gate is unimplementable over the CLI, and a mode that reads
|
|
18
|
+
BOW ownership over one transport and identity over another would be two idioms
|
|
19
|
+
anyway. Both reads therefore go over the REST API this daemon's Alissa token
|
|
20
|
+
already authenticates — the same transport, endpoints and taxonomy orcloop's
|
|
21
|
+
`alissa_client.py` uses, so the two daemons read one contract. Stdlib
|
|
22
|
+
`urllib` only: the distribution ships no third-party runtime dependency, and
|
|
23
|
+
this must not be the change that adds one.
|
|
24
|
+
|
|
25
|
+
Endpoints, both verified against the live API:
|
|
26
|
+
|
|
27
|
+
``GET /v1/ping``
|
|
28
|
+
``{"pong", "timestamp", "userId", "actorId", "displayName"}`` — the
|
|
29
|
+
token's identity endpoint, and the only one (orcloop's spike found
|
|
30
|
+
``/v1/me``, ``/v1/whoami``, ``/v1/actors/me`` and six others all 404).
|
|
31
|
+
`actorId` is the same opaque id that appears as `ownerActorId` on a Body
|
|
32
|
+
of Work, which is what lets the authority default to "self".
|
|
33
|
+
|
|
34
|
+
``GET /v1/bodies-of-work?includeShared=true``
|
|
35
|
+
``{"bodiesOfWork": [{"_id", "title", "status", "ownerActorId", ...}]}``.
|
|
36
|
+
`includeShared=true` is load-bearing: the endpoint defaults to OWNED-only,
|
|
37
|
+
and a feed container is created by the operator (there is no create
|
|
38
|
+
endpoint) with the daemon added as a COLLABORATOR — without the flag the
|
|
39
|
+
feed's only discovery path returns nothing and `bows` mode is silently
|
|
40
|
+
inert. The flag is also what makes the authority gate necessary: it widens
|
|
41
|
+
the listing to every container ANY actor shared with this one, unilaterally
|
|
42
|
+
and with no acceptance step on this side. Membership of this list means
|
|
43
|
+
"visible", never "authoritative" (see `config.Config.trusts_feed_owner`).
|
|
44
|
+
|
|
45
|
+
Errors arrive as three narrow buckets so the caller can react without
|
|
46
|
+
string-matching a stderr. The caller here is a REFRESH, and its answer to all
|
|
47
|
+
three is the same — keep the last good set and warn — so the taxonomy exists
|
|
48
|
+
for the log line and for the boot path, which distinguishes "your token is
|
|
49
|
+
wrong" (permanent, operator-fixable) from "the API blinked".
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
import logging
|
|
56
|
+
import os
|
|
57
|
+
import urllib.error
|
|
58
|
+
import urllib.request
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
|
|
61
|
+
log = logging.getLogger(__name__)
|
|
62
|
+
|
|
63
|
+
DEFAULT_ENDPOINT = "https://api.alissa.app"
|
|
64
|
+
|
|
65
|
+
# The env var the CLI itself reads, so a daemon whose `alissa` CLI is already
|
|
66
|
+
# authenticated needs no second secret.
|
|
67
|
+
ENV_TOKEN = "ALISSA_API_TOKEN"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AlissaError(Exception):
|
|
71
|
+
"""Base of the taxonomy. `status` is 0 for a transport failure (there was
|
|
72
|
+
no HTTP response to carry one)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, status: int, detail: object, code: "str | None" = None):
|
|
75
|
+
super().__init__(f"HTTP {status}: {detail}" if status else str(detail))
|
|
76
|
+
self.status = status
|
|
77
|
+
self.detail = detail
|
|
78
|
+
self.code = code
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AlissaAuthError(AlissaError):
|
|
82
|
+
"""401/403, or no token at all. Permanent and operator-fixable — retrying
|
|
83
|
+
it every refresh only writes the same warning again."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AlissaNotFound(AlissaError):
|
|
87
|
+
"""404: the endpoint or resource is absent for this actor."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AlissaTransient(AlissaError):
|
|
91
|
+
"""408/429/5xx and every transport failure (DNS, refused, timeout). The
|
|
92
|
+
'the API blinked' bucket the never-shrink cache exists for."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Identity:
|
|
97
|
+
"""Who this token acts as (`GET /v1/ping`).
|
|
98
|
+
|
|
99
|
+
`actor_id` is the load-bearing field: it is the same opaque id a Body of
|
|
100
|
+
Work reports as `ownerActorId`, so it can be compared directly. The other
|
|
101
|
+
two are carried for LOGGING only — a display name must never reach an
|
|
102
|
+
authority check."""
|
|
103
|
+
|
|
104
|
+
actor_id: str
|
|
105
|
+
user_id: str = ""
|
|
106
|
+
display_name: str = ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class BodyOfWork:
|
|
111
|
+
"""A Body of Work as the LIST endpoint reports it — only the four fields
|
|
112
|
+
the feed reads.
|
|
113
|
+
|
|
114
|
+
`owner_id` is the container's provenance and the only field here that is
|
|
115
|
+
not operator-authored text: the listing includes containers merely SHARED
|
|
116
|
+
with this actor, so the title alone cannot say whether a feed is one the
|
|
117
|
+
operator set up. Empty when the payload omits it, which the authority gate
|
|
118
|
+
treats as untrusted rather than unknown."""
|
|
119
|
+
|
|
120
|
+
id: str
|
|
121
|
+
title: str
|
|
122
|
+
status: str = ""
|
|
123
|
+
owner_id: str = ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AlissaClient:
|
|
127
|
+
"""The two reads, with the transport hidden behind the taxonomy.
|
|
128
|
+
|
|
129
|
+
Reads `ALISSA_API_TOKEN` from the environment; `base` defaults to the
|
|
130
|
+
public API. Both are constructor arguments so a test never needs the
|
|
131
|
+
network and an operator can point a daemon at another deployment."""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
token: "str | None" = None,
|
|
136
|
+
base: "str | None" = None,
|
|
137
|
+
*,
|
|
138
|
+
timeout: int = 30,
|
|
139
|
+
):
|
|
140
|
+
self.base = (base or DEFAULT_ENDPOINT).rstrip("/")
|
|
141
|
+
self._token = token if token is not None else os.environ.get(ENV_TOKEN)
|
|
142
|
+
self._timeout = timeout
|
|
143
|
+
|
|
144
|
+
def _request(self, path: str) -> object:
|
|
145
|
+
"""One GET. Every failure leaves as a taxonomy exception — the caller
|
|
146
|
+
never sees a raw urllib error or an HTTP status."""
|
|
147
|
+
if not self._token:
|
|
148
|
+
# No token at all is an auth condition, not a transport one: the
|
|
149
|
+
# operator must set the env var. Fail the way a 401 would, so the
|
|
150
|
+
# boot path treats it as permanent rather than retrying forever.
|
|
151
|
+
raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
|
|
152
|
+
|
|
153
|
+
req = urllib.request.Request(
|
|
154
|
+
f"{self.base}{path}",
|
|
155
|
+
method="GET",
|
|
156
|
+
headers={
|
|
157
|
+
"Accept": "application/json",
|
|
158
|
+
"Authorization": f"Bearer {self._token}",
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
163
|
+
raw = resp.read().decode("utf-8")
|
|
164
|
+
except urllib.error.HTTPError as exc:
|
|
165
|
+
raise self._classify(exc) from None
|
|
166
|
+
except urllib.error.URLError as exc:
|
|
167
|
+
raise AlissaTransient(0, str(exc.reason)) from None
|
|
168
|
+
except (TimeoutError, OSError) as exc: # pragma: no cover - defence
|
|
169
|
+
# A socket timeout on the READ does not arrive as URLError.
|
|
170
|
+
raise AlissaTransient(0, str(exc)) from None
|
|
171
|
+
try:
|
|
172
|
+
return json.loads(raw) if raw.strip() else {}
|
|
173
|
+
except json.JSONDecodeError as exc:
|
|
174
|
+
# A 2xx that is not JSON is a contract violation, not a retry
|
|
175
|
+
# signal -- but it must not escape as a bare ValueError either,
|
|
176
|
+
# because the refresh catches AlissaError and nothing else.
|
|
177
|
+
raise AlissaError(200, f"response was not JSON ({exc})") from None
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _classify(exc: "urllib.error.HTTPError") -> AlissaError:
|
|
181
|
+
"""Map an HTTP error onto the taxonomy. The API sends JSON error
|
|
182
|
+
bodies (`{"error": CODE, "message": ...}`); the code rides along when
|
|
183
|
+
present, but classification keys on the STATUS — codes are advisory,
|
|
184
|
+
statuses are the contract."""
|
|
185
|
+
detail: object = exc.read().decode("utf-8", "replace")
|
|
186
|
+
code: "str | None" = None
|
|
187
|
+
try:
|
|
188
|
+
parsed = json.loads(detail) # type: ignore[arg-type]
|
|
189
|
+
except (json.JSONDecodeError, TypeError):
|
|
190
|
+
parsed = None
|
|
191
|
+
if isinstance(parsed, dict):
|
|
192
|
+
detail = parsed
|
|
193
|
+
raw_code = parsed.get("error")
|
|
194
|
+
code = raw_code if isinstance(raw_code, str) else None
|
|
195
|
+
|
|
196
|
+
status = exc.code
|
|
197
|
+
if status in (401, 403):
|
|
198
|
+
return AlissaAuthError(status, detail, code)
|
|
199
|
+
if status == 404:
|
|
200
|
+
return AlissaNotFound(status, detail, code)
|
|
201
|
+
if status in (408, 429) or 500 <= status <= 599:
|
|
202
|
+
return AlissaTransient(status, detail, code)
|
|
203
|
+
return AlissaError(status, detail, code)
|
|
204
|
+
|
|
205
|
+
def whoami(self) -> Identity:
|
|
206
|
+
"""The identity this token acts as.
|
|
207
|
+
|
|
208
|
+
On the BOOT path, and fatal there — see
|
|
209
|
+
`__main__.resolve_feed_authority` for why an unanswered whoami must
|
|
210
|
+
stop the daemon rather than soften into an empty authority."""
|
|
211
|
+
payload = self._request("/v1/ping")
|
|
212
|
+
row = payload if isinstance(payload, dict) else {}
|
|
213
|
+
actor_id = row.get("actorId")
|
|
214
|
+
if not isinstance(actor_id, str) or not actor_id.strip():
|
|
215
|
+
# A 2xx with no actorId is a contract violation, not a missing
|
|
216
|
+
# resource: name it as such rather than letting an empty authority
|
|
217
|
+
# travel onwards looking like a configuration choice.
|
|
218
|
+
raise AlissaError(
|
|
219
|
+
200,
|
|
220
|
+
f"GET /v1/ping returned no actorId (got {payload!r}) — the "
|
|
221
|
+
f"token's acting identity could not be determined",
|
|
222
|
+
)
|
|
223
|
+
return Identity(
|
|
224
|
+
actor_id=actor_id.strip(),
|
|
225
|
+
user_id=str(row.get("userId") or ""),
|
|
226
|
+
display_name=str(row.get("displayName") or ""),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def list_bodies_of_work(self) -> "list[BodyOfWork]":
|
|
230
|
+
"""Every Body of Work this actor owns OR collaborates on — ONE call.
|
|
231
|
+
|
|
232
|
+
There is no server-side title or status filter, so the caller narrows.
|
|
233
|
+
Rows without an `_id` are dropped: a container that cannot be
|
|
234
|
+
addressed cannot be reported on either.
|
|
235
|
+
|
|
236
|
+
A payload with no `bodiesOfWork` LIST is a contract violation, not an
|
|
237
|
+
empty listing, and is raised as such -- `whoami`'s missing-`actorId`
|
|
238
|
+
reasoning applied to the other read. Degrading it to `[]` would report
|
|
239
|
+
SUCCESS to the refresh, which would then derive nothing and drop every
|
|
240
|
+
feed-enrolled repo on a routine INFO line: a response-shape change or
|
|
241
|
+
an `alissa_endpoint` answering 200 with something else would silently
|
|
242
|
+
un-enroll everything, which is precisely what the never-shrink rule
|
|
243
|
+
promises cannot happen. Raising routes it to `_log_listing_failure`
|
|
244
|
+
instead, which keeps the last good set. `{"bodiesOfWork": []}` is
|
|
245
|
+
untouched -- present-and-empty is a legitimate empty listing, and the
|
|
246
|
+
documented way an operator un-enrolls the last repo."""
|
|
247
|
+
payload = self._request("/v1/bodies-of-work?includeShared=true")
|
|
248
|
+
rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
|
|
249
|
+
if not isinstance(rows, list):
|
|
250
|
+
raise AlissaError(
|
|
251
|
+
200,
|
|
252
|
+
f"GET /v1/bodies-of-work returned no bodiesOfWork list "
|
|
253
|
+
f"(got {payload!r})",
|
|
254
|
+
)
|
|
255
|
+
out: "list[BodyOfWork]" = []
|
|
256
|
+
for row in rows:
|
|
257
|
+
if not isinstance(row, dict):
|
|
258
|
+
continue
|
|
259
|
+
bow_id = row.get("_id")
|
|
260
|
+
if not isinstance(bow_id, str) or not bow_id:
|
|
261
|
+
continue
|
|
262
|
+
out.append(
|
|
263
|
+
BodyOfWork(
|
|
264
|
+
id=bow_id,
|
|
265
|
+
title=str(row.get("title") or ""),
|
|
266
|
+
status=str(row.get("status") or ""),
|
|
267
|
+
owner_id=str(row.get("ownerActorId") or ""),
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return out
|