Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions apps/codecov-api/services/task/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,39 @@ def upload_breadcrumb(
},
).apply_async()

def notify_signature(self, repoid, commitid, current_yaml=None, empty_upload=None):
def notify_signature(
self,
repoid,
commitid,
current_yaml=None,
empty_upload=None,
force_notify=False,
):
return self._create_signature(
celery_config.notify_task_name,
kwargs={
"repoid": repoid,
"commitid": commitid,
"current_yaml": current_yaml,
"empty_upload": empty_upload,
"force_notify": force_notify,
},
)

def notify(self, repoid, commitid, current_yaml=None, empty_upload=None):
def notify(
self,
repoid,
commitid,
current_yaml=None,
empty_upload=None,
force_notify=False,
):
self.notify_signature(
repoid, commitid, current_yaml=current_yaml, empty_upload=empty_upload
repoid,
commitid,
current_yaml=current_yaml,
empty_upload=empty_upload,
force_notify=force_notify,
).apply_async()

def pulls_sync(self, repoid, pullid):
Expand Down
1 change: 1 addition & 0 deletions apps/worker/services/comparison/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class ComparisonContext:
# We need to send commit statuses to this other commit, to guarantee that the check is not ignored.
# See https://docs.gitlab.com/ee/ci/pipelines/merged_results_pipelines.html#successful-merged-results-pipeline-overrides-a-failed-branch-pipeline
gitlab_extra_shas: set[str] | None = None
force_notify: bool = False


NOT_RESOLVED: Any = object()
Expand Down
47 changes: 34 additions & 13 deletions apps/worker/services/notification/notifiers/checks/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ def notify(
comparison: ComparisonProxy,
status_or_checks_helper_text: dict[str, str] | None = None,
) -> NotificationResult:
force_notify = getattr(comparison.context, "force_notify", False)

if not force_notify:
validation_result = self._perform_validation_checks(comparison)
if validation_result:
return validation_result

return self._perform_notification(comparison)

def _perform_validation_checks(
self, comparison: ComparisonProxy
) -> NotificationResult | None:
if comparison.pull is None or ():
log.debug(
"Falling back to commit_status: Not a pull request",
Expand Down Expand Up @@ -132,7 +144,7 @@ def notify(
data_sent=None,
data_received=None,
)
if comparison.pull.state != "open":
if comparison.pull and comparison.pull.state != "open":
log.debug(
"Falling back to commit_status: Pull request closed",
extra={
Expand Down Expand Up @@ -187,28 +199,37 @@ def notify(
data_sent=None,
data_received=None,
)

flag_coverage_not_uploaded_behavior = (
self.determine_status_check_behavior_to_apply(
comparison, "flag_coverage_not_uploaded_behavior"
)
)
if (
flag_coverage_not_uploaded_behavior == "exclude"
and not self.flag_coverage_was_uploaded(comparison)
):
return NotificationResult(
notification_attempted=False,
notification_successful=None,
explanation="exclude_flag_coverage_not_uploaded_checks",
data_sent=None,
data_received=None,
)

return None

def _perform_notification(self, comparison: ComparisonProxy) -> NotificationResult:
payload = None
try:
with nullcontext():
# If flag coverage wasn't uploaded, apply the appropriate behavior
flag_coverage_not_uploaded_behavior = (
self.determine_status_check_behavior_to_apply(
comparison, "flag_coverage_not_uploaded_behavior"
)
)
if not comparison.has_head_report():
payload = self.build_payload(comparison)
elif (
flag_coverage_not_uploaded_behavior == "exclude"
and not self.flag_coverage_was_uploaded(comparison)
):
return NotificationResult(
notification_attempted=False,
notification_successful=None,
explanation="exclude_flag_coverage_not_uploaded_checks",
data_sent=None,
data_received=None,
)
elif (
flag_coverage_not_uploaded_behavior == "pass"
and not self.flag_coverage_was_uploaded(comparison)
Expand Down
30 changes: 18 additions & 12 deletions apps/worker/services/notification/notifiers/comment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,25 @@ def notify(
},
)

for condition in self.notify_conditions:
condition_result = condition.check_condition(
notifier=self, comparison=comparison
)
if condition_result == False:
side_effect_result = condition.on_failure_side_effect(self, comparison)
default_result = NotificationResult(
notification_attempted=False,
explanation=condition.failure_explanation,
data_sent=None,
data_received=None,
force_notify = getattr(comparison.context, "force_notify", False)

if not force_notify:
for condition in self.notify_conditions:
condition_result = condition.check_condition(
notifier=self, comparison=comparison
)
return default_result.merge(side_effect_result)
if condition_result == False:
Comment on lines +98 to +102
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential bug: Bypassing `comparison.pull` and `comparison.enriched_pull.provider_pull` validation in the comment notifier with `force_notify=True` can cause `AttributeError` when accessing their attributes, leading to crashes.
  • Description: When "force_notify=True", the comment notifier bypasses validation checks like ComparisonHasPull (ensuring comparison.pull is not None) and PullRequestInProvider (ensuring comparison.enriched_pull.provider_pull is not None). This allows execution to reach build_message() (e.g., line 427) where comparison.pull.is_first_coverage_pull is accessed, or comparison.enriched_pull.provider_pull (line 428) is accessed directly. Similarly, pull.pullid (line 134) and comparison.enriched_pull.provider_pull["author"] in _create_upgrade_message() are accessed without null checks. If the bypassed objects are None, these accesses will raise an AttributeError (e.g., 'NoneType' object has no attribute 'is_first_coverage_pull'), leading to an unhandled exception and a crash in the notification process.
  • Suggested fix: Reinstate ComparisonHasPull and PullRequestInProvider validations in the comment notifier when force_notify=True, or add explicit null checks for comparison.pull and comparison.enriched_pull.provider_pull before accessing their attributes.
    severity: 0.9, confidence: 0.95

Did we get this right? 👍 / 👎 to inform future reviews.

side_effect_result = condition.on_failure_side_effect(
self, comparison
)
default_result = NotificationResult(
notification_attempted=False,
explanation=condition.failure_explanation,
data_sent=None,
data_received=None,
)
return default_result.merge(side_effect_result)

pull = comparison.pull
try:
message = self.build_message(
Expand Down
5 changes: 4 additions & 1 deletion apps/worker/services/notification/notifiers/generics.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ def notify(
filtered_comparison = comparison.get_filtered_comparison(
**self.get_notifier_filters()
)
if self.should_notify_comparison(filtered_comparison):

force_notify = getattr(comparison.context, "force_notify", False)

if force_notify or self.should_notify_comparison(filtered_comparison):
result = self.do_notify(filtered_comparison)
else:
result = NotificationResult(
Expand Down
58 changes: 45 additions & 13 deletions apps/worker/services/notification/notifiers/status/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,18 @@ def notify(
comparison: ComparisonProxy,
status_or_checks_helper_text: dict[str, str] | None = None,
) -> NotificationResult:
payload = None
force_notify = getattr(comparison.context, "force_notify", False)

if not force_notify:
validation_result = self._perform_validation_checks(comparison)
if validation_result:
return validation_result

return self._perform_notification(comparison)

def _perform_validation_checks(
self, comparison: ComparisonProxy
) -> NotificationResult | None:
if not self.can_we_set_this_status(comparison):
return NotificationResult(
notification_attempted=False,
Expand All @@ -163,6 +174,28 @@ def notify(
explanation="need_more_builds",
data_sent=None,
)

# Check flag coverage behavior when force_notify is False
flag_coverage_not_uploaded_behavior = (
self.determine_status_check_behavior_to_apply(
comparison, "flag_coverage_not_uploaded_behavior"
)
)
if (
flag_coverage_not_uploaded_behavior == "exclude"
and not self.flag_coverage_was_uploaded(comparison)
):
return NotificationResult(
notification_attempted=False,
notification_successful=None,
explanation="exclude_flag_coverage_not_uploaded_checks",
data_sent=None,
data_received=None,
)

return None

def _perform_notification(self, comparison: ComparisonProxy) -> NotificationResult:
# Filter the coverage report based on fields in this notification's YAML settings
# e.g. if "paths" is specified, exclude the coverage not on those paths
try:
Expand All @@ -174,17 +207,6 @@ def notify(
)
if not comparison.has_head_report():
payload = self.build_payload(comparison)
elif (
flag_coverage_not_uploaded_behavior == "exclude"
and not self.flag_coverage_was_uploaded(comparison)
):
return NotificationResult(
notification_attempted=False,
notification_successful=None,
explanation="exclude_flag_coverage_not_uploaded_checks",
data_sent=None,
data_received=None,
)
elif (
flag_coverage_not_uploaded_behavior == "pass"
and not self.flag_coverage_was_uploaded(comparison)
Expand Down Expand Up @@ -262,6 +284,11 @@ def get_status_external_name(self) -> str:
def maybe_send_notification(
self, comparison: ComparisonProxy, payload: dict
) -> NotificationResult:
force_notify = getattr(comparison.context, "force_notify", False)

if force_notify:
return self.send_notification(comparison, payload)

base_commit = (
comparison.project_coverage_base.commit
if comparison.project_coverage_base
Expand Down Expand Up @@ -306,14 +333,19 @@ def maybe_send_notification(
)

def send_notification(self, comparison: ComparisonProxy, payload):
force_notify = getattr(comparison.context, "force_notify", False)

repository_service = self.repository_service
title = self.get_status_external_name()
head_commit_sha = comparison.head.commit.commitid
head_report = comparison.head.report
state = payload["state"]
message = payload["message"]
url = payload["url"]
if self.status_already_exists(comparison, title, state, message):

Comment on lines +336 to +345
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for handling force_notify in send_notification duplicates the check from maybe_send_notification. This creates a potential inconsistency where the behavior could differ between the two methods.

Suggested change
force_notify = getattr(comparison.context, "force_notify", False)
repository_service = self.repository_service
title = self.get_status_external_name()
head_commit_sha = comparison.head.commit.commitid
head_report = comparison.head.report
state = payload["state"]
message = payload["message"]
url = payload["url"]
if self.status_already_exists(comparison, title, state, message):
# Remove the duplicate force_notify check here since maybe_send_notification already handles it appropriately

Did we get this right? 👍 / 👎 to inform future reviews.

if not force_notify and self.status_already_exists(
comparison, title, state, message
):
log.info(
"Status already set",
extra={"context": title, "description": message, "state": state},
Expand Down
Loading