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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- 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'")
|