Files
base/scripts/add-task
2026-10-08 18:29:45 -07:00

244 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
add-task
Appends a new task to Documentation/Tasks.md and re-synchronizes the task list.
Usage:
add-task "My Title Goes Here" --project=shared --type=chore
add-task "My Title Goes Here" -p editor -t feature -s triage -d "Description text"
"""
import argparse
import importlib.machinery
import importlib.util
import os
import re
import sys
from pathlib import Path
def load_update_tasks_module():
script_dir = Path(__file__).resolve().parent
update_script = script_dir / "update-tasks"
if not update_script.exists():
raise FileNotFoundError(f"Could not find update-tasks at {update_script}")
loader = importlib.machinery.SourceFileLoader("update_tasks", str(update_script))
spec = importlib.util.spec_from_loader("update_tasks", loader)
if spec is None:
raise ImportError(f"Could not load spec for {update_script}")
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
return mod
def add_task(title, project="shared", subproject=None, task_type="feature", status="triage", description=None, tasks_file=None, epic=None):
update_mod = load_update_tasks_module()
tasks_path = update_mod.find_tasks_file(tasks_file)
with open(tasks_path, "r", encoding="utf-8") as f:
text = f.read()
# 1. Parse frontmatter for valid labels and values
fm_match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
if not fm_match:
raise ValueError("YAML frontmatter not found in tasks.md")
fm_data = update_mod.parse_yaml_frontmatter(fm_match.group(1))
prefix = update_mod.get_task_id_prefix(fm_data)
epic_key, epic_label = update_mod.resolve_epic(epic, fm_data)
project_list = fm_data.get("projects", [])
subproject_list = fm_data.get("subprojects", [])
status_list = fm_data.get("task-statuses", [])
type_list = fm_data.get("task-types", [])
project_map = {}
project_value_map = {}
for p in project_list:
val = p.get("value", "").strip()
lbl = p.get("label", "").strip()
if val:
project_map[val.lower()] = lbl or val.capitalize()
project_value_map[val.lower()] = val
if lbl:
project_map[lbl.lower()] = lbl
if val:
project_value_map[lbl.lower()] = val
subproject_map = {}
subproject_value_map = {}
for sp in subproject_list:
val = sp.get("value", "").strip()
lbl = sp.get("label", "").strip()
if val:
subproject_map[val.lower()] = lbl or val.replace("-", " ").title()
subproject_value_map[val.lower()] = val
if lbl:
subproject_map[lbl.lower()] = lbl
if val:
subproject_value_map[lbl.lower()] = val
status_map = {s.get("value", "").lower(): s.get("label", "") for s in status_list}
status_value_map = {s.get("value", "").lower(): s.get("value", "") for s in status_list}
for s in status_list:
lbl = s.get("label", "").strip()
val = s.get("value", "").strip()
if lbl:
status_map[lbl.lower()] = lbl
if val:
status_value_map[lbl.lower()] = val
status_map["fixed"] = "Fixed"
status_map["done"] = "Fixed"
status_value_map["fixed"] = "done"
status_value_map["done"] = "done"
type_map = {t.get("value", "").lower(): t.get("label", "") for t in type_list}
type_value_map = {t.get("value", "").lower(): t.get("value", "") for t in type_list}
for t in type_list:
lbl = t.get("label", "").strip()
val = t.get("value", "").strip()
if lbl:
type_map[lbl.lower()] = lbl
if val:
type_value_map[lbl.lower()] = val
# Normalize values
proj_input = project.strip().lower()
type_input = task_type.strip().lower()
status_input = status.strip().lower()
proj_key = project_value_map.get(proj_input, proj_input)
type_key = type_value_map.get(type_input, type_input)
status_key = status_value_map.get(status_input, status_input)
proj_label = project_map.get(proj_key, project_map.get(proj_input, project.strip().capitalize()))
type_label = type_map.get(type_key, type_map.get(type_input, task_type.strip().capitalize()))
status_label = status_map.get(status_key, status_map.get(status_input, status.strip().capitalize()))
subproj_key = None
subproj_label = None
if subproject and subproject.strip() and subproject.strip().lower() not in ("", "none", "null"):
sp_in = subproject.strip().lower()
subproj_key = subproject_value_map.get(sp_in, sp_in)
subproj_label = subproject_map.get(subproj_key, subproject_map.get(sp_in, subproject.strip().replace("-", " ").replace("_", " ").title()))
# 2. Find max task ID
detail_start = text.find("## Detailed Tasks\n")
if detail_start == -1:
raise ValueError("## Detailed Tasks heading not found")
ids = [int(m.group(2)) for m in re.finditer(update_mod.TASK_ID_PATTERN, text[detail_start:], re.IGNORECASE)]
next_num = (max(ids) + 1) if ids else 1
# Digit width calculation (minimum 3 digits)
digit_width = max(3, len(str(next_num)))
formatted_id = update_mod.format_task_id(next_num, prefix, digit_width)
formatted_aid = formatted_id.lower()
# 3. Format description
if description and description.strip():
desc_lines = description.strip().splitlines()
desc_body = "\n".join(desc_lines)
if not any(line.strip().startswith("- [") for line in desc_lines):
desc_body += "\n- [ ] "
else:
desc_body = "Describe task objectives and implementation requirements here.\n- [ ] "
if subproj_key:
anchor_attrs = f'id="{formatted_aid}" class="task" data-project="{proj_key}" data-subproject="{subproj_key}" data-status="{status_key}" data-task-type="{type_key}"'
subproj_line = f"**Subproject:** {subproj_label}\n"
else:
anchor_attrs = f'id="{formatted_aid}" class="task" data-project="{proj_key}" data-status="{status_key}" data-task-type="{type_key}"'
subproj_line = ""
if epic_key:
anchor_attrs += f' data-epic="{epic_key}"'
epic_line = f"**Epic:** {epic_label}\n" if epic_key else ""
new_task_chunk = (
f'<a {anchor_attrs}></a>\n'
f"### {title.strip()}\n"
f"**ID:** {formatted_id}\n"
f"**Project:** {proj_label}\n"
f"{subproj_line}"
f"{epic_line}"
f"**Status:** {status_label}\n"
f"**Type:** {type_label}\n\n"
f"**Description:**\n"
f"{desc_body}\n"
)
# 4. Insert before Notes marker
notes_marker = "\n---\n\n## Notes\n"
notes_pos = text.find(notes_marker)
if notes_pos == -1:
raise ValueError("Could not find ## Notes marker to insert task before")
updated_text = text[:notes_pos].rstrip() + "\n\n" + new_task_chunk + "\n---\n\n## Notes\n" + text[notes_pos + len(notes_marker):]
with open(tasks_path, "w", encoding="utf-8") as f:
f.write(updated_text)
# 5. Re-run update_tasks_document to sort, standardize, and refresh tables
update_mod.update_tasks_document(tasks_path)
return formatted_id, title.strip(), proj_label, subproj_label, status_label, type_label, tasks_path
def main():
parser = argparse.ArgumentParser(description="Add a new task to tasks.md and update tables.")
parser.add_argument("title", help="Title of the new task")
parser.add_argument("--epic", default=None, help="Epic value or label from frontmatter (optional)")
parser.add_argument(
"-p", "--project",
default="shared",
help="Project name or value (default: shared)"
)
parser.add_argument(
"-S", "--subproject",
default=None,
help="Subproject name or category (optional, e.g. actions, expressions, schema)"
)
parser.add_argument(
"-t", "--type",
default="feature",
help="Task type (default: feature, e.g. chore, bug, feature, lint, docs)"
)
parser.add_argument(
"-s", "--status",
default="triage",
help="Initial task status (default: triage, e.g. planning, triage, pending, fixed)"
)
parser.add_argument(
"-d", "--description",
default=None,
help="Task description and requirements"
)
parser.add_argument(
"-f", "--file",
default=None,
help="Path to tasks.md (optional)"
)
args = parser.parse_args()
try:
new_id, title, proj, subproj, status, ttype, tasks_path = add_task(
title=args.title,
project=args.project,
subproject=args.subproject,
task_type=args.type,
status=args.status,
description=args.description,
tasks_file=args.file,
epic=args.epic,
)
tag_info = f"{proj} | {subproj} | {status} | {ttype}" if subproj else f"{proj} | {status} | {ttype}"
print(f"Successfully added {new_id}: \"{title}\" [{tag_info}] to {tasks_path}")
except Exception as e:
print(f"Error adding task: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()