linear_sync.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. #!/usr/bin/env python3
  2. """Linear sync hook for Trellis task lifecycle.
  3. Syncs task events to Linear via the `linearis` CLI.
  4. Usage (called automatically by task.py hooks):
  5. python3 .trellis/scripts/hooks/linear_sync.py create
  6. python3 .trellis/scripts/hooks/linear_sync.py start
  7. python3 .trellis/scripts/hooks/linear_sync.py archive
  8. Manual usage:
  9. TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync
  10. Environment:
  11. TASK_JSON_PATH - Absolute path to task.json (set by task.py)
  12. Configuration:
  13. .trellis/hooks.local.json - Local config (gitignored), example:
  14. {
  15. "linear": {
  16. "team": "TEAM_KEY",
  17. "project": "Project Name",
  18. "assignees": {
  19. "dev-name": "linear-user-id"
  20. }
  21. }
  22. }
  23. """
  24. from __future__ import annotations
  25. import json
  26. import os
  27. import subprocess
  28. import sys
  29. from pathlib import Path
  30. # ─── Configuration ────────────────────────────────────────────────────────────
  31. # Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low)
  32. PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4}
  33. # Linear status names (must match your team's workflow)
  34. STATUS_IN_PROGRESS = "In Progress"
  35. STATUS_DONE = "Done"
  36. def _load_config() -> dict:
  37. """Load local hook config from .trellis/hooks.local.json."""
  38. task_json_path = os.environ.get("TASK_JSON_PATH", "")
  39. if task_json_path:
  40. # Walk up from task.json to find .trellis/
  41. trellis_dir = Path(task_json_path).parent.parent.parent
  42. else:
  43. trellis_dir = Path(".trellis")
  44. config_path = trellis_dir / "hooks.local.json"
  45. try:
  46. with open(config_path, encoding="utf-8") as f:
  47. return json.load(f)
  48. except (OSError, json.JSONDecodeError):
  49. return {}
  50. CONFIG = _load_config()
  51. LINEAR_CFG = CONFIG.get("linear", {})
  52. TEAM = LINEAR_CFG.get("team", "")
  53. PROJECT = LINEAR_CFG.get("project", "")
  54. ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {})
  55. # ─── Helpers ──────────────────────────────────────────────────────────────────
  56. def _read_task() -> tuple[dict, str]:
  57. path = os.environ.get("TASK_JSON_PATH", "")
  58. if not path:
  59. print("TASK_JSON_PATH not set", file=sys.stderr)
  60. sys.exit(1)
  61. with open(path, encoding="utf-8") as f:
  62. return json.load(f), path
  63. def _write_task(data: dict, path: str) -> None:
  64. with open(path, "w", encoding="utf-8") as f:
  65. json.dump(data, f, indent=2, ensure_ascii=False)
  66. f.write("\n")
  67. def _linearis(*args: str) -> dict | None:
  68. result = subprocess.run(
  69. ["linearis", *args],
  70. capture_output=True,
  71. text=True,
  72. encoding="utf-8",
  73. errors="replace",
  74. )
  75. if result.returncode != 0:
  76. print(f"linearis error: {result.stderr.strip()}", file=sys.stderr)
  77. sys.exit(1)
  78. stdout = result.stdout.strip()
  79. if stdout:
  80. return json.loads(stdout)
  81. return None
  82. def _get_linear_issue(task: dict) -> str | None:
  83. meta = task.get("meta")
  84. if isinstance(meta, dict):
  85. return meta.get("linear_issue")
  86. return None
  87. # ─── Actions ──────────────────────────────────────────────────────────────────
  88. def cmd_create() -> None:
  89. if not TEAM:
  90. print("No linear.team configured in hooks.local.json", file=sys.stderr)
  91. sys.exit(1)
  92. task, path = _read_task()
  93. # Skip if already linked
  94. if _get_linear_issue(task):
  95. print(f"Already linked: {_get_linear_issue(task)}")
  96. return
  97. title = task.get("title") or task.get("name") or "Untitled"
  98. args = ["issues", "create", title, "--team", TEAM]
  99. # Map priority
  100. priority = PRIORITY_MAP.get(task.get("priority", ""), 0)
  101. if priority:
  102. args.extend(["-p", str(priority)])
  103. # Set project
  104. if PROJECT:
  105. args.extend(["--project", PROJECT])
  106. # Assign to Linear user
  107. assignee = task.get("assignee", "")
  108. linear_user_id = ASSIGNEE_MAP.get(assignee)
  109. if linear_user_id:
  110. args.extend(["--assignee", linear_user_id])
  111. # Link to parent's Linear issue if available
  112. parent_issue = _resolve_parent_linear_issue(task)
  113. if parent_issue:
  114. args.extend(["--parent-ticket", parent_issue])
  115. result = _linearis(*args)
  116. if result and "identifier" in result:
  117. if not isinstance(task.get("meta"), dict):
  118. task["meta"] = {}
  119. task["meta"]["linear_issue"] = result["identifier"]
  120. _write_task(task, path)
  121. print(f"Created Linear issue: {result['identifier']}")
  122. def cmd_start() -> None:
  123. task, _ = _read_task()
  124. issue = _get_linear_issue(task)
  125. if not issue:
  126. return
  127. _linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS)
  128. print(f"Updated {issue} -> {STATUS_IN_PROGRESS}")
  129. cmd_sync()
  130. def cmd_archive() -> None:
  131. task, _ = _read_task()
  132. issue = _get_linear_issue(task)
  133. if not issue:
  134. return
  135. _linearis("issues", "update", issue, "-s", STATUS_DONE)
  136. print(f"Updated {issue} -> {STATUS_DONE}")
  137. def cmd_sync() -> None:
  138. """Sync prd.md content to Linear issue description."""
  139. task, _ = _read_task()
  140. issue = _get_linear_issue(task)
  141. if not issue:
  142. print("No linear_issue in meta, run create first", file=sys.stderr)
  143. sys.exit(1)
  144. # Find prd.md next to task.json
  145. task_json_path = os.environ.get("TASK_JSON_PATH", "")
  146. prd_path = Path(task_json_path).parent / "prd.md"
  147. if not prd_path.is_file():
  148. print(f"No prd.md found at {prd_path}", file=sys.stderr)
  149. sys.exit(1)
  150. description = prd_path.read_text(encoding="utf-8").strip()
  151. _linearis("issues", "update", issue, "-d", description)
  152. print(f"Synced prd.md to {issue} description")
  153. # ─── Parent Issue Resolution ─────────────────────────────────────────────────
  154. def _resolve_parent_linear_issue(task: dict) -> str | None:
  155. """Find parent task's Linear issue identifier."""
  156. parent_name = task.get("parent")
  157. if not parent_name:
  158. return None
  159. task_json_path = os.environ.get("TASK_JSON_PATH", "")
  160. if not task_json_path:
  161. return None
  162. current_task_dir = Path(task_json_path).parent
  163. tasks_dir = current_task_dir.parent
  164. parent_json = tasks_dir / parent_name / "task.json"
  165. if parent_json.exists():
  166. try:
  167. with open(parent_json, encoding="utf-8") as f:
  168. parent_task = json.load(f)
  169. return _get_linear_issue(parent_task)
  170. except (json.JSONDecodeError, OSError):
  171. pass
  172. return None
  173. # ─── Main ─────────────────────────────────────────────────────────────────────
  174. if __name__ == "__main__":
  175. action = sys.argv[1] if len(sys.argv) > 1 else ""
  176. actions = {
  177. "create": cmd_create,
  178. "start": cmd_start,
  179. "archive": cmd_archive,
  180. "sync": cmd_sync,
  181. }
  182. fn = actions.get(action)
  183. if fn:
  184. fn()
  185. else:
  186. print(f"Unknown action: {action}", file=sys.stderr)
  187. print(f"Valid actions: {', '.join(actions)}", file=sys.stderr)
  188. sys.exit(1)