Skip to content

Commit 6507c8b

Browse files
committed
feat: display accurate accessible repository counts for users and organizations
- Replace public_repos with accessible_repos count showing both public and private repos user has access to - Fetch actual accessible repositories via GitHub API for accurate counts instead of relying on public_repos metadata - Use /orgs/{org}/repos for organizations to include private repos with user access - Use /user/repos with affiliation=owner,collaborator for user accounts to exclude org repos and prevent duplicates - Add max_repos limit (200) to organizations list for improved performance when counting - Add retry logic for GitHub API 502/503/504 server errors to improve resilience - Configure structlog to respect LOG_LEVEL environment variable for better debugging - Add detailed logging for repository access checks and permissions Fixes issue where organizations showed "0 repos" despite user having access to repositories. Resolves duplicate repositories appearing under both user account and organization listings.
1 parent 9cf168d commit 6507c8b

3 files changed

Lines changed: 151 additions & 54 deletions

File tree

dashboard/src/components/RepositoryManager.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface Organization {
2626
type: string; // "Organization" or "User"
2727
public_repos: number;
2828
total_private_repos?: number;
29+
accessible_repos?: number;
2930
}
3031

3132
interface OrganizationRepositories {
@@ -375,15 +376,15 @@ const RepositoryManager: React.FC = () => {
375376
<span className="organization-type">({org.type})</span>
376377
</h3>
377378
<p className="organization-description">
378-
{org.description || `${org.public_repos} public repositories`}
379+
{org.description || `${org.accessible_repos ?? org.public_repos} accessible repositories`}
379380
</p>
380381
</div>
381382
</div>
382383
<div className="organization-expand">
383384
<span className={`expand-arrow ${isExpanded ? 'expanded' : ''}`}>
384385
385386
</span>
386-
<span className="repo-count">{org.public_repos} repos</span>
387+
<span className="repo-count">{org.accessible_repos ?? org.public_repos} repos</span>
387388
</div>
388389
</div>
389390

gateway/app.py

Lines changed: 70 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
from github_client import GitHubAPIClient, validate_public_repository, parse_github_url
3131

3232
# Configure structured logging
33+
import logging
34+
35+
# Set up Python's standard logging to respect LOG_LEVEL
36+
logging.basicConfig(
37+
format="%(message)s",
38+
level=os.getenv("LOG_LEVEL", "INFO"),
39+
)
40+
3341
structlog.configure(
3442
processors=[
3543
structlog.stdlib.filter_by_level,
@@ -166,6 +174,7 @@ class OrganizationResponse(BaseModel):
166174
type: str # "Organization" or "User"
167175
public_repos: int
168176
total_private_repos: Optional[int] = None
177+
accessible_repos: Optional[int] = None
169178

170179

171180
class OrganizationRepositoriesResponse(BaseModel):
@@ -232,10 +241,10 @@ async def connect(self, websocket: WebSocket, user: Optional[dict] = None):
232241
await websocket.accept()
233242
self.active_connections[websocket] = user
234243
logger.info(
235-
"Client connected",
244+
"Client connected",
236245
total_connections=len(self.active_connections),
237246
authenticated=user is not None,
238-
user_id=user.get("sub") if user else None
247+
user_id=user.get("sub") if user else None,
239248
)
240249

241250
def disconnect(self, websocket: WebSocket):
@@ -307,7 +316,7 @@ async def health_check():
307316
async def websocket_endpoint(websocket: WebSocket, token: Optional[str] = None):
308317
"""WebSocket endpoint for real-time updates with JWT authentication"""
309318
user = None
310-
319+
311320
if token:
312321
try:
313322
user = verify_jwt_token(token)
@@ -318,7 +327,7 @@ async def websocket_endpoint(websocket: WebSocket, token: Optional[str] = None):
318327
return
319328
else:
320329
logger.info("WebSocket connection without authentication (public access)")
321-
330+
322331
await manager.connect(websocket, user)
323332
try:
324333
while True:
@@ -349,9 +358,11 @@ async def get_public_repository_issues(
349358
(owner, repo),
350359
)
351360
repo_row = cur.fetchone()
352-
361+
353362
if not repo_row or not repo_row["is_public_dashboard"]:
354-
raise HTTPException(status_code=404, detail="Public repository not found")
363+
raise HTTPException(
364+
status_code=404, detail="Public repository not found"
365+
)
355366

356367
where_conditions = ["i.repository_owner = %s", "i.repository_name = %s"]
357368
params = [owner, repo]
@@ -410,7 +421,12 @@ async def get_public_repository_issues(
410421
except HTTPException:
411422
raise
412423
except Exception as e:
413-
logger.error("Error fetching public repository issues", owner=owner, repo=repo, error=str(e))
424+
logger.error(
425+
"Error fetching public repository issues",
426+
owner=owner,
427+
repo=repo,
428+
error=str(e),
429+
)
414430
raise HTTPException(status_code=500, detail="Failed to fetch issues")
415431

416432

@@ -506,7 +522,9 @@ async def get_issues(
506522

507523

508524
@app.get("/api/issues/{issue_id}", response_model=IssueResponse)
509-
async def get_issue(issue_id: int, current_user: Optional[dict] = Depends(get_current_user)):
525+
async def get_issue(
526+
issue_id: int, current_user: Optional[dict] = Depends(get_current_user)
527+
):
510528
"""Get a specific issue by ID (requires auth or public repo)"""
511529
try:
512530
conn = psycopg2.connect(DATABASE_URL)
@@ -535,7 +553,7 @@ async def get_issue(issue_id: int, current_user: Optional[dict] = Depends(get_cu
535553
is_public = row.get("is_public_dashboard", False)
536554
if not is_public and not current_user:
537555
raise HTTPException(status_code=401, detail="Authentication required")
538-
556+
539557
if not is_public and current_user:
540558
user_id = current_user.get("sub")
541559
conn = psycopg2.connect(DATABASE_URL)
@@ -552,7 +570,7 @@ async def get_issue(issue_id: int, current_user: Optional[dict] = Depends(get_cu
552570
)
553571
has_access = cur.fetchone() is not None
554572
conn.close()
555-
573+
556574
if not has_access:
557575
raise HTTPException(status_code=403, detail="Access denied")
558576

@@ -669,9 +687,11 @@ async def get_public_repository_stats(owner: str, repo: str):
669687
(owner, repo),
670688
)
671689
repo_row = cur.fetchone()
672-
690+
673691
if not repo_row or not repo_row["is_public_dashboard"]:
674-
raise HTTPException(status_code=404, detail="Public repository not found")
692+
raise HTTPException(
693+
status_code=404, detail="Public repository not found"
694+
)
675695

676696
cur.execute(
677697
"""
@@ -739,7 +759,12 @@ async def get_public_repository_stats(owner: str, repo: str):
739759
except HTTPException:
740760
raise
741761
except Exception as e:
742-
logger.error("Error fetching public repository stats", owner=owner, repo=repo, error=str(e))
762+
logger.error(
763+
"Error fetching public repository stats",
764+
owner=owner,
765+
repo=repo,
766+
error=str(e),
767+
)
743768
raise HTTPException(status_code=500, detail="Failed to fetch statistics")
744769

745770

@@ -1208,22 +1233,29 @@ async def get_user_organizations(
12081233
async with GitHubAPIClient(github_token) as github_client:
12091234
organizations = await github_client.get_user_organizations()
12101235

1211-
# Convert to response models
1212-
result = []
1213-
for org in organizations:
1214-
result.append(
1215-
OrganizationResponse(
1216-
id=org.id,
1217-
login=org.login,
1218-
name=org.name,
1219-
description=org.description,
1220-
avatar_url=org.avatar_url,
1221-
html_url=org.html_url,
1222-
type=org.type,
1223-
public_repos=org.public_repos,
1224-
total_private_repos=org.total_private_repos,
1236+
# Fetch accessible repos count for each organization
1237+
result = []
1238+
for org in organizations:
1239+
# Get accessible repositories for this org to get accurate count
1240+
# Limit to 200 repos for performance (just for counting)
1241+
accessible_repos = await github_client.get_organization_repositories(
1242+
org.login, max_repos=200
1243+
)
1244+
1245+
result.append(
1246+
OrganizationResponse(
1247+
id=org.id,
1248+
login=org.login,
1249+
name=org.name,
1250+
description=org.description,
1251+
avatar_url=org.avatar_url,
1252+
html_url=org.html_url,
1253+
type=org.type,
1254+
public_repos=org.public_repos,
1255+
total_private_repos=org.total_private_repos,
1256+
accessible_repos=len(accessible_repos),
1257+
)
12251258
)
1226-
)
12271259

12281260
logger.info("Fetched user organizations", user_id=user_id, count=len(result))
12291261
return result
@@ -1649,19 +1681,19 @@ async def _process_message(self, message):
16491681
if "issue" in event_data:
16501682
repo_owner = event_data["issue"].get("repository_owner")
16511683
repo_name = event_data["issue"].get("repository_name")
1652-
1684+
16531685
# Create filter function for user access control
16541686
def user_can_see_issue(user: Optional[Dict[str, Any]]) -> bool:
16551687
if user is None:
16561688
return False
1657-
1689+
16581690
if not repo_owner or not repo_name:
16591691
return True
1660-
1692+
16611693
user_id = user.get("sub")
16621694
if not user_id:
16631695
return False
1664-
1696+
16651697
try:
16661698
conn = psycopg2.connect(DATABASE_URL)
16671699
with conn.cursor() as cur:
@@ -1686,7 +1718,9 @@ def user_can_see_issue(user: Optional[Dict[str, Any]]) -> bool:
16861718
f"DEBUG: Broadcasting to {len(self.manager.active_connections)} WebSocket clients with filtering"
16871719
)
16881720

1689-
await self.manager.broadcast(json.dumps(websocket_message), user_filter=user_can_see_issue)
1721+
await self.manager.broadcast(
1722+
json.dumps(websocket_message), user_filter=user_can_see_issue
1723+
)
16901724

16911725
print(f"DEBUG: Successfully broadcasted message from {message.topic}")
16921726

@@ -1695,7 +1729,9 @@ def user_can_see_issue(user: Optional[Dict[str, Any]]) -> bool:
16951729
topic=message.topic,
16961730
connected_clients=len(self.manager.active_connections),
16971731
message_type=websocket_message["type"],
1698-
repository=f"{repo_owner}/{repo_name}" if repo_owner and repo_name else None,
1732+
repository=f"{repo_owner}/{repo_name}"
1733+
if repo_owner and repo_name
1734+
else None,
16991735
)
17001736

17011737
except Exception as e:

gateway/github_client.py

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class GitHubOrganization(BaseModel):
5353
type: str # "Organization" or "User"
5454
public_repos: int
5555
total_private_repos: Optional[int] = None
56+
accessible_repos: Optional[int] = None
5657

5758

5859
class GitHubAPIClient:
@@ -186,34 +187,62 @@ async def get_user_organizations(self) -> List[GitHubOrganization]:
186187
return organizations
187188

188189
async def get_organization_repositories(
189-
self, org_login: str, per_page: int = 100
190+
self, org_login: str, per_page: int = 100, max_repos: Optional[int] = None
190191
) -> List[Dict[str, Any]]:
191192
"""Get repositories for a specific organization or user"""
192193
repositories = []
193194
page = 1
194195

195-
# Determine the endpoint based on whether this is a user or organization
196-
# Try user repos first, then org repos if that fails
197-
endpoints_to_try = [
198-
f"{self.base_url}/users/{org_login}/repos", # User repositories
199-
f"{self.base_url}/orgs/{org_login}/repos", # Organization repositories
200-
]
201-
202-
for endpoint in endpoints_to_try:
196+
# Get current authenticated user to check if org_login is the user themselves
197+
try:
198+
user_response = await self.session.get(f"{self.base_url}/user")
199+
user_response.raise_for_status()
200+
current_user = user_response.json()
201+
is_current_user = current_user["login"] == org_login
202+
except Exception as e:
203+
logger.error("Error fetching current user", error=str(e))
204+
is_current_user = False
205+
206+
# Determine endpoints based on whether this is the current user or an org
207+
if is_current_user:
208+
# For the authenticated user, use /user/repos to get owned/collaborated repos only
209+
# Exclude organization_member to avoid duplicates (orgs are listed separately)
210+
endpoints_to_try = [
211+
(
212+
f"{self.base_url}/user/repos",
213+
{"affiliation": "owner,collaborator"},
214+
),
215+
]
216+
else:
217+
# For organizations, try /orgs first (returns public + private with access)
218+
# Then fall back to /users (returns public only)
219+
endpoints_to_try = [
220+
(f"{self.base_url}/orgs/{org_login}/repos", {}),
221+
(f"{self.base_url}/users/{org_login}/repos", {}),
222+
]
223+
224+
for endpoint_config in endpoints_to_try:
225+
endpoint = (
226+
endpoint_config[0]
227+
if isinstance(endpoint_config, tuple)
228+
else endpoint_config
229+
)
230+
extra_params = (
231+
endpoint_config[1] if isinstance(endpoint_config, tuple) else {}
232+
)
203233
try:
204234
page = 1
205235
repositories = []
206236

207237
while True:
208-
response = await self.session.get(
209-
endpoint,
210-
params={
211-
"per_page": per_page,
212-
"page": page,
213-
"sort": "updated",
214-
"direction": "desc",
215-
},
216-
)
238+
params = {
239+
"per_page": per_page,
240+
"page": page,
241+
"sort": "updated",
242+
"direction": "desc",
243+
**extra_params,
244+
}
245+
response = await self.session.get(endpoint, params=params)
217246

218247
if response.status_code == 404:
219248
# Try next endpoint
@@ -235,10 +264,32 @@ async def get_organization_repositories(
235264
or permissions.get("push", False)
236265
or permissions.get("admin", False)
237266
):
267+
logger.info(
268+
"User has access to repository",
269+
repo_name=repo.get("full_name"),
270+
endpoint=endpoint,
271+
permissions=repo.get("permissions", {}),
272+
)
238273
accessible_repos.append(repo)
274+
else:
275+
logger.info(
276+
"User does not have access to repository",
277+
repo_name=repo.get("full_name"),
278+
permissions=repo.get("permissions", {}),
279+
)
239280

240281
repositories.extend(accessible_repos)
241282

283+
# Check if we've hit the max_repos limit
284+
if max_repos and len(repositories) >= max_repos:
285+
logger.info(
286+
"Reached max_repos limit",
287+
org=org_login,
288+
max_repos=max_repos,
289+
fetched=len(repositories),
290+
)
291+
break
292+
242293
# Check rate limiting
243294
remaining = int(response.headers.get("X-RateLimit-Remaining", 0))
244295
if remaining < 10:
@@ -260,6 +311,15 @@ async def get_organization_repositories(
260311
except httpx.HTTPStatusError as e:
261312
if e.response.status_code == 404:
262313
continue # Try next endpoint
314+
elif e.response.status_code in [502, 503, 504]:
315+
# GitHub server errors - try fallback endpoint
316+
logger.warning(
317+
"GitHub server error, trying fallback endpoint",
318+
org=org_login,
319+
endpoint=endpoint,
320+
status=e.response.status_code,
321+
)
322+
continue
263323
else:
264324
logger.error(
265325
"Error fetching organization repositories",

0 commit comments

Comments
 (0)