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

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 full_name: str 

28 description: str 

29 updated_at: str 

30 

31 

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

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

34 

35 Args: 

36 topic: GitHub topic to search for. 

37 

38 Returns: 

39 List of repository info objects. 

40 

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

52 

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

56 

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 ] 

65 

66 

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

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

69 

70 Args: 

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

72 

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] 

79 

80 

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

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

83 

84 Args: 

85 text: The text to wrap. 

86 width: Maximum line width in characters. 

87 

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

105 

106 

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

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

109 

110 Args: 

111 repos: List of repository info objects to display. 

112 

113 Returns: 

114 Formatted table string ready to print. 

115 """ 

116 if not repos: 

117 return "No repositories found." 

118 

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

120 

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

124 

125 def cell_row(r: str, d: str, u: str) -> str: 

126 return f"{r:<{rw}}{d:<{dw}}{u:<{uw}}" 

127 

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

129 

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) 

143 

144 

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

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

147 

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

149 and prints them in a formatted table. 

150 

151 Args: 

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

153 

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 

162 

163 if not repos: 

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

165 return True 

166 

167 print(_render_table(repos)) 

168 return True