diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 0000000000..fdba2035bb --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -0,0 +1,57 @@ +# .github/workflows/stale-issue-auditor.yml + +# Best Practice: Always have a 'name' field at the top. +name: ADK Stale Issue Auditor + +# The 'on' block defines the triggers. +on: + # The 'workflow_dispatch' trigger allows manual runs. + workflow_dispatch: + + # The 'schedule' trigger runs the bot on a timer. + schedule: + # This runs at 6:00 AM UTC (e.g., 10 PM PST). + - cron: '0 6 * * *' + +# The 'jobs' block contains the work to be done. +jobs: + # A unique ID for the job. + audit-stale-issues: + # The runner environment. + runs-on: ubuntu-latest + + # Permissions for the job's temporary GITHUB_TOKEN. + # These are standard and syntactically correct. + permissions: + issues: write + contents: read + + # The sequence of steps for the job. + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + # The '|' character allows for multi-line shell commands. + run: | + python -m pip install --upgrade pip + pip install requests google-adk + + - name: Run Auditor Agent Script + # The 'env' block for setting environment variables. + env: + GITHUB_TOKEN: ${{ secrets.ADK_TRIAGE_AGENT }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OWNER: google + REPO: adk-python + ISSUES_PER_RUN: 100 + LLM_MODEL_NAME: "gemini-2.5-flash" + PYTHONPATH: contributing/samples + + # The final 'run' command. + run: python -m adk_stale_agent.main \ No newline at end of file diff --git a/contributing/samples/adk_stale_agent/PROMPT_INSTRUCTION.txt b/contributing/samples/adk_stale_agent/PROMPT_INSTRUCTION.txt new file mode 100644 index 0000000000..8ab6cda3e5 --- /dev/null +++ b/contributing/samples/adk_stale_agent/PROMPT_INSTRUCTION.txt @@ -0,0 +1,40 @@ +You are a highly intelligent and transparent repository auditor for '{OWNER}/{REPO}'. + Your job is to analyze all open issues and report on your findings before taking any action. + + **Primary Directive:** Ignore any events from users ending in `[bot]`. + **Reporting Directive:** For EVERY issue you analyze, you MUST output a concise, human-readable summary, starting with "Analysis for Issue #[number]:". + + **WORKFLOW:** + 1. **Context Gathering**: Call `get_repository_maintainers` and `get_all_open_issues`. + 2. **Per-Issue Analysis**: For each issue, call `get_issue_state`, passing in the maintainers list. + 3. **Decision & Reporting**: Based on the summary from `get_issue_state`, follow this strict decision tree in order. + + --- **DECISION TREE & REPORTING TEMPLATES** --- + + **STEP 1: CHECK FOR ACTIVITY (IS THE ISSUE ACTIVE?)** + - **Condition**: Was the last human action NOT from a maintainer? (i.e., `last_human_commenter_is_maintainer` is `False`). + - **Action**: The author or a third party has acted. The issue is ACTIVE. + - **Report and Action**: If '{STALE_LABEL_NAME}' is present, report: "Analysis for Issue #[number]: Issue is ACTIVE. The last action was a [action type] by a non-maintainer. To get the [action type], you MUST use the value from the 'last_human_action_type' field in the summary you received from the tool." Action: Removing stale label and then call `remove_label_from_issue` with the label name '{STALE_LABEL_NAME}'. Otherwise, report: "Analysis for Issue #[number]: Issue is ACTIVE. No stale label to remove. Action: None." + - **If this condition is met, stop processing this issue.** + + **STEP 2: IF PENDING, MANAGE THE STALE LIFECYCLE.** + - **Condition**: The last human action WAS from a maintainer (`last_human_commenter_is_maintainer` is `True`). The issue is PENDING. + - **Action**: You must now determine the correct state. + + - **First, check if the issue is already STALE.** + - **Condition**: Is the `'{STALE_LABEL_NAME}'` label present in `current_labels`? + - **Action**: The issue is STALE. Your only job is to check if it should be closed. + - **Get Time Difference**: Call `calculate_time_difference` with the `stale_label_applied_at` timestamp. + - **Decision & Report**: If `hours_passed` > **{CLOSE_HOURS_AFTER_STALE_THRESHOLD}**: Report "Analysis for Issue #[number]: STALE. Close threshold met ({CLOSE_HOURS_AFTER_STALE_THRESHOLD} hours) with no author activity." Action: Closing issue and then call `close_as_stale`. Otherwise, report "Analysis for Issue #[number]: STALE. Close threshold not yet met. Action: None." + + - **ELSE (the issue is PENDING but not yet stale):** + - **Analyze Intent**: Semantically analyze the `last_maintainer_comment_text`. Is it either a question, a request for information, a suggestion, or a request for changes? + - **If YES (it is either a question, a request for information, a suggestion, or a request for changes)**: + - **CRITICAL CHECK**: Now, you must verify the author has not already responded. Compare the `last_author_event_time` and the `last_maintainer_comment_time`. + - **IF the author has NOT responded** (i.e., `last_author_event_time` is older than `last_maintainer_comment_time` or is null): + - **Get Time Difference**: Call `calculate_time_difference` with the `last_maintainer_comment_time`. + - **Decision & Report**: If `hours_passed` > **{STALE_HOURS_THRESHOLD}**: Report "Analysis for Issue #[number]: PENDING. Stale threshold met ({STALE_HOURS_THRESHOLD} hours)." Action: Marking as stale and then call `add_stale_label_and_comment` and if label name '{REQUEST_CLARIFICATION_LABEL}' is missing then call `add_label_to_issue` with the label name '{REQUEST_CLARIFICATION_LABEL}'. Otherwise, report: "Analysis for Issue #[number]: PENDING. Stale threshold not met. Action: None." + - **ELSE (the author HAS responded)**: + - **Report**: "Analysis for Issue #[number]: PENDING, but author has already responded to the last maintainer request. Action: None." + - **If NO (it is not a request):** + - **Report**: "Analysis for Issue #[number]: PENDING. Maintainer's last comment was not a request. Action: None." \ No newline at end of file diff --git a/contributing/samples/adk_stale_agent/README.md b/contributing/samples/adk_stale_agent/README.md new file mode 100644 index 0000000000..965402ca09 --- /dev/null +++ b/contributing/samples/adk_stale_agent/README.md @@ -0,0 +1,65 @@ +# ADK Stale Issue Auditor Agent + +This directory contains an autonomous agent designed to audit a GitHub repository for stale issues, helping to maintain repository hygiene and ensure that all open items are actionable. + +The agent operates as a "Repository Auditor," proactively scanning all open issues rather than waiting for a specific trigger. It uses a combination of deterministic Python tools and the semantic understanding of a Large Language Model (LLM) to make intelligent decisions about the state of a conversation. + +--- + +## Core Logic & Features + +The agent's primary goal is to identify issues where a maintainer has requested information from the author, and to manage the lifecycle of that issue based on the author's response (or lack thereof). + +**The agent follows a precise decision tree:** + +1. **Audits All Open Issues:** On each run, the agent fetches a batch of the oldest open issues in the repository. +2. **Identifies Pending Issues:** It analyzes the full timeline of each issue to see if the last human action was a comment from a repository maintainer. +3. **Semantic Intent Analysis:** If the last comment was from a maintainer, the agent uses the LLM to determine if the comment was a **question or a request for clarification**. +4. **Marks as Stale:** If the maintainer's question has gone unanswered by the author for a configurable period (e.g., 7 days), the agent will: + * Apply a `stale` label to the issue. + * Post a comment notifying the author that the issue is now considered stale and will be closed if no further action is taken. + * Proactively add a `request clarification` label if it's missing, to make the issue's state clear. +5. **Handles Activity:** If any non-maintainer (the author or a third party) comments on an issue, the agent will automatically remove the `stale` label, marking the issue as active again. +6. **Closes Stale Issues:** If an issue remains in the `stale` state for another configurable period (e.g., 7 days) with no new activity, the agent will post a final comment and close the issue. + +### Self-Configuration + +A key feature of this agent is its ability to self-configure. It does not require a hard-coded list of maintainer usernames. On each run, it uses the GitHub API to dynamically fetch the list of users with write access to the repository, ensuring its logic is always based on the current team. + +--- + +## Configuration + +The agent is configured entirely via environment variables, which should be set as secrets in the GitHub Actions workflow environment. + +### Required Secrets + +| Secret Name | Description | +| :--- | :--- | +| `GITHUB_TOKEN` | A GitHub Personal Access Token (PAT) with the required permissions. It's recommended to use a PAT from a dedicated "bot" account. +| `GOOGLE_API_KEY` | An API key for the Google AI (Gemini) model used for the agent's reasoning. + +### Required PAT Permissions + +The `GITHUB_TOKEN` requires the following **Repository Permissions**: +* **Issues**: `Read & write` (to read issues, add labels, comment, and close) +* **Administration**: `Read-only` (to read the list of repository collaborators/maintainers) + +### Optional Configuration + +These environment variables can be set in the workflow file to override the defaults in `settings.py`. + +| Variable Name | Description | Default | +| :--- | :--- | :--- | +| `STALE_HOURS_THRESHOLD` | The number of hours of inactivity after a maintainer's question before an issue is marked as `stale`. | `168` (7 days) | +| `CLOSE_HOURS_AFTER_STALE_THRESHOLD` | The number of hours after being marked `stale` before an issue is closed. | `168` (7 days) | +| `ISSUES_PER_RUN` | The maximum number of oldest open issues to process in a single workflow run. | `100` | +| `LLM_MODEL_NAME`| LLM model to use. | `gemini-2.5-flash` | + +--- + +## Deployment + +To deploy this agent, a GitHub Actions workflow file (`.github/workflows/stale-bot.yml`) is included. This workflow runs on a daily schedule and executes the agent's main script. + +Ensure the necessary repository secrets are configured and the `stale` and `request clarification` labels exist in the repository. \ No newline at end of file diff --git a/contributing/samples/adk_stale_agent/__init__.py b/contributing/samples/adk_stale_agent/__init__.py new file mode 100644 index 0000000000..c48963cdc7 --- /dev/null +++ b/contributing/samples/adk_stale_agent/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 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. + +from . import agent diff --git a/contributing/samples/adk_stale_agent/agent.py b/contributing/samples/adk_stale_agent/agent.py new file mode 100644 index 0000000000..abcb128288 --- /dev/null +++ b/contributing/samples/adk_stale_agent/agent.py @@ -0,0 +1,434 @@ +# Copyright 2025 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. + +from datetime import datetime +from datetime import timezone +import logging +import os +from typing import Any + +from adk_stale_agent.settings import CLOSE_HOURS_AFTER_STALE_THRESHOLD +from adk_stale_agent.settings import GITHUB_BASE_URL +from adk_stale_agent.settings import ISSUES_PER_RUN +from adk_stale_agent.settings import LLM_MODEL_NAME +from adk_stale_agent.settings import OWNER +from adk_stale_agent.settings import REPO +from adk_stale_agent.settings import REQUEST_CLARIFICATION_LABEL +from adk_stale_agent.settings import STALE_HOURS_THRESHOLD +from adk_stale_agent.settings import STALE_LABEL_NAME +from adk_stale_agent.utils import delete_request +from adk_stale_agent.utils import error_response +from adk_stale_agent.utils import get_request +from adk_stale_agent.utils import patch_request +from adk_stale_agent.utils import post_request +import dateutil.parser +from google.adk.agents.llm_agent import Agent +from requests.exceptions import RequestException + +logger = logging.getLogger("google_adk." + __name__) + +# --- Primary Tools for the Agent --- + + +def load_prompt_template(filename: str) -> str: + """Loads the prompt text file from the same directory as this script. + + Args: + filename: The name of the prompt file to load. + + Returns: + The content of the file as a string. + """ + file_path = os.path.join(os.path.dirname(__file__), filename) + + with open(file_path, "r") as f: + return f.read() + + +PROMPT_TEMPLATE = load_prompt_template("PROMPT_INSTRUCTION.txt") + + +def get_repository_maintainers() -> dict[str, Any]: + """ + Fetches the list of repository collaborators with 'push' (write) access or higher. + This should only be called once per run. + + Returns: + A dictionary with the status and a list of maintainer usernames, or an + error dictionary. + """ + logger.debug("Fetching repository maintainers with push access...") + try: + url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/collaborators" + params = {"permission": "push"} + collaborators_data = get_request(url, params) + + maintainers = [user["login"] for user in collaborators_data] + logger.info(f"Found {len(maintainers)} repository maintainers.") + logger.debug(f"Maintainer list: {maintainers}") + + return {"status": "success", "maintainers": maintainers} + except RequestException as e: + logger.error(f"Failed to fetch repository maintainers: {e}", exc_info=True) + return error_response(f"Error fetching repository maintainers: {e}") + + +def get_all_open_issues() -> dict[str, Any]: + """Fetches a batch of the oldest open issues for an audit. + + Returns: + A dictionary containing the status and a list of open issues, or an error + dictionary. + """ + logger.info( + f"Fetching a batch of {ISSUES_PER_RUN} oldest open issues for audit..." + ) + url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues" + params = { + "state": "open", + "sort": "created", + "direction": "asc", + "per_page": ISSUES_PER_RUN, + } + try: + items = get_request(url, params) + logger.info(f"Found {len(items)} open issues to audit.") + return {"status": "success", "items": items} + except RequestException as e: + logger.error(f"Failed to fetch open issues: {e}", exc_info=True) + return error_response(f"Error fetching all open issues: {e}") + + +def get_issue_state(item_number: int, maintainers: list[str]) -> dict[str, Any]: + """Analyzes an issue's complete history to create a comprehensive state summary. + + This function acts as the primary "detective" for the agent. It performs the + complex, deterministic work of fetching and parsing an issue's full history, + allowing the LLM agent to focus on high-level semantic decision-making. + + It is designed to be highly robust by fetching the complete, multi-page history + from the GitHub `/timeline` API. By handling pagination correctly, it ensures + that even issues with a very long history (more than 100 events) are analyzed + in their entirety, preventing incorrect decisions based on incomplete data. + + Args: + item_number (int): The number of the GitHub issue or pull request to analyze. + maintainers (list[str]): A dynamically fetched list of GitHub usernames to be + considered maintainers. This is used to categorize actors found in + the issue's history. + + Returns: + A dictionary that serves as a clean, factual report summarizing the + issue's state. On failure, it returns a dictionary with an 'error' status. + """ + try: + # Fetch core issue data and prepare for timeline fetching. + logger.debug(f"Fetching full timeline for issue #{item_number}...") + issue_url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}" + issue_data = get_request(issue_url) + + # Fetch All pages from the timeline API to build a complete history. + timeline_url_base = f"{issue_url}/timeline" + timeline_data = [] + page = 1 + + while True: + paginated_url = f"{timeline_url_base}?per_page=100&page={page}" + logger.debug(f"Fetching timeline page {page} for issue #{item_number}...") + events_page = get_request(paginated_url) + if not events_page: + break + timeline_data.extend(events_page) + if len(events_page) < 100: + break + page += 1 + + logger.debug( + f"Fetched a total of {len(timeline_data)} timeline events across" + f" {page-1} page(s) for issue #{item_number}." + ) + + # Initialize key variables for the analysis. + issue_author = issue_data.get("user", {}).get("login") + current_labels = [label["name"] for label in issue_data.get("labels", [])] + + # Filter and sort all events into a clean, chronological history of human activity. + human_events = [] + for event in timeline_data: + actor = event.get("actor", {}).get("login") + timestamp_str = event.get("created_at") or event.get("submitted_at") + + if not actor or not timestamp_str or actor.endswith("[bot]"): + continue + + event["parsed_time"] = dateutil.parser.isoparse(timestamp_str) + human_events.append(event) + + human_events.sort(key=lambda e: e["parsed_time"]) + + # Find the most recent, relevant events by iterating backwards. + last_maintainer_comment = None + stale_label_event_time = None + + for event in reversed(human_events): + if ( + not last_maintainer_comment + and event.get("actor", {}).get("login") in maintainers + and event.get("event") == "commented" + ): + last_maintainer_comment = event + + if ( + not stale_label_event_time + and event.get("event") == "labeled" + and event.get("label", {}).get("name") == STALE_LABEL_NAME + ): + stale_label_event_time = event["parsed_time"] + + if last_maintainer_comment and stale_label_event_time: + break + + last_author_action = next( + ( + e + for e in reversed(human_events) + if e.get("actor", {}).get("login") == issue_author + ), + None, + ) + + # Build and return the final summary report for the LLM agent. + last_human_event = human_events[-1] if human_events else None + last_human_actor = ( + last_human_event.get("actor", {}).get("login") + if last_human_event + else None + ) + + return { + "status": "success", + "issue_author": issue_author, + "current_labels": current_labels, + "last_maintainer_comment_text": ( + last_maintainer_comment.get("body") + if last_maintainer_comment + else None + ), + "last_maintainer_comment_time": ( + last_maintainer_comment["parsed_time"].isoformat() + if last_maintainer_comment + else None + ), + "last_author_event_time": ( + last_author_action["parsed_time"].isoformat() + if last_author_action + else None + ), + "last_author_action_type": ( + last_author_action.get("event") if last_author_action else "unknown" + ), + "last_human_action_type": ( + last_human_event.get("event") if last_human_event else "unknown" + ), + "last_human_commenter_is_maintainer": ( + last_human_actor in maintainers if last_human_actor else False + ), + "stale_label_applied_at": ( + stale_label_event_time.isoformat() + if stale_label_event_time + else None + ), + } + + except RequestException as e: + logger.error( + f"Failed to fetch comprehensive issue state for #{item_number}: {e}", + exc_info=True, + ) + return error_response( + f"Error getting comprehensive issue state for #{item_number}: {e}" + ) + + +def calculate_time_difference(timestamp_str: str) -> dict[str, Any]: + """Calculates the difference in hours between a UTC timestamp string and now. + + Args: + timestamp_str: An ISO 8601 formatted timestamp string. + + Returns: + A dictionary with the status and the time difference in hours, or an error + dictionary. + """ + try: + if not timestamp_str: + return error_response("Input timestamp is empty.") + event_time = dateutil.parser.isoparse(timestamp_str) + current_time_utc = datetime.now(timezone.utc) + time_difference = current_time_utc - event_time + hours_passed = time_difference.total_seconds() / 3600 + return {"status": "success", "hours_passed": hours_passed} + except (dateutil.parser.ParserError, TypeError) as e: + logger.error( + "Error calculating time difference for timestamp" + f" '{timestamp_str}': {e}", + exc_info=True, + ) + return error_response(f"Error calculating time difference: {e}") + + +def add_label_to_issue(item_number: int, label_name: str) -> dict[str, Any]: + """Adds a specific label to an issue. + + Args: + item_number: The issue number. + label_name: The name of the label to add. + + Returns: + A dictionary indicating the status of the operation. + """ + logger.debug(f"Adding label '{label_name}' to issue #{item_number}.") + url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/labels" + try: + post_request(url, [label_name]) + logger.info( + f"Successfully added label '{label_name}' to issue #{item_number}." + ) + return {"status": "success"} + except RequestException as e: + logger.error(f"Failed to add '{label_name}' to issue #{item_number}: {e}") + return error_response(f"Error adding label: {e}") + + +def remove_label_from_issue( + item_number: int, label_name: str +) -> dict[str, Any]: + """Removes a specific label from an issue or PR. + + Args: + item_number: The issue number. + label_name: The name of the label to remove. + + Returns: + A dictionary indicating the status of the operation. + """ + logger.debug(f"Removing label '{label_name}' from issue #{item_number}.") + url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/labels/{label_name}" + try: + delete_request(url) + logger.info( + f"Successfully removed label '{label_name}' from issue #{item_number}." + ) + return {"status": "success"} + except RequestException as e: + logger.error( + f"Failed to remove '{label_name}' from issue #{item_number}: {e}" + ) + return error_response(f"Error removing label: {e}") + + +def add_stale_label_and_comment(item_number: int) -> dict[str, Any]: + """Adds the 'stale' label to an issue and posts a comment explaining why. + + Args: + item_number: The issue number. + + Returns: + A dictionary indicating the status of the operation. + """ + logger.debug(f"Adding stale label and comment to issue #{item_number}.") + comment = ( + "This issue has been automatically marked as stale because it has not" + " had recent activity after a maintainer requested clarification. It" + " will be closed if no further activity occurs within" + f" {CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24:.0f} days." + ) + try: + post_request( + f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/comments", + {"body": comment}, + ) + post_request( + f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/labels", + [STALE_LABEL_NAME], + ) + logger.info(f"Successfully marked issue #{item_number} as stale.") + return {"status": "success"} + except RequestException as e: + logger.error( + f"Failed to mark issue #{item_number} as stale: {e}", exc_info=True + ) + return error_response(f"Error marking issue as stale: {e}") + + +def close_as_stale(item_number: int) -> dict[str, Any]: + """Posts a final comment and closes an issue or PR as stale. + + Args: + item_number: The issue number. + + Returns: + A dictionary indicating the status of the operation. + """ + logger.debug(f"Closing issue #{item_number} as stale.") + comment = ( + "This has been automatically closed because it has been marked as stale" + f" for over {CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24:.0f} days." + ) + try: + post_request( + f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/comments", + {"body": comment}, + ) + patch_request( + f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}", + {"state": "closed"}, + ) + logger.info(f"Successfully closed issue #{item_number} as stale.") + return {"status": "success"} + except RequestException as e: + logger.error( + f"Failed to close issue #{item_number} as stale: {e}", exc_info=True + ) + return error_response(f"Error closing issue: {e}") + + +# --- Agent Definition --- + +root_agent = Agent( + model=LLM_MODEL_NAME, + name="adk_repository_auditor_agent", + description=( + "Audits open issues to manage their state based on conversation" + " history." + ), + instruction=PROMPT_TEMPLATE.format( + OWNER=OWNER, + REPO=REPO, + STALE_LABEL_NAME=STALE_LABEL_NAME, + REQUEST_CLARIFICATION_LABEL=REQUEST_CLARIFICATION_LABEL, + STALE_HOURS_THRESHOLD=STALE_HOURS_THRESHOLD, + CLOSE_HOURS_AFTER_STALE_THRESHOLD=CLOSE_HOURS_AFTER_STALE_THRESHOLD, + ), + tools=[ + add_label_to_issue, + add_stale_label_and_comment, + calculate_time_difference, + close_as_stale, + get_all_open_issues, + get_issue_state, + get_repository_maintainers, + remove_label_from_issue, + ], +) diff --git a/contributing/samples/adk_stale_agent/main.py b/contributing/samples/adk_stale_agent/main.py new file mode 100644 index 0000000000..f6fba3fba0 --- /dev/null +++ b/contributing/samples/adk_stale_agent/main.py @@ -0,0 +1,74 @@ +# Copyright 2025 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. + +import asyncio +import logging +import time + +from adk_stale_agent.agent import root_agent +from adk_stale_agent.settings import OWNER +from adk_stale_agent.settings import REPO +from google.adk.cli.utils import logs +from google.adk.runners import InMemoryRunner +from google.genai import types + +logs.setup_adk_logger(level=logging.INFO) +logger = logging.getLogger("google_adk." + __name__) + +APP_NAME = "adk_stale_agent_app" +USER_ID = "adk_stale_agent_user" + + +async def main(): + """Initializes and runs the stale issue agent.""" + logger.info("--- Starting Stale Agent Run ---") + runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME) + session = await runner.session_service.create_session( + user_id=USER_ID, app_name=APP_NAME + ) + + prompt_text = ( + "Find and process all open issues to manage staleness according to your" + " rules." + ) + logger.info(f"Initial Agent Prompt: {prompt_text}\n") + prompt_message = types.Content( + role="user", parts=[types.Part(text=prompt_text)] + ) + + async for event in runner.run_async( + user_id=USER_ID, session_id=session.id, new_message=prompt_message + ): + if ( + event.content + and event.content.parts + and hasattr(event.content.parts[0], "text") + ): + # Print the agent's "thoughts" and actions for logging purposes + logger.debug(f"** {event.author} (ADK): {event.content.parts[0].text}") + + logger.info(f"--- Stale Agent Run Finished---") + + +if __name__ == "__main__": + start_time = time.time() + logger.info(f"Initializing stale agent for repository: {OWNER}/{REPO}") + logger.info("-" * 80) + + asyncio.run(main()) + + logger.info("-" * 80) + end_time = time.time() + duration = end_time - start_time + logger.info(f"Script finished in {duration:.2f} seconds.") diff --git a/contributing/samples/adk_stale_agent/settings.py b/contributing/samples/adk_stale_agent/settings.py new file mode 100644 index 0000000000..1b71e451f3 --- /dev/null +++ b/contributing/samples/adk_stale_agent/settings.py @@ -0,0 +1,49 @@ +# Copyright 2025 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. + +import os + +from dotenv import load_dotenv + +# Load environment variables from a .env file for local testing +load_dotenv(override=True) + +# --- GitHub API Configuration --- +GITHUB_BASE_URL = "https://api.github.com" +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +if not GITHUB_TOKEN: + raise ValueError("GITHUB_TOKEN environment variable not set") + +OWNER = os.getenv("OWNER", "google") +REPO = os.getenv("REPO", "adk-python") +LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "gemini-2.5-flash") + +STALE_LABEL_NAME = "stale" +REQUEST_CLARIFICATION_LABEL = "request clarification" + +# --- THRESHOLDS IN HOURS --- +# These values can be overridden in a .env file for rapid testing (e.g., STALE_HOURS_THRESHOLD=1) +# Default: 168 hours (7 days) +# The number of hours of inactivity after a maintainer comment before an issue is marked as stale. +STALE_HOURS_THRESHOLD = float(os.getenv("STALE_HOURS_THRESHOLD", 168)) + +# Default: 168 hours (7 days) +# The number of hours of inactivity after an issue is marked 'stale' before it is closed. +CLOSE_HOURS_AFTER_STALE_THRESHOLD = float( + os.getenv("CLOSE_HOURS_AFTER_STALE_THRESHOLD", 168) +) + +# --- BATCH SIZE CONFIGURATION --- +# The maximum number of oldest open issues to process in a single run of the bot. +ISSUES_PER_RUN = int(os.getenv("ISSUES_PER_RUN", 100)) diff --git a/contributing/samples/adk_stale_agent/utils.py b/contributing/samples/adk_stale_agent/utils.py new file mode 100644 index 0000000000..0efb051f72 --- /dev/null +++ b/contributing/samples/adk_stale_agent/utils.py @@ -0,0 +1,59 @@ +# Copyright 2025 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. + +from typing import Any + +from adk_stale_agent.settings import GITHUB_TOKEN +import requests + +_session = requests.Session() +_session.headers.update({ + "Authorization": f"token {GITHUB_TOKEN}", + "Accept": "application/vnd.github.v3+json", +}) + + +def get_request(url: str, params: dict[str, Any] | None = None) -> Any: + """Sends a GET request to the GitHub API.""" + response = _session.get(url, params=params or {}, timeout=60) + response.raise_for_status() + return response.json() + + +def post_request(url: str, payload: Any) -> Any: + """Sends a POST request to the GitHub API.""" + response = _session.post(url, json=payload, timeout=60) + response.raise_for_status() + return response.json() + + +def patch_request(url: str, payload: Any) -> Any: + """Sends a PATCH request to the GitHub API.""" + response = _session.patch(url, json=payload, timeout=60) + response.raise_for_status() + return response.json() + + +def delete_request(url: str) -> Any: + """Sends a DELETE request to the GitHub API.""" + response = _session.delete(url, timeout=60) + response.raise_for_status() + if response.status_code == 204: + return {"status": "success"} + return response.json() + + +def error_response(error_message: str) -> dict[str, Any]: + """Creates a standardized error dictionary for the agent.""" + return {"status": "error", "message": error_message} diff --git a/contributing/samples/gepa/experiment.py b/contributing/samples/gepa/experiment.py index 2f5d03a772..f68b349d9c 100644 --- a/contributing/samples/gepa/experiment.py +++ b/contributing/samples/gepa/experiment.py @@ -43,7 +43,6 @@ from tau_bench.types import EnvRunResult from tau_bench.types import RunConfig import tau_bench_agent as tau_bench_agent_lib - import utils diff --git a/contributing/samples/gepa/run_experiment.py b/contributing/samples/gepa/run_experiment.py index cfd850b3a3..1bc4ee58c8 100644 --- a/contributing/samples/gepa/run_experiment.py +++ b/contributing/samples/gepa/run_experiment.py @@ -25,7 +25,6 @@ from absl import flags import experiment from google.genai import types - import utils _OUTPUT_DIR = flags.DEFINE_string(