task_utils.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. #!/usr/bin/env python3
  2. """
  3. Task utility functions.
  4. Provides:
  5. is_safe_task_path - Validate task path is safe to operate on
  6. find_task_by_name - Find task directory by name
  7. resolve_task_dir - Resolve task directory from name, relative, or absolute path
  8. archive_task_dir - Archive task to monthly directory
  9. run_task_hooks - Run lifecycle hooks for task events
  10. """
  11. from __future__ import annotations
  12. import shutil
  13. import sys
  14. from datetime import datetime
  15. from pathlib import Path
  16. from .paths import get_repo_root, get_tasks_dir
  17. # =============================================================================
  18. # Path Safety
  19. # =============================================================================
  20. def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
  21. """Check if a relative task path is safe to operate on.
  22. Args:
  23. task_path: Task path (relative to repo_root).
  24. repo_root: Repository root path. Defaults to auto-detected.
  25. Returns:
  26. True if safe, False if dangerous.
  27. """
  28. if repo_root is None:
  29. repo_root = get_repo_root()
  30. normalized = task_path.replace("\\", "/")
  31. # Check empty or null
  32. if not normalized or normalized == "null":
  33. print("Error: empty or null task path", file=sys.stderr)
  34. return False
  35. # Reject absolute paths
  36. if Path(task_path).is_absolute():
  37. print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr)
  38. return False
  39. # Reject ".", "..", paths starting with "./" or "../", or containing ".."
  40. if normalized in (".", "..") or normalized.startswith("./") or normalized.startswith("../") or ".." in normalized:
  41. print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr)
  42. return False
  43. # Final check: ensure resolved path is not the repo root
  44. abs_path = repo_root / Path(normalized)
  45. if abs_path.exists():
  46. try:
  47. resolved = abs_path.resolve()
  48. root_resolved = repo_root.resolve()
  49. if resolved == root_resolved:
  50. print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr)
  51. return False
  52. except (OSError, IOError):
  53. pass
  54. return True
  55. # =============================================================================
  56. # Task Lookup
  57. # =============================================================================
  58. def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
  59. """Find task directory by name (exact or suffix match).
  60. Args:
  61. task_name: Task name to find.
  62. tasks_dir: Tasks directory path.
  63. Returns:
  64. Absolute path to task directory, or None if not found.
  65. """
  66. if not task_name or not tasks_dir or not tasks_dir.is_dir():
  67. return None
  68. # Try exact match first
  69. exact_match = tasks_dir / task_name
  70. if exact_match.is_dir():
  71. return exact_match
  72. # Try suffix match (e.g., "my-task" matches "01-21-my-task")
  73. for d in tasks_dir.iterdir():
  74. if d.is_dir() and d.name.endswith(f"-{task_name}"):
  75. return d
  76. return None
  77. # =============================================================================
  78. # Archive Operations
  79. # =============================================================================
  80. def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
  81. """Archive a task directory to archive/{YYYY-MM}/.
  82. Args:
  83. task_dir_abs: Absolute path to task directory.
  84. repo_root: Repository root path. Defaults to auto-detected.
  85. Returns:
  86. Path to archived directory, or None on error.
  87. """
  88. if not task_dir_abs.is_dir():
  89. print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
  90. return None
  91. # Get tasks directory (parent of the task)
  92. tasks_dir = task_dir_abs.parent
  93. archive_dir = tasks_dir / "archive"
  94. year_month = datetime.now().strftime("%Y-%m")
  95. month_dir = archive_dir / year_month
  96. # Create archive directory
  97. try:
  98. month_dir.mkdir(parents=True, exist_ok=True)
  99. except (OSError, IOError) as e:
  100. print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
  101. return None
  102. # Move task to archive
  103. task_name = task_dir_abs.name
  104. dest = month_dir / task_name
  105. try:
  106. shutil.move(str(task_dir_abs), str(dest))
  107. except (OSError, IOError, shutil.Error) as e:
  108. print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
  109. return None
  110. return dest
  111. def archive_task_complete(
  112. task_dir_abs: Path,
  113. repo_root: Path | None = None
  114. ) -> dict[str, str]:
  115. """Complete archive workflow: archive directory.
  116. Args:
  117. task_dir_abs: Absolute path to task directory.
  118. repo_root: Repository root path. Defaults to auto-detected.
  119. Returns:
  120. Dict with archive result info.
  121. """
  122. if not task_dir_abs.is_dir():
  123. print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
  124. return {}
  125. archive_dest = archive_task_dir(task_dir_abs, repo_root)
  126. if archive_dest:
  127. return {"archived_to": str(archive_dest)}
  128. return {}
  129. # =============================================================================
  130. # Task Directory Resolution
  131. # =============================================================================
  132. def resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
  133. """Resolve task directory to absolute path.
  134. Supports:
  135. - Absolute path: /path/to/task
  136. - Relative path: .trellis/tasks/01-31-my-task
  137. - Task name: my-task (uses find_task_by_name for lookup)
  138. Args:
  139. target_dir: Task directory specification.
  140. repo_root: Repository root path.
  141. Returns:
  142. Resolved absolute path.
  143. """
  144. if not target_dir:
  145. return Path()
  146. normalized = target_dir.replace("\\", "/")
  147. while normalized.startswith("./"):
  148. normalized = normalized[2:]
  149. # Absolute path
  150. if Path(target_dir).is_absolute():
  151. return Path(target_dir)
  152. # Relative path (contains path separator or starts with .trellis)
  153. if "/" in normalized or normalized.startswith(".trellis"):
  154. return repo_root / Path(normalized)
  155. # Task name - try to find in tasks directory
  156. tasks_dir = get_tasks_dir(repo_root)
  157. found = find_task_by_name(target_dir, tasks_dir)
  158. if found:
  159. return found
  160. # Fallback to treating as relative path
  161. return repo_root / Path(normalized)
  162. # =============================================================================
  163. # Lifecycle Hooks
  164. # =============================================================================
  165. def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
  166. """Run lifecycle hooks for a task event.
  167. Args:
  168. event: Event name (e.g. "after_create").
  169. task_json_path: Absolute path to the task's task.json.
  170. repo_root: Repository root for cwd and config lookup.
  171. """
  172. import os
  173. import subprocess
  174. from .config import get_hooks
  175. from .log import Colors, colored
  176. commands = get_hooks(event, repo_root)
  177. if not commands:
  178. return
  179. env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
  180. for cmd in commands:
  181. try:
  182. result = subprocess.run(
  183. cmd,
  184. shell=True,
  185. cwd=repo_root,
  186. env=env,
  187. capture_output=True,
  188. text=True,
  189. encoding="utf-8",
  190. errors="replace",
  191. )
  192. if result.returncode != 0:
  193. print(
  194. colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
  195. file=sys.stderr,
  196. )
  197. if result.stderr.strip():
  198. print(f" {result.stderr.strip()}", file=sys.stderr)
  199. except Exception as e:
  200. print(
  201. colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW),
  202. file=sys.stderr,
  203. )
  204. # =============================================================================
  205. # Main Entry (for testing)
  206. # =============================================================================
  207. if __name__ == "__main__":
  208. repo = get_repo_root()
  209. tasks = get_tasks_dir(repo)
  210. print(f"Tasks dir: {tasks}")
  211. print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}")
  212. print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")