github2gerrit 0.1.10__py3-none-any.whl → 0.1.11__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.
- github2gerrit/cli.py +793 -198
- github2gerrit/commit_normalization.py +44 -15
- github2gerrit/config.py +76 -30
- github2gerrit/core.py +1571 -267
- github2gerrit/duplicate_detection.py +222 -98
- github2gerrit/external_api.py +76 -25
- github2gerrit/gerrit_query.py +286 -0
- github2gerrit/gerrit_rest.py +53 -18
- github2gerrit/gerrit_urls.py +90 -33
- github2gerrit/github_api.py +19 -6
- github2gerrit/gitutils.py +43 -14
- github2gerrit/mapping_comment.py +345 -0
- github2gerrit/models.py +15 -1
- github2gerrit/orchestrator/__init__.py +25 -0
- github2gerrit/orchestrator/reconciliation.py +589 -0
- github2gerrit/pr_content_filter.py +65 -17
- github2gerrit/reconcile_matcher.py +595 -0
- github2gerrit/rich_display.py +502 -0
- github2gerrit/rich_logging.py +316 -0
- github2gerrit/similarity.py +65 -19
- github2gerrit/ssh_agent_setup.py +59 -22
- github2gerrit/ssh_common.py +30 -11
- github2gerrit/ssh_discovery.py +67 -20
- github2gerrit/trailers.py +340 -0
- github2gerrit/utils.py +6 -2
- {github2gerrit-0.1.10.dist-info → github2gerrit-0.1.11.dist-info}/METADATA +76 -24
- github2gerrit-0.1.11.dist-info/RECORD +31 -0
- {github2gerrit-0.1.10.dist-info → github2gerrit-0.1.11.dist-info}/WHEEL +1 -2
- github2gerrit-0.1.10.dist-info/RECORD +0 -24
- github2gerrit-0.1.10.dist-info/top_level.txt +0 -1
- {github2gerrit-0.1.10.dist-info → github2gerrit-0.1.11.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.10.dist-info → github2gerrit-0.1.11.dist-info}/licenses/LICENSE +0 -0
github2gerrit/gerrit_urls.py
CHANGED
@@ -25,19 +25,29 @@ _BASE_PATH_CACHE: dict[str, str] = {}
|
|
25
25
|
|
26
26
|
|
27
27
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
28
|
-
def http_error_301(
|
28
|
+
def http_error_301(
|
29
|
+
self, req: Any, fp: Any, code: int, msg: str, headers: Any
|
30
|
+
) -> Any:
|
29
31
|
return fp
|
30
32
|
|
31
|
-
def http_error_302(
|
33
|
+
def http_error_302(
|
34
|
+
self, req: Any, fp: Any, code: int, msg: str, headers: Any
|
35
|
+
) -> Any:
|
32
36
|
return fp
|
33
37
|
|
34
|
-
def http_error_303(
|
38
|
+
def http_error_303(
|
39
|
+
self, req: Any, fp: Any, code: int, msg: str, headers: Any
|
40
|
+
) -> Any:
|
35
41
|
return fp
|
36
42
|
|
37
|
-
def http_error_307(
|
43
|
+
def http_error_307(
|
44
|
+
self, req: Any, fp: Any, code: int, msg: str, headers: Any
|
45
|
+
) -> Any:
|
38
46
|
return fp
|
39
47
|
|
40
|
-
def http_error_308(
|
48
|
+
def http_error_308(
|
49
|
+
self, req: Any, fp: Any, code: int, msg: str, headers: Any
|
50
|
+
) -> Any:
|
41
51
|
return fp
|
42
52
|
|
43
53
|
|
@@ -83,23 +93,31 @@ def _discover_base_path_for_host(host: str, timeout: float = 5.0) -> str:
|
|
83
93
|
continue
|
84
94
|
try:
|
85
95
|
resp = opener.open(url, timeout=timeout)
|
86
|
-
code = getattr(resp, "getcode", lambda: None)() or getattr(
|
96
|
+
code = getattr(resp, "getcode", lambda: None)() or getattr(
|
97
|
+
resp, "status", 0
|
98
|
+
)
|
87
99
|
# If we reached the page without redirects
|
88
100
|
if code == 200:
|
89
|
-
|
90
|
-
log.info("Gerrit base path: ''")
|
101
|
+
log.debug("Gerrit base path: ''")
|
91
102
|
return ""
|
92
|
-
# Handle 3xx responses when redirects are disabled
|
103
|
+
# Handle 3xx responses when redirects are disabled
|
104
|
+
# (no-redirect opener)
|
93
105
|
if code in (301, 302, 303, 307, 308):
|
94
106
|
headers = getattr(resp, "headers", {}) or {}
|
95
|
-
loc =
|
107
|
+
loc = (
|
108
|
+
headers.get("Location")
|
109
|
+
or headers.get("location")
|
110
|
+
or ""
|
111
|
+
)
|
96
112
|
if loc:
|
97
113
|
# Normalize to absolute path
|
98
114
|
parsed = urllib.parse.urlparse(loc)
|
99
115
|
path = (
|
100
116
|
parsed.path
|
101
117
|
if parsed.scheme or parsed.netloc
|
102
|
-
else urllib.parse.urlparse(
|
118
|
+
else urllib.parse.urlparse(
|
119
|
+
f"https://{host}{loc}"
|
120
|
+
).path
|
103
121
|
)
|
104
122
|
# Determine candidate base path
|
105
123
|
segs = [s for s in path.split("/") if s]
|
@@ -109,21 +127,28 @@ def _discover_base_path_for_host(host: str, timeout: float = 5.0) -> str:
|
|
109
127
|
if first not in known_endpoints:
|
110
128
|
base = first
|
111
129
|
_BASE_PATH_CACHE[host] = base
|
112
|
-
log.
|
130
|
+
log.debug("Gerrit base path: '%s'", base)
|
113
131
|
return base
|
114
132
|
# If we get any other non-redirect response, try next probe
|
115
133
|
continue
|
116
134
|
except urllib.error.HTTPError as e:
|
117
|
-
# HTTPError doubles as the response; capture Location for
|
135
|
+
# HTTPError doubles as the response; capture Location for
|
136
|
+
# redirects
|
118
137
|
code = e.code
|
119
|
-
loc =
|
138
|
+
loc = (
|
139
|
+
e.headers.get("Location")
|
140
|
+
or e.headers.get("location")
|
141
|
+
or ""
|
142
|
+
)
|
120
143
|
if code in (301, 302, 303, 307, 308) and loc:
|
121
144
|
# Normalize to absolute path
|
122
145
|
parsed = urllib.parse.urlparse(loc)
|
123
146
|
path = (
|
124
147
|
parsed.path
|
125
148
|
if parsed.scheme or parsed.netloc
|
126
|
-
else urllib.parse.urlparse(
|
149
|
+
else urllib.parse.urlparse(
|
150
|
+
f"https://{host}{loc}"
|
151
|
+
).path
|
127
152
|
)
|
128
153
|
# Determine candidate base path
|
129
154
|
segs = [s for s in path.split("/") if s]
|
@@ -133,12 +158,17 @@ def _discover_base_path_for_host(host: str, timeout: float = 5.0) -> str:
|
|
133
158
|
if first not in known_endpoints:
|
134
159
|
base = first
|
135
160
|
_BASE_PATH_CACHE[host] = base
|
136
|
-
log.
|
161
|
+
log.debug("Gerrit base path: '%s'", base)
|
137
162
|
return base
|
138
163
|
# Non-redirect error; try next probe
|
139
164
|
continue
|
140
165
|
except Exception as exc:
|
141
|
-
log.debug(
|
166
|
+
log.debug(
|
167
|
+
"Gerrit base path probe failed for %s%s: %s",
|
168
|
+
host,
|
169
|
+
probe,
|
170
|
+
exc,
|
171
|
+
)
|
142
172
|
continue
|
143
173
|
|
144
174
|
except Exception as exc:
|
@@ -146,7 +176,7 @@ def _discover_base_path_for_host(host: str, timeout: float = 5.0) -> str:
|
|
146
176
|
return ""
|
147
177
|
# Default if nothing conclusive after exhausting all probes
|
148
178
|
_BASE_PATH_CACHE[host] = ""
|
149
|
-
log.
|
179
|
+
log.debug("Gerrit base path: ''")
|
150
180
|
return ""
|
151
181
|
|
152
182
|
|
@@ -167,7 +197,8 @@ class GerritUrlBuilder:
|
|
167
197
|
Args:
|
168
198
|
host: Gerrit hostname (without protocol)
|
169
199
|
base_path: Optional base path override. If None, reads from
|
170
|
-
GERRIT_HTTP_BASE_PATH environment variable or discovers
|
200
|
+
GERRIT_HTTP_BASE_PATH environment variable or discovers
|
201
|
+
dynamically.
|
171
202
|
"""
|
172
203
|
self.host = host.strip()
|
173
204
|
|
@@ -203,24 +234,32 @@ class GerritUrlBuilder:
|
|
203
234
|
Build the base URL with optional base path override.
|
204
235
|
|
205
236
|
Args:
|
206
|
-
base_path_override: Optional base path to use instead of the
|
237
|
+
base_path_override: Optional base path to use instead of the
|
238
|
+
instance default
|
207
239
|
|
208
240
|
Returns:
|
209
241
|
Base URL with trailing slash
|
210
242
|
"""
|
211
|
-
path =
|
243
|
+
path = (
|
244
|
+
base_path_override
|
245
|
+
if base_path_override is not None
|
246
|
+
else self._base_path
|
247
|
+
)
|
212
248
|
if path:
|
213
249
|
return f"https://{self.host}/{path}/"
|
214
250
|
else:
|
215
251
|
return f"https://{self.host}/"
|
216
252
|
|
217
|
-
def api_url(
|
253
|
+
def api_url(
|
254
|
+
self, endpoint: str = "", base_path_override: str | None = None
|
255
|
+
) -> str:
|
218
256
|
"""
|
219
257
|
Build a Gerrit REST API URL.
|
220
258
|
|
221
259
|
Args:
|
222
260
|
endpoint: API endpoint path (e.g., "/changes/", "/accounts/self")
|
223
|
-
base_path_override: Optional base path override for fallback
|
261
|
+
base_path_override: Optional base path override for fallback
|
262
|
+
scenarios
|
224
263
|
|
225
264
|
Returns:
|
226
265
|
Complete API URL
|
@@ -231,13 +270,16 @@ class GerritUrlBuilder:
|
|
231
270
|
endpoint = "/" + endpoint
|
232
271
|
return urljoin(base_url, endpoint.lstrip("/"))
|
233
272
|
|
234
|
-
def web_url(
|
273
|
+
def web_url(
|
274
|
+
self, path: str = "", base_path_override: str | None = None
|
275
|
+
) -> str:
|
235
276
|
"""
|
236
277
|
Build a Gerrit web UI URL.
|
237
278
|
|
238
279
|
Args:
|
239
280
|
path: Web path (e.g., "c/project/+/123", "dashboard")
|
240
|
-
base_path_override: Optional base path override for fallback
|
281
|
+
base_path_override: Optional base path override for fallback
|
282
|
+
scenarios
|
241
283
|
|
242
284
|
Returns:
|
243
285
|
Complete web URL
|
@@ -261,22 +303,27 @@ class GerritUrlBuilder:
|
|
261
303
|
Args:
|
262
304
|
project: Gerrit project name
|
263
305
|
change_number: Gerrit change number
|
264
|
-
base_path_override: Optional base path override for fallback
|
306
|
+
base_path_override: Optional base path override for fallback
|
307
|
+
scenarios
|
265
308
|
|
266
309
|
Returns:
|
267
310
|
Complete change URL
|
268
311
|
"""
|
269
|
-
# Don't URL-encode project names - Gerrit expects them as-is
|
312
|
+
# Don't URL-encode project names - Gerrit expects them as-is
|
313
|
+
# (backward compatibility)
|
270
314
|
path = f"c/{project}/+/{change_number}"
|
271
315
|
return self.web_url(path, base_path_override)
|
272
316
|
|
273
|
-
def hook_url(
|
317
|
+
def hook_url(
|
318
|
+
self, hook_name: str, base_path_override: str | None = None
|
319
|
+
) -> str:
|
274
320
|
"""
|
275
321
|
Build a URL for downloading Gerrit hooks.
|
276
322
|
|
277
323
|
Args:
|
278
324
|
hook_name: Name of the hook (e.g., "commit-msg")
|
279
|
-
base_path_override: Optional base path override for fallback
|
325
|
+
base_path_override: Optional base path override for fallback
|
326
|
+
scenarios
|
280
327
|
|
281
328
|
Returns:
|
282
329
|
Complete hook download URL
|
@@ -318,7 +365,8 @@ class GerritUrlBuilder:
|
|
318
365
|
"""
|
319
366
|
Get the web base path for URL construction.
|
320
367
|
|
321
|
-
This is useful when you need just the path component for manual URL
|
368
|
+
This is useful when you need just the path component for manual URL
|
369
|
+
building.
|
322
370
|
|
323
371
|
Args:
|
324
372
|
base_path_override: Optional base path override
|
@@ -326,7 +374,11 @@ class GerritUrlBuilder:
|
|
326
374
|
Returns:
|
327
375
|
Web base path with leading and trailing slashes (e.g., "/r/", "/")
|
328
376
|
"""
|
329
|
-
path =
|
377
|
+
path = (
|
378
|
+
base_path_override
|
379
|
+
if base_path_override is not None
|
380
|
+
else self._base_path
|
381
|
+
)
|
330
382
|
if path:
|
331
383
|
return f"/{path}/"
|
332
384
|
else:
|
@@ -334,10 +386,15 @@ class GerritUrlBuilder:
|
|
334
386
|
|
335
387
|
def __repr__(self) -> str:
|
336
388
|
"""String representation for debugging."""
|
337
|
-
return
|
389
|
+
return (
|
390
|
+
f"GerritUrlBuilder(host='{self.host}', "
|
391
|
+
f"base_path='{self._base_path}')"
|
392
|
+
)
|
338
393
|
|
339
394
|
|
340
|
-
def create_gerrit_url_builder(
|
395
|
+
def create_gerrit_url_builder(
|
396
|
+
host: str, base_path: str | None = None
|
397
|
+
) -> GerritUrlBuilder:
|
341
398
|
"""
|
342
399
|
Factory function to create a GerritUrlBuilder instance.
|
343
400
|
|
github2gerrit/github_api.py
CHANGED
@@ -44,7 +44,9 @@ class RateLimitExceededExceptionType(GithubExceptionType):
|
|
44
44
|
pass
|
45
45
|
|
46
46
|
|
47
|
-
def _load_github_classes() -> tuple[
|
47
|
+
def _load_github_classes() -> tuple[
|
48
|
+
Any | None, type[BaseException], type[BaseException]
|
49
|
+
]:
|
48
50
|
try:
|
49
51
|
exc_mod = import_module("github.GithubException")
|
50
52
|
ge = exc_mod.GithubException
|
@@ -151,17 +153,23 @@ def build_client(token: str | None = None) -> GhClient:
|
|
151
153
|
if auth_factory is not None and hasattr(auth_factory, "Token"):
|
152
154
|
auth_obj = auth_factory.Token(tok)
|
153
155
|
if base_url:
|
154
|
-
client_any = Github(
|
156
|
+
client_any = Github(
|
157
|
+
auth=auth_obj, per_page=100, base_url=base_url
|
158
|
+
)
|
155
159
|
else:
|
156
160
|
client_any = Github(auth=auth_obj, per_page=100)
|
157
161
|
else:
|
158
162
|
if base_url:
|
159
|
-
client_any = Github(
|
163
|
+
client_any = Github(
|
164
|
+
login_or_token=tok, per_page=100, base_url=base_url
|
165
|
+
)
|
160
166
|
else:
|
161
167
|
client_any = Github(login_or_token=tok, per_page=100)
|
162
168
|
except Exception:
|
163
169
|
if base_url:
|
164
|
-
client_any = Github(
|
170
|
+
client_any = Github(
|
171
|
+
login_or_token=tok, per_page=100, base_url=base_url
|
172
|
+
)
|
165
173
|
else:
|
166
174
|
client_any = Github(login_or_token=tok, per_page=100)
|
167
175
|
return cast(GhClient, client_any)
|
@@ -173,7 +181,10 @@ def get_repo_from_env(client: GhClient) -> GhRepository:
|
|
173
181
|
full = _getenv_str("GITHUB_REPOSITORY")
|
174
182
|
log.debug("GITHUB_REPOSITORY environment variable: '%s'", full)
|
175
183
|
if not full or "/" not in full:
|
176
|
-
log.error(
|
184
|
+
log.error(
|
185
|
+
"Invalid GITHUB_REPOSITORY: '%s' (expected format: 'owner/repo')",
|
186
|
+
full,
|
187
|
+
)
|
177
188
|
raise ValueError(_MSG_BAD_GITHUB_REPOSITORY)
|
178
189
|
repo = client.get_repo(full)
|
179
190
|
return repo
|
@@ -259,5 +270,7 @@ def close_pr(pr: GhPullRequest, *, comment: str | None = None) -> None:
|
|
259
270
|
try:
|
260
271
|
create_pr_comment(pr, comment)
|
261
272
|
except Exception as exc:
|
262
|
-
log.warning(
|
273
|
+
log.warning(
|
274
|
+
"Failed to add close comment to PR #%s: %s", pr.number, exc
|
275
|
+
)
|
263
276
|
pr.edit(state="closed")
|
github2gerrit/gitutils.py
CHANGED
@@ -53,7 +53,10 @@ if not log.handlers:
|
|
53
53
|
# Provide a minimal default if the app has not configured logging.
|
54
54
|
level_name = os.getenv("G2G_LOG_LEVEL", "INFO").upper()
|
55
55
|
level = getattr(logging, level_name, logging.INFO)
|
56
|
-
fmt =
|
56
|
+
fmt = (
|
57
|
+
"%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d | "
|
58
|
+
"%(message)s"
|
59
|
+
)
|
57
60
|
logging.basicConfig(level=level, format=fmt)
|
58
61
|
|
59
62
|
|
@@ -238,9 +241,14 @@ def run_cmd(
|
|
238
241
|
filtered_lines = []
|
239
242
|
skip_hint = False
|
240
243
|
for line in stderr_lines:
|
241
|
-
if
|
244
|
+
if (
|
245
|
+
"hint: Using '" in line
|
246
|
+
and "as the name for the initial branch" in line
|
247
|
+
):
|
242
248
|
skip_hint = True
|
243
|
-
elif (skip_hint and line.startswith("hint:")) or (
|
249
|
+
elif (skip_hint and line.startswith("hint:")) or (
|
250
|
+
skip_hint and not line.strip()
|
251
|
+
):
|
244
252
|
continue
|
245
253
|
else:
|
246
254
|
skip_hint = False
|
@@ -259,7 +267,8 @@ def non_interactive_env(include_git_ssh_command: bool = True) -> dict[str, str]:
|
|
259
267
|
|
260
268
|
Args:
|
261
269
|
include_git_ssh_command: Whether to include a default GIT_SSH_COMMAND.
|
262
|
-
Set to False when the caller will provide their
|
270
|
+
Set to False when the caller will provide their
|
271
|
+
own.
|
263
272
|
|
264
273
|
Returns:
|
265
274
|
Dictionary of environment variables for non-interactive operations.
|
@@ -332,10 +341,11 @@ def run_cmd_with_retries(
|
|
332
341
|
if predicate(res):
|
333
342
|
delay = _backoff_delay(attempt)
|
334
343
|
log.warning(
|
335
|
-
"Retrying (attempt %d) after transient error; delay %.1fs.
|
344
|
+
"Retrying (attempt %d) after transient error; delay %.1fs. "
|
345
|
+
"cmd=%s",
|
336
346
|
attempt,
|
337
347
|
delay,
|
338
|
-
|
348
|
+
" ".join(cmd) if isinstance(cmd, list) else str(cmd),
|
339
349
|
)
|
340
350
|
time.sleep(delay)
|
341
351
|
continue
|
@@ -483,14 +493,17 @@ def git_commit_amend(
|
|
483
493
|
if message is not None:
|
484
494
|
import tempfile as _tempfile
|
485
495
|
|
486
|
-
with _tempfile.NamedTemporaryFile(
|
496
|
+
with _tempfile.NamedTemporaryFile(
|
497
|
+
"w", delete=False, encoding="utf-8"
|
498
|
+
) as _tf:
|
487
499
|
_tf.write(message)
|
488
500
|
_tf.flush()
|
489
501
|
tmp_path = Path(_tf.name)
|
490
502
|
message_file = tmp_path
|
491
503
|
message = None
|
492
504
|
|
493
|
-
# Determine whether to add -s; only suppress if message already has a
|
505
|
+
# Determine whether to add -s; only suppress if message already has a
|
506
|
+
# sign-off for current committer
|
494
507
|
effective_signoff = bool(signoff)
|
495
508
|
try:
|
496
509
|
import os
|
@@ -516,7 +529,12 @@ def git_commit_amend(
|
|
516
529
|
for ln in text.splitlines():
|
517
530
|
if ln.lower().startswith("signed-off-by:"):
|
518
531
|
m = re.search(r"<([^>]+)>", ln)
|
519
|
-
if
|
532
|
+
if (
|
533
|
+
m
|
534
|
+
and committer_email
|
535
|
+
and m.group(1).strip().lower()
|
536
|
+
== committer_email.lower()
|
537
|
+
):
|
520
538
|
return True
|
521
539
|
return False
|
522
540
|
|
@@ -588,14 +606,17 @@ def git_commit_new(
|
|
588
606
|
if message is not None:
|
589
607
|
import tempfile as _tempfile
|
590
608
|
|
591
|
-
with _tempfile.NamedTemporaryFile(
|
609
|
+
with _tempfile.NamedTemporaryFile(
|
610
|
+
"w", delete=False, encoding="utf-8"
|
611
|
+
) as _tf:
|
592
612
|
_tf.write(message)
|
593
613
|
_tf.flush()
|
594
614
|
tmp_path = Path(_tf.name)
|
595
615
|
message_file = tmp_path
|
596
616
|
message = None
|
597
617
|
|
598
|
-
# Determine whether to add -s; only suppress if message already has a
|
618
|
+
# Determine whether to add -s; only suppress if message already has a
|
619
|
+
# sign-off for current committer
|
599
620
|
effective_signoff = bool(signoff)
|
600
621
|
try:
|
601
622
|
import os
|
@@ -621,7 +642,12 @@ def git_commit_new(
|
|
621
642
|
for ln in text.splitlines():
|
622
643
|
if ln.lower().startswith("signed-off-by:"):
|
623
644
|
m = re.search(r"<([^>]+)>", ln)
|
624
|
-
if
|
645
|
+
if (
|
646
|
+
m
|
647
|
+
and committer_email
|
648
|
+
and m.group(1).strip().lower()
|
649
|
+
== committer_email.lower()
|
650
|
+
):
|
625
651
|
return True
|
626
652
|
return False
|
627
653
|
|
@@ -693,7 +719,8 @@ def _parse_trailers(text: str) -> dict[str, list[str]]:
|
|
693
719
|
|
694
720
|
Git trailers are key-value pairs that appear at the end of commit messages,
|
695
721
|
separated from the body by a blank line. This function only parses trailers
|
696
|
-
from the actual footer section to avoid false positives from the message
|
722
|
+
from the actual footer section to avoid false positives from the message
|
723
|
+
body.
|
697
724
|
"""
|
698
725
|
trailers: dict[str, list[str]] = {}
|
699
726
|
lines = text.splitlines()
|
@@ -804,7 +831,9 @@ def git_config_get_all(
|
|
804
831
|
try:
|
805
832
|
res = git_quiet(args, cwd=None)
|
806
833
|
if res.returncode == 0:
|
807
|
-
values = [
|
834
|
+
values = [
|
835
|
+
ln.strip() for ln in res.stdout.splitlines() if ln.strip()
|
836
|
+
]
|
808
837
|
return values
|
809
838
|
else:
|
810
839
|
return []
|