py-harness-cli 0.3.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.
- finetune/__init__.py +1 -0
- finetune/agent_system.py +41 -0
- finetune/agent_traces.py +157 -0
- finetune/everyday.py +30 -0
- finetune/hf_ollama.py +158 -0
- finetune/huggingface_store.py +144 -0
- finetune/models.py +74 -0
- finetune/paths.py +11 -0
- finetune/python_vibe.py +788 -0
- finetune/splits.py +54 -0
- finetune/systems.py +9 -0
- harness/__init__.py +42 -0
- harness/__main__.py +8 -0
- harness/act/__init__.py +6 -0
- harness/act/autofix/__init__.py +110 -0
- harness/act/autofix/additions.py +217 -0
- harness/act/autofix/conflicts.py +124 -0
- harness/act/autofix/cover.py +419 -0
- harness/act/autofix/mechanical.py +151 -0
- harness/act/autofix/missing_imports.py +50 -0
- harness/act/autofix/moves.py +439 -0
- harness/act/autofix/names.py +339 -0
- harness/act/autofix/scaffold.py +224 -0
- harness/act/code.py +157 -0
- harness/act/gate.py +229 -0
- harness/act/parse.py +247 -0
- harness/act/patch_fix.py +138 -0
- harness/act/tools.py +244 -0
- harness/agent/__init__.py +11 -0
- harness/agent/dispatch.py +235 -0
- harness/agent/loop.py +699 -0
- harness/agent/options.py +144 -0
- harness/agent/policy.py +856 -0
- harness/agent/prompt.py +170 -0
- harness/cli.py +393 -0
- harness/editor_kit.py +265 -0
- harness/guard/__init__.py +6 -0
- harness/guard/fallbacks.py +6 -0
- harness/guard/loop_guard.py +57 -0
- harness/guard/python_vibe.py +68 -0
- harness/guard/run.py +41 -0
- harness/guard/types.py +19 -0
- harness/locate.py +767 -0
- harness/mcp_stdio.py +306 -0
- harness/memory/__init__.py +5 -0
- harness/memory/conversation.py +104 -0
- harness/model/__init__.py +6 -0
- harness/model/chat_backend.py +100 -0
- harness/model/engine.py +165 -0
- harness/model/ollama_generate.py +60 -0
- harness/model/openai_generate.py +156 -0
- harness/model/outbound.py +83 -0
- harness/model/route.py +90 -0
- harness/observe/__init__.py +6 -0
- harness/observe/eval_gate.py +80 -0
- harness/observe/eval_loop.py +185 -0
- harness/observe/eval_tasks.py +399 -0
- harness/observe/report_md.py +102 -0
- harness/observe/trace_record.py +79 -0
- harness/openai_api.py +81 -0
- harness/paths.py +88 -0
- harness/py.typed +0 -0
- harness/scan/__init__.py +6 -0
- harness/scan/app_spec.py +338 -0
- harness/scan/design.py +112 -0
- harness/scan/existing.py +131 -0
- harness/scan/layout.py +254 -0
- harness/scan/names.py +308 -0
- harness/scan/project_brief.py +287 -0
- harness/scan/project_docs.py +42 -0
- harness/scan/project_scan.py +49 -0
- harness/scan/repo_map.py +101 -0
- harness/secrets.py +39 -0
- harness/server.py +199 -0
- harness/ship/__init__.py +1 -0
- harness/ship/bot_pr.py +221 -0
- harness/ship/git_ship.py +262 -0
- harness/ship/identity.py +62 -0
- harness/ship/ticket.py +251 -0
- harness/skillkit/__init__.py +6 -0
- harness/skillkit/catalog.py +241 -0
- harness/skillkit/refuse_change.py +640 -0
- harness/skillkit/refuse_finish.py +295 -0
- harness/skillkit/target.py +238 -0
- harness/task.py +717 -0
- py_harness_cli-0.3.0.dist-info/METADATA +177 -0
- py_harness_cli-0.3.0.dist-info/RECORD +92 -0
- py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
- py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
- py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
- py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
- py_harness_cli-0.3.0.dist-info/top_level.txt +2 -0
harness/server.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Serve the agent over HTTP on the local machine only.
|
|
2
|
+
|
|
3
|
+
An HTTP request that reaches the agent can change files on the machine the
|
|
4
|
+
server runs on. Because of that, file changes are disabled unless the
|
|
5
|
+
person starting the server passes `--allow-writes`. The path restriction,
|
|
6
|
+
the draft guard and the `.bak` backup all still apply; this flag is an
|
|
7
|
+
additional outer control, not a replacement for them.
|
|
8
|
+
|
|
9
|
+
python -m harness serve --project ~/app
|
|
10
|
+
curl -s localhost:8090/health
|
|
11
|
+
curl -s localhost:8090/v1/layout -d '{}'
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from harness.agent import Agent, AgentOptions
|
|
21
|
+
from harness.openai_api import (
|
|
22
|
+
chat_completion_payload,
|
|
23
|
+
last_user_text,
|
|
24
|
+
models_payload,
|
|
25
|
+
parse_chat_body,
|
|
26
|
+
)
|
|
27
|
+
from harness.scan.layout import render_layout
|
|
28
|
+
from harness.scan.project_brief import classify_project, render_brief
|
|
29
|
+
from harness.task import looks_like_add_feature, looks_like_bugfix
|
|
30
|
+
|
|
31
|
+
HOST = "127.0.0.1"
|
|
32
|
+
MAX_BODY = 64 * 1024
|
|
33
|
+
READ_ONLY_ROUTES = ("/v1/brief", "/v1/layout", "/v1/ask")
|
|
34
|
+
WRITE_ROUTES = ("/v1/run",)
|
|
35
|
+
CHAT_ROUTES = ("/v1/chat/completions", "/chat/completions")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_handler(project: Path, *, allow_writes: bool, model: str):
|
|
39
|
+
class Handler(BaseHTTPRequestHandler):
|
|
40
|
+
server_version = "py-harness"
|
|
41
|
+
protocol_version = "HTTP/1.1"
|
|
42
|
+
|
|
43
|
+
def _send(self, code: int, payload: dict) -> None:
|
|
44
|
+
body = json.dumps(payload).encode("utf-8")
|
|
45
|
+
self.send_response(code)
|
|
46
|
+
self.send_header("content-type", "application/json")
|
|
47
|
+
self.send_header("content-length", str(len(body)))
|
|
48
|
+
# Say the connection is finished. A client left waiting for more
|
|
49
|
+
# blocks until its own timeout, which is what happened on Windows.
|
|
50
|
+
self.send_header("connection", "close")
|
|
51
|
+
self.end_headers()
|
|
52
|
+
self.wfile.write(body)
|
|
53
|
+
self.wfile.flush()
|
|
54
|
+
self.close_connection = True
|
|
55
|
+
|
|
56
|
+
def _body(self) -> dict | None:
|
|
57
|
+
length = int(self.headers.get("content-length") or 0)
|
|
58
|
+
if length > MAX_BODY:
|
|
59
|
+
self._send(413, {"error": "body too large"})
|
|
60
|
+
return None
|
|
61
|
+
raw = self.rfile.read(length) if length else b"{}"
|
|
62
|
+
try:
|
|
63
|
+
parsed = json.loads(raw or b"{}")
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
self._send(400, {"error": "invalid json"})
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(parsed, dict):
|
|
68
|
+
self._send(400, {"error": "object required"})
|
|
69
|
+
return None
|
|
70
|
+
return parsed
|
|
71
|
+
|
|
72
|
+
def do_GET(self) -> None: # noqa: N802
|
|
73
|
+
path = self.path.split("?", 1)[0]
|
|
74
|
+
if path in {"/v1/models", "/models"}:
|
|
75
|
+
self._send(200, models_payload(model or "llama3.1:8b"))
|
|
76
|
+
return
|
|
77
|
+
if path != "/health":
|
|
78
|
+
self._send(404, {"error": "no such route"})
|
|
79
|
+
return
|
|
80
|
+
self._send(
|
|
81
|
+
200,
|
|
82
|
+
{
|
|
83
|
+
"ok": True,
|
|
84
|
+
"project": str(project),
|
|
85
|
+
"allow_writes": allow_writes,
|
|
86
|
+
"model": model,
|
|
87
|
+
"routes": list(READ_ONLY_ROUTES)
|
|
88
|
+
+ list(CHAT_ROUTES)
|
|
89
|
+
+ (list(WRITE_ROUTES) if allow_writes else []),
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def do_POST(self) -> None: # noqa: N802
|
|
94
|
+
path = self.path.split("?", 1)[0]
|
|
95
|
+
if path in CHAT_ROUTES:
|
|
96
|
+
self._chat()
|
|
97
|
+
return
|
|
98
|
+
if path not in READ_ONLY_ROUTES + WRITE_ROUTES:
|
|
99
|
+
self._send(404, {"error": "no such route"})
|
|
100
|
+
return
|
|
101
|
+
if path in WRITE_ROUTES and not allow_writes:
|
|
102
|
+
self._send(
|
|
103
|
+
403,
|
|
104
|
+
{
|
|
105
|
+
"error": "this server is read-only",
|
|
106
|
+
"fix": "restart it with --allow-writes",
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
return
|
|
110
|
+
payload = self._body()
|
|
111
|
+
if payload is None:
|
|
112
|
+
return
|
|
113
|
+
scope = str(payload.get("scope") or "")
|
|
114
|
+
if path == "/v1/brief":
|
|
115
|
+
brief = classify_project(project, scope)
|
|
116
|
+
self._send(200, {"brief": render_brief(brief, scope=scope)})
|
|
117
|
+
return
|
|
118
|
+
if path == "/v1/layout":
|
|
119
|
+
self._send(200, {"layout": render_layout(project)})
|
|
120
|
+
return
|
|
121
|
+
task = str(payload.get("task") or "").strip()
|
|
122
|
+
if not task:
|
|
123
|
+
self._send(400, {"error": "task required"})
|
|
124
|
+
return
|
|
125
|
+
options = AgentOptions(
|
|
126
|
+
project=project,
|
|
127
|
+
task=task,
|
|
128
|
+
model=str(payload.get("model") or model),
|
|
129
|
+
scope=scope,
|
|
130
|
+
steps=int(payload.get("steps") or 20),
|
|
131
|
+
allow_writes=allow_writes and path in WRITE_ROUTES,
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
result = Agent(options).run()
|
|
135
|
+
except (ValueError, OSError) as exc:
|
|
136
|
+
self._send(400, {"error": str(exc)})
|
|
137
|
+
return
|
|
138
|
+
self._send(200, result.as_dict())
|
|
139
|
+
|
|
140
|
+
def _chat(self) -> None:
|
|
141
|
+
payload = self._body()
|
|
142
|
+
if payload is None:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
parsed = parse_chat_body(json.dumps(payload).encode("utf-8"))
|
|
146
|
+
except ValueError as exc:
|
|
147
|
+
self._send(400, {"error": str(exc)})
|
|
148
|
+
return
|
|
149
|
+
if parsed["stream"]:
|
|
150
|
+
self._send(400, {"error": "stream is not supported; set stream false"})
|
|
151
|
+
return
|
|
152
|
+
task = last_user_text(parsed["messages"])
|
|
153
|
+
if not task:
|
|
154
|
+
self._send(400, {"error": "messages required"})
|
|
155
|
+
return
|
|
156
|
+
wants_write = looks_like_add_feature(task) or looks_like_bugfix(task)
|
|
157
|
+
if wants_write and not allow_writes:
|
|
158
|
+
self._send(
|
|
159
|
+
403,
|
|
160
|
+
{
|
|
161
|
+
"error": "this server is read-only",
|
|
162
|
+
"fix": "restart it with --allow-writes, or run "
|
|
163
|
+
"py-harness run <project> \"<task>\" in the editor terminal",
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
options = AgentOptions(
|
|
168
|
+
project=project,
|
|
169
|
+
task=task,
|
|
170
|
+
model=str(parsed["model"] or model),
|
|
171
|
+
allow_writes=allow_writes and wants_write,
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
result = Agent(options).run()
|
|
175
|
+
except (ValueError, OSError) as exc:
|
|
176
|
+
self._send(400, {"error": str(exc)})
|
|
177
|
+
return
|
|
178
|
+
self._send(200, chat_completion_payload(result.summary, options.model))
|
|
179
|
+
|
|
180
|
+
def log_message(self, fmt: str, *args) -> None:
|
|
181
|
+
print(f"{self.address_string()} - {fmt % args}")
|
|
182
|
+
|
|
183
|
+
return Handler
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def serve(
|
|
187
|
+
project: Path, *, port: int = 8090, allow_writes: bool = False, model: str = ""
|
|
188
|
+
) -> int:
|
|
189
|
+
handler = make_handler(project, allow_writes=allow_writes, model=model)
|
|
190
|
+
httpd = ThreadingHTTPServer((HOST, port), handler)
|
|
191
|
+
mode = "read-write" if allow_writes else "read-only"
|
|
192
|
+
print(f"py-harness on http://{HOST}:{port} project {project} {mode}")
|
|
193
|
+
try:
|
|
194
|
+
httpd.serve_forever()
|
|
195
|
+
except KeyboardInterrupt:
|
|
196
|
+
pass
|
|
197
|
+
finally:
|
|
198
|
+
httpd.server_close()
|
|
199
|
+
return 0
|
harness/ship/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Layer: limited git / gh. Deterministic. No model."""
|
harness/ship/bot_pr.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Whether a pull request opened by a bot may be merged without a reader.
|
|
2
|
+
|
|
3
|
+
Dependency bots open the same pull request shape every week, and most of
|
|
4
|
+
them are dull enough to merge on sight. The one that is not dull looks
|
|
5
|
+
exactly like the ones that are.
|
|
6
|
+
|
|
7
|
+
`Bump actions/github-script from 7 to 9` reads like the rest. Its
|
|
8
|
+
release notes say `require('@actions/github')` stops working and that
|
|
9
|
+
`getOctokit` becomes an injected parameter, so a workflow that used
|
|
10
|
+
either breaks on merge. The title carries none of that. What it does
|
|
11
|
+
carry is `7` and `9`, and a first number that changed is the whole
|
|
12
|
+
signal: a major bump is where breaking changes are allowed to live.
|
|
13
|
+
|
|
14
|
+
So this refuses on the shape of the version, on a red or unfinished
|
|
15
|
+
check, and on anything GitHub already says is not mergeable. It never
|
|
16
|
+
approves; it only fails to object.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# The bot writes two shapes, and reading only one of them missed a
|
|
25
|
+
# major bump entirely. Actions get "bump x from 7 to 9"; Python
|
|
26
|
+
# requirements get "update x requirement from >=0.26.0 to >=1.29.0".
|
|
27
|
+
_BUMP = re.compile(
|
|
28
|
+
r"\b(?:bump|update)\s+(?P<what>\S+)"
|
|
29
|
+
r"(?:\s+requirement)?"
|
|
30
|
+
r"\s+from\s+(?P<old>[<>=~^!]*\s*v?[\w.\-]+)"
|
|
31
|
+
r"\s+to\s+(?P<new>[<>=~^!]*\s*v?[\w.\-]+)",
|
|
32
|
+
re.I,
|
|
33
|
+
)
|
|
34
|
+
# A requirement carries its comparator: `>=0.26.0` is version 0, not a
|
|
35
|
+
# string starting with a bracket.
|
|
36
|
+
_LEADING_NUMBER = re.compile(r"^[<>=~^!\s]*v?(\d+)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bump_in(title: str) -> tuple[str, str, str] | None:
|
|
40
|
+
"""(what, old version, new version) from a bot's title, or None."""
|
|
41
|
+
found = _BUMP.search(title or "")
|
|
42
|
+
if not found:
|
|
43
|
+
return None
|
|
44
|
+
return found.group("what"), found.group("old"), found.group("new")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_a_major_bump(old: str, new: str) -> bool:
|
|
48
|
+
"""Did the first number change?
|
|
49
|
+
|
|
50
|
+
Unparseable versions are not called major. Refusing everything this
|
|
51
|
+
cannot read would make the check about the parser rather than about
|
|
52
|
+
the risk, and the other reasons still apply.
|
|
53
|
+
"""
|
|
54
|
+
before, after = _LEADING_NUMBER.match(old), _LEADING_NUMBER.match(new)
|
|
55
|
+
if not before or not after:
|
|
56
|
+
return False
|
|
57
|
+
return before.group(1) != after.group(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# CANCELLED, SKIPPED and STALE are deliberately absent from both sets
|
|
61
|
+
# below. A cancelled run did not fail; it gave no answer, usually
|
|
62
|
+
# because a newer push superseded it. Naming it as a failure refused two
|
|
63
|
+
# pull requests whose checks had passed.
|
|
64
|
+
_FAILED = {"FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"}
|
|
65
|
+
_RUNNING = {"PENDING", "IN_PROGRESS", "QUEUED", "EXPECTED", "WAITING", ""}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def latest_of_each(pull: dict) -> list[dict]:
|
|
69
|
+
"""One entry per check name: the most recent run of it.
|
|
70
|
+
|
|
71
|
+
A pull request keeps every run of a check, not just the current one.
|
|
72
|
+
Pushing again cancels the run in flight and starts another, so the
|
|
73
|
+
rollup holds a cancelled entry and a successful entry under the same
|
|
74
|
+
name. Reading them all as equal reported "checks are failing: readme"
|
|
75
|
+
on two pull requests whose readme check had passed forty seconds
|
|
76
|
+
after being superseded.
|
|
77
|
+
"""
|
|
78
|
+
newest: dict[str, dict] = {}
|
|
79
|
+
for check in pull.get("statusCheckRollup") or []:
|
|
80
|
+
name = str(check.get("name") or check.get("context") or "a check")
|
|
81
|
+
when = str(check.get("completedAt") or check.get("startedAt") or "")
|
|
82
|
+
seen = newest.get(name)
|
|
83
|
+
if seen is None or when >= str(
|
|
84
|
+
seen.get("completedAt") or seen.get("startedAt") or ""
|
|
85
|
+
):
|
|
86
|
+
newest[name] = check
|
|
87
|
+
return [newest[name] for name in sorted(newest)]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _check_state(pull: dict) -> tuple[list[str], list[str]]:
|
|
91
|
+
"""(failing, unfinished) check names, counting each check once."""
|
|
92
|
+
failing, unfinished = [], []
|
|
93
|
+
for check in latest_of_each(pull):
|
|
94
|
+
name = str(check.get("name") or check.get("context") or "a check")
|
|
95
|
+
conclusion = (check.get("conclusion") or "").upper()
|
|
96
|
+
state = (check.get("state") or check.get("status") or "").upper()
|
|
97
|
+
if conclusion in _FAILED:
|
|
98
|
+
failing.append(name)
|
|
99
|
+
elif conclusion in {"", "NEUTRAL"} and state in _RUNNING:
|
|
100
|
+
unfinished.append(name)
|
|
101
|
+
return sorted(set(failing)), sorted(set(unfinished))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def refuse_bot_merge(pull: dict, project: Path | None = None) -> str:
|
|
105
|
+
"""Why this pull request needs a person. "" when nothing objects.
|
|
106
|
+
|
|
107
|
+
`project` lets a major bump be answered rather than only refused.
|
|
108
|
+
Refusing every one of them scored nought for five here: five action
|
|
109
|
+
bumps were merged, every workflow stayed green, and the rule caught
|
|
110
|
+
nothing. A refusal nobody needs is a refusal that gets switched off.
|
|
111
|
+
|
|
112
|
+
What the pull request's own checks prove is that the workflows they
|
|
113
|
+
ran still work. If every workflow using the bumped action ran green
|
|
114
|
+
here, the thing that would break has already been exercised. If some
|
|
115
|
+
did not — `Pages` is skipped on a pull request, `Celebrate merge`
|
|
116
|
+
only runs on merge — then nothing was proved and a person reads it.
|
|
117
|
+
"""
|
|
118
|
+
failing, unfinished = _check_state(pull)
|
|
119
|
+
if failing:
|
|
120
|
+
return f"checks are failing: {', '.join(failing)}"
|
|
121
|
+
if unfinished:
|
|
122
|
+
return f"checks have not finished: {', '.join(unfinished)}"
|
|
123
|
+
mergeable = (pull.get("mergeable") or "").upper()
|
|
124
|
+
if mergeable == "CONFLICTING":
|
|
125
|
+
return "this branch conflicts with the base branch"
|
|
126
|
+
# The durable reason comes before the transient one. A major bump is
|
|
127
|
+
# true whatever GitHub is still working out, and it is the answer the
|
|
128
|
+
# person needs; being told to ask again would waste the trip.
|
|
129
|
+
bump = bump_in(str(pull.get("title") or ""))
|
|
130
|
+
if bump and is_a_major_bump(bump[1], bump[2]):
|
|
131
|
+
what, was, now = bump
|
|
132
|
+
unproven = (
|
|
133
|
+
unproven_workflows(project, pull, what) if project is not None else None
|
|
134
|
+
)
|
|
135
|
+
if unproven == []:
|
|
136
|
+
# Every workflow that uses it ran green on this very pull
|
|
137
|
+
# request. There is nothing left for a person to check.
|
|
138
|
+
return ""
|
|
139
|
+
where = f" It is used by {', '.join(unproven)}, which this pull " \
|
|
140
|
+
"request did not run." if unproven else ""
|
|
141
|
+
return (
|
|
142
|
+
f"{what} {was} to {now} is a major version bump. That is where "
|
|
143
|
+
"breaking changes are allowed to live, and the title never "
|
|
144
|
+
f"says so.{where} Read the release notes and merge it by hand."
|
|
145
|
+
)
|
|
146
|
+
state = (pull.get("mergeStateStatus") or "").upper()
|
|
147
|
+
if state in {"BLOCKED", "DIRTY", "BEHIND", "DRAFT"}:
|
|
148
|
+
return f"GitHub will not merge it yet: {state.lower()}"
|
|
149
|
+
# GitHub works mergeability out when asked, not in advance, so the
|
|
150
|
+
# first read of a fresh pull request often says nothing. Nothing is
|
|
151
|
+
# not the same as yes.
|
|
152
|
+
if mergeable in {"", "UNKNOWN"} or state in {"", "UNKNOWN"}:
|
|
153
|
+
return "GitHub has not worked out yet whether this merges; ask again"
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Where a repository keeps the workflows a bumped action might appear in.
|
|
158
|
+
WORKFLOW_DIR = Path(".github/workflows")
|
|
159
|
+
_WORKFLOW_NAME = re.compile(r"^name:\s*(.+?)\s*$", re.M)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def workflows_using(project: Path, action: str) -> dict[str, str]:
|
|
163
|
+
"""{workflow name: file name} for every workflow naming this action.
|
|
164
|
+
|
|
165
|
+
An action is bumped by its repository path, `actions/checkout`, and
|
|
166
|
+
that is exactly the string a workflow writes in its `uses:` line.
|
|
167
|
+
"""
|
|
168
|
+
found: dict[str, str] = {}
|
|
169
|
+
root = Path(project) / WORKFLOW_DIR
|
|
170
|
+
if not root.is_dir():
|
|
171
|
+
return found
|
|
172
|
+
for path in sorted(root.glob("*.y*ml")):
|
|
173
|
+
try:
|
|
174
|
+
text = path.read_text(encoding="utf-8")
|
|
175
|
+
except OSError:
|
|
176
|
+
continue
|
|
177
|
+
if action not in text:
|
|
178
|
+
continue
|
|
179
|
+
named = _WORKFLOW_NAME.search(text)
|
|
180
|
+
found[named.group(1) if named else path.stem] = path.name
|
|
181
|
+
return found
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def workflows_that_passed(pull: dict) -> set[str]:
|
|
185
|
+
"""Workflows this pull request actually ran green.
|
|
186
|
+
|
|
187
|
+
A check that was skipped or cancelled proves nothing about the
|
|
188
|
+
workflow it belongs to, which is the whole point: `Pages / build`
|
|
189
|
+
comes back SKIPPED on a pull request, so a green run says nothing
|
|
190
|
+
about whether the page still deploys.
|
|
191
|
+
"""
|
|
192
|
+
passed: set[str] = set()
|
|
193
|
+
failed: set[str] = set()
|
|
194
|
+
for check in latest_of_each(pull):
|
|
195
|
+
name = str(check.get("workflowName") or "")
|
|
196
|
+
if not name:
|
|
197
|
+
continue
|
|
198
|
+
if (check.get("conclusion") or "").upper() == "SUCCESS":
|
|
199
|
+
passed.add(name)
|
|
200
|
+
else:
|
|
201
|
+
failed.add(name)
|
|
202
|
+
return passed - failed
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def unproven_workflows(project: Path, pull: dict, action: str) -> list[str] | None:
|
|
206
|
+
"""Workflows using this action that the pull request did not exercise.
|
|
207
|
+
|
|
208
|
+
Empty means every workflow touching the action ran green here, so
|
|
209
|
+
the bump is already tested by the thing that would break.
|
|
210
|
+
|
|
211
|
+
**None means no workflow uses it at all**, which is not the same
|
|
212
|
+
thing and must not be read as one. A Python dependency appears in no
|
|
213
|
+
workflow, so an empty list would have waved through every
|
|
214
|
+
`huggingface-hub 0 to 1` on the grounds that nothing it touches
|
|
215
|
+
failed — when nothing it touches was looked at.
|
|
216
|
+
"""
|
|
217
|
+
using = workflows_using(project, action)
|
|
218
|
+
if not using:
|
|
219
|
+
return None
|
|
220
|
+
green = workflows_that_passed(pull)
|
|
221
|
+
return sorted(name for name in using if name not in green)
|
harness/ship/git_ship.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Limited git and gh. Deterministic. No model. No force. No main."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from harness.paths import SECRET_NAMES
|
|
12
|
+
from harness.ship.bot_pr import refuse_bot_merge
|
|
13
|
+
from harness.ship.identity import co_author_line, with_co_author
|
|
14
|
+
from harness.ship.ticket import identity_from_user_json, parse_ticket, render_ticket
|
|
15
|
+
|
|
16
|
+
PROTECTED = frozenset({"main", "master"})
|
|
17
|
+
_BRANCH = re.compile(r"^[A-Za-z0-9._][A-Za-z0-9._/-]{0,79}$")
|
|
18
|
+
CO_AUTHOR = co_author_line()
|
|
19
|
+
# Says on the pull request itself which tool did the work, the way a
|
|
20
|
+
# commit trailer does for a commit.
|
|
21
|
+
PR_FOOTER = (
|
|
22
|
+
"\n\n---\nOpened with [py-harness](https://github.com/YauhenBichel/py-harness).\n"
|
|
23
|
+
f"{CO_AUTHOR}\n"
|
|
24
|
+
)
|
|
25
|
+
_TIMEOUT = 45
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run(
|
|
29
|
+
project: Path,
|
|
30
|
+
argv: list[str],
|
|
31
|
+
*,
|
|
32
|
+
env: dict[str, str] | None = None,
|
|
33
|
+
keep_all: bool = False,
|
|
34
|
+
) -> tuple[int, str]:
|
|
35
|
+
merged = os.environ.copy()
|
|
36
|
+
if env:
|
|
37
|
+
merged.update(env)
|
|
38
|
+
# The person stays the author, so the commit is theirs and appears in
|
|
39
|
+
# their history. python-vibe is recorded as a co-author instead, which
|
|
40
|
+
# GitHub renders on the commit, so it is visible where it was used.
|
|
41
|
+
try:
|
|
42
|
+
proc = subprocess.run(
|
|
43
|
+
argv,
|
|
44
|
+
cwd=project,
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
timeout=_TIMEOUT,
|
|
48
|
+
check=False,
|
|
49
|
+
env=merged,
|
|
50
|
+
)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
return 127, f"{argv[0]} is not on PATH"
|
|
53
|
+
except subprocess.TimeoutExpired:
|
|
54
|
+
return 124, "timed out"
|
|
55
|
+
out = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
|
56
|
+
if keep_all:
|
|
57
|
+
return proc.returncode, out
|
|
58
|
+
# Command output is cut from the front, because the end of a git or gh
|
|
59
|
+
# message is the part that says what happened. JSON has to be kept
|
|
60
|
+
# whole or it will not parse, which is what `keep_all` is for.
|
|
61
|
+
return proc.returncode, out[-4000:]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def git_root(project: Path) -> Path | None:
|
|
65
|
+
code, out = _run(project, ["git", "rev-parse", "--show-toplevel"])
|
|
66
|
+
if code != 0:
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
return Path(out.splitlines()[0]).resolve()
|
|
70
|
+
except (IndexError, OSError):
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _in_project(project: Path) -> str:
|
|
75
|
+
root = git_root(project)
|
|
76
|
+
if root is None:
|
|
77
|
+
return "not a git repository"
|
|
78
|
+
if root != project.resolve():
|
|
79
|
+
return f"git root is {root}, not {project} — refuse"
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def current_branch(project: Path) -> str:
|
|
84
|
+
code, out = _run(project, ["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
85
|
+
return out.splitlines()[0] if code == 0 and out else ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def github_viewer(project: Path) -> str:
|
|
89
|
+
"""The signed-in `gh` login, or empty when gh is missing or logged out."""
|
|
90
|
+
code, out = _run(project, ["gh", "api", "user"], keep_all=True)
|
|
91
|
+
if code != 0:
|
|
92
|
+
return ""
|
|
93
|
+
login, _name, _email = identity_from_user_json(out)
|
|
94
|
+
return login
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _view(project: Path, kind: str, number: str) -> str:
|
|
98
|
+
"""Read one issue or pull request, and say where it points in this project.
|
|
99
|
+
|
|
100
|
+
Uses the signed-in gh user so comments from other users on the same
|
|
101
|
+
ticket are visible — the same account that can see them in the browser.
|
|
102
|
+
"""
|
|
103
|
+
if not number.isdigit():
|
|
104
|
+
return f"{kind} needs Number: (digits)"
|
|
105
|
+
fields = "number,title,body,state,comments"
|
|
106
|
+
if kind == "pr":
|
|
107
|
+
fields += ",files,reviews"
|
|
108
|
+
code, out = _run(
|
|
109
|
+
project, ["gh", kind, "view", number, "--json", fields], keep_all=True
|
|
110
|
+
)
|
|
111
|
+
if code != 0:
|
|
112
|
+
return out or f"gh {kind} view {number} failed"
|
|
113
|
+
ticket = parse_ticket(
|
|
114
|
+
out,
|
|
115
|
+
project,
|
|
116
|
+
kind="pull request" if kind == "pr" else "issue",
|
|
117
|
+
viewer=github_viewer(project),
|
|
118
|
+
)
|
|
119
|
+
if ticket is None:
|
|
120
|
+
return out[:3500]
|
|
121
|
+
return render_ticket(ticket)[:3500]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read_issue(project: Path, number: str) -> str:
|
|
125
|
+
return _view(project, "issue", number)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_pr(project: Path, number: str) -> str:
|
|
129
|
+
return _view(project, "pr", number)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_ticket(project: Path, number: str, *, prefer: str = "issue") -> str:
|
|
133
|
+
"""Read an issue, or a pull request when the task named a PR."""
|
|
134
|
+
first = read_pr if prefer == "pr" else read_issue
|
|
135
|
+
second = read_issue if prefer == "pr" else read_pr
|
|
136
|
+
out = first(project, number)
|
|
137
|
+
failed = "failed" in out.lower() or "could not" in out.lower() or "needs Number" in out
|
|
138
|
+
if failed:
|
|
139
|
+
other = second(project, number)
|
|
140
|
+
if "failed" not in other.lower() and "could not" not in other.lower():
|
|
141
|
+
return other
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def make_branch(project: Path, name: str) -> str:
|
|
146
|
+
blocked = _in_project(project)
|
|
147
|
+
if blocked:
|
|
148
|
+
return blocked
|
|
149
|
+
name = name.strip().lstrip("/")
|
|
150
|
+
if not _BRANCH.match(name) or name in PROTECTED or name.startswith("-"):
|
|
151
|
+
return (
|
|
152
|
+
"bad branch name. Use proceed/short-slug "
|
|
153
|
+
"(letters, digits, . _ / -). Not main or master."
|
|
154
|
+
)
|
|
155
|
+
code, out = _run(project, ["git", "checkout", "-B", name])
|
|
156
|
+
return out or f"now on {name}" if code == 0 else out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def commit_changes(project: Path, summary: str) -> str:
|
|
160
|
+
blocked = _in_project(project)
|
|
161
|
+
if blocked:
|
|
162
|
+
return blocked
|
|
163
|
+
message = " ".join(summary.strip().split())
|
|
164
|
+
if len(message) < 8:
|
|
165
|
+
return "commit needs Summary: of at least 8 characters (why, not what)"
|
|
166
|
+
_run(project, ["git", "add", "-A"])
|
|
167
|
+
for rel in SECRET_NAMES:
|
|
168
|
+
path = project / rel
|
|
169
|
+
if path.exists() or path.is_symlink():
|
|
170
|
+
_run(project, ["git", "reset", "-q", "--", rel])
|
|
171
|
+
code, staged = _run(project, ["git", "diff", "--cached", "--name-only"])
|
|
172
|
+
names = [line for line in staged.splitlines() if line.strip()] if code == 0 else []
|
|
173
|
+
if any(Path(name).name in SECRET_NAMES for name in names):
|
|
174
|
+
return "refusing to commit secret filenames"
|
|
175
|
+
if not names:
|
|
176
|
+
return "nothing to commit"
|
|
177
|
+
code, out = _run(project, ["git", "commit", "-m", with_co_author(message)])
|
|
178
|
+
return out or "committed" if code == 0 else out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def push_branch(project: Path) -> str:
|
|
182
|
+
blocked = _in_project(project)
|
|
183
|
+
if blocked:
|
|
184
|
+
return blocked
|
|
185
|
+
branch = current_branch(project)
|
|
186
|
+
if not branch or branch in PROTECTED:
|
|
187
|
+
return f"refusing to push {branch or 'detached'} (not main/master)"
|
|
188
|
+
code, remotes = _run(project, ["git", "remote"])
|
|
189
|
+
if code != 0 or "origin" not in remotes.split():
|
|
190
|
+
return "no origin remote. Add origin or push yourself."
|
|
191
|
+
code, out = _run(project, ["git", "push", "-u", "origin", "HEAD"])
|
|
192
|
+
return out or "pushed" if code == 0 else out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def create_pr(project: Path, title: str, body: str) -> str:
|
|
196
|
+
blocked = _in_project(project)
|
|
197
|
+
if blocked:
|
|
198
|
+
return blocked
|
|
199
|
+
branch = current_branch(project)
|
|
200
|
+
if branch in PROTECTED:
|
|
201
|
+
return "refusing to open a PR from main/master. Action: branch first."
|
|
202
|
+
title = " ".join(title.strip().split())
|
|
203
|
+
if len(title) < 8:
|
|
204
|
+
return "pr needs Title: of at least 8 characters"
|
|
205
|
+
text = (body.strip() or title) + PR_FOOTER
|
|
206
|
+
code, out = _run(
|
|
207
|
+
project,
|
|
208
|
+
["gh", "pr", "create", "--title", title, "--body", text],
|
|
209
|
+
)
|
|
210
|
+
return out or "opened pull request" if code == 0 else out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
PR_FIELDS = "title,mergeable,mergeStateStatus,statusCheckRollup,author"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_pr_state(project: Path, number: str) -> dict:
|
|
217
|
+
"""What GitHub currently says about this pull request. {} when unknown."""
|
|
218
|
+
code, out = _run(
|
|
219
|
+
project,
|
|
220
|
+
["gh", "pr", "view", number, "--json", PR_FIELDS],
|
|
221
|
+
keep_all=True,
|
|
222
|
+
)
|
|
223
|
+
if code != 0:
|
|
224
|
+
return {}
|
|
225
|
+
try:
|
|
226
|
+
loaded = json.loads(out)
|
|
227
|
+
except json.JSONDecodeError:
|
|
228
|
+
return {}
|
|
229
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def merge_pr(project: Path, number: str, *, allowed: bool) -> str:
|
|
233
|
+
if not allowed:
|
|
234
|
+
return "merge only when the task says merge"
|
|
235
|
+
if not number.isdigit():
|
|
236
|
+
return "merge needs Number: (PR digits)"
|
|
237
|
+
blocked = _in_project(project)
|
|
238
|
+
if blocked:
|
|
239
|
+
return blocked
|
|
240
|
+
state = read_pr_state(project, number)
|
|
241
|
+
# An empty read means gh could not answer. Merging anyway would make
|
|
242
|
+
# every check here optional the moment the network hiccups.
|
|
243
|
+
if not state:
|
|
244
|
+
return f"cannot read #{number} from GitHub, so not merging it"
|
|
245
|
+
refused = refuse_bot_merge(state, project)
|
|
246
|
+
if refused:
|
|
247
|
+
return f"not merging #{number}: {refused}"
|
|
248
|
+
code, out = _run(
|
|
249
|
+
project,
|
|
250
|
+
[
|
|
251
|
+
"gh",
|
|
252
|
+
"pr",
|
|
253
|
+
"merge",
|
|
254
|
+
number,
|
|
255
|
+
"--merge",
|
|
256
|
+
"--subject",
|
|
257
|
+
f"Merge pull request #{number}",
|
|
258
|
+
"--body",
|
|
259
|
+
with_co_author(f"Merged #{number}."),
|
|
260
|
+
],
|
|
261
|
+
)
|
|
262
|
+
return out or f"merged #{number}" if code == 0 else out
|