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.
- {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
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/loop.py +116 -15
- {alissa_tools_github_orcloop-0.3.6 → 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.6 → 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.6/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
- {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
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/config.py +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/state.py +0 -0
- {alissa_tools_github_orcloop-0.3.6 → alissa_tools_github_orcloop-0.3.7}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {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
- {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
- {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
- {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
|
@@ -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
|
|
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
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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,
|
|
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
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.7
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.6
|
|
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
|