Coverage for src / rhiza / commands / list_repos.py: 100%
78 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-15 18:22 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-15 18:22 +0000
1"""Command for listing GitHub repositories with the rhiza topic.
3This module queries the GitHub Search API for repositories tagged with
4the 'rhiza' topic and displays them in a formatted table.
5"""
7import json
8import os
9import urllib.error
10import urllib.request
11from dataclasses import dataclass
13from loguru import logger
15_GITHUB_SEARCH_URL = "https://api.github.com/search/repositories"
16_DEFAULT_TOPIC = "rhiza"
17_PER_PAGE = 50
19# Fixed column content widths (excluding 1-space padding on each side)
20_REPO_WIDTH = 20
21_DESC_WIDTH = 56
22_DATE_WIDTH = 10
25@dataclass
26class _RepoInfo:
27 """Minimal repository metadata returned by the GitHub Search API."""
29 full_name: str
30 description: str
31 updated_at: str
34def _fetch_repos(topic: str = _DEFAULT_TOPIC) -> list[_RepoInfo]:
35 """Fetch repositories from the GitHub Search API with the given topic.
37 Args:
38 topic: GitHub topic to search for.
40 Returns:
41 List of repository info objects.
43 Raises:
44 urllib.error.URLError: If the API request fails.
45 """
46 url = f"{_GITHUB_SEARCH_URL}?q=topic:{topic}&per_page={_PER_PAGE}"
47 headers: dict[str, str] = {
48 "Accept": "application/vnd.github+json",
49 "X-GitHub-Api-Version": "2022-11-28",
50 }
51 token = os.environ.get("GITHUB_TOKEN")
52 if token:
53 headers["Authorization"] = f"Bearer {token}"
55 req = urllib.request.Request(url, headers=headers) # nosec B310 # noqa: S310
56 with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # noqa: S310
57 data = json.loads(resp.read().decode())
59 return [
60 _RepoInfo(
61 full_name=item["full_name"],
62 description=item.get("description") or "",
63 updated_at=item.get("updated_at") or "",
64 )
65 for item in data.get("items", [])
66 ]
69def _format_date(iso_date: str) -> str:
70 """Format an ISO 8601 date string to YYYY-MM-DD.
72 Args:
73 iso_date: ISO 8601 date string (e.g. '2026-03-02T12:12:02Z').
75 Returns:
76 Date string in YYYY-MM-DD format, or empty string if input is empty.
77 """
78 if not iso_date:
79 return ""
80 return iso_date[:10]
83def _wrap_text(text: str, width: int) -> list[str]:
84 """Wrap text to fit within a given width, splitting on word boundaries.
86 Args:
87 text: The text to wrap.
88 width: Maximum line width in characters.
90 Returns:
91 List of lines, each at most *width* characters wide.
92 """
93 if not text:
94 return [""]
95 words = text.split()
96 lines: list[str] = []
97 current = ""
98 for word in words:
99 if current and len(current) + 1 + len(word) > width:
100 lines.append(current)
101 current = word
102 else:
103 current = f"{current} {word}".strip() if current else word
104 if current:
105 lines.append(current)
106 return lines or [""]
109def _render_table(repos: list[_RepoInfo]) -> str:
110 """Render a list of repositories as a formatted table.
112 Args:
113 repos: List of repository info objects to display.
115 Returns:
116 Formatted table string ready to print.
117 """
118 if not repos:
119 return "No repositories found."
121 rw, dw, uw = _REPO_WIDTH, _DESC_WIDTH, _DATE_WIDTH
123 top = f"┌{'─' * (rw + 2)}┬{'─' * (dw + 2)}┬{'─' * (uw + 2)}┐"
124 sep = f"├{'─' * (rw + 2)}┼{'─' * (dw + 2)}┼{'─' * (uw + 2)}┤"
125 bot = f"└{'─' * (rw + 2)}┴{'─' * (dw + 2)}┴{'─' * (uw + 2)}┘"
127 def cell_row(r: str, d: str, u: str) -> str:
128 """Format one table row with repo, description, and updated columns."""
129 return f"│ {r:<{rw}} │ {d:<{dw}} │ {u:<{uw}} │"
131 header = f"│ {'Repo':^{rw}} │ {'Description':^{dw}} │ {'Updated':^{uw}} │"
133 lines = [top, header, sep]
134 for i, repo in enumerate(repos):
135 if i > 0:
136 lines.append(sep)
137 desc_lines = _wrap_text(repo.description, dw)
138 date_str = _format_date(repo.updated_at)
139 for j, desc_line in enumerate(desc_lines):
140 if j == 0:
141 lines.append(cell_row(repo.full_name, desc_line, date_str))
142 else:
143 lines.append(cell_row("", desc_line, ""))
144 lines.append(bot)
145 return "\n".join(lines)
148def list_repos(topic: str = _DEFAULT_TOPIC) -> bool:
149 """List GitHub repositories tagged with the given topic.
151 Queries the GitHub Search API for repositories with the specified topic
152 and prints them in a formatted table.
154 Args:
155 topic: GitHub topic to search for (default: 'rhiza').
157 Returns:
158 True on success, False if the API request failed.
159 """
160 try:
161 repos = _fetch_repos(topic)
162 except urllib.error.URLError as exc:
163 logger.error(f"Failed to fetch repositories: {exc}")
164 return False
166 if not repos:
167 logger.info(f"No repositories found with topic '{topic}'.")
168 return True
170 print(_render_table(repos))
171 return True