Deduplicate kanban card slugs
This commit is contained in:
@@ -7542,6 +7542,44 @@ def normalize_kanban_slug(value, fallback="untitled"):
|
|||||||
return slug or fallback
|
return slug or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def kanban_timestamp_token(value=None):
|
||||||
|
if value is None:
|
||||||
|
value = datetime.now(timezone.utc)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
value = datetime.now(timezone.utc)
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
value = value.astimezone(timezone.utc)
|
||||||
|
return value.strftime("%Y%m%d%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
|
def unique_kanban_slug(column, slug, existing_cards=None, now=None):
|
||||||
|
column = normalize_kanban_column(column)
|
||||||
|
slug = normalize_kanban_slug(slug)
|
||||||
|
cards = existing_cards
|
||||||
|
if cards is None:
|
||||||
|
cards = list_kanban_cards({"column": column, "active_only": False, "limit": DISCOVERY_COMPACT_MAX_LIMIT, "view": "metadata"}).get("items", [])
|
||||||
|
folder = f"/kanban/{column}"
|
||||||
|
existing_names = set()
|
||||||
|
for card in cards or []:
|
||||||
|
path = normalize_node_path(card.get("full_path") or "")
|
||||||
|
if path.startswith(f"{folder}/"):
|
||||||
|
existing_names.add(path[len(folder) + 1:])
|
||||||
|
if slug not in existing_names:
|
||||||
|
return slug
|
||||||
|
stamped = f"{slug}-{kanban_timestamp_token(now)}"
|
||||||
|
if stamped not in existing_names:
|
||||||
|
return stamped
|
||||||
|
for index in range(2, 100):
|
||||||
|
candidate = f"{stamped}-{index}"
|
||||||
|
if candidate not in existing_names:
|
||||||
|
return candidate
|
||||||
|
return f"{stamped}-{secrets.token_hex(3)}"
|
||||||
|
|
||||||
|
|
||||||
def kanban_compact_card(node, view="summary", preview_chars=240):
|
def kanban_compact_card(node, view="summary", preview_chars=240):
|
||||||
item = summarize_node(node, view=view, preview_chars=preview_chars)
|
item = summarize_node(node, view=view, preview_chars=preview_chars)
|
||||||
metadata = node_metadata(node)
|
metadata = node_metadata(node)
|
||||||
@@ -7861,6 +7899,8 @@ def create_kanban_card(args):
|
|||||||
raise RuntimeError("title is required")
|
raise RuntimeError("title is required")
|
||||||
column = normalize_kanban_column(args.get("column"), "todo")
|
column = normalize_kanban_column(args.get("column"), "todo")
|
||||||
slug = normalize_kanban_slug(args.get("slug") or title)
|
slug = normalize_kanban_slug(args.get("slug") or title)
|
||||||
|
if not as_bool(args.get("upsert"), False):
|
||||||
|
slug = unique_kanban_slug(column, slug, now=args.get("now"))
|
||||||
path = f"/kanban/{column}/{slug}"
|
path = f"/kanban/{column}/{slug}"
|
||||||
metadata = kanban_card_metadata(args, column=column)
|
metadata = kanban_card_metadata(args, column=column)
|
||||||
metadata.setdefault("rank", next_kanban_rank(column))
|
metadata.setdefault("rank", next_kanban_rank(column))
|
||||||
|
|||||||
@@ -260,6 +260,31 @@ def test_kanban_workflow_create_move_and_block(mod):
|
|||||||
assert writes[2][2]["metadata"]["blocked_by"] == "waiting"
|
assert writes[2][2]["metadata"]["blocked_by"] == "waiting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kanban_create_deduplicates_overlapping_titles(mod):
|
||||||
|
writes = []
|
||||||
|
existing_cards = [
|
||||||
|
node("/kanban/todo/add-mcp-commands", "todo", metadata={"column": "todo"}, tags=["kanban"], node_type="kanban_card"),
|
||||||
|
node("/kanban/todo/add-mcp-commands-20260510123456", "todo", metadata={"column": "todo"}, tags=["kanban"], node_type="kanban_card"),
|
||||||
|
node("/kanban/doing/add-mcp-commands", "doing", metadata={"column": "doing"}, tags=["kanban"], node_type="kanban_card"),
|
||||||
|
]
|
||||||
|
|
||||||
|
mod.list_kanban_cards = lambda args: {"items": existing_cards if args.get("column") == "todo" else []}
|
||||||
|
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload)) or {
|
||||||
|
**existing_cards[0],
|
||||||
|
"id": "id-deduped",
|
||||||
|
"full_path": f"{payload.get('folder_path')}/{payload.get('name')}",
|
||||||
|
"folder_path": payload.get("folder_path"),
|
||||||
|
"name": payload.get("name"),
|
||||||
|
"metadata": payload.get("metadata", {}),
|
||||||
|
"tags": payload.get("tags", []),
|
||||||
|
}
|
||||||
|
mod.index_node = lambda node: None
|
||||||
|
|
||||||
|
created = mod.create_kanban_card({"title": "Add MCP commands", "column": "todo", "now": "2026-05-10T12:34:56Z"})
|
||||||
|
assert created["path"] == "/kanban/todo/add-mcp-commands-20260510123456-2"
|
||||||
|
assert writes[0][2]["name"] == "add-mcp-commands-20260510123456-2"
|
||||||
|
|
||||||
|
|
||||||
def test_plan_lifecycle_dry_run_and_write(mod):
|
def test_plan_lifecycle_dry_run_and_write(mod):
|
||||||
plan = node("/plans/example", "in_progress", tags=["plan"])
|
plan = node("/plans/example", "in_progress", tags=["plan"])
|
||||||
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
|
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
|
||||||
@@ -348,6 +373,8 @@ def run():
|
|||||||
mod = load_module()
|
mod = load_module()
|
||||||
test_kanban_workflow_create_move_and_block(mod)
|
test_kanban_workflow_create_move_and_block(mod)
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
|
test_kanban_create_deduplicates_overlapping_titles(mod)
|
||||||
|
mod = load_module()
|
||||||
test_plan_lifecycle_dry_run_and_write(mod)
|
test_plan_lifecycle_dry_run_and_write(mod)
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
test_plan_lifecycle_archived_idempotent(mod)
|
test_plan_lifecycle_archived_idempotent(mod)
|
||||||
|
|||||||
Reference in New Issue
Block a user