forked from mengyxu/noob-components
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
268 lines
8.2 KiB
268 lines
8.2 KiB
#!/usr/bin/env python3 |
|
""" |
|
Task utility functions. |
|
|
|
Provides: |
|
is_safe_task_path - Validate task path is safe to operate on |
|
find_task_by_name - Find task directory by name |
|
resolve_task_dir - Resolve task directory from name, relative, or absolute path |
|
archive_task_dir - Archive task to monthly directory |
|
run_task_hooks - Run lifecycle hooks for task events |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import shutil |
|
import sys |
|
from datetime import datetime |
|
from pathlib import Path |
|
|
|
from .paths import get_repo_root, get_tasks_dir |
|
|
|
|
|
# ============================================================================= |
|
# Path Safety |
|
# ============================================================================= |
|
|
|
def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool: |
|
"""Check if a relative task path is safe to operate on. |
|
|
|
Args: |
|
task_path: Task path (relative to repo_root). |
|
repo_root: Repository root path. Defaults to auto-detected. |
|
|
|
Returns: |
|
True if safe, False if dangerous. |
|
""" |
|
if repo_root is None: |
|
repo_root = get_repo_root() |
|
|
|
# Check empty or null |
|
if not task_path or task_path == "null": |
|
print("Error: empty or null task path", file=sys.stderr) |
|
return False |
|
|
|
# Reject absolute paths |
|
if task_path.startswith("/"): |
|
print(f"Error: absolute path not allowed: {task_path}", file=sys.stderr) |
|
return False |
|
|
|
# Reject ".", "..", paths starting with "./" or "../", or containing ".." |
|
if task_path in (".", "..") or task_path.startswith("./") or task_path.startswith("../") or ".." in task_path: |
|
print(f"Error: path traversal not allowed: {task_path}", file=sys.stderr) |
|
return False |
|
|
|
# Final check: ensure resolved path is not the repo root |
|
abs_path = repo_root / task_path |
|
if abs_path.exists(): |
|
try: |
|
resolved = abs_path.resolve() |
|
root_resolved = repo_root.resolve() |
|
if resolved == root_resolved: |
|
print(f"Error: path resolves to repo root: {task_path}", file=sys.stderr) |
|
return False |
|
except (OSError, IOError): |
|
pass |
|
|
|
return True |
|
|
|
|
|
# ============================================================================= |
|
# Task Lookup |
|
# ============================================================================= |
|
|
|
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None: |
|
"""Find task directory by name (exact or suffix match). |
|
|
|
Args: |
|
task_name: Task name to find. |
|
tasks_dir: Tasks directory path. |
|
|
|
Returns: |
|
Absolute path to task directory, or None if not found. |
|
""" |
|
if not task_name or not tasks_dir or not tasks_dir.is_dir(): |
|
return None |
|
|
|
# Try exact match first |
|
exact_match = tasks_dir / task_name |
|
if exact_match.is_dir(): |
|
return exact_match |
|
|
|
# Try suffix match (e.g., "my-task" matches "01-21-my-task") |
|
for d in tasks_dir.iterdir(): |
|
if d.is_dir() and d.name.endswith(f"-{task_name}"): |
|
return d |
|
|
|
return None |
|
|
|
|
|
# ============================================================================= |
|
# Archive Operations |
|
# ============================================================================= |
|
|
|
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None: |
|
"""Archive a task directory to archive/{YYYY-MM}/. |
|
|
|
Args: |
|
task_dir_abs: Absolute path to task directory. |
|
repo_root: Repository root path. Defaults to auto-detected. |
|
|
|
Returns: |
|
Path to archived directory, or None on error. |
|
""" |
|
if not task_dir_abs.is_dir(): |
|
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) |
|
return None |
|
|
|
# Get tasks directory (parent of the task) |
|
tasks_dir = task_dir_abs.parent |
|
archive_dir = tasks_dir / "archive" |
|
year_month = datetime.now().strftime("%Y-%m") |
|
month_dir = archive_dir / year_month |
|
|
|
# Create archive directory |
|
try: |
|
month_dir.mkdir(parents=True, exist_ok=True) |
|
except (OSError, IOError) as e: |
|
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr) |
|
return None |
|
|
|
# Move task to archive |
|
task_name = task_dir_abs.name |
|
dest = month_dir / task_name |
|
|
|
try: |
|
shutil.move(str(task_dir_abs), str(dest)) |
|
except (OSError, IOError, shutil.Error) as e: |
|
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr) |
|
return None |
|
|
|
return dest |
|
|
|
|
|
def archive_task_complete( |
|
task_dir_abs: Path, |
|
repo_root: Path | None = None |
|
) -> dict[str, str]: |
|
"""Complete archive workflow: archive directory. |
|
|
|
Args: |
|
task_dir_abs: Absolute path to task directory. |
|
repo_root: Repository root path. Defaults to auto-detected. |
|
|
|
Returns: |
|
Dict with archive result info. |
|
""" |
|
if not task_dir_abs.is_dir(): |
|
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr) |
|
return {} |
|
|
|
archive_dest = archive_task_dir(task_dir_abs, repo_root) |
|
if archive_dest: |
|
return {"archived_to": str(archive_dest)} |
|
|
|
return {} |
|
|
|
|
|
# ============================================================================= |
|
# Task Directory Resolution |
|
# ============================================================================= |
|
|
|
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path: |
|
"""Resolve task directory to absolute path. |
|
|
|
Supports: |
|
- Absolute path: /path/to/task |
|
- Relative path: .trellis/tasks/01-31-my-task |
|
- Task name: my-task (uses find_task_by_name for lookup) |
|
|
|
Args: |
|
target_dir: Task directory specification. |
|
repo_root: Repository root path. |
|
|
|
Returns: |
|
Resolved absolute path. |
|
""" |
|
if not target_dir: |
|
return Path() |
|
|
|
# Absolute path |
|
if target_dir.startswith("/"): |
|
return Path(target_dir) |
|
|
|
# Relative path (contains path separator or starts with .trellis) |
|
if "/" in target_dir or target_dir.startswith(".trellis"): |
|
return repo_root / target_dir |
|
|
|
# Task name - try to find in tasks directory |
|
tasks_dir = get_tasks_dir(repo_root) |
|
found = find_task_by_name(target_dir, tasks_dir) |
|
if found: |
|
return found |
|
|
|
# Fallback to treating as relative path |
|
return repo_root / target_dir |
|
|
|
|
|
# ============================================================================= |
|
# Lifecycle Hooks |
|
# ============================================================================= |
|
|
|
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None: |
|
"""Run lifecycle hooks for a task event. |
|
|
|
Args: |
|
event: Event name (e.g. "after_create"). |
|
task_json_path: Absolute path to the task's task.json. |
|
repo_root: Repository root for cwd and config lookup. |
|
""" |
|
import os |
|
import subprocess |
|
|
|
from .config import get_hooks |
|
from .log import Colors, colored |
|
|
|
commands = get_hooks(event, repo_root) |
|
if not commands: |
|
return |
|
|
|
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)} |
|
|
|
for cmd in commands: |
|
try: |
|
result = subprocess.run( |
|
cmd, |
|
shell=True, |
|
cwd=repo_root, |
|
env=env, |
|
capture_output=True, |
|
text=True, |
|
encoding="utf-8", |
|
errors="replace", |
|
) |
|
if result.returncode != 0: |
|
print( |
|
colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW), |
|
file=sys.stderr, |
|
) |
|
if result.stderr.strip(): |
|
print(f" {result.stderr.strip()}", file=sys.stderr) |
|
except Exception as e: |
|
print( |
|
colored(f"[WARN] Hook error ({event}): {cmd} — {e}", Colors.YELLOW), |
|
file=sys.stderr, |
|
) |
|
|
|
|
|
# ============================================================================= |
|
# Main Entry (for testing) |
|
# ============================================================================= |
|
|
|
if __name__ == "__main__": |
|
repo = get_repo_root() |
|
tasks = get_tasks_dir(repo) |
|
|
|
print(f"Tasks dir: {tasks}") |
|
print(f"is_safe_task_path('.trellis/tasks/test'): {is_safe_task_path('.trellis/tasks/test', repo)}") |
|
print(f"is_safe_task_path('../test'): {is_safe_task_path('../test', repo)}")
|
|
|