alissa-tools-github-orcloop 0.3.6__tar.gz → 0.3.7__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.6/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.7}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/loop.py +116 -15
  3. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/markers.py +59 -0
  4. alissa_tools_github_orcloop-0.3.7/src/main/alissa/tools/github/orcloop/version +1 -0
  5. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_orcloop-0.3.6/src/main/alissa/tools/github/orcloop/version +0 -1
  7. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/MANIFEST.in +0 -0
  8. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/README.md +0 -0
  9. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/requirements.txt +0 -0
  10. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/setup.cfg +0 -0
  11. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/setup.py +0 -0
  12. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  13. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/config.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/state.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/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.6
3
+ Version: 0.3.7
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -262,7 +262,13 @@ from .alissa_client import (
262
262
  )
263
263
  from .config import FEED_BOW, Config
264
264
  from .ghclient import GitHub, Issue, PullRequest, RateLimited, parse_task_ref
265
- from .markers import is_bow_feed, parse_bow_repo, parse_marker, parse_scope
265
+ from .markers import (
266
+ find_mid_line_trailer,
267
+ is_bow_feed,
268
+ parse_bow_repo,
269
+ parse_marker,
270
+ parse_scope,
271
+ )
266
272
  from .proc import CommandError
267
273
  from .state import State
268
274
 
@@ -497,6 +503,18 @@ DEGRADED_DISCOVERY_REASON = (
497
503
  "— held rather than released to a possibly-wrong repo; retries next poll"
498
504
  )
499
505
 
506
+ # The one wording every missed-trailer warning uses (O13, issue #32). Appended
507
+ # to a reason or a warning that was ALREADY going to be emitted, never a line of
508
+ # its own: a missed trailer is not a new condition, it is the likeliest
509
+ # EXPLANATION of a degradation the operator is already reading about. It states
510
+ # the anchoring rule because that rule is the fix -- the operator does not have
511
+ # to read the parser to learn why their text was ignored.
512
+ MALFORMED_TRAILER_NOTE = (
513
+ "NOTE: `{key}:` found mid-line — malformed trailer? {fragment!r} (a "
514
+ "trailer is only read at the START of its own line, so that text was "
515
+ "ignored)"
516
+ )
517
+
500
518
  SWEEP_INVISIBLE_REASON = (
501
519
  "carries the release marker but is attached to no {prefix!r} body of work "
502
520
  "— the bow feed will never release it (attachment is the only trigger); "
@@ -640,6 +658,24 @@ class Orchestrator:
640
658
  log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
641
659
  return (slug, decision)
642
660
 
661
+ def _malformed_trailer(self, text: str, key: str) -> str:
662
+ """The `; NOTE: ...` suffix naming trailer-like text the anchored parser
663
+ ignored -- empty string when there is nothing to name (the usual case).
664
+
665
+ Ask this ONLY where the anchored parse has already answered "absent",
666
+ and only for the suffix: the detector behind it is lenient by design
667
+ (case-insensitive, fires on prose) and its answer may never reach a
668
+ decision, a dedupe or a lane. Every caller computes it once per subject
669
+ per pass and appends it to a line it was already emitting, which is what
670
+ keeps the no-new-spam-channel rule true.
671
+
672
+ The `key` is passed in by the caller from `Config`, so a deployment that
673
+ renamed its trailers gets warnings about ITS names, not the defaults."""
674
+ fragment = find_mid_line_trailer(text, key)
675
+ if fragment is None:
676
+ return ""
677
+ return "; " + MALFORMED_TRAILER_NOTE.format(key=key, fragment=fragment)
678
+
643
679
  # -- escalation --------------------------------------------------------
644
680
 
645
681
  def _escalate(
@@ -710,6 +746,24 @@ class Orchestrator:
710
746
  ):
711
747
  issue = self.github.issue(owner, repo, number)
712
748
  scope = parse_scope(issue.body, self.config.scope_key)
749
+ if not scope:
750
+ # This issue is about to occupy its repo's WHOLE lane, which is
751
+ # the right reading of a scopeless body and the wrong one of a
752
+ # malformed trailer -- and the two are indistinguishable in
753
+ # every later line, because from here on the lane only carries
754
+ # `[]`. Said once per issue per pass, here, where the difference
755
+ # is still visible. (A released body keeps the operator's prose
756
+ # verbatim, mid-line fragment and all: `_strip_control_lines`
757
+ # drops trailers by the same anchored rule, so what was missed
758
+ # in the task is missed in the issue too.)
759
+ note = self._malformed_trailer(issue.body, self.config.scope_key)
760
+ if note:
761
+ log.warning(
762
+ "%s declares no `%s:` trailer, so it occupies the WHOLE "
763
+ "%s lane%s",
764
+ issue.issue_slug, self.config.scope_key,
765
+ issue.full_name, note,
766
+ )
713
767
  occupancy.setdefault(issue.full_name, []).append(scope)
714
768
  task_number = parse_task_ref(issue.body)
715
769
  if task_number is None:
@@ -1457,11 +1511,21 @@ class Orchestrator:
1457
1511
  if target is None:
1458
1512
  marker = parse_marker(task.description, self.config.marker_key)
1459
1513
  if marker is None or not self.config.watches(marker):
1460
- reason = (
1461
- f"no valid `{self.config.marker_key}: <owner>/<repo>` trailer"
1462
- if marker is None
1463
- else f"target {marker} is not in the watched repos allowlist"
1464
- )
1514
+ if marker is None:
1515
+ # The commonest way to reach `unreleasable` by accident: the
1516
+ # trailer IS there, just not at the start of a line. The
1517
+ # page and the log line both carry the reason, so extending
1518
+ # it here is what puts the suspect text in front of the
1519
+ # operator on the one channel this condition has.
1520
+ reason = (
1521
+ f"no valid `{self.config.marker_key}: <owner>/<repo>` "
1522
+ f"trailer"
1523
+ + self._malformed_trailer(
1524
+ task.description, self.config.marker_key
1525
+ )
1526
+ )
1527
+ else:
1528
+ reason = f"target {marker} is not in the watched repos allowlist"
1465
1529
  self._escalate(
1466
1530
  task.task_number,
1467
1531
  ESCALATION_UNRELEASABLE,
@@ -1491,13 +1555,24 @@ class Orchestrator:
1491
1555
  return self._release_blocked(task)
1492
1556
 
1493
1557
  scope = parse_scope(task.description, self.config.scope_key)
1558
+ # A missed scope trailer is never neutral: scopeless means "occupies the
1559
+ # whole lane", so it both HOLDS this candidate behind any occupant and,
1560
+ # once released, holds everything else behind it. Both outcomes are
1561
+ # decided right below, and both are worth explaining -- so the note is
1562
+ # computed once here, on the task body, and carried into whichever line
1563
+ # this candidate produces.
1564
+ note = "" if scope else self._malformed_trailer(
1565
+ task.description, self.config.scope_key
1566
+ )
1494
1567
  lane_reason = self._lane_blocked(target, scope, occupancy)
1495
1568
  if lane_reason is not None:
1496
1569
  return self._logged(task.ref, Decision(
1497
- Action.HELD, f"lane {target} occupied: {lane_reason}", task.task_number,
1570
+ Action.HELD,
1571
+ f"lane {target} occupied: {lane_reason}{note}",
1572
+ task.task_number,
1498
1573
  ))
1499
1574
 
1500
- return self._release(task, target, scope, occupancy, bow=bow)
1575
+ return self._release(task, target, scope, occupancy, bow=bow, note=note)
1501
1576
 
1502
1577
  def _release_blocked(self, task: Task) -> tuple[str, Decision]:
1503
1578
  """The dependency gate holds. Below the floor this is a quiet per-poll
@@ -1720,6 +1795,23 @@ class Orchestrator:
1720
1795
  )
1721
1796
 
1722
1797
  issue_scope = parse_scope(issue.body, self.config.scope_key)
1798
+ # Same read and same degradation as `_scan_released`, on a body a human
1799
+ # wrote by hand -- the likeliest place of all for a trailer to sit in a
1800
+ # sentence. Computed once and appended to each of the four exits that
1801
+ # REPORT A LANE (hold, dry-run, adopt, already-labeled skip), so the
1802
+ # pass never carries the same note twice.
1803
+ #
1804
+ # The fifth exit -- the `except` below, where the adoption itself failed
1805
+ # -- deliberately does not carry it: its story is the exception, and it
1806
+ # is not the recurring line it looks like. That branch wraps
1807
+ # `record_release` AND `add_label`, so its common shape (row written,
1808
+ # label call failed) leaves a `labeled=0` row, and the next pass routes
1809
+ # to `_dedupe_result`'s crash-recovery branch rather than back here.
1810
+ # What recurs in the malformed-trailer case is the lane hold above,
1811
+ # which does carry it.
1812
+ issue_note = "" if issue_scope else self._malformed_trailer(
1813
+ issue.body, self.config.scope_key
1814
+ )
1723
1815
  labeled = self.config.label in issue.labels
1724
1816
  if not labeled:
1725
1817
  lane_reason = self._lane_blocked(target, issue_scope, occupancy)
@@ -1742,7 +1834,7 @@ class Orchestrator:
1742
1834
  f"cannot adopt {issue.issue_slug} yet: lane {target} "
1743
1835
  f"occupied: {lane_reason} — the issue declares "
1744
1836
  f"{issue_scope or '(scopeless — needs an empty lane)'}, "
1745
- f"whatever the task declares; retries next poll",
1837
+ f"whatever the task declares; retries next poll{issue_note}",
1746
1838
  task.task_number,
1747
1839
  ))
1748
1840
 
@@ -1769,7 +1861,7 @@ class Orchestrator:
1769
1861
  Action.SKIPPED if labeled else Action.RELEASED,
1770
1862
  f"[dry-run] {verb} pre-existing {issue.issue_slug} (carries "
1771
1863
  f"this task's `Alissa-Task:` trailer{unlabeled_note})"
1772
- f"{in_flight}{self._detach(task.ref, bow)}",
1864
+ f"{in_flight}{self._detach(task.ref, bow)}{issue_note}",
1773
1865
  task.task_number,
1774
1866
  ))
1775
1867
 
@@ -1796,7 +1888,7 @@ class Orchestrator:
1796
1888
  f"already released by hand as {issue.issue_slug} (trailer "
1797
1889
  f"and {self.config.label!r} label present); recorded in the "
1798
1890
  f"ledger, no second issue{in_flight}"
1799
- f"{self._detach(task.ref, bow)}",
1891
+ f"{self._detach(task.ref, bow)}{issue_note}",
1800
1892
  task.task_number,
1801
1893
  ))
1802
1894
  self.github.add_label(issue.owner, issue.repo, issue.number, self.config.label)
@@ -1819,7 +1911,7 @@ class Orchestrator:
1819
1911
  f"adopted pre-existing {issue.issue_slug} (carries this task's "
1820
1912
  f"trailer, was unlabeled): ledger row recorded and "
1821
1913
  f"{self.config.label!r} applied{in_flight}"
1822
- f"{self._detach(task.ref, bow)}",
1914
+ f"{self._detach(task.ref, bow)}{issue_note}",
1823
1915
  task.task_number,
1824
1916
  ))
1825
1917
 
@@ -1999,6 +2091,7 @@ class Orchestrator:
1999
2091
  occupancy: dict[str, list[list[str]]],
2000
2092
  *,
2001
2093
  bow: BodyOfWork | None = None,
2094
+ note: str = "",
2002
2095
  ) -> tuple[str, Decision]:
2003
2096
  """Create the issue (unlabeled), record the ledger row, stamp the label,
2004
2097
  move the origin task `committed → in_progress`, then -- under the `bow`
@@ -2011,7 +2104,15 @@ class Orchestrator:
2011
2104
 
2012
2105
  Nothing is created until the TRAILER dedupe answers: the pass-level
2013
2106
  scan is label-scoped and cannot see an unlabeled issue that already
2014
- carries this task's trailer (see `_trailer_dedupe`)."""
2107
+ carries this task's trailer (see `_trailer_dedupe`).
2108
+
2109
+ `note` is the caller's missed-trailer warning (empty unless this is a
2110
+ SCOPELESS release with trailer-like text in the body). It rides on the
2111
+ release line rather than being logged separately, because that line is
2112
+ the record of a task taking the whole lane -- possibly on the strength
2113
+ of a trailer nobody parsed. It is passed in rather than recomputed so
2114
+ one candidate can never produce two of them; the dedupe path returns
2115
+ above with its own note, computed on the ISSUE body it acts on."""
2015
2116
  owner, _, repo = target.partition("/")
2016
2117
  preexisting = self._trailer_dedupe(task, target, occupancy, bow=bow)
2017
2118
  if preexisting is not None:
@@ -2026,7 +2127,7 @@ class Orchestrator:
2026
2127
  Action.RELEASED,
2027
2128
  f"[dry-run] would release to {target} "
2028
2129
  f"(label {self.config.label!r}){in_flight}"
2029
- f"{self._detach(task.ref, bow)}",
2130
+ f"{self._detach(task.ref, bow)}{note}",
2030
2131
  task.task_number,
2031
2132
  ))
2032
2133
 
@@ -2053,7 +2154,7 @@ class Orchestrator:
2053
2154
  return self._logged(task.ref, Decision(
2054
2155
  Action.RELEASED,
2055
2156
  f"released to {issue.issue_slug}{in_flight}"
2056
- f"{self._detach(task.ref, bow)}",
2157
+ f"{self._detach(task.ref, bow)}{note}",
2057
2158
  task.task_number,
2058
2159
  ))
2059
2160
 
@@ -35,6 +35,17 @@ IS the repo declaration. `Autonomous-Scope` still comes from the task body. The
35
35
  owner/repo shape is validated with the same strictness as the trailer, so a
36
36
  mistyped BOW title reads as "no repo" (→ `unreleasable`) rather than silently
37
37
  targeting something unexpected.
38
+
39
+ O13 adds `find_mid_line_trailer`, which is deliberately NOT one of the parsers
40
+ above: it is a LENIENT detector whose only output is a warning. The anchoring
41
+ rule is correct and stays, but its failure mode is silent — a trailer written at
42
+ the end of a paragraph reads as absent, and "absent" is a meaningful answer
43
+ everywhere the loop asks (a scopeless task occupies a whole lane; a task with no
44
+ `Autonomous-Dev` is unreleasable). The detector exists so those degradations can
45
+ NAME the text they ignored. Its answer must never feed a decision: it fires on
46
+ prose (`see Autonomous-Scope: x for the format`) by design, because judging
47
+ intent is the operator's job and a false positive in a log line costs nothing
48
+ while a false positive in a release decision cannot be taken back.
38
49
  """
39
50
 
40
51
  from __future__ import annotations
@@ -52,6 +63,23 @@ _SCOPE_TEMPLATE = r"^{key}:[ \t]*(.*?)\s*$"
52
63
  # Scope items are separated by commas and/or any run of whitespace.
53
64
  _SCOPE_SPLIT = re.compile(r"[,\s]+")
54
65
 
66
+ # The lenient detector (O13). `(?!^)` is the whole point: under MULTILINE, `^`
67
+ # matches at the start of every line, so refusing it leaves EXACTLY the
68
+ # positions the anchored parsers cannot see -- mid-sentence, and after an indent
69
+ # (` Autonomous-Scope: src/**` is not a trailer either, and is the same
70
+ # mistake). The key is matched case-insensitively via a scoped inline flag, so
71
+ # an operator-typed `autonomous-scope:` is caught without loosening anything
72
+ # else. `[ \t]*\S` requires CONTENT after the colon: a bare `... Autonomous-Dev:`
73
+ # ending a sentence declares nothing, so it is not evidence of a lost
74
+ # declaration and reporting it would be noise.
75
+ _MID_LINE_TEMPLATE = r"(?!^)(?i:{key}):[ \t]*\S"
76
+
77
+ # How much of the offending line the warning quotes. The fragment goes into log
78
+ # lines and escalation comments that already carry a reason, so it is a pointer
79
+ # ("here is the text I ignored"), not the body -- enough to find with a search,
80
+ # short enough to keep one decision on one line.
81
+ MID_LINE_FRAGMENT_LIMIT = 80
82
+
55
83
  # The whole BOW title after its prefix must be exactly one owner/repo — the same
56
84
  # shape the `Autonomous-Dev` trailer accepts, anchored to the whole remainder so
57
85
  # a title like `autodev: acme/widgets (paused)` does NOT parse as a feed.
@@ -90,6 +118,37 @@ def parse_scope(text: str, scope_key: str = "Autonomous-Scope") -> list[str]:
90
118
  return [glob for glob in _SCOPE_SPLIT.split(match.group(1).strip()) if glob]
91
119
 
92
120
 
121
+ @lru_cache(maxsize=None)
122
+ def _mid_line_re(key: str) -> re.Pattern[str]:
123
+ return re.compile(_MID_LINE_TEMPLATE.format(key=re.escape(key)), re.MULTILINE)
124
+
125
+
126
+ def find_mid_line_trailer(
127
+ text: str, key: str, *, limit: int = MID_LINE_FRAGMENT_LIMIT
128
+ ) -> str | None:
129
+ """A quotable fragment of the first `<key>:` written MID-LINE, or None.
130
+
131
+ Warning input only -- see the module docstring. Callers must ask this only
132
+ where the anchored parser already answered "absent", and must never let the
133
+ answer change what they do; `None` means "nothing here looks like a missed
134
+ trailer", not "no trailer".
135
+
136
+ The fragment starts at the key (what the operator has to go and fix), stops
137
+ at the end of that line (a decision line stays one line), and is truncated
138
+ to `limit` characters so a wall of prose cannot swamp the reason it is
139
+ appended to.
140
+ """
141
+ match = _mid_line_re(key).search(text)
142
+ if match is None:
143
+ return None
144
+ line_end = text.find("\n", match.start())
145
+ end = len(text) if line_end < 0 else line_end
146
+ fragment = text[match.start():end].strip()
147
+ if len(fragment) > limit:
148
+ fragment = fragment[:limit].rstrip() + "..."
149
+ return fragment
150
+
151
+
93
152
  def is_bow_feed(title: str, prefix: str) -> bool:
94
153
  """Whether a Body of Work's title claims to be a release feed.
95
154
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera