alissa-tools-github-devloop 0.8.2__tar.gz → 0.8.4__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 (32) hide show
  1. {alissa_tools_github_devloop-0.8.2/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.4}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/__main__.py +73 -1
  3. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/config.py +126 -0
  4. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/ghclient.py +89 -26
  5. alissa_tools_github_devloop-0.8.4/src/main/alissa/tools/github/devloop/janitor.py +513 -0
  6. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/loop.py +632 -14
  7. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/state.py +358 -57
  8. alissa_tools_github_devloop-0.8.4/src/main/alissa/tools/github/devloop/version +1 -0
  9. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +9 -13
  10. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  11. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  12. alissa_tools_github_devloop-0.8.2/src/main/alissa/tools/github/devloop/version +0 -1
  13. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/LICENSE +0 -0
  14. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/MANIFEST.in +0 -0
  15. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/NOTICE +0 -0
  16. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/README.md +0 -0
  17. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/requirements.txt +0 -0
  18. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/setup.cfg +0 -0
  19. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/setup.py +0 -0
  20. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  21. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  22. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  23. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/version.py +0 -0
  24. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  25. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  26. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  27. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  28. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  29. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  30. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/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.8.2
3
+ Version: 0.8.4
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -243,6 +243,62 @@ def build_parser() -> argparse.ArgumentParser:
243
243
  "(two overlapping instances can then both spawn on one item)",
244
244
  )
245
245
 
246
+ janitor = over.add_mutually_exclusive_group()
247
+ janitor.add_argument(
248
+ "--janitor",
249
+ dest="janitor_enabled",
250
+ action="store_true",
251
+ default=None,
252
+ help="reclaim per-task workspaces whose anchor PR is terminal, whose "
253
+ "worker session is gone, and whose grace elapsed, and bound the "
254
+ "package-manager caches (the default)",
255
+ )
256
+ janitor.add_argument(
257
+ "--no-janitor",
258
+ dest="janitor_enabled",
259
+ action="store_false",
260
+ help="never reclaim workspaces or trim caches; disk telemetry stops "
261
+ "with it (the whole janitor pass is skipped)",
262
+ )
263
+ over.add_argument(
264
+ "--janitor-grace-minutes",
265
+ type=int,
266
+ metavar="MINUTES",
267
+ help="how long a workspace's anchor must have been terminal before it "
268
+ "may be reclaimed; 0 = use reap_grace_minutes (the default)",
269
+ )
270
+ over.add_argument(
271
+ "--janitor-interval-minutes",
272
+ type=int,
273
+ metavar="MINUTES",
274
+ help="how often the janitor's expensive half (hub walk, tree sizing, "
275
+ "anchor lookups) runs; 0 = every poll (default: 10). The disk "
276
+ "telemetry line still logs every poll",
277
+ )
278
+ over.add_argument(
279
+ "--cache-cap-mb",
280
+ type=int,
281
+ metavar="MiB",
282
+ help="size cap per package-manager cache directory; over it the "
283
+ "oldest entries are removed until under. 0 = no cache bound "
284
+ "(default: 2048)",
285
+ )
286
+ over.add_argument(
287
+ "--cache-path",
288
+ dest="cache_paths",
289
+ action="append",
290
+ metavar="PATH",
291
+ help="a cache directory the cap applies to; repeatable. Replaces the "
292
+ "built-in list (npm/pip/uv/pnpm/yarn/bun in their standard homes)",
293
+ )
294
+ over.add_argument(
295
+ "--disk-warn-percent",
296
+ type=int,
297
+ metavar="PERCENT",
298
+ help="volume usage at which the per-poll disk line escalates to a "
299
+ "warning naming the biggest workspaces; 0 = never warn (default: 80)",
300
+ )
301
+
246
302
  dry = over.add_mutually_exclusive_group()
247
303
  dry.add_argument(
248
304
  "--dry-run",
@@ -287,6 +343,12 @@ def overrides_from(args: argparse.Namespace) -> dict:
287
343
  "orphan_sweep_enabled": args.orphan_sweep_enabled,
288
344
  "orphan_grace_minutes": args.orphan_grace_minutes,
289
345
  "activity_claims_enabled": args.activity_claims_enabled,
346
+ "janitor_enabled": args.janitor_enabled,
347
+ "janitor_grace_minutes": args.janitor_grace_minutes,
348
+ "janitor_interval_minutes": args.janitor_interval_minutes,
349
+ "cache_cap_mb": args.cache_cap_mb,
350
+ "cache_paths": tuple(args.cache_paths) if args.cache_paths else None,
351
+ "disk_warn_percent": args.disk_warn_percent,
290
352
  "dry_run": args.dry_run,
291
353
  }
292
354
 
@@ -321,7 +383,8 @@ def log_effective_config(config: Config, login: str) -> None:
321
383
  "max_sessions=%s; reap_grace=%s min; fix_rounds=%s; maintain=%s "
322
384
  "(label %r); resume=%s; "
323
385
  "rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
324
- "activity_claims=%s",
386
+ "activity_claims=%s; janitor=%s (grace %s min, every %s min, cache "
387
+ "cap %s MiB, warn at %s%%)",
325
388
  config.poll_interval, config.dry_run, config.attempt_cap,
326
389
  config.stale_minutes,
327
390
  config.max_sessions or "unlimited",
@@ -334,8 +397,17 @@ def log_effective_config(config: Config, login: str) -> None:
334
397
  config.orphan_sweep_enabled,
335
398
  config.orphan_grace_seconds // 60,
336
399
  config.activity_claims_enabled,
400
+ config.janitor_enabled,
401
+ config.janitor_grace_seconds // 60,
402
+ config.janitor_interval_minutes or "poll",
403
+ config.cache_cap_mb,
404
+ config.disk_warn_percent,
337
405
  )
338
406
  log.debug("hub_template: %s", config.hub_template)
407
+ log.debug(
408
+ "cache_paths: %s",
409
+ ", ".join(config.cache_paths) or "built-in defaults",
410
+ )
339
411
  log.debug("agent_profile: %s", config.agent_profile)
340
412
  log.debug("developer_login: %s", config.developer_login or f"{login} (auto)")
341
413
  log.debug("state_db: %s", config.state_db)
@@ -69,6 +69,12 @@ CONFIG_KEYS = (
69
69
  "orphan_sweep_enabled",
70
70
  "orphan_grace_minutes",
71
71
  "activity_claims_enabled",
72
+ "janitor_enabled",
73
+ "janitor_grace_minutes",
74
+ "janitor_interval_minutes",
75
+ "cache_cap_mb",
76
+ "cache_paths",
77
+ "disk_warn_percent",
72
78
  "dry_run",
73
79
  )
74
80
 
@@ -260,6 +266,56 @@ class Config:
260
266
  # that would rather not pay the extra comments fetch.
261
267
  activity_claims_enabled: bool = True
262
268
 
269
+ # The workspace janitor (issue #74): the filesystem sibling of the session
270
+ # reaper. Every worker session materializes a per-task workspace under its
271
+ # repo's hub (a `TASK-<n>-<DESC>` worktree plus whatever its lane installs
272
+ # into it) and nothing removed them, so disk grew monotonically with PR
273
+ # throughput -- 3 GB to 32 GB in three days against a 50 GB cap, of which
274
+ # 21.4 GiB was 53 already-merged task worktrees. With this on, a workspace
275
+ # whose anchor PR is terminal, whose worker session is gone, and whose
276
+ # grace has elapsed is reclaimed (worktree removed, branch deleted), and
277
+ # package-manager caches are bounded rather than zeroed. Default ON, on
278
+ # the same grounds as the reaper: it acts only on devloop's own
279
+ # `TASK-<n>-…` directories under its own hubs, never on anything with
280
+ # uncommitted or unpushed work, and every side effect honors dry_run.
281
+ janitor_enabled: bool = True
282
+
283
+ # How long the anchor must have been terminal before its workspace may be
284
+ # reclaimed. 0 means "share the reaper's grace" (reap_grace_minutes,
285
+ # itself falling back to stale_minutes) -- the sentinel every other grace
286
+ # knob here uses, and the documented default: the two sweeps are the same
287
+ # decision about the same lane, so their windows should move together
288
+ # unless an operator deliberately parts them.
289
+ janitor_grace_minutes: int = 0
290
+
291
+ # How often the janitor's expensive half runs: the hub walk, the tree
292
+ # sizing, and the one GitHub call per candidate workspace. Deliberately
293
+ # NOT every poll -- at a 30s interval the walk alone measured ~8s on the
294
+ # real 21 GiB volume, and the anchor lookups would be ~90 API calls a
295
+ # minute for work whose input changes on the timescale of a merge. The
296
+ # cheap half (the volume's own used/free figures) still logs EVERY poll,
297
+ # so the telemetry line never goes quiet. 0 means every poll.
298
+ janitor_interval_minutes: int = 10
299
+
300
+ # The cache bound, in MiB: package-manager caches over this are trimmed
301
+ # oldest-entry-first back under it. 0 disables the trim entirely. A cache
302
+ # is a legitimate speed tradeoff -- the default is generous on purpose
303
+ # (the measured real-world figure was 1.4 MB, four orders of magnitude
304
+ # under this), because a cache that is not the problem should not be
305
+ # cleared to prove a point.
306
+ cache_cap_mb: int = 2048
307
+
308
+ # Which cache directories the bound applies to. Empty means the built-in
309
+ # list (`janitor.DEFAULT_CACHE_PATHS`: npm/pip/uv/pnpm/yarn/bun in their
310
+ # standard homes); entries that do not exist are skipped. `~` expands.
311
+ cache_paths: tuple[str, ...] = ()
312
+
313
+ # Percent of the workspace volume in use at which the per-poll telemetry
314
+ # line escalates from INFO to a WARNING naming the biggest consumers.
315
+ # 80 by default -- far enough ahead of full that a human still has room
316
+ # to act (at the incident's growth rate, ~2 days). 0 never warns.
317
+ disk_warn_percent: int = 80
318
+
263
319
  dry_run: bool = False
264
320
 
265
321
  # Derived at build time: `repos` casefolded for matching. GitHub names are
@@ -311,6 +367,30 @@ class Config:
311
367
  ready-and-request a draft opened seconds ago on a default config."""
312
368
  return (self.orphan_grace_minutes or self.stale_minutes) * 60
313
369
 
370
+ @property
371
+ def janitor_grace_seconds(self) -> int:
372
+ """How long an anchor must have been terminal before its workspace may
373
+ be reclaimed, in seconds. Read this, never the raw field -- the
374
+ `reap_grace_seconds` contract, one rung longer: 0 means "the reaper's
375
+ grace", which is itself 0-means-stale_minutes, so a caller reading the
376
+ field directly would delete a workspace the instant its PR merged on a
377
+ default config."""
378
+ return (self.janitor_grace_minutes * 60) or self.reap_grace_seconds
379
+
380
+ @property
381
+ def janitor_interval_seconds(self) -> int:
382
+ """How often the janitor's expensive half may run, in seconds. 0 means
383
+ every poll, and needs no sentinel: unlike the grace knobs, "every
384
+ pass" is a coherent answer to "how often"."""
385
+ return max(0, self.janitor_interval_minutes) * 60
386
+
387
+ @property
388
+ def cache_cap_bytes(self) -> int:
389
+ """The cache bound in bytes (0 = no bound). MiB in the config because
390
+ that is the unit an operator thinks about a cache in; bytes here
391
+ because that is what the walker measures."""
392
+ return max(0, self.cache_cap_mb) * 1024 * 1024
393
+
314
394
  @property
315
395
  def manifest_path(self) -> Path:
316
396
  return self.workspace_root / "alissa-workspace.yaml"
@@ -446,6 +526,44 @@ class Config:
446
526
  f"got {orphan_grace}"
447
527
  )
448
528
 
529
+ # The janitor's grace, the reaper's sentinel one rung longer (0 = use
530
+ # reap_grace_minutes). Negative is the only invalid value, and it is
531
+ # the dangerous one: it would license reclaiming a workspace the
532
+ # instant its PR merged, racing the close-out work the grace exists
533
+ # for.
534
+ janitor_grace = int(
535
+ raw.get("janitor_grace_minutes", cls.janitor_grace_minutes)
536
+ )
537
+ if janitor_grace < 0:
538
+ raise ValueError(
539
+ f"janitor_grace_minutes must be >= 0 (0 = use "
540
+ f"reap_grace_minutes), got {janitor_grace}"
541
+ )
542
+
543
+ # 0 means "every poll" here (not a fallback sentinel -- see
544
+ # janitor_interval_seconds), so again only negatives are rejected.
545
+ janitor_interval = int(
546
+ raw.get("janitor_interval_minutes", cls.janitor_interval_minutes)
547
+ )
548
+ if janitor_interval < 0:
549
+ raise ValueError(
550
+ f"janitor_interval_minutes must be >= 0 (0 = every poll), "
551
+ f"got {janitor_interval}"
552
+ )
553
+
554
+ cache_cap = int(raw.get("cache_cap_mb", cls.cache_cap_mb))
555
+ if cache_cap < 0:
556
+ raise ValueError(
557
+ f"cache_cap_mb must be >= 0 (0 = no cache bound), got {cache_cap}"
558
+ )
559
+
560
+ warn_percent = int(raw.get("disk_warn_percent", cls.disk_warn_percent))
561
+ if not 0 <= warn_percent <= 100:
562
+ raise ValueError(
563
+ f"disk_warn_percent must be between 0 and 100 (0 = never "
564
+ f"warn), got {warn_percent}"
565
+ )
566
+
449
567
  interval = int(raw.get("poll_interval", cls.poll_interval))
450
568
  if interval < MIN_POLL_INTERVAL:
451
569
  raise ValueError(
@@ -490,6 +608,14 @@ class Config:
490
608
  activity_claims_enabled=bool(
491
609
  raw.get("activity_claims_enabled", cls.activity_claims_enabled)
492
610
  ),
611
+ janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
612
+ janitor_grace_minutes=janitor_grace,
613
+ janitor_interval_minutes=janitor_interval,
614
+ cache_cap_mb=cache_cap,
615
+ cache_paths=_string_list(
616
+ raw.get("cache_paths", cls.cache_paths), "cache_paths"
617
+ ),
618
+ disk_warn_percent=warn_percent,
493
619
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
494
620
  )
495
621
 
@@ -128,6 +128,13 @@ class PullRequest:
128
128
  # other three edges -- none of which flip drafts -- need not supply it;
129
129
  # `mark_ready` refuses an empty one rather than POSTing a nonsense mutation.
130
130
  node_id: str = ""
131
+ # When this PR was closed (merged or not), unix seconds; None while it is
132
+ # open OR when the payload carried no readable stamp. The workspace
133
+ # janitor's grace clock -- a merged PR's task workspace is reclaimable
134
+ # only once the anchor has been terminal for a while, and this is the
135
+ # instant that window runs from. Defaulted for the same reason as node_id:
136
+ # the three spawn edges never ask, and neither do their fakes.
137
+ closed_at: "int | None" = None
131
138
 
132
139
  @property
133
140
  def full_name(self) -> str:
@@ -141,6 +148,49 @@ class PullRequest:
141
148
  return f"{self.owner}/{self.repo}#pr{self.number}"
142
149
 
143
150
 
151
+ def _pull_request_from(
152
+ data: "dict", owner: str, repo: str, *, number: "int | None" = None
153
+ ) -> PullRequest:
154
+ """One REST pull-request payload as a PullRequest.
155
+
156
+ Shared by the single-PR GET and the branch listing, which return the SAME
157
+ object shape -- so the two callers cannot drift on what `draft`, the head,
158
+ or the pending review requests mean. `number` is passed explicitly by the
159
+ single GET (it knows which PR it asked for even if the payload is empty);
160
+ the listing reads it from each entry.
161
+ """
162
+ head = data.get("head") or {}
163
+ # Users carry `login`, teams `slug`; both hold the review-response edge
164
+ # closed -- a pending TEAM re-request still means the ball is in a
165
+ # reviewer's court, so it must not read as "awaiting the author".
166
+ requested = tuple(
167
+ name
168
+ for entry in (
169
+ list(data.get("requested_reviewers") or [])
170
+ + list(data.get("requested_teams") or [])
171
+ )
172
+ if isinstance(entry, dict)
173
+ and (name := (entry.get("login") or entry.get("slug") or ""))
174
+ )
175
+ return PullRequest(
176
+ owner=owner,
177
+ repo=repo,
178
+ number=int(number if number is not None else data.get("number") or 0),
179
+ state=data.get("state", ""),
180
+ title=data.get("title", ""),
181
+ author=(data.get("user") or {}).get("login", ""),
182
+ head_ref=head.get("ref") or "",
183
+ head_sha=head.get("sha") or "",
184
+ draft=bool(data.get("draft")),
185
+ url=data.get("html_url", ""),
186
+ # `or ""`: GitHub sends null for an empty description.
187
+ body=data.get("body") or "",
188
+ requested_reviewers=requested,
189
+ node_id=data.get("node_id") or "",
190
+ closed_at=parse_github_timestamp(data.get("closed_at")),
191
+ )
192
+
193
+
144
194
  @dataclass(frozen=True)
145
195
  class Review:
146
196
  author: str
@@ -620,34 +670,47 @@ class GitHub:
620
670
  issue edge's resume probe reads for provenance; it rides along on the
621
671
  same GET rather than costing a call of its own.)"""
622
672
  data = self._api(f"repos/{owner}/{repo}/pulls/{number}") or {}
623
- head = data.get("head") or {}
624
- # Users carry `login`, teams `slug`; both hold the edge closed -- a
625
- # pending TEAM re-request still means the ball is in a reviewer's
626
- # court, so it must not read as "awaiting the author".
627
- requested = tuple(
628
- name
629
- for entry in (
630
- list(data.get("requested_reviewers") or [])
631
- + list(data.get("requested_teams") or [])
673
+ return _pull_request_from(data, owner, repo, number=number)
674
+
675
+ def branch_pulls(
676
+ self, owner: str, repo: str, head_ref: str
677
+ ) -> "tuple[PullRequest, ...]":
678
+ """Every pull request ever opened FROM `head_ref`, in any state --
679
+ the workspace janitor's anchor lookup.
680
+
681
+ A task workspace is a branch checkout, and the only thing that can say
682
+ whether that branch's work is over is the PR it was opened as. `state`
683
+ is deliberately `all`: an OPEN PR is precisely the case that must
684
+ spare the workspace, so a query that could not see it would be
685
+ unsafe, and a branch with no PR at all comes back empty (spared too --
686
+ unpublished work has no terminal anchor).
687
+
688
+ `head` is qualified with the owner (`owner:branch`), the form GitHub
689
+ requires to disambiguate forks. Malformed entries are skipped rather
690
+ than fatal, like `reviews()`; a single page (per_page=100) is a
691
+ documented window, and a branch with more than 100 pull requests is
692
+ far outside anything this daemon creates.
693
+ """
694
+ data = (
695
+ self._api(
696
+ "-X",
697
+ "GET",
698
+ f"repos/{owner}/{repo}/pulls",
699
+ "-f",
700
+ f"head={owner}:{head_ref}",
701
+ "-f",
702
+ "state=all",
703
+ "-f",
704
+ "per_page=100",
632
705
  )
633
- if isinstance(entry, dict)
634
- and (name := (entry.get("login") or entry.get("slug") or ""))
706
+ or []
635
707
  )
636
- return PullRequest(
637
- owner=owner,
638
- repo=repo,
639
- number=number,
640
- state=data.get("state", ""),
641
- title=data.get("title", ""),
642
- author=(data.get("user") or {}).get("login", ""),
643
- head_ref=head.get("ref") or "",
644
- head_sha=head.get("sha") or "",
645
- draft=bool(data.get("draft")),
646
- url=data.get("html_url", ""),
647
- # `or ""`: GitHub sends null for an empty description.
648
- body=data.get("body") or "",
649
- requested_reviewers=requested,
650
- node_id=data.get("node_id") or "",
708
+ if not isinstance(data, list):
709
+ return ()
710
+ return tuple(
711
+ _pull_request_from(entry, owner, repo)
712
+ for entry in data
713
+ if isinstance(entry, dict) and entry.get("number") is not None
651
714
  )
652
715
 
653
716
  def reviews(self, owner: str, repo: str, number: int) -> list[Review]: