app frame + storage
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# Planka Skill for AI agents
|
||||
|
||||
An AI agent skill for managing [Planka](https://github.com/plankanban/planka) kanban boards through natural language. No rigid CLI syntax — just describe what you want.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Clone this repo into your Claude Code skills directory:
|
||||
```bash
|
||||
git clone <repo-url> ~/.agents/skills/planka-skill
|
||||
```
|
||||
Or copy the folder manually to `~/.agents/skills/`.
|
||||
|
||||
2. Generate an API key in Planka: User Settings → API Key
|
||||
|
||||
3. Set environment variables (in project `.env`, `~/.env`, or shell):
|
||||
```bash
|
||||
PLANKA_URL=https://planka.example.com
|
||||
PLANKA_API_KEY=your-api-key
|
||||
```
|
||||
A trailing slash in `PLANKA_URL` is fine. The skill strips it before adding `/api`.
|
||||
When both env files exist, the project's `.env` overrides `~/.env`;
|
||||
values in either file override existing shell values.
|
||||
|
||||
## Helper CLI
|
||||
|
||||
Run from the target project's root. If the skill lives elsewhere, replace the script path with its installed location. The CLI needs only Python 3's standard library.
|
||||
|
||||
```bash
|
||||
python3 .pi/skills/planka/planka.py # projects, boards, card counts
|
||||
python3 .pi/skills/planka/planka.py board TodoApp # lists and cards
|
||||
python3 .pi/skills/planka/planka.py card TodoApp "Login Bug" # card details
|
||||
python3 .pi/skills/planka/planka.py card TodoApp "Login Bug" --full
|
||||
python3 .pi/skills/planka/planka.py spec endpoints card-labels
|
||||
python3 .pi/skills/planka/planka.py spec refresh
|
||||
```
|
||||
|
||||
After the user approves a board write:
|
||||
|
||||
```bash
|
||||
python3 .pi/skills/planka/planka.py create-card TodoApp TODO "Fix login" --description "Steps to reproduce..." --confirm
|
||||
python3 .pi/skills/planka/planka.py move-card TodoApp "Fix login" "IN PROGRESS" --confirm
|
||||
```
|
||||
|
||||
The script resolves board, list, and card names to current IDs. Ambiguous names fail instead of picking one. Writes require `--confirm` **and** prior user approval in conversation. It refuses unknown flags. Run each subcommand with `--help` for arguments.
|
||||
|
||||
The first API call downloads the published OpenAPI spec to `.pi/cache/planka-swagger.json` in the current project; later calls use the cache. Run `spec refresh` to fetch a new copy. The cache is public schema data, not credentials. If the server's API differs from the published spec, refresh and inspect its version rather than guessing paths. A first-time run needs network access to both the spec and Planka; subsequent endpoint checks work offline, but board operations still need Planka.
|
||||
|
||||
For unsupported operations, inspect the cached spec first (`spec endpoints <term>` and the request schema in the JSON file). Use the API base `${PLANKA_URL%/}/api`, `X-Api-Key` authentication, encoded JSON bodies, and validate the response content type and JSON. Never print the key. The helper reads simple `KEY=value` / quoted values for `PLANKA_URL` and `PLANKA_API_KEY` from `~/.env` then `.env`; it does not execute shell expressions in those files.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```
|
||||
/planka overview # all projects and boards at a glance
|
||||
/planka board "My Project" # detailed board view with cards and progress
|
||||
```
|
||||
|
||||
## What You Can Do
|
||||
|
||||
Talk naturally — the skill understands intent and figures out the API calls.
|
||||
|
||||
### Browse & Inspect
|
||||
```
|
||||
/planka show me all my boards
|
||||
/planka what's in the ToDo list?
|
||||
/planka show me card "Login Bug" with all its tasks
|
||||
```
|
||||
|
||||
### Create & Organize
|
||||
```
|
||||
/planka create a card "Dark Mode" in Backlog with fitting labels
|
||||
/planka set up a new board "Backend" with lists ToDo, In Progress, Done, Blocked
|
||||
/planka add labels Bug (red), Feature (blue), Urgent (red) to the "My Project" board
|
||||
/planka add tasks to "Dark Mode": update theme provider, test contrast, update docs
|
||||
```
|
||||
|
||||
### Move & Update
|
||||
```
|
||||
/planka move "Dark Mode" to In Progress
|
||||
/planka mark "Dark Mode" as done
|
||||
/planka rename "Dark Mode" to "Dark Mode v2"
|
||||
/planka add a comment to "Dark Mode": waiting for design review
|
||||
```
|
||||
|
||||
### Bulk Operations
|
||||
```
|
||||
/planka move everything from In Progress to Done
|
||||
/planka read the git log and create cards for all completed features
|
||||
/planka look at board X and create the same structure for a new board
|
||||
/planka check all cards for typos and fix them
|
||||
```
|
||||
|
||||
### Project Sync
|
||||
```
|
||||
/planka sync the board with our recent git commits
|
||||
/planka create cards for all open issues
|
||||
/planka what changed on the board since last week?
|
||||
```
|
||||
|
||||
The helper caches the [Planka OpenAPI spec](https://plankanban.github.io/planka/swagger-ui/swagger.json). Refresh it explicitly when the API changes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Planka instance with API key support
|
||||
- Python 3
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: planka
|
||||
description: Manage Planka kanban boards — create, organize, and track project tasks
|
||||
argument-hint: overview | board <name>
|
||||
---
|
||||
|
||||
Interact with a self-hosted Planka instance. Planka is a Trello-like kanban board.
|
||||
|
||||
> See [README.md](README.md) for setup instructions, usage examples, and advanced use cases.
|
||||
|
||||
## Setup
|
||||
|
||||
Required environment variables:
|
||||
|
||||
- `PLANKA_URL` — Base URL (e.g. `https://planka.example.com`)
|
||||
- `PLANKA_API_KEY` — API key (generated in Planka UI → User Settings)
|
||||
|
||||
These can be set in:
|
||||
1. The current project's `.env` file (most common)
|
||||
2. `~/.env` (global, for cross-project use)
|
||||
3. Shell environment (e.g. `~/.bashrc` or `~/.zshrc`)
|
||||
|
||||
Use the bundled helper from the **project root** (replace the path if the skill was installed elsewhere):
|
||||
|
||||
```bash
|
||||
python3 .pi/skills/planka/planka.py # projects and boards
|
||||
python3 .pi/skills/planka/planka.py board TodoApp
|
||||
python3 .pi/skills/planka/planka.py card TodoApp "Card title" --full
|
||||
python3 .pi/skills/planka/planka.py spec endpoints card-labels
|
||||
python3 .pi/skills/planka/planka.py spec refresh
|
||||
```
|
||||
|
||||
The helper loads `~/.env`, then the project's `.env` (project values win), then normalizes `PLANKA_URL` before appending `/api`. It validates JSON and does not print the key. It stores the published OpenAPI spec in `.pi/cache/planka-swagger.json` in the current project on first use, reuses it offline, and refreshes it only on `spec refresh`. The cache contains public API documentation, never credentials. Run from the project root so the right env and cache are used.
|
||||
|
||||
**Full OpenAPI spec**: https://plankanban.github.io/planka/swagger-ui/swagger.json
|
||||
|
||||
### Finding endpoints
|
||||
|
||||
**Never guess endpoints.** Use `spec endpoints <search-term>` to inspect the cached raw spec. Do not rely on summaries of the spec. For operations beyond the helper's commands, inspect the relevant request schema in `.pi/cache/planka-swagger.json` before calling the API. On 404, do not repeat the same request; refresh the spec and check the installed server's version. The helper refuses endpoints absent from its cached spec, so refresh if it is stale.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Hierarchy**: Project → Board → List → Card → Task List → Task
|
||||
- **Responses**: `{"item": {...}}` for single, `{"items": [...]}` for collections, related data in `"included"`
|
||||
- **Positioning**: Numeric, multiples of 65536. Lower = higher on the board.
|
||||
- **Colors** (for lists/labels): e.g. `dark-granite`, `lagoon-blue`, `orange-peel`, `bright-moss`, `berry-red`, `light-mud`, `midnight-blue`, `light-cocoa`, `summer-sky`, `pumpkin-orange`
|
||||
|
||||
## How to Use
|
||||
|
||||
The user can talk naturally or use optional shorthand commands:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/planka overview` | Show all projects and boards |
|
||||
| `/planka board <name>` | Show a specific board with lists and cards |
|
||||
|
||||
These are shortcuts — the user can also just describe what they want freely: "create a card for Feature X", "move Bug Y to Done", "set up a new board with labels", etc.
|
||||
|
||||
Understand the intent, look up the right endpoints from the OpenAPI spec if needed, and execute.
|
||||
|
||||
**Finding boards**: Never hardcode IDs. Always list projects first, find boards from the included data, and match by name.
|
||||
|
||||
**Null safety**: Many Planka fields can be `None`/`null` — name, description, color, position, etc. Always use `or ""`, `or 0`, `(x or "").strip()` patterns in Python code. Never call methods on potentially null values without guarding.
|
||||
|
||||
**Display formatting**:
|
||||
- `overview`: Show projects as headers with boards as bullet points. Include board card count.
|
||||
- `board <name>`: Show each list as a section with its cards listed below. For each card show: name, labels (as colored badges), task progress (e.g. 3/5), and description preview if available. Use markdown formatting — headers for lists, bullet points for cards. Skip empty/unnamed system lists (archive, trash).
|
||||
|
||||
Always:
|
||||
|
||||
- Prefer the helper for supported operations. Use `create-card <board> <list> <title> [--description TEXT] --confirm` and `move-card <board> <card> <list> --confirm` only after user approval. Names must be unambiguous; the helper resolves current IDs each time. `--confirm` is a safety gate, **not** a substitute for conversational approval.
|
||||
- For unsupported operations, inspect the cached spec first and use `${PLANKA_URL%/}/api` (never `//api`), JSON encoding, response validation, and the same env precedence as the helper. See [README.md](README.md).
|
||||
- Ask the user when something is ambiguous (e.g. multiple boards/projects).
|
||||
- **Always confirm before executing a board write**: describe what you're about to do (e.g. "I'll create a card 'Dark Mode' in Backlog") and wait for approval. This applies to create, update, move, and delete.
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small, dependency-free Planka CLI for agents. Run from the target project directory."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
SPEC_URL = "https://plankanban.github.io/planka/swagger-ui/swagger.json"
|
||||
CACHE = Path(".pi/cache/planka-swagger.json")
|
||||
VERSION = "0.1.0"
|
||||
|
||||
|
||||
class PlankaError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def emit(key, value):
|
||||
print(f"{key}: {json.dumps(value, ensure_ascii=False)}")
|
||||
|
||||
|
||||
def table(name, fields, rows):
|
||||
print(f"{name}[{len(rows)}]{{{','.join(fields)}}}:")
|
||||
for row in rows:
|
||||
print(" " + ",".join(json.dumps(value, ensure_ascii=False) for value in row))
|
||||
if not rows:
|
||||
emit("state", f"0 {name} found")
|
||||
|
||||
|
||||
def load_config():
|
||||
config = {k: os.environ.get(k, "") for k in ("PLANKA_URL", "PLANKA_API_KEY")}
|
||||
for file in (Path.home() / ".env", Path.cwd() / ".env"):
|
||||
if not file.is_file():
|
||||
continue
|
||||
for line in file.read_text(encoding="utf-8").splitlines():
|
||||
match = re.match(r"^\s*(?:export\s+)?(PLANKA_URL|PLANKA_API_KEY)\s*=\s*(.*)$", line)
|
||||
if match:
|
||||
try:
|
||||
parts = shlex.split(match[2], comments=True)
|
||||
except ValueError:
|
||||
raise PlankaError(f"Invalid {match[1]} in {file}") from None
|
||||
config[match[1]] = " ".join(parts) if parts else ""
|
||||
missing = [key for key, value in config.items() if not value]
|
||||
if missing:
|
||||
raise PlankaError(f"Set {', '.join(missing)} in .env, ~/.env, or the shell")
|
||||
url = config["PLANKA_URL"].rstrip("/")
|
||||
if not url.startswith(("https://", "http://")):
|
||||
raise PlankaError("PLANKA_URL must start with https:// or http://")
|
||||
return url, config["PLANKA_API_KEY"]
|
||||
|
||||
|
||||
def fetch_json(url, key=None, method="GET", payload=None):
|
||||
headers = {"Accept": "application/json"}
|
||||
if key:
|
||||
headers["X-Api-Key"] = key
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(url, headers=headers, method=method,
|
||||
data=json.dumps(payload).encode() if payload is not None else None)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
raw = response.read()
|
||||
except HTTPError as exc:
|
||||
raise PlankaError(f"{method} {url}: HTTP {exc.code}; check the endpoint, permissions, and input") from None
|
||||
except URLError as exc:
|
||||
raise PlankaError(f"Could not reach {url}: {exc.reason}") from None
|
||||
if "json" not in content_type.lower():
|
||||
raise PlankaError(f"{method} {url}: expected JSON, got {content_type or 'unknown content type'}; check PLANKA_URL and API path")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise PlankaError(f"{method} {url}: invalid JSON response") from None
|
||||
|
||||
|
||||
def spec(refresh=False):
|
||||
if not refresh and CACHE.is_file():
|
||||
try:
|
||||
cached = json.loads(CACHE.read_text(encoding="utf-8"))
|
||||
if isinstance(cached.get("paths"), dict):
|
||||
return cached
|
||||
except (ValueError, OSError, AttributeError):
|
||||
pass
|
||||
data = fetch_json(SPEC_URL)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("paths"), dict):
|
||||
raise PlankaError("OpenAPI response has no paths; cache was not updated")
|
||||
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=CACHE.parent, delete=False) as temp:
|
||||
json.dump(data, temp)
|
||||
temp_path = Path(temp.name)
|
||||
temp_path.replace(CACHE)
|
||||
return data
|
||||
|
||||
|
||||
def check_endpoint(method, template):
|
||||
if method.lower() not in spec().get("paths", {}).get(template, {}):
|
||||
raise PlankaError(f"{method} {template} is not in the cached spec; run `spec refresh` and inspect `spec endpoints`")
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
url, self.key = load_config()
|
||||
self.base = url + "/api"
|
||||
|
||||
def request(self, method, template, path, payload=None):
|
||||
check_endpoint(method, template)
|
||||
return fetch_json(self.base + path, self.key, method, payload)
|
||||
|
||||
def projects(self):
|
||||
return self.request("GET", "/projects", "/projects")
|
||||
|
||||
def board(self, name):
|
||||
projects = self.projects()
|
||||
boards = (projects.get("included") or {}).get("boards") or []
|
||||
matches = [b for b in boards if b.get("name") == name]
|
||||
if len(matches) != 1:
|
||||
raise PlankaError(f"Board {name!r}: found {len(matches)} matches; use `overview` to check names")
|
||||
return self.request("GET", "/boards/{id}", "/boards/" + matches[0]["id"])
|
||||
|
||||
|
||||
def board_parts(data):
|
||||
inc = data.get("included") or {}
|
||||
lists = inc.get("lists") or []
|
||||
cards = inc.get("cards") or []
|
||||
return lists, cards
|
||||
|
||||
|
||||
def unique_list(lists, name):
|
||||
matches = [item for item in lists if item.get("name") == name]
|
||||
if len(matches) != 1:
|
||||
raise PlankaError(f"List {name!r}: found {len(matches)} matches; use `board` to check names")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def unique_card(cards, name):
|
||||
matches = [item for item in cards if item.get("name") == name]
|
||||
if len(matches) != 1:
|
||||
raise PlankaError(f"Card {name!r}: found {len(matches)} matches; use `board` to check names")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def next_position(cards, list_id):
|
||||
return max((c.get("position") or 0 for c in cards if c.get("listId") == list_id), default=0) + 65536
|
||||
|
||||
|
||||
def preview(text, full):
|
||||
text = text or ""
|
||||
if full or len(text) <= 800:
|
||||
return text
|
||||
return text[:800] + f"... (truncated, {len(text)} chars total; use --full)"
|
||||
|
||||
|
||||
def command(args):
|
||||
if args.action == "spec":
|
||||
document = spec(refresh=args.spec_action == "refresh")
|
||||
if args.spec_action == "refresh":
|
||||
emit("spec", str(CACHE))
|
||||
emit("endpoints", len(document["paths"]))
|
||||
else:
|
||||
rows = [(method.upper(), path) for path, methods in document["paths"].items()
|
||||
for method in methods if method in ("get", "post", "patch", "put", "delete")
|
||||
and (not args.search or args.search.lower() in path.lower())]
|
||||
table("endpoints", ("method", "path"), rows)
|
||||
return
|
||||
|
||||
client = Client()
|
||||
if args.action == "overview":
|
||||
projects = client.projects()
|
||||
boards = (projects.get("included") or {}).get("boards") or []
|
||||
rows = []
|
||||
for project in projects.get("items") or []:
|
||||
for board in boards:
|
||||
if board.get("projectId") == project.get("id"):
|
||||
detail = client.request("GET", "/boards/{id}", "/boards/" + board["id"])
|
||||
rows.append((project.get("name") or "", board.get("name") or "",
|
||||
len((detail.get("included") or {}).get("cards") or [])))
|
||||
table("boards", ("project", "board", "cards"), rows)
|
||||
return
|
||||
data = client.board(args.board)
|
||||
lists, cards = board_parts(data)
|
||||
if args.action == "board":
|
||||
emit("board", args.board)
|
||||
rows = [(item.get("name") or "", c.get("name") or "", c.get("id") or "")
|
||||
for item in lists if item.get("name")
|
||||
for c in cards if c.get("listId") == item.get("id")]
|
||||
table("cards", ("list", "title", "id"), rows)
|
||||
table("lists", ("name", "cards"), [(item.get("name") or "",
|
||||
sum(c.get("listId") == item.get("id") for c in cards))
|
||||
for item in lists if item.get("name")])
|
||||
return
|
||||
card = unique_card(cards, args.card) if args.action != "create-card" else None
|
||||
if args.action == "card":
|
||||
detail = client.request("GET", "/cards/{id}", "/cards/" + card["id"])
|
||||
item = detail.get("item") or {}
|
||||
emit("title", item.get("name") or "")
|
||||
emit("id", item.get("id") or "")
|
||||
emit("description", preview(item.get("description"), args.full))
|
||||
inc = detail.get("included") or {}
|
||||
table("task_lists", ("name", "id"), [(x.get("name") or "", x.get("id") or "") for x in inc.get("taskLists") or []])
|
||||
table("tasks", ("name", "completed"), [(x.get("name") or "", bool(x.get("isCompleted"))) for x in inc.get("tasks") or []])
|
||||
return
|
||||
if args.action == "create-card":
|
||||
target = unique_list(lists, args.list)
|
||||
if any(c.get("name") == args.title and c.get("listId") == target["id"] for c in cards):
|
||||
emit("result", "card already exists in target list (no-op)")
|
||||
return
|
||||
result = client.request("POST", "/lists/{listId}/cards", "/lists/" + target["id"] + "/cards",
|
||||
{"type": "project", "name": args.title, "description": args.description,
|
||||
"position": next_position(cards, target["id"])})
|
||||
emit("created", (result.get("item") or {}).get("name") or args.title)
|
||||
return
|
||||
if args.action == "move-card":
|
||||
target = unique_list(lists, args.list)
|
||||
if card.get("listId") == target["id"]:
|
||||
emit("result", "card already in target list (no-op)")
|
||||
return
|
||||
result = client.request("PATCH", "/cards/{id}", "/cards/" + card["id"],
|
||||
{"listId": target["id"], "position": next_position(cards, target["id"])})
|
||||
emit("moved", (result.get("item") or {}).get("name") or args.card)
|
||||
emit("list", args.list)
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
def error(self, message):
|
||||
emit("error", message)
|
||||
emit("help", self.format_usage().strip())
|
||||
self.exit(2)
|
||||
|
||||
|
||||
def parser():
|
||||
p = Parser(description="Inspect and manage Planka boards in the current project")
|
||||
p.add_argument("--version", "-v", "-V", action="version", version=VERSION)
|
||||
sub = p.add_subparsers(dest="action", parser_class=Parser)
|
||||
sub.add_parser("overview", help="List projects and boards")
|
||||
board = sub.add_parser("board", help="List cards on a board")
|
||||
board.add_argument("board")
|
||||
card = sub.add_parser("card", help="Show a card")
|
||||
card.add_argument("board")
|
||||
card.add_argument("card")
|
||||
card.add_argument("--full", action="store_true", help="Do not truncate description")
|
||||
create = sub.add_parser("create-card", help="Create a card; requires --confirm")
|
||||
create.add_argument("board")
|
||||
create.add_argument("list")
|
||||
create.add_argument("title")
|
||||
create.add_argument("--description", default="")
|
||||
create.add_argument("--confirm", action="store_true", help="Confirm the board write")
|
||||
move = sub.add_parser("move-card", help="Move a card; requires --confirm")
|
||||
move.add_argument("board")
|
||||
move.add_argument("card")
|
||||
move.add_argument("list")
|
||||
move.add_argument("--confirm", action="store_true", help="Confirm the board write")
|
||||
spec_cmd = sub.add_parser("spec", help="Inspect cached OpenAPI spec")
|
||||
spec_sub = spec_cmd.add_subparsers(dest="spec_action", required=True)
|
||||
endpoints = spec_sub.add_parser("endpoints", help="List endpoints, optionally filtered")
|
||||
endpoints.add_argument("search", nargs="?")
|
||||
spec_sub.add_parser("refresh", help="Refresh the spec cache")
|
||||
return p
|
||||
|
||||
|
||||
def main():
|
||||
p = parser()
|
||||
try:
|
||||
args = p.parse_args()
|
||||
except SystemExit as exc:
|
||||
return exc.code
|
||||
if args.action is None:
|
||||
args.action = "overview"
|
||||
if args.action in ("create-card", "move-card") and not args.confirm:
|
||||
emit("error", "Board writes require --confirm after user approval")
|
||||
emit("help", f"Run `planka.py {args.action} --help` for usage")
|
||||
return 2
|
||||
try:
|
||||
command(args)
|
||||
except (PlankaError, OSError, KeyError, TypeError) as exc:
|
||||
emit("error", str(exc))
|
||||
emit("help", "Check `planka.py --help` or `planka.py spec endpoints <term>`")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Run: python3 -m unittest discover -s .pi/skills/planka -p 'test_*.py'"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("planka", Path(__file__).with_name("planka.py"))
|
||||
planka = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(planka)
|
||||
|
||||
|
||||
class PlankaTests(unittest.TestCase):
|
||||
def test_config_precedence_and_trailing_slash(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / ".env").write_text('PLANKA_URL="https://project.example/"\nPLANKA_API_KEY=project-key\n')
|
||||
home = root / "home"
|
||||
home.mkdir()
|
||||
(home / ".env").write_text('PLANKA_URL=https://global.example/\nPLANKA_API_KEY=global-key\n')
|
||||
with patch.object(Path, "home", return_value=home), patch.object(Path, "cwd", return_value=root), patch.dict(os.environ, {"PLANKA_URL": "https://shell.example", "PLANKA_API_KEY": "shell-key"}):
|
||||
self.assertEqual(planka.load_config(), ("https://project.example", "project-key"))
|
||||
|
||||
def test_cache_fetch_once_and_refresh(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "spec.json"
|
||||
with patch.object(planka, "CACHE", path), patch.object(planka, "fetch_json", return_value={"paths": {"/projects": {"get": {}}}}) as fetch:
|
||||
self.assertEqual(planka.spec()["paths"], {"/projects": {"get": {}}})
|
||||
planka.spec()
|
||||
self.assertEqual(fetch.call_count, 1)
|
||||
planka.spec(refresh=True)
|
||||
self.assertEqual(fetch.call_count, 2)
|
||||
self.assertEqual(json.loads(path.read_text())["paths"], {"/projects": {"get": {}}})
|
||||
|
||||
def test_non_json_html_200_is_rejected(self):
|
||||
class Response:
|
||||
headers = {"Content-Type": "text/html"}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def read(self): return b"<html>app</html>"
|
||||
with patch.object(planka, "urlopen", return_value=Response()):
|
||||
with self.assertRaisesRegex(planka.PlankaError, "expected JSON"):
|
||||
planka.fetch_json("https://example.test/api/projects", "secret")
|
||||
|
||||
def test_positions_and_duplicate_selection(self):
|
||||
cards = [{"name": "Same", "listId": "a", "position": 65536}, {"name": "Same", "listId": "b", "position": 131072}]
|
||||
self.assertEqual(planka.next_position(cards, "a"), 131072)
|
||||
with self.assertRaisesRegex(planka.PlankaError, "2 matches"):
|
||||
planka.unique_card(cards, "Same")
|
||||
|
||||
def test_write_requires_confirm(self):
|
||||
with patch.object(planka, "Client") as client, patch.object(planka.sys, "argv", ["planka.py", "move-card", "Board", "Card", "List"]):
|
||||
self.assertEqual(planka.main(), 2)
|
||||
client.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user