@codebend3r/che 1.1.0 โ†’ 1.2.1

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 (54) hide show
  1. package/README.md +58 -26
  2. package/bin/art.py +22 -20
  3. package/bin/branchsync.py +324 -0
  4. package/bin/che.py +208 -587
  5. package/bin/commands.py +219 -231
  6. package/bin/doctor.py +220 -0
  7. package/bin/drives/eject-all-drives.py +21 -38
  8. package/bin/drives/mount-all-drives.py +18 -62
  9. package/bin/drives/ping-nas.py +77 -105
  10. package/bin/files/compress-folders.py +7 -11
  11. package/bin/files/delete-by-ext.py +10 -14
  12. package/bin/files/delete-empty-folders.py +33 -37
  13. package/bin/files/delete-smb-files.py +12 -21
  14. package/bin/files/files-under-size.py +5 -11
  15. package/bin/files/find-largest-files.py +21 -25
  16. package/bin/files/list-permission.py +40 -0
  17. package/bin/files/make-alpha-dir.py +2 -0
  18. package/bin/git/all-actions.py +350 -253
  19. package/bin/git/checkout-my-branches.py +42 -63
  20. package/bin/git/clean-stale-branches.py +23 -48
  21. package/bin/git/prune-worktrees.py +12 -51
  22. package/bin/git/sync-all-branches.py +75 -297
  23. package/bin/git/update-local-branches.py +67 -73
  24. package/bin/gitutils.py +263 -0
  25. package/bin/install.py +393 -1182
  26. package/bin/machine.py +345 -0
  27. package/bin/media.py +127 -0
  28. package/bin/menu.py +437 -0
  29. package/bin/nas.py +102 -0
  30. package/bin/rcfile.py +380 -0
  31. package/bin/shellgen.py +503 -431
  32. package/bin/system/btop-launch.py +17 -13
  33. package/bin/system/update-brew.py +57 -68
  34. package/bin/tui.py +156 -153
  35. package/bin/utils.py +165 -237
  36. package/bin/video/delete-duplicate-videos.py +64 -62
  37. package/bin/video/detect-green-magenta-videos.py +105 -95
  38. package/bin/video/find-movie-by-year.py +3 -0
  39. package/bin/video/find-video-mkv-issues.py +48 -65
  40. package/bin/video/fix-codecs.py +25 -30
  41. package/bin/video/largest-tv-shows.py +60 -57
  42. package/bin/video/remove-metadata.py +3 -6
  43. package/bin/video/rename-video-file.py +74 -84
  44. package/bin/video/scan-videos-audio-language.py +16 -33
  45. package/bin/video/show-codecs.py +20 -24
  46. package/bin/video/validate-video-files.py +5 -8
  47. package/bin/video/video-list.py +17 -20
  48. package/package.json +4 -10
  49. package/shell/che.bash +33 -33
  50. package/shell/che.fish +7 -7
  51. package/shell/che.zsh +33 -33
  52. package/shell/completions/_che +19 -19
  53. package/shell/completions/che.bash +15 -15
  54. package/shell/completions/che.fish +34 -50
@@ -1,20 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """๐ŸŽฌ Show the latest GitHub Actions run for every open PR you authored.
2
+ """๐ŸŽฌ Show the latest GitHub Actions run per open PR and per default branch.
3
3
 
4
- One row per PR, so a repo with five open PRs gets five rows. The repo name is a
5
- terminal hyperlink to that run.
4
+ Two tables. The first is one row per open PR you authored, so a repo with five
5
+ open PRs gets five rows. The second is one row per repo you own, showing the
6
+ run on its default branch - the sanity checks and anything else that fires on
7
+ a push to main. The repo name is a terminal hyperlink to that run.
6
8
 
7
- When a PR's head commit has several workflow runs, the row shows the one worth
8
- acting on: anything still in flight, else a failure, else the most recent run.
9
+ When a commit has several workflow runs, the row shows the one worth acting
10
+ on: anything still in flight, else a failure, else the most recent run.
9
11
 
10
12
  Usage:
11
13
  all-actions.py [--owner=NAME[,NAME...]] [--author=NAME] [--pr-limit=N]
14
+ [--repo-limit=N] [--no-main]
12
15
  [--watch[=SECONDS]] [--interval=SECONDS]
13
16
 
14
17
  Options:
15
18
  --owner=NAME Owner(s) to scan, comma-separated (default: your gh login)
16
19
  --author=NAME PR author to match (default: your gh login)
17
20
  --pr-limit=N Open PRs to inspect, max 100 (default: 100)
21
+ --repo-limit=N Repos to sweep for default-branch runs, max 100 (default: 100)
22
+ --no-main Skip the default-branch table, showing PRs only
18
23
  --interval=SECONDS Refresh interval used by --watch (default: 15)
19
24
  --watch[=SECONDS] Refresh every interval until interrupted
20
25
  --help Show help
@@ -23,26 +28,32 @@ Options:
23
28
  from __future__ import annotations
24
29
 
25
30
  import json
26
- import re
27
31
  import sys
28
32
  import time
29
- import unicodedata
33
+ from collections.abc import Callable, Sequence
34
+ from concurrent.futures import ThreadPoolExecutor
30
35
  from datetime import UTC, datetime
31
36
  from pathlib import Path
37
+ from typing import NamedTuple
32
38
 
33
39
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
34
40
 
41
+ # Cell widths come from tui's unicodedata-based measure, which replaced the
42
+ # shell's count of 4-byte UTF-8 sequences and its hand-kept list of wide emoji.
43
+ from tui import display_width, hyperlink
35
44
  from utils import (
36
45
  CYAN,
37
46
  GREEN,
38
- MAGENTA,
39
47
  NC,
40
48
  RED,
41
49
  YELLOW,
50
+ add_bool_flag,
42
51
  build_parser,
43
- color_enabled,
52
+ heading,
44
53
  info,
54
+ log_color,
45
55
  note,
56
+ parse_count,
46
57
  require_binary,
47
58
  run,
48
59
  run_cli,
@@ -51,40 +62,56 @@ from utils import (
51
62
  warning,
52
63
  )
53
64
 
54
- __version__ = "2.0.0"
55
-
56
- STATUS_COLORS = {
57
- "in_progress": CYAN,
58
- "queued": YELLOW,
59
- "waiting": YELLOW,
60
- "pending": YELLOW,
61
- "requested": YELLOW,
62
- "success": GREEN,
63
- "failure": RED,
64
- "timed_out": RED,
65
- "startup_failure": RED,
66
- "action_required": RED,
65
+ __version__ = "2.2.0"
66
+
67
+ # (icon, row colour) per status; see status_style for one not listed here.
68
+ STATUS_STYLE = {
69
+ "in_progress": ("๐Ÿ”„", CYAN),
70
+ "queued": ("โณ", YELLOW),
71
+ "waiting": ("โณ", YELLOW),
72
+ "pending": ("โณ", YELLOW),
73
+ "requested": ("โณ", YELLOW),
74
+ "success": ("โœ…", GREEN),
75
+ "failure": ("โŒ", RED),
76
+ "timed_out": ("โŒ", RED),
77
+ "startup_failure": ("โŒ", RED),
78
+ "action_required": ("โšช", RED),
79
+ "cancelled": ("๐Ÿšซ", NC),
80
+ "skipped": ("โฉ", NC),
81
+ "none": ("โž–", NC), # noqa: RUF001
67
82
  }
68
83
 
69
- STATUS_ICONS = {
70
- "in_progress": "๐Ÿ”„",
71
- "queued": "โณ",
72
- "waiting": "โณ",
73
- "pending": "โณ",
74
- "requested": "โณ",
75
- "success": "โœ…",
76
- "failure": "โŒ",
77
- "timed_out": "โŒ",
78
- "startup_failure": "โŒ",
79
- "cancelled": "๐Ÿšซ",
80
- "skipped": "โฉ",
81
- "none": "โž–", # noqa: RUF001
82
- }
83
84
 
84
- LIVE_STATUSES = frozenset({"in_progress", "queued", "waiting", "pending", "requested"})
85
+ def status_style(status: str) -> tuple[str, str]:
86
+ """The (icon, row colour) for ``status``, neutral for one GitHub adds later."""
87
+ return STATUS_STYLE.get(status, ("โšช", NC))
88
+
89
+
85
90
  BAD_CONCLUSIONS = frozenset({"FAILURE", "TIMED_OUT", "STARTUP_FAILURE"})
86
91
 
87
- GRAPHQL_QUERY = """
92
+ # The check suites both queries read off a commit, so a PR's head commit and a
93
+ # repo's default-branch tip come back in the same shape (see suite_run).
94
+ RUN_SUITES = """
95
+ fragment RunSuites on Commit {
96
+ checkSuites(first: 20) {
97
+ nodes {
98
+ status
99
+ conclusion
100
+ workflowRun {
101
+ url
102
+ createdAt
103
+ updatedAt
104
+ workflow { name }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ """
110
+
111
+ # Every open PR and its head-commit check suites, in a single call: one call
112
+ # keeps a 15-second refresh well inside the rate limit.
113
+ GRAPHQL_QUERY = (
114
+ """
88
115
  query($q: String!, $first: Int!) {
89
116
  search(query: $q, type: ISSUE, first: $first) {
90
117
  nodes {
@@ -95,20 +122,7 @@ query($q: String!, $first: Int!) {
95
122
  repository { nameWithOwner }
96
123
  commits(last: 1) {
97
124
  nodes {
98
- commit {
99
- checkSuites(first: 20) {
100
- nodes {
101
- status
102
- conclusion
103
- workflowRun {
104
- url
105
- createdAt
106
- updatedAt
107
- workflow { name }
108
- }
109
- }
110
- }
111
- }
125
+ commit { ...RunSuites }
112
126
  }
113
127
  }
114
128
  }
@@ -116,62 +130,80 @@ query($q: String!, $first: Int!) {
116
130
  }
117
131
  }
118
132
  """
133
+ + RUN_SUITES
134
+ )
119
135
 
120
136
 
121
- # U+FE0F, the emoji presentation selector. It renders the PRECEDING character
122
- # as an emoji, which is two columns wide even when that base character is a
123
- # narrow symbol - so it promotes rather than taking zero width of its own.
124
- # Workflow names like "โœ”๏ธ Lint" and "โš ๏ธ Nightly" are exactly this shape.
125
- VARIATION_SELECTOR_16 = "\ufe0f"
137
+ # The default-branch sweep. `repositoryOwner` takes one login, unlike the
138
+ # search query's `user:` qualifiers, so this runs once per --owner - one call
139
+ # per owner rather than one per repo: sweeping fifty repos through the REST
140
+ # API would be fifty calls a cycle, which a 15-second --watch would spend the
141
+ # whole rate limit on.
142
+ #
143
+ # `isFork: false, isArchived: false` on purpose: a fork's CI belongs to whoever
144
+ # you forked from, and an archived repo cannot have a run you would act on.
145
+ # PUSHED_AT ordering means a --repo-limit below the account's repo count keeps
146
+ # the repos you actually touch.
147
+ BRANCH_GRAPHQL_QUERY = (
148
+ """
149
+ query($login: String!, $first: Int!) {
150
+ repositoryOwner(login: $login) {
151
+ repositories(
152
+ first: $first
153
+ isFork: false
154
+ isArchived: false
155
+ ownerAffiliations: [OWNER]
156
+ orderBy: {field: PUSHED_AT, direction: DESC}
157
+ ) {
158
+ nodes {
159
+ nameWithOwner
160
+ defaultBranchRef {
161
+ name
162
+ target { ...RunSuites }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ """
169
+ + RUN_SUITES
170
+ )
126
171
 
127
172
 
128
- def display_width(text: str) -> int:
129
- """Terminal columns ``text`` occupies.
173
+ class Run(NamedTuple):
174
+ """The workflow run a row shows."""
130
175
 
131
- The shell version counted 4-byte UTF-8 sequences and kept a hand-maintained
132
- list of double-width BMP emoji, which had to grow every time a new icon was
133
- added. ``unicodedata`` covers the common cases directly: wide and fullwidth
134
- characters take two columns, combining marks take none, everything else
135
- takes one.
176
+ live: bool # still in flight; decides sort order and how TOOK is measured
177
+ status: str
178
+ workflow: str
179
+ created: str
180
+ updated: str
181
+ url: str
136
182
 
137
- Known limitation, unchanged from the shell version: a ZWJ sequence such as
138
- "๐Ÿ‘จโ€๐Ÿ’ป" is counted per component rather than as one glyph, so it over-counts.
139
- """
140
- width = 0
141
- previous = 0
142
-
143
- for char in text:
144
- if char == VARIATION_SELECTOR_16:
145
- # Promote a narrow base to two columns; a base that is already
146
- # wide stays wide.
147
- if previous == 1:
148
- width += 1
149
- previous = 2
150
- continue
151
- if unicodedata.combining(char):
152
- continue
153
- previous = 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
154
- width += previous
155
183
 
156
- return width
184
+ # A PR whose head commit has no run yet. It still links to the PR itself.
185
+ NO_RUN = Run(live=False, status="none", workflow="-", created="", updated="", url="")
186
+
157
187
 
188
+ class Row(NamedTuple):
189
+ """One table row: a PR's head commit, or a repo's default-branch tip."""
158
190
 
159
- def pad(value: str, width: int) -> str:
160
- """Left-align ``value`` in a cell ``width`` terminal columns wide."""
161
- return value + " " * max(0, width - display_width(value))
191
+ repo: str
192
+ branch: str
193
+ run: Run
194
+ number: int | None = None # the PR; None on a default-branch row
162
195
 
163
196
 
164
- def pad_link(url: str, value: str, width: int) -> str:
165
- """Same as :func:`pad`, but wraps the text in an OSC 8 hyperlink.
197
+ def pad(value: str, width: int, url: str = "") -> str:
198
+ """Left-align ``value`` in a cell ``width`` terminal columns wide.
166
199
 
167
- The escape sequences are zero-width, so the padding is computed from the
168
- plain text and only the text itself gets wrapped. Suppressed alongside
169
- color, so a piped or NO_COLOR run is plain text throughout.
200
+ With a ``url`` the text is wrapped in an OSC 8 hyperlink. The escapes are
201
+ zero-width, so the padding is computed from the plain text and only the
202
+ text itself gets wrapped. ``hyperlink`` drops them alongside colour, so a
203
+ piped or NO_COLOR run is plain text throughout.
170
204
  """
171
205
  padding = " " * max(0, width - display_width(value))
172
- if not url or not color_enabled():
173
- return value + padding
174
- return f"\033]8;;{url}\033\\{value}\033]8;;\033\\{padding}"
206
+ return (hyperlink(value, url) if url else value) + padding
175
207
 
176
208
 
177
209
  def parse_iso(value: str) -> datetime | None:
@@ -209,22 +241,21 @@ def fmt_duration(seconds: int) -> str:
209
241
  return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
210
242
 
211
243
 
212
- def run_duration(status: str, created: str, updated: str) -> str:
244
+ def run_duration(run: Run) -> str:
213
245
  """How long a run took.
214
246
 
215
247
  Completed runs are createdAt -> updatedAt; anything still in flight is
216
- measured against now, so the column ticks up while it runs.
248
+ measured against now, so the column ticks up while it runs. ``live`` is
249
+ the run's own flag, so a suite whose status came back null - shown as
250
+ "unknown", but not COMPLETED - ticks too, rather than reading as finished.
217
251
  """
218
- start = parse_iso(created)
252
+ start = parse_iso(run.created)
219
253
  if start is None:
220
254
  return "-"
221
255
 
222
- if status in LIVE_STATUSES:
223
- end = datetime.now(UTC)
224
- else:
225
- end = parse_iso(updated)
226
- if end is None:
227
- return "-"
256
+ end = datetime.now(UTC) if run.live else parse_iso(run.updated)
257
+ if end is None:
258
+ return "-"
228
259
 
229
260
  return fmt_duration(max(0, int((end - start).total_seconds())))
230
261
 
@@ -253,7 +284,29 @@ def pick_suite(suites: list[dict]) -> dict | None:
253
284
  return sorted(usable, key=created)[-1]
254
285
 
255
286
 
256
- def build_rows(payload: dict) -> list[dict]:
287
+ def suite_run(suite: dict) -> Run:
288
+ """The :class:`Run` one check suite (from :func:`pick_suite`) describes.
289
+
290
+ Shared by both queries: a PR's head commit and a repo's default-branch tip
291
+ return the same ``checkSuites`` shape, so they render from the same code.
292
+ """
293
+ workflow_run = suite["workflowRun"]
294
+ live = suite.get("status") != "COMPLETED"
295
+ # `or "unknown"`: jq died outright on a null status, taking the whole
296
+ # run's output with it. An empty cell would be worse than saying so.
297
+ status = (suite.get("status") if live else suite.get("conclusion")) or "unknown"
298
+
299
+ return Run(
300
+ live=live,
301
+ status=status.lower(),
302
+ workflow=((workflow_run.get("workflow") or {}).get("name")) or "-",
303
+ created=workflow_run.get("createdAt") or "",
304
+ updated=workflow_run.get("updatedAt") or "",
305
+ url=workflow_run.get("url") or "",
306
+ )
307
+
308
+
309
+ def build_rows(payload: dict) -> list[Row]:
257
310
  """Flatten the GraphQL response into one row per PR.
258
311
 
259
312
  This replaces the shell version's jq program, which is why jq is no longer
@@ -272,163 +325,191 @@ def build_rows(payload: dict) -> list[dict]:
272
325
  commit = (commits[0] or {}).get("commit") or {}
273
326
  suites = ((commit.get("checkSuites") or {}).get("nodes")) or []
274
327
 
328
+ # A PR with no run still links somewhere useful, so it keeps its row.
329
+ # A repo with no run does not - see build_branch_rows.
275
330
  suite = pick_suite(suites)
331
+ rows.append(
332
+ Row(
333
+ repo=(pr.get("repository") or {}).get("nameWithOwner") or "",
334
+ branch=pr.get("headRefName") or "",
335
+ run=suite_run(suite) if suite else NO_RUN._replace(url=pr.get("url") or ""),
336
+ number=pr["number"],
337
+ )
338
+ )
276
339
 
340
+ return rows
341
+
342
+
343
+ def build_branch_rows(payload: dict) -> list[Row]:
344
+ """Flatten the repository response into one row per default branch.
345
+
346
+ Repos whose default-branch tip has no workflow run are dropped rather than
347
+ shown as a "none" row: an account owns far more repos without CI than with
348
+ it, and thirty empty rows would bury the handful worth looking at.
349
+
350
+ The branch shown is each repo's DEFAULT branch, whatever it is named -
351
+ plenty of older repos still default to `master`, and their sanity checks
352
+ are as worth seeing as anything on `main`.
353
+ """
354
+ rows = []
355
+ owner = (payload.get("data") or {}).get("repositoryOwner") or {}
356
+ nodes = (owner.get("repositories") or {}).get("nodes") or []
357
+
358
+ for repo in nodes:
359
+ if not repo:
360
+ continue
361
+
362
+ ref = repo.get("defaultBranchRef") or {}
363
+ target = ref.get("target") or {}
364
+ suites = ((target.get("checkSuites") or {}).get("nodes")) or []
365
+
366
+ suite = pick_suite(suites)
277
367
  if suite is None:
278
- status = "none"
279
- workflow = "-"
280
- created = updated = ""
281
- url = pr.get("url") or ""
282
- live = False
283
- else:
284
- workflow_run = suite["workflowRun"]
285
- if suite.get("status") != "COMPLETED":
286
- # `or "unknown"`: jq died outright on a null status, taking the
287
- # whole run's output with it. An empty cell would be worse
288
- # than saying so.
289
- status = (suite.get("status") or "unknown").lower()
290
- live = True
291
- else:
292
- status = (suite.get("conclusion") or "unknown").lower()
293
- live = False
294
- workflow = ((workflow_run.get("workflow") or {}).get("name")) or "-"
295
- created = workflow_run.get("createdAt") or ""
296
- updated = workflow_run.get("updatedAt") or ""
297
- url = workflow_run.get("url") or ""
368
+ continue
298
369
 
299
370
  rows.append(
300
- {
301
- "repo": (pr.get("repository") or {}).get("nameWithOwner") or "",
302
- "number": pr["number"],
303
- "branch": pr.get("headRefName") or "",
304
- "live": live,
305
- "status": status,
306
- "workflow": workflow,
307
- "created": created,
308
- "updated": updated,
309
- "url": url,
310
- }
371
+ Row(
372
+ repo=repo.get("nameWithOwner") or "",
373
+ branch=ref.get("name") or "",
374
+ run=suite_run(suite),
375
+ )
311
376
  )
312
377
 
313
378
  return rows
314
379
 
315
380
 
316
- def fetch_rows(owners: str, author: str, pr_limit: int) -> list[dict]:
317
- """Every open PR and its head-commit check suites, in a single GraphQL call.
381
+ def gh_graphql(query: str, first: int, **variables: str) -> dict:
382
+ """One ``gh api graphql`` call, or ``{}`` when it fails or is not JSON.
318
383
 
319
- One call keeps a 15-second refresh well inside the rate limit.
384
+ ``{}`` flattens to no rows. One bad owner (a typo, an org you lost access
385
+ to) or one failed cycle of a --watch must not sink the rest of the sweep.
320
386
  """
321
- # `user:` qualifiers are OR'd by GitHub search, so several owners widen the
322
- # sweep rather than narrowing it.
323
- query = f"is:pr is:open archived:false author:{author}"
324
- for owner in owners.split(","):
325
- owner = owner.strip()
326
- if owner:
327
- query += f" user:{owner}"
328
-
329
- completed = run(
330
- [
331
- "gh",
332
- "api",
333
- "graphql",
334
- "-f",
335
- f"query={GRAPHQL_QUERY}",
336
- "-f",
337
- f"q={query}",
338
- "-F",
339
- f"first={pr_limit}",
340
- ],
341
- check=False,
342
- capture=True,
343
- )
344
- if completed.returncode != 0:
345
- return []
387
+ argv = ["gh", "api", "graphql", "-f", f"query={query}", "-F", f"first={first}"]
388
+ for name, value in variables.items():
389
+ argv += ["-f", f"{name}={value}"]
346
390
 
391
+ completed = run(argv, check=False, capture=True)
392
+ if completed.returncode != 0:
393
+ return {}
347
394
  try:
348
- payload = json.loads(completed.stdout or "{}")
395
+ return json.loads(completed.stdout or "{}")
349
396
  except json.JSONDecodeError:
350
- return []
397
+ return {}
351
398
 
352
- return build_rows(payload)
353
399
 
400
+ def sort_rows(rows: list[Row]) -> None:
401
+ """In-flight rows first, then grouped by repo, newest run first.
354
402
 
355
- def render(owners: str, author: str, pr_limit: int) -> None:
356
- """Draw the table once."""
357
- single_owner = "," not in owners
403
+ Two passes because Python's sort is stable: the second preserves the
404
+ ordering the first established within each group.
405
+ """
406
+ rows.sort(key=lambda r: r.run.created, reverse=True)
407
+ rows.sort(key=lambda r: (not r.run.live, r.repo))
358
408
 
359
- rows = fetch_rows(owners, author, pr_limit)
360
- # After the fetch, not before: the shell called date(1) inside the banner,
361
- # so in --watch mode the timestamp reflects when the data landed.
362
- clock = datetime.now().strftime("%H:%M:%S")
363
409
 
364
- if not rows:
365
- note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
366
- note(f"๐ŸŽฌ all-actions โ€” {clock}")
367
- note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
368
- info(f"๐Ÿซ™ No open pull requests authored by {author} under: {owners}")
369
- return
410
+ def draw_table(rows: list[Row], columns: Sequence[tuple[str, Callable[[Row], str], int]]) -> None:
411
+ """Print one aligned table of ``rows``.
370
412
 
371
- # In-flight rows first, then grouped by repo, newest run first. Two passes
372
- # because Python's sort is stable: the second pass preserves the ordering
373
- # the first established within each group.
374
- rows.sort(key=lambda r: r["created"] or "", reverse=True)
375
- rows.sort(key=lambda r: (not r["live"], r["repo"]))
413
+ ``columns`` is (title, cell, minimum width) per column; every column is
414
+ sized to its widest cell. The first column, the repo name in both tables,
415
+ links to the row's run.
376
416
 
377
- repo_count = len({r["repo"] for r in rows})
417
+ Factored out when the default-branch table arrived: it shares every column
418
+ but PR with the pull request table, and two copies of the width arithmetic
419
+ would drift the moment either grew a column.
420
+ """
421
+ widths = [
422
+ max([minimum, *(display_width(cell(row)) for row in rows)]) for _, cell, minimum in columns
423
+ ]
424
+ last = len(columns) - 1
378
425
 
379
- note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
380
- note(f"๐ŸŽฌ all-actions โ€” {len(rows)} open PR(s) ยท {repo_count} repo(s) ยท {clock}")
381
- note("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
426
+ # The header's final cell is not padded, so the line has no trailing
427
+ # whitespace; data cells are, matching what the shell version emitted.
428
+ header = " ".join(
429
+ title if index == last else pad(title, width)
430
+ for index, ((title, _, _), width) in enumerate(zip(columns, widths, strict=True))
431
+ )
432
+ success(header)
382
433
 
383
- def label(row: dict) -> str:
384
- return row["repo"].split("/", 1)[-1] if single_owner else row["repo"]
434
+ for row in rows:
435
+ line = " ".join(
436
+ pad(cell(row), width, row.run.url if index == 0 else "")
437
+ for index, ((_, cell, _), width) in enumerate(zip(columns, widths, strict=True))
438
+ )
439
+ log_color(status_style(row.run.status)[1], line)
385
440
 
386
- def status_cell(row: dict) -> str:
387
- return f"{STATUS_ICONS.get(row['status'], 'โšช')} {row['status']}"
388
441
 
389
- w_repo = max(4, *(display_width(label(r)) for r in rows))
390
- w_pr = max(3, *(display_width(f"#{r['number']}") for r in rows))
391
- w_status = max(6, *(display_width(status_cell(r)) for r in rows))
392
- w_workflow = max(8, *(display_width(r["workflow"]) for r in rows))
393
- w_branch = max(6, *(display_width(r["branch"]) for r in rows))
394
- w_age, w_took = 4, 5
442
+ def render(
443
+ owners: tuple[str, ...], author: str, pr_limit: int, repo_limit: int, include_main: bool
444
+ ) -> None:
445
+ """Draw both tables once."""
446
+ owners_text = ",".join(owners)
447
+ # `user:` qualifiers are OR'd by GitHub search, so several owners widen the
448
+ # sweep rather than narrowing it.
449
+ search = " ".join(
450
+ ["is:pr is:open archived:false", f"author:{author}", *(f"user:{o}" for o in owners)]
451
+ )
395
452
 
396
- header = " ".join(
397
- [
398
- pad("REPO", w_repo),
399
- pad("PR", w_pr),
400
- pad("STATUS", w_status),
401
- pad("WORKFLOW", w_workflow),
402
- pad("BRANCH", w_branch),
403
- pad("AGE", w_age),
404
- "TOOK",
453
+ # The PR search and each owner's sweep in parallel: run one after another,
454
+ # every --watch cycle waited on 1 + N round trips back to back.
455
+ with ThreadPoolExecutor() as pool:
456
+ pr_payload = pool.submit(gh_graphql, GRAPHQL_QUERY, pr_limit, q=search)
457
+ branch_payloads = [
458
+ pool.submit(gh_graphql, BRANCH_GRAPHQL_QUERY, repo_limit, login=owner)
459
+ for owner in (owners if include_main else ())
405
460
  ]
461
+ rows = build_rows(pr_payload.result())
462
+ # Collected in submission order, not as each call finishes, so the
463
+ # output never depends on which reply happened to land first.
464
+ branch_rows = [row for f in branch_payloads for row in build_branch_rows(f.result())]
465
+
466
+ # After the fetch, not before: the shell called date(1) inside the banner,
467
+ # so in --watch mode the timestamp reflects when the data landed.
468
+ clock = datetime.now().strftime("%H:%M:%S")
469
+
470
+ sort_rows(rows)
471
+ sort_rows(branch_rows)
472
+
473
+ single_owner = len(owners) == 1
474
+
475
+ def label(row: Row) -> str:
476
+ return row.repo.split("/", 1)[-1] if single_owner else row.repo
477
+
478
+ # Every column but PR is shared: a push to the default branch has no pull
479
+ # request behind it, and a column of dashes would just take width from
480
+ # the workflow names, which are the point of that table.
481
+ repo_column = ("REPO", label, 4)
482
+ run_columns = (
483
+ ("STATUS", lambda r: f"{status_style(r.run.status)[0]} {r.run.status}", 6),
484
+ ("WORKFLOW", lambda r: r.run.workflow, 8),
485
+ ("BRANCH", lambda r: r.branch, 6),
486
+ ("AGE", lambda r: iso_to_age(r.run.created), 4),
487
+ ("TOOK", lambda r: run_duration(r.run), 5),
406
488
  )
407
- colors = color_enabled()
408
- print(f"{MAGENTA}{header}{NC}" if colors else header, flush=True)
409
489
 
410
- live_count = 0
411
- for row in rows:
412
- cells = " ".join(
413
- [
414
- pad_link(row["url"], label(row), w_repo),
415
- pad(f"#{row['number']}", w_pr),
416
- pad(status_cell(row), w_status),
417
- pad(row["workflow"], w_workflow),
418
- pad(row["branch"], w_branch),
419
- pad(iso_to_age(row["created"]), w_age),
420
- pad(run_duration(row["status"], row["created"], row["updated"]), w_took),
421
- ]
422
- )
423
- color = STATUS_COLORS.get(row["status"], NC)
424
- print(f"{color}{cells}{NC}" if colors else cells, flush=True)
425
- if row["live"]:
426
- live_count += 1
490
+ repo_count = len({r.repo for r in rows})
491
+ heading(f"๐ŸŽฌ all-actions โ€” {len(rows)} open PR(s) ยท {repo_count} repo(s) ยท {clock}")
492
+
493
+ if rows:
494
+ draw_table(rows, (repo_column, ("PR", lambda r: f"#{r.number}", 3), *run_columns))
495
+ else:
496
+ info(f"๐Ÿซ™ No open pull requests authored by {author} under: {owners_text}")
497
+
498
+ if include_main:
499
+ print(flush=True)
500
+ note(f"๐ŸŒณ default branch โ€” {len(branch_rows)} repo(s)")
501
+ if branch_rows:
502
+ draw_table(branch_rows, (repo_column, *run_columns))
503
+ else:
504
+ info(f"๐Ÿซ™ No default-branch runs found under: {owners_text}")
505
+
506
+ total = len(rows) + len(branch_rows)
507
+ live_count = sum(1 for row in (*rows, *branch_rows) if row.run.live)
508
+ idle_count = total - live_count
427
509
 
428
- idle_count = len(rows) - live_count
429
510
  print(flush=True)
430
511
  if live_count == 0:
431
- success(f"๐Ÿ˜ด Nothing in flight โ€” {idle_count} open PR(s) showing their last run.")
512
+ success(f"๐Ÿ˜ด Nothing in flight โ€” {idle_count} run(s) showing their last result.")
432
513
  else:
433
514
  success(f"โ•โ•โ• {live_count} in flight ยท {idle_count} settled (last run) โ•โ•โ•")
434
515
 
@@ -437,13 +518,16 @@ def main(argv: list[str] | None = None) -> int:
437
518
  parser = build_parser(
438
519
  prog="all-actions",
439
520
  description=(
440
- "๐ŸŽฌ Show the latest GitHub Actions run for every open pull request you\n"
441
- " authored, across every repo you own - one row per PR, so a repo with\n"
442
- " five open PRs gets five rows. The repo name is a terminal hyperlink\n"
443
- " to that run.\n\n"
444
- " When a PR's head commit has several workflow runs, the row shows the\n"
445
- " one worth acting on: anything still in flight, else a failure, else\n"
446
- " the most recent run."
521
+ "๐ŸŽฌ Show the latest GitHub Actions run per open pull request and per\n"
522
+ " default branch, across every repo you own.\n\n"
523
+ " The first table is one row per open PR you authored, so a repo with\n"
524
+ " five open PRs gets five rows. The second is one row per repo, showing\n"
525
+ " the run on its default branch - the sanity checks and anything else\n"
526
+ " that fires on a push to main. Pass --no-main to skip it. The repo\n"
527
+ " name is a terminal hyperlink to that run.\n\n"
528
+ " When a commit has several workflow runs, the row shows the one worth\n"
529
+ " acting on: anything still in flight, else a failure, else the most\n"
530
+ " recent run."
447
531
  ),
448
532
  )
449
533
  parser.add_argument(
@@ -462,9 +546,22 @@ def main(argv: list[str] | None = None) -> int:
462
546
  "--pr-limit",
463
547
  default="100",
464
548
  metavar="N",
465
- dest="pr_limit",
466
549
  help="Open PRs to inspect, max 100 (default: 100)",
467
550
  )
551
+ parser.add_argument(
552
+ "--repo-limit",
553
+ default="100",
554
+ metavar="N",
555
+ help="Repos to sweep for default-branch runs, max 100 (default: 100)",
556
+ )
557
+ # allow_value=False, per the convention for inverted flags: `--no-main=false`
558
+ # is a double negative nobody should have to decode.
559
+ add_bool_flag(
560
+ parser,
561
+ "--no-main",
562
+ help="Skip the default-branch table, showing pull requests only",
563
+ allow_value=False,
564
+ )
468
565
  parser.add_argument(
469
566
  "--interval",
470
567
  default="15",
@@ -487,27 +584,22 @@ def main(argv: list[str] | None = None) -> int:
487
584
  # After parsing, unlike the shell version, so --help works without gh.
488
585
  require_binary("gh", hint="Install it with `brew install gh`.")
489
586
 
490
- interval_raw = args.interval
491
- if args.watch:
492
- interval_raw = args.watch
493
-
494
- for name, value in (("pr-limit", args.pr_limit), ("interval", interval_raw)):
495
- if not re.fullmatch(r"[0-9]+", value):
496
- warning(f"โŒ --{name} must be a non-negative integer (got: {value})")
497
- return 1
498
-
499
- # The GraphQL search connection caps out at 100 nodes per page.
500
- pr_limit = min(100, max(1, int(args.pr_limit)))
501
- interval = max(1, int(interval_raw))
587
+ # Both GraphQL connections cap out at 100 nodes per page.
588
+ pr_limit = min(100, max(1, parse_count(args.pr_limit, "--pr-limit")))
589
+ repo_limit = min(100, max(1, parse_count(args.repo_limit, "--repo-limit")))
590
+ # A value given to --watch overrides --interval, so an error names it.
591
+ interval_flag = "--watch" if args.watch else "--interval"
592
+ interval = max(1, parse_count(args.watch or args.interval, interval_flag))
502
593
  watch = args.watch is not None
594
+ include_main = not args.no_main
503
595
 
504
596
  if run(["gh", "auth", "status"], check=False, capture=True).returncode != 0:
505
597
  warning("โŒ Not logged in to GitHub. Run: gh auth login")
506
598
  return 1
507
599
 
508
- owners = args.owner
600
+ owners_text = args.owner
509
601
  author = args.author
510
- if not owners or not author:
602
+ if not owners_text or not author:
511
603
  login = run_output(["gh", "api", "user", "--jq", ".login"], check=False).strip()
512
604
  if not login:
513
605
  # Without a login the query loses its `user:` qualifier entirely and
@@ -515,11 +607,16 @@ def main(argv: list[str] | None = None) -> int:
515
607
  # and still fail here (missing read:user, unauthorised SSO, a 5xx).
516
608
  warning("โŒ Could not determine your gh login. Pass --owner= and --author=.")
517
609
  return 1
518
- owners = owners or login
610
+ owners_text = owners_text or login
519
611
  author = author or login
520
612
 
613
+ owners = tuple(owner for part in owners_text.split(",") if (owner := part.strip()))
614
+
615
+ def draw() -> None:
616
+ render(owners, author, pr_limit, repo_limit, include_main)
617
+
521
618
  if not watch:
522
- render(owners, author, pr_limit)
619
+ draw()
523
620
  return 0
524
621
 
525
622
  try:
@@ -527,7 +624,7 @@ def main(argv: list[str] | None = None) -> int:
527
624
  # ANSI clear + home, rather than shelling out to clear(1).
528
625
  print("\033[2J\033[H", end="", flush=True)
529
626
  try:
530
- render(owners, author, pr_limit)
627
+ draw()
531
628
  # Broad on purpose: the shell wrapped this as `render || true`.
532
629
  except Exception as exc:
533
630
  # One unexpected payload shape must not end a long watch.