alissa-tools-github-devloop 0.1.0__tar.gz → 0.3.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 (24) hide show
  1. {alissa_tools_github_devloop-0.1.0/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.3.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/__main__.py +22 -2
  3. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/alissa.py +33 -4
  4. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/config.py +37 -3
  5. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/ghclient.py +198 -1
  6. alissa_tools_github_devloop-0.3.0/src/main/alissa/tools/github/devloop/loop.py +1413 -0
  7. alissa_tools_github_devloop-0.3.0/src/main/alissa/tools/github/devloop/state.py +206 -0
  8. alissa_tools_github_devloop-0.3.0/src/main/alissa/tools/github/devloop/version +1 -0
  9. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/loop.py +0 -620
  11. alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/state.py +0 -113
  12. alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/version +0 -1
  13. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/MANIFEST.in +0 -0
  14. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/README.md +0 -0
  15. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/requirements.txt +0 -0
  16. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/setup.cfg +0 -0
  17. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/setup.py +0 -0
  18. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  19. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  20. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
  21. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  22. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  23. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  24. {alissa_tools_github_devloop-0.1.0 → alissa_tools_github_devloop-0.3.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.1.0
3
+ Version: 0.3.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -98,6 +98,23 @@ def build_parser() -> argparse.ArgumentParser:
98
98
  )
99
99
  over.add_argument("--on-missing-hub", choices=[HUB_SKIP, HUB_ADD])
100
100
 
101
+ fix = over.add_mutually_exclusive_group()
102
+ fix.add_argument(
103
+ "--fix-rounds",
104
+ dest="fix_rounds_enabled",
105
+ action="store_true",
106
+ default=None,
107
+ help="poll open PRs authored by the token for a request_changes "
108
+ "review awaiting the author, and spawn fix sessions (the default)",
109
+ )
110
+ fix.add_argument(
111
+ "--no-fix-rounds",
112
+ dest="fix_rounds_enabled",
113
+ action="store_false",
114
+ help="issues-only polling: never evaluate PRs or spawn fix sessions, "
115
+ "even if the config enables it",
116
+ )
117
+
101
118
  dry = over.add_mutually_exclusive_group()
102
119
  dry.add_argument(
103
120
  "--dry-run",
@@ -131,6 +148,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
131
148
  "stale_minutes": args.stale_minutes,
132
149
  "on_missing_origin_task": args.on_missing_origin_task,
133
150
  "on_missing_hub": args.on_missing_hub,
151
+ "fix_rounds_enabled": args.fix_rounds_enabled,
134
152
  "dry_run": args.dry_run,
135
153
  }
136
154
 
@@ -161,8 +179,10 @@ def log_effective_config(config: Config, login: str) -> None:
161
179
  )
162
180
  log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
163
181
  log.info(
164
- "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min",
165
- config.poll_interval, config.dry_run, config.attempt_cap, config.stale_minutes,
182
+ "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
183
+ "fix_rounds=%s",
184
+ config.poll_interval, config.dry_run, config.attempt_cap,
185
+ config.stale_minutes, config.fix_rounds_enabled,
166
186
  )
167
187
  log.debug("hub_template: %s", config.hub_template)
168
188
  log.debug("agent_profile: %s", config.agent_profile)
@@ -1,10 +1,12 @@
1
- """Alissa CLI access: enqueue the fresh developer session and probe the
2
- worker. The developer's Alissa task is created by the session itself (as a
3
- downstream of the origin task), so unlike reviewloop there is no task lookup
4
- here -- the daemon's only Alissa surfaces are the tmux queue and the worker."""
1
+ """Alissa CLI access: enqueue the fresh developer session, probe the worker,
2
+ and list/kill managed tmux sessions for the reaper sweep. The developer's
3
+ Alissa task is created by the session itself (as a downstream of the origin
4
+ task), so unlike reviewloop there is no task lookup here -- the daemon's only
5
+ Alissa surfaces are the tmux queue, the worker, and the tmux session list."""
5
6
 
6
7
  from __future__ import annotations
7
8
 
9
+ import json
8
10
  import logging
9
11
  from pathlib import Path
10
12
 
@@ -66,6 +68,33 @@ class Alissa:
66
68
  # Cloning a repo can be slow; the poll loop tolerates a long pass.
67
69
  run(argv, timeout=600, cwd=workspace_root)
68
70
 
71
+ def list_sessions(self) -> "list[dict]":
72
+ """Managed tmux sessions as `alissa tmux ls --json` reports them
73
+ (dicts carrying at least `name` and `status`). Raises CommandError
74
+ when the CLI cannot answer at all (the caller skips that sweep);
75
+ unparseable or non-list JSON degrades to an empty list -- a broken
76
+ listing must read as "nothing to reap", never crash the poll."""
77
+ out = run(["alissa", "tmux", "ls", "--json"], timeout=60)
78
+ if not out.strip():
79
+ return []
80
+ try:
81
+ data = json.loads(out)
82
+ except json.JSONDecodeError:
83
+ log.warning(
84
+ "`alissa tmux ls --json` returned unparseable output -- "
85
+ "treating as no sessions this sweep"
86
+ )
87
+ return []
88
+ if not isinstance(data, list):
89
+ return []
90
+ return [entry for entry in data if isinstance(entry, dict)]
91
+
92
+ def kill_session(self, name: str) -> None:
93
+ """Kill ONE managed session by name -- never the tmux server. Raises
94
+ CommandError on failure (including a CLI old enough to lack
95
+ `tmux kill`); the reaper treats kills as best-effort."""
96
+ run(["alissa", "tmux", "kill", name], timeout=60)
97
+
69
98
  def worker_running(self) -> bool:
70
99
  """The queue only drains while `alissa worker` reconciles it."""
71
100
  try:
@@ -58,11 +58,18 @@ CONFIG_KEYS = (
58
58
  "stale_minutes",
59
59
  "on_missing_origin_task",
60
60
  "on_missing_hub",
61
+ "fix_rounds_enabled",
61
62
  "dry_run",
62
63
  )
63
64
 
64
65
  MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
65
66
 
67
+ # The only placeholders hub_for supplies. Probed at load time: nothing else
68
+ # calls hub_for until the daemon loop is already running, so a typo'd
69
+ # placeholder would otherwise outlive Config.build AND preflight and surface
70
+ # as a raw KeyError mid-poll instead of a config error at startup.
71
+ HUB_TEMPLATE_FIELDS = ("root", "owner", "repo")
72
+
66
73
 
67
74
  def default_state_path(workspace_root: Path) -> Path:
68
75
  return Path(workspace_root) / ".devloop" / "state.db"
@@ -126,6 +133,13 @@ class Config:
126
133
 
127
134
  on_missing_origin_task: str = ON_MISSING_WARN
128
135
  on_missing_hub: str = HUB_SKIP
136
+
137
+ # The review-response edge (poll open PRs authored by the token for a
138
+ # request_changes verdict awaiting the author, and spawn fix sessions).
139
+ # Default ON: the edge is the daemon's second half, and every side effect
140
+ # still honors dry_run. Turning it off reverts to issues-only polling.
141
+ fix_rounds_enabled: bool = True
142
+
129
143
  dry_run: bool = False
130
144
 
131
145
  # Derived at build time: `repos` casefolded for matching. GitHub names are
@@ -209,6 +223,23 @@ class Config:
209
223
  f"on_missing_hub must be one of {sorted(_HUB_MODES)}, got {hub_mode!r}"
210
224
  )
211
225
 
226
+ template = raw.get("hub_template", cls.hub_template)
227
+ valid = ", ".join("{" + name + "}" for name in HUB_TEMPLATE_FIELDS)
228
+ try:
229
+ template.format(**{name: name for name in HUB_TEMPLATE_FIELDS})
230
+ except KeyError as exc:
231
+ raise ValueError(
232
+ f"hub_template has an unknown placeholder {{{exc.args[0]}}}: "
233
+ f"{template!r}. Valid placeholders: {valid}"
234
+ ) from exc
235
+ except (IndexError, ValueError) as exc:
236
+ # Positional fields ({}, {0}) or unbalanced braces — str.format
237
+ # reports these without naming the template, so name it here.
238
+ raise ValueError(
239
+ f"hub_template is not a valid template: {template!r} ({exc}). "
240
+ f"Use named placeholders: {valid}"
241
+ ) from exc
242
+
212
243
  repos = _string_list(raw.get("repos", cls.repos), "repos")
213
244
  if hub_mode == HUB_ADD and not repos:
214
245
  # Anyone who can label an issue could otherwise cause an arbitrary
@@ -233,21 +264,24 @@ class Config:
233
264
  f"poll_interval must be >= {MIN_POLL_INTERVAL} seconds, got {interval}"
234
265
  )
235
266
 
236
- state_path = raw.get("state_path")
267
+ state_path = raw.get("state_path", cls.state_path)
237
268
  return cls(
238
269
  workspace_root=Path(workspace_root),
239
- hub_template=raw.get("hub_template", cls.hub_template),
270
+ hub_template=template,
240
271
  poll_interval=interval,
241
272
  label=raw.get("label", cls.label),
242
273
  repos=repos,
243
274
  agent_profile=raw.get("agent_profile", cls.agent_profile),
244
- developer_login=raw.get("developer_login"),
275
+ developer_login=raw.get("developer_login", cls.developer_login),
245
276
  reviewers=_string_list(raw.get("reviewers", cls.reviewers), "reviewers"),
246
277
  state_path=Path(state_path).expanduser() if state_path else None,
247
278
  attempt_cap=cap,
248
279
  stale_minutes=stale,
249
280
  on_missing_origin_task=mode,
250
281
  on_missing_hub=hub_mode,
282
+ fix_rounds_enabled=bool(
283
+ raw.get("fix_rounds_enabled", cls.fix_rounds_enabled)
284
+ ),
251
285
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
252
286
  )
253
287
 
@@ -36,6 +36,12 @@ TASK_REF_RE = re.compile(r"TASK-(\d+)")
36
36
  TIMELINE_PAGE_SIZE = 100
37
37
  TIMELINE_MAX_PAGES = 5
38
38
 
39
+ # Review states that count as "a review was submitted". PENDING is a draft
40
+ # review that was never submitted, so it closes nothing. Same set as
41
+ # reviewloop's SUBMITTED_STATES -- the two daemons must count rounds
42
+ # identically or they disagree about which round a PR is in.
43
+ SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
44
+
39
45
 
40
46
  def parse_task_ref(body: str) -> int | None:
41
47
  """Origin Alissa task number from an issue body, or None if absent.
@@ -74,6 +80,58 @@ class Issue:
74
80
  return f"{self.owner}/{self.repo}#{self.number}"
75
81
 
76
82
 
83
+ @dataclass(frozen=True)
84
+ class PullRequest:
85
+ owner: str
86
+ repo: str
87
+ number: int
88
+ state: str
89
+ title: str
90
+ author: str
91
+ head_ref: str
92
+ head_sha: str
93
+ draft: bool
94
+ url: str
95
+ # Logins/slugs whose review is CURRENTLY requested (users and teams
96
+ # merged): non-empty means the ball is in a reviewer's court, so the
97
+ # review-response edge is closed. GitHub clears an entry when that
98
+ # reviewer submits and re-adds it on re-request.
99
+ requested_reviewers: tuple[str, ...]
100
+
101
+ @property
102
+ def full_name(self) -> str:
103
+ return f"{self.owner}/{self.repo}"
104
+
105
+ @property
106
+ def pr_slug(self) -> str:
107
+ # `#pr16`, not `#16`: PR and issue numbers share GitHub's namespace,
108
+ # and poll_once logs both edges into one Decision list -- the slug is
109
+ # what tells `acme/widgets#7` (issue) from `acme/widgets#pr7` (PR).
110
+ return f"{self.owner}/{self.repo}#pr{self.number}"
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Review:
115
+ author: str
116
+ state: str
117
+ commit_id: str
118
+ submitted_at: str
119
+ url: str
120
+ body: str = ""
121
+
122
+ @property
123
+ def is_substantive(self) -> bool:
124
+ """A real review round, not a side effect of an inline comment.
125
+
126
+ Posting a standalone inline comment on a PR creates its own review
127
+ record with an empty body, so review records outnumber rounds. The
128
+ round-closing review always carries the verdict write-up in its body.
129
+ Same rule as reviewloop's Review.is_substantive -- both daemons (and
130
+ alissa-pr-review's round counter) must agree on what closes a round.
131
+ """
132
+ return bool(self.body.strip())
133
+
134
+
77
135
  class RateLimited(RuntimeError):
78
136
  pass
79
137
 
@@ -258,6 +316,136 @@ class GitHub:
258
316
  out.append((parts[-2], parts[-1], number, assignees))
259
317
  return out
260
318
 
319
+ def search_prs(self, repos: tuple[str, ...] = ()) -> list[tuple[str, str, int]]:
320
+ """Open non-draft PRs AUTHORED by the token identity across the
321
+ watched repos, as `(owner, repo, number)` hits -- the candidate set of
322
+ the review-response edge.
323
+
324
+ `author:@me` resolves server-side from the gh token (the same token
325
+ verify_identity() pins at startup), so the daemon only ever considers
326
+ its own PRs -- the developer identity is the author of every PR its
327
+ spawned sessions push. `draft:false` keeps the daemon's own fresh
328
+ draft PRs (the pre-review norm) from costing a pull_request() re-fetch
329
+ every poll; evaluate_pr() re-checks `draft` anyway, since a PR can
330
+ flip back to draft between search and fetch (same belt-and-braces as
331
+ reviewloop's review_requests + CR1 re-check).
332
+
333
+ Fails closed on an empty allowlist for the same reason search_issues
334
+ does: an unscoped query would return every PR the token ever authored
335
+ anywhere on GitHub, and the caller spawns code-writing sessions
336
+ against what comes back. Empty/whitespace entries fail closed too.
337
+ """
338
+ repos = tuple(r.strip() for r in repos if r and r.strip())
339
+ if not repos:
340
+ log.warning(
341
+ "search_prs: repo allowlist is empty -- nothing is watched. "
342
+ "An unscoped search would match every PR this token authored "
343
+ "anywhere on GitHub, so this fails closed; configure `repos` "
344
+ "to watch something."
345
+ )
346
+ return []
347
+
348
+ query = "is:pr is:open draft:false author:@me"
349
+ for full_name in repos:
350
+ query += f" repo:{full_name}"
351
+
352
+ payload = self._api(
353
+ "-X",
354
+ "GET",
355
+ "search/issues",
356
+ "-f",
357
+ f"q={query}",
358
+ "-f",
359
+ f"per_page={self.SEARCH_PER_PAGE}",
360
+ )
361
+ items = (payload or {}).get("items", [])
362
+ if len(items) == self.SEARCH_PER_PAGE:
363
+ log.warning(
364
+ "search_prs: got exactly %d items -- results are likely "
365
+ "truncated. Pagination is deliberately not implemented "
366
+ "(same hard ceiling as search_issues); the window only "
367
+ "drains as PRs close or merge.",
368
+ self.SEARCH_PER_PAGE,
369
+ )
370
+
371
+ out: list[tuple[str, str, int]] = []
372
+ for item in items:
373
+ # repository_url looks like https://api.github.com/repos/<owner>/<repo>
374
+ parts = item.get("repository_url", "").rstrip("/").split("/")
375
+ if len(parts) < 2:
376
+ log.warning("could not parse repo from %s", item.get("repository_url"))
377
+ continue
378
+ try:
379
+ number = int(item["number"])
380
+ except (KeyError, TypeError, ValueError):
381
+ log.warning("could not parse PR number from %r", item.get("number"))
382
+ continue
383
+ out.append((parts[-2], parts[-1], number))
384
+ return out
385
+
386
+ def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
387
+ """The evaluate_pr() re-fetch: draft state, head, author, and the
388
+ pending review requests -- the consistency guard over search
389
+ staleness, like issue() is for the issue edge."""
390
+ data = self._api(f"repos/{owner}/{repo}/pulls/{number}") or {}
391
+ head = data.get("head") or {}
392
+ # Users carry `login`, teams `slug`; both hold the edge closed -- a
393
+ # pending TEAM re-request still means the ball is in a reviewer's
394
+ # court, so it must not read as "awaiting the author".
395
+ requested = tuple(
396
+ name
397
+ for entry in (
398
+ list(data.get("requested_reviewers") or [])
399
+ + list(data.get("requested_teams") or [])
400
+ )
401
+ if isinstance(entry, dict)
402
+ and (name := (entry.get("login") or entry.get("slug") or ""))
403
+ )
404
+ return PullRequest(
405
+ owner=owner,
406
+ repo=repo,
407
+ number=number,
408
+ state=data.get("state", ""),
409
+ title=data.get("title", ""),
410
+ author=(data.get("user") or {}).get("login", ""),
411
+ head_ref=head.get("ref") or "",
412
+ head_sha=head.get("sha") or "",
413
+ draft=bool(data.get("draft")),
414
+ url=data.get("html_url", ""),
415
+ requested_reviewers=requested,
416
+ )
417
+
418
+ def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
419
+ """All review records on a PR, tolerant of malformed entries. The
420
+ substantive/reviewer filtering happens in the loop (it needs config);
421
+ per_page=100 is a documented single-page window like the searches --
422
+ a PR with more than 100 review records is far past the round cap.
423
+ """
424
+ data = (
425
+ self._api(
426
+ "-X",
427
+ "GET",
428
+ f"repos/{owner}/{repo}/pulls/{number}/reviews",
429
+ "-f",
430
+ "per_page=100",
431
+ )
432
+ or []
433
+ )
434
+ if not isinstance(data, list):
435
+ return []
436
+ return [
437
+ Review(
438
+ author=(r.get("user") or {}).get("login", ""),
439
+ state=r.get("state", ""),
440
+ commit_id=r.get("commit_id") or "",
441
+ submitted_at=r.get("submitted_at") or "",
442
+ url=r.get("html_url", ""),
443
+ body=r.get("body") or "",
444
+ )
445
+ for r in data
446
+ if isinstance(r, dict)
447
+ ]
448
+
261
449
  def issue(self, owner: str, repo: str, number: int) -> Issue:
262
450
  # `or {}`: run_json returns None on empty stdout, and the siblings
263
451
  # (search_issues, assign_self) already guard the same way.
@@ -307,7 +495,10 @@ class GitHub:
307
495
  comments, labeled/assigned/mentioned events, bot noise -- ahead of
308
496
  the cross-reference, so a long pre-label discussion can push it past
309
497
  the first page, and a first-page-only probe would mis-read live work
310
- as dead (and respawn over it). The walk is bounded: it stops at the
498
+ as dead (and respawn over it). The walk is bounded: it stops as soon
499
+ as a page has yielded any open same-repo PR (every caller's question
500
+ is effectively boolean -- further pages could only lengthen a
501
+ comment's URL list), it stops at the
311
502
  first short page (the timeline is exhausted) and hard-caps at
312
503
  TIMELINE_MAX_PAGES pages (TIMELINE_MAX_PAGES * TIMELINE_PAGE_SIZE
313
504
  events) -- a cross-reference past that ceiling is invisible to the
@@ -353,6 +544,12 @@ class GitHub:
353
544
  if not url.startswith(same_repo_pull):
354
545
  continue # a mention from another repo says nothing here
355
546
  urls.append(url)
547
+ if urls:
548
+ # The caller's question is boolean -- is ANY open same-repo
549
+ # PR linked? -- so once a page yields one, later pages can
550
+ # only add more URLs to a message, never change the
551
+ # decision: stop paying for them.
552
+ break
356
553
  if len(events) < TIMELINE_PAGE_SIZE:
357
554
  break # short page: the timeline is exhausted
358
555
  # One PR can cross-reference the issue more than once (body edit,