diff --git a/CHANGELOG.md b/CHANGELOG.md index 730e1b7..6589aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `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. + ## [2.0.0] - 2026-07-21 ### Changed diff --git a/docs/cli.md b/docs/cli.md index 7d1b71c..899a1b2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -52,6 +52,10 @@ github-rest-cli repo get --name my-repo --format json | `-o` / `--org` | No | authenticated user | Organization owner | | `-f` / `--format` | No | `table` | Output format: `table` or `json` | +Table mode shows a key/value detail view (`Field` | `Value`) with curated fields: `name`, `full_name`, `owner`, `description`, `visibility`, `default_branch`, `language`, `topics`, `html_url`, `created_at`, `updated_at`, `pushed_at`, `fork`, `archived`, `disabled`. + +JSON mode returns the full raw GitHub repository object from the API. + ### `repo list` List repositories for the authenticated user. @@ -69,7 +73,7 @@ github-rest-cli repo list --role owner --format json | `-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 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`. ### `repo create` @@ -152,6 +156,9 @@ github-rest-cli environment create --name my-repo --env staging --org my-org - `table` (default) — PrettyTable display - `json` — JSON string suitable for piping or scripting +For `repo get`, table is a curated key/value detail view; JSON is the full API payload. +For `repo list`, table and JSON both use the summary fields `name`, `owner`, `url`, `visibility`. + ```shell github-rest-cli repo list --format json github-rest-cli repo get --name my-repo --format table diff --git a/src/github_rest_cli/api.py b/src/github_rest_cli/api.py index 14ac7a1..930bd01 100644 --- a/src/github_rest_cli/api.py +++ b/src/github_rest_cli/api.py @@ -1,6 +1,6 @@ import requests from github_rest_cli.globals import get_api_url, get_headers -from github_rest_cli.utils import rich_output, CliOutput +from github_rest_cli.utils import rich_output, format_repo_get, format_repo_list def request_with_handling( @@ -68,12 +68,7 @@ def get_repository(name: str, org: str = None, output_format: str = "table"): if not response: return None - data = response.json() - - if output_format == "json": - return CliOutput(data).get_json_output() - - return CliOutput([data]).default_format() + return format_repo_get(response.json(), output_format) def list_repositories(page: int, property: str, role: str, output_format: str): @@ -93,12 +88,7 @@ def list_repositories(page: int, property: str, role: str, output_format: str): if not response: return None - output = CliOutput(response.json()) - - if output_format == "json": - return output.json_format() - - return output.default_format() + return format_repo_list(response.json(), output_format) def create_repository(name: str, visibility: str, org: str = None, empty: bool = False): diff --git a/src/github_rest_cli/utils.py b/src/github_rest_cli/utils.py index 5d0c1f2..67acc8f 100644 --- a/src/github_rest_cli/utils.py +++ b/src/github_rest_cli/utils.py @@ -1,70 +1,105 @@ from rich import print as rprint import json - -class CliFormatOutput: - def to_json(self, data): - return json.dumps(data, indent=2) - - def to_table(self, fields, data): - from prettytable import PrettyTable - - table = PrettyTable() - table.title = "GitHub Repositories" - table.header_style = "upper" - - if isinstance(fields, list): - table.field_names = fields - - # Align all columns to left. - table.align = "l" - - if isinstance(data, list): - for row in data: - table.add_row(row) - - return table - - -class CliOutput: - def __init__(self, data): - self.data = data - self.formatter = CliFormatOutput() - - def json_format(self): - repos = { - "repositories": [ - { - "name": f.get("name"), - "owner": f.get("owner", {}).get("login"), - "url": f.get("html_url"), - "visibility": f.get("visibility"), - } - for f in self.data - ], - } - return self.formatter.to_json(repos) - - def table_format(self): - fields = ["name", "owner", "url", "visibility"] - values = [] - for repo in self.data: - values.append( - [ - repo.get("name"), - repo.get("owner", {}).get("login"), - repo.get("html_url"), - repo.get("visibility"), - ] - ) - - return self.formatter.to_table(fields, values) - - def get_json_output(self): - return self.formatter.to_json(self.data) - - def default_format(self): - return self.table_format() +REPO_SUMMARY_COLUMNS = ["name", "owner", "url", "visibility"] + +REPO_DETAIL_FIELDS = [ + "name", + "full_name", + "owner", + "description", + "visibility", + "default_branch", + "language", + "topics", + "html_url", + "created_at", + "updated_at", + "pushed_at", + "fork", + "archived", + "disabled", +] + + +def to_json(data) -> str: + return json.dumps(data, indent=2) + + +def to_table(rows, *, columns, title): + from prettytable import PrettyTable + + table = PrettyTable() + table.title = title + table.header_style = "upper" + table.field_names = columns + table.align = "l" + + for row in rows: + table.add_row(row) + + return table + + +def to_key_value_table(pairs, *, title): + return to_table(pairs, columns=["field", "value"], title=title) + + +def _stringify(value) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def project_repo_summary(repo: dict) -> dict: + return { + "name": repo.get("name"), + "owner": repo.get("owner", {}).get("login"), + "url": repo.get("html_url"), + "visibility": repo.get("visibility"), + } + + +def project_repo_detail(repo: dict) -> list[tuple[str, str]]: + topics = repo.get("topics") or [] + values = { + "name": repo.get("name"), + "full_name": repo.get("full_name"), + "owner": repo.get("owner", {}).get("login"), + "description": repo.get("description") or "", + "visibility": repo.get("visibility"), + "default_branch": repo.get("default_branch"), + "language": repo.get("language"), + "topics": ", ".join(topics), + "html_url": repo.get("html_url"), + "created_at": repo.get("created_at"), + "updated_at": repo.get("updated_at"), + "pushed_at": repo.get("pushed_at"), + "fork": repo.get("fork"), + "archived": repo.get("archived"), + "disabled": repo.get("disabled"), + } + return [(field, _stringify(values[field])) for field in REPO_DETAIL_FIELDS] + + +def format_repo_list(repos, output_format: str = "table"): + summaries = [project_repo_summary(repo) for repo in repos] + + if output_format == "json": + return to_json({"repositories": summaries}) + + rows = [[s[column] for column in REPO_SUMMARY_COLUMNS] for s in summaries] + return to_table(rows, columns=REPO_SUMMARY_COLUMNS, title="GitHub Repositories") + + +def format_repo_get(repo, output_format: str = "table"): + if output_format == "json": + return to_json(repo) + + pairs = project_repo_detail(repo) + return to_key_value_table(pairs, title="GitHub Repository") def rich_output(message: str, format_str: str = "bold green"): diff --git a/tests/test_api.py b/tests/test_api.py index a00f3e5..245f11e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -46,9 +46,20 @@ def test_create_repository_org(mocker): SAMPLE_REPO = { "name": "test-repo", - "owner": {"login": "test-user"}, + "full_name": "test-user/test-repo", + "owner": {"login": "test-user", "id": 1}, + "description": "A test repository", "html_url": "https://github.com/test-user/test-repo", "visibility": "public", + "default_branch": "main", + "language": "Python", + "topics": ["cli", "github"], + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-06-01T00:00:00Z", + "pushed_at": "2024-06-02T00:00:00Z", + "fork": False, + "archived": False, + "disabled": False, } @@ -70,6 +81,8 @@ def test_get_repository_json_format(mocker): assert isinstance(result, str) assert '"name": "test-repo"' in result assert '"visibility": "public"' in result + assert '"login": "test-user"' in result + assert '"id": 1' in result def test_get_repository_table_format(mocker): @@ -78,8 +91,13 @@ def test_get_repository_table_format(mocker): result = api.get_repository("test-repo", output_format="table") table_text = str(result) + assert "GITHUB REPOSITORY" in table_text.upper() + assert "FIELD" in table_text.upper() + assert "VALUE" in table_text.upper() assert "test-repo" in table_text assert "test-user" in table_text + assert "default_branch" in table_text + assert "main" in table_text assert not table_text.strip().startswith("{") @@ -91,6 +109,8 @@ def test_list_repositories_json_format(mocker): assert isinstance(result, str) assert '"repositories"' in result assert '"name": "test-repo"' in result + assert '"owner": "test-user"' in result + assert '"login"' not in result def test_list_repositories_table_format(mocker): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..2e67b20 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,107 @@ +from github_rest_cli.utils import ( + format_repo_get, + format_repo_list, + project_repo_detail, + project_repo_summary, +) + + +SAMPLE_REPO = { + "name": "test-repo", + "full_name": "test-user/test-repo", + "owner": {"login": "test-user", "id": 1}, + "description": "A test repository", + "html_url": "https://github.com/test-user/test-repo", + "visibility": "public", + "default_branch": "main", + "language": "Python", + "topics": ["cli", "github"], + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-06-01T00:00:00Z", + "pushed_at": "2024-06-02T00:00:00Z", + "fork": False, + "archived": False, + "disabled": False, +} + + +def test_project_repo_summary(): + assert project_repo_summary(SAMPLE_REPO) == { + "name": "test-repo", + "owner": "test-user", + "url": "https://github.com/test-user/test-repo", + "visibility": "public", + } + + +def test_project_repo_detail_ordered_fields(): + pairs = project_repo_detail(SAMPLE_REPO) + fields = [field for field, _ in pairs] + + assert fields == [ + "name", + "full_name", + "owner", + "description", + "visibility", + "default_branch", + "language", + "topics", + "html_url", + "created_at", + "updated_at", + "pushed_at", + "fork", + "archived", + "disabled", + ] + assert dict(pairs)["topics"] == "cli, github" + assert dict(pairs)["fork"] == "false" + + +def test_project_repo_detail_null_and_missing_fields(): + repo = { + "name": "sparse-repo", + "owner": {}, + "description": None, + "topics": None, + } + + values = dict(project_repo_detail(repo)) + + assert values["name"] == "sparse-repo" + assert values["owner"] == "" + assert values["description"] == "" + assert values["topics"] == "" + assert values["full_name"] == "" + assert values["language"] == "" + assert values["fork"] == "" + + +def test_project_repo_detail_empty_topics(): + repo = {**SAMPLE_REPO, "topics": []} + values = dict(project_repo_detail(repo)) + assert values["topics"] == "" + + +def test_format_repo_get_json_is_raw(): + result = format_repo_get(SAMPLE_REPO, "json") + assert '"login": "test-user"' in result + assert '"id": 1' in result + assert '"repositories"' not in result + + +def test_format_repo_get_table_is_key_value(): + table_text = str(format_repo_get(SAMPLE_REPO, "table")) + assert "GITHUB REPOSITORY" in table_text.upper() + assert "FIELD" in table_text.upper() + assert "VALUE" in table_text.upper() + assert "default_branch" in table_text + assert "main" in table_text + + +def test_format_repo_list_json_is_projected(): + result = format_repo_list([SAMPLE_REPO], "json") + assert '"repositories"' in result + assert '"owner": "test-user"' in result + assert '"login"' not in result diff --git a/uv.lock b/uv.lock index e0e8a1e..436836b 100644 --- a/uv.lock +++ b/uv.lock @@ -84,7 +84,7 @@ wheels = [ [[package]] name = "github-rest-cli" -version = "1.0.3" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "dynaconf" },