patch-via-github 1.0.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.
- patch_via_github/__init__.py +11 -0
- patch_via_github/scripts/__init__.py +1 -0
- patch_via_github/scripts/main.py +965 -0
- patch_via_github/tests/__init__.py +1 -0
- patch_via_github/tests/conftest.py +185 -0
- patch_via_github/tests/manifest.xml +18 -0
- patch_via_github/tests/test_patch_via_github.py +1576 -0
- patch_via_github-1.0.0.dist-info/METADATA +205 -0
- patch_via_github-1.0.0.dist-info/RECORD +12 -0
- patch_via_github-1.0.0.dist-info/WHEEL +4 -0
- patch_via_github-1.0.0.dist-info/entry_points.txt +3 -0
- patch_via_github-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1576 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from unittest.mock import patch, MagicMock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import requests.exceptions
|
|
10
|
+
|
|
11
|
+
import patch_via_github.scripts.main as app
|
|
12
|
+
from patch_via_github.tests.conftest import commit, make_pr_api_response
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_ini(tmp_path, *lines):
|
|
16
|
+
ini = tmp_path / "test.ini"
|
|
17
|
+
ini.write_text("\n".join(["[main]", *lines]) + "\n")
|
|
18
|
+
return str(ini)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ===================================================================
|
|
22
|
+
# Utilities
|
|
23
|
+
# ===================================================================
|
|
24
|
+
|
|
25
|
+
class TestDefaultIniFile:
|
|
26
|
+
def test_under_home_ssh_dir(self, tmp_path):
|
|
27
|
+
assert app.default_ini_file() == str(
|
|
28
|
+
tmp_path / "home" / ".ssh" / "patch_via_github.ini"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestParseCSVs:
|
|
33
|
+
@pytest.mark.parametrize('arguments, expected', [
|
|
34
|
+
(['value1'], ['value1']),
|
|
35
|
+
(['a,b,c'], ['a', 'b', 'c']),
|
|
36
|
+
(['a,,b'], ['a', 'b']),
|
|
37
|
+
(['a,b', 'c,d'], ['a', 'b', 'c', 'd']),
|
|
38
|
+
])
|
|
39
|
+
def test_split(self, arguments, expected):
|
|
40
|
+
namespace = argparse.Namespace()
|
|
41
|
+
app.ParseCSVs(None, 'dest')(None, namespace, arguments, None)
|
|
42
|
+
assert namespace.dest == expected
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestPrKey:
|
|
46
|
+
def test_case_insensitive(self):
|
|
47
|
+
assert app.pr_key("Couchbase/TLM", "7") == "couchbase/tlm#7"
|
|
48
|
+
|
|
49
|
+
def test_number_normalised(self):
|
|
50
|
+
assert app.pr_key("couchbase/tlm", "007") == "couchbase/tlm#7"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ===================================================================
|
|
54
|
+
# GitHubPR
|
|
55
|
+
# ===================================================================
|
|
56
|
+
|
|
57
|
+
class TestGitHubPR:
|
|
58
|
+
def test_init(self):
|
|
59
|
+
data = make_pr_api_response(
|
|
60
|
+
42, "tlm", head_sha="deadbeef", labels=["bug-fix", "urgent"]
|
|
61
|
+
)
|
|
62
|
+
pr = app.GitHubPR(data)
|
|
63
|
+
assert pr.number == 42
|
|
64
|
+
assert pr.project == "tlm"
|
|
65
|
+
assert pr.repo_full_name == "couchbase/tlm"
|
|
66
|
+
assert pr.branch == "master"
|
|
67
|
+
assert pr.head_sha == "deadbeef"
|
|
68
|
+
assert pr.html_url == "https://github.com/couchbase/tlm/pull/42"
|
|
69
|
+
assert pr.fetch_url == "https://github.com/couchbase/tlm.git"
|
|
70
|
+
assert pr.ref == "couchbase/tlm#42"
|
|
71
|
+
assert pr.key == "couchbase/tlm#42"
|
|
72
|
+
|
|
73
|
+
def test_commit_count(self):
|
|
74
|
+
data = make_pr_api_response(1, "tlm")
|
|
75
|
+
assert app.GitHubPR(data).commit_count is None
|
|
76
|
+
data['commits'] = 3
|
|
77
|
+
assert app.GitHubPR(data).commit_count == 3
|
|
78
|
+
|
|
79
|
+
def test_fetch_command_uses_ssh_url(self):
|
|
80
|
+
data = make_pr_api_response(7, "server-ui", org="myorg")
|
|
81
|
+
pr = app.GitHubPR(data, use_ssh=True)
|
|
82
|
+
assert pr.fetch_url == 'git@github.com:myorg/server-ui.git'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ===================================================================
|
|
86
|
+
# GitHubPatches — Configuration
|
|
87
|
+
# ===================================================================
|
|
88
|
+
|
|
89
|
+
class TestGitHubPatchesConfig:
|
|
90
|
+
|
|
91
|
+
def test_from_config_file(self, config_file):
|
|
92
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
93
|
+
assert gp.default_org == "couchbase"
|
|
94
|
+
assert gp.checkout is False
|
|
95
|
+
|
|
96
|
+
def test_missing_file_raises(self, tmp_path):
|
|
97
|
+
with pytest.raises(app.PatchError, match="nonexistent.ini missing"):
|
|
98
|
+
app.GitHubPatches.from_config_file(
|
|
99
|
+
str(tmp_path / "nonexistent.ini")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def test_unreadable_file_raises(self, tmp_path):
|
|
103
|
+
ini = write_ini(tmp_path, "token = x")
|
|
104
|
+
os.chmod(ini, 0)
|
|
105
|
+
try:
|
|
106
|
+
with pytest.raises(app.PatchError,
|
|
107
|
+
match="Could not read configuration file"):
|
|
108
|
+
app.GitHubPatches.from_config_file(ini)
|
|
109
|
+
finally:
|
|
110
|
+
os.chmod(ini, 0o600)
|
|
111
|
+
|
|
112
|
+
def test_missing_main_section_raises(self, tmp_path):
|
|
113
|
+
bad_ini = tmp_path / "bad.ini"
|
|
114
|
+
bad_ini.write_text("[wrong]\nfoo = bar\n")
|
|
115
|
+
with pytest.raises(app.PatchError, match='missing "main" section'):
|
|
116
|
+
app.GitHubPatches.from_config_file(str(bad_ini))
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize('content, lines', [
|
|
119
|
+
("[main]\nghp_SECRET\n", "2"),
|
|
120
|
+
("ghp_SECRET\n", "1"),
|
|
121
|
+
("[main]\ntoken = ghp_SECRET\ntoken = ghp_SECRET\n", "3"),
|
|
122
|
+
])
|
|
123
|
+
def test_unparseable_file_does_not_quote_it(
|
|
124
|
+
self, tmp_path, content, lines
|
|
125
|
+
):
|
|
126
|
+
ini = tmp_path / "bad.ini"
|
|
127
|
+
ini.write_text(content)
|
|
128
|
+
with pytest.raises(app.PatchError) as e:
|
|
129
|
+
app.GitHubPatches.from_config_file(str(ini))
|
|
130
|
+
assert f"could not parse line {lines})" in str(e.value)
|
|
131
|
+
assert e.value.__cause__ is None and e.value.__suppress_context__
|
|
132
|
+
assert "SECRET" not in str(e.value)
|
|
133
|
+
|
|
134
|
+
def test_missing_token_raises(self, tmp_path):
|
|
135
|
+
with pytest.raises(app.PatchError,
|
|
136
|
+
match='Required option "token" is missing'):
|
|
137
|
+
app.GitHubPatches.from_config_file(
|
|
138
|
+
write_ini(tmp_path, "default_org = couchbase")
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def test_empty_token_raises(self, tmp_path):
|
|
142
|
+
with pytest.raises(app.PatchError, match='"token" .* is empty'):
|
|
143
|
+
app.GitHubPatches.from_config_file(write_ini(tmp_path, "token ="))
|
|
144
|
+
|
|
145
|
+
def test_with_checkout(self, config_file):
|
|
146
|
+
gp = app.GitHubPatches.from_config_file(config_file, checkout=True)
|
|
147
|
+
assert gp.checkout is True
|
|
148
|
+
|
|
149
|
+
def test_default_org_override(self, config_file):
|
|
150
|
+
gp = app.GitHubPatches.from_config_file(
|
|
151
|
+
config_file, default_org="other-org"
|
|
152
|
+
)
|
|
153
|
+
assert gp.default_org == "other-org"
|
|
154
|
+
|
|
155
|
+
def test_no_org_in_file(self, config_file_no_org):
|
|
156
|
+
gp = app.GitHubPatches.from_config_file(config_file_no_org)
|
|
157
|
+
assert gp.default_org is None
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def prepared_headers(gp):
|
|
161
|
+
request = requests.Request('GET', 'https://api.github.com/x')
|
|
162
|
+
return gp.session.prepare_request(request).headers
|
|
163
|
+
|
|
164
|
+
def test_request_headers(self, config_file):
|
|
165
|
+
headers = self.prepared_headers(
|
|
166
|
+
app.GitHubPatches.from_config_file(config_file)
|
|
167
|
+
)
|
|
168
|
+
assert headers['Authorization'] == (
|
|
169
|
+
'Bearer ghp_test_token_000000000000000000000'
|
|
170
|
+
)
|
|
171
|
+
assert headers['Accept'] == 'application/vnd.github+json'
|
|
172
|
+
assert headers['X-GitHub-Api-Version'] == '2022-11-28'
|
|
173
|
+
|
|
174
|
+
def test_netrc_does_not_override_token(self, tmp_path, monkeypatch):
|
|
175
|
+
netrc = tmp_path / "netrc"
|
|
176
|
+
netrc.write_text("machine api.github.com login bob password other\n")
|
|
177
|
+
monkeypatch.setenv('NETRC', str(netrc))
|
|
178
|
+
headers = self.prepared_headers(app.GitHubPatches("tok"))
|
|
179
|
+
assert headers['Authorization'] == 'Bearer tok'
|
|
180
|
+
|
|
181
|
+
def test_api_requests_retried(self):
|
|
182
|
+
retry = app.GitHubPatches("tok").session.get_adapter(
|
|
183
|
+
'https://api.github.com/x'
|
|
184
|
+
).max_retries
|
|
185
|
+
assert retry.total == 3
|
|
186
|
+
assert {502, 503, 504, 429} <= retry.status_forcelist
|
|
187
|
+
assert retry.allowed_methods == {'GET'}
|
|
188
|
+
assert retry.raise_on_status is False
|
|
189
|
+
|
|
190
|
+
def test_ssh_default_true(self, config_file):
|
|
191
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
192
|
+
assert gp.use_ssh is True
|
|
193
|
+
|
|
194
|
+
def test_ssh_disabled_via_config(self, tmp_path):
|
|
195
|
+
gp = app.GitHubPatches.from_config_file(
|
|
196
|
+
write_ini(tmp_path, "token = x", "ssh = false")
|
|
197
|
+
)
|
|
198
|
+
assert gp.use_ssh is False
|
|
199
|
+
|
|
200
|
+
def test_invalid_ssh_value_raises(self, tmp_path):
|
|
201
|
+
with pytest.raises(app.PatchError,
|
|
202
|
+
match='Invalid value for "ssh".*"maybe"'):
|
|
203
|
+
app.GitHubPatches.from_config_file(
|
|
204
|
+
write_ini(tmp_path, "token = x", "ssh = maybe")
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ===================================================================
|
|
209
|
+
# GitHubPatches — PR Reference Parsing
|
|
210
|
+
# ===================================================================
|
|
211
|
+
|
|
212
|
+
class TestParsePrReference:
|
|
213
|
+
|
|
214
|
+
def test_full_format(self, config_file):
|
|
215
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
216
|
+
assert gp.parse_pr_reference("myorg/myrepo#123") == (
|
|
217
|
+
"myorg", "myrepo", "123"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def test_short_format(self, config_file):
|
|
221
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
222
|
+
assert gp.parse_pr_reference("tlm#456") == ("couchbase", "tlm", "456")
|
|
223
|
+
|
|
224
|
+
def test_short_format_no_org_raises(self, config_file_no_org):
|
|
225
|
+
gp = app.GitHubPatches.from_config_file(config_file_no_org)
|
|
226
|
+
with pytest.raises(app.PatchError, match="needs an org"):
|
|
227
|
+
gp.parse_pr_reference("tlm#456")
|
|
228
|
+
|
|
229
|
+
@pytest.mark.parametrize('ref', ["not-valid", "org/repo#", "123"])
|
|
230
|
+
def test_invalid_format_raises(self, config_file, ref):
|
|
231
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
232
|
+
with pytest.raises(app.PatchError) as e:
|
|
233
|
+
gp.parse_pr_reference(ref)
|
|
234
|
+
assert f'Invalid PR reference: "{ref}"' in str(e.value)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ===================================================================
|
|
238
|
+
# GitHubPatches — API Layer
|
|
239
|
+
# ===================================================================
|
|
240
|
+
|
|
241
|
+
def ok_response(data, links=None):
|
|
242
|
+
response = MagicMock()
|
|
243
|
+
response.json.return_value = data
|
|
244
|
+
response.links = links or {}
|
|
245
|
+
response.raise_for_status.return_value = None
|
|
246
|
+
return response
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def api_response(status, reason, body=b'not json', headers=None,
|
|
250
|
+
url='https://api.github.com/x'):
|
|
251
|
+
response = requests.models.Response()
|
|
252
|
+
response.status_code = status
|
|
253
|
+
response.reason = reason
|
|
254
|
+
response.url = url
|
|
255
|
+
response.headers.update(headers or {})
|
|
256
|
+
response._content = body
|
|
257
|
+
return response
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def http_error_response(status, reason, message=None, headers=None):
|
|
261
|
+
body = f'{{"message": "{message}"}}'.encode() if message else b'{}'
|
|
262
|
+
return api_response(status, reason, body, headers)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestApiRequests:
|
|
266
|
+
|
|
267
|
+
def test_get_returns_json_from_full_url(self, config_file):
|
|
268
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
269
|
+
with patch.object(gp.session, 'get',
|
|
270
|
+
return_value=ok_response({"id": 1})) as mock_get:
|
|
271
|
+
result = gp._api_get('/repos/org/repo/pulls/42')
|
|
272
|
+
assert result == {"id": 1}
|
|
273
|
+
mock_get.assert_called_once_with(
|
|
274
|
+
'https://api.github.com/repos/org/repo/pulls/42', timeout=30
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def test_http_error_without_response(self, config_file):
|
|
278
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
279
|
+
response = MagicMock()
|
|
280
|
+
response.raise_for_status.side_effect = (
|
|
281
|
+
requests.exceptions.HTTPError("404 Not Found")
|
|
282
|
+
)
|
|
283
|
+
with patch.object(gp.session, 'get', return_value=response):
|
|
284
|
+
with pytest.raises(app.PatchError,
|
|
285
|
+
match="^GitHub API error: 404 Not Found$"):
|
|
286
|
+
gp._api_get('/x')
|
|
287
|
+
|
|
288
|
+
def test_connection_error(self, config_file):
|
|
289
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
290
|
+
with patch.object(gp.session, 'get',
|
|
291
|
+
side_effect=requests.exceptions.ConnectionError(
|
|
292
|
+
"Name or service not known")):
|
|
293
|
+
with pytest.raises(app.PatchError,
|
|
294
|
+
match="Name or service not known"):
|
|
295
|
+
gp._api_get('/x')
|
|
296
|
+
|
|
297
|
+
def test_timeout(self, config_file):
|
|
298
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
299
|
+
with patch.object(gp.session, 'get',
|
|
300
|
+
side_effect=requests.exceptions.Timeout("slow")):
|
|
301
|
+
with pytest.raises(app.PatchError, match="slow"):
|
|
302
|
+
gp._api_get_paginated('/x')
|
|
303
|
+
|
|
304
|
+
def test_invalid_json(self, config_file):
|
|
305
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
306
|
+
response = api_response(200, 'OK')
|
|
307
|
+
with patch.object(gp.session, 'get', return_value=response):
|
|
308
|
+
with pytest.raises(app.PatchError, match="invalid JSON"):
|
|
309
|
+
gp._api_get('/x')
|
|
310
|
+
|
|
311
|
+
@pytest.mark.parametrize('status, reason, message, headers, expected', [
|
|
312
|
+
(401, 'Unauthorized', 'Bad credentials', {},
|
|
313
|
+
"401 Unauthorized for https://api.github.com/x (Bad credentials)"
|
|
314
|
+
" - check the token"),
|
|
315
|
+
(404, 'Not Found', 'Not Found', {}, "can access the repository"),
|
|
316
|
+
(403, 'Forbidden', 'Resource protected by SAML', {},
|
|
317
|
+
"(Resource protected by SAML) - check your token has access"),
|
|
318
|
+
(403, 'Forbidden', 'API rate limit exceeded',
|
|
319
|
+
{'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': '0'},
|
|
320
|
+
"rate limit exhausted, resets at "),
|
|
321
|
+
(403, 'Forbidden', 'API rate limit exceeded',
|
|
322
|
+
{'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': 'soon'},
|
|
323
|
+
"rate limit exhausted, X-RateLimit-Reset: soon"),
|
|
324
|
+
(403, 'Forbidden', 'secondary rate limit', {'Retry-After': '60'},
|
|
325
|
+
"secondary rate limit hit, retry after 60 seconds"),
|
|
326
|
+
(500, 'Server Error', None, {},
|
|
327
|
+
"GitHub API error: 500 Server Error for https://api.github.com/x"),
|
|
328
|
+
])
|
|
329
|
+
def test_http_error_message(
|
|
330
|
+
self, config_file, status, reason, message, headers, expected
|
|
331
|
+
):
|
|
332
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
333
|
+
response = http_error_response(status, reason, message, headers)
|
|
334
|
+
with patch.object(gp.session, 'get', return_value=response):
|
|
335
|
+
with pytest.raises(app.PatchError) as e:
|
|
336
|
+
gp._api_get('/x')
|
|
337
|
+
assert expected in str(e.value)
|
|
338
|
+
|
|
339
|
+
def test_rate_limit_without_reset_header(self, config_file):
|
|
340
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
341
|
+
response = http_error_response(
|
|
342
|
+
403, 'Forbidden', 'API rate limit exceeded',
|
|
343
|
+
{'X-RateLimit-Remaining': '0'}
|
|
344
|
+
)
|
|
345
|
+
with patch.object(gp.session, 'get', return_value=response):
|
|
346
|
+
with pytest.raises(app.PatchError) as e:
|
|
347
|
+
gp._api_get('/x')
|
|
348
|
+
assert str(e.value).endswith("rate limit exhausted")
|
|
349
|
+
|
|
350
|
+
def test_non_json_error_body(self, config_file):
|
|
351
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
352
|
+
response = api_response(502, 'Bad Gateway', b'<html>oops</html>')
|
|
353
|
+
with patch.object(gp.session, 'get', return_value=response):
|
|
354
|
+
with pytest.raises(app.PatchError) as e:
|
|
355
|
+
gp._api_get('/x')
|
|
356
|
+
assert str(e.value) == (
|
|
357
|
+
"GitHub API error: 502 Bad Gateway for https://api.github.com/x"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def test_paginated_follows_link_header(self, config_file):
|
|
361
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
362
|
+
page1 = api_response(200, 'OK', b'[{"id": 1}, {"id": 2}]', {
|
|
363
|
+
'Link': '<https://api.github.com/p2>; rel="next", '
|
|
364
|
+
'<https://api.github.com/p2>; rel="last"'
|
|
365
|
+
})
|
|
366
|
+
page2 = api_response(200, 'OK', b'[{"id": 3}]')
|
|
367
|
+
with patch.object(gp.session, 'get',
|
|
368
|
+
side_effect=[page1, page2]) as mock_get:
|
|
369
|
+
results = gp._api_get_paginated('/endpoint')
|
|
370
|
+
assert results == [{"id": 1}, {"id": 2}, {"id": 3}]
|
|
371
|
+
assert [c.args[0] for c in mock_get.call_args_list] == [
|
|
372
|
+
"https://api.github.com/endpoint", "https://api.github.com/p2"
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
@pytest.mark.parametrize('next_url', [
|
|
376
|
+
"https://evil.example/p2", "https://api.github.com.evil.example/p2",
|
|
377
|
+
"http://api.github.com/p2",
|
|
378
|
+
])
|
|
379
|
+
def test_paginated_refuses_off_host_link(self, config_file, next_url):
|
|
380
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
381
|
+
page1 = ok_response([{"id": 1}], {"next": {"url": next_url}})
|
|
382
|
+
with patch.object(gp.session, 'get',
|
|
383
|
+
side_effect=[page1, ok_response([])]) as mock_get:
|
|
384
|
+
with pytest.raises(app.PatchError, match="refusing to follow"):
|
|
385
|
+
gp._api_get_paginated('/endpoint')
|
|
386
|
+
mock_get.assert_called_once()
|
|
387
|
+
|
|
388
|
+
def test_paginated_non_list_response(self, config_file):
|
|
389
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
390
|
+
with patch.object(gp.session, 'get',
|
|
391
|
+
return_value=ok_response({"message": "odd"})):
|
|
392
|
+
with pytest.raises(app.PatchError, match="expected a list"):
|
|
393
|
+
gp._api_get_paginated('/x')
|
|
394
|
+
|
|
395
|
+
def test_malformed_pr_data(self, config_file):
|
|
396
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
397
|
+
with patch.object(gp, '_api_get', return_value={"number": 1}):
|
|
398
|
+
with pytest.raises(app.PatchError, match="unexpected PR data"):
|
|
399
|
+
gp.get_pr("couchbase", "tlm", "1")
|
|
400
|
+
|
|
401
|
+
def test_malformed_pr_listing(self, config_file):
|
|
402
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
403
|
+
with patch.object(gp, '_api_get_paginated',
|
|
404
|
+
return_value=[{"number": 1}]):
|
|
405
|
+
with pytest.raises(app.PatchError, match="unexpected PR data"):
|
|
406
|
+
gp.get_open_prs_by_label("couchbase", "tlm", "x")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class TestGetPr:
|
|
410
|
+
|
|
411
|
+
def test_fetches_pr_from_endpoint(self, config_file):
|
|
412
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
413
|
+
with patch.object(gp, '_api_get',
|
|
414
|
+
return_value=make_pr_api_response(42, "myrepo")
|
|
415
|
+
) as mock_api:
|
|
416
|
+
pr = gp.get_pr("myorg", "myrepo", "42")
|
|
417
|
+
mock_api.assert_called_once_with('/repos/myorg/myrepo/pulls/42')
|
|
418
|
+
assert pr.key == "couchbase/myrepo#42"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class TestGetOpenPrsByLabel:
|
|
422
|
+
|
|
423
|
+
def test_filters_open_prs_by_label(self, config_file):
|
|
424
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
425
|
+
listing = [
|
|
426
|
+
make_pr_api_response(1, "tlm", labels=["backport"]),
|
|
427
|
+
make_pr_api_response(2, "tlm", labels=["feature"]),
|
|
428
|
+
make_pr_api_response(3, "tlm", labels=["backport", "urgent"]),
|
|
429
|
+
]
|
|
430
|
+
with patch.object(gp, '_api_get_paginated',
|
|
431
|
+
return_value=listing) as mock_api:
|
|
432
|
+
prs = gp.get_open_prs_by_label("couchbase", "tlm", "backport")
|
|
433
|
+
mock_api.assert_called_once_with(
|
|
434
|
+
'/repos/couchbase/tlm/pulls?state=open&per_page=100'
|
|
435
|
+
)
|
|
436
|
+
assert sorted(prs) == ["couchbase/tlm#1", "couchbase/tlm#3"]
|
|
437
|
+
|
|
438
|
+
@pytest.mark.parametrize('use_ssh, url_field', [
|
|
439
|
+
(True, 'ssh_url'), (False, 'clone_url'),
|
|
440
|
+
])
|
|
441
|
+
def test_fetch_url_follows_ssh_setting(
|
|
442
|
+
self, config_file, use_ssh, url_field
|
|
443
|
+
):
|
|
444
|
+
gp = app.GitHubPatches.from_config_file(config_file, use_ssh=use_ssh)
|
|
445
|
+
data = make_pr_api_response(1, "tlm", labels=["x"])
|
|
446
|
+
with patch.object(gp, '_api_get_paginated', return_value=[data]):
|
|
447
|
+
pr, = gp.get_open_prs_by_label("couchbase", "tlm", "x").values()
|
|
448
|
+
assert pr.fetch_url == data['base']['repo'][url_field]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ===================================================================
|
|
452
|
+
# Manifest Resolution
|
|
453
|
+
# ===================================================================
|
|
454
|
+
|
|
455
|
+
class TestManifestResolution:
|
|
456
|
+
|
|
457
|
+
@pytest.mark.parametrize('project, expected', [
|
|
458
|
+
("server-ui", ("server-ui", "main")),
|
|
459
|
+
("couchbase-cli", ("couchbase-cli-tools", "master")),
|
|
460
|
+
("cbgt", ("cbgt", "master")), # inherits <default revision>
|
|
461
|
+
("stellar-gateway",
|
|
462
|
+
("stellar-gateway", "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")),
|
|
463
|
+
("eventing",
|
|
464
|
+
("goproj/src/github.com/couchbase/eventing", "master")),
|
|
465
|
+
("tlm", ("tlm", "master")),
|
|
466
|
+
("ns_server", ("ns_server", "master")),
|
|
467
|
+
("build", ("build", "master")),
|
|
468
|
+
("geocouch",
|
|
469
|
+
("geocouch", "3e8040787139e3c67766ce50398860c185737b8a")),
|
|
470
|
+
("nonexistent", (None, None)),
|
|
471
|
+
])
|
|
472
|
+
def test_lookup(self, gp_with_manifest, project, expected):
|
|
473
|
+
assert gp_with_manifest.get_project_path_and_branch_from_manifest(
|
|
474
|
+
project
|
|
475
|
+
) == expected
|
|
476
|
+
|
|
477
|
+
@pytest.mark.parametrize('project, branch', [
|
|
478
|
+
("own", "own-rev"),
|
|
479
|
+
("via-remote", "remote-rev"),
|
|
480
|
+
("other-remote", "default-rev"),
|
|
481
|
+
("full-ref", "trinity"),
|
|
482
|
+
("tag", "refs/tags/v1"),
|
|
483
|
+
])
|
|
484
|
+
def test_revision_precedence(self, config_file, project, branch):
|
|
485
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
486
|
+
gp.manifest = app.EleTree.fromstring(
|
|
487
|
+
'<manifest>'
|
|
488
|
+
'<remote name="r" revision="remote-rev"/><remote name="s"/>'
|
|
489
|
+
'<default remote="r" revision="default-rev"/>'
|
|
490
|
+
'<project name="own" revision="own-rev"/>'
|
|
491
|
+
'<project name="via-remote"/>'
|
|
492
|
+
'<project name="other-remote" remote="s"/>'
|
|
493
|
+
'<project name="full-ref" revision="refs/heads/trinity"/>'
|
|
494
|
+
'<project name="tag" revision="refs/tags/v1"/>'
|
|
495
|
+
'</manifest>'
|
|
496
|
+
)
|
|
497
|
+
assert gp.get_project_path_and_branch_from_manifest(project) == (
|
|
498
|
+
project, branch
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def test_no_revision_anywhere(self, config_file):
|
|
502
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
503
|
+
gp.manifest = app.EleTree.fromstring(
|
|
504
|
+
'<manifest><project name="p"/></manifest>'
|
|
505
|
+
)
|
|
506
|
+
assert gp.get_project_path_and_branch_from_manifest("p") == (
|
|
507
|
+
"p", None
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def test_stale_manifest_read_from_repo_tool(self, config_file):
|
|
511
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
512
|
+
manifest_xml = (
|
|
513
|
+
b'<manifest>'
|
|
514
|
+
b'<default revision="main"/>'
|
|
515
|
+
b'<project name="foo" revision="dev"/>'
|
|
516
|
+
b'</manifest>'
|
|
517
|
+
)
|
|
518
|
+
with patch('patch_via_github.scripts.main.which',
|
|
519
|
+
return_value='/usr/bin/repo'), \
|
|
520
|
+
patch('subprocess.check_output',
|
|
521
|
+
return_value=manifest_xml) as mock_run:
|
|
522
|
+
assert gp.get_project_path_and_branch_from_manifest("foo") == (
|
|
523
|
+
"foo", "dev"
|
|
524
|
+
)
|
|
525
|
+
gp.get_project_path_and_branch_from_manifest("foo")
|
|
526
|
+
mock_run.assert_called_once_with(
|
|
527
|
+
['/usr/bin/repo', 'manifest'], timeout=app.REPO_MANIFEST_TIMEOUT
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def test_missing_repo_tool_raises(self, config_file):
|
|
531
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
532
|
+
with patch('patch_via_github.scripts.main.which', return_value=None):
|
|
533
|
+
with pytest.raises(app.PatchError, match="'repo' tool"):
|
|
534
|
+
gp.get_project_path_and_branch_from_manifest("foo")
|
|
535
|
+
|
|
536
|
+
def test_repo_tool_not_runnable_raises(self, config_file):
|
|
537
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
538
|
+
with patch('patch_via_github.scripts.main.which',
|
|
539
|
+
return_value='/nonexistent/repo'):
|
|
540
|
+
with pytest.raises(app.PatchError,
|
|
541
|
+
match="Could not run 'repo manifest'"):
|
|
542
|
+
gp.get_project_path_and_branch_from_manifest("foo")
|
|
543
|
+
|
|
544
|
+
def test_repo_manifest_failure_raises(self, config_file):
|
|
545
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
546
|
+
with patch('patch_via_github.scripts.main.which',
|
|
547
|
+
return_value='/usr/bin/repo'), \
|
|
548
|
+
patch('subprocess.check_output',
|
|
549
|
+
side_effect=subprocess.CalledProcessError(1, 'repo')):
|
|
550
|
+
with pytest.raises(app.PatchError, match="repo manifest"):
|
|
551
|
+
gp.get_project_path_and_branch_from_manifest("foo")
|
|
552
|
+
|
|
553
|
+
@pytest.mark.parametrize('outcome, expected', [
|
|
554
|
+
({'return_value': b'<manifest'}, "Could not parse 'repo manifest'"),
|
|
555
|
+
({'side_effect': subprocess.TimeoutExpired('repo', 300)},
|
|
556
|
+
"'repo manifest' timed out after 300s"),
|
|
557
|
+
])
|
|
558
|
+
def test_repo_manifest_bad_output_or_timeout_raises(
|
|
559
|
+
self, config_file, outcome, expected
|
|
560
|
+
):
|
|
561
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
562
|
+
with patch('patch_via_github.scripts.main.which',
|
|
563
|
+
return_value='/usr/bin/repo'), \
|
|
564
|
+
patch('subprocess.check_output', **outcome):
|
|
565
|
+
with pytest.raises(app.PatchError, match=expected):
|
|
566
|
+
gp.get_project_path_and_branch_from_manifest("foo")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ===================================================================
|
|
570
|
+
# resolve_prs — PR Orchestration & Branch Filtering
|
|
571
|
+
# ===================================================================
|
|
572
|
+
|
|
573
|
+
class TestResolvePrsByPr:
|
|
574
|
+
|
|
575
|
+
def test_pr_not_found_raises(self, gp_with_manifest):
|
|
576
|
+
gp = gp_with_manifest
|
|
577
|
+
with patch.object(
|
|
578
|
+
gp, '_api_get',
|
|
579
|
+
side_effect=app.PatchError("GitHub API error: 404")
|
|
580
|
+
):
|
|
581
|
+
with pytest.raises(
|
|
582
|
+
app.PatchError,
|
|
583
|
+
match="Failed to fetch couchbase/server-ui#999: "
|
|
584
|
+
"GitHub API error: 404"
|
|
585
|
+
):
|
|
586
|
+
gp.resolve_prs(["couchbase/server-ui#999"], 'pr')
|
|
587
|
+
|
|
588
|
+
def test_every_ref_validated_before_fetching(self, gp_with_manifest):
|
|
589
|
+
gp = gp_with_manifest
|
|
590
|
+
with patch.object(gp, '_api_get') as mock_api:
|
|
591
|
+
with pytest.raises(app.PatchError, match="Invalid PR reference"):
|
|
592
|
+
gp.resolve_prs(["couchbase/server-ui#1", "bad"], 'pr')
|
|
593
|
+
mock_api.assert_not_called()
|
|
594
|
+
|
|
595
|
+
def test_open_pr_matching_branch_included(self, gp_with_manifest):
|
|
596
|
+
gp = gp_with_manifest
|
|
597
|
+
pr_data = make_pr_api_response(10, "server-ui", base_ref="main")
|
|
598
|
+
with patch.object(gp, '_api_get', return_value=pr_data):
|
|
599
|
+
prs = gp.resolve_prs(["couchbase/server-ui#10"], 'pr')
|
|
600
|
+
assert list(prs) == ["couchbase/server-ui#10"]
|
|
601
|
+
|
|
602
|
+
@pytest.mark.parametrize('state, base_ref', [
|
|
603
|
+
("closed", "main"), ("open", "develop"),
|
|
604
|
+
])
|
|
605
|
+
def test_requested_pr_kept_whatever_state_or_branch(
|
|
606
|
+
self, gp_with_manifest, state, base_ref
|
|
607
|
+
):
|
|
608
|
+
gp = gp_with_manifest
|
|
609
|
+
pr_data = make_pr_api_response(
|
|
610
|
+
10, "server-ui", base_ref=base_ref, state=state
|
|
611
|
+
)
|
|
612
|
+
with patch.object(gp, '_api_get', return_value=pr_data):
|
|
613
|
+
prs = gp.resolve_prs(["couchbase/server-ui#10"], 'pr')
|
|
614
|
+
assert list(prs) == ["couchbase/server-ui#10"]
|
|
615
|
+
|
|
616
|
+
def test_same_number_in_different_repos_both_kept(
|
|
617
|
+
self, gp_with_manifest
|
|
618
|
+
):
|
|
619
|
+
gp = gp_with_manifest
|
|
620
|
+
pr1 = make_pr_api_response(10, "server-ui", base_ref="main")
|
|
621
|
+
pr2 = make_pr_api_response(10, "couchbase-cli", base_ref="master")
|
|
622
|
+
|
|
623
|
+
def mock_api_get(endpoint):
|
|
624
|
+
return pr1 if "server-ui" in endpoint else pr2
|
|
625
|
+
|
|
626
|
+
with patch.object(gp, '_api_get', side_effect=mock_api_get):
|
|
627
|
+
prs = gp.resolve_prs(
|
|
628
|
+
["couchbase/server-ui#10", "couchbase/couchbase-cli#10"],
|
|
629
|
+
'pr'
|
|
630
|
+
)
|
|
631
|
+
assert set(prs) == {
|
|
632
|
+
"couchbase/server-ui#10", "couchbase/couchbase-cli#10"
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
def test_request_order_kept_duplicates_at_first_position(
|
|
636
|
+
self, gp_with_manifest
|
|
637
|
+
):
|
|
638
|
+
gp = gp_with_manifest
|
|
639
|
+
|
|
640
|
+
def api_get(endpoint):
|
|
641
|
+
repo, number = endpoint.split('/')[3], endpoint.split('/')[-1]
|
|
642
|
+
return make_pr_api_response(int(number), repo)
|
|
643
|
+
|
|
644
|
+
with patch.object(gp, '_api_get', side_effect=api_get):
|
|
645
|
+
prs = gp.resolve_prs([
|
|
646
|
+
"couchbase/tlm#30", "couchbase/server-ui#2",
|
|
647
|
+
"couchbase/tlm#4", "couchbase/server-ui#2",
|
|
648
|
+
], 'pr')
|
|
649
|
+
assert list(prs) == [
|
|
650
|
+
"couchbase/tlm#30", "couchbase/server-ui#2", "couchbase/tlm#4"
|
|
651
|
+
]
|
|
652
|
+
|
|
653
|
+
def test_renamed_repo_keyed_by_canonical_name(self, gp_with_manifest):
|
|
654
|
+
gp = gp_with_manifest
|
|
655
|
+
pr_data = make_pr_api_response(10, "server-ui", base_ref="main")
|
|
656
|
+
with patch.object(gp, '_api_get', return_value=pr_data):
|
|
657
|
+
prs = gp.resolve_prs(
|
|
658
|
+
["couchbase/old-name#10", "couchbase/server-ui#10"], 'pr'
|
|
659
|
+
)
|
|
660
|
+
assert list(prs) == ["couchbase/server-ui#10"]
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
class TestResolvePrsByLabel:
|
|
664
|
+
|
|
665
|
+
def test_valid_label_returns_matching_prs(self, gp_with_manifest):
|
|
666
|
+
gp = gp_with_manifest
|
|
667
|
+
listing = [
|
|
668
|
+
make_pr_api_response(
|
|
669
|
+
1, "server-ui", base_ref="main", labels=["backport"]
|
|
670
|
+
),
|
|
671
|
+
make_pr_api_response(
|
|
672
|
+
2, "server-ui", base_ref="main", labels=["feature"]
|
|
673
|
+
),
|
|
674
|
+
]
|
|
675
|
+
with patch.object(gp, '_api_get_paginated', return_value=listing):
|
|
676
|
+
prs = gp.resolve_prs(["couchbase/server-ui:backport"], 'label')
|
|
677
|
+
assert list(prs) == ["couchbase/server-ui#1"]
|
|
678
|
+
|
|
679
|
+
def test_label_order_then_pr_number_order(self, gp_with_manifest):
|
|
680
|
+
gp = gp_with_manifest
|
|
681
|
+
listings = {
|
|
682
|
+
"server-ui": [make_pr_api_response(n, "server-ui", base_ref="main",
|
|
683
|
+
labels=["x"])
|
|
684
|
+
for n in (30, 4, 12)],
|
|
685
|
+
"couchbase-cli": [make_pr_api_response(n, "couchbase-cli",
|
|
686
|
+
labels=["x"])
|
|
687
|
+
for n in (7, 1)],
|
|
688
|
+
}
|
|
689
|
+
with patch.object(gp, '_api_get_paginated',
|
|
690
|
+
side_effect=lambda e: listings[e.split('/')[3]]):
|
|
691
|
+
prs = gp.resolve_prs(
|
|
692
|
+
["couchbase/server-ui:x", "couchbase/couchbase-cli:x",
|
|
693
|
+
"couchbase/server-ui:x"], 'label'
|
|
694
|
+
)
|
|
695
|
+
assert list(prs) == [
|
|
696
|
+
"couchbase/server-ui#4", "couchbase/server-ui#12",
|
|
697
|
+
"couchbase/server-ui#30", "couchbase/couchbase-cli#1",
|
|
698
|
+
"couchbase/couchbase-cli#7",
|
|
699
|
+
]
|
|
700
|
+
|
|
701
|
+
def test_invalid_label_format_raises(self, gp_with_manifest):
|
|
702
|
+
gp = gp_with_manifest
|
|
703
|
+
with patch.object(gp, '_api_get_paginated') as mock_api:
|
|
704
|
+
with pytest.raises(
|
|
705
|
+
app.PatchError,
|
|
706
|
+
match='Invalid label reference: "invalid-label-format"'
|
|
707
|
+
):
|
|
708
|
+
gp.resolve_prs(
|
|
709
|
+
["couchbase/server-ui:ok", "invalid-label-format"],
|
|
710
|
+
'label'
|
|
711
|
+
)
|
|
712
|
+
mock_api.assert_not_called()
|
|
713
|
+
|
|
714
|
+
def test_same_number_in_different_repos_both_kept(
|
|
715
|
+
self, gp_with_manifest
|
|
716
|
+
):
|
|
717
|
+
gp = gp_with_manifest
|
|
718
|
+
|
|
719
|
+
def mock_paginated(endpoint):
|
|
720
|
+
repo = "server-ui" if "server-ui" in endpoint else "couchbase-cli"
|
|
721
|
+
branch = "main" if repo == "server-ui" else "master"
|
|
722
|
+
return [make_pr_api_response(
|
|
723
|
+
5, repo, base_ref=branch, labels=["backport"]
|
|
724
|
+
)]
|
|
725
|
+
|
|
726
|
+
with patch.object(gp, '_api_get_paginated',
|
|
727
|
+
side_effect=mock_paginated):
|
|
728
|
+
prs = gp.resolve_prs([
|
|
729
|
+
"couchbase/server-ui:backport",
|
|
730
|
+
"couchbase/couchbase-cli:backport",
|
|
731
|
+
], 'label')
|
|
732
|
+
assert set(prs) == {
|
|
733
|
+
"couchbase/server-ui#5", "couchbase/couchbase-cli#5"
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
def test_api_error_raises(self, gp_with_manifest):
|
|
737
|
+
gp = gp_with_manifest
|
|
738
|
+
with patch.object(gp, '_api_get_paginated',
|
|
739
|
+
side_effect=app.PatchError("boom")):
|
|
740
|
+
with pytest.raises(
|
|
741
|
+
app.PatchError,
|
|
742
|
+
match="Failed to fetch PRs for couchbase/server-ui:backport: "
|
|
743
|
+
"boom"
|
|
744
|
+
):
|
|
745
|
+
gp.resolve_prs(["couchbase/server-ui:backport"], 'label')
|
|
746
|
+
|
|
747
|
+
def test_label_branch_mismatch_filtered(self, gp_with_manifest):
|
|
748
|
+
gp = gp_with_manifest
|
|
749
|
+
listing = [make_pr_api_response(
|
|
750
|
+
1, "server-ui", base_ref="develop", labels=["backport"]
|
|
751
|
+
)]
|
|
752
|
+
with patch.object(gp, '_api_get_paginated', return_value=listing):
|
|
753
|
+
prs = gp.resolve_prs(["couchbase/server-ui:backport"], 'label')
|
|
754
|
+
assert prs == {}
|
|
755
|
+
|
|
756
|
+
def test_sha_locked_manifest_skips_branch_filter(self, gp_with_manifest):
|
|
757
|
+
gp = gp_with_manifest
|
|
758
|
+
listing = [make_pr_api_response(
|
|
759
|
+
70, "stellar-gateway", base_ref="feature-x", labels=["x"]
|
|
760
|
+
)]
|
|
761
|
+
with patch.object(gp, '_api_get_paginated', return_value=listing):
|
|
762
|
+
prs = gp.resolve_prs(["couchbase/stellar-gateway:x"], 'label')
|
|
763
|
+
assert list(prs) == ["couchbase/stellar-gateway#70"]
|
|
764
|
+
|
|
765
|
+
def test_project_not_in_manifest_kept(self, gp_with_manifest):
|
|
766
|
+
gp = gp_with_manifest
|
|
767
|
+
listing = [make_pr_api_response(
|
|
768
|
+
1, "nonexistent-repo", base_ref="develop", labels=["backport"]
|
|
769
|
+
)]
|
|
770
|
+
with patch.object(gp, '_api_get_paginated', return_value=listing):
|
|
771
|
+
prs = gp.resolve_prs(
|
|
772
|
+
["couchbase/nonexistent-repo:backport"], 'label'
|
|
773
|
+
)
|
|
774
|
+
assert list(prs) == ["couchbase/nonexistent-repo#1"]
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
# ===================================================================
|
|
778
|
+
# check_requested_prs_applied
|
|
779
|
+
# ===================================================================
|
|
780
|
+
|
|
781
|
+
class TestCheckRequestedPrsApplied:
|
|
782
|
+
|
|
783
|
+
@pytest.fixture(autouse=True)
|
|
784
|
+
def info_logs(self, caplog):
|
|
785
|
+
caplog.set_level(logging.INFO, logger='patch_via_github')
|
|
786
|
+
|
|
787
|
+
@pytest.fixture
|
|
788
|
+
def gp(self, config_file):
|
|
789
|
+
return app.GitHubPatches.from_config_file(config_file)
|
|
790
|
+
|
|
791
|
+
def test_all_applied_succeeds(self, gp, caplog):
|
|
792
|
+
gp.applied_prs = ["couchbase/tlm#42"]
|
|
793
|
+
gp.check_requested_prs_applied('pr', [], ["couchbase/tlm#42"])
|
|
794
|
+
assert "All explicitly-requested PRs applied" in caplog.text
|
|
795
|
+
|
|
796
|
+
def test_missing_pr_raises(self, gp):
|
|
797
|
+
with pytest.raises(
|
|
798
|
+
app.PatchError,
|
|
799
|
+
match=r"Failed to apply all explicitly-requested PRs! "
|
|
800
|
+
r"Requested: \['couchbase/tlm#42'\] Applied: \[\]"
|
|
801
|
+
):
|
|
802
|
+
gp.check_requested_prs_applied('pr', [], ["couchbase/tlm#42"])
|
|
803
|
+
|
|
804
|
+
def test_same_number_other_repo_does_not_count(self, gp):
|
|
805
|
+
gp.applied_prs = ["couchbase/kv_engine#123"]
|
|
806
|
+
with pytest.raises(app.PatchError):
|
|
807
|
+
gp.check_requested_prs_applied('pr', [], [
|
|
808
|
+
"couchbase/kv_engine#123", "couchbase/ns_server#123"
|
|
809
|
+
])
|
|
810
|
+
|
|
811
|
+
def test_nothing_requested_or_applied_is_silent(self, gp, caplog):
|
|
812
|
+
gp.check_requested_prs_applied('pr', [], [])
|
|
813
|
+
assert caplog.text == ""
|
|
814
|
+
|
|
815
|
+
def test_label_mode_reporting(self, gp, caplog):
|
|
816
|
+
gp.applied_prs = ["couchbase/tlm#10", "couchbase/tlm#20"]
|
|
817
|
+
gp.check_requested_prs_applied(
|
|
818
|
+
'label', ["couchbase/tlm:backport"], ["couchbase/tlm#30"]
|
|
819
|
+
)
|
|
820
|
+
assert (
|
|
821
|
+
"Applied PRs from label(s) 'couchbase/tlm:backport': "
|
|
822
|
+
"['couchbase/tlm#10', 'couchbase/tlm#20']"
|
|
823
|
+
) in caplog.text
|
|
824
|
+
|
|
825
|
+
def test_label_mode_nothing_applied_is_silent(self, gp, caplog):
|
|
826
|
+
gp.check_requested_prs_applied('label', ["couchbase/tlm:x"], [])
|
|
827
|
+
assert caplog.text == ""
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# ===================================================================
|
|
831
|
+
# load_pr_commits
|
|
832
|
+
# ===================================================================
|
|
833
|
+
|
|
834
|
+
class TestLoadPrCommits:
|
|
835
|
+
|
|
836
|
+
@staticmethod
|
|
837
|
+
def pr(head_sha, commit_count):
|
|
838
|
+
data = make_pr_api_response(1, "tlm", head_sha=head_sha)
|
|
839
|
+
data['commits'] = commit_count
|
|
840
|
+
return app.GitHubPR(data)
|
|
841
|
+
|
|
842
|
+
def test_linear_history_oldest_first(self, config_file):
|
|
843
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
844
|
+
pr = self.pr("c3", 3)
|
|
845
|
+
data = [commit("c1", ["base"]), commit("c2", ["c1"]),
|
|
846
|
+
commit("c3", ["c2"])]
|
|
847
|
+
with patch.object(gp, '_api_get_paginated',
|
|
848
|
+
return_value=data) as mock_api:
|
|
849
|
+
gp.load_pr_commits(pr)
|
|
850
|
+
assert pr.commits == ["c1", "c2", "c3"]
|
|
851
|
+
mock_api.assert_called_once_with(
|
|
852
|
+
'/repos/couchbase/tlm/pulls/1/commits?per_page=100'
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
def test_order_of_api_listing_does_not_matter(self, config_file):
|
|
856
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
857
|
+
pr = self.pr("c3", 3)
|
|
858
|
+
data = [commit("c2", ["c1"]), commit("c3", ["c2"]),
|
|
859
|
+
commit("c1", ["base"])]
|
|
860
|
+
with patch.object(gp, '_api_get_paginated', return_value=data):
|
|
861
|
+
gp.load_pr_commits(pr)
|
|
862
|
+
assert pr.commits == ["c1", "c2", "c3"]
|
|
863
|
+
|
|
864
|
+
def test_merge_commit_rejected(self, config_file):
|
|
865
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
866
|
+
pr = self.pr("c2", 2)
|
|
867
|
+
data = [commit("c1", ["base"]), commit("c2", ["c1", "other"])]
|
|
868
|
+
with patch.object(gp, '_api_get_paginated', return_value=data):
|
|
869
|
+
with pytest.raises(app.PatchError, match="merge commit"):
|
|
870
|
+
gp.load_pr_commits(pr)
|
|
871
|
+
|
|
872
|
+
def test_commit_outside_head_ancestry_rejected(self, config_file):
|
|
873
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
874
|
+
pr = self.pr("c2", 3)
|
|
875
|
+
data = [commit("c1", ["base"]), commit("c2", ["c1"]),
|
|
876
|
+
commit("stray", ["base"])]
|
|
877
|
+
with patch.object(gp, '_api_get_paginated', return_value=data):
|
|
878
|
+
with pytest.raises(app.PatchError, match="stray"):
|
|
879
|
+
gp.load_pr_commits(pr)
|
|
880
|
+
|
|
881
|
+
def test_head_missing_from_listing_rejected(self, config_file):
|
|
882
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
883
|
+
pr = self.pr("c9", 2)
|
|
884
|
+
data = [commit("c1", ["base"]), commit("c2", ["c1"])]
|
|
885
|
+
with patch.object(gp, '_api_get_paginated', return_value=data):
|
|
886
|
+
with pytest.raises(app.PatchError, match="not in the commit"):
|
|
887
|
+
gp.load_pr_commits(pr)
|
|
888
|
+
|
|
889
|
+
def test_count_mismatch_rejected(self, config_file):
|
|
890
|
+
"""A truncated listing that still ends at head must not pass."""
|
|
891
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
892
|
+
pr = self.pr("c3", 5)
|
|
893
|
+
data = [commit("c2", ["c1"]), commit("c3", ["c2"])]
|
|
894
|
+
with patch.object(gp, '_api_get_paginated', return_value=data):
|
|
895
|
+
with pytest.raises(app.PatchError, match="reports 5 commits"):
|
|
896
|
+
gp.load_pr_commits(pr)
|
|
897
|
+
|
|
898
|
+
def test_250_commits_accepted(self, config_file):
|
|
899
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
900
|
+
shas = [f"c{i}" for i in range(1, 251)]
|
|
901
|
+
data = [commit(sha, [parent])
|
|
902
|
+
for sha, parent in zip(shas, ["base", *shas])]
|
|
903
|
+
pr = self.pr("c250", 250)
|
|
904
|
+
with patch.object(gp, '_api_get_paginated', return_value=data):
|
|
905
|
+
gp.load_pr_commits(pr)
|
|
906
|
+
assert pr.commits == shas
|
|
907
|
+
|
|
908
|
+
def test_over_250_commits_rejected_without_listing(self, config_file):
|
|
909
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
910
|
+
pr = self.pr("c251", 251)
|
|
911
|
+
with patch.object(gp, '_api_get_paginated') as mock_api:
|
|
912
|
+
with pytest.raises(app.PatchError, match="251 commits"):
|
|
913
|
+
gp.load_pr_commits(pr)
|
|
914
|
+
mock_api.assert_not_called()
|
|
915
|
+
|
|
916
|
+
def test_missing_count_fetched_from_single_pr(self, config_file):
|
|
917
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
918
|
+
pr = self.pr("c1", None)
|
|
919
|
+
latest = self.pr("c1", 1)
|
|
920
|
+
with patch.object(gp, 'get_pr', return_value=latest) as mock_get, \
|
|
921
|
+
patch.object(gp, '_api_get_paginated',
|
|
922
|
+
return_value=[commit("c1", ["base"])]):
|
|
923
|
+
gp.load_pr_commits(pr)
|
|
924
|
+
mock_get.assert_called_once_with("couchbase", "tlm", 1)
|
|
925
|
+
assert pr.commits == ["c1"]
|
|
926
|
+
|
|
927
|
+
def test_count_missing_from_single_pr_raises(self, config_file):
|
|
928
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
929
|
+
pr = self.pr("c1", None)
|
|
930
|
+
with patch.object(gp, 'get_pr', return_value=self.pr("c1", None)):
|
|
931
|
+
with pytest.raises(app.PatchError, match="no commit count"):
|
|
932
|
+
gp.load_pr_commits(pr)
|
|
933
|
+
|
|
934
|
+
def test_head_moved_while_fetching_count(self, config_file):
|
|
935
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
936
|
+
pr = self.pr("c1", None)
|
|
937
|
+
with patch.object(gp, 'get_pr', return_value=self.pr("c2", 2)):
|
|
938
|
+
with pytest.raises(app.PatchError, match="head moved"):
|
|
939
|
+
gp.load_pr_commits(pr)
|
|
940
|
+
|
|
941
|
+
def test_malformed_commit_data(self, config_file):
|
|
942
|
+
gp = app.GitHubPatches.from_config_file(config_file)
|
|
943
|
+
pr = self.pr("c1", 1)
|
|
944
|
+
with patch.object(gp, '_api_get_paginated',
|
|
945
|
+
return_value=[{"sha": "c1"}]):
|
|
946
|
+
with pytest.raises(app.PatchError, match="unexpected commit"):
|
|
947
|
+
gp.load_pr_commits(pr)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
# ===================================================================
|
|
951
|
+
# apply_single_pr — against real git repositories
|
|
952
|
+
# ===================================================================
|
|
953
|
+
|
|
954
|
+
def apply(gp, pr, api_commits, path):
|
|
955
|
+
with patch.object(gp, '_api_get_paginated', return_value=api_commits):
|
|
956
|
+
gp.apply_single_pr(pr, str(path))
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
class TestApplySinglePr:
|
|
960
|
+
|
|
961
|
+
@pytest.fixture(autouse=True)
|
|
962
|
+
def info_logs(self, caplog):
|
|
963
|
+
caplog.set_level(logging.INFO, logger='patch_via_github')
|
|
964
|
+
|
|
965
|
+
@pytest.fixture
|
|
966
|
+
def gp(self, config_file):
|
|
967
|
+
return app.GitHubPatches.from_config_file(config_file, use_ssh=False)
|
|
968
|
+
|
|
969
|
+
def test_missing_dir_raises_exit_5(self, gp):
|
|
970
|
+
pr = app.GitHubPR(make_pr_api_response(1, "tlm"))
|
|
971
|
+
with pytest.raises(app.PatchError, match="missing on disk") as e:
|
|
972
|
+
gp.apply_single_pr(pr, "/nonexistent_path_xyz")
|
|
973
|
+
assert e.value.exit_code == 5
|
|
974
|
+
|
|
975
|
+
def test_dir_not_a_git_checkout_raises(self, gp, tmp_path, monkeypatch):
|
|
976
|
+
monkeypatch.setenv('GIT_CEILING_DIRECTORIES', str(tmp_path))
|
|
977
|
+
(tmp_path / "plain").mkdir()
|
|
978
|
+
pr = app.GitHubPR(make_pr_api_response(1, "tlm"))
|
|
979
|
+
with pytest.raises(app.PatchError, match="not a usable git checkout"):
|
|
980
|
+
gp.apply_single_pr(pr, str(tmp_path / "plain"))
|
|
981
|
+
assert gp.originals == {}
|
|
982
|
+
|
|
983
|
+
def test_cherry_picks_every_pr_commit(self, gp, repos, caplog):
|
|
984
|
+
pr, api_commits = repos.make_pr(1, "p1", [
|
|
985
|
+
("f", "base\none\n", "c1"),
|
|
986
|
+
("f", "base\none\ntwo\n", "c2"),
|
|
987
|
+
("g", "new\n", "c3"),
|
|
988
|
+
])
|
|
989
|
+
apply(gp, pr, api_commits, repos.work)
|
|
990
|
+
assert repos.work_log() == ["c3", "c2", "c1", "base"]
|
|
991
|
+
assert repos.work_file("f") == "base\none\ntwo\n"
|
|
992
|
+
assert gp.applied_prs == ["couchbase/work#1"]
|
|
993
|
+
assert "Cherry-picked 3 of 3 commit(s)" in caplog.text
|
|
994
|
+
assert "added no new changes" not in caplog.text
|
|
995
|
+
|
|
996
|
+
def test_lands_on_top_of_local_commits(self, gp, repos):
|
|
997
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "x\n", "c1")])
|
|
998
|
+
repos.commit_file("h", "local\n", "local", repo=repos.work)
|
|
999
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1000
|
+
assert repos.work_log() == ["c1", "local", "base"]
|
|
1001
|
+
|
|
1002
|
+
def test_stacked_pr_skips_commits_already_applied(
|
|
1003
|
+
self, gp, repos, caplog
|
|
1004
|
+
):
|
|
1005
|
+
pr1, commits1 = repos.make_pr(1, "p1", [
|
|
1006
|
+
("g", "one\n", "c1"), ("h", "two\n", "c1b"),
|
|
1007
|
+
])
|
|
1008
|
+
pr2, commits2 = repos.make_pr(2, "p2", [
|
|
1009
|
+
("i", "three\n", "c2"),
|
|
1010
|
+
], start="p1")
|
|
1011
|
+
# A stacked PR on GitHub lists its dependency's commits too
|
|
1012
|
+
pr2.commit_count = 3
|
|
1013
|
+
apply(gp, pr1, commits1, repos.work)
|
|
1014
|
+
caplog.clear()
|
|
1015
|
+
apply(gp, pr2, commits1 + commits2, repos.work)
|
|
1016
|
+
assert repos.work_log() == ["c2", "c1b", "c1", "base"]
|
|
1017
|
+
assert "Skipped the first 2 commit(s)" in caplog.text
|
|
1018
|
+
assert "Cherry-picked 1 of 3 commit(s)" in caplog.text
|
|
1019
|
+
assert gp.applied_prs == ["couchbase/work#1", "couchbase/work#2"]
|
|
1020
|
+
|
|
1021
|
+
def test_squash_merged_dependency_skipped(self, gp, repos):
|
|
1022
|
+
pr1, commits1 = repos.make_pr(1, "p1", [
|
|
1023
|
+
("g", "one\n", "c1"), ("g", "one\ntwo\n", "c1b"),
|
|
1024
|
+
])
|
|
1025
|
+
pr2, commits2 = repos.make_pr(2, "p2", [
|
|
1026
|
+
("h", "three\n", "c2"),
|
|
1027
|
+
], start="p1")
|
|
1028
|
+
pr2.commit_count = 3
|
|
1029
|
+
repos.commit_file("g", "one\ntwo\n", "squashed", repo=repos.work)
|
|
1030
|
+
apply(gp, pr2, commits1 + commits2, repos.work)
|
|
1031
|
+
assert repos.work_log() == ["c2", "squashed", "base"]
|
|
1032
|
+
|
|
1033
|
+
def test_commit_already_present_mid_pr_skipped(self, gp, repos, caplog):
|
|
1034
|
+
pr, api_commits = repos.make_pr(1, "p1", [
|
|
1035
|
+
("g", "one\n", "c1"), ("h", "two\n", "c2"),
|
|
1036
|
+
])
|
|
1037
|
+
repos.commit_file("h", "two\n", "local h", repo=repos.work)
|
|
1038
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1039
|
+
assert repos.work_log() == ["c1", "local h", "base"]
|
|
1040
|
+
assert (f"Skipped commit {api_commits[0]['sha']}: its changes are "
|
|
1041
|
+
"already present") in caplog.text
|
|
1042
|
+
assert "Cherry-picked 1 of 2 commit(s)" in caplog.text
|
|
1043
|
+
assert "added no new changes" not in caplog.text
|
|
1044
|
+
|
|
1045
|
+
def test_fully_present_pr_warns(self, gp, repos, caplog):
|
|
1046
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1047
|
+
repos.commit_file("g", "one\n", "same change", repo=repos.work)
|
|
1048
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1049
|
+
assert repos.work_log() == ["same change", "base"]
|
|
1050
|
+
assert "couchbase/work#1 added no new changes" in caplog.text
|
|
1051
|
+
assert gp.applied_prs == ["couchbase/work#1"]
|
|
1052
|
+
|
|
1053
|
+
def test_commit_already_present_with_unrelated_local_edits(
|
|
1054
|
+
self, gp, repos, caplog
|
|
1055
|
+
):
|
|
1056
|
+
pr, api_commits = repos.make_pr(1, "p1", [
|
|
1057
|
+
("g", "one\n", "c1"), ("h", "two\n", "c2"),
|
|
1058
|
+
])
|
|
1059
|
+
repos.commit_file("h", "two\n", "local h", repo=repos.work)
|
|
1060
|
+
(repos.work / "f").write_text("base\nlocal edit\n")
|
|
1061
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1062
|
+
assert repos.work_log() == ["c1", "local h", "base"]
|
|
1063
|
+
assert "its changes are already present" in caplog.text
|
|
1064
|
+
assert repos.work_file("f") == "base\nlocal edit\n"
|
|
1065
|
+
|
|
1066
|
+
def test_empty_commit_in_pr_skipped(self, gp, repos, caplog):
|
|
1067
|
+
pr, api_commits = repos.make_pr(1, "p1", [
|
|
1068
|
+
("g", "one\n", "c1"), ("g", None, "empty"),
|
|
1069
|
+
("h", "two\n", "c2"),
|
|
1070
|
+
])
|
|
1071
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1072
|
+
assert repos.work_log() == ["c2", "c1", "base"]
|
|
1073
|
+
assert "it is empty in the PR" in caplog.text
|
|
1074
|
+
assert "Cherry-picked 2 of 3 commit(s)" in caplog.text
|
|
1075
|
+
|
|
1076
|
+
def test_conflict_is_informative(self, gp, repos):
|
|
1077
|
+
pr, api_commits = repos.make_pr(1, "p1", [("f", "theirs\n", "c1")])
|
|
1078
|
+
repos.commit_file("f", "ours\n", "local", repo=repos.work)
|
|
1079
|
+
with pytest.raises(app.PatchError) as e:
|
|
1080
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1081
|
+
msg = str(e.value)
|
|
1082
|
+
assert f"git cherry-pick {api_commits[0]['sha']}" in msg
|
|
1083
|
+
assert "couchbase/work#1" in msg
|
|
1084
|
+
assert "CONFLICT" in msg
|
|
1085
|
+
assert "cherry-pick --abort" in msg
|
|
1086
|
+
assert gp.applied_prs == []
|
|
1087
|
+
|
|
1088
|
+
@pytest.mark.parametrize('checkout', [False, True])
|
|
1089
|
+
def test_fetched_head_mismatch_raises(self, config_file, repos, checkout):
|
|
1090
|
+
gp = app.GitHubPatches.from_config_file(
|
|
1091
|
+
config_file, checkout=checkout, use_ssh=False
|
|
1092
|
+
)
|
|
1093
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1094
|
+
before = repos.git(repos.work, 'rev-parse', 'HEAD')
|
|
1095
|
+
# PR force-pushed after GitHub told us its head
|
|
1096
|
+
repos.make_pr(1, "p1", [("g", "other\n", "c1'")])
|
|
1097
|
+
with pytest.raises(app.PatchError, match="updated during"):
|
|
1098
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1099
|
+
assert repos.git(repos.work, 'rev-parse', 'HEAD') == before
|
|
1100
|
+
|
|
1101
|
+
def test_fetch_failure_is_informative(self, gp, repos):
|
|
1102
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1103
|
+
pr.fetch_url = str(repos.root / "no-such-repo")
|
|
1104
|
+
with pytest.raises(app.PatchError) as e:
|
|
1105
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1106
|
+
assert f"git fetch {pr.fetch_url} pull/1/head" in str(e.value)
|
|
1107
|
+
assert "cherry-pick --abort" not in str(e.value)
|
|
1108
|
+
|
|
1109
|
+
def test_fetch_timeout_raises(self, gp, repos, monkeypatch):
|
|
1110
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1111
|
+
real_run = subprocess.run
|
|
1112
|
+
|
|
1113
|
+
def run(command, **kwargs):
|
|
1114
|
+
if command[1] == 'fetch':
|
|
1115
|
+
assert kwargs['timeout'] == app.FETCH_TIMEOUT
|
|
1116
|
+
raise subprocess.TimeoutExpired(command, kwargs['timeout'])
|
|
1117
|
+
return real_run(command, **kwargs)
|
|
1118
|
+
|
|
1119
|
+
monkeypatch.setattr(subprocess, 'run', run)
|
|
1120
|
+
with pytest.raises(app.PatchError,
|
|
1121
|
+
match=f"timed out after {app.FETCH_TIMEOUT}s"):
|
|
1122
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1123
|
+
|
|
1124
|
+
def test_git_not_runnable_raises(self, gp, repos, monkeypatch):
|
|
1125
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1126
|
+
monkeypatch.setenv('PATH', '')
|
|
1127
|
+
with pytest.raises(app.PatchError, match="Could not run git"):
|
|
1128
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1129
|
+
|
|
1130
|
+
def test_present_prefix_diff_failure_raises(self, gp, repos):
|
|
1131
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1132
|
+
pr.commits = ["0" * 40]
|
|
1133
|
+
with pytest.raises(app.PatchError, match="git diff --binary"):
|
|
1134
|
+
gp._present_prefix(pr, str(repos.work))
|
|
1135
|
+
|
|
1136
|
+
def test_checkout_mode(self, config_file, repos):
|
|
1137
|
+
gp = app.GitHubPatches.from_config_file(
|
|
1138
|
+
config_file, checkout=True, use_ssh=False
|
|
1139
|
+
)
|
|
1140
|
+
pr, _ = repos.make_pr(1, "p1", [
|
|
1141
|
+
("g", "one\n", "c1"), ("g", "two\n", "c2"),
|
|
1142
|
+
])
|
|
1143
|
+
with patch.object(gp, 'load_pr_commits') as mock_load:
|
|
1144
|
+
gp.apply_single_pr(pr, str(repos.work))
|
|
1145
|
+
mock_load.assert_not_called()
|
|
1146
|
+
assert repos.git(repos.work, 'rev-parse', 'HEAD') == pr.head_sha
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
class TestRollBack:
|
|
1151
|
+
|
|
1152
|
+
@pytest.fixture
|
|
1153
|
+
def gp(self, config_file):
|
|
1154
|
+
return app.GitHubPatches.from_config_file(config_file, use_ssh=False)
|
|
1155
|
+
|
|
1156
|
+
def test_failure_rolls_back_every_touched_project(
|
|
1157
|
+
self, gp, repos, caplog, monkeypatch
|
|
1158
|
+
):
|
|
1159
|
+
other = repos.clone(repos.root / "other")
|
|
1160
|
+
before_work = repos.git(repos.work, 'rev-parse', 'HEAD')
|
|
1161
|
+
pr1, _ = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1162
|
+
pr2, _ = repos.make_pr(2, "p2", [("f", "theirs\n", "c2")],
|
|
1163
|
+
project="other")
|
|
1164
|
+
repos.commit_file("f", "ours\n", "local", repo=other)
|
|
1165
|
+
before_other = repos.git(other, 'rev-parse', 'HEAD')
|
|
1166
|
+
|
|
1167
|
+
gp.manifest = app.EleTree.fromstring(
|
|
1168
|
+
'<manifest><project name="work"/><project name="other"/>'
|
|
1169
|
+
'</manifest>'
|
|
1170
|
+
)
|
|
1171
|
+
monkeypatch.chdir(repos.root)
|
|
1172
|
+
with patch.object(gp, 'resolve_prs',
|
|
1173
|
+
return_value={pr1.key: pr1, pr2.key: pr2}), \
|
|
1174
|
+
patch.object(gp, '_api_get_paginated',
|
|
1175
|
+
side_effect=repos.api_get):
|
|
1176
|
+
with pytest.raises(SystemExit) as e:
|
|
1177
|
+
gp.patch_repo_sync([], 'pr')
|
|
1178
|
+
|
|
1179
|
+
assert e.value.code == 5
|
|
1180
|
+
assert repos.git(repos.work, 'rev-parse', 'HEAD') == before_work
|
|
1181
|
+
assert repos.git(repos.work, 'symbolic-ref', '--short',
|
|
1182
|
+
'HEAD') == 'master'
|
|
1183
|
+
assert repos.git(other, 'rev-parse', 'HEAD') == before_other
|
|
1184
|
+
assert repos.git(other, 'status', '--porcelain') == ''
|
|
1185
|
+
assert "PRs applied: couchbase/work#1)" in caplog.text
|
|
1186
|
+
assert "Rolled back work to master at" in caplog.text
|
|
1187
|
+
assert "Rolled back other to master at" in caplog.text
|
|
1188
|
+
|
|
1189
|
+
def test_project_patched_twice_restored_to_first_original(
|
|
1190
|
+
self, gp, repos
|
|
1191
|
+
):
|
|
1192
|
+
before = repos.git(repos.work, 'rev-parse', 'HEAD')
|
|
1193
|
+
pr1, commits1 = repos.make_pr(1, "p1", [("f", "one\n", "c1")])
|
|
1194
|
+
pr2, commits2 = repos.make_pr(2, "p2", [("f", "two\n", "c2")])
|
|
1195
|
+
apply(gp, pr1, commits1, repos.work)
|
|
1196
|
+
with pytest.raises(app.PatchError, match="CONFLICT"):
|
|
1197
|
+
apply(gp, pr2, commits2, repos.work)
|
|
1198
|
+
assert gp.roll_back() is True
|
|
1199
|
+
assert repos.git(repos.work, 'rev-parse', 'HEAD') == before
|
|
1200
|
+
|
|
1201
|
+
def test_uncommitted_local_changes_survive(self, gp, repos):
|
|
1202
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1203
|
+
(repos.work / "f").write_text("base\nlocal edit\n")
|
|
1204
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1205
|
+
assert gp.roll_back() is True
|
|
1206
|
+
assert repos.work_log() == ["base"]
|
|
1207
|
+
assert repos.work_file("f") == "base\nlocal edit\n"
|
|
1208
|
+
|
|
1209
|
+
def test_checkout_mode_restores_branch(self, config_file, repos):
|
|
1210
|
+
gp = app.GitHubPatches.from_config_file(
|
|
1211
|
+
config_file, checkout=True, use_ssh=False
|
|
1212
|
+
)
|
|
1213
|
+
pr, _ = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1214
|
+
gp.apply_single_pr(pr, str(repos.work))
|
|
1215
|
+
assert repos.git(repos.work, 'rev-parse', 'HEAD') == pr.head_sha
|
|
1216
|
+
assert gp.roll_back() is True
|
|
1217
|
+
assert repos.git(repos.work, 'symbolic-ref', '--short',
|
|
1218
|
+
'HEAD') == 'master'
|
|
1219
|
+
assert repos.work_log() == ["base"]
|
|
1220
|
+
|
|
1221
|
+
def test_detached_head_restored_detached(self, gp, repos):
|
|
1222
|
+
base = repos.git(repos.work, 'rev-parse', 'HEAD')
|
|
1223
|
+
repos.git(repos.work, 'checkout', '-q', '--detach')
|
|
1224
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1225
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1226
|
+
assert gp.roll_back() is True
|
|
1227
|
+
assert repos.git(repos.work, 'rev-parse', 'HEAD') == base
|
|
1228
|
+
assert subprocess.run(
|
|
1229
|
+
['git', 'symbolic-ref', '-q', 'HEAD'], cwd=repos.work
|
|
1230
|
+
).returncode != 0
|
|
1231
|
+
|
|
1232
|
+
def test_mid_cherry_pick_aborted(self, gp, repos):
|
|
1233
|
+
pr, api_commits = repos.make_pr(1, "p1", [("f", "theirs\n", "c1")])
|
|
1234
|
+
repos.commit_file("f", "ours\n", "local", repo=repos.work)
|
|
1235
|
+
before = repos.git(repos.work, 'rev-parse', 'HEAD')
|
|
1236
|
+
with pytest.raises(app.PatchError):
|
|
1237
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1238
|
+
assert gp.roll_back() is True
|
|
1239
|
+
assert repos.git(repos.work, 'rev-parse', 'HEAD') == before
|
|
1240
|
+
assert repos.git(repos.work, 'status', '--porcelain') == ''
|
|
1241
|
+
|
|
1242
|
+
def test_rollback_failure_reported(self, gp, repos, caplog):
|
|
1243
|
+
pr, api_commits = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1244
|
+
apply(gp, pr, api_commits, repos.work)
|
|
1245
|
+
# A local edit to a file the rollback must change blocks reset --keep
|
|
1246
|
+
(repos.work / "g").write_text("edited\n")
|
|
1247
|
+
assert gp.roll_back() is False
|
|
1248
|
+
assert "Could not roll back" in caplog.text
|
|
1249
|
+
assert "Restore it manually" in caplog.text
|
|
1250
|
+
assert repos.work_file("g") == "edited\n"
|
|
1251
|
+
|
|
1252
|
+
def test_incomplete_rollback_reported(self, gp_with_manifest, caplog):
|
|
1253
|
+
gp = gp_with_manifest
|
|
1254
|
+
gp.originals = {"somewhere": ("master", "abc")}
|
|
1255
|
+
with patch.object(gp, 'resolve_prs',
|
|
1256
|
+
side_effect=app.PatchError("it broke")), \
|
|
1257
|
+
patch.object(gp, 'roll_back', return_value=False):
|
|
1258
|
+
with pytest.raises(SystemExit) as e:
|
|
1259
|
+
gp.patch_repo_sync([], 'pr')
|
|
1260
|
+
assert e.value.code == 5
|
|
1261
|
+
assert "Rollback incomplete" in caplog.text
|
|
1262
|
+
|
|
1263
|
+
@pytest.mark.parametrize('exc, logged', [
|
|
1264
|
+
(KeyError('oops'), "Unexpected error: KeyError('oops')"),
|
|
1265
|
+
(KeyboardInterrupt(), "Interrupted"),
|
|
1266
|
+
])
|
|
1267
|
+
def test_other_exceptions_roll_back(
|
|
1268
|
+
self, gp_with_manifest, caplog, exc, logged
|
|
1269
|
+
):
|
|
1270
|
+
gp = gp_with_manifest
|
|
1271
|
+
gp.originals = {"somewhere": ("master", "abc")}
|
|
1272
|
+
with patch.object(gp, 'resolve_prs', side_effect=exc), \
|
|
1273
|
+
patch.object(gp, 'roll_back', return_value=True) as mock_back:
|
|
1274
|
+
with pytest.raises(SystemExit) as e:
|
|
1275
|
+
gp.patch_repo_sync([], 'pr')
|
|
1276
|
+
assert e.value.code == 5
|
|
1277
|
+
mock_back.assert_called_once()
|
|
1278
|
+
assert logged in caplog.text
|
|
1279
|
+
|
|
1280
|
+
@pytest.mark.parametrize('exc, code', [
|
|
1281
|
+
(KeyError('oops'), 1), (KeyboardInterrupt(), 130),
|
|
1282
|
+
])
|
|
1283
|
+
def test_other_exceptions_before_any_change(
|
|
1284
|
+
self, gp_with_manifest, exc, code
|
|
1285
|
+
):
|
|
1286
|
+
gp = gp_with_manifest
|
|
1287
|
+
with patch.object(gp, 'resolve_prs', side_effect=exc):
|
|
1288
|
+
with pytest.raises(SystemExit) as e:
|
|
1289
|
+
gp.patch_repo_sync([], 'pr')
|
|
1290
|
+
assert e.value.code == code
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
# ===================================================================
|
|
1294
|
+
# apply_prs
|
|
1295
|
+
# ===================================================================
|
|
1296
|
+
|
|
1297
|
+
class TestApplyPrs:
|
|
1298
|
+
|
|
1299
|
+
@pytest.mark.parametrize('project, path', [
|
|
1300
|
+
("server-ui", "server-ui"),
|
|
1301
|
+
("couchbase-cli", "couchbase-cli-tools"),
|
|
1302
|
+
])
|
|
1303
|
+
def test_applied_at_manifest_path(self, gp_with_manifest, project, path):
|
|
1304
|
+
gp = gp_with_manifest
|
|
1305
|
+
pr = app.GitHubPR(make_pr_api_response(10, project))
|
|
1306
|
+
with patch.object(gp, 'apply_single_pr') as mock_apply:
|
|
1307
|
+
gp.apply_prs({pr.key: pr})
|
|
1308
|
+
mock_apply.assert_called_once_with(pr, path)
|
|
1309
|
+
|
|
1310
|
+
def test_unknown_project_skipped(self, gp_with_manifest, caplog):
|
|
1311
|
+
caplog.set_level(logging.INFO, logger='patch_via_github')
|
|
1312
|
+
gp = gp_with_manifest
|
|
1313
|
+
pr = app.GitHubPR(make_pr_api_response(30, "nonexistent-repo"))
|
|
1314
|
+
with patch.object(gp, 'apply_single_pr') as mock_apply:
|
|
1315
|
+
gp.apply_prs({pr.key: pr})
|
|
1316
|
+
mock_apply.assert_not_called()
|
|
1317
|
+
assert "ignoring PR couchbase/nonexistent-repo#30" in caplog.text
|
|
1318
|
+
|
|
1319
|
+
def test_prs_applied_in_given_order(self, gp_with_manifest):
|
|
1320
|
+
gp = gp_with_manifest
|
|
1321
|
+
pr10 = app.GitHubPR(make_pr_api_response(10, "couchbase-cli"))
|
|
1322
|
+
pr2 = app.GitHubPR(
|
|
1323
|
+
make_pr_api_response(2, "server-ui", base_ref="main")
|
|
1324
|
+
)
|
|
1325
|
+
applied_order = []
|
|
1326
|
+
|
|
1327
|
+
with patch.object(gp, 'apply_single_pr',
|
|
1328
|
+
side_effect=lambda pr, *a, **k:
|
|
1329
|
+
applied_order.append(pr.ref)):
|
|
1330
|
+
gp.apply_prs({pr10.key: pr10, pr2.key: pr2})
|
|
1331
|
+
|
|
1332
|
+
assert applied_order == [
|
|
1333
|
+
"couchbase/couchbase-cli#10", "couchbase/server-ui#2"
|
|
1334
|
+
]
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
# ===================================================================
|
|
1338
|
+
# patch_repo_sync — Top-Level Orchestration
|
|
1339
|
+
# ===================================================================
|
|
1340
|
+
|
|
1341
|
+
def pr_for(project, number):
|
|
1342
|
+
return app.GitHubPR(make_pr_api_response(number, project))
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
class TestPatchRepoSync:
|
|
1346
|
+
|
|
1347
|
+
@pytest.fixture
|
|
1348
|
+
def resolved(self):
|
|
1349
|
+
prs = (pr_for("server-ui", 2), pr_for("couchbase-cli", 3))
|
|
1350
|
+
return {pr.key: pr for pr in prs}
|
|
1351
|
+
|
|
1352
|
+
def run(self, gp, resolved, applied):
|
|
1353
|
+
"""patch_repo_sync with only PRs to the projects in applied applying"""
|
|
1354
|
+
|
|
1355
|
+
def apply(pr, *args):
|
|
1356
|
+
if pr.project in applied:
|
|
1357
|
+
gp.applied_prs.append(pr.key)
|
|
1358
|
+
|
|
1359
|
+
with patch.object(gp, 'resolve_prs', return_value=resolved), \
|
|
1360
|
+
patch.object(gp, 'apply_single_pr',
|
|
1361
|
+
side_effect=apply) as mock_apply:
|
|
1362
|
+
gp.patch_repo_sync(["refs"], 'pr')
|
|
1363
|
+
return mock_apply
|
|
1364
|
+
|
|
1365
|
+
def test_unapplied_request_fails(self, gp_with_manifest, resolved, caplog):
|
|
1366
|
+
with pytest.raises(SystemExit) as e:
|
|
1367
|
+
self.run(gp_with_manifest, resolved, {"server-ui"})
|
|
1368
|
+
assert e.value.code == 1
|
|
1369
|
+
assert ("Failed to apply all explicitly-requested PRs! Requested: "
|
|
1370
|
+
"['couchbase/server-ui#2', 'couchbase/couchbase-cli#3'] "
|
|
1371
|
+
"Applied: ['couchbase/server-ui#2']") in caplog.text
|
|
1372
|
+
|
|
1373
|
+
def test_all_applied_passes(self, gp_with_manifest, resolved):
|
|
1374
|
+
mock_apply = self.run(
|
|
1375
|
+
gp_with_manifest, resolved, {"server-ui", "couchbase-cli"}
|
|
1376
|
+
)
|
|
1377
|
+
assert mock_apply.call_count == 2
|
|
1378
|
+
|
|
1379
|
+
def test_unapplied_request_rolls_back_applied_prs(
|
|
1380
|
+
self, gp_with_manifest, resolved
|
|
1381
|
+
):
|
|
1382
|
+
gp = gp_with_manifest
|
|
1383
|
+
gp.originals = {"server-ui": ("master", "abc")}
|
|
1384
|
+
with patch.object(gp, 'roll_back', return_value=True) as mock_back:
|
|
1385
|
+
with pytest.raises(SystemExit) as e:
|
|
1386
|
+
self.run(gp, resolved, {"server-ui"})
|
|
1387
|
+
assert e.value.code == 5
|
|
1388
|
+
mock_back.assert_called_once()
|
|
1389
|
+
|
|
1390
|
+
def test_failure_before_any_change_keeps_exit_code(
|
|
1391
|
+
self, gp_with_manifest, caplog
|
|
1392
|
+
):
|
|
1393
|
+
gp = gp_with_manifest
|
|
1394
|
+
with patch.object(gp, 'resolve_prs',
|
|
1395
|
+
side_effect=app.PatchError("it broke")), \
|
|
1396
|
+
patch.object(gp, 'roll_back') as mock_roll_back:
|
|
1397
|
+
with pytest.raises(SystemExit) as e:
|
|
1398
|
+
gp.patch_repo_sync(["couchbase/tlm#1"], 'pr')
|
|
1399
|
+
assert e.value.code == 1
|
|
1400
|
+
assert "it broke" in caplog.text
|
|
1401
|
+
mock_roll_back.assert_not_called()
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
# ===================================================================
|
|
1405
|
+
# main() — argument parsing
|
|
1406
|
+
# ===================================================================
|
|
1407
|
+
|
|
1408
|
+
def set_argv(monkeypatch, *args):
|
|
1409
|
+
monkeypatch.setattr(sys, 'argv', ['patch_via_github', *args])
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
class TestMainArguments:
|
|
1413
|
+
|
|
1414
|
+
def test_requires_pr_or_label(self, monkeypatch, capsys):
|
|
1415
|
+
set_argv(monkeypatch)
|
|
1416
|
+
with pytest.raises(SystemExit) as e:
|
|
1417
|
+
app.main()
|
|
1418
|
+
assert e.value.code == 2
|
|
1419
|
+
assert ("one of the arguments -p/--pull-request -l/--label is "
|
|
1420
|
+
"required") in capsys.readouterr().err
|
|
1421
|
+
|
|
1422
|
+
def test_pr_and_label_mutually_exclusive(
|
|
1423
|
+
self, monkeypatch, capsys, config_file
|
|
1424
|
+
):
|
|
1425
|
+
set_argv(monkeypatch, '-c', config_file,
|
|
1426
|
+
'-p', 'couchbase/tlm#1', '-l', 'couchbase/tlm:bug')
|
|
1427
|
+
with pytest.raises(SystemExit) as e:
|
|
1428
|
+
app.main()
|
|
1429
|
+
assert e.value.code == 2
|
|
1430
|
+
assert ("argument -l/--label: not allowed with argument "
|
|
1431
|
+
"-p/--pull-request") in capsys.readouterr().err
|
|
1432
|
+
|
|
1433
|
+
def test_invalid_repo_source_exits(
|
|
1434
|
+
self, monkeypatch, config_file, tmp_path, caplog
|
|
1435
|
+
):
|
|
1436
|
+
set_argv(monkeypatch, '-c', config_file, '-p', 'couchbase/tlm#1',
|
|
1437
|
+
'-s', str(tmp_path / "does-not-exist"))
|
|
1438
|
+
with pytest.raises(SystemExit) as e:
|
|
1439
|
+
app.main()
|
|
1440
|
+
assert e.value.code == 1
|
|
1441
|
+
assert "Path for repo sync checkout doesn't exist" in caplog.text
|
|
1442
|
+
|
|
1443
|
+
@pytest.mark.parametrize('option', ['-p', '-l'])
|
|
1444
|
+
def test_no_values_after_splitting_rejected(
|
|
1445
|
+
self, monkeypatch, capsys, option
|
|
1446
|
+
):
|
|
1447
|
+
set_argv(monkeypatch, option, ',')
|
|
1448
|
+
with pytest.raises(SystemExit) as e:
|
|
1449
|
+
app.main()
|
|
1450
|
+
assert e.value.code == 2
|
|
1451
|
+
assert f"argument {option}: expected a value" in (
|
|
1452
|
+
capsys.readouterr().err
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
def test_config_error_exits(self, monkeypatch, tmp_path, caplog):
|
|
1456
|
+
ini = tmp_path / "bad.ini"
|
|
1457
|
+
ini.write_text("[main]\nghp_SECRET\n")
|
|
1458
|
+
set_argv(monkeypatch, '-c', str(ini), '-s', str(tmp_path),
|
|
1459
|
+
'-p', 'couchbase/tlm#1')
|
|
1460
|
+
with pytest.raises(SystemExit) as e:
|
|
1461
|
+
app.main()
|
|
1462
|
+
assert e.value.code == 1
|
|
1463
|
+
assert "could not parse line 2" in caplog.text
|
|
1464
|
+
assert "SECRET" not in caplog.text
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
# ===================================================================
|
|
1468
|
+
# main() — end to end against real git repositories
|
|
1469
|
+
# ===================================================================
|
|
1470
|
+
|
|
1471
|
+
MANIFEST = b'<manifest><default revision="master"/><project name="work"/>' \
|
|
1472
|
+
b'</manifest>'
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
class TestMainEndToEnd:
|
|
1476
|
+
|
|
1477
|
+
@pytest.fixture
|
|
1478
|
+
def ini(self, tmp_path):
|
|
1479
|
+
return write_ini(tmp_path, "token = x", "ssh = false")
|
|
1480
|
+
|
|
1481
|
+
@pytest.fixture
|
|
1482
|
+
def run_main(self, monkeypatch, repos):
|
|
1483
|
+
"""Run main() from outside the repo sync, with -s pointing at it"""
|
|
1484
|
+
elsewhere = repos.root / "elsewhere"
|
|
1485
|
+
elsewhere.mkdir()
|
|
1486
|
+
monkeypatch.chdir(elsewhere)
|
|
1487
|
+
monkeypatch.setenv('LD_LIBRARY_PATH', '/pyinstaller/libs')
|
|
1488
|
+
|
|
1489
|
+
def run(*args):
|
|
1490
|
+
set_argv(monkeypatch, '-s', str(repos.root), *args)
|
|
1491
|
+
with patch.object(app, 'which', return_value='/usr/bin/repo'), \
|
|
1492
|
+
patch('subprocess.check_output', return_value=MANIFEST), \
|
|
1493
|
+
patch.object(app.GitHubPatches, '_api_get',
|
|
1494
|
+
side_effect=repos.api_get), \
|
|
1495
|
+
patch.object(app.GitHubPatches, '_api_get_paginated',
|
|
1496
|
+
side_effect=repos.api_get):
|
|
1497
|
+
app.main()
|
|
1498
|
+
|
|
1499
|
+
return run
|
|
1500
|
+
|
|
1501
|
+
def test_closed_pr_requested_explicitly_is_applied(
|
|
1502
|
+
self, run_main, repos, ini, capsys
|
|
1503
|
+
):
|
|
1504
|
+
repos.make_pr(1, "p1", [("g", "one\n", "c1")], state="closed")
|
|
1505
|
+
run_main('-c', ini, '-p', 'Couchbase/Work#1,couchbase/work#1')
|
|
1506
|
+
assert repos.work_log() == ["c1", "base"]
|
|
1507
|
+
err = capsys.readouterr().err
|
|
1508
|
+
assert err.count("***** Applying") == 1
|
|
1509
|
+
assert "All explicitly-requested PRs applied" in err
|
|
1510
|
+
assert "DEBUG:" not in err
|
|
1511
|
+
assert 'LD_LIBRARY_PATH' not in os.environ
|
|
1512
|
+
|
|
1513
|
+
def test_prs_applied_in_requested_order(self, run_main, repos, ini):
|
|
1514
|
+
repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1515
|
+
repos.make_pr(2, "p2", [("h", "two\n", "c2")])
|
|
1516
|
+
run_main('-c', ini, '-p', 'couchbase/work#2,couchbase/work#1')
|
|
1517
|
+
assert repos.work_log() == ["c1", "c2", "base"]
|
|
1518
|
+
|
|
1519
|
+
def test_short_ref_uses_default_org_option(self, run_main, repos, ini):
|
|
1520
|
+
repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1521
|
+
run_main('-c', ini, '-p', 'work#1', '-o', 'couchbase')
|
|
1522
|
+
assert repos.endpoints[0] == '/repos/couchbase/work/pulls/1'
|
|
1523
|
+
assert repos.work_log() == ["c1", "base"]
|
|
1524
|
+
|
|
1525
|
+
def test_checkout_option(self, run_main, repos, ini):
|
|
1526
|
+
pr, _ = repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1527
|
+
repos.commit_file("h", "local\n", "local", repo=repos.work)
|
|
1528
|
+
run_main('-c', ini, '-C', '-p', 'couchbase/work#1')
|
|
1529
|
+
assert repos.git(repos.work, 'rev-parse', 'HEAD') == pr.head_sha
|
|
1530
|
+
|
|
1531
|
+
def test_label_applies_open_labelled_prs(
|
|
1532
|
+
self, run_main, repos, ini, capsys
|
|
1533
|
+
):
|
|
1534
|
+
repos.make_pr(1, "p1", [("g", "one\n", "c1")], labels=["lbl"])
|
|
1535
|
+
repos.make_pr(2, "p2", [("h", "two\n", "c2")], labels=["other"])
|
|
1536
|
+
run_main('-c', ini, '-l', 'couchbase/work:lbl')
|
|
1537
|
+
assert repos.work_log() == ["c1", "base"]
|
|
1538
|
+
assert ("Applied PRs from label(s) 'couchbase/work:lbl': "
|
|
1539
|
+
"['couchbase/work#1']") in capsys.readouterr().err
|
|
1540
|
+
|
|
1541
|
+
def test_unapplied_request_rolls_back(
|
|
1542
|
+
self, run_main, repos, ini, capsys
|
|
1543
|
+
):
|
|
1544
|
+
repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1545
|
+
repos.make_pr(2, "p2", [("x", "two\n", "c2")], project="gone")
|
|
1546
|
+
with pytest.raises(SystemExit) as e:
|
|
1547
|
+
run_main('-c', ini, '-p', 'couchbase/work#1,couchbase/gone#2')
|
|
1548
|
+
assert e.value.code == 5
|
|
1549
|
+
err = capsys.readouterr().err
|
|
1550
|
+
assert "ignoring PR couchbase/gone#2" in err
|
|
1551
|
+
assert "Failed to apply all explicitly-requested PRs" in err
|
|
1552
|
+
assert repos.work_log() == ["base"]
|
|
1553
|
+
|
|
1554
|
+
def test_ini_ssh_used_for_fetch(self, run_main, repos, tmp_path, capsys):
|
|
1555
|
+
repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1556
|
+
with pytest.raises(SystemExit) as e:
|
|
1557
|
+
run_main('-c', write_ini(tmp_path, "token = x", "ssh = true"),
|
|
1558
|
+
'-p', 'couchbase/work#1')
|
|
1559
|
+
assert e.value.code == 5
|
|
1560
|
+
assert f"git fetch {repos.root / 'ssh-unreachable'}" in (
|
|
1561
|
+
capsys.readouterr().err
|
|
1562
|
+
)
|
|
1563
|
+
assert repos.work_log() == ["base"]
|
|
1564
|
+
|
|
1565
|
+
def test_no_ssh_option_overrides_ini(self, run_main, repos, tmp_path):
|
|
1566
|
+
repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1567
|
+
run_main('-c', write_ini(tmp_path, "token = x", "ssh = true"),
|
|
1568
|
+
'--no-ssh', '-p', 'couchbase/work#1')
|
|
1569
|
+
assert repos.work_log() == ["c1", "base"]
|
|
1570
|
+
|
|
1571
|
+
def test_debug_option(self, run_main, repos, ini, capsys):
|
|
1572
|
+
repos.make_pr(1, "p1", [("g", "one\n", "c1")])
|
|
1573
|
+
run_main('-c', ini, '-d', '-p', 'couchbase/work#1')
|
|
1574
|
+
assert "DEBUG: Fetching PR couchbase/work#1" in (
|
|
1575
|
+
capsys.readouterr().err
|
|
1576
|
+
)
|