2026-01-15 18:03:19 -08:00
|
|
|
#!/usr/bin/env -S uv run --script
|
|
|
|
|
# /// script
|
|
|
|
|
# requires-python = ">=3.12"
|
Update tooling dependencies (Feb 2026 cycle) (#254)
## Summary
Monthly tooling dependency update cycle:
- **Pre-commit hooks**: trufflehog v3.92.5→v3.93.4, ruff v0.14.13→v0.15.2, shellcheck v0.10.0.1→v0.11.0.1, prettier v3.8.0→v3.8.1, actionlint v1.7.10→v1.7.11
- **Fly.io Dockerfile**: pin nginx to 1.28.2-alpine (was unpinned), bump alloy v1.5.1→v1.13.1
- **Mise tasks**: normalize httpx lower bound to >=0.28.0 and typer to >=0.15.0 across all scripts
- **Forgejo workflows**: actions/checkout@v4 is current, no changes needed
- **New how-to doc**: [[update-tooling-dependencies]] documenting this monthly cycle
## No changes needed
- pre-commit-hooks v6.0.0, yamllint v1.38.0, shfmt v3.12.0-2, taplo v0.9.3, ansible-lint 26.1.1 — all already at latest
## Test plan
- [x] `uvx pre-commit run --all-files` — all 24 hooks pass
- [ ] Fly.io deploy (triggered automatically on merge to main via deploy-fly workflow)
Reviewed-on: https://forge.ops.eblu.me/eblume/blumeops/pulls/254
2026-02-23 13:08:41 -08:00
|
|
|
# dependencies = ["httpx>=0.28.0", "rich>=13.0.0"]
|
2026-01-15 18:03:19 -08:00
|
|
|
# ///
|
|
|
|
|
#MISE description="List Blumeops tasks from Todoist sorted by priority"
|
|
|
|
|
"""Fetch and display Blumeops tasks from Todoist, sorted by priority.
|
|
|
|
|
|
|
|
|
|
This script is specific to Erich Blume's personal development workflow and
|
|
|
|
|
is not intended for general use. It requires:
|
|
|
|
|
|
|
|
|
|
- A 1Password CLI (`op`) configured with access to the author's vault
|
|
|
|
|
- A Todoist account with a project named "Blumeops"
|
|
|
|
|
|
|
|
|
|
The script fetches tasks and displays them sorted by a custom priority order:
|
|
|
|
|
p1 (urgent), p2 (high), p4 (normal/default), p3 (backlog). The p3-last ordering
|
|
|
|
|
reflects a deliberate choice to treat p3 as "backlog" rather than moderate
|
|
|
|
|
priority.
|
|
|
|
|
|
|
|
|
|
Usage: mise run blumeops-tasks
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-02-08 10:38:44 -08:00
|
|
|
from datetime import date
|
2026-01-15 18:03:19 -08:00
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
from rich.console import Console
|
|
|
|
|
from rich.text import Text
|
|
|
|
|
|
2026-02-11 14:33:37 -08:00
|
|
|
TODOIST_API_BASE = "https://api.todoist.com/api/v1"
|
2026-01-15 18:03:19 -08:00
|
|
|
PROJECT_NAME = "Blumeops"
|
|
|
|
|
|
|
|
|
|
# Priority mapping: Todoist API uses 1=normal(p4), 2=moderate(p3), 3=high(p2), 4=urgent(p1)
|
|
|
|
|
# User wants order: p1, p2, p4, p3 (p3 is backlog, goes last)
|
|
|
|
|
PRIORITY_LABELS = {4: "p1", 3: "p2", 1: "p4", 2: "p3"}
|
|
|
|
|
PRIORITY_SORT_ORDER = {4: 1, 3: 2, 1: 3, 2: 4} # Lower = earlier
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_todoist_token() -> str:
|
|
|
|
|
"""Retrieve Todoist API token from 1Password."""
|
|
|
|
|
result = subprocess.run(
|
2026-02-11 14:33:37 -08:00
|
|
|
["op", "read", "op://vg6xf6vvfmoh5hqjjhlhbeoaie/c53h3xnmswhvexa5mntoyvhgpm/credential"],
|
2026-01-15 18:03:19 -08:00
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
)
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
raise RuntimeError(f"Failed to get Todoist token from 1Password: {result.stderr}")
|
|
|
|
|
return result.stdout.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_project_id(client: httpx.Client, project_name: str) -> str:
|
|
|
|
|
"""Find project ID by name."""
|
2026-02-11 14:33:37 -08:00
|
|
|
cursor = None
|
|
|
|
|
while True:
|
|
|
|
|
params = {}
|
|
|
|
|
if cursor:
|
|
|
|
|
params["cursor"] = cursor
|
|
|
|
|
response = client.get(f"{TODOIST_API_BASE}/projects", params=params)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
data = response.json()
|
|
|
|
|
for project in data.get("results", data if isinstance(data, list) else []):
|
|
|
|
|
if project["name"] == project_name:
|
|
|
|
|
return project["id"]
|
|
|
|
|
cursor = data.get("next_cursor") if isinstance(data, dict) else None
|
|
|
|
|
if not cursor:
|
|
|
|
|
break
|
2026-01-15 18:03:19 -08:00
|
|
|
|
|
|
|
|
raise RuntimeError(f"Project '{project_name}' not found in Todoist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_tasks(client: httpx.Client, project_id: str) -> list[dict]:
|
|
|
|
|
"""Get all tasks for a project."""
|
2026-02-11 14:33:37 -08:00
|
|
|
tasks = []
|
|
|
|
|
cursor = None
|
|
|
|
|
while True:
|
|
|
|
|
params = {"project_id": project_id}
|
|
|
|
|
if cursor:
|
|
|
|
|
params["cursor"] = cursor
|
|
|
|
|
response = client.get(f"{TODOIST_API_BASE}/tasks", params=params)
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
data = response.json()
|
|
|
|
|
tasks.extend(data.get("results", data if isinstance(data, list) else []))
|
|
|
|
|
cursor = data.get("next_cursor") if isinstance(data, dict) else None
|
|
|
|
|
if not cursor:
|
|
|
|
|
break
|
|
|
|
|
return tasks
|
2026-01-15 18:03:19 -08:00
|
|
|
|
|
|
|
|
|
2026-02-08 10:38:44 -08:00
|
|
|
def is_due(task: dict) -> bool:
|
|
|
|
|
"""Check if a task should be displayed based on its due date.
|
|
|
|
|
|
|
|
|
|
Tasks without a due date are always shown. Tasks with a due date
|
|
|
|
|
are only shown when the date is today or in the past.
|
|
|
|
|
"""
|
|
|
|
|
due = task.get("due")
|
|
|
|
|
if due is None:
|
|
|
|
|
return True
|
|
|
|
|
due_date = date.fromisoformat(due["date"][:10])
|
|
|
|
|
return due_date <= date.today()
|
|
|
|
|
|
|
|
|
|
|
2026-01-15 18:03:19 -08:00
|
|
|
def sort_tasks(tasks: list[dict]) -> list[dict]:
|
|
|
|
|
"""Sort tasks by custom priority order: p1, p2, p4, p3."""
|
|
|
|
|
return sorted(tasks, key=lambda t: PRIORITY_SORT_ORDER.get(t["priority"], 5))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
console = Console()
|
|
|
|
|
|
|
|
|
|
# Get API token
|
|
|
|
|
try:
|
|
|
|
|
token = get_todoist_token()
|
|
|
|
|
except RuntimeError as e:
|
|
|
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
# Create HTTP client with auth header
|
|
|
|
|
with httpx.Client(headers={"Authorization": f"Bearer {token}"}) as client:
|
|
|
|
|
# Find project
|
|
|
|
|
try:
|
|
|
|
|
project_id = get_project_id(client, PROJECT_NAME)
|
|
|
|
|
except RuntimeError as e:
|
|
|
|
|
console.print(f"[red]Error:[/red] {e}")
|
|
|
|
|
return 1
|
|
|
|
|
|
2026-02-08 10:38:44 -08:00
|
|
|
# Get, filter, and sort tasks
|
2026-01-15 18:03:19 -08:00
|
|
|
tasks = get_tasks(client, project_id)
|
2026-02-08 10:38:44 -08:00
|
|
|
tasks = [t for t in tasks if is_due(t)]
|
2026-01-15 18:03:19 -08:00
|
|
|
sorted_tasks = sort_tasks(tasks)
|
|
|
|
|
|
|
|
|
|
if not sorted_tasks:
|
|
|
|
|
console.print("No tasks found in Blumeops project")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
# Display tasks
|
|
|
|
|
console.print(f"[bold]Blumeops Tasks[/bold] ({len(sorted_tasks)} tasks)")
|
|
|
|
|
console.print("=" * 40)
|
|
|
|
|
console.print()
|
|
|
|
|
|
|
|
|
|
for task in sorted_tasks:
|
|
|
|
|
priority = task["priority"]
|
|
|
|
|
label = PRIORITY_LABELS.get(priority, "p?")
|
|
|
|
|
content = task["content"]
|
|
|
|
|
description = task.get("description", "")
|
|
|
|
|
|
|
|
|
|
# Header line with priority and content
|
|
|
|
|
header = Text()
|
|
|
|
|
header.append(f"[{label}]", style="bold")
|
|
|
|
|
header.append(f" {content}")
|
|
|
|
|
console.print(header)
|
|
|
|
|
|
|
|
|
|
# Description indented
|
|
|
|
|
if description:
|
|
|
|
|
for line in description.split("\n"):
|
|
|
|
|
console.print(f" {line}", style="dim")
|
|
|
|
|
|
|
|
|
|
console.print()
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|