Files
filament/test/renderdiff/src/viewer.py
Powei Feng 98eea205f3 renderdiff: viewer can pull artifacts given PR number (#8919)
This will allow for a faster flow then downloading manually.
Though it will require user to supply a github token.
2025-07-03 14:17:31 -07:00

259 lines
11 KiB
Python

# Copyright (C) 2025 The Android Open Source Project
#
# 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
import flask
import pathlib
import json
import requests
import io
import zipfile
from utils import ArgParseImpl
from flask import Flask, request, make_response, send_from_directory
DIR = pathlib.Path(__file__).parent.absolute()
HTML_DIR = os.path.join(DIR, "viewer_html")
# Generated by gemini
def _download_github_artifacts(pr_number, github_token, output_dir= ".") -> None:
"""
Downloads artifacts associated with a specific GitHub Pull Request.
This function performs the following steps:
1. Fetches the details of the Pull Request to get its head commit SHA.
2. Searches for GitHub Actions workflow runs triggered by that specific commit.
3. Iterates through successful workflow runs to find and list all associated artifacts.
4. Downloads each artifact (which comes as a ZIP file).
5. Extracts the contents of each downloaded ZIP file into a unique subdirectory
within the specified output directory.
Args:
owner (str): The GitHub repository owner (e.g., "octocat").
repo (str): The GitHub repository name (e.g., "Spoon-Knife").
pr_number (int): The Pull Request number.
output_dir (str): The local directory where downloaded artifacts will be saved.
Defaults to the current directory.
"""
# Prepare HTTP headers for GitHub API requests
headers = {"Accept": "application/vnd.github.v3+json"}
if github_token:
headers["Authorization"] = f"token {github_token}"
OWNER_REPO = 'google/filament'
# --- Step 1: Get PR details to find the head commit SHA ---
print(f"Fetching details for PR #{pr_number} in {OWNER_REPO}...")
pr_url = f"https://api.github.com/repos/{OWNER_REPO}/pulls/{pr_number}"
try:
response = requests.get(pr_url, headers=headers)
response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
pr_data = response.json()
commit_sha = pr_data["head"]["sha"]
print(f"PR #{pr_number} is associated with commit SHA: {commit_sha}")
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"Error: PR #{pr_number} not found in {OWNER_REPO}. Please check the PR number, owner, and repository name.")
elif e.response.status_code == 403:
print(f"Error: Access forbidden to PR #{pr_number}. You might be hitting API rate limits or need a valid GitHub Token.")
else:
print(f"An HTTP error occurred while fetching PR details: {e}")
return # Exit function on error
except requests.exceptions.RequestException as e:
print(f"A network error occurred while fetching PR details: {e}")
return # Exit function on error
# --- Step 2: Find workflow runs associated with the commit SHA ---
print(f"Searching for workflow runs for commit SHA: {commit_sha}...")
workflow_runs_url = f"https://api.github.com/repos/{OWNER_REPO}/actions/runs"
# Filter by head_sha and event='pull_request' for precision
params = {"head_sha": commit_sha, "event": "pull_request"}
try:
response = requests.get(workflow_runs_url, headers=headers, params=params)
response.raise_for_status()
runs_data = response.json()
workflow_runs = runs_data.get("workflow_runs", [])
if not workflow_runs:
print(f"No workflow runs found directly associated with PR #{pr_number} (commit SHA: {commit_sha}).")
print("This might happen if the workflow was triggered by a push after the PR was opened,")
print("or if the PR head branch was updated without triggering a new workflow run with this exact SHA.")
print("Consider checking GitHub Actions runs manually for this PR's branch on GitHub.")
return None
# Filter for runs that completed successfully
successful_runs = [run for run in workflow_runs if run.get("conclusion") == "success" and run.get("status") == "completed"]
if not successful_runs:
print(f"No *successful and completed* workflow runs found for PR #{pr_number} with commit SHA {commit_sha}. Exiting.")
return None
except requests.exceptions.HTTPError as e:
print(f"An HTTP error occurred while searching for workflow runs: {e}")
return None
except requests.exceptions.RequestException as e:
print(f"A network error occurred while searching for workflow runs: {e}")
return None
# Create the main output directory if it doesn't exist
os.makedirs(output_dir, exist_ok=True)
print(f"Ensuring output directory exists: {os.path.abspath(output_dir)}")
downloaded_any_artifact = False # Flag to track if any artifact was downloaded
# --- Step 3 & 4: List and Download Artifacts for each successful run ---
for run in successful_runs:
run_id = run["id"]
run_name = run["name"]
print(f"\nProcessing workflow run '{run_name}' (ID: {run_id})...")
artifacts_url = f"https://api.github.com/repos/{OWNER_REPO}/actions/runs/{run_id}/artifacts"
try:
response = requests.get(artifacts_url, headers=headers)
response.raise_for_status()
artifacts_data = response.json()
artifacts = artifacts_data.get("artifacts", [])
if not artifacts:
print(f" No artifacts found for workflow run ID {run_id}.")
continue # Move to the next workflow run
for artifact in artifacts:
artifact_id = artifact["id"]
artifact_name = artifact["name"]
archive_download_url = artifact["archive_download_url"]
print(f" Found artifact: '{artifact_name}' (ID: {artifact_id})")
# Perform the download request
print(f" Downloading '{artifact_name}'...")
# Use a copy of headers and specific Accept for ZIP download
download_headers = headers.copy()
download_headers["Accept"] = "application/vnd.github.v3+zip"
download_response = requests.get(archive_download_url, headers=download_headers, stream=True)
download_response.raise_for_status() # Check for errors in download
# --- Step 5: Extract the contents ---
# Use BytesIO to handle the zip file content in memory without saving to a temporary file
with io.BytesIO(download_response.content) as zip_buffer:
try:
with zipfile.ZipFile(zip_buffer, 'r') as zip_ref:
# Create a unique subdirectory for each artifact to avoid file name conflicts
extract_path = os.path.join(output_dir, f"{artifact_name}_{artifact_id}")
os.makedirs(extract_path, exist_ok=True)
zip_ref.extractall(extract_path)
print(f" Successfully extracted '{artifact_name}' to '{extract_path}/'")
downloaded_any_artifact = True
except zipfile.BadZipFile:
print(f" Error: Downloaded file for '{artifact_name}' is not a valid zip file. Skipping extraction.")
except Exception as e:
print(f" An error occurred during extraction of '{artifact_name}': {e}")
except requests.exceptions.HTTPError as e:
print(f" An HTTP error occurred while fetching artifacts for run {run_id}: {e}")
if e.response.status_code == 403:
print(" This often means you need a GitHub Personal Access Token with 'repo' scope (even for public repos for artifact downloads).")
continue # Continue processing the next workflow run
except requests.exceptions.RequestException as e:
print(f" A network error occurred while fetching artifacts for run {run_id}: {e}")
continue # Continue processing the next workflow run
if not downloaded_any_artifact:
print("\nNo artifacts were downloaded for the specified PR.")
else:
print("\nAll available artifacts have been processed.")
return 'Done'
def _create_app(config):
app = Flask(__name__)
client_config = config.copy()
diff_dir = client_config['diff_dir']
base_dir = os.path.join(diff_dir, client_config['base_dir'])
comparison_dir = os.path.join(diff_dir, client_config['comparison_dir'])
del client_config['base_dir']
del client_config['comparison_dir']
del client_config['diff_dir']
@app.route('/r/', methods=['GET'])
def get_r():
return json.dumps(client_config)
@app.route('/g/<path:filepath>', methods=['GET'])
def get_g(filepath):
return send_from_directory(base_dir, filepath)
@app.route('/d/<path:filepath>', methods=['GET'])
def get_d(filepath):
return send_from_directory(diff_dir, filepath)
@app.route('/c/<path:filepath>', methods=['GET'])
def get_c(filepath):
return send_from_directory(comparison_dir, filepath)
@app.route('/<path:filepath>')
def get_static_file(filepath):
return send_from_directory(HTML_DIR, filepath)
@app.route('/')
def get_index():
return send_from_directory(HTML_DIR, 'index.html')
app.url_map.strict_slashes = False
return app
if __name__ == '__main__':
PORT = 8901
parser = ArgParseImpl()
parser.add_argument('--diff', type=str, help='Diff result directory')
parser.add_argument('--pr_number', type=str, help='Pull request artifacts to examine')
parser.add_argument('--github_token', type=str, help='Necessary for pull PR artifacts')
args, _ = parser.parse_known_args(sys.argv[1:])
if not args.diff and not args.pr_number:
print('Need to specify either a diff result directory or a Pull Request number')
exit(1)
if args.diff and args.pr_number:
print('Cannot specify both a diff result directory and a Pull Request number')
exit(1)
fdir = args.diff
if args.pr_number:
if not args.github_token:
print('Must provide --github_token to be able to download artifacts')
exit(1)
output_dir = f'/tmp/filament-pr{args.pr_number}-rdiff-result'
res = _download_github_artifacts(args.pr_number, args.github_token, output_dir)
if not res:
print('Failed to retrieve PR artifacts')
exit(1)
# TODO: Clean up the following so that we're not so specific on the paths diffs/presubmit
directory_name = list(os.listdir(output_dir))[0]
fdir = os.path.join(os.path.join(output_dir, directory_name), 'diffs/presubmit')
with open(os.path.join(fdir, 'compare_results.json'), 'r') as f:
config = json.loads(f.read())
config['diff_dir'] = os.path.abspath(fdir)
app = _create_app(config)
from waitress import serve
print(f'Point your browser to http://localhost:{PORT} to see the diff results')
serve(app, host="127.0.0.1", port=PORT)