Enhance ClickUp task creation, smart field setting, and Cora distribution comments

- Add set_custom_field_smart() to auto-resolve dropdown option UUIDs
- Extend create_task with priority, assignees, time_estimate params
- Expand clickup_create_task tool and CLI script with tags, due dates, custom fields
- Add _comment_distributed_tasks to post clear ClickUp comments on Cora distribution
  (e.g. "Cora XLSX moved to cora-inbox" / "content-cora-inbox")
- Remove unused _find_all_todo_tasks; simplify AutoCora sibling matching
- Add tests for set_custom_field_smart dropdown and text fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fix/customer-field-migration
PeninsulaInd 2026-03-11 18:06:03 -05:00
parent 0d5450fb65
commit fb4498b978
9 changed files with 361 additions and 66 deletions

View File

@ -93,6 +93,8 @@ uv add --group test <package>
| `identity/SOUL.md` | Agent personality |
| `identity/USER.md` | User profile |
| `skills/` | Markdown skill files with YAML frontmatter |
| `scripts/create_clickup_task.py` | CLI script to create ClickUp tasks |
| `docs/clickup-task-creation.md` | Task creation conventions, per-type fields, and defaults |
## Conventions

View File

@ -456,6 +456,50 @@ class ClickUpClient:
log.error("Failed to set field '%s' on task %s: %s", field_name, task_id, e)
return False
def set_custom_field_smart(
self, task_id: str, list_id: str, field_name: str, value: str
) -> bool:
"""Set a custom field by name, auto-resolving dropdown option UUIDs.
For dropdown fields, *value* is matched against option names
(case-insensitive). For all other field types, *value* is passed through.
"""
try:
fields = self.get_custom_fields(list_id)
target = None
for f in fields:
if f.get("name") == field_name:
target = f
break
if not target:
log.warning("Field '%s' not found in list %s", field_name, list_id)
return False
field_id = target["id"]
resolved = value
if target.get("type") == "drop_down":
options = target.get("type_config", {}).get("options", [])
for opt in options:
if opt.get("name", "").lower() == value.lower():
resolved = opt["id"]
break
else:
log.warning(
"Dropdown option '%s' not found for field '%s'",
value,
field_name,
)
return False
return self.set_custom_field_value(task_id, field_id, resolved)
except Exception as e:
log.error(
"Failed to set field '%s' on task %s: %s", field_name, task_id, e
)
return False
def get_custom_field_by_name(self, task_id: str, field_name: str) -> Any:
"""Read a custom field value from a task by field name.
@ -478,6 +522,9 @@ class ClickUpClient:
due_date: int | None = None,
tags: list[str] | None = None,
custom_fields: list[dict] | None = None,
priority: int | None = None,
assignees: list[int] | None = None,
time_estimate: int | None = None,
) -> dict:
"""Create a new task in a ClickUp list.
@ -489,6 +536,9 @@ class ClickUpClient:
due_date: Due date as Unix timestamp in milliseconds.
tags: List of tag names to apply.
custom_fields: List of custom field dicts ({"id": ..., "value": ...}).
priority: 1=Urgent, 2=High, 3=Normal, 4=Low.
assignees: List of ClickUp user IDs.
time_estimate: Time estimate in milliseconds.
Returns:
API response dict containing task id, url, etc.
@ -502,6 +552,12 @@ class ClickUpClient:
payload["tags"] = tags
if custom_fields:
payload["custom_fields"] = custom_fields
if priority is not None:
payload["priority"] = priority
if assignees:
payload["assignees"] = assignees
if time_estimate is not None:
payload["time_estimate"] = time_estimate
def _call():
resp = self._client.post(f"/list/{list_id}/task", json=payload)

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import contextlib
import json
import logging
import re
import shutil
@ -1146,6 +1147,72 @@ class Scheduler:
category="autocora",
)
# Post ClickUp comment on matched tasks
self._comment_distributed_tasks(client, xlsx_path.stem, copied_to, tasks, stem)
def _comment_distributed_tasks(
self, client, xlsx_stem: str, copied_to: list[str], tasks, normalized_stem: str
):
"""Post a ClickUp comment on tasks when a Cora report is distributed."""
from .tools.autocora import _slugify
from .tools.linkbuilding import _fuzzy_keyword_match, _normalize_for_match
parts = []
for dest in copied_to:
if dest.startswith("link"):
parts.append("cora-inbox")
elif dest.startswith("content"):
parts.append("content-cora-inbox")
else:
parts.append(dest)
dest_label = " and ".join(parts)
comment = f"Cora XLSX moved to {dest_label}."
# Try to find task_ids from job JSON files
task_ids: list[str] = []
jobs_dir = Path(self.config.autocora.jobs_dir)
slug = _slugify(xlsx_stem)
if jobs_dir.is_dir():
# Check both jobs/ root and processed/ subfolder
search_dirs = [jobs_dir]
processed = jobs_dir / "processed"
if processed.is_dir():
search_dirs.append(processed)
for search_dir in search_dirs:
for job_file in search_dir.glob("job-*.json"):
# Strip "job-{timestamp}-" prefix to get the slug
parts = job_file.stem.split("-", 2)
if len(parts) >= 3:
job_slug = parts[2]
if job_slug == slug:
try:
data = json.loads(job_file.read_text(encoding="utf-8"))
task_ids = data.get("task_ids", [])
except (json.JSONDecodeError, OSError) as e:
log.warning("Could not read job file %s: %s", job_file, e)
break
if task_ids:
break
# Fallback: match from the task list we already have
if not task_ids:
for task in tasks:
keyword = task.custom_fields.get("Keyword", "")
if not keyword:
continue
keyword_norm = _normalize_for_match(str(keyword))
if _fuzzy_keyword_match(normalized_stem, keyword_norm):
task_ids.append(task.id)
# Post comments
for tid in task_ids:
try:
client.add_comment(tid, comment)
except Exception as e:
log.warning("Failed to comment on task %s: %s", tid, e)
# ── Morning Briefing ──
_CENTRAL = ZoneInfo("America/Chicago")

View File

@ -217,18 +217,6 @@ def _is_lookahead(task, today_end_ms, lookahead_end_ms) -> bool:
return False
def _find_all_todo_tasks(client, config, categories: list[str]):
"""Find ALL 'to do' tasks in cora_categories (no date filter).
Used to find sibling tasks sharing the same keyword.
"""
space_id = config.clickup.space_id
if not space_id:
return []
tasks = client.get_tasks_from_space(space_id, statuses=["to do"])
return [t for t in tasks if t.task_type in categories]
def _group_by_keyword(tasks, all_tasks):
"""Group tasks by normalized keyword, pulling in sibling tasks from all_tasks.
@ -324,11 +312,8 @@ def submit_autocora_jobs(target_date: str = "", ctx: dict | None = None) -> str:
if not qualifying:
return f"No qualifying tasks found ({label})."
# Find ALL to-do tasks in cora categories for sibling keyword matching
all_todo = _find_all_todo_tasks(client, config, autocora.cora_categories)
# Group by keyword
groups, alerts = _group_by_keyword(qualifying, all_todo)
# Group by keyword — only siblings that also passed the sweep qualify
groups, alerts = _group_by_keyword(qualifying, qualifying)
if not groups and alerts:
return "No jobs submitted.\n\n" + "\n".join(f"- {a}" for a in alerts)

View File

@ -163,7 +163,9 @@ def clickup_task_status(task_id: str, ctx: dict | None = None) -> str:
@tool(
"clickup_create_task",
"Create a new ClickUp task for a client. Requires task name and client name. "
"Optionally set work category, description, and status. "
"Optionally set work category, description, status, due_date (Unix ms), "
"tags (comma-separated), and arbitrary custom fields via custom_fields_json "
'(JSON object like {"Keyword":"value","CLIFlags":"--tier1-count 5"}). '
"The task is created in the 'Overall' list within the client's folder.",
category="clickup",
)
@ -173,9 +175,17 @@ def clickup_create_task(
work_category: str = "",
description: str = "",
status: str = "to do",
due_date: str = "",
tags: str = "",
custom_fields_json: str = "",
priority: int = 2,
assignee: int = 10765627,
time_estimate_ms: int = 0,
ctx: dict | None = None,
) -> str:
"""Create a new ClickUp task in the client's Overall list."""
import json as _json
client_obj = _get_clickup_client(ctx)
if not client_obj:
return "Error: ClickUp API token not configured."
@ -188,29 +198,48 @@ def clickup_create_task(
# Find the client's Overall list
list_id = client_obj.find_list_in_folder(cfg.space_id, client)
if not list_id:
return f"Error: Could not find folder '{client}' with an 'Overall' list in space."
return (
f"Error: Could not find folder '{client}' "
f"with an 'Overall' list in space."
)
# Build create kwargs
create_kwargs: dict = {
"list_id": list_id,
"name": name,
"description": description,
"status": status,
"priority": priority,
"assignees": [assignee],
}
if due_date:
create_kwargs["due_date"] = int(due_date)
if tags:
create_kwargs["tags"] = [t.strip() for t in tags.split(",")]
if time_estimate_ms:
create_kwargs["time_estimate"] = time_estimate_ms
# Create the task
result = client_obj.create_task(
list_id=list_id,
name=name,
description=description,
status=status,
)
result = client_obj.create_task(**create_kwargs)
task_id = result.get("id", "")
task_url = result.get("url", "")
# Set Client dropdown field
client_obj.set_custom_field_by_name(task_id, "Client", client)
client_obj.set_custom_field_smart(task_id, list_id, "Client", client)
# Set Work Category if provided
if work_category:
field_info = client_obj.discover_field_filter(list_id, "Work Category")
if field_info and work_category in field_info["options"]:
option_id = field_info["options"][work_category]
client_obj.set_custom_field_value(task_id, field_info["field_id"], option_id)
else:
log.warning("Work Category '%s' not found in dropdown options", work_category)
client_obj.set_custom_field_smart(
task_id, list_id, "Work Category", work_category
)
# Set any additional custom fields
if custom_fields_json:
extra_fields = _json.loads(custom_fields_json)
for field_name, field_value in extra_fields.items():
client_obj.set_custom_field_smart(
task_id, list_id, field_name, str(field_value)
)
return (
f"Task created successfully!\n"

View File

@ -2,8 +2,9 @@
Usage:
uv run python scripts/create_clickup_task.py --name "Task" --client "Client"
uv run python scripts/create_clickup_task.py --name "PR" --client "Acme" \\
--category "Press Release"
uv run python scripts/create_clickup_task.py --name "LB" --client "Acme" \\
--category "Link Building" --due-date 2026-03-11 --tag feb26 \\
--field "Keyword=some keyword" --field "CLIFlags=--tier1-count 5"
"""
from __future__ import annotations
@ -12,6 +13,7 @@ import argparse
import json
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
# Add project root to path so we can import cheddahbot
@ -21,16 +23,80 @@ from dotenv import load_dotenv
from cheddahbot.clickup import ClickUpClient
DEFAULT_ASSIGNEE = 10765627 # Bryan Bigari
def _date_to_unix_ms(date_str: str) -> int:
"""Convert YYYY-MM-DD to Unix milliseconds (noon UTC).
Noon UTC ensures the date displays correctly in US timezones.
"""
dt = datetime.strptime(date_str, "%Y-%m-%d").replace(
hour=12, tzinfo=UTC
)
return int(dt.timestamp() * 1000)
def _parse_time_estimate(s: str) -> int:
"""Parse a human time string like '2h', '30m', '1h30m' to ms."""
import re
total_min = 0
match = re.match(r"(?:(\d+)h)?(?:(\d+)m)?$", s.strip())
if not match or not any(match.groups()):
raise ValueError(f"Invalid time estimate: '{s}' (use e.g. '2h', '30m', '1h30m')")
if match.group(1):
total_min += int(match.group(1)) * 60
if match.group(2):
total_min += int(match.group(2))
return total_min * 60 * 1000
def main():
load_dotenv()
parser = argparse.ArgumentParser(description="Create a ClickUp task")
parser.add_argument("--name", required=True, help="Task name")
parser.add_argument("--client", required=True, help="Client folder name in ClickUp")
parser.add_argument("--category", default="", help="Work Category (e.g. 'Press Release')")
parser.add_argument(
"--client", required=True, help="Client folder name"
)
parser.add_argument(
"--category", default="", help="Work Category dropdown value"
)
parser.add_argument("--description", default="", help="Task description")
parser.add_argument("--status", default="to do", help="Initial status (default: 'to do')")
parser.add_argument(
"--status", default="to do", help="Initial status (default: 'to do')"
)
parser.add_argument(
"--due-date", default="", help="Due date as YYYY-MM-DD"
)
parser.add_argument(
"--tag", action="append", default=[], help="Tag (mmmYY, repeatable)"
)
parser.add_argument(
"--field",
action="append",
default=[],
help="Custom field as Name=Value (repeatable)",
)
parser.add_argument(
"--priority",
type=int,
default=2,
help="Priority: 1=Urgent, 2=High, 3=Normal, 4=Low (default: 2)",
)
parser.add_argument(
"--assignee",
type=int,
action="append",
default=[],
help="ClickUp user ID (default: Bryan 10765627)",
)
parser.add_argument(
"--time-estimate",
default="",
help="Time estimate (e.g. '2h', '30m', '1h30m')",
)
args = parser.parse_args()
api_token = os.environ.get("CLICKUP_API_TOKEN", "")
@ -43,6 +109,15 @@ def main():
print("Error: CLICKUP_SPACE_ID not set", file=sys.stderr)
sys.exit(1)
# Parse custom fields
custom_fields: dict[str, str] = {}
for f in args.field:
if "=" not in f:
print(f"Error: --field must be Name=Value, got: {f}", file=sys.stderr)
sys.exit(1)
name, value = f.split("=", 1)
custom_fields[name] = value
client = ClickUpClient(api_token=api_token)
try:
# Find the client's Overall list
@ -52,27 +127,47 @@ def main():
print(msg, file=sys.stderr)
sys.exit(1)
# Build create_task kwargs
create_kwargs: dict = {
"list_id": list_id,
"name": args.name,
"description": args.description,
"status": args.status,
}
if args.due_date:
create_kwargs["due_date"] = _date_to_unix_ms(args.due_date)
if args.tag:
create_kwargs["tags"] = args.tag
create_kwargs["priority"] = args.priority
create_kwargs["assignees"] = args.assignee or [DEFAULT_ASSIGNEE]
if args.time_estimate:
create_kwargs["time_estimate"] = _parse_time_estimate(
args.time_estimate
)
# Create the task
result = client.create_task(
list_id=list_id,
name=args.name,
description=args.description,
status=args.status,
)
result = client.create_task(**create_kwargs)
task_id = result.get("id", "")
# Set Client dropdown field
client.set_custom_field_by_name(task_id, "Client", args.client)
client.set_custom_field_smart(task_id, list_id, "Client", args.client)
# Set Work Category if provided
if args.category:
field_info = client.discover_field_filter(list_id, "Work Category")
if field_info and args.category in field_info["options"]:
option_id = field_info["options"][args.category]
client.set_custom_field_value(task_id, field_info["field_id"], option_id)
else:
msg = f"Warning: Work Category '{args.category}' not found"
print(msg, file=sys.stderr)
client.set_custom_field_smart(
task_id, list_id, "Work Category", args.category
)
# Set any additional custom fields
for field_name, field_value in custom_fields.items():
ok = client.set_custom_field_smart(
task_id, list_id, field_name, field_value
)
if not ok:
print(
f"Warning: Failed to set '{field_name}'",
file=sys.stderr,
)
print(json.dumps({
"id": task_id,

View File

@ -204,9 +204,7 @@ class TestSubmitAutocoraJobs:
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task]
)
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task]
)
result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx)
assert "Submitted 1 job" in result
@ -233,9 +231,7 @@ class TestSubmitAutocoraJobs:
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task]
)
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task]
)
submit_autocora_jobs(target_date="2025-01-01", ctx=ctx)
@ -256,9 +252,7 @@ class TestSubmitAutocoraJobs:
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task]
)
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task]
)
# First submit
submit_autocora_jobs(target_date="2025-01-01", ctx=ctx)
@ -278,9 +272,7 @@ class TestSubmitAutocoraJobs:
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task]
)
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task]
)
result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx)
assert "missing Keyword" in result
@ -296,9 +288,7 @@ class TestSubmitAutocoraJobs:
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_qualifying_tasks", lambda *a, **kw: [task]
)
monkeypatch.setattr(
"cheddahbot.tools.autocora._find_all_todo_tasks", lambda *a, **kw: [task]
)
result = submit_autocora_jobs(target_date="2025-01-01", ctx=ctx)
assert "Submitted 1 job" in result

View File

@ -569,3 +569,73 @@ class TestClickUpClient:
result = client.find_list_in_folder("space_1", "NonExistent Client")
assert result is None
client.close()
@respx.mock
def test_set_custom_field_smart_dropdown(self):
"""Resolves dropdown option name to UUID automatically."""
respx.get(f"{BASE_URL}/list/list_1/field").mock(
return_value=httpx.Response(
200,
json={
"fields": [
{
"id": "cf_lb",
"name": "LB Method",
"type": "drop_down",
"type_config": {
"options": [
{"id": "opt_cora", "name": "Cora Backlinks"},
{"id": "opt_manual", "name": "Manual"},
]
},
},
]
},
)
)
respx.post(f"{BASE_URL}/task/t1/field/cf_lb").mock(
return_value=httpx.Response(200, json={})
)
client = ClickUpClient(api_token="pk_test")
result = client.set_custom_field_smart(
"t1", "list_1", "LB Method", "Cora Backlinks"
)
assert result is True
import json
body = json.loads(respx.calls.last.request.content)
assert body["value"] == "opt_cora"
client.close()
@respx.mock
def test_set_custom_field_smart_text(self):
"""Passes text field values through without resolution."""
respx.get(f"{BASE_URL}/list/list_1/field").mock(
return_value=httpx.Response(
200,
json={
"fields": [
{
"id": "cf_kw",
"name": "Keyword",
"type": "short_text",
},
]
},
)
)
respx.post(f"{BASE_URL}/task/t1/field/cf_kw").mock(
return_value=httpx.Response(200, json={})
)
client = ClickUpClient(api_token="pk_test")
result = client.set_custom_field_smart(
"t1", "list_1", "Keyword", "shaft manufacturing"
)
assert result is True
import json
body = json.loads(respx.calls.last.request.content)
assert body["value"] == "shaft manufacturing"
client.close()

View File

@ -13,6 +13,7 @@ from cheddahbot.config import AutoCoraConfig, Config, ContentConfig, LinkBuildin
class FakeTask:
"""Minimal ClickUp task stub for distribution tests."""
id: str = "fake_id"
name: str = ""
task_type: str = ""
custom_fields: dict = field(default_factory=dict)