Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
server-side) and the fetched page is full β€” previously matches beyond the page
were dropped with no indication. Specific states are already filtered
server-side when a project is given (#728)
- πŸ› `yt issues list` (and `search`) no longer issue a single unbounded request
for large/whole-project fetches, which could exceed a gateway's per-request
time limit and be killed (caller saw 0 issues, no error). Results are now
fetched in bounded pages (≀ `--page-size`, default 100) and accumulated, so
each request stays small; `--top`/`--max-results` cap the total (#727)

### Added
- ✨ New `compact` field profile for issues: core fields plus `description` but
Expand Down
59 changes: 54 additions & 5 deletions tests/managers/test_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,41 @@ async def test_search_issues_with_project_filter(self, issue_manager):
call_args = issue_manager.issue_service.search_issues.call_args
assert "project: TEST" in call_args[1]["query"]

@pytest.mark.asyncio
async def test_search_issues_paginates_large_result(self, issue_manager):
"""#727: a large result is fetched in bounded pages (each request ≀
page_size) and accumulated, so no single request is oversized."""
page1 = {"status": "success", "data": [{"idReadable": f"P-{i}"} for i in range(100)]}
page2 = {"status": "success", "data": [{"idReadable": f"P-{i}"} for i in range(100, 150)]}
issue_manager.issue_service.search_issues = AsyncMock(side_effect=[page1, page2])

result = await issue_manager.search_issues("", page_size=100, format_output="json")

assert result["count"] == 150
assert issue_manager.issue_service.search_issues.call_count == 2
# Second request skips past the first page.
assert issue_manager.issue_service.search_issues.call_args_list[1].kwargs["skip"] == 100

@pytest.mark.asyncio
async def test_search_issues_stops_at_top_cap(self, issue_manager):
"""#727: --top bounds the total fetched, so paging stops once the cap is met."""
page = {"status": "success", "data": [{"idReadable": f"P-{i}"} for i in range(100)]}
issue_manager.issue_service.search_issues = AsyncMock(return_value=page)

result = await issue_manager.search_issues("", top=100, page_size=100, format_output="json")

assert result["count"] == 100
assert issue_manager.issue_service.search_issues.call_count == 1

@pytest.mark.asyncio
async def test_search_issues_error_on_first_page_surfaces(self, issue_manager):
"""A failure on the first page is returned as an error, not swallowed."""
issue_manager.issue_service.search_issues = AsyncMock(return_value={"status": "error", "message": "boom"})

result = await issue_manager.search_issues("", format_output="json")

assert result["status"] == "error"

@staticmethod
def _issue_with_state(issue_id, state_name, *, field_name="Status", summary="summary"):
"""Build an issue whose state lives in a StateIssueCustomField, matching
Expand Down Expand Up @@ -259,20 +294,34 @@ async def test_list_issues_profile_resolves_to_field_set(self, issue_manager):
assert "customFields" not in fields # minimal carries no customFields

@pytest.mark.asyncio
async def test_list_issues_clientside_fallback_warns_on_full_page(self, issue_manager):
"""#728: when state is filtered client-side (no project) and the fetched
page is full, warn that matches beyond it may be missing."""
async def test_list_issues_clientside_fallback_warns_when_capped(self, issue_manager):
"""#728: when state is filtered client-side (no project) and --top caps the
fetch, warn that matches beyond the cap may be missing."""
issue_manager.project_service.discover_state_field.return_value = {"status": "error"}
# page_size default is 100 β†’ a 100-issue page is "full".
# top=100 caps the fetch; a full 100-issue page means more may exist.
data = [self._issue_with_state(f"P-{i}", "Done") for i in range(100)]
issue_manager.issue_service.search_issues.return_value = {"status": "success", "data": data}

with patch("youtrack_cli.managers.issues.logger") as mock_logger:
await issue_manager.list_issues(state="In Progress") # no project_id β†’ fallback
await issue_manager.list_issues(state="In Progress", top=100) # no project_id β†’ fallback

assert mock_logger.warning.called
assert "client-side" in mock_logger.warning.call_args[0][0]

@pytest.mark.asyncio
async def test_list_issues_clientside_fallback_no_warning_when_uncapped(self, issue_manager):
"""#728: with no cap, search_issues paginates the full set, so client-side
filtering is complete β€” no truncation warning."""
issue_manager.project_service.discover_state_field.return_value = {"status": "error"}
# A short page (< page_size) signals the end, so pagination stops naturally.
data = [self._issue_with_state(f"P-{i}", "Done") for i in range(10)]
issue_manager.issue_service.search_issues.return_value = {"status": "success", "data": data}

with patch("youtrack_cli.managers.issues.logger") as mock_logger:
await issue_manager.list_issues(state="In Progress") # no cap

assert not mock_logger.warning.called

@pytest.mark.asyncio
async def test_list_issues_multiword_field_name_falls_back_to_clientside(self, issue_manager):
"""A multi-word discovered field name (e.g. 'Workflow State') would misparse
Expand Down
71 changes: 45 additions & 26 deletions youtrack_cli/managers/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,25 +218,42 @@ async def search_issues(
if project_id:
full_query = f"project: {project_id} {query}".strip()

# Use service to search issues
result = await self.issue_service.search_issues(
query=full_query,
fields=fields,
top=top,
skip=skip,
)

# Add count field for backward compatibility with command layer
if result["status"] == "success" and "data" in result:
issues = result["data"]
if isinstance(issues, list):
result["count"] = len(issues)
# Fetch in bounded pages so a large/whole-project result never becomes a
# single oversized request that a gateway can time out and kill (#727).
# Each request asks for at most `page_size` issues; paging stops at the
# overall cap (`top`/`max_results`) or when a short page signals the end.
overall_limit = top if top is not None else max_results
per_page = page_size if page_size and page_size > 0 else 100
collected: list[dict[str, Any]] = []
offset = skip or 0
while overall_limit is None or len(collected) < overall_limit:
this_page = per_page if overall_limit is None else min(per_page, overall_limit - len(collected))
page_result = await self.issue_service.search_issues(
query=full_query,
fields=fields,
top=this_page,
skip=offset,
)
if page_result.get("status") != "success":
# Nothing collected yet β†’ surface the error. Otherwise keep the
# pages we already have rather than losing them to a late failure.
if not collected:
return page_result
logger.warning("Issue pagination stopped after a failed page at skip=%d", offset)
break
page_data = page_result.get("data") or []
if not isinstance(page_data, list):
page_data = []
collected.extend(page_data)
offset += len(page_data)
if len(page_data) < this_page:
break # short page β†’ no more results

result: dict[str, Any] = {"status": "success", "data": collected, "count": len(collected)}

# Add presentation logic for different output formats
if result["status"] == "success" and format_output != "json":
issues = result["data"]
if isinstance(issues, list):
result["formatted_output"] = self._format_issues_for_display(issues, format_output, no_pagination)
if format_output != "json":
result["formatted_output"] = self._format_issues_for_display(collected, format_output, no_pagination)

return result

Expand Down Expand Up @@ -693,25 +710,27 @@ async def list_issues(
format_output=format_output,
no_pagination=no_pagination,
use_cached_fields=use_cached_fields,
page_size=page_size,
max_results=max_results,
)

if client_side_state and result.get("status") == "success" and isinstance(result.get("data"), list):
# Fallback path only (no project / discovery failed): filters the
# fetched page, so matches beyond it are missed β€” widen with --top.
# Fallback path only (no project / discovery failed): state is filtered
# over whatever search_issues fetched. That is now the full paginated
# set unless capped by --top/--max-results, in which case matches beyond
# the cap may be missing (#728).
raw_count = len(result["data"])
wanted = client_side_state.strip().casefold()
result["data"] = [
issue for issue in result["data"] if self._get_state_field_value(issue).casefold() == wanted
]
result["count"] = len(result["data"])
# The state field couldn't be filtered server-side, so this only saw one
# page. If that page looks full, later matches may be silently missed (#728).
limit = top or page_size
if raw_count >= limit:
cap = top if top is not None else max_results
if cap is not None and raw_count >= cap:
logger.warning(
"State filter was applied client-side to a single full page of %d issues, so matching "
"issues beyond it may be missing. Pass --project-id so the state can be filtered "
"server-side across all issues, or widen --top.",
"State filter was applied client-side and --top/--max-results capped the fetch at %d "
"issues, so matching issues beyond the cap may be missing. Pass --project-id so the "
"state can be filtered server-side across all issues, or raise the cap.",
raw_count,
)

Expand Down
Loading