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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
61 changes: 47 additions & 14 deletions src/github_rest_cli/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
27 changes: 24 additions & 3 deletions src/github_rest_cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
67 changes: 65 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
20 changes: 20 additions & 0 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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