codeberg-cli 0.1.0__py3-none-any.whl → 0.3.0__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.
Files changed (112) hide show
  1. codeberg_cli/__main__.py +1 -1
  2. codeberg_cli/client.py +26 -5
  3. codeberg_cli/git.py +18 -7
  4. codeberg_cli/helpers.py +105 -5
  5. codeberg_cli/routes/__init__.py +10 -3
  6. codeberg_cli/routes/actions/__init__.py +6 -0
  7. codeberg_cli/routes/actions/dispatch.py +31 -0
  8. codeberg_cli/routes/actions/run.py +40 -0
  9. codeberg_cli/routes/actions/runs.py +52 -0
  10. codeberg_cli/routes/actions/workflows.py +46 -0
  11. codeberg_cli/routes/auth/__init__.py +6 -1
  12. codeberg_cli/routes/auth/login.py +0 -2
  13. codeberg_cli/routes/auth/status.py +0 -2
  14. codeberg_cli/routes/auth/whoami.py +0 -2
  15. codeberg_cli/routes/issue/__init__.py +6 -1
  16. codeberg_cli/routes/issue/comment.py +41 -0
  17. codeberg_cli/routes/issue/create.py +2 -2
  18. codeberg_cli/routes/issue/delete.py +31 -0
  19. codeberg_cli/routes/issue/edit.py +38 -0
  20. codeberg_cli/routes/issue/labels/__init__.py +6 -0
  21. codeberg_cli/routes/issue/labels/add.py +27 -0
  22. codeberg_cli/routes/issue/labels/list.py +43 -0
  23. codeberg_cli/routes/issue/labels/remove.py +27 -0
  24. codeberg_cli/routes/issue/list.py +15 -10
  25. codeberg_cli/routes/issue/pin.py +26 -0
  26. codeberg_cli/routes/issue/search.py +59 -0
  27. codeberg_cli/routes/issue/subscribe.py +27 -0
  28. codeberg_cli/routes/issue/time/__init__.py +6 -0
  29. codeberg_cli/routes/issue/time/add.py +31 -0
  30. codeberg_cli/routes/issue/time/list.py +42 -0
  31. codeberg_cli/routes/issue/unpin.py +26 -0
  32. codeberg_cli/routes/issue/unsubscribe.py +27 -0
  33. codeberg_cli/routes/issue/view.py +6 -2
  34. codeberg_cli/routes/label/__init__.py +6 -1
  35. codeberg_cli/routes/label/edit.py +41 -0
  36. codeberg_cli/routes/label/list.py +12 -10
  37. codeberg_cli/routes/milestone/__init__.py +6 -1
  38. codeberg_cli/routes/milestone/delete.py +38 -0
  39. codeberg_cli/routes/milestone/edit.py +44 -0
  40. codeberg_cli/routes/milestone/list.py +15 -15
  41. codeberg_cli/routes/milestone/view.py +42 -0
  42. codeberg_cli/routes/notifications.py +42 -0
  43. codeberg_cli/routes/org/__init__.py +6 -0
  44. codeberg_cli/routes/org/list.py +28 -0
  45. codeberg_cli/routes/org/members.py +31 -0
  46. codeberg_cli/routes/org/teams.py +38 -0
  47. codeberg_cli/routes/org/view.py +33 -0
  48. codeberg_cli/routes/pr/__init__.py +6 -1
  49. codeberg_cli/routes/pr/check_merge.py +33 -0
  50. codeberg_cli/routes/pr/comment.py +41 -0
  51. codeberg_cli/routes/pr/commits.py +44 -0
  52. codeberg_cli/routes/pr/create.py +2 -2
  53. codeberg_cli/routes/pr/diff.py +31 -0
  54. codeberg_cli/routes/pr/edit.py +38 -0
  55. codeberg_cli/routes/pr/files.py +42 -0
  56. codeberg_cli/routes/pr/list.py +16 -10
  57. codeberg_cli/routes/pr/reopen.py +26 -0
  58. codeberg_cli/routes/pr/update.py +27 -0
  59. codeberg_cli/routes/pr/view.py +6 -2
  60. codeberg_cli/routes/release/__init__.py +6 -1
  61. codeberg_cli/routes/release/create.py +2 -2
  62. codeberg_cli/routes/release/delete.py +38 -0
  63. codeberg_cli/routes/release/edit.py +56 -0
  64. codeberg_cli/routes/release/list.py +14 -11
  65. codeberg_cli/routes/release/upload.py +1 -1
  66. codeberg_cli/routes/release/view.py +13 -4
  67. codeberg_cli/routes/repo/__init__.py +6 -1
  68. codeberg_cli/routes/repo/archive.py +36 -0
  69. codeberg_cli/routes/repo/branch/__init__.py +6 -0
  70. codeberg_cli/routes/repo/branch/create.py +31 -0
  71. codeberg_cli/routes/repo/branch/delete.py +26 -0
  72. codeberg_cli/routes/repo/branch/list.py +36 -0
  73. codeberg_cli/routes/repo/branch/view.py +36 -0
  74. codeberg_cli/routes/repo/clone.py +3 -2
  75. codeberg_cli/routes/repo/collaborator/__init__.py +6 -0
  76. codeberg_cli/routes/repo/collaborator/add.py +31 -0
  77. codeberg_cli/routes/repo/collaborator/delete.py +26 -0
  78. codeberg_cli/routes/repo/collaborator/list.py +37 -0
  79. codeberg_cli/routes/repo/commits.py +51 -0
  80. codeberg_cli/routes/repo/contents.py +55 -0
  81. codeberg_cli/routes/repo/create.py +5 -4
  82. codeberg_cli/routes/repo/edit.py +64 -0
  83. codeberg_cli/routes/repo/fork.py +1 -1
  84. codeberg_cli/routes/repo/forks/sync.py +32 -0
  85. codeberg_cli/routes/repo/forks.py +42 -0
  86. codeberg_cli/routes/repo/languages.py +45 -0
  87. codeberg_cli/routes/repo/list.py +16 -10
  88. codeberg_cli/routes/repo/migrate.py +33 -0
  89. codeberg_cli/routes/repo/search.py +58 -0
  90. codeberg_cli/routes/repo/star.py +25 -0
  91. codeberg_cli/routes/repo/stargazers.py +40 -0
  92. codeberg_cli/routes/repo/tag/__init__.py +6 -0
  93. codeberg_cli/routes/repo/tag/create.py +34 -0
  94. codeberg_cli/routes/repo/tag/delete.py +26 -0
  95. codeberg_cli/routes/repo/tag/list.py +36 -0
  96. codeberg_cli/routes/repo/topics/__init__.py +6 -0
  97. codeberg_cli/routes/repo/topics/list.py +36 -0
  98. codeberg_cli/routes/repo/topics/set.py +27 -0
  99. codeberg_cli/routes/repo/transfer.py +35 -0
  100. codeberg_cli/routes/repo/unstar.py +25 -0
  101. codeberg_cli/routes/repo/unwatch.py +25 -0
  102. codeberg_cli/routes/repo/view.py +9 -4
  103. codeberg_cli/routes/repo/watch.py +25 -0
  104. codeberg_cli/routes/repo/watchers.py +40 -0
  105. codeberg_cli/routes/user.py +30 -0
  106. codeberg_cli-0.3.0.dist-info/METADATA +232 -0
  107. codeberg_cli-0.3.0.dist-info/RECORD +122 -0
  108. codeberg_cli-0.1.0.dist-info/METADATA +0 -182
  109. codeberg_cli-0.1.0.dist-info/RECORD +0 -49
  110. {codeberg_cli-0.1.0.dist-info → codeberg_cli-0.3.0.dist-info}/WHEEL +0 -0
  111. {codeberg_cli-0.1.0.dist-info → codeberg_cli-0.3.0.dist-info}/entry_points.txt +0 -0
  112. {codeberg_cli-0.1.0.dist-info → codeberg_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
codeberg_cli/__main__.py CHANGED
@@ -2,7 +2,7 @@ from xclif import Cli
2
2
 
3
3
  from . import routes
4
4
 
5
- cli = Cli.from_routes(routes)
5
+ cli = Cli.from_routes(routes, config_name="codeberg-cli")
6
6
 
7
7
  if __name__ == "__main__":
8
8
  cli()
codeberg_cli/client.py CHANGED
@@ -2,11 +2,12 @@ import httpx
2
2
  from rich.status import Status
3
3
  from xclif.context import get_context
4
4
 
5
- BASE_URL = "https://codeberg.org/api/v1"
5
+ DEFAULT_BASE_URL = "https://codeberg.org"
6
6
 
7
7
  _VERBS = {
8
8
  "GET": "Fetching",
9
9
  "POST": "Creating",
10
+ "PUT": "Updating",
10
11
  "PATCH": "Updating",
11
12
  "DELETE": "Deleting",
12
13
  }
@@ -19,7 +20,7 @@ class ClientError(RuntimeError):
19
20
  class Client:
20
21
  """HTTP client for the Codeberg API."""
21
22
 
22
- def __init__(self, token: str | None = None) -> None:
23
+ def __init__(self, token: str | None = None, base_url: str | None = None) -> None:
23
24
  headers = {
24
25
  "Accept": "application/json",
25
26
  "User-Agent": "codeberg-cli/0.1.0",
@@ -27,7 +28,18 @@ class Client:
27
28
  }
28
29
  if token:
29
30
  headers["Authorization"] = f"Bearer {token}"
30
- self._client = httpx.Client(base_url=BASE_URL, headers=headers, timeout=30.0)
31
+ raw = base_url or DEFAULT_BASE_URL
32
+ if "://" not in raw:
33
+ raw = "https://" + raw
34
+ raw = raw.rstrip("/")
35
+ if not raw.endswith("/api/v1"):
36
+ raw += "/api/v1"
37
+ self._client = httpx.Client(base_url=raw, headers=headers, timeout=30.0)
38
+
39
+ @property
40
+ def base_url(self) -> str:
41
+ """The API base URL used by this client."""
42
+ return str(self._client.base_url)
31
43
 
32
44
  def get(
33
45
  self, path: str, params: dict | None = None, action: str | None = None
@@ -44,16 +56,25 @@ class Client:
44
56
  ) -> dict | list:
45
57
  return self._request("PATCH", path, json=data, action=action)
46
58
 
59
+ def put(
60
+ self, path: str, data: dict | None = None, action: str | None = None
61
+ ) -> dict | list:
62
+ return self._request("PUT", path, json=data, action=action)
63
+
47
64
  def delete(self, path: str, action: str | None = None) -> None:
48
65
  self._request("DELETE", path, action=action)
49
66
 
50
67
  def _request(
51
68
  self, method: str, path: str, action: str | None = None, **kwargs
52
69
  ) -> dict | list | None:
53
- ctx = get_context()
70
+ try:
71
+ ctx = get_context()
72
+ verbosity = ctx.verbosity
73
+ except RuntimeError:
74
+ verbosity = 0
54
75
  label = action or (
55
76
  f"{method} {path}"
56
- if ctx.verbosity >= 2
77
+ if verbosity >= 2
57
78
  else f"{_VERBS.get(method, method)} {path.split('/')[-1]}"
58
79
  )
59
80
  with Status(f"{label}..."):
codeberg_cli/git.py CHANGED
@@ -1,16 +1,27 @@
1
- import re
2
1
  import subprocess
3
2
  from pathlib import Path
3
+ from urllib.parse import urlparse
4
4
 
5
5
 
6
6
  def parse_repo_from_remote(remote_url: str) -> str:
7
7
  """Extract owner/repo from a git remote URL."""
8
- # HTTPS: https://codeberg.org/owner/repo.git
9
- # SSH: git@codeberg.org:owner/repo.git
10
- m = re.search(r"codeberg\.org[:/](.+?)(?:\.git)?/?$", remote_url)
11
- if m:
12
- return m.group(1)
13
- raise ValueError(f"Could not parse repo from remote: {remote_url}")
8
+ # HTTPS: https://hostname/owner/repo.git
9
+ parsed = urlparse(remote_url)
10
+ if parsed.scheme in ("http", "https"):
11
+ path = parsed.path
12
+ elif "@" in remote_url:
13
+ # SSH: git@hostname:owner/repo.git
14
+ path = remote_url.split(":", 1)[1]
15
+ else:
16
+ raise ValueError(f"Could not parse repo from remote: {remote_url}")
17
+
18
+ path = path.rstrip("/")
19
+ if path.endswith(".git"):
20
+ path = path[:-4]
21
+ owner_repo = path.removeprefix("/")
22
+ if "/" not in owner_repo:
23
+ raise ValueError(f"Could not parse repo from remote: {remote_url}")
24
+ return owner_repo
14
25
 
15
26
 
16
27
  def get_default_branch(cwd: Path) -> str:
codeberg_cli/helpers.py CHANGED
@@ -1,20 +1,120 @@
1
- from codeberg_cli.client import Client, ClientError
1
+ from __future__ import annotations
2
+
3
+ import json as _json
4
+ import sys
5
+ from collections.abc import Iterable, Mapping
6
+ from typing import Any
7
+
8
+ import rich
9
+
10
+ from codeberg_cli.client import Client, ClientError, DEFAULT_BASE_URL
2
11
  from codeberg_cli.config import load_config
3
12
 
4
13
 
5
- def get_authenticated_client() -> Client | None:
14
+ def is_json_mode() -> bool:
15
+ """Check whether ``--json`` was passed on the CLI."""
16
+ return "--json" in sys.argv[1:]
17
+
18
+
19
+ def output(
20
+ data: Any,
21
+ *,
22
+ json: bool | None = None,
23
+ default: str | None = None,
24
+ ) -> None:
25
+ """Print *data* as JSON or fall back to *default* text.
26
+
27
+ When ``json=True`` (or global ``--json`` was passed), data is printed as
28
+ prettified JSON. When ``json=False`` the *default* string (if given) is
29
+ printed. When *default* is ``None`` and JSON mode is off, this is a no-op
30
+ — the caller is expected to handle rich printing themselves.
31
+ """
32
+ if json is None:
33
+ json = is_json_mode()
34
+ if json:
35
+ _json.dump(
36
+ data,
37
+ sys.stdout,
38
+ indent=2,
39
+ default=str,
40
+ )
41
+ sys.stdout.write("\n")
42
+ elif default is not None:
43
+ rich.print(default)
44
+
45
+
46
+ def print_table(
47
+ columns: list[str],
48
+ rows: Iterable[Iterable[str]],
49
+ *,
50
+ json: bool | None = None,
51
+ json_data: Any = None,
52
+ title: str | None = None,
53
+ ) -> None:
54
+ """Print a table, or JSON when ``--json`` is active.
55
+
56
+ When JSON mode is on, *json_data* is printed as JSON (or *rows* converted
57
+ to a list if no explicit json_data is given). When not in JSON mode, a
58
+ rich ``Table`` is printed.
59
+ """
60
+ if json is None:
61
+ json = is_json_mode()
62
+ if json:
63
+ _json.dump(
64
+ json_data if json_data is not None else list(rows),
65
+ sys.stdout,
66
+ indent=2,
67
+ default=str,
68
+ )
69
+ sys.stdout.write("\n")
70
+ return
71
+
72
+ from rich.table import Table
73
+
74
+ table = Table(*columns, title=title)
75
+ for row in rows:
76
+ table.add_row(*row)
77
+ rich.print(table)
78
+
79
+
80
+ def get_base_url() -> str:
81
+ """Get the web base URL from cascading CLI context or default."""
82
+ from xclif.context import get_context
83
+
84
+ try:
85
+ ctx = get_context()
86
+ raw = ctx.get("base_url", DEFAULT_BASE_URL)
87
+ except RuntimeError:
88
+ raw = DEFAULT_BASE_URL
89
+
90
+ # Normalize: ensure scheme, strip API path suffix for backward compat
91
+ if "://" not in raw:
92
+ raw = "https://" + raw
93
+ raw = raw.rstrip("/")
94
+ if raw.endswith("/api/v1"):
95
+ raw = raw[:-7]
96
+ return raw
97
+
98
+
99
+ def get_web_base_url() -> str:
100
+ """Derive the web UI base URL from the API base URL."""
101
+ return get_base_url()
102
+
103
+
104
+ def get_authenticated_client(base_url: str | None = None) -> Client | None:
6
105
  """Return a Client if token is stored, else None."""
7
106
  config = load_config()
8
107
  token = config.get("token")
9
108
  if not token:
10
109
  return None
11
- return Client(token=token)
110
+ return Client(token=token, base_url=base_url or get_base_url())
12
111
 
13
112
 
14
- def require_client() -> Client:
113
+ def require_client(base_url: str | None = None) -> Client:
15
114
  """Return an authenticated Client or print error and exit."""
16
- client = get_authenticated_client()
115
+ client = get_authenticated_client(base_url=base_url)
17
116
  if client is None:
18
117
  from xclif.errors import UsageError
118
+
19
119
  raise UsageError("Not logged in. Run 'cb auth login' first.")
20
120
  return client
@@ -1,6 +1,13 @@
1
- from xclif import command
1
+ from codeberg_cli.client import DEFAULT_BASE_URL
2
+ from xclif import Cascade, WithConfig, command
2
3
 
3
4
 
4
5
  @command("cb")
5
- def _() -> None:
6
- """A Codeberg CLI."""
6
+ def _(
7
+ base_url: Cascade[WithConfig[str]] = DEFAULT_BASE_URL,
8
+ json: bool = False,
9
+ ) -> None:
10
+ """Interact with Codeberg or any Forgejo instance — manage repos, issues, PRs, releases, actions, and more.
11
+
12
+ Use --json on any command for machine-readable output.
13
+ """
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("actions")
5
+ def _() -> None:
6
+ """Manage Forgejo Actions — view runs, list workflows, and dispatch workflows."""
@@ -0,0 +1,31 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("dispatch")
11
+ def _(
12
+ workflow: Annotated[str, Arg(description="Workflow filename (e.g. ci.yml)")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ref: Annotated[str, Option(description="Branch or tag to dispatch on", name="ref")] = "main",
15
+ ) -> None:
16
+ """Dispatch a workflow (trigger a workflow_dispatch event)."""
17
+ client = require_client()
18
+
19
+ if not repo:
20
+ inferred = infer_repo()
21
+ if not inferred:
22
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
23
+ return 1
24
+ repo = inferred
25
+
26
+ client.post(
27
+ f"/repos/{repo}/actions/workflows/{workflow}/dispatches",
28
+ data={"ref": ref},
29
+ action="Dispatching workflow",
30
+ )
31
+ rich.print(f"[green]Dispatched {workflow} on {ref} in {repo}.[/green]")
@@ -0,0 +1,40 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import is_json_mode, output, require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("run")
11
+ def _(
12
+ run_id: Annotated[int, Arg(description="Run ID")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ) -> None:
15
+ """View details of a specific action run."""
16
+ client = require_client()
17
+
18
+ if not repo:
19
+ inferred = infer_repo()
20
+ if not inferred:
21
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
22
+ return 1
23
+ repo = inferred
24
+
25
+ run = client.get(f"/repos/{repo}/actions/runs/{run_id}")
26
+
27
+ if is_json_mode():
28
+ output(run)
29
+ return
30
+
31
+ rich.print(f"[bold]Run #{run['id']}:[/bold] {run['display_title']}")
32
+ rich.print(f" Status: {run.get('status', '?')}")
33
+ rich.print(f" Conclusion: {run.get('conclusion', 'N/A')}")
34
+ rich.print(f" Event: {run.get('event', '?')}")
35
+ rich.print(f" Branch: {run.get('head_branch', '?')}")
36
+ rich.print(f" Commit: {run.get('head_sha', '?')[:12]}")
37
+ rich.print(f" Created: {run.get('created_at', '?')}")
38
+ rich.print(f" Updated: {run.get('updated_at', '?')}")
39
+ if run.get("url"):
40
+ rich.print(f" URL: {run['url']}")
@@ -0,0 +1,52 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import print_table, require_client
7
+ from xclif import Option, command
8
+
9
+
10
+ @command("runs")
11
+ def _(
12
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
13
+ limit: Annotated[int, Option(description="Maximum runs to show", name="limit")] = 20,
14
+ ) -> None:
15
+ """List action runs for a repository."""
16
+ client = require_client()
17
+
18
+ if not repo:
19
+ inferred = infer_repo()
20
+ if not inferred:
21
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
22
+ return 1
23
+ repo = inferred
24
+
25
+ runs = client.get(f"/repos/{repo}/actions/runs", params={"limit": limit, "page": 1})
26
+
27
+ if not runs:
28
+ rich.print(f"[dim]No action runs in {repo}.[/dim]")
29
+ return
30
+
31
+ rows = []
32
+ json_rows = []
33
+ for run in runs:
34
+ status = run.get("status", "?")
35
+ conclusion = run.get("conclusion", "")
36
+ label = f"{status}" if not conclusion else f"{conclusion}"
37
+ rows.append((str(run["id"]), run["display_title"][:72], label, run["event"]))
38
+ json_rows.append({
39
+ "id": run["id"],
40
+ "title": run["display_title"],
41
+ "status": status,
42
+ "conclusion": conclusion,
43
+ "event": run["event"],
44
+ "created_at": run.get("created_at", ""),
45
+ "head_branch": run.get("head_branch", ""),
46
+ })
47
+
48
+ print_table(
49
+ ["ID", "Title", "Status", "Event"],
50
+ rows,
51
+ json_data=json_rows,
52
+ )
@@ -0,0 +1,46 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import print_table, require_client
7
+ from xclif import Option, command
8
+
9
+
10
+ @command("workflows")
11
+ def _(
12
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
13
+ ) -> None:
14
+ """List workflows for a repository."""
15
+ client = require_client()
16
+
17
+ if not repo:
18
+ inferred = infer_repo()
19
+ if not inferred:
20
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
21
+ return 1
22
+ repo = inferred
23
+
24
+ workflows = client.get(f"/repos/{repo}/actions/workflows")
25
+
26
+ if not workflows:
27
+ rich.print(f"[dim]No workflows in {repo}.[/dim]")
28
+ return
29
+
30
+ rows = []
31
+ json_rows = []
32
+ for wf in workflows:
33
+ state = wf.get("state", "?")
34
+ rows.append((str(wf["id"]), wf["name"], wf.get("filename", ""), state))
35
+ json_rows.append({
36
+ "id": wf["id"],
37
+ "name": wf["name"],
38
+ "filename": wf.get("filename", ""),
39
+ "state": state,
40
+ })
41
+
42
+ print_table(
43
+ ["ID", "Name", "Filename", "State"],
44
+ rows,
45
+ json_data=json_rows,
46
+ )
@@ -1 +1,6 @@
1
- # Namespace package for auth subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("auth")
5
+ def _() -> None:
6
+ """Manage authentication — login, logout, check status, and whoami."""
@@ -19,5 +19,3 @@ def _(
19
19
  user = client.get("/user", action="Verifying token")
20
20
  save_config({"token": token})
21
21
  rich.print(f"[bold green]Logged in as[/bold green] [bold]{user['login']}[/bold]")
22
-
23
-
@@ -17,5 +17,3 @@ def _() -> None:
17
17
  rich.print(f"Logged in to [cyan]Codeberg[/cyan] as [bold]{user['login']}[/bold]")
18
18
  rich.print(f" User ID: {user['id']}")
19
19
  rich.print(f" Full Name: {user.get('full_name', '(not set)')}")
20
-
21
-
@@ -10,5 +10,3 @@ def _() -> None:
10
10
  client = require_client()
11
11
  user = client.get("/user")
12
12
  print(user["login"])
13
-
14
-
@@ -1 +1,6 @@
1
- # Namespace package for issue subcommands
1
+ from xclif import command
2
+
3
+
4
+ @command("issue")
5
+ def _() -> None:
6
+ """Manage issues — create, view, close, and more."""
@@ -0,0 +1,41 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("comment")
11
+ def _(
12
+ id: Annotated[int, Arg(description="Issue number")],
13
+ message: Annotated[str, Option(description="Comment text", name="message")] = "",
14
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
15
+ ) -> None:
16
+ """Comment on an issue."""
17
+ client = require_client()
18
+
19
+ if not repo:
20
+ inferred = infer_repo()
21
+ if not inferred:
22
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
23
+ return 1
24
+ repo = inferred
25
+
26
+ if not message:
27
+ rich.print("[dim]Enter comment (Ctrl+D to finish):[/dim]")
28
+ try:
29
+ lines = []
30
+ while True:
31
+ line = input()
32
+ lines.append(line)
33
+ except (EOFError, KeyboardInterrupt):
34
+ pass
35
+ message = "\n".join(lines).strip()
36
+ if not message:
37
+ rich.print("[bold red]Error:[/bold red] Comment cannot be empty")
38
+ return 1
39
+
40
+ client.post(f"/repos/{repo}/issues/{id}/comments", data={"body": message})
41
+ rich.print(f"[bold green]Commented[/bold green] on issue #{id} in {repo}")
@@ -3,7 +3,7 @@ from typing import Annotated
3
3
  import rich
4
4
 
5
5
  from codeberg_cli.git import infer_repo
6
- from codeberg_cli.helpers import require_client
6
+ from codeberg_cli.helpers import get_web_base_url, require_client
7
7
  from xclif import Option, command
8
8
 
9
9
 
@@ -50,6 +50,6 @@ def _(
50
50
  result = client.post(f"/repos/{repo}/issues", data=data)
51
51
 
52
52
  rich.print(f"[bold green]Created issue[/bold green] #{result['number']}: {result['title']}")
53
- rich.print(f"[dim]https://codeberg.org/{repo}/issues/{result['number']}[/dim]")
53
+ rich.print(f"[dim]{get_web_base_url()}/{repo}/issues/{result['number']}[/dim]")
54
54
 
55
55
 
@@ -0,0 +1,31 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("delete")
11
+ def _(
12
+ id: Annotated[int, Arg(description="Issue number")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ) -> None:
15
+ """Delete an issue."""
16
+ client = require_client()
17
+
18
+ if not repo:
19
+ inferred = infer_repo()
20
+ if not inferred:
21
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
22
+ return 1
23
+ repo = inferred
24
+
25
+ confirm = input(f"Are you sure you want to delete issue #{id} in {repo}? [y/N] ").strip().lower()
26
+ if confirm != "y":
27
+ rich.print("[dim]Cancelled.[/dim]")
28
+ return
29
+
30
+ client.delete(f"/repos/{repo}/issues/{id}")
31
+ rich.print(f"[bold green]Deleted[/bold green] issue #{id} in {repo}")
@@ -0,0 +1,38 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("edit")
11
+ def _(
12
+ id: Annotated[int, Arg(description="Issue number")],
13
+ title: Annotated[str, Option(description="New title", name="title")] = "",
14
+ body: Annotated[str, Option(description="New body", name="body")] = "",
15
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
16
+ ) -> None:
17
+ """Edit an issue's title or body."""
18
+ client = require_client()
19
+
20
+ if not repo:
21
+ inferred = infer_repo()
22
+ if not inferred:
23
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
24
+ return 1
25
+ repo = inferred
26
+
27
+ data: dict = {}
28
+ if title:
29
+ data["title"] = title
30
+ if body:
31
+ data["body"] = body
32
+
33
+ if not data:
34
+ rich.print("[bold yellow]Nothing to update.[/bold yellow] Pass --title and/or --body.")
35
+ return 1
36
+
37
+ client.patch(f"/repos/{repo}/issues/{id}", data=data)
38
+ rich.print(f"[bold green]Updated[/bold green] issue #{id} in {repo}")
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("labels")
5
+ def _() -> None:
6
+ """Manage issue labels — list, add, and remove."""
@@ -0,0 +1,27 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.git import infer_repo
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("add")
11
+ def _(
12
+ id: Annotated[int, Arg(description="Issue number")],
13
+ label: Annotated[str, Arg(description="Label name")],
14
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
15
+ ) -> None:
16
+ """Add a label to an issue."""
17
+ client = require_client()
18
+
19
+ if not repo:
20
+ inferred = infer_repo()
21
+ if not inferred:
22
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
23
+ return 1
24
+ repo = inferred
25
+
26
+ client.post(f"/repos/{repo}/issues/{id}/labels", data={"labels": [label]})
27
+ rich.print(f"[bold green]Added label[/bold green] \"{label}\" to issue #{id} in {repo}")