github2gerrit 1.2.0__py3-none-any.whl → 1.2.2__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 CHANGED
@@ -2226,7 +2226,7 @@ def _process() -> None:
2226
2226
  ):
2227
2227
  try:
2228
2228
  log.debug(
2229
- "🔍 Checking for Gerrit change to abandon for PR #%s",
2229
+ "Checking for Gerrit change to abandon for PR #%s",
2230
2230
  gh.pr_number,
2231
2231
  )
2232
2232
  change_number = abandon_gerrit_change_for_closed_pr(
@@ -2238,16 +2238,29 @@ def _process() -> None:
2238
2238
  progress_tracker=None,
2239
2239
  )
2240
2240
  if change_number:
2241
- gerrit_change_url = (
2242
- f"https://{data.gerrit_server}/c/"
2243
- f"{data.gerrit_project}/+/{change_number}"
2244
- )
2245
- log.debug(
2246
- "✅ Successfully abandoned Gerrit change %s "
2247
- "for pull request #%s",
2248
- gerrit_change_url,
2249
- gh.pr_number,
2250
- )
2241
+ try:
2242
+ from .gerrit_urls import create_gerrit_url_builder
2243
+
2244
+ _url_builder = create_gerrit_url_builder(
2245
+ data.gerrit_server
2246
+ )
2247
+ gerrit_change_url = _url_builder.change_url(
2248
+ data.gerrit_project,
2249
+ int(change_number),
2250
+ )
2251
+ log.debug(
2252
+ "Successfully abandoned Gerrit "
2253
+ "change %s for pull request #%s",
2254
+ gerrit_change_url,
2255
+ gh.pr_number,
2256
+ )
2257
+ except Exception:
2258
+ log.debug(
2259
+ "Successfully abandoned Gerrit "
2260
+ "change %s for pull request #%s",
2261
+ change_number,
2262
+ gh.pr_number,
2263
+ )
2251
2264
  # Console output already done by
2252
2265
  # abandon_gerrit_change_for_closed_pr
2253
2266
  else:
@@ -2295,7 +2308,7 @@ def _process() -> None:
2295
2308
  log.warning("Gerrit cleanup failed: %s", exc)
2296
2309
 
2297
2310
  log.debug(
2298
- "Cleanup operations completed for closed PR #%s",
2311
+ "Cleanup operations completed for closed PR #%s",
2299
2312
  gh.pr_number or "unknown",
2300
2313
  )
2301
2314
  return
@@ -401,8 +401,9 @@ class CommitNormalizer:
401
401
  # Remove trailing ellipsis
402
402
  title = re.sub(r"\s*[.]{3,}.*$", "", title)
403
403
 
404
- # Remove markdown formatting
405
- title = re.sub(r"[*_`]", "", title)
404
+ # Remove markdown bold/code formatting but preserve underscores
405
+ # (which appear in package names and filesystem paths).
406
+ title = re.sub(r"[*`]", "", title)
406
407
 
407
408
  # For dependabot titles, extract the essential information
408
409
  for pattern in DEPENDABOT_PATTERNS:
github2gerrit/core.py CHANGED
@@ -154,6 +154,93 @@ def _clean_ellipses_from_message(message: str) -> str:
154
154
  return "\n".join(cleaned_lines)
155
155
 
156
156
 
157
+ def _clean_squash_title_line(title_line: str | None) -> str:
158
+ """Clean and truncate a squashed commit title line.
159
+
160
+ Handles markdown removal, separator splitting, and length
161
+ truncation while preserving conventional commit prefixes
162
+ and underscores in package/path names.
163
+
164
+ Args:
165
+ title_line: Raw title line from git log output.
166
+
167
+ Returns:
168
+ Cleaned title line, safe for use as a commit subject.
169
+ """
170
+ from .similarity import CC_PREFIX_RE
171
+
172
+ if not title_line:
173
+ return ""
174
+
175
+ # Remove markdown links
176
+ title_line = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", title_line)
177
+ # Remove trailing ellipsis/truncation
178
+ title_line = re.sub(r"\s*[.]{3,}.*$", "", title_line)
179
+ # Split on common separators to avoid leaking body content
180
+ for separator in [". Bumps ", " Bumps ", ". - ", " - "]:
181
+ if separator in title_line:
182
+ title_line = title_line.split(separator)[0].strip()
183
+ break
184
+ # Remove markdown bold/code formatting but preserve underscores
185
+ # (which appear in package names and filesystem paths).
186
+ title_line = re.sub(r"[*`]", "", title_line).strip()
187
+
188
+ if len(title_line) > 100:
189
+ # Detect conventional commit prefix length so that the
190
+ # ": " break-point does not split on the prefix separator
191
+ # (e.g. "Build(deps): " should not be treated as a sentence
192
+ # break).
193
+ cc_match = CC_PREFIX_RE.match(title_line)
194
+ cc_prefix_len = cc_match.end() if cc_match else 0
195
+
196
+ break_points = [". ", "! ", "? ", " - ", ": "]
197
+ max_bp_len = max(len(bp) for bp in break_points)
198
+ truncated = False
199
+ for bp in break_points:
200
+ # For the ": " break-point, start searching after the
201
+ # conventional commit prefix to avoid splitting there.
202
+ search_start = cc_prefix_len if bp == ": " else 0
203
+ # Extend the slice by (max_bp_len - 1) so that a
204
+ # break-point starting just before position 100 is
205
+ # still detected even if it spans across the boundary.
206
+ candidate_end = min(len(title_line), 100 + max_bp_len - 1)
207
+ candidate = title_line[search_start:candidate_end]
208
+ bp_offset = candidate.find(bp)
209
+ if bp_offset != -1:
210
+ bp_idx = search_start + bp_offset
211
+ # Only use this break-point if it starts within
212
+ # the 100-char limit.
213
+ if bp_idx >= 100:
214
+ continue
215
+ # Punctuation break-points (". ", ": ") — include
216
+ # the punctuation mark. Separator break-points
217
+ # (" - ") — truncate before the separator.
218
+ if bp[0].isspace():
219
+ title_line = title_line[:bp_idx].rstrip()
220
+ else:
221
+ title_line = title_line[
222
+ : bp_idx + len(bp.rstrip())
223
+ ].rstrip()
224
+ truncated = True
225
+ break
226
+
227
+ if not truncated and cc_prefix_len == 0:
228
+ # Non-CC title with no break-point found: fall back
229
+ # to word-boundary truncation at 100 characters.
230
+ words = title_line[:100].split()
231
+ title_line = (
232
+ " ".join(words[:-1])
233
+ if len(words) > 1
234
+ else title_line[:100].rstrip()
235
+ )
236
+ # For CC titles with no break-point: pass through the
237
+ # full title. The length is inherent to the structured
238
+ # subject (e.g. long dependency paths), not body-content
239
+ # leakage.
240
+
241
+ return title_line
242
+
243
+
157
244
  # ---------------------
158
245
  # Utility functions
159
246
  # ---------------------
@@ -678,6 +765,9 @@ class Orchestrator:
678
765
  2. GitHub-Hash trailer matching
679
766
  3. GitHub-PR trailer URL matching
680
767
  4. Mapping comment parsing from PR comments
768
+ 5. Dependency package match — find an open change that
769
+ bumps the same dependency (for Dependabot / Renovate
770
+ supersession).
681
771
 
682
772
  Args:
683
773
  gh: GitHub context containing PR information
@@ -793,6 +883,10 @@ class Orchestrator:
793
883
  except Exception as exc:
794
884
  log.debug("GitHub-Hash trailer query failed: %s", exc)
795
885
 
886
+ # Cache the PR title for reuse across strategies 4 and 5
887
+ # so we don't duplicate GitHub API requests.
888
+ cached_pr_title: str = ""
889
+
796
890
  # Strategy 4: Parse mapping comments from PR
797
891
  try:
798
892
  from .mapping_comment import parse_mapping_comments
@@ -800,6 +894,7 @@ class Orchestrator:
800
894
  client_gh = build_client()
801
895
  repo = get_repo_from_env(client_gh)
802
896
  pr_obj = get_pull(repo, int(gh.pr_number))
897
+ cached_pr_title = getattr(pr_obj, "title", "") or ""
803
898
 
804
899
  issue = pr_obj.as_issue()
805
900
  comments = list(issue.get_comments())
@@ -830,6 +925,110 @@ class Orchestrator:
830
925
  except Exception as exc:
831
926
  log.debug("Mapping comment parsing failed: %s", exc)
832
927
 
928
+ # Strategy 5: Dependency package match (supersession)
929
+ # When a new Dependabot/Renovate PR bumps the same dependency
930
+ # as an existing open Gerrit change, reuse that Change-Id so
931
+ # the push creates a new patchset instead of a duplicate change.
932
+ try:
933
+ from .gerrit_query import GerritChange
934
+ from .gerrit_query import query_open_changes_by_project
935
+ from .gerrit_rest import build_client_for_host
936
+ from .similarity import extract_dependency_package_from_subject
937
+ from .trailers import GITHUB_PR_TRAILER
938
+ from .trailers import parse_trailers
939
+
940
+ # Reuse PR title cached by Strategy 4 to avoid a
941
+ # duplicate GitHub API request.
942
+ pr_title = cached_pr_title
943
+ if not pr_title:
944
+ log.debug(
945
+ "Strategy 5: PR title cache miss, fetching from GitHub API",
946
+ )
947
+ try:
948
+ gh_client = build_client()
949
+ gh_repo = get_repo_from_env(gh_client)
950
+ pr_obj = get_pull(gh_repo, int(gh.pr_number))
951
+ pr_title = getattr(pr_obj, "title", "") or ""
952
+ except Exception:
953
+ pr_title = ""
954
+
955
+ current_pkg = extract_dependency_package_from_subject(pr_title)
956
+ if current_pkg:
957
+ log.debug(
958
+ "Strategy 5: searching for open changes that "
959
+ "bump dependency '%s'",
960
+ current_pkg,
961
+ )
962
+ dep_client = build_client_for_host(gerrit.host)
963
+ open_changes = query_open_changes_by_project(
964
+ dep_client,
965
+ gerrit.project,
966
+ branch=gh.base_ref,
967
+ max_results=200,
968
+ )
969
+
970
+ # Collect all matching changes, then select the
971
+ # oldest one (lowest change number) to avoid
972
+ # "downgrading" a newer change by uploading an
973
+ # older patchset to it.
974
+ candidates: list[tuple[int, GerritChange]] = []
975
+ for change in open_changes:
976
+ candidate_pkg = extract_dependency_package_from_subject(
977
+ change.subject
978
+ )
979
+ if candidate_pkg and candidate_pkg == current_pkg:
980
+ # Verify this is a GitHub2Gerrit change
981
+ commit_msg = change.commit_message or ""
982
+ trailers = parse_trailers(commit_msg)
983
+ if GITHUB_PR_TRAILER not in trailers:
984
+ log.debug(
985
+ "Strategy 5: skipping change %s "
986
+ "(no GitHub2Gerrit metadata)",
987
+ change.number,
988
+ )
989
+ continue
990
+ try:
991
+ change_num = int(change.number)
992
+ except (TypeError, ValueError):
993
+ log.debug(
994
+ "Strategy 5: skipping change with "
995
+ "invalid number %r for subject %r",
996
+ change.number,
997
+ change.subject,
998
+ )
999
+ continue
1000
+ candidates.append((change_num, change))
1001
+
1002
+ if candidates:
1003
+ # Prefer the oldest open change so the newest
1004
+ # PR always updates the original change and
1005
+ # the post-push sweep abandons the rest.
1006
+ candidates.sort(key=lambda t: t[0])
1007
+ _, oldest = candidates[0]
1008
+ change_ids = [oldest.change_id]
1009
+ log.info(
1010
+ "Found superseding target by dependency "
1011
+ "package '%s': change %s (%s) "
1012
+ "(oldest of %d candidate(s))",
1013
+ current_pkg,
1014
+ oldest.number,
1015
+ oldest.subject,
1016
+ len(candidates),
1017
+ )
1018
+ return change_ids
1019
+
1020
+ log.debug(
1021
+ "No open changes found for dependency '%s'",
1022
+ current_pkg,
1023
+ )
1024
+ else:
1025
+ log.debug(
1026
+ "Strategy 5 skipped: could not extract dependency "
1027
+ "package from PR title"
1028
+ )
1029
+ except Exception as exc:
1030
+ log.debug("Dependency package strategy failed: %s", exc)
1031
+
833
1032
  log.warning(
834
1033
  "⚠️ No existing Gerrit changes found for PR #%s",
835
1034
  gh.pr_number,
@@ -1936,6 +2135,49 @@ class Orchestrator:
1936
2135
  # Validate that no unexpected files were committed
1937
2136
  self._validate_committed_files(gh, result)
1938
2137
 
2138
+ # Post-push supersession sweep (Option A fallback).
2139
+ # After a successful push, check whether other open Gerrit
2140
+ # changes in the same project bump the same dependency
2141
+ # package. If Strategy 5 already reused the old Change-Id
2142
+ # (update-in-place), no duplicates should exist. If that
2143
+ # path was skipped (e.g. non-dependency PR, or the query
2144
+ # failed), this sweep catches and abandons stale changes.
2145
+ if not inputs.dry_run and gerrit and prep.change_ids:
2146
+ try:
2147
+ from .gerrit_pr_closer import (
2148
+ abandon_superseded_dependency_changes,
2149
+ )
2150
+
2151
+ # Derive the subject from the pushed commit,
2152
+ # regardless of whether change URL lookup
2153
+ # succeeded.
2154
+ push_subject = ""
2155
+ try:
2156
+ push_subject = run_cmd(
2157
+ [
2158
+ "git",
2159
+ "show",
2160
+ "-s",
2161
+ "--pretty=format:%s",
2162
+ "HEAD",
2163
+ ],
2164
+ cwd=self.workspace,
2165
+ ).stdout.strip()
2166
+ except Exception:
2167
+ push_subject = ""
2168
+
2169
+ if push_subject:
2170
+ abandon_superseded_dependency_changes(
2171
+ gerrit_server=gerrit.host,
2172
+ gerrit_project=gerrit.project,
2173
+ current_subject=push_subject,
2174
+ exclude_change_ids=prep.change_ids,
2175
+ dry_run=False,
2176
+ target_branch=self._resolve_target_branch(),
2177
+ )
2178
+ except Exception as exc:
2179
+ log.debug("Post-push supersession sweep skipped: %s", exc)
2180
+
1939
2181
  self._close_pull_request_if_required(gh)
1940
2182
 
1941
2183
  log.debug("Pipeline complete: %s", result)
@@ -3564,32 +3806,7 @@ class Orchestrator:
3564
3806
  return message_lines, signed_off, change_ids
3565
3807
 
3566
3808
  def _clean_title_line(title_line: str) -> str:
3567
- # Remove markdown links
3568
- title_line = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", title_line)
3569
- # Remove trailing ellipsis/truncation
3570
- title_line = re.sub(r"\s*[.]{3,}.*$", "", title_line)
3571
- # Split on common separators to avoid leaking body content
3572
- for separator in [". Bumps ", " Bumps ", ". - ", " - "]:
3573
- if separator in title_line:
3574
- title_line = title_line.split(separator)[0].strip()
3575
- break
3576
- # Remove simple markdown/formatting artifacts
3577
- title_line = re.sub(r"[*_`]", "", title_line).strip()
3578
- if len(title_line) > 100:
3579
- break_points = [". ", "! ", "? ", " - ", ": "]
3580
- for bp in break_points:
3581
- if bp in title_line[:100]:
3582
- title_line = title_line[
3583
- : title_line.index(bp) + len(bp.strip())
3584
- ]
3585
- break
3586
- else:
3587
- words = title_line[:100].split()
3588
- title_line = (
3589
- " ".join(words[:-1])
3590
- if len(words) > 1
3591
- else title_line[:100].rstrip()
3592
- )
3809
+ title_line = _clean_squash_title_line(title_line)
3593
3810
 
3594
3811
  # Apply conventional commit normalization if enabled
3595
3812
  if inputs.normalise_commit and gh.pr_number:
@@ -54,6 +54,32 @@ FORCE_ABANDONED_CLEANUP = _env_bool("CLEANUP_ABANDONED", True)
54
54
  FORCE_GERRIT_CLEANUP = _env_bool("CLEANUP_GERRIT", True)
55
55
 
56
56
 
57
+ def _build_gerrit_change_url(
58
+ gerrit_server: str,
59
+ gerrit_project: str,
60
+ change_number: str,
61
+ ) -> str | None:
62
+ """Build a Gerrit change URL from a change number string.
63
+
64
+ Returns the URL string, or ``None`` when URL generation fails.
65
+ """
66
+ try:
67
+ from .gerrit_urls import create_gerrit_url_builder
68
+
69
+ url_builder = create_gerrit_url_builder(gerrit_server)
70
+ return url_builder.change_url(gerrit_project, int(change_number))
71
+ except Exception:
72
+ log.debug(
73
+ "Could not build Gerrit change URL for server=%r, project=%r, "
74
+ "change_number=%r; skipping URL generation",
75
+ gerrit_server,
76
+ gerrit_project,
77
+ change_number,
78
+ exc_info=True,
79
+ )
80
+ return None
81
+
82
+
57
83
  def extract_change_number_from_url(
58
84
  gerrit_change_url: str,
59
85
  ) -> tuple[str, str] | None:
@@ -1078,7 +1104,7 @@ def abandon_gerrit_change_for_closed_pr(
1078
1104
  (or would be in dry-run), None otherwise
1079
1105
  """
1080
1106
  log.debug(
1081
- "🔍 Looking for Gerrit change associated with PR #%d",
1107
+ "Looking for Gerrit change associated with PR #%d",
1082
1108
  pr_number,
1083
1109
  )
1084
1110
 
@@ -1232,24 +1258,35 @@ def abandon_gerrit_change_for_closed_pr(
1232
1258
 
1233
1259
  # Abandon the Gerrit change
1234
1260
  if not dry_run:
1235
- gerrit_change_url = (
1236
- f"https://{gerrit_server}/c/"
1237
- f"{gerrit_project}/+/{change_number}"
1261
+ gerrit_change_url = _build_gerrit_change_url(
1262
+ gerrit_server, gerrit_project, change_number
1238
1263
  )
1239
1264
  _abandon_gerrit_change(
1240
1265
  gerrit_client,
1241
1266
  change_number,
1242
1267
  abandon_message,
1243
1268
  )
1244
- log.debug(
1245
- "✅ Abandoned Gerrit change %s: %s",
1246
- change_number,
1247
- gerrit_change_url,
1248
- )
1249
- safe_console_print(
1250
- f"✅ Abandoned Gerrit change {gerrit_change_url} "
1251
- f"for pull request #{pr_number}"
1252
- )
1269
+ if gerrit_change_url:
1270
+ log.debug(
1271
+ "Abandoned Gerrit change %s: %s",
1272
+ change_number,
1273
+ gerrit_change_url,
1274
+ )
1275
+ safe_console_print(
1276
+ f" Abandoned Gerrit change "
1277
+ f"{gerrit_change_url} "
1278
+ f"for pull request #{pr_number}"
1279
+ )
1280
+ else:
1281
+ log.debug(
1282
+ "Abandoned Gerrit change %s",
1283
+ change_number,
1284
+ )
1285
+ safe_console_print(
1286
+ f"✅ Abandoned Gerrit change "
1287
+ f"{change_number} "
1288
+ f"for pull request #{pr_number}"
1289
+ )
1253
1290
  else:
1254
1291
  log.debug(
1255
1292
  "DRY-RUN: Would abandon Gerrit change %s",
@@ -1274,9 +1311,8 @@ def abandon_gerrit_change_for_closed_pr(
1274
1311
  )
1275
1312
 
1276
1313
  if not dry_run:
1277
- gerrit_change_url = (
1278
- f"https://{gerrit_server}/c/"
1279
- f"{gerrit_project}/+/{change_number}"
1314
+ gerrit_change_url = _build_gerrit_change_url(
1315
+ gerrit_server, gerrit_project, change_number
1280
1316
  )
1281
1317
  _abandon_gerrit_change(
1282
1318
  gerrit_client,
@@ -1284,10 +1320,18 @@ def abandon_gerrit_change_for_closed_pr(
1284
1320
  simple_message,
1285
1321
  )
1286
1322
  log.debug("Abandoned Gerrit change %s", change_number)
1287
- safe_console_print(
1288
- f"✅ Abandoned Gerrit change {gerrit_change_url} "
1289
- f"for pull request #{pr_number}"
1290
- )
1323
+ if gerrit_change_url:
1324
+ safe_console_print(
1325
+ f" Abandoned Gerrit change "
1326
+ f"{gerrit_change_url} "
1327
+ f"for pull request #{pr_number}"
1328
+ )
1329
+ else:
1330
+ safe_console_print(
1331
+ f"✅ Abandoned Gerrit change "
1332
+ f"{change_number} "
1333
+ f"for pull request #{pr_number}"
1334
+ )
1291
1335
  else:
1292
1336
  log.debug(
1293
1337
  "DRY-RUN: Would abandon Gerrit change %s",
@@ -1454,20 +1498,25 @@ def cleanup_closed_github_prs(
1454
1498
 
1455
1499
  # Abandon the Gerrit change
1456
1500
  if not dry_run:
1457
- gerrit_change_url = (
1458
- f"https://{gerrit_server}/c/"
1459
- f"{gerrit_project}/+/{change_number}"
1501
+ gerrit_change_url = _build_gerrit_change_url(
1502
+ gerrit_server, gerrit_project, change_number
1460
1503
  )
1461
1504
  _abandon_gerrit_change(
1462
1505
  gerrit_client,
1463
1506
  change_number,
1464
1507
  abandon_message,
1465
1508
  )
1466
- log.info(
1467
- "Abandoned Gerrit change %s: %s",
1468
- change_number,
1469
- gerrit_change_url,
1470
- )
1509
+ if gerrit_change_url:
1510
+ log.info(
1511
+ "Abandoned Gerrit change %s: %s",
1512
+ change_number,
1513
+ gerrit_change_url,
1514
+ )
1515
+ else:
1516
+ log.info(
1517
+ "Abandoned Gerrit change %s",
1518
+ change_number,
1519
+ )
1471
1520
  else:
1472
1521
  log.info(
1473
1522
  "DRY-RUN: Would abandon Gerrit change %s",
@@ -1652,3 +1701,157 @@ def _abandon_gerrit_change(
1652
1701
  except Exception:
1653
1702
  log.exception("Failed to abandon Gerrit change %s", change_number)
1654
1703
  raise
1704
+
1705
+
1706
+ def abandon_superseded_dependency_changes(
1707
+ gerrit_server: str,
1708
+ gerrit_project: str,
1709
+ current_subject: str,
1710
+ exclude_change_ids: list[str],
1711
+ *,
1712
+ dry_run: bool = False,
1713
+ target_branch: str | None = None,
1714
+ ) -> list[str]:
1715
+ """Abandon open Gerrit changes superseded by a newer dependency update.
1716
+
1717
+ After pushing a new dependency-update change, this function
1718
+ queries Gerrit for other open changes in the same project that
1719
+ bump the **same** dependency package. Matches are abandoned
1720
+ with an explanatory message.
1721
+
1722
+ This serves as the "Option A fallback" when the primary
1723
+ update-in-place strategy (Change-Id reuse) could not be
1724
+ applied.
1725
+
1726
+ Args:
1727
+ gerrit_server: Gerrit server hostname.
1728
+ gerrit_project: Gerrit project name.
1729
+ current_subject: Subject of the change that was just pushed.
1730
+ exclude_change_ids: Change-IDs to exclude (the change we
1731
+ just pushed, so we don't abandon ourselves).
1732
+ dry_run: If True, log but do not actually abandon.
1733
+ target_branch: Optional Gerrit branch name. When provided,
1734
+ only changes targeting this branch are considered.
1735
+
1736
+ Returns:
1737
+ List of Gerrit change numbers that were abandoned
1738
+ (or would be in dry-run mode).
1739
+ """
1740
+ from .gerrit_query import query_open_changes_by_project
1741
+ from .gerrit_rest import build_client_for_host
1742
+ from .gerrit_urls import create_gerrit_url_builder
1743
+ from .similarity import extract_dependency_package_from_subject
1744
+ from .trailers import GITHUB_PR_TRAILER
1745
+ from .trailers import parse_trailers
1746
+
1747
+ current_pkg = extract_dependency_package_from_subject(current_subject)
1748
+ if not current_pkg:
1749
+ log.debug(
1750
+ "Cannot extract dependency package from subject: %s",
1751
+ current_subject,
1752
+ )
1753
+ return []
1754
+
1755
+ log.info(
1756
+ "Checking for superseded dependency changes (package: %s)",
1757
+ current_pkg,
1758
+ )
1759
+
1760
+ abandoned: list[str] = []
1761
+ try:
1762
+ client = build_client_for_host(gerrit_server)
1763
+ open_changes = query_open_changes_by_project(
1764
+ client, gerrit_project, branch=target_branch, max_results=200
1765
+ )
1766
+
1767
+ url_builder = create_gerrit_url_builder(gerrit_server)
1768
+
1769
+ for change in open_changes:
1770
+ # Skip the change(s) we just pushed
1771
+ if change.change_id in exclude_change_ids:
1772
+ continue
1773
+
1774
+ # Only target changes created by GitHub2Gerrit
1775
+ commit_msg = change.commit_message or ""
1776
+ trailers = parse_trailers(commit_msg)
1777
+ if GITHUB_PR_TRAILER not in trailers:
1778
+ log.debug(
1779
+ "Skipping change %s: no GitHub2Gerrit "
1780
+ "metadata (missing %s trailer)",
1781
+ change.number,
1782
+ GITHUB_PR_TRAILER,
1783
+ )
1784
+ continue
1785
+
1786
+ candidate_pkg = extract_dependency_package_from_subject(
1787
+ change.subject
1788
+ )
1789
+ if not candidate_pkg or candidate_pkg != current_pkg:
1790
+ continue
1791
+
1792
+ # Parse change number for URL construction
1793
+ try:
1794
+ candidate_num = int(change.number)
1795
+ except (TypeError, ValueError):
1796
+ log.debug(
1797
+ "Skipping change with unparsable number: %r",
1798
+ change.number,
1799
+ )
1800
+ continue
1801
+
1802
+ change_url = url_builder.change_url(gerrit_project, candidate_num)
1803
+ log.info(
1804
+ "Found superseded change %s (%s)",
1805
+ change.number,
1806
+ change.subject,
1807
+ )
1808
+
1809
+ if dry_run:
1810
+ log.info(
1811
+ "DRY-RUN: Would abandon superseded change %s",
1812
+ change_url,
1813
+ )
1814
+ abandoned.append(str(change.number))
1815
+ continue
1816
+
1817
+ superseding_info = f"New change subject: {current_subject}"
1818
+ if exclude_change_ids:
1819
+ superseding_info += "\nChange-Id(s): " + ", ".join(
1820
+ str(cid) for cid in exclude_change_ids
1821
+ )
1822
+ abandon_msg = (
1823
+ f"Superseded by a newer update for {current_pkg}\n\n"
1824
+ f"{superseding_info}\n\n"
1825
+ "This change was automatically abandoned by "
1826
+ "GitHub2Gerrit because a newer dependency update "
1827
+ "for the same package was pushed."
1828
+ )
1829
+ try:
1830
+ _abandon_gerrit_change(client, str(change.number), abandon_msg)
1831
+ log.info(
1832
+ "Abandoned superseded change: %s",
1833
+ change_url,
1834
+ )
1835
+ abandoned.append(str(change.number))
1836
+ except Exception as exc:
1837
+ log.warning(
1838
+ "Failed to abandon superseded change %s: %s",
1839
+ change_url,
1840
+ exc,
1841
+ )
1842
+
1843
+ except Exception as exc:
1844
+ log.warning(
1845
+ "Superseded dependency change sweep failed (non-fatal): %s",
1846
+ exc,
1847
+ )
1848
+
1849
+ if abandoned:
1850
+ log.info(
1851
+ "Supersession sweep complete: abandoned %d change(s)",
1852
+ len(abandoned),
1853
+ )
1854
+ else:
1855
+ log.debug("No superseded dependency changes found")
1856
+
1857
+ return abandoned
@@ -10,6 +10,7 @@ based on topics, with support for pagination and safe parsing.
10
10
  import logging
11
11
  from dataclasses import dataclass
12
12
  from typing import Any
13
+ from urllib.parse import quote
13
14
 
14
15
  from .gerrit_rest import GerritRestClient
15
16
 
@@ -17,6 +18,20 @@ from .gerrit_rest import GerritRestClient
17
18
  log = logging.getLogger(__name__)
18
19
 
19
20
 
21
+ def _gerrit_quote(value: str) -> str:
22
+ """Escape a value for safe use inside Gerrit query double-quotes.
23
+
24
+ Gerrit query syntax uses double-quoted strings for values
25
+ containing special characters. Backslashes and double-quotes
26
+ inside the value must be escaped to prevent query injection or
27
+ malformed queries (e.g. branch names containing ``"``).
28
+
29
+ Returns:
30
+ The escaped string (without surrounding quotes).
31
+ """
32
+ return value.replace("\\", "\\\\").replace('"', '\\"')
33
+
34
+
20
35
  @dataclass
21
36
  class GerritChange:
22
37
  """Represents a Gerrit change from query results."""
@@ -82,7 +97,7 @@ def query_changes_by_topic(
82
97
 
83
98
  # Build query string
84
99
  status_query = " OR ".join(f"status:{status}" for status in statuses)
85
- query = f"topic:{topic} AND ({status_query})"
100
+ query = f'topic:"{_gerrit_quote(topic)}" AND ({status_query})'
86
101
 
87
102
  log.debug("Querying Gerrit for changes: %s", query)
88
103
 
@@ -105,6 +120,56 @@ def query_changes_by_topic(
105
120
  return changes
106
121
 
107
122
 
123
+ def query_open_changes_by_project(
124
+ client: GerritRestClient,
125
+ project: str,
126
+ *,
127
+ branch: str | None = None,
128
+ max_results: int = 100,
129
+ ) -> list[GerritChange]:
130
+ """Query open changes owned by the current user in a Gerrit project.
131
+
132
+ Used by the supersession sweep to discover open changes that
133
+ may be superseded by a newer dependency update. Only returns
134
+ changes owned by the authenticated user (``owner:self``) to
135
+ avoid acting on unrelated human-authored changes.
136
+
137
+ Args:
138
+ client: Gerrit REST client.
139
+ project: Gerrit project name (e.g. ``myorg/myrepo``).
140
+ branch: Optional Gerrit branch name to scope the query.
141
+ When provided, only changes targeting this branch are
142
+ returned.
143
+ max_results: Maximum number of results to return.
144
+
145
+ Returns:
146
+ List of open ``GerritChange`` objects.
147
+ """
148
+ query = f'project:"{_gerrit_quote(project)}" status:open owner:self'
149
+ if branch:
150
+ query += f' branch:"{_gerrit_quote(branch)}"'
151
+ log.debug("Querying Gerrit for open changes: %s", query)
152
+
153
+ try:
154
+ changes = _execute_query_with_pagination(
155
+ client, query, max_results=max_results
156
+ )
157
+ log.debug(
158
+ "Found %d open changes in project '%s'",
159
+ len(changes),
160
+ project,
161
+ )
162
+ except Exception as exc:
163
+ log.warning(
164
+ "Failed to query open Gerrit changes for project '%s': %s",
165
+ project,
166
+ exc,
167
+ )
168
+ return []
169
+ else:
170
+ return changes
171
+
172
+
108
173
  def _execute_query_with_pagination(
109
174
  client: GerritRestClient,
110
175
  query: str,
@@ -135,7 +200,7 @@ def _execute_query_with_pagination(
135
200
  # Build query URL with parameters
136
201
  # Gerrit REST API: /changes/?q=query&n=limit&S=skip&o=options
137
202
  query_params = [
138
- f"q={query}",
203
+ f"q={quote(query, safe='')}",
139
204
  f"n={current_limit}",
140
205
  f"S={start}",
141
206
  "o=CURRENT_REVISION",
github2gerrit/netrc.py CHANGED
@@ -769,9 +769,8 @@ def resolve_gerrit_credentials(
769
769
 
770
770
  if env_user and env_pass:
771
771
  log.debug(
772
- "Using credentials from environment variables %s/%s",
772
+ "Using credentials from environment variable %s",
773
773
  env_username_var,
774
- env_password_var,
775
774
  )
776
775
  return GerritCredentials(
777
776
  username=env_user,
@@ -787,9 +786,8 @@ def resolve_gerrit_credentials(
787
786
 
788
787
  if fallback_user and fallback_pass:
789
788
  log.debug(
790
- "Using credentials from fallback environment variables %s/%s",
789
+ "Using credentials from fallback environment variable %s",
791
790
  fallback_env_username_var,
792
- fallback_env_password_var,
793
791
  )
794
792
  return GerritCredentials(
795
793
  username=fallback_user,
@@ -803,9 +801,9 @@ def resolve_gerrit_credentials(
803
801
  fallback_user = os.getenv(fallback_env_username_var, "").strip()
804
802
  if fallback_user and env_pass:
805
803
  log.debug(
806
- "Using credentials from mixed environment variables %s/%s",
804
+ "Using credentials from mixed environment variables"
805
+ " (%s + primary password)",
807
806
  fallback_env_username_var,
808
- env_password_var,
809
807
  )
810
808
  return GerritCredentials(
811
809
  username=fallback_user,
@@ -112,7 +112,7 @@ class DependabotRule(FilterRule):
112
112
  "Bumps " in title and " from " in title and " to " in title,
113
113
  "Dependabot will resolve any conflicts" in body,
114
114
  "<details>" in body and "<summary>" in body,
115
- "camo.githubusercontent.com" in body,
115
+ "https://camo.githubusercontent.com/" in body,
116
116
  ]
117
117
 
118
118
  # Require multiple indicators for confidence
@@ -33,6 +33,7 @@ from difflib import SequenceMatcher
33
33
 
34
34
  # Public API surface
35
35
  __all__ = [
36
+ "CC_PREFIX_RE",
36
37
  "ScoreResult",
37
38
  "ScoringConfig",
38
39
  "aggregate_scores",
@@ -48,6 +49,14 @@ __all__ = [
48
49
  "sequence_ratio",
49
50
  ]
50
51
 
52
+ # Compiled conventional-commit prefix regex, shared across modules.
53
+ # Matches types like "feat:", "Fix(scope):", "Build(deps)!:" etc.
54
+ CC_PREFIX_RE = re.compile(
55
+ r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"
56
+ r"(?:\([^)]*\))?\s*!?\s*:\s*",
57
+ re.IGNORECASE,
58
+ )
59
+
51
60
 
52
61
  @dataclass(frozen=True)
53
62
  class ScoringConfig:
@@ -197,6 +206,7 @@ def extract_dependency_package_from_subject(subject: str) -> str:
197
206
  Examples to consider (to be implemented):
198
207
  - "Bump requests from 2.31.0 to 2.32.0" -> "requests"
199
208
  - "chore: update org/tool from v1.2.3 to v1.2.4" -> "org/tool"
209
+ - "Build(deps): Bump org/tool from 1.0 to 2.0" -> "org/tool"
200
210
 
201
211
  Args:
202
212
  subject: The (possibly unnormalized) subject line.
@@ -205,15 +215,20 @@ def extract_dependency_package_from_subject(subject: str) -> str:
205
215
  Package identifier, or empty string if none could be extracted.
206
216
  """
207
217
  s = (subject or "").lower()
218
+ # Strip any conventional-commit prefix before matching
219
+ cc_match = CC_PREFIX_RE.match(s)
220
+ if cc_match:
221
+ s = s[cc_match.end() :]
222
+
208
223
  patterns = [
209
224
  # Full version with "from" clause
210
- r"(?:chore.*?:\s*)?bump\s+([^\s]+)\s+from\s+",
211
- r"(?:chore.*?:\s*)?update\s+([^\s]+)\s+from\s+",
212
- r"(?:chore.*?:\s*)?upgrade\s+([^\s]+)\s+from\s+",
225
+ r"bump\s+([^\s]+)\s+from\s+",
226
+ r"update\s+([^\s]+)\s+from\s+",
227
+ r"upgrade\s+([^\s]+)\s+from\s+",
213
228
  # Truncated version without "from" clause (for Gerrit subjects)
214
- r"(?:chore.*?:\s*)?bump\s+([^\s]+)\s*$",
215
- r"(?:chore.*?:\s*)?update\s+([^\s]+)\s*$",
216
- r"(?:chore.*?:\s*)?upgrade\s+([^\s]+)\s*$",
229
+ r"bump\s+([^\s]+)\s*$",
230
+ r"update\s+([^\s]+)\s*$",
231
+ r"upgrade\s+([^\s]+)\s*$",
217
232
  ]
218
233
  for pat in patterns:
219
234
  m = re.search(pat, s)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Submit a GitHub pull request to a Gerrit repository.
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github2gerrit-action
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github2gerrit-action
@@ -22,6 +22,7 @@ Classifier: Topic :: Software Development :: Build Tools
22
22
  Classifier: Topic :: Software Development :: Version Control
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.11
25
+ Requires-Dist: click>=8.1.7
25
26
  Requires-Dist: cryptography>=46.0.5
26
27
  Requires-Dist: git-review>=2.5.0
27
28
  Requires-Dist: pygerrit2>=2.0.15
@@ -1,15 +1,15 @@
1
1
  github2gerrit/__init__.py,sha256=N1Vj1HJ28LKCJLAynQdm5jFGQQAz9YSMzZhEfvbBgow,886
2
- github2gerrit/cli.py,sha256=iPrtQ39YClUmDaTLFrcXbX-Efdkvq9BpLMgy3XmsRQg,118733
3
- github2gerrit/commit_normalization.py,sha256=5HqUJ7p3WQzUYNTkganIsu82aW1wZrh7J7ivQvdCYXw,17033
2
+ github2gerrit/cli.py,sha256=osLCG6Xshrcp6QBFeA3OoWoH3NywRy6HSo_jYUkMDHU,119341
3
+ github2gerrit/commit_normalization.py,sha256=6FOkNfYvR_wicBAhbd4gcwVlkgnqdVFWvUFQ2cwefYA,17131
4
4
  github2gerrit/commit_rules.py,sha256=GMlCYwuZb-RcEP1SwMfI3r4i_Dy2yqK6f0RwnYFMGH4,16297
5
5
  github2gerrit/config.py,sha256=nvSc9e_wJM2GS5whFE5R3UwmZjYPZgucaS8s6rrEbjY,26469
6
6
  github2gerrit/constants.py,sha256=uCAx-lZiyxlVSHaGmDx6TFbCajkK3VO2ggCOej0EeXk,1306
7
- github2gerrit/core.py,sha256=7JXt2J9fKt--zd5vwBqzqkS7oB5xRN18cBpD5dkwqpI,245296
7
+ github2gerrit/core.py,sha256=W08u18H9Uhsy1_0cm7hiXZmuXljr6AwGI03ybNcUO-Y,254613
8
8
  github2gerrit/duplicate_detection.py,sha256=CTrzD3YLc-AyPmaR5bSC5b22LpBDpCcNbVxMv2X0o7A,32127
9
9
  github2gerrit/error_codes.py,sha256=MclL3tiOSR4R61c3J642CYDyJJ10tzEQmI2YW7Wkqjw,19098
10
10
  github2gerrit/external_api.py,sha256=9483kkgIs1ECOl_f0lcGb8GrJQF9IfYmWfBQwUJT9hk,18480
11
- github2gerrit/gerrit_pr_closer.py,sha256=flDGupYls2-9SBLhpUxAUtdP9l7J_GNxMRMIfr4czvs,54432
12
- github2gerrit/gerrit_query.py,sha256=7AR9UEwZxtTb9V-l17UDdtWvlvROlnIeJVMa57ilf6s,8343
11
+ github2gerrit/gerrit_pr_closer.py,sha256=irsAI4eNJWi66P39q1gRibpV5SdhiVKv11u-G8yD9a0,61591
12
+ github2gerrit/gerrit_query.py,sha256=pn44IN46tgKPq-s48XnGXpS6kfeQyiECQP75CnJSOr8,10472
13
13
  github2gerrit/gerrit_rest.py,sha256=FyQAMCSuZmJ7ClLuPRgkwapXbJnhW41TYdgkB6UGKQY,12612
14
14
  github2gerrit/gerrit_urls.py,sha256=aoPuUOCysHffVr9qkBRIPCZEUoSBMHmfviydy1U1uSQ,13843
15
15
  github2gerrit/github_api.py,sha256=wYKWRMQsYu7zbxyRQRO7N4cTqBvE6vYf8SdN_3C6XTo,11486
@@ -17,13 +17,13 @@ github2gerrit/gitreview.py,sha256=I1FPsYjkPttj6DdjqHI0HkWOwsnInhmijLeGjnD658I,23
17
17
  github2gerrit/gitutils.py,sha256=J-pgSeeOslspYSwkzkxHAjej4h7t65s2w15ijDpEHOY,26462
18
18
  github2gerrit/mapping_comment.py,sha256=3WAL3KQgjfPnUMZS_-aqNL7qtD0jNXr0VTK_GPEcW3k,9746
19
19
  github2gerrit/models.py,sha256=beZ1C3S8v-qA4tD3Niq6trpoHofxLx6q3VQgvOz0WyI,4304
20
- github2gerrit/netrc.py,sha256=F9_ff2iV8rjN35cY6H1QteHDPGlOawm2VlBE0T4jKNc,27612
20
+ github2gerrit/netrc.py,sha256=x8l53j-t1kl-v1YZEPaDT2Mmf5qUfgP5J0YZzE7fkIY,27534
21
21
  github2gerrit/pr_commands.py,sha256=VZNEvzK1MHnXul3SbkjjoY_HW34M9QFyt74RxcRZpQU,12440
22
- github2gerrit/pr_content_filter.py,sha256=cTikck3rgQSE6612eWpWnfWEcIingPD_MpRgNhblWX4,19269
22
+ github2gerrit/pr_content_filter.py,sha256=WUeYiTTYbIgz4Kb0u8M5e3vEoNU5L2WQ9h27xV1iNwE,19278
23
23
  github2gerrit/reconcile_matcher.py,sha256=s7v3LJdv5CZmi7LvhENFoXlQ-GCxFS-CtkuOCUsD5ow,20466
24
24
  github2gerrit/rich_display.py,sha256=Y2rtJNLP_EG9zdR29NiSqM3j_o_51gpQVeTNBqZch5o,16038
25
25
  github2gerrit/rich_logging.py,sha256=D8yyV4NrsLf74fZH5-AFr1c-Im7MyNyx3LbJTLqepHs,10991
26
- github2gerrit/similarity.py,sha256=nbmGPGDSwxPKCuBfyJ7lxUwkMPnfURB4qWp0EJw9xe0,16164
26
+ github2gerrit/similarity.py,sha256=UHJI_qQB8wGEdWroyHcH5Nb1f3P-fgUU4XnBr9EzIUU,16582
27
27
  github2gerrit/ssh_agent_setup.py,sha256=5nccjdjJuTxbDR9nxnP1ITkSGItbCFD4Owxsrnt8IYc,22613
28
28
  github2gerrit/ssh_common.py,sha256=OC20SQsX_AP3yTUJ_tqz6XAN30O5GbgtZwzwUzyX4DQ,8216
29
29
  github2gerrit/ssh_config_parser.py,sha256=Yd6yoh95lSfUXHRHw2mrgNlY0yizz1A7GmQod30dCT8,15523
@@ -32,8 +32,8 @@ github2gerrit/trailers.py,sha256=9w0vIxPNBNQp56sIy-MF62d22Rm6vY-msh9ao1lX0rQ,838
32
32
  github2gerrit/utils.py,sha256=zNH1qcxyOYdhIn4Ku0o5p9pbwYWdQfboLNqzADDts8A,3804
33
33
  github2gerrit/orchestrator/__init__.py,sha256=HAEcdCAHOFr8LsdIwAdcIcFZn_ayMbX9rdVUULp8410,864
34
34
  github2gerrit/orchestrator/reconciliation.py,sha256=-JQre_PUx6aZeX8Qs6uHqzujKAXXCj9wNv7Cy-543Z8,19391
35
- github2gerrit-1.2.0.dist-info/METADATA,sha256=8WHdDf2jYgv2FJqLIjnTfU48Aiw1Ra9f5ifTChs9ljk,70182
36
- github2gerrit-1.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
37
- github2gerrit-1.2.0.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
38
- github2gerrit-1.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
- github2gerrit-1.2.0.dist-info/RECORD,,
35
+ github2gerrit-1.2.2.dist-info/METADATA,sha256=JUrvlCF0qFREPgEa7h_tgro6fp9VT0FO3zUF5c0Jjjs,70210
36
+ github2gerrit-1.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
37
+ github2gerrit-1.2.2.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
38
+ github2gerrit-1.2.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
39
+ github2gerrit-1.2.2.dist-info/RECORD,,