@openhands/extensions 0.19.0 → 0.21.0
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.
- package/.github/workflows/check-extensions.yml +4 -0
- package/.github/workflows/pr-artifacts.yml +294 -0
- package/.github/workflows/pr-description-check.yml +4 -2
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +28 -4
- package/README.md +5 -2
- package/automations/README.md +52 -36
- package/automations/bundle-index.js +13 -3
- package/automations/catalog/custom-automation/manifest.json +229 -0
- package/automations/catalog/github-agents-md-maintainer/manifest.json +2 -1
- package/automations/catalog/github-issue-to-pr/manifest.json +3 -2
- package/automations/catalog/github-issue-triage/manifest.json +72 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +3 -2
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +124 -0
- package/automations/catalog/qa-changes/manifest.json +1 -1
- package/automations/catalog-index.js +23 -17
- package/automations/catalog.schema.json +81 -7
- package/automations/index.d.ts +52 -2
- package/automations/interface.json +2 -1
- package/integrations/catalog/gitlab.json +27 -5
- package/integrations/catalog/reportportal.json +76 -0
- package/integrations/catalog-index.js +6 -4
- package/marketplaces/openhands-extensions.json +36 -0
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/github/scripts/github_client.py +204 -0
- package/skills/github-agents-md-maintainer/SKILL.md +7 -1
- package/skills/github-agents-md-maintainer/scripts/main.py +4 -40
- package/skills/github-issue-to-pr/SKILL.md +8 -1
- package/skills/github-issue-to-pr/scripts/main.py +3 -40
- package/skills/github-issue-triage/.plugin/plugin.json +9 -0
- package/skills/github-issue-triage/README.md +9 -0
- package/skills/github-issue-triage/SKILL.md +37 -0
- package/skills/github-issue-triage/commands/github-issue-triage.md +8 -0
- package/skills/github-issue-triage/scripts/worker.py +123 -0
- package/skills/github-pr-reviewer/SKILL.md +8 -1
- package/skills/github-pr-reviewer/scripts/main.py +3 -41
- package/skills/github-repo-monitor/SKILL.md +7 -1
- package/skills/github-repo-monitor/scripts/main.py +4 -46
- package/skills/gitlab-issue-to-mr/.plugin/plugin.json +19 -0
- package/skills/gitlab-issue-to-mr/README.md +92 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +408 -0
- package/skills/gitlab-issue-to-mr/commands/issue-to-mr-setup.md +8 -0
- package/skills/gitlab-issue-to-mr/references/state-schema.md +140 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +1448 -0
- package/skills/index.js +43 -6
- package/skills/openhands-automation/README.md +1 -1
- package/skills/openhands-automation/SKILL.md +18 -8
- package/skills/openhands-enterprise-troubleshooting/.plugin/plugin.json +20 -0
- package/skills/openhands-enterprise-troubleshooting/README.md +79 -0
- package/skills/openhands-enterprise-troubleshooting/SKILL.md +339 -0
- package/skills/openhands-enterprise-troubleshooting/references/diagnostics.md +698 -0
- package/skills/openhands-enterprise-troubleshooting/references/support-bundle-analysis.md +570 -0
- package/skills/openhands-enterprise-troubleshooting/scripts/bundle_triage.py +532 -0
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/tests/fixtures/automations/custom-automation.json +398 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/github_automation_helpers.py +25 -0
- package/tests/test_automation_setup.py +340 -92
- package/tests/test_github_automation_foundation.py +73 -0
- package/tests/test_github_skill_installation.py +63 -0
- package/tests/test_github_triage_delivery.py +53 -0
- package/tests/test_gitlab_issue_to_mr.py +823 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
"""Unit tests for the gitlab-issue-to-mr automation script.
|
|
2
|
+
|
|
3
|
+
The focus is what the script owns rather than the agent: the credential never
|
|
4
|
+
reaching the workspace, the secrets the conversation is handed, the project path
|
|
5
|
+
it builds every API call from, the branch it picks, the commits it counts, and
|
|
6
|
+
the clone it removes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import urllib.error
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
SCRIPT_PATH = (
|
|
18
|
+
Path(__file__).parent.parent / "skills" / "gitlab-issue-to-mr" / "scripts" / "main.py"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_module(monkeypatch, workspace_base: Path):
|
|
23
|
+
"""Import main.py under its own module name, with a scratch workspace."""
|
|
24
|
+
monkeypatch.setenv("WORKSPACE_BASE", str(workspace_base))
|
|
25
|
+
monkeypatch.delenv("AUTOMATION_KV_TOKEN", raising=False)
|
|
26
|
+
monkeypatch.delenv("AUTOMATION_API_URL", raising=False)
|
|
27
|
+
spec = importlib.util.spec_from_file_location("gitlab_issue_to_mr_main", SCRIPT_PATH)
|
|
28
|
+
module = importlib.util.module_from_spec(spec)
|
|
29
|
+
spec.loader.exec_module(module)
|
|
30
|
+
return module
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def main(monkeypatch, tmp_path):
|
|
35
|
+
return _load_module(monkeypatch, tmp_path / "workspace")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _http_error(code: int, body: bytes = b"{}") -> urllib.error.HTTPError:
|
|
39
|
+
return urllib.error.HTTPError("https://gitlab.com/api/v4", code, "err", {}, None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Configuration ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_config_json_overrides_the_constants(main, tmp_path):
|
|
46
|
+
"""The catalog path ships an unmodified script plus a rendered config.json,
|
|
47
|
+
because a declarative host cannot rewrite Python."""
|
|
48
|
+
(tmp_path / "config.json").write_text(
|
|
49
|
+
json.dumps(
|
|
50
|
+
{
|
|
51
|
+
"projects": ["acme/one", "acme/two"],
|
|
52
|
+
"trigger_label": "ship-it",
|
|
53
|
+
"branch_prefix": "bot/issue",
|
|
54
|
+
"merge_request_mode": "ready",
|
|
55
|
+
"max_new_per_run": 5,
|
|
56
|
+
"gitlab_api_url": "https://gitlab.example.com/api/v4",
|
|
57
|
+
"agent_secret_names": ["NPM_TOKEN"],
|
|
58
|
+
"openhands_url": "https://app.example.com",
|
|
59
|
+
"unknown_key": "ignored",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
config = main.load_config(tmp_path)
|
|
65
|
+
|
|
66
|
+
assert config == {
|
|
67
|
+
"projects": ["acme/one", "acme/two"],
|
|
68
|
+
"trigger_label": "ship-it",
|
|
69
|
+
"branch_prefix": "bot/issue",
|
|
70
|
+
"merge_request_mode": "ready",
|
|
71
|
+
"max_new_per_run": 5,
|
|
72
|
+
"gitlab_api_url": "https://gitlab.example.com/api/v4",
|
|
73
|
+
"agent_secret_names": ["NPM_TOKEN"],
|
|
74
|
+
"openhands_url": "https://app.example.com",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_a_missing_config_leaves_the_constants_alone(main, tmp_path):
|
|
79
|
+
assert main.load_config(tmp_path) == {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.parametrize(
|
|
83
|
+
"config",
|
|
84
|
+
[
|
|
85
|
+
{"projects": "acme/one"},
|
|
86
|
+
{"projects": []},
|
|
87
|
+
{"projects": ["acme/one", ""]},
|
|
88
|
+
{"trigger_label": ["openhands"]},
|
|
89
|
+
{"merge_request_mode": "maybe"},
|
|
90
|
+
{"max_new_per_run": 0},
|
|
91
|
+
{"max_new_per_run": True},
|
|
92
|
+
{"gitlab_api_url": "gitlab.example.com"},
|
|
93
|
+
{"agent_secret_names": [1]},
|
|
94
|
+
],
|
|
95
|
+
)
|
|
96
|
+
def test_a_config_that_would_misbehave_fails_the_run(main, tmp_path, config):
|
|
97
|
+
"""Polling the string "group/project" one character at a time is worse than
|
|
98
|
+
stopping, so a wrong type is a hard error rather than a coercion."""
|
|
99
|
+
(tmp_path / "config.json").write_text(json.dumps(config))
|
|
100
|
+
|
|
101
|
+
with pytest.raises(SystemExit):
|
|
102
|
+
main.load_config(tmp_path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_a_config_that_is_not_json_fails_the_run(main, tmp_path):
|
|
106
|
+
(tmp_path / "config.json").write_text("{not json")
|
|
107
|
+
|
|
108
|
+
with pytest.raises(SystemExit):
|
|
109
|
+
main.load_config(tmp_path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize(
|
|
113
|
+
("written", "expected"),
|
|
114
|
+
[
|
|
115
|
+
("group/project", "group/project"),
|
|
116
|
+
(" group/project ", "group/project"),
|
|
117
|
+
("https://gitlab.com/group/project", "group/project"),
|
|
118
|
+
("https://gitlab.com/group/project.git", "group/project"),
|
|
119
|
+
("git@gitlab.com:group/project.git", "group/project"),
|
|
120
|
+
("https://gitlab.example.com/group/project", "group/project"),
|
|
121
|
+
("https://gitlab.com/group/team/service", "group/team/service"),
|
|
122
|
+
("https://gitlab.com/group/project/-/issues/42", "group/project"),
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
def test_projects_are_normalized_to_a_project_path(main, written, expected):
|
|
126
|
+
"""A clone URL is what a project page offers to copy, so it is what ends up
|
|
127
|
+
pasted into the form. Left alone it becomes a 404 blamed on the token."""
|
|
128
|
+
assert main.normalize_project(written) == expected
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.parametrize("written", ["", "project", "https://gitlab.com/group"])
|
|
132
|
+
def test_a_value_that_is_not_a_project_is_named(main, written):
|
|
133
|
+
with pytest.raises(ValueError):
|
|
134
|
+
main.normalize_project(written)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_a_subgroup_path_survives_normalization(main):
|
|
138
|
+
"""Truncating group/team/service to the last two segments would point at a
|
|
139
|
+
project that does not exist."""
|
|
140
|
+
assert main.normalize_project("group/team/service") == "group/team/service"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── Project paths in API calls ────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_the_project_path_is_url_encoded_for_every_call(main):
|
|
147
|
+
"""GitLab takes the path where an ID is expected, so every separator has to
|
|
148
|
+
be encoded or the segments become routes of their own."""
|
|
149
|
+
assert main._project_id("group/project") == "group%2Fproject"
|
|
150
|
+
assert main._project_id("group/team/service") == "group%2Fteam%2Fservice"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_the_instance_url_is_the_api_root_without_its_suffix(main, monkeypatch):
|
|
154
|
+
assert main._instance_url() == "https://gitlab.com"
|
|
155
|
+
|
|
156
|
+
monkeypatch.setattr(main, "GITLAB_API_URL", "https://gitlab.example.com/gl/api/v4")
|
|
157
|
+
assert main._instance_url() == "https://gitlab.example.com/gl"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_the_clone_url_comes_from_gitlab_when_it_states_one(main):
|
|
161
|
+
"""A self-managed instance may serve git over a host that is not the API
|
|
162
|
+
host, and it is the only party that knows."""
|
|
163
|
+
stated = main._clone_url(
|
|
164
|
+
"group/project", {"http_url_to_repo": "https://git.example.com/group/project.git"}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert stated == "https://git.example.com/group/project.git"
|
|
168
|
+
assert main._clone_url("group/project", {}) == "https://gitlab.com/group/project.git"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── Access level ──────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_a_role_below_developer_is_refused(main, monkeypatch):
|
|
175
|
+
monkeypatch.setattr(
|
|
176
|
+
main,
|
|
177
|
+
"_gitlab_request",
|
|
178
|
+
lambda *a, **k: ({"permissions": {"project_access": {"access_level": 20}}}, {}),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
with pytest.raises(RuntimeError, match="below Developer"):
|
|
182
|
+
main._get_project("token", "group/project")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_a_group_role_counts_when_the_project_states_none(main, monkeypatch):
|
|
186
|
+
"""A token acting through a group reports only the group role; reading just
|
|
187
|
+
project_access would refuse a project it can push to."""
|
|
188
|
+
monkeypatch.setattr(
|
|
189
|
+
main,
|
|
190
|
+
"_gitlab_request",
|
|
191
|
+
lambda *a, **k: (
|
|
192
|
+
{
|
|
193
|
+
"default_branch": "main",
|
|
194
|
+
"permissions": {"project_access": None, "group_access": {"access_level": 40}},
|
|
195
|
+
},
|
|
196
|
+
{},
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
assert main._get_project("token", "group/project")["default_branch"] == "main"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_an_unstated_role_is_not_treated_as_no_role(main, monkeypatch):
|
|
204
|
+
"""A project access token reports no role at all, and still pushes."""
|
|
205
|
+
monkeypatch.setattr(
|
|
206
|
+
main, "_gitlab_request", lambda *a, **k: ({"default_branch": "trunk", "permissions": {}}, {})
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
assert main._get_project("token", "group/project")["default_branch"] == "trunk"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_an_inaccessible_project_names_itself(main, monkeypatch):
|
|
213
|
+
def fake_request(*args, **kwargs):
|
|
214
|
+
raise _http_error(404)
|
|
215
|
+
|
|
216
|
+
monkeypatch.setattr(main, "_gitlab_request", fake_request)
|
|
217
|
+
|
|
218
|
+
with pytest.raises(RuntimeError, match="group/project"):
|
|
219
|
+
main._get_project("token", "group/project")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ── The credential never reaches the workspace ────────────────────────────────
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_git_passes_the_token_as_a_header_in_the_environment(main, monkeypatch):
|
|
226
|
+
"""A token on the command line shows up in `ps`; one in the clone's config
|
|
227
|
+
is readable by the agent. It travels in the environment instead."""
|
|
228
|
+
captured = {}
|
|
229
|
+
|
|
230
|
+
def fake_run(argv, **kwargs):
|
|
231
|
+
captured["argv"] = argv
|
|
232
|
+
captured["env"] = kwargs["env"]
|
|
233
|
+
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
234
|
+
|
|
235
|
+
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
|
236
|
+
main._git(["clone", "https://gitlab.com/group/project.git", "/tmp/x"], token="glpat_secret")
|
|
237
|
+
|
|
238
|
+
assert "glpat_secret" not in " ".join(captured["argv"])
|
|
239
|
+
assert captured["env"]["GIT_CONFIG_KEY_0"] == "http.extraHeader"
|
|
240
|
+
assert captured["env"]["GIT_CONFIG_VALUE_0"].startswith("Authorization: Basic ")
|
|
241
|
+
assert "glpat_secret" not in captured["env"]["GIT_CONFIG_VALUE_0"]
|
|
242
|
+
assert captured["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_git_authenticates_the_token_as_the_oauth2_user(main, monkeypatch):
|
|
246
|
+
"""GitLab accepts a personal access token over HTTPS as the `oauth2` user."""
|
|
247
|
+
import base64
|
|
248
|
+
|
|
249
|
+
captured = {}
|
|
250
|
+
|
|
251
|
+
def fake_run(argv, **kwargs):
|
|
252
|
+
captured["env"] = kwargs["env"]
|
|
253
|
+
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
254
|
+
|
|
255
|
+
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
|
256
|
+
main._git(["fetch"], token="glpat_secret")
|
|
257
|
+
|
|
258
|
+
encoded = captured["env"]["GIT_CONFIG_VALUE_0"].removeprefix("Authorization: Basic ")
|
|
259
|
+
assert base64.b64decode(encoded).decode() == "oauth2:glpat_secret"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_git_failures_do_not_echo_the_token(main, monkeypatch):
|
|
263
|
+
def fake_run(argv, **kwargs):
|
|
264
|
+
return subprocess.CompletedProcess(argv, 128, "", "fatal: bad credentials glpat_secret")
|
|
265
|
+
|
|
266
|
+
monkeypatch.setattr(main.subprocess, "run", fake_run)
|
|
267
|
+
|
|
268
|
+
with pytest.raises(RuntimeError) as excinfo:
|
|
269
|
+
main._git(["push", "origin", "HEAD"], token="glpat_secret")
|
|
270
|
+
|
|
271
|
+
assert "glpat_secret" not in str(excinfo.value)
|
|
272
|
+
assert "***" in str(excinfo.value)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ── Secrets handed to the conversation ────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_only_the_gitlab_token_is_forwarded_by_default(main, monkeypatch):
|
|
279
|
+
"""The agent reads the issue itself, so it needs the GitLab token - and
|
|
280
|
+
nothing else in the deployment's secret store."""
|
|
281
|
+
monkeypatch.setattr(
|
|
282
|
+
main,
|
|
283
|
+
"_list_secret_names",
|
|
284
|
+
lambda agent_url, api_key: [
|
|
285
|
+
{"name": "GITLAB_TOKEN"},
|
|
286
|
+
{"name": "NPM_TOKEN"},
|
|
287
|
+
{"name": "AWS_SECRET_ACCESS_KEY"},
|
|
288
|
+
],
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
assert main.AGENT_SECRET_NAMES == ["GITLAB_TOKEN"]
|
|
292
|
+
assert list(main._build_secrets_payload("http://agent", "key")) == ["GITLAB_TOKEN"]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_an_empty_allow_list_forwards_nothing(main, monkeypatch):
|
|
296
|
+
monkeypatch.setattr(main, "AGENT_SECRET_NAMES", [])
|
|
297
|
+
called = False
|
|
298
|
+
|
|
299
|
+
def fake_list(agent_url, api_key):
|
|
300
|
+
nonlocal called
|
|
301
|
+
called = True
|
|
302
|
+
return [{"name": "GITLAB_TOKEN"}]
|
|
303
|
+
|
|
304
|
+
monkeypatch.setattr(main, "_list_secret_names", fake_list)
|
|
305
|
+
|
|
306
|
+
assert main._build_secrets_payload("http://agent", "key") == {}
|
|
307
|
+
assert called is False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_only_declared_secrets_are_forwarded(main, monkeypatch):
|
|
311
|
+
monkeypatch.setattr(main, "AGENT_SECRET_NAMES", ["NPM_TOKEN", "ABSENT_TOKEN"])
|
|
312
|
+
monkeypatch.setattr(
|
|
313
|
+
main,
|
|
314
|
+
"_list_secret_names",
|
|
315
|
+
lambda agent_url, api_key: [{"name": "GITLAB_TOKEN"}, {"name": "NPM_TOKEN"}],
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
payload = main._build_secrets_payload("http://agent", "key")
|
|
319
|
+
|
|
320
|
+
assert list(payload) == ["NPM_TOKEN"]
|
|
321
|
+
assert payload["NPM_TOKEN"]["url"] == "/api/settings/secrets/NPM_TOKEN"
|
|
322
|
+
assert payload["NPM_TOKEN"]["headers"] == {"X-Session-API-Key": "key"}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_the_conversation_payload_carries_no_secrets_block_when_there_are_none(
|
|
326
|
+
main, monkeypatch, tmp_path
|
|
327
|
+
):
|
|
328
|
+
sent = {}
|
|
329
|
+
|
|
330
|
+
monkeypatch.setattr(main, "_get_agent_dict", lambda url, key: {"kind": "Agent"})
|
|
331
|
+
monkeypatch.setattr(main, "_build_secrets_payload", lambda url, key: {})
|
|
332
|
+
monkeypatch.setattr(main, "_get_mcp_config", lambda url, key: None)
|
|
333
|
+
|
|
334
|
+
def fake_request(agent_url, api_key, method, path, body=None):
|
|
335
|
+
sent["body"] = body
|
|
336
|
+
return {"id": "conv-1"}
|
|
337
|
+
|
|
338
|
+
monkeypatch.setattr(main, "_oh_request", fake_request)
|
|
339
|
+
|
|
340
|
+
conv_id = main.create_conversation("http://agent", "key", "do the thing", tmp_path)
|
|
341
|
+
|
|
342
|
+
assert conv_id == "conv-1"
|
|
343
|
+
assert "secrets" not in sent["body"]
|
|
344
|
+
assert "mcp_config" not in sent["body"]
|
|
345
|
+
assert sent["body"]["workspace"] == {"working_dir": str(tmp_path)}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ── MCP servers handed to the conversation ────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_the_deployments_mcp_servers_are_forwarded(main, monkeypatch):
|
|
352
|
+
"""A connected GitLab server gives the agent typed tools instead of curl."""
|
|
353
|
+
config = {"mcpServers": {"gitlab": {"url": "https://gitlab.com/api/v4/mcp"}}}
|
|
354
|
+
monkeypatch.setattr(
|
|
355
|
+
main, "_fetch_settings", lambda url, key: {"agent_settings": {"mcp_config": config}}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
assert main._get_mcp_config("http://agent", "key") == config
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_a_deployment_with_no_mcp_servers_forwards_nothing(main, monkeypatch):
|
|
362
|
+
monkeypatch.setattr(
|
|
363
|
+
main,
|
|
364
|
+
"_fetch_settings",
|
|
365
|
+
lambda url, key: {"agent_settings": {"mcp_config": {"mcpServers": {}}}},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
assert main._get_mcp_config("http://agent", "key") is None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_an_unreadable_mcp_config_does_not_drop_the_task(main, monkeypatch):
|
|
372
|
+
"""The agent falls back to the REST calls the prompt spells out, so this is
|
|
373
|
+
a warning rather than a task that never starts."""
|
|
374
|
+
def boom(url, key):
|
|
375
|
+
raise RuntimeError("settings unreachable")
|
|
376
|
+
|
|
377
|
+
monkeypatch.setattr(main, "_fetch_settings", boom)
|
|
378
|
+
|
|
379
|
+
assert main._get_mcp_config("http://agent", "key") is None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_the_conversation_carries_the_mcp_config_when_there_is_one(main, monkeypatch, tmp_path):
|
|
383
|
+
sent = {}
|
|
384
|
+
config = {"mcpServers": {"gitlab": {"url": "https://gitlab.com/api/v4/mcp"}}}
|
|
385
|
+
|
|
386
|
+
monkeypatch.setattr(main, "_get_agent_dict", lambda url, key: {"kind": "Agent"})
|
|
387
|
+
monkeypatch.setattr(main, "_build_secrets_payload", lambda url, key: {})
|
|
388
|
+
monkeypatch.setattr(main, "_get_mcp_config", lambda url, key: config)
|
|
389
|
+
|
|
390
|
+
def fake_request(agent_url, api_key, method, path, body=None):
|
|
391
|
+
sent["body"] = body
|
|
392
|
+
return {"id": "conv-1"}
|
|
393
|
+
|
|
394
|
+
monkeypatch.setattr(main, "_oh_request", fake_request)
|
|
395
|
+
main.create_conversation("http://agent", "key", "do the thing", tmp_path)
|
|
396
|
+
|
|
397
|
+
assert sent["body"]["mcp_config"] == config
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ── Issue discovery ───────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_only_open_issues_with_the_label_are_requested(main, monkeypatch):
|
|
404
|
+
"""GitLab keeps merge requests on their own endpoint, so the filter is the
|
|
405
|
+
whole of the selection."""
|
|
406
|
+
captured = {}
|
|
407
|
+
|
|
408
|
+
def fake_paginate(token, path, params=None):
|
|
409
|
+
captured["path"] = path
|
|
410
|
+
captured["params"] = params
|
|
411
|
+
return [{"iid": 1, "title": "an issue"}]
|
|
412
|
+
|
|
413
|
+
monkeypatch.setattr(main, "_gitlab_paginate", fake_paginate)
|
|
414
|
+
|
|
415
|
+
issues = main._list_labeled_issues("token", "group/project")
|
|
416
|
+
|
|
417
|
+
assert [issue["iid"] for issue in issues] == [1]
|
|
418
|
+
assert captured["path"] == "/projects/group%2Fproject/issues"
|
|
419
|
+
assert captured["params"]["state"] == "opened"
|
|
420
|
+
assert captured["params"]["labels"] == "openhands"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_latest_matching_label_event_wins(main, monkeypatch):
|
|
424
|
+
events = [
|
|
425
|
+
{"action": "add", "id": 1, "created_at": "2026-01-01T00:00:00Z", "label": {"name": "openhands"}},
|
|
426
|
+
{"action": "add", "id": 2, "created_at": "2026-01-03T00:00:00Z", "label": {"name": "other"}},
|
|
427
|
+
{"action": "remove", "id": 3, "created_at": "2026-01-04T00:00:00Z", "label": {"name": "openhands"}},
|
|
428
|
+
{"action": "add", "id": 4, "created_at": "2026-01-02T00:00:00Z", "label": {"name": "OpenHands"}},
|
|
429
|
+
]
|
|
430
|
+
monkeypatch.setattr(main, "_gitlab_paginate", lambda token, path, params=None: events)
|
|
431
|
+
|
|
432
|
+
event = main._latest_trigger_label_event("token", "group/project", 42)
|
|
433
|
+
|
|
434
|
+
assert event["id"] == 4
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_an_event_whose_label_was_deleted_is_skipped(main, monkeypatch):
|
|
438
|
+
"""GitLab keeps the event and nulls its label when the label is deleted."""
|
|
439
|
+
monkeypatch.setattr(
|
|
440
|
+
main,
|
|
441
|
+
"_gitlab_paginate",
|
|
442
|
+
lambda token, path, params=None: [
|
|
443
|
+
{"action": "add", "id": 9, "created_at": "2026-01-01T00:00:00Z", "label": None}
|
|
444
|
+
],
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
assert main._latest_trigger_label_event("token", "group/project", 42) is None
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_no_matching_label_event_returns_none(main, monkeypatch):
|
|
451
|
+
monkeypatch.setattr(
|
|
452
|
+
main,
|
|
453
|
+
"_gitlab_paginate",
|
|
454
|
+
lambda token, path, params=None: [
|
|
455
|
+
{"action": "remove", "id": 9, "created_at": "2026-01-01T00:00:00Z", "label": {"name": "openhands"}}
|
|
456
|
+
],
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
assert main._latest_trigger_label_event("token", "group/project", 42) is None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def test_gitlab_labels_are_plain_strings(main):
|
|
463
|
+
"""GitLab returns issue labels as strings, not objects."""
|
|
464
|
+
assert main._has_trigger_label({"labels": ["bug", "OpenHands"]}) is True
|
|
465
|
+
assert main._has_trigger_label({"labels": ["bug"]}) is False
|
|
466
|
+
assert main._has_trigger_label({}) is False
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ── Branch naming ─────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def test_branch_name_is_the_first_free_one(main, monkeypatch):
|
|
473
|
+
"""Re-applying the label must not force-push over the previous attempt."""
|
|
474
|
+
taken = {"openhands/issue-42", "openhands/issue-42-2"}
|
|
475
|
+
|
|
476
|
+
def fake_request(token, method, path, params=None, body=None):
|
|
477
|
+
branch = path.split("/repository/branches/", 1)[1].replace("%2F", "/")
|
|
478
|
+
if branch in taken:
|
|
479
|
+
return {"name": branch}, {}
|
|
480
|
+
raise _http_error(404)
|
|
481
|
+
|
|
482
|
+
monkeypatch.setattr(main, "_gitlab_request", fake_request)
|
|
483
|
+
|
|
484
|
+
assert main._branch_name("token", "group/project", 42) == "openhands/issue-42-3"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_the_branch_name_is_url_encoded_in_the_lookup(main, monkeypatch):
|
|
488
|
+
"""A prefix with a slash in it becomes extra path segments if left alone."""
|
|
489
|
+
seen = []
|
|
490
|
+
|
|
491
|
+
def fake_request(token, method, path, params=None, body=None):
|
|
492
|
+
seen.append(path)
|
|
493
|
+
raise _http_error(404)
|
|
494
|
+
|
|
495
|
+
monkeypatch.setattr(main, "_gitlab_request", fake_request)
|
|
496
|
+
main._branch_name("token", "group/project", 42)
|
|
497
|
+
|
|
498
|
+
assert seen[0] == "/projects/group%2Fproject/repository/branches/openhands%2Fissue-42"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def test_branch_lookup_does_not_swallow_other_errors(main, monkeypatch):
|
|
502
|
+
def fake_request(token, method, path, params=None, body=None):
|
|
503
|
+
raise _http_error(500)
|
|
504
|
+
|
|
505
|
+
monkeypatch.setattr(main, "_gitlab_request", fake_request)
|
|
506
|
+
|
|
507
|
+
with pytest.raises(urllib.error.HTTPError):
|
|
508
|
+
main._branch_name("token", "group/project", 42)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ── Merge request creation ────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def test_a_draft_is_a_title_prefix(main, monkeypatch):
|
|
515
|
+
"""GitLab has no draft flag on the merge request API."""
|
|
516
|
+
assert main._merge_request_title("[#42] Retry uploads") == "Draft: [#42] Retry uploads"
|
|
517
|
+
|
|
518
|
+
monkeypatch.setattr(main, "DRAFT_MERGE_REQUEST", False)
|
|
519
|
+
assert main._merge_request_title("[#42] Retry uploads") == "[#42] Retry uploads"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_the_merge_request_is_opened_from_the_branch_onto_the_default(main, monkeypatch):
|
|
523
|
+
sent = {}
|
|
524
|
+
|
|
525
|
+
def fake_request(token, method, path, params=None, body=None):
|
|
526
|
+
sent["path"] = path
|
|
527
|
+
sent["body"] = body
|
|
528
|
+
return {"web_url": "https://gitlab.com/group/project/-/merge_requests/7", "iid": 7}, {}
|
|
529
|
+
|
|
530
|
+
monkeypatch.setattr(main, "_gitlab_request", fake_request)
|
|
531
|
+
|
|
532
|
+
mr = main._open_merge_request(
|
|
533
|
+
"token", "group/project", "openhands/issue-42", "main", "[#42] Retry uploads", "why"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
assert mr["iid"] == 7
|
|
537
|
+
assert sent["path"] == "/projects/group%2Fproject/merge_requests"
|
|
538
|
+
assert sent["body"]["source_branch"] == "openhands/issue-42"
|
|
539
|
+
assert sent["body"]["target_branch"] == "main"
|
|
540
|
+
assert sent["body"]["title"] == "Draft: [#42] Retry uploads"
|
|
541
|
+
assert sent["body"]["description"] == "why"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def test_a_conflict_adopts_the_merge_request_that_already_exists(main, monkeypatch):
|
|
545
|
+
"""A retried finalization must not fail on the merge request it opened the
|
|
546
|
+
first time round."""
|
|
547
|
+
def fake_request(token, method, path, params=None, body=None):
|
|
548
|
+
raise _http_error(409)
|
|
549
|
+
|
|
550
|
+
monkeypatch.setattr(main, "_gitlab_request", fake_request)
|
|
551
|
+
monkeypatch.setattr(
|
|
552
|
+
main,
|
|
553
|
+
"_existing_merge_request",
|
|
554
|
+
lambda token, project, branch: {"web_url": "https://gitlab.com/mr/7", "iid": 7},
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
mr = main._open_merge_request(
|
|
558
|
+
"token", "group/project", "openhands/issue-42", "main", "title", "body"
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
assert mr["iid"] == 7
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def test_a_conflict_with_no_merge_request_behind_it_is_still_an_error(main, monkeypatch):
|
|
565
|
+
def fake_request(token, method, path, params=None, body=None):
|
|
566
|
+
raise urllib.error.HTTPError("url", 409, "err", {}, None)
|
|
567
|
+
|
|
568
|
+
monkeypatch.setattr(main, "_gitlab_request", fake_request)
|
|
569
|
+
monkeypatch.setattr(main, "_existing_merge_request", lambda token, project, branch: None)
|
|
570
|
+
|
|
571
|
+
with pytest.raises((RuntimeError, AttributeError)):
|
|
572
|
+
main._open_merge_request("token", "group/project", "b", "main", "title", "body")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ── Commits the agent left behind ─────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _init_repo(path: Path) -> str:
|
|
579
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
580
|
+
run = lambda *args: subprocess.run(["git", *args], cwd=path, check=True, capture_output=True)
|
|
581
|
+
run("init", "-q", "-b", "main")
|
|
582
|
+
run("config", "user.name", "Test")
|
|
583
|
+
run("config", "user.email", "test@example.com")
|
|
584
|
+
(path / "README.md").write_text("base\n")
|
|
585
|
+
run("add", "-A")
|
|
586
|
+
run("commit", "-q", "-m", "base")
|
|
587
|
+
return subprocess.run(
|
|
588
|
+
["git", "rev-parse", "HEAD"], cwd=path, check=True, capture_output=True, text=True
|
|
589
|
+
).stdout.strip()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def test_uncommitted_work_is_committed_and_counted(main, tmp_path):
|
|
593
|
+
checkout = tmp_path / "clone"
|
|
594
|
+
base_sha = _init_repo(checkout)
|
|
595
|
+
(checkout / "fix.py").write_text("print('fixed')\n")
|
|
596
|
+
|
|
597
|
+
commits = main._commit_agent_work(checkout, 42, "Retry uploads on a 502", base_sha)
|
|
598
|
+
|
|
599
|
+
assert commits == 1
|
|
600
|
+
assert main._git(["status", "--porcelain"], cwd=checkout).stdout.strip() == ""
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def test_commits_the_agent_made_itself_are_kept(main, tmp_path):
|
|
604
|
+
checkout = tmp_path / "clone"
|
|
605
|
+
base_sha = _init_repo(checkout)
|
|
606
|
+
(checkout / "fix.py").write_text("print('fixed')\n")
|
|
607
|
+
subprocess.run(["git", "add", "-A"], cwd=checkout, check=True, capture_output=True)
|
|
608
|
+
subprocess.run(
|
|
609
|
+
["git", "commit", "-q", "-m", "agent commit"], cwd=checkout, check=True, capture_output=True
|
|
610
|
+
)
|
|
611
|
+
(checkout / "extra.py").write_text("print('extra')\n")
|
|
612
|
+
|
|
613
|
+
commits = main._commit_agent_work(checkout, 42, "title", base_sha)
|
|
614
|
+
|
|
615
|
+
assert commits == 2
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def test_an_untouched_clone_produces_no_commits(main, tmp_path):
|
|
619
|
+
"""No commits is how an agent reports an issue it could not implement, so it
|
|
620
|
+
must not become an empty merge request."""
|
|
621
|
+
checkout = tmp_path / "clone"
|
|
622
|
+
base_sha = _init_repo(checkout)
|
|
623
|
+
|
|
624
|
+
assert main._commit_agent_work(checkout, 42, "title", base_sha) == 0
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
# ── Releasing the clone ───────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def test_a_running_conversation_keeps_its_clone(main, monkeypatch, tmp_path):
|
|
631
|
+
checkout = main._checkouts_root() / "group__project" / "issue-42-1"
|
|
632
|
+
checkout.mkdir(parents=True)
|
|
633
|
+
rec = {"workspace_dir": str(checkout), "conversation_id": "conv-1"}
|
|
634
|
+
monkeypatch.setattr(main, "conversation_status", lambda *a: "running")
|
|
635
|
+
|
|
636
|
+
assert main._release_checkout(rec, "http://agent", "key") is False
|
|
637
|
+
assert checkout.exists()
|
|
638
|
+
assert rec["workspace_dir"] == str(checkout)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def test_a_stopped_conversation_releases_its_clone(main, monkeypatch, tmp_path):
|
|
642
|
+
checkout = main._checkouts_root() / "group__project" / "issue-42-1"
|
|
643
|
+
checkout.mkdir(parents=True)
|
|
644
|
+
rec = {"workspace_dir": str(checkout), "conversation_id": "conv-1"}
|
|
645
|
+
monkeypatch.setattr(main, "conversation_status", lambda *a: "finished")
|
|
646
|
+
|
|
647
|
+
assert main._release_checkout(rec, "http://agent", "key") is True
|
|
648
|
+
assert not checkout.exists()
|
|
649
|
+
assert "workspace_dir" not in rec
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def test_a_path_outside_the_checkout_root_is_never_removed(main, monkeypatch, tmp_path):
|
|
653
|
+
outside = tmp_path / "precious"
|
|
654
|
+
outside.mkdir()
|
|
655
|
+
rec = {"workspace_dir": str(outside), "conversation_id": "conv-1"}
|
|
656
|
+
monkeypatch.setattr(main, "conversation_status", lambda *a: "finished")
|
|
657
|
+
|
|
658
|
+
assert main._release_checkout(rec, "http://agent", "key") is True
|
|
659
|
+
assert outside.exists()
|
|
660
|
+
assert "workspace_dir" not in rec
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def test_an_unconfirmable_conversation_keeps_its_clone(main, monkeypatch):
|
|
664
|
+
checkout = main._checkouts_root() / "group__project" / "issue-42-1"
|
|
665
|
+
checkout.mkdir(parents=True)
|
|
666
|
+
rec = {"workspace_dir": str(checkout), "conversation_id": "conv-1"}
|
|
667
|
+
|
|
668
|
+
def boom(*args):
|
|
669
|
+
raise RuntimeError("agent server unreachable")
|
|
670
|
+
|
|
671
|
+
monkeypatch.setattr(main, "conversation_status", boom)
|
|
672
|
+
|
|
673
|
+
assert main._release_checkout(rec, "http://agent", "key") is False
|
|
674
|
+
assert checkout.exists()
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# ── Merge request description ─────────────────────────────────────────────────
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def test_merge_request_body_links_the_issue_and_discloses_the_agent(main):
|
|
681
|
+
body = main._merge_request_body(42, "Adds a retry.", "http://oh/conversations/c1")
|
|
682
|
+
|
|
683
|
+
assert "Adds a retry." in body
|
|
684
|
+
assert "Closes #42" in body
|
|
685
|
+
assert "http://oh/conversations/c1" in body
|
|
686
|
+
assert "_This merge request was opened by an AI agent (OpenHands)._" in body
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def test_merge_request_body_is_truncated(main):
|
|
690
|
+
body = main._merge_request_body(42, "x" * (main.MAX_MR_BODY_CHARS + 100), "url")
|
|
691
|
+
|
|
692
|
+
assert len(body) < main.MAX_MR_BODY_CHARS + 400
|
|
693
|
+
assert "(summary truncated)" in body
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# ── Prompt ────────────────────────────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _prompt(main):
|
|
700
|
+
return main._build_implementation_prompt(
|
|
701
|
+
"group/project",
|
|
702
|
+
{
|
|
703
|
+
"iid": 42,
|
|
704
|
+
"title": "Retry uploads",
|
|
705
|
+
"description": "It 502s, see the log in the linked pipeline.",
|
|
706
|
+
"author": {"username": "alice"},
|
|
707
|
+
"labels": ["openhands"],
|
|
708
|
+
"web_url": "https://gitlab.com/group/project/-/issues/42",
|
|
709
|
+
},
|
|
710
|
+
{"id": 1, "created_at": "2026-01-02T00:00:00Z"},
|
|
711
|
+
"openhands/issue-42",
|
|
712
|
+
"main",
|
|
713
|
+
"abc123",
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def test_the_prompt_names_the_issue_and_sends_the_agent_to_read_it(main):
|
|
718
|
+
"""A copy of the description pasted at dispatch is stale as soon as someone
|
|
719
|
+
comments, and it stops where the issue's own text stops."""
|
|
720
|
+
prompt = _prompt(main)
|
|
721
|
+
|
|
722
|
+
assert "#42" in prompt
|
|
723
|
+
assert "https://gitlab.com/group/project/-/issues/42" in prompt
|
|
724
|
+
assert "https://gitlab.com/api/v4/projects/group%2Fproject/issues/42" in prompt
|
|
725
|
+
assert "/notes" in prompt
|
|
726
|
+
assert "GITLAB_TOKEN" in prompt
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def test_the_prompt_does_not_embed_the_issue_text(main):
|
|
730
|
+
prompt = _prompt(main)
|
|
731
|
+
|
|
732
|
+
assert "It 502s" not in prompt
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def test_the_prompt_tells_the_agent_to_push_and_open_the_merge_request(main):
|
|
736
|
+
"""The merge request should appear when the agent stops, not on the next poll."""
|
|
737
|
+
prompt = _prompt(main)
|
|
738
|
+
|
|
739
|
+
assert "git push" in prompt and "https://oauth2:$GITLAB_TOKEN@gitlab.com/group/project.git" in prompt
|
|
740
|
+
assert "HEAD:refs/heads/openhands/issue-42" in prompt
|
|
741
|
+
assert "/projects/group%2Fproject/merge_requests" in prompt
|
|
742
|
+
assert '"Draft: [#42] Retry uploads"' in prompt
|
|
743
|
+
assert "Closes #42" in prompt
|
|
744
|
+
assert "GITLAB_MR_OPENED" in prompt
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def test_the_prompt_says_a_command_must_name_the_secret(main):
|
|
748
|
+
"""The SDK only puts a secret in the environment of a command that mentions
|
|
749
|
+
it, so an instruction that omits the name would simply fail to authenticate."""
|
|
750
|
+
prompt = _prompt(main)
|
|
751
|
+
|
|
752
|
+
assert "only put in the environment of a command that mentions it" in prompt
|
|
753
|
+
assert "Never echo it" in prompt
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def test_the_prompt_keeps_the_untrusted_input_boundary(main):
|
|
757
|
+
prompt = _prompt(main)
|
|
758
|
+
|
|
759
|
+
assert "untrusted input" in prompt
|
|
760
|
+
assert "projects other than group/project" in prompt
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def test_the_prompt_prefers_mcp_tools_when_they_are_there(main):
|
|
764
|
+
"""The curl commands stay as the fallback, and the push is git either way."""
|
|
765
|
+
prompt = _prompt(main)
|
|
766
|
+
|
|
767
|
+
assert "connected MCP server" in prompt
|
|
768
|
+
assert "prefer" in prompt
|
|
769
|
+
assert "git push" in prompt
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def test_a_ready_for_review_configuration_drops_the_draft_prefix(main, monkeypatch):
|
|
773
|
+
monkeypatch.setattr(main, "DRAFT_MERGE_REQUEST", False)
|
|
774
|
+
prompt = _prompt(main)
|
|
775
|
+
|
|
776
|
+
assert "Draft: [#42]" not in prompt
|
|
777
|
+
assert "ready for review" in prompt
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def test_the_prompt_points_at_a_self_managed_instance(main, monkeypatch):
|
|
781
|
+
monkeypatch.setattr(main, "GITLAB_API_URL", "https://gitlab.example.com/api/v4")
|
|
782
|
+
prompt = _prompt(main)
|
|
783
|
+
|
|
784
|
+
assert "https://gitlab.example.com/api/v4/projects/group%2Fproject/issues/42" in prompt
|
|
785
|
+
assert "https://oauth2:$GITLAB_TOKEN@gitlab.example.com/group/project.git" in prompt
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# ── State ─────────────────────────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def test_state_is_kept_per_project(main, tmp_path):
|
|
792
|
+
main.save_state("group/one", {"version": 1, "project": "group/one", "tasks": {"1:label:1": {}}})
|
|
793
|
+
main.save_state("group/two", {"version": 1, "project": "group/two", "tasks": {}})
|
|
794
|
+
|
|
795
|
+
assert list(main.load_state("group/one")["tasks"]) == ["1:label:1"]
|
|
796
|
+
assert main.load_state("group/two")["tasks"] == {}
|
|
797
|
+
assert main.load_state("group/three") == {
|
|
798
|
+
"version": 1,
|
|
799
|
+
"project": "group/three",
|
|
800
|
+
"trigger_label": main.TRIGGER_LABEL,
|
|
801
|
+
"tasks": {},
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def test_a_subgroup_project_gets_its_own_state_document(main):
|
|
806
|
+
assert main._state_key("group/team/service") == "state:group__team__service"
|
|
807
|
+
assert "group__team__service" in main._state_file_path("group/team/service")
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def test_unreadable_state_starts_fresh_rather_than_failing_the_run(main):
|
|
811
|
+
path = Path(main._state_file_path("group/project"))
|
|
812
|
+
path.write_text("{not json")
|
|
813
|
+
|
|
814
|
+
assert main.load_state("group/project")["tasks"] == {}
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def test_state_is_written_atomically(main):
|
|
818
|
+
state = {"version": 1, "project": "group/project", "tasks": {"1:label:1": {"status": "active"}}}
|
|
819
|
+
main.save_state("group/project", state)
|
|
820
|
+
|
|
821
|
+
path = Path(main._state_file_path("group/project"))
|
|
822
|
+
assert json.loads(path.read_text()) == state
|
|
823
|
+
assert not Path(f"{path}.tmp").exists()
|