Files
google--adk-python/contributing/samples/adk_team/adk_stale_agent/agent.py
T
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

607 lines
18 KiB
Python

# 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.
from datetime import datetime
from datetime import timezone
import logging
import os
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
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 GRAPHQL_COMMENT_LIMIT
from adk_stale_agent.settings import GRAPHQL_EDIT_LIMIT
from adk_stale_agent.settings import GRAPHQL_TIMELINE_LIMIT
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__)
# --- Constants ---
# Used to detect if the bot has already posted an alert to avoid spamming.
BOT_ALERT_SIGNATURE = (
"**Notification:** The author has updated the issue description"
)
BOT_NAME = "adk-bot"
# --- Global Cache ---
_MAINTAINERS_CACHE: Optional[List[str]] = None
def _get_cached_maintainers() -> List[str]:
"""
Fetches the list of repository maintainers.
This function relies on `utils.get_request` for network resilience.
`get_request` is configured with an HTTPAdapter that automatically performs
exponential backoff retries (up to 6 times) for 5xx errors and rate limits.
If the retries are exhausted or the data format is invalid, this function
raises a RuntimeError to prevent the bot from running with incorrect permissions.
Returns:
List[str]: A list of GitHub usernames with push access.
Raises:
RuntimeError: If the API fails after all retries or returns invalid data.
"""
global _MAINTAINERS_CACHE
if _MAINTAINERS_CACHE is not None:
return _MAINTAINERS_CACHE
logger.info("Initializing Maintainers Cache...")
try:
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/collaborators"
params = {"permission": "push"}
data = get_request(url, params)
if isinstance(data, list):
_MAINTAINERS_CACHE = [u["login"] for u in data if "login" in u]
logger.info(f"Cached {len(_MAINTAINERS_CACHE)} maintainers.")
return _MAINTAINERS_CACHE
else:
logger.error(
f"Invalid API response format: Expected list, got {type(data)}"
)
raise ValueError(f"GitHub API returned non-list data: {data}")
except Exception as e:
logger.critical(
f"FATAL: Failed to verify repository maintainers. Error: {e}"
)
raise RuntimeError(
"Maintainer verification failed. processing aborted."
) from e
def load_prompt_template(filename: str) -> str:
"""
Loads the raw text content of a prompt file.
Args:
filename (str): The name of the file (e.g., 'PROMPT_INSTRUCTION.txt').
Returns:
str: The file content.
"""
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 _fetch_graphql_data(item_number: int) -> Dict[str, Any]:
"""
Executes the GraphQL query to fetch raw issue data, including comments,
edits, and timeline events.
Args:
item_number (int): The GitHub issue number.
Returns:
Dict[str, Any]: The raw 'issue' object from the GraphQL response.
Raises:
RequestException: If the GraphQL query returns errors or the issue is not found.
"""
query = """
query($owner: String!, $name: String!, $number: Int!, $commentLimit: Int!, $timelineLimit: Int!, $editLimit: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
author { login }
createdAt
labels(first: 20) { nodes { name } }
comments(last: $commentLimit) {
nodes {
author { login }
body
createdAt
lastEditedAt
}
}
userContentEdits(last: $editLimit) {
nodes {
editor { login }
editedAt
}
}
timelineItems(itemTypes: [LABELED_EVENT, RENAMED_TITLE_EVENT, REOPENED_EVENT], last: $timelineLimit) {
nodes {
__typename
... on LabeledEvent {
createdAt
actor { login }
label { name }
}
... on RenamedTitleEvent {
createdAt
actor { login }
}
... on ReopenedEvent {
createdAt
actor { login }
}
}
}
}
}
}
"""
variables = {
"owner": OWNER,
"name": REPO,
"number": item_number,
"commentLimit": GRAPHQL_COMMENT_LIMIT,
"editLimit": GRAPHQL_EDIT_LIMIT,
"timelineLimit": GRAPHQL_TIMELINE_LIMIT,
}
response = post_request(
f"{GITHUB_BASE_URL}/graphql", {"query": query, "variables": variables}
)
if "errors" in response:
raise RequestException(f"GraphQL Error: {response['errors'][0]['message']}")
data = response.get("data", {}).get("repository", {}).get("issue", {})
if not data:
raise RequestException(f"Issue #{item_number} not found.")
return data
def _build_history_timeline(
data: Dict[str, Any],
) -> Tuple[List[Dict[str, Any]], List[datetime], Optional[datetime]]:
"""
Parses raw GraphQL data into a unified, chronologically sorted history list.
Also extracts specific event times needed for logic checks.
Args:
data (Dict[str, Any]): The raw issue data from `_fetch_graphql_data`.
Returns:
Tuple[List[Dict], List[datetime], Optional[datetime]]:
- history: A list of normalized event dictionaries sorted by time.
- label_events: A list of timestamps when the stale label was applied.
- last_bot_alert_time: Timestamp of the last bot silent-edit alert (if any).
"""
issue_author = data.get("author", {}).get("login")
history = []
label_events = []
last_bot_alert_time = None
# 1. Baseline: Issue Creation
history.append({
"type": "created",
"actor": issue_author,
"time": dateutil.parser.isoparse(data["createdAt"]),
"data": None,
})
# 2. Process Comments
for c in data.get("comments", {}).get("nodes", []):
if not c:
continue
actor = c.get("author", {}).get("login")
c_body = c.get("body", "")
c_time = dateutil.parser.isoparse(c.get("createdAt"))
# Track bot alerts for spam prevention
if BOT_ALERT_SIGNATURE in c_body:
if last_bot_alert_time is None or c_time > last_bot_alert_time:
last_bot_alert_time = c_time
continue
if actor and not actor.endswith("[bot]") and actor != BOT_NAME:
# Use edit time if available, otherwise creation time
e_time = c.get("lastEditedAt")
actual_time = dateutil.parser.isoparse(e_time) if e_time else c_time
history.append({
"type": "commented",
"actor": actor,
"time": actual_time,
"data": c_body,
})
# 3. Process Body Edits ("Ghost Edits")
for e in data.get("userContentEdits", {}).get("nodes", []):
if not e:
continue
actor = e.get("editor", {}).get("login")
if actor and not actor.endswith("[bot]") and actor != BOT_NAME:
history.append({
"type": "edited_description",
"actor": actor,
"time": dateutil.parser.isoparse(e.get("editedAt")),
"data": None,
})
# 4. Process Timeline Events
for t in data.get("timelineItems", {}).get("nodes", []):
if not t:
continue
etype = t.get("__typename")
actor = t.get("actor", {}).get("login")
time_val = dateutil.parser.isoparse(t.get("createdAt"))
if etype == "LabeledEvent":
if t.get("label", {}).get("name") == STALE_LABEL_NAME:
label_events.append(time_val)
continue
if actor and not actor.endswith("[bot]") and actor != BOT_NAME:
pretty_type = (
"renamed_title" if etype == "RenamedTitleEvent" else "reopened"
)
history.append({
"type": pretty_type,
"actor": actor,
"time": time_val,
"data": None,
})
# Sort chronologically
history.sort(key=lambda x: x["time"])
return history, label_events, last_bot_alert_time
def _replay_history_to_find_state(
history: List[Dict[str, Any]], maintainers: List[str], issue_author: str
) -> Dict[str, Any]:
"""
Replays the unified event history to determine the absolute last actor and their role.
Args:
history (List[Dict]): Chronologically sorted list of events.
maintainers (List[str]): List of maintainer usernames.
issue_author (str): Username of the issue author.
Returns:
Dict[str, Any]: A dictionary containing the last state of the issue:
- last_action_role (str): 'author', 'maintainer', or 'other_user'.
- last_activity_time (datetime): Timestamp of the last human action.
- last_action_type (str): The type of the last action (e.g., 'commented').
- last_comment_text (Optional[str]): The text of the last comment.
- last_actor_name (str): The specific username of the last actor.
"""
last_action_role = "author"
last_activity_time = history[0]["time"]
last_action_type = "created"
last_comment_text = None
last_actor_name = issue_author
for event in history:
actor = event["actor"]
etype = event["type"]
role = "other_user"
if actor == issue_author:
role = "author"
elif actor in maintainers:
role = "maintainer"
last_action_role = role
last_activity_time = event["time"]
last_action_type = etype
last_actor_name = actor
# Only store text if it was a comment (resets on other events like labels/edits)
if etype == "commented":
last_comment_text = event["data"]
else:
last_comment_text = None
return {
"last_action_role": last_action_role,
"last_activity_time": last_activity_time,
"last_action_type": last_action_type,
"last_comment_text": last_comment_text,
"last_actor_name": last_actor_name,
}
def get_issue_state(item_number: int) -> Dict[str, Any]:
"""
Retrieves the comprehensive state of a GitHub issue using GraphQL.
This function orchestrates the fetching, parsing, and analysis of the issue's
history to determine if it is stale, active, or pending maintainer review.
Args:
item_number (int): The GitHub issue number.
Returns:
Dict[str, Any]: A comprehensive state dictionary for the LLM agent.
Contains keys such as 'last_action_role', 'is_stale', 'days_since_activity',
and 'maintainer_alert_needed'.
"""
try:
maintainers = _get_cached_maintainers()
# 1. Fetch
raw_data = _fetch_graphql_data(item_number)
issue_author = raw_data.get("author", {}).get("login")
labels_list = [
l["name"] for l in raw_data.get("labels", {}).get("nodes", [])
]
# 2. Parse & Sort
history, label_events, last_bot_alert_time = _build_history_timeline(
raw_data
)
# 3. Analyze (Replay)
state = _replay_history_to_find_state(history, maintainers, issue_author)
# 4. Final Calculations & Alert Logic
current_time = datetime.now(timezone.utc)
days_since_activity = (
current_time - state["last_activity_time"]
).total_seconds() / 86400
# Stale Checks
is_stale = STALE_LABEL_NAME in labels_list
days_since_stale_label = 0.0
if is_stale and label_events:
latest_label_time = max(label_events)
days_since_stale_label = (
current_time - latest_label_time
).total_seconds() / 86400
# Silent Edit Alert Logic
maintainer_alert_needed = False
if (
state["last_action_role"] in ["author", "other_user"]
and state["last_action_type"] == "edited_description"
):
if (
last_bot_alert_time
and last_bot_alert_time > state["last_activity_time"]
):
logger.info(
f"#{item_number}: Silent edit detected, but Bot already alerted. No"
" spam."
)
else:
maintainer_alert_needed = True
logger.info(f"#{item_number}: Silent edit detected. Alert needed.")
logger.debug(
f"#{item_number} VERDICT: Role={state['last_action_role']}, "
f"Idle={days_since_activity:.2f}d"
)
return {
"status": "success",
"last_action_role": state["last_action_role"],
"last_action_type": state["last_action_type"],
"last_actor_name": state["last_actor_name"],
"maintainer_alert_needed": maintainer_alert_needed,
"is_stale": is_stale,
"days_since_activity": days_since_activity,
"days_since_stale_label": days_since_stale_label,
"last_comment_text": state["last_comment_text"],
"current_labels": labels_list,
"stale_threshold_days": STALE_HOURS_THRESHOLD / 24,
"close_threshold_days": CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24,
"maintainers": maintainers,
"issue_author": issue_author,
}
except RequestException as e:
return error_response(f"Network Error: {e}")
except Exception as e:
logger.error(
f"Unexpected error analyzing #{item_number}: {e}", exc_info=True
)
return error_response(f"Analysis Error: {e}")
# --- Tool Definitions ---
def _format_days(hours: float) -> str:
"""
Formats a duration in hours into a clean day string.
Example:
168.0 -> "7"
12.0 -> "0.5"
"""
days = hours / 24
return f"{days:.1f}" if days % 1 != 0 else f"{int(days)}"
def add_label_to_issue(item_number: int, label_name: str) -> dict[str, Any]:
"""
Adds a label to the issue.
Args:
item_number (int): The GitHub issue number.
label_name (str): The name of the label to add.
"""
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])
return {"status": "success"}
except RequestException as 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 label from the issue.
Args:
item_number (int): The GitHub issue number.
label_name (str): The name of the label to remove.
"""
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)
return {"status": "success"}
except RequestException as e:
return error_response(f"Error removing label: {e}")
def add_stale_label_and_comment(item_number: int) -> dict[str, Any]:
"""
Marks the issue as stale with a comment and label.
Args:
item_number (int): The GitHub issue number.
"""
stale_days_str = _format_days(STALE_HOURS_THRESHOLD)
close_days_str = _format_days(CLOSE_HOURS_AFTER_STALE_THRESHOLD)
comment = (
"This issue has been automatically marked as stale because it has not"
f" had recent activity for {stale_days_str} days after a maintainer"
" requested clarification. It will be closed if no further activity"
f" occurs within {close_days_str} 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],
)
return {"status": "success"}
except RequestException as e:
return error_response(f"Error marking issue as stale: {e}")
def alert_maintainer_of_edit(item_number: int) -> dict[str, Any]:
"""
Posts a comment alerting maintainers of a silent description update.
Args:
item_number (int): The GitHub issue number.
"""
# Uses the constant signature to ensure detection logic in get_issue_state works.
comment = f"{BOT_ALERT_SIGNATURE}. Maintainers, please review."
try:
post_request(
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/comments",
{"body": comment},
)
return {"status": "success"}
except RequestException as e:
return error_response(f"Error posting alert: {e}")
def close_as_stale(item_number: int) -> dict[str, Any]:
"""
Closes the issue as not planned/stale.
Args:
item_number (int): The GitHub issue number.
"""
days_str = _format_days(CLOSE_HOURS_AFTER_STALE_THRESHOLD)
comment = (
"This has been automatically closed because it has been marked as stale"
f" for over {days_str} 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"},
)
return {"status": "success"}
except RequestException as e:
return error_response(f"Error closing issue: {e}")
root_agent = Agent(
model=LLM_MODEL_NAME,
name="adk_repository_auditor_agent",
description="Audits open issues.",
instruction=PROMPT_TEMPLATE.format(
OWNER=OWNER,
REPO=REPO,
STALE_LABEL_NAME=STALE_LABEL_NAME,
REQUEST_CLARIFICATION_LABEL=REQUEST_CLARIFICATION_LABEL,
stale_threshold_days=STALE_HOURS_THRESHOLD / 24,
close_threshold_days=CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24,
),
tools=[
add_label_to_issue,
add_stale_label_and_comment,
alert_maintainer_of_edit,
close_as_stale,
get_issue_state,
remove_label_from_issue,
],
)