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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1"""The GitHub helpers: github.mk, as tasks.
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``.
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.
13Two shapes from the fragment disappear:
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.
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"""
27from __future__ import annotations
29from ..config import Config
30from ..spec import Guard, Skip, task
31from ..uv import capture, tool
33SECTION = "GitHub Helpers"
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."""
41RELEASE_WORKFLOW_JQ = '.[] | select(.name | test("release";"i")) | .name'
42"""github.mk's own filter: the first workflow whose name mentions "release", any case."""
45def _header(*labels: str) -> str:
46 """Build the bold header row of a gh table template.
48 Args:
49 *labels: Column headings, in order.
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 + "}}"
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)
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)
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)
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)
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)
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)
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.
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 )
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.
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 )
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.
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 )
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.
193 Args:
194 cfg: The resolved config.
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")
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 )
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.
225 Args:
226 cfg: The resolved config.
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")
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 )
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.
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 )