qaas-python 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.
Files changed (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/adapters/vcs.py ADDED
@@ -0,0 +1,494 @@
1
+ """Version control behind an adapter, so policy has one place to stand.
2
+
3
+ The adapter deliberately knows nothing about agents or policy: it is a thin,
4
+ argv-only wrapper over git. Every "may this agent do this" question is answered
5
+ one layer up, in `qaas.mcp.vcs`, because that is where the run context lives and
6
+ where a refusal can be logged to the ledger.
7
+
8
+ The split also keeps the §8.1 matrix honest when the backend changes. Swapping
9
+ `vcs: local` for `vcs: github` must not quietly widen what FORGE may do, and it
10
+ cannot, because the enforcement is not in here.
11
+
12
+ Two capabilities are absent on purpose rather than by oversight: there is no
13
+ force-push and no merge. §8.1 says never force-push and §8.4 says merge is
14
+ always human, and the cheapest way to guarantee that is to never write the code.
15
+
16
+ The hosted backend drives the `gh` CLI rather than the REST API, so the system
17
+ never holds a token: it acts as whoever is already logged in, with exactly their
18
+ access and no more.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ import subprocess
26
+ from abc import ABC, abstractmethod
27
+ from pathlib import Path
28
+ from typing import Any, Mapping, Sequence
29
+
30
+ # Long enough for a slow `git status` on a large tree, short enough that a
31
+ # hanging git (a credential prompt, a stuck lock) fails the tool call instead of
32
+ # stalling the agent's turn.
33
+ GIT_TIMEOUT_S = 60
34
+
35
+ # Network round trips (`gh pr create`, a clone) are slower than local git but
36
+ # still bounded: an agent's turn should fail loudly, never hang.
37
+ GH_TIMEOUT_S = 120
38
+
39
+ # Used only when the environment has no committer identity of its own. Never
40
+ # overrides a configured one: an agent commit in a real repo should carry the
41
+ # repo's configured author, not a synthetic one.
42
+ FALLBACK_IDENTITY = ("QAaS Agent", "qaas-agent@localhost")
43
+
44
+
45
+ class VcsError(RuntimeError):
46
+ """A git operation failed. Carries the command's own stderr, which is the
47
+ only text an agent can actually act on."""
48
+
49
+
50
+ class VcsAdapter(ABC):
51
+ """The operations the QA system needs from version control. Nothing more.
52
+
53
+ Kept small on purpose: every method here is a method some agent can reach
54
+ through the vcs MCP server, so each addition widens the blast radius.
55
+ """
56
+
57
+ @abstractmethod
58
+ def current_branch(self) -> str:
59
+ """Name of the checked-out branch ('HEAD' if detached)."""
60
+
61
+ @abstractmethod
62
+ def create_branch(self, name: str, from_ref: str | None = None) -> str:
63
+ """Create `name` and switch to it. Returns the branch now checked out."""
64
+
65
+ @abstractmethod
66
+ def write_files(self, files: Mapping[str, str]) -> list[str]:
67
+ """Write repo-relative path -> content. Returns the paths written."""
68
+
69
+ @abstractmethod
70
+ def commit(self, message: str, paths: Sequence[str] | None = None) -> str:
71
+ """Stage `paths` (all tracked changes if None) and commit. Returns the sha."""
72
+
73
+ @abstractmethod
74
+ def diff(self, ref: str | None = None, paths: Sequence[str] | None = None) -> str:
75
+ """Unified diff of the working tree, optionally against `ref`."""
76
+
77
+ @abstractmethod
78
+ def list_branches(self) -> list[str]:
79
+ """Local branch names."""
80
+
81
+
82
+ class LocalGit(VcsAdapter):
83
+ """git in a local checkout, driven by argv lists.
84
+
85
+ Never takes a command string. Everything an agent supplies arrives as a
86
+ single argv element, so a branch named `x; rm -rf /` is a branch name git
87
+ rejects, not a shell fragment.
88
+ """
89
+
90
+ def __init__(self, repo: Path | str, timeout_s: int = GIT_TIMEOUT_S):
91
+ self.repo = Path(repo).resolve()
92
+ self.timeout_s = timeout_s
93
+
94
+ # -- plumbing ---------------------------------------------------------
95
+
96
+ def _git(self, *args: str, check: bool = True) -> str:
97
+ try:
98
+ proc = subprocess.run(
99
+ ["git", *args],
100
+ cwd=self.repo,
101
+ capture_output=True,
102
+ text=True,
103
+ timeout=self.timeout_s,
104
+ check=False,
105
+ )
106
+ except subprocess.TimeoutExpired as exc:
107
+ raise VcsError(f"git {' '.join(args)} timed out after {self.timeout_s}s") from exc
108
+ except OSError as exc: # git missing, repo path gone
109
+ raise VcsError(f"could not run git: {exc}") from exc
110
+ if check and proc.returncode != 0:
111
+ detail = (proc.stderr or proc.stdout).strip() or f"exit {proc.returncode}"
112
+ raise VcsError(f"git {' '.join(args)} failed: {detail}")
113
+ return proc.stdout
114
+
115
+ def _identity_args(self) -> list[str]:
116
+ """`-c` overrides only when the environment has no identity configured."""
117
+ configured = self._git("config", "--get", "user.email", check=False).strip()
118
+ if configured:
119
+ return []
120
+ name, email = FALLBACK_IDENTITY
121
+ return ["-c", f"user.name={name}", "-c", f"user.email={email}"]
122
+
123
+ # -- VcsAdapter -------------------------------------------------------
124
+
125
+ def current_branch(self) -> str:
126
+ # `branch --show-current` answers even on a repo with no commits yet,
127
+ # where `rev-parse --abbrev-ref HEAD` errors out.
128
+ name = self._git("branch", "--show-current").strip()
129
+ return name or "HEAD"
130
+
131
+ def create_branch(self, name: str, from_ref: str | None = None) -> str:
132
+ args = ["checkout", "-b", name]
133
+ if from_ref:
134
+ args.append(from_ref)
135
+ self._git(*args)
136
+ return self.current_branch()
137
+
138
+ def checkout(self, ref: str) -> str:
139
+ """Switch to an existing ref. Not part of the abstract surface: only the
140
+ local backend has a working tree to switch."""
141
+ self._git("checkout", ref)
142
+ return self.current_branch()
143
+
144
+ def write_files(self, files: Mapping[str, str]) -> list[str]:
145
+ written: list[str] = []
146
+ for rel, content in files.items():
147
+ path = self.repo / rel
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+ path.write_text(content)
150
+ written.append(rel)
151
+ return written
152
+
153
+ def commit(self, message: str, paths: Sequence[str] | None = None) -> str:
154
+ if paths:
155
+ self._git("add", "--", *paths)
156
+ else:
157
+ self._git("add", "-A")
158
+ staged = self._git("diff", "--cached", "--name-only").strip()
159
+ if not staged:
160
+ raise VcsError("nothing staged to commit")
161
+ self._git(*self._identity_args(), "commit", "-m", message)
162
+ return self._git("rev-parse", "HEAD").strip()
163
+
164
+ def diff(self, ref: str | None = None, paths: Sequence[str] | None = None) -> str:
165
+ args = ["diff"]
166
+ if ref:
167
+ args.append(ref)
168
+ if paths:
169
+ args.extend(["--", *paths])
170
+ return self._git(*args)
171
+
172
+ def list_branches(self) -> list[str]:
173
+ out = self._git("for-each-ref", "--format=%(refname:short)", "refs/heads")
174
+ return [line.strip() for line in out.splitlines() if line.strip()]
175
+
176
+
177
+ # Branches an agent may never publish or use as a PR head. Enforced in the
178
+ # adapter and not only in `qaas.mcp.vcs`, because the adapter is the layer that
179
+ # actually touches the user's remote: a caller that skips the MCP server (a
180
+ # script, a future runner, a mistake) still cannot push to main.
181
+ PROTECTED_HEAD_BRANCHES = frozenset({"main", "master", "trunk", "develop"})
182
+ PROTECTED_HEAD_PREFIXES = ("release",)
183
+
184
+ # Appended to every PR body. A reviewer looking at a pull request has to be able
185
+ # to tell in one line that no human wrote it and which finding it came from;
186
+ # §8.4 makes the merge their decision, and they cannot make it well while
187
+ # mistaking the author for a colleague.
188
+ AUTOMATION_NOTICE = (
189
+ "---\n"
190
+ "Opened by an automated QA agent (QAaS) for ticket {ticket}. "
191
+ "No human authored this branch. Merge is a human decision (architecture §8.4)."
192
+ )
193
+
194
+ _PR_NUMBER_IN_URL = re.compile(r"/pull/(\d+)")
195
+
196
+ _GH_MISSING = (
197
+ "the GitHub CLI ('gh') is not available: {detail}. Install it from "
198
+ "https://cli.github.com, run `gh auth login`, or set `vcs: local` in "
199
+ "config/system.yaml to work against a local checkout instead."
200
+ )
201
+ _GH_UNAUTHENTICATED = (
202
+ "the GitHub CLI is not authenticated: {detail}. Run `gh auth login` (the "
203
+ "adapter deliberately holds no token of its own and reuses your gh "
204
+ "credentials), or set `vcs: local` in config/system.yaml."
205
+ )
206
+
207
+
208
+ def _reject_flaglike(kind: str, value: str) -> str:
209
+ """Refuse a ref/slug that could be read as an option by git or gh.
210
+
211
+ Everything is passed as its own argv element, so there is no shell to
212
+ escape; the one remaining trick is a value like `--upload-pack=...` landing
213
+ where a ref was expected. Names cannot start with `-`, so refusing that is
214
+ free.
215
+ """
216
+ text = str(value).strip()
217
+ if not text:
218
+ raise VcsError(f"{kind} is required.")
219
+ if text.startswith("-"):
220
+ raise VcsError(f"refusing {kind} '{text}': a ref may not start with '-'.")
221
+ return text
222
+
223
+
224
+ def is_protected_head(branch: str) -> bool:
225
+ """Whether `branch` is one no agent may push or open a PR from (§8.1)."""
226
+ name = branch.strip().lower().removeprefix("refs/heads/")
227
+ return name in PROTECTED_HEAD_BRANCHES or name.startswith(PROTECTED_HEAD_PREFIXES)
228
+
229
+
230
+ class GitHubVcs(LocalGit):
231
+ """A real GitHub checkout: local git for the tree, `gh` for the hosted side.
232
+
233
+ Driving `gh` rather than the REST API is a deliberate trade. `gh` is already
234
+ authenticated on a developer's machine, so the system never holds, stores or
235
+ refreshes a token of its own, and it acts as the user with exactly the
236
+ access the user already has — a QA agent should not be able to reach further
237
+ into a repository than the person who pointed it there.
238
+
239
+ Inherits the working-tree half from `LocalGit` because that half genuinely
240
+ is plain git in a clone; only publishing (push, PR) needs GitHub.
241
+
242
+ Three capabilities are missing on purpose and must stay missing: there is no
243
+ merge (§8.4 makes that a human act), no force-push (§8.1), and no way to use
244
+ main/master/trunk/develop/release* as a head branch.
245
+ """
246
+
247
+ # No token here on purpose: `gh auth` owns credentials. These only narrow or
248
+ # override what `gh` would infer from the checkout's remotes.
249
+ OPTIONAL_ENV = ("GITHUB_REPOSITORY", "GITHUB_DEFAULT_BRANCH")
250
+
251
+ DEFAULT_BASE = "main"
252
+
253
+ def __init__(
254
+ self,
255
+ repo: Path | str,
256
+ *,
257
+ repo_slug: str | None = None,
258
+ gh_path: str = "gh",
259
+ timeout_s: int = GH_TIMEOUT_S,
260
+ remote: str = "origin",
261
+ ):
262
+ super().__init__(repo, timeout_s=timeout_s)
263
+ # `owner/name`. Left unset, gh infers it from the checkout's remotes,
264
+ # which is the right answer whenever the system was pointed at a clone.
265
+ self.repo_slug = repo_slug or os.environ.get("GITHUB_REPOSITORY") or None
266
+ self.gh_path = gh_path
267
+ self.remote = remote
268
+ self._gh_ready = False
269
+
270
+ # -- plumbing ---------------------------------------------------------
271
+
272
+ def _gh(self, *args: str, check: bool = True) -> str:
273
+ """Run `gh` in the checkout, argv-only, with `gh`'s own stderr surfaced.
274
+
275
+ The stderr matters more than the exit code: "no commits between main and
276
+ x" or "must be authenticated" is the only text an agent can act on.
277
+ """
278
+ self._require_gh()
279
+ return _run_gh([self.gh_path, *args], self.repo, self.timeout_s, check=check)
280
+
281
+ def _require_gh(self) -> None:
282
+ """Fail with instructions, once, rather than a traceback per call."""
283
+ if self._gh_ready:
284
+ return
285
+ _ensure_gh(self.gh_path, self.timeout_s, cwd=self.repo)
286
+ self._gh_ready = True
287
+
288
+ def _repo_args(self) -> list[str]:
289
+ """`--repo owner/name` when we were told which repo; nothing otherwise."""
290
+ return ["--repo", self.repo_slug] if self.repo_slug else []
291
+
292
+ def _api_repo_path(self) -> str:
293
+ """`gh api` expands `{owner}`/`{repo}` from the checkout when unset."""
294
+ return self.repo_slug or "{owner}/{repo}"
295
+
296
+ def default_base(self) -> str:
297
+ """Base branch for a new PR when the caller names none."""
298
+ return os.environ.get("GITHUB_DEFAULT_BRANCH") or self.DEFAULT_BASE
299
+
300
+ @staticmethod
301
+ def _guard_head(branch: str) -> str:
302
+ name = _reject_flaglike("branch", branch)
303
+ if is_protected_head(name):
304
+ raise VcsError(
305
+ f"refusing to publish '{name}': it is a protected branch (§8.1). "
306
+ "Agent work belongs on its own branch, and merging into a "
307
+ "protected branch is a human decision (§8.4)."
308
+ )
309
+ return name
310
+
311
+ # -- publishing -------------------------------------------------------
312
+
313
+ def push(self, branch: str | None = None) -> str:
314
+ """Publish `branch` (default: the current one) to the remote.
315
+
316
+ Takes no force flag and never will. `--set-upstream` is here so the
317
+ follow-up `gh pr create` can resolve the head without the agent having
318
+ to know about tracking refs.
319
+ """
320
+ name = self._guard_head(branch or self.current_branch())
321
+ self._git("push", "--set-upstream", self.remote, name)
322
+ return name
323
+
324
+ def open_pr(
325
+ self,
326
+ branch: str,
327
+ title: str,
328
+ body: str,
329
+ base: str | None = None,
330
+ draft: bool = True,
331
+ *,
332
+ ticket: str | None = None,
333
+ ) -> dict[str, Any]:
334
+ """Open a pull request from `branch`, as a draft unless told otherwise.
335
+
336
+ Draft is the default because a ready-for-review PR pulls a human into a
337
+ review they did not ask for; a draft waits for one. `ticket` is required
338
+ rather than optional so the body can always say what this PR is for —
339
+ see AUTOMATION_NOTICE.
340
+ """
341
+ head = self._guard_head(branch)
342
+ base_ref = _reject_flaglike("base", base or self.default_base())
343
+ subject = str(title).strip()
344
+ if not subject:
345
+ raise VcsError("a PR needs a title.")
346
+ key = str(ticket or "").strip()
347
+ if not key:
348
+ raise VcsError(
349
+ "open_pr needs the ticket this work came from: every agent PR "
350
+ "must say on its face what finding it answers."
351
+ )
352
+
353
+ args = [
354
+ "pr",
355
+ "create",
356
+ *self._repo_args(),
357
+ "--head",
358
+ head,
359
+ "--base",
360
+ base_ref,
361
+ "--title",
362
+ subject,
363
+ "--body",
364
+ self.pr_body(body, key),
365
+ ]
366
+ if draft:
367
+ args.append("--draft")
368
+
369
+ out = self._gh(*args).strip()
370
+ url = next(
371
+ (line.strip() for line in reversed(out.splitlines()) if line.strip().startswith("http")),
372
+ "",
373
+ )
374
+ match = _PR_NUMBER_IN_URL.search(url)
375
+ return {
376
+ "number": int(match.group(1)) if match else None,
377
+ "url": url or out,
378
+ "branch": head,
379
+ "base": base_ref,
380
+ "draft": draft,
381
+ }
382
+
383
+ @staticmethod
384
+ def pr_body(body: str, ticket: str) -> str:
385
+ """The agent's body with the automated-origin line appended, always."""
386
+ return f"{str(body).rstrip()}\n\n{AUTOMATION_NOTICE.format(ticket=ticket)}\n"
387
+
388
+ # -- reading a PR -----------------------------------------------------
389
+
390
+ def pr_diff(self, number: int | str) -> str:
391
+ """Unified diff of an existing PR, for an agent asked to review one."""
392
+ ref = _reject_flaglike("pr number", str(number))
393
+ return self._gh("pr", "diff", *self._repo_args(), ref)
394
+
395
+ def list_changed_files(self, base: str, head: str) -> list[str]:
396
+ """Paths that differ between two refs, via the compare endpoint.
397
+
398
+ Asking GitHub rather than the local tree means this answers for a PR
399
+ whose head was never fetched into this checkout.
400
+ """
401
+ base_ref = _reject_flaglike("base", base)
402
+ head_ref = _reject_flaglike("head", head)
403
+ out = self._gh(
404
+ "api",
405
+ f"repos/{self._api_repo_path()}/compare/{base_ref}...{head_ref}",
406
+ "--jq",
407
+ ".files[].filename",
408
+ )
409
+ return [line.strip() for line in out.splitlines() if line.strip()]
410
+
411
+ # -- getting a checkout at all ----------------------------------------
412
+
413
+ @classmethod
414
+ def clone(
415
+ cls,
416
+ url: str,
417
+ dest: Path | str,
418
+ *,
419
+ depth: int = 1,
420
+ gh_path: str = "gh",
421
+ timeout_s: int = GH_TIMEOUT_S,
422
+ ) -> "GitHubVcs":
423
+ """Shallow-clone `url` into `dest` and return an adapter rooted there.
424
+
425
+ Shallow because the agents read the current tree, never the history, and
426
+ a full clone of a real repository is minutes of wall clock the run does
427
+ not have. `gh repo clone` rather than `git clone` so the user's existing
428
+ credentials cover private repositories.
429
+ """
430
+ target = Path(dest)
431
+ parent = target.parent if target.parent.exists() else Path.cwd()
432
+ _ensure_gh(gh_path, timeout_s, cwd=parent)
433
+ _run_gh(
434
+ [gh_path, "repo", "clone", _reject_flaglike("url", url), str(target), "--", f"--depth={depth}"],
435
+ parent,
436
+ timeout_s,
437
+ )
438
+ return cls(target, gh_path=gh_path, timeout_s=timeout_s)
439
+
440
+
441
+ def _run_gh(argv: list[str], cwd: Path, timeout_s: int, *, check: bool = True) -> str:
442
+ """One `gh` invocation. Shared by the adapter and by `clone`, which has no
443
+ instance yet."""
444
+ try:
445
+ proc = subprocess.run(
446
+ argv,
447
+ cwd=cwd,
448
+ capture_output=True,
449
+ text=True,
450
+ timeout=timeout_s,
451
+ check=False,
452
+ )
453
+ except subprocess.TimeoutExpired as exc:
454
+ raise VcsError(f"gh {' '.join(argv[1:])} timed out after {timeout_s}s") from exc
455
+ except OSError as exc:
456
+ raise VcsError(_GH_MISSING.format(detail=exc)) from exc
457
+ if check and proc.returncode != 0:
458
+ detail = (proc.stderr or proc.stdout).strip() or f"exit {proc.returncode}"
459
+ raise VcsError(f"gh {' '.join(argv[1:])} failed: {detail}")
460
+ return proc.stdout
461
+
462
+
463
+ def _ensure_gh(gh_path: str, timeout_s: int, cwd: Path) -> None:
464
+ """Check gh is installed and logged in, and say what to do when it is not.
465
+
466
+ Both failures are ordinary setup mistakes rather than bugs, so they must
467
+ reach the user as an instruction ("run `gh auth login`") and never as a
468
+ traceback out of subprocess.
469
+ """
470
+ try:
471
+ proc = subprocess.run(
472
+ [gh_path, "auth", "status"],
473
+ cwd=cwd if cwd.exists() else None,
474
+ capture_output=True,
475
+ text=True,
476
+ timeout=timeout_s,
477
+ check=False,
478
+ )
479
+ except subprocess.TimeoutExpired as exc:
480
+ raise VcsError(f"`gh auth status` timed out after {timeout_s}s") from exc
481
+ except OSError as exc:
482
+ raise VcsError(_GH_MISSING.format(detail=exc)) from exc
483
+ if proc.returncode != 0:
484
+ detail = (proc.stderr or proc.stdout).strip() or f"exit {proc.returncode}"
485
+ raise VcsError(_GH_UNAUTHENTICATED.format(detail=detail))
486
+
487
+
488
+ def build_vcs(backend: str, repo_root: Path | str) -> VcsAdapter:
489
+ """Pick the adapter named by `config.vcs`."""
490
+ if backend == "local":
491
+ return LocalGit(repo_root)
492
+ if backend == "github":
493
+ return GitHubVcs(repo_root)
494
+ raise ValueError(f"unknown vcs backend '{backend}'; expected 'local' or 'github'")