Files

914 lines
34 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
任务 CRUD 操作。
提供任务目录创建、归档、分支/范围/metadata 设置以及父子任务关联。
机器字段和命令名称保持原文;CLI 提示默认使用简体中文。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from .config import (
get_codex_dispatch_mode,
get_packages,
get_session_auto_commit,
is_monorepo,
resolve_package,
validate_package,
)
from .git import branch_exists_locally, resolve_default_branch, run_git
from .io import read_json, write_json
from .log import Colors, colored
from .paths import (
DIR_ARCHIVE,
DIR_TASKS,
DIR_WORKFLOW,
FILE_TASK_JSON,
generate_task_date_prefix,
get_developer,
get_repo_root,
get_tasks_dir,
)
from .safe_commit import (
print_gitignore_warning,
safe_archive_paths_to_add,
safe_git_add,
)
from .task_utils import (
archive_task_complete,
find_task_by_name,
is_within_tasks_dir,
resolve_task_dir,
run_task_hooks,
)
# =============================================================================
# 辅助函数
# =============================================================================
def _slugify(title: str) -> str:
"""将标题转换为 slug(仅支持 ASCII)。"""
result = title.lower()
result = re.sub(r"[^a-z0-9]", "-", result)
result = re.sub(r"-+", "-", result)
result = result.strip("-")
return result
def ensure_tasks_dir(repo_root: Path) -> Path:
"""确保 tasks 目录存在。"""
tasks_dir = get_tasks_dir(repo_root)
archive_dir = tasks_dir / "archive"
if not tasks_dir.exists():
tasks_dir.mkdir(parents=True)
print(colored(f"已创建任务目录:{tasks_dir}", Colors.GREEN), file=sys.stderr)
if not archive_dir.exists():
archive_dir.mkdir(parents=True)
return tasks_dir
def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
"""按活动任务目录的精确名称查找归档任务目录。"""
archive_dir = tasks_dir / DIR_ARCHIVE
if not archive_dir.is_dir():
return None
for month_dir in sorted(archive_dir.iterdir()):
if not month_dir.is_dir():
continue
candidate = month_dir / dir_name
if candidate.is_dir():
return candidate
return None
def _repo_relative_path(path: Path, repo_root: Path) -> str:
"""在可能时将路径格式化为相对于仓库根目录的路径。"""
try:
return path.relative_to(repo_root).as_posix()
except ValueError:
return str(path)
# =============================================================================
# Sub-agent 平台检测与 JSONL seed
# =============================================================================
# 消费 implement.jsonl / check.jsonl 的平台配置目录。必须与 src/types/ai-tools.ts 的
# AI_TOOLS 条目保持同步;这些平台列在 workflow.md 的“支持 agent” Skill Routing 区块中。
# Codex 单独检查,因为显式 inline 模式不消费 JSONL。Kilo / Antigravity / Devin 也不在
# 此列表中:它们通过 skills 加载 spec,而不是 JSONL。
_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
".claude",
".cursor",
".kiro",
".gemini",
".opencode",
".qoder",
".codebuddy",
".factory", # Factory Droid
".github/copilot",
".pi", # Pi Agent
".trae", # Trae IDE
".omp", # Oh My Pi
".zcode", # ZCode
".grok", # Grok Build
".kimi-code", # Kimi Code
)
_CODEX_CONFIG_DIR = ".codex"
_SEED_EXAMPLE = (
"请填写 {\"file\": \"<path>\", \"reason\": \"<原因>\"}。"
"这里只引用 spec/research 文件,不要填写产品代码路径。"
"可运行 `python3 ./.trellis/scripts/get_context.py --mode packages` 查看可用规范。"
"加入真实条目后删除本行。"
)
def _has_subagent_platform(repo_root: Path) -> bool:
"""配置了任一支持 sub-agent 的平台时返回 True。
通过探测仓库根目录的已知配置目录判断。Codex 默认通过
``codex.dispatch_mode: auto`` 计入(包括旧的 ``sub-agent`` 别名);显式 inline 模式
通过 skills 而不是 JSONL 加载上下文。
"""
for config_dir in _SUBAGENT_CONFIG_DIRS:
if (repo_root / config_dir).is_dir():
return True
if (repo_root / _CODEX_CONFIG_DIR).is_dir():
return get_codex_dispatch_mode(repo_root) == "auto"
return False
def _write_seed_jsonl(path: Path) -> None:
"""写入只有一行、带自描述 ``_example`` 的 seed JSONL 文件。
seed 行没有 ``file`` 字段,因此下游消费者(hooks + preludes)通过
``item.get("file")`` 遍历时会自然跳过。该行仅作为文件内提示,提醒 AI 整理真实条目。
"""
seed = {"_example": _SEED_EXAMPLE}
path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None:
"""将可重复的 ``--meta key=value`` 参数解析为 dict。
遇到第一处 malformed pair(缺少 ``=`` 或 key 为空)时先打印包含错误值的提示,再返回
``None``。值按原样保存(字符串、不嵌套、不做类型转换)。
"""
meta: dict[str, str] = {}
for pair in pairs or []:
key, sep, value = pair.partition("=")
if not sep or not key:
print(
colored(f"错误:--meta 值 '{pair}' 格式不正确(应为 key=value)。", Colors.RED),
file=sys.stderr,
)
return None
meta[key] = value
return meta
def _default_prd_content(title: str, description: str | None = None) -> str:
"""返回每个任务创建时使用的默认 PRD 骨架。"""
goal = (description or "").strip() or "待补充。"
heading = title.strip() or "未命名任务"
return f"""# {heading}
## 目标
{goal}
## 需求
- 待补充
## 验收标准
- [ ] 待补充
## 备注
- `prd.md` 只记录需求、约束和验收标准。
- 轻量任务可以只保留 `prd.md`。
- 复杂任务在 `task.py start` 前补充 `design.md`(技术设计)和 `implement.md`(执行计划)。
"""
# =============================================================================
# 命令:create
# =============================================================================
def cmd_create(args: argparse.Namespace) -> int:
"""创建新任务。"""
repo_root = get_repo_root()
if not args.title:
print(colored("错误:必须提供 title。", Colors.RED), file=sys.stderr)
return 1
# 校验 --meta(CLI 来源:fail-fast,且在创建目录前执行)。
meta = _parse_meta_pairs(getattr(args, "meta", None))
if meta is None:
return 1
# 校验 --package(CLI 来源:fail-fast)。
package: str | None = getattr(args, "package", None)
if not is_monorepo(repo_root):
# Single-repo:忽略 --package,不添加 package 前缀。
if package:
print(colored("警告:单仓库项目忽略 --package。", Colors.YELLOW), file=sys.stderr)
package = None
elif package:
if not validate_package(package, repo_root):
packages = get_packages(repo_root)
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
print(colored(f"错误:未知 package '{package}'。可用值:{available}", Colors.RED), file=sys.stderr)
return 1
else:
# 推断顺序:default_package → None(create 阶段还没有 task.json)。
package = resolve_package(repo_root=repo_root)
# 默认 assignee 为当前开发者。
assignee = args.assignee
if not assignee:
assignee = get_developer(repo_root)
if not assignee:
print(colored("错误:尚未设置开发者身份。请先运行 init_developer.py,或使用 --assignee。", Colors.RED), file=sys.stderr)
return 1
ensure_tasks_dir(repo_root)
# 将当前开发者作为 creator。
creator = get_developer(repo_root) or assignee
# 未提供时生成 slug。
slug = args.slug or _slugify(args.title)
if not slug:
print(colored("错误:无法根据 title 生成 slug。", Colors.RED), file=sys.stderr)
return 1
# 按 MM-DD-slug 格式创建任务目录。
tasks_dir = get_tasks_dir(repo_root)
date_prefix = generate_task_date_prefix()
# 防止 --slug 已带日期前缀(例如直接粘贴完整任务目录名),否则会产生
# MM-DD-MM-DD-slug(issue #377)。只对显式 --slug 做此保护;标题生成的 slug 保持不变。
if args.slug:
m = re.match(r"^(\d{2})-(\d{2})-(.+)$", slug)
if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31:
slug_prefix = f"{m.group(1)}-{m.group(2)}"
if slug_prefix == date_prefix:
slug = m.group(3)
print(
colored(
f'警告:--slug 不应包含 MM-DD 前缀,已规范化为 "{slug}"。',
Colors.YELLOW,
),
file=sys.stderr,
)
else:
print(
colored(
f"错误:--slug 以日期前缀 ({slug_prefix}-) 开头,但 task.py create 始终使用当天日期 ({date_prefix})。",
Colors.RED,
),
file=sys.stderr,
)
print(f"请只传入 slug 正文,例如 --slug {m.group(3)}。", file=sys.stderr)
return 1
dir_name = f"{date_prefix}-{slug}"
task_dir = tasks_dir / dir_name
task_json_path = task_dir / FILE_TASK_JSON
archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name)
if archived_task_dir:
print(colored(f"错误:任务已归档:{dir_name}。", Colors.RED), file=sys.stderr)
print(f"归档位置:{_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
print("如果要创建新任务,请使用新的 slug。", file=sys.stderr)
return 1
if task_dir.exists():
print(colored(f"警告:任务目录已存在:{dir_name}。", Colors.YELLOW), file=sys.stderr)
else:
task_dir.mkdir(parents=True)
today = datetime.now().strftime("%Y-%m-%d")
# 记录 PR 目标 branch。优先使用仓库实际默认 branch(origin/HEAD),避免从 feature
# branch 创建任务时误把 feature branch 记录为 PR 目标(#399 第 1 项)。默认 branch
# 无法解析时(未配置 remote、离线等)回退到当前检出的 branch,保持既有行为。
# 两者都不合适时可用 --base-branch 覆盖。
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
current_branch = branch_out.strip() or "main"
explicit_base_branch: str | None = getattr(args, "base_branch", None)
if explicit_base_branch:
base_branch = explicit_base_branch
else:
resolved_base_branch = resolve_default_branch(repo_root)
if resolved_base_branch:
base_branch = resolved_base_branch
else:
base_branch = current_branch
print(
colored(
f"警告:无法解析仓库默认 branch(未配置 remote、离线等);"
f"base_branch 将记录为当前检出分支 '{base_branch}'。可用 --base-branch 覆盖。",
Colors.YELLOW,
),
file=sys.stderr,
)
description = (args.description or "").strip()
if not description.strip():
print(
colored(
"警告:task description 为空;请使用 --description,便于检索和后续审计。",
Colors.YELLOW,
),
file=sys.stderr,
)
task_data = {
"id": slug,
"name": slug,
"title": args.title,
"description": description,
"status": "planning",
"dev_type": None,
"scope": None,
"package": package,
"priority": args.priority,
"creator": creator,
"assignee": assignee,
"createdAt": today,
"completedAt": None,
"branch": None,
"base_branch": base_branch,
"worktree_path": None,
"commit": None,
"pr_url": None,
"subtasks": [],
"children": [],
"parent": None,
"relatedFiles": [],
"notes": "",
"meta": meta,
}
write_json(task_json_path, task_data)
prd_path = task_dir / "prd.md"
if not prd_path.exists():
prd_path.write_text(
_default_prd_content(args.title, description),
encoding="utf-8",
)
# 为支持 sub-agent 的平台生成 implement.jsonl / check.jsonl seed。
# 任务需要上下文时,AI 会在规划阶段整理真实条目。
# 无 agent 的平台(Kilo / Antigravity / Devin)跳过此步骤,通过 trellis-before-dev
# skill 而不是 JSONL 加载 spec。
seeded_jsonl = False
if _has_subagent_platform(repo_root):
for jsonl_name in ("implement.jsonl", "check.jsonl"):
jsonl_path = task_dir / jsonl_name
if not jsonl_path.exists():
_write_seed_jsonl(jsonl_path)
seeded_jsonl = True
# 处理 --parent:建立双向关联。
if args.parent:
parent_dir = resolve_task_dir(args.parent, repo_root)
parent_json_path = parent_dir / FILE_TASK_JSON
if not parent_json_path.is_file():
print(colored(f"警告:找不到父任务 task.json:{args.parent}。", Colors.YELLOW), file=sys.stderr)
else:
parent_data = read_json(parent_json_path)
if parent_data:
# 将 child 添加到 parent 的 children 列表。
parent_children = parent_data.get("children", [])
if dir_name not in parent_children:
parent_children.append(dir_name)
parent_data["children"] = parent_children
write_json(parent_json_path, parent_data)
# 在 child 的 task.json 设置 parent。
task_data["parent"] = parent_dir.name
write_json(task_json_path, task_data)
print(colored(f"已关联为子任务:{parent_dir.name}。", Colors.GREEN), file=sys.stderr)
# 自动激活新任务,使每轮 breadcrumb 进入 planning 状态。尽力而为:没有 session
# identity(在 AI session 外运行 CLI)时平滑降级——任务仍会创建,用户之后可运行
# task.py start。指针按 session 隔离,因此不会影响其他 AI session。
if getattr(args, "no_start", False):
print(
colored(
"已跳过 session 激活(--no-start);准备好后运行 task.py start。",
Colors.YELLOW,
),
file=sys.stderr,
)
else:
try:
from .active_task import resolve_context_key, set_active_task
except Exception as exc:
print(
colored(f"警告:无法激活 session(导入失败:{exc})。", Colors.YELLOW),
file=sys.stderr,
)
else:
try:
context_key = resolve_context_key()
except Exception as exc:
print(
colored(f"警告:session 激活失败(context 解析:{exc})。", Colors.YELLOW),
file=sys.stderr,
)
else:
# 没有 session identity 是在 AI session 外运行 CLI 的正常情况(见上方说明),
# 保持静默,不视为失败。
if context_key:
try:
rel_dir = task_dir.relative_to(repo_root).as_posix()
except ValueError:
rel_dir = str(task_dir)
try:
active = set_active_task(rel_dir, repo_root)
except Exception as exc:
print(
colored(f"警告:session 激活失败(pointer 持久化:{exc})。", Colors.YELLOW),
file=sys.stderr,
)
else:
if active:
print(
colored(f"已为当前 session 激活任务:{active.task_path}", Colors.GREEN),
file=sys.stderr,
)
print(f"来源:{active.source}", file=sys.stderr)
else:
print(
colored("警告:session 激活失败(没有返回 pointer)。", Colors.YELLOW),
file=sys.stderr,
)
print(colored(f"已创建任务:{dir_name}", Colors.GREEN), file=sys.stderr)
print("", file=sys.stderr)
print(colored("后续步骤:", Colors.BLUE), file=sys.stderr)
print(" - 在 prd.md 中填写需求和验收标准。", file=sys.stderr)
print(" - 轻量任务:只保留 PRD 即可。", file=sys.stderr)
print(" - 复杂任务:在 task.py start 前补充 design.md 和 implement.md。", file=sys.stderr)
if seeded_jsonl:
print(
" - 需要 sub-agent 上下文时,整理 implement.jsonl / check.jsonl,仅引用 spec/research。",
file=sys.stderr,
)
print(" - 使用 /trellis:continue 或 phase context 判断下一步。", file=sys.stderr)
print("", file=sys.stderr)
# 输出相对路径,供脚本串联。
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
run_task_hooks("after_create", task_json_path, repo_root)
return 0
# =============================================================================
# 命令:archive
# =============================================================================
def cmd_archive(args: argparse.Namespace) -> int:
"""归档已完成任务。"""
repo_root = get_repo_root()
task_name = args.name
if not task_name:
print(colored("错误:必须提供任务名称。", Colors.RED), file=sys.stderr)
return 1
tasks_dir = get_tasks_dir(repo_root)
# 解析任务目录(支持任务名称、相对路径或绝对路径)。
task_dir = resolve_task_dir(task_name, repo_root)
if not task_dir or not task_dir.is_dir():
print(colored(f"错误:找不到任务:{task_name}。", Colors.RED), file=sys.stderr)
print("当前活动任务:", file=sys.stderr)
# 延迟导入,避免循环依赖。
from .tasks import iter_active_tasks
for t in iter_active_tasks(tasks_dir):
print(f" - {t.dir_name}/", file=sys.stderr)
return 1
# 拒绝归档不在 .trellis/tasks/ 直接层级下的目录。误输入名称(例如 "src")可能解析
# 为 repo_root/src;它虽是目录却不是任务。没有此保护,archive 会把用户源码目录移出仓库。
if not is_within_tasks_dir(task_dir, repo_root):
print(colored(
f"错误:拒绝归档 '{task_name}':{task_dir} 不在任务目录 {tasks_dir} 下。",
Colors.RED), file=sys.stderr)
return 1
dir_name = task_dir.name
task_json_path = task_dir / FILE_TASK_JSON
# 归档前先更新 status。
today = datetime.now().strftime("%Y-%m-%d")
# 下面会修改其 task.json 的子任务目录名称;传给 safe_archive_paths_to_add,确保本次
# commit 一并 stage。
modified_children: list[str] = []
if task_json_path.is_file():
data = read_json(task_json_path)
if data:
# 记录的 branch 过期时只警告、不阻塞;它很可能已经合并并删除
# (#399 第 2 项)。
stored_branch = data.get("branch")
if stored_branch and not branch_exists_locally(stored_branch, repo_root):
print(
colored(
f"警告:记录的 branch '{stored_branch}' 已不存在于本地(可能已合并并删除)。",
Colors.YELLOW,
),
file=sys.stderr,
)
data["status"] = "completed"
data["completedAt"] = today
write_json(task_json_path, data)
# 处理归档时的子任务关系。保留该任务在 parent 的 children 列表中,使进度计数
# (children_progress)保持一致;不在活动集合中的 child 视为已完成。
task_children = data.get("children", [])
# 如果这是 parent,清除所有 child 的 parent 字段。
if task_children:
for child_name in task_children:
child_dir_path = find_task_by_name(child_name, tasks_dir)
if child_dir_path:
child_json = child_dir_path / FILE_TASK_JSON
if child_json.is_file():
child_data = read_json(child_json)
if child_data:
child_data["parent"] = None
write_json(child_json, child_data)
modified_children.append(child_dir_path.name)
# 移动路径前清除仍指向此任务的 session。
from .active_task import clear_task_from_sessions
clear_task_from_sessions(str(task_dir), repo_root)
# 执行归档。
result = archive_task_complete(task_dir, repo_root)
if "archived_to" in result:
archive_dest = Path(result["archived_to"])
year_month = archive_dest.parent.name
print(colored(f"已归档:{dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
# 除非指定 --no-commit,否则自动提交。
if not getattr(args, "no_commit", False):
if not _auto_commit_archive(dir_name, repo_root, modified_children):
print(
colored(
"归档目录已在磁盘移动,但 Git 自动提交未完成。"
"请先处理 `git status` 再继续。",
Colors.RED,
),
file=sys.stderr,
)
return 1
# 返回归档路径。
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
# 使用归档后的路径运行 hooks。
archived_json = archive_dest / FILE_TASK_JSON
run_task_hooks("after_archive", archived_json, repo_root)
return 0
return 1
def _auto_commit_archive(
task_name: str,
repo_root: Path,
modified_children: list[str] | None = None,
) -> bool:
"""stage Trellis 所有的任务路径,并在归档后提交。
范围严格限制为已归档任务的源路径和目标路径,以及 task.json 被编辑的 child 任务目录
(parent → children 关系更新)。其他活动任务目录中的脏变更不会打包进归档 commit。
如果 ``.gitignore`` 阻止这些路径,则警告并跳过,绝不使用 ``git add -f`` 重试。警告会
明确禁止 ``git add -f .trellis/``(它会扩散到缓存/备份),并提示用户设置
``session_auto_commit: false``。
遵循 ``.trellis/config.yaml`` 中的 ``session_auto_commit``:设置为 ``false`` 时立即
返回,不触碰 git(磁盘上的 archive 目录移动不受影响)。
"""
if not get_session_auto_commit(repo_root):
print(
"[OK] session_auto_commit: false — 跳过 Git stage/commit。",
file=sys.stderr,
)
return True
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
rc, tracked_out, _ = run_git(
["ls-files", "--", source_rel],
cwd=repo_root,
)
source_was_tracked = rc == 0 and bool(tracked_out.strip())
paths = safe_archive_paths_to_add(
repo_root, task_name=task_name, modified_children=modified_children
)
if not paths:
print("[OK] 没有需要提交的任务变更。", file=sys.stderr)
return True
success, _, err = safe_git_add(paths, repo_root)
if not success:
if err and "ignored by" in err.lower():
print_gitignore_warning(paths)
else:
print(
f"[WARN] git add 失败:{err.strip() if err else '未知错误'}",
file=sys.stderr,
)
return not source_was_tracked
# 为 phantom-delete bug 加双保险:`safe_git_add` 使用 `git add`(不带 -A),只会 stage
# 新增/修改。源任务目录已由 `shutil.move` 移走,因此必须显式执行 `git rm --cached`
# 来在同一 commit 中 stage 删除;否则它们会一直以相对 HEAD 的未提交“phantom delete”
# 留在工作树,直到之后的操作碰巧拾取。
#
# 任务从未被跟踪时(例如只存在于工作树的任务),`--ignore-unmatch` 会使此操作无害。
run_git(
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
cwd=repo_root,
)
rc, _, _ = run_git(
["diff", "--cached", "--quiet", "--", *paths, source_rel],
cwd=repo_root,
)
if rc == 0:
print("[OK] 没有需要提交的任务变更。", file=sys.stderr)
return True
commit_msg = f"chore(task): archive {task_name}"
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
if rc == 0:
print(f"[OK] 已自动提交:{commit_msg}", file=sys.stderr)
return True
else:
print(f"[WARN] 自动提交失败:{err.strip()}", file=sys.stderr)
return not source_was_tracked
# =============================================================================
# 命令:add-subtask
# =============================================================================
def cmd_add_subtask(args: argparse.Namespace) -> int:
"""将 child 任务关联到 parent 任务。"""
repo_root = get_repo_root()
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
child_dir = resolve_task_dir(args.child_dir, repo_root)
parent_json_path = parent_dir / FILE_TASK_JSON
child_json_path = child_dir / FILE_TASK_JSON
if not parent_json_path.is_file():
print(colored(f"错误:找不到父任务 task.json:{args.parent_dir}。", Colors.RED), file=sys.stderr)
return 1
if not child_json_path.is_file():
print(colored(f"错误:找不到子任务 task.json:{args.child_dir}。", Colors.RED), file=sys.stderr)
return 1
parent_data = read_json(parent_json_path)
child_data = read_json(child_json_path)
if not parent_data or not child_data:
print(colored("错误:读取 task.json 失败。", Colors.RED), file=sys.stderr)
return 1
# 检查 child 是否已有 parent。
existing_parent = child_data.get("parent")
if existing_parent:
print(colored(f"错误:子任务已有父任务:{existing_parent}。", Colors.RED), file=sys.stderr)
return 1
# 将 child 添加到 parent 的 children 列表。
parent_children = parent_data.get("children", [])
child_dir_name = child_dir.name
if child_dir_name not in parent_children:
parent_children.append(child_dir_name)
parent_data["children"] = parent_children
# 在 child 的 task.json 设置 parent。
child_data["parent"] = parent_dir.name
# 写回两个 task.json。
write_json(parent_json_path, parent_data)
write_json(child_json_path, child_data)
print(colored(f"已关联:{child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
return 0
# =============================================================================
# 命令:remove-subtask
# =============================================================================
def cmd_remove_subtask(args: argparse.Namespace) -> int:
"""解除 child 任务与 parent 任务的关联。"""
repo_root = get_repo_root()
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
child_dir = resolve_task_dir(args.child_dir, repo_root)
parent_json_path = parent_dir / FILE_TASK_JSON
child_json_path = child_dir / FILE_TASK_JSON
if not parent_json_path.is_file():
print(colored(f"错误:找不到父任务 task.json:{args.parent_dir}。", Colors.RED), file=sys.stderr)
return 1
if not child_json_path.is_file():
print(colored(f"错误:找不到子任务 task.json:{args.child_dir}。", Colors.RED), file=sys.stderr)
return 1
parent_data = read_json(parent_json_path)
child_data = read_json(child_json_path)
if not parent_data or not child_data:
print(colored("错误:读取 task.json 失败。", Colors.RED), file=sys.stderr)
return 1
# 从 parent 的 children 列表移除 child。
parent_children = parent_data.get("children", [])
child_dir_name = child_dir.name
if child_dir_name in parent_children:
parent_children.remove(child_dir_name)
parent_data["children"] = parent_children
# 清除 child 的 task.json 中的 parent。
child_data["parent"] = None
# 写回两个 task.json。
write_json(parent_json_path, parent_data)
write_json(child_json_path, child_data)
print(colored(f"已解除关联:{child_dir.name} <- {parent_dir.name}", Colors.GREEN), file=sys.stderr)
return 0
# =============================================================================
# 命令:set-branch
# =============================================================================
def cmd_set_branch(args: argparse.Namespace) -> int:
"""为任务设置 git branch。"""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
branch = args.branch
if not branch:
print(colored("错误:缺少参数。", Colors.RED))
print("用法:python3 task.py set-branch <task-dir> <branch-name>")
return 1
task_json = target_dir / FILE_TASK_JSON
if not task_json.is_file():
print(colored(f"错误:{target_dir} 下找不到 task.json。", Colors.RED))
return 1
data = read_json(task_json)
if not data:
return 1
data["branch"] = branch
write_json(task_json, data)
print(colored(f"✓ branch 已设置为:{branch}", Colors.GREEN))
return 0
# =============================================================================
# 命令:set-base-branch
# =============================================================================
def cmd_set_base_branch(args: argparse.Namespace) -> int:
"""为任务设置 base branch(PR 目标)。"""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
base_branch = args.base_branch
if not base_branch:
print(colored("错误:缺少参数。", Colors.RED))
print("用法:python3 task.py set-base-branch <task-dir> <base-branch>")
print("示例:python3 task.py set-base-branch <dir> develop")
print()
print("此命令设置 PR 的目标 branch(功能将合并到的分支)。")
return 1
task_json = target_dir / FILE_TASK_JSON
if not task_json.is_file():
print(colored(f"错误:{target_dir} 下找不到 task.json。", Colors.RED))
return 1
data = read_json(task_json)
if not data:
return 1
data["base_branch"] = base_branch
write_json(task_json, data)
print(colored(f"✓ base branch 已设置为:{base_branch}", Colors.GREEN))
print(f" PR 目标:{base_branch}")
return 0
# =============================================================================
# 命令:set-scope
# =============================================================================
def cmd_set_scope(args: argparse.Namespace) -> int:
"""设置 PR 标题使用的 scope。"""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
scope = args.scope
if not scope:
print(colored("错误:缺少参数。", Colors.RED))
print("用法:python3 task.py set-scope <task-dir> <scope>")
return 1
task_json = target_dir / FILE_TASK_JSON
if not task_json.is_file():
print(colored(f"错误:{target_dir} 下找不到 task.json。", Colors.RED))
return 1
data = read_json(task_json)
if not data:
return 1
data["scope"] = scope
write_json(task_json, data)
print(colored(f"✓ scope 已设置为:{scope}", Colors.GREEN))
return 0
# =============================================================================
# 命令:set-meta
# =============================================================================
def cmd_set_meta(args: argparse.Namespace) -> int:
"""在已有任务上设置或覆盖一个 metadata key。"""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
key = args.key
value = args.value
if not key:
print(colored("错误:缺少参数。", Colors.RED))
print("用法:python3 task.py set-meta <task-dir> <key> <value>")
return 1
task_json = target_dir / FILE_TASK_JSON
if not task_json.is_file():
print(colored(f"错误:{target_dir} 下找不到 task.json。", Colors.RED))
return 1
data = read_json(task_json)
if not data:
return 1
meta = data.get("meta")
if not isinstance(meta, dict):
meta = {}
meta[key] = value
data["meta"] = meta
write_json(task_json, data)
print(colored(f"✓ metadata 已设置:{key} = {value}", Colors.GREEN))
return 0