chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
@@ -0,0 +1,73 @@
You are a highly intelligent repository auditor for '{OWNER}/{REPO}'.
Your job is to analyze a specific issue and report findings before taking action.
**Primary Directive:** Ignore any events from users ending in `[bot]`.
**Reporting Directive:** Output a concise summary starting with "Analysis for Issue #[number]:".
**THRESHOLDS:**
- Stale Threshold: {stale_threshold_days} days.
- Close Threshold: {close_threshold_days} days.
**WORKFLOW:**
1. **Context Gathering**: Call `get_issue_state`.
2. **Decision**: Follow this strict decision tree using the data returned by the tool.
--- **DECISION TREE** ---
**STEP 1: CHECK IF ALREADY STALE**
- **Condition**: Is `is_stale` (from tool) **True**?
- **Action**:
- **Check Role**: Look at `last_action_role`.
- **IF 'author' OR 'other_user'**:
- **Context**: The user has responded. The issue is now ACTIVE.
- **Action 1**: Call `remove_label_from_issue` with '{STALE_LABEL_NAME}'.
- **Action 2 (ALERT CHECK)**: Look at `maintainer_alert_needed`.
- **IF True**: User edited description silently.
-> **Action**: Call `alert_maintainer_of_edit`.
- **IF False**: User commented normally. No alert needed.
- **Report**: "Analysis for Issue #[number]: ACTIVE. User activity detected. Removed stale label."
- **IF 'maintainer'**:
- **Check Time**: Check `days_since_stale_label`.
- **If `days_since_stale_label` > {close_threshold_days}**:
- **Action**: Call `close_as_stale`.
- **Report**: "Analysis for Issue #[number]: STALE. Close threshold met. Closing."
- **Else**:
- **Report**: "Analysis for Issue #[number]: STALE. Waiting for close threshold. No action."
**STEP 2: CHECK IF ACTIVE (NOT STALE)**
- **Condition**: `is_stale` is **False**.
- **Action**:
- **Check Role**: If `last_action_role` is 'author' or 'other_user':
- **Context**: The issue is Active.
- **Action (ALERT CHECK)**: Look at `maintainer_alert_needed`.
- **IF True**: The user edited the description silently, and we haven't alerted yet.
-> **Action**: Call `alert_maintainer_of_edit`.
-> **Report**: "Analysis for Issue #[number]: ACTIVE. Silent update detected (Description Edit). Alerted maintainer."
- **IF False**:
-> **Report**: "Analysis for Issue #[number]: ACTIVE. Last action was by user. No action."
- **Check Role**: If `last_action_role` is 'maintainer':
- **Proceed to STEP 3.**
**STEP 3: ANALYZE MAINTAINER INTENT**
- **Context**: The last person to act was a Maintainer.
- **Action**: Analyze `last_comment_text` using `maintainers` list and `last_actor_name`.
- **Internal Discussion Check**: Does the comment mention or address any username found in the `maintainers` list (other than the speaker `last_actor_name`)?
- **Verdict**: **ACTIVE** (Internal Team Discussion).
- **Report**: "Analysis for Issue #[number]: ACTIVE. Maintainer is discussing with another maintainer. No action."
- **Question Check**: Does the text ask a question, request clarification, ask for logs, or give suggestions?
- **Time Check**: Is `days_since_activity` > {stale_threshold_days}?
- **DECISION**:
- **IF (Question == YES) AND (Time == YES) AND (Internal Discussion Check == FALSE):**
- **Action**: Call `add_stale_label_and_comment`.
- **Check**: If '{REQUEST_CLARIFICATION_LABEL}' is not in `current_labels`, call `add_label_to_issue` with '{REQUEST_CLARIFICATION_LABEL}'.
- **Report**: "Analysis for Issue #[number]: STALE. Maintainer asked question [days_since_activity] days ago. Marking stale."
- **IF (Question == YES) BUT (Time == NO)**:
- **Report**: "Analysis for Issue #[number]: PENDING. Maintainer asked question, but threshold not met yet. No action."
- **IF (Question == NO) OR (Internal Discussion Check == TRUE):**
- **Report**: "Analysis for Issue #[number]: ACTIVE. Maintainer gave status update or internal discussion detected. No action."
@@ -0,0 +1,97 @@
# ADK Stale Issue Auditor Agent
This directory contains an autonomous, **GraphQL-powered** agent designed to audit a GitHub repository for stale issues. It maintains repository hygiene by ensuring all open items are actionable and responsive.
Unlike traditional "Stale Bots" that only look at timestamps, this agent uses a **Unified History Trace** and an **LLM (Large Language Model)** to understand the *context* of a conversation. It distinguishes between a maintainer asking a question (stale candidate) vs. a maintainer providing a status update (active).
______________________________________________________________________
## Core Logic & Features
The agent operates as a "Repository Auditor," proactively scanning open issues using a high-efficiency decision tree.
### 1. Smart State Verification (GraphQL)
Instead of making multiple expensive API calls, the agent uses a single **GraphQL** query per issue to reconstruct the entire history of the conversation. It combines:
- **Comments**
- **Description/Body Edits** ("Ghost Edits")
- **Title Renames**
- **State Changes** (Reopens)
It sorts these events chronologically to determine the **Last Active Actor**.
### 2. The "Last Actor" Rule
The agent follows a precise logic flow based on who acted last:
- **If Author/User acted last:** The issue is **ACTIVE**.
- This includes comments, title changes, and *silent* description edits.
- **Action:** The agent immediately removes the `stale` label.
- **Silent Update Alert:** If the user edited the description but *did not* comment, the agent posts a specific alert: *"Notification: The author has updated the issue description..."* to ensure maintainers are notified (since GitHub does not trigger notifications for body edits).
- **Spam Prevention:** The agent checks if it has already alerted about a specific silent edit to avoid spamming the thread.
- **If Maintainer acted last:** The issue is **POTENTIALLY STALE**.
- The agent passes the text of the maintainer's last comment to the LLM.
### 3. Semantic Intent Analysis (LLM)
If the maintainer was the last person to speak, the LLM analyzes the comment text to determine intent:
- **Question/Request:** "Can you provide logs?" / "Please try v2.0."
- **Verdict:** **STALE** (Waiting on Author).
- **Action:** If the time threshold is met, the agent adds the `stale` label. It also checks for the `request clarification` label and adds it if missing.
- **Status Update:** "We are working on a fix." / "Added to backlog."
- **Verdict:** **ACTIVE** (Waiting on Maintainer).
- **Action:** No action taken. The issue remains open without stale labels.
### 4. Lifecycle Management
- **Marking Stale:** After `STALE_HOURS_THRESHOLD` (default: 7 days) of inactivity following a maintainer's question.
- **Closing:** After `CLOSE_HOURS_AFTER_STALE_THRESHOLD` (default: 7 days) of continued inactivity while marked stale.
______________________________________________________________________
## Performance & Safety
- **GraphQL Optimized:** Fetches comments, edits, labels, and timeline events in a single network request to minimize latency and API quota usage.
- **Search API Filtering:** Uses the GitHub Search API to pre-filter issues created recently, ensuring the bot doesn't waste cycles analyzing brand-new issues.
- **Rate Limit Aware:** Includes intelligent sleeping and retry logic (exponential backoff) to handle GitHub API rate limits (HTTP 429) gracefully.
- **Execution Metrics:** Logs the time taken and API calls consumed for every issue processed.
______________________________________________________________________
## Configuration
The agent is configured via environment variables, typically set as secrets in GitHub Actions.
### Required Secrets
| Secret Name | Description |
| :--------------- | :------------------------------------------------------------------------------- |
| `GITHUB_TOKEN` | A GitHub Personal Access Token (PAT) or Service Account Token with `repo` scope. |
| `GOOGLE_API_KEY` | An API key for the Google AI (Gemini) model used for reasoning. |
### Optional Configuration
These variables control the timing thresholds and model selection.
| Variable Name | Description | Default |
| :---------------------------------- | :--------------------------------------------------------------------------- | :---------------------- |
| `STALE_HOURS_THRESHOLD` | Hours of inactivity after a maintainer's question before marking as `stale`. | `168` (7 days) |
| `CLOSE_HOURS_AFTER_STALE_THRESHOLD` | Hours after being marked `stale` before the issue is closed. | `168` (7 days) |
| `LLM_MODEL_NAME` | The specific Gemini model version to use. | `gemini-2.5-flash` |
| `OWNER` | Repository owner (auto-detected in Actions). | (Environment dependent) |
| `REPO` | Repository name (auto-detected in Actions). | (Environment dependent) |
______________________________________________________________________
## Deployment
To deploy this agent, a GitHub Actions workflow file (`.github/workflows/stale-bot.yml`) is recommended.
### Directory Structure Note
Because this agent resides within the `adk-python` package structure, the workflow must ensure the script is executed correctly to handle imports.
@@ -0,0 +1,15 @@
# 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 . import agent
@@ -0,0 +1,606 @@
# 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,
],
)
@@ -0,0 +1,195 @@
# 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.
import asyncio
import logging
import time
from typing import Tuple
from adk_stale_agent.agent import root_agent
from adk_stale_agent.settings import CONCURRENCY_LIMIT
from adk_stale_agent.settings import OWNER
from adk_stale_agent.settings import REPO
from adk_stale_agent.settings import SLEEP_BETWEEN_CHUNKS
from adk_stale_agent.settings import STALE_HOURS_THRESHOLD
from adk_stale_agent.utils import get_api_call_count
from adk_stale_agent.utils import get_old_open_issue_numbers
from adk_stale_agent.utils import reset_api_call_count
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 = "stale_bot_app"
USER_ID = "stale_bot_user"
async def process_single_issue(issue_number: int) -> Tuple[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:
- 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()
start_api_calls = get_api_call_count()
logger.info(f"Processing Issue #{issue_number}...")
logger.debug(f"#{issue_number}: Initializing runner and session.")
try:
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 = f"Audit Issue #{issue_number}."
prompt_message = types.Content(
role="user", parts=[types.Part(text=prompt_text)]
)
logger.debug(f"#{issue_number}: Sending prompt to agent.")
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")
):
text = event.content.parts[0].text
if text:
clean_text = text[:150].replace("\n", " ")
logger.info(f"#{issue_number} Decision: {clean_text}...")
except Exception as e:
logger.error(f"Error processing issue #{issue_number}: {e}", exc_info=True)
duration = time.perf_counter() - start_time
end_api_calls = get_api_call_count()
issue_api_calls = end_api_calls - start_api_calls
logger.info(
f"Issue #{issue_number} finished in {duration:.2f}s "
f"with ~{issue_api_calls} API calls."
)
return duration, issue_api_calls
async def main():
"""
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.
"""
logger.info(f"--- Starting Stale Bot for {OWNER}/{REPO} ---")
logger.info(f"Concurrency level set to {CONCURRENCY_LIMIT}")
reset_api_call_count()
filter_days = STALE_HOURS_THRESHOLD / 24
logger.debug(f"Fetching issues older than {filter_days:.2f} days...")
try:
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
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
logger.info(
f"Found {total_count} issues to process. "
f"(Initial search used {search_api_calls} API calls)."
)
total_processing_time = 0.0
total_issue_api_calls = 0
processed_count = 0
# Process the list in chunks of size CONCURRENCY_LIMIT
for i in range(0, total_count, CONCURRENCY_LIMIT):
chunk = all_issues[i : i + CONCURRENCY_LIMIT]
current_chunk_num = i // CONCURRENCY_LIMIT + 1
logger.info(
f"--- Starting chunk {current_chunk_num}: Processing issues {chunk} ---"
)
tasks = [process_single_issue(issue_num) for issue_num in chunk]
results = await asyncio.gather(*tasks)
for duration, api_calls in results:
total_processing_time += duration
total_issue_api_calls += api_calls
processed_count += len(chunk)
logger.info(
f"--- Finished chunk {current_chunk_num}. Progress:"
f" {processed_count}/{total_count} ---"
)
if (i + CONCURRENCY_LIMIT) < total_count:
logger.debug(
f"Sleeping for {SLEEP_BETWEEN_CHUNKS}s to respect rate limits..."
)
await asyncio.sleep(SLEEP_BETWEEN_CHUNKS)
total_api_calls_for_run = search_api_calls + total_issue_api_calls
avg_time_per_issue = (
total_processing_time / total_count if total_count > 0 else 0
)
logger.info("--- Stale Agent Run Finished ---")
logger.info(f"Successfully processed {processed_count} issues.")
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."
)
if __name__ == "__main__":
start_time = time.perf_counter()
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.warning("Bot execution interrupted manually.")
except Exception as e:
logger.critical(f"Unexpected fatal error: {e}", exc_info=True)
duration = time.perf_counter() - start_time
logger.info(f"Full audit finished in {duration/60:.2f} minutes.")
@@ -0,0 +1,63 @@
# 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.
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 ---
# 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)
)
# --- Performance Configuration ---
# The number of issues to process concurrently.
# Higher values are faster but increase the immediate rate of API calls
CONCURRENCY_LIMIT = int(os.getenv("CONCURRENCY_LIMIT", 3))
# --- GraphQL Query Limits ---
# The number of most recent comments to fetch for context analysis.
GRAPHQL_COMMENT_LIMIT = int(os.getenv("GRAPHQL_COMMENT_LIMIT", 30))
# The number of most recent description edits to fetch.
GRAPHQL_EDIT_LIMIT = int(os.getenv("GRAPHQL_EDIT_LIMIT", 10))
# The number of most recent timeline events (labels, renames, reopens) to fetch.
GRAPHQL_TIMELINE_LIMIT = int(os.getenv("GRAPHQL_TIMELINE_LIMIT", 20))
# --- Rate Limiting ---
# Time in seconds to wait between processing chunks.
SLEEP_BETWEEN_CHUNKS = float(os.getenv("SLEEP_BETWEEN_CHUNKS", 1.5))
@@ -0,0 +1,260 @@
# 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 timedelta
from datetime import timezone
import logging
import threading
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from adk_stale_agent.settings import GITHUB_TOKEN
from adk_stale_agent.settings import STALE_HOURS_THRESHOLD
import dateutil.parser
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
logger = logging.getLogger("google_adk." + __name__)
# --- API Call Counter for Monitoring ---
_api_call_count = 0
_counter_lock = threading.Lock()
def get_api_call_count() -> int:
"""
Returns the total number of API calls made since the last reset.
Returns:
int: The global count of API calls.
"""
with _counter_lock:
return _api_call_count
def reset_api_call_count() -> None:
"""Resets the global API call counter to zero."""
global _api_call_count
with _counter_lock:
_api_call_count = 0
def _increment_api_call_count() -> None:
"""
Atomically increments the global API call counter.
Required because the agent may run tools in parallel threads.
"""
global _api_call_count
with _counter_lock:
_api_call_count += 1
# --- Production-Ready HTTP Session with Exponential Backoff ---
# Configure the retry strategy:
retry_strategy = Retry(
total=6,
backoff_factor=2,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=[
"HEAD",
"GET",
"POST",
"PUT",
"DELETE",
"OPTIONS",
"TRACE",
"PATCH",
],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
# Create a single, reusable Session object for connection pooling
_session = requests.Session()
_session.mount("https://", adapter)
_session.mount("http://", adapter)
_session.headers.update({
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
})
def get_request(url: str, params: Optional[Dict[str, Any]] = None) -> Any:
"""
Sends a GET request to the GitHub API with automatic retries.
Args:
url (str): The URL endpoint.
params (Optional[Dict[str, Any]]): Query parameters.
Returns:
Any: The JSON response parsed into a dict or list.
Raises:
requests.exceptions.RequestException: If retries are exhausted.
"""
_increment_api_call_count()
try:
response = _session.get(url, params=params or {}, timeout=60)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"GET request failed for {url}: {e}")
raise
def post_request(url: str, payload: Any) -> Any:
"""
Sends a POST request to the GitHub API with automatic retries.
Args:
url (str): The URL endpoint.
payload (Any): The JSON payload.
Returns:
Any: The JSON response.
"""
_increment_api_call_count()
try:
response = _session.post(url, json=payload, timeout=60)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"POST request failed for {url}: {e}")
raise
def patch_request(url: str, payload: Any) -> Any:
"""
Sends a PATCH request to the GitHub API with automatic retries.
Args:
url (str): The URL endpoint.
payload (Any): The JSON payload.
Returns:
Any: The JSON response.
"""
_increment_api_call_count()
try:
response = _session.patch(url, json=payload, timeout=60)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"PATCH request failed for {url}: {e}")
raise
def delete_request(url: str) -> Any:
"""
Sends a DELETE request to the GitHub API with automatic retries.
Args:
url (str): The URL endpoint.
Returns:
Any: A success dict if 204, else the JSON response.
"""
_increment_api_call_count()
try:
response = _session.delete(url, timeout=60)
response.raise_for_status()
if response.status_code == 204:
return {"status": "success", "message": "Deletion successful."}
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"DELETE request failed for {url}: {e}")
raise
def error_response(error_message: str) -> Dict[str, Any]:
"""
Creates a standardized error response dictionary for tool outputs.
Args:
error_message (str): The error details.
Returns:
Dict[str, Any]: Standardized error object.
"""
return {"status": "error", "message": error_message}
def get_old_open_issue_numbers(
owner: str, repo: str, days_old: Optional[float] = None
) -> List[int]:
"""
Finds open issues older than the specified threshold using server-side filtering.
OPTIMIZATION:
Instead of fetching ALL issues and filtering in Python (which wastes API calls),
this uses the GitHub Search API `created:<DATE` syntax.
Args:
owner (str): Repository owner.
repo (str): Repository name.
days_old (Optional[float]): Filter issues older than this many days.
Defaults to STALE_HOURS_THRESHOLD / 24.
Returns:
List[int]: A list of issue numbers matching the criteria.
"""
if days_old is None:
days_old = STALE_HOURS_THRESHOLD / 24
now_utc = datetime.now(timezone.utc)
cutoff_dt = now_utc - timedelta(days=days_old)
cutoff_str = cutoff_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
query = f"repo:{owner}/{repo} is:issue state:open created:<{cutoff_str}"
logger.info(
f"Searching for issues in '{owner}/{repo}' created before {cutoff_str}..."
)
issue_numbers = []
page = 1
url = "https://api.github.com/search/issues"
while True:
params = {"q": query, "per_page": 100, "page": page}
try:
data = get_request(url, params=params)
items = data.get("items", [])
if not items:
break
for item in items:
if "pull_request" not in item:
issue_numbers.append(item["number"])
if len(items) < 100:
break
page += 1
except requests.exceptions.RequestException as e:
logger.error(f"GitHub search failed on page {page}: {e}")
break
logger.info(f"Found {len(issue_numbers)} stale issues.")
return issue_numbers