initial commit

This commit is contained in:
Felix Rudat
2026-01-27 09:13:54 +01:00
commit 19d462a4aa
13 changed files with 1298 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
PLANKA_URL=https://planka.example.com
PLANKA_USERNAME=your_username
PLANKA_PASSWORD=your_password
+1
View File
@@ -0,0 +1 @@
+470
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
plankapy
typer
python-dotenv
rich