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/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
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")
|