Coverage for src/rhiza_task/tasks/github.py: 100%

48 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:13 +0000

1"""The GitHub helpers: github.mk, as tasks. 

2 

3Six thin wrappers over ``gh``, and the reason the fragment could not retire with the 

4other ten: ``github`` is in the ``github-project`` profile, so a consumer on the flagship 

5profile would have lost ``make view-prs``. 

6 

7Nothing here is a gate. No aggregate names them, no workflow invokes them, and they 

8produce a table for a human at a prompt -- which is why the gh templates are carried over 

9character for character rather than reimplemented against ``--json``. Reproducing 

10``timeago`` and gh's colour handling in Python would be a worse table and a new thing to 

11maintain. 

12 

13Two shapes from the fragment disappear: 

14 

15``require-gh`` and ``gh-install`` were both "is gh installed?", spelled twice because make 

16has no way to say it once -- one hard-failing as a prerequisite, one warning as a target a 

17human runs. :class:`~rhiza_task.spec.Guard`'s ``tool`` field says it once, and the 

18outcome is a skip with the install URL attached. ``gh-install`` as a *task* goes: it never 

19installed anything, and ``rhiza-task doctor`` is where "what is missing on this machine" 

20belongs. 

21 

22``FORGE_TYPE`` goes too. github.mk computes it at parse time from the presence of 

23``.github/workflows`` or ``.gitlab-ci.yml`` and then no target in the fragment -- or in 

24any other fragment -- ever reads it. 

25""" 

26 

27from __future__ import annotations 

28 

29from ..config import Config 

30from ..spec import Guard, Skip, task 

31from ..uv import capture, tool 

32 

33SECTION = "GitHub Helpers" 

34 

35HAVE_GH = Guard( 

36 tool="gh", 

37 reason="gh not found; install from https://github.com/cli/cli#installation", 

38) 

39"""The single spelling of ``require-gh``, shared by every task in this module.""" 

40 

41RELEASE_WORKFLOW_JQ = '.[] | select(.name | test("release";"i")) | .name' 

42"""github.mk's own filter: the first workflow whose name mentions "release", any case.""" 

43 

44 

45def _header(*labels: str) -> str: 

46 """Build the bold header row of a gh table template. 

47 

48 Args: 

49 *labels: Column headings, in order. 

50 

51 Returns: 

52 A ``{{tablerow ...}}`` action rendering them in bold. 

53 """ 

54 cells = " ".join(f'(printf "{label}" | color "bold")' for label in labels) 

55 return "{{tablerow " + cells + "}}" 

56 

57 

58PR_TEMPLATE = _header("NUM", "TITLE", "AUTHOR", "BRANCH", "UPDATED") + ( 

59 "{{range .}}{{tablerow " 

60 '(printf "#%v" .number | color "green") ' 

61 ".title " 

62 '(.author.login | color "cyan") ' 

63 '(.headRefName | color "yellow") ' 

64 '(timeago .updatedAt | color "white")' 

65 "}}{{end}}" 

66) 

67 

68ISSUE_TEMPLATE = _header("NUM", "TITLE", "AUTHOR", "LABELS", "UPDATED") + ( 

69 "{{range .}}{{tablerow " 

70 '(printf "#%v" .number | color "green") ' 

71 ".title " 

72 '(.author.login | color "cyan") ' 

73 '(pluck "name" .labels | join ", " | color "yellow") ' 

74 '(timeago .updatedAt | color "white")' 

75 "}}{{end}}" 

76) 

77 

78FAILED_RUN_TEMPLATE = _header("STATUS", "NAME", "BRANCH", "EVENT", "TIME") + ( 

79 "{{range .}}{{tablerow " 

80 '(printf "%s" .conclusion | color "red") ' 

81 ".name " 

82 '(.headBranch | color "cyan") ' 

83 '(.event | color "yellow") ' 

84 '(timeago .createdAt | color "white")' 

85 "}}{{end}}" 

86) 

87 

88WORKFLOW_RUN_TEMPLATE = _header("STATUS", "CONCLUSION", "TITLE", "EVENT", "TIME") + ( 

89 "{{range .}}{{tablerow " 

90 '(printf "%s" .status | color "cyan") ' 

91 '(printf "%s" (or .conclusion "—") | color ' 

92 '(or (and (eq .conclusion "success") "green") (and (eq .conclusion "failure") "red") "yellow")) ' 

93 ".displayTitle " 

94 '(.event | color "yellow") ' 

95 '(timeago .createdAt | color "white")' 

96 "}}{{end}}" 

97) 

98 

99WHOAMI_TEMPLATE = ( 

100 "{{range $host, $accounts := .hosts}}{{range $accounts}}{{if .active}}" 

101 r' {{printf "✓" | color "green"}} Logged in to {{$host}} account ' 

102 r'{{.login | color "bold"}} ({{.tokenSource}}){{"\n"}}' 

103 r' Active account: {{printf "true" | color "green"}}{{"\n"}}' 

104 r' Git operations protocol: {{.gitProtocol | color "yellow"}}{{"\n"}}' 

105 r' Token scopes: {{.scopes | color "yellow"}}{{"\n"}}' 

106 "{{end}}{{end}}{{end}}" 

107) 

108 

109RELEASE_TEMPLATE = ( 

110 r' Tag: {{.tagName | color "green"}}{{"\n"}}' 

111 r' Name: {{.name}}{{"\n"}}' 

112 r' Author: {{.author.login}}{{"\n"}}' 

113 r' Published: {{timeago .publishedAt}}{{"\n"}}' 

114 r" Status: {{if .isDraft}}" 

115 r'{{printf "Draft" | color "yellow"}}' 

116 r"{{else if .isPrerelease}}" 

117 r'{{printf "Pre-release" | color "yellow"}}' 

118 r"{{else}}" 

119 r'{{printf "Published" | color "green"}}' 

120 r'{{end}}{{"\n"}}' 

121 r' URL: {{.url}}{{"\n"}}' 

122) 

123 

124 

125@task("view-prs", "list open pull requests", section=SECTION, guards=(HAVE_GH,)) 

126def view_prs(cfg: Config) -> None: 

127 """List the repository's open pull requests as a table. 

128 

129 Args: 

130 cfg: The resolved config. 

131 """ 

132 print("[INFO] Open Pull Requests:") 

133 tool( 

134 "gh", 

135 "pr", 

136 "list", 

137 "--json", 

138 "number,title,author,headRefName,updatedAt", 

139 "--template", 

140 PR_TEMPLATE, 

141 cwd=cfg.root, 

142 ) 

143 

144 

145@task("view-issues", "list open issues", section=SECTION, guards=(HAVE_GH,)) 

146def view_issues(cfg: Config) -> None: 

147 """List the repository's open issues as a table. 

148 

149 Args: 

150 cfg: The resolved config. 

151 """ 

152 print("[INFO] Open Issues:") 

153 tool( 

154 "gh", 

155 "issue", 

156 "list", 

157 "--json", 

158 "number,title,author,labels,updatedAt", 

159 "--template", 

160 ISSUE_TEMPLATE, 

161 cwd=cfg.root, 

162 ) 

163 

164 

165@task("failed-workflows", "list recent failing workflow runs", section=SECTION, guards=(HAVE_GH,)) 

166def failed_workflows(cfg: Config) -> None: 

167 """Show the ten most recent runs that concluded in failure. 

168 

169 Args: 

170 cfg: The resolved config. 

171 """ 

172 print("[INFO] Recent Failing Workflow Runs:") 

173 tool( 

174 "gh", 

175 "run", 

176 "list", 

177 "--limit", 

178 "10", 

179 "--status", 

180 "failure", 

181 "--json", 

182 "conclusion,name,headBranch,event,createdAt", 

183 "--template", 

184 FAILED_RUN_TEMPLATE, 

185 cwd=cfg.root, 

186 ) 

187 

188 

189@task("workflow-status", "show recent runs for the release workflow", section=SECTION, guards=(HAVE_GH,)) 

190def workflow_status(cfg: Config) -> None: 

191 """Find the release workflow by name, then show its five most recent runs. 

192 

193 Args: 

194 cfg: The resolved config. 

195 

196 Raises: 

197 Skip: When no workflow's name mentions "release". 

198 """ 

199 listing = capture("gh", "workflow", "list", "--json", "name,id", "--jq", RELEASE_WORKFLOW_JQ, cwd=cfg.root) 

200 workflow = next((line.strip() for line in listing.splitlines() if line.strip()), "") 

201 if not workflow: 

202 raise Skip("no release workflow in this repository") 

203 

204 print(f"[INFO] Release workflow: {workflow}") 

205 tool( 

206 "gh", 

207 "run", 

208 "list", 

209 "--workflow", 

210 workflow, 

211 "--limit", 

212 "5", 

213 "--json", 

214 "status,conclusion,headBranch,event,createdAt,displayTitle,url", 

215 "--template", 

216 WORKFLOW_RUN_TEMPLATE, 

217 cwd=cfg.root, 

218 ) 

219 

220 

221@task("latest-release", "show information about the latest GitHub release", section=SECTION, guards=(HAVE_GH,)) 

222def latest_release(cfg: Config) -> None: 

223 """Print tag, author, publication time and status for the newest release. 

224 

225 Args: 

226 cfg: The resolved config. 

227 

228 Raises: 

229 Skip: When the repository has published no release. 

230 """ 

231 # The probe is `capture`, not a second `gh release view`: it is the one call whose 

232 # *value* matters rather than its output, and capture returns "" for a non-zero exit, 

233 # which is exactly github.mk's `if gh release view ... >/dev/null 2>&1` branch. 

234 if not capture("gh", "release", "view", "--json", "tagName", "--jq", ".tagName", cwd=cfg.root): 

235 raise Skip("no releases in this repository") 

236 

237 print("[INFO] Latest release:") 

238 tool( 

239 "gh", 

240 "release", 

241 "view", 

242 "--json", 

243 "tagName,name,publishedAt,url,isDraft,isPrerelease,author", 

244 "--template", 

245 RELEASE_TEMPLATE, 

246 cwd=cfg.root, 

247 ) 

248 

249 

250@task("whoami", "check github auth status", section=SECTION, guards=(HAVE_GH,)) 

251def whoami(cfg: Config) -> None: 

252 """Report which account gh is authenticated as, and with what scopes. 

253 

254 Args: 

255 cfg: The resolved config. 

256 """ 

257 print("[INFO] GitHub Authentication Status:") 

258 tool( 

259 "gh", 

260 "auth", 

261 "status", 

262 "--hostname", 

263 "github.com", 

264 "--json", 

265 "hosts", 

266 "--template", 

267 WHOAMI_TEMPLATE, 

268 cwd=cfg.root, 

269 )