Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 36 additions & 3 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
# -------------------------
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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():
Expand Down
42 changes: 42 additions & 0 deletions tests/test_tasks_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================
Expand Down
Loading