Skip to content

Commit 22facfb

Browse files
committed
fix(gitlab): preserve user-added labels in publish_labels and get_pr_labels
PRReviewer.set_review_labels and similar callers do a read-modify-write cycle on MR labels (read existing via get_pr_labels(update=True), filter out our managed labels, add review-effort/security labels, write back via publish_labels) specifically to preserve user-added labels. On the GitLab provider both halves of that cycle were buggy, so any label a user added after the webhook fired would be wiped on every /review. Issue 1: GitLabProvider.get_pr_labels(update=False) ignored the update flag and returned self.mr.labels from the snapshot cached at provider construction. Callers requesting a fresh read silently got stale data, so user-added labels were missing from current_labels and excluded from the write. Issue 2: GitLabProvider.publish_labels did: self.mr.labels = list(set(pr_types)) self.mr.save() which issues a full PUT on the labels array against that same stale snapshot. Any concurrent addition during the read-modify-write window is silently dropped. This change fixes both halves: - get_pr_labels(update=True) now actually refreshes via self._get_merge_request() before reading. update=False keeps the cached read so the hot path is unchanged. Refresh failure propagates to the caller so a stale snapshot cannot corrupt the read-modify-write cycle (see PRDescription change below). - publish_labels refreshes the MR immediately before computing the delta, then uses python-gitlab's add_labels / remove_labels attributes (forwarded by python-gitlab to identically-named API parameters on PUT /projects/:id/merge_requests/:merge_request_iid) on save(). The server now applies an incremental set-diff, so concurrent additions outside the diff are preserved. - save() is wrapped in try/finally; both add_labels and remove_labels are delattr'd in the finally block so a later self.mr.save() on the same provider instance (e.g. publish_description) cannot resend the prior label diff. Cleanup runs on save() failure too. - publish_labels aborts on refresh failure (logs and returns without calling save()) so we never publish from a stale snapshot. - The refresh-failure warning logs only the exception type, not the raw exception object. HTTP/SDK client errors can carry credentialed URLs or headers in their message; logs are a security boundary. PRDescription.run now isolates label-update failures: the label refresh/publish block is wrapped in an inline try/except so that a transient GitLab API error during get_pr_labels(update=True) (or a publish_labels failure) does not abort the surrounding description publish. Without this wrapper, the outer try/except in run() is wide enough that a label-step exception would skip the description publish too. PRReviewer.set_review_labels already wraps its get_pr_labels(update=True) call in a try/except, so no change is needed there. Other providers' get_pr_labels methods swallow exceptions and return [], which is why callers were not previously structured to expect exceptions here. Tests: New tests in tests/unittest/test_gitlab_provider.py cover: - publish_labels: noop when sets are equal, add-only diff, remove-only diff, user labels outside the diff are not touched, abort on refresh failure (and the warning log redacts the exception body), diff-attr cleanup on save() failure. - get_pr_labels: no-update returns cached, update refreshes, update propagates refresh failure. New tests in tests/unittest/test_pr_description.py cover the description publish flow remaining intact when: - get_pr_labels(update=True) raises. - publish_labels raises. PYTHONPATH=. ./.venv/bin/pytest tests/unittest -q 1052 passed Signed-off-by: Allen Shearin <allen.p.shearin@gmail.com>
1 parent bd09b6c commit 22facfb

4 files changed

Lines changed: 408 additions & 12 deletions

File tree

pr_agent/git_providers/gitlab_provider.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -886,15 +886,86 @@ def get_user_id(self):
886886

887887
def publish_labels(self, pr_types):
888888
try:
889-
self.mr.labels = list(set(pr_types))
890-
self.mr.save()
889+
# Race-safe label update.
890+
#
891+
# Previous behavior: ``self.mr.labels = list(set(pr_types))`` followed
892+
# by ``self.mr.save()`` issued a full PUT on the labels array using
893+
# whatever snapshot of ``self.mr`` was cached at provider-construction
894+
# time. Any label a user added between webhook delivery and this save
895+
# was silently dropped.
896+
#
897+
# New behavior: re-fetch the MR immediately before computing the
898+
# delta, then use python-gitlab's ``add_labels`` / ``remove_labels``
899+
# attributes on save. python-gitlab forwards those attributes to
900+
# the matching ``add_labels`` / ``remove_labels`` parameters on
901+
# ``PUT /projects/:id/merge_requests/:merge_request_iid`` so the
902+
# server applies an incremental set-diff and concurrent additions
903+
# outside the diff are preserved.
904+
desired = set(pr_types)
905+
try:
906+
self.mr = self._get_merge_request()
907+
except Exception as refresh_err:
908+
# Strict policy: a stale snapshot can produce an incorrect
909+
# add/remove diff that re-introduces the bug this method is
910+
# meant to fix. Abort the publish, log, and leave server
911+
# state untouched rather than risk clobbering user labels.
912+
#
913+
# Log only the exception type — the message from HTTP/SDK
914+
# client errors may include credentialed URLs or headers, so
915+
# we avoid emitting the raw object.
916+
get_logger().warning(
917+
"publish_labels: aborting, failed to refresh MR before save "
918+
f"({type(refresh_err).__name__})"
919+
)
920+
return
921+
current = set(self.mr.labels or [])
922+
to_add = sorted(desired - current)
923+
to_remove = sorted(current - desired)
924+
if not to_add and not to_remove:
925+
return
926+
# GitLab accepts comma-separated strings for these attributes.
927+
# Wrap the save in try/finally so the transient add_labels /
928+
# remove_labels attributes are always cleared from ``self.mr``,
929+
# even on save() failure. Otherwise a later self.mr.save() call
930+
# in an unrelated tool (e.g. publish_description) would resend
931+
# the prior label diff and cause unexpected churn.
932+
try:
933+
if to_add:
934+
self.mr.add_labels = ",".join(to_add)
935+
if to_remove:
936+
self.mr.remove_labels = ",".join(to_remove)
937+
self.mr.save()
938+
finally:
939+
for attr in ("add_labels", "remove_labels"):
940+
try:
941+
delattr(self.mr, attr)
942+
except (AttributeError, KeyError):
943+
# Already absent (e.g. only one side of the diff was
944+
# set, or python-gitlab cleared it on save). Safe to
945+
# ignore.
946+
pass
891947
except Exception as e:
892948
get_logger().warning(f"Failed to publish labels, error: {e}")
893949

894950
def publish_inline_comments(self, comments: list[dict]):
895951
pass
896952

897953
def get_pr_labels(self, update=False):
954+
# The previous implementation ignored the ``update`` flag entirely and
955+
# always returned the snapshot cached at provider construction. Callers
956+
# such as ``PRReviewer.set_review_labels`` rely on a fresh read to
957+
# preserve user-added labels in their read-modify-write cycle; without
958+
# the refresh, any label added after the webhook fired would be missing
959+
# from ``current_labels`` and dropped by the subsequent publish_labels.
960+
#
961+
# Strict policy on refresh failure: a stale snapshot will produce an
962+
# incorrect diff in the caller's filter-and-republish cycle, so we
963+
# surface the failure to the caller instead of silently returning
964+
# cached data. ``set_review_labels`` already wraps this in a broad
965+
# try/except, so the failure degrades into "skip the label update for
966+
# this run" rather than breaking the whole review.
967+
if update:
968+
self.mr = self._get_merge_request()
898969
return self.mr.labels
899970

900971
def get_repo_labels(self):

pr_agent/tools/pr_description.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,29 @@ async def run(self):
157157

158158
# publish labels
159159
if get_settings().pr_description.publish_labels and pr_labels and self.git_provider.is_supported("get_labels"):
160-
original_labels = self.git_provider.get_pr_labels(update=True)
161-
get_logger().debug(f"original labels", artifact=original_labels)
162-
user_labels = get_user_labels(original_labels)
163-
new_labels = pr_labels + user_labels
164-
get_logger().debug(f"published labels", artifact=new_labels)
165-
if set(new_labels) != set(original_labels):
166-
get_logger().info(f"Setting describe labels:\n{new_labels}")
167-
self.git_provider.publish_labels(new_labels)
168-
else:
169-
get_logger().debug(f"Labels are the same, not updating")
160+
# Isolate label refresh/publish failures from the description
161+
# publish: GitLabProvider.get_pr_labels(update=True) now raises
162+
# on a stale-snapshot refresh failure (see PR #2484), and the
163+
# outer try/except in this method is wide enough that an
164+
# unhandled exception here would skip the description publish
165+
# too. Label updates are best-effort for /describe.
166+
try:
167+
original_labels = self.git_provider.get_pr_labels(update=True)
168+
get_logger().debug("original labels", artifact=original_labels)
169+
user_labels = get_user_labels(original_labels)
170+
new_labels = pr_labels + user_labels
171+
get_logger().debug("published labels", artifact=new_labels)
172+
if set(new_labels) != set(original_labels):
173+
get_logger().info(f"Setting describe labels:\n{new_labels}")
174+
self.git_provider.publish_labels(new_labels)
175+
else:
176+
get_logger().debug("Labels are the same, not updating")
177+
except Exception as label_err:
178+
get_logger().warning(
179+
"Failed to update labels during PR description; "
180+
"continuing with description publish "
181+
f"({type(label_err).__name__})"
182+
)
170183

171184
# publish description
172185
if get_settings().pr_description.publish_description_as_comment:

tests/unittest/test_gitlab_provider.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,184 @@ def test_publish_description_with_title_updates_both(self, gitlab_provider):
255255
assert gitlab_provider.mr.title == "AI title"
256256
assert gitlab_provider.mr.description == "Updated description"
257257
gitlab_provider.mr.save.assert_called_once()
258+
# ---- publish_labels / get_pr_labels tests ----
259+
260+
def _prime_mr_for_labels(self, gitlab_provider, server_labels):
261+
"""Install a mock MR with server_labels for label-publishing tests.
262+
263+
_get_merge_request is patched to return a fresh MagicMock with the
264+
same label set, simulating a successful server refresh. spec=[...]
265+
keeps MagicMock from silently auto-creating add_labels /
266+
remove_labels attrs so tests can assert which side(s) of the diff
267+
were written (or were cleared) after publish_labels returns.
268+
"""
269+
mr = MagicMock(spec=["labels", "save"])
270+
mr.labels = list(server_labels)
271+
gitlab_provider.mr = mr
272+
gitlab_provider._get_merge_request = MagicMock(return_value=mr)
273+
return mr
274+
275+
def _capture_wire_payload_on_save(self, mr):
276+
"""Capture add_labels / remove_labels at the moment save() is called.
277+
278+
publish_labels deletes the transient diff attributes in a ``finally``
279+
block, so asserting on them *after* the call returns is meaningless
280+
(they will always be absent). This helper installs a save() side_effect
281+
that records the diff payload that was actually written to the wire,
282+
which is what we need to validate.
283+
"""
284+
captured = {}
285+
286+
def _record_then_succeed(*_a, **_kw):
287+
captured["add_labels"] = getattr(mr, "add_labels", None)
288+
captured["remove_labels"] = getattr(mr, "remove_labels", None)
289+
290+
mr.save.side_effect = _record_then_succeed
291+
return captured
292+
293+
def test_publish_labels_noop_when_sets_equal(self, gitlab_provider):
294+
mr = self._prime_mr_for_labels(gitlab_provider, ["bug", "review effort 3/5"])
295+
296+
gitlab_provider.publish_labels(["bug", "review effort 3/5"])
297+
298+
# No diff -> no save, no transient attributes touched.
299+
mr.save.assert_not_called()
300+
assert not hasattr(mr, "add_labels")
301+
assert not hasattr(mr, "remove_labels")
302+
303+
def test_publish_labels_adds_only_missing(self, gitlab_provider):
304+
mr = self._prime_mr_for_labels(gitlab_provider, ["bug"])
305+
captured = self._capture_wire_payload_on_save(mr)
306+
307+
gitlab_provider.publish_labels(["bug", "review effort 3/5"])
308+
309+
assert mr.save.call_count == 1
310+
# Only the missing label is in the add diff; nothing is being
311+
# removed because every server label is still desired.
312+
assert captured["add_labels"] == "review effort 3/5"
313+
assert captured["remove_labels"] is None
314+
# Diff attrs are cleared on the way out.
315+
assert not hasattr(mr, "add_labels")
316+
assert not hasattr(mr, "remove_labels")
317+
318+
def test_publish_labels_removes_stale_managed_labels(self, gitlab_provider):
319+
mr = self._prime_mr_for_labels(
320+
gitlab_provider, ["review effort 5/5", "Possible security concern"]
321+
)
322+
captured = self._capture_wire_payload_on_save(mr)
323+
324+
# Caller wants to switch the managed labels to a fresh set.
325+
gitlab_provider.publish_labels(["review effort 2/5"])
326+
327+
assert mr.save.call_count == 1
328+
# "review effort 2/5" is added; both prior managed labels are removed.
329+
# sorted() determinism is part of the contract so we can assert the
330+
# exact comma-separated payload sent on the wire.
331+
assert captured["add_labels"] == "review effort 2/5"
332+
assert captured["remove_labels"] == "Possible security concern,review effort 5/5"
333+
assert not hasattr(mr, "add_labels")
334+
assert not hasattr(mr, "remove_labels")
335+
336+
def test_publish_labels_preserves_user_labels_outside_diff(self, gitlab_provider):
337+
# The bug this PR fixes: a user-added label outside the diff must
338+
# not be touched. With spec on the mock, ``mr.labels`` should remain
339+
# the exact list we primed it with (no full-array overwrite).
340+
mr = self._prime_mr_for_labels(
341+
gitlab_provider, ["area/backend", "review effort 3/5"]
342+
)
343+
captured = self._capture_wire_payload_on_save(mr)
344+
345+
# Caller flipped the managed label only; ``area/backend`` stays.
346+
gitlab_provider.publish_labels(["area/backend", "review effort 4/5"])
347+
348+
# Wire-level diff: only the managed label is updated.
349+
assert captured["add_labels"] == "review effort 4/5"
350+
assert captured["remove_labels"] == "review effort 3/5"
351+
# We wrote exactly one save and never reassigned ``mr.labels`` (the
352+
# pre-fix bug).
353+
assert mr.save.call_count == 1
354+
assert mr.labels == ["area/backend", "review effort 3/5"]
355+
# Diff attrs cleared on the way out.
356+
assert not hasattr(mr, "add_labels")
357+
assert not hasattr(mr, "remove_labels")
358+
359+
def test_publish_labels_aborts_when_refresh_fails(self, gitlab_provider):
360+
# Pre-fix behavior would have proceeded against the cached snapshot,
361+
# potentially clobbering user labels. New strict behavior: abort the
362+
# publish and leave server state untouched.
363+
cached_mr = MagicMock(spec=["labels", "save"])
364+
cached_mr.labels = ["stale label that no longer reflects server"]
365+
gitlab_provider.mr = cached_mr
366+
367+
class _SecretError(RuntimeError):
368+
"""Sentinel whose repr would carry a credentialed URL if logged raw."""
369+
370+
# Use an obviously-fake credential placeholder rather than anything
371+
# that resembles a real token; secret scanners flag the latter.
372+
secret_marker = "REDACTED-TOKEN-PLACEHOLDER"
373+
gitlab_provider._get_merge_request = MagicMock(
374+
side_effect=_SecretError(f"https://oauth2:{secret_marker}@gitlab.example.com/api/v4/...")
375+
)
376+
377+
with patch("pr_agent.git_providers.gitlab_provider.get_logger") as mock_logger:
378+
gitlab_provider.publish_labels(["review effort 3/5"])
379+
380+
cached_mr.save.assert_not_called()
381+
# Log must name the exception type but never the raw message: the
382+
# message body can carry credentials embedded in client-side URLs.
383+
warning_messages = [
384+
call.args[0] for call in mock_logger.return_value.warning.call_args_list
385+
]
386+
assert any("publish_labels: aborting" in m for m in warning_messages)
387+
assert any("_SecretError" in m for m in warning_messages)
388+
assert not any(secret_marker in m for m in warning_messages)
389+
assert not any("oauth2" in m for m in warning_messages)
390+
391+
def test_publish_labels_clears_diff_attrs_on_save_failure(self, gitlab_provider):
392+
# If ``self.mr.save()`` raises, the transient diff fields must still
393+
# be cleared so a later, unrelated save() (e.g. publish_description)
394+
# does not resend them.
395+
mr = self._prime_mr_for_labels(gitlab_provider, ["bug"])
396+
mr.save.side_effect = RuntimeError("network blip")
397+
398+
gitlab_provider.publish_labels(["review effort 3/5"]) # adds + removes
399+
400+
# publish_labels swallows the outer Exception by design; what matters
401+
# is that the transient attrs do not leak into the next save().
402+
assert not hasattr(mr, "add_labels")
403+
assert not hasattr(mr, "remove_labels")
404+
405+
def test_get_pr_labels_no_update_returns_cached(self, gitlab_provider):
406+
cached_mr = MagicMock()
407+
cached_mr.labels = ["cached"]
408+
gitlab_provider.mr = cached_mr
409+
gitlab_provider._get_merge_request = MagicMock()
410+
411+
result = gitlab_provider.get_pr_labels(update=False)
412+
413+
assert result == ["cached"]
414+
gitlab_provider._get_merge_request.assert_not_called()
415+
416+
def test_get_pr_labels_with_update_refreshes(self, gitlab_provider):
417+
cached_mr = MagicMock()
418+
cached_mr.labels = ["cached-stale"]
419+
fresh_mr = MagicMock()
420+
fresh_mr.labels = ["fresh-from-server"]
421+
gitlab_provider.mr = cached_mr
422+
gitlab_provider._get_merge_request = MagicMock(return_value=fresh_mr)
423+
424+
result = gitlab_provider.get_pr_labels(update=True)
425+
426+
assert result == ["fresh-from-server"]
427+
assert gitlab_provider.mr is fresh_mr
428+
429+
def test_get_pr_labels_with_update_propagates_refresh_failure(self, gitlab_provider):
430+
# Strict policy: surface the refresh failure to the caller (which
431+
# wraps the call in a broader try/except), rather than silently
432+
# returning stale data that would corrupt the read-modify-write cycle.
433+
gitlab_provider.mr = MagicMock()
434+
gitlab_provider._get_merge_request = MagicMock(side_effect=RuntimeError("boom"))
435+
436+
with pytest.raises(RuntimeError):
437+
gitlab_provider.get_pr_labels(update=True)
438+

0 commit comments

Comments
 (0)