project-kit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- project_kit/__init__.py +8 -0
- project_kit/__main__.py +5 -0
- project_kit/agents.py +81 -0
- project_kit/ai_attribution.py +69 -0
- project_kit/approval.py +36 -0
- project_kit/assets/hooks/commit-msg +14 -0
- project_kit/assets/project/workflow.yml +63 -0
- project_kit/assets/skills/pk-decide/SKILL.md +63 -0
- project_kit/assets/skills/pk-discover/SKILL.md +57 -0
- project_kit/assets/skills/pk-handoff/SKILL.md +43 -0
- project_kit/assets/skills/pk-plan/SKILL.md +55 -0
- project_kit/assets/skills/pk-provision/SKILL.md +45 -0
- project_kit/assets/skills/pk-start/SKILL.md +47 -0
- project_kit/assets/skills/pk-workflow/SKILL.md +51 -0
- project_kit/assets/templates/adr.md +32 -0
- project_kit/assets/templates/backlog.example.json +59 -0
- project_kit/assets/templates/brief.md +77 -0
- project_kit/assets/templates/constitution-input.md +78 -0
- project_kit/assets/templates/development-workflow.md +51 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/adr.yml +21 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/bug.yml +25 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/config.yml +1 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/epic.yml +15 -0
- project_kit/assets/templates/github/ISSUE_TEMPLATE/feature.yml +22 -0
- project_kit/assets/templates/github/pull_request_template.md +21 -0
- project_kit/assets/templates/interview.md +68 -0
- project_kit/backlog.py +312 -0
- project_kit/cli.py +286 -0
- project_kit/config.py +139 -0
- project_kit/doctor.py +187 -0
- project_kit/fsutil.py +27 -0
- project_kit/gh.py +152 -0
- project_kit/handoff.py +360 -0
- project_kit/help.py +152 -0
- project_kit/installer.py +100 -0
- project_kit/labels.py +17 -0
- project_kit/project_choice.py +127 -0
- project_kit/providers/__init__.py +1 -0
- project_kit/providers/github.py +329 -0
- project_kit/provision.py +260 -0
- project_kit/provision_issues.py +360 -0
- project_kit/state.py +115 -0
- project_kit/templating.py +23 -0
- project_kit/workflow.py +168 -0
- project_kit_cli-0.1.0.dist-info/METADATA +97 -0
- project_kit_cli-0.1.0.dist-info/RECORD +49 -0
- project_kit_cli-0.1.0.dist-info/WHEEL +4 -0
- project_kit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- project_kit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
project_kit/config.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
""".project-kit/config.yml: project-level settings, versioned with the repo."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from project_kit.agents import AGENT_IDS
|
|
10
|
+
from project_kit.fsutil import atomic_write_text
|
|
11
|
+
|
|
12
|
+
PROJECT_DIR = ".project-kit"
|
|
13
|
+
PROVIDERS = ("github", "gitlab")
|
|
14
|
+
BRANCH_KEYS = ("main", "release", "develop")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(ValueError):
|
|
18
|
+
"""The configuration file is missing, unreadable or invalid."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def config_path(root: Path) -> Path:
|
|
22
|
+
return root / PROJECT_DIR / "config.yml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _default_branches() -> dict[str, str]:
|
|
26
|
+
return {"main": "main", "release": "release", "develop": "develop"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Config:
|
|
31
|
+
project_name: str
|
|
32
|
+
provider: str = "github"
|
|
33
|
+
language: str = "en"
|
|
34
|
+
branches: dict[str, str] = field(default_factory=_default_branches)
|
|
35
|
+
# False: AI assistants must not appear as co-authors of commits and pull requests.
|
|
36
|
+
ai_coauthor: bool = True
|
|
37
|
+
# The AI agent that guides the phases (see agents.py); empty until one is chosen.
|
|
38
|
+
agent: str = ""
|
|
39
|
+
|
|
40
|
+
def validate(self) -> None:
|
|
41
|
+
problems = []
|
|
42
|
+
if not self.project_name.strip():
|
|
43
|
+
problems.append("project_name must not be empty")
|
|
44
|
+
if self.provider not in PROVIDERS:
|
|
45
|
+
problems.append(
|
|
46
|
+
f"provider must be one of {', '.join(PROVIDERS)}, got {self.provider!r}"
|
|
47
|
+
)
|
|
48
|
+
if self.agent and self.agent not in AGENT_IDS:
|
|
49
|
+
problems.append(f"agent must be one of {', '.join(AGENT_IDS)}, got {self.agent!r}")
|
|
50
|
+
if not self.language.strip():
|
|
51
|
+
problems.append("language must not be empty")
|
|
52
|
+
if set(self.branches) != set(BRANCH_KEYS):
|
|
53
|
+
problems.append(f"branches must define exactly: {', '.join(BRANCH_KEYS)}")
|
|
54
|
+
else:
|
|
55
|
+
for key, name in self.branches.items():
|
|
56
|
+
if not isinstance(name, str) or not name.strip():
|
|
57
|
+
problems.append(f"branches.{key} must be a non-empty string")
|
|
58
|
+
names = list(self.branches.values())
|
|
59
|
+
if len(set(names)) != len(names):
|
|
60
|
+
problems.append("branches must use three different names")
|
|
61
|
+
if problems:
|
|
62
|
+
raise ConfigError("Invalid config: " + "; ".join(problems))
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
data: dict[str, Any] = {
|
|
66
|
+
"project_name": self.project_name,
|
|
67
|
+
"provider": self.provider,
|
|
68
|
+
"language": self.language,
|
|
69
|
+
"branches": dict(self.branches),
|
|
70
|
+
"ai_coauthor": self.ai_coauthor,
|
|
71
|
+
}
|
|
72
|
+
if self.agent:
|
|
73
|
+
data["agent"] = self.agent
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_HEADER = "# project-kit configuration. Safe to edit by hand.\n"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_config(text: str) -> Config:
|
|
81
|
+
try:
|
|
82
|
+
data = yaml.safe_load(text)
|
|
83
|
+
except yaml.YAMLError as exc:
|
|
84
|
+
raise ConfigError(f"config.yml is not valid YAML: {exc}") from exc
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise ConfigError("config.yml must contain a mapping of settings")
|
|
87
|
+
|
|
88
|
+
unknown = set(data) - {
|
|
89
|
+
"project_name",
|
|
90
|
+
"provider",
|
|
91
|
+
"language",
|
|
92
|
+
"branches",
|
|
93
|
+
"ai_coauthor",
|
|
94
|
+
"agent",
|
|
95
|
+
}
|
|
96
|
+
if unknown:
|
|
97
|
+
raise ConfigError(
|
|
98
|
+
f"Unknown setting(s) in config.yml: {', '.join(sorted(map(str, unknown)))}"
|
|
99
|
+
)
|
|
100
|
+
if "project_name" not in data:
|
|
101
|
+
raise ConfigError("config.yml is missing required setting: project_name")
|
|
102
|
+
|
|
103
|
+
raw_branches = data.get("branches") or {}
|
|
104
|
+
if not isinstance(raw_branches, dict):
|
|
105
|
+
raise ConfigError("branches must be a mapping (main, release, develop)")
|
|
106
|
+
branches = {**_default_branches(), **raw_branches}
|
|
107
|
+
for key in ("project_name", "provider", "language", "agent"):
|
|
108
|
+
if key in data and not isinstance(data[key], str):
|
|
109
|
+
raise ConfigError(f"{key} must be a string")
|
|
110
|
+
|
|
111
|
+
ai_coauthor = data.get("ai_coauthor", True)
|
|
112
|
+
if not isinstance(ai_coauthor, bool):
|
|
113
|
+
raise ConfigError("ai_coauthor must be true or false")
|
|
114
|
+
|
|
115
|
+
config = Config(
|
|
116
|
+
project_name=data["project_name"],
|
|
117
|
+
provider=data.get("provider", "github"),
|
|
118
|
+
language=data.get("language", "en"),
|
|
119
|
+
branches=branches,
|
|
120
|
+
ai_coauthor=ai_coauthor,
|
|
121
|
+
agent=data.get("agent", ""),
|
|
122
|
+
)
|
|
123
|
+
config.validate()
|
|
124
|
+
return config
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_config(root: Path) -> Config:
|
|
128
|
+
path = config_path(root)
|
|
129
|
+
try:
|
|
130
|
+
text = path.read_text(encoding="utf-8")
|
|
131
|
+
except FileNotFoundError as exc:
|
|
132
|
+
raise ConfigError(f"No config found at {path}. Run `project-kit init` first.") from exc
|
|
133
|
+
return parse_config(text)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def save_config(root: Path, config: Config) -> None:
|
|
137
|
+
config.validate()
|
|
138
|
+
body = yaml.safe_dump(config.to_dict(), sort_keys=False, allow_unicode=True)
|
|
139
|
+
atomic_write_text(config_path(root), _HEADER + body)
|
project_kit/doctor.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""``project-kit doctor``: check that the tools project-kit relies on are available."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from project_kit.gh import Gh, GhAuthError, GhError, GhNotFoundError
|
|
12
|
+
|
|
13
|
+
REQUIRED_GH_SCOPES = ("repo", "project")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Status(Enum):
|
|
17
|
+
OK = "ok"
|
|
18
|
+
WARN = "warn" # something optional is missing; project-kit still works
|
|
19
|
+
FAIL = "fail" # a hard requirement is missing; project-kit cannot do its job
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Check:
|
|
24
|
+
name: str
|
|
25
|
+
status: Status
|
|
26
|
+
detail: str
|
|
27
|
+
hint: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
Which = Callable[[str], str | None]
|
|
31
|
+
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _version(exe: str, runner: Runner) -> str:
|
|
35
|
+
"""First line of ``<exe> --version``, or an empty string if it cannot be read."""
|
|
36
|
+
try:
|
|
37
|
+
proc = runner([exe, "--version"], capture_output=True, text=True, encoding="utf-8")
|
|
38
|
+
except OSError:
|
|
39
|
+
return ""
|
|
40
|
+
return (proc.stdout or "").strip().splitlines()[0] if proc.returncode == 0 else ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_git(which: Which, runner: Runner) -> Check:
|
|
44
|
+
if not which("git"):
|
|
45
|
+
return Check(
|
|
46
|
+
"git",
|
|
47
|
+
Status.FAIL,
|
|
48
|
+
"not found",
|
|
49
|
+
"Install Git from https://git-scm.com/downloads and make sure it is on PATH.",
|
|
50
|
+
)
|
|
51
|
+
return Check("git", Status.OK, _version("git", runner) or "found")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_scopes(auth_status: str) -> list[str] | None:
|
|
55
|
+
"""Scopes listed by ``gh auth status``; ``None`` if the output does not list any."""
|
|
56
|
+
match = re.search(r"token scopes:\s*(.+)", auth_status, re.IGNORECASE)
|
|
57
|
+
if not match:
|
|
58
|
+
return None
|
|
59
|
+
return re.findall(r"[\w:.-]+", match.group(1))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_gh(gh: Gh) -> list[Check]:
|
|
63
|
+
"""One check for the CLI itself and, if usable, one for the session and its scopes."""
|
|
64
|
+
try:
|
|
65
|
+
version = gh.run("--version").strip().splitlines()[0]
|
|
66
|
+
except GhNotFoundError:
|
|
67
|
+
return [
|
|
68
|
+
Check(
|
|
69
|
+
"gh",
|
|
70
|
+
Status.FAIL,
|
|
71
|
+
"not found",
|
|
72
|
+
"Install the GitHub CLI from https://cli.github.com/ and make sure it is on PATH.",
|
|
73
|
+
)
|
|
74
|
+
]
|
|
75
|
+
except GhError as exc:
|
|
76
|
+
return [Check("gh", Status.FAIL, str(exc))]
|
|
77
|
+
|
|
78
|
+
checks = [Check("gh", Status.OK, version)]
|
|
79
|
+
try:
|
|
80
|
+
scopes = parse_scopes(gh.run("auth", "status"))
|
|
81
|
+
except GhAuthError:
|
|
82
|
+
checks.append(
|
|
83
|
+
Check(
|
|
84
|
+
"gh auth",
|
|
85
|
+
Status.FAIL,
|
|
86
|
+
"not logged in",
|
|
87
|
+
"Run `gh auth login` and grant the scopes: " + ", ".join(REQUIRED_GH_SCOPES),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return checks
|
|
91
|
+
except GhError as exc:
|
|
92
|
+
checks.append(Check("gh auth", Status.FAIL, str(exc)))
|
|
93
|
+
return checks
|
|
94
|
+
|
|
95
|
+
if scopes is None:
|
|
96
|
+
# Fine-grained tokens and GH_TOKEN do not list scopes, so this cannot be verified.
|
|
97
|
+
checks.append(
|
|
98
|
+
Check(
|
|
99
|
+
"gh auth",
|
|
100
|
+
Status.WARN,
|
|
101
|
+
"logged in, but token scopes cannot be verified",
|
|
102
|
+
f"Make sure the token allows: {', '.join(REQUIRED_GH_SCOPES)}.",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return checks
|
|
106
|
+
|
|
107
|
+
missing = [s for s in REQUIRED_GH_SCOPES if s not in scopes]
|
|
108
|
+
if missing:
|
|
109
|
+
checks.append(
|
|
110
|
+
Check(
|
|
111
|
+
"gh auth",
|
|
112
|
+
Status.FAIL,
|
|
113
|
+
f"missing scopes: {', '.join(missing)}",
|
|
114
|
+
f"Run `gh auth refresh -s {','.join(missing)}`.",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
checks.append(Check("gh auth", Status.OK, f"scopes: {', '.join(scopes)}"))
|
|
119
|
+
return checks
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_uv(which: Which, runner: Runner) -> Check:
|
|
123
|
+
if not which("uv"):
|
|
124
|
+
return Check(
|
|
125
|
+
"uv",
|
|
126
|
+
Status.WARN,
|
|
127
|
+
"not found",
|
|
128
|
+
"Needed to install Spec Kit. See https://docs.astral.sh/uv/ .",
|
|
129
|
+
)
|
|
130
|
+
return Check("uv", Status.OK, _version("uv", runner) or "found")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_speckit(which: Which, runner: Runner, cwd: Path) -> Check:
|
|
134
|
+
initialised = (cwd / ".specify").is_dir()
|
|
135
|
+
if which("specify"):
|
|
136
|
+
version = _version("specify", runner)
|
|
137
|
+
detail = version or "specify found"
|
|
138
|
+
return Check(
|
|
139
|
+
"spec-kit", Status.OK, detail + ("" if initialised else " (not initialised here)")
|
|
140
|
+
)
|
|
141
|
+
if initialised:
|
|
142
|
+
return Check("spec-kit", Status.OK, ".specify/ found (specify CLI not on PATH)")
|
|
143
|
+
return Check(
|
|
144
|
+
"spec-kit",
|
|
145
|
+
Status.WARN,
|
|
146
|
+
"not installed",
|
|
147
|
+
"Optional until the handoff phase; project-kit can install it for you.",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run_checks(
|
|
152
|
+
*,
|
|
153
|
+
gh: Gh | None = None,
|
|
154
|
+
which: Which = shutil.which,
|
|
155
|
+
runner: Runner = subprocess.run,
|
|
156
|
+
cwd: Path | None = None,
|
|
157
|
+
) -> list[Check]:
|
|
158
|
+
return [
|
|
159
|
+
check_git(which, runner),
|
|
160
|
+
*check_gh(gh or Gh()),
|
|
161
|
+
check_uv(which, runner),
|
|
162
|
+
check_speckit(which, runner, cwd or Path.cwd()),
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_MARK = {Status.OK: "ok ", Status.WARN: "warn", Status.FAIL: "FAIL"}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def render(checks: list[Check]) -> str:
|
|
170
|
+
lines = []
|
|
171
|
+
for c in checks:
|
|
172
|
+
lines.append(f"[{_MARK[c.status]}] {c.name}: {c.detail}")
|
|
173
|
+
if c.hint and c.status is not Status.OK:
|
|
174
|
+
lines.append(f" -> {c.hint}")
|
|
175
|
+
failed = sum(c.status is Status.FAIL for c in checks)
|
|
176
|
+
warned = sum(c.status is Status.WARN for c in checks)
|
|
177
|
+
if failed:
|
|
178
|
+
lines.append(f"\n{failed} blocking problem(s). Fix them before using project-kit.")
|
|
179
|
+
elif warned:
|
|
180
|
+
lines.append(f"\nReady, with {warned} warning(s).")
|
|
181
|
+
else:
|
|
182
|
+
lines.append("\nAll good.")
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def exit_code(checks: list[Check]) -> int:
|
|
187
|
+
return 1 if any(c.status is Status.FAIL for c in checks) else 0
|
project_kit/fsutil.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""File helpers."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
10
|
+
"""Write ``text`` to ``path`` so readers see either the old or the new content, never a
|
|
11
|
+
half-written file, even if the process is killed mid-write.
|
|
12
|
+
|
|
13
|
+
The data goes to a temporary file in the same directory (so the final rename stays on
|
|
14
|
+
one filesystem), is flushed to disk, and then replaces the target in a single step.
|
|
15
|
+
"""
|
|
16
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
18
|
+
try:
|
|
19
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as tmp:
|
|
20
|
+
tmp.write(text)
|
|
21
|
+
tmp.flush()
|
|
22
|
+
os.fsync(tmp.fileno())
|
|
23
|
+
os.replace(tmp_name, path)
|
|
24
|
+
except BaseException:
|
|
25
|
+
with contextlib.suppress(OSError):
|
|
26
|
+
os.unlink(tmp_name)
|
|
27
|
+
raise
|
project_kit/gh.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Thin wrapper around the GitHub CLI (``gh``), run as a subprocess (design decision D7).
|
|
2
|
+
|
|
3
|
+
``gh`` inherits the user's authentication and scopes. Transient failures (HTTP 5xx, flaky
|
|
4
|
+
GraphQL responses, network hiccups) are retried with exponential backoff; anything else
|
|
5
|
+
is raised immediately with a clear message.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
# Matched case-insensitively against stderr. Kept narrow on purpose: retrying a real
|
|
16
|
+
# error (bad arguments, missing permissions) would only hide it behind a delay.
|
|
17
|
+
_TRANSIENT = re.compile(
|
|
18
|
+
r"\b(50[0-4])\b"
|
|
19
|
+
r"|bad gateway|service unavailable|gateway time-?out"
|
|
20
|
+
r"|something went wrong while executing your query"
|
|
21
|
+
r"|timeout|timed out|connection reset|unexpected eof|\beof\b"
|
|
22
|
+
r"|temporary failure|try again later|secondary rate limit",
|
|
23
|
+
re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
# A *primary* limit or a burst limit: retrying within seconds does not help, so it is reported
|
|
26
|
+
# instead (the "secondary rate limit" wording is still retried, see _TRANSIENT).
|
|
27
|
+
_RATE_LIMITED = re.compile(r"rate limit exceeded|api rate limit", re.IGNORECASE)
|
|
28
|
+
_NOT_AUTHENTICATED = re.compile(
|
|
29
|
+
r"gh auth login|not logged in|authentication required|bad credentials|http 401",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GhError(Exception):
|
|
37
|
+
"""A ``gh`` invocation failed."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
message: str,
|
|
42
|
+
*,
|
|
43
|
+
args_: Sequence[str] = (),
|
|
44
|
+
returncode: int | None = None,
|
|
45
|
+
stderr: str = "",
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.gh_args = list(args_)
|
|
49
|
+
self.returncode = returncode
|
|
50
|
+
self.stderr = stderr
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GhNotFoundError(GhError):
|
|
54
|
+
"""The ``gh`` executable is not installed or not on PATH."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class GhAuthError(GhError):
|
|
58
|
+
"""``gh`` is installed but there is no valid session."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GhRateLimitError(GhError):
|
|
62
|
+
"""GitHub's API rate limit was reached."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decode(data: bytes | str | None) -> str:
|
|
66
|
+
"""Output as text with LF line endings, whatever the platform or how GitHub stored it
|
|
67
|
+
(bodies typed in the web editor come back with CRLF)."""
|
|
68
|
+
if data is None:
|
|
69
|
+
return ""
|
|
70
|
+
text = data.decode("utf-8", errors="replace") if isinstance(data, bytes) else data
|
|
71
|
+
return text.replace("\r\n", "\n")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Gh:
|
|
75
|
+
"""Runs ``gh`` commands. ``runner`` and ``sleep`` are injectable for tests."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
executable: str = "gh",
|
|
81
|
+
retries: int = 3,
|
|
82
|
+
backoff: float = 1.0,
|
|
83
|
+
runner: Runner = subprocess.run,
|
|
84
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.executable = executable
|
|
87
|
+
self.retries = retries
|
|
88
|
+
self.backoff = backoff
|
|
89
|
+
self._runner = runner
|
|
90
|
+
self._sleep = sleep
|
|
91
|
+
|
|
92
|
+
def run(self, *args: str, input: str | None = None) -> str:
|
|
93
|
+
"""Run ``gh <args>`` and return stdout. Retries transient failures."""
|
|
94
|
+
cmd = [self.executable, *args]
|
|
95
|
+
attempt = 0
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
proc = self._runner(
|
|
99
|
+
cmd,
|
|
100
|
+
# Bytes, not text: in text mode Windows turns every LF we send into
|
|
101
|
+
# CRLF, which would end up in issue bodies.
|
|
102
|
+
input=None if input is None else input.encode("utf-8"),
|
|
103
|
+
capture_output=True,
|
|
104
|
+
)
|
|
105
|
+
except FileNotFoundError as exc:
|
|
106
|
+
raise GhNotFoundError(
|
|
107
|
+
"GitHub CLI (gh) not found. Install it from https://cli.github.com/ "
|
|
108
|
+
"and make sure it is on your PATH.",
|
|
109
|
+
args_=args,
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
if proc.returncode == 0:
|
|
113
|
+
return _decode(proc.stdout)
|
|
114
|
+
|
|
115
|
+
stderr = _decode(proc.stderr).strip()
|
|
116
|
+
if _RATE_LIMITED.search(stderr) and not _TRANSIENT.search(stderr):
|
|
117
|
+
raise GhRateLimitError(
|
|
118
|
+
"GitHub's API rate limit was reached. Wait a minute (longer if it "
|
|
119
|
+
"happens again) and run the command again; progress so far is saved.",
|
|
120
|
+
args_=args,
|
|
121
|
+
returncode=proc.returncode,
|
|
122
|
+
stderr=stderr,
|
|
123
|
+
)
|
|
124
|
+
if _NOT_AUTHENTICATED.search(stderr):
|
|
125
|
+
raise GhAuthError(
|
|
126
|
+
"gh is not authenticated. Run `gh auth login` (scopes needed: repo, project).",
|
|
127
|
+
args_=args,
|
|
128
|
+
returncode=proc.returncode,
|
|
129
|
+
stderr=stderr,
|
|
130
|
+
)
|
|
131
|
+
if attempt < self.retries and _TRANSIENT.search(stderr):
|
|
132
|
+
self._sleep(self.backoff * 2**attempt)
|
|
133
|
+
attempt += 1
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
shown = " ".join(args)
|
|
137
|
+
raise GhError(
|
|
138
|
+
f"`gh {shown}` failed (exit {proc.returncode}): {stderr or 'no error output'}",
|
|
139
|
+
args_=args,
|
|
140
|
+
returncode=proc.returncode,
|
|
141
|
+
stderr=stderr,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def json(self, *args: str, input: str | None = None) -> Any:
|
|
145
|
+
"""Run ``gh <args>`` and parse stdout as JSON."""
|
|
146
|
+
out = self.run(*args, input=input)
|
|
147
|
+
try:
|
|
148
|
+
return json.loads(out)
|
|
149
|
+
except json.JSONDecodeError as exc:
|
|
150
|
+
raise GhError(
|
|
151
|
+
f"`gh {' '.join(args)}` did not return valid JSON: {exc}", args_=args
|
|
152
|
+
) from exc
|