alissa-tools-github-orcloop 0.3.7__tar.gz → 0.3.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.
Files changed (23) hide show
  1. {alissa_tools_github_orcloop-0.3.7/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.9}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/alissa_client.py +123 -0
  3. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/ghclient.py +18 -1
  4. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/loop.py +1061 -24
  5. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/state.py +10 -6
  6. alissa_tools_github_orcloop-0.3.9/src/main/alissa/tools/github/orcloop/version +1 -0
  7. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_orcloop-0.3.7/src/main/alissa/tools/github/orcloop/version +0 -1
  9. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/MANIFEST.in +0 -0
  10. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/README.md +0 -0
  11. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/requirements.txt +0 -0
  12. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/setup.cfg +0 -0
  13. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/setup.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/config.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.9}/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.3.7
3
+ Version: 0.3.9
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -127,6 +127,37 @@ would then hold a task whose only dependency is fully validated -- forever,
127
127
  eventually paging ``release-blocked``. Deduplicating matches what the resolved
128
128
  view actually does.
129
129
 
130
+ TASK CONTEXT ATTACHMENTS (O14)
131
+ ==============================
132
+ An operator's decisions about a task do not all fit in the description that was
133
+ written before the work started. Alissa's answer is the task's **context
134
+ attachments** -- ``GET /v1/tasks/<ref>/context`` -> ``{"context": [...]}`` --
135
+ and O14 makes them travel: orcloop renders them into the issue it files, so a
136
+ decision recorded after authoring reaches the implementing worker (repo issue
137
+ #34). Verified live against ``api.alissa.app``:
138
+
139
+ * Each row carries ``_id``, ``title``, ``createdAt`` (ms), ``actorId``/
140
+ ``authorName``, and then AT MOST ONE payload of ``markdownContent`` /
141
+ ``link`` / a vault reference, plus an optional one-line ``note``. The
142
+ OpenAPI schema pins only ``_id`` and ``title`` (everything else is
143
+ ``additionalProperties``), so `get_task_context` treats every payload field
144
+ as optional and an item with none of them still renders as a titled,
145
+ empty subsection rather than being dropped -- a decision the operator
146
+ attached and orcloop silently discarded is the one outcome #34 exists to
147
+ prevent.
148
+ * The task DETAIL payload carries ``contextCount`` (an int), which is what
149
+ lets the release path skip this read entirely for the common zero-context
150
+ task -- see `Task.context_count`.
151
+
152
+ Note for a future reader: the detail payload ALSO embeds the attachments
153
+ themselves under an undocumented ``contextAttachments`` key, so the second
154
+ call could in principle be avoided. orcloop deliberately does not read it. The
155
+ dedicated route is the documented, access-gated one (the OpenAPI ``Task``
156
+ schema pins neither the key nor its item shape), and the release path's
157
+ conservatism depends on being able to tell "there are N attachments" from "I
158
+ read them": a silently-absent embedded array would read as "no context" on
159
+ exactly the payload that declares some.
160
+
130
161
  WHO AM I (O8)
131
162
  =============
132
163
  ``GET /v1/ping`` -- ``{"pong", "timestamp", "userId", "actorId",
@@ -266,6 +297,29 @@ class Dependency:
266
297
  status: str
267
298
 
268
299
 
300
+ @dataclass(frozen=True)
301
+ class ContextItem:
302
+ """One knowledge/reference attachment on a task (O14).
303
+
304
+ The operator's decision channel: whatever was decided AFTER the task was
305
+ authored lands here rather than being rewritten into the description. Every
306
+ field but `id` is optional in the payload, so every one but `id` defaults
307
+ to empty -- an attachment is identified by its title and rendered from
308
+ whichever payload it carries.
309
+
310
+ `markdown` is the write-up, `link` a bare URL, `note` the one-line "why
311
+ this is relevant"; an attachment may carry any combination, including none
312
+ (a vault reference the REST payload does not inline). `created_at` is ms
313
+ since epoch, 0 when the payload omits it -- the renderer orders on it."""
314
+
315
+ id: str
316
+ title: str = ""
317
+ markdown: str = ""
318
+ link: str = ""
319
+ note: str = ""
320
+ created_at: int = 0
321
+
322
+
269
323
  @dataclass(frozen=True)
270
324
  class Task:
271
325
  """A task as the DETAIL endpoint reports it -- full description and DoD,
@@ -293,6 +347,13 @@ class Task:
293
347
  # len(dependencies) when an upstream is invisible to this actor -- the gate
294
348
  # treats that mismatch as "not provably clear".
295
349
  depends_on_count: int = 0
350
+ # How many context attachments the DETAIL payload declares (`contextCount`,
351
+ # O14). Purely a "is the second read worth making" hint for the release
352
+ # path: 0/absent skips `get_task_context` entirely, which is the common
353
+ # case. It is NOT a length contract -- attachments can be added or deleted
354
+ # between the two reads -- so nothing keys on it agreeing with what the
355
+ # context endpoint returns. Only "declares some" vs "declares none" matters.
356
+ context_count: int = 0
296
357
 
297
358
 
298
359
  def _query(params: dict) -> str:
@@ -625,6 +686,17 @@ class AlissaClient:
625
686
  created_raw = task.get("createdAt")
626
687
  created_at = int(created_raw) if isinstance(created_raw, (int, float)) else 0
627
688
 
689
+ # A bool IS an int in Python; `contextCount: true` would read as 1 and
690
+ # buy a context read on a payload that declared nothing countable, so
691
+ # it is excluded explicitly. Negatives clamp to 0 -- "fewer than none"
692
+ # is not a reason to make the read.
693
+ count_raw = task.get("contextCount")
694
+ context_count = (
695
+ max(0, int(count_raw))
696
+ if isinstance(count_raw, (int, float)) and not isinstance(count_raw, bool)
697
+ else 0
698
+ )
699
+
628
700
  number = task.get("taskNumber")
629
701
  return Task(
630
702
  ref=f"TASK-{number}" if isinstance(number, int) else str(ref),
@@ -638,7 +710,58 @@ class AlissaClient:
638
710
  created_at=created_at,
639
711
  dependencies=tuple(dependencies),
640
712
  depends_on_count=depends_on_count,
713
+ context_count=context_count,
714
+ )
715
+
716
+ def get_task_context(self, ref: str) -> list[ContextItem]:
717
+ """The task's context attachments, in the order the endpoint returns
718
+ them (`GET /v1/tasks/<ref>/context`).
719
+
720
+ Ordering is the CALLER's to impose (the renderer sorts on
721
+ `created_at`); this stays a faithful read of the payload. Rows without
722
+ an `_id` are dropped -- an attachment that cannot be addressed cannot be
723
+ cited or edited -- but a row with an id and nothing else is KEPT: the
724
+ payload pins only `_id` and `title`, so treating a shape this adapter
725
+ does not recognise as "no context" would drop the very decisions the
726
+ feature exists to carry.
727
+
728
+ A 2xx whose `context` key is not an array is a contract violation, not
729
+ an empty list, and surfaces as the base `AlissaError` -- the same way
730
+ `get_task` refuses to fabricate a Task. That distinction is load-
731
+ bearing on the release path: it holds the release instead of filing an
732
+ issue that silently lost the operator's decisions.
733
+ """
734
+ payload = self._request(
735
+ "GET", f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/context"
641
736
  )
737
+ rows = payload.get("context") if isinstance(payload, dict) else None
738
+ if not isinstance(rows, list):
739
+ raise AlissaError(200, f"no context array in response for {ref!r}")
740
+
741
+ out: list[ContextItem] = []
742
+ for row in rows:
743
+ if not isinstance(row, dict):
744
+ continue
745
+ item_id = row.get("_id")
746
+ if not isinstance(item_id, str) or not item_id:
747
+ continue
748
+ created_raw = row.get("createdAt")
749
+ out.append(
750
+ ContextItem(
751
+ id=item_id,
752
+ title=str(row.get("title") or ""),
753
+ markdown=str(row.get("markdownContent") or ""),
754
+ link=str(row.get("link") or ""),
755
+ note=str(row.get("note") or ""),
756
+ created_at=(
757
+ int(created_raw)
758
+ if isinstance(created_raw, (int, float))
759
+ and not isinstance(created_raw, bool)
760
+ else 0
761
+ ),
762
+ )
763
+ )
764
+ return out
642
765
 
643
766
  def add_evidence(self, ref: str, title: str, markdown: str, *, item_type: str = "evidence") -> None:
644
767
  """Attach an evidence/deliverable item (outputs surface, not context).
@@ -127,11 +127,20 @@ class PullRequest:
127
127
  body: str
128
128
  merged: bool
129
129
  url: str
130
+ # The merge commit's SHA (`merge_commit_sha`), for anchoring the close
131
+ # evidence to a point in history. Defaulted rather than required: it is
132
+ # meaningful only on a merged PR, and a payload that omits it must degrade
133
+ # to "unknown" in the evidence rather than fail the close walk.
134
+ merge_sha: str = ""
130
135
 
131
136
  @property
132
137
  def full_name(self) -> str:
133
138
  return f"{self.owner}/{self.repo}"
134
139
 
140
+ @property
141
+ def pr_slug(self) -> str:
142
+ return f"{self.owner}/{self.repo}#{self.number}"
143
+
135
144
 
136
145
  class RateLimited(RuntimeError):
137
146
  """A `gh` rate-limit signal -- a retry-later condition, distinct from a
@@ -377,7 +386,14 @@ class GitHub:
377
386
  """One PR's close-loop-relevant fields, or None if it cannot be read.
378
387
  A malformed/empty payload degrades to None rather than a fabricated PR
379
388
  -- the close-loop treats "unreadable" as "not the closer, keep
380
- looking", never as a merge."""
389
+ looking", never as a merge.
390
+
391
+ `body` and `merge_sha` come from this ONE payload, which is why the
392
+ close edge's delivery-record evidence (O15) costs no extra round-trip:
393
+ the PR the close walk already resolved carries everything the evidence
394
+ needs. A field the payload omits degrades to the empty string here and
395
+ is reported as missing at the render site -- never fabricated, never
396
+ fetched a second time."""
381
397
  data = self._api(f"repos/{owner}/{repo}/pulls/{number}")
382
398
  if not isinstance(data, dict) or not data:
383
399
  return None
@@ -390,6 +406,7 @@ class GitHub:
390
406
  body=data.get("body") or "",
391
407
  merged=bool(data.get("merged") or data.get("merged_at")),
392
408
  url=data.get("html_url") or "",
409
+ merge_sha=str(data.get("merge_commit_sha") or ""),
393
410
  )
394
411
 
395
412
  def _referenced_pr_numbers(self, owner: str, repo: str, number: int) -> list[int]: