blumeops/mise-tasks/blumeops-tasks

216 lines
6.8 KiB
Text
Raw Normal View History

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
C1: SHA-pin tooling dependencies (2026-04 cycle) (#344) ## Summary Monthly tooling dependency refresh, with a one-time conversion from version-tag pins (`rev = "vX.Y.Z"`, `image:tag`, `>=`) to SHA / digest pins everywhere. ## Changes - **prek hooks**: all `rev = "vX.Y.Z"` → commit SHA + `# vX.Y.Z` comment. Bumped trufflehog (3.94.0→3.95.2), kingfisher (1.91.0→1.97.0), ruff (0.15.7→0.15.12), shfmt (3.13.0→3.13.1), prettier (3.8.1→3.8.3), actionlint (1.7.11→1.7.12). - **fly/Dockerfile**: tag pins → `image@sha256:...` digest pins. Bumped nginx (1.29.6→1.30.0-alpine), tailscale (v1.94.1→v1.94.2 — still inside the safe pre-1.96.5 range), alloy (v1.14.1→v1.16.0). - **mise-tasks**: PEP 723 inline deps converted from `>=` to `==` (PEP 508 doesn't support hashes inline). All scripts pinned to current latest: rich 15.0.0, typer 0.25.0, pyyaml 6.0.3, httpx 0.28.1. - **prek `additional_dependencies`**: ansible-lint==26.4.0, ansible-core==2.20.5. - **taplo-lint**: pass `--no-schema`. Upstream's `--default-schema-catalogs` returns a format taplo v0.9.3 can't parse — we don't validate against TOML schemas anyway, so this turns off the broken catalog fetch. - **docs/update-tooling-dependencies**: documents the SHA-pin convention, `docker buildx imagetools inspect` for digest lookup, and `prek clean` before re-verifying (cache grows to several GiB). Forgejo workflow `actions/checkout@v6.0.2` was already at the latest SHA — no change. ## Test plan - [x] `prek run --all-files` passes after `prek clean` - [x] `deploy-fly` workflow builds and deploys the new fly image on merge - [x] `fly status -a blumeops-proxy` healthy after deploy - [x] Spot-check a few mise tasks (`mise run blumeops-tasks`, `mise run docs-check-links`) to confirm pinned deps resolve cleanly Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/344
2026-04-30 16:51:43 -07:00
# dependencies = ["httpx==0.28.1", "rich==15.0.0"]
# ///
#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
from datetime import date
import httpx
from rich.console import Console
from rich.markup import escape
from rich.text import Text
TODOIST_API_BASE = "https://api.todoist.com/api/v1"
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(
["op", "read", "op://vg6xf6vvfmoh5hqjjhlhbeoaie/c53h3xnmswhvexa5mntoyvhgpm/credential"],
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."""
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
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."""
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
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()
def days_until_due(task: dict) -> int | None:
"""Return signed days offset from today, or None if no due date.
Negative = days remaining before due (e.g. -2 = due in 2 days).
Positive = days past due (overdue). Zero = due today.
"""
due = task.get("due")
if due is None:
return None
due_date = date.fromisoformat(due["date"][:10])
return (date.today() - due_date).days
def recurrence_string(task: dict) -> str | None:
"""Return the Todoist natural-language recurrence string, or None.
Todoist's REST API doesn't expose RFC 5545 RRULE; the natural-language
`due.string` (e.g. "every monday", "every 2 weeks") is the terse form.
"""
due = task.get("due")
if due is None or not due.get("is_recurring"):
return None
return due.get("string")
def sort_tasks(tasks: list[dict]) -> list[dict]:
"""Sort by overdue-ness, then priority.
Most overdue first (largest +N); tasks with no due date come last.
Within a given day, tiebreaker is the custom priority order p1, p2, p4, p3.
"""
def key(task: dict) -> tuple[int, int, int]:
days = days_until_due(task)
no_due = 1 if days is None else 0
days_key = -(days if days is not None else 0) # descending
return (no_due, days_key, PRIORITY_SORT_ORDER.get(task["priority"], 5))
return sorted(tasks, key=key)
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
# Get, filter, and sort tasks
tasks = get_tasks(client, project_id)
tasks = [t for t in tasks if is_due(t)]
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}")
meta = []
days = days_until_due(task)
if days is not None:
if days == 0:
meta.append("due today")
elif days > 0:
meta.append(f"{days}d overdue")
else:
meta.append(f"due in {-days}d")
recurrence = recurrence_string(task)
if recurrence:
meta.append(f"↻ {recurrence}")
if meta:
header.append(f" ({', '.join(meta)})", style="dim")
console.print(header)
# Description indented (escape rich markup to preserve brackets)
if description:
for line in description.split("\n"):
console.print(f" {escape(line)}", style="dim")
console.print()
return 0
if __name__ == "__main__":
sys.exit(main())