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.
@@ -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
+ )