plane-cli 0.1.0__tar.gz
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.
- plane_cli-0.1.0/PKG-INFO +7 -0
- plane_cli-0.1.0/plane_cli/__init__.py +3 -0
- plane_cli-0.1.0/plane_cli/client.py +90 -0
- plane_cli-0.1.0/plane_cli/commands/__init__.py +0 -0
- plane_cli-0.1.0/plane_cli/commands/config_cmd.py +123 -0
- plane_cli-0.1.0/plane_cli/commands/issues.py +317 -0
- plane_cli-0.1.0/plane_cli/commands/labels.py +141 -0
- plane_cli-0.1.0/plane_cli/commands/projects.py +151 -0
- plane_cli-0.1.0/plane_cli/commands/states.py +157 -0
- plane_cli-0.1.0/plane_cli/config.py +144 -0
- plane_cli-0.1.0/plane_cli/main.py +73 -0
- plane_cli-0.1.0/plane_cli/output.py +338 -0
- plane_cli-0.1.0/pyproject.toml +20 -0
plane_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""PlaneClient factory and retry logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from plane.client.plane_client import PlaneClient
|
|
10
|
+
from plane.errors.errors import HttpError
|
|
11
|
+
|
|
12
|
+
from plane_cli.output import print_error
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from plane_cli.config import Config
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_client(cfg: "Config") -> PlaneClient:
|
|
21
|
+
"""Build and return a PlaneClient, validating required credentials."""
|
|
22
|
+
if not cfg.api_key:
|
|
23
|
+
print_error(
|
|
24
|
+
"auth_error",
|
|
25
|
+
"No API key configured. Set PLANE_API_KEY, use --api-key, or run `plane config init`.",
|
|
26
|
+
)
|
|
27
|
+
raise typer.Exit(1)
|
|
28
|
+
if not cfg.workspace_slug:
|
|
29
|
+
print_error(
|
|
30
|
+
"config_error",
|
|
31
|
+
"No workspace slug configured. Set PLANE_WORKSPACE_SLUG, use --workspace, or run `plane config init`.",
|
|
32
|
+
)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
return PlaneClient(base_url=cfg.base_url, api_key=cfg.api_key)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def call_with_retry(fn: Callable[..., T], *args: Any, max_retries: int = 3, **kwargs: Any) -> T:
|
|
39
|
+
"""Call fn(*args, **kwargs) with retry on HTTP 429."""
|
|
40
|
+
for attempt in range(max_retries):
|
|
41
|
+
try:
|
|
42
|
+
return fn(*args, **kwargs)
|
|
43
|
+
except HttpError as exc:
|
|
44
|
+
if exc.status_code == 429:
|
|
45
|
+
# Try to read Retry-After header
|
|
46
|
+
retry_after = 5
|
|
47
|
+
if hasattr(exc, "response") and exc.response is not None:
|
|
48
|
+
try:
|
|
49
|
+
retry_after = int(exc.response.headers.get("Retry-After", 5))
|
|
50
|
+
except (AttributeError, ValueError):
|
|
51
|
+
retry_after = 5
|
|
52
|
+
|
|
53
|
+
if attempt < max_retries - 1:
|
|
54
|
+
time.sleep(retry_after)
|
|
55
|
+
continue
|
|
56
|
+
else:
|
|
57
|
+
print_error(
|
|
58
|
+
"rate_limit",
|
|
59
|
+
f"Rate limit exceeded after {max_retries} attempts.",
|
|
60
|
+
status_code=429,
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(2)
|
|
63
|
+
else:
|
|
64
|
+
_handle_http_error(exc)
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
# Should not reach here
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _handle_http_error(exc: HttpError) -> None:
|
|
72
|
+
"""Translate an HttpError into a structured error output."""
|
|
73
|
+
status = exc.status_code
|
|
74
|
+
message = str(exc)
|
|
75
|
+
|
|
76
|
+
if status == 401:
|
|
77
|
+
error_type = "auth_error"
|
|
78
|
+
message = "Authentication failed. Check your API key."
|
|
79
|
+
elif status == 403:
|
|
80
|
+
error_type = "forbidden"
|
|
81
|
+
message = "Permission denied."
|
|
82
|
+
elif status == 404:
|
|
83
|
+
error_type = "not_found"
|
|
84
|
+
message = f"Resource not found. {message}"
|
|
85
|
+
elif status == 400:
|
|
86
|
+
error_type = "validation_error"
|
|
87
|
+
else:
|
|
88
|
+
error_type = "api_error"
|
|
89
|
+
|
|
90
|
+
print_error(error_type, message, status_code=status)
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""config subcommand: show / set / init."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.prompt import Prompt
|
|
11
|
+
|
|
12
|
+
from plane_cli.config import (
|
|
13
|
+
Config,
|
|
14
|
+
CONFIG_PATH,
|
|
15
|
+
config_as_dict,
|
|
16
|
+
load_config,
|
|
17
|
+
save_config,
|
|
18
|
+
save_config_key,
|
|
19
|
+
)
|
|
20
|
+
from plane_cli.output import print_json, print_error, out_console
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(name="config", help="Manage plane-cli configuration.", no_args_is_help=True)
|
|
23
|
+
console = Console()
|
|
24
|
+
err_console = Console(stderr=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("show")
|
|
28
|
+
def config_show(
|
|
29
|
+
ctx: typer.Context,
|
|
30
|
+
reveal: bool = typer.Option(False, "--reveal", help="Show the full API key."),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Show the resolved configuration (masks api_key by default)."""
|
|
33
|
+
cfg: Config = ctx.obj
|
|
34
|
+
print_json(config_as_dict(cfg, reveal=reveal))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command("set")
|
|
38
|
+
def config_set(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
key: str = typer.Argument(..., help="Dotted config key, e.g. defaults.project"),
|
|
41
|
+
value: str = typer.Argument(..., help="Value to set"),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Write a config value. Example: plane config set defaults.project <UUID>"""
|
|
44
|
+
try:
|
|
45
|
+
save_config_key(key, value)
|
|
46
|
+
print_json({"ok": True, "key": key, "value": value})
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
print_error("config_error", f"Failed to write config: {exc}")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("init")
|
|
53
|
+
def config_init(ctx: typer.Context) -> None:
|
|
54
|
+
"""Interactive first-run wizard (TTY only)."""
|
|
55
|
+
if not sys.stdin.isatty():
|
|
56
|
+
print_error("validation_error", "`plane config init` requires an interactive terminal.")
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
console.print("[bold]plane-cli setup wizard[/bold]")
|
|
60
|
+
console.print(f"Config will be saved to: [dim]{CONFIG_PATH}[/dim]\n")
|
|
61
|
+
|
|
62
|
+
cfg: Config = ctx.obj
|
|
63
|
+
|
|
64
|
+
api_key = Prompt.ask(
|
|
65
|
+
"API key",
|
|
66
|
+
password=True,
|
|
67
|
+
default=cfg.api_key or "",
|
|
68
|
+
)
|
|
69
|
+
workspace_slug = Prompt.ask(
|
|
70
|
+
"Workspace slug",
|
|
71
|
+
default=cfg.workspace_slug or "",
|
|
72
|
+
)
|
|
73
|
+
base_url = Prompt.ask(
|
|
74
|
+
"Base URL",
|
|
75
|
+
default=cfg.base_url,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if not api_key:
|
|
79
|
+
print_error("validation_error", "API key is required.")
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
if not workspace_slug:
|
|
82
|
+
print_error("validation_error", "Workspace slug is required.")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
# Validate credentials by listing projects
|
|
86
|
+
console.print("\nValidating credentials…")
|
|
87
|
+
try:
|
|
88
|
+
from plane.client.plane_client import PlaneClient
|
|
89
|
+
client = PlaneClient(base_url=base_url, api_key=api_key)
|
|
90
|
+
response = client.projects.list(workspace_slug)
|
|
91
|
+
projects = response.results or []
|
|
92
|
+
console.print(f"[green]✓[/green] Connected. Found {len(projects)} project(s).")
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
print_error("auth_error", f"Could not connect: {exc}")
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
# Optionally set default project
|
|
98
|
+
default_project: Optional[str] = None
|
|
99
|
+
if projects:
|
|
100
|
+
console.print("\nAvailable projects:")
|
|
101
|
+
for i, p in enumerate(projects, 1):
|
|
102
|
+
console.print(f" {i}. [{p.identifier}] {p.name} (id: {p.id})")
|
|
103
|
+
choice = Prompt.ask(
|
|
104
|
+
"Set a default project? Enter number or press Enter to skip",
|
|
105
|
+
default="",
|
|
106
|
+
)
|
|
107
|
+
if choice.strip().isdigit():
|
|
108
|
+
idx = int(choice.strip()) - 1
|
|
109
|
+
if 0 <= idx < len(projects):
|
|
110
|
+
default_project = projects[idx].id
|
|
111
|
+
console.print(f"[green]✓[/green] Default project: {projects[idx].name}")
|
|
112
|
+
|
|
113
|
+
# Build and save config
|
|
114
|
+
new_cfg = load_config()
|
|
115
|
+
new_cfg.api_key = api_key
|
|
116
|
+
new_cfg.workspace_slug = workspace_slug
|
|
117
|
+
new_cfg.base_url = base_url
|
|
118
|
+
if default_project:
|
|
119
|
+
new_cfg.project = default_project
|
|
120
|
+
|
|
121
|
+
save_config(new_cfg)
|
|
122
|
+
console.print(f"\n[green]✓[/green] Config saved to {CONFIG_PATH}")
|
|
123
|
+
print_json(config_as_dict(new_cfg))
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""issues subcommand: list / get / create / update / delete + comment add/list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from plane.models.work_items import (
|
|
10
|
+
CreateWorkItem,
|
|
11
|
+
CreateWorkItemComment,
|
|
12
|
+
UpdateWorkItem,
|
|
13
|
+
)
|
|
14
|
+
from plane.models.query_params import WorkItemQueryParams
|
|
15
|
+
|
|
16
|
+
from plane_cli.client import get_client, call_with_retry
|
|
17
|
+
from plane_cli.config import Config
|
|
18
|
+
from plane_cli.output import (
|
|
19
|
+
build_comments_table,
|
|
20
|
+
build_issues_table,
|
|
21
|
+
out_console,
|
|
22
|
+
print_error,
|
|
23
|
+
print_json,
|
|
24
|
+
read_text_arg,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(name="issues", help="Manage issues (work items).", no_args_is_help=True)
|
|
28
|
+
comment_app = typer.Typer(name="comment", help="Manage issue comments.", no_args_is_help=True)
|
|
29
|
+
app.add_typer(comment_app)
|
|
30
|
+
|
|
31
|
+
_VALID_PRIORITIES = ("urgent", "high", "medium", "low", "none")
|
|
32
|
+
_MAX_ALL_PAGES = 1000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _issue_to_dict(issue: object) -> dict:
|
|
36
|
+
return issue.model_dump() if hasattr(issue, "model_dump") else dict(issue)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_project(cfg: Config, project_flag: Optional[str]) -> str:
|
|
40
|
+
project_id = project_flag or cfg.project
|
|
41
|
+
if not project_id:
|
|
42
|
+
print_error(
|
|
43
|
+
"config_error",
|
|
44
|
+
"No project specified. Use --project or set defaults.project in config.",
|
|
45
|
+
)
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
return project_id
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command("list")
|
|
51
|
+
def issues_list(
|
|
52
|
+
ctx: typer.Context,
|
|
53
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
54
|
+
state: Optional[str] = typer.Option(None, "--state", help="Filter by state ID"),
|
|
55
|
+
priority: Optional[str] = typer.Option(None, "--priority", help="Filter by priority"),
|
|
56
|
+
label: Optional[List[str]] = typer.Option(None, "--label", help="Filter by label ID (repeatable)"),
|
|
57
|
+
assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Filter by assignee ID (repeatable)"),
|
|
58
|
+
page: int = typer.Option(1, "--page", help="Page number"),
|
|
59
|
+
per_page: Optional[int] = typer.Option(None, "--per-page", help="Results per page"),
|
|
60
|
+
all_pages: bool = typer.Option(False, "--all", help="Fetch all pages (cap: 1000)"),
|
|
61
|
+
) -> None:
|
|
62
|
+
"""List issues in a project."""
|
|
63
|
+
cfg: Config = ctx.obj
|
|
64
|
+
project_id = _resolve_project(cfg, project)
|
|
65
|
+
client = get_client(cfg)
|
|
66
|
+
|
|
67
|
+
effective_per_page = per_page or cfg.per_page
|
|
68
|
+
|
|
69
|
+
if all_pages:
|
|
70
|
+
all_issues: list[dict] = []
|
|
71
|
+
cursor: Optional[str] = None
|
|
72
|
+
|
|
73
|
+
while True:
|
|
74
|
+
params = WorkItemQueryParams(per_page=effective_per_page)
|
|
75
|
+
if cursor:
|
|
76
|
+
params.cursor = cursor
|
|
77
|
+
|
|
78
|
+
response = call_with_retry(
|
|
79
|
+
client.work_items.list, cfg.workspace_slug, project_id, params
|
|
80
|
+
)
|
|
81
|
+
batch = [_issue_to_dict(i) for i in (response.results or [])]
|
|
82
|
+
all_issues.extend(batch)
|
|
83
|
+
|
|
84
|
+
if len(all_issues) >= _MAX_ALL_PAGES:
|
|
85
|
+
from plane_cli.output import err_console
|
|
86
|
+
err_console.print(
|
|
87
|
+
f"[yellow]Warning: reached {_MAX_ALL_PAGES} issue limit; stopping pagination.[/yellow]"
|
|
88
|
+
)
|
|
89
|
+
all_issues = all_issues[:_MAX_ALL_PAGES]
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
if not response.next_page_results:
|
|
93
|
+
break
|
|
94
|
+
cursor = response.next_cursor
|
|
95
|
+
|
|
96
|
+
issues = all_issues
|
|
97
|
+
else:
|
|
98
|
+
# Manual single-page fetch
|
|
99
|
+
# cursor-based: build cursor from page number
|
|
100
|
+
cursor_str: Optional[str] = None
|
|
101
|
+
if page > 1:
|
|
102
|
+
# Plane uses cursor format: "per_page:offset:0" style but the SDK
|
|
103
|
+
# exposes next_cursor strings from responses. For manual page navigation
|
|
104
|
+
# we use the offset pattern.
|
|
105
|
+
offset = (page - 1) * effective_per_page
|
|
106
|
+
cursor_str = f"{effective_per_page}:{offset}:0"
|
|
107
|
+
|
|
108
|
+
params = WorkItemQueryParams(per_page=effective_per_page)
|
|
109
|
+
if cursor_str:
|
|
110
|
+
params.cursor = cursor_str
|
|
111
|
+
|
|
112
|
+
response = call_with_retry(
|
|
113
|
+
client.work_items.list, cfg.workspace_slug, project_id, params
|
|
114
|
+
)
|
|
115
|
+
issues = [_issue_to_dict(i) for i in (response.results or [])]
|
|
116
|
+
|
|
117
|
+
if cfg.pretty:
|
|
118
|
+
table = build_issues_table(issues)
|
|
119
|
+
out_console.print(table)
|
|
120
|
+
else:
|
|
121
|
+
print_json(issues)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("get")
|
|
125
|
+
def issues_get(
|
|
126
|
+
ctx: typer.Context,
|
|
127
|
+
issue_id: str = typer.Argument(..., help="Issue ID"),
|
|
128
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Get a single issue by ID."""
|
|
131
|
+
cfg: Config = ctx.obj
|
|
132
|
+
project_id = _resolve_project(cfg, project)
|
|
133
|
+
client = get_client(cfg)
|
|
134
|
+
|
|
135
|
+
issue = call_with_retry(
|
|
136
|
+
client.work_items.retrieve, cfg.workspace_slug, project_id, issue_id
|
|
137
|
+
)
|
|
138
|
+
print_json(_issue_to_dict(issue))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command("create")
|
|
142
|
+
def issues_create(
|
|
143
|
+
ctx: typer.Context,
|
|
144
|
+
title: str = typer.Option(..., "--title", "-t", help="Issue title"),
|
|
145
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
146
|
+
description: Optional[str] = typer.Option(
|
|
147
|
+
None, "--description", "-d", help="Description or '-' for stdin"
|
|
148
|
+
),
|
|
149
|
+
state: Optional[str] = typer.Option(None, "--state", help="State ID"),
|
|
150
|
+
priority: Optional[str] = typer.Option(
|
|
151
|
+
None, "--priority", help=f"Priority: {', '.join(_VALID_PRIORITIES)}"
|
|
152
|
+
),
|
|
153
|
+
label: Optional[List[str]] = typer.Option(None, "--label", help="Label ID (repeatable)"),
|
|
154
|
+
assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Assignee ID (repeatable)"),
|
|
155
|
+
due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date YYYY-MM-DD"),
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Create a new issue."""
|
|
158
|
+
cfg: Config = ctx.obj
|
|
159
|
+
project_id = _resolve_project(cfg, project)
|
|
160
|
+
|
|
161
|
+
if priority is not None and priority not in _VALID_PRIORITIES:
|
|
162
|
+
print_error(
|
|
163
|
+
"validation_error",
|
|
164
|
+
f"Invalid priority '{priority}'. Choose from: {', '.join(_VALID_PRIORITIES)}",
|
|
165
|
+
)
|
|
166
|
+
raise typer.Exit(1)
|
|
167
|
+
|
|
168
|
+
data_kwargs: dict = {"name": title}
|
|
169
|
+
|
|
170
|
+
if description is not None:
|
|
171
|
+
desc_text = read_text_arg(description)
|
|
172
|
+
data_kwargs["description_html"] = f"<p>{desc_text}</p>"
|
|
173
|
+
data_kwargs["description_stripped"] = desc_text
|
|
174
|
+
|
|
175
|
+
if state is not None:
|
|
176
|
+
data_kwargs["state"] = state
|
|
177
|
+
if priority is not None:
|
|
178
|
+
data_kwargs["priority"] = priority
|
|
179
|
+
if label:
|
|
180
|
+
data_kwargs["labels"] = list(label)
|
|
181
|
+
if assignee:
|
|
182
|
+
data_kwargs["assignees"] = list(assignee)
|
|
183
|
+
if due_date is not None:
|
|
184
|
+
data_kwargs["target_date"] = due_date
|
|
185
|
+
|
|
186
|
+
client = get_client(cfg)
|
|
187
|
+
data = CreateWorkItem(**data_kwargs)
|
|
188
|
+
issue = call_with_retry(client.work_items.create, cfg.workspace_slug, project_id, data)
|
|
189
|
+
print_json(_issue_to_dict(issue))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command("update")
|
|
193
|
+
def issues_update(
|
|
194
|
+
ctx: typer.Context,
|
|
195
|
+
issue_id: str = typer.Argument(..., help="Issue ID"),
|
|
196
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
197
|
+
title: Optional[str] = typer.Option(None, "--title", "-t"),
|
|
198
|
+
description: Optional[str] = typer.Option(
|
|
199
|
+
None, "--description", "-d", help="Description or '-' for stdin"
|
|
200
|
+
),
|
|
201
|
+
state: Optional[str] = typer.Option(None, "--state", help="State ID"),
|
|
202
|
+
priority: Optional[str] = typer.Option(None, "--priority"),
|
|
203
|
+
label: Optional[List[str]] = typer.Option(None, "--label", help="Label ID (repeatable)"),
|
|
204
|
+
due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date YYYY-MM-DD"),
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Update an issue."""
|
|
207
|
+
cfg: Config = ctx.obj
|
|
208
|
+
project_id = _resolve_project(cfg, project)
|
|
209
|
+
|
|
210
|
+
data_kwargs: dict = {}
|
|
211
|
+
|
|
212
|
+
if title is not None:
|
|
213
|
+
data_kwargs["name"] = title
|
|
214
|
+
if description is not None:
|
|
215
|
+
desc_text = read_text_arg(description)
|
|
216
|
+
data_kwargs["description_html"] = f"<p>{desc_text}</p>"
|
|
217
|
+
data_kwargs["description_stripped"] = desc_text
|
|
218
|
+
if state is not None:
|
|
219
|
+
data_kwargs["state"] = state
|
|
220
|
+
if priority is not None:
|
|
221
|
+
if priority not in _VALID_PRIORITIES:
|
|
222
|
+
print_error(
|
|
223
|
+
"validation_error",
|
|
224
|
+
f"Invalid priority '{priority}'. Choose from: {', '.join(_VALID_PRIORITIES)}",
|
|
225
|
+
)
|
|
226
|
+
raise typer.Exit(1)
|
|
227
|
+
data_kwargs["priority"] = priority
|
|
228
|
+
if label:
|
|
229
|
+
data_kwargs["labels"] = list(label)
|
|
230
|
+
if due_date is not None:
|
|
231
|
+
data_kwargs["target_date"] = due_date
|
|
232
|
+
|
|
233
|
+
if not data_kwargs:
|
|
234
|
+
print_error("validation_error", "No fields to update.")
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
|
|
237
|
+
client = get_client(cfg)
|
|
238
|
+
data = UpdateWorkItem(**data_kwargs)
|
|
239
|
+
issue = call_with_retry(
|
|
240
|
+
client.work_items.update, cfg.workspace_slug, project_id, issue_id, data
|
|
241
|
+
)
|
|
242
|
+
print_json(_issue_to_dict(issue))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command("delete")
|
|
246
|
+
def issues_delete(
|
|
247
|
+
ctx: typer.Context,
|
|
248
|
+
issue_id: str = typer.Argument(..., help="Issue ID"),
|
|
249
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
250
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Delete an issue."""
|
|
253
|
+
cfg: Config = ctx.obj
|
|
254
|
+
project_id = _resolve_project(cfg, project)
|
|
255
|
+
|
|
256
|
+
if not yes and not sys.stdin.isatty():
|
|
257
|
+
print_error("validation_error", "Pass --yes for non-interactive deletion.")
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
if not yes:
|
|
260
|
+
typer.confirm(f"Delete issue {issue_id}?", abort=True)
|
|
261
|
+
|
|
262
|
+
client = get_client(cfg)
|
|
263
|
+
call_with_retry(client.work_items.delete, cfg.workspace_slug, project_id, issue_id)
|
|
264
|
+
print_json({"ok": True, "deleted": issue_id})
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# comment sub-subcommand
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
@comment_app.command("list")
|
|
272
|
+
def comment_list(
|
|
273
|
+
ctx: typer.Context,
|
|
274
|
+
issue_id: str = typer.Argument(..., help="Issue ID"),
|
|
275
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
276
|
+
) -> None:
|
|
277
|
+
"""List comments on an issue."""
|
|
278
|
+
cfg: Config = ctx.obj
|
|
279
|
+
project_id = _resolve_project(cfg, project)
|
|
280
|
+
client = get_client(cfg)
|
|
281
|
+
|
|
282
|
+
response = call_with_retry(
|
|
283
|
+
client.work_items.comments.list, cfg.workspace_slug, project_id, issue_id
|
|
284
|
+
)
|
|
285
|
+
comments = [_issue_to_dict(c) for c in (response.results or [])]
|
|
286
|
+
|
|
287
|
+
if cfg.pretty:
|
|
288
|
+
table = build_comments_table(comments)
|
|
289
|
+
out_console.print(table)
|
|
290
|
+
else:
|
|
291
|
+
print_json(comments)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@comment_app.command("add")
|
|
295
|
+
def comment_add(
|
|
296
|
+
ctx: typer.Context,
|
|
297
|
+
issue_id: str = typer.Argument(..., help="Issue ID"),
|
|
298
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
299
|
+
body: str = typer.Option(..., "--body", "-b", help="Comment body or '-' for stdin"),
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Add a comment to an issue."""
|
|
302
|
+
cfg: Config = ctx.obj
|
|
303
|
+
project_id = _resolve_project(cfg, project)
|
|
304
|
+
|
|
305
|
+
body_text = read_text_arg(body)
|
|
306
|
+
comment_html = f"<p>{body_text}</p>"
|
|
307
|
+
|
|
308
|
+
client = get_client(cfg)
|
|
309
|
+
data = CreateWorkItemComment(comment_html=comment_html)
|
|
310
|
+
comment = call_with_retry(
|
|
311
|
+
client.work_items.comments.create,
|
|
312
|
+
cfg.workspace_slug,
|
|
313
|
+
project_id,
|
|
314
|
+
issue_id,
|
|
315
|
+
data,
|
|
316
|
+
)
|
|
317
|
+
print_json(_issue_to_dict(comment))
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""labels subcommand: list / get / create / update / delete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from plane.models.labels import CreateLabel, UpdateLabel
|
|
10
|
+
|
|
11
|
+
from plane_cli.client import get_client, call_with_retry
|
|
12
|
+
from plane_cli.config import Config
|
|
13
|
+
from plane_cli.output import (
|
|
14
|
+
print_json,
|
|
15
|
+
print_error,
|
|
16
|
+
build_labels_table,
|
|
17
|
+
out_console,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(name="labels", help="Manage work item labels.", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _label_to_dict(lb: object) -> dict:
|
|
24
|
+
return lb.model_dump() if hasattr(lb, "model_dump") else dict(lb)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_project(cfg: Config, project_flag: Optional[str]) -> str:
|
|
28
|
+
project_id = project_flag or cfg.project
|
|
29
|
+
if not project_id:
|
|
30
|
+
print_error(
|
|
31
|
+
"config_error",
|
|
32
|
+
"No project specified. Use --project or set defaults.project in config.",
|
|
33
|
+
)
|
|
34
|
+
raise typer.Exit(1)
|
|
35
|
+
return project_id
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("list")
|
|
39
|
+
def labels_list(
|
|
40
|
+
ctx: typer.Context,
|
|
41
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""List all labels in a project."""
|
|
44
|
+
cfg: Config = ctx.obj
|
|
45
|
+
project_id = _resolve_project(cfg, project)
|
|
46
|
+
client = get_client(cfg)
|
|
47
|
+
|
|
48
|
+
response = call_with_retry(client.labels.list, cfg.workspace_slug, project_id)
|
|
49
|
+
labels = [_label_to_dict(lb) for lb in (response.results or [])]
|
|
50
|
+
|
|
51
|
+
if cfg.pretty:
|
|
52
|
+
table = build_labels_table(labels)
|
|
53
|
+
out_console.print(table)
|
|
54
|
+
else:
|
|
55
|
+
print_json(labels)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("get")
|
|
59
|
+
def labels_get(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
label_id: str = typer.Argument(..., help="Label ID"),
|
|
62
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Get a single label by ID."""
|
|
65
|
+
cfg: Config = ctx.obj
|
|
66
|
+
project_id = _resolve_project(cfg, project)
|
|
67
|
+
client = get_client(cfg)
|
|
68
|
+
|
|
69
|
+
label = call_with_retry(client.labels.retrieve, cfg.workspace_slug, project_id, label_id)
|
|
70
|
+
print_json(_label_to_dict(label))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("create")
|
|
74
|
+
def labels_create(
|
|
75
|
+
ctx: typer.Context,
|
|
76
|
+
name: str = typer.Option(..., "--name", "-n", help="Label name"),
|
|
77
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
78
|
+
color: Optional[str] = typer.Option(None, "--color", help="Hex color code"),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Create a new label."""
|
|
81
|
+
cfg: Config = ctx.obj
|
|
82
|
+
project_id = _resolve_project(cfg, project)
|
|
83
|
+
|
|
84
|
+
data_kwargs: dict = {"name": name}
|
|
85
|
+
if color is not None:
|
|
86
|
+
data_kwargs["color"] = color
|
|
87
|
+
|
|
88
|
+
client = get_client(cfg)
|
|
89
|
+
data = CreateLabel(**data_kwargs)
|
|
90
|
+
label = call_with_retry(client.labels.create, cfg.workspace_slug, project_id, data)
|
|
91
|
+
print_json(_label_to_dict(label))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command("update")
|
|
95
|
+
def labels_update(
|
|
96
|
+
ctx: typer.Context,
|
|
97
|
+
label_id: str = typer.Argument(..., help="Label ID"),
|
|
98
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
99
|
+
name: Optional[str] = typer.Option(None, "--name", "-n"),
|
|
100
|
+
color: Optional[str] = typer.Option(None, "--color"),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Update a label."""
|
|
103
|
+
cfg: Config = ctx.obj
|
|
104
|
+
project_id = _resolve_project(cfg, project)
|
|
105
|
+
|
|
106
|
+
data_kwargs: dict = {}
|
|
107
|
+
if name is not None:
|
|
108
|
+
data_kwargs["name"] = name
|
|
109
|
+
if color is not None:
|
|
110
|
+
data_kwargs["color"] = color
|
|
111
|
+
|
|
112
|
+
if not data_kwargs:
|
|
113
|
+
print_error("validation_error", "No fields to update.")
|
|
114
|
+
raise typer.Exit(1)
|
|
115
|
+
|
|
116
|
+
client = get_client(cfg)
|
|
117
|
+
data = UpdateLabel(**data_kwargs)
|
|
118
|
+
label = call_with_retry(client.labels.update, cfg.workspace_slug, project_id, label_id, data)
|
|
119
|
+
print_json(_label_to_dict(label))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("delete")
|
|
123
|
+
def labels_delete(
|
|
124
|
+
ctx: typer.Context,
|
|
125
|
+
label_id: str = typer.Argument(..., help="Label ID"),
|
|
126
|
+
project: Optional[str] = typer.Option(None, "--project", "-p", help="Project ID"),
|
|
127
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Delete a label."""
|
|
130
|
+
cfg: Config = ctx.obj
|
|
131
|
+
project_id = _resolve_project(cfg, project)
|
|
132
|
+
|
|
133
|
+
if not yes and not sys.stdin.isatty():
|
|
134
|
+
print_error("validation_error", "Pass --yes for non-interactive deletion.")
|
|
135
|
+
raise typer.Exit(1)
|
|
136
|
+
if not yes:
|
|
137
|
+
typer.confirm(f"Delete label {label_id}?", abort=True)
|
|
138
|
+
|
|
139
|
+
client = get_client(cfg)
|
|
140
|
+
call_with_retry(client.labels.delete, cfg.workspace_slug, project_id, label_id)
|
|
141
|
+
print_json({"ok": True, "deleted": label_id})
|