3"""Read-only ``gh`` probes, and the templates ``work plan`` emits for a human to run.
5Two jobs, one module, and the split between them is the point.
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.
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.
22Nothing in this module writes anything, anywhere.
25from __future__
import annotations
27from dataclasses
import dataclass
28from shutil
import which
30from work_git
import ToolMissingError, WorkError, run_process
36STATE_DEGRADED =
"DEGRADED"
39STATE_UNAVAILABLE =
"UNAVAILABLE"
42PROJECT_SCOPE =
"project"
44_SCOPES_MARKER =
"Token scopes:"
47@dataclass(frozen=True)
49 """One three-valued readiness answer."""
56def gh_executable() -> str | None:
57 """Return the resolved path to ``gh``, or None when it is not installed.
60 An absolute path, or None.
65def probe_version() -> Probe:
66 """Report whether ``gh`` is installed and which version answered.
69 :data:`STATE_OK` with the version line, or :data:`STATE_UNAVAILABLE`
70 naming which of "not installed" or "did not answer" applies.
72 found = gh_executable()
74 return Probe(
"gh", STATE_UNAVAILABLE,
"gh is not installed on PATH")
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}")
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)
85def parse_scopes(text: str) -> list[str] |
None:
86 """Extract the token scope list from ``gh auth status`` output.
89 text: Combined stdout and stderr of ``gh auth status``.
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
96 for line
in text.splitlines():
97 if _SCOPES_MARKER
not in line:
99 tail = line.split(_SCOPES_MARKER, 1)[1]
100 return [item.strip().strip(
"'\"")
for item
in tail.split(
",")
if item.strip().strip(
"'\"")]
104def probe_auth() -> Probe:
105 """Report whether a ``gh`` token is present and whether it can mutate a board.
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.
113 found = gh_executable()
115 return Probe(
"gh auth", STATE_UNAVAILABLE,
"gh is not installed, so no token can be read")
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}")
126 return Probe(
"gh auth", STATE_UNAVAILABLE,
"gh auth status reported no token scopes line")
127 if PROJECT_SCOPE
not in scopes:
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."
132 return Probe(
"gh auth", STATE_DEGRADED, detail)
133 return Probe(
"gh auth", STATE_OK, f
"token scopes are [{', '.join(scopes)}]")
136def _first_useful_line(text: str) -> str:
137 """Return the first non-empty line of ``text``, for a one-line diagnostic.
140 text: Redacted combined output.
143 The first non-empty stripped line, or an empty string.
145 for line
in text.splitlines():