From 33bd1f3970e631ba2350e0f6d4964472e7175280 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 20:59:33 +0000 Subject: [PATCH 01/12] Add script to print GitHub workflow run errors This script allows users to specify a workflow name and branch to find the latest run. It then identifies any failed jobs within that run and prints the last N lines of logs for each failed step. Key features: - Fetches the most recent workflow run for a given workflow and branch. - Identifies jobs within the run that have a 'failure' conclusion. - For each failed job, attempts to identify failed steps and extracts their logs. - Prints the last 500 lines (configurable) of the log for each failed step. - Handles various scenarios including successful runs, running workflows, and missing data. - Supports GitHub token via CLI arg, env var, or ~/.github_token. - Auto-detects repository from git remote, or accepts via CLI args. --- scripts/print_workflow_run_errors.py | 410 +++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 scripts/print_workflow_run_errors.py diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py new file mode 100644 index 0000000000..221fc8c39c --- /dev/null +++ b/scripts/print_workflow_run_errors.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +# Copyright 2024 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. + +"""Fetches and prints errors from a GitHub Workflow run.""" + +import argparse +import os +import sys +import datetime +import requests +import json +import re +import subprocess +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry + +# Constants for GitHub API interaction +RETRIES = 3 +BACKOFF = 5 +RETRY_STATUS = (403, 500, 502, 504) # HTTP status codes to retry on +TIMEOUT = 10 # Default timeout for requests in seconds +LONG_TIMEOUT = 30 # Timeout for potentially longer requests like log downloads + +# Global variables for the target repository, populated by set_repo_info() +OWNER = '' +REPO = '' +BASE_URL = 'https://api.github.com' +GITHUB_API_URL = '' + + +def set_repo_info(owner_name, repo_name): + """Sets the global repository owner, name, and API URL.""" + global OWNER, REPO, GITHUB_API_URL + OWNER = owner_name + REPO = repo_name + GITHUB_API_URL = f'{BASE_URL}/repos/{OWNER}/{REPO}' + return True + + +def requests_retry_session(retries=RETRIES, + backoff_factor=BACKOFF, + status_forcelist=RETRY_STATUS): + """Creates a requests session with retry logic.""" + session = requests.Session() + retry = Retry(total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + +def main(): + """Main function to parse arguments and orchestrate the script.""" + determined_owner = None + determined_repo = None + try: + git_url_bytes = subprocess.check_output(["git", "remote", "get-url", "origin"], stderr=subprocess.PIPE) + git_url = git_url_bytes.decode().strip() + match = re.search(r"(?:(?:https?://github\.com/)|(?:git@github\.com:))([^/]+)/([^/.]+)(?:\.git)?", git_url) + if match: + determined_owner = match.group(1) + determined_repo = match.group(2) + sys.stderr.write(f"Determined repository: {determined_owner}/{determined_repo} from git remote 'origin'.\n") + except (subprocess.CalledProcessError, FileNotFoundError, UnicodeDecodeError) as e: + sys.stderr.write(f"Could not automatically determine repository from git remote 'origin': {e}\n") + except Exception as e: + sys.stderr.write(f"An unexpected error occurred while determining repository: {e}\n") + + def parse_repo_url_arg(url_string): + """Parses owner and repository name from various GitHub URL formats.""" + url_match = re.search(r"(?:(?:https?://github\.com/)|(?:git@github\.com:))([^/]+)/([^/.]+?)(?:\.git)?/?$", url_string) + if url_match: + return url_match.group(1), url_match.group(2) + return None, None + + parser = argparse.ArgumentParser( + description="Fetch and display failed steps and their logs from a GitHub workflow run.", + formatter_class=argparse.RawTextHelpFormatter + ) + parser.add_argument( + "workflow_name", + type=str, + help="Name of the workflow file (e.g., 'main.yml' or 'build-test.yml')." + ) + parser.add_argument( + "branch", + type=str, + help="GitHub branch name to check for the workflow run." + ) + parser.add_argument( + "--url", + type=str, + default=None, + help="Full GitHub repository URL (e.g., https://github.com/owner/repo or git@github.com:owner/repo.git). Takes precedence over --owner/--repo." + ) + parser.add_argument( + "--owner", + type=str, + default=determined_owner, + help=f"Repository owner. Used if --url is not provided. {'Default: ' + determined_owner if determined_owner else 'Required if --url is not used and not determinable from git.'}" + ) + parser.add_argument( + "--repo", + type=str, + default=determined_repo, + help=f"Repository name. Used if --url is not provided. {'Default: ' + determined_repo if determined_repo else 'Required if --url is not used and not determinable from git.'}" + ) + parser.add_argument( + "--token", + type=str, + default=os.environ.get("GITHUB_TOKEN"), + help="GitHub token. Can also be set via GITHUB_TOKEN env var or from ~/.github_token." + ) + parser.add_argument( + "--log-lines", + type=int, + default=500, + help="Number of lines to print from the end of each failed step's log. Default: 500." + ) + + args = parser.parse_args() + error_suffix = " (use --help for more details)" + + token = args.token + if not token: + try: + with open(os.path.expanduser("~/.github_token"), "r") as f: + token = f.read().strip() + if token: + sys.stderr.write("Using token from ~/.github_token\n") + except FileNotFoundError: + pass + except Exception as e: + sys.stderr.write(f"Warning: Could not read ~/.github_token: {e}\n") + + if not token: + sys.stderr.write(f"Error: GitHub token not provided. Set GITHUB_TOKEN, use --token, or place it in ~/.github_token.{error_suffix}\n") + sys.exit(1) + args.token = token # Ensure args.token is populated + + final_owner = None + final_repo = None + + if args.url: + owner_explicitly_set_via_arg = args.owner is not None and args.owner != determined_owner + repo_explicitly_set_via_arg = args.repo is not None and args.repo != determined_repo + if owner_explicitly_set_via_arg or repo_explicitly_set_via_arg: + sys.stderr.write(f"Error: Cannot use --owner or --repo when --url is specified.{error_suffix}\n") + sys.exit(1) + + parsed_owner, parsed_repo = parse_repo_url_arg(args.url) + if parsed_owner and parsed_repo: + final_owner = parsed_owner + final_repo = parsed_repo + sys.stderr.write(f"Using repository from --url: {final_owner}/{final_repo}\n") + else: + sys.stderr.write(f"Error: Invalid URL format: {args.url}. Expected https://github.com/owner/repo or git@github.com:owner/repo.git{error_suffix}\n") + sys.exit(1) + else: + is_owner_from_user_arg = args.owner is not None and args.owner != determined_owner + is_repo_from_user_arg = args.repo is not None and args.repo != determined_repo + + if is_owner_from_user_arg or is_repo_from_user_arg: # User explicitly set at least one of owner/repo via args + if args.owner and args.repo: + final_owner = args.owner + final_repo = args.repo + sys.stderr.write(f"Using repository from --owner/--repo args: {final_owner}/{final_repo}\n") + else: + sys.stderr.write(f"Error: Both --owner and --repo must be specified if one is provided explicitly (and --url is not used).{error_suffix}\n") + sys.exit(1) + elif args.owner and args.repo: # Both args have values, likely from successful auto-detection (or user provided matching defaults) + final_owner = args.owner + final_repo = args.repo + # No specific message needed if it's from auto-detection, already printed. + # If user explicitly provided args that match auto-detected, that's fine. + # If final_owner/repo are still None here, it means auto-detection failed AND user provided nothing for owner/repo. + # Or, only one of owner/repo was auto-detected and the other wasn't provided. + + if not final_owner or not final_repo: + missing_parts = [] + if not final_owner: missing_parts.append("--owner") + if not final_repo: missing_parts.append("--repo") + + error_msg = "Error: Could not determine repository." + if missing_parts: + error_msg += f" Missing { ' and '.join(missing_parts) }." + error_msg += f" Please specify --url, OR both --owner and --repo, OR ensure git remote 'origin' is configured correctly.{error_suffix}" + sys.stderr.write(error_msg + "\n") + sys.exit(1) + + if not set_repo_info(final_owner, final_repo): + # This path should ideally not be reached if final_owner/repo are validated, + # but as a safeguard: + sys.stderr.write(f"Error: Could not set repository info to {final_owner}/{final_repo}. Ensure owner/repo are correct.{error_suffix}\n") + sys.exit(1) + + sys.stderr.write(f"Processing workflow '{args.workflow_name}' on branch '{args.branch}' for repo {OWNER}/{REPO}\n") + + run = get_latest_workflow_run(args.token, args.workflow_name, args.branch) + if not run: + sys.stderr.write(f"No workflow run found for workflow '{args.workflow_name}' on branch '{args.branch}'.\n") + sys.exit(0) + + sys.stderr.write(f"Found workflow run ID: {run['id']} (Status: {run.get('status')}, Conclusion: {run.get('conclusion')})\n") + + failed_jobs = get_failed_jobs_for_run(args.token, run['id']) + + if not failed_jobs: + sys.stderr.write(f"No failed jobs found for workflow run ID: {run['id']}.\n") + if run.get('conclusion') == 'success': + print(f"Workflow run {run['id']} completed successfully with no failed jobs.") + elif run.get('status') == 'in_progress' and run.get('conclusion') is None: + print(f"Workflow run {run['id']} is still in progress. No failed jobs reported yet.") + else: + # This case might indicate the workflow failed but not at a job level, + # or jobs are still pending/running. + print(f"Workflow run {run['id']} has conclusion '{run.get('conclusion')}' but no specific failed jobs were identified by this script's criteria.") + sys.exit(0) + + print(f"\n--- Failed Jobs for Workflow Run ID: {run['id']} ({run.get('html_url', 'No URL')}) ---\n") + + for job in failed_jobs: + print(f"==================================================================================") + print(f"Job: {job['name']} (ID: {job['id']}) - FAILED") + print(f"Job URL: {job.get('html_url', 'N/A')}") + print(f"==================================================================================") + + job_logs = get_job_logs(args.token, job['id']) + if not job_logs: + print("Could not retrieve logs for this job.") + continue + + failed_steps_details = [] + if job.get('steps'): + for step in job['steps']: + if step.get('conclusion') == 'failure': + failed_steps_details.append(step) + + if not failed_steps_details: + print("\nNo specific failed steps found in job data, but job marked as failed. Printing last lines of full job log as fallback:\n") + log_lines = job_logs.splitlines() + for line in log_lines[-args.log_lines:]: + print(line) + print("\n--- End of log snippet for job ---") + continue + + print(f"\n--- Failed Steps in Job: {job['name']} ---") + for step in failed_steps_details: + step_name = step.get('name', 'Unnamed step') + print(f"\n--- Step: {step_name} ---") + # Attempt to extract specific step log + # GitHub log format: ##[group]Step Name ... ##[endgroup] + # A simpler approach for now is to print the relevant section of the full job log + # if we can identify it. If not, we might fall back to the full log or last N lines. + # For now, we'll just print the last N lines of the *entire job log* for *each* failed step found by API, + # as parsing the full log to attribute lines to specific steps is complex. + # A more advanced implementation would parse the log structure. + + # Simplistic approach: Print last N lines of the whole job log for context for this step. + # This is not ideal as it doesn't isolate the step's specific log lines. + # A better method would be to parse the job_logs string. + + # Placeholder for more precise log extraction for the specific step + # For now, we'll find the step in the log and print lines around it or from it. + + # Crude log extraction: + step_log_lines = [] + in_step_group = False + # Regex to match group start, attempting to capture the step name robustly + # Handles cases like "Run echo "hello"" where step['name'] is `Run echo "hello"` + # and in logs it might be `##[group]Run echo "hello"` + # We need to be careful with regex special characters in step_name + escaped_step_name = re.escape(step_name) + # Try to match common step prefixes if the exact name isn't found + # This is still very heuristic. + step_start_pattern = re.compile(r"^##\[group\](?:Run\s+|Setup\s+|Complete\s+)?.*?" + escaped_step_name, re.IGNORECASE) + step_end_pattern = re.compile(r"^##\[endgroup\]") + + current_step_log_segment = [] + capturing_for_failed_step = False + + log_lines = job_logs.splitlines() + + # Try to find the specific step's log segment + for line in log_lines: + if step_start_pattern.search(line): + capturing_for_failed_step = True + current_step_log_segment = [line] # Start with the group line + continue + if capturing_for_failed_step: + current_step_log_segment.append(line) + if step_end_pattern.search(line): + capturing_for_failed_step = False + # Found the end of the targeted step's log + break # Stop processing lines for this step + + if current_step_log_segment: + print(f"Log for failed step '{step_name}' (last {args.log_lines} lines of its segment):") + for log_line in current_step_log_segment[-args.log_lines:]: + print(log_line) + else: + # Fallback if specific step log segment couldn't be reliably identified + print(f"Could not isolate log for step '{step_name}'. Printing last {args.log_lines} lines of the entire job log as context:") + for log_line in log_lines[-args.log_lines:]: + print(log_line) + print(f"--- End of log for step: {step_name} ---") + + print(f"\n--- End of Failed Steps for Job: {job['name']} ---\n") + + +def get_latest_workflow_run(token, workflow_name, branch_name): + """Fetches the most recent workflow run for a given workflow name and branch.""" + url = f'{GITHUB_API_URL}/actions/workflows/{workflow_name}/runs' + headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'} + params = {'branch': branch_name, 'per_page': 1, 'page': 1} # Get the most recent 1 + + try: + with requests_retry_session().get(url, headers=headers, params=params, timeout=TIMEOUT) as response: + response.raise_for_status() + data = response.json() + if data['workflow_runs'] and len(data['workflow_runs']) > 0: + return data['workflow_runs'][0] # The first one is the most recent + else: + return None + except requests.exceptions.RequestException as e: + sys.stderr.write(f"Error: Failed to fetch workflow runs for '{workflow_name}' on branch '{branch_name}': {e}\n") + if e.response is not None: + sys.stderr.write(f"Response content: {e.response.text}\n") + return None + except json.JSONDecodeError as e: + sys.stderr.write(f"Error: Failed to parse JSON response for workflow runs: {e}\n") + return None + + +def get_failed_jobs_for_run(token, run_id): + """Fetches all jobs for a given workflow run and filters for failed ones.""" + url = f'{GITHUB_API_URL}/actions/runs/{run_id}/jobs' + headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'} + + page = 1 + per_page = 100 # GitHub API default and max is 100 for many paginated endpoints + all_jobs = [] + + while True: + params = {'per_page': per_page, 'page': page, 'filter': 'latest'} # 'latest' attempt for each job + try: + with requests_retry_session().get(url, headers=headers, params=params, timeout=TIMEOUT) as response: + response.raise_for_status() + data = response.json() + current_page_jobs = data.get('jobs', []) + if not current_page_jobs: + break + all_jobs.extend(current_page_jobs) + if len(current_page_jobs) < per_page: + break # Reached last page + page += 1 + except requests.exceptions.RequestException as e: + sys.stderr.write(f"Error: Failed to fetch jobs for run ID {run_id} (page {page}): {e}\n") + if e.response is not None: + sys.stderr.write(f"Response content: {e.response.text}\n") + return None # Return None if any page fails + except json.JSONDecodeError as e: + sys.stderr.write(f"Error: Failed to parse JSON response for jobs: {e}\n") + return None + + failed_jobs = [job for job in all_jobs if job.get('conclusion') == 'failure'] + return failed_jobs + + +def get_job_logs(token, job_id): + """Downloads the logs for a specific job.""" + url = f'{GITHUB_API_URL}/actions/jobs/{job_id}/logs' + headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'} + + try: + # Logs can be large, use a longer timeout and stream if necessary, + # but for typical use, direct content might be fine. + # The GitHub API for logs redirects to a download URL. `requests` handles this. + with requests_retry_session().get(url, headers=headers, timeout=LONG_TIMEOUT, stream=False) as response: + response.raise_for_status() + # The response for logs is plain text, not JSON + return response.text + except requests.exceptions.RequestException as e: + sys.stderr.write(f"Error: Failed to download logs for job ID {job_id}: {e}\n") + if e.response is not None: + # Log URLs might expire or have other issues, content might be HTML error page + sys.stderr.write(f"Response status: {e.response.status_code}\n") + # Avoid printing potentially huge HTML error pages to stderr directly + # sys.stderr.write(f"Response content: {e.response.text[:500]}...\n") # Print a snippet + return None + + +if __name__ == "__main__": + main() From bcdf292ce51bea95fc3acf9d136967b55af9ce75 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:05:42 +0000 Subject: [PATCH 02/12] Enhance workflow error script with options and defaults This commit updates the `print_workflow_run_errors.py` script: - Workflow name and branch are now optional arguments: - `--workflow` (or `--workflow-name`) defaults to "integration_test.yml". - `--branch` defaults to the current Git branch. - Changed default log lines printed from 500 to 100 (`--log-lines`). - Added `--all-failed-steps` flag: - If false (default), only logs for the first failed step in a job are printed. - If true, logs for all failed steps in a job are printed. These changes provide more flexibility and sensible defaults for common use cases. --- scripts/print_workflow_run_errors.py | 81 ++++++++++++++++------------ 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 221fc8c39c..6c902c38cc 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -65,6 +65,19 @@ def requests_retry_session(retries=RETRIES, return session +def get_current_branch_name(): + """Gets the current git branch name.""" + try: + branch_bytes = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], stderr=subprocess.PIPE) + return branch_bytes.decode().strip() + except (subprocess.CalledProcessError, FileNotFoundError, UnicodeDecodeError) as e: + sys.stderr.write(f"Info: Could not determine current git branch via 'git rev-parse --abbrev-ref HEAD': {e}. Branch will need to be specified.\n") + return None + except Exception as e: # Catch any other unexpected error. + sys.stderr.write(f"Info: An unexpected error occurred while determining current git branch: {e}. Branch will need to be specified.\n") + return None + + def main(): """Main function to parse arguments and orchestrate the script.""" determined_owner = None @@ -89,19 +102,23 @@ def parse_repo_url_arg(url_string): return url_match.group(1), url_match.group(2) return None, None + current_branch = get_current_branch_name() + parser = argparse.ArgumentParser( description="Fetch and display failed steps and their logs from a GitHub workflow run.", formatter_class=argparse.RawTextHelpFormatter ) parser.add_argument( - "workflow_name", + "--workflow", "--workflow-name", type=str, - help="Name of the workflow file (e.g., 'main.yml' or 'build-test.yml')." + default="integration_test.yml", + help="Name of the workflow file (e.g., 'main.yml' or 'build-test.yml'). Default: 'integration_test.yml'." ) parser.add_argument( - "branch", + "--branch", type=str, - help="GitHub branch name to check for the workflow run." + default=current_branch, + help=f"GitHub branch name to check for the workflow run. {'Default: ' + current_branch if current_branch else 'Required if not determinable from current git branch.'}" ) parser.add_argument( "--url", @@ -130,8 +147,14 @@ def parse_repo_url_arg(url_string): parser.add_argument( "--log-lines", type=int, - default=500, - help="Number of lines to print from the end of each failed step's log. Default: 500." + default=100, + help="Number of lines to print from the end of each failed step's log. Default: 100." + ) + parser.add_argument( + "--all-failed-steps", + action="store_true", + default=False, + help="If set, print logs for all failed steps in a job. Default is to print logs only for the first failed step." ) args = parser.parse_args() @@ -210,11 +233,15 @@ def parse_repo_url_arg(url_string): sys.stderr.write(f"Error: Could not set repository info to {final_owner}/{final_repo}. Ensure owner/repo are correct.{error_suffix}\n") sys.exit(1) - sys.stderr.write(f"Processing workflow '{args.workflow_name}' on branch '{args.branch}' for repo {OWNER}/{REPO}\n") + if not args.branch: + sys.stderr.write(f"Error: Branch name is required. Please specify --branch or ensure it can be detected from your current git repository.{error_suffix}\n") + sys.exit(1) + + sys.stderr.write(f"Processing workflow '{args.workflow}' on branch '{args.branch}' for repo {OWNER}/{REPO}\n") - run = get_latest_workflow_run(args.token, args.workflow_name, args.branch) + run = get_latest_workflow_run(args.token, args.workflow, args.branch) if not run: - sys.stderr.write(f"No workflow run found for workflow '{args.workflow_name}' on branch '{args.branch}'.\n") + sys.stderr.write(f"No workflow run found for workflow '{args.workflow}' on branch '{args.branch}'.\n") sys.exit(0) sys.stderr.write(f"Found workflow run ID: {run['id']} (Status: {run.get('status')}, Conclusion: {run.get('conclusion')})\n") @@ -261,44 +288,27 @@ def parse_repo_url_arg(url_string): continue print(f"\n--- Failed Steps in Job: {job['name']} ---") + first_failed_step_logged = False for step in failed_steps_details: + if not args.all_failed_steps and first_failed_step_logged: + print(f"\n--- Skipping subsequent failed step: {step.get('name', 'Unnamed step')} (use --all-failed-steps to see all) ---") + break # Stop after the first failed step if not --all-failed-steps + step_name = step.get('name', 'Unnamed step') print(f"\n--- Step: {step_name} ---") - # Attempt to extract specific step log - # GitHub log format: ##[group]Step Name ... ##[endgroup] - # A simpler approach for now is to print the relevant section of the full job log - # if we can identify it. If not, we might fall back to the full log or last N lines. - # For now, we'll just print the last N lines of the *entire job log* for *each* failed step found by API, - # as parsing the full log to attribute lines to specific steps is complex. - # A more advanced implementation would parse the log structure. - - # Simplistic approach: Print last N lines of the whole job log for context for this step. - # This is not ideal as it doesn't isolate the step's specific log lines. - # A better method would be to parse the job_logs string. - - # Placeholder for more precise log extraction for the specific step - # For now, we'll find the step in the log and print lines around it or from it. # Crude log extraction: - step_log_lines = [] - in_step_group = False # Regex to match group start, attempting to capture the step name robustly - # Handles cases like "Run echo "hello"" where step['name'] is `Run echo "hello"` - # and in logs it might be `##[group]Run echo "hello"` - # We need to be careful with regex special characters in step_name escaped_step_name = re.escape(step_name) - # Try to match common step prefixes if the exact name isn't found - # This is still very heuristic. step_start_pattern = re.compile(r"^##\[group\](?:Run\s+|Setup\s+|Complete\s+)?.*?" + escaped_step_name, re.IGNORECASE) step_end_pattern = re.compile(r"^##\[endgroup\]") current_step_log_segment = [] capturing_for_failed_step = False - - log_lines = job_logs.splitlines() + log_lines_for_job = job_logs.splitlines() # Split once per job # Try to find the specific step's log segment - for line in log_lines: + for line in log_lines_for_job: if step_start_pattern.search(line): capturing_for_failed_step = True current_step_log_segment = [line] # Start with the group line @@ -308,7 +318,7 @@ def parse_repo_url_arg(url_string): if step_end_pattern.search(line): capturing_for_failed_step = False # Found the end of the targeted step's log - break # Stop processing lines for this step + break # Stop processing lines for this step (within this job's logs) if current_step_log_segment: print(f"Log for failed step '{step_name}' (last {args.log_lines} lines of its segment):") @@ -317,9 +327,10 @@ def parse_repo_url_arg(url_string): else: # Fallback if specific step log segment couldn't be reliably identified print(f"Could not isolate log for step '{step_name}'. Printing last {args.log_lines} lines of the entire job log as context:") - for log_line in log_lines[-args.log_lines:]: + for log_line in log_lines_for_job[-args.log_lines:]: # Use the job's split lines print(log_line) print(f"--- End of log for step: {step_name} ---") + first_failed_step_logged = True # Mark that we've logged at least one step print(f"\n--- End of Failed Steps for Job: {job['name']} ---\n") From 6ba2558079e809f753943a80b70eed52f57c3ac1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:09:43 +0000 Subject: [PATCH 03/12] Add grep functionality to workflow error script This commit introduces a grep-like feature to the `print_workflow_run_errors.py` script. New features: - Added `--grep-pattern` (`-g`) argument to specify an Extended Regular Expression (ERE) for searching within fetched logs. - Added `--grep-context` (`-C`) argument to specify the number of lines of context to show around matches (default is 5). Behavior: - If a grep pattern is provided, the script will use the system `grep` command to filter the logs of failed steps (or the full job log if a specific step's log cannot be isolated). - Output clearly indicates when grep results are shown, the pattern used, and the context lines. - Handles cases where `grep` finds no matches or if the `grep` command itself fails (e.g., not found, bad pattern). - If no grep pattern is provided, the script defaults to its previous behavior of printing the last N lines of the log. --- scripts/print_workflow_run_errors.py | 60 ++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 6c902c38cc..6e4a80116c 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -156,6 +156,18 @@ def parse_repo_url_arg(url_string): default=False, help="If set, print logs for all failed steps in a job. Default is to print logs only for the first failed step." ) + parser.add_argument( + "--grep-pattern", "-g", + type=str, + default=None, + help="Extended Regular Expression (ERE) to search for in logs. If provided, log output will be filtered by grep." + ) + parser.add_argument( + "--grep-context", "-C", + type=int, + default=5, + help="Number of lines of leading and trailing context to print for grep matches. Default: 5." + ) args = parser.parse_args() error_suffix = " (use --help for more details)" @@ -320,15 +332,49 @@ def parse_repo_url_arg(url_string): # Found the end of the targeted step's log break # Stop processing lines for this step (within this job's logs) + log_to_process = "" + log_source_message = "" + if current_step_log_segment: - print(f"Log for failed step '{step_name}' (last {args.log_lines} lines of its segment):") - for log_line in current_step_log_segment[-args.log_lines:]: - print(log_line) + log_to_process = "\n".join(current_step_log_segment) + log_source_message = f"Log for failed step '{step_name}'" else: - # Fallback if specific step log segment couldn't be reliably identified - print(f"Could not isolate log for step '{step_name}'. Printing last {args.log_lines} lines of the entire job log as context:") - for log_line in log_lines_for_job[-args.log_lines:]: # Use the job's split lines - print(log_line) + log_to_process = "\n".join(log_lines_for_job) # Use the full job log as fallback + log_source_message = f"Could not isolate log for step '{step_name}'. Using entire job log" + + if args.grep_pattern: + print(f"{log_source_message} (grep results for pattern '{args.grep_pattern}' with context {args.grep_context}):") + try: + # Using subprocess to call grep + # Pass log_to_process as stdin to grep + process = subprocess.run( + ['grep', '-E', f"-C{args.grep_context}", args.grep_pattern], + input=log_to_process, + text=True, + capture_output=True, + check=False # Do not throw exception on non-zero exit (e.g. no match) + ) + if process.returncode == 0: # Match found + print(process.stdout.strip()) + elif process.returncode == 1: # No match found + print(f"No matches found for pattern '{args.grep_pattern}' in this log segment.") + else: # Grep error + sys.stderr.write(f"Grep command failed with error code {process.returncode}:\n{process.stderr}\n") + except FileNotFoundError: + sys.stderr.write("Error: 'grep' command not found. Please ensure it is installed and in your PATH to use --grep-pattern.\n") + # Fallback to printing last N lines if grep is not found? Or just skip log? For now, skip. + print("Skipping log display for this step as grep is unavailable.") + except Exception as e: + sys.stderr.write(f"An unexpected error occurred while running grep: {e}\n") + print("Skipping log display due to an error with grep.") + else: + # Default behavior: print last N lines + print(f"{log_source_message} (last {args.log_lines} lines):") + # current_step_log_segment is a list of lines, log_lines_for_job is also a list of lines + lines_to_print_from = current_step_log_segment if current_step_log_segment else log_lines_for_job + for log_line in lines_to_print_from[-args.log_lines:]: + print(log_line) + print(f"--- End of log for step: {step_name} ---") first_failed_step_logged = True # Mark that we've logged at least one step From 2f0aab6176dc68072d7e72f86bc8aadaf9e14dc1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:24:44 +0000 Subject: [PATCH 04/12] Update grep defaults in workflow error script This commit refines the grep functionality in the `print_workflow_run_errors.py` script by updating default values: - The default `--grep-pattern` is now `"[Ee]rror[: ]"`. Logs will be automatically filtered for this pattern if no other pattern is specified. - The default `--grep-context` is now 10 lines. If an empty string is explicitly passed to `--grep-pattern` (e.g., `--grep-pattern ""`), grep functionality will be disabled, and the script will fall back to printing the last N lines of the log. --- scripts/print_workflow_run_errors.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 6e4a80116c..9d4067193d 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -159,14 +159,14 @@ def parse_repo_url_arg(url_string): parser.add_argument( "--grep-pattern", "-g", type=str, - default=None, - help="Extended Regular Expression (ERE) to search for in logs. If provided, log output will be filtered by grep." + default="[Ee]rror[: ]", + help="Extended Regular Expression (ERE) to search for in logs. Default: \"[Ee]rror[: ]\". If an empty string is passed, grep is disabled." ) parser.add_argument( "--grep-context", "-C", type=int, - default=5, - help="Number of lines of leading and trailing context to print for grep matches. Default: 5." + default=10, + help="Number of lines of leading and trailing context to print for grep matches. Default: 10." ) args = parser.parse_args() From 7319defbf4eeb3cfa62e75bfa460f035243d031c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:33:50 +0000 Subject: [PATCH 05/12] Add job name pattern filtering to workflow error script This commit introduces a new feature to filter jobs by name using a regular expression in the `print_workflow_run_errors.py` script. New features: - Added `--job-pattern` argument, which accepts a regex string. Only jobs whose names match this pattern will be processed for failures. - The default value for `--job-pattern` is `'^build.*'`, meaning by default, the script will only look at jobs starting with 'build'. Behavior: - Job name filtering is applied before checking for job failures. - If an invalid regex is provided for `--job-pattern`, the script prints an error and exits gracefully. - This new filter works in conjunction with existing log processing options (like `--grep-pattern` and `--all-failed-steps`), which are applied to the jobs that pass the name pattern filter and are failed. --- scripts/print_workflow_run_errors.py | 59 +++++++++++++++++++++------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 9d4067193d..1252dc1b69 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -111,8 +111,8 @@ def parse_repo_url_arg(url_string): parser.add_argument( "--workflow", "--workflow-name", type=str, - default="integration_test.yml", - help="Name of the workflow file (e.g., 'main.yml' or 'build-test.yml'). Default: 'integration_test.yml'." + default="integration_tests.yml", + help="Name of the workflow file (e.g., 'main.yml' or 'build-test.yml'). Default: 'integration_tests.yml'." ) parser.add_argument( "--branch", @@ -168,6 +168,12 @@ def parse_repo_url_arg(url_string): default=10, help="Number of lines of leading and trailing context to print for grep matches. Default: 10." ) + parser.add_argument( + "--job-pattern", + type=str, + default='^build.*', + help="Regular expression to filter job names. Only jobs matching this pattern will be processed. Default: '^build.*'" + ) args = parser.parse_args() error_suffix = " (use --help for more details)" @@ -258,24 +264,47 @@ def parse_repo_url_arg(url_string): sys.stderr.write(f"Found workflow run ID: {run['id']} (Status: {run.get('status')}, Conclusion: {run.get('conclusion')})\n") - failed_jobs = get_failed_jobs_for_run(args.token, run['id']) + try: + job_name_pattern = re.compile(args.job_pattern) + except re.error as e: + sys.stderr.write(f"Error: Invalid regex for --job-pattern '{args.job_pattern}': {e}\n") + sys.exit(1) + + # 1. Fetch all jobs for the run + all_jobs_api_response = get_all_jobs_for_run(args.token, run['id']) + + if all_jobs_api_response is None: # Indicates an API error during fetch + sys.stderr.write(f"Could not retrieve jobs for workflow run ID: {run['id']}. Exiting.\n") + sys.exit(1) + + # 2. Filter jobs by name pattern + name_matching_jobs = [job for job in all_jobs_api_response if job_name_pattern.search(job['name'])] + + if not name_matching_jobs: + sys.stderr.write(f"No jobs found matching pattern '{args.job_pattern}' in workflow run ID: {run['id']}.\n") + sys.exit(0) + + sys.stderr.write(f"Found {len(name_matching_jobs)} job(s) matching pattern '{args.job_pattern}'. Checking for failures...\n") + + # 3. From the name-matching jobs, find the ones that actually failed + failed_jobs_matching_criteria = [job for job in name_matching_jobs if job.get('conclusion') == 'failure'] - if not failed_jobs: - sys.stderr.write(f"No failed jobs found for workflow run ID: {run['id']}.\n") + if not failed_jobs_matching_criteria: + sys.stderr.write(f"No failed jobs found among those matching pattern '{args.job_pattern}' for workflow run ID: {run['id']}.\n") if run.get('conclusion') == 'success': - print(f"Workflow run {run['id']} completed successfully with no failed jobs.") + print(f"Workflow run {run['id']} ({run.get('html_url', 'N/A')}) completed successfully. No jobs matching pattern '{args.job_pattern}' failed.") elif run.get('status') == 'in_progress' and run.get('conclusion') is None: - print(f"Workflow run {run['id']} is still in progress. No failed jobs reported yet.") + print(f"Workflow run {run['id']} ({run.get('html_url', 'N/A')}) is still in progress. No jobs matching pattern '{args.job_pattern}' have failed yet.") else: - # This case might indicate the workflow failed but not at a job level, - # or jobs are still pending/running. - print(f"Workflow run {run['id']} has conclusion '{run.get('conclusion')}' but no specific failed jobs were identified by this script's criteria.") + print(f"Workflow run {run['id']} ({run.get('html_url', 'N/A')}) has conclusion '{run.get('conclusion')}', but no jobs matching pattern ('{args.job_pattern}') were found to have failed.") sys.exit(0) - print(f"\n--- Failed Jobs for Workflow Run ID: {run['id']} ({run.get('html_url', 'No URL')}) ---\n") + print(f"\n--- Failed Jobs (matching pattern '{args.job_pattern}') for Workflow Run ID: {run['id']} ({run.get('html_url', 'No URL')}) ---\n") - for job in failed_jobs: + for job in failed_jobs_matching_criteria: print(f"==================================================================================") + # Keep the job pattern in the individual job heading for clarity if needed, or remove if too verbose. + # For now, let's assume it's clear from the main heading. print(f"Job: {job['name']} (ID: {job['id']}) - FAILED") print(f"Job URL: {job.get('html_url', 'N/A')}") print(f"==================================================================================") @@ -405,8 +434,8 @@ def get_latest_workflow_run(token, workflow_name, branch_name): return None -def get_failed_jobs_for_run(token, run_id): - """Fetches all jobs for a given workflow run and filters for failed ones.""" +def get_all_jobs_for_run(token, run_id): + """Fetches all jobs for a given workflow run.""" url = f'{GITHUB_API_URL}/actions/runs/{run_id}/jobs' headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'} @@ -437,7 +466,7 @@ def get_failed_jobs_for_run(token, run_id): return None failed_jobs = [job for job in all_jobs if job.get('conclusion') == 'failure'] - return failed_jobs + return all_jobs # Return all jobs, filtering happens in main def get_job_logs(token, job_id): From c20edf6ac1ec54a0aa45f336c2298986b018d514 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:40:56 +0000 Subject: [PATCH 06/12] Refine default grep pattern in workflow error script This commit updates the default regular expression for the `--grep-pattern` argument in the `print_workflow_run_errors.py` script. The default pattern is changed from `"[Ee]rror[: ]"` to `"[Ee][Rr][Rr][Oo][Rr][: ]"` for more specific matching of "Error" (case-insensitive) followed by a colon or space. --- scripts/print_workflow_run_errors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 1252dc1b69..1b4fc30bf7 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -159,8 +159,8 @@ def parse_repo_url_arg(url_string): parser.add_argument( "--grep-pattern", "-g", type=str, - default="[Ee]rror[: ]", - help="Extended Regular Expression (ERE) to search for in logs. Default: \"[Ee]rror[: ]\". If an empty string is passed, grep is disabled." + default="[Ee][Rr][Rr][Oo][Rr][: ]", + help="Extended Regular Expression (ERE) to search for in logs. Default: \"[Ee][Rr][Rr][Oo][Rr][: ]\". If an empty string is passed, grep is disabled." ) parser.add_argument( "--grep-context", "-C", From bcf6245062f21767ace814f527b6568d0f6ac84b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:03:49 +0000 Subject: [PATCH 07/12] Format workflow error script output as Markdown This commit updates the `print_workflow_run_errors.py` script to format its standard output using Markdown for improved readability when viewed in Markdown-aware environments. Changes include: - Job and step headings are now formatted as Markdown headers (H1-H4). - Workflow run and job URLs are presented as clickable Markdown links. - Actual log content (both grep results and last N lines) is enclosed in ```log ... ``` fenced code blocks. - Horizontal rules (`---`) are used to better separate sections. - Minor textual adjustments for clarity within the Markdown structure. The stderr output remains plain text for informational messages. --- scripts/print_workflow_run_errors.py | 70 +++++++++++++++++++--------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 1b4fc30bf7..99db97c287 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -148,7 +148,7 @@ def parse_repo_url_arg(url_string): "--log-lines", type=int, default=100, - help="Number of lines to print from the end of each failed step's log. Default: 100." + help="Number of lines to print from the end of each failed step's log (if not using grep). Default: 100." ) parser.add_argument( "--all-failed-steps", @@ -299,15 +299,17 @@ def parse_repo_url_arg(url_string): print(f"Workflow run {run['id']} ({run.get('html_url', 'N/A')}) has conclusion '{run.get('conclusion')}', but no jobs matching pattern ('{args.job_pattern}') were found to have failed.") sys.exit(0) - print(f"\n--- Failed Jobs (matching pattern '{args.job_pattern}') for Workflow Run ID: {run['id']} ({run.get('html_url', 'No URL')}) ---\n") + # Print summary of failed jobs to stderr + sys.stderr.write("\nSummary of failed jobs matching criteria:\n") + for job in failed_jobs_matching_criteria: + sys.stderr.write(f" - {job['name']} (ID: {job['id']})\n") + sys.stderr.write("\n") # Add a newline for separation before stdout details + + print(f"\n# Detailed Logs for Failed Jobs (matching pattern '{args.job_pattern}') for Workflow Run ID: {run['id']} ([Run Link]({run.get('html_url', 'No URL')}))\n") for job in failed_jobs_matching_criteria: - print(f"==================================================================================") - # Keep the job pattern in the individual job heading for clarity if needed, or remove if too verbose. - # For now, let's assume it's clear from the main heading. - print(f"Job: {job['name']} (ID: {job['id']}) - FAILED") - print(f"Job URL: {job.get('html_url', 'N/A')}") - print(f"==================================================================================") + print(f"\n## Job: {job['name']} (ID: {job['id']}) - FAILED") + print(f"[Job URL]({job.get('html_url', 'N/A')})\n") job_logs = get_job_logs(args.token, job['id']) if not job_logs: @@ -320,23 +322,41 @@ def parse_repo_url_arg(url_string): if step.get('conclusion') == 'failure': failed_steps_details.append(step) - if not failed_steps_details: - print("\nNo specific failed steps found in job data, but job marked as failed. Printing last lines of full job log as fallback:\n") - log_lines = job_logs.splitlines() - for line in log_lines[-args.log_lines:]: - print(line) - print("\n--- End of log snippet for job ---") + if not failed_steps_details: # No specific failed steps found in API, but job is failed + print("\n**Note: No specific failed steps were identified in the job's metadata, but the job itself is marked as failed.**") + log_lines_for_job_fallback = job_logs.splitlines() + if args.grep_pattern: + print(f"Displaying grep results for pattern '{args.grep_pattern}' with context {args.grep_context} from **entire job log**:") + print("\n```log") + try: + process = subprocess.run( + ['grep', '-E', f"-C{args.grep_context}", args.grep_pattern], + input="\n".join(log_lines_for_job_fallback), text=True, capture_output=True, check=False + ) + if process.returncode == 0: print(process.stdout.strip()) + elif process.returncode == 1: print(f"No matches found for pattern '{args.grep_pattern}' in entire job log.") + else: sys.stderr.write(f"Grep command failed on full job log: {process.stderr}\n") + except FileNotFoundError: sys.stderr.write("Error: 'grep' not found, cannot process full job log with grep.\n") + except Exception as e: sys.stderr.write(f"Grep error on full job log: {e}\n") + print("```") + else: + print(f"Displaying last {args.log_lines} lines from **entire job log** as fallback:") + print("\n```log") + for line in log_lines_for_job_fallback[-args.log_lines:]: + print(line) + print("```") + print("\n---") # Horizontal rule continue - print(f"\n--- Failed Steps in Job: {job['name']} ---") + print(f"\n### Failed Steps in Job: {job['name']}") first_failed_step_logged = False for step in failed_steps_details: if not args.all_failed_steps and first_failed_step_logged: - print(f"\n--- Skipping subsequent failed step: {step.get('name', 'Unnamed step')} (use --all-failed-steps to see all) ---") + print(f"\n--- Skipping subsequent failed step: {step.get('name', 'Unnamed step')} (use --all-failed-steps to see all) ---") # Keep this as plain text for now break # Stop after the first failed step if not --all-failed-steps step_name = step.get('name', 'Unnamed step') - print(f"\n--- Step: {step_name} ---") + print(f"\n#### Step: {step_name}") # Crude log extraction: # Regex to match group start, attempting to capture the step name robustly @@ -372,7 +392,8 @@ def parse_repo_url_arg(url_string): log_source_message = f"Could not isolate log for step '{step_name}'. Using entire job log" if args.grep_pattern: - print(f"{log_source_message} (grep results for pattern '{args.grep_pattern}' with context {args.grep_context}):") + print(f"\n{log_source_message} (grep results for pattern `{args.grep_pattern}` with context {args.grep_context}):\n") + print("```log") try: # Using subprocess to call grep # Pass log_to_process as stdin to grep @@ -388,26 +409,29 @@ def parse_repo_url_arg(url_string): elif process.returncode == 1: # No match found print(f"No matches found for pattern '{args.grep_pattern}' in this log segment.") else: # Grep error - sys.stderr.write(f"Grep command failed with error code {process.returncode}:\n{process.stderr}\n") + # Print error within the log block if possible, or as a note if it's too disruptive + print(f"Grep command failed with error code {process.returncode}. Stderr:\n{process.stderr}") except FileNotFoundError: sys.stderr.write("Error: 'grep' command not found. Please ensure it is installed and in your PATH to use --grep-pattern.\n") - # Fallback to printing last N lines if grep is not found? Or just skip log? For now, skip. print("Skipping log display for this step as grep is unavailable.") except Exception as e: sys.stderr.write(f"An unexpected error occurred while running grep: {e}\n") print("Skipping log display due to an error with grep.") + print("```") else: # Default behavior: print last N lines - print(f"{log_source_message} (last {args.log_lines} lines):") + print(f"\n{log_source_message} (last {args.log_lines} lines):\n") + print("```log") # current_step_log_segment is a list of lines, log_lines_for_job is also a list of lines lines_to_print_from = current_step_log_segment if current_step_log_segment else log_lines_for_job for log_line in lines_to_print_from[-args.log_lines:]: print(log_line) + print("```") - print(f"--- End of log for step: {step_name} ---") + print(f"\n---") # Horizontal rule after each step's log first_failed_step_logged = True # Mark that we've logged at least one step - print(f"\n--- End of Failed Steps for Job: {job['name']} ---\n") + print(f"\n---") # Horizontal rule after all steps for a job def get_latest_workflow_run(token, workflow_name, branch_name): From 4c2fb0838f882d16294727ef2c524f3d8c0c891e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:17:11 +0000 Subject: [PATCH 08/12] Enhance script with log download status and timestamp stripping This commit adds two main enhancements to the `print_workflow_run_errors.py` script: 1. **Log Download Status (stderr)**: - Prints messages to stderr indicating the progress of log downloads (e.g., "INFO: Downloading log X/Y for job 'job_name'..."). - Prints a summary to stderr after all jobs are processed, showing how many logs were successfully fetched and processed (e.g., "INFO: Processed logs for S/T targeted failed jobs."). - Includes a warning on stderr if a specific job's log fails to download. 2. **Timestamp Stripping (stdout)**: - Implemented a function `strip_initial_timestamp` that uses a regex to remove ISO 8601-like timestamps from the beginning of log lines. - This stripping is applied to all log lines (from specific step segments or full job log fallbacks) before they are further processed by `grep` or printed as the 'last N lines'. This makes the logs cleaner and potentially easier for other tools or users to parse. --- scripts/print_workflow_run_errors.py | 85 +++++++++++++++++----------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 99db97c287..fc3936dd8d 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -64,6 +64,13 @@ def requests_retry_session(retries=RETRIES, session.mount('https://', adapter) return session +# Regex to match ISO 8601 timestamps like "2023-10-27T18:30:59.1234567Z " or "2023-10-27T18:30:59Z " +TIMESTAMP_REGEX = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z\s*") + +def strip_initial_timestamp(line: str) -> str: + """Removes an ISO 8601-like timestamp from the beginning of a line if present.""" + return TIMESTAMP_REGEX.sub("", line) + def get_current_branch_name(): """Gets the current git branch name.""" @@ -307,14 +314,24 @@ def parse_repo_url_arg(url_string): print(f"\n# Detailed Logs for Failed Jobs (matching pattern '{args.job_pattern}') for Workflow Run ID: {run['id']} ([Run Link]({run.get('html_url', 'No URL')}))\n") - for job in failed_jobs_matching_criteria: + total_failed_jobs_to_process = len(failed_jobs_matching_criteria) + successful_log_fetches = 0 + + for idx, job in enumerate(failed_jobs_matching_criteria): + sys.stderr.write(f"INFO: Downloading log {idx+1}/{total_failed_jobs_to_process} for job '{job['name']}' (ID: {job['id']})...\n") + job_logs = get_job_logs(args.token, job['id']) + print(f"\n## Job: {job['name']} (ID: {job['id']}) - FAILED") print(f"[Job URL]({job.get('html_url', 'N/A')})\n") - job_logs = get_job_logs(args.token, job['id']) if not job_logs: - print("Could not retrieve logs for this job.") - continue + print("**Could not retrieve logs for this job.**") + # Also print to stderr if it's a critical failure to fetch + sys.stderr.write(f"WARNING: Failed to retrieve logs for job '{job['name']}' (ID: {job['id']}).\n") + continue # Skip to the next job + + successful_log_fetches += 1 + # If logs were fetched, proceed to process them (already existing logic) failed_steps_details = [] if job.get('steps'): @@ -324,14 +341,17 @@ def parse_repo_url_arg(url_string): if not failed_steps_details: # No specific failed steps found in API, but job is failed print("\n**Note: No specific failed steps were identified in the job's metadata, but the job itself is marked as failed.**") - log_lines_for_job_fallback = job_logs.splitlines() + + # Apply timestamp stripping to the full job log + stripped_log_lines_fallback = [strip_initial_timestamp(line) for line in job_logs.splitlines()] + if args.grep_pattern: print(f"Displaying grep results for pattern '{args.grep_pattern}' with context {args.grep_context} from **entire job log**:") print("\n```log") try: process = subprocess.run( ['grep', '-E', f"-C{args.grep_context}", args.grep_pattern], - input="\n".join(log_lines_for_job_fallback), text=True, capture_output=True, check=False + input="\n".join(stripped_log_lines_fallback), text=True, capture_output=True, check=False ) if process.returncode == 0: print(process.stdout.strip()) elif process.returncode == 1: print(f"No matches found for pattern '{args.grep_pattern}' in entire job log.") @@ -342,7 +362,7 @@ def parse_repo_url_arg(url_string): else: print(f"Displaying last {args.log_lines} lines from **entire job log** as fallback:") print("\n```log") - for line in log_lines_for_job_fallback[-args.log_lines:]: + for line in stripped_log_lines_fallback[-args.log_lines:]: # Use stripped lines print(line) print("```") print("\n---") # Horizontal rule @@ -364,52 +384,52 @@ def parse_repo_url_arg(url_string): step_start_pattern = re.compile(r"^##\[group\](?:Run\s+|Setup\s+|Complete\s+)?.*?" + escaped_step_name, re.IGNORECASE) step_end_pattern = re.compile(r"^##\[endgroup\]") - current_step_log_segment = [] - capturing_for_failed_step = False - log_lines_for_job = job_logs.splitlines() # Split once per job + # Get raw lines for the entire job first + raw_log_lines_for_job = job_logs.splitlines() - # Try to find the specific step's log segment - for line in log_lines_for_job: + current_step_raw_log_segment_lines = [] # Stores raw lines of the isolated step + capturing_for_failed_step = False + for line in raw_log_lines_for_job: # Iterate raw lines to find segment if step_start_pattern.search(line): capturing_for_failed_step = True - current_step_log_segment = [line] # Start with the group line + current_step_raw_log_segment_lines = [line] continue if capturing_for_failed_step: - current_step_log_segment.append(line) + current_step_raw_log_segment_lines.append(line) if step_end_pattern.search(line): capturing_for_failed_step = False - # Found the end of the targeted step's log - break # Stop processing lines for this step (within this job's logs) + break - log_to_process = "" + # Determine which set of lines to process (isolated step or full job) and strip timestamps + lines_to_process_stripped = [] log_source_message = "" - if current_step_log_segment: - log_to_process = "\n".join(current_step_log_segment) + if current_step_raw_log_segment_lines: + lines_to_process_stripped = [strip_initial_timestamp(line) for line in current_step_raw_log_segment_lines] log_source_message = f"Log for failed step '{step_name}'" else: - log_to_process = "\n".join(log_lines_for_job) # Use the full job log as fallback + # Fallback to full job log if specific step segment couldn't be isolated + lines_to_process_stripped = [strip_initial_timestamp(line) for line in raw_log_lines_for_job] log_source_message = f"Could not isolate log for step '{step_name}'. Using entire job log" + log_content_for_processing = "\n".join(lines_to_process_stripped) + if args.grep_pattern: print(f"\n{log_source_message} (grep results for pattern `{args.grep_pattern}` with context {args.grep_context}):\n") print("```log") try: - # Using subprocess to call grep - # Pass log_to_process as stdin to grep process = subprocess.run( ['grep', '-E', f"-C{args.grep_context}", args.grep_pattern], - input=log_to_process, + input=log_content_for_processing, # Use stripped content text=True, capture_output=True, - check=False # Do not throw exception on non-zero exit (e.g. no match) + check=False ) - if process.returncode == 0: # Match found + if process.returncode == 0: print(process.stdout.strip()) - elif process.returncode == 1: # No match found + elif process.returncode == 1: print(f"No matches found for pattern '{args.grep_pattern}' in this log segment.") - else: # Grep error - # Print error within the log block if possible, or as a note if it's too disruptive + else: print(f"Grep command failed with error code {process.returncode}. Stderr:\n{process.stderr}") except FileNotFoundError: sys.stderr.write("Error: 'grep' command not found. Please ensure it is installed and in your PATH to use --grep-pattern.\n") @@ -419,12 +439,10 @@ def parse_repo_url_arg(url_string): print("Skipping log display due to an error with grep.") print("```") else: - # Default behavior: print last N lines print(f"\n{log_source_message} (last {args.log_lines} lines):\n") print("```log") - # current_step_log_segment is a list of lines, log_lines_for_job is also a list of lines - lines_to_print_from = current_step_log_segment if current_step_log_segment else log_lines_for_job - for log_line in lines_to_print_from[-args.log_lines:]: + # Print from the already stripped lines (lines_to_process_stripped) + for log_line in lines_to_process_stripped[-args.log_lines:]: print(log_line) print("```") @@ -433,6 +451,9 @@ def parse_repo_url_arg(url_string): print(f"\n---") # Horizontal rule after all steps for a job + # Print final summary of log fetching to stderr + sys.stderr.write(f"\nINFO: Processed logs for {successful_log_fetches}/{total_failed_jobs_to_process} targeted failed jobs.\n") + def get_latest_workflow_run(token, workflow_name, branch_name): """Fetches the most recent workflow run for a given workflow name and branch.""" From 0ffb5a619b77519fd913e68761b2681063561027 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:41:47 +0000 Subject: [PATCH 09/12] Implement prioritized job pattern checking This commit introduces a prioritized, cascading job pattern checking feature to the `print_workflow_run_errors.py` script. Key changes: - The `--job-pattern` argument now uses `action='append'`, allowing users to specify multiple patterns. These are checked in the order provided. - If no `--job-pattern` is specified by the user, a default prioritized sequence is used: `['^build.*', '^test.*', '.*']`. - The script iterates through the determined list of patterns: - For the first pattern that matches jobs AND has failures among those matched jobs, the script processes and displays logs for those failures. - It then stops checking subsequent patterns. - If a pattern results in no matching jobs, or if all matching jobs succeeded, the script moves to the next pattern in the sequence. - Informative messages are printed to stderr indicating which pattern is being checked, the outcome for that pattern, and if subsequent patterns are skipped. - The main log processing loop has been refactored into a helper function `_process_and_display_logs_for_failed_jobs` for better organization. - Handles invalid regular expressions within the pattern list by warning the user and skipping the invalid pattern. --- scripts/print_workflow_run_errors.py | 401 +++++++++++++-------------- 1 file changed, 195 insertions(+), 206 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index fc3936dd8d..033b4da352 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -39,6 +39,15 @@ BASE_URL = 'https://api.github.com' GITHUB_API_URL = '' +DEFAULT_JOB_PATTERNS = ['^build.*', '^test.*', '.*'] + +# Regex to match ISO 8601 timestamps like "2023-10-27T18:30:59.1234567Z " or "2023-10-27T18:30:59Z " +TIMESTAMP_REGEX = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z\s*") + +def strip_initial_timestamp(line: str) -> str: + """Removes an ISO 8601-like timestamp from the beginning of a line if present.""" + return TIMESTAMP_REGEX.sub("", line) + def set_repo_info(owner_name, repo_name): """Sets the global repository owner, name, and API URL.""" @@ -64,13 +73,6 @@ def requests_retry_session(retries=RETRIES, session.mount('https://', adapter) return session -# Regex to match ISO 8601 timestamps like "2023-10-27T18:30:59.1234567Z " or "2023-10-27T18:30:59Z " -TIMESTAMP_REGEX = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z\s*") - -def strip_initial_timestamp(line: str) -> str: - """Removes an ISO 8601-like timestamp from the beginning of a line if present.""" - return TIMESTAMP_REGEX.sub("", line) - def get_current_branch_name(): """Gets the current git branch name.""" @@ -84,6 +86,142 @@ def get_current_branch_name(): sys.stderr.write(f"Info: An unexpected error occurred while determining current git branch: {e}. Branch will need to be specified.\n") return None +def _process_and_display_logs_for_failed_jobs(args, list_of_failed_jobs, workflow_run_html_url, current_pattern_str): + """ + Helper function to process a list of already identified failed jobs for a specific pattern. + It handles fetching logs, stripping timestamps, grepping, and printing Markdown output. + """ + print(f"\n# Detailed Logs for Failed Jobs (matching pattern '{current_pattern_str}') for Workflow Run ([Run Link]({workflow_run_html_url}))\n") + + total_failed_jobs_to_process = len(list_of_failed_jobs) + successful_log_fetches = 0 + + # Print summary of these specific failed jobs to stderr + sys.stderr.write(f"INFO: Summary of failed jobs for pattern '{current_pattern_str}':\n") + for job in list_of_failed_jobs: + sys.stderr.write(f" - {job['name']} (ID: {job['id']})\n") + sys.stderr.write("\n") + + for idx, job in enumerate(list_of_failed_jobs): + sys.stderr.write(f"INFO: Downloading log {idx+1}/{total_failed_jobs_to_process} for job '{job['name']}' (ID: {job['id']})...\n") + job_logs_raw = get_job_logs(args.token, job['id']) # Renamed to avoid conflict with global + + print(f"\n## Job: {job['name']} (ID: {job['id']}) - FAILED") + print(f"[Job URL]({job.get('html_url', 'N/A')})\n") + + if not job_logs_raw: + print("**Could not retrieve logs for this job.**") + sys.stderr.write(f"WARNING: Failed to retrieve logs for job '{job['name']}' (ID: {job['id']}).\n") + continue + + successful_log_fetches += 1 + + failed_steps_details = [] + if job.get('steps'): + for step in job['steps']: + if step.get('conclusion') == 'failure': + failed_steps_details.append(step) + + if not failed_steps_details: + print("\n**Note: No specific failed steps were identified in the job's metadata, but the job itself is marked as failed.**") + stripped_log_lines_fallback = [strip_initial_timestamp(line) for line in job_logs_raw.splitlines()] + if args.grep_pattern: + print(f"Displaying grep results for pattern '{args.grep_pattern}' with context {args.grep_context} from **entire job log**:") + print("\n```log") + try: + process = subprocess.run( + ['grep', '-E', f"-C{args.grep_context}", args.grep_pattern], + input="\n".join(stripped_log_lines_fallback), text=True, capture_output=True, check=False + ) + if process.returncode == 0: print(process.stdout.strip()) + elif process.returncode == 1: print(f"No matches found for pattern '{args.grep_pattern}' in entire job log.") + else: sys.stderr.write(f"Grep command failed on full job log: {process.stderr}\n") # Should this be in log block? + except FileNotFoundError: sys.stderr.write("Error: 'grep' not found, cannot process full job log with grep.\n") + except Exception as e: sys.stderr.write(f"Grep error on full job log: {e}\n") + print("```") + else: + print(f"Displaying last {args.log_lines} lines from **entire job log** as fallback:") + print("\n```log") + for line in stripped_log_lines_fallback[-args.log_lines:]: + print(line) + print("```") + print("\n---") + continue + + print(f"\n### Failed Steps in Job: {job['name']}") + first_failed_step_logged = False + for step in failed_steps_details: + if not args.all_failed_steps and first_failed_step_logged: + print(f"\n--- Skipping subsequent failed step: {step.get('name', 'Unnamed step')} (use --all-failed-steps to see all) ---") + break + + step_name = step.get('name', 'Unnamed step') + print(f"\n#### Step: {step_name}") + + escaped_step_name = re.escape(step_name) + step_start_pattern = re.compile(r"^##\[group\](?:Run\s+|Setup\s+|Complete\s+)?.*?" + escaped_step_name, re.IGNORECASE) + step_end_pattern = re.compile(r"^##\[endgroup\]") + + raw_log_lines_for_job_step_search = job_logs_raw.splitlines() + current_step_raw_log_segment_lines = [] + capturing_for_failed_step = False + for line in raw_log_lines_for_job_step_search: + if step_start_pattern.search(line): + capturing_for_failed_step = True + current_step_raw_log_segment_lines = [line] + continue + if capturing_for_failed_step: + current_step_raw_log_segment_lines.append(line) + if step_end_pattern.search(line): + capturing_for_failed_step = False + break + + lines_to_process_stripped = [] + log_source_message = "" + + if current_step_raw_log_segment_lines: + lines_to_process_stripped = [strip_initial_timestamp(line) for line in current_step_raw_log_segment_lines] + log_source_message = f"Log for failed step '{step_name}'" + else: + lines_to_process_stripped = [strip_initial_timestamp(line) for line in raw_log_lines_for_job_step_search] # Use full job log if segment not found + log_source_message = f"Could not isolate log for step '{step_name}'. Using entire job log" + + log_content_for_processing = "\n".join(lines_to_process_stripped) + + if args.grep_pattern: + print(f"\n{log_source_message} (grep results for pattern `{args.grep_pattern}` with context {args.grep_context}):\n") + print("```log") + try: + process = subprocess.run( + ['grep', '-E', f"-C{args.grep_context}", args.grep_pattern], + input=log_content_for_processing, text=True, capture_output=True, check=False + ) + if process.returncode == 0: + print(process.stdout.strip()) + elif process.returncode == 1: + print(f"No matches found for pattern '{args.grep_pattern}' in this log segment.") + else: + print(f"Grep command failed with error code {process.returncode}. Stderr:\n{process.stderr}") + except FileNotFoundError: + sys.stderr.write("Error: 'grep' command not found. Please ensure it is installed and in your PATH to use --grep-pattern.\n") + print("Skipping log display for this step as grep is unavailable.") + except Exception as e: + sys.stderr.write(f"An unexpected error occurred while running grep: {e}\n") + print("Skipping log display due to an error with grep.") + print("```") + else: + print(f"\n{log_source_message} (last {args.log_lines} lines):\n") + print("```log") + for log_line in lines_to_process_stripped[-args.log_lines:]: + print(log_line) + print("```") + + print(f"\n---") + first_failed_step_logged = True + print(f"\n---") + + sys.stderr.write(f"INFO: Log processing complete for this batch. Successfully fetched and processed logs for {successful_log_fetches}/{total_failed_jobs_to_process} job(s) from pattern '{current_pattern_str}'.\n") + def main(): """Main function to parse arguments and orchestrate the script.""" @@ -177,9 +315,10 @@ def parse_repo_url_arg(url_string): ) parser.add_argument( "--job-pattern", + action='append', type=str, - default='^build.*', - help="Regular expression to filter job names. Only jobs matching this pattern will be processed. Default: '^build.*'" + help="Regular expression to filter job names. Can be specified multiple times to check patterns in order. " + "If no patterns are specified, defaults to checking: '^build.*', then '^test.*', then '.*'." ) args = parser.parse_args() @@ -200,7 +339,7 @@ def parse_repo_url_arg(url_string): if not token: sys.stderr.write(f"Error: GitHub token not provided. Set GITHUB_TOKEN, use --token, or place it in ~/.github_token.{error_suffix}\n") sys.exit(1) - args.token = token # Ensure args.token is populated + args.token = token final_owner = None final_repo = None @@ -224,7 +363,7 @@ def parse_repo_url_arg(url_string): is_owner_from_user_arg = args.owner is not None and args.owner != determined_owner is_repo_from_user_arg = args.repo is not None and args.repo != determined_repo - if is_owner_from_user_arg or is_repo_from_user_arg: # User explicitly set at least one of owner/repo via args + if is_owner_from_user_arg or is_repo_from_user_arg: if args.owner and args.repo: final_owner = args.owner final_repo = args.repo @@ -232,29 +371,21 @@ def parse_repo_url_arg(url_string): else: sys.stderr.write(f"Error: Both --owner and --repo must be specified if one is provided explicitly (and --url is not used).{error_suffix}\n") sys.exit(1) - elif args.owner and args.repo: # Both args have values, likely from successful auto-detection (or user provided matching defaults) + elif args.owner and args.repo: final_owner = args.owner final_repo = args.repo - # No specific message needed if it's from auto-detection, already printed. - # If user explicitly provided args that match auto-detected, that's fine. - # If final_owner/repo are still None here, it means auto-detection failed AND user provided nothing for owner/repo. - # Or, only one of owner/repo was auto-detected and the other wasn't provided. if not final_owner or not final_repo: missing_parts = [] if not final_owner: missing_parts.append("--owner") if not final_repo: missing_parts.append("--repo") - error_msg = "Error: Could not determine repository." - if missing_parts: - error_msg += f" Missing { ' and '.join(missing_parts) }." + if missing_parts: error_msg += f" Missing { ' and '.join(missing_parts) }." error_msg += f" Please specify --url, OR both --owner and --repo, OR ensure git remote 'origin' is configured correctly.{error_suffix}" sys.stderr.write(error_msg + "\n") sys.exit(1) if not set_repo_info(final_owner, final_repo): - # This path should ideally not be reached if final_owner/repo are validated, - # but as a safeguard: sys.stderr.write(f"Error: Could not set repository info to {final_owner}/{final_repo}. Ensure owner/repo are correct.{error_suffix}\n") sys.exit(1) @@ -271,202 +402,66 @@ def parse_repo_url_arg(url_string): sys.stderr.write(f"Found workflow run ID: {run['id']} (Status: {run.get('status')}, Conclusion: {run.get('conclusion')})\n") - try: - job_name_pattern = re.compile(args.job_pattern) - except re.error as e: - sys.stderr.write(f"Error: Invalid regex for --job-pattern '{args.job_pattern}': {e}\n") - sys.exit(1) + patterns_to_check = args.job_pattern if args.job_pattern else DEFAULT_JOB_PATTERNS - # 1. Fetch all jobs for the run all_jobs_api_response = get_all_jobs_for_run(args.token, run['id']) - - if all_jobs_api_response is None: # Indicates an API error during fetch + if all_jobs_api_response is None: sys.stderr.write(f"Could not retrieve jobs for workflow run ID: {run['id']}. Exiting.\n") sys.exit(1) - # 2. Filter jobs by name pattern - name_matching_jobs = [job for job in all_jobs_api_response if job_name_pattern.search(job['name'])] - - if not name_matching_jobs: - sys.stderr.write(f"No jobs found matching pattern '{args.job_pattern}' in workflow run ID: {run['id']}.\n") - sys.exit(0) - - sys.stderr.write(f"Found {len(name_matching_jobs)} job(s) matching pattern '{args.job_pattern}'. Checking for failures...\n") - - # 3. From the name-matching jobs, find the ones that actually failed - failed_jobs_matching_criteria = [job for job in name_matching_jobs if job.get('conclusion') == 'failure'] - - if not failed_jobs_matching_criteria: - sys.stderr.write(f"No failed jobs found among those matching pattern '{args.job_pattern}' for workflow run ID: {run['id']}.\n") - if run.get('conclusion') == 'success': - print(f"Workflow run {run['id']} ({run.get('html_url', 'N/A')}) completed successfully. No jobs matching pattern '{args.job_pattern}' failed.") - elif run.get('status') == 'in_progress' and run.get('conclusion') is None: - print(f"Workflow run {run['id']} ({run.get('html_url', 'N/A')}) is still in progress. No jobs matching pattern '{args.job_pattern}' have failed yet.") - else: - print(f"Workflow run {run['id']} ({run.get('html_url', 'N/A')}) has conclusion '{run.get('conclusion')}', but no jobs matching pattern ('{args.job_pattern}') were found to have failed.") - sys.exit(0) - - # Print summary of failed jobs to stderr - sys.stderr.write("\nSummary of failed jobs matching criteria:\n") - for job in failed_jobs_matching_criteria: - sys.stderr.write(f" - {job['name']} (ID: {job['id']})\n") - sys.stderr.write("\n") # Add a newline for separation before stdout details - - print(f"\n# Detailed Logs for Failed Jobs (matching pattern '{args.job_pattern}') for Workflow Run ID: {run['id']} ([Run Link]({run.get('html_url', 'No URL')}))\n") - - total_failed_jobs_to_process = len(failed_jobs_matching_criteria) - successful_log_fetches = 0 - - for idx, job in enumerate(failed_jobs_matching_criteria): - sys.stderr.write(f"INFO: Downloading log {idx+1}/{total_failed_jobs_to_process} for job '{job['name']}' (ID: {job['id']})...\n") - job_logs = get_job_logs(args.token, job['id']) - - print(f"\n## Job: {job['name']} (ID: {job['id']}) - FAILED") - print(f"[Job URL]({job.get('html_url', 'N/A')})\n") - - if not job_logs: - print("**Could not retrieve logs for this job.**") - # Also print to stderr if it's a critical failure to fetch - sys.stderr.write(f"WARNING: Failed to retrieve logs for job '{job['name']}' (ID: {job['id']}).\n") - continue # Skip to the next job - - successful_log_fetches += 1 - # If logs were fetched, proceed to process them (already existing logic) - - failed_steps_details = [] - if job.get('steps'): - for step in job['steps']: - if step.get('conclusion') == 'failure': - failed_steps_details.append(step) - - if not failed_steps_details: # No specific failed steps found in API, but job is failed - print("\n**Note: No specific failed steps were identified in the job's metadata, but the job itself is marked as failed.**") - - # Apply timestamp stripping to the full job log - stripped_log_lines_fallback = [strip_initial_timestamp(line) for line in job_logs.splitlines()] - - if args.grep_pattern: - print(f"Displaying grep results for pattern '{args.grep_pattern}' with context {args.grep_context} from **entire job log**:") - print("\n```log") - try: - process = subprocess.run( - ['grep', '-E', f"-C{args.grep_context}", args.grep_pattern], - input="\n".join(stripped_log_lines_fallback), text=True, capture_output=True, check=False - ) - if process.returncode == 0: print(process.stdout.strip()) - elif process.returncode == 1: print(f"No matches found for pattern '{args.grep_pattern}' in entire job log.") - else: sys.stderr.write(f"Grep command failed on full job log: {process.stderr}\n") - except FileNotFoundError: sys.stderr.write("Error: 'grep' not found, cannot process full job log with grep.\n") - except Exception as e: sys.stderr.write(f"Grep error on full job log: {e}\n") - print("```") - else: - print(f"Displaying last {args.log_lines} lines from **entire job log** as fallback:") - print("\n```log") - for line in stripped_log_lines_fallback[-args.log_lines:]: # Use stripped lines - print(line) - print("```") - print("\n---") # Horizontal rule - continue - - print(f"\n### Failed Steps in Job: {job['name']}") - first_failed_step_logged = False - for step in failed_steps_details: - if not args.all_failed_steps and first_failed_step_logged: - print(f"\n--- Skipping subsequent failed step: {step.get('name', 'Unnamed step')} (use --all-failed-steps to see all) ---") # Keep this as plain text for now - break # Stop after the first failed step if not --all-failed-steps - - step_name = step.get('name', 'Unnamed step') - print(f"\n#### Step: {step_name}") - - # Crude log extraction: - # Regex to match group start, attempting to capture the step name robustly - escaped_step_name = re.escape(step_name) - step_start_pattern = re.compile(r"^##\[group\](?:Run\s+|Setup\s+|Complete\s+)?.*?" + escaped_step_name, re.IGNORECASE) - step_end_pattern = re.compile(r"^##\[endgroup\]") + found_failures_and_processed = False + for current_pattern_str in patterns_to_check: + sys.stderr.write(f"\nINFO: Checking with job pattern: '{current_pattern_str}'...\n") + try: + current_job_name_regex = re.compile(current_pattern_str) + except re.error as e: + sys.stderr.write(f"WARNING: Invalid regex for job pattern '{current_pattern_str}': {e}. Skipping this pattern.\n") + continue - # Get raw lines for the entire job first - raw_log_lines_for_job = job_logs.splitlines() + name_matching_jobs = [j for j in all_jobs_api_response if current_job_name_regex.search(j['name'])] - current_step_raw_log_segment_lines = [] # Stores raw lines of the isolated step - capturing_for_failed_step = False - for line in raw_log_lines_for_job: # Iterate raw lines to find segment - if step_start_pattern.search(line): - capturing_for_failed_step = True - current_step_raw_log_segment_lines = [line] - continue - if capturing_for_failed_step: - current_step_raw_log_segment_lines.append(line) - if step_end_pattern.search(line): - capturing_for_failed_step = False - break + if not name_matching_jobs: + sys.stderr.write(f"INFO: No jobs found matching pattern '{current_pattern_str}'.\n") + continue - # Determine which set of lines to process (isolated step or full job) and strip timestamps - lines_to_process_stripped = [] - log_source_message = "" + sys.stderr.write(f"INFO: Found {len(name_matching_jobs)} job(s) matching pattern '{current_pattern_str}'. Checking for failures...\n") + failed_jobs_this_pattern = [j for j in name_matching_jobs if j.get('conclusion') == 'failure'] - if current_step_raw_log_segment_lines: - lines_to_process_stripped = [strip_initial_timestamp(line) for line in current_step_raw_log_segment_lines] - log_source_message = f"Log for failed step '{step_name}'" - else: - # Fallback to full job log if specific step segment couldn't be isolated - lines_to_process_stripped = [strip_initial_timestamp(line) for line in raw_log_lines_for_job] - log_source_message = f"Could not isolate log for step '{step_name}'. Using entire job log" + if failed_jobs_this_pattern: + sys.stderr.write(f"INFO: Found {len(failed_jobs_this_pattern)} failed job(s) for pattern '{current_pattern_str}'.\n") - log_content_for_processing = "\n".join(lines_to_process_stripped) + # Call the refactored processing function + _process_and_display_logs_for_failed_jobs(args, failed_jobs_this_pattern, run.get('html_url'), current_pattern_str) - if args.grep_pattern: - print(f"\n{log_source_message} (grep results for pattern `{args.grep_pattern}` with context {args.grep_context}):\n") - print("```log") - try: - process = subprocess.run( - ['grep', '-E', f"-C{args.grep_context}", args.grep_pattern], - input=log_content_for_processing, # Use stripped content - text=True, - capture_output=True, - check=False - ) - if process.returncode == 0: - print(process.stdout.strip()) - elif process.returncode == 1: - print(f"No matches found for pattern '{args.grep_pattern}' in this log segment.") - else: - print(f"Grep command failed with error code {process.returncode}. Stderr:\n{process.stderr}") - except FileNotFoundError: - sys.stderr.write("Error: 'grep' command not found. Please ensure it is installed and in your PATH to use --grep-pattern.\n") - print("Skipping log display for this step as grep is unavailable.") - except Exception as e: - sys.stderr.write(f"An unexpected error occurred while running grep: {e}\n") - print("Skipping log display due to an error with grep.") - print("```") - else: - print(f"\n{log_source_message} (last {args.log_lines} lines):\n") - print("```log") - # Print from the already stripped lines (lines_to_process_stripped) - for log_line in lines_to_process_stripped[-args.log_lines:]: - print(log_line) - print("```") - - print(f"\n---") # Horizontal rule after each step's log - first_failed_step_logged = True # Mark that we've logged at least one step - - print(f"\n---") # Horizontal rule after all steps for a job + found_failures_and_processed = True + sys.stderr.write(f"INFO: Failures processed for pattern '{current_pattern_str}'. Subsequent patterns will not be checked.\n") + break + else: + sys.stderr.write(f"INFO: All {len(name_matching_jobs)} job(s) matching pattern '{current_pattern_str}' succeeded or are not yet concluded.\n") - # Print final summary of log fetching to stderr - sys.stderr.write(f"\nINFO: Processed logs for {successful_log_fetches}/{total_failed_jobs_to_process} targeted failed jobs.\n") + if not found_failures_and_processed: + sys.stderr.write(f"\nINFO: No failed jobs found for any of the specified/default patterns ('{', '.join(patterns_to_check)}') after checking the workflow run.\n") + # Optionally print overall run status if nothing specific was found + overall_status = run.get('status') + overall_conclusion = run.get('conclusion') + if overall_status and overall_conclusion: + sys.stderr.write(f"INFO: Overall workflow run status: {overall_status}, conclusion: {overall_conclusion}.\n") + elif overall_status: + sys.stderr.write(f"INFO: Overall workflow run status: {overall_status}.\n") def get_latest_workflow_run(token, workflow_name, branch_name): """Fetches the most recent workflow run for a given workflow name and branch.""" url = f'{GITHUB_API_URL}/actions/workflows/{workflow_name}/runs' headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'} - params = {'branch': branch_name, 'per_page': 1, 'page': 1} # Get the most recent 1 + params = {'branch': branch_name, 'per_page': 1, 'page': 1} try: with requests_retry_session().get(url, headers=headers, params=params, timeout=TIMEOUT) as response: response.raise_for_status() data = response.json() if data['workflow_runs'] and len(data['workflow_runs']) > 0: - return data['workflow_runs'][0] # The first one is the most recent + return data['workflow_runs'][0] else: return None except requests.exceptions.RequestException as e: @@ -485,11 +480,11 @@ def get_all_jobs_for_run(token, run_id): headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'} page = 1 - per_page = 100 # GitHub API default and max is 100 for many paginated endpoints + per_page = 100 all_jobs = [] while True: - params = {'per_page': per_page, 'page': page, 'filter': 'latest'} # 'latest' attempt for each job + params = {'per_page': per_page, 'page': page, 'filter': 'latest'} try: with requests_retry_session().get(url, headers=headers, params=params, timeout=TIMEOUT) as response: response.raise_for_status() @@ -499,19 +494,20 @@ def get_all_jobs_for_run(token, run_id): break all_jobs.extend(current_page_jobs) if len(current_page_jobs) < per_page: - break # Reached last page + break page += 1 except requests.exceptions.RequestException as e: sys.stderr.write(f"Error: Failed to fetch jobs for run ID {run_id} (page {page}): {e}\n") if e.response is not None: sys.stderr.write(f"Response content: {e.response.text}\n") - return None # Return None if any page fails + return None except json.JSONDecodeError as e: sys.stderr.write(f"Error: Failed to parse JSON response for jobs: {e}\n") return None - failed_jobs = [job for job in all_jobs if job.get('conclusion') == 'failure'] - return all_jobs # Return all jobs, filtering happens in main + # This was an error in previous version, failed_jobs was defined but all_jobs returned + # Now it correctly returns all_jobs as intended by the function name. + return all_jobs def get_job_logs(token, job_id): @@ -520,20 +516,13 @@ def get_job_logs(token, job_id): headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'} try: - # Logs can be large, use a longer timeout and stream if necessary, - # but for typical use, direct content might be fine. - # The GitHub API for logs redirects to a download URL. `requests` handles this. with requests_retry_session().get(url, headers=headers, timeout=LONG_TIMEOUT, stream=False) as response: response.raise_for_status() - # The response for logs is plain text, not JSON return response.text except requests.exceptions.RequestException as e: sys.stderr.write(f"Error: Failed to download logs for job ID {job_id}: {e}\n") if e.response is not None: - # Log URLs might expire or have other issues, content might be HTML error page sys.stderr.write(f"Response status: {e.response.status_code}\n") - # Avoid printing potentially huge HTML error pages to stderr directly - # sys.stderr.write(f"Response content: {e.response.text[:500]}...\n") # Print a snippet return None From e1ce17a90b3b23cd95e67d8a6fb5d8d98a37f0c8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:45:52 +0000 Subject: [PATCH 10/12] Revert default grep context to 5 This commit changes the default value for the `--grep-context` argument in the `print_workflow_run_errors.py` script back to 5 (from 10). --- scripts/print_workflow_run_errors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 033b4da352..f079293bed 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -310,8 +310,8 @@ def parse_repo_url_arg(url_string): parser.add_argument( "--grep-context", "-C", type=int, - default=10, - help="Number of lines of leading and trailing context to print for grep matches. Default: 10." + default=5, + help="Number of lines of leading and trailing context to print for grep matches. Default: 5." ) parser.add_argument( "--job-pattern", From d7cea5c5ee7fea6a3db6ae651b60088e1933179e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:53:19 +0000 Subject: [PATCH 11/12] Add --run-id option to process specific workflow run This commit enhances `print_workflow_run_errors.py` by adding a `--run-id` command-line option. This allows users to specify a particular workflow run ID for processing, bypassing the default behavior of searching for the latest run by workflow name and branch. Key changes: - Added `--run-id ` optional argument. - If `--run-id` is provided, the script fetches details for that specific run using a new helper function `get_workflow_run_details_by_id`. The `--workflow` and `--branch` arguments are ignored in this mode. - If `--run-id` is not provided, the script retains its existing behavior of using `--workflow` and `--branch` to find the latest run. - The new helper function includes error handling for invalid or non-existent run IDs. - Standard error messages have been updated to reflect whether the script is processing a run by specified ID or by search criteria. --- scripts/print_workflow_run_errors.py | 73 +++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index f079293bed..3e4edaec17 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -223,6 +223,37 @@ def _process_and_display_logs_for_failed_jobs(args, list_of_failed_jobs, workflo sys.stderr.write(f"INFO: Log processing complete for this batch. Successfully fetched and processed logs for {successful_log_fetches}/{total_failed_jobs_to_process} job(s) from pattern '{current_pattern_str}'.\n") +def get_workflow_run_details_by_id(token, run_id_to_fetch): + """Fetches details for a specific workflow run ID from GitHub API.""" + url = f'{GITHUB_API_URL}/actions/runs/{run_id_to_fetch}' + headers = {'Accept': 'application/vnd.github.v3+json', 'Authorization': f'token {token}'} + + sys.stderr.write(f"INFO: Fetching details for workflow run ID: {run_id_to_fetch} from {url}\n") + try: + with requests_retry_session().get(url, headers=headers, timeout=TIMEOUT) as response: + response.raise_for_status() + return response.json() # Returns the full run object + except requests.exceptions.HTTPError as e: + if e.response.status_code == 404: + sys.stderr.write(f"ERROR: Workflow run ID {run_id_to_fetch} not found.\n") + else: + sys.stderr.write(f"ERROR: HTTP error fetching details for run ID {run_id_to_fetch}: {e}\n") + if e.response is not None: + # Avoid printing potentially very large HTML error pages from GitHub + try: + error_detail = e.response.json() # Check if there's a JSON error message + sys.stderr.write(f"Response JSON: {json.dumps(error_detail, indent=2)}\n") + except json.JSONDecodeError: + sys.stderr.write(f"Response Text (first 500 chars): {e.response.text[:500]}...\n") + return None + except requests.exceptions.RequestException as e: + sys.stderr.write(f"ERROR: Request failed while fetching details for run ID {run_id_to_fetch}: {e}\n") + return None + except json.JSONDecodeError as e: # Should be caught by RequestException or HTTPError if response is bad + sys.stderr.write(f"ERROR: Failed to parse JSON response for run ID {run_id_to_fetch} details: {e}\n") + return None + + def main(): """Main function to parse arguments and orchestrate the script.""" determined_owner = None @@ -320,6 +351,12 @@ def parse_repo_url_arg(url_string): help="Regular expression to filter job names. Can be specified multiple times to check patterns in order. " "If no patterns are specified, defaults to checking: '^build.*', then '^test.*', then '.*'." ) + parser.add_argument( + "--run-id", + type=int, + default=None, + help="Specify a specific workflow run ID to process. If provided, --workflow and --branch are ignored." + ) args = parser.parse_args() error_suffix = " (use --help for more details)" @@ -393,18 +430,40 @@ def parse_repo_url_arg(url_string): sys.stderr.write(f"Error: Branch name is required. Please specify --branch or ensure it can be detected from your current git repository.{error_suffix}\n") sys.exit(1) - sys.stderr.write(f"Processing workflow '{args.workflow}' on branch '{args.branch}' for repo {OWNER}/{REPO}\n") + run_details = None # This will hold the workflow run information + + if args.run_id: + sys.stderr.write(f"INFO: Attempting to process directly specified workflow run ID: {args.run_id}\n") + # When run_id is given, --workflow and --branch are ignored as per help text. + # We need to fetch the run details to get its html_url and confirm existence. + run_details = get_workflow_run_details_by_id(args.token, args.run_id) + if not run_details: + # get_workflow_run_details_by_id already prints specific errors + sys.stderr.write(f"ERROR: Could not retrieve details for specified run ID {args.run_id}. Exiting.\n") + sys.exit(1) + sys.stderr.write(f"INFO: Successfully fetched details for run ID: {run_details['id']} (Status: {run_details.get('status')}, Conclusion: {run_details.get('conclusion')}, URL: {run_details.get('html_url')})\n") + else: + # Original logic: find run by workflow name and branch + if not args.branch: # This check might be redundant if get_current_branch_name always provides one or script exits + sys.stderr.write(f"Error: --branch is required when --run-id is not specified.{error_suffix}\n") + sys.exit(1) + if not args.workflow: # Should not happen due to default, but good practice + sys.stderr.write(f"Error: --workflow is required when --run-id is not specified.{error_suffix}\n") + sys.exit(1) - run = get_latest_workflow_run(args.token, args.workflow, args.branch) - if not run: - sys.stderr.write(f"No workflow run found for workflow '{args.workflow}' on branch '{args.branch}'.\n") - sys.exit(0) + sys.stderr.write(f"INFO: Searching for latest workflow run for '{args.workflow}' on branch '{args.branch}' in repo {OWNER}/{REPO}...\n") + run_details = get_latest_workflow_run(args.token, args.workflow, args.branch) + if not run_details: + sys.stderr.write(f"INFO: No workflow run found for workflow '{args.workflow}' on branch '{args.branch}'.\n") + sys.exit(0) + sys.stderr.write(f"INFO: Found latest workflow run ID: {run_details['id']} (Status: {run_details.get('status')}, Conclusion: {run_details.get('conclusion')})\n") - sys.stderr.write(f"Found workflow run ID: {run['id']} (Status: {run.get('status')}, Conclusion: {run.get('conclusion')})\n") + # At this point, run_details should be populated either from --run-id or by search + # The rest of the script uses run_details['id'] and run_details.get('html_url') patterns_to_check = args.job_pattern if args.job_pattern else DEFAULT_JOB_PATTERNS - all_jobs_api_response = get_all_jobs_for_run(args.token, run['id']) + all_jobs_api_response = get_all_jobs_for_run(args.token, run_details['id']) if all_jobs_api_response is None: sys.stderr.write(f"Could not retrieve jobs for workflow run ID: {run['id']}. Exiting.\n") sys.exit(1) From 15531c53271ebad22bc208c4caf15466b94ab1f0 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:19:05 +0000 Subject: [PATCH 12/12] Fix NameError for run_details variable Corrects a NameError in the `main` function when calling `_process_and_display_logs_for_failed_jobs`. The script was attempting to use `run.get('html_url')` where it should have been `run_details.get('html_url')` after a previous refactor. This fix ensures the correct variable is used, allowing the script to properly pass the workflow run's HTML URL to the log processing function. --- scripts/print_workflow_run_errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/print_workflow_run_errors.py b/scripts/print_workflow_run_errors.py index 3e4edaec17..dd3411e7dd 100644 --- a/scripts/print_workflow_run_errors.py +++ b/scripts/print_workflow_run_errors.py @@ -490,7 +490,7 @@ def parse_repo_url_arg(url_string): sys.stderr.write(f"INFO: Found {len(failed_jobs_this_pattern)} failed job(s) for pattern '{current_pattern_str}'.\n") # Call the refactored processing function - _process_and_display_logs_for_failed_jobs(args, failed_jobs_this_pattern, run.get('html_url'), current_pattern_str) + _process_and_display_logs_for_failed_jobs(args, failed_jobs_this_pattern, run_details.get('html_url'), current_pattern_str) found_failures_and_processed = True sys.stderr.write(f"INFO: Failures processed for pattern '{current_pattern_str}'. Subsequent patterns will not be checked.\n")