#!/usr/bin/env python3 """Linear sync hook for Trellis task lifecycle. Syncs task events to Linear via the `linearis` CLI. Usage (called automatically by task.py hooks): python3 .trellis/scripts/hooks/linear_sync.py create python3 .trellis/scripts/hooks/linear_sync.py start python3 .trellis/scripts/hooks/linear_sync.py archive Manual usage: TASK_JSON_PATH=.trellis/tasks//task.json python3 .trellis/scripts/hooks/linear_sync.py sync Environment: TASK_JSON_PATH - Absolute path to task.json (set by task.py) Configuration: .trellis/hooks.local.json - Local config (gitignored), example: { "linear": { "team": "TEAM_KEY", "project": "Project Name", "assignees": { "dev-name": "linear-user-id" } } } """ from __future__ import annotations import json import os import subprocess import sys from pathlib import Path # ─── Configuration ──────────────────────────────────────────────────────────── # Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low) PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4} # Linear status names (must match your team's workflow) STATUS_IN_PROGRESS = "In Progress" STATUS_DONE = "Done" def _load_config() -> dict: """Load local hook config from .trellis/hooks.local.json.""" task_json_path = os.environ.get("TASK_JSON_PATH", "") if task_json_path: # Walk up from task.json to find .trellis/ trellis_dir = Path(task_json_path).parent.parent.parent else: trellis_dir = Path(".trellis") config_path = trellis_dir / "hooks.local.json" try: with open(config_path, encoding="utf-8") as f: return json.load(f) except (OSError, json.JSONDecodeError): return {} CONFIG = _load_config() LINEAR_CFG = CONFIG.get("linear", {}) TEAM = LINEAR_CFG.get("team", "") PROJECT = LINEAR_CFG.get("project", "") ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {}) # ─── Helpers ────────────────────────────────────────────────────────────────── def _read_task() -> tuple[dict, str]: path = os.environ.get("TASK_JSON_PATH", "") if not path: print("TASK_JSON_PATH not set", file=sys.stderr) sys.exit(1) with open(path, encoding="utf-8") as f: return json.load(f), path def _write_task(data: dict, path: str) -> None: with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") def _linearis(*args: str) -> dict | None: result = subprocess.run( ["linearis", *args], capture_output=True, text=True, encoding="utf-8", errors="replace", ) if result.returncode != 0: print(f"linearis error: {result.stderr.strip()}", file=sys.stderr) sys.exit(1) stdout = result.stdout.strip() if stdout: return json.loads(stdout) return None def _get_linear_issue(task: dict) -> str | None: meta = task.get("meta") if isinstance(meta, dict): return meta.get("linear_issue") return None # ─── Actions ────────────────────────────────────────────────────────────────── def cmd_create() -> None: if not TEAM: print("No linear.team configured in hooks.local.json", file=sys.stderr) sys.exit(1) task, path = _read_task() # Skip if already linked if _get_linear_issue(task): print(f"Already linked: {_get_linear_issue(task)}") return title = task.get("title") or task.get("name") or "Untitled" args = ["issues", "create", title, "--team", TEAM] # Map priority priority = PRIORITY_MAP.get(task.get("priority", ""), 0) if priority: args.extend(["-p", str(priority)]) # Set project if PROJECT: args.extend(["--project", PROJECT]) # Assign to Linear user assignee = task.get("assignee", "") linear_user_id = ASSIGNEE_MAP.get(assignee) if linear_user_id: args.extend(["--assignee", linear_user_id]) # Link to parent's Linear issue if available parent_issue = _resolve_parent_linear_issue(task) if parent_issue: args.extend(["--parent-ticket", parent_issue]) result = _linearis(*args) if result and "identifier" in result: if not isinstance(task.get("meta"), dict): task["meta"] = {} task["meta"]["linear_issue"] = result["identifier"] _write_task(task, path) print(f"Created Linear issue: {result['identifier']}") def cmd_start() -> None: task, _ = _read_task() issue = _get_linear_issue(task) if not issue: return _linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS) print(f"Updated {issue} -> {STATUS_IN_PROGRESS}") cmd_sync() def cmd_archive() -> None: task, _ = _read_task() issue = _get_linear_issue(task) if not issue: return _linearis("issues", "update", issue, "-s", STATUS_DONE) print(f"Updated {issue} -> {STATUS_DONE}") def cmd_sync() -> None: """Sync prd.md content to Linear issue description.""" task, _ = _read_task() issue = _get_linear_issue(task) if not issue: print("No linear_issue in meta, run create first", file=sys.stderr) sys.exit(1) # Find prd.md next to task.json task_json_path = os.environ.get("TASK_JSON_PATH", "") prd_path = Path(task_json_path).parent / "prd.md" if not prd_path.is_file(): print(f"No prd.md found at {prd_path}", file=sys.stderr) sys.exit(1) description = prd_path.read_text(encoding="utf-8").strip() _linearis("issues", "update", issue, "-d", description) print(f"Synced prd.md to {issue} description") # ─── Parent Issue Resolution ───────────────────────────────────────────────── def _resolve_parent_linear_issue(task: dict) -> str | None: """Find parent task's Linear issue identifier.""" parent_name = task.get("parent") if not parent_name: return None task_json_path = os.environ.get("TASK_JSON_PATH", "") if not task_json_path: return None current_task_dir = Path(task_json_path).parent tasks_dir = current_task_dir.parent parent_json = tasks_dir / parent_name / "task.json" if parent_json.exists(): try: with open(parent_json, encoding="utf-8") as f: parent_task = json.load(f) return _get_linear_issue(parent_task) except (json.JSONDecodeError, OSError): pass return None # ─── Main ───────────────────────────────────────────────────────────────────── if __name__ == "__main__": action = sys.argv[1] if len(sys.argv) > 1 else "" actions = { "create": cmd_create, "start": cmd_start, "archive": cmd_archive, "sync": cmd_sync, } fn = actions.get(action) if fn: fn() else: print(f"Unknown action: {action}", file=sys.stderr) print(f"Valid actions: {', '.join(actions)}", file=sys.stderr) sys.exit(1)