@@ -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