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.
178 lines
5.4 KiB
178 lines
5.4 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 |
|
archive_task_dir - Archive task to monthly directory |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import shutil |
|
import sys |
|
from datetime import datetime |
|
from pathlib import Path |
|
|
|
from .paths import get_repo_root |
|
|
|
|
|
# ============================================================================= |
|
# 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 {} |
|
|
|
|
|
# ============================================================================= |
|
# Main Entry (for testing) |
|
# ============================================================================= |
|
|
|
if __name__ == "__main__": |
|
from .paths import get_tasks_dir |
|
|
|
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)}")
|
|
|