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
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:
@@ -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
|
||||
Reference in New Issue
Block a user