Skip to content

Commit 94f3313

Browse files
authored
feat(ignore): adding event logger for ignored comments (#178)
* feat(ignore): adding event logger for ignored comments
1 parent aa1ba53 commit 94f3313

9 files changed

Lines changed: 471 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.2.83"
9+
version = "2.2.84"
1010
requires-python = ">= 3.11"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

socketsecurity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.2.83'
2+
__version__ = '2.2.84'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/core/cli_client.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import json
23
import logging
34
from typing import Dict, List, Optional, Union
45

@@ -55,3 +56,18 @@ def request(
5556
except requests.exceptions.RequestException as e:
5657
logger.error(f"API request failed: {str(e)}")
5758
raise APIFailure(f"Request failed: {str(e)}")
59+
60+
def post_telemetry_events(self, org_slug: str, events: List[Dict]) -> None:
61+
"""Post telemetry events one at a time to the v0 telemetry API. Fire-and-forget — logs errors but never raises."""
62+
logger.debug(f"Sending {len(events)} telemetry event(s) to v0/orgs/{org_slug}/telemetry")
63+
for i, event in enumerate(events):
64+
try:
65+
logger.debug(f"Telemetry event {i+1}/{len(events)}: {json.dumps(event)}")
66+
resp = self.request(
67+
path=f"orgs/{org_slug}/telemetry",
68+
method="POST",
69+
payload=json.dumps(event),
70+
)
71+
logger.debug(f"Telemetry event {i+1}/{len(events)} sent: status={resp.status_code}")
72+
except Exception as e:
73+
logger.warning(f"Failed to send telemetry event {i+1}/{len(events)}: {e}")

socketsecurity/core/scm/gitlab.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
import sys
34
from dataclasses import dataclass
@@ -219,6 +220,24 @@ def update_comment(self, body: str, comment_id: str) -> None:
219220
base_url=self.config.api_url
220221
)
221222

223+
def has_thumbsup_reaction(self, comment_id: int) -> bool:
224+
"""Best-effort check for 'thumbsup' award emoji on a MR note."""
225+
if not self.config.mr_project_id or not self.config.mr_iid:
226+
return False
227+
path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}/award_emoji"
228+
try:
229+
response = self._request_with_fallback(
230+
path=path,
231+
headers=self.config.headers,
232+
base_url=self.config.api_url
233+
)
234+
for emoji in response.json():
235+
if emoji.get("name") == "thumbsup":
236+
return True
237+
except Exception as e:
238+
log.debug(f"Could not check award emoji for note {comment_id} (best effort): {e}")
239+
return False
240+
222241
def get_comments_for_pr(self) -> dict:
223242
log.debug(f"Getting Gitlab comments for Repo {self.config.repository} for PR {self.config.mr_iid}")
224243
path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes"
@@ -326,9 +345,32 @@ def set_commit_status(self, state: str, description: str, target_url: str = '')
326345
except Exception as e:
327346
log.error(f"Failed to set commit status: {e}")
328347

348+
def post_thumbsup_reaction(self, comment_id: int) -> None:
349+
"""Best-effort: add 'thumbsup' award emoji to a MR note."""
350+
if not self.config.mr_project_id or not self.config.mr_iid:
351+
return
352+
path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}/award_emoji"
353+
try:
354+
headers = {**self.config.headers, "Content-Type": "application/json"}
355+
self._request_with_fallback(
356+
path=path,
357+
payload=json.dumps({"name": "thumbsup"}),
358+
method="POST",
359+
headers=headers,
360+
base_url=self.config.api_url
361+
)
362+
except Exception as e:
363+
log.debug(f"Could not add thumbsup emoji to note {comment_id} (best effort): {e}")
364+
365+
def handle_ignore_reactions(self, comments: dict) -> None:
366+
for comment in comments.get("ignore", []):
367+
if "SocketSecurity ignore" in comment.body and not self.has_thumbsup_reaction(comment.id):
368+
self.post_thumbsup_reaction(comment.id)
369+
329370
def remove_comment_alerts(self, comments: dict):
330371
security_alert = comments.get("security")
331372
if security_alert is not None:
332373
# Type narrowing: after None check, mypy knows this is Comment
333374
new_body = Comments.process_security_comment(security_alert, comments)
375+
self.handle_ignore_reactions(comments)
334376
self.update_comment(new_body, str(security_alert.id))

socketsecurity/core/scm_comments.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,13 @@ def get_ignore_options(comments: dict) -> [bool, list]:
5151
for comment in comments["ignore"]:
5252
comment: Comment
5353
first_line = comment.body_list[0]
54-
if not ignore_all and "SocketSecurity ignore" in first_line:
54+
if not ignore_all and "socketsecurity ignore" in first_line.lower():
5555
try:
5656
first_line = first_line.lstrip("@")
57-
_, command = first_line.split("SocketSecurity ")
58-
command = command.strip()
57+
# Case-insensitive split: find "SocketSecurity " regardless of casing
58+
lower_line = first_line.lower()
59+
split_idx = lower_line.index("socketsecurity ") + len("socketsecurity ")
60+
command = first_line[split_idx:].strip()
5961
if command == "ignore-all":
6062
ignore_all = True
6163
else:

socketsecurity/socketcli.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import traceback
55
import shutil
66
import warnings
7+
from datetime import datetime, timezone
8+
from uuid import uuid4
79

810
from dotenv import load_dotenv
911
from git import InvalidGitRepositoryError, NoSuchPathError
@@ -478,6 +480,17 @@ def main_code():
478480

479481
# Handle SCM-specific flows
480482
log.debug(f"Flow decision: scm={scm is not None}, force_diff_mode={force_diff_mode}, force_api_mode={force_api_mode}, enable_diff={config.enable_diff}")
483+
484+
def _is_unprocessed(c):
485+
"""Check if an ignore comment has not yet been marked with '+1' reaction.
486+
For GitHub, reactions['+1'] is already in the comment response (no extra call).
487+
For GitLab, has_thumbsup_reaction() makes a lazy API call per comment."""
488+
if getattr(c, "reactions", {}).get("+1"):
489+
return False
490+
if hasattr(scm, "has_thumbsup_reaction") and scm.has_thumbsup_reaction(c.id):
491+
return False
492+
return True
493+
481494
if scm is not None and scm.check_event_type() == "comment":
482495
# FIXME: This entire flow should be a separate command called "filter_ignored_alerts_in_comments"
483496
# It's not related to scanning or diff generation - it just:
@@ -489,6 +502,44 @@ def main_code():
489502

490503
if not config.disable_ignore:
491504
comments = scm.get_comments_for_pr()
505+
506+
# Emit telemetry for ignore comments before +1 reaction is added.
507+
# The +1 reaction (added by remove_comment_alerts) serves as the "processed" marker.
508+
if "ignore" in comments:
509+
unprocessed = [c for c in comments["ignore"] if _is_unprocessed(c)]
510+
if unprocessed:
511+
try:
512+
events = []
513+
for c in unprocessed:
514+
single = {"ignore": [c]}
515+
ignore_all, ignore_commands = Comments.get_ignore_options(single)
516+
user = getattr(c, "user", None) or getattr(c, "author", None) or {}
517+
now = datetime.now(timezone.utc).isoformat()
518+
shared_fields = {
519+
"event_kind": "user-action",
520+
"client_action": "ignore",
521+
"alert_action": "error",
522+
"event_sender_created_at": now,
523+
"vcs_provider": integration_type,
524+
"owner": config.repo.split("/")[0] if "/" in config.repo else "",
525+
"repo": config.repo,
526+
"pr_number": pr_number,
527+
"ignore_all": ignore_all,
528+
"sender_name": user.get("login") or user.get("username", ""),
529+
"sender_id": str(user.get("id", "")),
530+
}
531+
if ignore_commands:
532+
for name, version in ignore_commands:
533+
events.append({**shared_fields, "event_id": str(uuid4()), "artifact_input": f"{name}@{version}"})
534+
elif ignore_all:
535+
events.append({**shared_fields, "event_id": str(uuid4())})
536+
537+
if events:
538+
log.debug(f"Ignore telemetry: {len(events)} events to send")
539+
client.post_telemetry_events(org_slug, events)
540+
except Exception as e:
541+
log.warning(f"Failed to send ignore telemetry: {e}")
542+
492543
log.debug("Removing comment alerts")
493544
scm.remove_comment_alerts(comments)
494545
else:
@@ -504,9 +555,76 @@ def main_code():
504555
# FIXME: this overwrites diff.new_alerts, which was previously populated by Core.create_issue_alerts
505556
if not config.disable_ignore:
506557
log.debug("Removing comment alerts")
558+
alerts_before = list(diff.new_alerts)
507559
diff.new_alerts = Comments.remove_alerts(comments, diff.new_alerts)
560+
561+
ignored_alerts = [a for a in alerts_before if a not in diff.new_alerts]
562+
# Emit telemetry per-comment so each event carries the comment author.
563+
unprocessed_ignore = [
564+
c for c in comments.get("ignore", [])
565+
if _is_unprocessed(c)
566+
]
567+
if ignored_alerts and unprocessed_ignore:
568+
try:
569+
events = []
570+
now = datetime.now(timezone.utc).isoformat()
571+
for c in unprocessed_ignore:
572+
single = {"ignore": [c]}
573+
c_ignore_all, c_ignore_commands = Comments.get_ignore_options(single)
574+
user = getattr(c, "user", None) or getattr(c, "author", None) or {}
575+
sender_name = user.get("login") or user.get("username", "")
576+
sender_id = str(user.get("id", ""))
577+
578+
# Match this comment's targets to the actual ignored alerts
579+
matched_alerts = []
580+
if c_ignore_all:
581+
matched_alerts = ignored_alerts
582+
else:
583+
for alert in ignored_alerts:
584+
full_name = f"{alert.pkg_type}/{alert.pkg_name}"
585+
purl = (full_name, alert.pkg_version)
586+
purl_star = (full_name, "*")
587+
if purl in c_ignore_commands or purl_star in c_ignore_commands:
588+
matched_alerts.append(alert)
589+
590+
shared_fields = {
591+
"event_kind": "user-action",
592+
"client_action": "ignore",
593+
"event_sender_created_at": now,
594+
"vcs_provider": integration_type,
595+
"owner": config.repo.split("/")[0] if "/" in config.repo else "",
596+
"repo": config.repo,
597+
"pr_number": pr_number,
598+
"ignore_all": c_ignore_all,
599+
"sender_name": sender_name,
600+
"sender_id": sender_id,
601+
}
602+
if matched_alerts:
603+
for alert in matched_alerts:
604+
# Derive alert_action from the alert's resolved action flags
605+
if getattr(alert, "error", False):
606+
alert_action = "error"
607+
elif getattr(alert, "warn", False):
608+
alert_action = "warn"
609+
elif getattr(alert, "monitor", False):
610+
alert_action = "monitor"
611+
else:
612+
alert_action = "error"
613+
events.append({**shared_fields, "alert_action": alert_action, "event_id": str(uuid4()), "artifact_purl": alert.purl})
614+
elif c_ignore_all:
615+
events.append({**shared_fields, "event_id": str(uuid4())})
616+
617+
if events:
618+
client.post_telemetry_events(org_slug, events)
619+
620+
# Mark ignore comments as processed with +1 reaction
621+
if hasattr(scm, "handle_ignore_reactions"):
622+
scm.handle_ignore_reactions(comments)
623+
except Exception as e:
624+
log.warning(f"Failed to send ignore telemetry: {e}")
508625
else:
509626
log.info("Ignore commands disabled (--disable-ignore), all alerts will be reported")
627+
510628
log.debug("Creating Dependency Overview Comment")
511629

512630
overview_comment = Messages.dependency_overview_template(diff)

tests/unit/test_client.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,53 @@ def test_request_with_payload(client):
122122

123123
args, kwargs = mock_request.call_args
124124
assert kwargs['method'] == "POST"
125-
assert kwargs['data'] == payload
125+
assert kwargs['data'] == payload
126+
127+
128+
def test_post_telemetry_events_sends_individually(client):
129+
"""Test that telemetry events are posted one at a time to v0 API"""
130+
import json
131+
132+
events = [
133+
{"event_kind": "user-action", "client_action": "ignore_alerts", "artifact_purl": "pkg:npm/foo@1.0.0"},
134+
{"event_kind": "user-action", "client_action": "ignore_alerts", "artifact_purl": "pkg:npm/bar@2.0.0"},
135+
]
136+
137+
with patch('requests.request') as mock_request:
138+
mock_response = Mock()
139+
mock_response.status_code = 201
140+
mock_request.return_value = mock_response
141+
142+
client.post_telemetry_events("test-org", events)
143+
144+
assert mock_request.call_count == 2
145+
146+
first_call = mock_request.call_args_list[0]
147+
assert first_call.kwargs['url'] == "https://api.socket.dev/v0/orgs/test-org/telemetry"
148+
assert first_call.kwargs['method'] == "POST"
149+
assert first_call.kwargs['data'] == json.dumps(events[0])
150+
151+
second_call = mock_request.call_args_list[1]
152+
assert second_call.kwargs['data'] == json.dumps(events[1])
153+
154+
155+
def test_post_telemetry_events_continues_on_failure(client):
156+
"""Test that a failed event does not prevent subsequent events from being sent"""
157+
import json
158+
159+
events = [
160+
{"event_kind": "user-action", "artifact_purl": "pkg:npm/foo@1.0.0"},
161+
{"event_kind": "user-action", "artifact_purl": "pkg:npm/bar@2.0.0"},
162+
]
163+
164+
with patch('requests.request') as mock_request:
165+
mock_response = Mock()
166+
mock_response.status_code = 201
167+
mock_request.side_effect = [
168+
requests.exceptions.ConnectionError("timeout"),
169+
mock_response,
170+
]
171+
172+
client.post_telemetry_events("test-org", events)
173+
174+
assert mock_request.call_count == 2

0 commit comments

Comments
 (0)