diff --git a/CHANGELOG.md b/CHANGELOG.md index 6589aac..7e29fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `repo list --all` fetches every page by following GitHub `Link` headers. +- `repo list --page` selects the page number (default `1`). + ### Changed +- `repo list --page` no longer means page size. Use `--per-page` (default `20`) instead. - `repo get` table output is a curated key/value detail view instead of the list summary columns. - Split output formatting into projection helpers and generic renderers (`format_repo_get` / `format_repo_list`). ### Documentation - Clarified `repo get` vs `repo list` table/JSON field behavior in the CLI guide. +- Documented `repo list` pagination flags (`--per-page`, `--page`, `--all`). ## [2.0.0] - 2026-07-21 diff --git a/docs/cli.md b/docs/cli.md index 899a1b2..c550d4d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,18 +62,22 @@ List repositories for the authenticated user. ```shell github-rest-cli repo list -github-rest-cli repo list --page 50 --sort pushed +github-rest-cli repo list --per-page 50 --sort pushed +github-rest-cli repo list --page 2 --per-page 30 +github-rest-cli repo list --all --format json github-rest-cli repo list --role owner --format json ``` | Flag | Required | Default | Description | | --- | --- | --- | --- | -| `-p` / `--page` | No | `20` | Number of results (`per_page`) | +| `--per-page` | No | `20` | Results per page (`per_page`, max 100) | +| `-p` / `--page` | No | `1` | Page number to fetch (ignored with `--all`) | +| `--all` | No | off | Fetch every page by following `Link` headers | | `-s` / `--sort` | No | `pushed` | Sort field (e.g. `pushed`, `updated`, `created`) | | `-r` / `--role` | No | unset | Filter by affiliation/role | | `-f` / `--format` | No | `table` | Output format: `table` or `json` | -`--format` only changes presentation. Table and JSON use the same repository set and the same summary fields: `name`, `owner`, `url`, `visibility`. +`--format` only changes presentation. Table and JSON use the same repository set and the same summary fields: `name`, `owner`, `url`, `visibility`. With `--all`, that set is the concatenated result of every page. ### `repo create` diff --git a/src/github_rest_cli/api.py b/src/github_rest_cli/api.py index 930bd01..89df4a5 100644 --- a/src/github_rest_cli/api.py +++ b/src/github_rest_cli/api.py @@ -71,24 +71,57 @@ def get_repository(name: str, org: str = None, output_format: str = "table"): return format_repo_get(response.json(), output_format) -def list_repositories(page: int, property: str, role: str, output_format: str): +def list_repositories( + per_page: int, + page: int, + property: str, + role: str, + output_format: str, + fetch_all: bool = False, +): headers = get_headers() url = build_url("user", "repos") + start_page = 1 if fetch_all else page + params = {"per_page": per_page, "page": start_page, "sort": property} + if role: + params["type"] = role + + if not fetch_all: + response = request_with_handling( + "GET", + url, + params=params, + headers=headers, + error_msg={ + 401: "Unauthorized access. Please check your token or credentials." + }, + ) + if not response: + return None + return format_repo_list(response.json(), output_format) + + repos = [] + next_url = url + next_params = params + + while next_url: + response = request_with_handling( + "GET", + next_url, + params=next_params, + headers=headers, + error_msg={ + 401: "Unauthorized access. Please check your token or credentials." + }, + ) + if not response: + return None - params = {"per_page": page, "sort": property, "type": role} - - response = request_with_handling( - "GET", - url, - params=params, - headers=headers, - error_msg={401: "Unauthorized access. Please check your token or credentials."}, - ) - - if not response: - return None + repos.extend(response.json()) + next_url = response.links.get("next", {}).get("url") + next_params = None - return format_repo_list(response.json(), output_format) + return format_repo_list(repos, output_format) def create_repository(name: str, visibility: str, org: str = None, empty: bool = False): diff --git a/src/github_rest_cli/parser.py b/src/github_rest_cli/parser.py index 6bb4151..c27dde6 100644 --- a/src/github_rest_cli/parser.py +++ b/src/github_rest_cli/parser.py @@ -53,7 +53,14 @@ def run_get_repo(args: Namespace) -> None: def run_list_repo(args: Namespace) -> None: - repos = list_repositories(args.page, args.sort, args.role, args.format) + repos = list_repositories( + args.per_page, + args.page, + args.sort, + args.role, + args.format, + fetch_all=args.fetch_all, + ) if repos is not None: print(repos) # noqa: T201 @@ -142,14 +149,28 @@ def build_parser() -> argparse.ArgumentParser: dest="role", help="List repositories by role", ) + list_repo_parser.add_argument( + "--per-page", + required=False, + default=20, + type=int, + dest="per_page", + help="Number of results per page (max 100)", + ) list_repo_parser.add_argument( "-p", "--page", required=False, - default=20, + default=1, type=int, dest="page", - help="The number of results", + help="Page number to fetch (ignored with --all)", + ) + list_repo_parser.add_argument( + "--all", + action="store_true", + dest="fetch_all", + help="Fetch all pages (follows Link headers)", ) list_repo_parser.add_argument( "-s", diff --git a/tests/test_api.py b/tests/test_api.py index 245f11e..cb0b87b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -104,7 +104,7 @@ def test_get_repository_table_format(mocker): def test_list_repositories_json_format(mocker): _mock_repo_response(mocker, [SAMPLE_REPO]) - result = api.list_repositories(20, "pushed", None, "json") + result = api.list_repositories(20, 1, "pushed", None, "json") assert isinstance(result, str) assert '"repositories"' in result @@ -116,8 +116,71 @@ def test_list_repositories_json_format(mocker): def test_list_repositories_table_format(mocker): _mock_repo_response(mocker, [SAMPLE_REPO]) - result = api.list_repositories(20, "pushed", None, "table") + result = api.list_repositories(20, 1, "pushed", None, "table") table_text = str(result) assert "test-repo" in table_text assert "GITHUB REPOSITORIES" in table_text.upper() + + +def test_list_repositories_passes_page_params(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [SAMPLE_REPO] + mock_response.links = {} + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, return_value=mock_response) + + api.list_repositories(30, 2, "updated", "owner", "json") + + assert request_mock.call_count == 1 + _, kwargs = request_mock.call_args + assert kwargs["params"] == { + "per_page": 30, + "page": 2, + "sort": "updated", + "type": "owner", + } + + +def test_list_repositories_fetch_all_follows_link_headers(mocker): + mocker.patch(GET_HEADERS_FUNCTION, return_value={"Authorization": "token fake"}) + + page1 = { + "name": "repo-one", + "owner": {"login": "test-user"}, + "html_url": "https://github.com/test-user/repo-one", + "visibility": "public", + } + page2 = { + "name": "repo-two", + "owner": {"login": "test-user"}, + "html_url": "https://github.com/test-user/repo-two", + "visibility": "private", + } + + first = mocker.Mock() + first.status_code = 200 + first.json.return_value = [page1] + first.links = {"next": {"url": "https://api.github.com/user/repos?page=2"}} + + second = mocker.Mock() + second.status_code = 200 + second.json.return_value = [page2] + second.links = {} + + request_mock = mocker.patch(REQUEST_HANDLER_FUNCTION, side_effect=[first, second]) + + result = api.list_repositories(20, 5, "pushed", None, "json", fetch_all=True) + + assert request_mock.call_count == 2 + first_args, first_kwargs = request_mock.call_args_list[0] + assert first_kwargs["params"]["page"] == 1 + assert first_kwargs["params"]["per_page"] == 20 + + second_args, second_kwargs = request_mock.call_args_list[1] + assert second_args[1] == "https://api.github.com/user/repos?page=2" + assert second_kwargs["params"] is None + + assert '"name": "repo-one"' in result + assert '"name": "repo-two"' in result diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 4e19eff..6d5c145 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -82,3 +82,23 @@ def test_repo_create_public_and_private_conflict(capsys): ) assert exc_info.value.code == 2 + + +def test_repo_list_pagination_defaults(): + parser = build_parser() + args = parser.parse_args(["repo", "list"]) + + assert args.per_page == 20 + assert args.page == 1 + assert args.fetch_all is False + + +def test_repo_list_pagination_flags(): + parser = build_parser() + args = parser.parse_args( + ["repo", "list", "--per-page", "50", "--page", "3", "--all"] + ) + + assert args.per_page == 50 + assert args.page == 3 + assert args.fetch_all is True