From 208197a1df22cb776bbd6f10483974edebbbe2b9 Mon Sep 17 00:00:00 2001 From: budhash Date: Mon, 13 Jul 2026 12:48:55 -0700 Subject: [PATCH] feat: set ID --title to retitle an item in place (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no CLI verb to correct an item's descriptive title after creation — the only options were a hook-discouraged manual TASKS.md edit or re-file + defer (ID churn). Add `set ID --title "New title"`: replaces the descriptive text while preserving the ID, status, priority, and all trailing machine tags (via split_item_tags), applied to the primary line and any @shadow copies so the derived copies stay in sync. Guards: empty titles are rejected, and so is a title that ENDS with a tag-shaped token (e.g. "... @milestone=m1") — on re-parse that token would merge into the trailing machine-tag region and silently become a real tag. Tag-lookalikes elsewhere in the title remain prose, per the #9 parsing rules. New e2e scenario 50 covers retitle + tag preservation, shadowed-feature sync, both guards, and the mid-title lookalike case. version 1.1.2 -> 1.2.0. --- CHANGELOG.md | 10 ++++++++++ tasks.py | 39 +++++++++++++++++++++++++++++++++++--- tests/test_tasks_e2e.sh | 42 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e815b4..f7dda24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to `tasks`. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); this project uses [semantic versioning](https://semver.org/). +## [1.2.0] — 2026-07-13 + +### Added +- `set ID --title "New title"` — replace an item's descriptive title in place, + preserving its ID, status, priority, and all trailing machine tags (#18; the + retitle half of #11). Applies to the primary line and any `@shadow` copies. + Titles that would *end* with a tag-shaped token (e.g. `... @milestone=m1`) are + rejected, since on re-parse that token would merge into the machine-tag + region; tag-lookalikes elsewhere in the title stay prose, per the #9 rules. + ## [1.1.2] — 2026-07-13 ### Fixed diff --git a/tasks.py b/tasks.py index aad4ba6..57e475d 100755 --- a/tasks.py +++ b/tasks.py @@ -148,6 +148,7 @@ set ID --effort HOURS Set @effort= (e.g., 4h, 8h) set ID --system TAG Set @system= (sprint-*, milestone-*, phase-*, epic-*) set ID --milestone ID Set @milestone= (id/alias; "", "clear", or sentinel name clears) + set ID --title "New title" Replace the descriptive title (ID/status/tags preserved) milestone [ID] [--table] Milestone rollup; `milestone ID` for detail; `--table` for a milestone x features x status table migrate-tags-to-milestone TAG Rewrite interim @tags=TAG into @milestone=TAG @@ -190,7 +191,7 @@ from pathlib import Path from typing import List, Tuple, Optional, Dict, Set, Iterator -__version__ = "1.1.2" +__version__ = "1.2.0" # Canonical source for `selfupdate` — the published raw URL. `selfupdate` # overwrites this very script with the fetched content, so a NON-default source @@ -1429,6 +1430,26 @@ def set_tag(item_id: str, tag: str, value: str) -> None: print(f"Set @{tag}={value} on {item_id}") +def retitle_item(item_id: str, new_title: str) -> None: + """Replace an item's descriptive title, preserving its trailing machine tags.""" + item_id = normalize_id(item_id) + lines = load() + + idxs = find_all_item_lines(lines, item_id) + if not idxs: + raise SystemExit(f"Item {item_id} not found") + + for i in idxs: # primary + shadow copies stay in sync + parsed = parse_item(lines[i]) + assert parsed is not None + indent, box, iid, prio, status, rest = parsed + _old_title, tag_tokens = split_item_tags(rest) + lines[i] = build_item_line(indent, iid, prio, status, _join_title_tags(new_title, tag_tokens)) + + save(lines) + print(f"Retitled {item_id}: {new_title}") + + # ------------------------- # Commands # ------------------------- @@ -2334,7 +2355,7 @@ def cmd_link(args: List[str]): def cmd_set(args: List[str]): if len(args) < 3: - raise SystemExit("usage: set ID --branch NAME | --pr NUMBER | --issue NUMBER | --tags TAG1,TAG2 | --effort HOURS") + raise SystemExit("usage: set ID --branch NAME | --pr NUMBER | --issue NUMBER | --tags TAG1,TAG2 | --effort HOURS | --system TAG | --milestone ID | --title \"New title\"") item_id = args[0] flag = args[1] @@ -2416,8 +2437,20 @@ def cmd_set(args: List[str]): raise SystemExit(f"Invalid milestone '{value}'. Use letters, digits, hyphens, underscores.") set_tag(item_id, "milestone", value) _warn_unknown_milestone(value) + elif flag == "--title": + new_title = value.strip() + if not new_title: + raise SystemExit("--title requires a non-empty title") + # A trailing tag-shaped token would merge into the machine-tag region on + # re-parse, silently becoming a real tag — reject rather than surprise. + last_tok = new_title.split()[-1] + if _is_tag_token(last_tok): + raise SystemExit( + f"Title must not end with a machine-tag token ('{last_tok}'). " + f"Rephrase it, or set the tag explicitly (e.g. set ID --milestone ...).") + retitle_item(item_id, new_title) else: - raise SystemExit(f"Unknown flag: {flag}. Use --branch, --pr, --issue, --tags, --effort, --system, or --milestone") + raise SystemExit(f"Unknown flag: {flag}. Use --branch, --pr, --issue, --tags, --effort, --system, --milestone, or --title") def cmd_current(): diff --git a/tests/test_tasks_e2e.sh b/tests/test_tasks_e2e.sh index e446633..ee97154 100755 --- a/tests/test_tasks_e2e.sh +++ b/tests/test_tasks_e2e.sh @@ -1486,6 +1486,48 @@ assert_contains "link edits the primary line" "$primary" "@rel=T-0001" out=$(run validate) assert_contains "Validate passes after synced edits" "$out" "OK" +# ============================================================ +echo -e "\n${YELLOW}=== SCENARIO 50: set --title retitles an item, preserving tags (issue #18) ===${NC}" +# ============================================================ +setup_test_dir +fresh_init > /dev/null + +run new feature "Old feature name" --prio P1 --section Backlog > /dev/null +run new task "Old task name" --prio P0 --under F-0001 --effort 4h --tags security,mvp > /dev/null + +out=$(run set T-0001 --title "Reframed task name") +assert_contains "Retitle reports success" "$out" "Retitled T-0001" + +out=$(run show T-0001) +assert_contains "New title present" "$out" "Reframed task name" +assert_not_contains "Old title gone" "$out" "Old task name" +assert_contains "Effort preserved through retitle" "$out" "Effort: 4h" +assert_contains "Tags preserved through retitle" "$out" "Tags: security,mvp" + +# Retitle a shadowed feature: primary + shadow copies both get the new title. +run start T-0001 > /dev/null +run set F-0001 --title "Reframed feature name" > /dev/null +primary=$(grep "(F-0001).*Reframed feature name" TASKS.md | grep -v "@shadow") +shadow=$(grep "(F-0001).*Reframed feature name" TASKS.md | grep "@shadow") +assert_contains "Primary line retitled" "$primary" "Reframed feature name" +assert_contains "Shadow copy retitled in sync" "$shadow" "@shadow" + +# Guards. +out=$(run set T-0001 --title "" 2>&1 || true) +assert_contains "Empty title rejected" "$out" "non-empty" + +out=$(run set T-0001 --title "ends with a tag token @milestone=m1" 2>&1 || true) +assert_contains "Title ending in a tag-shaped token rejected" "$out" "must not end with a machine-tag token" + +# Prose tag-lookalike in the MIDDLE of a title is fine (per #9 semantics). +out=$(run set T-0001 --title "migrate @tags=x to @milestone=x everywhere") +assert_contains "Mid-title tag-lookalike accepted" "$out" "Retitled T-0001" +out=$(run milestone) +assert_not_contains "Mid-title lookalike creates no phantom bucket" "$out" " x" + +out=$(run validate) +assert_contains "Validate passes after retitles" "$out" "OK" + # ============================================================ # Summary # ============================================================