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.
Files changed (29) hide show
  1. {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
  2. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/__main__.py +21 -1
  3. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/config.py +22 -0
  4. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/ghclient.py +108 -13
  5. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/loop.py +1068 -116
  6. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/state.py +90 -0
  7. alissa_tools_github_devloop-0.6.0/src/main/alissa/tools/github/devloop/version +1 -0
  8. {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
  9. {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
  10. alissa_tools_github_devloop-0.5.0/src/main/alissa/tools/github/devloop/version +0 -1
  11. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/MANIFEST.in +0 -0
  12. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/README.md +0 -0
  13. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/requirements.txt +0 -0
  14. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/setup.cfg +0 -0
  15. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/setup.py +0 -0
  16. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  17. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  18. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  19. {alissa_tools_github_devloop-0.5.0 → alissa_tools_github_devloop-0.6.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
  20. {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
  21. {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
  22. {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
  23. {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
  24. {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
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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 the
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(self, owner: str, repo: str, number: int) -> list[str]:
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: it stops as soon
637
- as a page has yielded any open same-repo PR (every caller's question
638
- is effectively boolean -- further pages could only lengthen a
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 same-repo
687
- # PR linked? -- so once a page yields one, later pages can
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
+ )