codeberg-cli 0.1.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 (49) hide show
  1. codeberg_cli/__main__.py +8 -0
  2. codeberg_cli/client.py +70 -0
  3. codeberg_cli/config.py +29 -0
  4. codeberg_cli/git.py +49 -0
  5. codeberg_cli/helpers.py +20 -0
  6. codeberg_cli/routes/__init__.py +6 -0
  7. codeberg_cli/routes/api.py +23 -0
  8. codeberg_cli/routes/auth/__init__.py +1 -0
  9. codeberg_cli/routes/auth/login.py +23 -0
  10. codeberg_cli/routes/auth/logout.py +17 -0
  11. codeberg_cli/routes/auth/status.py +21 -0
  12. codeberg_cli/routes/auth/whoami.py +14 -0
  13. codeberg_cli/routes/issue/__init__.py +1 -0
  14. codeberg_cli/routes/issue/close.py +28 -0
  15. codeberg_cli/routes/issue/create.py +55 -0
  16. codeberg_cli/routes/issue/list.py +49 -0
  17. codeberg_cli/routes/issue/reopen.py +28 -0
  18. codeberg_cli/routes/issue/view.py +46 -0
  19. codeberg_cli/routes/label/__init__.py +1 -0
  20. codeberg_cli/routes/label/create.py +35 -0
  21. codeberg_cli/routes/label/delete.py +26 -0
  22. codeberg_cli/routes/label/list.py +41 -0
  23. codeberg_cli/routes/milestone/__init__.py +1 -0
  24. codeberg_cli/routes/milestone/create.py +35 -0
  25. codeberg_cli/routes/milestone/list.py +46 -0
  26. codeberg_cli/routes/pr/__init__.py +1 -0
  27. codeberg_cli/routes/pr/checkout.py +42 -0
  28. codeberg_cli/routes/pr/close.py +28 -0
  29. codeberg_cli/routes/pr/create.py +68 -0
  30. codeberg_cli/routes/pr/list.py +43 -0
  31. codeberg_cli/routes/pr/merge.py +32 -0
  32. codeberg_cli/routes/pr/view.py +42 -0
  33. codeberg_cli/routes/release/__init__.py +1 -0
  34. codeberg_cli/routes/release/create.py +40 -0
  35. codeberg_cli/routes/release/list.py +42 -0
  36. codeberg_cli/routes/release/upload.py +51 -0
  37. codeberg_cli/routes/release/view.py +50 -0
  38. codeberg_cli/routes/repo/__init__.py +1 -0
  39. codeberg_cli/routes/repo/clone.py +21 -0
  40. codeberg_cli/routes/repo/create.py +77 -0
  41. codeberg_cli/routes/repo/delete.py +27 -0
  42. codeberg_cli/routes/repo/fork.py +28 -0
  43. codeberg_cli/routes/repo/list.py +36 -0
  44. codeberg_cli/routes/repo/view.py +41 -0
  45. codeberg_cli-0.1.0.dist-info/METADATA +182 -0
  46. codeberg_cli-0.1.0.dist-info/RECORD +49 -0
  47. codeberg_cli-0.1.0.dist-info/WHEEL +4 -0
  48. codeberg_cli-0.1.0.dist-info/entry_points.txt +2 -0
  49. codeberg_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,8 @@
1
+ from xclif import Cli
2
+
3
+ from . import routes
4
+
5
+ cli = Cli.from_routes(routes)
6
+
7
+ if __name__ == "__main__":
8
+ cli()
codeberg_cli/client.py ADDED
@@ -0,0 +1,70 @@
1
+ import httpx
2
+ from rich.status import Status
3
+ from xclif.context import get_context
4
+
5
+ BASE_URL = "https://codeberg.org/api/v1"
6
+
7
+ _VERBS = {
8
+ "GET": "Fetching",
9
+ "POST": "Creating",
10
+ "PATCH": "Updating",
11
+ "DELETE": "Deleting",
12
+ }
13
+
14
+
15
+ class ClientError(RuntimeError):
16
+ """Raised when the API returns a non-2xx status."""
17
+
18
+
19
+ class Client:
20
+ """HTTP client for the Codeberg API."""
21
+
22
+ def __init__(self, token: str | None = None) -> None:
23
+ headers = {
24
+ "Accept": "application/json",
25
+ "User-Agent": "codeberg-cli/0.1.0",
26
+ "Content-Type": "application/json",
27
+ }
28
+ if token:
29
+ headers["Authorization"] = f"Bearer {token}"
30
+ self._client = httpx.Client(base_url=BASE_URL, headers=headers, timeout=30.0)
31
+
32
+ def get(
33
+ self, path: str, params: dict | None = None, action: str | None = None
34
+ ) -> dict | list:
35
+ return self._request("GET", path, params=params, action=action)
36
+
37
+ def post(
38
+ self, path: str, data: dict | None = None, action: str | None = None
39
+ ) -> dict | list:
40
+ return self._request("POST", path, json=data, action=action)
41
+
42
+ def patch(
43
+ self, path: str, data: dict | None = None, action: str | None = None
44
+ ) -> dict | list:
45
+ return self._request("PATCH", path, json=data, action=action)
46
+
47
+ def delete(self, path: str, action: str | None = None) -> None:
48
+ self._request("DELETE", path, action=action)
49
+
50
+ def _request(
51
+ self, method: str, path: str, action: str | None = None, **kwargs
52
+ ) -> dict | list | None:
53
+ ctx = get_context()
54
+ label = action or (
55
+ f"{method} {path}"
56
+ if ctx.verbosity >= 2
57
+ else f"{_VERBS.get(method, method)} {path.split('/')[-1]}"
58
+ )
59
+ with Status(f"{label}..."):
60
+ response = self._client.request(method, path, **kwargs)
61
+ if not response.is_success:
62
+ msg = (
63
+ response.json().get("message", "unknown error")
64
+ if response.text
65
+ else "unknown error"
66
+ )
67
+ raise ClientError(f"{response.status_code} {msg}")
68
+ if response.status_code == 204:
69
+ return None
70
+ return response.json()
codeberg_cli/config.py ADDED
@@ -0,0 +1,29 @@
1
+ from pathlib import Path
2
+
3
+ import tomlkit
4
+ from platformdirs import user_config_path
5
+
6
+
7
+ def get_config_path() -> Path:
8
+ """Return the config directory for codeberg-cli."""
9
+ return user_config_path("codeberg-cli", ensure_exists=False)
10
+
11
+
12
+ def config_file_path() -> Path:
13
+ """Return the path to the config file."""
14
+ return get_config_path() / "config.toml"
15
+
16
+
17
+ def load_config() -> dict:
18
+ """Load config from ~/.config/codeberg-cli/config.toml. Returns {} if missing."""
19
+ path = config_file_path()
20
+ if not path.exists():
21
+ return {}
22
+ return dict(tomlkit.parse(path.read_text()))
23
+
24
+
25
+ def save_config(data: dict) -> None:
26
+ """Save config to ~/.config/codeberg-cli/config.toml."""
27
+ path = config_file_path()
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ path.write_text(tomlkit.dumps(data))
codeberg_cli/git.py ADDED
@@ -0,0 +1,49 @@
1
+ import re
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+
6
+ def parse_repo_from_remote(remote_url: str) -> str:
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}")
14
+
15
+
16
+ def get_default_branch(cwd: Path) -> str:
17
+ """Get the default branch name for a repo."""
18
+ try:
19
+ result = subprocess.run(
20
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
21
+ capture_output=True, text=True, check=True, cwd=cwd,
22
+ )
23
+ return result.stdout.strip()
24
+ except (subprocess.CalledProcessError, FileNotFoundError):
25
+ return "main"
26
+
27
+
28
+ def get_repo_remote(cwd: Path | None = None) -> str | None:
29
+ """Get the 'origin' remote URL from a git repo, or None."""
30
+ try:
31
+ result = subprocess.run(
32
+ ["git", "remote", "get-url", "origin"],
33
+ capture_output=True, text=True, check=True,
34
+ cwd=cwd or Path.cwd(),
35
+ )
36
+ return result.stdout.strip()
37
+ except (subprocess.CalledProcessError, FileNotFoundError):
38
+ return None
39
+
40
+
41
+ def infer_repo(cwd: Path | None = None) -> str | None:
42
+ """Infer owner/repo from git remote origin."""
43
+ remote = get_repo_remote(cwd)
44
+ if remote is None:
45
+ return None
46
+ try:
47
+ return parse_repo_from_remote(remote)
48
+ except ValueError:
49
+ return None
@@ -0,0 +1,20 @@
1
+ from codeberg_cli.client import Client, ClientError
2
+ from codeberg_cli.config import load_config
3
+
4
+
5
+ def get_authenticated_client() -> Client | None:
6
+ """Return a Client if token is stored, else None."""
7
+ config = load_config()
8
+ token = config.get("token")
9
+ if not token:
10
+ return None
11
+ return Client(token=token)
12
+
13
+
14
+ def require_client() -> Client:
15
+ """Return an authenticated Client or print error and exit."""
16
+ client = get_authenticated_client()
17
+ if client is None:
18
+ from xclif.errors import UsageError
19
+ raise UsageError("Not logged in. Run 'cb auth login' first.")
20
+ return client
@@ -0,0 +1,6 @@
1
+ from xclif import command
2
+
3
+
4
+ @command("cb")
5
+ def _() -> None:
6
+ """A Codeberg CLI."""
@@ -0,0 +1,23 @@
1
+ import json
2
+ from typing import Annotated
3
+
4
+ import rich
5
+
6
+ from codeberg_cli.helpers import require_client
7
+ from xclif import Arg, Option, command
8
+
9
+
10
+ @command("api")
11
+ def _(
12
+ method: Annotated[str, Arg(description="HTTP method (GET, POST, PATCH, DELETE)")],
13
+ path: Annotated[str, Arg(description="API path (e.g. /repos/owner/repo)")],
14
+ data: Annotated[str, Option(description="JSON body for POST/PATCH", name="data")] = "",
15
+ ) -> None:
16
+ """Make an authenticated API request to Codeberg."""
17
+ client = require_client()
18
+ kwargs = {}
19
+ if data:
20
+ kwargs["json"] = json.loads(data)
21
+ result = client._request(method.upper(), path, **kwargs)
22
+ if result is not None:
23
+ rich.print_json(data=result)
@@ -0,0 +1 @@
1
+ # Namespace package for auth subcommands
@@ -0,0 +1,23 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+
5
+ from codeberg_cli.client import Client
6
+ from codeberg_cli.config import save_config
7
+ from xclif import Option, command
8
+
9
+
10
+ @command("login")
11
+ def _(
12
+ token: Annotated[str, Option(description="Codeberg access token", name="token")] = "",
13
+ ) -> None:
14
+ """Authenticate with Codeberg using a personal access token."""
15
+ if not token:
16
+ token = input("Enter your Codeberg access token: ").strip()
17
+
18
+ client = Client(token=token)
19
+ user = client.get("/user", action="Verifying token")
20
+ save_config({"token": token})
21
+ rich.print(f"[bold green]Logged in as[/bold green] [bold]{user['login']}[/bold]")
22
+
23
+
@@ -0,0 +1,17 @@
1
+ import rich
2
+
3
+ from codeberg_cli.config import config_file_path
4
+ from xclif import command
5
+
6
+
7
+ @command("logout")
8
+ def _() -> None:
9
+ """Remove stored Codeberg credentials."""
10
+ path = config_file_path()
11
+ if path.exists():
12
+ path.unlink()
13
+ rich.print("[dim]Logged out.[/dim]")
14
+ else:
15
+ rich.print("[dim]Not logged in.[/dim]")
16
+
17
+
@@ -0,0 +1,21 @@
1
+ import rich
2
+
3
+ from codeberg_cli.helpers import get_authenticated_client
4
+ from xclif import command
5
+
6
+
7
+ @command("status")
8
+ def _() -> None:
9
+ """Show authentication status."""
10
+ client = get_authenticated_client()
11
+ if client is None:
12
+ rich.print("[yellow]Not logged in[/yellow]")
13
+ rich.print("Run [bold]cb auth login[/bold] to authenticate.")
14
+ return
15
+
16
+ user = client.get("/user", action="Checking authentication")
17
+ rich.print(f"Logged in to [cyan]Codeberg[/cyan] as [bold]{user['login']}[/bold]")
18
+ rich.print(f" User ID: {user['id']}")
19
+ rich.print(f" Full Name: {user.get('full_name', '(not set)')}")
20
+
21
+
@@ -0,0 +1,14 @@
1
+ import rich
2
+
3
+ from codeberg_cli.helpers import require_client
4
+ from xclif import command
5
+
6
+
7
+ @command("whoami")
8
+ def _() -> None:
9
+ """Print the current authenticated user's username."""
10
+ client = require_client()
11
+ user = client.get("/user")
12
+ print(user["login"])
13
+
14
+
@@ -0,0 +1 @@
1
+ # Namespace package for issue subcommands
@@ -0,0 +1,28 @@
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("close")
11
+ def _(
12
+ id: Annotated[int, Arg(description="Issue number")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ) -> None:
15
+ """Close 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
+ client.patch(f"/repos/{repo}/issues/{id}", data={"state": "closed"})
26
+ rich.print(f"[bold green]Closed[/bold green] #{id} in {repo}")
27
+
28
+
@@ -0,0 +1,55 @@
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 Option, command
8
+
9
+
10
+ @command("create")
11
+ def _(
12
+ title: Annotated[str, Option(description="Issue title", name="title")] = "",
13
+ body: Annotated[str, Option(description="Issue body", name="body")] = "",
14
+ labels: Annotated[str, Option(description="Comma-separated label names", name="labels")] = "",
15
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
16
+ ) -> None:
17
+ """Create an issue."""
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
+ if not title:
28
+ title = input("Title: ").strip()
29
+ if not title:
30
+ rich.print("[bold red]Error:[/bold red] Title is required")
31
+ return 1
32
+
33
+ if not body:
34
+ rich.print("[dim]Enter body (Ctrl+D to finish, or leave empty):[/dim]")
35
+ try:
36
+ lines = []
37
+ while True:
38
+ line = input()
39
+ lines.append(line)
40
+ except (EOFError, KeyboardInterrupt):
41
+ pass
42
+ body = "\n".join(lines).strip()
43
+
44
+ data = {"title": title}
45
+ if body:
46
+ data["body"] = body
47
+ if labels:
48
+ data["labels"] = [l.strip() for l in labels.split(",")]
49
+
50
+ result = client.post(f"/repos/{repo}/issues", data=data)
51
+
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]")
54
+
55
+
@@ -0,0 +1,49 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+ from rich.table import Table
5
+
6
+ from codeberg_cli.git import infer_repo
7
+ from codeberg_cli.helpers import require_client
8
+ from xclif import Option, command
9
+
10
+
11
+ @command("list", "ls")
12
+ def _(
13
+ state: Annotated[str, Option(description="Filter by state: open, closed", name="state")] = "open",
14
+ label: Annotated[str, Option(description="Filter by label", name="label")] = "",
15
+ limit: Annotated[int, Option(description="Maximum issues to show", name="limit")] = 30,
16
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
17
+ ) -> None:
18
+ """List issues."""
19
+ client = require_client()
20
+
21
+ if not repo:
22
+ inferred = infer_repo()
23
+ if not inferred:
24
+ rich.print("[bold red]Error:[/bold red] No repo specified and not in a git directory")
25
+ return 1
26
+ repo = inferred
27
+
28
+ params = {"state": state, "limit": limit, "page": 1, "type": "issues"}
29
+ if label:
30
+ params["labels"] = label
31
+
32
+ issues = client.get(f"/repos/{repo}/issues", params=params)
33
+
34
+ if not issues:
35
+ rich.print(f"[dim]No {state} issues in {repo}.[/dim]")
36
+ return
37
+
38
+ table = Table("#", "Title", "Author", "Labels")
39
+ for issue in issues:
40
+ labels_str = ", ".join(l["name"] for l in (issue.get("labels") or []))
41
+ table.add_row(
42
+ str(issue["number"]),
43
+ issue["title"],
44
+ issue["user"]["login"],
45
+ labels_str,
46
+ )
47
+ rich.print(table)
48
+
49
+
@@ -0,0 +1,28 @@
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("reopen")
11
+ def _(
12
+ id: Annotated[int, Arg(description="Issue number")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ) -> None:
15
+ """Reopen a closed 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
+ client.patch(f"/repos/{repo}/issues/{id}", data={"state": "open"})
26
+ rich.print(f"[bold green]Reopened[/bold green] #{id} in {repo}")
27
+
28
+
@@ -0,0 +1,46 @@
1
+ import webbrowser
2
+ from typing import Annotated
3
+
4
+ import rich
5
+
6
+ from codeberg_cli.git import infer_repo
7
+ from codeberg_cli.helpers import require_client
8
+ from xclif import Arg, Option, command
9
+
10
+
11
+ @command("view")
12
+ def _(
13
+ id: Annotated[int, Arg(description="Issue number")],
14
+ web: Annotated[bool, Option(description="Open in browser", name="web")] = False,
15
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
16
+ ) -> None:
17
+ """View an issue."""
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
+ if web:
28
+ webbrowser.open(f"https://codeberg.org/{repo}/issues/{id}")
29
+ return
30
+
31
+ issue = client.get(f"/repos/{repo}/issues/{id}")
32
+ comments = client.get(f"/repos/{repo}/issues/{id}/comments")
33
+
34
+ state_color = "green" if issue["state"] == "open" else "red"
35
+ rich.print(f"[bold]#{issue['number']}[/bold] [bold]{issue['title']}[/bold]")
36
+ rich.print(f"[{state_color}]{issue['state']}[/{state_color}] [dim]by {issue['user']['login']}[/dim]")
37
+ if issue.get("body"):
38
+ rich.print()
39
+ rich.print(issue["body"])
40
+ if comments:
41
+ rich.print()
42
+ rich.print(f"[bold]{len(comments)} comments[/bold]")
43
+ for comment in comments:
44
+ rich.print(f" [dim]{comment['user']['login']}:[/dim] {comment.get('body', '')[:100]}")
45
+
46
+
@@ -0,0 +1 @@
1
+ # Namespace package for label subcommands
@@ -0,0 +1,35 @@
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("create")
11
+ def _(
12
+ name: Annotated[str, Arg(description="Label name")],
13
+ color: Annotated[str, Option(description="Label color (hex, e.g. ff0000)")] = "",
14
+ description: Annotated[str, Option(description="Label description", name="description")] = "",
15
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
16
+ ) -> None:
17
+ """Create a label."""
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 = {"name": name}
28
+ if color:
29
+ data["color"] = color.lstrip("#")
30
+ if description:
31
+ data["description"] = description
32
+
33
+ label = client.post(f"/repos/{repo}/labels", data=data)
34
+
35
+ rich.print(f"[bold green]Created label[/bold green] {label['name']} (#{label['color']})")
@@ -0,0 +1,26 @@
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
+ label_id: Annotated[int, Arg(description="Label ID")],
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ ) -> None:
15
+ """Delete a label."""
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
+ client.delete(f"/repos/{repo}/labels/{label_id}")
26
+ rich.print(f"[bold green]Deleted label[/bold green] #{label_id}")
@@ -0,0 +1,41 @@
1
+ from typing import Annotated
2
+
3
+ import rich
4
+ from rich.table import Table
5
+
6
+ from codeberg_cli.git import infer_repo
7
+ from codeberg_cli.helpers import require_client
8
+ from xclif import Option, command
9
+
10
+
11
+ @command("list", "ls")
12
+ def _(
13
+ repo: Annotated[str, Option(description="Repository (owner/repo)", name="repo")] = "",
14
+ limit: Annotated[int, Option(description="Maximum labels to show", name="limit")] = 30,
15
+ ) -> None:
16
+ """List labels for a repository."""
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
+ labels = client.get(f"/repos/{repo}/labels", params={"limit": limit})
27
+
28
+ if not labels:
29
+ rich.print(f"[dim]No labels in {repo}.[/dim]")
30
+ return
31
+
32
+ table = Table("#", "Name", "Color", "Description")
33
+ for label in labels:
34
+ color = label.get("color", "ffffff")
35
+ table.add_row(
36
+ str(label["id"]),
37
+ f"[bold #{color} on #{color}] [/bold #{color} on #{color}] {label['name']}",
38
+ f"#{color}",
39
+ label.get("description", "") or "",
40
+ )
41
+ rich.print(table)
@@ -0,0 +1 @@
1
+ # Namespace package for milestone subcommands
@@ -0,0 +1,35 @@
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("create")
11
+ def _(
12
+ title: Annotated[str, Arg(description="Milestone title")],
13
+ description: Annotated[str, Option(description="Milestone description")] = "",
14
+ due_on: Annotated[str, Option(description="Due date (YYYY-MM-DD)")] = "",
15
+ repo: Annotated[str, Option(description="Repository (owner/repo)")] = "",
16
+ ) -> None:
17
+ """Create a milestone."""
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 = {"title": title}
28
+ if description:
29
+ data["description"] = description
30
+ if due_on:
31
+ data["due_on"] = due_on
32
+
33
+ ms = client.post(f"/repos/{repo}/milestones", data=data)
34
+
35
+ rich.print(f"[bold green]Created milestone[/bold green] #{ms['id']}: {ms['title']}")