Skip to content

Commit a1df23e

Browse files
authored
feat(scribe): Log user interactions to #scribe-qa. (#5942)
1 parent bea3cdc commit a1df23e

File tree

5 files changed

+315
-5
lines changed

5 files changed

+315
-5
lines changed

servers/fai/src/fai/routes/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ async def get_toggle_status(
388388
LOGGER.exception("Failed to get toggle status")
389389
return JSONResponse(content=jsonable_encoder(ToggleStatusResponse(status="failed", ask_ai_enabled=False)))
390390

391+
391392
@fai_app.post(
392393
"/settings/ask-ai/set-job-id",
393394
response_model=SetJobIdResponse,

servers/fai/src/fai/utils/scribe/pr_qa_logger.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,74 @@
99
)
1010
from fai.utils.slack.client import send_slack_message
1111

12+
QA_CHANNEL_ID = "C0A0YHMKJUT"
13+
14+
15+
async def log_install_for_qa(integration: ScribeIntegrationDb) -> None:
16+
try:
17+
qa_bot_token = VARIABLES.SCRIBE_SLACK_BOT_TOKEN
18+
19+
message_text = f"""*SCRIBE APP INSTALLED* 🎉
20+
21+
*Org:* {integration.slack_team_name or 'Unknown'}
22+
*Repository:* `{integration.github_repo}`
23+
"""
24+
25+
message_key = f"scribe_install_{integration.integration_id}"
26+
await send_slack_message(
27+
channel=QA_CHANNEL_ID,
28+
text=message_text,
29+
bot_token=qa_bot_token,
30+
message_key=message_key,
31+
)
32+
33+
LOGGER.info(f"[SCRIBE] Sent install notification to QA channel for {integration.slack_team_name}")
34+
35+
except Exception as e:
36+
LOGGER.error(f"[SCRIBE] Failed to send install notification: {e}")
37+
38+
39+
async def log_pr_created_for_qa(session_db: ScribeSessionDb) -> None:
40+
try:
41+
qa_bot_token = VARIABLES.SCRIBE_SLACK_BOT_TOKEN
42+
43+
async with async_session_maker() as session:
44+
result = await session.execute(
45+
select(ScribeIntegrationDb).where(ScribeIntegrationDb.integration_id == session_db.integration_id)
46+
)
47+
integration = result.scalar_one_or_none()
48+
49+
if not integration:
50+
LOGGER.warning(f"[SCRIBE] No integration found for session {session_db.id}")
51+
return
52+
53+
message_text = f"""*SCRIBE PR CREATED* 🚀
54+
55+
*Org:* {integration.slack_team_name or 'Unknown'}
56+
*Pull request:* {session_db.pr_url}
57+
"""
58+
59+
if session_db.devin_session_url:
60+
message_text += f"*Devin Session:* {session_db.devin_session_url}\n"
61+
if session_db.slack_thread_ts:
62+
message_text += f"*Slack Thread:* https://slack.com/app_redirect?channel={session_db.slack_channel}&message_ts={session_db.slack_thread_ts}\n"
63+
64+
message_key = f"scribe_pr_created_{session_db.id}"
65+
await send_slack_message(
66+
channel=QA_CHANNEL_ID,
67+
text=message_text,
68+
bot_token=qa_bot_token,
69+
message_key=message_key,
70+
)
71+
72+
LOGGER.info(f"[SCRIBE] Sent PR created notification to QA channel for {session_db.pr_url}")
73+
74+
except Exception as e:
75+
LOGGER.error(f"[SCRIBE] Failed to send PR created notification: {e}")
76+
1277

1378
async def log_merged_pr_for_qa(session_db: ScribeSessionDb, status: str) -> None:
1479
try:
15-
qa_channel_id = "C0A0YHMKJUT"
1680
qa_bot_token = VARIABLES.SCRIBE_SLACK_BOT_TOKEN
1781

1882
async with async_session_maker() as session:
@@ -34,9 +98,8 @@ async def log_merged_pr_for_qa(session_db: ScribeSessionDb, status: str) -> None
3498

3599
message_text = f"""{title}
36100
37-
*Repository:* `{integration.github_repo}`
38-
*PR URL:* {session_db.pr_url}
39-
*Team:* {integration.slack_team_name or 'Unknown'}
101+
*Org:* {integration.slack_team_name or 'Unknown'}
102+
*Pull request:* {session_db.pr_url}
40103
"""
41104

42105
if session_db.devin_session_url:
@@ -46,7 +109,7 @@ async def log_merged_pr_for_qa(session_db: ScribeSessionDb, status: str) -> None
46109

47110
message_key = f"scribe_pr_{session_db.id}_{status}"
48111
await send_slack_message(
49-
channel=qa_channel_id,
112+
channel=QA_CHANNEL_ID,
50113
text=message_text,
51114
bot_token=qa_bot_token,
52115
message_key=message_key,

servers/fai/src/fai/utils/scribe/session_poller.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ async def poll_devin_session(
130130
except Exception as e:
131131
LOGGER.error(f"[SCRIBE] Failed to post message to Slack: {e}")
132132

133+
pr_was_created = False
133134
async with async_session_maker() as db_session:
134135
result = await db_session.execute(select(ScribeSessionDb).where(ScribeSessionDb.id == session_id))
135136
session_record = result.scalar_one_or_none()
@@ -144,10 +145,19 @@ async def poll_devin_session(
144145
if pr_url:
145146
session_record.pr_url = pr_url
146147
session_record.pr_status = "open"
148+
pr_was_created = True
147149
LOGGER.info(f"[SCRIBE] Stored PR URL for session {session_id}: {pr_url}")
148150

149151
await db_session.commit()
150152

153+
if pr_was_created and session_record:
154+
from fai.utils.scribe.pr_qa_logger import log_pr_created_for_qa
155+
156+
try:
157+
await log_pr_created_for_qa(session_record)
158+
except Exception as e:
159+
LOGGER.error(f"[SCRIBE] Failed to send PR created notification to QA channel: {e}")
160+
151161
if status_enum in ["blocked", "stopped"]:
152162
LOGGER.info(f"[SCRIBE] Devin session {devin_session_id} reached terminal state: {status_enum}")
153163
break

servers/fai/src/fai/utils/slack/integration_common.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,14 @@ async def handle_oauth_callback(
216216

217217
LOGGER.info(f"{log_prefix} Successfully installed Slack app for team: {integration.slack_team_id}")
218218

219+
if log_prefix == "[SCRIBE]":
220+
from fai.utils.scribe.pr_qa_logger import log_install_for_qa
221+
222+
try:
223+
await log_install_for_qa(integration)
224+
except Exception as e:
225+
LOGGER.error(f"{log_prefix} Failed to send install notification to QA channel: {e}")
226+
219227
metadata = {}
220228
if hasattr(integration, "domain"):
221229
metadata["domain"] = integration.domain
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
from unittest.mock import (
2+
AsyncMock,
3+
MagicMock,
4+
patch,
5+
)
6+
7+
import pytest
8+
9+
from fai.models.db.scribe_integration_db import ScribeIntegrationDb
10+
from fai.models.db.scribe_session_db import ScribeSessionDb
11+
from fai.utils.scribe.pr_qa_logger import (
12+
QA_CHANNEL_ID,
13+
log_install_for_qa,
14+
log_merged_pr_for_qa,
15+
log_pr_created_for_qa,
16+
)
17+
18+
19+
@pytest.fixture
20+
def mock_integration() -> ScribeIntegrationDb:
21+
integration = MagicMock(spec=ScribeIntegrationDb)
22+
integration.integration_id = "test-integration-123"
23+
integration.slack_team_name = "Test Org"
24+
integration.github_repo = "test-org/test-repo"
25+
return integration
26+
27+
28+
@pytest.fixture
29+
def mock_session() -> ScribeSessionDb:
30+
session = MagicMock(spec=ScribeSessionDb)
31+
session.id = "test-session-456"
32+
session.integration_id = "test-integration-123"
33+
session.pr_url = "https://github.com/test-org/test-repo/pull/42"
34+
session.devin_session_url = "https://app.devin.ai/sessions/test-session"
35+
session.slack_thread_ts = "1234567890.123456"
36+
session.slack_channel = "C0123456789"
37+
return session
38+
39+
40+
class TestLogInstallForQA:
41+
@pytest.mark.asyncio
42+
async def test_sends_install_notification(self, mock_integration: ScribeIntegrationDb) -> None:
43+
with (
44+
patch("fai.utils.scribe.pr_qa_logger.send_slack_message", new_callable=AsyncMock) as mock_send,
45+
patch("fai.utils.scribe.pr_qa_logger.VARIABLES") as mock_vars,
46+
):
47+
mock_vars.SCRIBE_SLACK_BOT_TOKEN = "test-bot-token"
48+
49+
await log_install_for_qa(mock_integration)
50+
51+
mock_send.assert_called_once()
52+
call_args = mock_send.call_args
53+
assert call_args.kwargs["channel"] == QA_CHANNEL_ID
54+
assert call_args.kwargs["bot_token"] == "test-bot-token"
55+
assert call_args.kwargs["message_key"] == "scribe_install_test-integration-123"
56+
57+
message_text = call_args.kwargs["text"]
58+
assert "*SCRIBE APP INSTALLED* 🎉" in message_text
59+
assert "*Org:* Test Org" in message_text
60+
assert "*Repository:* `test-org/test-repo`" in message_text
61+
62+
@pytest.mark.asyncio
63+
async def test_handles_unknown_org_name(self, mock_integration: ScribeIntegrationDb) -> None:
64+
mock_integration.slack_team_name = None
65+
66+
with (
67+
patch("fai.utils.scribe.pr_qa_logger.send_slack_message", new_callable=AsyncMock) as mock_send,
68+
patch("fai.utils.scribe.pr_qa_logger.VARIABLES") as mock_vars,
69+
):
70+
mock_vars.SCRIBE_SLACK_BOT_TOKEN = "test-bot-token"
71+
72+
await log_install_for_qa(mock_integration)
73+
74+
message_text = mock_send.call_args.kwargs["text"]
75+
assert "*Org:* Unknown" in message_text
76+
77+
78+
class TestLogPRCreatedForQA:
79+
@pytest.mark.asyncio
80+
async def test_sends_pr_created_notification(
81+
self, mock_session: ScribeSessionDb, mock_integration: ScribeIntegrationDb
82+
) -> None:
83+
mock_db_session = AsyncMock()
84+
mock_result = MagicMock()
85+
mock_result.scalar_one_or_none.return_value = mock_integration
86+
mock_db_session.execute = AsyncMock(return_value=mock_result)
87+
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
88+
mock_db_session.__aexit__ = AsyncMock()
89+
90+
with (
91+
patch("fai.utils.scribe.pr_qa_logger.async_session_maker", return_value=mock_db_session),
92+
patch("fai.utils.scribe.pr_qa_logger.send_slack_message", new_callable=AsyncMock) as mock_send,
93+
patch("fai.utils.scribe.pr_qa_logger.VARIABLES") as mock_vars,
94+
):
95+
mock_vars.SCRIBE_SLACK_BOT_TOKEN = "test-bot-token"
96+
97+
await log_pr_created_for_qa(mock_session)
98+
99+
mock_send.assert_called_once()
100+
call_args = mock_send.call_args
101+
assert call_args.kwargs["channel"] == QA_CHANNEL_ID
102+
assert call_args.kwargs["bot_token"] == "test-bot-token"
103+
assert call_args.kwargs["message_key"] == "scribe_pr_created_test-session-456"
104+
105+
message_text = call_args.kwargs["text"]
106+
assert "*SCRIBE PR CREATED* 🚀" in message_text
107+
assert "*Org:* Test Org" in message_text
108+
assert "*Pull request:* https://github.com/test-org/test-repo/pull/42" in message_text
109+
assert "*Devin Session:* https://app.devin.ai/sessions/test-session" in message_text
110+
assert (
111+
"*Slack Thread:* https://slack.com/app_redirect?channel=C0123456789&message_ts=1234567890.123456"
112+
in message_text
113+
)
114+
115+
@pytest.mark.asyncio
116+
async def test_handles_missing_optional_fields(
117+
self, mock_session: ScribeSessionDb, mock_integration: ScribeIntegrationDb
118+
) -> None:
119+
mock_session.devin_session_url = None
120+
mock_session.slack_thread_ts = None
121+
122+
mock_db_session = AsyncMock()
123+
mock_result = MagicMock()
124+
mock_result.scalar_one_or_none.return_value = mock_integration
125+
mock_db_session.execute = AsyncMock(return_value=mock_result)
126+
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
127+
mock_db_session.__aexit__ = AsyncMock()
128+
129+
with (
130+
patch("fai.utils.scribe.pr_qa_logger.async_session_maker", return_value=mock_db_session),
131+
patch("fai.utils.scribe.pr_qa_logger.send_slack_message", new_callable=AsyncMock) as mock_send,
132+
patch("fai.utils.scribe.pr_qa_logger.VARIABLES") as mock_vars,
133+
):
134+
mock_vars.SCRIBE_SLACK_BOT_TOKEN = "test-bot-token"
135+
136+
await log_pr_created_for_qa(mock_session)
137+
138+
message_text = mock_send.call_args.kwargs["text"]
139+
assert "*Devin Session:*" not in message_text
140+
assert "*Slack Thread:*" not in message_text
141+
142+
143+
class TestLogMergedPRForQA:
144+
@pytest.mark.asyncio
145+
async def test_sends_merged_pr_notification(
146+
self, mock_session: ScribeSessionDb, mock_integration: ScribeIntegrationDb
147+
) -> None:
148+
mock_db_session = AsyncMock()
149+
mock_result = MagicMock()
150+
mock_result.scalar_one_or_none.return_value = mock_integration
151+
mock_db_session.execute = AsyncMock(return_value=mock_result)
152+
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
153+
mock_db_session.__aexit__ = AsyncMock()
154+
155+
with (
156+
patch("fai.utils.scribe.pr_qa_logger.async_session_maker", return_value=mock_db_session),
157+
patch("fai.utils.scribe.pr_qa_logger.send_slack_message", new_callable=AsyncMock) as mock_send,
158+
patch("fai.utils.scribe.pr_qa_logger.VARIABLES") as mock_vars,
159+
):
160+
mock_vars.SCRIBE_SLACK_BOT_TOKEN = "test-bot-token"
161+
162+
await log_merged_pr_for_qa(mock_session, "merged")
163+
164+
mock_send.assert_called_once()
165+
call_args = mock_send.call_args
166+
assert call_args.kwargs["channel"] == QA_CHANNEL_ID
167+
assert call_args.kwargs["bot_token"] == "test-bot-token"
168+
assert call_args.kwargs["message_key"] == "scribe_pr_test-session-456_merged"
169+
170+
message_text = call_args.kwargs["text"]
171+
assert "*SCRIBE PR MERGED* ✅" in message_text
172+
assert "*Org:* Test Org" in message_text
173+
assert "*Pull request:* https://github.com/test-org/test-repo/pull/42" in message_text
174+
assert "*Devin Session:* https://app.devin.ai/sessions/test-session" in message_text
175+
assert (
176+
"*Slack Thread:* https://slack.com/app_redirect?channel=C0123456789&message_ts=1234567890.123456"
177+
in message_text
178+
)
179+
180+
@pytest.mark.asyncio
181+
async def test_sends_closed_pr_notification(
182+
self, mock_session: ScribeSessionDb, mock_integration: ScribeIntegrationDb
183+
) -> None:
184+
mock_db_session = AsyncMock()
185+
mock_result = MagicMock()
186+
mock_result.scalar_one_or_none.return_value = mock_integration
187+
mock_db_session.execute = AsyncMock(return_value=mock_result)
188+
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
189+
mock_db_session.__aexit__ = AsyncMock()
190+
191+
with (
192+
patch("fai.utils.scribe.pr_qa_logger.async_session_maker", return_value=mock_db_session),
193+
patch("fai.utils.scribe.pr_qa_logger.send_slack_message", new_callable=AsyncMock) as mock_send,
194+
patch("fai.utils.scribe.pr_qa_logger.VARIABLES") as mock_vars,
195+
):
196+
mock_vars.SCRIBE_SLACK_BOT_TOKEN = "test-bot-token"
197+
198+
await log_merged_pr_for_qa(mock_session, "closed")
199+
200+
message_text = mock_send.call_args.kwargs["text"]
201+
assert "*SCRIBE PR CLOSED* ❌" in message_text
202+
203+
@pytest.mark.asyncio
204+
async def test_updated_format_uses_org_not_team(
205+
self, mock_session: ScribeSessionDb, mock_integration: ScribeIntegrationDb
206+
) -> None:
207+
mock_db_session = AsyncMock()
208+
mock_result = MagicMock()
209+
mock_result.scalar_one_or_none.return_value = mock_integration
210+
mock_db_session.execute = AsyncMock(return_value=mock_result)
211+
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
212+
mock_db_session.__aexit__ = AsyncMock()
213+
214+
with (
215+
patch("fai.utils.scribe.pr_qa_logger.async_session_maker", return_value=mock_db_session),
216+
patch("fai.utils.scribe.pr_qa_logger.send_slack_message", new_callable=AsyncMock) as mock_send,
217+
patch("fai.utils.scribe.pr_qa_logger.VARIABLES") as mock_vars,
218+
):
219+
mock_vars.SCRIBE_SLACK_BOT_TOKEN = "test-bot-token"
220+
221+
await log_merged_pr_for_qa(mock_session, "merged")
222+
223+
message_text = mock_send.call_args.kwargs["text"]
224+
assert "*Org:*" in message_text
225+
assert "*Pull request:*" in message_text
226+
assert "*Team:*" not in message_text
227+
assert "*Repository:*" not in message_text
228+
assert "*PR URL:*" not in message_text

0 commit comments

Comments
 (0)