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.
Files changed (32) hide show
  1. recon_github-0.1.6/.agents/rich-ui-architect.md → recon_github-1.0.0/.github/agents/rich-ui-architect.agent.md +5 -0
  2. {recon_github-0.1.6 → recon_github-1.0.0}/PKG-INFO +7 -8
  3. {recon_github-0.1.6 → recon_github-1.0.0}/README.md +6 -7
  4. {recon_github-0.1.6 → recon_github-1.0.0}/app/commands/repo.py +17 -62
  5. {recon_github-0.1.6 → recon_github-1.0.0}/app/ui/repo.py +208 -66
  6. {recon_github-0.1.6 → recon_github-1.0.0}/pyproject.toml +1 -1
  7. recon_github-1.0.0/tests/test_cli_commands.py +212 -0
  8. {recon_github-0.1.6 → recon_github-1.0.0}/uv.lock +1 -1
  9. recon_github-0.1.6/tests/test_cli_commands.py +0 -80
  10. {recon_github-0.1.6 → recon_github-1.0.0}/.github/workflows/cd.yml +0 -0
  11. {recon_github-0.1.6 → recon_github-1.0.0}/.github/workflows/ci.yml +0 -0
  12. {recon_github-0.1.6 → recon_github-1.0.0}/.gitignore +0 -0
  13. {recon_github-0.1.6 → recon_github-1.0.0}/.python-version +0 -0
  14. {recon_github-0.1.6 → recon_github-1.0.0}/LICENSE +0 -0
  15. {recon_github-0.1.6 → recon_github-1.0.0}/ROADMAP.md +0 -0
  16. {recon_github-0.1.6 → recon_github-1.0.0}/app/cli.py +0 -0
  17. {recon_github-0.1.6 → recon_github-1.0.0}/app/commands/auth.py +0 -0
  18. {recon_github-0.1.6 → recon_github-1.0.0}/app/commands/me.py +0 -0
  19. {recon_github-0.1.6 → recon_github-1.0.0}/app/services/auth.py +0 -0
  20. {recon_github-0.1.6 → recon_github-1.0.0}/app/services/github.py +0 -0
  21. {recon_github-0.1.6 → recon_github-1.0.0}/app/services/github_errors.py +0 -0
  22. {recon_github-0.1.6 → recon_github-1.0.0}/app/services/storage.py +0 -0
  23. {recon_github-0.1.6 → recon_github-1.0.0}/app/ui/avatar.py +0 -0
  24. {recon_github-0.1.6 → recon_github-1.0.0}/app/ui/display.py +0 -0
  25. {recon_github-0.1.6 → recon_github-1.0.0}/main.py +0 -0
  26. {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_auth_service.py +0 -0
  27. {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_cli.py +0 -0
  28. {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_github_errors.py +0 -0
  29. {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_github_service.py +0 -0
  30. {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_repo_helpers.py +0 -0
  31. {recon_github-0.1.6 → recon_github-1.0.0}/tests/test_storage.py +0 -0
  32. {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.1.6
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
  [![License](https://img.shields.io/github/license/owenpalfreymandev/reconcli)](https://github.com/owenpalfreymandev/reconcli/blob/main/LICENSE)
26
25
  ## Overview
27
26
 
28
- Recon allows for developers to view basic GitHub analytics in the terminal quickly, rather than opening up their browser. This means it allows agents to interect with the service without having browser capabilities.
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 it's prebuilt components.
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 recomend you install it with [uv package manager](https://docs.astral.sh/uv/getting-started/installation/).
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 commiting, or it will fail CI/CD:
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 could just run `recon details owenpalfreymandev reconcli --contributors` wink wink*
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
  [![License](https://img.shields.io/github/license/owenpalfreymandev/reconcli)](https://github.com/owenpalfreymandev/reconcli/blob/main/LICENSE)
13
12
  ## Overview
14
13
 
15
- Recon allows for developers to view basic GitHub analytics in the terminal quickly, rather than opening up their browser. This means it allows agents to interect with the service without having browser capabilities.
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 it's prebuilt components.
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 recomend you install it with [uv package manager](https://docs.astral.sh/uv/getting-started/installation/).
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 commiting, or it will fail CI/CD:
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 could just run `recon details owenpalfreymandev reconcli --contributors` wink wink*
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
- for repo in repos:
77
- typer.echo(f"{repo['full_name']}")
78
- description = repo.get("description") or "—"
79
- typer.echo(f"description: {format_description(description)}")
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
- display_view_header("Details", details.get("full_name", f"{owner}/{repo}"))
155
+ from app.ui.repo import display_repo_details
164
156
 
165
- # Repo Details
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 build_view_header(title: str, subtitle: str) -> Panel:
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=60, border_style="cyan")
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: list[str],
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
- if contributions:
119
- for contributor in contributions:
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["login"],
122
- str(contributor["commits"]),
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="Contributions",
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
- for language in languages:
147
- language_name, percentage = language.split(": ")
148
- language_table.add_row(
149
- language_name,
150
- percentage,
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
- console.print(header_panel)
160
- console.print(Text(description, style="dim"))
161
- console.print(
162
- Text.assemble(
163
- (visibility.capitalize(), "cyan"),
164
- (" "),
165
- (primary_language, "green"),
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
- [stats_panel, contributions_panel],
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
- console.print(
181
- Text(
182
- f"↗ {url}",
183
- style="dim",
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.1.6"
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
@@ -423,7 +423,7 @@ wheels = [
423
423
 
424
424
  [[package]]
425
425
  name = "recon-github"
426
- version = "0.1.6"
426
+ version = "1.0.0"
427
427
  source = { editable = "." }
428
428
  dependencies = [
429
429
  { name = "dotenv" },
@@ -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