From fb4498b9789d23c159abd6bd38fe8955791572c2 Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Wed, 11 Mar 2026 18:06:03 -0500 Subject: [PATCH] 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 --- CLAUDE.md | 2 + cheddahbot/clickup.py | 56 +++++++++++++ cheddahbot/scheduler.py | 67 ++++++++++++++++ cheddahbot/tools/autocora.py | 19 +---- cheddahbot/tools/clickup_tool.py | 59 ++++++++++---- scripts/create_clickup_task.py | 133 ++++++++++++++++++++++++++----- tests/test_autocora.py | 20 ++--- tests/test_clickup.py | 70 ++++++++++++++++ tests/test_cora_distribute.py | 1 + 9 files changed, 361 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d5c03e7..5b21b91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,8 @@ uv add --group test | `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 diff --git a/cheddahbot/clickup.py b/cheddahbot/clickup.py index 8f735f7..7fe0972 100644 --- a/cheddahbot/clickup.py +++ b/cheddahbot/clickup.py @@ -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) diff --git a/cheddahbot/scheduler.py b/cheddahbot/scheduler.py index 88c0df4..7a453e6 100644 --- a/cheddahbot/scheduler.py +++ b/cheddahbot/scheduler.py @@ -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") diff --git a/cheddahbot/tools/autocora.py b/cheddahbot/tools/autocora.py index 3b8dcbc..91cb988 100644 --- a/cheddahbot/tools/autocora.py +++ b/cheddahbot/tools/autocora.py @@ -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) diff --git a/cheddahbot/tools/clickup_tool.py b/cheddahbot/tools/clickup_tool.py index 7bbc75b..25007b1 100644 --- a/cheddahbot/tools/clickup_tool.py +++ b/cheddahbot/tools/clickup_tool.py @@ -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" diff --git a/scripts/create_clickup_task.py b/scripts/create_clickup_task.py index 617025d..127399b 100644 --- a/scripts/create_clickup_task.py +++ b/scripts/create_clickup_task.py @@ -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, diff --git a/tests/test_autocora.py b/tests/test_autocora.py index 9cffaa4..bc0f6a2 100644 --- a/tests/test_autocora.py +++ b/tests/test_autocora.py @@ -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 diff --git a/tests/test_clickup.py b/tests/test_clickup.py index 4f24f1f..4dfeecd 100644 --- a/tests/test_clickup.py +++ b/tests/test_clickup.py @@ -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() diff --git a/tests/test_cora_distribute.py b/tests/test_cora_distribute.py index 150bf6e..876274c 100644 --- a/tests/test_cora_distribute.py +++ b/tests/test_cora_distribute.py @@ -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)