@codebend3r/che 1.0.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/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- package/shell/completions/che.fish +371 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""๐ฌ Show the latest GitHub Actions run for every open PR you authored.
|
|
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.
|
|
6
|
+
|
|
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
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
all-actions.py [--owner=NAME[,NAME...]] [--author=NAME] [--pr-limit=N]
|
|
12
|
+
[--watch[=SECONDS]] [--interval=SECONDS]
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--owner=NAME Owner(s) to scan, comma-separated (default: your gh login)
|
|
16
|
+
--author=NAME PR author to match (default: your gh login)
|
|
17
|
+
--pr-limit=N Open PRs to inspect, max 100 (default: 100)
|
|
18
|
+
--interval=SECONDS Refresh interval used by --watch (default: 15)
|
|
19
|
+
--watch[=SECONDS] Refresh every interval until interrupted
|
|
20
|
+
--help Show help
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import unicodedata
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
34
|
+
|
|
35
|
+
from utils import (
|
|
36
|
+
CYAN,
|
|
37
|
+
GREEN,
|
|
38
|
+
MAGENTA,
|
|
39
|
+
NC,
|
|
40
|
+
RED,
|
|
41
|
+
YELLOW,
|
|
42
|
+
build_parser,
|
|
43
|
+
color_enabled,
|
|
44
|
+
info,
|
|
45
|
+
note,
|
|
46
|
+
require_binary,
|
|
47
|
+
run,
|
|
48
|
+
run_cli,
|
|
49
|
+
run_output,
|
|
50
|
+
success,
|
|
51
|
+
warning,
|
|
52
|
+
)
|
|
53
|
+
|
|
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,
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
LIVE_STATUSES = frozenset({"in_progress", "queued", "waiting", "pending", "requested"})
|
|
85
|
+
BAD_CONCLUSIONS = frozenset({"FAILURE", "TIMED_OUT", "STARTUP_FAILURE"})
|
|
86
|
+
|
|
87
|
+
GRAPHQL_QUERY = """
|
|
88
|
+
query($q: String!, $first: Int!) {
|
|
89
|
+
search(query: $q, type: ISSUE, first: $first) {
|
|
90
|
+
nodes {
|
|
91
|
+
... on PullRequest {
|
|
92
|
+
number
|
|
93
|
+
url
|
|
94
|
+
headRefName
|
|
95
|
+
repository { nameWithOwner }
|
|
96
|
+
commits(last: 1) {
|
|
97
|
+
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
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
|
|
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"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def display_width(text: str) -> int:
|
|
129
|
+
"""Terminal columns ``text`` occupies.
|
|
130
|
+
|
|
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.
|
|
136
|
+
|
|
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
|
+
|
|
156
|
+
return width
|
|
157
|
+
|
|
158
|
+
|
|
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))
|
|
162
|
+
|
|
163
|
+
|
|
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.
|
|
166
|
+
|
|
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.
|
|
170
|
+
"""
|
|
171
|
+
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}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_iso(value: str) -> datetime | None:
|
|
178
|
+
"""Parse GitHub's ISO-8601 UTC timestamps, or None if absent/unparseable."""
|
|
179
|
+
if not value:
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
183
|
+
except ValueError:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def iso_to_age(value: str) -> str:
|
|
188
|
+
"""Compact age since ``value``: "45s", "12m", "3h", "2d"."""
|
|
189
|
+
then = parse_iso(value)
|
|
190
|
+
if then is None:
|
|
191
|
+
return "-"
|
|
192
|
+
|
|
193
|
+
delta = max(0, int((datetime.now(UTC) - then).total_seconds()))
|
|
194
|
+
if delta < 60:
|
|
195
|
+
return f"{delta}s"
|
|
196
|
+
if delta < 3600:
|
|
197
|
+
return f"{delta // 60}m"
|
|
198
|
+
if delta < 86400:
|
|
199
|
+
return f"{delta // 3600}h"
|
|
200
|
+
return f"{delta // 86400}d"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def fmt_duration(seconds: int) -> str:
|
|
204
|
+
"""Compact duration: "45s", "2m10s", "1h04m"."""
|
|
205
|
+
if seconds < 60:
|
|
206
|
+
return f"{seconds}s"
|
|
207
|
+
if seconds < 3600:
|
|
208
|
+
return f"{seconds // 60}m{seconds % 60:02d}s"
|
|
209
|
+
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def run_duration(status: str, created: str, updated: str) -> str:
|
|
213
|
+
"""How long a run took.
|
|
214
|
+
|
|
215
|
+
Completed runs are createdAt -> updatedAt; anything still in flight is
|
|
216
|
+
measured against now, so the column ticks up while it runs.
|
|
217
|
+
"""
|
|
218
|
+
start = parse_iso(created)
|
|
219
|
+
if start is None:
|
|
220
|
+
return "-"
|
|
221
|
+
|
|
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 "-"
|
|
228
|
+
|
|
229
|
+
return fmt_duration(max(0, int((end - start).total_seconds())))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def pick_suite(suites: list[dict]) -> dict | None:
|
|
233
|
+
"""The check suite worth showing: in flight, else failed, else newest."""
|
|
234
|
+
usable = [s for s in suites if s.get("workflowRun")]
|
|
235
|
+
if not usable:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
def created(suite: dict) -> str:
|
|
239
|
+
return suite["workflowRun"].get("createdAt") or ""
|
|
240
|
+
|
|
241
|
+
# sorted(...)[-1], not max(...): jq's `sort_by | last` is a stable sort, so
|
|
242
|
+
# for suites sharing a createdAt it returns the LAST in document order,
|
|
243
|
+
# where max() returns the first. GitHub stamps every workflow run triggered
|
|
244
|
+
# by one push with the same second, so this tie fires constantly.
|
|
245
|
+
live = [s for s in usable if s.get("status") != "COMPLETED"]
|
|
246
|
+
if live:
|
|
247
|
+
return sorted(live, key=created)[-1]
|
|
248
|
+
|
|
249
|
+
bad = [s for s in usable if (s.get("conclusion") or "") in BAD_CONCLUSIONS]
|
|
250
|
+
if bad:
|
|
251
|
+
return sorted(bad, key=created)[-1]
|
|
252
|
+
|
|
253
|
+
return sorted(usable, key=created)[-1]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def build_rows(payload: dict) -> list[dict]:
|
|
257
|
+
"""Flatten the GraphQL response into one row per PR.
|
|
258
|
+
|
|
259
|
+
This replaces the shell version's jq program, which is why jq is no longer
|
|
260
|
+
a dependency.
|
|
261
|
+
"""
|
|
262
|
+
rows = []
|
|
263
|
+
nodes = (payload.get("data", {}).get("search", {}) or {}).get("nodes") or []
|
|
264
|
+
|
|
265
|
+
for pr in nodes:
|
|
266
|
+
if not pr or pr.get("number") is None:
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
commits = (pr.get("commits") or {}).get("nodes") or []
|
|
270
|
+
suites: list[dict] = []
|
|
271
|
+
if commits:
|
|
272
|
+
commit = (commits[0] or {}).get("commit") or {}
|
|
273
|
+
suites = ((commit.get("checkSuites") or {}).get("nodes")) or []
|
|
274
|
+
|
|
275
|
+
suite = pick_suite(suites)
|
|
276
|
+
|
|
277
|
+
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 ""
|
|
298
|
+
|
|
299
|
+
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
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return rows
|
|
314
|
+
|
|
315
|
+
|
|
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.
|
|
318
|
+
|
|
319
|
+
One call keeps a 15-second refresh well inside the rate limit.
|
|
320
|
+
"""
|
|
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 []
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
payload = json.loads(completed.stdout or "{}")
|
|
349
|
+
except json.JSONDecodeError:
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
return build_rows(payload)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def render(owners: str, author: str, pr_limit: int) -> None:
|
|
356
|
+
"""Draw the table once."""
|
|
357
|
+
single_owner = "," not in owners
|
|
358
|
+
|
|
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
|
+
|
|
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
|
|
370
|
+
|
|
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"]))
|
|
376
|
+
|
|
377
|
+
repo_count = len({r["repo"] for r in rows})
|
|
378
|
+
|
|
379
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
380
|
+
note(f"๐ฌ all-actions โ {len(rows)} open PR(s) ยท {repo_count} repo(s) ยท {clock}")
|
|
381
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
382
|
+
|
|
383
|
+
def label(row: dict) -> str:
|
|
384
|
+
return row["repo"].split("/", 1)[-1] if single_owner else row["repo"]
|
|
385
|
+
|
|
386
|
+
def status_cell(row: dict) -> str:
|
|
387
|
+
return f"{STATUS_ICONS.get(row['status'], 'โช')} {row['status']}"
|
|
388
|
+
|
|
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
|
|
395
|
+
|
|
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",
|
|
405
|
+
]
|
|
406
|
+
)
|
|
407
|
+
colors = color_enabled()
|
|
408
|
+
print(f"{MAGENTA}{header}{NC}" if colors else header, flush=True)
|
|
409
|
+
|
|
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
|
|
427
|
+
|
|
428
|
+
idle_count = len(rows) - live_count
|
|
429
|
+
print(flush=True)
|
|
430
|
+
if live_count == 0:
|
|
431
|
+
success(f"๐ด Nothing in flight โ {idle_count} open PR(s) showing their last run.")
|
|
432
|
+
else:
|
|
433
|
+
success(f"โโโ {live_count} in flight ยท {idle_count} settled (last run) โโโ")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def main(argv: list[str] | None = None) -> int:
|
|
437
|
+
parser = build_parser(
|
|
438
|
+
prog="all-actions",
|
|
439
|
+
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."
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
parser.add_argument(
|
|
450
|
+
"--owner",
|
|
451
|
+
default="",
|
|
452
|
+
metavar="NAME",
|
|
453
|
+
help="Owner(s) to scan, comma-separated (default: your gh login)",
|
|
454
|
+
)
|
|
455
|
+
parser.add_argument(
|
|
456
|
+
"--author",
|
|
457
|
+
default="",
|
|
458
|
+
metavar="NAME",
|
|
459
|
+
help="PR author to match (default: your gh login)",
|
|
460
|
+
)
|
|
461
|
+
parser.add_argument(
|
|
462
|
+
"--pr-limit",
|
|
463
|
+
default="100",
|
|
464
|
+
metavar="N",
|
|
465
|
+
dest="pr_limit",
|
|
466
|
+
help="Open PRs to inspect, max 100 (default: 100)",
|
|
467
|
+
)
|
|
468
|
+
parser.add_argument(
|
|
469
|
+
"--interval",
|
|
470
|
+
default="15",
|
|
471
|
+
metavar="SECONDS",
|
|
472
|
+
help="Refresh interval used by --watch (default: 15)",
|
|
473
|
+
)
|
|
474
|
+
# --watch is special: it is both a bare flag and a flag that carries the
|
|
475
|
+
# interval, so it cannot use add_bool_flag. nargs="?" also accepts the
|
|
476
|
+
# space form `--watch 30`, which the shell version rejected.
|
|
477
|
+
parser.add_argument(
|
|
478
|
+
"--watch",
|
|
479
|
+
nargs="?",
|
|
480
|
+
const="",
|
|
481
|
+
default=None,
|
|
482
|
+
metavar="SECONDS",
|
|
483
|
+
help="Refresh every interval until interrupted",
|
|
484
|
+
)
|
|
485
|
+
args = parser.parse_args(argv)
|
|
486
|
+
|
|
487
|
+
# After parsing, unlike the shell version, so --help works without gh.
|
|
488
|
+
require_binary("gh", hint="Install it with `brew install gh`.")
|
|
489
|
+
|
|
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))
|
|
502
|
+
watch = args.watch is not None
|
|
503
|
+
|
|
504
|
+
if run(["gh", "auth", "status"], check=False, capture=True).returncode != 0:
|
|
505
|
+
warning("โ Not logged in to GitHub. Run: gh auth login")
|
|
506
|
+
return 1
|
|
507
|
+
|
|
508
|
+
owners = args.owner
|
|
509
|
+
author = args.author
|
|
510
|
+
if not owners or not author:
|
|
511
|
+
login = run_output(["gh", "api", "user", "--jq", ".login"], check=False).strip()
|
|
512
|
+
if not login:
|
|
513
|
+
# Without a login the query loses its `user:` qualifier entirely and
|
|
514
|
+
# silently searches all of GitHub. A token can pass `gh auth status`
|
|
515
|
+
# and still fail here (missing read:user, unauthorised SSO, a 5xx).
|
|
516
|
+
warning("โ Could not determine your gh login. Pass --owner= and --author=.")
|
|
517
|
+
return 1
|
|
518
|
+
owners = owners or login
|
|
519
|
+
author = author or login
|
|
520
|
+
|
|
521
|
+
if not watch:
|
|
522
|
+
render(owners, author, pr_limit)
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
while True:
|
|
527
|
+
# ANSI clear + home, rather than shelling out to clear(1).
|
|
528
|
+
print("\033[2J\033[H", end="", flush=True)
|
|
529
|
+
try:
|
|
530
|
+
render(owners, author, pr_limit)
|
|
531
|
+
# Broad on purpose: the shell wrapped this as `render || true`.
|
|
532
|
+
except Exception as exc:
|
|
533
|
+
# One unexpected payload shape must not end a long watch.
|
|
534
|
+
warning(f"โ ๏ธ Render failed, retrying next cycle: {exc}")
|
|
535
|
+
info(f"๐ Refreshing every {interval}s โ Ctrl-C to stop.")
|
|
536
|
+
time.sleep(interval)
|
|
537
|
+
except KeyboardInterrupt:
|
|
538
|
+
# Ctrl-C during a watch loop is the normal exit, not a failure.
|
|
539
|
+
print(flush=True)
|
|
540
|
+
success("๐ Stopped watching.")
|
|
541
|
+
return 0
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
if __name__ == "__main__":
|
|
545
|
+
run_cli(main)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""๐ฟ Check out recent remote branches authored by you that aren't yet local.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
checkout-my-branches.py [--author=EMAIL] [--limit=N]
|
|
6
|
+
|
|
7
|
+
Options:
|
|
8
|
+
--author=EMAIL Author email to match (default: git config user.email)
|
|
9
|
+
--limit=N How many recent remote branches to scan (default: 100)
|
|
10
|
+
--help Show help
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
20
|
+
|
|
21
|
+
from utils import (
|
|
22
|
+
build_parser,
|
|
23
|
+
info,
|
|
24
|
+
log,
|
|
25
|
+
note,
|
|
26
|
+
require_binary,
|
|
27
|
+
run,
|
|
28
|
+
run_cli,
|
|
29
|
+
run_lines,
|
|
30
|
+
run_output,
|
|
31
|
+
success,
|
|
32
|
+
warning,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "3.0.0"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> int:
|
|
39
|
+
default_author = run_output(["git", "config", "user.email"], check=False).strip()
|
|
40
|
+
|
|
41
|
+
parser = build_parser(
|
|
42
|
+
prog="checkout-my-branches",
|
|
43
|
+
description="๐ฟ Check out recent remote branches you authored that aren't yet local.",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--author",
|
|
47
|
+
default=default_author,
|
|
48
|
+
metavar="EMAIL",
|
|
49
|
+
help="Author email to match (default: git config user.email)",
|
|
50
|
+
)
|
|
51
|
+
# Kept as a string, not `type=int`: argparse's own int conversion would
|
|
52
|
+
# own the failure and exit before the shell version's message could print.
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--limit",
|
|
55
|
+
default="100",
|
|
56
|
+
metavar="N",
|
|
57
|
+
help="How many recent remote branches to scan (default: 100)",
|
|
58
|
+
)
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
|
|
61
|
+
if not args.author:
|
|
62
|
+
warning("โ No author email โ pass --author=EMAIL or set git config user.email")
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
if not re.fullmatch(r"[0-9]+", args.limit) or int(args.limit) < 1:
|
|
66
|
+
warning(f"โ --limit must be a positive integer (got: {args.limit})")
|
|
67
|
+
return 1
|
|
68
|
+
limit = int(args.limit)
|
|
69
|
+
|
|
70
|
+
require_binary("git")
|
|
71
|
+
|
|
72
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
73
|
+
note("๐ฟ checkout-my-branches")
|
|
74
|
+
note("โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ")
|
|
75
|
+
|
|
76
|
+
if run_output(["git", "status", "--porcelain"]):
|
|
77
|
+
warning("โ Working tree is dirty. Commit or stash before running. ๐งบ")
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
info("๐ก Fetching remotes...")
|
|
81
|
+
run(["git", "fetch", "--all", "--prune", "--quiet"])
|
|
82
|
+
|
|
83
|
+
info(f"๐ฆ Scanning {limit} most recent remote branches for author ๐ค {args.author}...")
|
|
84
|
+
|
|
85
|
+
checked_out = 0
|
|
86
|
+
skipped = 0
|
|
87
|
+
not_mine = 0
|
|
88
|
+
|
|
89
|
+
# One pass: author email + refname + symref target, newest first. %(symref)
|
|
90
|
+
# is non-empty for symbolic refs like origin/HEAD, which must be skipped -
|
|
91
|
+
# it is a pointer, not a branch anyone authored.
|
|
92
|
+
#
|
|
93
|
+
# Separator is \x01 rather than the shell version's '|': an author email or
|
|
94
|
+
# a branch name may contain a pipe, and the shell version then mis-split
|
|
95
|
+
# the record. Control characters are illegal in ref names, so \x01 is safe.
|
|
96
|
+
lines = run_lines(
|
|
97
|
+
[
|
|
98
|
+
"git",
|
|
99
|
+
"for-each-ref",
|
|
100
|
+
"--sort=-committerdate",
|
|
101
|
+
"--format=%(authoremail)%01%(refname:short)%01%(symref)",
|
|
102
|
+
"refs/remotes/origin",
|
|
103
|
+
]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
for line in lines[:limit]:
|
|
107
|
+
parts = line.split("\x01")
|
|
108
|
+
if len(parts) < 3:
|
|
109
|
+
continue
|
|
110
|
+
author_email, remote_branch, symref = parts[0], parts[1], parts[2]
|
|
111
|
+
|
|
112
|
+
if not remote_branch or symref:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
author_email = author_email.removeprefix("<").removesuffix(">")
|
|
116
|
+
if author_email != args.author:
|
|
117
|
+
not_mine += 1
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
local_branch = remote_branch.removeprefix("origin/")
|
|
121
|
+
if not local_branch or local_branch == remote_branch:
|
|
122
|
+
continue # malformed entry โ don't touch
|
|
123
|
+
|
|
124
|
+
exists = run(
|
|
125
|
+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{local_branch}"],
|
|
126
|
+
check=False,
|
|
127
|
+
capture=True,
|
|
128
|
+
)
|
|
129
|
+
if exists.returncode == 0:
|
|
130
|
+
info(f"โญ๏ธ Already local: {local_branch}")
|
|
131
|
+
skipped += 1
|
|
132
|
+
else:
|
|
133
|
+
log(f"๐ Checking out: {local_branch}")
|
|
134
|
+
run(["git", "checkout", "-b", local_branch, "--track", remote_branch])
|
|
135
|
+
checked_out += 1
|
|
136
|
+
|
|
137
|
+
print()
|
|
138
|
+
success("โโโ summary โโโ")
|
|
139
|
+
success(f"๐ Checked out: {checked_out}")
|
|
140
|
+
success(f"โญ๏ธ Already local: {skipped}")
|
|
141
|
+
success(f"๐ป Skipped (not yours): {not_mine}")
|
|
142
|
+
success("โจ Done.")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
run_cli(main)
|