575 lines
23 KiB
Python
Executable File
575 lines
23 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
update-tasks
|
|
|
|
Synchronizes and regenerates docs/tasks.md:
|
|
1. Parses YAML frontmatter for projects, epics, task-statuses, task-types, and task-id-prefix.
|
|
2. Extracts all task chunks and sorts the Detailed Tasks section strictly by numeric task ID.
|
|
3. Updates the Detailed Tasks section:
|
|
- Normalizes all task IDs to uniform digit width (minimum 3 digits, e.g. EX-001).
|
|
- Synchronizes task detail blocks (including optional **Epic:**) with anchor tags (SSOT).
|
|
4. Re-renders the Overall Progress Table (tasks-list) from the sorted, updated task details:
|
|
- Bold (**Title**) for pending tasks
|
|
- Italic (*Title*) for planning and triage tasks
|
|
- Plain (Title) for fixed/done/other tasks
|
|
- Type column links ([Type](#ex-xxx))
|
|
5. Re-renders the metadata derived enum tables at the end of the document:
|
|
- Projects (Rendered from frontmatter projects)
|
|
- Task Statuses (Rendered from frontmatter task-statuses)
|
|
- Task Types (Rendered from frontmatter task-types)
|
|
6. Assembles and writes the updated document.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
def find_tasks_file(explicit_path=None):
|
|
explicit_path = explicit_path or os.environ.get("TASKS_FILE")
|
|
if explicit_path:
|
|
p = Path(explicit_path)
|
|
if p.is_file():
|
|
return p
|
|
raise FileNotFoundError(f"Specified file not found: {explicit_path}")
|
|
|
|
# Search upwards or relative to script / cwd
|
|
script_dir = Path(__file__).resolve().parent
|
|
candidates = [
|
|
Path.cwd() / "docs" / "tasks.md",
|
|
Path.cwd() / "docs" / "Tasks.md",
|
|
Path.cwd() / "Documentation" / "tasks.md",
|
|
Path.cwd() / "Documentation" / "Tasks.md",
|
|
Path.cwd() / "tasks.md",
|
|
Path.cwd() / "Tasks.md",
|
|
script_dir.parent / "docs" / "tasks.md",
|
|
script_dir.parent / "docs" / "Tasks.md",
|
|
script_dir.parent / "Documentation" / "tasks.md",
|
|
script_dir.parent / "Documentation" / "Tasks.md",
|
|
script_dir / "docs" / "tasks.md",
|
|
script_dir / "docs" / "Tasks.md",
|
|
script_dir / "Documentation" / "tasks.md",
|
|
script_dir / "Documentation" / "Tasks.md",
|
|
]
|
|
for c in candidates:
|
|
if c.is_file():
|
|
return c
|
|
|
|
raise FileNotFoundError("Could not locate docs/tasks.md or docs/Tasks.md")
|
|
|
|
|
|
def parse_yaml_frontmatter(fm_text):
|
|
sections = {}
|
|
current_section = None
|
|
current_item = None
|
|
|
|
for line in fm_text.splitlines():
|
|
if line and not line.startswith(" ") and ":" in line and not line.endswith(":"):
|
|
key, value = line.split(":", 1)
|
|
sections[key.strip()] = value.strip().strip("\"'")
|
|
current_section = None
|
|
current_item = None
|
|
elif line and not line.startswith(" ") and line.endswith(":"):
|
|
current_section = line[:-1].strip()
|
|
sections[current_section] = []
|
|
current_item = None
|
|
elif current_section is not None:
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
if line.startswith(" - "):
|
|
current_item = {}
|
|
sections[current_section].append(current_item)
|
|
kv = line[4:].strip()
|
|
if ":" in kv:
|
|
k, v = kv.split(":", 1)
|
|
current_item[k.strip()] = v.strip().strip("\"'")
|
|
elif line.startswith(" ") and current_item is not None:
|
|
kv = line.strip()
|
|
if ":" in kv:
|
|
k, v = kv.split(":", 1)
|
|
current_item[k.strip()] = v.strip().strip("\"'")
|
|
|
|
return sections
|
|
|
|
|
|
TASK_ID_PATTERN = r'\bid=["\']([a-z][a-z0-9_-]*-(\d+))["\']'
|
|
TASK_ANCHOR_PATTERN = r'<(?:a\s+)?([^>]*' + TASK_ID_PATTERN + r'[^>]*)>'
|
|
|
|
|
|
def read_frontmatter(text):
|
|
match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
|
|
if not match:
|
|
raise ValueError("YAML frontmatter not found in tasks.md")
|
|
return parse_yaml_frontmatter(match.group(1))
|
|
|
|
|
|
def get_task_id_prefix(fm_data):
|
|
prefix = os.environ.get("TASK_ID_PREFIX", fm_data.get("task-id-prefix", "EX"))
|
|
if not isinstance(prefix, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", prefix):
|
|
raise ValueError("Task ID prefix must start with a letter and contain only letters, digits, underscores, or hyphens")
|
|
return prefix.upper()
|
|
|
|
|
|
def format_task_id(number, prefix, digit_width=3):
|
|
return f"{prefix}-{number:0{max(3, digit_width, len(str(number)))}d}"
|
|
|
|
|
|
def parse_task_id(id_str):
|
|
match = re.fullmatch(r"(?:[a-z][a-z0-9_-]*-)?(\d+)", str(id_str).strip(), re.IGNORECASE)
|
|
if not match:
|
|
raise ValueError(f"Invalid task ID '{id_str}'; expected a task number or PREFIX-number")
|
|
return int(match.group(1))
|
|
|
|
|
|
def task_id_matches(id_str, anchor_id, prefix):
|
|
number = parse_task_id(id_str)
|
|
task_id = str(id_str).strip()
|
|
anchor_prefix, anchor_number = anchor_id.rsplit("-", 1)
|
|
return number == int(anchor_number) and (
|
|
task_id.isdigit() or task_id.rsplit("-", 1)[0].lower() in (anchor_prefix.lower(), prefix.lower())
|
|
)
|
|
|
|
|
|
def resolve_epic(epic, fm_data):
|
|
if epic is None or epic.strip().lower() in ("", "none", "null", "clear"):
|
|
return "", ""
|
|
for item in fm_data.get("epics", []):
|
|
value = item.get("value", "").strip()
|
|
label = item.get("label", "").strip() or value.replace("-", " ").replace("_", " ").title()
|
|
if value and epic.strip().lower() in (value.lower(), label.lower()):
|
|
return value, label
|
|
raise ValueError(f"Unknown epic '{epic}'; use a value or label from frontmatter epics")
|
|
|
|
|
|
def build_markdown_table(headers, rows, alignments=None):
|
|
if not rows:
|
|
return ""
|
|
|
|
num_cols = len(headers)
|
|
widths = [len(h) for h in headers]
|
|
for row in rows:
|
|
for i, val in enumerate(row):
|
|
widths[i] = max(widths[i], len(str(val)))
|
|
|
|
if alignments is None:
|
|
alignments = [":---"] * num_cols
|
|
|
|
header_line = "| " + " | ".join(f"{h:<{widths[i]}}" for i, h in enumerate(headers)) + " |\n"
|
|
separator_parts = []
|
|
for i, a in enumerate(alignments):
|
|
w = max(widths[i], 3)
|
|
if a.startswith(":") and a.endswith(":"):
|
|
separator_parts.append(":" + "-" * (w - 2) + ":")
|
|
elif a.endswith(":"):
|
|
separator_parts.append("-" * (w - 1) + ":")
|
|
else:
|
|
separator_parts.append(":" + "-" * (w - 1))
|
|
separator_line = "|" + "|".join(separator_parts) + "|\n"
|
|
|
|
row_lines = []
|
|
for row in rows:
|
|
line = "| " + " | ".join(f"{str(row[i]):<{widths[i]}}" for i in range(num_cols)) + " |\n"
|
|
row_lines.append(line)
|
|
|
|
return header_line + separator_line + "".join(row_lines)
|
|
|
|
|
|
def update_tasks_document(tasks_path):
|
|
tasks_path = Path(tasks_path)
|
|
with open(tasks_path, "r", encoding="utf-8") as f:
|
|
text = f.read()
|
|
|
|
# 1. Parse frontmatter
|
|
fm_match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
|
|
if not fm_match:
|
|
raise ValueError("YAML frontmatter not found in tasks.md")
|
|
|
|
fm_text = fm_match.group(1)
|
|
fm_data = parse_yaml_frontmatter(fm_text)
|
|
prefix = get_task_id_prefix(fm_data)
|
|
|
|
project_list = fm_data.get("projects", [])
|
|
epic_list = fm_data.get("epics", [])
|
|
subproject_list = fm_data.get("subprojects", [])
|
|
status_list = fm_data.get("task-statuses", [])
|
|
type_list = fm_data.get("task-types", [])
|
|
|
|
# Maps for lookup (both value and label matching)
|
|
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
|
|
# Accept 'done' or 'fixed' fallbacks if not defined in frontmatter
|
|
status_map.setdefault("fixed", status_map.get("done", "Done"))
|
|
status_map.setdefault("done", status_map.get("fixed", "Done"))
|
|
status_value_map.setdefault("fixed", "done")
|
|
status_value_map.setdefault("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
|
|
|
|
# 2. Locate boundaries
|
|
m_detail = re.search(r"^## Detailed Tasks\s*$", text, re.MULTILINE)
|
|
if not m_detail:
|
|
raise ValueError("## Detailed Tasks heading not found")
|
|
detail_start = m_detail.start()
|
|
detail_header_len = len(m_detail.group(0))
|
|
|
|
m_notes = re.search(r"(?:\n\s*---\s*)?\n\s*## Notes\s*$", text, re.MULTILINE)
|
|
if not m_notes:
|
|
raise ValueError("## Notes section marker not found")
|
|
notes_start = m_notes.start()
|
|
|
|
# Determine part_a (everything before the summary table)
|
|
tasks_list_m = re.search(r'<a\s+[^>]*\bid=["\']tasks-list["\'][^>]*></a>', text, re.IGNORECASE)
|
|
progress_m = re.search(r'^## Overall Progress\s*$', text, re.MULTILINE)
|
|
|
|
if tasks_list_m and tasks_list_m.start() < detail_start:
|
|
part_a = text[:tasks_list_m.end()].rstrip() + "\n\n"
|
|
table_search_start = tasks_list_m.end()
|
|
elif progress_m and progress_m.start() < detail_start:
|
|
part_a = text[:progress_m.end()].rstrip() + "\n\n"
|
|
table_search_start = progress_m.end()
|
|
else:
|
|
table_start_marker = "| ID"
|
|
table_start = text.find(table_start_marker)
|
|
if table_start == -1 or table_start >= detail_start:
|
|
raise ValueError("Overall Progress Table (| ID) not found")
|
|
# Strip any leading git conflict markers right before | ID from part_a
|
|
raw_part_a = text[:table_start]
|
|
raw_part_a = re.sub(r'<{7}\s+[^\n]*\n*$', '', raw_part_a)
|
|
part_a = raw_part_a.rstrip() + "\n\n"
|
|
table_search_start = table_start
|
|
|
|
# Determine part_c (the transition into ## Detailed Tasks)
|
|
# Check if <a id="task-details"></a> was present between table and ## Detailed Tasks
|
|
between_table_and_detail = text[table_search_start:detail_start]
|
|
has_task_details_anchor = bool(re.search(r'<a\s+[^>]*\bid=["\']task-details["\'][^>]*>', between_table_and_detail, re.IGNORECASE))
|
|
|
|
if has_task_details_anchor:
|
|
part_c = "\n\n---\n\n<a id=\"task-details\"></a>\n\n## Detailed Tasks\n"
|
|
else:
|
|
part_c = "\n\n---\n\n## Detailed Tasks\n"
|
|
|
|
detail_body = text[detail_start + detail_header_len : notes_start]
|
|
|
|
# 3. Extract and Sort Task Chunks by Numeric ID
|
|
task_chunks = re.split(r"(?=<a\s+[^>]*\bid=|<id=)", detail_body)
|
|
task_chunks = [c.strip() for c in task_chunks if c.strip()]
|
|
|
|
if not task_chunks:
|
|
raise ValueError("No task chunks found in Detailed Tasks")
|
|
|
|
extracted_chunks = []
|
|
for chunk in task_chunks:
|
|
m = re.search(TASK_ID_PATTERN, chunk, re.IGNORECASE)
|
|
if not m:
|
|
raise ValueError(f"Could not find task ID in chunk:\n{chunk[:150]}")
|
|
num = int(m.group(2))
|
|
extracted_chunks.append((num, chunk))
|
|
|
|
seen_nums = {}
|
|
for num, chunk in extracted_chunks:
|
|
seen_nums.setdefault(num, []).append(chunk)
|
|
|
|
duplicates = [num for num, chunks in seen_nums.items() if len(chunks) > 1]
|
|
if duplicates:
|
|
prefix = get_task_id_prefix(fm_data)
|
|
dup_ids = [format_task_id(num, prefix) for num in sorted(duplicates)]
|
|
raise ValueError(f"Duplicate numeric task IDs found in Detailed Tasks: {', '.join(dup_ids)}")
|
|
|
|
# Sort task detail section chunks by numeric ID
|
|
extracted_chunks.sort(key=lambda x: x[0])
|
|
|
|
max_id_num = extracted_chunks[-1][0] if extracted_chunks else 1
|
|
digit_width = max(3, len(str(max_id_num)))
|
|
|
|
# 4. Update the Task Detail Section (First)
|
|
updated_detailed_chunks = []
|
|
updated_task_records = []
|
|
|
|
header_block_pattern = r'<(?:a\s+[^>]*\bid=|\bid=)["\'][a-zA-Z][a-zA-Z0-9_-]*-\d+["\'][^>]*></a>\s*\n###\s+[^\n]+\n\*\*ID:\*\*[^\n]+\n\*\*Project:\*\*[^\n]+(?:\n\*\*(?:Subproject|Epic):\*\*[^\n]*)*\n\*\*Status:\*\*[^\n]+\n\*\*Type:\*\*[^\n]+'
|
|
anchor_replacements = {}
|
|
|
|
for num, chunk in extracted_chunks:
|
|
anchor_match = re.search(TASK_ANCHOR_PATTERN, chunk, re.IGNORECASE)
|
|
if not anchor_match:
|
|
raise ValueError(f"Anchor tag missing in task chunk {num}")
|
|
anchor_tag = anchor_match.group(1)
|
|
|
|
proj_m = re.search(r'data-project=["\']([^"\']+)["\']', anchor_tag)
|
|
subproj_m = re.search(r'data-subproject=["\']([^"\']*)["\']', anchor_tag)
|
|
status_m = re.search(r'data-status=["\']([^"\']+)["\']', anchor_tag)
|
|
type_m = re.search(r'data-task-type=["\']([^"\']+)["\']', anchor_tag)
|
|
epic_m = re.search(r'data-epic=["\']([^"\']*)["\']', anchor_tag)
|
|
epic_key, expected_epic = resolve_epic(epic_m.group(1) if epic_m else None, fm_data)
|
|
|
|
# Also check for **Subproject:** in chunk if anchor tag didn't have data-subproject
|
|
if not subproj_m:
|
|
subproj_field_m = re.search(r'^\*\*Subproject:\*\*\s*([^\n]+)$', chunk, re.MULTILINE)
|
|
subproj_val = subproj_field_m.group(1).strip() if subproj_field_m else None
|
|
else:
|
|
subproj_val = subproj_m.group(1).strip() if subproj_m.group(1).strip() else None
|
|
|
|
proj_raw = proj_m.group(1).strip() if proj_m else "shared"
|
|
status_raw = status_m.group(1).strip() if status_m else "triage"
|
|
type_raw = type_m.group(1).strip() if type_m else "feature"
|
|
|
|
proj_key = project_value_map.get(proj_raw.lower(), proj_raw.lower())
|
|
status_key = status_value_map.get(status_raw.lower(), status_raw.lower())
|
|
type_key = type_value_map.get(type_raw.lower(), type_raw.lower())
|
|
|
|
expected_proj = project_map.get(proj_key, project_map.get(proj_raw.lower(), proj_raw.capitalize()))
|
|
expected_status = status_map.get(status_key, status_map.get(status_raw.lower(), status_raw.capitalize()))
|
|
expected_type = type_map.get(type_key, type_map.get(type_raw.lower(), type_raw.capitalize()))
|
|
|
|
subproj_key = None
|
|
expected_subproj = None
|
|
if subproj_val and subproj_val.lower() not in ("", "none", "null"):
|
|
subproj_key = subproject_value_map.get(subproj_val.lower(), subproj_val.lower())
|
|
expected_subproj = subproject_map.get(subproj_key, subproject_map.get(subproj_val.lower(), subproj_val.replace("-", " ").replace("_", " ").title()))
|
|
|
|
title_m = re.search(r'^###\s+(.+)$', chunk, re.MULTILINE)
|
|
title = title_m.group(1).strip() if title_m else f"Task {num}"
|
|
|
|
normalized_id = format_task_id(num, prefix, digit_width)
|
|
normalized_aid = normalized_id.lower()
|
|
anchor_replacements[anchor_match.group(2).lower()] = normalized_aid
|
|
|
|
if subproj_key:
|
|
anchor_tag_str = f'<a id="{normalized_aid}" class="task" data-project="{proj_key}" data-subproject="{subproj_key}" data-status="{status_key}" data-task-type="{type_key}"></a>'
|
|
subproj_line = f"**Subproject:** {expected_subproj}\n"
|
|
else:
|
|
anchor_tag_str = f'<a id="{normalized_aid}" class="task" data-project="{proj_key}" data-status="{status_key}" data-task-type="{type_key}"></a>'
|
|
subproj_line = ""
|
|
|
|
if epic_key:
|
|
anchor_tag_str = anchor_tag_str.replace('></a>', f' data-epic="{epic_key}"></a>')
|
|
epic_line = f"**Epic:** {expected_epic}\n" if epic_key else ""
|
|
|
|
new_header_block = (
|
|
f'{anchor_tag_str}\n'
|
|
f"### {title}\n"
|
|
f"**ID:** {normalized_id}\n"
|
|
f"**Project:** {expected_proj}\n"
|
|
f"{subproj_line}"
|
|
f"{epic_line}"
|
|
f"**Status:** {expected_status}\n"
|
|
f"**Type:** {expected_type}"
|
|
)
|
|
|
|
if re.search(header_block_pattern, chunk):
|
|
updated_chunk = re.sub(header_block_pattern, new_header_block, chunk, count=1)
|
|
else:
|
|
# Fallback if header spacing or fields differed slightly
|
|
rest = chunk.split("**Type:**", 1)[1].lstrip().split("\n", 1)[1] if "**Type:**" in chunk else ""
|
|
updated_chunk = f"{new_header_block}\n\n{rest}".rstrip()
|
|
|
|
updated_detailed_chunks.append(updated_chunk)
|
|
|
|
updated_task_records.append({
|
|
"num": num,
|
|
"id": normalized_id,
|
|
"aid": normalized_aid,
|
|
"title": title,
|
|
"project": expected_proj,
|
|
"subproject": expected_subproj or "",
|
|
"epic": expected_epic,
|
|
"status": expected_status,
|
|
"type": expected_type,
|
|
})
|
|
|
|
# Assemble sorted, updated Detailed Tasks section
|
|
sorted_detailed_tasks = "\n\n".join(updated_detailed_chunks)
|
|
|
|
# 5. Rebuild Summary Table (Rendered from the sorted, updated task details)
|
|
has_subprojects = any(bool(t.get("subproject")) for t in updated_task_records)
|
|
if has_subprojects:
|
|
summary_headers = ["ID", "Title", "Project", "Subproject", "Status", "Type"]
|
|
alignments = [":---", ":---", ":---", ":---", ":---", ":---"]
|
|
else:
|
|
summary_headers = ["ID", "Title", "Project", "Status", "Type"]
|
|
alignments = [":---", ":---", ":---", ":---", ":---"]
|
|
has_epics = bool(epic_list)
|
|
if has_epics:
|
|
summary_headers.insert(-2, "Epic")
|
|
alignments.insert(-2, ":---")
|
|
|
|
summary_rows = []
|
|
|
|
for task in updated_task_records:
|
|
clean_title = task["title"]
|
|
while (clean_title.startswith("**") and clean_title.endswith("**")) or (
|
|
clean_title.startswith("*") and clean_title.endswith("*")
|
|
):
|
|
if clean_title.startswith("**") and clean_title.endswith("**"):
|
|
clean_title = clean_title[2:-2].strip()
|
|
elif clean_title.startswith("*") and clean_title.endswith("*"):
|
|
clean_title = clean_title[1:-1].strip()
|
|
|
|
st_lower = task["status"].lower()
|
|
if st_lower == "pending":
|
|
formatted_title = f"**{clean_title}**"
|
|
elif st_lower in ("triage", "planning"):
|
|
formatted_title = f"*{clean_title}*"
|
|
else:
|
|
formatted_title = clean_title
|
|
|
|
formatted_type = f"[{task['type']}](#{task['aid']})"
|
|
row = [task["id"], formatted_title, task["project"]]
|
|
if has_subprojects:
|
|
row.append(task["subproject"])
|
|
if has_epics:
|
|
row.append(task["epic"])
|
|
row.extend([task["status"], formatted_type])
|
|
summary_rows.append(row)
|
|
|
|
new_summary_table = build_markdown_table(
|
|
summary_headers, summary_rows, alignments=alignments
|
|
).rstrip("\n")
|
|
|
|
# 8. Re-render metadata derived enum tables at end of document
|
|
end_text = text[notes_start:]
|
|
|
|
# Projects table: Value | Label | Prefix | Path
|
|
project_headers = ["Value", "Label", "Prefix", "Path"]
|
|
project_rows = [
|
|
[p.get("value", ""), p.get("label", ""), p.get("prefix", ""), p.get("path", "")]
|
|
for p in project_list
|
|
]
|
|
projects_table = build_markdown_table(project_headers, project_rows, [":---", ":---", ":---", ":---"]).strip()
|
|
|
|
# Statuses table: Value | Label | Description
|
|
status_headers = ["Value", "Label", "Description"]
|
|
status_rows = [
|
|
[s.get("value", ""), s.get("label", ""), s.get("description", "")]
|
|
for s in status_list
|
|
]
|
|
statuses_table = build_markdown_table(status_headers, status_rows, [":---", ":---", ":---"]).strip()
|
|
|
|
# Types table: Value | Label | Prefix | Description
|
|
type_headers = ["Value", "Label", "Prefix", "Description"]
|
|
type_rows = [
|
|
[t.get("value", ""), t.get("label", ""), t.get("prefix", ""), t.get("description", "")]
|
|
for t in type_list
|
|
]
|
|
types_table = build_markdown_table(type_headers, type_rows, [":---", ":---", ":---", ":---"]).strip()
|
|
|
|
# Subprojects table if present in frontmatter
|
|
subproject_section = ""
|
|
if subproject_list:
|
|
subproject_headers = ["Value", "Label", "Description"]
|
|
subproject_rows = [
|
|
[sp.get("value", ""), sp.get("label", ""), sp.get("description", "")]
|
|
for sp in subproject_list
|
|
]
|
|
subprojects_table = build_markdown_table(subproject_headers, subproject_rows, [":---", ":---", ":---"]).strip()
|
|
subproject_section = f"\n\n#### Subprojects (Rendered from frontmatter subprojects)\n{subprojects_table}"
|
|
|
|
epic_section = ""
|
|
if epic_list:
|
|
epic_rows = [[e.get("value", ""), e.get("label", ""), e.get("description", "")] for e in epic_list]
|
|
epics_table = build_markdown_table(["Value", "Label", "Description"], epic_rows).strip()
|
|
epic_section = f"\n\n#### Epics (Rendered from frontmatter epics)\n{epics_table}"
|
|
|
|
# Replace enum tables in end_text
|
|
enum_tables_pattern = r"(#### Projects \(Rendered from frontmatter projects\)\n).*?(<!-- Table Sort Injection -->)"
|
|
new_enum_tables = (
|
|
"#### Projects (Rendered from frontmatter projects)\n"
|
|
f"{projects_table}\n\n"
|
|
"#### Task Statuses (Rendered from frontmatter task-statuses)\n"
|
|
f"{statuses_table}\n\n"
|
|
"#### Task Types (Rendered from frontmatter task-types)\n"
|
|
f"{types_table}"
|
|
f"{subproject_section}{epic_section}\n\n"
|
|
r"\2"
|
|
)
|
|
|
|
updated_end_text = re.sub(enum_tables_pattern, new_enum_tables, end_text, flags=re.DOTALL)
|
|
|
|
# 9. Assemble full document
|
|
new_full_text = (
|
|
part_a
|
|
+ new_summary_table
|
|
+ part_c
|
|
+ "\n"
|
|
+ sorted_detailed_tasks
|
|
+ "\n\n"
|
|
+ updated_end_text.lstrip("\n")
|
|
)
|
|
new_full_text = re.sub(
|
|
r'(\]\(|href=["\']|^\[[^\]\n]+\]:[ \t]*)#([a-z][a-z0-9_-]*-\d+)(?=[\s)"\']|$)',
|
|
lambda m: m.group(1) + "#" + anchor_replacements.get(m.group(2).lower(), m.group(2)),
|
|
new_full_text,
|
|
flags=re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
|
|
with open(tasks_path, "w", encoding="utf-8") as f:
|
|
f.write(new_full_text)
|
|
|
|
return len(updated_task_records)
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description="Synchronize and update Documentation/tasks.md")
|
|
parser.add_argument(
|
|
"-f", "--file",
|
|
dest="file",
|
|
default=None,
|
|
help="Path to tasks.md (defaults to searching workspace)"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
tasks_file = find_tasks_file(args.file)
|
|
count = update_tasks_document(tasks_file)
|
|
print(f"Successfully synchronized and updated {count} tasks in {tasks_file}")
|
|
except Exception as e:
|
|
print(f"Error updating tasks: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|