alissa-tools-github-orcloop 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_orcloop-0.2.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.1}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/__main__.py +65 -4
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/alissa_client.py +165 -0
- alissa_tools_github_orcloop-0.3.1/src/main/alissa/tools/github/orcloop/config.py +600 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/loop.py +728 -38
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/markers.py +49 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/state.py +35 -1
- alissa_tools_github_orcloop-0.3.1/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/config.py +0 -333
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/README.md +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""CLI entry point: alissa-orcloop (or python -m alissa.tools.github.orcloop).
|
|
2
2
|
|
|
3
3
|
O1 wired the config surface and the argument parser; O3 wires the decision
|
|
4
|
-
loop behind it
|
|
4
|
+
loop behind it; O6 adds the release-feed selector (`--release-feed bow` and its
|
|
5
|
+
`--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs).
|
|
6
|
+
|
|
7
|
+
The CLI resolves and validates the full configuration
|
|
5
8
|
(four-layer precedence, fail-closed `repos` guard), reports the effective
|
|
6
9
|
settings, then runs the orchestrator: `--once` for a single sweep (pair it
|
|
7
10
|
with `--dry-run -v` to print every decision and its reasons without touching
|
|
@@ -18,6 +21,8 @@ from pathlib import Path
|
|
|
18
21
|
|
|
19
22
|
from .alissa_client import AlissaError
|
|
20
23
|
from .config import (
|
|
24
|
+
FEED_BOW,
|
|
25
|
+
RELEASE_FEEDS,
|
|
21
26
|
Config,
|
|
22
27
|
load_config_file,
|
|
23
28
|
resolve_config_path,
|
|
@@ -79,9 +84,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
79
84
|
dest="repos",
|
|
80
85
|
action="append",
|
|
81
86
|
metavar="OWNER/REPO",
|
|
82
|
-
help="watch this repo; repeatable
|
|
83
|
-
"
|
|
84
|
-
"
|
|
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.",
|
|
85
91
|
)
|
|
86
92
|
over.add_argument("--label", metavar="LABEL", help="issue label released work carries")
|
|
87
93
|
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
@@ -91,6 +97,41 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
91
97
|
over.add_argument("--scope-key", metavar="KEY", help="Autonomous-Scope trailer key")
|
|
92
98
|
over.add_argument("--alissa-endpoint", metavar="URL")
|
|
93
99
|
over.add_argument("--ledger-path", type=Path, metavar="PATH")
|
|
100
|
+
over.add_argument(
|
|
101
|
+
"--release-feed",
|
|
102
|
+
choices=RELEASE_FEEDS,
|
|
103
|
+
metavar="{" + ",".join(RELEASE_FEEDS) + "}",
|
|
104
|
+
help="where release candidates come from: 'scan' (the default) lists "
|
|
105
|
+
"every committed task and filters it on the release marker; 'bow' "
|
|
106
|
+
"reads the per-repo Bodies of Work named <bow-prefix><owner>/<repo>, "
|
|
107
|
+
"where attaching a task is the release trigger AND the repo declaration",
|
|
108
|
+
)
|
|
109
|
+
over.add_argument(
|
|
110
|
+
"--bow-prefix",
|
|
111
|
+
metavar="PREFIX",
|
|
112
|
+
help="title prefix that marks a Body of Work as a release feed "
|
|
113
|
+
"(default 'autodev: '); the rest of the title is the target owner/repo",
|
|
114
|
+
)
|
|
115
|
+
over.add_argument(
|
|
116
|
+
"--bow-owner",
|
|
117
|
+
dest="bow_owners",
|
|
118
|
+
action="append",
|
|
119
|
+
metavar="ACTOR_ID",
|
|
120
|
+
help="bow feed only: trust bodies of work OWNED by this Alissa actor "
|
|
121
|
+
"as release feeds; repeatable. When given, REPLACES the config "
|
|
122
|
+
"`bow_owners` list. Fail-closed like --repo: `--release-feed bow` with "
|
|
123
|
+
"an empty owner list is a fatal startup error, because being SHARED a "
|
|
124
|
+
"body of work is not a claim to drive this daemon. An actor id is the "
|
|
125
|
+
"`ownerActorId` on the container's row in GET /v1/bodies-of-work.",
|
|
126
|
+
)
|
|
127
|
+
over.add_argument(
|
|
128
|
+
"--scan-fallback-interval",
|
|
129
|
+
type=int,
|
|
130
|
+
metavar="SECONDS",
|
|
131
|
+
help="bow feed only: how often to ALSO run the full marker scan and "
|
|
132
|
+
"log every marked task the feed did not surface (default 3600; 0 "
|
|
133
|
+
"disables it). The sweep reports, it never releases.",
|
|
134
|
+
)
|
|
94
135
|
|
|
95
136
|
close = over.add_mutually_exclusive_group()
|
|
96
137
|
close.add_argument(
|
|
@@ -139,6 +180,10 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
139
180
|
"ledger_path": args.ledger_path,
|
|
140
181
|
"close_loop_enabled": args.close_loop_enabled,
|
|
141
182
|
"dry_run": args.dry_run,
|
|
183
|
+
"release_feed": args.release_feed,
|
|
184
|
+
"bow_prefix": args.bow_prefix,
|
|
185
|
+
"bow_owners": tuple(args.bow_owners) if args.bow_owners else None,
|
|
186
|
+
"scan_fallback_interval": args.scan_fallback_interval,
|
|
142
187
|
}
|
|
143
188
|
|
|
144
189
|
|
|
@@ -161,6 +206,22 @@ def log_effective_config(config: Config) -> None:
|
|
|
161
206
|
config.poll_interval, config.repo_parallelism, config.dry_run,
|
|
162
207
|
config.close_loop_enabled,
|
|
163
208
|
)
|
|
209
|
+
if config.release_feed == FEED_BOW:
|
|
210
|
+
log.info(
|
|
211
|
+
"release feed: bow — candidates are the tasks attached to the "
|
|
212
|
+
"%r bodies of work OWNED by %s; scan fallback sweep %s",
|
|
213
|
+
config.bow_prefix,
|
|
214
|
+
", ".join(config.bow_owners),
|
|
215
|
+
f"every {config.scan_fallback_interval}s"
|
|
216
|
+
if config.scan_fallback_interval
|
|
217
|
+
else "DISABLED",
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
log.info(
|
|
221
|
+
"release feed: scan — candidates are committed tasks carrying %r, "
|
|
222
|
+
"targeted by their %s trailer",
|
|
223
|
+
config.label, config.marker_key,
|
|
224
|
+
)
|
|
164
225
|
log.debug("marker_key: %s", config.marker_key)
|
|
165
226
|
log.debug("scope_key: %s", config.scope_key)
|
|
166
227
|
log.debug("release_blocked_floor_days: %s", config.release_blocked_floor_days)
|
|
@@ -53,6 +53,47 @@ Three narrow, actionable buckets so the loop can react without string-matching:
|
|
|
53
53
|
|
|
54
54
|
Anything else (a 400 validation error, say) surfaces as the base
|
|
55
55
|
``AlissaError`` -- a bug to fix, not a condition to retry blindly.
|
|
56
|
+
|
|
57
|
+
THE BODY-OF-WORK SURFACE (O6)
|
|
58
|
+
=============================
|
|
59
|
+
The `bow` release feed reads candidates out of per-repo Bodies of Work instead
|
|
60
|
+
of scanning every committed task. Three endpoints, all verified live against
|
|
61
|
+
``api.alissa.app`` while implementing O6:
|
|
62
|
+
|
|
63
|
+
* ``GET /v1/bodies-of-work?includeShared=true`` -> ``{"bodiesOfWork": [...]}``.
|
|
64
|
+
**One call, no server-side title/status filter** (``?status=``/``?q=`` are
|
|
65
|
+
silently ignored), so the ``bow_prefix`` match runs here. The endpoint does,
|
|
66
|
+
however, have a SCOPE parameter, and it is not optional for a daemon: the
|
|
67
|
+
default list is what the actor **owns**, and ``includeShared=true`` adds the
|
|
68
|
+
ones it merely collaborates on. Measured live: 14 owned, 19 with the flag.
|
|
69
|
+
Feed containers are operator-created (there is no create endpoint) and the
|
|
70
|
+
daemon is a collaborator, so every one of them lives in that difference --
|
|
71
|
+
omitting the flag makes the ``bow`` feed discover nothing, silently. A
|
|
72
|
+
collaborator BOW is otherwise perfectly visible: ``GET
|
|
73
|
+
/v1/bodies-of-work/<id>`` returns it and membership reads work; it is only
|
|
74
|
+
absent from the DEFAULT listing. This is the whole discovery cost: the number
|
|
75
|
+
of Bodies of Work an org has is a small constant, not a function of task
|
|
76
|
+
count. The flag also widens the listing to containers ANY actor shared with
|
|
77
|
+
the daemon, so each row carries its ``ownerActorId`` and the loop admits only
|
|
78
|
+
the owners the operator allowlisted (``bow_owners``) -- see
|
|
79
|
+
``list_bodies_of_work``.
|
|
80
|
+
* ``GET /v1/bodies-of-work/<id>`` -> ``{"bodyOfWork": {..., "tasks": [{"_id",
|
|
81
|
+
"status", "title"}]}}``. The membership list is EMBEDDED in the BOW detail;
|
|
82
|
+
there is no ``/bodies-of-work/<id>/tasks`` route, and ``GET /v1/tasks`` has no
|
|
83
|
+
``bodyOfWorkId`` filter (an unknown query param is silently ignored, which
|
|
84
|
+
would have read as "every task is a member"). Member rows carry no
|
|
85
|
+
``taskNumber``, so the loop resolves each committed member through
|
|
86
|
+
``get_task`` -- which accepts the opaque ``_id`` as a ref.
|
|
87
|
+
* ``DELETE /v1/bodies-of-work/<id>/tasks/<ref>`` -> ``{"removed": bool}``. The
|
|
88
|
+
detach that terminates a successful release. **Idempotent**: detaching a task
|
|
89
|
+
that is not attached returns ``removed: false`` rather than an error, so a
|
|
90
|
+
crash-resumed detach is safe. The ``<ref>`` accepts both ``TASK-<n>`` and the
|
|
91
|
+
opaque id. A task id that does not exist at all is a 404 (``TASK_NOT_FOUND``).
|
|
92
|
+
|
|
93
|
+
Both BOW endpoints require the daemon actor to be a **collaborator** on the
|
|
94
|
+
Body of Work (otherwise 403 ``FORBIDDEN`` on attach/detach) -- an operator setup
|
|
95
|
+
step, documented in the README. orcloop never attaches: attaching is the
|
|
96
|
+
operator's trigger, detaching is orcloop's terminal release act.
|
|
56
97
|
"""
|
|
57
98
|
|
|
58
99
|
from __future__ import annotations
|
|
@@ -116,6 +157,38 @@ class MarkedTask:
|
|
|
116
157
|
description: str
|
|
117
158
|
|
|
118
159
|
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class BodyOfWork:
|
|
162
|
+
"""A Body of Work as the LIST endpoint reports it. Only the fields the feed
|
|
163
|
+
needs: the id to read membership with, the title carrying the
|
|
164
|
+
`<prefix><owner>/<repo>` naming convention, the BOW's own status, and
|
|
165
|
+
`owner_id` -- the actor that OWNS the container (`ownerActorId`).
|
|
166
|
+
|
|
167
|
+
`owner_id` is the container's provenance and the only thing here that is
|
|
168
|
+
not operator-authored text: the listing includes containers this actor was
|
|
169
|
+
merely SHARED, so the title alone cannot say whether a feed is one the
|
|
170
|
+
operator set up (see `Config.bow_owners`). Empty when the payload omits it,
|
|
171
|
+
which the caller treats as untrusted rather than unknown."""
|
|
172
|
+
|
|
173
|
+
id: str
|
|
174
|
+
title: str
|
|
175
|
+
status: str = ""
|
|
176
|
+
owner_id: str = ""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class BowMember:
|
|
181
|
+
"""One task attached to a Body of Work, as the BOW DETAIL endpoint reports
|
|
182
|
+
it. Deliberately thin: the membership rows carry `_id`, `status` and
|
|
183
|
+
`title` and NOTHING else -- no taskNumber, no description. `status` is
|
|
184
|
+
enough to hold an uncommitted member without a detail round-trip; a
|
|
185
|
+
committed member is resolved through `get_task(id)`."""
|
|
186
|
+
|
|
187
|
+
id: str
|
|
188
|
+
title: str
|
|
189
|
+
status: str
|
|
190
|
+
|
|
191
|
+
|
|
119
192
|
@dataclass(frozen=True)
|
|
120
193
|
class Criterion:
|
|
121
194
|
id: str
|
|
@@ -296,6 +369,98 @@ class AlissaClient:
|
|
|
296
369
|
)
|
|
297
370
|
return out
|
|
298
371
|
|
|
372
|
+
# --- the Body-of-Work feed (O6) ----------------------------------------
|
|
373
|
+
|
|
374
|
+
def list_bodies_of_work(self) -> list[BodyOfWork]:
|
|
375
|
+
"""Every Body of Work this actor owns OR collaborates on -- ONE call.
|
|
376
|
+
|
|
377
|
+
`includeShared=true` is load-bearing, not decoration: the endpoint
|
|
378
|
+
defaults to OWNED-only, and a feed container is created by the operator
|
|
379
|
+
in Studio (there is no create endpoint) with the daemon added as a
|
|
380
|
+
COLLABORATOR. Without the flag the feed's only discovery path returns
|
|
381
|
+
nothing and `release_feed: bow` is silently inert. There is still no
|
|
382
|
+
server-side title/status filter (see the module docstring), so the
|
|
383
|
+
caller narrows by prefix. Rows without an `_id` are dropped: a BOW that
|
|
384
|
+
cannot be addressed cannot be read for membership.
|
|
385
|
+
|
|
386
|
+
THE TRUST BOUNDARY THE FLAG MOVES. `includeShared=true` is necessary
|
|
387
|
+
(above) but it widens this listing to every container ANY actor in the
|
|
388
|
+
tenant has shared with the daemon -- unilaterally, with no acceptance
|
|
389
|
+
step on this side. So membership of this list is NOT a claim to be a
|
|
390
|
+
release feed: it means "visible", not "authoritative". The title-derived
|
|
391
|
+
gates downstream (`parse_bow_repo`, `Config.watches`) bound which repo a
|
|
392
|
+
release lands in, not whose task reaches it, and an allowlisted repo is
|
|
393
|
+
precisely where the daemon's writes are most trusted. Provenance is
|
|
394
|
+
therefore carried out of here as `BodyOfWork.owner_id` and checked
|
|
395
|
+
against the operator's `bow_owners` allowlist before a container is
|
|
396
|
+
treated as a feed. Collaboration makes a container READABLE (and is
|
|
397
|
+
still required, for detach); `bow_owners` makes it AUTHORITATIVE.
|
|
398
|
+
"""
|
|
399
|
+
payload = self._request("GET", "/v1/bodies-of-work?includeShared=true")
|
|
400
|
+
rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
|
|
401
|
+
if not isinstance(rows, list):
|
|
402
|
+
return []
|
|
403
|
+
out: list[BodyOfWork] = []
|
|
404
|
+
for row in rows:
|
|
405
|
+
if not isinstance(row, dict):
|
|
406
|
+
continue
|
|
407
|
+
bow_id = row.get("_id")
|
|
408
|
+
if not isinstance(bow_id, str) or not bow_id:
|
|
409
|
+
continue
|
|
410
|
+
out.append(
|
|
411
|
+
BodyOfWork(
|
|
412
|
+
id=bow_id,
|
|
413
|
+
title=row.get("title") or "",
|
|
414
|
+
status=row.get("status") or "",
|
|
415
|
+
owner_id=row.get("ownerActorId") or "",
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
def body_of_work_tasks(self, bow_id: str) -> list[BowMember]:
|
|
421
|
+
"""The tasks attached to one Body of Work, in the BOW's own order.
|
|
422
|
+
|
|
423
|
+
Membership is embedded in the BOW detail payload; there is no separate
|
|
424
|
+
listing route. Rows without an `_id` are dropped for the same reason as
|
|
425
|
+
above -- an unaddressable member cannot be resolved or detached.
|
|
426
|
+
"""
|
|
427
|
+
payload = self._request(
|
|
428
|
+
"GET", f"/v1/bodies-of-work/{urllib.parse.quote(str(bow_id), safe='')}"
|
|
429
|
+
)
|
|
430
|
+
bow = payload.get("bodyOfWork") if isinstance(payload, dict) else None
|
|
431
|
+
if not isinstance(bow, dict):
|
|
432
|
+
raise AlissaError(200, f"no bodyOfWork object in response for {bow_id!r}")
|
|
433
|
+
out: list[BowMember] = []
|
|
434
|
+
for row in bow.get("tasks") or []:
|
|
435
|
+
if not isinstance(row, dict):
|
|
436
|
+
continue
|
|
437
|
+
task_id = row.get("_id")
|
|
438
|
+
if not isinstance(task_id, str) or not task_id:
|
|
439
|
+
continue
|
|
440
|
+
out.append(
|
|
441
|
+
BowMember(
|
|
442
|
+
id=task_id,
|
|
443
|
+
title=row.get("title") or "",
|
|
444
|
+
status=row.get("status") or "",
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
return out
|
|
448
|
+
|
|
449
|
+
def detach_task(self, bow_id: str, ref: str) -> bool:
|
|
450
|
+
"""Detach a task from a Body of Work; True when this call removed it.
|
|
451
|
+
|
|
452
|
+
Idempotent by contract: detaching an already-detached task answers
|
|
453
|
+
`removed: false` with a 2xx, so the terminal step of a release can be
|
|
454
|
+
retried after a crash without a "was it already done?" probe. `ref`
|
|
455
|
+
accepts `TASK-<n>` or the opaque task id.
|
|
456
|
+
"""
|
|
457
|
+
payload = self._request(
|
|
458
|
+
"DELETE",
|
|
459
|
+
f"/v1/bodies-of-work/{urllib.parse.quote(str(bow_id), safe='')}"
|
|
460
|
+
f"/tasks/{urllib.parse.quote(str(ref), safe='')}",
|
|
461
|
+
)
|
|
462
|
+
return bool(payload.get("removed")) if isinstance(payload, dict) else False
|
|
463
|
+
|
|
299
464
|
def get_task(self, ref: str) -> Task:
|
|
300
465
|
"""Full detail for one task. `ref` is any reference form the API accepts
|
|
301
466
|
(`TASK-<n>`, public id, url). Raises `AlissaNotFound` when the bound
|