alissa-tools-github-orcloop 0.3.5__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.
- {alissa_tools_github_orcloop-0.3.5/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.7}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/loop.py +345 -41
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/markers.py +59 -0
- alissa_tools_github_orcloop-0.3.7/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.3.5/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/alissa_client.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/config.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/state.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.3.5 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -60,6 +60,17 @@ issues the develop daemon can pick up:
|
|
|
60
60
|
crash BETWEEN create and the ledger write: it leaves an unlabeled (and so
|
|
61
61
|
un-triggered, harmless) orphan issue; this is the accepted trade the O2 state
|
|
62
62
|
design already documents.
|
|
63
|
+
* **The origin task goes `committed -> in_progress`** as soon as its issue
|
|
64
|
+
exists (issue #30) -- on the create path, on the adopt path, and on the
|
|
65
|
+
crash-recovery path that completes an earlier release's label -- through the
|
|
66
|
+
same `AlissaClient.set_status` the close edge opens its pending_validation
|
|
67
|
+
walk with. `committed` means scoped-in TODO and released work is being worked,
|
|
68
|
+
so without this a task would read TODO for the entire implementation window
|
|
69
|
+
and only jump to `pending_validation` at merge. It is a strictly-from-
|
|
70
|
+
`committed` move (anything further along is left alone -- never a downgrade)
|
|
71
|
+
and it is NON-BLOCKING: a failed set is a warning, the release stands, and the
|
|
72
|
+
close edge's walk applies the same transition at merge. See
|
|
73
|
+
`_mark_in_progress`.
|
|
63
74
|
* **Operator park**: if the operator removes the trigger label from a released
|
|
64
75
|
issue, orcloop NEVER re-labels it (that would override the operator) and
|
|
65
76
|
raises the `parked` escalation once. Park is detected on the close edge,
|
|
@@ -92,8 +103,11 @@ titled `autodev: <owner>/<repo>` (`bow_prefix`).
|
|
|
92
103
|
one line at a time).
|
|
93
104
|
* **Discovery is O(total BOW membership)**, never O(all committed tasks): ONE
|
|
94
105
|
`GET /v1/bodies-of-work`, then one membership read per matching BOW, then one
|
|
95
|
-
`get_task` per
|
|
96
|
-
|
|
106
|
+
`get_task` per member whose row reads `committed` or `in_progress`
|
|
107
|
+
(`BOW_RESOLVE_STATES` -- membership rows carry a status but no task number, so
|
|
108
|
+
that gate is applied before spending the detail read). The second status is
|
|
109
|
+
not a release candidate: it is how a released task whose detach did not land
|
|
110
|
+
reads, and the read is what makes the retry reachable at all.
|
|
97
111
|
`list_marked_committed_tasks` is never called on the release path.
|
|
98
112
|
* **All the gates are unchanged**: committed status, the dependsOn gate, lane
|
|
99
113
|
occupancy, priority-then-age ordering, the same mechanical rendering. A
|
|
@@ -119,8 +133,10 @@ titled `autodev: <owner>/<repo>` (`bow_prefix`).
|
|
|
119
133
|
naming the unreadable count, the reason on every held decision, and a skipped
|
|
120
134
|
fallback sweep (which likewise cannot distinguish unattached from unread).
|
|
121
135
|
* **Detach is the TERMINAL act of a successful release** (create-issue ->
|
|
122
|
-
ledger -> label -> detach), never the first: a crash mid-release
|
|
123
|
-
task attached and therefore still discoverable. orcloop NEVER
|
|
136
|
+
ledger -> label -> status -> detach), never the first: a crash mid-release
|
|
137
|
+
leaves the task attached and therefore still discoverable. orcloop NEVER
|
|
138
|
+
attaches. The status step sits before it and is not coupled to it in either
|
|
139
|
+
direction, so a status failure can never leave a task attached.
|
|
124
140
|
* **Operator detach = park, and it is terminal.** A detached task simply stops
|
|
125
141
|
being a candidate; nothing re-attaches it and no later pass resurrects it. If
|
|
126
142
|
the operator RE-attaches a task orcloop already released, the dedupe path
|
|
@@ -138,7 +154,12 @@ CRASH STORY, per step of the bow-mode release (all resumed by the next poll):
|
|
|
138
154
|
3. crash after the label, before the detach -- the row is `labeled=1` and the
|
|
139
155
|
task is still attached; the next pass takes the dedupe path and detaches.
|
|
140
156
|
Detach is idempotent server-side (`removed: false` when already gone), so a
|
|
141
|
-
crash *during* the detach is equally safe.
|
|
157
|
+
crash *during* the detach is equally safe. Note the status set landed first,
|
|
158
|
+
so the task arrives at that pass at `in_progress`: the feed's committed gates
|
|
159
|
+
would judge it as a non-candidate and hold it forever, which is why an
|
|
160
|
+
already-released member is routed to the dedupe path on the LEDGER's answer
|
|
161
|
+
before its status is judged at all (`_bow_feed_pass`, and `BOW_RESOLVE_STATES`
|
|
162
|
+
for the membership-row half of the same gate).
|
|
142
163
|
|
|
143
164
|
THE SCAN-FALLBACK SWEEP (`scan_fallback_interval`, default hourly, 0 = off)
|
|
144
165
|
is the `bow` feed's level-based safety net: every interval the loop ALSO runs
|
|
@@ -241,7 +262,13 @@ from .alissa_client import (
|
|
|
241
262
|
)
|
|
242
263
|
from .config import FEED_BOW, Config
|
|
243
264
|
from .ghclient import GitHub, Issue, PullRequest, RateLimited, parse_task_ref
|
|
244
|
-
from .markers import
|
|
265
|
+
from .markers import (
|
|
266
|
+
find_mid_line_trailer,
|
|
267
|
+
is_bow_feed,
|
|
268
|
+
parse_bow_repo,
|
|
269
|
+
parse_marker,
|
|
270
|
+
parse_scope,
|
|
271
|
+
)
|
|
245
272
|
from .proc import CommandError
|
|
246
273
|
from .state import State
|
|
247
274
|
|
|
@@ -252,10 +279,28 @@ log = logging.getLogger(__name__)
|
|
|
252
279
|
# the dependency (soft/hard) does not enter here -- both hold identically.
|
|
253
280
|
DEP_CLEAR_STATES = frozenset({"pending_validation", "validated"})
|
|
254
281
|
|
|
255
|
-
# The
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
|
|
282
|
+
# The two origin-task statuses the `committed → in_progress` transition moves
|
|
283
|
+
# between. `committed` = scoped-in TODO; `in_progress` = being worked.
|
|
284
|
+
#
|
|
285
|
+
# BOTH edges make exactly this move, through the same `AlissaClient.set_status`:
|
|
286
|
+
# the close edge as the first step of its pending_validation walk, and (issue
|
|
287
|
+
# #30) the release edge the moment the GitHub issue exists. Released to the
|
|
288
|
+
# pipeline IS being worked -- devloop claims a labeled issue within minutes, so
|
|
289
|
+
# a released task that kept reading `committed` until merge would read as TODO
|
|
290
|
+
# to every observer for the whole implementation window. One definition of the
|
|
291
|
+
# transition, so the two edges can never drift on which one they mean.
|
|
292
|
+
TASK_TODO_STATE = "committed"
|
|
293
|
+
TASK_IN_FLIGHT_STATE = "in_progress"
|
|
294
|
+
|
|
295
|
+
# The close edge only mutates a task the loop itself left in one of these two
|
|
296
|
+
# states. `in_progress` is the NORMAL one since issue #30 -- the release edge
|
|
297
|
+
# sets it the moment the issue exists, so that is how a task in flight reads.
|
|
298
|
+
# `committed` is now the exception rather than the rule: a release-edge status
|
|
299
|
+
# set that failed, a release that crashed before reaching it, or a task released
|
|
300
|
+
# by a dist older than 0.3.6. Either way there is work left to do, and the walk
|
|
301
|
+
# in `_mutate_close` starts from whichever it finds. Any OTHER status means the
|
|
302
|
+
# operator moved the task by hand -- skip, never fight them.
|
|
303
|
+
CLOSE_EXPECTED_STATES = frozenset({TASK_TODO_STATE, TASK_IN_FLIGHT_STATE})
|
|
259
304
|
|
|
260
305
|
# Priority ordering for the release queue. Unknown/blank priority sorts last
|
|
261
306
|
# (rank 0); ties break on age (older first).
|
|
@@ -330,6 +375,24 @@ CLOSE_HANDOFF_STATE = "pending_validation"
|
|
|
330
375
|
# gate still decides whether the work is ready to leave the queue.
|
|
331
376
|
BOW_RELEASABLE_STATE = "committed"
|
|
332
377
|
|
|
378
|
+
# The member statuses that earn a `get_task` before being judged. Wider than
|
|
379
|
+
# BOW_RELEASABLE_STATE by exactly one entry, and only since the release edge
|
|
380
|
+
# started setting `in_progress` (issue #30): an ATTACHED task at that status is
|
|
381
|
+
# most likely one of orcloop's own releases whose terminal detach did not land,
|
|
382
|
+
# and the detach retry lives behind the detail read (`_dedupe_result`). Gating
|
|
383
|
+
# it out on the membership row alone would strand it -- attached forever, held
|
|
384
|
+
# once per poll, invisible to the marker sweep (which lists COMMITTED tasks),
|
|
385
|
+
# and unreachable by the only `detach_task` caller there is. The detail read is
|
|
386
|
+
# the price of that recovery, and the bound is one read per poll per member at
|
|
387
|
+
# this status -- no writes, and nothing that scales with the task graph. Two
|
|
388
|
+
# shapes reach it, and only one is transient: an unfinished detach clears on the
|
|
389
|
+
# pass that resolves it (in steady state a released task leaves the feed on the
|
|
390
|
+
# pass that released it), while a task an operator ATTACHED at `in_progress` is
|
|
391
|
+
# held and stays attached, so it earns that read every poll until they detach
|
|
392
|
+
# it. Still the right trade against a permanent strand. Every other status
|
|
393
|
+
# (draft, blocked, cancelled, validated...) is held off the row, unread.
|
|
394
|
+
BOW_RESOLVE_STATES = frozenset({BOW_RELEASABLE_STATE, TASK_IN_FLIGHT_STATE})
|
|
395
|
+
|
|
333
396
|
# `state.meta` key: unix seconds of the last scan-fallback sweep, so the
|
|
334
397
|
# low-frequency safety net keeps its cadence across daemon restarts (a restart
|
|
335
398
|
# loop must not turn an hourly sweep into a per-boot full scan).
|
|
@@ -440,6 +503,18 @@ DEGRADED_DISCOVERY_REASON = (
|
|
|
440
503
|
"— held rather than released to a possibly-wrong repo; retries next poll"
|
|
441
504
|
)
|
|
442
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
|
+
|
|
443
518
|
SWEEP_INVISIBLE_REASON = (
|
|
444
519
|
"carries the release marker but is attached to no {prefix!r} body of work "
|
|
445
520
|
"— the bow feed will never release it (attachment is the only trigger); "
|
|
@@ -583,6 +658,24 @@ class Orchestrator:
|
|
|
583
658
|
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
584
659
|
return (slug, decision)
|
|
585
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
|
+
|
|
586
679
|
# -- escalation --------------------------------------------------------
|
|
587
680
|
|
|
588
681
|
def _escalate(
|
|
@@ -653,6 +746,24 @@ class Orchestrator:
|
|
|
653
746
|
):
|
|
654
747
|
issue = self.github.issue(owner, repo, number)
|
|
655
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
|
+
)
|
|
656
767
|
occupancy.setdefault(issue.full_name, []).append(scope)
|
|
657
768
|
task_number = parse_task_ref(issue.body)
|
|
658
769
|
if task_number is None:
|
|
@@ -698,9 +809,13 @@ class Orchestrator:
|
|
|
698
809
|
sweep needs to measure itself against this pass (see `FeedPass`).
|
|
699
810
|
|
|
700
811
|
Cost is O(total BOW membership): one list call, one membership read per
|
|
701
|
-
feed BOW, and one `get_task` per
|
|
702
|
-
|
|
703
|
-
never spent on work
|
|
812
|
+
feed BOW, and one `get_task` per member the row reports at a status in
|
|
813
|
+
`BOW_RESOLVE_STATES`. Every other status is held off the row alone, so
|
|
814
|
+
the detail read is never spent on work nothing could do anything with.
|
|
815
|
+
The read IS spent on `in_progress` members, which cannot release --
|
|
816
|
+
deliberately: that is how an already-released task whose detach failed
|
|
817
|
+
reads, and the gate below routes it to `_dedupe_result` to finish the
|
|
818
|
+
detach. Recovering it is worth one read.
|
|
704
819
|
|
|
705
820
|
Membership is read for EVERY feed BOW before any member is evaluated,
|
|
706
821
|
because a task attached to two feed BOWs has an ambiguous target repo
|
|
@@ -840,7 +955,7 @@ class Orchestrator:
|
|
|
840
955
|
ambiguous = self._ambiguous_claim(owners)
|
|
841
956
|
reason = ambiguous or broken
|
|
842
957
|
|
|
843
|
-
if member.status
|
|
958
|
+
if member.status not in BOW_RESOLVE_STATES:
|
|
844
959
|
if ambiguous is not None:
|
|
845
960
|
log.warning("%s is %s", self._member_slug(member), ambiguous)
|
|
846
961
|
# Held off the membership row, so no `get_task` and no task
|
|
@@ -871,11 +986,31 @@ class Orchestrator:
|
|
|
871
986
|
# it: a task moved out of `committed` between the two reads
|
|
872
987
|
# must not release off a stale membership row.
|
|
873
988
|
if task.status != BOW_RELEASABLE_STATE:
|
|
989
|
+
if task.task_number in released_github or self.state.is_released(
|
|
990
|
+
task.task_number
|
|
991
|
+
):
|
|
992
|
+
# Not a release candidate at all -- an ALREADY-RELEASED
|
|
993
|
+
# task that is still attached is an unfinished detach,
|
|
994
|
+
# and since the release edge leaves what it releases at
|
|
995
|
+
# `in_progress` (issue #30) this is the shape that
|
|
996
|
+
# arrives here. Judging it on its status would hold the
|
|
997
|
+
# very task whose detach is waiting to be retried, in a
|
|
998
|
+
# state nothing else in the daemon can leave: the ledger
|
|
999
|
+
# answer has to come first. Same call the committed path
|
|
1000
|
+
# makes below, so the retry is the one already
|
|
1001
|
+
# documented.
|
|
1002
|
+
results.append(
|
|
1003
|
+
self._dedupe_result(task.task_number, bow=bow, task=task)
|
|
1004
|
+
)
|
|
1005
|
+
continue
|
|
1006
|
+
stale = (
|
|
1007
|
+
" — the membership row was stale"
|
|
1008
|
+
if member.status == BOW_RELEASABLE_STATE else ""
|
|
1009
|
+
)
|
|
874
1010
|
results.append(self._logged(task.ref, Decision(
|
|
875
1011
|
Action.HELD,
|
|
876
1012
|
f"attached to {bow.title!r} but its task detail says "
|
|
877
|
-
f"{task.status!r}, not {BOW_RELEASABLE_STATE!r}
|
|
878
|
-
f"membership row was stale",
|
|
1013
|
+
f"{task.status!r}, not {BOW_RELEASABLE_STATE!r}{stale}",
|
|
879
1014
|
task.task_number,
|
|
880
1015
|
)))
|
|
881
1016
|
continue
|
|
@@ -911,7 +1046,9 @@ class Orchestrator:
|
|
|
911
1046
|
if task.task_number in released_github or self.state.is_released(
|
|
912
1047
|
task.task_number
|
|
913
1048
|
):
|
|
914
|
-
results.append(
|
|
1049
|
+
results.append(
|
|
1050
|
+
self._dedupe_result(task.task_number, bow=bow, task=task)
|
|
1051
|
+
)
|
|
915
1052
|
continue
|
|
916
1053
|
assert target is not None # `broken` is None, so it parsed
|
|
917
1054
|
pending.append((task, bow, target))
|
|
@@ -1178,7 +1315,11 @@ class Orchestrator:
|
|
|
1178
1315
|
return results
|
|
1179
1316
|
|
|
1180
1317
|
def _dedupe_result(
|
|
1181
|
-
self,
|
|
1318
|
+
self,
|
|
1319
|
+
task_number: int,
|
|
1320
|
+
*,
|
|
1321
|
+
bow: BodyOfWork | None = None,
|
|
1322
|
+
task: Task | None = None,
|
|
1182
1323
|
) -> tuple[str, Decision]:
|
|
1183
1324
|
"""A task the dedupe set already covers. Usually a plain SKIPPED, but a
|
|
1184
1325
|
ledger row still at `labeled=0` is a create->ledger crash survivor: the
|
|
@@ -1190,7 +1331,18 @@ class Orchestrator:
|
|
|
1190
1331
|
Seeing an already-released task in the feed at all means it is still
|
|
1191
1332
|
attached: either a crash landed between the label and the detach, or the
|
|
1192
1333
|
operator re-attached it. Either way the answer is the same -- never a
|
|
1193
|
-
second issue, just finish the release by detaching.
|
|
1334
|
+
second issue, just finish the release by detaching.
|
|
1335
|
+
|
|
1336
|
+
The crash-recovery branch completes the ORIGIN STATUS too, because it is
|
|
1337
|
+
completing the very release that never reached it: it applies the
|
|
1338
|
+
trigger label, which arms devloop, so leaving the task `committed` would
|
|
1339
|
+
reproduce exactly the symptom issue #30 was filed for. `task` is passed
|
|
1340
|
+
in by the `bow` feed (which has already read the detail); the `scan`
|
|
1341
|
+
feed reaches this method with a task NUMBER only -- deliberately, so a
|
|
1342
|
+
dedupe hit costs no detail read -- so on that path the read is spent
|
|
1343
|
+
HERE, inside the rare branch. The hot branch below returns first and
|
|
1344
|
+
still reads nothing, and a failed read is a warning, never a hold: the
|
|
1345
|
+
label is what matters and it has already landed."""
|
|
1194
1346
|
ref = f"TASK-{task_number}"
|
|
1195
1347
|
row = self.state.release_for(task_number)
|
|
1196
1348
|
if row is None or row["labeled"]:
|
|
@@ -1208,6 +1360,7 @@ class Orchestrator:
|
|
|
1208
1360
|
return self._logged(ref, Decision(
|
|
1209
1361
|
Action.RELEASED,
|
|
1210
1362
|
f"[dry-run] would complete labeling of {slug} (crash recovery)"
|
|
1363
|
+
f"{self._resolved_in_progress(ref, task)}"
|
|
1211
1364
|
f"{self._detach(ref, bow)}",
|
|
1212
1365
|
task_number,
|
|
1213
1366
|
))
|
|
@@ -1222,12 +1375,89 @@ class Orchestrator:
|
|
|
1222
1375
|
return self._logged(ref, Decision(
|
|
1223
1376
|
Action.HELD, f"crash-recovery labeling of {slug} failed: {exc}", task_number,
|
|
1224
1377
|
))
|
|
1378
|
+
in_flight = self._resolved_in_progress(ref, task)
|
|
1225
1379
|
return self._logged(ref, Decision(
|
|
1226
1380
|
Action.RELEASED,
|
|
1227
|
-
f"crash recovery: applied label to {slug}{
|
|
1381
|
+
f"crash recovery: applied label to {slug}{in_flight}"
|
|
1382
|
+
f"{self._detach(ref, bow)}",
|
|
1228
1383
|
task_number,
|
|
1229
1384
|
))
|
|
1230
1385
|
|
|
1386
|
+
def _resolved_in_progress(self, ref: str, task: Task | None) -> str:
|
|
1387
|
+
"""`_mark_in_progress` for a caller that may not hold the task detail.
|
|
1388
|
+
|
|
1389
|
+
The read is spent only where it is called from (the rare crash-recovery
|
|
1390
|
+
branch), and only when the caller did not already have the task. A
|
|
1391
|
+
failed read cannot hold anything -- the issue is filed and labeled by
|
|
1392
|
+
now -- so it degrades exactly like a failed status set: one warning, and
|
|
1393
|
+
the close edge's walk corrects the status at merge."""
|
|
1394
|
+
if task is None:
|
|
1395
|
+
try:
|
|
1396
|
+
task = self.alissa.get_task(ref)
|
|
1397
|
+
except AlissaError as exc:
|
|
1398
|
+
log.warning(
|
|
1399
|
+
"could not read %s to set it %s after completing its "
|
|
1400
|
+
"release: %s — the release stands and the close edge "
|
|
1401
|
+
"applies the same transition at merge",
|
|
1402
|
+
ref, TASK_IN_FLIGHT_STATE, exc,
|
|
1403
|
+
)
|
|
1404
|
+
return f"; STATUS SET PENDING (→ {TASK_IN_FLIGHT_STATE}: {exc})"
|
|
1405
|
+
return self._mark_in_progress(task)
|
|
1406
|
+
|
|
1407
|
+
def _mark_in_progress(self, task: Task) -> str:
|
|
1408
|
+
"""Move a released origin task `committed → in_progress` and return a
|
|
1409
|
+
suffix for the decision's reason.
|
|
1410
|
+
|
|
1411
|
+
Released IS being worked (see `TASK_IN_FLIGHT_STATE`): by the time this
|
|
1412
|
+
runs the issue exists and carries the trigger label, so a developer
|
|
1413
|
+
session is minutes away, while the close edge's status walk only runs at
|
|
1414
|
+
merge -- hours or days later. Without this step the task reads TODO for
|
|
1415
|
+
that entire window.
|
|
1416
|
+
|
|
1417
|
+
Three disciplines, each deliberate:
|
|
1418
|
+
|
|
1419
|
+
* **Transition matrix.** Attempted ONLY from `committed`. A task some
|
|
1420
|
+
other surface has already advanced (a human, a race with the close
|
|
1421
|
+
edge) is left exactly as it is: no downgrade, no illegal
|
|
1422
|
+
`in_progress → in_progress` re-issue, no error -- a silent no-op that
|
|
1423
|
+
returns an empty suffix, so the release reads the same as it always
|
|
1424
|
+
did.
|
|
1425
|
+
* **Never blocking.** A failed set logs a WARNING naming the task and
|
|
1426
|
+
the release proceeds unchanged: the issue exists, the label is on, the
|
|
1427
|
+
detach still runs, and the decision is still `released`. No escalation
|
|
1428
|
+
kind, because the close edge's walk applies this same transition at
|
|
1429
|
+
merge -- the failure is cosmetic and self-healing, and a page that
|
|
1430
|
+
resolves itself is a page that teaches operators to ignore pages.
|
|
1431
|
+
* **Before the detach.** Callers sequence this ahead of `_detach` so the
|
|
1432
|
+
release's terminal act stays terminal. Nothing here couples to the
|
|
1433
|
+
detach in either direction, so a status failure can never leave a task
|
|
1434
|
+
attached to its feed. The converse -- a detach failure meeting a task
|
|
1435
|
+
this method has already advanced -- is why `_bow_feed_pass` consults
|
|
1436
|
+
the ledger before it judges an attached task's status (see `_detach`).
|
|
1437
|
+
|
|
1438
|
+
The mechanism is the close edge's, not a second one: the same
|
|
1439
|
+
`AlissaClient.set_status` call and the same two status constants that
|
|
1440
|
+
`_mutate_close` opens its pending_validation walk with."""
|
|
1441
|
+
if task.status != TASK_TODO_STATE:
|
|
1442
|
+
return ""
|
|
1443
|
+
if self.config.dry_run:
|
|
1444
|
+
return f"; [dry-run] would set {task.ref} {TASK_IN_FLIGHT_STATE}"
|
|
1445
|
+
try:
|
|
1446
|
+
self.alissa.set_status(task.ref, TASK_IN_FLIGHT_STATE)
|
|
1447
|
+
except AlissaError as exc:
|
|
1448
|
+
log.warning(
|
|
1449
|
+
"released %s but could not set it %s: %s — the release stands "
|
|
1450
|
+
"(the issue exists and is labeled) and the close edge applies "
|
|
1451
|
+
"the same transition at merge, so the task is only mis-reported "
|
|
1452
|
+
"as TODO until then",
|
|
1453
|
+
task.ref, TASK_IN_FLIGHT_STATE, exc,
|
|
1454
|
+
)
|
|
1455
|
+
return f"; STATUS SET PENDING (→ {TASK_IN_FLIGHT_STATE}: {exc})"
|
|
1456
|
+
log.info(
|
|
1457
|
+
"%s → %s (released to the pipeline)", task.ref, TASK_IN_FLIGHT_STATE
|
|
1458
|
+
)
|
|
1459
|
+
return f"; task → {TASK_IN_FLIGHT_STATE}"
|
|
1460
|
+
|
|
1231
1461
|
def _detach(self, ref: str, bow: BodyOfWork | None) -> str:
|
|
1232
1462
|
"""Detach a released task from its feed BOW -- the TERMINAL act of a
|
|
1233
1463
|
successful release -- and return a suffix for the decision's reason.
|
|
@@ -1237,7 +1467,14 @@ class Orchestrator:
|
|
|
1237
1467
|
durable in the ledger, and leaving the task attached is the SAFE
|
|
1238
1468
|
failure -- it stays discoverable, and the next pass's dedupe path
|
|
1239
1469
|
retries the detach. The reverse (detaching first) would be a take-then-
|
|
1240
|
-
act that could lose the work entirely.
|
|
1470
|
+
act that could lose the work entirely.
|
|
1471
|
+
|
|
1472
|
+
That retry is only reachable because `_bow_feed_pass` asks the LEDGER
|
|
1473
|
+
before it judges an attached task's status: the release edge runs
|
|
1474
|
+
`_mark_in_progress` before this call, so the task waiting for a retry is
|
|
1475
|
+
`in_progress`, which every committed-shaped gate would otherwise treat
|
|
1476
|
+
as "not a candidate" -- a hold nothing could ever clear, since this is
|
|
1477
|
+
`detach_task`'s only caller and the close edge never detaches."""
|
|
1241
1478
|
if bow is None:
|
|
1242
1479
|
return ""
|
|
1243
1480
|
if self.config.dry_run:
|
|
@@ -1274,11 +1511,21 @@ class Orchestrator:
|
|
|
1274
1511
|
if target is None:
|
|
1275
1512
|
marker = parse_marker(task.description, self.config.marker_key)
|
|
1276
1513
|
if marker is None or not self.config.watches(marker):
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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"
|
|
1282
1529
|
self._escalate(
|
|
1283
1530
|
task.task_number,
|
|
1284
1531
|
ESCALATION_UNRELEASABLE,
|
|
@@ -1308,13 +1555,24 @@ class Orchestrator:
|
|
|
1308
1555
|
return self._release_blocked(task)
|
|
1309
1556
|
|
|
1310
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
|
+
)
|
|
1311
1567
|
lane_reason = self._lane_blocked(target, scope, occupancy)
|
|
1312
1568
|
if lane_reason is not None:
|
|
1313
1569
|
return self._logged(task.ref, Decision(
|
|
1314
|
-
Action.HELD,
|
|
1570
|
+
Action.HELD,
|
|
1571
|
+
f"lane {target} occupied: {lane_reason}{note}",
|
|
1572
|
+
task.task_number,
|
|
1315
1573
|
))
|
|
1316
1574
|
|
|
1317
|
-
return self._release(task, target, scope, occupancy, bow=bow)
|
|
1575
|
+
return self._release(task, target, scope, occupancy, bow=bow, note=note)
|
|
1318
1576
|
|
|
1319
1577
|
def _release_blocked(self, task: Task) -> tuple[str, Decision]:
|
|
1320
1578
|
"""The dependency gate holds. Below the floor this is a quiet per-poll
|
|
@@ -1513,6 +1771,11 @@ class Orchestrator:
|
|
|
1513
1771
|
in-flight whatever orcloop thinks, so there is no trigger to withhold,
|
|
1514
1772
|
only a ledger row to record and a lane to account for.
|
|
1515
1773
|
|
|
1774
|
+
Both branches end at `_mark_in_progress`, like the create path: adoption
|
|
1775
|
+
means the work is (back) in flight, and the already-labeled branch is
|
|
1776
|
+
the strongest case of all -- that issue has been armed for devloop since
|
|
1777
|
+
before this pass ran.
|
|
1778
|
+
|
|
1516
1779
|
An adopted body is a human's, so it is not the renderer's: only the
|
|
1517
1780
|
trailer is contractual (it is all the develop daemon reads too). A body
|
|
1518
1781
|
without the `Autonomous-Dev` trailer is adopted anyway -- the target
|
|
@@ -1532,6 +1795,23 @@ class Orchestrator:
|
|
|
1532
1795
|
)
|
|
1533
1796
|
|
|
1534
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
|
+
)
|
|
1535
1815
|
labeled = self.config.label in issue.labels
|
|
1536
1816
|
if not labeled:
|
|
1537
1817
|
lane_reason = self._lane_blocked(target, issue_scope, occupancy)
|
|
@@ -1554,7 +1834,7 @@ class Orchestrator:
|
|
|
1554
1834
|
f"cannot adopt {issue.issue_slug} yet: lane {target} "
|
|
1555
1835
|
f"occupied: {lane_reason} — the issue declares "
|
|
1556
1836
|
f"{issue_scope or '(scopeless — needs an empty lane)'}, "
|
|
1557
|
-
f"whatever the task declares; retries next poll",
|
|
1837
|
+
f"whatever the task declares; retries next poll{issue_note}",
|
|
1558
1838
|
task.task_number,
|
|
1559
1839
|
))
|
|
1560
1840
|
|
|
@@ -1576,11 +1856,12 @@ class Orchestrator:
|
|
|
1576
1856
|
verb = "would record" if labeled else "would adopt"
|
|
1577
1857
|
unlabeled_note = "" if labeled else f", no {self.config.label!r} label"
|
|
1578
1858
|
occupancy.setdefault(target, []).append(issue_scope)
|
|
1859
|
+
in_flight = self._mark_in_progress(task)
|
|
1579
1860
|
return self._logged(task.ref, Decision(
|
|
1580
1861
|
Action.SKIPPED if labeled else Action.RELEASED,
|
|
1581
1862
|
f"[dry-run] {verb} pre-existing {issue.issue_slug} (carries "
|
|
1582
1863
|
f"this task's `Alissa-Task:` trailer{unlabeled_note})"
|
|
1583
|
-
f"{self._detach(task.ref, bow)}",
|
|
1864
|
+
f"{in_flight}{self._detach(task.ref, bow)}{issue_note}",
|
|
1584
1865
|
task.task_number,
|
|
1585
1866
|
))
|
|
1586
1867
|
|
|
@@ -1598,11 +1879,16 @@ class Orchestrator:
|
|
|
1598
1879
|
# ran before this issue existed, so no later candidate would
|
|
1599
1880
|
# otherwise measure itself against it.
|
|
1600
1881
|
occupancy.setdefault(target, []).append(issue_scope)
|
|
1882
|
+
# `skipped` describes what was FILED (nothing), not what is
|
|
1883
|
+
# happening to the work: an armed issue for this task is live,
|
|
1884
|
+
# so the task is in flight exactly like an adopted one.
|
|
1885
|
+
in_flight = self._mark_in_progress(task)
|
|
1601
1886
|
return self._logged(task.ref, Decision(
|
|
1602
1887
|
Action.SKIPPED,
|
|
1603
1888
|
f"already released by hand as {issue.issue_slug} (trailer "
|
|
1604
1889
|
f"and {self.config.label!r} label present); recorded in the "
|
|
1605
|
-
f"ledger, no second issue{
|
|
1890
|
+
f"ledger, no second issue{in_flight}"
|
|
1891
|
+
f"{self._detach(task.ref, bow)}{issue_note}",
|
|
1606
1892
|
task.task_number,
|
|
1607
1893
|
))
|
|
1608
1894
|
self.github.add_label(issue.owner, issue.repo, issue.number, self.config.label)
|
|
@@ -1619,11 +1905,13 @@ class Orchestrator:
|
|
|
1619
1905
|
))
|
|
1620
1906
|
|
|
1621
1907
|
occupancy.setdefault(target, []).append(issue_scope)
|
|
1908
|
+
in_flight = self._mark_in_progress(task)
|
|
1622
1909
|
return self._logged(task.ref, Decision(
|
|
1623
1910
|
Action.RELEASED,
|
|
1624
1911
|
f"adopted pre-existing {issue.issue_slug} (carries this task's "
|
|
1625
1912
|
f"trailer, was unlabeled): ledger row recorded and "
|
|
1626
|
-
f"{self.config.label!r} applied{
|
|
1913
|
+
f"{self.config.label!r} applied{in_flight}"
|
|
1914
|
+
f"{self._detach(task.ref, bow)}{issue_note}",
|
|
1627
1915
|
task.task_number,
|
|
1628
1916
|
))
|
|
1629
1917
|
|
|
@@ -1803,18 +2091,28 @@ class Orchestrator:
|
|
|
1803
2091
|
occupancy: dict[str, list[list[str]]],
|
|
1804
2092
|
*,
|
|
1805
2093
|
bow: BodyOfWork | None = None,
|
|
2094
|
+
note: str = "",
|
|
1806
2095
|
) -> tuple[str, Decision]:
|
|
1807
2096
|
"""Create the issue (unlabeled), record the ledger row, stamp the label,
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
crash at any point leaves the work
|
|
2097
|
+
move the origin task `committed → in_progress`, then -- under the `bow`
|
|
2098
|
+
feed -- detach the task from its Body of Work. In that order, and detach
|
|
2099
|
+
strictly last: the label is the develop daemon's trigger and the detach
|
|
2100
|
+
is the release's terminal act, so a crash at any point leaves the work
|
|
2101
|
+
discoverable rather than lost.
|
|
1812
2102
|
Occupancy is updated in-memory so a later candidate in THIS pass sees
|
|
1813
2103
|
the new occupant and respects the lane.
|
|
1814
2104
|
|
|
1815
2105
|
Nothing is created until the TRAILER dedupe answers: the pass-level
|
|
1816
2106
|
scan is label-scoped and cannot see an unlabeled issue that already
|
|
1817
|
-
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."""
|
|
1818
2116
|
owner, _, repo = target.partition("/")
|
|
1819
2117
|
preexisting = self._trailer_dedupe(task, target, occupancy, bow=bow)
|
|
1820
2118
|
if preexisting is not None:
|
|
@@ -1824,10 +2122,12 @@ class Orchestrator:
|
|
|
1824
2122
|
|
|
1825
2123
|
if self.config.dry_run:
|
|
1826
2124
|
occupancy.setdefault(target, []).append(scope)
|
|
2125
|
+
in_flight = self._mark_in_progress(task)
|
|
1827
2126
|
return self._logged(task.ref, Decision(
|
|
1828
2127
|
Action.RELEASED,
|
|
1829
2128
|
f"[dry-run] would release to {target} "
|
|
1830
|
-
f"(label {self.config.label!r}){
|
|
2129
|
+
f"(label {self.config.label!r}){in_flight}"
|
|
2130
|
+
f"{self._detach(task.ref, bow)}{note}",
|
|
1831
2131
|
task.task_number,
|
|
1832
2132
|
))
|
|
1833
2133
|
|
|
@@ -1848,9 +2148,13 @@ class Orchestrator:
|
|
|
1848
2148
|
))
|
|
1849
2149
|
|
|
1850
2150
|
occupancy.setdefault(target, []).append(scope)
|
|
2151
|
+
# Status BEFORE detach, and outside the try above: the issue is filed
|
|
2152
|
+
# and armed, so nothing from here on may turn this into a non-release.
|
|
2153
|
+
in_flight = self._mark_in_progress(task)
|
|
1851
2154
|
return self._logged(task.ref, Decision(
|
|
1852
2155
|
Action.RELEASED,
|
|
1853
|
-
f"released to {issue.issue_slug}{
|
|
2156
|
+
f"released to {issue.issue_slug}{in_flight}"
|
|
2157
|
+
f"{self._detach(task.ref, bow)}{note}",
|
|
1854
2158
|
task.task_number,
|
|
1855
2159
|
))
|
|
1856
2160
|
|
|
@@ -2025,8 +2329,8 @@ class Orchestrator:
|
|
|
2025
2329
|
|
|
2026
2330
|
Criteria satisfaction is naturally idempotent (already-satisfied ones are
|
|
2027
2331
|
skipped), so it needs no gate."""
|
|
2028
|
-
if task.status ==
|
|
2029
|
-
self.alissa.set_status(ref,
|
|
2332
|
+
if task.status == TASK_TODO_STATE:
|
|
2333
|
+
self.alissa.set_status(ref, TASK_IN_FLIGHT_STATE)
|
|
2030
2334
|
for criterion in task.criteria:
|
|
2031
2335
|
if not criterion.satisfied:
|
|
2032
2336
|
self.alissa.satisfy_criterion(ref, criterion.id)
|
|
@@ -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
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.7
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.5
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|