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.
Files changed (24) hide show
  1. {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
  2. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/__main__.py +65 -4
  3. {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
  4. alissa_tools_github_orcloop-0.3.1/src/main/alissa/tools/github/orcloop/config.py +600 -0
  5. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/loop.py +728 -38
  6. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/markers.py +49 -0
  7. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/state.py +35 -1
  8. alissa_tools_github_orcloop-0.3.1/src/main/alissa/tools/github/orcloop/version +1 -0
  9. {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
  10. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/config.py +0 -333
  11. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/version +0 -1
  12. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/MANIFEST.in +0 -0
  13. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/README.md +0 -0
  14. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/requirements.txt +0 -0
  15. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/setup.cfg +0 -0
  16. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/setup.py +0 -0
  17. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  18. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  19. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  20. {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.1}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  21. {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
  22. {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
  23. {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
  24. {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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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. The CLI resolves and validates the full configuration
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. When given, REPLACES the config "
83
- "`repos` list. The allowlist is fail-closed: an empty allowlist is a "
84
- "fatal startup error, not a no-op.",
87
+ help="watch this repo; repeatable, and one flag may carry several "
88
+ "entries separated by `|` (the daemon-family convention) or `,`. When "
89
+ "given, REPLACES the config `repos` list. The allowlist is fail-closed: "
90
+ "an empty allowlist is a fatal startup error, not a no-op.",
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