Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions contributing/samples/adk_team/adk_stale_agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import asyncio
import logging
import sys
import time
from typing import Tuple

Expand All @@ -37,20 +38,18 @@
USER_ID = "stale_bot_user"


async def process_single_issue(issue_number: int) -> Tuple[float, int]:
async def process_single_issue(issue_number: int) -> Tuple[bool, float, int]:
"""
Processes a single GitHub issue using the AI agent and logs execution metrics.

Args:
issue_number (int): The GitHub issue number to audit.

Returns:
Tuple[float, int]: A tuple containing:
Tuple[bool, float, int]: A tuple containing:
- success (bool): Whether the issue was processed without error.
- duration (float): Time taken to process the issue in seconds.
- api_calls (int): The number of API calls made during this specific execution.

Raises:
Exception: catches generic exceptions to prevent one failure from stopping the batch.
"""
start_time = time.perf_counter()

Expand All @@ -59,6 +58,8 @@ async def process_single_issue(issue_number: int) -> Tuple[float, int]:
logger.info(f"Processing Issue #{issue_number}...")
logger.debug(f"#{issue_number}: Initializing runner and session.")

success = True

try:
runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME)
session = await runner.session_service.create_session(
Expand Down Expand Up @@ -87,6 +88,7 @@ async def process_single_issue(issue_number: int) -> Tuple[float, int]:

except Exception as e:
logger.error(f"Error processing issue #{issue_number}: {e}", exc_info=True)
success = False

duration = time.perf_counter() - start_time

Expand All @@ -98,15 +100,20 @@ async def process_single_issue(issue_number: int) -> Tuple[float, int]:
f"with ~{issue_api_calls} API calls."
)

return duration, issue_api_calls
return success, duration, issue_api_calls


async def main():
async def main() -> bool:
"""
Main entry point to run the stale issue bot concurrently.

Fetches old issues and processes them in batches to respect API rate limits
and concurrency constraints.

Returns:
bool: True if every fetched issue was processed successfully, False if
one or more individual issues failed. Some issues failing is a
normal, expected outcome and is not treated as a fatal error here.
"""
logger.info(f"--- Starting Stale Bot for {OWNER}/{REPO} ---")
logger.info(f"Concurrency level set to {CONCURRENCY_LIMIT}")
Expand All @@ -120,15 +127,15 @@ async def main():
all_issues = get_old_open_issue_numbers(OWNER, REPO, days_old=filter_days)
except Exception as e:
logger.critical(f"Failed to fetch issue list: {e}", exc_info=True)
return
return True

total_count = len(all_issues)

search_api_calls = get_api_call_count()

if total_count == 0:
logger.info("No issues matched the criteria. Run finished.")
return
return True

logger.info(
f"Found {total_count} issues to process. "
Expand All @@ -138,6 +145,8 @@ async def main():
total_processing_time = 0.0
total_issue_api_calls = 0
processed_count = 0
completed_count = 0
failed_issue_numbers = []

# Process the list in chunks of size CONCURRENCY_LIMIT
for i in range(0, total_count, CONCURRENCY_LIMIT):
Expand All @@ -152,14 +161,18 @@ async def main():

results = await asyncio.gather(*tasks)

for duration, api_calls in results:
for issue_num, (success, duration, api_calls) in zip(chunk, results):
total_processing_time += duration
total_issue_api_calls += api_calls
completed_count += 1
if success:
processed_count += 1
else:
failed_issue_numbers.append(issue_num)

processed_count += len(chunk)
logger.info(
f"--- Finished chunk {current_chunk_num}. Progress:"
f" {processed_count}/{total_count} ---"
f" {completed_count}/{total_count} ---"
)

if (i + CONCURRENCY_LIMIT) < total_count:
Expand All @@ -175,21 +188,34 @@ async def main():

logger.info("--- Stale Agent Run Finished ---")
logger.info(f"Successfully processed {processed_count} issues.")
if failed_issue_numbers:
logger.error(
f"Failed to process {len(failed_issue_numbers)} issues:"
f" {failed_issue_numbers}"
)
logger.info(f"Total API calls made this run: {total_api_calls_for_run}")
logger.info(
f"Average processing time per issue: {avg_time_per_issue:.2f} seconds."
)

return not failed_issue_numbers

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the author confirm whether this PR, including its review-response commits, was created or updated by an automated AI pipeline? If so, please disclose the tool and confirm that a human has reviewed and tested the resulting changes.



if __name__ == "__main__":
start_time = time.perf_counter()
exit_code = 0

try:
asyncio.run(main())
if not asyncio.run(main()):
exit_code = 1
except KeyboardInterrupt:
logger.warning("Bot execution interrupted manually.")
exit_code = 1
except Exception as e:
logger.critical(f"Unexpected fatal error: {e}", exc_info=True)
exit_code = 1

duration = time.perf_counter() - start_time
logger.info(f"Full audit finished in {duration/60:.2f} minutes.")

sys.exit(exit_code)
97 changes: 97 additions & 0 deletions tests/unittests/test_adk_stale_agent_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tests for the adk_stale_agent sample's batch outcome accounting.

Regression test for a bug where `process_single_issue` swallowed exceptions
and still reported success, letting `main()` log "Successfully processed N
issues" and exit 0 even when every issue in the batch failed.
"""

from __future__ import annotations

import asyncio
import importlib
from pathlib import Path
import sys
from types import SimpleNamespace

import pytest

ADK_TEAM_DIR = (
Path(__file__).parent.parent.parent
/ "contributing"
/ "samples"
/ "adk_team"
)


class _FailingRunner:
"""A fake InMemoryRunner whose run_async always raises."""

def __init__(self, **kwargs):
self.session_service = self

async def create_session(self, **kwargs):
return SimpleNamespace(id="synthetic-session")

async def run_async(self, **kwargs):
if False: # pragma: no cover - makes this an async generator.
yield None
raise RuntimeError("synthetic runner failure")


def _unload_adk_stale_agent():
for mod_name in list(sys.modules):
if mod_name == "adk_stale_agent" or mod_name.startswith("adk_stale_agent."):
del sys.modules[mod_name]


@pytest.fixture
def stale_agent_main(monkeypatch):
"""Imports adk_stale_agent.main with a fake GitHub token in place."""
monkeypatch.setenv("GITHUB_TOKEN", "test-token")
monkeypatch.syspath_prepend(str(ADK_TEAM_DIR))
_unload_adk_stale_agent()

main = importlib.import_module("adk_stale_agent.main")
yield main

_unload_adk_stale_agent()


def test_main_returns_false_when_every_issue_fails(
stale_agent_main, monkeypatch
):
main = stale_agent_main
monkeypatch.setattr(main, "InMemoryRunner", _FailingRunner)
monkeypatch.setattr(
main,
"get_old_open_issue_numbers",
lambda *args, **kwargs: [101, 202, 303],
)
monkeypatch.setattr(main, "get_api_call_count", lambda: 0)
monkeypatch.setattr(main, "reset_api_call_count", lambda: None)

assert asyncio.run(main.main()) is False


def test_process_single_issue_reports_failure(stale_agent_main, monkeypatch):
main = stale_agent_main
monkeypatch.setattr(main, "InMemoryRunner", _FailingRunner)
monkeypatch.setattr(main, "get_api_call_count", lambda: 0)

success, _, _ = asyncio.run(main.process_single_issue(101))

assert success is False
Loading