Files
planka-cli/scripts/planka_cli.py
T

737 lines
26 KiB
Python

import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
import click
import typer
from dotenv import load_dotenv, set_key
from plankapy.v2 import Card, Planka
from rich.console import Console
from rich.table import Table
from typer.core import TyperGroup
def get_binary_dir() -> Path:
argv0 = sys.argv[0]
path = Path(argv0)
if not path.is_absolute():
resolved = shutil.which(argv0)
if resolved:
path = Path(resolved)
if path.exists():
return path.resolve().parent if path.is_file() else path.resolve()
return Path(__file__).resolve().parent
ENV_PATH = get_binary_dir() / ".env"
# Load environment variables from the .env next to the binary/script.
load_dotenv(dotenv_path=ENV_PATH)
class HelpOnUnknownCommandGroup(TyperGroup):
def get_command(self, ctx: click.Context, cmd_name: str):
command = super().get_command(ctx, cmd_name)
if command is None:
click.echo(ctx.get_help())
ctx.exit(2)
return command
app = typer.Typer(cls=HelpOnUnknownCommandGroup)
console = Console()
projects_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage projects")
boards_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage boards")
lists_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage lists")
cards_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage cards")
notifications_app = typer.Typer(cls=HelpOnUnknownCommandGroup, help="Manage notifications")
app.add_typer(projects_app, name="projects")
app.add_typer(boards_app, name="boards")
app.add_typer(lists_app, name="lists")
app.add_typer(cards_app, name="cards")
app.add_typer(notifications_app, name="notifications")
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
"""Planka CLI."""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
def get_env_config() -> tuple[Optional[str], Optional[str], Optional[str]]:
return (
os.getenv("PLANKA_URL"),
os.getenv("PLANKA_USERNAME"),
os.getenv("PLANKA_PASSWORD"),
)
def parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
if value is None:
return None
candidate = value.strip()
if candidate.endswith("Z"):
candidate = f"{candidate[:-1]}+00:00"
try:
return datetime.fromisoformat(candidate)
except ValueError as exc:
raise typer.BadParameter(
"Invalid datetime. Use ISO-8601 like 2025-01-31 or 2025-01-31T10:30:00Z."
) from exc
def parse_position(value: Optional[str]) -> Optional[Union[str, int]]:
if value is None:
return None
normalized = value.strip().lower()
if normalized in ("top", "bottom"):
return normalized
try:
return int(value)
except ValueError as exc:
raise typer.BadParameter("Position must be 'top', 'bottom', or an integer.") from exc
def find_list(planka: Planka, list_id: str):
for project in planka.projects:
for board in project.boards:
for list_item in board.lists:
if list_item.id == list_id:
return list_item
return None
def find_list_with_board(planka: Planka, list_id: str):
"""Find a list and return both the list and its parent board."""
for project in planka.projects:
for board in project.boards:
for list_item in board.lists:
if list_item.id == list_id:
return list_item, board
return None, None
def get_card_by_id(planka: Planka, card_id: str) -> Optional[Card]:
try:
card_data = planka.endpoints.getCard(card_id)["item"]
except Exception:
return None
return Card(card_data, planka)
def make_table(title: str) -> Table:
return Table(
title=title,
show_header=True,
header_style="bold",
show_edge=False,
show_lines=False,
box=None,
)
def render_notifications(title: str, notifications: list) -> None:
if not notifications:
console.print("No notifications found.")
return
table = make_table(title)
table.add_column("ID", justify="right", style="cyan", no_wrap=True)
table.add_column("Type", style="magenta")
table.add_column("Read", justify="center")
table.add_column("Created At", justify="right")
table.add_column("Card ID", justify="right")
for notification in notifications:
card_id = notification.schema.get("cardId")
table.add_row(
str(notification.id),
str(notification.type),
"yes" if notification.is_read else "no",
str(notification.created_at),
str(card_id),
)
console.print(table)
def get_planka() -> Planka:
planka_url, planka_username, planka_password = get_env_config()
if not planka_url or not planka_username or not planka_password:
console.print(
"[bold red]Error:[/bold red] Missing credentials. "
f"Set PLANKA_URL, PLANKA_USERNAME, and PLANKA_PASSWORD or run "
f"`planka-cli login --url ... --username ... --password ...` "
f"to write {ENV_PATH}."
)
sys.exit(1)
try:
planka = Planka(planka_url)
planka.login(username=planka_username, password=planka_password)
return planka
except Exception as e:
console.print(f"[bold red]Connection Error:[/bold red] {e}")
sys.exit(1)
@app.command()
def login(
url: str = typer.Option(..., "--url", "-u", help="Planka base URL"),
username: str = typer.Option(..., "--username", "-n", help="Planka username"),
password: str = typer.Option(..., "--password", "-p", help="Planka password"),
):
"""Store credentials in a .env file next to the binary."""
try:
ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
set_key(str(ENV_PATH), "PLANKA_URL", url)
set_key(str(ENV_PATH), "PLANKA_USERNAME", username)
set_key(str(ENV_PATH), "PLANKA_PASSWORD", password)
except OSError as e:
console.print(f"[bold red]Error:[/bold red] Could not write {ENV_PATH}: {e}")
raise typer.Exit(1)
try:
os.chmod(ENV_PATH, 0o600)
except OSError:
pass
console.print(f"[green]Saved credentials to[/green] {ENV_PATH}")
@app.command()
def logout():
"""Delete the stored .env file next to the binary."""
if not ENV_PATH.exists():
console.print(f"No stored credentials found at {ENV_PATH}")
return
try:
ENV_PATH.unlink()
except OSError as e:
console.print(f"[bold red]Error:[/bold red] Could not delete {ENV_PATH}: {e}")
raise typer.Exit(1)
console.print("[green]Logged out.[/green] Removed stored credentials.")
@app.command()
def status():
"""Check connection and print current user info."""
planka = get_planka()
try:
user = planka.me
console.print(
f"[green]Connected![/green] Logged in as: [bold]{user.username}[/bold] (ID: {user.id})"
)
if user.name:
console.print(f"Name: {user.name}")
console.print(f"Email: {user.email}")
except Exception as e:
console.print(f"[bold red]Error fetching status:[/bold red] {e}")
@projects_app.command("list")
def list_projects():
"""List all projects."""
planka = get_planka()
try:
projects = planka.projects
if not projects:
console.print("No projects found.")
return
table = make_table("Projects")
table.add_column("ID", justify="right", style="cyan", no_wrap=True)
table.add_column("Name", style="magenta")
table.add_column("Created At", justify="right")
for p in projects:
table.add_row(str(p.id), p.name, str(p.created_at))
console.print(table)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@boards_app.command("list")
def list_boards(
project_id: Optional[str] = typer.Argument(None, help="Project ID to filter by"),
):
"""List boards. Optionally filter by Project ID."""
planka = get_planka()
try:
if project_id is not None:
# Find specific project
project = next((p for p in planka.projects if p.id == project_id), None)
if not project:
console.print(f"[red]Project {project_id} not found.[/red]")
return
boards_list = project.boards
title = f"Boards in Project {project.name}"
else:
# List all boards from all projects the user has access to
# plankapy doesn't have a direct 'all_boards', so we iterate projects
boards_list = []
for p in planka.projects:
boards_list.extend(p.boards)
title = "All Boards"
if not boards_list:
console.print("No boards found.")
return
planka_url, _, _ = get_env_config()
table = make_table(title)
table.add_column("ID", justify="right", style="cyan", no_wrap=True)
table.add_column("Name", style="magenta")
table.add_column("Project ID", justify="right")
table.add_column("URL", style="magenta")
for b in boards_list:
project_id_value = b.schema.get("projectId")
board_url = None
if planka_url and b.id:
board_url = f"{planka_url.rstrip('/')}/boards/{b.id}"
table.add_row(str(b.id), b.name, str(project_id_value), str(board_url or "-"))
console.print(table)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@lists_app.command("list")
def list_lists(board_id: str):
"""List all lists in a board."""
planka = get_planka()
try:
# We need to find the board first to get its lists
# This is a bit inefficient in plankapy if we don't know the project, but we can search
target_board = None
for p in planka.projects:
for b in p.boards:
if b.id == board_id:
target_board = b
break
if target_board:
break
if not target_board:
console.print(f"[red]Board {board_id} not found.[/red]")
return
table = make_table(f"Lists in Board: {target_board.name}")
table.add_column("ID", justify="right", style="cyan", no_wrap=True)
table.add_column("Name", style="magenta")
table.add_column("Board ID", justify="right")
table.add_column("Position", justify="right")
for list_item in target_board.lists:
table.add_row(
str(list_item.id), list_item.name, str(target_board.id), str(list_item.position)
)
console.print(table)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@cards_app.command("list")
def list_cards(list_id: str):
"""List all cards in a list."""
planka = get_planka()
try:
target_list, target_board = find_list_with_board(planka, list_id)
if not target_list:
console.print(f"[red]List {list_id} not found.[/red]")
return
planka_url, _, _ = get_env_config()
board_id = target_board.id if target_board else None
table = make_table(f"Cards in List: {target_list.name}")
table.add_column("ID", justify="right", style="cyan", no_wrap=True)
table.add_column("Name", style="magenta")
table.add_column("List ID", justify="right")
table.add_column("Position", justify="right")
table.add_column("URL", style="magenta")
for c in target_list.cards:
card_url = None
if planka_url and board_id and c.id:
card_url = f"{planka_url.rstrip('/')}/boards/{board_id}/cards/{c.id}"
table.add_row(str(c.id), c.name, str(list_id), str(c.position), str(card_url or "-"))
console.print(table)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@cards_app.command("show")
def show_card(card_id: str):
"""Show details for a card."""
planka = get_planka()
try:
card = get_card_by_id(planka, card_id)
if not card:
console.print(f"[red]Card {card_id} not found.[/red]")
return
table = make_table(f"Card: {card.name}")
table.add_column("Field", style="cyan", no_wrap=True)
table.add_column("Value", style="magenta")
def safe_attr(obj: object, name: str) -> object:
try:
return getattr(obj, name)
except Exception:
return None
def normalize_url(base_url: Optional[str], value: Optional[str]) -> Optional[str]:
if not value:
return None
candidate = str(value).strip()
if not candidate:
return None
if candidate.startswith("http://") or candidate.startswith("https://"):
return candidate
if base_url:
return f"{base_url.rstrip('/')}/{candidate.lstrip('/')}"
return candidate
def extract_attachment_url(attachment: object, base_url: Optional[str]) -> Optional[str]:
data = safe_attr(attachment, "data")
if isinstance(data, dict):
for key in (
"url",
"downloadUrl",
"download_url",
"link",
"href",
"path",
):
value = data.get(key)
if isinstance(value, str) and value.strip():
return normalize_url(base_url, value)
file_info = data.get("file")
if isinstance(file_info, dict):
for key in (
"url",
"downloadUrl",
"download_url",
"path",
"thumbnailUrl",
"thumbUrl",
"thumbnail_url",
):
value = file_info.get(key)
if isinstance(value, str) and value.strip():
return normalize_url(base_url, value)
direct_url = safe_attr(attachment, "url")
if isinstance(direct_url, str) and direct_url.strip():
return normalize_url(base_url, direct_url)
return None
def add_row(label: str, value: object) -> None:
if value is None or value == "":
table.add_row(label, "-")
else:
table.add_row(label, str(value))
try:
schema = card.schema or {}
except Exception:
schema = {}
list_id_value = schema.get("listId")
board_id_value = schema.get("boardId")
list_name_value = None
list_obj = safe_attr(card, "list")
if list_obj is not None:
list_id_value = safe_attr(list_obj, "id") or list_id_value
list_name_value = safe_attr(list_obj, "name")
if not board_id_value:
list_schema = safe_attr(list_obj, "schema")
if isinstance(list_schema, dict):
board_id_value = list_schema.get("boardId")
list_display = "-"
if list_name_value and list_id_value:
list_display = f"{list_name_value} ({list_id_value})"
elif list_name_value:
list_display = list_name_value
elif list_id_value:
list_display = list_id_value
due_date = safe_attr(card, "due_date") or schema.get("dueDate")
due_completed = safe_attr(card, "due_date_completed")
if due_completed is None:
due_completed = schema.get("isDueCompleted")
if isinstance(due_completed, bool):
due_completed = "yes" if due_completed else "no"
attachments_error = None
comments_error = None
try:
attachments = list(safe_attr(card, "attachments") or [])
except Exception as exc:
attachments = []
attachments_error = str(exc)
try:
comments = list(safe_attr(card, "comments") or [])
except Exception as exc:
comments = []
comments_error = str(exc)
comments_count = safe_attr(card, "comments_count")
if comments_count is None and comments_error is None:
comments_count = len(comments)
planka_url, _, _ = get_env_config()
card_url = None
if planka_url and board_id_value and card.id:
card_url = f"{planka_url.rstrip('/')}/boards/{board_id_value}/cards/{card.id}"
add_row("ID", card.id)
add_row("URL", card_url)
add_row("Name", card.name)
add_row("Description", safe_attr(card, "description") or schema.get("description"))
add_row("Board ID", board_id_value)
add_row("List", list_display)
add_row("Position", safe_attr(card, "position") or schema.get("position"))
add_row("Type", safe_attr(card, "type") or schema.get("type"))
add_row("Due Date", due_date)
add_row("Due Completed", due_completed)
if attachments_error:
add_row("Attachments", f"Error: {attachments_error}")
else:
add_row("Attachments", len(attachments))
if comments_error:
add_row("Comments", f"Error: {comments_error}")
else:
add_row("Comments", comments_count)
add_row("Created At", safe_attr(card, "created_at") or schema.get("createdAt"))
add_row("Updated At", safe_attr(card, "updated_at") or schema.get("updatedAt"))
console.print(table)
if attachments and attachments_error is None:
attachments_table = make_table("Attachments")
attachments_table.add_column("ID", justify="right", style="cyan", no_wrap=True)
attachments_table.add_column("Name", style="magenta")
attachments_table.add_column("Type", style="magenta")
attachments_table.add_column("URL", style="magenta")
attachments_table.add_column("Created At", justify="right")
for attachment in attachments:
attachment_url = extract_attachment_url(attachment, planka_url)
attachments_table.add_row(
str(safe_attr(attachment, "id") or "-"),
str(safe_attr(attachment, "name") or "-"),
str(safe_attr(attachment, "type") or "-"),
str(attachment_url or "-"),
str(safe_attr(attachment, "created_at") or "-"),
)
console.print(attachments_table)
if comments and comments_error is None:
comments_table = make_table("Comments")
comments_table.add_column("ID", justify="right", style="cyan", no_wrap=True)
comments_table.add_column("User", style="magenta")
comments_table.add_column("Text", style="magenta")
comments_table.add_column("Created At", justify="right")
for comment in comments:
user = safe_attr(comment, "user")
user_label = (
safe_attr(user, "name")
or safe_attr(user, "username")
or safe_attr(user, "id")
or "-"
)
text = safe_attr(comment, "text")
text_label = " ".join(str(text).split()) if text else "-"
comments_table.add_row(
str(safe_attr(comment, "id") or "-"),
str(user_label),
text_label,
str(safe_attr(comment, "created_at") or "-"),
)
console.print(comments_table)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@cards_app.command("create")
def create_card(
list_id: str = typer.Argument(..., help="List ID to create the card in"),
name: str = typer.Argument(..., help="Card name/title"),
description: Optional[str] = typer.Option(None, "--description", "-d", help="Card description"),
position: str = typer.Option(
"bottom", "--position", "-p", help="Position: top, bottom, or integer"
),
card_type: str = typer.Option("project", "--type", "-t", help="Card type (project, story)"),
due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date (ISO-8601)"),
due_completed: bool = typer.Option(False, "--due-completed", help="Mark due date as completed"),
):
"""Create a new card in a list."""
planka = get_planka()
try:
target_list = find_list(planka, list_id)
if not target_list:
console.print(f"[red]List {list_id} not found.[/red]")
return
parsed_due_date = parse_iso_datetime(due_date)
parsed_position = parse_position(position) or "bottom"
card = target_list.create_card(
name=name,
position=parsed_position,
type=card_type,
description=description,
due_date=parsed_due_date,
due_date_completed=due_completed,
)
console.print(
f"[green]Created card[/green] [bold]{card.name}[/bold] "
f"(ID: {card.id}) in list [bold]{target_list.name}[/bold]"
)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@cards_app.command("update")
def update_card(
card_id: str = typer.Argument(..., help="Card ID to update"),
name: Optional[str] = typer.Option(None, "--name", help="New card name"),
description: Optional[str] = typer.Option(
None, "--description", "-d", help="New card description"
),
clear_description: bool = typer.Option(
False, "--clear-description", help="Clear the description"
),
position: Optional[str] = typer.Option(
None, "--position", "-p", help="Position: top, bottom, or integer"
),
list_id: Optional[str] = typer.Option(None, "--list-id", help="Move to a new list"),
card_type: Optional[str] = typer.Option(
None, "--type", "-t", help="Card type (project, story)"
),
due_date: Optional[str] = typer.Option(None, "--due-date", help="Due date (ISO-8601)"),
clear_due_date: bool = typer.Option(False, "--clear-due-date", help="Clear the due date"),
due_completed: Optional[bool] = typer.Option(
None,
"--due-completed/--no-due-completed",
help="Mark due date as completed/uncompleted",
),
):
"""Update an existing card."""
if description is not None and clear_description:
console.print(
"[bold red]Error:[/bold red] Use either --description or --clear-description."
)
raise typer.Exit(1)
if due_date is not None and clear_due_date:
console.print("[bold red]Error:[/bold red] Use either --due-date or --clear-due-date.")
raise typer.Exit(1)
planka = get_planka()
try:
card = get_card_by_id(planka, card_id)
if not card:
console.print(f"[red]Card {card_id} not found.[/red]")
return
update_fields: dict[str, object] = {}
if name is not None:
update_fields["name"] = name
if description is not None:
update_fields["description"] = description
elif clear_description:
update_fields["description"] = None
if card_type is not None:
update_fields["type"] = card_type
if due_date is not None:
update_fields["dueDate"] = parse_iso_datetime(due_date)
elif clear_due_date:
update_fields["dueDate"] = None
if due_completed is not None:
update_fields["isDueCompleted"] = due_completed
move_position = parse_position(position)
if list_id is not None or move_position is not None:
target_list = find_list(planka, list_id) if list_id is not None else card.list
if not target_list:
console.print(f"[red]List {list_id} not found.[/red]")
return
card.move(target_list, position=move_position or "top")
if update_fields:
card.update(**update_fields)
if not update_fields and list_id is None and move_position is None:
console.print("[yellow]No updates provided.[/yellow]")
return
console.print(f"[green]Updated card[/green] [bold]{card.name}[/bold] (ID: {card.id})")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@cards_app.command("delete")
def delete_card(
card_id: str = typer.Argument(..., help="Card ID to delete"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Delete a card."""
planka = get_planka()
try:
card = get_card_by_id(planka, card_id)
if not card:
console.print(f"[red]Card {card_id} not found.[/red]")
return
if not yes:
confirm = typer.confirm(f"Delete card '{card.name}' ({card.id})?")
if not confirm:
console.print("Cancelled.")
return
card.delete()
console.print(f"[green]Deleted card[/green] {card_id}")
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@notifications_app.command("all")
def all_notifications():
"""List all notifications."""
planka = get_planka()
try:
render_notifications("Notifications", planka.notifications)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
@notifications_app.command("unread")
def unread_notifications():
"""List unread notifications."""
planka = get_planka()
try:
render_notifications("Unread Notifications", planka.unread_notifications)
except Exception as e:
console.print(f"[bold red]Error:[/bold red] {e}")
if __name__ == "__main__":
app()