gitpro 0.3.1__py3-none-any.whl

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.
gitpro/forge.py ADDED
@@ -0,0 +1,1244 @@
1
+ """Talking to GitHub / GitLab through their CLIs (gh, glab): accounts, tokens in a child's env only.
2
+
3
+ Package 01 ships the runner (Account ... no_online_error); package 05 adds the read-only `forge` command
4
+ (repos, forks, email, similar) below it. Every forge call is a read: gh repo list/view, gh search repos,
5
+ gh api --method GET, glab auth status, glab repo list, glab api --method GET. Nothing is written.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import fnmatch
11
+ import json
12
+ import os
13
+ import re
14
+ import shlex
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional, Set, Tuple
22
+
23
+ from . import jsonout
24
+
25
+ from .config import ConfigError
26
+
27
+ if TYPE_CHECKING:
28
+ from .config import Config
29
+
30
+ GITHUB = "github.com"
31
+ GITLAB = "gitlab.com"
32
+ _GH_TOKEN_VARS = ("GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN")
33
+ _GLAB_TOKEN_VARS = ("GITLAB_TOKEN", "GITLAB_ACCESS_TOKEN", "OAUTH_TOKEN")
34
+ TOKEN_TIMEOUT = 30
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Account:
39
+ host: str
40
+ cli: str # "gh" | "glab"
41
+ login: str
42
+
43
+
44
+ def available(cli: str) -> bool:
45
+ return shutil.which(cli) is not None
46
+
47
+
48
+ def accounts(cfg: "Config") -> List[Account]:
49
+ """Every (host, cli, login) the config names: [default] first (gh on github.com, glab on gitlab.com),
50
+ then each identity on its own host; duplicates dropped. No network."""
51
+ d = cfg.default
52
+ out = []
53
+ if d.gh_account:
54
+ out.append(Account(GITHUB, "gh", d.gh_account))
55
+ if d.glab_account:
56
+ out.append(Account(GITLAB, "glab", d.glab_account))
57
+ for i in cfg.identities:
58
+ if i.gh_account:
59
+ out.append(Account(i.host, "gh", i.gh_account))
60
+ if i.glab_account:
61
+ out.append(Account(i.host, "glab", i.glab_account))
62
+ return list(dict.fromkeys(out))
63
+
64
+
65
+ def _stripped(names: Tuple[str, ...], base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
66
+ return {k: v for k, v in (os.environ if base_env is None else base_env).items() if k not in names}
67
+
68
+
69
+ def gh_env(host: str, login: str, *, base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
70
+ """os.environ without ambient gh tokens, plus GH_HOST and the token of `login` on `host`
71
+ (GH_TOKEN on github.com, GH_ENTERPRISE_TOKEN elsewhere). The token lives only in this dict.
72
+ base_env lets scoped executors preserve their own child environment without global mutation."""
73
+ env = _stripped(_GH_TOKEN_VARS, base_env)
74
+ try:
75
+ r = subprocess.run(["gh", "auth", "token", "-u", login, "-h", host], env=env, stdin=subprocess.DEVNULL,
76
+ capture_output=True, text=True, timeout=TOKEN_TIMEOUT)
77
+ except FileNotFoundError:
78
+ raise ConfigError("gh not found; install the GitHub CLI (https://cli.github.com)") from None
79
+ except subprocess.TimeoutExpired:
80
+ raise ConfigError("gh auth token timed out (keyring locked?)") from None
81
+ token = r.stdout.strip()
82
+ if r.returncode != 0 or not token:
83
+ if "unknown shorthand flag" in r.stderr or "unknown flag" in r.stderr:
84
+ raise ConfigError("gh 2.40 or newer is needed (for 'gh auth token -u'); upgrade gh")
85
+ raise ConfigError(f"gh has no login for {login} on {host}; run: gh auth login -h {host}")
86
+ env["GH_HOST"] = host
87
+ env["GH_TOKEN" if host.lower() == GITHUB else "GH_ENTERPRISE_TOKEN"] = token
88
+ return env
89
+
90
+
91
+ def _glab_env(host: str) -> Dict[str, str]:
92
+ env = _stripped(_GLAB_TOKEN_VARS)
93
+ env["GITLAB_HOST"] = host
94
+ return env
95
+
96
+
97
+ def run(acc: Account, argv: List[str], *, timeout: int = 30,
98
+ stdin=subprocess.DEVNULL) -> subprocess.CompletedProcess:
99
+ """[acc.cli, *argv] as acc, captured text. 127 when the CLI is missing, 124 on timeout; never raises
100
+ for a failing command (gh_env may raise ConfigError when gh has no such login)."""
101
+ if not available(acc.cli):
102
+ return subprocess.CompletedProcess([acc.cli, *argv], 127, "", f"{acc.cli}: command not found\n")
103
+ env = gh_env(acc.host, acc.login) if acc.cli == "gh" else _glab_env(acc.host)
104
+ return _exec(acc, argv, env, timeout=timeout, stdin=stdin)
105
+
106
+
107
+ def _exec(acc: Account, argv: List[str], env: Dict[str, str], *, timeout: int = 30,
108
+ stdin=subprocess.DEVNULL) -> subprocess.CompletedProcess:
109
+ """run() with a ready env (the token fetched already)."""
110
+ cmd = [acc.cli, *argv]
111
+ kw = {"input": stdin} if isinstance(stdin, (str, bytes)) else {"stdin": stdin}
112
+ try:
113
+ return subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout, **kw)
114
+ except FileNotFoundError:
115
+ return subprocess.CompletedProcess(cmd, 127, "", f"{acc.cli}: command not found\n")
116
+ except subprocess.TimeoutExpired:
117
+ return subprocess.CompletedProcess(cmd, 124, "", f"{acc.cli} timed out after {timeout}s\n")
118
+
119
+
120
+ def logged_in(acc: Account) -> bool:
121
+ """gh: the token works and `gh api user` names the login; glab: `glab auth status` names it."""
122
+ if acc.cli == "gh":
123
+ try:
124
+ r = run(acc, ["api", "user", "--jq", ".login"])
125
+ except ConfigError:
126
+ return False
127
+ return r.returncode == 0 and r.stdout.strip().lower() == acc.login.lower()
128
+ r = run(acc, ["auth", "status", "-h", acc.host])
129
+ return r.returncode == 0 and acc.login.lower() in (r.stdout + r.stderr).lower()
130
+
131
+
132
+ def _first_line(text: str) -> str:
133
+ return next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
134
+
135
+
136
+ def repo_exists(acc: Account, owner_repo: str, repo: Optional[str] = None) -> Tuple[str, Optional[str]]:
137
+ """("ok", None) | ("renamed", "new/name") | ("missing", None) | ("unknown", "<first stderr line>").
138
+ Also callable as repo_exists(acc, owner, repo). Names compare case-insensitively."""
139
+ name = f"{owner_repo}/{repo}" if repo is not None else owner_repo
140
+ if acc.cli == "gh":
141
+ r = run(acc, ["repo", "view", name, "--json", "nameWithOwner"])
142
+ field, gone = "nameWithOwner", ("Could not resolve to a Repository",)
143
+ else:
144
+ r = run(acc, ["repo", "view", name, "--output", "json"])
145
+ field, gone = "path_with_namespace", ("404 Not Found", "404 Project Not Found")
146
+ if r.returncode != 0:
147
+ if any(g in r.stderr for g in gone):
148
+ return "missing", None
149
+ return "unknown", _first_line(r.stderr) or f"{acc.cli} exited {r.returncode}"
150
+ try:
151
+ got = json.loads(r.stdout)[field]
152
+ except (ValueError, KeyError, TypeError):
153
+ return "unknown", f"unexpected {acc.cli} output"
154
+ return ("ok", None) if str(got).lower() == name.lower() else ("renamed", str(got))
155
+
156
+
157
+ def _who(accs: List[Account]) -> str:
158
+ groups: Dict[Tuple[str, str], List[str]] = {}
159
+ for a in accs:
160
+ groups.setdefault((a.host, a.cli), []).append(a.login)
161
+ return " and ".join(f"{h} via {c} (account {', '.join(dict.fromkeys(ls))})" for (h, c), ls in groups.items())
162
+
163
+
164
+ def announce(what: str, accounts: List[Account], sends: str) -> None:
165
+ """The first line of every online command: service, CLI, account, what is sent."""
166
+ print(f"online: asking {_who(accounts)} {what}; sends {sends}", flush=True)
167
+
168
+
169
+ def no_online_error(command: str, what: str, accounts: List[Account], sends: str) -> int:
170
+ """The refusal for a command that needs the network and lacks --online; returns 2."""
171
+ print(f"✗ {command} needs the network: it asks {_who(accounts)} {what}; sends {sends}", file=sys.stderr)
172
+ print("→ re-run with --online, or set [forge] online = true", file=sys.stderr)
173
+ return 2
174
+
175
+
176
+ # ================================================================ 05: `gitpro forge` (read-only)
177
+
178
+ NEEDS_CLI = "needs gh or glab on PATH: install gh (https://cli.github.com) or glab (https://gitlab.com/gitlab-org/cli)"
179
+ GH_FIELDS = "nameWithOwner,isFork,isArchived,sshUrl,pushedAt"
180
+ GH_LIMIT = 1000 # gh repo list --limit
181
+ GLAB_PER_PAGE = 100
182
+ GLAB_MAX_PAGES = 100 # 10 000 projects; a guard against a CLI that ignores --page
183
+ SEARCH_PAUSE = 0.5 # seconds between two `gh search repos` calls
184
+ SEARCH_LIMIT = 10
185
+ WORKERS = 8 # git processes reading remotes in parallel
186
+ _SECRET_RE = re.compile(r"(?<![A-Za-z0-9_])(?:gh[pousr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}"
187
+ r"|gl(?:pat|dt|rt|oas|ptt|cbt|imt|soat|ft|agent|ffct|wt)-[A-Za-z0-9_-]{8,})")
188
+ # a legacy 40-hex token, only where something says it is one (token=..., Authorization: token ..., ...)
189
+ _HEX_TOKEN_RE = re.compile(r"(?i)((?:token|bearer|password|passwd|secret|authorization|auth)"
190
+ r"[\"']?\s*[:=]?\s*[\"']?(?:(?:token|bearer|basic)\s+)?)[0-9a-f]{40}(?![0-9a-f])")
191
+ _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069]")
192
+ _URL_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s'\"<>]+")
193
+ _GLAB_AS_RE = re.compile(r"Logged in to (\S+) as (\S+)", re.I)
194
+ _RATE_RE = re.compile(r"rate limit|HTTP 429|abuse detection", re.I) # checked before _SCOPE_RE
195
+ _SCOPE_RE = re.compile(r"HTTP 40[34]|scope", re.I)
196
+
197
+
198
+ def redact(text: str, secrets: Iterable[str] = ()) -> str:
199
+ """text with token-looking words (ghp_, gho_, github_pat_, glpat-, gldt-, ..., a 40-hex token after
200
+ "token"), URL credentials and each exact value in secrets masked."""
201
+ from .resolve import redact_url
202
+ for v in secrets:
203
+ if v and len(v) >= 4:
204
+ text = text.replace(v, "***")
205
+ text = _SECRET_RE.sub("***", text)
206
+ text = _HEX_TOKEN_RE.sub(lambda m: m[1] + "***", text)
207
+ return _URL_RE.sub(lambda m: redact_url(m[0]) or "", text)
208
+
209
+
210
+ def safe(text: object) -> str:
211
+ """text for a terminal: control and bidi characters shown as \\xNN / \\uNNNN, never interpreted."""
212
+ return _CONTROL_RE.sub(lambda m: m[0].encode("unicode_escape").decode("ascii"), str(text))
213
+
214
+
215
+ def _strip_userinfo(url: str) -> str:
216
+ """url for a suggested command: http(s) userinfo dropped, a password dropped elsewhere (ssh://git@ kept)."""
217
+ m = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*)://([^/]*)@", url)
218
+ if not m:
219
+ return url
220
+ user, sep, _secret = m[2].partition(":")
221
+ if m[1].lower() in ("http", "https"):
222
+ return f"{m[1]}://{url[m.end():]}"
223
+ return f"{m[1]}://{user}@{url[m.end():]}" if sep else url
224
+
225
+
226
+ def _err_line(r: subprocess.CompletedProcess) -> str:
227
+ return redact(_first_line(r.stderr)) or f"exit {r.returncode}"
228
+
229
+
230
+ _FAILED = object() # Session.json_call: the call failed (already warned); a parsed `null` is None
231
+
232
+
233
+ def _plural(n: int, word: str) -> str:
234
+ return f"{n} {word}{'s' * (n != 1)}"
235
+
236
+
237
+ def _tilde(path: object, home: Path) -> str:
238
+ p = Path(str(path))
239
+ try:
240
+ rest = p.relative_to(home)
241
+ except ValueError:
242
+ return str(p)
243
+ return "~" if str(rest) == "." else f"~/{rest}"
244
+
245
+
246
+ def _sh_path(path: object, home: Path) -> str:
247
+ """A path for a command line shown to the user: quoted, ~ kept for paths under home."""
248
+ shown = _tilde(path, home)
249
+ if shown == "~" or shown.startswith("~/"):
250
+ return "~" + ("/" + shlex.quote(shown[2:]) if shown != "~" else "")
251
+ return shlex.quote(shown)
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class Target:
256
+ """One identity (or [default]) mapped to one forge account."""
257
+ id: str
258
+ email: Optional[str]
259
+ owners: Tuple[str, ...]
260
+ acc: Account
261
+
262
+
263
+ def targets(cfg: "Config", ids: Optional[Iterable[str]] = None) -> Tuple[List[Target], List[str]]:
264
+ """(targets, warnings): [default] first, then each identity, as forge.accounts orders them; ids
265
+ restricts the list ("default" names [default]). An identity without an account is a warning."""
266
+ known = ["default"] + [i.id for i in cfg.identities]
267
+ want = list(dict.fromkeys(ids)) if ids else None
268
+ for x in want or ():
269
+ if x not in known:
270
+ raise ConfigError(f"unknown identity '{x}' (known: {', '.join(known)})")
271
+ out: List[Target] = []
272
+ warnings: List[str] = []
273
+ d = cfg.default
274
+ if want is None or "default" in want:
275
+ if d.gh_account:
276
+ out.append(Target("default", d.email, (), Account(GITHUB, "gh", d.gh_account)))
277
+ if d.glab_account:
278
+ out.append(Target("default", d.email, (), Account(GITLAB, "glab", d.glab_account)))
279
+ if want and not (d.gh_account or d.glab_account):
280
+ warnings.append("[default] has no gh_account/glab_account; skipped")
281
+ for i in cfg.identities:
282
+ if want is not None and i.id not in want:
283
+ continue
284
+ if i.gh_account:
285
+ out.append(Target(i.id, i.email, tuple(i.owners), Account(i.host, "gh", i.gh_account)))
286
+ if i.glab_account:
287
+ out.append(Target(i.id, i.email, tuple(i.owners), Account(i.host, "glab", i.glab_account)))
288
+ if not (i.gh_account or i.glab_account):
289
+ warnings.append(f"identity {i.id} has no gh_account/glab_account; skipped")
290
+ return out, warnings
291
+
292
+
293
+ # ---------------------------------------------------------------- local repos ("cloned")
294
+
295
+ @dataclass
296
+ class Local:
297
+ """The repos under [scan] roots (02's roots and excludes) with their raw remote URLs."""
298
+ roots: List[Path]
299
+ repos: List[Tuple[Path, Dict[str, str]]]
300
+ aliases: Dict[str, str] = field(default_factory=dict) # ssh alias -> real host
301
+
302
+ def key(self, url: Optional[str]) -> Optional[Tuple[str, str]]:
303
+ """(host, owner/repo), lowercased, for a raw remote URL; an ssh alias counts as its real host."""
304
+ if not url:
305
+ return None
306
+ from .resolve import parse_url
307
+ host, owner, name = parse_url(url)
308
+ if not (host and owner and name):
309
+ return None
310
+ host = host.lower()
311
+ return self.aliases.get(host, host), f"{owner}/{name}".lower()
312
+
313
+ def by_origin(self) -> Dict[Tuple[str, str], Tuple[Path, Dict[str, str]]]:
314
+ out: Dict[Tuple[str, str], Tuple[Path, Dict[str, str]]] = {}
315
+ for path, rems in self.repos:
316
+ k = self.key(rems.get("origin"))
317
+ if k and k not in out:
318
+ out[k] = (path, rems)
319
+ return out
320
+
321
+
322
+ def _remotes(path: Path) -> Dict[str, str]:
323
+ from .resolve import remotes
324
+ try:
325
+ return remotes(path)
326
+ except Exception: # an unreadable repo is simply not "cloned"
327
+ return {}
328
+
329
+
330
+ def local_repos(cfg: "Config") -> Local:
331
+ from concurrent.futures import ThreadPoolExecutor
332
+
333
+ from . import scan
334
+ roots, exclude, _source = scan.effective_roots([], cfg.scan)
335
+ paths = scan.find(roots, exclude=exclude)
336
+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
337
+ rems = list(pool.map(_remotes, paths))
338
+ aliases = {i.ssh_alias.lower(): i.host.lower() for i in cfg.identities if i.ssh_alias}
339
+ return Local(list(roots), list(zip(paths, rems)), aliases)
340
+
341
+
342
+ def _swap(url: Optional[str], old: str, new: str) -> Optional[str]:
343
+ """url with its trailing owner/repo `old` replaced by `new` (scheme, user, alias kept)."""
344
+ if not url:
345
+ return None
346
+ m = re.search(r"[:/](" + re.escape(old) + r")(\.git)?/?$", url, re.I)
347
+ return url[:m.start(1)] + new + (m[2] or "") if m else None
348
+
349
+
350
+ def _clone_prefix() -> str:
351
+ import importlib.util
352
+ try:
353
+ return "gitpro clone" if importlib.util.find_spec("gitpro.clone") else "git clone"
354
+ except (ImportError, ValueError):
355
+ return "git clone"
356
+
357
+
358
+ # ---------------------------------------------------------------- a session: calls, warnings, record
359
+
360
+ class Session:
361
+ """One forge command run: the targets, what was sent where, warnings (queued until the online line
362
+ is out, so it stays the first line), the local scan (lazy)."""
363
+
364
+ def __init__(self, cfg: "Config", home: Path, ids: Optional[Iterable[str]] = None, *, as_json: bool = False):
365
+ self.cfg, self.home, self.as_json = cfg, home, as_json
366
+ self.targets, queued = targets(cfg, ids)
367
+ self.warnings: List[str] = []
368
+ self.sent: Dict[str, List[str]] = {}
369
+ self.live = False
370
+ self.explicit = False # --online given (a config-only online never exits 2)
371
+ self._queue: List[str] = []
372
+ self._envs: Dict[Account, object] = {} # the child env per account (or the ConfigError), once per run
373
+ self._local: Optional[Local] = None
374
+ self._glab: Dict[Account, Optional[str]] = {}
375
+ self.tried: Set[Account] = set()
376
+ self.answered: Set[Account] = set()
377
+ for w in queued:
378
+ self.warn(w)
379
+
380
+ # output -------------------------------------------------------
381
+ def secrets(self) -> List[str]:
382
+ return [e.get(k, "") for e in self._envs.values() if isinstance(e, dict)
383
+ for k in ("GH_TOKEN", "GH_ENTERPRISE_TOKEN")]
384
+
385
+ def warn(self, msg: str) -> None:
386
+ msg = redact(msg, self.secrets())
387
+ self.warnings.append(msg)
388
+ if self.live:
389
+ print(f"⚠ {safe(msg)}", file=sys.stderr, flush=True)
390
+ else:
391
+ self._queue.append(msg)
392
+
393
+ def flush(self) -> None:
394
+ for msg in self._queue:
395
+ print(f"⚠ {safe(msg)}", file=sys.stderr, flush=True)
396
+ self._queue = []
397
+
398
+ def announce(self, what: str, accs: List[Account], sends: str) -> None:
399
+ sys.stdout.flush()
400
+ with contextlib.redirect_stdout(sys.stderr if self.as_json else sys.stdout):
401
+ announce(what, accs, sends)
402
+
403
+ @property
404
+ def local(self) -> Local:
405
+ if self._local is None:
406
+ self._local = local_repos(self.cfg)
407
+ return self._local
408
+
409
+ def accounts(self) -> List[Account]:
410
+ return list(dict.fromkeys(t.acc for t in self.targets))
411
+
412
+ def who(self, acc: Account) -> str:
413
+ return ", ".join(dict.fromkeys(t.id for t in self.targets if t.acc == acc))
414
+
415
+ # the gate -----------------------------------------------------
416
+ def gate(self, command: str, what: str, sends: str, *, explicit: bool, args) -> Optional[int]:
417
+ """None to go on (self.live says whether online), else the exit code: no --online (2), no
418
+ account (2), no gh/glab with --online (2). [forge] online = true without gh/glab: ⚠, offline."""
419
+ from .args import fail
420
+ accs = self.accounts()
421
+ if not accs:
422
+ self.flush()
423
+ return fail(f"{command}: no identity has a gh_account or glab_account; nothing to ask", args)
424
+ self.explicit = bool(explicit)
425
+ if not (explicit or self.cfg.forge.online):
426
+ self.flush()
427
+ if self.as_json:
428
+ return fail(f"{command} needs the network: it asks {_who(accs)} {what}; sends {sends}; "
429
+ "re-run with --online, or set [forge] online = true", args)
430
+ return no_online_error(command, what, accs, sends)
431
+ clis = list(dict.fromkeys(a.cli for a in accs))
432
+ missing = [c for c in clis if not available(c)]
433
+ if len(missing) == len(clis):
434
+ if explicit:
435
+ self.flush()
436
+ return fail(f"{command} {NEEDS_CLI}", args)
437
+ self.warn(f"[forge] online = true, but {command} {NEEDS_CLI}; nothing checked")
438
+ self.flush()
439
+ return None
440
+ for c in missing:
441
+ ids = ", ".join(dict.fromkeys(t.id for t in self.targets if t.acc.cli == c))
442
+ self.warn(f"{c} not found; {ids} skipped")
443
+ self.targets = [t for t in self.targets if t.acc.cli not in missing]
444
+ self.live = True
445
+ self.announce(what, self.accounts(), sends)
446
+ self.flush()
447
+ return None
448
+
449
+ # calls --------------------------------------------------------
450
+ def env(self, acc: Account) -> Dict[str, str]:
451
+ """The child env of acc, fetched once per run (gh auth token); raises the ConfigError again."""
452
+ if acc not in self._envs:
453
+ try:
454
+ self._envs[acc] = gh_env(acc.host, acc.login) if acc.cli == "gh" else _glab_env(acc.host)
455
+ except ConfigError as e:
456
+ self._envs[acc] = e
457
+ got = self._envs[acc]
458
+ if isinstance(got, ConfigError):
459
+ raise got
460
+ return got # type: ignore[return-value]
461
+
462
+ def run(self, acc: Account, argv: List[str]) -> subprocess.CompletedProcess:
463
+ """forge.run with the env cached for this run."""
464
+ if not available(acc.cli):
465
+ return subprocess.CompletedProcess([acc.cli, *argv], 127, "", f"{acc.cli}: command not found\n")
466
+ return _exec(acc, argv, self.env(acc))
467
+
468
+ def call(self, acc: Account, argv: List[str], label: str, who: Optional[str] = None, *,
469
+ check: bool = True) -> Optional[subprocess.CompletedProcess]:
470
+ """run(acc, argv), recorded under self.sent; None (with a ⚠) when it could not run, or failed and
471
+ check is set. Error text is redacted."""
472
+ who = who or self.who(acc)
473
+ self.tried.add(acc)
474
+ try:
475
+ r = self.run(acc, argv)
476
+ except ConfigError as e:
477
+ self.warn(f"{redact(str(e))}; {who} skipped")
478
+ return None
479
+ if r.returncode == 127:
480
+ self.warn(f"{acc.cli} not found; {who} skipped")
481
+ return None
482
+ self.sent.setdefault(acc.host, []).append(label)
483
+ if r.returncode == 124:
484
+ self.warn(f"{acc.cli} timed out; {who} skipped")
485
+ return None
486
+ if r.returncode != 0 and check:
487
+ self.warn(f"{acc.host}: {_err_line(r)}; {who} skipped")
488
+ return None
489
+ return r
490
+
491
+ def json_call(self, acc: Account, argv: List[str], label: str, who: Optional[str] = None):
492
+ """The parsed JSON of a successful call (None for `null`), else _FAILED (with a ⚠)."""
493
+ r = self.call(acc, argv, label, who)
494
+ if r is None:
495
+ return _FAILED
496
+ try:
497
+ return json.loads(r.stdout)
498
+ except ValueError:
499
+ self.warn(f"{acc.host}: unexpected {acc.cli} output; {who or self.who(acc)} skipped")
500
+ return _FAILED
501
+
502
+ def glab_ok(self, t: Target) -> bool:
503
+ """glab acts as whoever is logged in on the host (one login per host): go on only when that is
504
+ t.acc.login, else ⚠ and skip, so another login's data is never shown as this identity's."""
505
+ acc = t.acc
506
+ if acc not in self._glab:
507
+ self._glab[acc] = self._glab_login(acc)
508
+ active = self._glab[acc]
509
+ if active is not None and active.lower() == acc.login.lower():
510
+ return True
511
+ fix = f"glab auth login --hostname {acc.host}"
512
+ self.tried.add(acc)
513
+ if active:
514
+ self.warn(f"glab is logged in to {acc.host} as {active}, identity {t.id} expects {acc.login}; "
515
+ f"{t.id} skipped → {fix}")
516
+ else:
517
+ self.warn(f"glab is not logged in to {acc.host} as {acc.login}; {t.id} skipped → {fix}")
518
+ return False
519
+
520
+ def _glab_login(self, acc: Account) -> Optional[str]:
521
+ r = self.call(acc, ["auth", "status", "-h", acc.host], "auth status", check=False)
522
+ if r is None:
523
+ return None
524
+ text = f"{r.stdout}\n{r.stderr}"
525
+ logins = [m[2].rstrip(".,") for m in _GLAB_AS_RE.finditer(text) if m[1].rstrip(".,:") == acc.host]
526
+ if logins:
527
+ return logins[0]
528
+ if r.returncode != 0:
529
+ return None
530
+ r = self.call(acc, ["api", "--method", "GET", "user"], "api user", check=False)
531
+ try:
532
+ return str(json.loads(r.stdout)["username"]) if r is not None and r.returncode == 0 else None
533
+ except (ValueError, KeyError, TypeError):
534
+ return None
535
+
536
+ def ready(self, t: Target) -> bool:
537
+ return t.acc.cli != "glab" or self.glab_ok(t)
538
+
539
+ def failed_all(self) -> bool:
540
+ """The forge was asked and no account answered."""
541
+ return self.live and bool(self.tried) and not self.answered
542
+
543
+ def record(self) -> Dict[str, List[str]]:
544
+ return {h: list(dict.fromkeys(v)) for h, v in self.sent.items()}
545
+
546
+
547
+ def glab_pages(acc: Account, argv: List[str]) -> Tuple[List[dict], Optional[subprocess.CompletedProcess]]:
548
+ """Every item of a paged `glab ... --output json` listing (glab has no --limit): --page 1, 2, ...
549
+ until a page is short or adds nothing new (`null` / empty is no items). (items, None), or (items so far, the failing CompletedProcess)."""
550
+ argv = list(argv)
551
+ if "--per-page" not in argv:
552
+ argv += ["--per-page", str(GLAB_PER_PAGE)]
553
+ per_page = int(argv[argv.index("--per-page") + 1])
554
+ items: List[dict] = []
555
+ seen: Set[object] = set()
556
+ for page in range(1, GLAB_MAX_PAGES + 1):
557
+ r = run(acc, [*argv, "--page", str(page)])
558
+ if r.returncode != 0:
559
+ return items, r
560
+ try:
561
+ got = json.loads(r.stdout.strip() or "null")
562
+ except ValueError:
563
+ return items, subprocess.CompletedProcess(r.args, 1, "", f"unexpected {acc.cli} output\n")
564
+ if got is None:
565
+ got = []
566
+ if not isinstance(got, list):
567
+ return items, subprocess.CompletedProcess(r.args, 1, "", f"unexpected {acc.cli} output\n")
568
+ new = 0
569
+ for x in got:
570
+ if not isinstance(x, dict):
571
+ continue
572
+ k = x.get("id") or x.get("path_with_namespace") or json.dumps(x, sort_keys=True, default=str)
573
+ if k not in seen:
574
+ seen.add(k)
575
+ items.append(x)
576
+ new += 1
577
+ if len(got) < per_page or not new: # a short page, or a CLI that keeps sending the same page
578
+ break
579
+ return items, None
580
+
581
+
582
+ # ---------------------------------------------------------------- repos
583
+
584
+ def _row_gh(x: dict) -> dict:
585
+ return {"name_with_owner": str(x.get("nameWithOwner") or ""), "ssh_url": x.get("sshUrl"),
586
+ "fork": bool(x.get("isFork")), "archived": bool(x.get("isArchived")), "pushed_at": x.get("pushedAt")}
587
+
588
+
589
+ def _row_glab(x: dict) -> dict:
590
+ parent = x.get("forked_from_project")
591
+ return {"name_with_owner": str(x.get("path_with_namespace") or ""), "ssh_url": x.get("ssh_url_to_repo"),
592
+ "fork": isinstance(parent, dict), "archived": bool(x.get("archived")),
593
+ "pushed_at": x.get("last_activity_at"),
594
+ "parent": parent.get("path_with_namespace") if isinstance(parent, dict) else None}
595
+
596
+
597
+ def _gh_list(s: Session, acc: Account, owner: str, who: str) -> Optional[List[dict]]:
598
+ got = s.json_call(acc, ["repo", "list", owner, "--limit", str(GH_LIMIT), "--json", GH_FIELDS],
599
+ f"repo list {owner}", who)
600
+ if got is _FAILED:
601
+ return None
602
+ if not isinstance(got, list):
603
+ kind = "null" if got is None else type(got).__name__
604
+ s.warn(f"{acc.host}: unexpected gh output for repo list {owner} ({kind}); {who} skipped")
605
+ return None
606
+ if len(got) >= GH_LIMIT:
607
+ s.warn(f"{acc.host}: {owner}: showing the first {GH_LIMIT} repos; there may be more")
608
+ return [_row_gh(x) for x in got if isinstance(x, dict) and x.get("nameWithOwner")]
609
+
610
+
611
+ def _glab_list(s: Session, acc: Account, who: str) -> Optional[List[dict]]:
612
+ s.tried.add(acc)
613
+ try:
614
+ items, err = glab_pages(acc, ["repo", "list", "--mine", "--per-page", str(GLAB_PER_PAGE), "--output", "json"])
615
+ except ConfigError as e:
616
+ s.warn(f"{redact(str(e))}; {who} skipped")
617
+ return None
618
+ s.sent.setdefault(acc.host, []).append("repo list --mine")
619
+ if err is not None:
620
+ s.warn(f"glab timed out; {who} skipped" if err.returncode == 124 else f"{acc.host}: {_err_line(err)}; {who} skipped")
621
+ return None
622
+ return [_row_glab(x) for x in items if x.get("path_with_namespace")]
623
+
624
+
625
+ def _orgs(s: Session, acc: Account, who: str) -> List[str]:
626
+ r = s.call(acc, ["api", "--method", "GET", "user/orgs", "--paginate", "--jq", ".[].login"], "user/orgs", check=False)
627
+ if r is None:
628
+ return []
629
+ if r.returncode != 0:
630
+ s.warn(f"{acc.host}: {_err_line(r)}; organisations of {acc.login} not listed")
631
+ return []
632
+ return [o.strip() for o in r.stdout.splitlines() if o.strip() and o.strip().lower() != acc.login.lower()]
633
+
634
+
635
+ def account_repos(s: Session, t: Target, *, orgs: bool = False) -> Optional[List[dict]]:
636
+ """Every repo of t's account (and, with orgs, of its organisations), sorted; None when unreadable."""
637
+ acc, who = t.acc, s.who(t.acc)
638
+ if not s.ready(t):
639
+ return None
640
+ rows = _glab_list(s, acc, who) if acc.cli == "glab" else _gh_list(s, acc, acc.login, who)
641
+ if rows is None:
642
+ return None
643
+ s.answered.add(acc)
644
+ if orgs and acc.cli == "gh":
645
+ seen = {r["name_with_owner"].lower() for r in rows}
646
+ for org in _orgs(s, acc, who):
647
+ more = _gh_list(s, acc, org, f"organisation {org}") or []
648
+ for r in more:
649
+ if r["name_with_owner"].lower() not in seen:
650
+ seen.add(r["name_with_owner"].lower())
651
+ rows.append(r)
652
+ elif orgs:
653
+ s.warn(f"--orgs: glab lists the projects of every group you belong to already ({who})")
654
+ return sorted(rows, key=lambda r: r["name_with_owner"].lower())
655
+
656
+
657
+ def repos(s: Session, *, orgs: bool = False, not_cloned: bool = False) -> List[dict]:
658
+ """One entry per account: {id, ids, host, cli, account, repos: [... cloned, clone_cmd]}."""
659
+ if not s.live:
660
+ return []
661
+ index = s.local.by_origin()
662
+ prefix = _clone_prefix()
663
+ out = []
664
+ for acc in s.accounts():
665
+ t = next(x for x in s.targets if x.acc == acc)
666
+ rows = account_repos(s, t, orgs=orgs)
667
+ if rows is None:
668
+ continue
669
+ for r in rows:
670
+ hit = index.get((acc.host.lower(), r["name_with_owner"].lower()))
671
+ r["cloned"] = str(hit[0]) if hit else None
672
+ r["clone_cmd"] = f"{prefix} {shlex.quote(r['ssh_url'])}" if not hit and r["ssh_url"] else None
673
+ r.pop("parent", None)
674
+ ids = list(dict.fromkeys(x.id for x in s.targets if x.acc == acc))
675
+ shown = [r for r in rows if r["cloned"] is None] if not_cloned else rows
676
+ out.append({"id": ids[0], "ids": ids, "host": acc.host, "cli": acc.cli, "account": acc.login,
677
+ "total": len(rows), "cloned_count": sum(r["cloned"] is not None for r in rows),
678
+ "archived_count": sum(r["archived"] for r in rows), "repos": shown})
679
+ return out
680
+
681
+
682
+ def render_repos(groups: List[dict]) -> str:
683
+ if not groups:
684
+ return ""
685
+ lw = max(len(", ".join(g["ids"])) for g in groups)
686
+ aw = max(len(f"{g['account']}@{g['host']}") for g in groups)
687
+ lines = []
688
+ for g in groups:
689
+ n, c = g["total"], g["cloned_count"]
690
+ head = f"{_plural(n, 'repo')} · {c} cloned"
691
+ if n - c:
692
+ head += f" · {n - c} not cloned"
693
+ if g["archived_count"]:
694
+ head += f" · {g['archived_count']} archived"
695
+ lines.append(f"{', '.join(g['ids']):<{lw}} {safe(g['account'] + '@' + g['host']):<{aw}} {head}")
696
+ todo = [r for r in g["repos"] if r["cloned"] is None]
697
+ if not todo:
698
+ continue
699
+ lines.append(" not cloned:")
700
+ nw = max(len(safe(r["name_with_owner"])) for r in todo)
701
+ for r in todo:
702
+ pushed = f"pushed {safe(str(r['pushed_at'])[:10])}" if r["pushed_at"] else "never pushed"
703
+ tail = "archived" if r["archived"] else (f"→ {safe(r['clone_cmd'])}" if r["clone_cmd"] else "")
704
+ lines.append(f" {safe(r['name_with_owner']):<{nw}} {pushed:<17} {tail}".rstrip())
705
+ return "\n".join(lines) + "\n"
706
+
707
+
708
+ # ---------------------------------------------------------------- forks
709
+
710
+ def _parent_of(view: object) -> Tuple[bool, Optional[str]]:
711
+ """(known, parent nameWithOwner or None) from `gh repo view --json parent,nameWithOwner`."""
712
+ if not isinstance(view, dict) or "parent" not in view:
713
+ return False, None
714
+ p = view["parent"]
715
+ if p is None:
716
+ return True, None
717
+ if isinstance(p, dict):
718
+ if p.get("nameWithOwner"):
719
+ return True, str(p["nameWithOwner"])
720
+ owner = p.get("owner")
721
+ login = owner.get("login") if isinstance(owner, dict) else None
722
+ if login and p.get("name"):
723
+ return True, f"{login}/{p['name']}"
724
+ return False, None
725
+
726
+
727
+ def _fork_entry(s: Session, acc: Account, nwo: str, parent: Optional[str], index) -> dict:
728
+ """Status ok | missing | moved | local_path (upstream is a path, not compared) | parent_deleted |
729
+ not_cloned. URLs shown are redacted; a suggested URL is built from a URL without its credentials."""
730
+ from .resolve import parse_url, redact_url
731
+ hit = index.get((acc.host.lower(), nwo.lower()))
732
+ path, rems = hit if hit else (None, {})
733
+ up = rems.get("upstream")
734
+ e = {"name_with_owner": nwo, "parent": parent, "local": str(path) if path else None,
735
+ "upstream_url": redact_url(up), "status": "ok", "fix": None, "account": acc.login, "host": acc.host}
736
+ if parent is None:
737
+ e["status"] = "parent_deleted"
738
+ return e
739
+ if path is None:
740
+ e["status"] = "not_cloned"
741
+ return e
742
+ up_host, up_owner, up_name = parse_url(up) if up else (None, None, None)
743
+ clean_up = _strip_userinfo(up) if up and up_host else None
744
+ origin = rems.get("origin")
745
+ clean_origin = _strip_userinfo(origin) if origin and parse_url(origin)[0] else None
746
+ want = (_swap(clean_up, f"{up_owner}/{up_name}", parent) if clean_up and up_owner else None) \
747
+ or _swap(clean_origin, nwo, parent) or f"git@{acc.host}:{parent}.git"
748
+ q = shlex.quote
749
+ if up is None:
750
+ e["status"] = "missing"
751
+ e["fix"] = f"git -C {q(str(path))} remote add upstream {q(want)}"
752
+ return e
753
+ if not up_host:
754
+ e["status"] = "local_path"
755
+ e["fix"] = f"git -C {q(str(path))} remote set-url upstream {q(want)}"
756
+ return e
757
+ if f"{up_owner}/{up_name}".lower() != parent.lower():
758
+ e["status"] = "moved"
759
+ e["fix"] = f"git -C {q(str(path))} remote set-url upstream {q(want)}"
760
+ return e
761
+
762
+
763
+ def forks(s: Session) -> List[dict]:
764
+ """Every fork of each account, compared with the local clone's upstream remote."""
765
+ if not s.live:
766
+ return []
767
+ index = s.local.by_origin()
768
+ out = []
769
+ for acc in s.accounts():
770
+ t = next(x for x in s.targets if x.acc == acc)
771
+ rows = account_repos(s, t)
772
+ if rows is None:
773
+ continue
774
+ for r in (r for r in rows if r["fork"]):
775
+ nwo = r["name_with_owner"]
776
+ if acc.cli == "glab":
777
+ parent = r.get("parent")
778
+ else:
779
+ view = s.json_call(acc, ["repo", "view", nwo, "--json", "parent,nameWithOwner"], f"repo view {nwo}",
780
+ f"fork {nwo}")
781
+ if view is _FAILED:
782
+ continue
783
+ known, parent = _parent_of(view)
784
+ if not known:
785
+ s.warn(f"{acc.host}: unexpected gh output for {nwo}; skipped")
786
+ continue
787
+ out.append(_fork_entry(s, acc, nwo, parent, index))
788
+ return out
789
+
790
+
791
+ def render_forks(s: Session, entries: List[dict]) -> str:
792
+ home = s.home
793
+ lines = []
794
+ for acc in s.accounts():
795
+ if acc not in s.answered:
796
+ continue
797
+ mine = [e for e in entries if e["account"] == acc.login and e["host"] == acc.host]
798
+ lines.append(f"forks of {acc.login} ({acc.host} via {acc.cli})")
799
+ if not mine:
800
+ lines.append(" no forks")
801
+ continue
802
+ cols = [(safe(e["name_with_owner"]), f"parent {safe(e['parent'] or '(deleted)')}",
803
+ f"local {safe(_tilde(e['local'], home))}" if e["local"] else "not cloned") for e in mine]
804
+ w = [max(len(c[i]) for c in cols) for i in range(3)]
805
+ for e, c in zip(mine, cols):
806
+ lead = f" {c[0]:<{w[0]}} {c[1]:<{w[1]}} {c[2]:<{w[2]}} "
807
+ status = {"ok": "upstream ok", "parent_deleted": "→ upstream is gone; keep or archive",
808
+ "not_cloned": "", "missing": "no upstream remote"}.get(e["status"])
809
+ if e["status"] == "moved":
810
+ from .resolve import parse_url
811
+ _h, o, n = parse_url(e["upstream_url"])
812
+ status = f"upstream remote points to {safe(o)}/{safe(n)}"
813
+ elif e["status"] == "local_path":
814
+ status = f"upstream remote is a local path ({safe(_tilde(e['upstream_url'], home))}); not compared"
815
+ lines.append((lead + (status or "")).rstrip())
816
+ if e["fix"]:
817
+ fix = e["fix"].replace(f"git -C {shlex.quote(e['local'])} ", f"git -C {_sh_path(e['local'], home)} ", 1)
818
+ lines.append(" " * len(lead) + f"→ {safe(fix)}")
819
+ return "\n".join(lines) + ("\n" if lines else "")
820
+
821
+
822
+ # ---------------------------------------------------------------- email
823
+
824
+ def _emails_url(acc: Account) -> str:
825
+ if acc.cli == "glab":
826
+ return f"https://{acc.host}/-/profile/emails"
827
+ return f"https://{acc.host}/settings/emails"
828
+
829
+
830
+ def _verified(s: Session, t: Target) -> Tuple[bool, Optional[Set[str]]]:
831
+ """(answered, verified emails lowercased or None when unknown)."""
832
+ acc, who = t.acc, s.who(t.acc)
833
+ if not s.ready(t):
834
+ return False, None
835
+ if acc.cli == "gh":
836
+ r = s.call(acc, ["api", "--method", "GET", "user/emails", "--paginate", "--jq",
837
+ ".[] | select(.verified) | .email"], "user/emails", who, check=False)
838
+ if r is None:
839
+ return False, None
840
+ if r.returncode != 0:
841
+ if _RATE_RE.search(r.stderr):
842
+ s.warn(f"{acc.host}: rate limited ({_err_line(r)}); {who} skipped")
843
+ return False, None
844
+ if _SCOPE_RE.search(r.stderr):
845
+ s.warn(f"gh token for {acc.login} lacks the user:email scope; run: "
846
+ f"gh auth refresh -h {acc.host} -s user:email")
847
+ return True, None
848
+ s.warn(f"{acc.host}: {_err_line(r)}; {who} skipped")
849
+ return False, None
850
+ return True, {ln.strip().lower() for ln in r.stdout.splitlines() if ln.strip()}
851
+ user = s.json_call(acc, ["api", "--method", "GET", "user"], "api user", who)
852
+ if not isinstance(user, dict):
853
+ if user is not _FAILED:
854
+ s.warn(f"{acc.host}: unexpected glab output for api user; {who} skipped")
855
+ return False, None
856
+ found = {str(user["email"]).lower()} if user.get("email") else set()
857
+ r = s.call(acc, ["api", "--method", "GET", "user/emails"], "api user/emails", who, check=False)
858
+ if r is not None and r.returncode == 0:
859
+ try:
860
+ more = json.loads(r.stdout)
861
+ except ValueError:
862
+ more = []
863
+ for e in more if isinstance(more, list) else []:
864
+ if isinstance(e, dict) and e.get("email") and e.get("confirmed_at", True):
865
+ found.add(str(e["email"]).lower())
866
+ return True, found
867
+
868
+
869
+ def emails(s: Session) -> List[dict]:
870
+ """Per identity: is its email a verified email of its account? verified true / false / null."""
871
+ if not s.live:
872
+ return []
873
+ cache: Dict[Account, Tuple[bool, Optional[Set[str]]]] = {}
874
+ out = []
875
+ for t in s.targets:
876
+ if not t.email:
877
+ continue
878
+ if t.acc not in cache:
879
+ cache[t.acc] = _verified(s, t)
880
+ answered, got = cache[t.acc]
881
+ if not answered:
882
+ continue
883
+ s.answered.add(t.acc)
884
+ verified = None if got is None else t.email.lower() in got
885
+ fix = (f"add it at {_emails_url(t.acc)} or fix the identity" if verified is False else None)
886
+ out.append({"id": t.id, "email": t.email, "host": t.acc.host, "cli": t.acc.cli, "account": t.acc.login,
887
+ "verified": verified, "fix": fix})
888
+ return out
889
+
890
+
891
+ def render_emails(entries: List[dict]) -> str:
892
+ if not entries:
893
+ return ""
894
+ rows = []
895
+ for e in entries:
896
+ if e["verified"] is True:
897
+ rows.append((e, f"verified on {e['host']} ({e['account']})", "✓"))
898
+ elif e["verified"] is False:
899
+ rows.append((e, f"not a verified email of {e['host']} account {e['account']}", "✗"))
900
+ else:
901
+ rows.append((e, f"could not check {e['host']} account {e['account']}", "⚠"))
902
+ iw = max(len(e["id"]) for e in entries)
903
+ ew = max(len(e["email"]) for e in entries)
904
+ tw = max(len(r[1]) for r in rows)
905
+ lines = [f"{e['id']:<{iw}} {e['email']:<{ew}} {text:<{tw}} {sym}" + (f" → {e['fix']}" if e["fix"] else "")
906
+ for e, text, sym in rows]
907
+ return "\n".join(lines) + "\n"
908
+
909
+
910
+ # ---------------------------------------------------------------- similar
911
+
912
+ def _name_of(path: Path, rems: Dict[str, str]) -> str:
913
+ """The last segment of origin (or the first remote), else the dir name."""
914
+ from .resolve import parse_url
915
+ url = rems.get("origin") or next(iter(rems.values()), None)
916
+ if url is None:
917
+ return path.name
918
+ return parse_url(url)[2] or path.name
919
+
920
+
921
+ def similar_names(s: Session, hosts: List[str], patterns: Iterable[str] = ()) -> Tuple[Dict[str, List[str]], int]:
922
+ """({host: names to send}, number of local names not sent): a repo's name goes to a host only when the
923
+ repo has no remote or every remote is on that host; --name patterns (fnmatch) narrow it further."""
924
+ pats = list(patterns)
925
+ want: Dict[str, Set[str]] = {h: set() for h in hosts}
926
+ every: Set[str] = set()
927
+ for path, rems in s.local.repos:
928
+ name = _name_of(path, rems)
929
+ every.add(name)
930
+ if pats and not any(fnmatch.fnmatchcase(name, p) for p in pats):
931
+ continue
932
+ if not rems:
933
+ for h in hosts:
934
+ want[h].add(name)
935
+ continue
936
+ on = {(k[0] if k else None) for k in map(s.local.key, rems.values())}
937
+ if len(on) == 1:
938
+ h = next(iter(on))
939
+ if h in want:
940
+ want[h].add(name)
941
+ chosen = {h: sorted(v, key=str.lower) for h, v in want.items()}
942
+ sent = set().union(*want.values()) if want else set()
943
+ return chosen, len(every - sent)
944
+
945
+
946
+ def _search_accounts(s: Session) -> Tuple[List[Account], List[str]]:
947
+ """One gh account per host (searches are public; the first identity's login is used)."""
948
+ accs: Dict[str, Account] = {}
949
+ skipped = []
950
+ for t in s.targets:
951
+ if t.acc.cli != "gh":
952
+ skipped.append(t.acc.host)
953
+ continue
954
+ accs.setdefault(t.acc.host.lower(), t.acc)
955
+ return list(accs.values()), list(dict.fromkeys(skipped))
956
+
957
+
958
+ def similar(s: Session, names: Dict[str, List[str]], *, confirm: Callable[[str], bool]) -> Optional[dict]:
959
+ """Search each host for public repos named like the local ones: one `gh search repos` per name,
960
+ SEARCH_PAUSE apart, after confirm(question) (the announcement is out by then). None when declined.
961
+ Stops on a host at the first error (rate limit): the rest is not sent. Ctrl-C stops every host and
962
+ returns what was sent so far with "interrupted": true."""
963
+ total = len(set().union(*names.values())) if names else 0
964
+ if not confirm(f"send these {_plural(total, 'name')}?"):
965
+ return None
966
+ accs = {a.host.lower(): a for a in _search_accounts(s)[0]}
967
+ mine: Dict[str, Set[str]] = {}
968
+ for t in s.targets:
969
+ mine.setdefault(t.acc.host.lower(), set()).update(o.lower() for o in (t.acc.login, *t.owners))
970
+ index = s.local.by_origin()
971
+ sent: Dict[str, List[str]] = {}
972
+ failed: List[str] = []
973
+ matches: List[dict] = []
974
+ interrupted = False
975
+ for host, todo in names.items():
976
+ acc = accs[host]
977
+ done: List[str] = []
978
+ sent[acc.host] = done
979
+ try:
980
+ _search_host(s, acc, host, todo, done, failed, matches, mine, index)
981
+ except KeyboardInterrupt:
982
+ interrupted = True
983
+ s.warn(f"interrupted: {acc.host} answered {_plural(len(done), 'name')}; the rest were not sent")
984
+ break
985
+ flat = sorted(set().union(*sent.values()) if sent else set(), key=str.lower)
986
+ return {"sent": flat, "sent_to": sent, "failed": failed, "matches": matches, "interrupted": interrupted}
987
+
988
+
989
+ def _search_host(s: Session, acc: Account, host: str, todo: List[str], done: List[str], failed: List[str],
990
+ matches: List[dict], mine: Dict[str, Set[str]], index) -> None:
991
+ for n, name in enumerate(todo):
992
+ if n:
993
+ time.sleep(SEARCH_PAUSE)
994
+ argv = ["search", "repos", "--match", "name", "--limit", str(SEARCH_LIMIT),
995
+ "--json", "nameWithOwner,isFork", "--", name]
996
+ s.tried.add(acc)
997
+ try:
998
+ r = s.run(acc, argv)
999
+ except ConfigError as e:
1000
+ left = "nothing sent to" if not done else f"{_plural(len(done), 'name')} sent; the rest not sent to"
1001
+ s.warn(f"{redact(str(e))}; {left} {acc.host}")
1002
+ return
1003
+ if r.returncode != 0:
1004
+ failed.append(name)
1005
+ if r.returncode == 124:
1006
+ s.warn(f"{acc.host}: gh timed out after {_plural(len(done), 'name')}; the rest were not sent")
1007
+ elif _RATE_RE.search(r.stderr):
1008
+ s.warn(f"{acc.host}: rate limited after {len(done)} names; the rest were not sent")
1009
+ else:
1010
+ s.warn(f"{acc.host}: {_err_line(r)}; stopped after {_plural(len(done), 'name')}; "
1011
+ "the rest were not sent")
1012
+ return
1013
+ try:
1014
+ got = json.loads(r.stdout.strip() or "[]")
1015
+ except ValueError:
1016
+ got = []
1017
+ done.append(name)
1018
+ s.answered.add(acc)
1019
+ results = []
1020
+ for x in got if isinstance(got, list) else []:
1021
+ nwo = str(x.get("nameWithOwner") or "") if isinstance(x, dict) else ""
1022
+ if not nwo:
1023
+ continue
1024
+ hit = index.get((host, nwo.lower()))
1025
+ results.append({"name_with_owner": nwo, "fork": bool(x.get("isFork")),
1026
+ "yours": nwo.split("/", 1)[0].lower() in mine.get(host, set()),
1027
+ "cloned": str(hit[0]) if hit else None})
1028
+ matches.append({"name": name, "host": acc.host, "results": results})
1029
+
1030
+
1031
+ def render_similar(rep: dict, home: Path) -> str:
1032
+ if not rep["matches"]:
1033
+ return ""
1034
+ w = max(len(safe(m["name"])) for m in rep["matches"])
1035
+ lines = []
1036
+ for m in rep["matches"]:
1037
+ res = m["results"]
1038
+ if not res:
1039
+ lines.append(f" {safe(m['name']):<{w}} 0")
1040
+ continue
1041
+ shown = []
1042
+ for r in res:
1043
+ tags = (["yours"] if r["yours"] else ["not yours"]) + (["fork"] if r["fork"] else [])
1044
+ if r["cloned"]:
1045
+ tags.append(f"cloned {safe(_tilde(r['cloned'], home))}")
1046
+ shown.append(f"{safe(r['name_with_owner'])} ({', '.join(tags)})")
1047
+ word = "match" if len(res) == 1 else "matches"
1048
+ lines.append(f" {safe(m['name']):<{w}} {len(res)} public {word}: {', '.join(shown)}")
1049
+ return "\n".join(lines) + "\n"
1050
+
1051
+
1052
+ # ---------------------------------------------------------------- CLI glue
1053
+
1054
+ def _session(args) -> Session:
1055
+ from . import cli
1056
+ cfg, env = cli._load_all(args)
1057
+ return Session(cfg, env.home, args.identity, as_json=jsonout.wants_json(args))
1058
+
1059
+
1060
+ def _finish(s: Session, args, command: str, key: str, data, text: str, *, rc: int = 0, ok: bool = True) -> int:
1061
+ """Print the report (or the JSON object). Exit 2 when --online was given and no account answered; a
1062
+ config-only [forge] online = true only warns (the ⚠ lines say why) and exits as usual."""
1063
+ if s.failed_all():
1064
+ if s.explicit:
1065
+ return _no_answer(s, command)
1066
+ s.warn(f"{command}: no account could be read; nothing checked")
1067
+ if s.as_json:
1068
+ jsonout.emit(command, {"ok": ok, "online": s.live, "sent": s.record(), key: data, "warnings": s.warnings})
1069
+ else:
1070
+ sys.stdout.write(text)
1071
+ return rc
1072
+
1073
+
1074
+ def _no_answer(s: Session, command: str, extra: Optional[dict] = None) -> int:
1075
+ msg = f"{command}: no account could be read (see the warnings above)"
1076
+ if s.as_json:
1077
+ jsonout.emit(command, {"ok": False, "error": msg, "online": True, "sent": s.record(), **(extra or {}),
1078
+ "warnings": s.warnings})
1079
+ else:
1080
+ print(f"✗ {msg}", file=sys.stderr)
1081
+ return 2
1082
+
1083
+
1084
+ def cmd_repos(args) -> int:
1085
+ s = _session(args)
1086
+ what = ("for the repo lists and organisations of these accounts, and the repo list of each organisation"
1087
+ if args.orgs else "for the repo lists of these accounts")
1088
+ rc = s.gate("forge repos", what, "nothing else", explicit=args.online, args=args)
1089
+ if rc is not None:
1090
+ return rc
1091
+ groups = repos(s, orgs=args.orgs, not_cloned=args.not_cloned)
1092
+ return _finish(s, args, "forge repos", "identities", groups, render_repos(groups))
1093
+
1094
+
1095
+ def cmd_forks(args) -> int:
1096
+ s = _session(args)
1097
+ rc = s.gate("forge forks", "for the repo lists of these accounts and the parent of each fork",
1098
+ "only the fork names it got back", explicit=args.online, args=args)
1099
+ if rc is not None:
1100
+ return rc
1101
+ entries = forks(s)
1102
+ return _finish(s, args, "forge forks", "forks", entries, render_forks(s, entries))
1103
+
1104
+
1105
+ def cmd_email(args) -> int:
1106
+ s = _session(args)
1107
+ rc = s.gate("forge email", "for the verified emails of these accounts", "nothing; emails are compared locally",
1108
+ explicit=args.online, args=args)
1109
+ if rc is not None:
1110
+ return rc
1111
+ entries = emails(s)
1112
+ bad = any(e["verified"] is False for e in entries)
1113
+ return _finish(s, args, "forge email", "emails", entries, render_emails(entries), rc=1 if bad else 0, ok=not bad)
1114
+
1115
+
1116
+ def _roots_text(s: Session) -> str:
1117
+ return ", ".join(safe(_tilde(r, s.home)) for r in s.local.roots)
1118
+
1119
+
1120
+ def cmd_similar(args) -> int:
1121
+ from .args import fail
1122
+ command = "forge similar"
1123
+ s = _session(args)
1124
+ accs, glab_hosts = _search_accounts(s)
1125
+ for h in glab_hosts:
1126
+ s.warn(f"{h}: forge similar searches with gh only; glab accounts skipped")
1127
+ if not accs:
1128
+ s.flush()
1129
+ return fail(f"{command} needs a gh_account (it searches with gh search repos)", args)
1130
+ s.targets = [t for t in s.targets if t.acc.cli == "gh"]
1131
+ s.explicit = bool(args.online)
1132
+ online = bool(args.online or s.cfg.forge.online)
1133
+ if not (online and (args.send_repo_names or s.cfg.forge.similar)):
1134
+ names, _n = similar_names(s, [a.host.lower() for a in accs], args.name or ())
1135
+ total = len(set().union(*names.values()))
1136
+ where = " and ".join(f"{a.host} via gh" for a in accs)
1137
+ s.flush()
1138
+ msg = (f"{command} sends your local repo names ({_plural(total, 'name')} from {_roots_text(s)}) to {where} "
1139
+ "as search queries")
1140
+ if s.as_json:
1141
+ return fail(f"{msg}; re-run with --online --send-repo-names to allow that", args)
1142
+ print(f"✗ {msg}", file=sys.stderr)
1143
+ print("→ re-run with --online --send-repo-names to allow that", file=sys.stderr)
1144
+ return 2
1145
+ if not available("gh"):
1146
+ if args.online:
1147
+ s.flush()
1148
+ return fail(f"{command} {NEEDS_CLI}", args)
1149
+ s.warn(f"[forge] online = true, but {command} {NEEDS_CLI}; nothing checked")
1150
+ s.flush()
1151
+ return _similar_json(s, None, 0) if s.as_json else 0
1152
+ if s.as_json and not args.yes:
1153
+ s.flush()
1154
+ return fail(f"{command} --json needs -y (there is no question under --json)", args)
1155
+ if not args.yes and not sys.stdin.isatty():
1156
+ s.flush()
1157
+ return fail("no terminal to confirm on; re-run with --yes", args)
1158
+ names, not_sent = similar_names(s, [a.host.lower() for a in accs], args.name or ())
1159
+ names = {h: v for h, v in names.items() if v}
1160
+ if not names:
1161
+ s.flush()
1162
+ if s.as_json:
1163
+ return _similar_json(s, {"sent": [], "sent_to": {}, "failed": [], "matches": []}, not_sent)
1164
+ print(f"✓ no local repo names to send (scanned {_roots_text(s)})")
1165
+ return 0
1166
+ s.live = True
1167
+ by_host = {a.host.lower(): a for a in accs}
1168
+ for host, todo in names.items():
1169
+ s.announce(f"for public repos named like {_plural(len(todo), 'local repo')} (gh search repos <name>, "
1170
+ "one search per name)", [by_host[host]],
1171
+ f"these {_plural(len(todo), 'repo name')}: {', '.join(map(safe, todo))}; "
1172
+ "no paths, emails or contents")
1173
+ s.flush()
1174
+
1175
+ def ask(question: str) -> bool:
1176
+ if args.yes:
1177
+ return True
1178
+ from rich.markup import escape
1179
+
1180
+ from . import view
1181
+ return view.confirm(escape(question))
1182
+
1183
+ rep = similar(s, names, confirm=ask)
1184
+ if rep is None:
1185
+ print("✗ nothing sent", file=sys.stderr)
1186
+ return 1
1187
+ if rep.pop("interrupted", False):
1188
+ if s.as_json:
1189
+ _similar_json(s, {**rep, "error": "interrupted; the rest were not sent"}, not_sent, ok=False)
1190
+ else:
1191
+ sys.stdout.write(render_similar(rep, s.home))
1192
+ sent = ", ".join(map(safe, rep["sent"])) or "none"
1193
+ print(f"✗ interrupted; sent so far: {sent}", file=sys.stderr)
1194
+ return 130
1195
+ if s.failed_all() and s.explicit:
1196
+ return _no_answer(s, command, {**rep, "not_sent": not_sent})
1197
+ if s.as_json:
1198
+ return _similar_json(s, rep, not_sent)
1199
+ sys.stdout.write(render_similar(rep, s.home))
1200
+ return 0
1201
+
1202
+
1203
+ def _similar_json(s: Session, rep: Optional[dict], not_sent: int, *, ok: bool = True) -> int:
1204
+ rep = rep or {"sent": [], "sent_to": {}, "failed": [], "matches": []}
1205
+ jsonout.emit("forge similar", {"ok": ok, "online": s.live, **rep, "not_sent": not_sent, "warnings": s.warnings})
1206
+ return 0
1207
+
1208
+
1209
+ def _common_flags(p, *, what: str) -> None:
1210
+ p.add_argument("--identity", action="append", metavar="ID",
1211
+ help="only this identity (repeatable; 'default' is [default])").gitpro_values = "identities"
1212
+ p.add_argument("--online", action="store_true", help=f"allow the network ({what})")
1213
+ p.add_argument("--json", action="store_true", help="machine-readable output")
1214
+
1215
+
1216
+ def register(sub, common, confirm) -> None:
1217
+ from .args import capability, fail
1218
+ p = sub.add_parser("forge", parents=[common], help="read-only view of your gh/glab accounts (online)",
1219
+ description="Read your GitHub/GitLab accounts through gh/glab. Every call is a read; "
1220
+ "the first line says what is sent. Needs --online or [forge] online = true.")
1221
+ p.set_defaults(func=lambda a: fail("forge needs a subcommand: repos, forks, email or similar "
1222
+ "(see gitpro forge -h)", a), command="forge")
1223
+ fs = p.add_subparsers(dest="forge_command", metavar="SUBCOMMAND")
1224
+ r = fs.add_parser("repos", parents=[common], help="repos of each account, and which are not cloned here")
1225
+ _common_flags(r, what="gh repo list / glab repo list per account")
1226
+ r.add_argument("--not-cloned", action="store_true", help="list only the repos with no clone under [scan] roots")
1227
+ r.add_argument("--orgs", action="store_true", help="also the repos of each organisation the account is in (gh)")
1228
+ r.set_defaults(func=cmd_repos, command="forge repos")
1229
+ f = fs.add_parser("forks", parents=[common], help="forks whose upstream moved, vanished or has no local remote")
1230
+ _common_flags(f, what="repo list, then gh repo view per fork")
1231
+ f.set_defaults(func=cmd_forks, command="forge forks")
1232
+ e = fs.add_parser("email", parents=[common], help="is each identity's email verified on its account?")
1233
+ _common_flags(e, what="gh api user/emails / glab api user")
1234
+ e.set_defaults(func=cmd_email, command="forge email")
1235
+ sm = fs.add_parser("similar", parents=[common, confirm],
1236
+ help="search public repos named like your local ones (sends repo names; opt-in)")
1237
+ _common_flags(sm, what="gh search repos per name")
1238
+ sm.add_argument("--send-repo-names", action="store_true",
1239
+ help="allow sending your local repo names as searches (needed on every run, "
1240
+ "or [forge] similar = true)")
1241
+ sm.add_argument("--name", action="append", metavar="PATTERN", help="only local names matching PATTERN "
1242
+ "(fnmatch, repeatable)")
1243
+ sm.set_defaults(func=cmd_similar, command="forge similar")
1244
+ capability("forge", "forge.similar")