recon-github 0.1.6__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- recon_github-0.1.6/.agents/rich-ui-architect.md → recon_github-1.0.0/.github/agents/rich-ui-architect.agent.md +5 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/PKG-INFO +7 -8
- {recon_github-0.1.6 → recon_github-1.0.0}/README.md +6 -7
- {recon_github-0.1.6 → recon_github-1.0.0}/app/commands/repo.py +17 -62
- {recon_github-0.1.6 → recon_github-1.0.0}/app/ui/repo.py +208 -66
- {recon_github-0.1.6 → recon_github-1.0.0}/pyproject.toml +1 -1
- recon_github-1.0.0/tests/test_cli_commands.py +212 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/uv.lock +1 -1
- recon_github-0.1.6/tests/test_cli_commands.py +0 -80
- {recon_github-0.1.6 → recon_github-1.0.0}/.github/workflows/cd.yml +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/.github/workflows/ci.yml +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/.gitignore +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/.python-version +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/LICENSE +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/ROADMAP.md +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/cli.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/commands/auth.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/commands/me.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/services/auth.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/services/github.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/services/github_errors.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/services/storage.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/ui/avatar.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/app/ui/display.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/main.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_auth_service.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_cli.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_github_errors.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_github_service.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_repo_helpers.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_storage.py +0 -0
- {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_ui_repo.py +0 -0
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rich-ui-architect
|
|
3
|
+
description: "Design and implement consistent, reusable Rich terminal UI for the Recon CLI."
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# Recon CLI — UI/UX & Rich Component Agent
|
|
2
7
|
|
|
3
8
|
You are the UI/UX and terminal-interface agent for **Recon CLI**, a Python CLI application for exploring GitHub profiles and repositories.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: recon-github
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Recon is a powerful CLI companion for developers and tools for agents, turning GitHub activity and repository data into useful insights from your terminal
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -11,7 +11,6 @@ Requires-Dist: rich>=15.0.0
|
|
|
11
11
|
Requires-Dist: typer>=0.27.1
|
|
12
12
|
Description-Content-Type: text/markdown
|
|
13
13
|
|
|
14
|
-
|
|
15
14
|
# Recon CLI
|
|
16
15
|
|
|
17
16
|
Recon is a powerful CLI companion for developers and tools for agents, turning GitHub activity and repository data into useful insights from your terminal.
|
|
@@ -25,9 +24,9 @@ Recon is a powerful CLI companion for developers and tools for agents, turning G
|
|
|
25
24
|
[](https://github.com/owenpalfreymandev/reconcli/blob/main/LICENSE)
|
|
26
25
|
## Overview
|
|
27
26
|
|
|
28
|
-
Recon allows
|
|
27
|
+
Recon allows developers to view basic GitHub analytics in the terminal quickly, rather than opening up their browser. This means it allows agents to interact with the service without having browser capabilities.
|
|
29
28
|
|
|
30
|
-
It is powered by [Typer](github.com/fastapi/typer), a library for building fast CLI tools, and made pretty by [Rich](https://github.com/textualize/rich) and
|
|
29
|
+
It is powered by [Typer](github.com/fastapi/typer), a library for building fast CLI tools, and made pretty by [Rich](https://github.com/textualize/rich) and its prebuilt components.
|
|
31
30
|
## Features
|
|
32
31
|
|
|
33
32
|
* **GitHub Authentication** — Securely authenticate with GitHub using OAuth device flow.
|
|
@@ -41,7 +40,7 @@ It is powered by [Typer](github.com/fastapi/typer), a library for building fast
|
|
|
41
40
|
|
|
42
41
|
## Installation
|
|
43
42
|
|
|
44
|
-
Since we want you to be able to use Recon from anywhere - not just one directory - we
|
|
43
|
+
Since we want you to be able to use Recon from anywhere - not just one directory - we recommend you install it with [uv package manager](https://docs.astral.sh/uv/getting-started/installation/).
|
|
45
44
|
|
|
46
45
|
```bash
|
|
47
46
|
uv tool install recon-github
|
|
@@ -68,7 +67,7 @@ Example output:
|
|
|
68
67
|
╭────── Owen Palfreyman ───────╮ ╭──────────────────────────────╮
|
|
69
68
|
│ Repositories 2 │ │ │
|
|
70
69
|
│ Followers 7 │ │ │
|
|
71
|
-
│ Following 2
|
|
70
|
+
│ Following 2 │ │ │
|
|
72
71
|
│ Location United Kingdom │ │ │
|
|
73
72
|
│ Company — │ │ │
|
|
74
73
|
│ Joined 2023-11-02 │ │ │
|
|
@@ -213,7 +212,7 @@ uv run pytest
|
|
|
213
212
|
uv run pyright
|
|
214
213
|
```
|
|
215
214
|
|
|
216
|
-
Be sure to lint before
|
|
215
|
+
Be sure to lint before committing, or it will fail CI/CD:
|
|
217
216
|
```bash
|
|
218
217
|
uv run ruff check --fix .
|
|
219
218
|
```
|
|
@@ -237,4 +236,4 @@ Recon is developed by Owen Palfreyman.
|
|
|
237
236
|
GitHub: @owenpalfreymandev
|
|
238
237
|
Repository: owenpalfreymandev/reconcli
|
|
239
238
|
|
|
240
|
-
*or could
|
|
239
|
+
*or could just run `recon details owenpalfreymandev reconcli --contributors` wink wink*
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
# Recon CLI
|
|
3
2
|
|
|
4
3
|
Recon is a powerful CLI companion for developers and tools for agents, turning GitHub activity and repository data into useful insights from your terminal.
|
|
@@ -12,9 +11,9 @@ Recon is a powerful CLI companion for developers and tools for agents, turning G
|
|
|
12
11
|
[](https://github.com/owenpalfreymandev/reconcli/blob/main/LICENSE)
|
|
13
12
|
## Overview
|
|
14
13
|
|
|
15
|
-
Recon allows
|
|
14
|
+
Recon allows developers to view basic GitHub analytics in the terminal quickly, rather than opening up their browser. This means it allows agents to interact with the service without having browser capabilities.
|
|
16
15
|
|
|
17
|
-
It is powered by [Typer](github.com/fastapi/typer), a library for building fast CLI tools, and made pretty by [Rich](https://github.com/textualize/rich) and
|
|
16
|
+
It is powered by [Typer](github.com/fastapi/typer), a library for building fast CLI tools, and made pretty by [Rich](https://github.com/textualize/rich) and its prebuilt components.
|
|
18
17
|
## Features
|
|
19
18
|
|
|
20
19
|
* **GitHub Authentication** — Securely authenticate with GitHub using OAuth device flow.
|
|
@@ -28,7 +27,7 @@ It is powered by [Typer](github.com/fastapi/typer), a library for building fast
|
|
|
28
27
|
|
|
29
28
|
## Installation
|
|
30
29
|
|
|
31
|
-
Since we want you to be able to use Recon from anywhere - not just one directory - we
|
|
30
|
+
Since we want you to be able to use Recon from anywhere - not just one directory - we recommend you install it with [uv package manager](https://docs.astral.sh/uv/getting-started/installation/).
|
|
32
31
|
|
|
33
32
|
```bash
|
|
34
33
|
uv tool install recon-github
|
|
@@ -55,7 +54,7 @@ Example output:
|
|
|
55
54
|
╭────── Owen Palfreyman ───────╮ ╭──────────────────────────────╮
|
|
56
55
|
│ Repositories 2 │ │ │
|
|
57
56
|
│ Followers 7 │ │ │
|
|
58
|
-
│ Following 2
|
|
57
|
+
│ Following 2 │ │ │
|
|
59
58
|
│ Location United Kingdom │ │ │
|
|
60
59
|
│ Company — │ │ │
|
|
61
60
|
│ Joined 2023-11-02 │ │ │
|
|
@@ -200,7 +199,7 @@ uv run pytest
|
|
|
200
199
|
uv run pyright
|
|
201
200
|
```
|
|
202
201
|
|
|
203
|
-
Be sure to lint before
|
|
202
|
+
Be sure to lint before committing, or it will fail CI/CD:
|
|
204
203
|
```bash
|
|
205
204
|
uv run ruff check --fix .
|
|
206
205
|
```
|
|
@@ -224,4 +223,4 @@ Recon is developed by Owen Palfreyman.
|
|
|
224
223
|
GitHub: @owenpalfreymandev
|
|
225
224
|
Repository: owenpalfreymandev/reconcli
|
|
226
225
|
|
|
227
|
-
*or could
|
|
226
|
+
*or could just run `recon details owenpalfreymandev reconcli --contributors` wink wink*
|
|
@@ -67,31 +67,24 @@ def format_languages(languages: dict[str, int], max_languages: int = 5) -> list[
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
@app.command()
|
|
70
|
-
def list(
|
|
70
|
+
def list(
|
|
71
|
+
boxy: bool = typer.Option(
|
|
72
|
+
False,
|
|
73
|
+
"--boxy",
|
|
74
|
+
help="Render each repository as a panel (not recommended for long lists).",
|
|
75
|
+
),
|
|
76
|
+
):
|
|
71
77
|
"""See a list of all your repos."""
|
|
72
|
-
from app.services.github import get_user_repos
|
|
78
|
+
from app.services.github import get_authenticated_user, get_user_repos
|
|
79
|
+
from app.ui.repo import display_repository_list
|
|
73
80
|
|
|
81
|
+
user = get_authenticated_user()
|
|
74
82
|
repos = get_user_repos()
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
typer.echo(
|
|
81
|
-
f"visibility: {repo.get('visibility') or ('private' if repo.get('private') else 'public')}"
|
|
82
|
-
)
|
|
83
|
-
typer.echo(f"language: {repo.get('language') or '—'}")
|
|
84
|
-
typer.echo(f"default branch: {repo.get('default_branch') or '—'}")
|
|
85
|
-
typer.echo(
|
|
86
|
-
f"stars: {repo.get('stargazers_count', 0)} forks: {repo.get('forks_count', 0)} open issues: {repo.get('open_issues_count', 0)}"
|
|
87
|
-
)
|
|
88
|
-
typer.echo(f" url: {repo['html_url']}")
|
|
89
|
-
|
|
90
|
-
topics = repo.get("topics") or []
|
|
91
|
-
if topics:
|
|
92
|
-
typer.echo(f" topics: {format_topics(topics)}")
|
|
93
|
-
|
|
94
|
-
typer.echo("")
|
|
83
|
+
display_repository_list(
|
|
84
|
+
repos,
|
|
85
|
+
user.get("login", "Unknown user"),
|
|
86
|
+
boxy=boxy,
|
|
87
|
+
)
|
|
95
88
|
|
|
96
89
|
|
|
97
90
|
@app.command()
|
|
@@ -112,7 +105,6 @@ def details(
|
|
|
112
105
|
get_repo_details,
|
|
113
106
|
get_top_contributors,
|
|
114
107
|
)
|
|
115
|
-
from app.ui.repo import display_view_header
|
|
116
108
|
|
|
117
109
|
details = get_repo_details(owner, repo)
|
|
118
110
|
|
|
@@ -160,43 +152,6 @@ def details(
|
|
|
160
152
|
get_top_contributors(owner, repo, limit=5),
|
|
161
153
|
)
|
|
162
154
|
|
|
163
|
-
|
|
155
|
+
from app.ui.repo import display_repo_details
|
|
164
156
|
|
|
165
|
-
|
|
166
|
-
typer.echo("Repository")
|
|
167
|
-
typer.echo("-----------")
|
|
168
|
-
typer.echo(f"{details.get('full_name', f'{owner}/{repo}')}") # Name
|
|
169
|
-
description = details.get("description") or "—"
|
|
170
|
-
typer.echo(f"description: {format_description(description)}") # Description
|
|
171
|
-
typer.echo(
|
|
172
|
-
f"visibility: {details.get('visibility') or ('private' if details.get('private') else 'public')}" # Visibility
|
|
173
|
-
)
|
|
174
|
-
typer.echo(
|
|
175
|
-
f"url: {details.get('html_url') or f'https://github.com/{owner}/{repo}'}"
|
|
176
|
-
) # URL
|
|
177
|
-
|
|
178
|
-
# Stats
|
|
179
|
-
typer.echo("")
|
|
180
|
-
typer.echo("Stats")
|
|
181
|
-
typer.echo("-----------")
|
|
182
|
-
typer.echo(f"stars: {details.get('stargazers_count') or 0}") # Stars
|
|
183
|
-
typer.echo(f"forks: {details.get('forks_count')}") # Forks
|
|
184
|
-
typer.echo(f"issues: {details.get('open_issues_count') or 0}") # Issues
|
|
185
|
-
typer.echo(f"size: {format_size(details.get('size'))}") # Size
|
|
186
|
-
|
|
187
|
-
typer.echo("")
|
|
188
|
-
typer.echo("Contributions")
|
|
189
|
-
typer.echo("-----------")
|
|
190
|
-
if not contributions:
|
|
191
|
-
typer.echo("No contributor data returned.")
|
|
192
|
-
else:
|
|
193
|
-
for contributor in contributions:
|
|
194
|
-
typer.echo(f"{contributor['login']}: {contributor['commits']} commits")
|
|
195
|
-
|
|
196
|
-
# Tech
|
|
197
|
-
typer.echo("")
|
|
198
|
-
typer.echo("Languages")
|
|
199
|
-
typer.echo("---------")
|
|
200
|
-
|
|
201
|
-
for language in format_languages(language_data):
|
|
202
|
-
typer.echo(language)
|
|
157
|
+
display_repo_details(details, language_data, contributions)
|
|
@@ -7,13 +7,143 @@ from rich.text import Text
|
|
|
7
7
|
console = Console()
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def build_repository_table(repositories: list[dict]) -> Table:
|
|
11
|
+
"""Build the compact repository listing used by ``recon list``."""
|
|
12
|
+
table_width = _repository_table_width()
|
|
13
|
+
repository_width = max(13, min(32, table_width - 66))
|
|
14
|
+
table = Table(
|
|
15
|
+
title=f"Repositories ({len(repositories)})",
|
|
16
|
+
title_style="bold cyan",
|
|
17
|
+
header_style="bold cyan",
|
|
18
|
+
box=None,
|
|
19
|
+
show_edge=False,
|
|
20
|
+
pad_edge=False,
|
|
21
|
+
padding=(0, 1),
|
|
22
|
+
collapse_padding=False,
|
|
23
|
+
width=table_width,
|
|
24
|
+
)
|
|
25
|
+
table.add_column(
|
|
26
|
+
"Repository",
|
|
27
|
+
no_wrap=True,
|
|
28
|
+
overflow="ellipsis",
|
|
29
|
+
width=repository_width,
|
|
30
|
+
max_width=repository_width,
|
|
31
|
+
)
|
|
32
|
+
table.add_column(
|
|
33
|
+
"Visibility", no_wrap=True, overflow="ellipsis", width=10, max_width=10
|
|
34
|
+
)
|
|
35
|
+
table.add_column(
|
|
36
|
+
"Language", no_wrap=True, overflow="ellipsis", width=9, max_width=9
|
|
37
|
+
)
|
|
38
|
+
table.add_column(
|
|
39
|
+
"Updated", no_wrap=True, overflow="ellipsis", width=10, max_width=10
|
|
40
|
+
)
|
|
41
|
+
table.add_column("Branch", no_wrap=True, overflow="ellipsis", width=6, max_width=6)
|
|
42
|
+
table.add_column("Stars", justify="right", no_wrap=True, width=5)
|
|
43
|
+
table.add_column("Forks", justify="right", no_wrap=True, width=5)
|
|
44
|
+
table.add_column("Issues", justify="right", no_wrap=True, width=5)
|
|
45
|
+
|
|
46
|
+
for repository in repositories:
|
|
47
|
+
visibility = repository.get("visibility") or (
|
|
48
|
+
"private" if repository.get("private") else "public"
|
|
49
|
+
)
|
|
50
|
+
table.add_row(
|
|
51
|
+
str(repository.get("full_name") or repository.get("name") or "—"),
|
|
52
|
+
str(visibility or "—"),
|
|
53
|
+
str(repository.get("language") or "—"),
|
|
54
|
+
_format_updated(repository.get("updated_at")),
|
|
55
|
+
str(repository.get("default_branch") or "—"),
|
|
56
|
+
_format_count(repository.get("stargazers_count")),
|
|
57
|
+
_format_count(repository.get("forks_count")),
|
|
58
|
+
_format_count(repository.get("open_issues_count")),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if not repositories:
|
|
62
|
+
table.add_row("No repositories found.", *["—"] * 7)
|
|
63
|
+
|
|
64
|
+
return table
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def display_repository_list(
|
|
68
|
+
repositories: list[dict], username: str, boxy: bool = False
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Render a compact, scan-friendly table of repositories."""
|
|
71
|
+
width = _repository_box_width() if boxy else _repository_table_width()
|
|
72
|
+
console.print(build_view_header("List", username, width=width))
|
|
73
|
+
if boxy:
|
|
74
|
+
for repository in repositories:
|
|
75
|
+
console.print(build_repository_panel(repository))
|
|
76
|
+
if not repositories:
|
|
77
|
+
console.print(Panel("No repositories found.", width=width))
|
|
78
|
+
return
|
|
79
|
+
console.print(build_repository_table(repositories))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_repository_panel(repository: dict) -> Panel:
|
|
83
|
+
"""Build the box-style repository summary used by ``recon list --boxy``."""
|
|
84
|
+
from app.commands.repo import format_topics
|
|
85
|
+
|
|
86
|
+
visibility = repository.get("visibility") or (
|
|
87
|
+
"private" if repository.get("private") else "public"
|
|
88
|
+
)
|
|
89
|
+
language = repository.get("language") or "—"
|
|
90
|
+
branch = repository.get("default_branch") or "—"
|
|
91
|
+
topics = repository.get("topics") or []
|
|
92
|
+
topic_text = (
|
|
93
|
+
format_topics([str(topic) for topic in topics]).replace(", ", " · ")
|
|
94
|
+
if topics
|
|
95
|
+
else "—"
|
|
96
|
+
)
|
|
97
|
+
content = Text()
|
|
98
|
+
content.append(
|
|
99
|
+
f"{str(visibility).capitalize()} · {language} · {branch}\n",
|
|
100
|
+
style="cyan",
|
|
101
|
+
)
|
|
102
|
+
content.append(
|
|
103
|
+
f"Stars {_format_count(repository.get('stargazers_count'))} "
|
|
104
|
+
f"Forks {_format_count(repository.get('forks_count'))} "
|
|
105
|
+
f"Issues {_format_count(repository.get('open_issues_count'))}\n"
|
|
106
|
+
)
|
|
107
|
+
content.append(topic_text, style="dim")
|
|
108
|
+
return Panel(
|
|
109
|
+
content,
|
|
110
|
+
title=str(repository.get("full_name") or repository.get("name") or "—"),
|
|
111
|
+
width=_repository_box_width(),
|
|
112
|
+
border_style="cyan",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _repository_table_width() -> int:
|
|
117
|
+
"""Calculate the shared width for the list table and header."""
|
|
118
|
+
return min(max(1, console.width - 1), 98)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _repository_box_width() -> int:
|
|
122
|
+
"""Calculate the narrower width used by boxy repository summaries."""
|
|
123
|
+
return min(max(1, console.width - 1), 72)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _format_count(value: object) -> str:
|
|
127
|
+
"""Format repository counters while keeping missing values predictable."""
|
|
128
|
+
if value is None:
|
|
129
|
+
return "0"
|
|
130
|
+
return f"{value:,}" if isinstance(value, int) else str(value)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _format_updated(value: object) -> str:
|
|
134
|
+
"""Show the date portion of GitHub's ISO timestamp."""
|
|
135
|
+
if not value:
|
|
136
|
+
return "—"
|
|
137
|
+
return str(value).split("T", 1)[0]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_view_header(title: str, subtitle: str, width: int = 60) -> Panel:
|
|
11
141
|
"""Create the shared header used by focused repository views."""
|
|
12
142
|
content = Text(justify="center")
|
|
13
143
|
content.append(title.upper(), style="bold cyan")
|
|
14
144
|
content.append("\n")
|
|
15
145
|
content.append(subtitle, style="bold")
|
|
16
|
-
return Panel(content, width=
|
|
146
|
+
return Panel(content, width=width, border_style="cyan")
|
|
17
147
|
|
|
18
148
|
|
|
19
149
|
def display_view_header(title: str, subtitle: str) -> None:
|
|
@@ -57,7 +187,7 @@ def format_bytes(size: int | None) -> str:
|
|
|
57
187
|
|
|
58
188
|
def display_repo_details(
|
|
59
189
|
details: dict,
|
|
60
|
-
languages:
|
|
190
|
+
languages: dict[str, int],
|
|
61
191
|
contributions: list[dict],
|
|
62
192
|
):
|
|
63
193
|
"""Display a repository overview."""
|
|
@@ -67,70 +197,51 @@ def display_repo_details(
|
|
|
67
197
|
visibility = details.get("visibility") or (
|
|
68
198
|
"private" if details.get("private") else "public"
|
|
69
199
|
)
|
|
70
|
-
primary_language = details.get("language") or "—"
|
|
71
200
|
url = details.get("html_url") or "—"
|
|
72
201
|
|
|
73
202
|
header_panel = build_view_header("Details", full_name)
|
|
74
203
|
|
|
75
|
-
# Statistics
|
|
76
|
-
stats = Table(
|
|
77
|
-
show_header=False,
|
|
78
|
-
box=None,
|
|
79
|
-
pad_edge=False,
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
stats.add_column("Metric", style="dim")
|
|
83
|
-
stats.add_column("Value", justify="right")
|
|
84
|
-
|
|
85
|
-
stats.add_row(
|
|
86
|
-
"★ Stars",
|
|
87
|
-
str(details.get("stargazers_count") or 0),
|
|
88
|
-
)
|
|
89
|
-
stats.add_row(
|
|
90
|
-
"⑂ Forks",
|
|
91
|
-
str(details.get("forks_count") or 0),
|
|
92
|
-
)
|
|
93
|
-
stats.add_row(
|
|
94
|
-
"! Issues",
|
|
95
|
-
str(details.get("open_issues_count") or 0),
|
|
96
|
-
)
|
|
97
|
-
stats.add_row(
|
|
98
|
-
"Size",
|
|
99
|
-
format_size(details.get("size")),
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
stats_panel = Panel(
|
|
103
|
-
stats,
|
|
104
|
-
title="Statistics",
|
|
105
|
-
expand=False,
|
|
106
|
-
)
|
|
107
|
-
|
|
108
204
|
# Contributions
|
|
109
205
|
contribution_table = Table(
|
|
110
206
|
show_header=False,
|
|
111
207
|
box=None,
|
|
112
208
|
pad_edge=False,
|
|
209
|
+
padding=(0, 1),
|
|
113
210
|
)
|
|
114
211
|
|
|
115
212
|
contribution_table.add_column("Contributor")
|
|
116
213
|
contribution_table.add_column("Commits", justify="right")
|
|
214
|
+
contribution_table.add_column("Share", justify="right")
|
|
117
215
|
|
|
118
|
-
|
|
119
|
-
|
|
216
|
+
ranked_contributions = contributions[:5]
|
|
217
|
+
if ranked_contributions:
|
|
218
|
+
total_commits = sum(
|
|
219
|
+
int(contributor.get("commits") or 0) for contributor in ranked_contributions
|
|
220
|
+
)
|
|
221
|
+
for index, contributor in enumerate(ranked_contributions):
|
|
222
|
+
commits = int(contributor.get("commits") or 0)
|
|
223
|
+
percentage = commits / total_commits * 100 if total_commits else 0
|
|
120
224
|
contribution_table.add_row(
|
|
121
|
-
contributor
|
|
122
|
-
|
|
225
|
+
str(contributor.get("login") or "Unknown"),
|
|
226
|
+
_format_count(commits),
|
|
227
|
+
_overview_bar_label(
|
|
228
|
+
percentage, spaced=index < len(ranked_contributions) - 1
|
|
229
|
+
),
|
|
123
230
|
)
|
|
124
231
|
else:
|
|
125
232
|
contribution_table.add_row(
|
|
126
233
|
"No contributor data",
|
|
127
234
|
"—",
|
|
235
|
+
"",
|
|
128
236
|
)
|
|
237
|
+
for _ in range(len(ranked_contributions) if ranked_contributions else 1, 5):
|
|
238
|
+
contribution_table.add_row("", "", "")
|
|
129
239
|
|
|
130
240
|
contributions_panel = Panel(
|
|
131
241
|
contribution_table,
|
|
132
|
-
title="
|
|
242
|
+
title="Contributors",
|
|
133
243
|
expand=False,
|
|
244
|
+
border_style="cyan",
|
|
134
245
|
)
|
|
135
246
|
|
|
136
247
|
# Languages
|
|
@@ -138,51 +249,82 @@ def display_repo_details(
|
|
|
138
249
|
show_header=False,
|
|
139
250
|
box=None,
|
|
140
251
|
pad_edge=False,
|
|
252
|
+
expand=True,
|
|
253
|
+
padding=(0, 1),
|
|
141
254
|
)
|
|
142
255
|
|
|
143
256
|
language_table.add_column("Language")
|
|
144
|
-
language_table.add_column("Usage")
|
|
257
|
+
language_table.add_column("Usage", justify="right")
|
|
145
258
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
259
|
+
if not languages:
|
|
260
|
+
language_table.add_row("No language data returned.", "")
|
|
261
|
+
else:
|
|
262
|
+
total_bytes = sum(languages.values())
|
|
263
|
+
ranked_languages = sorted(
|
|
264
|
+
languages.items(), key=lambda item: item[1], reverse=True
|
|
265
|
+
)[:5]
|
|
266
|
+
for index, (language, byte_count) in enumerate(ranked_languages):
|
|
267
|
+
percentage = byte_count / total_bytes * 100 if total_bytes else 0
|
|
268
|
+
language_table.add_row(
|
|
269
|
+
language,
|
|
270
|
+
_overview_bar_label(
|
|
271
|
+
percentage, spaced=index < len(ranked_languages) - 1
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
for _ in range(min(len(languages), 5), 5):
|
|
275
|
+
language_table.add_row("", "")
|
|
276
|
+
if not languages:
|
|
277
|
+
for _ in range(1, 5):
|
|
278
|
+
language_table.add_row("", "")
|
|
152
279
|
|
|
153
280
|
languages_panel = Panel(
|
|
154
281
|
language_table,
|
|
155
282
|
title="Languages",
|
|
156
283
|
expand=False,
|
|
284
|
+
border_style="cyan",
|
|
157
285
|
)
|
|
158
286
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
287
|
+
repository = Table(show_header=False, box=None, pad_edge=False, expand=True)
|
|
288
|
+
repository.add_column("Label", style="dim", no_wrap=True)
|
|
289
|
+
repository.add_column("Value", overflow="fold")
|
|
290
|
+
repository.add_row("Description", description)
|
|
291
|
+
repository.add_row("Visibility", visibility.capitalize())
|
|
292
|
+
repository.add_row("URL", url)
|
|
293
|
+
repository.add_row("★ Stars", str(details.get("stargazers_count") or 0))
|
|
294
|
+
repository.add_row("⑂ Forks", str(details.get("forks_count") or 0))
|
|
295
|
+
repository.add_row("! Issues", str(details.get("open_issues_count") or 0))
|
|
296
|
+
repository.add_row("Size", format_size(details.get("size")))
|
|
297
|
+
repository_panel = Panel(
|
|
298
|
+
repository,
|
|
299
|
+
title="Repository",
|
|
300
|
+
border_style="cyan",
|
|
301
|
+
width=60,
|
|
167
302
|
)
|
|
168
|
-
console.print()
|
|
169
303
|
|
|
304
|
+
console.print(header_panel)
|
|
305
|
+
console.print()
|
|
170
306
|
console.print(
|
|
171
307
|
Columns(
|
|
172
|
-
[
|
|
308
|
+
[contributions_panel, languages_panel],
|
|
173
309
|
expand=False,
|
|
174
310
|
equal=True,
|
|
311
|
+
padding=(0, 0),
|
|
175
312
|
)
|
|
176
313
|
)
|
|
314
|
+
console.print()
|
|
315
|
+
console.print(repository_panel)
|
|
177
316
|
|
|
178
|
-
console.print(languages_panel)
|
|
179
317
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
318
|
+
def _progress_bar(percentage: float, width: int = 12) -> str:
|
|
319
|
+
"""Build a compact proportional bar for overview panels."""
|
|
320
|
+
filled = round(max(0, min(100, percentage)) / 100 * width)
|
|
321
|
+
return f"[green]{'█' * filled}[/green]"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _overview_bar_label(percentage: float, spaced: bool = True) -> str:
|
|
325
|
+
"""Render an overview bar with a compact line of breathing room."""
|
|
326
|
+
suffix = "\n" if spaced else ""
|
|
327
|
+
return f"{_progress_bar(percentage)} {percentage:.1f}%{suffix}"
|
|
186
328
|
|
|
187
329
|
|
|
188
330
|
def display_contributors(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "recon-github"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "1.0.0"
|
|
4
4
|
description = "Recon is a powerful CLI companion for developers and tools for agents, turning GitHub activity and repository data into useful insights from your terminal"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from unittest.mock import Mock
|
|
2
|
+
|
|
3
|
+
from typer.testing import CliRunner
|
|
4
|
+
|
|
5
|
+
from app.cli import app
|
|
6
|
+
from app.commands import auth as auth_command
|
|
7
|
+
from app.commands import me as me_command
|
|
8
|
+
from app.ui.repo import build_repository_table
|
|
9
|
+
|
|
10
|
+
runner = CliRunner()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_login_logout_delegate(monkeypatch):
|
|
14
|
+
login, logout = Mock(), Mock()
|
|
15
|
+
monkeypatch.setattr(auth_command.auth, "login", login)
|
|
16
|
+
monkeypatch.setattr(auth_command.auth, "logout", logout)
|
|
17
|
+
assert runner.invoke(app, ["login"]).exit_code == 0
|
|
18
|
+
assert runner.invoke(app, ["logout"]).exit_code == 0
|
|
19
|
+
login.assert_called_once_with()
|
|
20
|
+
logout.assert_called_once_with()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def user():
|
|
24
|
+
return {
|
|
25
|
+
"login": "octo",
|
|
26
|
+
"name": "Octo",
|
|
27
|
+
"public_repos": 1,
|
|
28
|
+
"followers": 2,
|
|
29
|
+
"following": 3,
|
|
30
|
+
"created_at": "2020-01-01T00:00:00Z",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_me_and_scout_display_user(monkeypatch):
|
|
35
|
+
get_me, get_user, display = (
|
|
36
|
+
Mock(return_value=user()),
|
|
37
|
+
Mock(return_value=user()),
|
|
38
|
+
Mock(),
|
|
39
|
+
)
|
|
40
|
+
monkeypatch.setattr("app.services.github.get_authenticated_user", get_me)
|
|
41
|
+
monkeypatch.setattr("app.services.github.get_user", get_user)
|
|
42
|
+
monkeypatch.setattr(me_command, "display_user", display)
|
|
43
|
+
assert runner.invoke(app, ["me"]).exit_code == 0
|
|
44
|
+
assert runner.invoke(app, ["scout", "octo"]).exit_code == 0
|
|
45
|
+
get_me.assert_called_once_with()
|
|
46
|
+
get_user.assert_called_once_with("octo")
|
|
47
|
+
assert display.call_count == 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_list_handles_empty_and_missing_optional_fields(monkeypatch):
|
|
51
|
+
repo = {"full_name": "o/r", "html_url": "https://github.com/o/r"}
|
|
52
|
+
monkeypatch.setattr(
|
|
53
|
+
"app.services.github.get_authenticated_user",
|
|
54
|
+
Mock(return_value={"login": "octo"}),
|
|
55
|
+
)
|
|
56
|
+
monkeypatch.setattr(
|
|
57
|
+
"app.services.github.get_user_repos", Mock(side_effect=[[], [repo]])
|
|
58
|
+
)
|
|
59
|
+
assert runner.invoke(app, ["list"]).exit_code == 0
|
|
60
|
+
result = runner.invoke(app, ["list"])
|
|
61
|
+
assert result.exit_code == 0
|
|
62
|
+
assert "o/r" in result.output
|
|
63
|
+
assert "LIST" in result.output
|
|
64
|
+
assert "octo" in result.output
|
|
65
|
+
assert "Repositories (1)" in result.output
|
|
66
|
+
assert "—" in result.output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_list_renders_repository_columns(monkeypatch):
|
|
70
|
+
monkeypatch.setattr(
|
|
71
|
+
"app.services.github.get_authenticated_user",
|
|
72
|
+
Mock(return_value={"login": "octo"}),
|
|
73
|
+
)
|
|
74
|
+
monkeypatch.setattr(
|
|
75
|
+
"app.services.github.get_user_repos",
|
|
76
|
+
Mock(
|
|
77
|
+
return_value=[
|
|
78
|
+
{
|
|
79
|
+
"full_name": "octo/a-really-long-repository-name",
|
|
80
|
+
"visibility": "public",
|
|
81
|
+
"language": "Python",
|
|
82
|
+
"updated_at": "2026-09-25T12:34:56Z",
|
|
83
|
+
"default_branch": "main",
|
|
84
|
+
"stargazers_count": 1_234,
|
|
85
|
+
"forks_count": 56,
|
|
86
|
+
"open_issues_count": 7,
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
result = runner.invoke(app, ["list"])
|
|
93
|
+
|
|
94
|
+
assert result.exit_code == 0
|
|
95
|
+
assert [column.header for column in build_repository_table([{}]).columns] == [
|
|
96
|
+
"Repository",
|
|
97
|
+
"Visibility",
|
|
98
|
+
"Language",
|
|
99
|
+
"Updated",
|
|
100
|
+
"Branch",
|
|
101
|
+
"Stars",
|
|
102
|
+
"Forks",
|
|
103
|
+
"Issues",
|
|
104
|
+
]
|
|
105
|
+
assert "1,234" in result.output
|
|
106
|
+
assert "2026-09-25" in result.output
|
|
107
|
+
assert "Visibility" in result.output
|
|
108
|
+
assert "Language" in result.output
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_list_boxy_renders_repository_panels(monkeypatch):
|
|
112
|
+
monkeypatch.setattr(
|
|
113
|
+
"app.services.github.get_authenticated_user",
|
|
114
|
+
Mock(return_value={"login": "octo"}),
|
|
115
|
+
)
|
|
116
|
+
monkeypatch.setattr(
|
|
117
|
+
"app.services.github.get_user_repos",
|
|
118
|
+
Mock(
|
|
119
|
+
return_value=[
|
|
120
|
+
{
|
|
121
|
+
"full_name": "octo/reconcli",
|
|
122
|
+
"visibility": "public",
|
|
123
|
+
"language": "Python",
|
|
124
|
+
"default_branch": "main",
|
|
125
|
+
"stargazers_count": 0,
|
|
126
|
+
"forks_count": 0,
|
|
127
|
+
"open_issues_count": 0,
|
|
128
|
+
"topics": [
|
|
129
|
+
"cli",
|
|
130
|
+
"developer-tools",
|
|
131
|
+
"github",
|
|
132
|
+
"python",
|
|
133
|
+
"rich-cli",
|
|
134
|
+
"terminal",
|
|
135
|
+
],
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
result = runner.invoke(app, ["list", "--boxy"])
|
|
142
|
+
|
|
143
|
+
assert result.exit_code == 0
|
|
144
|
+
assert "LIST" in result.output
|
|
145
|
+
assert "octo/reconcli" in result.output
|
|
146
|
+
assert "Public · Python · main" in result.output
|
|
147
|
+
assert "Stars 0 Forks 0 Issues 0" in result.output
|
|
148
|
+
assert (
|
|
149
|
+
"cli · developer-tools · github · python · rich-cli + 1 more..."
|
|
150
|
+
in result.output
|
|
151
|
+
)
|
|
152
|
+
assert "Repositories (" not in result.output
|
|
153
|
+
assert result.output.count("─" * 70) >= 2
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_details_rejects_both_views():
|
|
157
|
+
result = runner.invoke(app, ["details", "o", "r", "--contributors", "--languages"])
|
|
158
|
+
assert result.exit_code != 0
|
|
159
|
+
assert "Choose either" in result.output
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_details_default_calls_services_and_prints(monkeypatch):
|
|
163
|
+
details = {
|
|
164
|
+
"full_name": "o/r",
|
|
165
|
+
"description": "desc",
|
|
166
|
+
"visibility": "public",
|
|
167
|
+
"html_url": "https://github.com/o/r",
|
|
168
|
+
"stargazers_count": 3,
|
|
169
|
+
"forks_count": 2,
|
|
170
|
+
"open_issues_count": 1,
|
|
171
|
+
"size": 1,
|
|
172
|
+
}
|
|
173
|
+
monkeypatch.setattr(
|
|
174
|
+
"app.services.github.get_repo_details", Mock(return_value=details)
|
|
175
|
+
)
|
|
176
|
+
monkeypatch.setattr(
|
|
177
|
+
"app.services.github.get_languages",
|
|
178
|
+
Mock(
|
|
179
|
+
return_value={
|
|
180
|
+
"Python": 6,
|
|
181
|
+
"Rust": 5,
|
|
182
|
+
"Go": 4,
|
|
183
|
+
"JavaScript": 3,
|
|
184
|
+
"HTML": 2,
|
|
185
|
+
"CSS": 1,
|
|
186
|
+
}
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
monkeypatch.setattr(
|
|
190
|
+
"app.services.github.get_top_contributors",
|
|
191
|
+
Mock(
|
|
192
|
+
return_value=[
|
|
193
|
+
{"login": "octo", "commits": 6},
|
|
194
|
+
{"login": "user2", "commits": 5},
|
|
195
|
+
{"login": "user3", "commits": 4},
|
|
196
|
+
{"login": "user4", "commits": 3},
|
|
197
|
+
{"login": "user5", "commits": 2},
|
|
198
|
+
{"login": "user6", "commits": 1},
|
|
199
|
+
]
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
result = runner.invoke(app, ["details", "o", "r"])
|
|
203
|
+
assert result.exit_code == 0
|
|
204
|
+
assert "DETAILS" in result.output
|
|
205
|
+
assert "Contributors" in result.output
|
|
206
|
+
assert "Repository" in result.output
|
|
207
|
+
assert "Languages" in result.output
|
|
208
|
+
assert "octo" in result.output
|
|
209
|
+
assert "user5" in result.output
|
|
210
|
+
assert "user6" not in result.output
|
|
211
|
+
assert "CSS" not in result.output
|
|
212
|
+
assert "Stats" not in result.output
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
from unittest.mock import Mock
|
|
2
|
-
|
|
3
|
-
from typer.testing import CliRunner
|
|
4
|
-
|
|
5
|
-
from app.cli import app
|
|
6
|
-
from app.commands import auth as auth_command
|
|
7
|
-
from app.commands import me as me_command
|
|
8
|
-
|
|
9
|
-
runner = CliRunner()
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_login_logout_delegate(monkeypatch):
|
|
13
|
-
login, logout = Mock(), Mock()
|
|
14
|
-
monkeypatch.setattr(auth_command.auth, "login", login)
|
|
15
|
-
monkeypatch.setattr(auth_command.auth, "logout", logout)
|
|
16
|
-
assert runner.invoke(app, ["login"]).exit_code == 0
|
|
17
|
-
assert runner.invoke(app, ["logout"]).exit_code == 0
|
|
18
|
-
login.assert_called_once_with()
|
|
19
|
-
logout.assert_called_once_with()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def user():
|
|
23
|
-
return {
|
|
24
|
-
"login": "octo",
|
|
25
|
-
"name": "Octo",
|
|
26
|
-
"public_repos": 1,
|
|
27
|
-
"followers": 2,
|
|
28
|
-
"following": 3,
|
|
29
|
-
"created_at": "2020-01-01T00:00:00Z",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_me_and_scout_display_user(monkeypatch):
|
|
34
|
-
get_me, get_user, display = (
|
|
35
|
-
Mock(return_value=user()),
|
|
36
|
-
Mock(return_value=user()),
|
|
37
|
-
Mock(),
|
|
38
|
-
)
|
|
39
|
-
monkeypatch.setattr("app.services.github.get_authenticated_user", get_me)
|
|
40
|
-
monkeypatch.setattr("app.services.github.get_user", get_user)
|
|
41
|
-
monkeypatch.setattr(me_command, "display_user", display)
|
|
42
|
-
assert runner.invoke(app, ["me"]).exit_code == 0
|
|
43
|
-
assert runner.invoke(app, ["scout", "octo"]).exit_code == 0
|
|
44
|
-
get_me.assert_called_once_with()
|
|
45
|
-
get_user.assert_called_once_with("octo")
|
|
46
|
-
assert display.call_count == 2
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_list_handles_empty_and_missing_optional_fields(monkeypatch):
|
|
50
|
-
repo = {"full_name": "o/r", "html_url": "https://github.com/o/r"}
|
|
51
|
-
monkeypatch.setattr(
|
|
52
|
-
"app.services.github.get_user_repos", Mock(side_effect=[[], [repo]])
|
|
53
|
-
)
|
|
54
|
-
assert runner.invoke(app, ["list"]).exit_code == 0
|
|
55
|
-
result = runner.invoke(app, ["list"])
|
|
56
|
-
assert result.exit_code == 0
|
|
57
|
-
assert "o/r" in result.output
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def test_details_rejects_both_views():
|
|
61
|
-
result = runner.invoke(app, ["details", "o", "r", "--contributors", "--languages"])
|
|
62
|
-
assert result.exit_code != 0
|
|
63
|
-
assert "Choose either" in result.output
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def test_details_default_calls_services_and_prints(monkeypatch):
|
|
67
|
-
details = {"full_name": "o/r", "description": "desc", "size": 1}
|
|
68
|
-
monkeypatch.setattr(
|
|
69
|
-
"app.services.github.get_repo_details", Mock(return_value=details)
|
|
70
|
-
)
|
|
71
|
-
monkeypatch.setattr(
|
|
72
|
-
"app.services.github.get_languages", Mock(return_value={"Python": 1})
|
|
73
|
-
)
|
|
74
|
-
monkeypatch.setattr(
|
|
75
|
-
"app.services.github.get_top_contributors", Mock(return_value=[])
|
|
76
|
-
)
|
|
77
|
-
result = runner.invoke(app, ["details", "o", "r"])
|
|
78
|
-
assert result.exit_code == 0
|
|
79
|
-
assert "Repository" in result.output
|
|
80
|
-
assert "Languages" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|