Skip to content

Commit 09e3172

Browse files
committed
feat(ignore): adding event logger for ignored comments
1 parent abe5df3 commit 09e3172

File tree

10 files changed

+400
-8
lines changed

10 files changed

+400
-8
lines changed

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.79"
9+
version = "2.2.80"
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.79'
2+
__version__ = '2.2.80'
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/github.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,20 @@ def post_reaction(self, comment_id: int) -> None:
211211
base_url=self.config.api_url
212212
)
213213

214+
def post_eyes_reaction(self, comment_id: int) -> None:
215+
path = f"repos/{self.config.owner}/{self.config.repository}/issues/comments/{comment_id}/reactions"
216+
payload = json.dumps({"content": "eyes"})
217+
try:
218+
self.client.request(
219+
path=path,
220+
payload=payload,
221+
method="POST",
222+
headers=self.config.headers,
223+
base_url=self.config.api_url
224+
)
225+
except Exception as error:
226+
log.warning(f"Failed to add eyes reaction to comment {comment_id}: {error}")
227+
214228
def comment_reaction_exists(self, comment_id: int) -> bool:
215229
path = f"repos/{self.config.owner}/{self.config.repository}/issues/comments/{comment_id}/reactions"
216230
try:

socketsecurity/core/scm/gitlab.py

Lines changed: 36 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_eyes_reaction(self, comment_id: int) -> bool:
224+
"""Best-effort check for 'eyes' 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") == "eyes":
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,6 +345,23 @@ 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_eyes_reaction(self, comment_id: int) -> None:
349+
"""Best-effort: add 'eyes' award emoji to a MR note. The token may lack permission."""
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": "eyes"}),
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 eyes emoji to note {comment_id} (best effort): {e}")
364+
329365
def remove_comment_alerts(self, comments: dict):
330366
security_alert = comments.get("security")
331367
if security_alert is not None:

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: 115 additions & 1 deletion
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 'eyes' reaction.
486+
For GitHub, reactions.eyes is already in the comment response (no extra call).
487+
For GitLab, has_eyes_reaction() makes a lazy API call per comment."""
488+
if getattr(c, "reactions", {}).get("eyes"):
489+
return False
490+
if hasattr(scm, "has_eyes_reaction") and scm.has_eyes_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:
@@ -486,10 +499,52 @@ def main_code():
486499
# 3. Updates the comment to remove ignored alerts
487500
# This is completely separate from the main scanning functionality
488501
log.info("Comment initiated flow")
489-
502+
490503
comments = scm.get_comments_for_pr()
504+
491505
log.debug("Removing comment alerts")
492506
scm.remove_comment_alerts(comments)
507+
508+
# Emit telemetry only for ignore comments not yet marked with 'eyes' reaction.
509+
# Process each comment individually so the comment author is recorded per event.
510+
if "ignore" in comments:
511+
unprocessed = [c for c in comments["ignore"] if _is_unprocessed(c)]
512+
if unprocessed:
513+
try:
514+
events = []
515+
for c in unprocessed:
516+
single = {"ignore": [c]}
517+
ignore_all, ignore_commands = Comments.get_ignore_options(single)
518+
user = getattr(c, "user", None) or getattr(c, "author", None) or {}
519+
now = datetime.now(timezone.utc).isoformat()
520+
shared_fields = {
521+
"event_kind": "user-action",
522+
"client_action": "ignore_alerts",
523+
"event_sender_created_at": now,
524+
"vcs_provider": integration_type,
525+
"owner": config.repo.split("/")[0] if "/" in config.repo else "",
526+
"repo": config.repo,
527+
"pr_number": pr_number,
528+
"ignore_all": ignore_all,
529+
"sender_name": user.get("login") or user.get("username", ""),
530+
"sender_id": str(user.get("id", "")),
531+
}
532+
if ignore_commands:
533+
for name, version in ignore_commands:
534+
events.append({**shared_fields, "event_id": str(uuid4()), "artifact_input": f"{name}@{version}"})
535+
elif ignore_all:
536+
events.append({**shared_fields, "event_id": str(uuid4())})
537+
538+
if events:
539+
log.debug(f"Ignore telemetry: {len(events)} events to send")
540+
client.post_telemetry_events(org_slug, events)
541+
542+
# Mark as processed with eyes reaction
543+
if hasattr(scm, "post_eyes_reaction"):
544+
for c in unprocessed:
545+
scm.post_eyes_reaction(c.id)
546+
except Exception as e:
547+
log.warning(f"Failed to send ignore telemetry: {e}")
493548

494549
elif scm is not None and scm.check_event_type() != "comment" and not force_api_mode:
495550
log.info("Push initiated flow")
@@ -500,7 +555,66 @@ def main_code():
500555
log.debug("Removing comment alerts")
501556

502557
# FIXME: this overwrites diff.new_alerts, which was previously populated by Core.create_issue_alerts
558+
alerts_before = list(diff.new_alerts)
503559
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_alerts",
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+
events.append({**shared_fields, "event_id": str(uuid4()), "artifact_purl": alert.purl})
605+
elif c_ignore_all:
606+
events.append({**shared_fields, "event_id": str(uuid4())})
607+
608+
if events:
609+
client.post_telemetry_events(org_slug, events)
610+
611+
# Mark ignore comments as processed
612+
if hasattr(scm, "post_eyes_reaction"):
613+
for c in unprocessed_ignore:
614+
scm.post_eyes_reaction(c.id)
615+
except Exception as e:
616+
log.warning(f"Failed to send ignore telemetry: {e}")
617+
504618
log.debug("Creating Dependency Overview Comment")
505619

506620
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)