Skip to content

Commit 4f2713c

Browse files
committed
Handle hidden (folderless) ClickUp folders transparently
- Filter hidden projects from interactive selection menu - Auto-merge lists from hidden siblings into selection/lookup - Add comprehensive test coverage for both paths - Fixes folderless lists now appearing seamlessly alongside real folders
1 parent 497965a commit 4f2713c

7 files changed

Lines changed: 264 additions & 10 deletions

File tree

keyup/cli/api_client.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,24 +141,28 @@ def get_project_for(space, argv, interactive=False):
141141
if len(projects) == 0:
142142
raise ProjectNotFoundError()
143143

144-
if len(projects) > 1:
144+
visible = [p for p in projects if not getattr(p, "hidden", False)]
145+
if not visible:
146+
visible = projects
147+
148+
if len(visible) > 1:
145149
if interactive:
146150
questions = [
147151
inquirer.List(
148152
"project",
149153
message=f"Select a {Color.GREEN}Project{Color.OFF}",
150-
choices=[f"{project.name} [{project.id}]" for project in projects],
154+
choices=[f"{project.name} [{project.id}]" for project in visible],
151155
)
152156
]
153157

154158
answers = inquirer.prompt(questions)
155159

156160
if answers:
157-
for project in projects:
161+
for project in visible:
158162
if f"{project.name} [{project.id}]" == answers["project"]:
159163
return project
160164

161-
return projects[0]
165+
return visible[0]
162166

163167
except ProjectNotFoundError:
164168
raise
@@ -184,10 +188,21 @@ def get_list_for(space_obj, argv, interactive=False):
184188
index = sys.argv.index("--list")
185189
list_id = sys.argv[index + 1]
186190
lists = get_lists_data(space_obj)
191+
# Also include lists from hidden (folderless) sibling projects
192+
if hasattr(space_obj, "space"):
193+
for p in get_projects_data(space_obj.space):
194+
if getattr(p, "hidden", False) and p.id != space_obj.id:
195+
lists = lists + get_lists_data(p)
187196
return next(li for li in lists if str(li.id) == list_id)
188197

189198
except ValueError:
190199
lists = get_lists_data(space_obj)
200+
# Also include lists from hidden (folderless) sibling projects
201+
if hasattr(space_obj, "space"):
202+
for p in get_projects_data(space_obj.space):
203+
if getattr(p, "hidden", False) and p.id != space_obj.id:
204+
lists = lists + get_lists_data(p)
205+
191206
if len(lists) == 0:
192207
raise ListNotFoundError()
193208

keyup/cli/cache.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,23 +175,24 @@ def get_lists_data(project) -> list:
175175
return lists
176176

177177

178-
def get_tasks_data(team, list_id: str) -> list:
178+
def get_tasks_data(team, list_id: str, include_closed: bool = False) -> list:
179179
"""Get tasks data for a list from cache or fetch from API.
180180
181181
Args:
182182
team: Team object.
183183
list_id: ClickUp list ID.
184+
include_closed: If True, include closed/done tasks.
184185
185186
Returns:
186187
List of task objects.
187188
"""
188189
cache = get_cache()
189-
cache_key = f"tasks:{list_id}"
190+
cache_key = f"tasks:{list_id}:closed" if include_closed else f"tasks:{list_id}"
190191

191192
if cache_key in cache:
192193
return cache.get(cache_key) # type: ignore[no-any-return]
193194

194-
tasks = team.get_all_tasks(subtasks=False, list_ids=[list_id])
195+
tasks = team.get_all_tasks(subtasks=False, list_ids=[list_id], include_closed=include_closed)
195196
cache.set(cache_key, tasks, expire=TASKS_TTL)
196197
cache.set(f"team_for_list:{list_id}", team.id, expire=TEAMS_TTL)
197198
return tasks

keyup/cli/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def list_tasks(
3030
group_by: Annotated[
3131
str, Parameter(name="--group-by", help="Group by: status (default), assignee, priority")
3232
] = "status",
33+
closed: Annotated[bool, Parameter(name="--closed", help="Include closed/done tasks")] = False,
3334
no_cache: Annotated[bool, Parameter(name="--no-cache", help="Bypass cache")] = False,
3435
interactive: Annotated[bool, Parameter(name="-i", help="Enable interactive mode")] = False,
3536
) -> None:
@@ -79,6 +80,7 @@ def list_tasks(
7980
priority=priority,
8081
due_before=due_before,
8182
group_by=group_by,
83+
include_closed=closed,
8284
team=team or team_obj.id,
8385
space=space or space_obj.id,
8486
project=project or project_obj.id,
@@ -117,6 +119,7 @@ def sprint(
117119
group_by: Annotated[
118120
str, Parameter(name="--group-by", help="Group by: status (default), assignee, priority")
119121
] = "status",
122+
closed: Annotated[bool, Parameter(name="--closed", help="Include closed/done tasks")] = False,
120123
no_cache: Annotated[bool, Parameter(name="--no-cache", help="Bypass cache")] = False,
121124
interactive: Annotated[bool, Parameter(name="-i", help="Enable interactive mode")] = False,
122125
) -> None:
@@ -167,6 +170,7 @@ def sprint(
167170
priority=priority,
168171
due_before=due_before,
169172
group_by=group_by,
173+
include_closed=closed,
170174
team=team or team_obj.id,
171175
space=space or space_obj.id,
172176
project=project or project_obj.id,

keyup/cli/renderer.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def render_list(
145145
priority=None,
146146
due_before=None,
147147
group_by="status",
148+
include_closed: bool = False,
148149
team=None,
149150
space=None,
150151
project=None,
@@ -160,6 +161,7 @@ def render_list(
160161
priority: Filter by priority level (low, normal, high, urgent).
161162
due_before: Filter tasks due before date (YYYY-MM-DD).
162163
group_by: Group by "status" (default), "assignee", or "priority".
164+
include_closed: If True, include closed/done tasks.
163165
team: Team ID from command line.
164166
space: Space ID from command line.
165167
project: Project ID from command line.
@@ -179,14 +181,16 @@ def render_list(
179181
filters.append(f"due_before={due_before}")
180182
if group_by != "status":
181183
filters.append(f"group_by={group_by}")
184+
if include_closed:
185+
filters.append("closed=true")
182186

183187
filter_info = f" {Effect.DIM}[{', '.join(filters)}]{Effect.DIM_OFF}" if filters else ""
184188
print(f"{styled_list_name} :: Team: {team_name}{filter_info}")
185189

186190
if no_cache:
187-
task_list = team_obj.get_all_tasks(subtasks=False, list_ids=[list_obj.id])
191+
task_list = team_obj.get_all_tasks(subtasks=False, list_ids=[list_obj.id], include_closed=include_closed)
188192
else:
189-
task_list = get_tasks_data(team_obj, list_obj.id)
193+
task_list = get_tasks_data(team_obj, list_obj.id, include_closed=include_closed)
190194

191195
# Apply filters
192196
task_list = _filter_tasks(task_list, assignee=assignee, priority=priority, due_before=due_before)
@@ -240,6 +244,8 @@ def render_list(
240244
cmd_parts.append(f"--due-before {due_before}")
241245
if group_by != "status":
242246
cmd_parts.append(f"--group-by {group_by}")
247+
if include_closed:
248+
cmd_parts.append("--closed")
243249
if no_cache:
244250
cmd_parts.append("--no-cache")
245251

tests/test_api_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,10 +413,13 @@ def setup_method(self):
413413
"""Set up test fixtures."""
414414
self.mock_space = Mock()
415415
self.patch_get_lists = patch("keyup.cli.api_client.get_lists_data", side_effect=lambda p: p.lists)
416+
self.patch_get_projects = patch("keyup.cli.api_client.get_projects_data", return_value=[])
416417
self.patch_get_lists.start()
418+
self.patch_get_projects.start()
417419

418420
def teardown_method(self):
419421
self.patch_get_lists.stop()
422+
self.patch_get_projects.stop()
420423

421424
@patch("sys.argv", ["keyup", "--list", "list-000"])
422425
def test_with_list_flag(self):

tests/test_cache.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,26 @@ def test_cache_miss(self, mock_get_cache):
216216
result = get_tasks_data(mock_team, "list-000")
217217

218218
assert result == ["api_task"]
219-
mock_team.get_all_tasks.assert_called_once_with(subtasks=False, list_ids=["list-000"])
219+
mock_team.get_all_tasks.assert_called_once_with(subtasks=False, list_ids=["list-000"], include_closed=False)
220220
mock_cache.set.assert_any_call("tasks:list-000", ["api_task"], expire=TASKS_TTL)
221221
mock_cache.set.assert_any_call("team_for_list:list-000", mock_team.id, expire=TEAMS_TTL)
222222

223+
@patch("keyup.cli.cache.get_cache")
224+
def test_cache_miss_with_include_closed(self, mock_get_cache):
225+
"""Test cache miss with include_closed uses separate cache key."""
226+
mock_cache = Mock()
227+
mock_cache.__contains__ = Mock(return_value=False)
228+
mock_get_cache.return_value = mock_cache
229+
230+
mock_team = Mock()
231+
mock_team.get_all_tasks.return_value = ["closed_task"]
232+
233+
result = get_tasks_data(mock_team, "list-000", include_closed=True)
234+
235+
assert result == ["closed_task"]
236+
mock_team.get_all_tasks.assert_called_once_with(subtasks=False, list_ids=["list-000"], include_closed=True)
237+
mock_cache.set.assert_any_call("tasks:list-000:closed", ["closed_task"], expire=TASKS_TTL)
238+
223239

224240
class TestFindTaskInCache:
225241
"""Tests for find_task_in_cache function."""

0 commit comments

Comments
 (0)