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,13 @@
|
||||
# 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.
|
||||
@@ -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,122 @@
|
||||
# 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
|
||||
import sys
|
||||
|
||||
SAMPLES_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
)
|
||||
if SAMPLES_DIR not in sys.path:
|
||||
sys.path.append(SAMPLES_DIR)
|
||||
|
||||
from adk_documentation.settings import CODE_OWNER
|
||||
from adk_documentation.settings import CODE_REPO
|
||||
from adk_documentation.settings import DOC_OWNER
|
||||
from adk_documentation.settings import DOC_REPO
|
||||
from adk_documentation.settings import IS_INTERACTIVE
|
||||
from adk_documentation.settings import LOCAL_REPOS_DIR_PATH
|
||||
from adk_documentation.tools import clone_or_pull_repo
|
||||
from adk_documentation.tools import create_pull_request_from_changes
|
||||
from adk_documentation.tools import get_issue
|
||||
from adk_documentation.tools import list_directory_contents
|
||||
from adk_documentation.tools import read_local_git_repo_file_content
|
||||
from adk_documentation.tools import search_local_git_repo
|
||||
from google.adk import Agent
|
||||
|
||||
if IS_INTERACTIVE:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"Ask for user approval or confirmation for creating the pull request."
|
||||
)
|
||||
else:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"**Do not** wait or ask for user approval or confirmation for creating"
|
||||
" the pull request."
|
||||
)
|
||||
|
||||
root_agent = Agent(
|
||||
model="gemini-3.5-flash",
|
||||
name="adk_docs_updater",
|
||||
description=(
|
||||
"Update the ADK docs based on the code in the ADK Python codebase"
|
||||
" according to the instructions in the ADK docs issues."
|
||||
),
|
||||
instruction=f"""
|
||||
# 1. Identity
|
||||
You are a helper bot that updates ADK docs in GitHub Repository {DOC_OWNER}/{DOC_REPO}
|
||||
based on the code in the ADK Python codebase in GitHub Repository {CODE_OWNER}/{CODE_REPO} according to the instructions in the ADK docs issues.
|
||||
|
||||
You are very familiar with GitHub, especially how to search for files in a GitHub repository using git grep.
|
||||
|
||||
# 2. Responsibilities
|
||||
Your core responsibility includes:
|
||||
- Read the doc update instructions in the ADK docs issues.
|
||||
- Find **all** the related Python files in ADK Python codebase.
|
||||
- Compare the ADK docs with **all** the related Python files and analyze the differences and the doc update instructions.
|
||||
- Create a pull request to update the ADK docs.
|
||||
|
||||
# 3. Workflow
|
||||
1. Always call the `clone_or_pull_repo` tool to make sure the ADK docs and codebase repos exist in the local folder {LOCAL_REPOS_DIR_PATH}/repo_name and are the latest version.
|
||||
2. Read and analyze the issue specified by user.
|
||||
- If user only specified the issue number, call the `get_issue` tool to get the issue details; otherwise, use the issue details provided by user directly.
|
||||
3. If the issue contains instructions about how to update the ADK docs, follow the instructions to update the ADK docs.
|
||||
4. Understand the doc update instructions.
|
||||
- Ignore and skip the instructions about updating API reference docs, since it will be automatically generated by the ADK team.
|
||||
5. Read the doc to update using the `read_local_git_repo_file_content` tool from the local ADK docs repo under {LOCAL_REPOS_DIR_PATH}/{DOC_REPO}.
|
||||
6. Find the related Python files in the ADK Python codebase.
|
||||
- If the doc update instructions specify paths to the Python files, use them directly; otherwise, use a list of regex search patterns to find the related Python files through the `search_local_git_repo` tool.
|
||||
- You should focus on the main ADK Python codebase, ignore the changes in tests or other auxiliary files.
|
||||
- You should find all the related Python files, not only the most relevant one.
|
||||
7. Read the specified or found Python files using the `read_local_git_repo_file_content` tool to find all the related code.
|
||||
- You can ignore unit test files, unless you are sure that the test code is useful to understand the related concepts.
|
||||
- You should read all the found files to find all the related code, unless you already know the content of the file or you are sure that the file is not related to the ADK doc.
|
||||
8. Update the ADK doc file according to the doc update instructions and the related code.
|
||||
- Use active voice phrasing in your doc updates.
|
||||
- Use second person "you" form of address in your doc updates.
|
||||
9. Create pull requests to update the ADK doc file using the `create_pull_request_from_changes` tool.
|
||||
- For each recommended change, create a separate pull request. Make sure the recommended change has exactly one pull request.
|
||||
For example, if the ADK doc issue contains the following 2 recommended changes:
|
||||
```
|
||||
1. Title of recommended change 1
|
||||
<content of recommended change 1>
|
||||
2. Title of recommended change 2
|
||||
<content of recommended change 2>
|
||||
```
|
||||
Then you should create 2 pull requests, one for each recommended change, even if each recommended change needs to update multiple ADK doc files.
|
||||
- The title of the pull request should be "Update ADK doc according to issue #<issue number> - <change id>", where <issue number> is the number of the ADK docs issue and <change id> is the id of the recommended change (e.g. "1", "2", etc.).
|
||||
- The body of the pull request should be the instructions about how to update the ADK docs.
|
||||
- **{APPROVAL_INSTRUCTION}**
|
||||
|
||||
# 4. Guidelines & Rules
|
||||
- **File Paths:** Always use absolute paths when calling the tools to read files, list directories, or search the codebase.
|
||||
- **Tool Call Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Avoid deletion:** Do not delete any existing content unless specifically directed to do so.
|
||||
- **Explanation:** Provide concise explanations for your actions and reasoning for each step.
|
||||
- **Minimize changes:** When making updates to documentation pages, make the minimum amount of changes to achieve the communication goal. Only make changes that are necessary, and leave everything else as-is.
|
||||
- **Avoid trivial code sample changes:** Update code samples only when adding or modifying functionality. Do not reformat code samples, change variable names, or change code syntax unless you are specifically directed to make those updates.
|
||||
|
||||
# 5. Output
|
||||
Present the following in an easy to read format as the final output to the user.
|
||||
- The actions you took and the reasoning
|
||||
- The summary of the pull request created
|
||||
""",
|
||||
tools=[
|
||||
clone_or_pull_repo,
|
||||
list_directory_contents,
|
||||
search_local_git_repo,
|
||||
read_local_git_repo_file_content,
|
||||
create_pull_request_from_changes,
|
||||
get_issue,
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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 argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from adk_documentation.adk_docs_updater import agent
|
||||
from adk_documentation.settings import CODE_OWNER
|
||||
from adk_documentation.settings import CODE_REPO
|
||||
from adk_documentation.settings import DOC_OWNER
|
||||
from adk_documentation.settings import DOC_REPO
|
||||
from adk_documentation.tools import get_issue
|
||||
from adk_documentation.utils import call_agent_async
|
||||
from adk_documentation.utils import parse_suggestions
|
||||
from google.adk.cli.utils import logs
|
||||
from google.adk.runners import InMemoryRunner
|
||||
|
||||
APP_NAME = "adk_docs_updater"
|
||||
USER_ID = "adk_docs_updater_user"
|
||||
|
||||
logs.setup_adk_logger(level=logging.INFO)
|
||||
|
||||
|
||||
def process_arguments():
|
||||
"""Parses command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="A script that creates pull requests to update ADK docs.",
|
||||
epilog=(
|
||||
"Example usage: \n"
|
||||
"\tpython -m adk_docs_updater.main --issue_number 123\n"
|
||||
),
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
|
||||
group.add_argument(
|
||||
"--issue_number",
|
||||
type=int,
|
||||
metavar="NUM",
|
||||
help="Answer a specific issue number.",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def main():
|
||||
args = process_arguments()
|
||||
if not args.issue_number:
|
||||
print("Please specify an issue number using --issue_number flag")
|
||||
return
|
||||
issue_number = args.issue_number
|
||||
|
||||
get_issue_response = get_issue(DOC_OWNER, DOC_REPO, issue_number)
|
||||
if get_issue_response["status"] != "success":
|
||||
print(f"Failed to get issue {issue_number}: {get_issue_response}\n")
|
||||
return
|
||||
issue = get_issue_response["issue"]
|
||||
issue_title = issue.get("title", "")
|
||||
issue_body = issue.get("body", "")
|
||||
|
||||
# Parse numbered suggestions from issue body
|
||||
suggestions = parse_suggestions(issue_body)
|
||||
|
||||
if not suggestions:
|
||||
print(f"No numbered suggestions found in issue #{issue_number}.")
|
||||
print("Falling back to processing the entire issue as a single task.")
|
||||
suggestions = [(1, issue_body)]
|
||||
|
||||
print(f"Found {len(suggestions)} suggestion(s) in issue #{issue_number}.")
|
||||
print("=" * 80)
|
||||
|
||||
runner = InMemoryRunner(
|
||||
agent=agent.root_agent,
|
||||
app_name=APP_NAME,
|
||||
)
|
||||
|
||||
results = []
|
||||
for suggestion_num, suggestion_text in suggestions:
|
||||
print(f"\n>>> Processing suggestion #{suggestion_num}...")
|
||||
print("-" * 80)
|
||||
|
||||
# Create a new session for each suggestion to avoid context interference
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
|
||||
prompt = f"""
|
||||
Please update the ADK docs according to suggestion #{suggestion_num} from issue #{issue_number}.
|
||||
|
||||
Issue title: {issue_title}
|
||||
|
||||
Suggestion to process:
|
||||
{suggestion_text}
|
||||
|
||||
Note: Focus only on this specific suggestion. Create exactly one pull request for this suggestion.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = await call_agent_async(
|
||||
runner,
|
||||
USER_ID,
|
||||
session.id,
|
||||
prompt,
|
||||
)
|
||||
results.append({
|
||||
"suggestion_num": suggestion_num,
|
||||
"status": "success",
|
||||
"response": response,
|
||||
})
|
||||
print(f"<<<< Suggestion #{suggestion_num} completed.")
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"suggestion_num": suggestion_num,
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
})
|
||||
print(f"<<<< Suggestion #{suggestion_num} failed: {e}")
|
||||
|
||||
print("-" * 80)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
successful = [r for r in results if r["status"] == "success"]
|
||||
failed = [r for r in results if r["status"] == "error"]
|
||||
print(
|
||||
f"Total: {len(results)}, Success: {len(successful)}, Failed:"
|
||||
f" {len(failed)}"
|
||||
)
|
||||
if failed:
|
||||
print("\nFailed suggestions:")
|
||||
for r in failed:
|
||||
print(f" - Suggestion #{r['suggestion_num']}: {r['error']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_time = time.time()
|
||||
print(
|
||||
f"Start creating pull requests to update {DOC_OWNER}/{DOC_REPO} docs"
|
||||
f" according the {CODE_OWNER}/{CODE_REPO} at"
|
||||
f" {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(start_time))}"
|
||||
)
|
||||
print("-" * 80)
|
||||
asyncio.run(main())
|
||||
print("-" * 80)
|
||||
end_time = time.time()
|
||||
print(
|
||||
"Updating finished at"
|
||||
f" {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(end_time))}",
|
||||
)
|
||||
print("Total script execution time:", f"{end_time - start_time:.2f} seconds")
|
||||
@@ -0,0 +1,102 @@
|
||||
# ADK Release Analyzer Agent
|
||||
|
||||
The ADK Release Analyzer Agent is a Python-based agent designed to help keep
|
||||
documentation up-to-date with code changes. It analyzes the differences between
|
||||
two releases of the `google/adk-python` repository, identifies required updates
|
||||
in the `google/adk-docs` repository, and automatically generates a GitHub issue
|
||||
with detailed instructions for documentation changes.
|
||||
|
||||
This agent can be operated in two distinct modes:
|
||||
|
||||
- an interactive mode for local use
|
||||
- a fully automated mode for integration into workflows.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Interactive Mode
|
||||
|
||||
This mode allows you to run the agent locally to review its recommendations in
|
||||
real-time before any changes are made.
|
||||
|
||||
### Features
|
||||
|
||||
- **Web Interface**: The agent's interactive mode can be rendered in a web
|
||||
browser using the ADK's `adk web` command.
|
||||
- **User Approval**: In interactive mode, the agent is instructed to ask for
|
||||
your confirmation before creating an issue on GitHub with the documentation
|
||||
update instructions.
|
||||
- **Question & Answer**: You ask questions about the releases and code changes.
|
||||
The agent will provide answers based on related information.
|
||||
|
||||
### Running in Interactive Mode
|
||||
|
||||
To run the agent in interactive mode, first set the required environment
|
||||
variables, ensuring `INTERACTIVE` is set to `1` or is unset. Then, execute the
|
||||
following command in your terminal:
|
||||
|
||||
```bash
|
||||
adk web contributing/samples/adk_documentation
|
||||
```
|
||||
|
||||
This will start a local server and provide a URL to access the agent's web
|
||||
interface in your browser.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Automated Mode
|
||||
|
||||
For automated, hands-off analysis, the agent can be run as a script (`main.py`),
|
||||
for example as part of a CI/CD pipeline. The workflow is configured in
|
||||
`.github/workflows/analyze-releases-for-adk-docs-updates.yml` and automatically
|
||||
checks the most recent two releases for docs updates.
|
||||
|
||||
### Workflow Triggers
|
||||
|
||||
The GitHub workflow is configured to run on specific triggers:
|
||||
|
||||
- **Release Events**: The workflow executes automatically whenever a new release
|
||||
is `published`.
|
||||
|
||||
- **Manual Dispatch**: The workflow also runs when manually triggered for
|
||||
testing and retrying.
|
||||
|
||||
### Automated Issue Creation
|
||||
|
||||
When running in automated mode, the agent operates non-interactively. It creates
|
||||
a GitHub issue with the documentation update instructions directly without
|
||||
requiring user approval. This behavior is configured by setting the
|
||||
`INTERACTIVE` environment variable to `0`.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Setup and Configuration
|
||||
|
||||
Whether running in interactive or automated mode, the agent requires the
|
||||
following setup.
|
||||
|
||||
### Dependencies
|
||||
|
||||
The agent requires the following Python libraries.
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install google-adk
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following environment variables are required for the agent to connect to
|
||||
the necessary services.
|
||||
|
||||
- `GITHUB_TOKEN`: **(Required)** A GitHub Personal Access Token with issues:write permissions for the documentation repository.
|
||||
- `GOOGLE_API_KEY`: **(Required)** Your API key for the Gemini API.
|
||||
- `DOC_OWNER`: The GitHub organization or username that owns the documentation repository (defaults to `google`).
|
||||
- `CODE_OWNER`: The GitHub organization or username that owns the code repository (defaults to `google`).
|
||||
- `DOC_REPO`: The name of the documentation repository (defaults to `adk-docs`).
|
||||
- `CODE_REPO`: The name of the code repository (defaults to `adk-python`).
|
||||
- `LOCAL_REPOS_DIR_PATH`: The local directory to clone the repositories into (defaults to `/tmp`).
|
||||
- `INTERACTIVE`: Controls the agent's interaction mode. Set to 1 for interactive mode (default), and 0 for automated mode.
|
||||
|
||||
For local execution, you can place these variables in a `.env` file in the
|
||||
project's root directory. For automated workflows, they should be configured as
|
||||
environment variables or secrets.
|
||||
@@ -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,691 @@
|
||||
# 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.
|
||||
|
||||
"""ADK Release Analyzer Agent - Multi-agent architecture for analyzing releases.
|
||||
|
||||
This agent uses a SequentialAgent + LoopAgent pattern to handle large releases
|
||||
without context overflow:
|
||||
|
||||
1. PlannerAgent: Collects changed files and creates analysis groups
|
||||
2. LoopAgent + FileGroupAnalyzer: Processes one group at a time
|
||||
3. SummaryAgent: Compiles all findings and creates the GitHub issue
|
||||
|
||||
State keys used:
|
||||
- start_tag, end_tag: Release tags being compared
|
||||
- compare_url: GitHub compare URL
|
||||
- file_groups: List of file groups to analyze
|
||||
- current_group_index: Index of current group being processed
|
||||
- recommendations: Accumulated recommendations from all groups
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
SAMPLES_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
)
|
||||
if SAMPLES_DIR not in sys.path:
|
||||
sys.path.append(SAMPLES_DIR)
|
||||
|
||||
from adk_documentation.settings import CODE_OWNER
|
||||
from adk_documentation.settings import CODE_REPO
|
||||
from adk_documentation.settings import DOC_OWNER
|
||||
from adk_documentation.settings import DOC_REPO
|
||||
from adk_documentation.settings import IS_INTERACTIVE
|
||||
from adk_documentation.settings import LOCAL_REPOS_DIR_PATH
|
||||
from adk_documentation.tools import clone_or_pull_repo
|
||||
from adk_documentation.tools import create_issue
|
||||
from adk_documentation.tools import get_changed_files_summary
|
||||
from adk_documentation.tools import get_file_diff_for_release
|
||||
from adk_documentation.tools import list_directory_contents
|
||||
from adk_documentation.tools import list_releases
|
||||
from adk_documentation.tools import read_local_git_repo_file_content
|
||||
from adk_documentation.tools import search_local_git_repo
|
||||
from google.adk import Agent
|
||||
from google.adk.agents.loop_agent import LoopAgent
|
||||
from google.adk.agents.readonly_context import ReadonlyContext
|
||||
from google.adk.agents.sequential_agent import SequentialAgent
|
||||
from google.adk.models import Gemini
|
||||
from google.adk.tools.exit_loop_tool import exit_loop
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
from google.genai import types
|
||||
|
||||
# Retry configuration for handling API rate limits and overload
|
||||
_RETRY_OPTIONS = types.HttpRetryOptions(
|
||||
initial_delay=10,
|
||||
attempts=8,
|
||||
exp_base=2,
|
||||
max_delay=300,
|
||||
http_status_codes=[429, 503],
|
||||
)
|
||||
|
||||
# Use gemini-3.1-pro-preview for planning and summary (better quality)
|
||||
GEMINI_PRO_WITH_RETRY = Gemini(
|
||||
model="gemini-3.1-pro-preview",
|
||||
retry_options=_RETRY_OPTIONS,
|
||||
)
|
||||
|
||||
# Maximum number of files per analysis group to avoid context overflow
|
||||
MAX_FILES_PER_GROUP = 5
|
||||
|
||||
if IS_INTERACTIVE:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"Ask for user approval or confirmation for creating or updating the"
|
||||
" issue."
|
||||
)
|
||||
else:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"**Do not** wait or ask for user approval or confirmation for creating"
|
||||
" or updating the issue."
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tool functions for state management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_next_file_group(tool_context: ToolContext) -> dict[str, Any]:
|
||||
"""Gets the next group of files to analyze from the state.
|
||||
|
||||
This tool retrieves the next file group from state["file_groups"]
|
||||
and increments the current_group_index.
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
|
||||
Returns:
|
||||
A dictionary with the next file group or indication that all groups
|
||||
are processed.
|
||||
"""
|
||||
file_groups = tool_context.state.get("file_groups", [])
|
||||
current_index = tool_context.state.get("current_group_index", 0)
|
||||
|
||||
if current_index >= len(file_groups):
|
||||
print(f"[Progress] All {len(file_groups)} groups processed.")
|
||||
return {
|
||||
"status": "complete",
|
||||
"message": "All file groups have been processed.",
|
||||
"total_groups": len(file_groups),
|
||||
"processed": current_index,
|
||||
}
|
||||
|
||||
current_group = file_groups[current_index]
|
||||
file_paths = [f.get("relative_path", "?") for f in current_group]
|
||||
print(
|
||||
f"[Progress] Starting group {current_index + 1}/{len(file_groups)}:"
|
||||
f" {file_paths}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"group_index": current_index,
|
||||
"total_groups": len(file_groups),
|
||||
"remaining": len(file_groups) - current_index - 1,
|
||||
"files": current_group,
|
||||
}
|
||||
|
||||
|
||||
def save_group_recommendations(
|
||||
tool_context: ToolContext,
|
||||
group_index: int,
|
||||
recommendations: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
"""Saves recommendations for a file group to state.
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
group_index: The index of the group these recommendations belong to.
|
||||
recommendations: List of recommendation dicts with keys:
|
||||
- summary: Brief summary of the change
|
||||
- doc_file: Path to the doc file to update
|
||||
- current_state: Current content in the doc
|
||||
- proposed_change: What should be changed
|
||||
- reasoning: Why this change is needed
|
||||
- reference: Reference to the code file
|
||||
|
||||
Returns:
|
||||
A dictionary confirming the save operation.
|
||||
"""
|
||||
all_recommendations = tool_context.state.get("recommendations", [])
|
||||
all_recommendations.extend(recommendations)
|
||||
tool_context.state["recommendations"] = all_recommendations
|
||||
# Advance index only after recommendations are saved, so interrupted
|
||||
# groups get retried on resume instead of being skipped.
|
||||
tool_context.state["current_group_index"] = group_index + 1
|
||||
|
||||
total_groups = len(tool_context.state.get("file_groups", []))
|
||||
print(
|
||||
f"[Progress] Group {group_index + 1}/{total_groups} done."
|
||||
f" +{len(recommendations)} recommendations"
|
||||
f" ({len(all_recommendations)} total)"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"group_index": group_index,
|
||||
"new_recommendations": len(recommendations),
|
||||
"total_recommendations": len(all_recommendations),
|
||||
}
|
||||
|
||||
|
||||
def get_all_recommendations(tool_context: ToolContext) -> dict[str, Any]:
|
||||
"""Retrieves all accumulated recommendations from state.
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
|
||||
Returns:
|
||||
A dictionary with all recommendations and metadata.
|
||||
"""
|
||||
recommendations = tool_context.state.get("recommendations", [])
|
||||
start_tag = tool_context.state.get("start_tag", "unknown")
|
||||
end_tag = tool_context.state.get("end_tag", "unknown")
|
||||
compare_url = tool_context.state.get("compare_url", "")
|
||||
|
||||
print(
|
||||
f"[Summary] Retrieving recommendations: {len(recommendations)} total,"
|
||||
f" release {start_tag} → {end_tag}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"start_tag": start_tag,
|
||||
"end_tag": end_tag,
|
||||
"compare_url": compare_url,
|
||||
"total_recommendations": len(recommendations),
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
||||
|
||||
def save_release_info(
|
||||
tool_context: ToolContext,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
compare_url: str,
|
||||
file_groups: list[list[dict[str, Any]]],
|
||||
release_summary: str,
|
||||
all_changed_files: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Saves release info and file groups to state for processing.
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
start_tag: The starting release tag.
|
||||
end_tag: The ending release tag.
|
||||
compare_url: The GitHub compare URL.
|
||||
file_groups: List of file groups, where each group is a list of file
|
||||
info dicts.
|
||||
release_summary: A high-level summary of all changes in this release,
|
||||
including the main themes (e.g., "new feature X", "refactoring Y",
|
||||
"bug fixes in Z"). This helps individual analyzers understand the
|
||||
bigger picture.
|
||||
all_changed_files: List of all changed file paths (for cross-reference).
|
||||
|
||||
Returns:
|
||||
A dictionary confirming the save operation.
|
||||
"""
|
||||
tool_context.state["start_tag"] = start_tag
|
||||
tool_context.state["end_tag"] = end_tag
|
||||
tool_context.state["compare_url"] = compare_url
|
||||
tool_context.state["file_groups"] = file_groups
|
||||
tool_context.state["current_group_index"] = 0
|
||||
tool_context.state["recommendations"] = []
|
||||
tool_context.state["release_summary"] = release_summary
|
||||
tool_context.state["all_changed_files"] = all_changed_files
|
||||
|
||||
total_files = sum(len(group) for group in file_groups)
|
||||
print(
|
||||
f"[Planning] Release {start_tag} → {end_tag}:"
|
||||
f" {total_files} files in {len(file_groups)} groups"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"start_tag": start_tag,
|
||||
"end_tag": end_tag,
|
||||
"total_groups": len(file_groups),
|
||||
"total_files": sum(len(group) for group in file_groups),
|
||||
}
|
||||
|
||||
|
||||
def get_release_context(tool_context: ToolContext) -> dict[str, Any]:
|
||||
"""Gets the global release context for cross-group awareness.
|
||||
|
||||
This allows individual file group analyzers to understand:
|
||||
- The overall theme of the release
|
||||
- What other files were changed (for identifying related changes)
|
||||
- What recommendations have already been made (to avoid duplicates)
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
|
||||
Returns:
|
||||
A dictionary with global release context.
|
||||
"""
|
||||
return {
|
||||
"status": "success",
|
||||
"start_tag": tool_context.state.get("start_tag", "unknown"),
|
||||
"end_tag": tool_context.state.get("end_tag", "unknown"),
|
||||
"release_summary": tool_context.state.get("release_summary", ""),
|
||||
"all_changed_files": tool_context.state.get("all_changed_files", []),
|
||||
"existing_recommendations": tool_context.state.get("recommendations", []),
|
||||
"current_group_index": tool_context.state.get("current_group_index", 0),
|
||||
"total_groups": len(tool_context.state.get("file_groups", [])),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent 1: Planner Agent
|
||||
# =============================================================================
|
||||
|
||||
planner_agent = Agent(
|
||||
model=GEMINI_PRO_WITH_RETRY,
|
||||
name="release_planner",
|
||||
description=(
|
||||
"Plans the analysis by fetching release info and organizing files into"
|
||||
" groups for incremental processing."
|
||||
),
|
||||
instruction=f"""
|
||||
# 1. Identity
|
||||
You are the Release Planner, responsible for setting up the analysis of ADK
|
||||
Python releases. You gather information about changes and organize them for
|
||||
efficient processing.
|
||||
|
||||
# 2. Workflow
|
||||
1. First, call `clone_or_pull_repo` for both repositories:
|
||||
- ADK Python codebase: owner={CODE_OWNER}, repo={CODE_REPO}, path={LOCAL_REPOS_DIR_PATH}/{CODE_REPO}
|
||||
- ADK Docs: owner={DOC_OWNER}, repo={DOC_REPO}, path={LOCAL_REPOS_DIR_PATH}/{DOC_REPO}
|
||||
|
||||
2. Call `list_releases` to find the release tags for {CODE_OWNER}/{CODE_REPO}.
|
||||
- By default, compare the two most recent releases.
|
||||
- If the user specifies tags, use those instead.
|
||||
|
||||
3. Call `get_changed_files_summary` to get the list of changed files WITHOUT
|
||||
the full patches (to save context space).
|
||||
- **IMPORTANT**: Pass these parameters:
|
||||
- `local_repo_path="{LOCAL_REPOS_DIR_PATH}/{CODE_REPO}"` to avoid 300-file limit
|
||||
- `path_filter="src/google/adk/"` to only get ADK source files (reduces token usage)
|
||||
|
||||
4. Further filter the returned files:
|
||||
- **EXCLUDE** test files and `__init__.py` files
|
||||
- **IMPORTANT**: Do NOT exclude any file just because it has few changes.
|
||||
Even single-line changes to public APIs need documentation updates.
|
||||
- **PRIORITIZE** by importance:
|
||||
a) New files (status: "added") - ALWAYS include these
|
||||
b) CLI files (cli/) - often contain user-facing flags and options
|
||||
c) Tool files (tools/) - may contain new tools or tool parameters
|
||||
d) Core files (agents/, models/, sessions/, memory/, a2a/, flows/,
|
||||
plugins/, evaluation/)
|
||||
e) Files with many changes (high additions + deletions)
|
||||
|
||||
5. **Create a high-level release summary** based on the changed files:
|
||||
- Identify the main themes (e.g., "new tool X added", "refactoring of Y")
|
||||
- Note any files that appear related (e.g., same feature area)
|
||||
- This summary will be shared with individual file analyzers so they
|
||||
understand the bigger picture.
|
||||
|
||||
6. Group the filtered files into groups of at most {MAX_FILES_PER_GROUP} files each.
|
||||
- **IMPORTANT**: Group RELATED files together (same directory or feature)
|
||||
- Files that are part of the same feature should be in the same group
|
||||
- Each group should be independently analyzable
|
||||
|
||||
7. Call `save_release_info` to save:
|
||||
- start_tag, end_tag
|
||||
- compare_url
|
||||
- file_groups (the organized groups)
|
||||
- release_summary (the high-level summary you created)
|
||||
- all_changed_files (list of all file paths for cross-reference)
|
||||
|
||||
# 3. Output
|
||||
Provide a summary of:
|
||||
- Which releases are being compared
|
||||
- The high-level themes of this release
|
||||
- How many files changed in total
|
||||
- How many files are relevant for doc analysis
|
||||
- How many groups were created
|
||||
""",
|
||||
tools=[
|
||||
clone_or_pull_repo,
|
||||
list_releases,
|
||||
get_changed_files_summary,
|
||||
save_release_info,
|
||||
],
|
||||
output_key="planner_output",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent 2: File Group Analyzer (runs inside LoopAgent)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def file_analyzer_instruction(readonly_context: ReadonlyContext) -> str:
|
||||
"""Dynamic instruction that includes current state info."""
|
||||
start_tag = readonly_context.state.get("start_tag", "unknown")
|
||||
end_tag = readonly_context.state.get("end_tag", "unknown")
|
||||
release_summary = readonly_context.state.get("release_summary", "")
|
||||
|
||||
return f"""
|
||||
# 1. Identity
|
||||
You are the File Group Analyzer, responsible for analyzing a group of changed
|
||||
files and finding related documentation that needs updating.
|
||||
|
||||
# 2. Context
|
||||
- Comparing releases: {start_tag} to {end_tag}
|
||||
- Code repository: {CODE_OWNER}/{CODE_REPO}
|
||||
- Docs repository: {DOC_OWNER}/{DOC_REPO}
|
||||
- Docs local path: {LOCAL_REPOS_DIR_PATH}/{DOC_REPO}
|
||||
- Code local path: {LOCAL_REPOS_DIR_PATH}/{CODE_REPO}
|
||||
|
||||
## Release Summary (from Planner)
|
||||
{release_summary}
|
||||
|
||||
# 3. Workflow
|
||||
1. Call `get_next_file_group` to get the next group of files to analyze.
|
||||
- If status is "complete", call the `exit_loop` tool to exit the loop.
|
||||
|
||||
2. **FIRST**, call `get_release_context` to understand:
|
||||
- The overall release themes (to understand how your files fit in)
|
||||
- What other files were changed (to identify related changes)
|
||||
- What recommendations already exist (to AVOID DUPLICATES)
|
||||
|
||||
3. For each file in the group:
|
||||
a) Call `get_file_diff_for_release` to get the patch content for that file.
|
||||
b) Analyze the changes THOROUGHLY. Look for:
|
||||
**API Changes:**
|
||||
- New functions, classes, methods (especially public ones)
|
||||
- New parameters added to existing functions
|
||||
- New CLI arguments or flags (look for argparse, click decorators)
|
||||
- New environment variables (look for os.environ, getenv)
|
||||
- New tools or features being added
|
||||
- Renamed or deprecated functionality
|
||||
**Behavior Changes (even without API changes):**
|
||||
- Default values changed
|
||||
- Error handling or exception types changed
|
||||
- Return value format or content changed
|
||||
- Side effects added or removed
|
||||
- Performance characteristics changed
|
||||
- Edge case handling changed
|
||||
- Validation rules changed
|
||||
c) Consider how this file relates to OTHER changed files in this release.
|
||||
d) Generate MULTIPLE search patterns based on:
|
||||
- Class/function names that changed
|
||||
- Feature names mentioned in the file path
|
||||
- Keywords from the patch content (e.g., "local_storage", "allow_origins")
|
||||
- Tool names, parameter names, environment variable names
|
||||
|
||||
4. For EACH significant change, call `search_local_git_repo` to find related docs
|
||||
in {LOCAL_REPOS_DIR_PATH}/{DOC_REPO}/docs/
|
||||
- Search for the feature name, class name, or related keywords
|
||||
- **ALWAYS** pass `ignored_dirs=["api-reference"]` to skip auto-generated API
|
||||
reference docs (they are updated automatically by code, not manually)
|
||||
- If no docs found, recommend creating new documentation
|
||||
|
||||
5. Call `read_local_git_repo_file_content` to read the relevant doc files
|
||||
and check if they need updating.
|
||||
- **SKIP** any files under `docs/api-reference/` — these are auto-generated.
|
||||
|
||||
6. For each documentation update needed, create a recommendation with:
|
||||
- summary: Brief summary of what needs to change
|
||||
- doc_file: Relative path in the docs repo (e.g., docs/tools/google-search.md)
|
||||
- current_state: What the doc currently says
|
||||
- proposed_change: What it should say instead
|
||||
- reasoning: Why this update is needed
|
||||
- reference: The source code file path
|
||||
- related_files: Other changed files that are part of the same change (if any)
|
||||
|
||||
7. Call `save_group_recommendations` with all recommendations for this group.
|
||||
|
||||
8. After saving, output a brief summary of what you found for this group.
|
||||
|
||||
# 4. Rules
|
||||
- **BE THOROUGH**: Check EVERY change in the diff that could affect users.
|
||||
This includes API changes AND behavior changes (default values, error handling,
|
||||
return formats, side effects, etc.).
|
||||
- Focus on changes that users need to know about
|
||||
- Include behavior changes even if the API signature stays the same
|
||||
- If a change only affects auto-generated API reference docs, note that
|
||||
regeneration is needed instead of manual updates
|
||||
- **AVOID DUPLICATES**: Check existing_recommendations before adding new ones
|
||||
- **CROSS-REFERENCE**: If files in your group relate to files in other groups,
|
||||
mention this in your recommendation so the Summary agent can consolidate
|
||||
- **DON'T MISS ITEMS**: Better to have too many recommendations than too few.
|
||||
If unsure whether something needs documentation, include it.
|
||||
- For new features with no existing docs, recommend creating a new page
|
||||
"""
|
||||
|
||||
|
||||
file_group_analyzer = Agent(
|
||||
model=GEMINI_PRO_WITH_RETRY,
|
||||
name="file_group_analyzer",
|
||||
description=(
|
||||
"Analyzes a group of changed files and generates recommendations."
|
||||
),
|
||||
instruction=file_analyzer_instruction,
|
||||
include_contents="none",
|
||||
tools=[
|
||||
get_next_file_group,
|
||||
get_release_context, # Get global context to avoid duplicates
|
||||
get_file_diff_for_release,
|
||||
search_local_git_repo,
|
||||
read_local_git_repo_file_content,
|
||||
list_directory_contents,
|
||||
save_group_recommendations,
|
||||
exit_loop, # Call this when all groups are processed
|
||||
],
|
||||
output_key="analyzer_output",
|
||||
)
|
||||
|
||||
# Loop agent that processes file groups one at a time
|
||||
file_analysis_loop = LoopAgent(
|
||||
name="file_analysis_loop",
|
||||
sub_agents=[file_group_analyzer],
|
||||
max_iterations=50, # Safety limit
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent 3: Summary Agent
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def summary_instruction(readonly_context: ReadonlyContext) -> str:
|
||||
"""Dynamic instruction with release info."""
|
||||
start_tag = readonly_context.state.get("start_tag", "unknown")
|
||||
end_tag = readonly_context.state.get("end_tag", "unknown")
|
||||
|
||||
return f"""
|
||||
# 1. Identity
|
||||
You are the Summary Agent, responsible for compiling all recommendations into
|
||||
a well-formatted GitHub issue.
|
||||
|
||||
# 2. Workflow
|
||||
1. Call `get_all_recommendations` to retrieve all accumulated recommendations.
|
||||
|
||||
2. Organize the recommendations:
|
||||
- Group by importance: Feature changes > Bug fixes > Other
|
||||
- Within each group, sort by number of affected files
|
||||
- Remove duplicates or merge similar recommendations
|
||||
|
||||
3. Format the issue body using this template for each recommendation:
|
||||
```
|
||||
### N. **Summary of the change**
|
||||
|
||||
**Doc file**: path/to/doc.md
|
||||
|
||||
**Current state**:
|
||||
> Current content in the doc
|
||||
|
||||
**Proposed Change**:
|
||||
> What it should say instead
|
||||
|
||||
**Reasoning**:
|
||||
Explanation of why this change is necessary.
|
||||
|
||||
**Reference**: src/google/adk/path/to/file.py
|
||||
```
|
||||
|
||||
4. Create the GitHub issue:
|
||||
- Title: "Found docs updates needed from ADK python release {start_tag} to {end_tag}"
|
||||
- Include the compare link at the top
|
||||
- {APPROVAL_INSTRUCTION}
|
||||
|
||||
5. Call `create_issue` for {DOC_OWNER}/{DOC_REPO} with the formatted content.
|
||||
|
||||
# 3. Output
|
||||
Present a summary of:
|
||||
- Total recommendations created
|
||||
- Issue URL if created
|
||||
- Any notes about the analysis
|
||||
"""
|
||||
|
||||
|
||||
summary_agent = Agent(
|
||||
model=GEMINI_PRO_WITH_RETRY,
|
||||
name="summary_agent",
|
||||
description="Compiles recommendations and creates the GitHub issue.",
|
||||
instruction=summary_instruction,
|
||||
include_contents="none",
|
||||
tools=[
|
||||
get_all_recommendations,
|
||||
create_issue,
|
||||
],
|
||||
output_key="summary_output",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pipeline Agent: Sequential orchestration of the analysis
|
||||
# =============================================================================
|
||||
|
||||
analysis_pipeline = SequentialAgent(
|
||||
name="analysis_pipeline",
|
||||
description=(
|
||||
"Executes the release analysis pipeline: planning, file analysis, and"
|
||||
" summary generation."
|
||||
),
|
||||
sub_agents=[
|
||||
planner_agent,
|
||||
file_analysis_loop,
|
||||
summary_agent,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Resume pipeline: skips planner, continues from where loop left off.
|
||||
# Deep copy agents since ADK agents can only have one parent.
|
||||
_resume_loop = copy.deepcopy(file_analysis_loop)
|
||||
_resume_loop.parent_agent = None
|
||||
_resume_summary = copy.deepcopy(summary_agent)
|
||||
_resume_summary.parent_agent = None
|
||||
|
||||
resume_pipeline = SequentialAgent(
|
||||
name="resume_pipeline",
|
||||
description=(
|
||||
"Resumes the release analysis pipeline from the file analysis loop,"
|
||||
" skipping the planning phase."
|
||||
),
|
||||
sub_agents=[
|
||||
_resume_loop,
|
||||
_resume_summary,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Root Agent: Entry point that understands user requests
|
||||
# =============================================================================
|
||||
|
||||
root_agent = Agent(
|
||||
model=GEMINI_PRO_WITH_RETRY,
|
||||
name="adk_release_analyzer",
|
||||
description=(
|
||||
"Analyzes ADK Python releases and generates documentation update"
|
||||
" recommendations."
|
||||
),
|
||||
instruction=f"""
|
||||
# 1. Identity
|
||||
You are the ADK Release Analyzer, a helper bot that analyzes changes between
|
||||
ADK Python releases and identifies documentation updates needed in the ADK
|
||||
Docs repository.
|
||||
|
||||
# 2. Capabilities
|
||||
You can help users in several ways:
|
||||
|
||||
## A. Full Release Analysis (delegate to analysis_pipeline)
|
||||
When users want a complete analysis of releases, delegate to the
|
||||
`analysis_pipeline` sub-agent. This will:
|
||||
- Clone/update repositories
|
||||
- Analyze all changed files
|
||||
- Generate recommendations
|
||||
- Create a GitHub issue
|
||||
|
||||
Use this when users say things like:
|
||||
- "Analyze the latest releases"
|
||||
- "Check what docs need updating for v1.15.0"
|
||||
- "Run a full analysis"
|
||||
|
||||
## B. Quick Queries (use your tools directly)
|
||||
For targeted questions, use your tools directly WITHOUT delegating:
|
||||
|
||||
- **"How should I modify doc1.md?"** → Use `search_local_git_repo` to find
|
||||
mentions of doc1.md in the codebase, then use `get_changed_files_summary`
|
||||
to see what changed, and provide specific guidance.
|
||||
|
||||
- **"What changed in the tools module?"** → Use `get_changed_files_summary`
|
||||
and filter for tools/ directory.
|
||||
|
||||
- **"Show me the recommendations from the last analysis"** → Use
|
||||
`get_all_recommendations` to retrieve stored recommendations.
|
||||
|
||||
- **"What releases are available?"** → Use `list_releases` directly.
|
||||
|
||||
# 3. Workflow Decision
|
||||
1. First, understand what the user is asking:
|
||||
- Full analysis request → delegate to analysis_pipeline
|
||||
- Specific question about a file/module → use tools directly
|
||||
- Query about previous results → use get_all_recommendations
|
||||
|
||||
2. For quick queries, ensure repos are cloned first using `clone_or_pull_repo`
|
||||
if needed.
|
||||
|
||||
3. Always explain what you're doing and provide clear, actionable answers.
|
||||
|
||||
# 4. Available Tools
|
||||
- `clone_or_pull_repo`: Ensure local repos are up to date
|
||||
- `list_releases`: See available release tags
|
||||
- `get_changed_files_summary`: Get list of changed files (lightweight)
|
||||
- `get_file_diff_for_release`: Get patch for a specific file
|
||||
- `search_local_git_repo`: Search for patterns in repos
|
||||
- `read_local_git_repo_file_content`: Read file contents
|
||||
- `get_all_recommendations`: Retrieve recommendations from previous analysis
|
||||
|
||||
# 5. Repository Info
|
||||
- Code repo: {CODE_OWNER}/{CODE_REPO} at {LOCAL_REPOS_DIR_PATH}/{CODE_REPO}
|
||||
- Docs repo: {DOC_OWNER}/{DOC_REPO} at {LOCAL_REPOS_DIR_PATH}/{DOC_REPO}
|
||||
""",
|
||||
tools=[
|
||||
clone_or_pull_repo,
|
||||
list_releases,
|
||||
get_changed_files_summary,
|
||||
get_file_diff_for_release,
|
||||
search_local_git_repo,
|
||||
read_local_git_repo_file_content,
|
||||
get_all_recommendations,
|
||||
],
|
||||
sub_agents=[analysis_pipeline],
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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 argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from adk_documentation.adk_release_analyzer import agent
|
||||
from adk_documentation.settings import CODE_OWNER
|
||||
from adk_documentation.settings import CODE_REPO
|
||||
from adk_documentation.settings import DOC_OWNER
|
||||
from adk_documentation.settings import DOC_REPO
|
||||
from adk_documentation.utils import call_agent_async
|
||||
from google.adk.cli.utils import logs
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions import DatabaseSessionService
|
||||
|
||||
APP_NAME = "adk_release_analyzer"
|
||||
USER_ID = "adk_release_analyzer_user"
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "sessions.db")
|
||||
DB_URL = f"sqlite+aiosqlite:///{DB_PATH}"
|
||||
|
||||
logs.setup_adk_logger(level=logging.INFO)
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="ADK Release Analyzer")
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
action="store_true",
|
||||
help="Resume from the last session instead of starting fresh.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--start-tag",
|
||||
type=str,
|
||||
default=None,
|
||||
help="The older release tag (base) for comparison, e.g. v1.26.0.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--end-tag",
|
||||
type=str,
|
||||
default=None,
|
||||
help="The newer release tag (head) for comparison, e.g. v1.27.0.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
session_service = DatabaseSessionService(db_url=DB_URL)
|
||||
|
||||
if args.resume:
|
||||
# Find the most recent session to resume
|
||||
sessions_response = await session_service.list_sessions(
|
||||
app_name=APP_NAME, user_id=USER_ID
|
||||
)
|
||||
if not sessions_response.sessions:
|
||||
print("No previous session found. Starting fresh.")
|
||||
args.resume = False
|
||||
|
||||
if args.resume:
|
||||
# Resume: use existing session with resume_pipeline (skip planner)
|
||||
last_session = sessions_response.sessions[-1]
|
||||
session_id = last_session.id
|
||||
session = await session_service.get_session(
|
||||
app_name=APP_NAME, user_id=USER_ID, session_id=session_id
|
||||
)
|
||||
state = session.state
|
||||
group_index = state.get("current_group_index", 0)
|
||||
total_groups = len(state.get("file_groups", []))
|
||||
num_recs = len(state.get("recommendations", []))
|
||||
print(f"Resuming session {session_id}")
|
||||
print(
|
||||
f" Progress: group {group_index + 1}/{total_groups},"
|
||||
f" {num_recs} recommendations so far"
|
||||
)
|
||||
print(
|
||||
f" Release: {state.get('start_tag', '?')} →"
|
||||
f" {state.get('end_tag', '?')}"
|
||||
)
|
||||
|
||||
runner = Runner(
|
||||
agent=agent.resume_pipeline,
|
||||
app_name=APP_NAME,
|
||||
session_service=session_service,
|
||||
)
|
||||
prompt = "Resume analyzing the remaining file groups."
|
||||
else:
|
||||
# Fresh run
|
||||
runner = Runner(
|
||||
agent=agent.root_agent,
|
||||
app_name=APP_NAME,
|
||||
session_service=session_service,
|
||||
)
|
||||
session = await session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
session_id = session.id
|
||||
if args.start_tag and args.end_tag:
|
||||
prompt = (
|
||||
f"Please analyze ADK Python releases from {args.start_tag} to"
|
||||
f" {args.end_tag}!"
|
||||
)
|
||||
elif args.end_tag:
|
||||
prompt = (
|
||||
f"Please analyze the ADK Python release {args.end_tag} against its"
|
||||
" previous release!"
|
||||
)
|
||||
else:
|
||||
prompt = "Please analyze the most recent two releases of ADK Python!"
|
||||
|
||||
print(f"Session ID: {session_id}")
|
||||
print("-" * 80)
|
||||
|
||||
response = await call_agent_async(runner, USER_ID, session_id, prompt)
|
||||
print(f"<<<< Agent Final Output: {response}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_time = time.time()
|
||||
print(
|
||||
f"Start analyzing {CODE_OWNER}/{CODE_REPO} releases for"
|
||||
f" {DOC_OWNER}/{DOC_REPO} updates at"
|
||||
f" {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(start_time))}"
|
||||
)
|
||||
print("-" * 80)
|
||||
asyncio.run(main())
|
||||
print("-" * 80)
|
||||
end_time = time.time()
|
||||
print(
|
||||
"Triaging finished at"
|
||||
f" {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(end_time))}",
|
||||
)
|
||||
print("Total script execution time:", f"{end_time - start_time:.2f} seconds")
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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_dotenv(override=True)
|
||||
|
||||
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")
|
||||
|
||||
DOC_OWNER = os.getenv("DOC_OWNER", "google")
|
||||
CODE_OWNER = os.getenv("CODE_OWNER", "google")
|
||||
DOC_REPO = os.getenv("DOC_REPO", "adk-docs")
|
||||
CODE_REPO = os.getenv("CODE_REPO", "adk-python")
|
||||
LOCAL_REPOS_DIR_PATH = os.getenv("LOCAL_REPOS_DIR_PATH", "/tmp")
|
||||
|
||||
IS_INTERACTIVE = os.getenv("INTERACTIVE", "1").lower() in ["true", "1"]
|
||||
@@ -0,0 +1,823 @@
|
||||
# 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
|
||||
import os
|
||||
import subprocess
|
||||
from subprocess import CompletedProcess
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from adk_documentation.settings import GITHUB_BASE_URL
|
||||
from adk_documentation.utils import error_response
|
||||
from adk_documentation.utils import get_paginated_request
|
||||
from adk_documentation.utils import get_request
|
||||
from adk_documentation.utils import patch_request
|
||||
from adk_documentation.utils import post_request
|
||||
import requests
|
||||
|
||||
|
||||
def list_releases(repo_owner: str, repo_name: str) -> Dict[str, Any]:
|
||||
"""Lists all releases for a repository.
|
||||
|
||||
This function retrieves all releases and for each one, returns its ID,
|
||||
creation time, publication time, and associated tag name. It handles
|
||||
pagination to ensure all releases are fetched.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a list of releases.
|
||||
"""
|
||||
# The initial URL for the releases endpoint
|
||||
# per_page=100 is used to reduce the number of API calls
|
||||
url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/releases?per_page=100"
|
||||
)
|
||||
|
||||
try:
|
||||
all_releases_data = get_paginated_request(url)
|
||||
|
||||
# Format the response to include only the requested fields
|
||||
formatted_releases = []
|
||||
for release in all_releases_data:
|
||||
formatted_releases.append({
|
||||
"id": release.get("id"),
|
||||
"tag_name": release.get("tag_name"),
|
||||
"created_at": release.get("created_at"),
|
||||
"published_at": release.get("published_at"),
|
||||
})
|
||||
|
||||
return {"status": "success", "releases": formatted_releases}
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error_response(f"HTTP Error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Request Error: {e}")
|
||||
|
||||
|
||||
def get_changed_files_between_releases(
|
||||
repo_owner: str, repo_name: str, start_tag: str, end_tag: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Gets changed files and their modifications between two release tags.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
start_tag: The older tag (base) for the comparison.
|
||||
end_tag: The newer tag (head) for the comparison.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a list of changed files.
|
||||
Each file includes its name, status (added, removed, modified),
|
||||
and the patch/diff content.
|
||||
"""
|
||||
# The 'basehead' parameter is specified as 'base...head'.
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/compare/{start_tag}...{end_tag}"
|
||||
|
||||
try:
|
||||
comparison_data = get_request(url)
|
||||
|
||||
# The API returns a 'files' key with the list of changed files.
|
||||
changed_files = comparison_data.get("files", [])
|
||||
|
||||
# Extract just the information we need for a cleaner output
|
||||
formatted_files = []
|
||||
for file_data in changed_files:
|
||||
formatted_files.append({
|
||||
"relative_path": file_data.get("filename"),
|
||||
"status": file_data.get("status"),
|
||||
"additions": file_data.get("additions"),
|
||||
"deletions": file_data.get("deletions"),
|
||||
"changes": file_data.get("changes"),
|
||||
"patch": file_data.get(
|
||||
"patch", "No patch available."
|
||||
), # The diff content
|
||||
})
|
||||
return {"status": "success", "changed_files": formatted_files}
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error_response(f"HTTP Error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Request Error: {e}")
|
||||
|
||||
|
||||
def clone_or_pull_repo(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
local_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Clones a GitHub repository to a local folder using owner and repo name.
|
||||
|
||||
If the folder already exists and is a valid Git repository, it pulls the
|
||||
latest changes instead.
|
||||
|
||||
Args:
|
||||
repo_owner: The username or organization that owns the repository.
|
||||
repo_name: The name of the repository.
|
||||
local_path: The local directory path where the repository should be cloned
|
||||
or updated.
|
||||
|
||||
Returns:
|
||||
A dictionary indicating the status of the operation, output message, and
|
||||
the head commit hash.
|
||||
"""
|
||||
repo_url = f"git@github.com:{repo_owner}/{repo_name}.git"
|
||||
|
||||
try:
|
||||
# Check local path and decide to clone or pull
|
||||
if os.path.exists(local_path):
|
||||
git_dir_path = os.path.join(local_path, ".git")
|
||||
if os.path.isdir(git_dir_path):
|
||||
print(f"Repository exists at '{local_path}'. Pulling latest changes...")
|
||||
try:
|
||||
output = _get_pull(local_path)
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"git pull failed: {e.stderr}")
|
||||
else:
|
||||
return error_response(
|
||||
f"Path '{local_path}' exists but is not a Git repository."
|
||||
)
|
||||
else:
|
||||
print(f"Cloning from {repo_owner}/{repo_name} into '{local_path}'...")
|
||||
try:
|
||||
output = _get_clone(repo_url, local_path)
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"git clone failed: {e.stderr}")
|
||||
head_commit_sha = _find_head_commit_sha(local_path)
|
||||
except FileNotFoundError:
|
||||
return error_response("Error: 'git' command not found. Is Git installed?")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return error_response(f"Command timeout: {e}")
|
||||
except (subprocess.CalledProcessError, OSError, ValueError) as e:
|
||||
return error_response(f"An unexpected error occurred: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"output": output,
|
||||
"head_commit_sha": head_commit_sha,
|
||||
}
|
||||
|
||||
|
||||
def read_local_git_repo_file_content(file_path: str) -> Dict[str, Any]:
|
||||
"""Reads the content of a specified file in a local Git repository.
|
||||
|
||||
Args:
|
||||
file_path: The full, absolute path to the file.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status, content of the file, and the head
|
||||
commit hash.
|
||||
"""
|
||||
print(f"Attempting to read file from path: {file_path}")
|
||||
if not os.path.isabs(file_path):
|
||||
return error_response(
|
||||
f"file_path must be an absolute path, got: {file_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
dir_path = os.path.dirname(file_path)
|
||||
head_commit_sha = _find_head_commit_sha(dir_path)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
head_commit_sha = "unknown"
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
lines = content.splitlines()
|
||||
numbered_lines = [f"{i + 1}: {line}" for i, line in enumerate(lines)]
|
||||
numbered_content = "\n".join(numbered_lines)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"file_path": file_path,
|
||||
"content": numbered_content,
|
||||
"head_commit_sha": head_commit_sha,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return error_response(f"Error: File not found at {file_path}")
|
||||
except (IOError, OSError) as e:
|
||||
return error_response(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
def list_directory_contents(directory_path: str) -> Dict[str, Any]:
|
||||
"""Recursively lists all files and directories within a specified directory.
|
||||
|
||||
Args:
|
||||
directory_path: The full, absolute path to the directory.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a map where keys are directory
|
||||
paths relative to the initial directory_path, and values are lists of
|
||||
their contents.
|
||||
Returns an error message if the directory cannot be accessed.
|
||||
"""
|
||||
print(
|
||||
f"Attempting to recursively list contents of directory: {directory_path}"
|
||||
)
|
||||
if not os.path.isdir(directory_path):
|
||||
return error_response(f"Error: Directory not found at {directory_path}")
|
||||
|
||||
directory_map = {}
|
||||
try:
|
||||
for root, dirs, files in os.walk(directory_path):
|
||||
# Filter out hidden directories from traversal and from the result
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
# Filter out hidden files
|
||||
non_hidden_files = [f for f in files if not f.startswith(".")]
|
||||
|
||||
relative_path = os.path.relpath(root, directory_path)
|
||||
directory_map[relative_path] = dirs + non_hidden_files
|
||||
return {
|
||||
"status": "success",
|
||||
"directory_path": directory_path,
|
||||
"directory_map": directory_map,
|
||||
}
|
||||
except (IOError, OSError) as e:
|
||||
return error_response(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
def search_local_git_repo(
|
||||
directory_path: str,
|
||||
pattern: str,
|
||||
extensions: Optional[List[str]] = None,
|
||||
ignored_dirs: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Searches a local Git repository for a pattern.
|
||||
|
||||
Args:
|
||||
directory_path: The absolute path to the local Git repository.
|
||||
pattern: The search pattern (can be a simple string or regex for git
|
||||
grep).
|
||||
extensions: The list of file extensions to search, e.g. ["py", "md"]. If
|
||||
None, all extensions will be searched.
|
||||
ignored_dirs: The list of directories to ignore, e.g. ["tests"]. If None,
|
||||
no directories will be ignored.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status, and a list of match details (relative
|
||||
file path to the directory_path, line number, content).
|
||||
"""
|
||||
print(
|
||||
f"Attempting to search for pattern: {pattern} in directory:"
|
||||
f" {directory_path}, with extensions: {extensions}"
|
||||
)
|
||||
try:
|
||||
grep_process = _git_grep(directory_path, pattern, extensions, ignored_dirs)
|
||||
if grep_process.returncode > 1:
|
||||
return error_response(f"git grep failed: {grep_process.stderr}")
|
||||
|
||||
matches = []
|
||||
if grep_process.stdout:
|
||||
for line in grep_process.stdout.strip().split("\n"):
|
||||
try:
|
||||
file_path, line_number_str, line_content = line.split(":", 2)
|
||||
matches.append({
|
||||
"file_path": file_path,
|
||||
"line_number": int(line_number_str),
|
||||
"line_content": line_content.strip(),
|
||||
})
|
||||
except ValueError:
|
||||
return error_response(
|
||||
f"Error: Failed to parse line: {line} from git grep output."
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"matches": matches,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return error_response(f"Directory not found: {directory_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"git grep failed: {e.stderr}")
|
||||
except (IOError, OSError, ValueError) as e:
|
||||
return error_response(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
def create_pull_request_from_changes(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
local_path: str,
|
||||
base_branch: str,
|
||||
changes: Dict[str, str],
|
||||
commit_message: str,
|
||||
pr_title: str,
|
||||
pr_body: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Creates a new branch, applies file changes, commits, pushes, and creates a PR.
|
||||
|
||||
Args:
|
||||
repo_owner: The username or organization that owns the repository.
|
||||
repo_name: The name of the repository.
|
||||
local_path: The local absolute path to the cloned repository.
|
||||
base_branch: The name of the branch to merge the changes into (e.g.,
|
||||
"main").
|
||||
changes: A dictionary where keys are file paths relative to the repo root
|
||||
and values are the new and full content for those files.
|
||||
commit_message: The message for the git commit.
|
||||
pr_title: The title for the pull request.
|
||||
pr_body: The body/description for the pull request.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and the pull request object on success,
|
||||
or an error message on failure.
|
||||
"""
|
||||
try:
|
||||
# Step 0: Ensure we are on the base branch and it's up to date.
|
||||
_run_git_command(["checkout", base_branch], local_path)
|
||||
_run_git_command(["pull", "origin", base_branch], local_path)
|
||||
|
||||
# Step 1: Create a new, unique branch from the base branch.
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
new_branch = f"agent-changes-{timestamp}"
|
||||
_run_git_command(["checkout", "-b", new_branch], local_path)
|
||||
print(f"Created and switched to new branch: {new_branch}")
|
||||
|
||||
# Step 2: Apply the file changes.
|
||||
if not changes:
|
||||
return error_response("No changes provided to apply.")
|
||||
|
||||
for relative_path, new_content in changes.items():
|
||||
full_path = os.path.join(local_path, relative_path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
print(f"Applied changes to {relative_path}")
|
||||
|
||||
# Step 3: Stage the changes.
|
||||
_run_git_command(["add", "."], local_path)
|
||||
print("Staged all changes.")
|
||||
|
||||
# Step 4: Commit the changes.
|
||||
_run_git_command(["commit", "-m", commit_message], local_path)
|
||||
print(f"Committed changes with message: '{commit_message}'")
|
||||
|
||||
# Step 5: Push the new branch to the remote repository.
|
||||
_run_git_command(["push", "-u", "origin", new_branch], local_path)
|
||||
print(f"Pushed branch '{new_branch}' to origin.")
|
||||
|
||||
# Step 6: Create the pull request via GitHub API.
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/pulls"
|
||||
payload = {
|
||||
"title": pr_title,
|
||||
"body": pr_body,
|
||||
"head": new_branch,
|
||||
"base": base_branch,
|
||||
}
|
||||
pr_response = post_request(url, payload)
|
||||
print(f"Successfully created pull request: {pr_response.get('html_url')}")
|
||||
|
||||
return {"status": "success", "pull_request": pr_response}
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"A git command failed: {e.stderr}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"GitHub API request failed: {e}")
|
||||
except (IOError, OSError) as e:
|
||||
return error_response(f"A file system error occurred: {e}")
|
||||
|
||||
|
||||
def get_issue(
|
||||
repo_owner: str, repo_name: str, issue_number: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the details of the specified issue number.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
issue_number: issue number of the GitHub issue.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the issue details when successful.
|
||||
"""
|
||||
url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/issues/{issue_number}"
|
||||
)
|
||||
try:
|
||||
response = get_request(url)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {"status": "success", "issue": response}
|
||||
|
||||
|
||||
def create_issue(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
title: str,
|
||||
body: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new issue in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
title: The title of the issue.
|
||||
body: The body of the issue.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the issue details when successful.
|
||||
"""
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/issues"
|
||||
payload = {"title": title, "body": body, "labels": ["docs updates"]}
|
||||
try:
|
||||
response = post_request(url, payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {"status": "success", "issue": response}
|
||||
|
||||
|
||||
def update_issue(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
issue_number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing issue in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
issue_number: The number of the issue to update.
|
||||
title: The title of the issue.
|
||||
body: The body of the issue.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the issue details when successful.
|
||||
"""
|
||||
url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/issues/{issue_number}"
|
||||
)
|
||||
payload = {"title": title, "body": body}
|
||||
try:
|
||||
response = patch_request(url, payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {"status": "success", "issue": response}
|
||||
|
||||
|
||||
def _run_git_command(command: List[str], cwd: str) -> CompletedProcess[str]:
|
||||
"""A helper to run a git command and raise an exception on error."""
|
||||
base_command = ["git"]
|
||||
process = subprocess.run(
|
||||
base_command + command,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True, # This will raise CalledProcessError if the command fails
|
||||
)
|
||||
return process
|
||||
|
||||
|
||||
def _find_head_commit_sha(repo_path: str) -> str:
|
||||
"""Checks the head commit hash of a Git repository."""
|
||||
head_sha_command = ["git", "rev-parse", "HEAD"]
|
||||
head_sha_process = subprocess.run(
|
||||
head_sha_command,
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
current_commit_sha = head_sha_process.stdout.strip()
|
||||
return current_commit_sha
|
||||
|
||||
|
||||
def _get_pull(repo_path: str) -> str:
|
||||
"""Pulls the latest changes from a Git repository."""
|
||||
pull_process = subprocess.run(
|
||||
["git", "pull"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return pull_process.stdout.strip()
|
||||
|
||||
|
||||
def _get_clone(repo_url: str, repo_path: str) -> str:
|
||||
"""Clones a Git repository to a local folder."""
|
||||
clone_process = subprocess.run(
|
||||
["git", "clone", repo_url, repo_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return clone_process.stdout.strip()
|
||||
|
||||
|
||||
def _git_grep(
|
||||
repo_path: str,
|
||||
pattern: str,
|
||||
extensions: Optional[List[str]] = None,
|
||||
ignored_dirs: Optional[List[str]] = None,
|
||||
) -> subprocess.CompletedProcess[Any]:
|
||||
"""Uses 'git grep' to find all matching lines in a Git repository."""
|
||||
grep_command = [
|
||||
"git",
|
||||
"grep",
|
||||
"-n",
|
||||
"-I",
|
||||
"-E",
|
||||
"--ignore-case",
|
||||
"-e",
|
||||
pattern,
|
||||
]
|
||||
pathspecs = []
|
||||
if extensions:
|
||||
pathspecs.extend([f"*.{ext}" for ext in extensions])
|
||||
if ignored_dirs:
|
||||
pathspecs.extend([f":(exclude){d}" for d in ignored_dirs])
|
||||
|
||||
if pathspecs:
|
||||
grep_command.append("--")
|
||||
grep_command.extend(pathspecs)
|
||||
|
||||
grep_process = subprocess.run(
|
||||
grep_command,
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False, # Don't raise error on non-zero exit code (1 means no match)
|
||||
)
|
||||
return grep_process
|
||||
|
||||
|
||||
def get_file_diff_for_release(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
file_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gets the diff/patch for a specific file between two release tags.
|
||||
|
||||
This is useful for incremental processing where you want to analyze
|
||||
one file at a time instead of loading all changes at once.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
start_tag: The older tag (base) for the comparison.
|
||||
end_tag: The newer tag (head) for the comparison.
|
||||
file_path: The relative path of the file to get the diff for.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and the file diff details.
|
||||
"""
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/compare/{start_tag}...{end_tag}"
|
||||
|
||||
try:
|
||||
comparison_data = get_request(url)
|
||||
changed_files = comparison_data.get("files", [])
|
||||
|
||||
for file_data in changed_files:
|
||||
if file_data.get("filename") == file_path:
|
||||
return {
|
||||
"status": "success",
|
||||
"file": {
|
||||
"relative_path": file_data.get("filename"),
|
||||
"status": file_data.get("status"),
|
||||
"additions": file_data.get("additions"),
|
||||
"deletions": file_data.get("deletions"),
|
||||
"changes": file_data.get("changes"),
|
||||
"patch": file_data.get("patch", "No patch available."),
|
||||
},
|
||||
}
|
||||
|
||||
return error_response(f"File {file_path} not found in the comparison.")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error_response(f"HTTP Error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Request Error: {e}")
|
||||
|
||||
|
||||
def get_changed_files_summary(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
local_repo_path: Optional[str] = None,
|
||||
path_filter: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gets a summary of changed files between two releases without patches.
|
||||
|
||||
This function uses local git commands when local_repo_path is provided,
|
||||
which avoids the GitHub API's 300-file limit for large comparisons.
|
||||
Falls back to GitHub API if local_repo_path is not provided or invalid.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
start_tag: The older tag (base) for the comparison.
|
||||
end_tag: The newer tag (head) for the comparison.
|
||||
local_repo_path: Optional absolute path to local git repo. If provided
|
||||
and valid, uses git diff instead of GitHub API to get complete
|
||||
file list (avoids 300-file limit).
|
||||
path_filter: Optional path prefix to filter files. Only files whose
|
||||
path starts with this prefix will be included. Example:
|
||||
"src/google/adk/" to only include ADK source files.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a summary of changed files.
|
||||
"""
|
||||
# Use local git if valid path is provided (avoids GitHub API 300-file limit)
|
||||
if local_repo_path and os.path.isdir(os.path.join(local_repo_path, ".git")):
|
||||
return _get_changed_files_from_local_git(
|
||||
local_repo_path, start_tag, end_tag, repo_owner, repo_name, path_filter
|
||||
)
|
||||
|
||||
# Fall back to GitHub API (limited to 300 files)
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/compare/{start_tag}...{end_tag}"
|
||||
|
||||
try:
|
||||
comparison_data = get_request(url)
|
||||
changed_files = comparison_data.get("files", [])
|
||||
|
||||
# Group files by directory for easier processing
|
||||
files_by_dir: Dict[str, List[Dict[str, Any]]] = {}
|
||||
formatted_files = []
|
||||
|
||||
for file_data in changed_files:
|
||||
file_info = {
|
||||
"relative_path": file_data.get("filename"),
|
||||
"status": file_data.get("status"),
|
||||
"additions": file_data.get("additions"),
|
||||
"deletions": file_data.get("deletions"),
|
||||
"changes": file_data.get("changes"),
|
||||
}
|
||||
formatted_files.append(file_info)
|
||||
|
||||
# Group by top-level directory
|
||||
path = file_data.get("filename", "")
|
||||
parts = path.split("/")
|
||||
top_dir = parts[0] if parts else "root"
|
||||
if top_dir not in files_by_dir:
|
||||
files_by_dir[top_dir] = []
|
||||
files_by_dir[top_dir].append(file_info)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"total_files": len(formatted_files),
|
||||
"files": formatted_files,
|
||||
"files_by_directory": files_by_dir,
|
||||
"compare_url": (
|
||||
f"https://github.com/{repo_owner}/{repo_name}"
|
||||
f"/compare/{start_tag}...{end_tag}"
|
||||
),
|
||||
"note": (
|
||||
(
|
||||
"Using GitHub API which is limited to 300 files. "
|
||||
"Provide local_repo_path to get complete file list."
|
||||
)
|
||||
if len(formatted_files) >= 300
|
||||
else None
|
||||
),
|
||||
}
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error_response(f"HTTP Error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Request Error: {e}")
|
||||
|
||||
|
||||
def _get_changed_files_from_local_git(
|
||||
local_repo_path: str,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
path_filter: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gets changed files using local git commands (no file limit).
|
||||
|
||||
Args:
|
||||
local_repo_path: Path to local git repository.
|
||||
start_tag: The older tag (base) for the comparison.
|
||||
end_tag: The newer tag (head) for the comparison.
|
||||
repo_owner: Repository owner for compare URL.
|
||||
repo_name: Repository name for compare URL.
|
||||
path_filter: Optional path prefix to filter files.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a summary of changed files.
|
||||
"""
|
||||
try:
|
||||
# Fetch tags to ensure we have them
|
||||
subprocess.run(
|
||||
["git", "fetch", "--tags"],
|
||||
cwd=local_repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Get list of changed files with their status
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", "--name-status", f"{start_tag}...{end_tag}"],
|
||||
cwd=local_repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Get numstat for additions/deletions
|
||||
numstat_result = subprocess.run(
|
||||
["git", "diff", "--numstat", f"{start_tag}...{end_tag}"],
|
||||
cwd=local_repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Parse numstat output (additions, deletions, filename)
|
||||
file_stats: Dict[str, Dict[str, int]] = {}
|
||||
for line in numstat_result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3:
|
||||
additions = int(parts[0]) if parts[0] != "-" else 0
|
||||
deletions = int(parts[1]) if parts[1] != "-" else 0
|
||||
filename = parts[2]
|
||||
file_stats[filename] = {
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"changes": additions + deletions,
|
||||
}
|
||||
|
||||
# Parse name-status output and combine with numstat
|
||||
status_map = {
|
||||
"A": "added",
|
||||
"D": "removed",
|
||||
"M": "modified",
|
||||
"R": "renamed",
|
||||
"C": "copied",
|
||||
}
|
||||
|
||||
files_by_dir: Dict[str, List[Dict[str, Any]]] = {}
|
||||
formatted_files = []
|
||||
|
||||
for line in diff_result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
status_code = parts[0][0] # First char is the status
|
||||
filename = parts[-1] # Last part is filename (handles renames)
|
||||
|
||||
# Apply path filter if specified
|
||||
if path_filter and not filename.startswith(path_filter):
|
||||
continue
|
||||
|
||||
stats = file_stats.get(
|
||||
filename,
|
||||
{
|
||||
"additions": 0,
|
||||
"deletions": 0,
|
||||
"changes": 0,
|
||||
},
|
||||
)
|
||||
|
||||
file_info = {
|
||||
"relative_path": filename,
|
||||
"status": status_map.get(status_code, "modified"),
|
||||
"additions": stats["additions"],
|
||||
"deletions": stats["deletions"],
|
||||
"changes": stats["changes"],
|
||||
}
|
||||
formatted_files.append(file_info)
|
||||
|
||||
# Group by top-level directory
|
||||
dir_parts = filename.split("/")
|
||||
top_dir = dir_parts[0] if dir_parts else "root"
|
||||
if top_dir not in files_by_dir:
|
||||
files_by_dir[top_dir] = []
|
||||
files_by_dir[top_dir].append(file_info)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"total_files": len(formatted_files),
|
||||
"files": formatted_files,
|
||||
"files_by_directory": files_by_dir,
|
||||
"compare_url": (
|
||||
f"https://github.com/{repo_owner}/{repo_name}"
|
||||
f"/compare/{start_tag}...{end_tag}"
|
||||
),
|
||||
}
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"Git command failed: {e.stderr}")
|
||||
except (OSError, ValueError) as e:
|
||||
return error_response(f"Error getting changed files: {e}")
|
||||
@@ -0,0 +1,144 @@
|
||||
# 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 re
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
|
||||
from adk_documentation.settings import GITHUB_TOKEN
|
||||
from google.adk.agents.run_config import RunConfig
|
||||
from google.adk.runners import Runner
|
||||
from google.genai import types
|
||||
import requests
|
||||
|
||||
HEADERS = {
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
}
|
||||
|
||||
|
||||
def error_response(error_message: str) -> Dict[str, Any]:
|
||||
return {"status": "error", "error_message": error_message}
|
||||
|
||||
|
||||
def get_request(
|
||||
url: str,
|
||||
headers: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Executes a GET request."""
|
||||
if headers is None:
|
||||
headers = HEADERS
|
||||
if params is None:
|
||||
params = {}
|
||||
response = requests.get(url, headers=headers, params=params, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def get_paginated_request(
|
||||
url: str, headers: dict[str, Any] | None = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Executes GET requests and follows 'next' pagination links to fetch all results."""
|
||||
if headers is None:
|
||||
headers = HEADERS
|
||||
|
||||
results = []
|
||||
while url:
|
||||
response = requests.get(url, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
results.extend(response.json())
|
||||
url = response.links.get("next", {}).get("url")
|
||||
return results
|
||||
|
||||
|
||||
def post_request(url: str, payload: Any) -> Dict[str, Any]:
|
||||
response = requests.post(url, headers=HEADERS, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def patch_request(url: str, payload: Any) -> Dict[str, Any]:
|
||||
response = requests.patch(url, headers=HEADERS, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def call_agent_async(
|
||||
runner: Runner, user_id: str, session_id: str, prompt: str
|
||||
) -> str:
|
||||
"""Call the agent asynchronously with the user's prompt."""
|
||||
content = types.Content(
|
||||
role="user", parts=[types.Part.from_text(text=prompt)]
|
||||
)
|
||||
|
||||
final_response_text = ""
|
||||
async for event in runner.run_async(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
new_message=content,
|
||||
run_config=RunConfig(save_input_blobs_as_artifacts=False),
|
||||
):
|
||||
if event.content and event.content.parts:
|
||||
if text := "".join(part.text or "" for part in event.content.parts):
|
||||
if event.author != "user":
|
||||
final_response_text += text
|
||||
|
||||
return final_response_text
|
||||
|
||||
|
||||
def parse_suggestions(issue_body: str) -> List[Tuple[int, str]]:
|
||||
"""Parse numbered suggestions from issue body.
|
||||
|
||||
Supports multiple formats:
|
||||
- Format A (markdown headers): "### 1. Title"
|
||||
- Format B (numbered list with bold): "1. **Title**"
|
||||
|
||||
Args:
|
||||
issue_body: The body text of the GitHub issue.
|
||||
|
||||
Returns:
|
||||
A list of tuples, where each tuple contains:
|
||||
- The suggestion number (1-based)
|
||||
- The full text of that suggestion
|
||||
"""
|
||||
# Try different patterns in order of preference
|
||||
patterns = [
|
||||
# Format A: "### 1. Title" (markdown header with number)
|
||||
(r"(?=^###\s+\d+\.)", r"^###\s+(\d+)\."),
|
||||
# Format B: "1. **Title**" (numbered list with bold)
|
||||
(r"(?=^\d+\.\s+\*\*)", r"^(\d+)\.\s+\*\*"),
|
||||
]
|
||||
|
||||
for split_pattern, match_pattern in patterns:
|
||||
parts = re.split(split_pattern, issue_body, flags=re.MULTILINE)
|
||||
|
||||
suggestions = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
match = re.match(match_pattern, part)
|
||||
if match:
|
||||
suggestion_num = int(match.group(1))
|
||||
suggestions.append((suggestion_num, part))
|
||||
|
||||
# If we found suggestions with this pattern, return them
|
||||
if suggestions:
|
||||
return suggestions
|
||||
|
||||
return []
|
||||
Reference in New Issue
Block a user