alissa-tools-github-devloop 0.5.0__tar.gz → 0.6.0__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_devloop-0.5.0/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.6.0}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/__main__.py +21 -1
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/config.py +22 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/ghclient.py +108 -13
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/loop.py +1068 -116
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/state.py +90 -0
- alissa_tools_github_devloop-0.6.0/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/webui/sources.py +1 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.5.0/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/README.md +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/setup.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -147,6 +147,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
147
147
|
"sessions, even if the config enables it",
|
|
148
148
|
)
|
|
149
149
|
|
|
150
|
+
resume = over.add_mutually_exclusive_group()
|
|
151
|
+
resume.add_argument(
|
|
152
|
+
"--resume",
|
|
153
|
+
dest="resume_enabled",
|
|
154
|
+
action="store_true",
|
|
155
|
+
default=None,
|
|
156
|
+
help="auto-resume a stale attempt whose session is provably dead "
|
|
157
|
+
"(absent from a successful `alissa tmux ls`) while its PR stays open, "
|
|
158
|
+
"instead of deferring behind an operator ping (the default)",
|
|
159
|
+
)
|
|
160
|
+
resume.add_argument(
|
|
161
|
+
"--no-resume",
|
|
162
|
+
dest="resume_enabled",
|
|
163
|
+
action="store_false",
|
|
164
|
+
help="legacy two-signal staleness: never read session liveness, "
|
|
165
|
+
"never auto-resume — a dead-with-PR lane waits for an operator",
|
|
166
|
+
)
|
|
167
|
+
|
|
150
168
|
dry = over.add_mutually_exclusive_group()
|
|
151
169
|
dry.add_argument(
|
|
152
170
|
"--dry-run",
|
|
@@ -184,6 +202,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
184
202
|
"fix_rounds_enabled": args.fix_rounds_enabled,
|
|
185
203
|
"maintain_label": args.maintain_label,
|
|
186
204
|
"maintain_enabled": args.maintain_enabled,
|
|
205
|
+
"resume_enabled": args.resume_enabled,
|
|
187
206
|
"dry_run": args.dry_run,
|
|
188
207
|
}
|
|
189
208
|
|
|
@@ -215,12 +234,13 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
215
234
|
log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
|
|
216
235
|
log.info(
|
|
217
236
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
218
|
-
"max_sessions=%s; fix_rounds=%s; maintain=%s (label %r)",
|
|
237
|
+
"max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s",
|
|
219
238
|
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
220
239
|
config.stale_minutes,
|
|
221
240
|
config.max_sessions or "unlimited",
|
|
222
241
|
config.fix_rounds_enabled,
|
|
223
242
|
config.maintain_enabled, config.maintain_label,
|
|
243
|
+
config.resume_enabled,
|
|
224
244
|
)
|
|
225
245
|
log.debug("hub_template: %s", config.hub_template)
|
|
226
246
|
log.debug("agent_profile: %s", config.agent_profile)
|
|
@@ -62,6 +62,7 @@ CONFIG_KEYS = (
|
|
|
62
62
|
"fix_rounds_enabled",
|
|
63
63
|
"maintain_label",
|
|
64
64
|
"maintain_enabled",
|
|
65
|
+
"resume_enabled",
|
|
65
66
|
"dry_run",
|
|
66
67
|
)
|
|
67
68
|
|
|
@@ -161,6 +162,24 @@ class Config:
|
|
|
161
162
|
maintain_label: str = "alissa:maintain"
|
|
162
163
|
maintain_enabled: bool = True
|
|
163
164
|
|
|
165
|
+
# The third staleness signal (session liveness, read from the reaper's own
|
|
166
|
+
# tmux listing) and the auto-resume it unlocks: a stale attempt whose
|
|
167
|
+
# ledger session is provably gone is respawned with RESUME_DIRECTIVE /
|
|
168
|
+
# FIX_RESUME_DIRECTIVE instead of parking the lane behind an operator
|
|
169
|
+
# ping. Provably gone is necessary, not sufficient: a healthy handoff ends
|
|
170
|
+
# in `alissa tmux kill` too, so on the ISSUE edge the resume additionally
|
|
171
|
+
# requires the linked PR to show the handoff never happened AND to be this
|
|
172
|
+
# issue's own work -- exactly one linked open PR, still a DRAFT, no
|
|
173
|
+
# reviewer requested, authored by the token identity, daemon-shaped head
|
|
174
|
+
# ref, and a body that CLOSES this issue (a cross-reference is raised by
|
|
175
|
+
# any mention, so a sibling task's PR would otherwise qualify). Default ON
|
|
176
|
+
# -- it closes the loop's one
|
|
177
|
+
# deliberate self-healing gap, and every side effect still honors dry_run.
|
|
178
|
+
# false restores the legacy two-signal matrix WHOLESALE on both edges (the
|
|
179
|
+
# floored defer, no liveness lookup, no resume): the escape hatch for an
|
|
180
|
+
# operator who wants a dead-with-PR lane to wait for a human.
|
|
181
|
+
resume_enabled: bool = True
|
|
182
|
+
|
|
164
183
|
dry_run: bool = False
|
|
165
184
|
|
|
166
185
|
# Derived at build time: `repos` casefolded for matching. GitHub names are
|
|
@@ -316,6 +335,9 @@ class Config:
|
|
|
316
335
|
maintain_enabled=bool(
|
|
317
336
|
raw.get("maintain_enabled", cls.maintain_enabled)
|
|
318
337
|
),
|
|
338
|
+
resume_enabled=bool(
|
|
339
|
+
raw.get("resume_enabled", cls.resume_enabled)
|
|
340
|
+
),
|
|
319
341
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
320
342
|
)
|
|
321
343
|
|
|
@@ -107,6 +107,13 @@ class PullRequest:
|
|
|
107
107
|
head_sha: str
|
|
108
108
|
draft: bool
|
|
109
109
|
url: str
|
|
110
|
+
# The PR description. Carried for PROVENANCE: a cross-reference proves
|
|
111
|
+
# only that a PR mentioned an issue, while a closing keyword ("Closes
|
|
112
|
+
# #<n>") is the author's claim that this PR is the issue's work -- the
|
|
113
|
+
# one the developer directives mandate, and the only thing that tells
|
|
114
|
+
# THIS issue's PR from a sibling task's PR that merely said "related to
|
|
115
|
+
# #<n>". Empty when the PR has no body.
|
|
116
|
+
body: str
|
|
110
117
|
# Logins/slugs whose review is CURRENTLY requested (users and teams
|
|
111
118
|
# merged): non-empty means the ball is in a reviewer's court, so the
|
|
112
119
|
# review-response edge is closed. GitHub clears an entry when that
|
|
@@ -147,6 +154,20 @@ class Review:
|
|
|
147
154
|
return bool(self.body.strip())
|
|
148
155
|
|
|
149
156
|
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class IssueComment:
|
|
159
|
+
"""An issue comment on an issue OR a PR (a PR is an issue on the issues
|
|
160
|
+
endpoints). Distinct from Review: issue comments live on the
|
|
161
|
+
`/issues/{n}/comments` endpoints and never create review records, so
|
|
162
|
+
posting or PATCHing one can never disturb the fix edge's round counting
|
|
163
|
+
or its author-activity liveness probe -- the non-interference the
|
|
164
|
+
mechanical activity comment leans on."""
|
|
165
|
+
|
|
166
|
+
id: int
|
|
167
|
+
author: str
|
|
168
|
+
body: str
|
|
169
|
+
|
|
170
|
+
|
|
150
171
|
class RateLimited(RuntimeError):
|
|
151
172
|
pass
|
|
152
173
|
|
|
@@ -522,9 +543,11 @@ class GitHub:
|
|
|
522
543
|
return newest
|
|
523
544
|
|
|
524
545
|
def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
|
|
525
|
-
"""The evaluate_pr() re-fetch: draft state, head, author, and
|
|
526
|
-
pending review requests -- the consistency guard over search
|
|
527
|
-
staleness, like issue() is for the issue edge.
|
|
546
|
+
"""The evaluate_pr() re-fetch: draft state, head, author, body, and
|
|
547
|
+
the pending review requests -- the consistency guard over search
|
|
548
|
+
staleness, like issue() is for the issue edge. (The body is what the
|
|
549
|
+
issue edge's resume probe reads for provenance; it rides along on the
|
|
550
|
+
same GET rather than costing a call of its own.)"""
|
|
528
551
|
data = self._api(f"repos/{owner}/{repo}/pulls/{number}") or {}
|
|
529
552
|
head = data.get("head") or {}
|
|
530
553
|
# Users carry `login`, teams `slug`; both hold the edge closed -- a
|
|
@@ -550,6 +573,8 @@ class GitHub:
|
|
|
550
573
|
head_sha=head.get("sha") or "",
|
|
551
574
|
draft=bool(data.get("draft")),
|
|
552
575
|
url=data.get("html_url", ""),
|
|
576
|
+
# `or ""`: GitHub sends null for an empty description.
|
|
577
|
+
body=data.get("body") or "",
|
|
553
578
|
requested_reviewers=requested,
|
|
554
579
|
)
|
|
555
580
|
|
|
@@ -605,7 +630,14 @@ class GitHub:
|
|
|
605
630
|
),
|
|
606
631
|
)
|
|
607
632
|
|
|
608
|
-
def linked_open_prs(
|
|
633
|
+
def linked_open_prs(
|
|
634
|
+
self,
|
|
635
|
+
owner: str,
|
|
636
|
+
repo: str,
|
|
637
|
+
number: int,
|
|
638
|
+
*,
|
|
639
|
+
exhaustive: bool = False,
|
|
640
|
+
) -> list[str]:
|
|
609
641
|
"""URLs of OPEN same-repo pull requests linked to the issue -- the
|
|
610
642
|
external liveness signal behind the stale-retry decision.
|
|
611
643
|
|
|
@@ -633,10 +665,9 @@ class GitHub:
|
|
|
633
665
|
comments, labeled/assigned/mentioned events, bot noise -- ahead of
|
|
634
666
|
the cross-reference, so a long pre-label discussion can push it past
|
|
635
667
|
the first page, and a first-page-only probe would mis-read live work
|
|
636
|
-
as dead (and respawn over it). The walk is bounded:
|
|
637
|
-
as a page has yielded any open same-repo PR (
|
|
638
|
-
|
|
639
|
-
comment's URL list), it stops at the
|
|
668
|
+
as dead (and respawn over it). The walk is bounded: by default it
|
|
669
|
+
stops as soon as a page has yielded any open same-repo PR (see
|
|
670
|
+
`exhaustive` below), it stops at the
|
|
640
671
|
first short page (the timeline is exhausted) and hard-caps at
|
|
641
672
|
TIMELINE_MAX_PAGES pages (TIMELINE_MAX_PAGES * TIMELINE_PAGE_SIZE
|
|
642
673
|
events) -- a cross-reference past that ceiling is invisible to the
|
|
@@ -646,6 +677,21 @@ class GitHub:
|
|
|
646
677
|
explicit `page=` loop is the safe shape for this client. Malformed
|
|
647
678
|
events are skipped, never fatal -- one odd timeline entry must not
|
|
648
679
|
wedge the staleness decision.
|
|
680
|
+
|
|
681
|
+
`exhaustive` picks WHICH question the walk answers. Default False is
|
|
682
|
+
the boolean one -- is ANY open same-repo PR linked? -- and stops at
|
|
683
|
+
the first page that yields a URL, because for that question later
|
|
684
|
+
pages can only lengthen a log message. True keeps walking to the
|
|
685
|
+
short page (or the TIMELINE_MAX_PAGES ceiling) and returns the
|
|
686
|
+
COMPLETE set within it: the issue edge's resume probe does not ask
|
|
687
|
+
whether a PR is linked but WHICH one, and it deliberately refuses to
|
|
688
|
+
act on more than one candidate -- against a truncated list that
|
|
689
|
+
refusal is unreachable, because "exactly one survivor" can just mean
|
|
690
|
+
the second was on the next page, and the daemon would push to the one
|
|
691
|
+
it happened to page in. The ceiling applies to both modes: a
|
|
692
|
+
cross-reference past TIMELINE_MAX_PAGES * TIMELINE_PAGE_SIZE events
|
|
693
|
+
is invisible either way, so `exhaustive` means complete UP TO the
|
|
694
|
+
documented ceiling, not unbounded.
|
|
649
695
|
"""
|
|
650
696
|
same_repo_pull = f"https://github.com/{owner}/{repo}/pull/"
|
|
651
697
|
urls: list[str] = []
|
|
@@ -682,11 +728,13 @@ class GitHub:
|
|
|
682
728
|
if not url.startswith(same_repo_pull):
|
|
683
729
|
continue # a mention from another repo says nothing here
|
|
684
730
|
urls.append(url)
|
|
685
|
-
if urls:
|
|
686
|
-
# The caller's question is boolean -- is ANY open
|
|
687
|
-
# PR linked? -- so once a page yields one, later
|
|
688
|
-
# only add more URLs to a message, never change the
|
|
689
|
-
# decision: stop paying for them.
|
|
731
|
+
if urls and not exhaustive:
|
|
732
|
+
# The DEFAULT caller's question is boolean -- is ANY open
|
|
733
|
+
# same-repo PR linked? -- so once a page yields one, later
|
|
734
|
+
# pages can only add more URLs to a message, never change the
|
|
735
|
+
# decision: stop paying for them. An `exhaustive` caller is
|
|
736
|
+
# asking which PR, not whether, and for THAT question the
|
|
737
|
+
# later pages are the whole point -- so it keeps walking.
|
|
690
738
|
break
|
|
691
739
|
if len(events) < TIMELINE_PAGE_SIZE:
|
|
692
740
|
break # short page: the timeline is exhausted
|
|
@@ -739,3 +787,50 @@ class GitHub:
|
|
|
739
787
|
"-f",
|
|
740
788
|
f"body={body}",
|
|
741
789
|
)
|
|
790
|
+
|
|
791
|
+
def issue_comments(
|
|
792
|
+
self, owner: str, repo: str, number: int
|
|
793
|
+
) -> list[IssueComment]:
|
|
794
|
+
"""Issue comments on an issue or PR (find-or-create reads these to
|
|
795
|
+
locate THE activity comment). per_page=100 is a documented single-page
|
|
796
|
+
window like the searches -- the activity comment is authored by the
|
|
797
|
+
daemon itself, so it lands on the first page unless a hundred other
|
|
798
|
+
comments were filed after it, and even then find-or-create degrades to
|
|
799
|
+
creating a second activity comment, never to touching a foreign one.
|
|
800
|
+
Malformed entries are tolerated (never fatal): one odd comment must not
|
|
801
|
+
wedge a spawn's telemetry."""
|
|
802
|
+
data = (
|
|
803
|
+
self._api(
|
|
804
|
+
"-X",
|
|
805
|
+
"GET",
|
|
806
|
+
f"repos/{owner}/{repo}/issues/{number}/comments",
|
|
807
|
+
"-f",
|
|
808
|
+
"per_page=100",
|
|
809
|
+
)
|
|
810
|
+
or []
|
|
811
|
+
)
|
|
812
|
+
if not isinstance(data, list):
|
|
813
|
+
return []
|
|
814
|
+
return [
|
|
815
|
+
IssueComment(
|
|
816
|
+
id=int(c.get("id") or 0),
|
|
817
|
+
author=(c.get("user") or {}).get("login", ""),
|
|
818
|
+
body=c.get("body") or "",
|
|
819
|
+
)
|
|
820
|
+
for c in data
|
|
821
|
+
if isinstance(c, dict)
|
|
822
|
+
]
|
|
823
|
+
|
|
824
|
+
def update_comment(
|
|
825
|
+
self, owner: str, repo: str, comment_id: int, body: str
|
|
826
|
+
) -> None:
|
|
827
|
+
"""PATCH an existing issue comment's body -- the append half of the
|
|
828
|
+
activity comment's find-or-create. Keyed on the comment id, so it only
|
|
829
|
+
ever rewrites a comment the caller already located and authored."""
|
|
830
|
+
self._api(
|
|
831
|
+
"-X",
|
|
832
|
+
"PATCH",
|
|
833
|
+
f"repos/{owner}/{repo}/issues/comments/{comment_id}",
|
|
834
|
+
"-f",
|
|
835
|
+
f"body={body}",
|
|
836
|
+
)
|