task_store.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. #!/usr/bin/env python3
  2. """
  3. Task CRUD operations.
  4. Provides:
  5. ensure_tasks_dir - Ensure tasks directory exists
  6. cmd_create - Create a new task
  7. cmd_archive - Archive completed task
  8. cmd_set_branch - Set git branch for task
  9. cmd_set_base_branch - Set PR target branch
  10. cmd_set_scope - Set scope for PR title
  11. cmd_add_subtask - Link child task to parent
  12. cmd_remove_subtask - Unlink child task from parent
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import json
  17. import re
  18. import sys
  19. from datetime import datetime
  20. from pathlib import Path
  21. from .config import (
  22. get_packages,
  23. get_session_auto_commit,
  24. is_monorepo,
  25. resolve_package,
  26. validate_package,
  27. )
  28. from .git import run_git
  29. from .io import read_json, write_json
  30. from .log import Colors, colored
  31. from .paths import (
  32. DIR_ARCHIVE,
  33. DIR_TASKS,
  34. DIR_WORKFLOW,
  35. FILE_TASK_JSON,
  36. generate_task_date_prefix,
  37. get_developer,
  38. get_repo_root,
  39. get_tasks_dir,
  40. )
  41. from .safe_commit import (
  42. print_gitignore_warning,
  43. safe_archive_paths_to_add,
  44. safe_git_add,
  45. )
  46. from .task_utils import (
  47. archive_task_complete,
  48. find_task_by_name,
  49. resolve_task_dir,
  50. run_task_hooks,
  51. )
  52. # =============================================================================
  53. # Helper Functions
  54. # =============================================================================
  55. def _slugify(title: str) -> str:
  56. """Convert title to slug (only works with ASCII)."""
  57. result = title.lower()
  58. result = re.sub(r"[^a-z0-9]", "-", result)
  59. result = re.sub(r"-+", "-", result)
  60. result = result.strip("-")
  61. return result
  62. def ensure_tasks_dir(repo_root: Path) -> Path:
  63. """Ensure tasks directory exists."""
  64. tasks_dir = get_tasks_dir(repo_root)
  65. archive_dir = tasks_dir / "archive"
  66. if not tasks_dir.exists():
  67. tasks_dir.mkdir(parents=True)
  68. print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
  69. if not archive_dir.exists():
  70. archive_dir.mkdir(parents=True)
  71. return tasks_dir
  72. def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
  73. """Find an archived task directory with the exact active-task dir name."""
  74. archive_dir = tasks_dir / DIR_ARCHIVE
  75. if not archive_dir.is_dir():
  76. return None
  77. for month_dir in sorted(archive_dir.iterdir()):
  78. if not month_dir.is_dir():
  79. continue
  80. candidate = month_dir / dir_name
  81. if candidate.is_dir():
  82. return candidate
  83. return None
  84. def _repo_relative_path(path: Path, repo_root: Path) -> str:
  85. """Format a path relative to the repo root when possible."""
  86. try:
  87. return path.relative_to(repo_root).as_posix()
  88. except ValueError:
  89. return str(path)
  90. # =============================================================================
  91. # Sub-agent platform detection + JSONL seeding
  92. # =============================================================================
  93. # Config directories of platforms that consume implement.jsonl / check.jsonl.
  94. # Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
  95. # platforms listed in workflow.md's "agent-capable" Skill Routing block
  96. # (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
  97. # Windsurf are NOT in this list: they do not consume JSONL.
  98. _SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
  99. ".claude",
  100. ".cursor",
  101. ".codex",
  102. ".kiro",
  103. ".gemini",
  104. ".opencode",
  105. ".qoder",
  106. ".codebuddy",
  107. ".factory", # Factory Droid
  108. ".github/copilot",
  109. ".pi", # Pi Agent
  110. )
  111. _SEED_EXAMPLE = (
  112. "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
  113. "Put spec/research files only — no code paths. "
  114. "Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. "
  115. "Delete this line once real entries are added."
  116. )
  117. def _has_subagent_platform(repo_root: Path) -> bool:
  118. """Return True if any sub-agent-capable platform is configured.
  119. Detected by probing well-known config directories at the repo root. Used
  120. only to decide whether ``task.py create`` should seed empty
  121. ``implement.jsonl`` / ``check.jsonl`` files.
  122. """
  123. for config_dir in _SUBAGENT_CONFIG_DIRS:
  124. if (repo_root / config_dir).is_dir():
  125. return True
  126. return False
  127. def _write_seed_jsonl(path: Path) -> None:
  128. """Write a one-line seed JSONL file with a self-describing ``_example``.
  129. The seed row has no ``file`` field, so downstream consumers (hooks +
  130. preludes) that iterate entries via ``item.get("file")`` naturally skip
  131. it. The row exists purely as an in-file prompt for the AI curator.
  132. """
  133. seed = {"_example": _SEED_EXAMPLE}
  134. path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
  135. # =============================================================================
  136. # Command: create
  137. # =============================================================================
  138. def cmd_create(args: argparse.Namespace) -> int:
  139. """Create a new task."""
  140. repo_root = get_repo_root()
  141. if not args.title:
  142. print(colored("Error: title is required", Colors.RED), file=sys.stderr)
  143. return 1
  144. # Validate --package (CLI source: fail-fast)
  145. package: str | None = getattr(args, "package", None)
  146. if not is_monorepo(repo_root):
  147. # Single-repo: ignore --package, no package prefix
  148. if package:
  149. print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr)
  150. package = None
  151. elif package:
  152. if not validate_package(package, repo_root):
  153. packages = get_packages(repo_root)
  154. available = ", ".join(sorted(packages.keys())) if packages else "(none)"
  155. print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr)
  156. return 1
  157. else:
  158. # Inferred: default_package → None (no task.json yet for create)
  159. package = resolve_package(repo_root=repo_root)
  160. # Default assignee to current developer
  161. assignee = args.assignee
  162. if not assignee:
  163. assignee = get_developer(repo_root)
  164. if not assignee:
  165. print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
  166. return 1
  167. ensure_tasks_dir(repo_root)
  168. # Get current developer as creator
  169. creator = get_developer(repo_root) or assignee
  170. # Generate slug if not provided
  171. slug = args.slug or _slugify(args.title)
  172. if not slug:
  173. print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
  174. return 1
  175. # Create task directory with MM-DD-slug format
  176. tasks_dir = get_tasks_dir(repo_root)
  177. date_prefix = generate_task_date_prefix()
  178. dir_name = f"{date_prefix}-{slug}"
  179. task_dir = tasks_dir / dir_name
  180. task_json_path = task_dir / FILE_TASK_JSON
  181. archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name)
  182. if archived_task_dir:
  183. print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr)
  184. print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
  185. print("Use a new slug if you intend to create a new task.", file=sys.stderr)
  186. return 1
  187. if task_dir.exists():
  188. print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr)
  189. else:
  190. task_dir.mkdir(parents=True)
  191. today = datetime.now().strftime("%Y-%m-%d")
  192. # Record current branch as base_branch (PR target)
  193. _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
  194. current_branch = branch_out.strip() or "main"
  195. task_data = {
  196. "id": slug,
  197. "name": slug,
  198. "title": args.title,
  199. "description": args.description or "",
  200. "status": "planning",
  201. "dev_type": None,
  202. "scope": None,
  203. "package": package,
  204. "priority": args.priority,
  205. "creator": creator,
  206. "assignee": assignee,
  207. "createdAt": today,
  208. "completedAt": None,
  209. "branch": None,
  210. "base_branch": current_branch,
  211. "worktree_path": None,
  212. "commit": None,
  213. "pr_url": None,
  214. "subtasks": [],
  215. "children": [],
  216. "parent": None,
  217. "relatedFiles": [],
  218. "notes": "",
  219. "meta": {},
  220. }
  221. write_json(task_json_path, task_data)
  222. # Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
  223. # Agent curates real entries in Phase 1.3 (see .trellis/workflow.md).
  224. # Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they
  225. # load specs via the trellis-before-dev skill instead of JSONL.
  226. seeded_jsonl = False
  227. if _has_subagent_platform(repo_root):
  228. for jsonl_name in ("implement.jsonl", "check.jsonl"):
  229. jsonl_path = task_dir / jsonl_name
  230. if not jsonl_path.exists():
  231. _write_seed_jsonl(jsonl_path)
  232. seeded_jsonl = True
  233. # Handle --parent: establish bidirectional link
  234. if args.parent:
  235. parent_dir = resolve_task_dir(args.parent, repo_root)
  236. parent_json_path = parent_dir / FILE_TASK_JSON
  237. if not parent_json_path.is_file():
  238. print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
  239. else:
  240. parent_data = read_json(parent_json_path)
  241. if parent_data:
  242. # Add child to parent's children list
  243. parent_children = parent_data.get("children", [])
  244. if dir_name not in parent_children:
  245. parent_children.append(dir_name)
  246. parent_data["children"] = parent_children
  247. write_json(parent_json_path, parent_data)
  248. # Set parent in child's task.json
  249. task_data["parent"] = parent_dir.name
  250. write_json(task_json_path, task_data)
  251. print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
  252. # Auto-activate the new task so the per-turn breadcrumb fires planning
  253. # state. Best-effort: gracefully degrade if no session identity (CLI run
  254. # outside an AI session) — the task is still created, the user can run
  255. # task.py start later. Pointer is session-scoped so this never affects
  256. # other AI sessions.
  257. try:
  258. from .active_task import resolve_context_key, set_active_task
  259. if resolve_context_key():
  260. try:
  261. rel_dir = task_dir.relative_to(repo_root).as_posix()
  262. except ValueError:
  263. rel_dir = str(task_dir)
  264. set_active_task(rel_dir, repo_root)
  265. except Exception:
  266. pass
  267. print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
  268. print("", file=sys.stderr)
  269. print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
  270. print(" 1. Create prd.md with requirements", file=sys.stderr)
  271. if seeded_jsonl:
  272. print(
  273. " 2. Curate implement.jsonl / check.jsonl (spec + research files only — "
  274. "see .trellis/workflow.md Phase 1.3)",
  275. file=sys.stderr,
  276. )
  277. print(" 3. Run: python3 task.py start <dir>", file=sys.stderr)
  278. else:
  279. print(" 2. Run: python3 task.py start <dir>", file=sys.stderr)
  280. print("", file=sys.stderr)
  281. # Output relative path for script chaining
  282. print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
  283. run_task_hooks("after_create", task_json_path, repo_root)
  284. return 0
  285. # =============================================================================
  286. # Command: archive
  287. # =============================================================================
  288. def cmd_archive(args: argparse.Namespace) -> int:
  289. """Archive completed task."""
  290. repo_root = get_repo_root()
  291. task_name = args.name
  292. if not task_name:
  293. print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
  294. return 1
  295. tasks_dir = get_tasks_dir(repo_root)
  296. # Resolve task directory (supports task name, relative path, or absolute path)
  297. task_dir = resolve_task_dir(task_name, repo_root)
  298. if not task_dir or not task_dir.is_dir():
  299. print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
  300. print("Active tasks:", file=sys.stderr)
  301. # Import lazily to avoid circular dependency
  302. from .tasks import iter_active_tasks
  303. for t in iter_active_tasks(tasks_dir):
  304. print(f" - {t.dir_name}/", file=sys.stderr)
  305. return 1
  306. dir_name = task_dir.name
  307. task_json_path = task_dir / FILE_TASK_JSON
  308. # Update status before archiving
  309. today = datetime.now().strftime("%Y-%m-%d")
  310. # Names of child task dirs whose task.json gets modified below; passed
  311. # into safe_archive_paths_to_add so they're staged in this commit.
  312. modified_children: list[str] = []
  313. if task_json_path.is_file():
  314. data = read_json(task_json_path)
  315. if data:
  316. data["status"] = "completed"
  317. data["completedAt"] = today
  318. write_json(task_json_path, data)
  319. # Handle subtask relationships on archive.
  320. # Keep this task in its parent's children list so progress
  321. # counters (children_progress) stay consistent — children
  322. # missing from the active set are treated as completed.
  323. task_children = data.get("children", [])
  324. # If this is a parent, clear parent field in all children
  325. if task_children:
  326. for child_name in task_children:
  327. child_dir_path = find_task_by_name(child_name, tasks_dir)
  328. if child_dir_path:
  329. child_json = child_dir_path / FILE_TASK_JSON
  330. if child_json.is_file():
  331. child_data = read_json(child_json)
  332. if child_data:
  333. child_data["parent"] = None
  334. write_json(child_json, child_data)
  335. modified_children.append(child_dir_path.name)
  336. # Clear any session that still points at this task before the path moves.
  337. from .active_task import clear_task_from_sessions
  338. clear_task_from_sessions(str(task_dir), repo_root)
  339. # Archive
  340. result = archive_task_complete(task_dir, repo_root)
  341. if "archived_to" in result:
  342. archive_dest = Path(result["archived_to"])
  343. year_month = archive_dest.parent.name
  344. print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
  345. # Auto-commit unless --no-commit
  346. if not getattr(args, "no_commit", False):
  347. _auto_commit_archive(dir_name, repo_root, modified_children)
  348. # Return the archive path
  349. print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
  350. # Run hooks with the archived path
  351. archived_json = archive_dest / FILE_TASK_JSON
  352. run_task_hooks("after_archive", archived_json, repo_root)
  353. return 0
  354. return 1
  355. def _auto_commit_archive(
  356. task_name: str,
  357. repo_root: Path,
  358. modified_children: list[str] | None = None,
  359. ) -> None:
  360. """Stage Trellis-owned task paths and commit after archive.
  361. Scoped narrowly to the archived task's source + destination paths
  362. plus any child task dirs whose ``task.json`` was edited (parent →
  363. children relationship update). Dirty changes in OTHER active task
  364. dirs are NOT bundled into the archive commit.
  365. If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
  366. retry with ``git add -f``. The warning explicitly forbids
  367. ``git add -f .trellis/`` (which would fan out to caches/backups)
  368. and points users at ``session_auto_commit: false``.
  369. Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
  370. set to ``false``, this function returns immediately without
  371. touching git (the archive directory move on disk is unaffected).
  372. """
  373. if not get_session_auto_commit(repo_root):
  374. print(
  375. "[OK] session_auto_commit: false — skipping git stage/commit.",
  376. file=sys.stderr,
  377. )
  378. return
  379. paths = safe_archive_paths_to_add(
  380. repo_root, task_name=task_name, modified_children=modified_children
  381. )
  382. if not paths:
  383. print("[OK] No task changes to commit.", file=sys.stderr)
  384. return
  385. success, _, err = safe_git_add(paths, repo_root)
  386. if not success:
  387. if err and "ignored by" in err.lower():
  388. print_gitignore_warning(paths)
  389. else:
  390. print(
  391. f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
  392. file=sys.stderr,
  393. )
  394. return
  395. # Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
  396. # `git add` (no -A) which only stages additions/modifications. The
  397. # source task directory was moved away by `shutil.move`, so its files
  398. # need an explicit `git rm --cached` to stage the deletions in this
  399. # same commit — otherwise they sit as uncommitted "phantom deletes"
  400. # against HEAD until something later picks them up.
  401. #
  402. # `--ignore-unmatch` makes this a no-op when the task was never tracked
  403. # (e.g. archiving a task that lived only in working tree).
  404. source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
  405. run_git(
  406. ["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
  407. cwd=repo_root,
  408. )
  409. rc, _, _ = run_git(
  410. ["diff", "--cached", "--quiet", "--", *paths, source_rel],
  411. cwd=repo_root,
  412. )
  413. if rc == 0:
  414. print("[OK] No task changes to commit.", file=sys.stderr)
  415. return
  416. commit_msg = f"chore(task): archive {task_name}"
  417. rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
  418. if rc == 0:
  419. print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
  420. else:
  421. print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
  422. # =============================================================================
  423. # Command: add-subtask
  424. # =============================================================================
  425. def cmd_add_subtask(args: argparse.Namespace) -> int:
  426. """Link a child task to a parent task."""
  427. repo_root = get_repo_root()
  428. parent_dir = resolve_task_dir(args.parent_dir, repo_root)
  429. child_dir = resolve_task_dir(args.child_dir, repo_root)
  430. parent_json_path = parent_dir / FILE_TASK_JSON
  431. child_json_path = child_dir / FILE_TASK_JSON
  432. if not parent_json_path.is_file():
  433. print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
  434. return 1
  435. if not child_json_path.is_file():
  436. print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
  437. return 1
  438. parent_data = read_json(parent_json_path)
  439. child_data = read_json(child_json_path)
  440. if not parent_data or not child_data:
  441. print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
  442. return 1
  443. # Check if child already has a parent
  444. existing_parent = child_data.get("parent")
  445. if existing_parent:
  446. print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
  447. return 1
  448. # Add child to parent's children list
  449. parent_children = parent_data.get("children", [])
  450. child_dir_name = child_dir.name
  451. if child_dir_name not in parent_children:
  452. parent_children.append(child_dir_name)
  453. parent_data["children"] = parent_children
  454. # Set parent in child's task.json
  455. child_data["parent"] = parent_dir.name
  456. # Write both
  457. write_json(parent_json_path, parent_data)
  458. write_json(child_json_path, child_data)
  459. print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
  460. return 0
  461. # =============================================================================
  462. # Command: remove-subtask
  463. # =============================================================================
  464. def cmd_remove_subtask(args: argparse.Namespace) -> int:
  465. """Unlink a child task from a parent task."""
  466. repo_root = get_repo_root()
  467. parent_dir = resolve_task_dir(args.parent_dir, repo_root)
  468. child_dir = resolve_task_dir(args.child_dir, repo_root)
  469. parent_json_path = parent_dir / FILE_TASK_JSON
  470. child_json_path = child_dir / FILE_TASK_JSON
  471. if not parent_json_path.is_file():
  472. print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
  473. return 1
  474. if not child_json_path.is_file():
  475. print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
  476. return 1
  477. parent_data = read_json(parent_json_path)
  478. child_data = read_json(child_json_path)
  479. if not parent_data or not child_data:
  480. print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
  481. return 1
  482. # Remove child from parent's children list
  483. parent_children = parent_data.get("children", [])
  484. child_dir_name = child_dir.name
  485. if child_dir_name in parent_children:
  486. parent_children.remove(child_dir_name)
  487. parent_data["children"] = parent_children
  488. # Clear parent in child's task.json
  489. child_data["parent"] = None
  490. # Write both
  491. write_json(parent_json_path, parent_data)
  492. write_json(child_json_path, child_data)
  493. print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
  494. return 0
  495. # =============================================================================
  496. # Command: set-branch
  497. # =============================================================================
  498. def cmd_set_branch(args: argparse.Namespace) -> int:
  499. """Set git branch for task."""
  500. repo_root = get_repo_root()
  501. target_dir = resolve_task_dir(args.dir, repo_root)
  502. branch = args.branch
  503. if not branch:
  504. print(colored("Error: Missing arguments", Colors.RED))
  505. print("Usage: python3 task.py set-branch <task-dir> <branch-name>")
  506. return 1
  507. task_json = target_dir / FILE_TASK_JSON
  508. if not task_json.is_file():
  509. print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
  510. return 1
  511. data = read_json(task_json)
  512. if not data:
  513. return 1
  514. data["branch"] = branch
  515. write_json(task_json, data)
  516. print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
  517. return 0
  518. # =============================================================================
  519. # Command: set-base-branch
  520. # =============================================================================
  521. def cmd_set_base_branch(args: argparse.Namespace) -> int:
  522. """Set the base branch (PR target) for task."""
  523. repo_root = get_repo_root()
  524. target_dir = resolve_task_dir(args.dir, repo_root)
  525. base_branch = args.base_branch
  526. if not base_branch:
  527. print(colored("Error: Missing arguments", Colors.RED))
  528. print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>")
  529. print("Example: python3 task.py set-base-branch <dir> develop")
  530. print()
  531. print("This sets the target branch for PR (the branch your feature will merge into).")
  532. return 1
  533. task_json = target_dir / FILE_TASK_JSON
  534. if not task_json.is_file():
  535. print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
  536. return 1
  537. data = read_json(task_json)
  538. if not data:
  539. return 1
  540. data["base_branch"] = base_branch
  541. write_json(task_json, data)
  542. print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
  543. print(f" PR will target: {base_branch}")
  544. return 0
  545. # =============================================================================
  546. # Command: set-scope
  547. # =============================================================================
  548. def cmd_set_scope(args: argparse.Namespace) -> int:
  549. """Set scope for PR title."""
  550. repo_root = get_repo_root()
  551. target_dir = resolve_task_dir(args.dir, repo_root)
  552. scope = args.scope
  553. if not scope:
  554. print(colored("Error: Missing arguments", Colors.RED))
  555. print("Usage: python3 task.py set-scope <task-dir> <scope>")
  556. return 1
  557. task_json = target_dir / FILE_TASK_JSON
  558. if not task_json.is_file():
  559. print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
  560. return 1
  561. data = read_json(task_json)
  562. if not data:
  563. return 1
  564. data["scope"] = scope
  565. write_json(task_json, data)
  566. print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
  567. return 0