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.
- codeberg_cli/__main__.py +8 -0
- codeberg_cli/client.py +70 -0
- codeberg_cli/config.py +29 -0
- codeberg_cli/git.py +49 -0
- codeberg_cli/helpers.py +20 -0
- codeberg_cli/routes/__init__.py +6 -0
- codeberg_cli/routes/api.py +23 -0
- codeberg_cli/routes/auth/__init__.py +1 -0
- codeberg_cli/routes/auth/login.py +23 -0
- codeberg_cli/routes/auth/logout.py +17 -0
- codeberg_cli/routes/auth/status.py +21 -0
- codeberg_cli/routes/auth/whoami.py +14 -0
- codeberg_cli/routes/issue/__init__.py +1 -0
- codeberg_cli/routes/issue/close.py +28 -0
- codeberg_cli/routes/issue/create.py +55 -0
- codeberg_cli/routes/issue/list.py +49 -0
- codeberg_cli/routes/issue/reopen.py +28 -0
- codeberg_cli/routes/issue/view.py +46 -0
- codeberg_cli/routes/label/__init__.py +1 -0
- codeberg_cli/routes/label/create.py +35 -0
- codeberg_cli/routes/label/delete.py +26 -0
- codeberg_cli/routes/label/list.py +41 -0
- codeberg_cli/routes/milestone/__init__.py +1 -0
- codeberg_cli/routes/milestone/create.py +35 -0
- codeberg_cli/routes/milestone/list.py +46 -0
- codeberg_cli/routes/pr/__init__.py +1 -0
- codeberg_cli/routes/pr/checkout.py +42 -0
- codeberg_cli/routes/pr/close.py +28 -0
- codeberg_cli/routes/pr/create.py +68 -0
- codeberg_cli/routes/pr/list.py +43 -0
- codeberg_cli/routes/pr/merge.py +32 -0
- codeberg_cli/routes/pr/view.py +42 -0
- codeberg_cli/routes/release/__init__.py +1 -0
- codeberg_cli/routes/release/create.py +40 -0
- codeberg_cli/routes/release/list.py +42 -0
- codeberg_cli/routes/release/upload.py +51 -0
- codeberg_cli/routes/release/view.py +50 -0
- codeberg_cli/routes/repo/__init__.py +1 -0
- codeberg_cli/routes/repo/clone.py +21 -0
- codeberg_cli/routes/repo/create.py +77 -0
- codeberg_cli/routes/repo/delete.py +27 -0
- codeberg_cli/routes/repo/fork.py +28 -0
- codeberg_cli/routes/repo/list.py +36 -0
- codeberg_cli/routes/repo/view.py +41 -0
- codeberg_cli-0.1.0.dist-info/METADATA +182 -0
- codeberg_cli-0.1.0.dist-info/RECORD +49 -0
- codeberg_cli-0.1.0.dist-info/WHEEL +4 -0
- codeberg_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codeberg_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
codeberg_cli/__main__.py
ADDED
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
|
codeberg_cli/helpers.py
ADDED
|
@@ -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,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']}")
|