Skip to content

Commit d8d8a6e

Browse files
GWealecopybara-github
authored andcommitted
fix(samples): separate skipped from failed issues in the monitoring agent
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 963583295
1 parent 30f32e3 commit d8d8a6e

2 files changed

Lines changed: 136 additions & 8 deletions

File tree

contributing/samples/adk_team/adk_issue_monitoring_agent/main.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
import asyncio
1616
import logging
1717
import re
18+
import sys
1819
import time
20+
from typing import Literal
1921

2022
from adk_issue_monitoring_agent.agent import root_agent
2123
from adk_issue_monitoring_agent.settings import BOT_ALERT_SIGNATURE
@@ -40,12 +42,17 @@
4042
APP_NAME = "issue_monitoring_app"
4143
USER_ID = "issue_monitoring_user"
4244

45+
# An issue is skipped when there is deliberately nothing to review, which is a
46+
# normal outcome and not a failure.
47+
_Outcome = Literal["audited", "skipped", "failed"]
48+
4349

4450
async def process_single_issue(
4551
runner: InMemoryRunner, issue_number: int, maintainers: list[str]
46-
) -> tuple[float, int]:
52+
) -> tuple[float, int, _Outcome]:
4753
start_time = time.perf_counter()
4854
start_api_calls = get_api_call_count()
55+
outcome: _Outcome = "audited"
4956

5057
try:
5158
# 1. Fetch the main issue AND the comments
@@ -87,6 +94,7 @@ async def process_single_issue(
8794
return (
8895
time.perf_counter() - start_time,
8996
get_api_call_count() - start_api_calls,
97+
"skipped",
9098
)
9199

92100
if (
@@ -111,6 +119,7 @@ async def process_single_issue(
111119
return (
112120
time.perf_counter() - start_time,
113121
get_api_call_count() - start_api_calls,
122+
"skipped",
114123
)
115124

116125
logger.info(
@@ -147,14 +156,15 @@ async def process_single_issue(
147156

148157
except Exception as e:
149158
logger.error(f"Error processing issue #{issue_number}: {e}", exc_info=True)
159+
outcome = "failed"
150160

151161
# Calculate duration and API calls regardless of success or failure
152162
duration = time.perf_counter() - start_time
153163
issue_api_calls = get_api_call_count() - start_api_calls
154-
return duration, issue_api_calls
164+
return duration, issue_api_calls, outcome
155165

156166

157-
async def main():
167+
async def main() -> int:
158168
logger.info(f"--- Starting Issue Monitoring Agent for {OWNER}/{REPO} ---")
159169
reset_api_call_count()
160170

@@ -164,25 +174,29 @@ async def main():
164174
logger.info(f"Found {len(maintainers)} maintainers.")
165175
except Exception as e:
166176
logger.critical(f"Failed to fetch maintainers: {e}")
167-
return
177+
return 1
168178

169179
# Step 2: Fetch target issues
170180
try:
171181
all_issues = get_target_issues(OWNER, REPO)
172182
except Exception as e:
173183
logger.critical(f"Failed to fetch issue list: {e}")
174-
return
184+
return 1
175185

176186
total_count = len(all_issues)
177187
if total_count == 0:
178188
logger.info("No issues matched criteria. Run finished.")
179-
return
189+
return 0
180190

181191
logger.info(f"Found {total_count} issues to process.")
182192

183193
# Initialize the runner ONCE for the entire run
184194
runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME)
185195

196+
audited_count = 0
197+
skipped_count = 0
198+
failed_count = 0
199+
186200
# Step 3: Iterate through issues async 'CONCURRENCY_LIMIT' at a time
187201
for i in range(0, total_count, CONCURRENCY_LIMIT):
188202
chunk = all_issues[i : i + CONCURRENCY_LIMIT]
@@ -192,13 +206,28 @@ async def main():
192206
process_single_issue(runner, issue_num, maintainers)
193207
for issue_num in chunk
194208
]
195-
await asyncio.gather(*tasks)
209+
results = await asyncio.gather(*tasks)
210+
211+
for _, _, outcome in results:
212+
if outcome == "audited":
213+
audited_count += 1
214+
elif outcome == "skipped":
215+
skipped_count += 1
216+
else:
217+
failed_count += 1
196218

197219
if (i + CONCURRENCY_LIMIT) < total_count:
198220
await asyncio.sleep(SLEEP_BETWEEN_CHUNKS)
199221

200222
logger.info(f"--- Run Finished. Total API calls: {get_api_call_count()} ---")
223+
logger.info(f"Successfully processed {audited_count} issues.")
224+
if skipped_count:
225+
logger.info(f"Skipped {skipped_count} issues.")
226+
if failed_count:
227+
logger.error(f"Failed to process {failed_count} issues.")
228+
229+
return 1 if failed_count else 0
201230

202231

203232
if __name__ == "__main__":
204-
asyncio.run(main())
233+
sys.exit(asyncio.run(main()))

tests/unittests/test_samples.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,102 @@ async def run_async(
354354
assert ("Failed to process 1 issues." in caplog.text) == (
355355
failing_issue is not None
356356
)
357+
358+
359+
@pytest.mark.parametrize(
360+
"failing_issue, expected_exit_code", [(None, 0), (4, 1)]
361+
)
362+
async def test_issue_monitoring_agent_separates_skips_from_failures(
363+
failing_issue: int | None,
364+
expected_exit_code: int,
365+
monkeypatch,
366+
caplog,
367+
):
368+
"""A skipped issue is not a failure, and a failed one is not a success."""
369+
for key, value in _DUMMY_ENV.items():
370+
monkeypatch.setenv(key, value)
371+
372+
reviewed: list[int] = []
373+
374+
class _FakeSession:
375+
id = "fake-session"
376+
377+
class _FakeSessionService:
378+
379+
async def create_session(
380+
self, *, user_id: str, app_name: str
381+
) -> _FakeSession:
382+
return _FakeSession()
383+
384+
class _FakeRunner:
385+
"""Stands in for InMemoryRunner, failing the audit of one issue."""
386+
387+
def __init__(self, *, agent: Any, app_name: str) -> None:
388+
self.session_service = _FakeSessionService()
389+
390+
async def run_async(
391+
self, *, user_id: str, session_id: str, new_message: types.Content
392+
) -> AsyncIterator[Event]:
393+
text = new_message.parts[0].text
394+
issue_number = int(text.split("#")[1].split(":")[0])
395+
reviewed.append(issue_number)
396+
if issue_number == failing_issue:
397+
raise RuntimeError("model backend unavailable")
398+
yield Event(
399+
author="agent",
400+
content=types.Content(
401+
role="model", parts=[types.Part(text="Not spam.")]
402+
),
403+
)
404+
405+
with _sample_module(
406+
SAMPLES_DIR / "adk_team" / "adk_issue_monitoring_agent", "main"
407+
) as main_module:
408+
# 1 and 4 are audited, 2 is skipped because the bot already alerted on it,
409+
# 3 is skipped because only a maintainer has written on it.
410+
details = {
411+
n: {"user": {"login": "maintainer"}, "body": "tracking"} for n in (2, 3)
412+
}
413+
details[1] = {"user": {"login": "outsider"}, "body": "buy things"}
414+
details[4] = {"user": {"login": "outsider"}, "body": "buy more things"}
415+
comments = {
416+
2: [{
417+
"user": {"login": main_module.BOT_NAME},
418+
"body": main_module.BOT_ALERT_SIGNATURE,
419+
}],
420+
3: [{"user": {"login": "maintainer"}, "body": "still looking"}],
421+
}
422+
423+
monkeypatch.setattr(main_module, "InMemoryRunner", _FakeRunner)
424+
monkeypatch.setattr(main_module, "SLEEP_BETWEEN_CHUNKS", 0)
425+
monkeypatch.setattr(
426+
main_module,
427+
"get_repository_maintainers",
428+
lambda owner, repo: ["maintainer"],
429+
)
430+
monkeypatch.setattr(
431+
main_module, "get_target_issues", lambda owner, repo: [1, 2, 3, 4]
432+
)
433+
monkeypatch.setattr(
434+
main_module,
435+
"get_issue_details",
436+
lambda owner, repo, issue_number: details[issue_number],
437+
)
438+
monkeypatch.setattr(
439+
main_module,
440+
"get_issue_comments",
441+
lambda owner, repo, issue_number: comments.get(issue_number, []),
442+
)
443+
with caplog.at_level(logging.INFO, logger="google_adk"):
444+
exit_code = await main_module.main()
445+
446+
assert exit_code == expected_exit_code
447+
# Every reviewable issue still reaches the agent: one failure must not abort
448+
# the batch.
449+
assert sorted(reviewed) == [1, 4]
450+
expected_successes = 2 if failing_issue is None else 1
451+
assert f"Successfully processed {expected_successes} issues." in caplog.text
452+
assert "Skipped 2 issues." in caplog.text
453+
assert ("Failed to process 1 issues." in caplog.text) == (
454+
failing_issue is not None
455+
)

0 commit comments

Comments
 (0)