256 lines
8.9 KiB
Python
256 lines
8.9 KiB
Python
"""CLI script to create a bundled set of ClickUp tasks (LINKS, PR, NEW, OPT).
|
|
|
|
Usage:
|
|
uv run python scripts/create_task_set.py \
|
|
--client "RPM Rubber" --keyword "rubber gaskets" \
|
|
--types OPT,LINKS,PR --url "https://example.com/page" \
|
|
--tag apr26 --due-date 2026-04-08 \
|
|
--pr-topic "discuss RPM rubber gasket capabilities"
|
|
|
|
uv run python scripts/create_task_set.py \
|
|
--client "Metal Craft Spinning" --keyword "fan panels" \
|
|
--types NEW,LINKS --tag may26 --due-date 2026-05-11
|
|
|
|
uv run python scripts/create_task_set.py \
|
|
--client "Hogge Precision" --keyword "swiss machining" \
|
|
--types LINKS --url "https://example.com/page" \
|
|
--tag jun26 --due-date 2026-06-08 --articles 5
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
from cheddahbot.clickup import ClickUpClient
|
|
|
|
DEFAULT_ASSIGNEE = 10765627 # Bryan Bigari
|
|
VALID_TYPES = {"NEW", "OPT", "LINKS", "PR"}
|
|
|
|
# Time estimates in milliseconds
|
|
TIME_ESTIMATES = {
|
|
"NEW": 14400000, # 4h
|
|
"OPT": 7200000, # 2h
|
|
"LINKS": 9000000, # 2.5h
|
|
"PR": 5400000, # 1.5h
|
|
}
|
|
|
|
WORK_CATEGORIES = {
|
|
"NEW": "Content Creation",
|
|
"OPT": "On Page Optimization",
|
|
"LINKS": "Link Building",
|
|
"PR": "Press Release",
|
|
}
|
|
|
|
|
|
def _date_to_unix_ms(date_str: str) -> int:
|
|
"""Convert YYYY-MM-DD to Unix milliseconds (noon UTC)."""
|
|
dt = datetime.strptime(date_str, "%Y-%m-%d").replace(hour=12, tzinfo=UTC)
|
|
return int(dt.timestamp() * 1000)
|
|
|
|
|
|
def _add_days_ms(unix_ms: int, days: int) -> int:
|
|
"""Add days to a Unix ms timestamp."""
|
|
return unix_ms + days * 86400 * 1000
|
|
|
|
|
|
def main():
|
|
load_dotenv()
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Create a bundled set of ClickUp tasks"
|
|
)
|
|
parser.add_argument("--client", required=True, help="Client folder name")
|
|
parser.add_argument("--keyword", required=True, help="SEO keyword")
|
|
parser.add_argument(
|
|
"--types",
|
|
required=True,
|
|
help="Comma-separated task types: NEW, OPT, LINKS, PR",
|
|
)
|
|
parser.add_argument("--due-date", required=True, help="Content due date (YYYY-MM-DD)")
|
|
parser.add_argument("--tag", required=True, help="Month tag (e.g. apr26)")
|
|
parser.add_argument("--url", default="", help="IMSURL (required for OPT)")
|
|
parser.add_argument("--pr-topic", default="", help="PR topic (required if PR in types)")
|
|
parser.add_argument("--articles", type=int, default=0, help="Tier-1 article count for LINKS")
|
|
parser.add_argument("--anchors", default="", help="Custom anchors for LINKS (comma-delimited)")
|
|
parser.add_argument("--priority", type=int, default=2, help="Priority (default: 2/High)")
|
|
parser.add_argument("--assignee", type=int, default=DEFAULT_ASSIGNEE, help="ClickUp user ID")
|
|
parser.add_argument(
|
|
"--links-tag", default="",
|
|
help="Override tag for LINKS/PR (if different from content tag)",
|
|
)
|
|
parser.add_argument(
|
|
"--pr-tag", default="",
|
|
help="Override tag for PR (if different from content tag)",
|
|
)
|
|
parser.add_argument(
|
|
"--links-date", default="",
|
|
help="Override due date for LINKS (YYYY-MM-DD, default: content + 7 days)",
|
|
)
|
|
parser.add_argument(
|
|
"--pr-date", default="",
|
|
help="Override due date for PR (YYYY-MM-DD, default: content + 7 days)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Parse and validate types
|
|
task_types = [t.strip().upper() for t in args.types.split(",")]
|
|
for t in task_types:
|
|
if t not in VALID_TYPES:
|
|
print(f"Error: Invalid type '{t}'. Must be one of: {VALID_TYPES}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
has_content = "NEW" in task_types or "OPT" in task_types
|
|
|
|
# Validate required fields
|
|
if "OPT" in task_types and not args.url:
|
|
print("Error: --url is required when OPT is in types", file=sys.stderr)
|
|
sys.exit(1)
|
|
if "PR" in task_types and not args.pr_topic:
|
|
print("Error: --pr-topic is required when PR is in types", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not args.url and "NEW" not in task_types:
|
|
needs_url = [t for t in task_types if t in ("LINKS", "PR")]
|
|
if needs_url:
|
|
print(
|
|
f"Error: --url is required for {needs_url} when NEW is not in types",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
api_token = os.environ.get("CLICKUP_API_TOKEN", "")
|
|
space_id = os.environ.get("CLICKUP_SPACE_ID", "")
|
|
if not api_token:
|
|
print("Error: CLICKUP_API_TOKEN not set", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not space_id:
|
|
print("Error: CLICKUP_SPACE_ID not set", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
client = ClickUpClient(api_token=api_token)
|
|
try:
|
|
list_id = client.find_list_in_folder(space_id, args.client)
|
|
if not list_id:
|
|
print(f"Error: No folder '{args.client}' with 'Overall' list", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
content_due_ms = _date_to_unix_ms(args.due_date)
|
|
followup_due_ms = _add_days_ms(content_due_ms, 7)
|
|
|
|
created = {}
|
|
new_task_id = None
|
|
|
|
# Determine task order: content tasks first (need ID for dependencies)
|
|
ordered = []
|
|
for t in ["NEW", "OPT"]:
|
|
if t in task_types:
|
|
ordered.append(t)
|
|
for t in ["LINKS", "PR"]:
|
|
if t in task_types:
|
|
ordered.append(t)
|
|
|
|
for task_type in ordered:
|
|
# Task name
|
|
name = f"{task_type} - {args.keyword}"
|
|
|
|
# Due date
|
|
if task_type in ("NEW", "OPT"):
|
|
due_ms = content_due_ms
|
|
elif task_type == "LINKS" and args.links_date:
|
|
due_ms = _date_to_unix_ms(args.links_date)
|
|
elif task_type == "PR" and args.pr_date:
|
|
due_ms = _date_to_unix_ms(args.pr_date)
|
|
elif has_content:
|
|
due_ms = followup_due_ms
|
|
else:
|
|
due_ms = content_due_ms
|
|
|
|
# Tag
|
|
if task_type == "LINKS" and args.links_tag:
|
|
tag = args.links_tag
|
|
elif task_type == "PR" and args.pr_tag:
|
|
tag = args.pr_tag
|
|
else:
|
|
tag = args.tag
|
|
|
|
result = client.create_task(
|
|
list_id=list_id,
|
|
name=name,
|
|
status="to do",
|
|
priority=args.priority,
|
|
assignees=[args.assignee],
|
|
due_date=due_ms,
|
|
tags=[tag],
|
|
time_estimate=TIME_ESTIMATES[task_type],
|
|
)
|
|
task_id = result.get("id", "")
|
|
task_url = result.get("url", "")
|
|
|
|
# Common fields
|
|
client.set_custom_field_smart(task_id, list_id, "Client", args.client)
|
|
client.set_custom_field_smart(
|
|
task_id, list_id, "Work Category", WORK_CATEGORIES[task_type]
|
|
)
|
|
client.set_custom_field_smart(task_id, list_id, "Delegate to Claude", "true")
|
|
|
|
# IMSURL
|
|
if args.url and task_type != "NEW":
|
|
client.set_custom_field_smart(task_id, list_id, "IMSURL", args.url)
|
|
|
|
# Type-specific fields
|
|
if task_type == "LINKS":
|
|
client.set_custom_field_smart(task_id, list_id, "LB Method", "Cora Backlinks")
|
|
client.set_custom_field_smart(task_id, list_id, "BrandedPlusRatio", "0.80")
|
|
if args.articles:
|
|
client.set_custom_field_smart(
|
|
task_id, list_id, "CLIFlags", f"--tier-1 {args.articles}"
|
|
)
|
|
if args.anchors:
|
|
client.set_custom_field_smart(
|
|
task_id, list_id, "CustomAnchors", args.anchors
|
|
)
|
|
|
|
if task_type == "PR":
|
|
client.set_custom_field_smart(task_id, list_id, "PR Topic", args.pr_topic)
|
|
|
|
# Track NEW task ID for dependencies
|
|
if task_type == "NEW":
|
|
new_task_id = task_id
|
|
|
|
# Set dependency if NEW exists and no URL
|
|
if task_type in ("LINKS", "PR") and new_task_id and not args.url:
|
|
ok = client.add_dependency(task_id, depends_on=new_task_id)
|
|
dep_status = "blocked by NEW" if ok else "DEPENDENCY FAILED"
|
|
else:
|
|
dep_status = ""
|
|
|
|
created[task_type] = {
|
|
"id": task_id,
|
|
"name": name,
|
|
"url": task_url,
|
|
"dep": dep_status,
|
|
}
|
|
|
|
status_parts = [f"{task_type} created: {task_id}"]
|
|
if dep_status:
|
|
status_parts.append(dep_status)
|
|
print(" | ".join(status_parts))
|
|
|
|
# Summary
|
|
print(json.dumps(created, indent=2))
|
|
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|