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

1"""Command for listing GitHub repositories with the rhiza topic. 

2 

3This module queries the GitHub Search API for repositories tagged with 

4the 'rhiza' topic and displays them in a formatted table. 

5""" 

6 

7import json 

8import os 

9import urllib.error 

10import urllib.request 

11from dataclasses import dataclass 

12 

13from loguru import logger 

14 

15_GITHUB_SEARCH_URL = "https://api.github.com/search/repositories" 

16_DEFAULT_TOPIC = "rhiza" 

17_PER_PAGE = 50 

18 

19# Fixed column content widths (excluding 1-space padding on each side) 

20_REPO_WIDTH = 20 

21_DESC_WIDTH = 56 

22_DATE_WIDTH = 10 

23 

24 

25@dataclass 

26class _RepoInfo: 

27 """Minimal repository metadata returned by the GitHub Search API.""" 

28 

29 full_name: str 

30 description: str 

31 updated_at: str 

32 

33 

34def _fetch_repos(topic: str = _DEFAULT_TOPIC) -> list[_RepoInfo]: 

35 """Fetch repositories from the GitHub Search API with the given topic. 

36 

37 Args: 

38 topic: GitHub topic to search for. 

39 

40 Returns: 

41 List of repository info objects. 

42 

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}" 

54 

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()) 

58 

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 ] 

67 

68 

69def _format_date(iso_date: str) -> str: 

70 """Format an ISO 8601 date string to YYYY-MM-DD. 

71 

72 Args: 

73 iso_date: ISO 8601 date string (e.g. '2026-03-02T12:12:02Z'). 

74 

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] 

81 

82 

83def _wrap_text(text: str, width: int) -> list[str]: 

84 """Wrap text to fit within a given width, splitting on word boundaries. 

85 

86 Args: 

87 text: The text to wrap. 

88 width: Maximum line width in characters. 

89 

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 [""] 

107 

108 

109def _render_table(repos: list[_RepoInfo]) -> str: 

110 """Render a list of repositories as a formatted table. 

111 

112 Args: 

113 repos: List of repository info objects to display. 

114 

115 Returns: 

116 Formatted table string ready to print. 

117 """ 

118 if not repos: 

119 return "No repositories found." 

120 

121 rw, dw, uw = _REPO_WIDTH, _DESC_WIDTH, _DATE_WIDTH 

122 

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)}" 

126 

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}}" 

130 

131 header = f"{'Repo':^{rw}}{'Description':^{dw}}{'Updated':^{uw}}" 

132 

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) 

146 

147 

148def list_repos(topic: str = _DEFAULT_TOPIC) -> bool: 

149 """List GitHub repositories tagged with the given topic. 

150 

151 Queries the GitHub Search API for repositories with the specified topic 

152 and prints them in a formatted table. 

153 

154 Args: 

155 topic: GitHub topic to search for (default: 'rhiza'). 

156 

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 

165 

166 if not repos: 

167 logger.info(f"No repositories found with topic '{topic}'.") 

168 return True 

169 

170 print(_render_table(repos)) 

171 return True