recon-github 0.1.0__py3-none-any.whl
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.
- app/cli.py +13 -0
- app/commands/auth.py +17 -0
- app/commands/me.py +23 -0
- app/commands/repo.py +202 -0
- app/services/auth.py +79 -0
- app/services/github.py +184 -0
- app/services/github_errors.py +39 -0
- app/services/storage.py +27 -0
- app/ui/avatar.py +24 -0
- app/ui/display.py +53 -0
- app/ui/repo.py +365 -0
- recon_github-0.1.0.dist-info/METADATA +241 -0
- recon_github-0.1.0.dist-info/RECORD +16 -0
- recon_github-0.1.0.dist-info/WHEEL +5 -0
- recon_github-0.1.0.dist-info/licenses/LICENSE +21 -0
- recon_github-0.1.0.dist-info/top_level.txt +1 -0
app/cli.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from app.commands import auth, me, repo
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
# Commands that can be ran in the CLI
|
|
8
|
+
app.command(name="login")(auth.login)
|
|
9
|
+
app.command(name="logout")(auth.logout)
|
|
10
|
+
app.command(name="me")(me.me)
|
|
11
|
+
app.command(name="scout")(me.scout)
|
|
12
|
+
app.command(name="list")(repo.list)
|
|
13
|
+
app.command(name="details")(repo.details)
|
app/commands/auth.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from app.services import auth
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.command()
|
|
9
|
+
def login():
|
|
10
|
+
"""Login to GitHub using the device flow."""
|
|
11
|
+
auth.login()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def logout():
|
|
16
|
+
"""Logout of GitHub."""
|
|
17
|
+
auth.logout()
|
app/commands/me.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from app.ui.display import display_user
|
|
4
|
+
|
|
5
|
+
app = typer.Typer()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.command()
|
|
9
|
+
def me():
|
|
10
|
+
"""Get information about me!"""
|
|
11
|
+
from app.services.github import get_authenticated_user
|
|
12
|
+
|
|
13
|
+
user = get_authenticated_user()
|
|
14
|
+
display_user(user)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def scout(user: str):
|
|
19
|
+
"""Scout another GitHub user."""
|
|
20
|
+
from app.services.github import get_user
|
|
21
|
+
|
|
22
|
+
profile = get_user(user)
|
|
23
|
+
display_user(profile)
|
app/commands/repo.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from builtins import list as builtins_list
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
app = typer.Typer()
|
|
7
|
+
|
|
8
|
+
units = ["KB", "MB", "GB", "TB", "PB"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_topics(topics: list[str], max_topics: int = 5) -> str:
|
|
12
|
+
shown = topics[:max_topics]
|
|
13
|
+
remaining = len(topics) - len(shown)
|
|
14
|
+
rendered = ", ".join(shown)
|
|
15
|
+
if remaining > 0:
|
|
16
|
+
rendered = f"{rendered} + {remaining} more..."
|
|
17
|
+
return rendered
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_description(description: str, max_chars: int = 90) -> str:
|
|
21
|
+
if len(description) <= max_chars:
|
|
22
|
+
return description
|
|
23
|
+
|
|
24
|
+
# Truncate at or after max_chars, but never in the middle of a word.
|
|
25
|
+
cutoff = description.find(" ", max_chars)
|
|
26
|
+
if cutoff == -1:
|
|
27
|
+
cutoff = max_chars
|
|
28
|
+
|
|
29
|
+
shown = description[:cutoff].rstrip()
|
|
30
|
+
return f"{shown}..."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_size(size: int) -> str:
|
|
34
|
+
units = ["KB", "MB", "GB", "TB", "PB"]
|
|
35
|
+
|
|
36
|
+
current_unit = 0
|
|
37
|
+
scaled_size: float = size
|
|
38
|
+
|
|
39
|
+
while scaled_size >= 1000 and current_unit < len(units) - 1:
|
|
40
|
+
scaled_size = scaled_size / 1000
|
|
41
|
+
current_unit += 1
|
|
42
|
+
|
|
43
|
+
return f"{scaled_size:.1f} {units[current_unit]}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_languages(languages: dict[str, int], max_languages: int = 5) -> list[str]:
|
|
47
|
+
if not languages:
|
|
48
|
+
return ["No language data returned."]
|
|
49
|
+
|
|
50
|
+
total_bytes = sum(languages.values())
|
|
51
|
+
|
|
52
|
+
sorted_languages = sorted(languages.items(), key=lambda item: item[1], reverse=True)
|
|
53
|
+
|
|
54
|
+
shown = sorted_languages[:max_languages]
|
|
55
|
+
remaining = len(sorted_languages) - len(shown)
|
|
56
|
+
|
|
57
|
+
formatted = []
|
|
58
|
+
|
|
59
|
+
for language, byte_count in shown:
|
|
60
|
+
percent = (byte_count / total_bytes * 100) if total_bytes else 0
|
|
61
|
+
formatted.append(f"{language}: {percent:.1f}%")
|
|
62
|
+
|
|
63
|
+
if remaining > 0:
|
|
64
|
+
formatted.append(f"+ {remaining} more...")
|
|
65
|
+
|
|
66
|
+
return formatted
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def list():
|
|
71
|
+
"""See a list of all your repos."""
|
|
72
|
+
from app.services.github import get_user_repos
|
|
73
|
+
|
|
74
|
+
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("")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def details(
|
|
99
|
+
owner: str = typer.Argument(..., help="Repository owner, e.g. owenpalfreymandev"),
|
|
100
|
+
repo: str = typer.Argument(..., help="Repository name, e.g. reconcli"),
|
|
101
|
+
contributors: bool = typer.Option(False, help="View contributors in more detail."),
|
|
102
|
+
languages: bool = typer.Option(False, help="View language usage in more detail."),
|
|
103
|
+
):
|
|
104
|
+
"""Gain insights into your repo"""
|
|
105
|
+
if contributors and languages:
|
|
106
|
+
raise typer.BadParameter("Choose either --contributors or --languages.")
|
|
107
|
+
|
|
108
|
+
from app.services.github import (
|
|
109
|
+
ContributorResults,
|
|
110
|
+
get_authenticated_user,
|
|
111
|
+
get_languages,
|
|
112
|
+
get_repo_details,
|
|
113
|
+
get_top_contributors,
|
|
114
|
+
)
|
|
115
|
+
from app.ui.repo import display_view_header
|
|
116
|
+
|
|
117
|
+
details = get_repo_details(owner, repo)
|
|
118
|
+
|
|
119
|
+
if languages:
|
|
120
|
+
from app.ui.repo import display_languages
|
|
121
|
+
|
|
122
|
+
display_languages(
|
|
123
|
+
details.get("full_name", f"{owner}/{repo}"),
|
|
124
|
+
get_languages(owner, repo),
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if contributors:
|
|
129
|
+
from app.ui.repo import display_contributors
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
authenticated_user = get_authenticated_user()
|
|
133
|
+
except RuntimeError:
|
|
134
|
+
# Contributor statistics remain useful when GitHub cannot identify
|
|
135
|
+
# the token owner for this request.
|
|
136
|
+
authenticated_user = {}
|
|
137
|
+
results = cast(
|
|
138
|
+
ContributorResults,
|
|
139
|
+
get_top_contributors(
|
|
140
|
+
owner,
|
|
141
|
+
repo,
|
|
142
|
+
limit=5,
|
|
143
|
+
current_login=authenticated_user.get("login"),
|
|
144
|
+
include_metadata=True,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
display_contributors(
|
|
148
|
+
details.get("full_name", f"{owner}/{repo}"),
|
|
149
|
+
results.contributors,
|
|
150
|
+
results.total_contributors,
|
|
151
|
+
results.total_commits,
|
|
152
|
+
authenticated_user.get("login"),
|
|
153
|
+
results.current_contributor,
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
language_data = get_languages(owner, repo)
|
|
158
|
+
contributions = cast(
|
|
159
|
+
builtins_list[dict[str, str | int | None]],
|
|
160
|
+
get_top_contributors(owner, repo, limit=5),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
display_view_header("Details", details.get("full_name", f"{owner}/{repo}"))
|
|
164
|
+
|
|
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)
|
app/services/auth.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import webbrowser
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
from app.services.storage import clear_token, save_token
|
|
9
|
+
|
|
10
|
+
load_dotenv()
|
|
11
|
+
|
|
12
|
+
CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
|
|
13
|
+
|
|
14
|
+
DEVICE_CODE_URL = "https://github.com/login/device/code"
|
|
15
|
+
TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def login():
|
|
19
|
+
if not CLIENT_ID:
|
|
20
|
+
raise RuntimeError("Missing GITHUB_CLIENT_ID in .env")
|
|
21
|
+
|
|
22
|
+
device = request_device_code()
|
|
23
|
+
|
|
24
|
+
print("Opening GitHub authentication...")
|
|
25
|
+
|
|
26
|
+
webbrowser.open(device.get("verification_uri_complete", device["verification_uri"]))
|
|
27
|
+
|
|
28
|
+
print(f"If required, enter code: {device['user_code']}")
|
|
29
|
+
|
|
30
|
+
token = poll_for_token(device)
|
|
31
|
+
|
|
32
|
+
save_token(token)
|
|
33
|
+
|
|
34
|
+
print("Successfully logged into GitHub!")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def request_device_code():
|
|
38
|
+
response = requests.post(
|
|
39
|
+
DEVICE_CODE_URL,
|
|
40
|
+
headers={"Accept": "application/json"},
|
|
41
|
+
data={"client_id": CLIENT_ID, "scope": "read:user repo"},
|
|
42
|
+
timeout=10,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
|
|
47
|
+
return response.json()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def poll_for_token(device):
|
|
51
|
+
interval = device.get("interval", 5)
|
|
52
|
+
|
|
53
|
+
while True:
|
|
54
|
+
response = requests.post(
|
|
55
|
+
TOKEN_URL,
|
|
56
|
+
headers={"Accept": "application/json"},
|
|
57
|
+
data={
|
|
58
|
+
"client_id": CLIENT_ID,
|
|
59
|
+
"device_code": device["device_code"],
|
|
60
|
+
"grant_type": ("urn:ietf:params:oauth:grant-type:device_code"),
|
|
61
|
+
},
|
|
62
|
+
timeout=10,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
data = response.json()
|
|
66
|
+
|
|
67
|
+
if "access_token" in data:
|
|
68
|
+
return data["access_token"]
|
|
69
|
+
|
|
70
|
+
if data.get("error") != "authorization_pending":
|
|
71
|
+
raise RuntimeError(data)
|
|
72
|
+
|
|
73
|
+
time.sleep(interval)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def logout():
|
|
77
|
+
clear_token()
|
|
78
|
+
|
|
79
|
+
print("Successfully logged out of GitHub!")
|
app/services/github.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from app.services.github_errors import (
|
|
8
|
+
github_headers,
|
|
9
|
+
raise_for_github_error,
|
|
10
|
+
)
|
|
11
|
+
from app.services.storage import get_token
|
|
12
|
+
|
|
13
|
+
GITHUB_API = "https://api.github.com"
|
|
14
|
+
|
|
15
|
+
TOP_CONTRIBUTORS = 10
|
|
16
|
+
STATS_POLL_ATTEMPTS = 5
|
|
17
|
+
STATS_POLL_INTERVAL_SECONDS = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ContributorResults:
|
|
22
|
+
"""Top contributor rows plus metadata from GitHub's statistics response."""
|
|
23
|
+
|
|
24
|
+
contributors: list[dict[str, str | int | None]]
|
|
25
|
+
total_contributors: int
|
|
26
|
+
total_commits: int
|
|
27
|
+
current_contributor: dict[str, str | int | None] | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_auth_headers():
|
|
31
|
+
token = get_token()
|
|
32
|
+
|
|
33
|
+
if not token:
|
|
34
|
+
raise RuntimeError("Not authenticated with GitHub. Run `auth login`.")
|
|
35
|
+
|
|
36
|
+
return github_headers(token)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_authenticated_user():
|
|
40
|
+
response = requests.get(
|
|
41
|
+
f"{GITHUB_API}/user",
|
|
42
|
+
headers=_get_auth_headers(),
|
|
43
|
+
timeout=10,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
raise_for_github_error(response)
|
|
47
|
+
|
|
48
|
+
return response.json()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_user(username: str):
|
|
52
|
+
"""Return a GitHub user's public profile."""
|
|
53
|
+
if not username:
|
|
54
|
+
raise ValueError("username must not be empty")
|
|
55
|
+
|
|
56
|
+
response = requests.get(
|
|
57
|
+
f"{GITHUB_API}/users/{quote(username, safe='')}",
|
|
58
|
+
headers=_get_auth_headers(),
|
|
59
|
+
timeout=10,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
raise_for_github_error(response)
|
|
63
|
+
|
|
64
|
+
return response.json()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_user_repos():
|
|
68
|
+
response = requests.get(
|
|
69
|
+
f"{GITHUB_API}/user/repos",
|
|
70
|
+
headers=_get_auth_headers(),
|
|
71
|
+
timeout=10,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
raise_for_github_error(response)
|
|
75
|
+
|
|
76
|
+
return response.json()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_repo_details(owner: str, repo: str):
|
|
80
|
+
response = requests.get(
|
|
81
|
+
f"{GITHUB_API}/repos/{owner}/{repo}",
|
|
82
|
+
headers=_get_auth_headers(),
|
|
83
|
+
timeout=10,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
raise_for_github_error(response)
|
|
87
|
+
|
|
88
|
+
return response.json()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_languages(owner: str, repo: str):
|
|
92
|
+
response = requests.get(
|
|
93
|
+
f"{GITHUB_API}/repos/{owner}/{repo}/languages",
|
|
94
|
+
headers=_get_auth_headers(),
|
|
95
|
+
timeout=10,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
raise_for_github_error(response)
|
|
99
|
+
|
|
100
|
+
return response.json()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_top_contributors(
|
|
104
|
+
owner: str,
|
|
105
|
+
repo: str,
|
|
106
|
+
limit: int = TOP_CONTRIBUTORS,
|
|
107
|
+
current_login: str | None = None,
|
|
108
|
+
include_metadata: bool = False,
|
|
109
|
+
) -> list[dict[str, str | int | None]] | ContributorResults:
|
|
110
|
+
"""
|
|
111
|
+
Return contributors ranked by commit count.
|
|
112
|
+
|
|
113
|
+
GitHub may return 202 while it calculates statistics,
|
|
114
|
+
so we retry until the data is available.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
if limit < 1:
|
|
118
|
+
raise ValueError("limit must be at least 1")
|
|
119
|
+
|
|
120
|
+
url = f"{GITHUB_API}/repos/{owner}/{repo}/stats/contributors"
|
|
121
|
+
|
|
122
|
+
for attempt in range(STATS_POLL_ATTEMPTS):
|
|
123
|
+
response = requests.get(
|
|
124
|
+
url,
|
|
125
|
+
headers=_get_auth_headers(),
|
|
126
|
+
timeout=10,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if response.status_code == 202:
|
|
130
|
+
if attempt < STATS_POLL_ATTEMPTS - 1:
|
|
131
|
+
time.sleep(STATS_POLL_INTERVAL_SECONDS)
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
"GitHub is still computing contributor statistics. Try again shortly."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
raise_for_github_error(response)
|
|
139
|
+
|
|
140
|
+
statistics = [
|
|
141
|
+
contributor
|
|
142
|
+
for contributor in response.json()
|
|
143
|
+
if contributor.get("author") is not None
|
|
144
|
+
]
|
|
145
|
+
contributors = sorted(
|
|
146
|
+
statistics,
|
|
147
|
+
key=lambda contributor: contributor["total"],
|
|
148
|
+
reverse=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def format_contributor(contributor: dict) -> dict[str, str | int | None]:
|
|
152
|
+
author = contributor["author"]
|
|
153
|
+
return {
|
|
154
|
+
"login": author["login"],
|
|
155
|
+
"commits": contributor["total"],
|
|
156
|
+
"profile_url": author["html_url"],
|
|
157
|
+
"avatar_url": author["avatar_url"],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
current_contributor = next(
|
|
161
|
+
(
|
|
162
|
+
format_contributor(contributor)
|
|
163
|
+
for contributor in contributors
|
|
164
|
+
if current_login
|
|
165
|
+
and contributor["author"]["login"].casefold()
|
|
166
|
+
== current_login.casefold()
|
|
167
|
+
),
|
|
168
|
+
None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
top_contributors = [
|
|
172
|
+
format_contributor(contributor) for contributor in contributors[:limit]
|
|
173
|
+
]
|
|
174
|
+
if not include_metadata:
|
|
175
|
+
return top_contributors
|
|
176
|
+
|
|
177
|
+
return ContributorResults(
|
|
178
|
+
contributors=top_contributors,
|
|
179
|
+
total_contributors=len(contributors),
|
|
180
|
+
total_commits=sum(contributor["total"] for contributor in contributors),
|
|
181
|
+
current_contributor=current_contributor,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return ContributorResults([], 0, 0) if include_metadata else []
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from requests import Response
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def github_headers(token: str) -> dict[str, str]:
|
|
5
|
+
"""Build headers for GitHub REST API requests."""
|
|
6
|
+
return {
|
|
7
|
+
"Authorization": f"Bearer {token}",
|
|
8
|
+
"Accept": "application/vnd.github+json",
|
|
9
|
+
"X-GitHub-Api-Version": "2026-03-10",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def raise_for_github_error(response: Response) -> None:
|
|
14
|
+
"""Raise a useful error message for failed GitHub API requests."""
|
|
15
|
+
if response.ok:
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
message = response.json().get("message", response.reason)
|
|
20
|
+
except ValueError:
|
|
21
|
+
message = response.text or response.reason
|
|
22
|
+
|
|
23
|
+
details = [f"GitHub API returned {response.status_code}: {message}"]
|
|
24
|
+
|
|
25
|
+
request_id = response.headers.get("X-GitHub-Request-Id")
|
|
26
|
+
if request_id:
|
|
27
|
+
details.append(f"request ID: {request_id}")
|
|
28
|
+
|
|
29
|
+
if response.status_code in {403, 429}:
|
|
30
|
+
remaining = response.headers.get("X-RateLimit-Remaining")
|
|
31
|
+
|
|
32
|
+
if remaining == "0":
|
|
33
|
+
details.append("primary API rate limit exhausted")
|
|
34
|
+
|
|
35
|
+
retry_after = response.headers.get("Retry-After")
|
|
36
|
+
if retry_after:
|
|
37
|
+
details.append(f"retry after {retry_after} seconds")
|
|
38
|
+
|
|
39
|
+
raise RuntimeError("; ".join(details))
|
app/services/storage.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
ATLAS_DIR = Path.home() / ".atlas"
|
|
5
|
+
CONFIG_FILE = ATLAS_DIR / "config.json"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def save_token(token: str):
|
|
9
|
+
ATLAS_DIR.mkdir(exist_ok=True)
|
|
10
|
+
|
|
11
|
+
data = {"github_token": token}
|
|
12
|
+
|
|
13
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=4), encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_token():
|
|
17
|
+
if not CONFIG_FILE.exists():
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
21
|
+
|
|
22
|
+
return data.get("github_token")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def clear_token():
|
|
26
|
+
if CONFIG_FILE.exists():
|
|
27
|
+
CONFIG_FILE.unlink()
|
app/ui/avatar.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from PIL import Image
|
|
5
|
+
from rich_pixels import Pixels
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_profile_picture(user: dict):
|
|
9
|
+
"""Return user profile picture as a small Rich renderable."""
|
|
10
|
+
|
|
11
|
+
avatar_url = user.get("avatar_url")
|
|
12
|
+
|
|
13
|
+
if not avatar_url:
|
|
14
|
+
return ""
|
|
15
|
+
|
|
16
|
+
response = requests.get(avatar_url, timeout=10)
|
|
17
|
+
response.raise_for_status()
|
|
18
|
+
|
|
19
|
+
image = Image.open(BytesIO(response.content))
|
|
20
|
+
|
|
21
|
+
# Resize avatar for terminal display
|
|
22
|
+
image.thumbnail((28, 28))
|
|
23
|
+
|
|
24
|
+
return Pixels.from_image(image)
|
app/ui/display.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from rich.columns import Columns
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from app.ui.avatar import get_profile_picture
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def display_user(user: dict):
|
|
12
|
+
"""Display a GitHub profile with avatar."""
|
|
13
|
+
|
|
14
|
+
title = user.get("name") or user["login"]
|
|
15
|
+
subtitle = f"@{user['login']}"
|
|
16
|
+
|
|
17
|
+
profile = Table(show_header=False, box=None, pad_edge=False)
|
|
18
|
+
profile.add_column("Field", style="cyan")
|
|
19
|
+
profile.add_column("Value")
|
|
20
|
+
|
|
21
|
+
profile.add_row("Repositories", str(user["public_repos"]))
|
|
22
|
+
profile.add_row("Followers", str(user["followers"]))
|
|
23
|
+
profile.add_row("Following", str(user["following"]))
|
|
24
|
+
profile.add_row("Location", user.get("location") or "—")
|
|
25
|
+
profile.add_row("Company", user.get("company") or "—")
|
|
26
|
+
profile.add_row("Joined", user["created_at"][:10])
|
|
27
|
+
|
|
28
|
+
profile_panel = Panel(
|
|
29
|
+
profile,
|
|
30
|
+
title=f"[bold]{title}[/bold]",
|
|
31
|
+
subtitle=subtitle,
|
|
32
|
+
expand=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
avatar_panel = Panel(
|
|
36
|
+
get_profile_picture(user),
|
|
37
|
+
# Pixels does not report its natural width to Rich, so constrain this
|
|
38
|
+
# panel to the 28-column thumbnail plus its border and horizontal padding.
|
|
39
|
+
width=32,
|
|
40
|
+
padding=(0, 1),
|
|
41
|
+
expand=False,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
console.print(
|
|
45
|
+
Columns(
|
|
46
|
+
[
|
|
47
|
+
profile_panel,
|
|
48
|
+
avatar_panel,
|
|
49
|
+
],
|
|
50
|
+
expand=False,
|
|
51
|
+
equal=False,
|
|
52
|
+
)
|
|
53
|
+
)
|
app/ui/repo.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
from rich.columns import Columns
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_view_header(title: str, subtitle: str) -> Panel:
|
|
11
|
+
"""Create the shared header used by focused repository views."""
|
|
12
|
+
content = Text(justify="center")
|
|
13
|
+
content.append(title.upper(), style="bold cyan")
|
|
14
|
+
content.append("\n")
|
|
15
|
+
content.append(subtitle, style="bold")
|
|
16
|
+
return Panel(content, width=60, border_style="cyan")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def display_view_header(title: str, subtitle: str) -> None:
|
|
20
|
+
"""Render the shared header used by focused repository views."""
|
|
21
|
+
console.print(build_view_header(title, subtitle))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_size(size: int | None) -> str:
|
|
25
|
+
# Format GitHub's repository size value for display.
|
|
26
|
+
if size is None:
|
|
27
|
+
return "Not available"
|
|
28
|
+
|
|
29
|
+
units = ["KB", "MB", "GB", "TB", "PB"]
|
|
30
|
+
value = size
|
|
31
|
+
current_unit = 0
|
|
32
|
+
|
|
33
|
+
while value >= 1000 and current_unit < len(units) - 1:
|
|
34
|
+
value /= 1000
|
|
35
|
+
current_unit += 1
|
|
36
|
+
|
|
37
|
+
return f"{value:.1f} {units[current_unit]}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_bytes(size: int | None) -> str:
|
|
41
|
+
"""Format byte size values for display."""
|
|
42
|
+
if size is None:
|
|
43
|
+
return "Not available"
|
|
44
|
+
|
|
45
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
46
|
+
value = float(size)
|
|
47
|
+
current_unit = 0
|
|
48
|
+
|
|
49
|
+
while value >= 1000 and current_unit < len(units) - 1:
|
|
50
|
+
value /= 1000
|
|
51
|
+
current_unit += 1
|
|
52
|
+
|
|
53
|
+
if current_unit == 0:
|
|
54
|
+
return f"{int(value)} B"
|
|
55
|
+
return f"{value:.1f} {units[current_unit]}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def display_repo_details(
|
|
59
|
+
details: dict,
|
|
60
|
+
languages: list[str],
|
|
61
|
+
contributions: list[dict],
|
|
62
|
+
):
|
|
63
|
+
"""Display a repository overview."""
|
|
64
|
+
|
|
65
|
+
full_name = details.get("full_name", "Unknown repository")
|
|
66
|
+
description = details.get("description") or "No description provided."
|
|
67
|
+
visibility = details.get("visibility") or (
|
|
68
|
+
"private" if details.get("private") else "public"
|
|
69
|
+
)
|
|
70
|
+
primary_language = details.get("language") or "—"
|
|
71
|
+
url = details.get("html_url") or "—"
|
|
72
|
+
|
|
73
|
+
header_panel = build_view_header("Details", full_name)
|
|
74
|
+
|
|
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
|
+
# Contributions
|
|
109
|
+
contribution_table = Table(
|
|
110
|
+
show_header=False,
|
|
111
|
+
box=None,
|
|
112
|
+
pad_edge=False,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
contribution_table.add_column("Contributor")
|
|
116
|
+
contribution_table.add_column("Commits", justify="right")
|
|
117
|
+
|
|
118
|
+
if contributions:
|
|
119
|
+
for contributor in contributions:
|
|
120
|
+
contribution_table.add_row(
|
|
121
|
+
contributor["login"],
|
|
122
|
+
str(contributor["commits"]),
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
contribution_table.add_row(
|
|
126
|
+
"No contributor data",
|
|
127
|
+
"—",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
contributions_panel = Panel(
|
|
131
|
+
contribution_table,
|
|
132
|
+
title="Contributions",
|
|
133
|
+
expand=False,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Languages
|
|
137
|
+
language_table = Table(
|
|
138
|
+
show_header=False,
|
|
139
|
+
box=None,
|
|
140
|
+
pad_edge=False,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
language_table.add_column("Language")
|
|
144
|
+
language_table.add_column("Usage")
|
|
145
|
+
|
|
146
|
+
for language in languages:
|
|
147
|
+
language_name, percentage = language.split(": ")
|
|
148
|
+
language_table.add_row(
|
|
149
|
+
language_name,
|
|
150
|
+
percentage,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
languages_panel = Panel(
|
|
154
|
+
language_table,
|
|
155
|
+
title="Languages",
|
|
156
|
+
expand=False,
|
|
157
|
+
)
|
|
158
|
+
|
|
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
|
+
)
|
|
167
|
+
)
|
|
168
|
+
console.print()
|
|
169
|
+
|
|
170
|
+
console.print(
|
|
171
|
+
Columns(
|
|
172
|
+
[stats_panel, contributions_panel],
|
|
173
|
+
expand=False,
|
|
174
|
+
equal=True,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
console.print(languages_panel)
|
|
179
|
+
|
|
180
|
+
console.print(
|
|
181
|
+
Text(
|
|
182
|
+
f"↗ {url}",
|
|
183
|
+
style="dim",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def display_contributors(
|
|
189
|
+
full_name: str,
|
|
190
|
+
contributors: list[dict],
|
|
191
|
+
total_contributors: int,
|
|
192
|
+
total_commits: int,
|
|
193
|
+
current_login: str | None = None,
|
|
194
|
+
current_contributor: dict | None = None,
|
|
195
|
+
):
|
|
196
|
+
"""Display a focused view of GitHub contributor statistics."""
|
|
197
|
+
display_view_header("Contributors", full_name)
|
|
198
|
+
|
|
199
|
+
shown_count = len(contributors)
|
|
200
|
+
if not total_contributors:
|
|
201
|
+
console.print(Text("No contributor data returned.", style="dim"))
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
console.print(
|
|
205
|
+
Text(
|
|
206
|
+
f"{total_contributors} contributors returned by GitHub · showing top {shown_count}",
|
|
207
|
+
style="dim",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
current_is_ranked = any(
|
|
212
|
+
current_login and contributor["login"].casefold() == current_login.casefold()
|
|
213
|
+
for contributor in contributors
|
|
214
|
+
)
|
|
215
|
+
largest_count = max(contributor["commits"] for contributor in contributors)
|
|
216
|
+
|
|
217
|
+
if current_contributor and not current_is_ranked:
|
|
218
|
+
console.print()
|
|
219
|
+
console.print(Text("YOUR CONTRIBUTION", style="bold cyan"))
|
|
220
|
+
console.print(Text("─" * 58, style="dim"))
|
|
221
|
+
console.print(
|
|
222
|
+
_contributor_row(
|
|
223
|
+
current_contributor,
|
|
224
|
+
total_commits,
|
|
225
|
+
largest_count,
|
|
226
|
+
marker="● ",
|
|
227
|
+
highlight=True,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
console.print()
|
|
232
|
+
console.print(Text("TOP CONTRIBUTORS", style="bold"))
|
|
233
|
+
console.print(Text("─" * 58, style="dim"))
|
|
234
|
+
for rank, contributor in enumerate(contributors, start=1):
|
|
235
|
+
is_current_user = bool(
|
|
236
|
+
current_login
|
|
237
|
+
and contributor["login"].casefold() == current_login.casefold()
|
|
238
|
+
)
|
|
239
|
+
console.print(
|
|
240
|
+
_contributor_row(
|
|
241
|
+
contributor,
|
|
242
|
+
total_commits,
|
|
243
|
+
largest_count,
|
|
244
|
+
rank=rank,
|
|
245
|
+
marker="● " if is_current_user else " ",
|
|
246
|
+
highlight=is_current_user,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
if rank < shown_count:
|
|
250
|
+
console.print()
|
|
251
|
+
|
|
252
|
+
console.print(Text("─" * 58, style="dim"))
|
|
253
|
+
console.print(
|
|
254
|
+
Text(
|
|
255
|
+
f"Showing {shown_count} of {total_contributors} contributors returned by GitHub",
|
|
256
|
+
style="dim",
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def display_languages(full_name: str, languages: dict[str, int]) -> None:
|
|
262
|
+
"""Display all languages reported by GitHub with proportional usage bars."""
|
|
263
|
+
display_view_header("Languages", full_name)
|
|
264
|
+
|
|
265
|
+
if not languages:
|
|
266
|
+
console.print(Text("No language data returned.", style="dim"))
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
total_bytes = sum(languages.values())
|
|
270
|
+
ranked_languages = sorted(
|
|
271
|
+
languages.items(),
|
|
272
|
+
key=lambda language: language[1],
|
|
273
|
+
reverse=True,
|
|
274
|
+
)
|
|
275
|
+
largest_count = ranked_languages[0][1]
|
|
276
|
+
language_count = len(ranked_languages)
|
|
277
|
+
|
|
278
|
+
console.print(
|
|
279
|
+
Text(
|
|
280
|
+
f"{language_count} languages returned by GitHub · 100% of reported code",
|
|
281
|
+
style="dim",
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
console.print()
|
|
285
|
+
console.print(Text("LANGUAGE BREAKDOWN", style="bold"))
|
|
286
|
+
console.print(Text("─" * 58, style="dim"))
|
|
287
|
+
|
|
288
|
+
for index, (language, byte_count) in enumerate(ranked_languages, start=1):
|
|
289
|
+
console.print(
|
|
290
|
+
_language_row(
|
|
291
|
+
language,
|
|
292
|
+
byte_count,
|
|
293
|
+
total_bytes,
|
|
294
|
+
largest_count,
|
|
295
|
+
rank=index,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
if index < language_count:
|
|
299
|
+
console.print()
|
|
300
|
+
|
|
301
|
+
console.print(Text("─" * 58, style="dim"))
|
|
302
|
+
console.print(Text(f"Showing all {language_count} languages", style="dim"))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _contributor_row(
|
|
306
|
+
contributor: dict,
|
|
307
|
+
total_commits: int,
|
|
308
|
+
largest_count: int,
|
|
309
|
+
rank: int | None = None,
|
|
310
|
+
marker: str = "",
|
|
311
|
+
highlight: bool = False,
|
|
312
|
+
) -> Text:
|
|
313
|
+
"""Build one compact contributor row with a proportional contribution bar."""
|
|
314
|
+
commits = int(contributor.get("commits") or 0)
|
|
315
|
+
percentage = commits / total_commits * 100 if total_commits else 0
|
|
316
|
+
bar_width = 32
|
|
317
|
+
filled = (
|
|
318
|
+
max(1, round(commits / largest_count * bar_width))
|
|
319
|
+
if commits and largest_count
|
|
320
|
+
else 0
|
|
321
|
+
)
|
|
322
|
+
bar = "█" * filled
|
|
323
|
+
username_style = "bold cyan" if highlight else ""
|
|
324
|
+
bar_style = "cyan" if highlight else "green"
|
|
325
|
+
commit_label = "commit" if commits == 1 else "commits"
|
|
326
|
+
|
|
327
|
+
row = Text()
|
|
328
|
+
if rank is not None:
|
|
329
|
+
row.append(f"{rank:<2} ", style="dim")
|
|
330
|
+
row.append(marker, style="cyan" if highlight else "dim")
|
|
331
|
+
row.append(f"{contributor.get('login', 'Unknown'):<30}", style=username_style)
|
|
332
|
+
row.append(f"{commits:>6} {commit_label}\n")
|
|
333
|
+
row.append(" " * (4 if rank is not None else 3))
|
|
334
|
+
row.append(bar.ljust(bar_width), style=bar_style)
|
|
335
|
+
row.append(f" {percentage:.1f}%", style="dim")
|
|
336
|
+
return row
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _language_row(
|
|
340
|
+
language: str,
|
|
341
|
+
byte_count: int,
|
|
342
|
+
total_bytes: int,
|
|
343
|
+
largest_count: int,
|
|
344
|
+
rank: int | None = None,
|
|
345
|
+
) -> Text:
|
|
346
|
+
"""Build one compact language row with a proportional usage bar."""
|
|
347
|
+
percentage = byte_count / total_bytes * 100 if total_bytes else 0
|
|
348
|
+
bar_width = 32
|
|
349
|
+
filled = (
|
|
350
|
+
max(1, round(byte_count / largest_count * bar_width))
|
|
351
|
+
if byte_count and largest_count
|
|
352
|
+
else 0
|
|
353
|
+
)
|
|
354
|
+
bar = "█" * filled
|
|
355
|
+
|
|
356
|
+
row = Text()
|
|
357
|
+
if rank is not None:
|
|
358
|
+
row.append(f"{rank:<2} ", style="dim")
|
|
359
|
+
row.append(" ", style="dim")
|
|
360
|
+
row.append(f"{language:<30}")
|
|
361
|
+
row.append(f"{format_bytes(byte_count):>12}\n")
|
|
362
|
+
row.append(" " * (4 if rank is not None else 3))
|
|
363
|
+
row.append(bar.ljust(bar_width), style="green")
|
|
364
|
+
row.append(f" {percentage:.1f}%", style="dim")
|
|
365
|
+
return row
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: recon-github
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: dotenv>=0.9.9
|
|
9
|
+
Requires-Dist: requests>=2.34.2
|
|
10
|
+
Requires-Dist: rich>=15.0.0
|
|
11
|
+
Requires-Dist: rich-pixels>=3.0.1
|
|
12
|
+
Requires-Dist: typer>=0.27.1
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Recon CLI
|
|
17
|
+
|
|
18
|
+
Recon is a powerful CLI companion for developers and tools for agents, turning GitHub activity and repository data into useful insights from your terminal.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
[](https://choosealicense.com/licenses/mit/)
|
|
23
|
+
[](https://github.com/owenpalfreymandev/reconcli/actions/workflows/ci.yml)
|
|
24
|
+
[](https://www.python.org/)
|
|
25
|
+
[](https://pypi.org/)
|
|
26
|
+
[](https://github.com/owenpalfreymandev/reconcli/blob/main/LICENSE)
|
|
27
|
+
## Overview
|
|
28
|
+
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
* **GitHub Authentication** — Securely authenticate with GitHub using OAuth device flow.
|
|
35
|
+
* **User Information** — View information about your authenticated GitHub account.
|
|
36
|
+
* **Repository Explorer** — List and inspect GitHub repositories directly from the terminal.
|
|
37
|
+
* **Repository Statistics** — Explore repository languages, contributors, and other useful statistics.
|
|
38
|
+
* **Rich Terminal UI** — Clean, structured terminal output powered by Rich.
|
|
39
|
+
* **Fast CLI Workflow** — Quickly access GitHub information without leaving the command line.
|
|
40
|
+
* **Modular Architecture** — Built with reusable services and components to make Recon easy to maintain and extend.
|
|
41
|
+
* **Automated Testing** — Tested with an automated CI pipeline to help maintain reliability and code quality.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
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/).
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uv tool install recon-gh
|
|
49
|
+
```
|
|
50
|
+
For the best experience, then login to your GitHub account (this step is not strictly required).
|
|
51
|
+
```bash
|
|
52
|
+
recon login
|
|
53
|
+
```
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
Here you can learn how to get started with terminal commands, and learn how to find more.
|
|
57
|
+
|
|
58
|
+
### View Your GitHub Profile
|
|
59
|
+
|
|
60
|
+
The `me` command displays information about your authenticated GitHub account.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
recon me
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Example output:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
╭────── Owen Palfreyman ───────╮ ╭──────────────────────────────╮
|
|
70
|
+
│ Repositories 2 │ │ │
|
|
71
|
+
│ Followers 7 │ │ │
|
|
72
|
+
│ Following 2 │ │ │
|
|
73
|
+
│ Location United Kingdom │ │ │
|
|
74
|
+
│ Company — │ │ │
|
|
75
|
+
│ Joined 2023-11-02 │ │ │
|
|
76
|
+
╰───── @owenpalfreymandev ─────╯ │ │
|
|
77
|
+
╰──────────────────────────────╯
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Scout Another GitHub User
|
|
81
|
+
|
|
82
|
+
Use `scout` to explore another GitHub user's profile.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
recon scout torvalds
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Example output:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
╭──────── Linus Torvalds ────────╮ ╭──────────────────────────────╮
|
|
92
|
+
│ Repositories 12 │ │ │
|
|
93
|
+
│ Followers 318572 │ │ │
|
|
94
|
+
│ Following 0 │ │ │
|
|
95
|
+
│ Location Portland, OR │ │ │
|
|
96
|
+
│ Company Linux Foundation │ │ │
|
|
97
|
+
│ Joined 2011-09-03 │ │ │
|
|
98
|
+
╰────────── @torvalds ───────────╯ │ │
|
|
99
|
+
╰──────────────────────────────╯
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### List Your Repositories
|
|
103
|
+
|
|
104
|
+
The `list` command displays your GitHub repositories.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
recon list
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This provides a quick way to see your repositories before using `details` to explore a specific project.
|
|
111
|
+
|
|
112
|
+
### Authentication
|
|
113
|
+
|
|
114
|
+
Recon uses GitHub's device flow for authentication.
|
|
115
|
+
|
|
116
|
+
To log in:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
recon login
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
To log out:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
recon logout
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Explore a Repository
|
|
129
|
+
|
|
130
|
+
The `details` command gives you an overview of a GitHub repository.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
recon details owenpalfreymandev fpark
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The command takes two arguments:
|
|
137
|
+
|
|
138
|
+
* `owner` — The GitHub username or organisation that owns the repository.
|
|
139
|
+
* `repo` — The name of the repository.
|
|
140
|
+
|
|
141
|
+
### Need Some Help?
|
|
142
|
+
|
|
143
|
+
Not sure what a command can do? Recon has built-in help for every command.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
recon details --help
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
This shows you the available arguments and options for the command, including additional ways to get information from a repository.
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
Usage: recon details [OPTIONS] {owner} {repo}
|
|
153
|
+
|
|
154
|
+
Gain insights into your repo
|
|
155
|
+
|
|
156
|
+
Arguments:
|
|
157
|
+
owner Repository owner, e.g. owenpalfreymandev
|
|
158
|
+
repo Repository name, e.g. reconcli
|
|
159
|
+
|
|
160
|
+
Options:
|
|
161
|
+
--contributors View contributors in more detail.
|
|
162
|
+
--languages View language usage in more detail.
|
|
163
|
+
--help Show this message and exit.
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
For example, the `--languages` option can be used to get a more detailed breakdown of the languages used in a repository:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
recon details owenpalfreymandev fpark --languages
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
╭──────────────────────────────────────────────────────────╮
|
|
174
|
+
│ LANGUAGES │
|
|
175
|
+
│ owenpalfreymandev/fpark │
|
|
176
|
+
╰──────────────────────────────────────────────────────────╯
|
|
177
|
+
4 languages returned by GitHub · 100% of reported code
|
|
178
|
+
|
|
179
|
+
LANGUAGE BREAKDOWN
|
|
180
|
+
──────────────────────────────────────────────────────────
|
|
181
|
+
1 TypeScript 279.9 KB
|
|
182
|
+
████████████████████████████████ 95.9%
|
|
183
|
+
|
|
184
|
+
2 PLpgSQL 7.0 KB
|
|
185
|
+
█ 2.4%
|
|
186
|
+
|
|
187
|
+
3 CSS 4.3 KB
|
|
188
|
+
█ 1.5%
|
|
189
|
+
|
|
190
|
+
4 JavaScript 559 B
|
|
191
|
+
█ 0.2%
|
|
192
|
+
──────────────────────────────────────────────────────────
|
|
193
|
+
Showing all 4 languages
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
### Explore Further
|
|
198
|
+
|
|
199
|
+
These are just some of the commands available in Recon. There are plenty more options to play with, with more commands and features coming soon. Check the [roadmap](https://github.com/owenpalfreymandev/reconcli/blob/main/README.md) for more details.
|
|
200
|
+
|
|
201
|
+
## Contributing
|
|
202
|
+
|
|
203
|
+
Contributions are always welcome!
|
|
204
|
+
|
|
205
|
+
To clone the repo and download all dependencies, run:
|
|
206
|
+
```bash
|
|
207
|
+
git clone https://github.com/owenpalfreymandev/reconcli.git
|
|
208
|
+
uv sync
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
You can run unit tests and type tests with:
|
|
212
|
+
```bash
|
|
213
|
+
uv run pytest
|
|
214
|
+
uv run pyright
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Be sure to lint before commiting, or it will fail CI/CD:
|
|
218
|
+
```bash
|
|
219
|
+
uv run ruff check --fix .
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Workflow
|
|
223
|
+
|
|
224
|
+
A basic workflow to follow is:
|
|
225
|
+
|
|
226
|
+
1. Fork the repository
|
|
227
|
+
2. Create a branch
|
|
228
|
+
3. Make changes
|
|
229
|
+
4. Run tests/lint/type checks
|
|
230
|
+
5. Open a PR
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
Recon is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
234
|
+
## Author
|
|
235
|
+
|
|
236
|
+
Recon is developed by Owen Palfreyman.
|
|
237
|
+
|
|
238
|
+
GitHub: @owenpalfreymandev
|
|
239
|
+
Repository: owenpalfreymandev/reconcli
|
|
240
|
+
|
|
241
|
+
*or could could just run `recon details owenpalfreymandev reconcli --contributors` wink wink*
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
app/cli.py,sha256=j_aGv8L7KRteU0Rt6sIaqdIXPjGz8CcczICuOoentlE,336
|
|
2
|
+
app/commands/auth.py,sha256=XP5IehUFW_NMXIzNHRjjFhQd0FeIQn3jjqs_4ZCw7IM,238
|
|
3
|
+
app/commands/me.py,sha256=FCJUYrTUw6rM2OkrGj421TzJJmC61cwEupy4AMUqDB8,434
|
|
4
|
+
app/commands/repo.py,sha256=TapGxTGWP7qJZB4nBju6lMbKFA9T-PVSW83XVbuKGjo,6357
|
|
5
|
+
app/services/auth.py,sha256=3QGHpdfikQblTWjn6j_ePCHet4AVGBtFR73JTZEIQHE,1801
|
|
6
|
+
app/services/github.py,sha256=N86ZFl9l41T_hHIXPvuBPi6fCRinfCVCVgU6dLy5JZY,4727
|
|
7
|
+
app/services/github_errors.py,sha256=14IpoR_mQMqky_tv2vjSrFAK-nO7_YhSbT6OhUgLs6U,1210
|
|
8
|
+
app/services/storage.py,sha256=bO_XKvfQFhS4nKDIkNuJXLFD12ThZi8ztPYOKyKIq24,540
|
|
9
|
+
app/ui/avatar.py,sha256=UAcqWQIk92HoI6TrG8nPvyrIFZhJg6dFNC8OJIu650o,526
|
|
10
|
+
app/ui/display.py,sha256=WUm6jybb3NWgGOE4Xicvj6OO80cwqiCuaKzI_gC-spc,1481
|
|
11
|
+
app/ui/repo.py,sha256=vxlqprTRne6TgCM6QBzVRDJvzklFxOE6xwvZXuoFvKs,10141
|
|
12
|
+
recon_github-0.1.0.dist-info/licenses/LICENSE,sha256=SeqZEVgNw2f7eXshxAwKfEH3Y6K-9QCY0QlrnJKGTBY,1074
|
|
13
|
+
recon_github-0.1.0.dist-info/METADATA,sha256=2pszzpJjIDLO_4Z3D0PqD31pSuWRJ6TI4rd6e1ZPjig,8893
|
|
14
|
+
recon_github-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
15
|
+
recon_github-0.1.0.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
|
|
16
|
+
recon_github-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 owenpalfreymandev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
app
|