ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
work_gh.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Read-only ``gh`` probes, and the templates ``work plan`` emits for a human to run.
4
5Two jobs, one module, and the split between them is the point.
6
7The probe half is what ``work doctor`` calls. It runs ``gh --version`` and
8``gh auth status`` and nothing else, and it reports three-valued results:
9:data:`STATE_OK`, :data:`STATE_DEGRADED` and :data:`STATE_UNAVAILABLE`. The
10distinction that matters is between "gh is here and its token lacks the
11``project`` scope" and "gh is not here, or could not answer at all". Collapsing
12those two into one failure is how an agent ends up believing a board mutation
13is impossible when the real problem is that it is standing on the wrong host.
14
15The template half never runs anything. ``work plan --emit-commands`` renders a
16shell script to stdout for a person to read and run themselves, from a host
17whose token carries the scope. Every project, field and option id in that
18script is discovered BY NAME at run time through ``gh api graphql``; there is
19not one hardcoded node id anywhere, because a pasted id is a fact about one
20board on one day and it fails silently when it stops being true.
21
22Nothing in this module writes anything, anywhere.
23"""
24
25from __future__ import annotations
26
27from dataclasses import dataclass
28from shutil import which
29
30from work_git import ToolMissingError, WorkError, run_process
31
32#: The probe answered and everything it needs is present.
33STATE_OK = "OK"
34
35#: The probe answered, but something it found limits what is possible.
36STATE_DEGRADED = "DEGRADED"
37
38#: The probe could not answer at all.
39STATE_UNAVAILABLE = "UNAVAILABLE"
40
41#: The OAuth scope a GitHub Projects mutation needs.
42PROJECT_SCOPE = "project"
43
44_SCOPES_MARKER = "Token scopes:"
45
46
47@dataclass(frozen=True)
48class Probe:
49 """One three-valued readiness answer."""
50
51 name: str
52 state: str
53 detail: str
54
55
56def gh_executable() -> str | None:
57 """Return the resolved path to ``gh``, or None when it is not installed.
58
59 Returns:
60 An absolute path, or None.
61 """
62 return which("gh")
63
64
65def probe_version() -> Probe:
66 """Report whether ``gh`` is installed and which version answered.
67
68 Returns:
69 :data:`STATE_OK` with the version line, or :data:`STATE_UNAVAILABLE`
70 naming which of "not installed" or "did not answer" applies.
71 """
72 found = gh_executable()
73 if found is None:
74 return Probe("gh", STATE_UNAVAILABLE, "gh is not installed on PATH")
75 try:
76 done = run_process([found, "--version"], timeout=20)
77 except (ToolMissingError, WorkError) as exc:
78 return Probe("gh", STATE_UNAVAILABLE, f"gh --version did not answer: {exc}")
79 if not done.ok:
80 return Probe("gh", STATE_UNAVAILABLE, f"gh --version exited {done.returncode}")
81 first = (done.stdout.strip().splitlines() or [""])[0]
82 return Probe("gh", STATE_OK, first)
83
84
85def parse_scopes(text: str) -> list[str] | None:
86 """Extract the token scope list from ``gh auth status`` output.
87
88 Args:
89 text: Combined stdout and stderr of ``gh auth status``.
90
91 Returns:
92 The scope names in the order reported, or None when no scope line was
93 present at all. An empty list is a real answer and means the token
94 reported no scopes.
95 """
96 for line in text.splitlines():
97 if _SCOPES_MARKER not in line:
98 continue
99 tail = line.split(_SCOPES_MARKER, 1)[1]
100 return [item.strip().strip("'\"") for item in tail.split(",") if item.strip().strip("'\"")]
101 return None
102
103
104def probe_auth() -> Probe:
105 """Report whether a ``gh`` token is present and whether it can mutate a board.
106
107 Returns:
108 :data:`STATE_OK` when the token carries :data:`PROJECT_SCOPE`,
109 :data:`STATE_DEGRADED` when it authenticates without that scope, and
110 :data:`STATE_UNAVAILABLE` when gh is missing or the status command
111 could not be trusted to answer.
112 """
113 found = gh_executable()
114 if found is None:
115 return Probe("gh auth", STATE_UNAVAILABLE, "gh is not installed, so no token can be read")
116 try:
117 done = run_process([found, "auth", "status", "--hostname", "github.com"], timeout=30)
118 except (ToolMissingError, WorkError) as exc:
119 return Probe("gh auth", STATE_UNAVAILABLE, f"gh auth status did not answer: {exc}")
120 combined = f"{done.stdout}\n{done.stderr}"
121 scopes = parse_scopes(combined)
122 if not done.ok and scopes is None:
123 detail = _first_useful_line(combined) or f"gh auth status exited {done.returncode}"
124 return Probe("gh auth", STATE_UNAVAILABLE, f"not authenticated: {detail}")
125 if scopes is None:
126 return Probe("gh auth", STATE_UNAVAILABLE, "gh auth status reported no token scopes line")
127 if PROJECT_SCOPE not in scopes:
128 detail = (
129 f"token scopes are [{', '.join(scopes)}] with no {PROJECT_SCOPE} scope. "
130 "Board mutations must be run from a host whose token has it."
131 )
132 return Probe("gh auth", STATE_DEGRADED, detail)
133 return Probe("gh auth", STATE_OK, f"token scopes are [{', '.join(scopes)}]")
134
135
136def _first_useful_line(text: str) -> str:
137 """Return the first non-empty line of ``text``, for a one-line diagnostic.
138
139 Args:
140 text: Redacted combined output.
141
142 Returns:
143 The first non-empty stripped line, or an empty string.
144 """
145 for line in text.splitlines():
146 if line.strip():
147 return line.strip()
148 return ""