2026-01-15 18:03:19 -08:00
|
|
|
#!/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"]
|
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
|
2026-04-06 10:37:40 -07:00
|
|
|
from rich.markup import escape
|
2026-01-15 18:03:19 -08:00
|
|
|
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-04-27 11:18:16 -07:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-01-15 18:03:19 -08:00
|
|
|
def sort_tasks(tasks: list[dict]) -> list[dict]:
|
2026-04-27 11:18:16 -07:00
|
|
|
"""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)
|
2026-01-15 18:03:19 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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}")
|
2026-04-27 11:18:16 -07:00
|
|
|
|
|
|
|
|
meta = []
|
|
|
|
|
days = days_until_due(task)
|
|
|
|
|
if days is not None:
|
2026-04-27 11:49:46 -07:00
|
|
|
if days == 0:
|
|
|
|
|
meta.append("due today")
|
|
|
|
|
elif days > 0:
|
|
|
|
|
meta.append(f"{days}d overdue")
|
|
|
|
|
else:
|
|
|
|
|
meta.append(f"due in {-days}d")
|
2026-04-27 11:18:16 -07:00
|
|
|
recurrence = recurrence_string(task)
|
|
|
|
|
if recurrence:
|
|
|
|
|
meta.append(f"↻ {recurrence}")
|
|
|
|
|
if meta:
|
|
|
|
|
header.append(f" ({', '.join(meta)})", style="dim")
|
2026-01-15 18:03:19 -08:00
|
|
|
console.print(header)
|
|
|
|
|
|
2026-04-06 10:37:40 -07:00
|
|
|
# Description indented (escape rich markup to preserve brackets)
|
2026-01-15 18:03:19 -08:00
|
|
|
if description:
|
|
|
|
|
for line in description.split("\n"):
|
2026-04-06 10:37:40 -07:00
|
|
|
console.print(f" {escape(line)}", style="dim")
|
2026-01-15 18:03:19 -08:00
|
|
|
|
|
|
|
|
console.print()
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|