initial commit
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
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")
|
||||
|
||||
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 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:
|
||||
# Initialize Planka client (v2) and authenticate
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
for b in boards_list:
|
||||
project_id_value = b.schema.get("projectId")
|
||||
table.add_row(str(b.id), b.name, str(project_id_value))
|
||||
|
||||
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("Position", justify="right")
|
||||
|
||||
for l in target_board.lists:
|
||||
table.add_row(str(l.id), l.name, str(l.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 = find_list(planka, list_id)
|
||||
|
||||
if not target_list:
|
||||
console.print(f"[red]List {list_id} not found.[/red]")
|
||||
return
|
||||
|
||||
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("Position", justify="right")
|
||||
|
||||
for c in target_list.cards:
|
||||
table.add_row(str(c.id), c.name, str(c.position))
|
||||
|
||||
console.print(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()
|
||||
Reference in New Issue
Block a user