github2gerrit 0.1.9__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 +796 -200
- github2gerrit/commit_normalization.py +44 -15
- github2gerrit/config.py +77 -30
- github2gerrit/core.py +1576 -260
- github2gerrit/duplicate_detection.py +224 -100
- 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 +66 -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.9.dist-info → github2gerrit-0.1.11.dist-info}/METADATA +99 -25
- github2gerrit-0.1.11.dist-info/RECORD +31 -0
- {github2gerrit-0.1.9.dist-info → github2gerrit-0.1.11.dist-info}/WHEEL +1 -2
- github2gerrit-0.1.9.dist-info/RECORD +0 -24
- github2gerrit-0.1.9.dist-info/top_level.txt +0 -1
- {github2gerrit-0.1.9.dist-info → github2gerrit-0.1.11.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.9.dist-info → github2gerrit-0.1.11.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,345 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
"""
|
4
|
+
Mapping comment utilities for serializing and deserializing Change-ID mapping
|
5
|
+
blocks.
|
6
|
+
|
7
|
+
This module handles the structured PR comments that track the mapping between
|
8
|
+
local commits and Gerrit Change-IDs for reconciliation purposes.
|
9
|
+
"""
|
10
|
+
|
11
|
+
import logging
|
12
|
+
from dataclasses import dataclass
|
13
|
+
|
14
|
+
|
15
|
+
log = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
# Error message constants to comply with TRY003
|
18
|
+
_MSG_INVALID_MODE = "Invalid mode"
|
19
|
+
_MSG_NO_CHANGE_IDS = "At least one Change-ID is required"
|
20
|
+
_MSG_INVALID_CHANGE_ID_FORMAT = "Invalid Change-ID format"
|
21
|
+
|
22
|
+
|
23
|
+
@dataclass
|
24
|
+
class ChangeIdMapping:
|
25
|
+
"""Represents a Change-ID mapping from a PR comment (with optional
|
26
|
+
digest)."""
|
27
|
+
|
28
|
+
pr_url: str
|
29
|
+
mode: str # "multi-commit" or "squash"
|
30
|
+
topic: str
|
31
|
+
change_ids: list[str]
|
32
|
+
github_hash: str = ""
|
33
|
+
digest: str = ""
|
34
|
+
|
35
|
+
def __post_init__(self) -> None:
|
36
|
+
"""Validate mapping data after initialization."""
|
37
|
+
if self.mode not in ("multi-commit", "squash"):
|
38
|
+
raise ValueError(_MSG_INVALID_MODE)
|
39
|
+
|
40
|
+
if not self.change_ids:
|
41
|
+
raise ValueError(_MSG_NO_CHANGE_IDS)
|
42
|
+
|
43
|
+
# Validate Change-Id format
|
44
|
+
for cid in self.change_ids:
|
45
|
+
if not cid.startswith("I") or len(cid) < 8:
|
46
|
+
raise ValueError(_MSG_INVALID_CHANGE_ID_FORMAT)
|
47
|
+
|
48
|
+
|
49
|
+
def serialize_mapping_comment(
|
50
|
+
pr_url: str,
|
51
|
+
mode: str,
|
52
|
+
topic: str,
|
53
|
+
change_ids: list[str],
|
54
|
+
github_hash: str,
|
55
|
+
digest: str | None = None,
|
56
|
+
) -> str:
|
57
|
+
"""
|
58
|
+
Serialize a Change-ID mapping into a structured PR comment.
|
59
|
+
|
60
|
+
Args:
|
61
|
+
pr_url: Full GitHub PR URL
|
62
|
+
mode: Submission mode ("multi-commit" or "squash")
|
63
|
+
topic: Gerrit topic name
|
64
|
+
change_ids: Ordered list of Change-IDs
|
65
|
+
github_hash: GitHub-Hash trailer value for verification
|
66
|
+
|
67
|
+
Returns:
|
68
|
+
Formatted comment body with mapping block
|
69
|
+
"""
|
70
|
+
if not change_ids:
|
71
|
+
raise ValueError(_MSG_NO_CHANGE_IDS)
|
72
|
+
|
73
|
+
lines = [
|
74
|
+
"<!-- github2gerrit:change-id-map v1 -->",
|
75
|
+
f"PR: {pr_url}",
|
76
|
+
f"Mode: {mode}",
|
77
|
+
f"Topic: {topic}",
|
78
|
+
"Change-Ids:",
|
79
|
+
]
|
80
|
+
|
81
|
+
# Add indented Change-IDs
|
82
|
+
for cid in change_ids:
|
83
|
+
lines.append(f" {cid}")
|
84
|
+
|
85
|
+
if digest:
|
86
|
+
lines.append(f"Digest: {digest}")
|
87
|
+
|
88
|
+
lines.extend(
|
89
|
+
[
|
90
|
+
f"GitHub-Hash: {github_hash}",
|
91
|
+
"<!-- end github2gerrit:change-id-map -->",
|
92
|
+
]
|
93
|
+
)
|
94
|
+
|
95
|
+
return "\n".join(lines)
|
96
|
+
|
97
|
+
|
98
|
+
def parse_mapping_comments(comment_bodies: list[str]) -> ChangeIdMapping | None:
|
99
|
+
"""
|
100
|
+
Parse Change-ID mapping from PR comment bodies.
|
101
|
+
|
102
|
+
Scans comments from oldest to newest, returning the latest valid mapping.
|
103
|
+
|
104
|
+
Args:
|
105
|
+
comment_bodies: List of comment body texts to scan
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
Latest valid ChangeIdMapping or None if no mapping found
|
109
|
+
"""
|
110
|
+
latest_mapping: ChangeIdMapping | None = None
|
111
|
+
|
112
|
+
start_marker = "<!-- github2gerrit:change-id-map v1 -->"
|
113
|
+
end_marker = "<!-- end github2gerrit:change-id-map -->"
|
114
|
+
|
115
|
+
for body in comment_bodies:
|
116
|
+
if start_marker not in body or end_marker not in body:
|
117
|
+
continue
|
118
|
+
|
119
|
+
try:
|
120
|
+
# Extract the mapping block
|
121
|
+
start_idx = body.find(start_marker)
|
122
|
+
end_idx = body.find(end_marker)
|
123
|
+
|
124
|
+
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
|
125
|
+
continue
|
126
|
+
|
127
|
+
block = body[start_idx + len(start_marker) : end_idx].strip()
|
128
|
+
mapping = _parse_mapping_block(block)
|
129
|
+
|
130
|
+
if mapping:
|
131
|
+
latest_mapping = mapping
|
132
|
+
log.debug(
|
133
|
+
"Found mapping with %d Change-IDs in %s mode",
|
134
|
+
len(mapping.change_ids),
|
135
|
+
mapping.mode,
|
136
|
+
)
|
137
|
+
|
138
|
+
except Exception as exc:
|
139
|
+
log.debug("Failed to parse mapping block: %s", exc)
|
140
|
+
continue
|
141
|
+
|
142
|
+
if latest_mapping:
|
143
|
+
log.debug(
|
144
|
+
"Recovered mapping with %d Change-ID(s) for topic '%s'",
|
145
|
+
len(latest_mapping.change_ids),
|
146
|
+
latest_mapping.topic,
|
147
|
+
)
|
148
|
+
|
149
|
+
return latest_mapping
|
150
|
+
|
151
|
+
|
152
|
+
def _parse_mapping_block(block: str) -> ChangeIdMapping | None:
|
153
|
+
"""
|
154
|
+
Parse a single mapping block into a ChangeIdMapping.
|
155
|
+
|
156
|
+
Args:
|
157
|
+
block: Mapping block content (without HTML markers)
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
Parsed ChangeIdMapping or None if invalid
|
161
|
+
"""
|
162
|
+
lines = [line.strip() for line in block.split("\n")]
|
163
|
+
|
164
|
+
pr_url = ""
|
165
|
+
mode = ""
|
166
|
+
topic = ""
|
167
|
+
change_ids: list[str] = []
|
168
|
+
github_hash = ""
|
169
|
+
digest = ""
|
170
|
+
|
171
|
+
in_change_ids = False
|
172
|
+
|
173
|
+
for line in lines:
|
174
|
+
if not line:
|
175
|
+
continue
|
176
|
+
|
177
|
+
if line.startswith("PR:"):
|
178
|
+
pr_url = line[3:].strip()
|
179
|
+
elif line.startswith("Mode:"):
|
180
|
+
mode = line[5:].strip()
|
181
|
+
elif line.startswith("Topic:"):
|
182
|
+
topic = line[6:].strip()
|
183
|
+
elif line.startswith("Change-Ids:"):
|
184
|
+
in_change_ids = True
|
185
|
+
elif line.startswith("GitHub-Hash:"):
|
186
|
+
github_hash = line[12:].strip()
|
187
|
+
in_change_ids = False
|
188
|
+
elif line.startswith("Digest:"):
|
189
|
+
digest = line[7:].strip()
|
190
|
+
elif in_change_ids and line.startswith("I"):
|
191
|
+
# Extract Change-ID (handle potential whitespace/formatting)
|
192
|
+
cid = line.split()[0]
|
193
|
+
if cid not in change_ids: # Avoid duplicates
|
194
|
+
change_ids.append(cid)
|
195
|
+
|
196
|
+
# Validate required fields (github_hash is optional for backward
|
197
|
+
# compatibility)
|
198
|
+
if not all([pr_url, mode, topic, change_ids]):
|
199
|
+
log.debug(
|
200
|
+
"Incomplete mapping block: pr_url=%s, mode=%s, topic=%s, "
|
201
|
+
"change_ids=%d, github_hash=%s",
|
202
|
+
bool(pr_url),
|
203
|
+
mode,
|
204
|
+
bool(topic),
|
205
|
+
len(change_ids),
|
206
|
+
bool(github_hash),
|
207
|
+
)
|
208
|
+
return None
|
209
|
+
|
210
|
+
try:
|
211
|
+
return ChangeIdMapping(
|
212
|
+
pr_url=pr_url,
|
213
|
+
mode=mode,
|
214
|
+
topic=topic,
|
215
|
+
change_ids=change_ids,
|
216
|
+
github_hash=github_hash or "",
|
217
|
+
digest=digest or "",
|
218
|
+
)
|
219
|
+
except ValueError as exc:
|
220
|
+
log.debug("Invalid mapping data: %s", exc)
|
221
|
+
return None
|
222
|
+
|
223
|
+
|
224
|
+
def find_mapping_comments(comment_bodies: list[str]) -> list[int]:
|
225
|
+
"""
|
226
|
+
Find indices of comments containing Change-ID mapping blocks.
|
227
|
+
|
228
|
+
Args:
|
229
|
+
comment_bodies: List of comment body texts
|
230
|
+
|
231
|
+
Returns:
|
232
|
+
List of comment indices that contain mapping blocks
|
233
|
+
"""
|
234
|
+
indices = []
|
235
|
+
start_marker = "<!-- github2gerrit:change-id-map v1 -->"
|
236
|
+
|
237
|
+
for i, body in enumerate(comment_bodies):
|
238
|
+
if start_marker in body:
|
239
|
+
indices.append(i)
|
240
|
+
|
241
|
+
return indices
|
242
|
+
|
243
|
+
|
244
|
+
def update_mapping_comment_body(
|
245
|
+
original_body: str,
|
246
|
+
new_mapping: ChangeIdMapping,
|
247
|
+
) -> str:
|
248
|
+
"""
|
249
|
+
Update a comment body with a new mapping, replacing the existing one.
|
250
|
+
|
251
|
+
Args:
|
252
|
+
original_body: Original comment body text
|
253
|
+
new_mapping: New mapping to insert
|
254
|
+
|
255
|
+
Returns:
|
256
|
+
Updated comment body with new mapping
|
257
|
+
"""
|
258
|
+
start_marker = "<!-- github2gerrit:change-id-map v1 -->"
|
259
|
+
end_marker = "<!-- end github2gerrit:change-id-map -->"
|
260
|
+
|
261
|
+
# Generate new mapping block
|
262
|
+
new_block = serialize_mapping_comment(
|
263
|
+
pr_url=new_mapping.pr_url,
|
264
|
+
mode=new_mapping.mode,
|
265
|
+
topic=new_mapping.topic,
|
266
|
+
change_ids=new_mapping.change_ids,
|
267
|
+
github_hash=new_mapping.github_hash,
|
268
|
+
)
|
269
|
+
|
270
|
+
# If no existing mapping, append the new one
|
271
|
+
if start_marker not in original_body:
|
272
|
+
if original_body and not original_body.endswith("\n"):
|
273
|
+
return original_body + "\n\n" + new_block
|
274
|
+
else:
|
275
|
+
return original_body + new_block
|
276
|
+
|
277
|
+
# Replace existing mapping
|
278
|
+
start_idx = original_body.find(start_marker)
|
279
|
+
end_idx = original_body.find(end_marker)
|
280
|
+
|
281
|
+
if start_idx == -1 or end_idx == -1:
|
282
|
+
# Malformed existing mapping, append new one
|
283
|
+
return original_body + "\n\n" + new_block
|
284
|
+
|
285
|
+
# Include the end marker in the replacement
|
286
|
+
end_idx += len(end_marker)
|
287
|
+
|
288
|
+
# Replace the old mapping with the new one
|
289
|
+
updated_body = (
|
290
|
+
original_body[:start_idx] + new_block + original_body[end_idx:]
|
291
|
+
)
|
292
|
+
|
293
|
+
return updated_body.strip()
|
294
|
+
|
295
|
+
|
296
|
+
def compute_mapping_digest(change_ids: list[str]) -> str:
|
297
|
+
"""
|
298
|
+
Compute a digest of the Change-ID list for quick comparison.
|
299
|
+
|
300
|
+
Args:
|
301
|
+
change_ids: Ordered list of Change-IDs
|
302
|
+
|
303
|
+
Returns:
|
304
|
+
SHA-256 digest (first 12 hex chars) of the ordered Change-IDs
|
305
|
+
"""
|
306
|
+
import hashlib
|
307
|
+
|
308
|
+
content = "\n".join(change_ids)
|
309
|
+
hash_obj = hashlib.sha256(content.encode("utf-8"))
|
310
|
+
return hash_obj.hexdigest()[:12]
|
311
|
+
|
312
|
+
|
313
|
+
def validate_mapping_consistency(
|
314
|
+
mapping: ChangeIdMapping,
|
315
|
+
expected_pr_url: str,
|
316
|
+
expected_github_hash: str,
|
317
|
+
) -> bool:
|
318
|
+
"""
|
319
|
+
Validate that a mapping is consistent with expected PR metadata.
|
320
|
+
|
321
|
+
Args:
|
322
|
+
mapping: Parsed mapping to validate
|
323
|
+
expected_pr_url: Expected GitHub PR URL
|
324
|
+
expected_github_hash: Expected GitHub-Hash value
|
325
|
+
|
326
|
+
Returns:
|
327
|
+
True if mapping is consistent with expectations
|
328
|
+
"""
|
329
|
+
if mapping.pr_url != expected_pr_url:
|
330
|
+
log.warning(
|
331
|
+
"Mapping PR URL mismatch: expected=%s, found=%s",
|
332
|
+
expected_pr_url,
|
333
|
+
mapping.pr_url,
|
334
|
+
)
|
335
|
+
return False
|
336
|
+
|
337
|
+
if mapping.github_hash != expected_github_hash:
|
338
|
+
log.warning(
|
339
|
+
"Mapping GitHub-Hash mismatch: expected=%s, found=%s",
|
340
|
+
expected_github_hash,
|
341
|
+
mapping.github_hash,
|
342
|
+
)
|
343
|
+
return False
|
344
|
+
|
345
|
+
return True
|
github2gerrit/models.py
CHANGED
@@ -49,13 +49,27 @@ class Inputs:
|
|
49
49
|
|
50
50
|
# Optional (reusable workflow compatibility / overrides)
|
51
51
|
gerrit_server: str
|
52
|
-
gerrit_server_port:
|
52
|
+
gerrit_server_port: int
|
53
53
|
gerrit_project: str
|
54
54
|
issue_id: str
|
55
55
|
allow_duplicates: bool
|
56
56
|
ci_testing: bool
|
57
57
|
duplicates_filter: str = "open"
|
58
58
|
|
59
|
+
# Reconciliation configuration options
|
60
|
+
reuse_strategy: str = "topic+comment" # topic, comment, topic+comment, none
|
61
|
+
similarity_subject: float = 0.7 # Subject token Jaccard threshold
|
62
|
+
similarity_files: bool = True # File signature match requirement
|
63
|
+
allow_orphan_changes: bool = (
|
64
|
+
False # Keep unmatched Gerrit changes without warning
|
65
|
+
)
|
66
|
+
persist_single_mapping_comment: bool = (
|
67
|
+
True # Replace vs append mapping comments
|
68
|
+
)
|
69
|
+
log_reconcile_json: bool = (
|
70
|
+
True # Emit structured JSON reconciliation summary
|
71
|
+
)
|
72
|
+
|
59
73
|
|
60
74
|
@dataclass(frozen=True)
|
61
75
|
class GitHubContext:
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
3
|
+
"""
|
4
|
+
Orchestrator subpackage.
|
5
|
+
|
6
|
+
Phase 1 extraction scaffold:
|
7
|
+
- Provides a namespace for incremental migration of logic from the legacy
|
8
|
+
monolithic `core.Orchestrator` implementation toward a phased pipeline.
|
9
|
+
- Initial deliverable only extracts reconciliation invocation (see
|
10
|
+
`reconciliation.py`) while preserving public behavior.
|
11
|
+
|
12
|
+
Subsequent phases will populate:
|
13
|
+
- `pipeline.py` for ordered phase coordination
|
14
|
+
- `context.py` for immutable run/repository context objects
|
15
|
+
- Additional modules (ssh, git_ops, mapping, backref, verification, etc.)
|
16
|
+
|
17
|
+
Import surface kept intentionally minimal to avoid premature coupling.
|
18
|
+
"""
|
19
|
+
|
20
|
+
from __future__ import annotations
|
21
|
+
|
22
|
+
from .reconciliation import perform_reconciliation # re-export convenience
|
23
|
+
|
24
|
+
|
25
|
+
__all__ = ["perform_reconciliation"]
|