pi-gogs-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1074 @@
1
+ #!/usr/bin/env python3
2
+ """gogs-cli — reusable, agent-agnostic Gogs capability CLI.
3
+
4
+ gh-style command surface wrapping the Gogs API and (for pull requests, which
5
+ have no API) the Gogs Web UI. Stateless: no workflow mapping, no local database,
6
+ no LI/LPR ids. Any agent (Codex, Claude Code, pi, ...) calls the same `gogs-cli`
7
+ command or imports this module directly.
8
+
9
+ Command tree (mirrors `gh`):
10
+ repo view | list (API)
11
+ issue list | view | create | edit | close | reopen | comment | develop (API; develop = local branch)
12
+ label list (API)
13
+ pr list (web UI scrape: no PR list API)
14
+ pr view (API: PR read via issues endpoint)
15
+ pr create | merge | close | reopen (web UI write + API verify)
16
+
17
+ PR ops drive the Web UI via the webform engine: session-cookie login + CSRF
18
+ form POSTs, Python stdlib only — no browser, no OS dependency, runs anywhere
19
+ (desktop, container, CI).
20
+
21
+ Auth/config (loaded from disk, never printed):
22
+ ~/.config/gogs-cli/config -> GOGS_TOKEN (+ GOGS_USERNAME/GOGS_PASSWORD
23
+ for webform PR ops)
24
+ ~/.codex/local/gogs-workflow/.env -> legacy location, still honored
25
+ <repo-root>/.gogs.local.env -> GOGS_BASE_URL, GOGS_WEB_BASE_URL,
26
+ GOGS_USERNAME, GOGS_PASSWORD
27
+ GOGS_FALLBACK_URLS env -> extra web bases probed in order when
28
+ the configured base is unreachable
29
+ Default :owner/:repo comes from `git remote get-url origin`.
30
+
31
+ Interface contract: references/gogs-api.md (authoritative endpoint map).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import functools
38
+ import http.cookiejar
39
+ import json
40
+ import os
41
+ import re
42
+ import subprocess
43
+ import sys
44
+ from dataclasses import dataclass
45
+ from pathlib import Path
46
+ from typing import Any, Callable, Sequence
47
+ from urllib import error, request
48
+ from urllib.parse import quote, urlencode, urlparse
49
+
50
+ USER_AGENT = "gogs-cli/1.0"
51
+ ENV_GLOBAL_CANDIDATES = [
52
+ Path("~/.config/gogs-cli/config").expanduser(), # canonical
53
+ Path("~/.codex/local/gogs-workflow/.env").expanduser(), # legacy compat
54
+ ]
55
+
56
+ # Optional extra web bases tried (in order) when the configured base is
57
+ # unreachable — useful when one instance is reachable via LAN at home and via
58
+ # a public host elsewhere. Same instance, several addresses. There is no
59
+ # built-in default: set GOGS_FALLBACK_URLS="http://lan-host:port https://public.host".
60
+
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # Config / env
64
+ # --------------------------------------------------------------------------- #
65
+ def load_env_file(path: Path) -> None:
66
+ if not path.exists():
67
+ return
68
+ for raw in path.read_text(encoding="utf-8").splitlines():
69
+ line = raw.strip()
70
+ if not line or line.startswith("#") or "=" not in line:
71
+ continue
72
+ key, value = line.split("=", 1)
73
+ key = key.strip()
74
+ value = value.strip()
75
+ if not key or key in os.environ:
76
+ continue
77
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
78
+ value = value[1:-1]
79
+ os.environ[key] = value
80
+
81
+
82
+ def load_all_env(repo_root: Path | None) -> None:
83
+ if repo_root is not None:
84
+ load_env_file(repo_root / ".gogs.local.env")
85
+ for candidate in ENV_GLOBAL_CANDIDATES:
86
+ load_env_file(candidate)
87
+
88
+
89
+ def resolve_repo_root(path: str | None) -> Path:
90
+ start = Path(path or os.getcwd()).expanduser().resolve()
91
+ res = subprocess.run(
92
+ ["git", "rev-parse", "--show-toplevel"],
93
+ cwd=start, check=False, text=True,
94
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
95
+ )
96
+ if res.returncode != 0:
97
+ raise SystemExit(f"Not inside a git repository: {start}")
98
+ return Path(res.stdout.strip()).resolve()
99
+
100
+
101
+ def run_git(repo_root: Path, args: Sequence[str]) -> str:
102
+ res = subprocess.run(
103
+ ["git", *args], cwd=repo_root, check=False, text=True,
104
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
105
+ )
106
+ if res.returncode != 0:
107
+ detail = res.stderr.strip() or res.stdout.strip()
108
+ raise SystemExit(f"git {' '.join(args)} failed: {detail}")
109
+ return res.stdout.strip()
110
+
111
+
112
+ # --------------------------------------------------------------------------- #
113
+ # Target resolution
114
+ # --------------------------------------------------------------------------- #
115
+ @dataclass(frozen=True)
116
+ class Target:
117
+ owner: str
118
+ repo: str
119
+ host: str
120
+ web_url: str
121
+ remote_url: str = ""
122
+ repo_root: str = ""
123
+
124
+
125
+ def _probe_base(base: str, timeout: float = 2.0) -> bool:
126
+ """True if the host responds at all; any HTTP status counts as reachable."""
127
+ try:
128
+ req = request.Request(
129
+ base.rstrip("/") + "/api/v1/version",
130
+ headers={"User-Agent": USER_AGENT}, method="GET",
131
+ )
132
+ with request.urlopen(req, timeout=timeout):
133
+ return True
134
+ except error.HTTPError:
135
+ return True # connected; the status code is irrelevant for a connectivity probe
136
+ except Exception:
137
+ return False
138
+
139
+
140
+ @functools.lru_cache(maxsize=1)
141
+ def resolved_web_base() -> str:
142
+ """Live Gogs web base: configured value first, then GOGS_FALLBACK_URLS in
143
+ order. Each candidate is probed; the first that responds wins. Cached per
144
+ process."""
145
+ configured = (os.environ.get("GOGS_WEB_BASE_URL") or os.environ.get("GOGS_BASE_URL") or "").strip().rstrip("/")
146
+ if configured.endswith("/api/v1"):
147
+ configured = configured[: -len("/api/v1")]
148
+ chain: list[str] = []
149
+ if configured:
150
+ chain.append(configured)
151
+ for b in (os.environ.get("GOGS_FALLBACK_URLS") or "").replace(",", " ").split():
152
+ b = b.strip().rstrip("/")
153
+ if b and b not in chain:
154
+ chain.append(b)
155
+ if not chain:
156
+ raise SystemExit(
157
+ "No Gogs base URL configured. Set GOGS_BASE_URL (or GOGS_WEB_BASE_URL)\n"
158
+ f"in {ENV_GLOBAL_CANDIDATES[0]} or <repo-root>/.gogs.local.env, e.g.:\n"
159
+ " GOGS_BASE_URL=https://your-gogs.example.com\n"
160
+ "Optional: GOGS_FALLBACK_URLS=\"http://lan-host:port https://public.host\"\n"
161
+ "lets the same invocation work from several networks."
162
+ )
163
+ for b in chain:
164
+ if _probe_base(b):
165
+ return b
166
+ return chain[0] # nothing responded; let a later call fail with a real error
167
+
168
+
169
+ def configured_web_base(default: str) -> str:
170
+ return resolved_web_base()
171
+
172
+
173
+ def _target_from_spec(repo_spec: str, repo_root: Path | None) -> Target:
174
+ parts = repo_spec.strip().strip("/").split("/")
175
+ if len(parts) < 2:
176
+ raise SystemExit(f"Cannot parse OWNER/REPO from: {repo_spec}")
177
+ owner, repo = parts[-2], re.sub(r"\.git$", "", parts[-1])
178
+ load_all_env(repo_root)
179
+ base = resolved_web_base()
180
+ host = urlparse(base).netloc or "gogs"
181
+ return Target(owner=owner, repo=repo, host=host, web_url=f"{base}/{owner}/{repo}")
182
+
183
+
184
+ def _target_from_remote(remote: str, repo_root_arg: str | None) -> Target:
185
+ root = resolve_repo_root(repo_root_arg)
186
+ load_all_env(root)
187
+ remote_url = run_git(root, ["remote", "get-url", remote or "origin"])
188
+
189
+ ssh = re.match(r"^(?:ssh://)?git@([^:/]+)(?::|/)(.+?)(?:\.git)?$", remote_url.strip())
190
+ if ssh:
191
+ host = ssh.group(1)
192
+ p = ssh.group(2).strip("/").split("/")
193
+ if len(p) < 2:
194
+ raise SystemExit(f"Cannot parse owner/repo from remote: {remote_url}")
195
+ owner, repo = p[-2], p[-1]
196
+ web = f"{configured_web_base(f'https://{host}')}/{owner}/{repo}"
197
+ return Target(owner, repo, host, web, remote_url, str(root))
198
+
199
+ parsed = urlparse(remote_url.strip())
200
+ if parsed.scheme in {"http", "https"} and parsed.netloc:
201
+ pp = parsed.path.strip("/").split("/")
202
+ if len(pp) < 2:
203
+ raise SystemExit(f"Cannot parse owner/repo from remote: {remote_url}")
204
+ owner, repo = pp[-2], re.sub(r"\.git$", "", pp[-1])
205
+ prefix = "/".join(pp[:-2])
206
+ base = configured_web_base(f"{parsed.scheme}://{parsed.netloc}")
207
+ web_path = "/".join(s for s in [prefix, owner, repo] if s)
208
+ return Target(owner, repo, parsed.netloc, f"{base}/{web_path}", remote_url, str(root))
209
+
210
+ raise SystemExit(f"Unsupported remote URL format: {remote_url}")
211
+
212
+
213
+ def resolve_target(repo_spec: str | None, remote: str, repo_root_arg: str | None) -> Target:
214
+ if repo_spec and "/" in repo_spec and not repo_spec.startswith("http"):
215
+ root: Path | None = resolve_repo_root(repo_root_arg) if repo_root_arg else None
216
+ return _target_from_spec(repo_spec, root)
217
+ return _target_from_remote(remote, repo_root_arg)
218
+
219
+
220
+ # --------------------------------------------------------------------------- #
221
+ # HTTP client
222
+ # --------------------------------------------------------------------------- #
223
+ def _request(
224
+ url: str, method: str, path_for_msg: str,
225
+ payload: dict[str, Any] | None = None,
226
+ ok: tuple[int, ...] = (200,),
227
+ token_required: bool = True,
228
+ ) -> Any:
229
+ headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
230
+ data = None
231
+ if payload is not None:
232
+ data = json.dumps(payload).encode("utf-8")
233
+ headers["Content-Type"] = "application/json"
234
+ if token_required:
235
+ headers["Authorization"] = f"token {gogs_token()}"
236
+ req = request.Request(url, data=data, method=method, headers=headers)
237
+ try:
238
+ with request.urlopen(req, timeout=30) as resp:
239
+ body = resp.read().decode("utf-8")
240
+ if resp.status not in ok:
241
+ raise SystemExit(f"{method} {path_for_msg} failed: HTTP {resp.status}: {body}")
242
+ return json.loads(body) if body else {}
243
+ except error.HTTPError as exc:
244
+ detail = exc.read().decode("utf-8", errors="replace")
245
+ raise SystemExit(f"{method} {path_for_msg} failed: HTTP {exc.code}: {detail}") from exc
246
+ except error.URLError as exc:
247
+ raise SystemExit(f"{method} {path_for_msg} failed: {exc.reason}") from exc
248
+
249
+
250
+ def api(
251
+ t: Target, method: str, path: str,
252
+ payload: dict[str, Any] | None = None,
253
+ ok: tuple[int, ...] = (200,), token_required: bool = True,
254
+ ) -> Any:
255
+ return _request(f"{api_base(t)}/{path.lstrip('/')}", method, path, payload, ok, token_required)
256
+
257
+
258
+ def _global_api_base() -> str:
259
+ explicit = os.environ.get("GOGS_API_BASE_URL")
260
+ if explicit:
261
+ base = explicit.rstrip("/")
262
+ return base if base.endswith("/api/v1") else f"{base}/api/v1"
263
+ return resolved_web_base().rstrip("/") + "/api/v1"
264
+
265
+
266
+ def api_root(
267
+ method: str, path: str,
268
+ payload: dict[str, Any] | None = None,
269
+ ok: tuple[int, ...] = (200,), token_required: bool = True,
270
+ ) -> Any:
271
+ return _request(f"{_global_api_base()}/{path.lstrip('/')}", method, path, payload, ok, token_required)
272
+
273
+
274
+ def api_base(t: Target) -> str:
275
+ explicit = os.environ.get("GOGS_API_BASE_URL")
276
+ if explicit:
277
+ base = explicit.rstrip("/")
278
+ return base if base.endswith("/api/v1") else f"{base}/api/v1"
279
+ owner_repo = f"/{t.owner}/{t.repo}"
280
+ if t.web_url.endswith(owner_repo):
281
+ base = t.web_url[: -len(owner_repo)]
282
+ else:
283
+ base = resolved_web_base()
284
+ return f"{base.rstrip('/')}/api/v1"
285
+
286
+
287
+ def token_setup_message() -> str:
288
+ base = resolved_web_base()
289
+ return "\n".join([
290
+ "GOGS_TOKEN is not set. Gogs API operations require an access token.",
291
+ "",
292
+ "Create one in Gogs:",
293
+ f"1. Log in and open: {base}/user/settings/applications",
294
+ "2. Generate a token (for example named: gogs-cli)",
295
+ f"3. Save it in: {ENV_GLOBAL}",
296
+ "",
297
+ "Example:",
298
+ "mkdir -p ~/.config/gogs-cli",
299
+ "printf 'GOGS_TOKEN=%s\\n' '<token>' >> ~/.config/gogs-cli/config",
300
+ "chmod 600 ~/.config/gogs-cli/config",
301
+ ])
302
+
303
+
304
+ def gogs_token() -> str:
305
+ token = os.environ.get("GOGS_TOKEN", "").strip()
306
+ if not token:
307
+ raise SystemExit(token_setup_message())
308
+ return token
309
+
310
+
311
+ # --------------------------------------------------------------------------- #
312
+ # Output
313
+ # --------------------------------------------------------------------------- #
314
+ def emit(obj: Any, json_mode: bool, human: Callable[[Any], None]) -> None:
315
+ if json_mode:
316
+ print(json.dumps(obj, ensure_ascii=False, indent=2))
317
+ else:
318
+ human(obj)
319
+
320
+
321
+ def read_text(body: str, body_file: str | None) -> str:
322
+ if body_file:
323
+ if body_file == "-":
324
+ return sys.stdin.read()
325
+ return Path(body_file).expanduser().read_text(encoding="utf-8")
326
+ return body or ""
327
+
328
+
329
+ def _owner_repo(t: Target) -> str:
330
+ return f"repos/{quote(t.owner)}/{quote(t.repo)}"
331
+
332
+
333
+ # --------------------------------------------------------------------------- #
334
+ # repo
335
+ # --------------------------------------------------------------------------- #
336
+ def repo_view(t: Target, json_mode: bool) -> None:
337
+ emit(api(t, "GET", _owner_repo(t)), json_mode, _fmt_repo)
338
+
339
+
340
+ def repo_list(user: str | None, limit: int, json_mode: bool, remote: str, repo_root_arg: str | None) -> None:
341
+ load_all_env(resolve_repo_root(repo_root_arg) if repo_root_arg else None)
342
+ rows: list[dict[str, Any]] = []
343
+ page = 1
344
+ base_path = f"users/{quote(user)}/repos" if user else "user/repos"
345
+ while len(rows) < limit and page <= 20:
346
+ batch = api_root("GET", f"{base_path}?page={page}")
347
+ if not batch:
348
+ break
349
+ rows.extend(batch)
350
+ page += 1
351
+ rows = rows[:limit]
352
+ emit(rows, json_mode, _fmt_repo_list)
353
+
354
+
355
+ def _fmt_repo(r: dict[str, Any]) -> None:
356
+ print(f"{r.get('full_name') or '/'.join(filter(None, [r.get('owner'), r.get('name')]))}")
357
+ print(f" url: {r.get('html_url') or r.get('website') or ''}")
358
+ print(f" desc: {(r.get('description') or '').strip() or '(none)'}")
359
+ print(f" default branch: {r.get('default_branch')}")
360
+ print(f" private: {bool(r.get('private'))}")
361
+
362
+
363
+ def _fmt_repo_list(rows: list[dict[str, Any]]) -> None:
364
+ if not rows:
365
+ print("(no repositories)")
366
+ return
367
+ for r in rows:
368
+ print(f"{r.get('full_name') or r.get('name')}\t{(r.get('description') or '').strip()}")
369
+
370
+
371
+ # --------------------------------------------------------------------------- #
372
+ # label
373
+ # --------------------------------------------------------------------------- #
374
+ def label_list(t: Target, json_mode: bool) -> None:
375
+ emit(api(t, "GET", f"{_owner_repo(t)}/labels"), json_mode, _fmt_label_list)
376
+
377
+
378
+ def resolve_label_ids(t: Target, names: list[str]) -> list[int]:
379
+ if not names:
380
+ return []
381
+ labels = api(t, "GET", f"{_owner_repo(t)}/labels")
382
+ by_name = {lab.get("name"): lab.get("id") for lab in labels}
383
+ ids: list[int] = []
384
+ for n in names:
385
+ if n not in by_name:
386
+ avail = ", ".join(sorted(str(k) for k in by_name if k)) or "(none)"
387
+ raise SystemExit(f"Unknown label: {n}. Available: {avail}")
388
+ ids.append(int(by_name[n]))
389
+ return ids
390
+
391
+
392
+ def _fmt_label_list(rows: list[dict[str, Any]]) -> None:
393
+ if not rows:
394
+ print("(no labels)")
395
+ return
396
+ for lab in rows:
397
+ print(f"{lab.get('id')}\t{lab.get('name')}\t{lab.get('color', '')}")
398
+
399
+
400
+ # --------------------------------------------------------------------------- #
401
+ # issue
402
+ # --------------------------------------------------------------------------- #
403
+ def _query(**params: Any) -> str:
404
+ pairs = [(k, v) for k, v in params.items() if v not in (None, "", [])]
405
+ if not pairs:
406
+ return ""
407
+ return "?" + "&".join(f"{k}={quote(str(v), safe=',')}" for k, v in pairs)
408
+
409
+
410
+ def _fetch_issues(t: Target, state: str, label_ids: list[int], assignee: str | None, cap: int) -> list[dict[str, Any]]:
411
+ """Page the issues endpoint for one state. Gogs ignores `limit`/`type` and
412
+ pages by `?page=` (~10/page); `state=all` is not honored, so callers pass a
413
+ concrete state and combine open+closed."""
414
+ q = _query(state=state, labels=",".join(str(i) for i in label_ids) or None, assignee=assignee)
415
+ sep = "&" if q else "?"
416
+ rows: list[dict[str, Any]] = []
417
+ page = 1
418
+ while len(rows) < cap and page <= 20:
419
+ batch = api(t, "GET", f"{_owner_repo(t)}/issues{q}{sep}page={page}")
420
+ if not batch:
421
+ break
422
+ rows.extend(batch)
423
+ page += 1
424
+ return rows
425
+
426
+
427
+ def issue_list(t: Target, state: str, labels: list[str], assignee: str | None, limit: int, json_mode: bool) -> None:
428
+ label_ids = resolve_label_ids(t, labels)
429
+ states = ["open", "closed"] if state == "all" else [state]
430
+ rows: list[dict[str, Any]] = []
431
+ for s in states:
432
+ rows.extend(_fetch_issues(t, s, label_ids, assignee, max(limit - len(rows), 0) + 1))
433
+ if len(rows) >= limit:
434
+ break
435
+ emit(rows[:limit], json_mode, _fmt_issue_list)
436
+
437
+
438
+ def issue_view(t: Target, number: int, json_mode: bool) -> None:
439
+ emit(api(t, "GET", f"{_owner_repo(t)}/issues/{number}"), json_mode, _fmt_issue)
440
+
441
+
442
+ def issue_create(
443
+ t: Target, title: str, body: str, assignee: str | None,
444
+ milestone: int | None, label_names: list[str], closed: bool, json_mode: bool,
445
+ ) -> None:
446
+ payload: dict[str, Any] = {"title": title, "body": body}
447
+ if assignee:
448
+ payload["assignee"] = assignee
449
+ if milestone is not None:
450
+ payload["milestone"] = milestone
451
+ ids = resolve_label_ids(t, label_names)
452
+ if ids:
453
+ payload["labels"] = ids
454
+ if closed:
455
+ payload["closed"] = True
456
+ emit(api(t, "POST", f"{_owner_repo(t)}/issues", payload, ok=(200, 201)), json_mode, _fmt_issue)
457
+
458
+
459
+ def issue_edit(
460
+ t: Target, number: int, title: str | None, body: str | None,
461
+ state: str | None, json_mode: bool,
462
+ ) -> None:
463
+ payload: dict[str, Any] = {}
464
+ if title:
465
+ payload["title"] = title
466
+ if body is not None:
467
+ payload["body"] = body
468
+ if state:
469
+ payload["state"] = state
470
+ if not payload:
471
+ raise SystemExit("issue edit: nothing to change (give --title/--body/--state).")
472
+ emit(api(t, "PATCH", f"{_owner_repo(t)}/issues/{number}", payload, ok=(200, 201)), json_mode, _fmt_issue)
473
+
474
+
475
+ def issue_set_state(t: Target, number: int, state: str, json_mode: bool) -> None:
476
+ emit(api(t, "PATCH", f"{_owner_repo(t)}/issues/{number}", {"state": state}, ok=(200, 201)), json_mode, _fmt_issue)
477
+
478
+
479
+ def issue_comment(t: Target, number: int, body: str, json_mode: bool) -> None:
480
+ if not body.strip():
481
+ raise SystemExit("issue comment: --body (or -F) is required and must not be empty.")
482
+ emit(api(t, "POST", f"{_owner_repo(t)}/issues/{number}/comments", {"body": body}, ok=(200, 201)), json_mode, _fmt_comment)
483
+
484
+
485
+ def _slugify(text: str) -> str:
486
+ return (re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") or "issue")[:40]
487
+
488
+
489
+ def issue_develop(t: Target, number: int, base: str, name: str | None, json_mode: bool) -> None:
490
+ """Create a local branch off <base>, named from the issue (gh `issue develop`)."""
491
+ if not t.repo_root:
492
+ raise SystemExit(
493
+ "issue develop: needs the repo context — run it from inside the repo "
494
+ "without an explicit OWNER/REPO (the branch is created locally from "
495
+ "the origin remote's issue repo)."
496
+ )
497
+ root = Path(t.repo_root)
498
+ data = api(t, "GET", f"{_owner_repo(t)}/issues/{number}")
499
+ branch = name or f"{number}-{_slugify(data.get('title') or '')}"
500
+ run_git(root, ["checkout", "-b", branch, base])
501
+ obj = {"issue": number, "branch": branch, "base": base, "title": data.get("title") or ""}
502
+ emit(obj, json_mode, lambda o: print(
503
+ f"created branch '{o['branch']}' off '{o['base']}' for issue #{o['issue']}: {o['title']}"))
504
+
505
+
506
+ def _fmt_issue(i: dict[str, Any]) -> None:
507
+ print(f"#{i.get('number')} [{i.get('state')}] {i.get('title')}")
508
+ if i.get("html_url") or i.get("url"):
509
+ print(f" url: {i.get('html_url') or i.get('url')}")
510
+ body = (i.get("body") or "").strip()
511
+ if body:
512
+ print(" ----")
513
+ for line in body.splitlines()[:12]:
514
+ print(f" {line}")
515
+ if len(body.splitlines()) > 12:
516
+ print(" ...")
517
+
518
+
519
+ def _fmt_issue_list(rows: list[dict[str, Any]]) -> None:
520
+ if not rows:
521
+ print("(no issues)")
522
+ return
523
+ for i in rows:
524
+ print(f"#{i.get('number')}\t[{i.get('state')}]\t{i.get('title')}")
525
+
526
+
527
+ def _fmt_comment(c: dict[str, Any]) -> None:
528
+ print(f"comment #{c.get('id')} on issue #{c.get('issue_number')}")
529
+ print(f" url: {c.get('html_url') or c.get('url') or '(none)'}")
530
+ print(f" body: {(c.get('body') or '').strip()}")
531
+
532
+
533
+ # --------------------------------------------------------------------------- #
534
+ # pr (read)
535
+ # view -> GET /issues/:n (API; PRs are issues with a pull_request key)
536
+ # list -> web UI scrape (Gogs issues list EXCLUDES PRs; no PR list API)
537
+ # --------------------------------------------------------------------------- #
538
+ def pr_view(t: Target, number: int, json_mode: bool) -> None:
539
+ data = api(t, "GET", f"{_owner_repo(t)}/issues/{number}")
540
+ if not data.get("pull_request"):
541
+ raise SystemExit(f"#{number} is an issue, not a pull request.")
542
+ emit(data, json_mode, _fmt_pr)
543
+
544
+
545
+ def _fmt_pr(i: dict[str, Any]) -> None:
546
+ pr = i.get("pull_request") or {}
547
+ merged = "merged" if pr.get("merged") else i.get("state", "open")
548
+ print(f"PR #{i.get('number')} [{merged}] {i.get('title')}")
549
+ if i.get("html_url"):
550
+ print(f" url: {i.get('html_url')}")
551
+ if pr.get("head") and pr.get("base"):
552
+ print(f" {pr['head'].get('ref')} -> {pr['base'].get('ref')}")
553
+
554
+
555
+ def _fmt_pr_list(rows: list[dict[str, Any]]) -> None:
556
+ if not rows:
557
+ print("(no pull requests)")
558
+ return
559
+ for i in rows:
560
+ pr = i.get("pull_request") or {}
561
+ state = "merged" if pr.get("merged") else i.get("state", "open")
562
+ print(f"#{i.get('number')}\t[{state}]\t{i.get('title')}")
563
+
564
+
565
+ def _verify_pr_state(t: Target, number: int) -> dict[str, Any]:
566
+ """Authoritative read-back via the issues API."""
567
+ return api(t, "GET", f"{_owner_repo(t)}/issues/{number}")
568
+
569
+
570
+ # --------------------------------------------------------------------------- #
571
+ # webform PR engine (stdlib HTTP): Gogs has no PR API, so PR ops submit the
572
+ # same Web-UI forms a browser would — session login + CSRF + form POSTs.
573
+ # --------------------------------------------------------------------------- #
574
+ def webform_setup_message() -> str:
575
+ return "\n".join([
576
+ "Web form credentials missing: PR operations require GOGS_USERNAME and",
577
+ "GOGS_PASSWORD (the same login you use in the Gogs Web UI).",
578
+ "Add them next to GOGS_TOKEN in ~/.config/gogs-cli/config",
579
+ "(or <repo-root>/.gogs.local.env):",
580
+ "",
581
+ " GOGS_USERNAME=<your Gogs login name>",
582
+ " GOGS_PASSWORD=<your Gogs password>",
583
+ ])
584
+
585
+
586
+ _CSRF_META_RE = re.compile(r'<meta\s+name="_csrf"\s+content="([^"]+)"')
587
+
588
+
589
+ def _strip_tags(html: str) -> str:
590
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html))
591
+
592
+
593
+ class _WebformClient:
594
+ """Minimal Gogs Web-UI client: login session, CSRF token, form POSTs.
595
+
596
+ stdlib only (urllib + cookiejar) — runs anywhere Python runs: desktop,
597
+ headless container, CI. Form fields and endpoints were sourced from the
598
+ live Web UI.
599
+ """
600
+
601
+ def __init__(self, base: str) -> None:
602
+ self.base = base.rstrip("/")
603
+ self.jar = http.cookiejar.CookieJar()
604
+ self.opener = request.build_opener(request.HTTPCookieProcessor(self.jar))
605
+ self._logged_in = False
606
+
607
+ # -- transport ---------------------------------------------------------- #
608
+ def _open(self, url: str, data: bytes | None = None) -> tuple[str, str]:
609
+ headers = {"User-Agent": USER_AGENT}
610
+ if data is not None:
611
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
612
+ req = request.Request(url, data=data, method="POST" if data is not None else "GET",
613
+ headers=headers)
614
+ try:
615
+ with self.opener.open(req, timeout=30) as resp:
616
+ return resp.geturl(), resp.read().decode("utf-8", errors="replace")
617
+ except error.HTTPError as exc:
618
+ detail = exc.read().decode("utf-8", errors="replace")[:500]
619
+ raise SystemExit(f"webform {req.method} {url} failed: HTTP {exc.code}: {detail}") from exc
620
+ except error.URLError as exc:
621
+ raise SystemExit(f"webform {req.method} {url} failed: {exc.reason}") from exc
622
+
623
+ def get(self, path: str) -> tuple[str, str]:
624
+ return self._open(self.base + path)
625
+
626
+ def post(self, path: str, fields: dict[str, str]) -> tuple[str, str]:
627
+ # Redirects are followed (urllib re-issues GET on 302/301), so the
628
+ # returned url is the FINAL destination — used to read back /pulls/N.
629
+ return self._open(self.base + path, urlencode(fields).encode("utf-8"))
630
+
631
+ # -- auth / csrf -------------------------------------------------------- #
632
+ def csrf_of(self, html: str) -> str:
633
+ m = _CSRF_META_RE.search(html)
634
+ if not m:
635
+ raise SystemExit("webform: no _csrf token found on page (unexpected page layout?)")
636
+ return m.group(1)
637
+
638
+ def ensure_login(self) -> None:
639
+ if self._logged_in:
640
+ return
641
+ username = (os.environ.get("GOGS_USERNAME") or "").strip()
642
+ password = os.environ.get("GOGS_PASSWORD") or ""
643
+ if not username or not password:
644
+ raise SystemExit(webform_setup_message())
645
+ _, page = self.get("/user/login")
646
+ final, body = self.post("/user/login", {
647
+ "_csrf": self.csrf_of(page),
648
+ "user_name": username,
649
+ "password": password,
650
+ })
651
+ if "Signed in as" not in body and "/user/logout" not in body:
652
+ raise SystemExit(
653
+ "webform login to Gogs failed: verify GOGS_USERNAME/GOGS_PASSWORD "
654
+ f"(post-login landed on {final})."
655
+ )
656
+ self._logged_in = True
657
+
658
+
659
+ @functools.lru_cache(maxsize=2)
660
+ def _webform(base: str) -> _WebformClient:
661
+ return _WebformClient(base)
662
+
663
+
664
+ def _webform_for(t: Target) -> _WebformClient:
665
+ wf = _webform(resolved_web_base())
666
+ wf.ensure_login()
667
+ return wf
668
+
669
+
670
+ # -- webform: pr list -------------------------------------------------------- #
671
+ _PULLS_ITEM_RE = re.compile(
672
+ r'<li class="item">\s*<div class="ui black label">#(\d+)</div>\s*'
673
+ r'<a class="title[^"]*" href="[^"]*">(.*?)</a>(.*?)</li>', re.S)
674
+ _PULLS_PAGE_RE = re.compile(r'[?&]page=(\d+)')
675
+
676
+
677
+ def webform_pr_list(t: Target, states: list[str]) -> dict[str, list[dict[str, Any]]]:
678
+ """Scrape /{owner}/{repo}/pulls tabs (Gogs issues list excludes PRs)."""
679
+ wf = _webform_for(t)
680
+ out: dict[str, list[dict[str, Any]]] = {}
681
+ for state in states:
682
+ rows: list[dict[str, Any]] = []
683
+ page = 1
684
+ while page <= 30: # safety cap; 10 items/page, repos here have <= 3 pages
685
+ _, html = wf.get(f"/{t.owner}/{t.repo}/pulls?type=all&state={state}&page={page}")
686
+ items = _PULLS_ITEM_RE.findall(html)
687
+ if not items:
688
+ break
689
+ for num, title, _rest in items:
690
+ rows.append({"number": int(num), "title": _strip_tags(title).strip()})
691
+ pages = [int(p) for p in _PULLS_PAGE_RE.findall(html)]
692
+ if page >= max(pages or [page]):
693
+ break
694
+ page += 1
695
+ out[state] = rows
696
+ return out
697
+
698
+
699
+ # -- webform: pr create ------------------------------------------------------ #
700
+ def webform_pr_create(t: Target, base: str, head: str, title: str, body: str) -> int:
701
+ """POST the compare-page form (the 'new pull request' dialog) directly."""
702
+ wf = _webform_for(t)
703
+ path = f"/{t.owner}/{t.repo}/compare/{quote(base, safe='/')}...{quote(head, safe='/')}"
704
+ final, page = wf.get(path)
705
+ existing = re.search(r"/pulls/(\d+)", final)
706
+ if existing and f"/{t.owner}/{t.repo}/compare/" not in final:
707
+ raise SystemExit(
708
+ f"pr create failed: Gogs redirected the compare page to existing PR #{existing.group(1)} "
709
+ f"({final}) — an open PR for these branches already exists."
710
+ )
711
+ if 'name="title"' not in page:
712
+ # Gogs omits the form (no redirect) when an open PR already exists for
713
+ # these branches — surface that link instead of a generic error.
714
+ dup = re.search(rf'<a[^>]*href="[^"]*/pulls/(\d+)"[^>]*>\s*{re.escape(t.owner)}/{re.escape(t.repo)}#\d+', page)
715
+ if dup:
716
+ raise SystemExit(
717
+ f"pr create failed: an open PR already exists for {base}...{head}: "
718
+ f"#{dup.group(1)} ({final})."
719
+ )
720
+ raise SystemExit(
721
+ "pr create failed: the compare page shows no new-PR form "
722
+ f"(no diff between {base} and {head}, or the head branch is unknown)."
723
+ )
724
+ final2, body_html = wf.post(path, {
725
+ "_csrf": wf.csrf_of(page),
726
+ "title": title,
727
+ "content": body or "",
728
+ })
729
+ m = re.search(r"/pulls/(\d+)", final2)
730
+ if not m:
731
+ flash = re.search(r'class="ui negative (?:flash )?message">(.*?)</div>', body_html, re.S)
732
+ detail = _strip_tags(flash.group(1)).strip() if flash else final2
733
+ raise SystemExit(f"pr create failed: no PR number in the redirect target. {detail[:300]}")
734
+ return int(m.group(1))
735
+
736
+
737
+ # -- webform: pr merge ------------------------------------------------------- #
738
+ _MERGE_FORM_RE = re.compile(
739
+ r'<form class="ui form" action="([^"]*/pulls/\d+/merge)" method="post">(.*?)</form>', re.S)
740
+ _MERGE_STYLE_RE = re.compile(r'name="merge_style"[^>]*value="([^"]+)"')
741
+ _WEBFORM_BANNER_RE = re.compile(
742
+ r"nothing to merge|there is nothing|can.?t be merged|cannot be merged|conflict"
743
+ r"|up-to-date|up to date|already merged|no changes|nothing to compare|unmerg", re.I)
744
+ _MERGE_BOX_RE = re.compile(r'<div class="comment merge box">(.*)<div class="comment form">', re.S)
745
+
746
+
747
+ def webform_pr_merge(t: Target, number: int, method: str) -> dict[str, Any]:
748
+ """POST the merge form; returns {'clicked': bool, 'banner': str} for
749
+ pr_merge()'s API verification path."""
750
+ wf = _webform_for(t)
751
+ _, page = wf.get(f"/{t.owner}/{t.repo}/pulls/{number}")
752
+ form = _MERGE_FORM_RE.search(page)
753
+ if not form:
754
+ box = _MERGE_BOX_RE.search(page)
755
+ region = box.group(1) if box else page
756
+ text = _strip_tags(region)
757
+ m = _WEBFORM_BANNER_RE.search(text)
758
+ return {"clicked": False, "banner": text[max(0, m.start() - 60):m.end() + 120].strip() if m else text[:300].strip()}
759
+ action, fields_html = form.group(1), form.group(2)
760
+ styles = _MERGE_STYLE_RE.findall(fields_html)
761
+ wanted = {"merge": "create_merge_commit", "squash": "squash", "rebase": "rebase"}[method]
762
+ if wanted not in styles and styles:
763
+ print(f"note: merge style '{method}' is not offered by this Gogs "
764
+ f"(available: {', '.join(styles)}); falling back to '{styles[0]}'.", file=sys.stderr)
765
+ wanted = styles[0]
766
+ _, _after = wf.post(action, {
767
+ "_csrf": wf.csrf_of(page),
768
+ "merge_style": wanted,
769
+ "commit_description": "",
770
+ })
771
+ return {"clicked": True, "banner": ""}
772
+
773
+
774
+ # -- webform: pr close / reopen ---------------------------------------------- #
775
+ _COMMENT_FORM_RE = re.compile(r'<form[^>]*action="([^"]*/issues/\d+/comments)"[^>]*method="post"')
776
+
777
+
778
+ def webform_pr_status(t: Target, number: int, verb: str) -> None:
779
+ """Close/reopen = POST the comment form with the hidden `status` field set,
780
+ exactly what the Web UI's Close/Reopen button does (content may be empty).
781
+ status values are data-status-val from the UI: 'close' / 'reopen'."""
782
+ status = {"close": "close", "reopen": "reopen"}[verb]
783
+ wf = _webform_for(t)
784
+ _, page = wf.get(f"/{t.owner}/{t.repo}/pulls/{number}")
785
+ form = _COMMENT_FORM_RE.search(page)
786
+ if not form:
787
+ raise SystemExit(f"pr {verb} failed: no comment/status form on the PR page "
788
+ f"(PR #{number} may not exist or you lack access).")
789
+ wf.post(form.group(1), {"content": "", "_csrf": wf.csrf_of(page), "status": status})
790
+
791
+
792
+ def pr_list(t: Target, state: str, base: str | None, head: str | None, limit: int, json_mode: bool) -> None:
793
+ """Gogs has no PR list API (the issues list excludes PRs). Scrape /pulls."""
794
+ if base or head:
795
+ print("note: --base/--head are not supported by pr list (no PR list API); ignored.", file=sys.stderr)
796
+ states = ["open", "closed"] if state == "all" else [state]
797
+ data = webform_pr_list(t, states)
798
+ rows = [{"number": r.get("number"), "title": r.get("title"), "state": s}
799
+ for s in states for r in data.get(s, [])]
800
+ emit(rows[:limit], json_mode, _fmt_pr_list)
801
+
802
+
803
+ def pr_create(t: Target, base: str, head: str | None, title: str, body: str, draft: bool, json_mode: bool) -> None:
804
+ if not head:
805
+ if not t.repo_root:
806
+ raise SystemExit("pr create: --head is required (no git repo to read the current branch).")
807
+ head = run_git(Path(t.repo_root), ["rev-parse", "--abbrev-ref", "HEAD"])
808
+ if draft:
809
+ print("note: Gogs Web UI has no draft concept; --draft ignored.", file=sys.stderr)
810
+ number = webform_pr_create(t, base, head, title, body)
811
+ verified = _verify_pr_state(t, int(number))
812
+ if not verified.get("pull_request"):
813
+ raise SystemExit(f"pr create: form reported #{number} but the issues API has no pull_request for it.")
814
+ emit(verified, json_mode, _fmt_pr)
815
+
816
+
817
+ EXIT_SOFT = 2 # non-zero warning: operation diagnosed but did not succeed (e.g. empty-diff merge)
818
+
819
+ # Gogs merge-box banner classifiers (best-effort; raw banner is always surfaced too).
820
+ _NO_MERGE_EMPTY = re.compile(r"nothing to merge|there is nothing|up-to-date|up to date|already merged|no changes|nothing to compare", re.I)
821
+ _NO_MERGE_CONFLICT = re.compile(r"conflict|can.?t be merged|cannot be merged|unmerg", re.I)
822
+
823
+
824
+ def _classify_no_merge(banner: str) -> str:
825
+ """Best-effort reason a merge could not proceed, derived from the Gogs banner text."""
826
+ if not banner:
827
+ return "not_mergeable"
828
+ if _NO_MERGE_CONFLICT.search(banner):
829
+ return "conflict"
830
+ if _NO_MERGE_EMPTY.search(banner):
831
+ return "empty_diff"
832
+ return "not_mergeable"
833
+
834
+
835
+ def _emit_pr_warning(number: int, reason: str, banner: str, verified: dict[str, Any], json_mode: bool) -> None:
836
+ """Report a soft-fail (non-zero, code 2): the PR was not merged, but the cause is known."""
837
+ merged = bool((verified.get("pull_request") or {}).get("merged"))
838
+ if json_mode:
839
+ print(json.dumps({"warning": reason, "reason": reason, "banner": banner,
840
+ "merged": merged, "number": number, "state": verified.get("state")},
841
+ ensure_ascii=False, indent=2))
842
+ elif reason == "already_merged":
843
+ print(f"warning: PR #{number} is already merged; nothing to do.", file=sys.stderr)
844
+ elif banner:
845
+ print(f"warning: PR #{number} was not merged ({reason}): {banner}", file=sys.stderr)
846
+ else:
847
+ print(f"warning: PR #{number} was not merged ({reason})", file=sys.stderr)
848
+ sys.exit(EXIT_SOFT)
849
+
850
+
851
+ def pr_merge(t: Target, number: int, method: str, delete_branch: bool, json_mode: bool) -> None:
852
+ out = webform_pr_merge(t, number, method)
853
+ data = {"ok": out.get("clicked"), "clicked": out.get("clicked"), "banner": out.get("banner", "")}
854
+ verified = _verify_pr_state(t, number)
855
+ pr = verified.get("pull_request") or {}
856
+ if not pr:
857
+ raise SystemExit(f"pr merge failed: #{number} is not a pull request (issues API has no pull_request).")
858
+ # No merge form — Gogs won't merge this. Classify and warn (non-zero), don't hard-fail.
859
+ if not (data.get("ok") and data.get("clicked")):
860
+ banner = (data.get("banner") or "").strip()
861
+ reason = "already_merged" if pr.get("merged") else _classify_no_merge(banner)
862
+ _emit_pr_warning(number, reason, banner, verified, json_mode)
863
+ # Merge form was submitted — verify via the issues API that it actually merged.
864
+ if not pr.get("merged"):
865
+ raise SystemExit(f"pr merge: form submitted but the issues API still reports unmerged for #{number}.")
866
+ if delete_branch:
867
+ print("note: --delete-branch is not automated for Gogs; delete the branch manually if needed.", file=sys.stderr)
868
+ emit(verified, json_mode, _fmt_pr)
869
+
870
+
871
+ def _pr_button_action(t: Target, number: int, verb: str, expected_state: str, json_mode: bool) -> None:
872
+ webform_pr_status(t, number, verb) # raises SystemExit on failure
873
+ verified = _verify_pr_state(t, number)
874
+ if verified.get("state") != expected_state:
875
+ raise SystemExit(f"pr {verb}: form submitted but the issues API reports state '{verified.get('state')}' (expected '{expected_state}') for #{number}.")
876
+ emit(verified, json_mode, _fmt_pr)
877
+
878
+
879
+ def pr_close(t: Target, number: int, json_mode: bool) -> None:
880
+ _pr_button_action(t, number, "close", "closed", json_mode)
881
+
882
+
883
+ def pr_reopen(t: Target, number: int, json_mode: bool) -> None:
884
+ _pr_button_action(t, number, "reopen", "open", json_mode)
885
+
886
+
887
+ # --------------------------------------------------------------------------- #
888
+ # CLI
889
+ # --------------------------------------------------------------------------- #
890
+ def _add_target(p: argparse.ArgumentParser) -> None:
891
+ p.add_argument("repo", nargs="?", help="OWNER/REPO. Default: origin remote.")
892
+ p.add_argument("--remote", default="origin")
893
+ p.add_argument("--repo-root", help="Path inside a repo when cwd is not the repo root.")
894
+ p.add_argument("--json", action="store_true", help="Machine-readable JSON output.")
895
+
896
+
897
+ def _add_body(p: argparse.ArgumentParser) -> None:
898
+ p.add_argument("--body", default="")
899
+ p.add_argument("-F", "--body-file", dest="body_file")
900
+
901
+
902
+ def _tgt(args: argparse.Namespace) -> Target:
903
+ return resolve_target(getattr(args, "repo", None), getattr(args, "remote", "origin"), getattr(args, "repo_root", None))
904
+
905
+
906
+ def build_parser() -> argparse.ArgumentParser:
907
+ parser = argparse.ArgumentParser(
908
+ prog="gogs-cli",
909
+ description="Reusable Gogs capability CLI (gh-style). Stateless — the remote Gogs instance is the single source of truth.",
910
+ formatter_class=argparse.RawDescriptionHelpFormatter,
911
+ epilog="""\
912
+ common workflows (params via `gogs-cli <cmd> --help`; OWNER/REPO defaults to origin):
913
+
914
+ issue -> branch -> PR -> merge -> close:
915
+ gogs-cli issue create --title "Fix reconnect" --body-file body.md
916
+ gogs-cli issue develop 29 # -> branch 29-fix-reconnect (off develop)
917
+ gogs-cli pr create --title "Fix reconnect" # base=develop, head=current branch
918
+ gogs-cli pr view <n> # verify Open
919
+ gogs-cli pr merge <n> # after acceptance; verify Merged
920
+ gogs-cli issue comment 29 --body "result..."; gogs-cli issue close 29
921
+
922
+ search before creating (avoid duplicates):
923
+ gogs-cli issue list --state open
924
+
925
+ host & auth: the web base comes from GOGS_BASE_URL (probed live); extra addresses
926
+ via GOGS_FALLBACK_URLS let the same invocation work from several networks.
927
+ PR ops submit the Web UI forms directly (Gogs has no PR API;
928
+ needs GOGS_USERNAME/GOGS_PASSWORD) and verify each write via the issues API
929
+ before reporting success.
930
+ Add --json anywhere for machine-readable output.
931
+ """,
932
+ )
933
+ sub = parser.add_subparsers(dest="resource", required=True)
934
+
935
+ # repo -------------------------------------------------------------------
936
+ repo = sub.add_parser("repo", help="Repository (API, read-only).")
937
+ repo_sub = repo.add_subparsers(dest="action", required=True)
938
+
939
+ rv = repo_sub.add_parser("view", help="View a repository.")
940
+ _add_target(rv)
941
+ rv.set_defaults(func=lambda a: repo_view(_tgt(a), a.json))
942
+
943
+ rl = repo_sub.add_parser("list", help="List repositories.")
944
+ rl.add_argument("--user", help="List this user's repos. Default: authenticated user.")
945
+ rl.add_argument("--limit", type=int, default=30)
946
+ rl.add_argument("--remote", default="origin")
947
+ rl.add_argument("--repo-root")
948
+ rl.add_argument("--json", action="store_true")
949
+ rl.set_defaults(func=lambda a: repo_list(a.user, a.limit, a.json, a.remote, a.repo_root))
950
+
951
+ # issue ------------------------------------------------------------------
952
+ issue = sub.add_parser("issue", help="Issues (API).")
953
+ issue_sub = issue.add_subparsers(dest="action", required=True)
954
+
955
+ il = issue_sub.add_parser("list", help="List issues.")
956
+ _add_target(il)
957
+ il.add_argument("--state", choices=["open", "closed", "all"], default="open")
958
+ il.add_argument("--label", action="append", default=[], help="Label name (repeatable).")
959
+ il.add_argument("--assignee")
960
+ il.add_argument("--limit", type=int, default=30)
961
+ il.set_defaults(func=lambda a: issue_list(_tgt(a), a.state, a.label, a.assignee, a.limit, a.json))
962
+
963
+ iv = issue_sub.add_parser("view", help="View an issue.")
964
+ _add_target(iv)
965
+ iv.add_argument("number", type=int)
966
+ iv.set_defaults(func=lambda a: issue_view(_tgt(a), a.number, a.json))
967
+
968
+ ic = issue_sub.add_parser("create", help="Create an issue.")
969
+ _add_target(ic)
970
+ ic.add_argument("--title", required=True)
971
+ _add_body(ic)
972
+ ic.add_argument("--assignee")
973
+ ic.add_argument("--milestone", type=int)
974
+ ic.add_argument("--label", action="append", default=[], help="Label name (repeatable).")
975
+ ic.add_argument("--closed", action="store_true")
976
+ ic.set_defaults(func=lambda a: issue_create(_tgt(a), a.title, read_text(a.body, a.body_file), a.assignee, a.milestone, a.label, a.closed, a.json))
977
+
978
+ ie = issue_sub.add_parser("edit", help="Edit an issue (title/body/state).")
979
+ _add_target(ie)
980
+ ie.add_argument("number", type=int)
981
+ ie.add_argument("--title")
982
+ ie.add_argument("--body", default=None)
983
+ ie.add_argument("-F", "--body-file", dest="body_file")
984
+ ie.add_argument("--state", choices=["open", "closed"])
985
+ ie.set_defaults(func=lambda a: issue_edit(_tgt(a), a.number, a.title, read_text(a.body, a.body_file) if a.body_file else a.body, a.state, a.json))
986
+
987
+ icl = issue_sub.add_parser("close", help="Close an issue.")
988
+ _add_target(icl)
989
+ icl.add_argument("number", type=int)
990
+ icl.set_defaults(func=lambda a: issue_set_state(_tgt(a), a.number, "closed", a.json))
991
+
992
+ iro = issue_sub.add_parser("reopen", help="Reopen an issue.")
993
+ _add_target(iro)
994
+ iro.add_argument("number", type=int)
995
+ iro.set_defaults(func=lambda a: issue_set_state(_tgt(a), a.number, "open", a.json))
996
+
997
+ icm = issue_sub.add_parser("comment", help="Comment on an issue.")
998
+ _add_target(icm)
999
+ icm.add_argument("number", type=int)
1000
+ _add_body(icm)
1001
+ icm.set_defaults(func=lambda a: issue_comment(_tgt(a), a.number, read_text(a.body, a.body_file), a.json))
1002
+
1003
+ idv = issue_sub.add_parser("develop", help="Create a local branch off --base named from an issue (gh-style).")
1004
+ _add_target(idv)
1005
+ idv.add_argument("number", type=int)
1006
+ idv.add_argument("--base", default="develop", help="Branch to start from. Default: develop.")
1007
+ idv.add_argument("--name", help="Override branch name (default: <number>-<title-slug>).")
1008
+ idv.set_defaults(func=lambda a: issue_develop(_tgt(a), a.number, a.base, a.name, a.json))
1009
+
1010
+ # label ------------------------------------------------------------------
1011
+ label = sub.add_parser("label", help="Labels (API).")
1012
+ label_sub = label.add_subparsers(dest="action", required=True)
1013
+ ll = label_sub.add_parser("list", help="List labels.")
1014
+ _add_target(ll)
1015
+ ll.set_defaults(func=lambda a: label_list(_tgt(a), a.json))
1016
+
1017
+ # pr ---------------------------------------------------------------------
1018
+ pr = sub.add_parser("pr", help="Pull requests (view via API; list/create/merge/close/reopen via Web UI forms).")
1019
+ pr_sub = pr.add_subparsers(dest="action", required=True)
1020
+
1021
+ pl = pr_sub.add_parser("list", help="List pull requests (Web UI scrape; no PR list API).")
1022
+ _add_target(pl)
1023
+ pl.add_argument("--state", choices=["open", "closed", "all"], default="open")
1024
+ pl.add_argument("--base")
1025
+ pl.add_argument("--head")
1026
+ pl.add_argument("--limit", type=int, default=30)
1027
+ pl.set_defaults(func=lambda a: pr_list(_tgt(a), a.state, a.base, a.head, a.limit, a.json))
1028
+
1029
+ pv = pr_sub.add_parser("view", help="View a pull request.")
1030
+ _add_target(pv)
1031
+ pv.add_argument("number", type=int)
1032
+ pv.set_defaults(func=lambda a: pr_view(_tgt(a), a.number, a.json))
1033
+
1034
+ pc = pr_sub.add_parser("create", help="Create a pull request (Web UI form).")
1035
+ _add_target(pc)
1036
+ pc.add_argument("--base", default="develop", help="Base (target) branch. Default: develop.")
1037
+ pc.add_argument("--head", help="Head (source) branch. Default: current git branch.")
1038
+ pc.add_argument("--title", required=True)
1039
+ _add_body(pc)
1040
+ pc.add_argument("--draft", action="store_true")
1041
+ pc.set_defaults(func=lambda a: pr_create(_tgt(a), a.base, a.head, a.title, read_text(a.body, a.body_file), a.draft, a.json))
1042
+
1043
+ pm = pr_sub.add_parser("merge", help="Merge a pull request (Web UI form).")
1044
+ _add_target(pm)
1045
+ pm.add_argument("number", type=int)
1046
+ method = pm.add_mutually_exclusive_group()
1047
+ method.add_argument("--merge", action="store_const", const="merge", dest="method")
1048
+ method.add_argument("--squash", action="store_const", const="squash", dest="method")
1049
+ method.add_argument("--rebase", action="store_const", const="rebase", dest="method")
1050
+ pm.set_defaults(method="merge")
1051
+ pm.add_argument("--delete-branch", action="store_true")
1052
+ pm.set_defaults(func=lambda a: pr_merge(_tgt(a), a.number, a.method, a.delete_branch, a.json))
1053
+
1054
+ pcl = pr_sub.add_parser("close", help="Close a pull request (Web UI form).")
1055
+ _add_target(pcl)
1056
+ pcl.add_argument("number", type=int)
1057
+ pcl.set_defaults(func=lambda a: pr_close(_tgt(a), a.number, a.json))
1058
+
1059
+ pro = pr_sub.add_parser("reopen", help="Reopen a pull request (Web UI form).")
1060
+ _add_target(pro)
1061
+ pro.add_argument("number", type=int)
1062
+ pro.set_defaults(func=lambda a: pr_reopen(_tgt(a), a.number, a.json))
1063
+
1064
+ return parser
1065
+
1066
+
1067
+ def main(argv: Sequence[str] | None = None) -> None:
1068
+ parser = build_parser()
1069
+ args = parser.parse_args(argv)
1070
+ args.func(args)
1071
+
1072
+
1073
+ if __name__ == "__main__":
1074
+ main()