Rename Client field to Customer and refine Overview sections
ClickUp "Client" custom field was deleted; switch all references to "Customer" across config, tools, tests, and docs. Refine Overview tab: rename Due Soon to Up Next (today/tomorrow, fallback next 5), expand This Month to include both month tag and due-date-in-month matches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>cora-start
parent
0d60b1b516
commit
f67f1b9124
|
|
@ -105,7 +105,7 @@ uv add --group test <package>
|
|||
- **Memory scoping**: Agents with `memory_scope` set use `memory/{scope}/` subdirectory. Empty scope = shared `memory/` root. Fallback search checks both scoped and shared directories.
|
||||
- **Database**: SQLite with WAL mode, thread-local connections via `threading.local()`
|
||||
- **KV store**: Task state stored as JSON at `clickup:task:{id}:state` keys
|
||||
- **ClickUp field mapping**: `Work Category` field (not `Task Type`) identifies task types like "Press Release", "Link Building". The `Client` field (not `Company`) holds the client name.
|
||||
- **ClickUp field mapping**: `Work Category` field (not `Task Type`) identifies task types like "Press Release", "Link Building". The `Customer` field holds the client name.
|
||||
- **Notifications**: All scheduler events go through `NotificationBus.push()`, never directly to a UI
|
||||
- **Tests**: Use `respx` to mock httpx calls, `tmp_db` fixture for isolated SQLite instances
|
||||
- **ClickUp attachments**: `ClickUpClient.upload_attachment()` uses module-level `httpx.post()` (not the shared client) for multipart uploads
|
||||
|
|
@ -175,7 +175,7 @@ skill_map:
|
|||
auto_execute: true
|
||||
field_mapping:
|
||||
topic: "task_name" # uses ClickUp task name
|
||||
company_name: "Client" # looks up "Client" custom field
|
||||
company_name: "Customer" # looks up "Customer" custom field
|
||||
```
|
||||
|
||||
Task lifecycle: `to do` → discovered → approved/awaiting_approval → executing → completed/failed (+ attachments uploaded)
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ def _find_clickup_task(ctx: dict, company_name: str) -> str:
|
|||
if task.task_type != "Press Release":
|
||||
continue
|
||||
|
||||
client_field = task.custom_fields.get("Client", "")
|
||||
client_field = task.custom_fields.get("Customer", "")
|
||||
if not (
|
||||
_fuzzy_company_match(company_name, task.name)
|
||||
or _fuzzy_company_match(company_name, client_field)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ clickup:
|
|||
auto_execute: true
|
||||
field_mapping:
|
||||
topic: "task_name"
|
||||
company_name: "Client"
|
||||
company_name: "Customer"
|
||||
target_url: "IMSURL"
|
||||
branded_url: "SocialURL"
|
||||
"Link Building":
|
||||
|
|
|
|||
|
|
@ -125,13 +125,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Due Soon (next 14 days) -->
|
||||
<!-- Up Next (today/tomorrow, or next 5 by due date) -->
|
||||
<div class="section section--tight">
|
||||
<div class="section__header">
|
||||
<h2 class="section__title"><span class="icon">⏰</span> Due Soon</h2>
|
||||
<span class="section__badge" id="due-soon-count">-</span>
|
||||
<h2 class="section__title"><span class="icon">⏰</span> Up Next</h2>
|
||||
<span class="section__badge" id="up-next-count">-</span>
|
||||
</div>
|
||||
<div class="task-table-wrap task-table-wrap--compact" id="overview-due-soon">
|
||||
<div class="task-table-wrap task-table-wrap--compact" id="overview-up-next">
|
||||
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -393,29 +393,55 @@ async function loadOverview() {
|
|||
document.getElementById('stat-agents-detail').textContent = 'Configured';
|
||||
}
|
||||
|
||||
// -- Due Soon: tasks due within 14 days --
|
||||
// -- Up Next: tasks due today/tomorrow, or next 5 by due date --
|
||||
if (tasks && tasks.tasks) {
|
||||
const now = Date.now();
|
||||
const fourteenDays = 14 * 24 * 60 * 60 * 1000;
|
||||
const dueSoon = tasks.tasks
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const endOfTomorrow = startOfToday + 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Only consider open tasks
|
||||
const openTasks = tasks.tasks.filter(t => {
|
||||
const s = (t.status || '').toLowerCase();
|
||||
return !(s.includes('complete') || s.includes('done') || s.includes('closed'));
|
||||
});
|
||||
|
||||
let upNext = openTasks
|
||||
.filter(t => {
|
||||
if (!t.due_date) return false;
|
||||
const due = parseInt(t.due_date, 10);
|
||||
return due > now && due <= now + fourteenDays;
|
||||
return due >= startOfToday && due < endOfTomorrow;
|
||||
})
|
||||
.sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date));
|
||||
const dueSoonIds = new Set(dueSoon.map(t => t.id));
|
||||
document.getElementById('due-soon-count').textContent = dueSoon.length;
|
||||
renderOverviewTable('overview-due-soon', dueSoon, true);
|
||||
|
||||
// -- This Month: tasks tagged with current month tag, excluding due-soon --
|
||||
// Fallback: if nothing due today/tomorrow, show next 5 with a due date
|
||||
if (upNext.length === 0) {
|
||||
upNext = openTasks
|
||||
.filter(t => t.due_date && parseInt(t.due_date, 10) >= startOfToday)
|
||||
.sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date))
|
||||
.slice(0, 5);
|
||||
}
|
||||
document.getElementById('up-next-count').textContent = upNext.length;
|
||||
renderOverviewTable('overview-up-next', upNext, true);
|
||||
|
||||
// -- This Month: tagged with month tag OR due date this month --
|
||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
||||
const d = new Date();
|
||||
const monthTag = monthNames[d.getMonth()] + String(d.getFullYear()).slice(2);
|
||||
const thisMonth = tasks.tasks.filter(t => {
|
||||
if (dueSoonIds.has(t.id)) return false;
|
||||
return (t.tags || []).some(tag => tag.toLowerCase() === monthTag);
|
||||
});
|
||||
const monthTag = monthNames[now.getMonth()] + String(now.getFullYear()).slice(2);
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
||||
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
|
||||
|
||||
const thisMonthSet = new Set();
|
||||
const thisMonth = [];
|
||||
for (const t of openTasks) {
|
||||
if (thisMonthSet.has(t.id)) continue;
|
||||
const hasTag = (t.tags || []).some(tag => tag.toLowerCase() === monthTag);
|
||||
const dueThisMonth = t.due_date &&
|
||||
parseInt(t.due_date, 10) >= startOfMonth &&
|
||||
parseInt(t.due_date, 10) < startOfNextMonth;
|
||||
if (hasTag || dueThisMonth) {
|
||||
thisMonth.push(t);
|
||||
thisMonthSet.add(t.id);
|
||||
}
|
||||
}
|
||||
document.getElementById('this-month-count').textContent = thisMonth.length;
|
||||
renderOverviewTable('overview-this-month', thisMonth, false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,327 @@
|
|||
"""Tests for the simplified ClickUp scheduler flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from cheddahbot.clickup import ClickUpTask
|
||||
from cheddahbot.scheduler import Scheduler
|
||||
|
||||
# ── Fixtures ──
|
||||
|
||||
|
||||
_PR_MAPPING = {
|
||||
"tool": "write_press_releases",
|
||||
"auto_execute": True,
|
||||
"field_mapping": {
|
||||
"topic": "task_name",
|
||||
"company_name": "Customer",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeClickUpConfig:
|
||||
api_token: str = "pk_test"
|
||||
workspace_id: str = "ws_1"
|
||||
space_id: str = "space_1"
|
||||
poll_interval_minutes: int = 20
|
||||
poll_statuses: list[str] = field(default_factory=lambda: ["to do"])
|
||||
review_status: str = "internal review"
|
||||
in_progress_status: str = "in progress"
|
||||
task_type_field_name: str = "Work Category"
|
||||
default_auto_execute: bool = True
|
||||
skill_map: dict = field(default_factory=lambda: {"Press Release": _PR_MAPPING})
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeSchedulerConfig:
|
||||
poll_interval_seconds: int = 60
|
||||
heartbeat_interval_minutes: int = 30
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeConfig:
|
||||
clickup: _FakeClickUpConfig = field(default_factory=_FakeClickUpConfig)
|
||||
scheduler: _FakeSchedulerConfig = field(default_factory=_FakeSchedulerConfig)
|
||||
identity_dir: str = "identity"
|
||||
|
||||
|
||||
def _make_task(task_id, name, task_type, due_date="", custom_fields=None):
|
||||
"""Build a ClickUpTask for testing."""
|
||||
return ClickUpTask(
|
||||
id=task_id,
|
||||
name=name,
|
||||
status="to do",
|
||||
task_type=task_type,
|
||||
due_date=due_date,
|
||||
custom_fields=custom_fields or {},
|
||||
)
|
||||
|
||||
|
||||
def _now_ms():
|
||||
return int(datetime.now(UTC).timestamp() * 1000)
|
||||
|
||||
|
||||
_FIELDS = {"Customer": "Acme"}
|
||||
|
||||
|
||||
# ── Tests ──
|
||||
|
||||
|
||||
class TestPollClickup:
|
||||
"""Test the simplified _poll_clickup method."""
|
||||
|
||||
def test_skips_task_with_no_space_id(self, tmp_db):
|
||||
config = _FakeConfig()
|
||||
config.clickup.space_id = ""
|
||||
agent = MagicMock()
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
# Should not raise — just log warning and return
|
||||
scheduler._poll_clickup()
|
||||
|
||||
def test_skips_task_with_empty_skill_map(self, tmp_db):
|
||||
config = _FakeConfig()
|
||||
config.clickup.skill_map = {}
|
||||
agent = MagicMock()
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
# Should skip without calling any API
|
||||
scheduler._poll_clickup()
|
||||
|
||||
def _make_mock_client(self, tasks=None, field_filter=None):
|
||||
"""Create a mock ClickUp client with proper return types."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_tasks_from_space.return_value = tasks or []
|
||||
mock_client.get_list_ids_from_space.return_value = {"list_1"}
|
||||
mock_client.discover_field_filter.return_value = field_filter
|
||||
return mock_client
|
||||
|
||||
def test_skips_task_already_completed(self, tmp_db):
|
||||
"""Tasks with completed state should be skipped."""
|
||||
config = _FakeConfig()
|
||||
agent = MagicMock()
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
state = {"state": "completed", "clickup_task_id": "t1"}
|
||||
tmp_db.kv_set("clickup:task:t1:state", json.dumps(state))
|
||||
|
||||
due = str(_now_ms() + 86400000)
|
||||
task = _make_task(
|
||||
"t1",
|
||||
"PR for Acme",
|
||||
"Press Release",
|
||||
due_date=due,
|
||||
custom_fields=_FIELDS,
|
||||
)
|
||||
|
||||
scheduler._clickup_client = self._make_mock_client(
|
||||
tasks=[task],
|
||||
)
|
||||
scheduler._poll_clickup()
|
||||
|
||||
scheduler._clickup_client.update_task_status.assert_not_called()
|
||||
|
||||
def test_skips_task_already_failed(self, tmp_db):
|
||||
"""Tasks with failed state should be skipped."""
|
||||
config = _FakeConfig()
|
||||
agent = MagicMock()
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
state = {"state": "failed", "clickup_task_id": "t1"}
|
||||
tmp_db.kv_set("clickup:task:t1:state", json.dumps(state))
|
||||
|
||||
due = str(_now_ms() + 86400000)
|
||||
task = _make_task(
|
||||
"t1",
|
||||
"PR for Acme",
|
||||
"Press Release",
|
||||
due_date=due,
|
||||
)
|
||||
|
||||
scheduler._clickup_client = self._make_mock_client(
|
||||
tasks=[task],
|
||||
)
|
||||
scheduler._poll_clickup()
|
||||
|
||||
scheduler._clickup_client.update_task_status.assert_not_called()
|
||||
|
||||
def test_skips_task_with_no_due_date(self, tmp_db):
|
||||
"""Tasks with no due date should be skipped."""
|
||||
config = _FakeConfig()
|
||||
agent = MagicMock()
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
task = _make_task(
|
||||
"t1",
|
||||
"PR for Acme",
|
||||
"Press Release",
|
||||
due_date="",
|
||||
)
|
||||
|
||||
scheduler._clickup_client = self._make_mock_client(
|
||||
tasks=[task],
|
||||
)
|
||||
scheduler._poll_clickup()
|
||||
|
||||
scheduler._clickup_client.update_task_status.assert_not_called()
|
||||
|
||||
def test_skips_unmapped_task_type(self, tmp_db):
|
||||
"""Tasks with unmapped task_type should be skipped."""
|
||||
config = _FakeConfig()
|
||||
agent = MagicMock()
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
due = str(_now_ms() + 86400000)
|
||||
task = _make_task(
|
||||
"t1",
|
||||
"Content task",
|
||||
"Content Creation",
|
||||
due_date=due,
|
||||
)
|
||||
|
||||
scheduler._clickup_client = self._make_mock_client(
|
||||
tasks=[task],
|
||||
)
|
||||
scheduler._poll_clickup()
|
||||
|
||||
scheduler._clickup_client.update_task_status.assert_not_called()
|
||||
|
||||
|
||||
class TestExecuteTask:
|
||||
"""Test the simplified _execute_task method."""
|
||||
|
||||
def test_success_flow(self, tmp_db):
|
||||
"""Successful execution: state=completed."""
|
||||
config = _FakeConfig()
|
||||
agent = MagicMock()
|
||||
agent._tools = MagicMock()
|
||||
agent._tools.execute.return_value = "## ClickUp Sync\nDone"
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.update_task_status.return_value = True
|
||||
scheduler._clickup_client = mock_client
|
||||
|
||||
due = str(_now_ms() + 86400000)
|
||||
task = _make_task(
|
||||
"t1",
|
||||
"PR for Acme",
|
||||
"Press Release",
|
||||
due_date=due,
|
||||
custom_fields=_FIELDS,
|
||||
)
|
||||
scheduler._execute_task(task)
|
||||
|
||||
mock_client.update_task_status.assert_any_call(
|
||||
"t1",
|
||||
"in progress",
|
||||
)
|
||||
|
||||
raw = tmp_db.kv_get("clickup:task:t1:state")
|
||||
state = json.loads(raw)
|
||||
assert state["state"] == "completed"
|
||||
|
||||
def test_success_fallback_path(self, tmp_db):
|
||||
"""Scheduler uploads docx and sets review status."""
|
||||
config = _FakeConfig()
|
||||
agent = MagicMock()
|
||||
agent._tools = MagicMock()
|
||||
agent._tools.execute.return_value = "Press releases done.\n**Docx:** `output/pr.docx`"
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.update_task_status.return_value = True
|
||||
mock_client.upload_attachment.return_value = True
|
||||
mock_client.add_comment.return_value = True
|
||||
scheduler._clickup_client = mock_client
|
||||
|
||||
due = str(_now_ms() + 86400000)
|
||||
task = _make_task(
|
||||
"t1",
|
||||
"PR for Acme",
|
||||
"Press Release",
|
||||
due_date=due,
|
||||
custom_fields=_FIELDS,
|
||||
)
|
||||
scheduler._execute_task(task)
|
||||
|
||||
mock_client.update_task_status.assert_any_call(
|
||||
"t1",
|
||||
"internal review",
|
||||
)
|
||||
mock_client.upload_attachment.assert_called_once_with(
|
||||
"t1",
|
||||
"output/pr.docx",
|
||||
)
|
||||
|
||||
raw = tmp_db.kv_get("clickup:task:t1:state")
|
||||
state = json.loads(raw)
|
||||
assert state["state"] == "completed"
|
||||
assert "output/pr.docx" in state["deliverable_paths"]
|
||||
|
||||
def test_failure_flow(self, tmp_db):
|
||||
"""Failed: state=failed, error comment, back to 'to do'."""
|
||||
config = _FakeConfig()
|
||||
agent = MagicMock()
|
||||
agent._tools = MagicMock()
|
||||
agent._tools.execute.side_effect = RuntimeError("API timeout")
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.update_task_status.return_value = True
|
||||
mock_client.add_comment.return_value = True
|
||||
scheduler._clickup_client = mock_client
|
||||
|
||||
due = str(_now_ms() + 86400000)
|
||||
task = _make_task(
|
||||
"t1",
|
||||
"PR for Acme",
|
||||
"Press Release",
|
||||
due_date=due,
|
||||
custom_fields=_FIELDS,
|
||||
)
|
||||
scheduler._execute_task(task)
|
||||
|
||||
mock_client.update_task_status.assert_any_call("t1", "to do")
|
||||
mock_client.add_comment.assert_called_once()
|
||||
comment_text = mock_client.add_comment.call_args[0][1]
|
||||
assert "failed" in comment_text.lower()
|
||||
|
||||
raw = tmp_db.kv_get("clickup:task:t1:state")
|
||||
state = json.loads(raw)
|
||||
assert state["state"] == "failed"
|
||||
assert "API timeout" in state["error"]
|
||||
|
||||
|
||||
class TestFieldFilterDiscovery:
|
||||
"""Test _discover_field_filter caching."""
|
||||
|
||||
def test_caches_after_first_call(self, tmp_db):
|
||||
config = _FakeConfig()
|
||||
agent = MagicMock()
|
||||
scheduler = Scheduler(config, tmp_db, agent)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_list_ids_from_space.return_value = {"list_1"}
|
||||
mock_client.discover_field_filter.return_value = {
|
||||
"field_id": "cf_wc",
|
||||
"options": {"Press Release": "opt_pr"},
|
||||
}
|
||||
mock_client.get_tasks_from_space.return_value = []
|
||||
scheduler._clickup_client = mock_client
|
||||
|
||||
# First poll should discover
|
||||
scheduler._poll_clickup()
|
||||
assert scheduler._field_filter_cache is not None
|
||||
assert scheduler._field_filter_cache["field_id"] == "cf_wc"
|
||||
|
||||
# Second poll reuses cache
|
||||
mock_client.discover_field_filter.reset_mock()
|
||||
scheduler._poll_clickup()
|
||||
mock_client.discover_field_filter.assert_not_called()
|
||||
Loading…
Reference in New Issue