Coverage for src / rhiza / commands / list_repos.py: 100%
78 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 07:04 +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 full_name: str
28 description: str
29 updated_at: str
32def _fetch_repos(topic: str = _DEFAULT_TOPIC) -> list[_RepoInfo]:
33 """Fetch repositories from the GitHub Search API with the given topic.
35 Args:
36 topic: GitHub topic to search for.
38 Returns:
39 List of repository info objects.
41 Raises:
42 urllib.error.URLError: If the API request fails.
43 """
44 url = f"{_GITHUB_SEARCH_URL}?q=topic:{topic}&per_page={_PER_PAGE}"
45 headers: dict[str, str] = {
46 "Accept": "application/vnd.github+json",
47 "X-GitHub-Api-Version": "2022-11-28",
48 }
49 token = os.environ.get("GITHUB_TOKEN")
50 if token:
51 headers["Authorization"] = f"Bearer {token}"
53 req = urllib.request.Request(url, headers=headers) # nosec B310 # noqa: S310
54 with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 # noqa: S310
55 data = json.loads(resp.read().decode())
57 return [
58 _RepoInfo(
59 full_name=item["full_name"],
60 description=item.get("description") or "",
61 updated_at=item.get("updated_at") or "",
62 )
63 for item in data.get("items", [])
64 ]
67def _format_date(iso_date: str) -> str:
68 """Format an ISO 8601 date string to YYYY-MM-DD.
70 Args:
71 iso_date: ISO 8601 date string (e.g. '2026-03-02T12:12:02Z').
73 Returns:
74 Date string in YYYY-MM-DD format, or empty string if input is empty.
75 """
76 if not iso_date:
77 return ""
78 return iso_date[:10]
81def _wrap_text(text: str, width: int) -> list[str]:
82 """Wrap text to fit within a given width, splitting on word boundaries.
84 Args:
85 text: The text to wrap.
86 width: Maximum line width in characters.
88 Returns:
89 List of lines, each at most *width* characters wide.
90 """
91 if not text:
92 return [""]
93 words = text.split()
94 lines: list[str] = []
95 current = ""
96 for word in words:
97 if current and len(current) + 1 + len(word) > width:
98 lines.append(current)
99 current = word
100 else:
101 current = f"{current} {word}".strip() if current else word
102 if current:
103 lines.append(current)
104 return lines or [""]
107def _render_table(repos: list[_RepoInfo]) -> str:
108 """Render a list of repositories as a formatted table.
110 Args:
111 repos: List of repository info objects to display.
113 Returns:
114 Formatted table string ready to print.
115 """
116 if not repos:
117 return "No repositories found."
119 rw, dw, uw = _REPO_WIDTH, _DESC_WIDTH, _DATE_WIDTH
121 top = f"┌{'─' * (rw + 2)}┬{'─' * (dw + 2)}┬{'─' * (uw + 2)}┐"
122 sep = f"├{'─' * (rw + 2)}┼{'─' * (dw + 2)}┼{'─' * (uw + 2)}┤"
123 bot = f"└{'─' * (rw + 2)}┴{'─' * (dw + 2)}┴{'─' * (uw + 2)}┘"
125 def cell_row(r: str, d: str, u: str) -> str:
126 return f"│ {r:<{rw}} │ {d:<{dw}} │ {u:<{uw}} │"
128 header = f"│ {'Repo':^{rw}} │ {'Description':^{dw}} │ {'Updated':^{uw}} │"
130 lines = [top, header, sep]
131 for i, repo in enumerate(repos):
132 if i > 0:
133 lines.append(sep)
134 desc_lines = _wrap_text(repo.description, dw)
135 date_str = _format_date(repo.updated_at)
136 for j, desc_line in enumerate(desc_lines):
137 if j == 0:
138 lines.append(cell_row(repo.full_name, desc_line, date_str))
139 else:
140 lines.append(cell_row("", desc_line, ""))
141 lines.append(bot)
142 return "\n".join(lines)
145def list_repos(topic: str = _DEFAULT_TOPIC) -> bool:
146 """List GitHub repositories tagged with the given topic.
148 Queries the GitHub Search API for repositories with the specified topic
149 and prints them in a formatted table.
151 Args:
152 topic: GitHub topic to search for (default: 'rhiza').
154 Returns:
155 True on success, False if the API request failed.
156 """
157 try:
158 repos = _fetch_repos(topic)
159 except urllib.error.URLError as exc:
160 logger.error(f"Failed to fetch repositories: {exc}")
161 return False
163 if not repos:
164 logger.info(f"No repositories found with topic '{topic}'.")
165 return True
167 print(_render_table(repos))
168 return True