@codebend3r/che 1.1.0 โ 1.2.0
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.
- package/README.md +38 -24
- package/bin/art.py +22 -20
- package/bin/che.py +208 -587
- package/bin/commands.py +220 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +54 -283
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1182
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +503 -431
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- package/shell/completions/che.fish +34 -50
package/bin/git/all-actions.py
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""๐ฌ Show the latest GitHub Actions run
|
|
2
|
+
"""๐ฌ Show the latest GitHub Actions run per open PR and per default branch.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
"""
|
|
173
|
+
class Run(NamedTuple):
|
|
174
|
+
"""The workflow run a row shows."""
|
|
130
175
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
165
|
-
"""
|
|
197
|
+
def pad(value: str, width: int, url: str = "") -> str:
|
|
198
|
+
"""Left-align ``value`` in a cell ``width`` terminal columns wide.
|
|
166
199
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
"
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
317
|
-
"""
|
|
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
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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}
|
|
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
|
|
441
|
-
"
|
|
442
|
-
"
|
|
443
|
-
"
|
|
444
|
-
"
|
|
445
|
-
"
|
|
446
|
-
"
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
600
|
+
owners_text = args.owner
|
|
509
601
|
author = args.author
|
|
510
|
-
if not
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|