diff --git a/marketplaces/default.json b/marketplaces/default.json index cdb2a1c..7cb1019 100644 --- a/marketplaces/default.json +++ b/marketplaces/default.json @@ -401,6 +401,32 @@ "hosting" ] }, + { + "name": "debug-github-ci", + "source": "./debug-github-ci", + "description": "Debug GitHub Actions CI failures by fetching logs, identifying root causes, and suggesting fixes.", + "category": "debugging", + "keywords": [ + "github", + "ci", + "actions", + "debugging", + "workflow" + ] + }, + { + "name": "debug-jenkins-ci", + "source": "./debug-jenkins-ci", + "description": "Debug Jenkins CI/CD pipeline failures by fetching logs, identifying root causes, and suggesting fixes.", + "category": "debugging", + "keywords": [ + "jenkins", + "ci", + "pipeline", + "debugging", + "build" + ] + }, { "name": "linear", "source": "./linear", diff --git a/plugins/debug-github-ci/README.md b/plugins/debug-github-ci/README.md new file mode 100644 index 0000000..2022cdb --- /dev/null +++ b/plugins/debug-github-ci/README.md @@ -0,0 +1,165 @@ +# Debug GitHub CI + +Automated debugging of GitHub Actions CI failures using OpenHands agents. + +## Overview + +This extension provides **dual-mode functionality**: + +1. **Skill Mode**: A knowledge skill (`SKILL.md`) that provides guidance and context for debugging CI failures interactively via OpenHands conversations. + +2. **Plugin Mode**: An executable GitHub Action (`action.yml`) that can be triggered automatically when CI fails, running an autonomous debug agent. + +Use **skill mode** when working interactively with OpenHands to debug CI issues. Use **plugin mode** to automate CI failure analysis in your GitHub workflows. + +## Quick Start + +Copy the workflow file to your repository: + +```bash +mkdir -p .github/workflows +curl -o .github/workflows/debug-ci-failure.yml \ + https://raw.githubusercontent.com/OpenHands/extensions/main/plugins/debug-github-ci/workflows/debug-ci-failure.yml +``` + +Then configure the required secrets (see [Installation](#installation) below). + +## Features + +- **Automatic CI Failure Analysis**: Triggered when workflow runs fail +- **Log Analysis**: Fetches and analyzes failed job logs to identify root causes +- **Actionable Suggestions**: Posts comments with specific fixes and commands +- **Error Pattern Recognition**: Identifies common CI failure patterns +- **Context-Aware**: Considers recent commits and PR changes + +## Plugin Contents + +``` +plugins/debug-github-ci/ +├── README.md # This file +├── action.yml # Composite GitHub Action +├── skills/ # Symbolic links to debug skills +│ └── debug-github-ci -> ../../../skills/debug-github-ci +├── workflows/ # Example GitHub workflow files +│ └── debug-ci-failure.yml +└── scripts/ # Python scripts for debug execution + ├── agent_script.py # Main CI debug agent script + └── prompt.py # Prompt template for debugging +``` + +Notes: +- The marketplace manifest uses the repo-wide `pluginRoot: "./skills"`, so `source: "./debug-github-ci"` resolves to `skills/debug-github-ci`. +- The `plugins/debug-github-ci/skills/debug-github-ci` symlink mirrors the `pr-review` plugin pattern so the plugin bundle can reference the matching skill content without duplicating `SKILL.md`. + +## Installation + +### 1. Copy the Workflow File + +Copy the workflow file to your repository's `.github/workflows/` directory: + +```bash +mkdir -p .github/workflows +curl -o .github/workflows/debug-ci-failure.yml \ + https://raw.githubusercontent.com/OpenHands/extensions/main/plugins/debug-github-ci/workflows/debug-ci-failure.yml +``` + +### 2. Configure Secrets + +Add the following secrets in your repository settings (**Settings → Secrets and variables → Actions**): + +| Secret | Required | Description | +|--------|----------|-------------| +| `LLM_API_KEY` | Yes | API key for your LLM provider | +| `GITHUB_TOKEN` | Auto | Provided automatically by GitHub Actions | + +### 3. Customize the Workflow (Optional) + +Edit the workflow file to customize: + +```yaml +- name: Debug CI Failure + uses: OpenHands/extensions/plugins/debug-github-ci@main + with: + # LLM model to use + llm-model: anthropic/claude-sonnet-4-5-20250929 + + # Secrets + llm-api-key: ${{ secrets.LLM_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Usage + +### Automatic Triggers + +CI debugging is automatically triggered when: + +1. A workflow run fails on the repository +2. The `debug-ci` label is added to a PR with failed checks + +### Manual Trigger + +You can manually trigger debugging: + +1. Go to **Actions** tab +2. Select **Debug CI Failure** workflow +3. Click **Run workflow** +4. Enter the failed run ID + +## Action Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `llm-model` | No | `anthropic/claude-sonnet-4-5-20250929` | LLM model to use | +| `llm-base-url` | No | `''` | Custom LLM endpoint URL | +| `run-id` | No | Auto | Specific workflow run ID to debug | +| `extensions-repo` | No | `OpenHands/extensions` | Extensions repository | +| `extensions-version` | No | `main` | Git ref (tag, branch, or SHA) | +| `llm-api-key` | Yes | - | LLM API key | +| `github-token` | Yes | - | GitHub token for API access | + +## What Gets Analyzed + +The agent analyzes: + +1. **Failed job logs**: Console output from failed steps +2. **Error messages**: Specific error patterns and stack traces +3. **Recent commits**: Changes that may have caused the failure +4. **Workflow configuration**: Issues in the workflow YAML +5. **Dependencies**: Version conflicts or missing packages + +## Output + +The agent posts a comment with: + +- **Root Cause Analysis**: What caused the failure +- **Suggested Fixes**: Specific commands or code changes +- **Prevention Tips**: How to avoid similar failures + +## Troubleshooting + +### Debug Not Triggered + +1. Ensure the workflow file is in `.github/workflows/` +2. Verify secrets are configured correctly +3. Check that the workflow has `workflow_run` trigger permissions + +### Analysis Incomplete + +1. Check if logs are available (some expire after 90 days) +2. Verify the `GITHUB_TOKEN` has read access to workflow logs +3. Increase timeout if analysis takes too long + +## Security + +- Uses `workflow_run` event for secure access to secrets +- Only analyzes public workflow logs +- Does not execute any code from the failed run + +## Contributing + +See the main [extensions repository](https://github.com/OpenHands/extensions) for contribution guidelines. + +## License + +This plugin is part of the OpenHands extensions repository. See [LICENSE](../../LICENSE) for details. diff --git a/plugins/debug-github-ci/action.yml b/plugins/debug-github-ci/action.yml new file mode 100644 index 0000000..262e664 --- /dev/null +++ b/plugins/debug-github-ci/action.yml @@ -0,0 +1,137 @@ +--- +name: OpenHands Debug GitHub CI +description: Automated CI failure debugging using OpenHands agent +author: OpenHands + +branding: + icon: alert-circle + color: red + +inputs: + llm-model: + description: LLM model to use for debugging + required: false + default: anthropic/claude-sonnet-4-5-20250929 + llm-base-url: + description: LLM base URL (optional, for custom LLM endpoints) + required: false + default: '' + run-id: + description: Specific workflow run ID to debug (auto-detected if not provided) + required: false + default: '' + extensions-repo: + description: GitHub repository for extensions (owner/repo) + required: false + default: OpenHands/extensions + extensions-version: + description: Git ref to use for extensions (tag, branch, or commit SHA) + required: false + default: main + llm-api-key: + description: LLM API key (required) + required: true + github-token: + description: GitHub token for API access (required) + required: true + use-cache: + description: | + Enable uv cache for faster dependency installation (default: false). + Disabled by default since debug tools benefit from fresh state to + avoid masking environment-related issues. + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Checkout extensions repository + uses: actions/checkout@v4 + with: + repository: ${{ inputs.extensions-repo }} + ref: ${{ inputs.extensions-version }} + path: extensions + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: ${{ inputs.use-cache == 'true' }} + + - name: Install GitHub CLI + shell: bash + run: | + # The bundled workflow template runs on ubuntu-24.04, so apt is the + # simplest way to guarantee gh is available on the runner. + sudo apt-get update + sudo apt-get install -y gh + + - name: Determine run ID + id: get-run-id + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + INPUT_RUN_ID: ${{ inputs.run-id }} + run: | + if [ -n "$INPUT_RUN_ID" ]; then + echo "run_id=$INPUT_RUN_ID" >> $GITHUB_OUTPUT + elif [ -n "${{ github.event.workflow_run.id }}" ]; then + echo "run_id=${{ github.event.workflow_run.id }}" >> $GITHUB_OUTPUT + else + # Get the most recent failed run + RUN_ID=$(gh run list --status failure --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null || echo "") + if [ -z "$RUN_ID" ]; then + echo "::error::No run ID provided and no failed runs found" + exit 1 + fi + echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT + fi + + - name: Check required configuration + shell: bash + env: + LLM_API_KEY: ${{ inputs.llm-api-key }} + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + if [ -z "$LLM_API_KEY" ]; then + echo "Error: llm-api-key is required." + exit 1 + fi + + if [ -z "$GITHUB_TOKEN" ]; then + echo "Error: github-token is required." + exit 1 + fi + + echo "Run ID to debug: ${{ steps.get-run-id.outputs.run_id }}" + echo "Repository: ${{ github.repository }}" + echo "LLM model: ${{ inputs.llm-model }}" + + - name: Run CI debug analysis + shell: bash + env: + LLM_MODEL: ${{ inputs.llm-model }} + LLM_BASE_URL: ${{ inputs.llm-base-url }} + LLM_API_KEY: ${{ inputs.llm-api-key }} + GITHUB_TOKEN: ${{ inputs.github-token }} + RUN_ID: ${{ steps.get-run-id.outputs.run_id }} + REPO_NAME: ${{ github.repository }} + # Marker to prevent recursive debugging - checked by agent_script.py + OH_DEBUG_WORKFLOW: 'true' + run: | + uv run --with openhands-sdk --with openhands-tools \ + python extensions/plugins/debug-github-ci/scripts/agent_script.py + + - name: Upload logs as artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: openhands-ci-debug-logs + path: | + *.log + output/ + retention-days: 7 diff --git a/plugins/debug-github-ci/scripts/agent_script.py b/plugins/debug-github-ci/scripts/agent_script.py new file mode 100644 index 0000000..b0843ea --- /dev/null +++ b/plugins/debug-github-ci/scripts/agent_script.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +""" +GitHub CI Debug Agent + +This script runs an OpenHands agent to debug GitHub Actions CI failures. +The agent fetches failed workflow logs, analyzes errors, and provides +actionable fixes. + +Environment Variables: + LLM_API_KEY: API key for the LLM (required) + LLM_MODEL: Language model to use (default: anthropic/claude-sonnet-4-5-20250929) + LLM_BASE_URL: Optional base URL for LLM API + GITHUB_TOKEN: GitHub token for API access (required) + RUN_ID: Workflow run ID to debug (required) + REPO_NAME: Repository name in format owner/repo (required) +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +try: + from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger + from openhands.sdk.context.skills import load_project_skills + from openhands.tools.preset.default import ( + get_default_condenser, + get_default_tools, + ) + OPENHANDS_IMPORT_ERROR: ModuleNotFoundError | None = None +except ModuleNotFoundError as exc: + LLM = Agent = AgentContext = Conversation = None + load_project_skills = get_default_condenser = get_default_tools = None + OPENHANDS_IMPORT_ERROR = exc + + def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) + +script_dir = Path(__file__).parent +sys.path.insert(0, str(script_dir)) + +from prompt import format_prompt + +logger = get_logger(__name__) + + +def _require_openhands_runtime() -> None: + """Raise a helpful error when OpenHands runtime deps are unavailable.""" + if OPENHANDS_IMPORT_ERROR is not None: + raise ModuleNotFoundError( + "OpenHands runtime dependencies are required to execute this script" + ) from OPENHANDS_IMPORT_ERROR + + +# Max characters to keep from logs. Configurable via MAX_LOG_SIZE env var. +# Default keeps ~50k chars which is roughly 12k tokens. +DEFAULT_MAX_LOG_SIZE = 50000 + + +class GHCommandError(Exception): + """Raised when a GitHub CLI command fails.""" + + pass + + +@dataclass +class Config: + """Configuration for the CI debug agent.""" + + api_key: str + github_token: str + model: str + base_url: str | None + run_id: str + repo_name: str + + +@dataclass +class WorkflowData: + """Data fetched from GitHub about a workflow run.""" + + name: str + branch: str + commit_sha: str + triggered_by: str + jobs: list[dict] + failed_jobs_summary: str + logs: str + + +def _get_max_log_size() -> int: + """Get max log size from env or use default.""" + try: + return int(os.getenv("MAX_LOG_SIZE", DEFAULT_MAX_LOG_SIZE)) + except ValueError: + return DEFAULT_MAX_LOG_SIZE + + +def _get_required_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise ValueError(f"{name} environment variable is required") + return value + + +def _run_gh_command(args: list[str]) -> str | None: + """Run a GitHub CLI command and return output. + + Returns: + The command output on success, None on failure (allows callers to + distinguish between empty output and API failure). + + Raises: + GHCommandError: When gh CLI is not found (unrecoverable). + """ + try: + result = subprocess.run( + ["gh"] + args, + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + logger.warning(f"gh command failed: {result.stderr}") + return None + return result.stdout + except subprocess.TimeoutExpired: + logger.warning("gh command timed out") + return None + except FileNotFoundError: + raise GHCommandError("gh CLI not found - please install GitHub CLI") + + +def get_workflow_run_info(run_id: str, repo_name: str) -> dict | None: + """Fetch workflow run information. + + Returns: + dict on success, None on failure. + """ + output = _run_gh_command([ + "run", "view", run_id, + "--repo", repo_name, + "--json", "name,headBranch,headSha,event,jobs,conclusion,createdAt" + ]) + if output is None: + return None + try: + return json.loads(output) + except json.JSONDecodeError: + logger.error("Failed to parse workflow run info") + return None + + +# Default error patterns to search for in logs (prioritized by importance) +# Customize by setting ERROR_PATTERNS_FILE env var to a JSON file with a list of patterns. +# Example: ERROR_PATTERNS_FILE=.github/ci-debug-patterns.json +# The JSON file should contain an array of regex patterns: +# ["(?i)cargo.*error", "(?i)go:.*cannot", "(?i)your-custom-pattern"] +DEFAULT_ERROR_PATTERNS = [ + r"(?i)error[:\s]", + r"(?i)failed[:\s]", + r"(?i)exception[:\s]", + r"(?i)traceback", + r"(?i)fatal[:\s]", + r"(?i)exit code [1-9]", + r"(?i)ENOENT", + r"(?i)permission denied", + r"(?i)not found", +] + + +def _load_error_patterns() -> list[str]: + """Load error patterns from file or use defaults. + + Environment variable ERROR_PATTERNS_FILE can point to a JSON file + containing custom patterns for project-specific error detection. + """ + patterns_file = os.getenv("ERROR_PATTERNS_FILE") + if patterns_file: + try: + with open(patterns_file) as f: + custom_patterns = json.load(f) + if isinstance(custom_patterns, list): + logger.info(f"Loaded {len(custom_patterns)} custom error patterns") + return custom_patterns + logger.warning("Invalid patterns file format, using defaults") + except (OSError, json.JSONDecodeError) as e: + logger.warning(f"Failed to load patterns file: {e}, using defaults") + return DEFAULT_ERROR_PATTERNS + + +def _compile_error_patterns(patterns: list[str]) -> list[re.Pattern[str]]: + """Compile regex patterns once before scanning logs.""" + compiled_patterns: list[re.Pattern[str]] = [] + for pattern in patterns: + try: + compiled_patterns.append(re.compile(pattern)) + except re.error as exc: + logger.warning(f"Skipping invalid error pattern {pattern!r}: {exc}") + return compiled_patterns + + +def _find_error_context( + logs: str, + max_size: int, + error_patterns: list[str] | None = None, +) -> str | None: + """Extract context around error patterns in logs. + + Strategy: + 1. Search for common error patterns (configurable via ERROR_PATTERNS_FILE) + 2. If found, extract context around the first significant error + 3. Fall back to head/tail truncation if no patterns found + """ + lines = logs.split("\n") + compiled_patterns = _compile_error_patterns( + error_patterns if error_patterns is not None else _load_error_patterns() + ) + + # Find lines containing error patterns + error_indices = [] + for i, line in enumerate(lines): + for pattern in compiled_patterns: + if pattern.search(line): + error_indices.append(i) + break + + if error_indices: + # Focus on first error with surrounding context + first_error = error_indices[0] + # Calculate how much context we can include + total_lines = len(lines) + # Estimate avg line length for context calculation + avg_line_len = len(logs) // max(total_lines, 1) + context_lines = max_size // max(avg_line_len, 50) + + # Get context: 20% before error, 80% after (errors cascade down) + before_context = int(context_lines * 0.2) + after_context = context_lines - before_context + + start = max(0, first_error - before_context) + end = min(total_lines, first_error + after_context) + + extracted_lines = lines[start:end] + extracted = "\n".join(extracted_lines) + + if len(extracted) > max_size: + # Still too big, do a final trim + extracted = extracted[:max_size] + + truncation_note = "" + if start > 0 or end < total_lines: + truncation_note = ( + f"\n[Context extracted around error at line {first_error + 1}. " + f"Original log: {total_lines} lines]\n\n" + ) + return truncation_note + extracted + + # No error patterns found - fall back to head/tail + return None + + +def _truncate_logs(logs: str, max_size: int) -> str: + """Truncate logs intelligently, preserving error context.""" + # Try context-aware truncation first + context_result = _find_error_context(logs, max_size) + if context_result: + return context_result + + # Fall back to head/tail truncation (40% head, 60% tail) + first_chunk = int(max_size * 0.4) + last_chunk = max_size - first_chunk + truncated_chars = len(logs) - max_size + return ( + logs[:first_chunk] + + f"\n\n[... {truncated_chars:,} characters truncated ...]\n\n" + + logs[-last_chunk:] + ) + + +def get_failed_job_logs(run_id: str, repo_name: str) -> str | None: + """Fetch logs from failed jobs. + + If logs exceed MAX_LOG_SIZE, uses context-aware truncation: + 1. Searches for error patterns and extracts surrounding context + 2. Falls back to head/tail truncation if no patterns found + + Returns: + Logs string on success, None on failure. + """ + output = _run_gh_command([ + "run", "view", run_id, + "--repo", repo_name, + "--log-failed" + ]) + if output is None: + return None + + max_size = _get_max_log_size() + if len(output) > max_size: + output = _truncate_logs(output, max_size) + + return output + + +def format_failed_jobs(jobs: list[dict]) -> str: + """Format failed jobs summary.""" + failed = [j for j in jobs if j.get("conclusion") == "failure"] + if not failed: + return "No failed jobs found." + + lines = [] + for job in failed: + name = job.get("name", "Unknown") + steps = job.get("steps", []) + failed_steps = [s for s in steps if s.get("conclusion") == "failure"] + + lines.append(f"### Job: {name}") + if failed_steps: + lines.append("Failed steps:") + for step in failed_steps: + step_name = step.get("name", "Unknown") + lines.append(f" - {step_name}") + lines.append("") + + return "\n".join(lines) + + +def validate_and_load_config() -> Config: + """Validate required environment variables and return configuration. + + Raises: + SystemExit: If required environment variables are missing. + """ + try: + api_key = _get_required_env("LLM_API_KEY") + github_token = _get_required_env("GITHUB_TOKEN") + run_id = _get_required_env("RUN_ID") + repo_name = _get_required_env("REPO_NAME") + except ValueError as e: + logger.error(str(e)) + sys.exit(1) + + return Config( + api_key=api_key, + github_token=github_token, + model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), + base_url=os.getenv("LLM_BASE_URL"), + run_id=run_id, + repo_name=repo_name, + ) + + +def fetch_workflow_data(run_id: str, repo_name: str) -> WorkflowData: + """Fetch workflow run data from GitHub. + + Raises: + SystemExit: If workflow data cannot be fetched. + """ + run_info = get_workflow_run_info(run_id, repo_name) + if run_info is None: + logger.error("Failed to fetch workflow run info (API error)") + sys.exit(1) + + workflow_name = run_info.get("name", "Unknown") + branch = run_info.get("headBranch", "unknown") + commit_sha = run_info.get("headSha", "unknown") + triggered_by = run_info.get("event", "unknown") + jobs = run_info.get("jobs", []) + + logger.info(f"Workflow: {workflow_name}") + logger.info(f"Branch: {branch}") + logger.info(f"Commit: {commit_sha[:8] if commit_sha != 'unknown' else commit_sha}") + + failed_jobs_summary = format_failed_jobs(jobs) + logs = get_failed_job_logs(run_id, repo_name) + + if logs is None: + logger.warning("Failed to fetch logs (API error)") + logs = "Log fetch failed. Use `gh run view --log-failed` to investigate." + elif not logs: + logger.warning("No failed job logs found") + logs = "No logs available. Use `gh run view --log-failed` to investigate." + + return WorkflowData( + name=workflow_name, + branch=branch, + commit_sha=commit_sha, + triggered_by=triggered_by, + jobs=jobs, + failed_jobs_summary=failed_jobs_summary, + logs=logs, + ) + + +def create_agent(config: Config) -> Agent: + """Create and configure the debug agent.""" + _require_openhands_runtime() + + llm_config = { + "model": config.model, + "api_key": config.api_key, + "usage_id": "ci_debug_agent", + "drop_params": True, + } + if config.base_url: + llm_config["base_url"] = config.base_url + + llm = LLM(**llm_config) + + cwd = os.getcwd() + project_skills = load_project_skills(cwd) + logger.info( + f"Loaded {len(project_skills)} project skills: " + f"{[s.name for s in project_skills]}" + ) + + agent_context = AgentContext( + load_public_skills=True, + skills=project_skills, + ) + + return Agent( + llm=llm, + tools=get_default_tools(enable_browser=False), + agent_context=agent_context, + system_prompt_kwargs={"cli_mode": True}, + condenser=get_default_condenser( + llm=llm.model_copy(update={"usage_id": "condenser"}) + ), + ) + + +def execute_debug_agent( + config: Config, workflow_data: WorkflowData +) -> Conversation: + """Build prompt, create agent, and run debug analysis.""" + prompt = format_prompt( + run_id=config.run_id, + repo_name=config.repo_name, + workflow_name=workflow_data.name, + branch=workflow_data.branch, + commit_sha=workflow_data.commit_sha, + triggered_by=workflow_data.triggered_by, + failed_jobs=workflow_data.failed_jobs_summary, + logs=workflow_data.logs, + ) + + agent = create_agent(config) + + secrets = { + "LLM_API_KEY": config.api_key, + "GITHUB_TOKEN": config.github_token, + } + + cwd = os.getcwd() + conversation = Conversation( + agent=agent, + workspace=cwd, + secrets=secrets, + ) + + logger.info("Starting CI failure analysis...") + conversation.send_message(prompt) + conversation.run() + + return conversation + + +def log_cost_summary(conversation: Conversation) -> None: + """Print cost information for CI output.""" + metrics = conversation.conversation_stats.get_combined_metrics() + print("\n=== CI Debug Cost Summary ===") + print(f"Total Cost: ${metrics.accumulated_cost:.6f}") + if metrics.accumulated_token_usage: + token_usage = metrics.accumulated_token_usage + print(f"Prompt Tokens: {token_usage.prompt_tokens}") + print(f"Completion Tokens: {token_usage.completion_tokens}") + + +def _is_debug_workflow_by_env() -> bool: + """Check if running inside a debug workflow via env var marker. + + This is the most robust check - if OH_DEBUG_WORKFLOW=true is set in the + action.yml, we know definitively this is a debug workflow run. + """ + return os.getenv("OH_DEBUG_WORKFLOW", "").lower() == "true" + + +def _is_debug_workflow_by_name(workflow_name: str) -> bool: + """Check if workflow name matches known debug workflow patterns. + + This is a fallback check using name patterns. Less robust than env var + but catches cases where the debug workflow has a custom name. + """ + debug_workflow_names = [ + "debug ci failure", + "debug-ci-failure", + "ci debug", + "debug ci", + ] + return workflow_name.lower().strip() in debug_workflow_names + + +def main(): + """Run the CI debug agent.""" + logger.info("Starting CI debug process...") + + # Primary recursion guard: check env var marker (most robust) + # If this script is running inside a debug workflow action, OH_DEBUG_WORKFLOW=true + # will be set by action.yml, regardless of what the workflow is named. + if _is_debug_workflow_by_env(): + # This shouldn't happen in normal operation since the workflow YAML + # should also filter out debug workflows. But if we somehow get here + # (e.g., user misconfigured workflow), exit safely. + logger.warning( + "Detected OH_DEBUG_WORKFLOW=true - this appears to be a debug workflow " + "trying to debug itself. Skipping to prevent recursion." + ) + print("::warning::Skipped debugging debug workflow to prevent recursion") + sys.exit(0) + + config = validate_and_load_config() + logger.info(f"Debugging workflow run {config.run_id} in {config.repo_name}") + + try: + workflow_data = fetch_workflow_data(config.run_id, config.repo_name) + + # Secondary recursion guard: check workflow name patterns + # This catches cases where a workflow with a debug-related name failed + if _is_debug_workflow_by_name(workflow_data.name): + logger.warning( + f"Skipping debug of workflow '{workflow_data.name}' to prevent " + "recursive debugging. Debug workflows should not debug themselves." + ) + print("::warning::Skipped debugging debug workflow to prevent recursion") + sys.exit(0) + + conversation = execute_debug_agent(config, workflow_data) + log_cost_summary(conversation) + logger.info("CI debug analysis completed successfully") + except GHCommandError as e: + logger.error(str(e)) + # Output GitHub Actions annotation for visibility + print(f"::error::CI debug failed: {e}") + sys.exit(1) + except Exception as e: + logger.error(f"CI debug failed: {e}") + # Output GitHub Actions annotation for visibility + print(f"::error::CI debug failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/debug-github-ci/scripts/prompt.py b/plugins/debug-github-ci/scripts/prompt.py new file mode 100644 index 0000000..7ef090c --- /dev/null +++ b/plugins/debug-github-ci/scripts/prompt.py @@ -0,0 +1,127 @@ +""" +CI Debug Prompt Template + +This module contains the prompt template used by the OpenHands agent +for debugging GitHub Actions CI failures. + +The template includes: +- {run_id} - The workflow run ID +- {repo_name} - The repository name +- {workflow_name} - Name of the failed workflow +- {failed_jobs} - Summary of failed jobs +- {logs} - The relevant log output from failed jobs +""" + +from __future__ import annotations + +import re + +PROMPT = """/debug-github-ci + +Debug the CI failure below and identify the root cause. + +## Workflow Run Information + +- **Repository**: {repo_name} +- **Run ID**: {run_id} +- **Workflow**: {workflow_name} +- **Branch**: {branch} +- **Commit**: {commit_sha} +- **Triggered by**: {triggered_by} + +## Failed Jobs + +{failed_jobs} + +## Error Logs + +The following logs are from the failed jobs. Analyze them to identify the root cause. + +``` +{logs} +``` + +## Your Task + +1. **Analyze the logs** to identify the specific error(s) that caused the failure +2. **Determine the root cause** - is it a code issue, dependency problem, configuration error, or flaky test? +3. **Provide actionable fixes** with specific commands or code changes +4. **Post a comment** on the associated PR (if any) or create an issue with your findings + +Use the GitHub CLI (`gh`) to: +- Fetch additional context if needed: `gh run view {run_id} --log` +- Post comments: `gh pr comment` or `gh issue create` +- Check recent commits: `gh api repos/{repo_name}/commits` + +Focus on providing clear, actionable guidance that helps developers fix the issue quickly. +""" + +# Validation patterns for inputs that are used in shell commands or API URLs +RUN_ID_PATTERN = re.compile(r"^[0-9]+$") +REPO_NAME_PATTERN = re.compile(r"^[\w.-]+/[\w.-]+$") + + +class PromptValidationError(ValueError): + """Raised when prompt inputs fail validation.""" + + pass + + +def _validate_run_id(run_id: str) -> None: + """Validate run_id is numeric (used in API calls).""" + if not RUN_ID_PATTERN.match(run_id): + raise PromptValidationError( + f"Invalid run_id: '{run_id}'. Expected numeric workflow run ID." + ) + + +def _validate_repo_name(repo_name: str) -> None: + """Validate repo_name matches owner/repo format (used in API calls).""" + if not REPO_NAME_PATTERN.match(repo_name): + raise PromptValidationError( + f"Invalid repo_name: '{repo_name}'. Expected format: owner/repo" + ) + + +def format_prompt( + run_id: str, + repo_name: str, + workflow_name: str, + branch: str, + commit_sha: str, + triggered_by: str, + failed_jobs: str, + logs: str, +) -> str: + """Format the CI debug prompt with all parameters. + + Args: + run_id: The workflow run ID (must be numeric) + repo_name: Repository name (owner/repo format) + workflow_name: Name of the failed workflow + branch: Branch the workflow ran on + commit_sha: Commit SHA that triggered the workflow + triggered_by: Event that triggered the workflow + failed_jobs: Formatted summary of failed jobs + logs: Log output from failed jobs + + Returns: + Formatted prompt string + + Raises: + PromptValidationError: If run_id or repo_name fail validation. + """ + # Validate inputs used in API calls/shell commands + _validate_run_id(run_id) + _validate_repo_name(repo_name) + + return PROMPT.format( + run_id=run_id, + repo_name=repo_name, + workflow_name=workflow_name, + branch=branch, + commit_sha=commit_sha, + triggered_by=triggered_by, + failed_jobs=failed_jobs, + logs=logs, + ) diff --git a/plugins/debug-github-ci/skills/debug-github-ci b/plugins/debug-github-ci/skills/debug-github-ci new file mode 120000 index 0000000..9f6c8ee --- /dev/null +++ b/plugins/debug-github-ci/skills/debug-github-ci @@ -0,0 +1 @@ +../../skills/debug-github-ci \ No newline at end of file diff --git a/plugins/debug-github-ci/workflows/debug-ci-failure.yml b/plugins/debug-github-ci/workflows/debug-ci-failure.yml new file mode 100644 index 0000000..7feaf34 --- /dev/null +++ b/plugins/debug-github-ci/workflows/debug-ci-failure.yml @@ -0,0 +1,48 @@ +--- +name: Debug CI Failure + +# IMPORTANT: This workflow is a template. Customize before use. +# Using workflows: ["*"] can cause high LLM costs in busy repositories. + +on: + # Trigger when specific workflows fail (CUSTOMIZE THIS LIST) + # Uncomment and edit to enable automatic debugging: + # workflow_run: + # workflows: ["CI", "Tests", "Build"] # List your critical workflows + # types: [completed] + + # Manual trigger (recommended for initial setup) + workflow_dispatch: + inputs: + run_id: + description: 'Workflow run ID to debug' + required: true + type: string + +permissions: + contents: read + actions: read + issues: write + pull-requests: write + +jobs: + debug-failure: + # Only run if manually triggered or the triggering workflow failed + # The agent script has additional env-var-based recursion guards + if: | + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'failure' + runs-on: ubuntu-24.04 + timeout-minutes: 15 # Prevent runaway debug sessions + env: + # Marker for recursion prevention - the agent script checks this + # to avoid debugging debug workflows, even if they have custom names + OH_DEBUG_WORKFLOW: 'true' + steps: + - name: Debug CI Failure + uses: OpenHands/extensions/plugins/debug-github-ci@main + with: + llm-model: anthropic/claude-sonnet-4-5-20250929 + run-id: ${{ github.event.inputs.run_id || github.event.workflow_run.id }} + llm-api-key: ${{ secrets.LLM_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/plugins/debug-jenkins-ci/README.md b/plugins/debug-jenkins-ci/README.md new file mode 100644 index 0000000..ef59dd8 --- /dev/null +++ b/plugins/debug-jenkins-ci/README.md @@ -0,0 +1,229 @@ +# Debug Jenkins CI Plugin + +Automated debugging of Jenkins CI/CD pipeline failures using OpenHands agents. This plugin provides scripts and configuration that can diagnose and suggest fixes when Jenkins builds fail. + +## Quick Start + +Set up your Jenkins environment: + +```bash +export JENKINS_URL="https://jenkins.example.com" +export JENKINS_USER="your-username" +export JENKINS_API_TOKEN="your-api-token" +``` + +Then run the debug script when a build fails. + +## Features + +- **Automatic Build Failure Analysis**: Triggered when Jenkins builds fail +- **Console Log Analysis**: Fetches and analyzes build console output +- **Pipeline Stage Debugging**: For Pipeline jobs, identifies which stage failed +- **Actionable Suggestions**: Provides specific fixes and Jenkinsfile changes +- **Error Pattern Recognition**: Identifies common Jenkins failure patterns + +## Plugin Contents + +``` +plugins/debug-jenkins-ci/ +├── README.md # This file +├── skills/ # Symbolic links to debug skills +│ └── debug-jenkins-ci -> ../../../skills/debug-jenkins-ci +└── scripts/ # Python scripts for debug execution + ├── agent_script.py # Main Jenkins debug agent script + └── prompt.py # Prompt template for debugging +``` + +Notes: +- The marketplace manifest uses the repo-wide `pluginRoot: "./skills"`, so `source: "./debug-jenkins-ci"` resolves to `skills/debug-jenkins-ci`. +- The `plugins/debug-jenkins-ci/skills/debug-jenkins-ci` symlink mirrors the `pr-review` plugin pattern so the plugin bundle can reference the matching skill content without duplicating `SKILL.md`. + +## Installation + +### 1. Configure Jenkins Credentials + +You'll need a Jenkins API token: + +1. Log in to Jenkins +2. Click your username → Configure +3. Add new API Token +4. Save the token securely + +### 2. Set Environment Variables + +```bash +export JENKINS_URL="https://jenkins.example.com" +export JENKINS_USER="your-username" +export JENKINS_API_TOKEN="your-api-token" +export LLM_API_KEY="your-llm-api-key" +``` + +### 3. Run the Debug Script + +```bash +# Debug a specific build +python scripts/agent_script.py --job "my-job" --build 123 + +# Debug the last failed build +python scripts/agent_script.py --job "my-job" --last-failed +``` + +## Integration Options + +### Option 1: Post-Build Action + +Add to your Jenkinsfile: + +```groovy +pipeline { + agent any + stages { + stage('Build') { + steps { + sh 'make build' + } + } + } + post { + failure { + script { + // Trigger OpenHands debug analysis + sh ''' + curl -X POST "$OPENHANDS_WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{ + \"job\": \"${JOB_NAME}\", + \"build\": \"${BUILD_NUMBER}\", + \"url\": \"${BUILD_URL}\" + }" + ''' + } + } + } +} +``` + +### Option 2: Jenkins Shared Library + +Create a shared library function: + +```groovy +// vars/debugBuildFailure.groovy +def call(Map config = [:]) { + def job = config.job ?: env.JOB_NAME + def build = config.build ?: env.BUILD_NUMBER + + withCredentials([string(credentialsId: 'llm-api-key', variable: 'LLM_API_KEY')]) { + sh """ + python /path/to/debug-jenkins-ci/scripts/agent_script.py \ + --job "${job}" \ + --build "${build}" + """ + } +} +``` + +Use in your pipeline: + +```groovy +post { + failure { + debugBuildFailure() + } +} +``` + +### Option 3: Standalone Script + +Run manually or via cron: + +```bash +# Check for recent failures and debug them +python scripts/agent_script.py --job "my-pipeline" --last-failed +``` + +## Script Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `--job` | Yes | Jenkins job name | +| `--build` | No | Specific build number to debug | +| `--last-failed` | No | Debug the last failed build | +| `--llm-model` | No | LLM model (default: claude-sonnet) | + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `JENKINS_URL` | Yes | Jenkins server URL | +| `JENKINS_USER` | Yes | Jenkins username | +| `JENKINS_API_TOKEN` | Yes | Jenkins API token | +| `LLM_API_KEY` | Yes | LLM API key | +| `LLM_MODEL` | No | LLM model to use | +| `LLM_BASE_URL` | No | Custom LLM endpoint | + +## What Gets Analyzed + +The agent analyzes: + +1. **Console output**: Full build log +2. **Pipeline stages**: For Pipeline jobs, which stage failed +3. **Error messages**: Specific error patterns and stack traces +4. **Jenkinsfile**: Issues in the pipeline configuration +5. **Node status**: Agent/executor availability + +## Output + +The agent provides: + +- **Root Cause Analysis**: What caused the failure +- **Suggested Fixes**: Specific Jenkinsfile changes or commands +- **Prevention Tips**: How to avoid similar failures +- **Related Documentation**: Links to relevant Jenkins docs + +## Common Failure Patterns + +The agent recognizes these patterns: + +| Pattern | Example | Typical Fix | +|---------|---------|-------------| +| Script error | `ERROR: script returned exit code 1` | Check the failing command | +| Timeout | `Timeout exceeded` | Increase timeout or optimize | +| Credentials | `CredentialsNotFoundException` | Configure missing credentials | +| Agent offline | `java.io.IOException` | Check agent connectivity | +| Sandbox violation | `RejectedAccessException` | Approve script or use @NonCPS | + +## Troubleshooting + +### Authentication Failed + +1. Verify `JENKINS_API_TOKEN` is valid (tokens expire) +2. Check `JENKINS_USER` has read access to the job +3. Ensure `JENKINS_URL` doesn't have trailing slash + +### No Logs Found + +1. Build may have been deleted (check retention policy) +2. User may not have permission to view console +3. Build may still be running + +### Pipeline Stages Not Detected + +1. Ensure it's a Pipeline job (not Freestyle) +2. Check if Blue Ocean plugin is installed +3. Verify the build completed (not aborted) + +## Security + +- Uses Jenkins API tokens (not passwords) +- Only reads build logs (no write operations) +- Does not execute any code from the failed build +- API tokens should be stored securely (use credentials binding) + +## Contributing + +See the main [extensions repository](https://github.com/OpenHands/extensions) for contribution guidelines. + +## License + +This plugin is part of the OpenHands extensions repository. See [LICENSE](../../LICENSE) for details. diff --git a/plugins/debug-jenkins-ci/scripts/agent_script.py b/plugins/debug-jenkins-ci/scripts/agent_script.py new file mode 100644 index 0000000..bcc2dc5 --- /dev/null +++ b/plugins/debug-jenkins-ci/scripts/agent_script.py @@ -0,0 +1,583 @@ +#!/usr/bin/env python3 +""" +Jenkins CI Debug Agent + +This script runs an OpenHands agent to debug Jenkins CI/CD pipeline failures. +The agent fetches failed build logs, analyzes errors, and provides actionable fixes. + +Environment Variables: + LLM_API_KEY: API key for the LLM (required) + LLM_MODEL: Language model to use (default: anthropic/claude-sonnet-4-5-20250929) + LLM_BASE_URL: Optional base URL for LLM API + JENKINS_URL: Jenkins server URL (required) + JENKINS_USER: Jenkins username (required) + JENKINS_API_TOKEN: Jenkins API token (required) + JOB_NAME: Jenkins job name (required) + BUILD_NUMBER: Build number to debug (optional, defaults to lastFailedBuild) + +Usage: + python agent_script.py --job "my-pipeline" --build 123 + python agent_script.py --job "my-pipeline" --last-failed +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import logging +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +try: + from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger + from openhands.sdk.context.skills import load_project_skills + from openhands.tools.preset.default import ( + get_default_condenser, + get_default_tools, + ) + OPENHANDS_IMPORT_ERROR: ModuleNotFoundError | None = None +except ModuleNotFoundError as exc: + LLM = Agent = AgentContext = Conversation = None + load_project_skills = get_default_condenser = get_default_tools = None + OPENHANDS_IMPORT_ERROR = exc + + def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) + +script_dir = Path(__file__).parent +sys.path.insert(0, str(script_dir)) + +from prompt import format_prompt + +logger = get_logger(__name__) + + +def _require_openhands_runtime() -> None: + """Raise a helpful error when OpenHands runtime deps are unavailable.""" + if OPENHANDS_IMPORT_ERROR is not None: + raise ModuleNotFoundError( + "OpenHands runtime dependencies are required to execute this script" + ) from OPENHANDS_IMPORT_ERROR + + +# Max characters to keep from logs. Configurable via MAX_LOG_SIZE env var. +# Default keeps ~50k chars which is roughly 12k tokens. +DEFAULT_MAX_LOG_SIZE = 50000 + + +class JenkinsAPIError(Exception): + """Raised when a Jenkins API request fails.""" + + pass + + +@dataclass +class Config: + """Configuration for the Jenkins debug agent.""" + + api_key: str + model: str + base_url: str | None + jenkins_url: str + jenkins_user: str + jenkins_token: str + + +@dataclass +class BuildData: + """Data fetched from Jenkins about a build.""" + + job_name: str + build_number: str + result: str + duration: str + timestamp: str + stages: str + logs: str + + +def _get_max_log_size() -> int: + """Get max log size from env or use default.""" + try: + return int(os.getenv("MAX_LOG_SIZE", DEFAULT_MAX_LOG_SIZE)) + except ValueError: + return DEFAULT_MAX_LOG_SIZE + + +def _get_required_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise ValueError(f"{name} environment variable is required") + return value + + +def _jenkins_api_request( + url: str, jenkins_url: str, user: str, token: str +) -> dict | str | None: + """Make a Jenkins API request. + + Returns: + dict for JSON responses, str for text responses, None on failure. + """ + if not url.startswith("http"): + url = f"{jenkins_url.rstrip('/')}/{url.lstrip('/')}" + + credentials = base64.b64encode(f"{user}:{token}".encode()).decode() + + request = urllib.request.Request(url) + request.add_header("Authorization", f"Basic {credentials}") + request.add_header("Accept", "application/json") + + try: + with urllib.request.urlopen(request, timeout=60) as response: + content_type = response.headers.get("Content-Type", "") + data = response.read().decode("utf-8", errors="replace") + if "application/json" in content_type: + return json.loads(data) + return data + except urllib.error.HTTPError as e: + logger.warning(f"Jenkins API request failed: HTTP {e.code} {e.reason}") + return None + except urllib.error.URLError as e: + logger.warning(f"Jenkins API request failed: {e.reason}") + return None + + +def get_build_info( + jenkins_url: str, user: str, token: str, job_name: str, build_number: str +) -> dict | None: + """Fetch build information from Jenkins. + + Returns: + dict on success, None on failure. + """ + url = f"/job/{job_name}/{build_number}/api/json" + result = _jenkins_api_request(url, jenkins_url, user, token) + if result is None: + return None + return result if isinstance(result, dict) else None + + +# Default error patterns for log analysis. Configurable via ERROR_PATTERNS_FILE env var. +# Each pattern should be a valid regex. Patterns are checked in order (first match wins). +DEFAULT_ERROR_PATTERNS = [ + r"(?i)error[:\s]", + r"(?i)failed[:\s]", + r"(?i)exception[:\s]", + r"(?i)traceback", + r"(?i)fatal[:\s]", + r"(?i)build failed", + r"(?i)RejectedAccessException", + r"(?i)CredentialsNotFoundException", + r"(?i)timeout", +] + + +def _load_error_patterns() -> list[str]: + """Load error patterns from file or use defaults. + + Configurable via ERROR_PATTERNS_FILE env var pointing to a JSON file + containing a list of regex patterns. This allows customization for + different languages/frameworks (Maven, Gradle, NPM, etc.). + + Example patterns file: + [ + "(?i)BUILD FAILURE", // Maven + "(?i)FAILURE: Build failed", // Gradle + "(?i)npm ERR!" // NPM + ] + """ + patterns_file = os.getenv("ERROR_PATTERNS_FILE") + if patterns_file: + try: + with open(patterns_file, "r") as f: + patterns = json.load(f) + if isinstance(patterns, list) and all(isinstance(p, str) for p in patterns): + logger.info(f"Loaded {len(patterns)} custom error patterns from {patterns_file}") + return patterns + else: + logger.warning("Invalid patterns file format, using defaults") + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"Failed to load patterns file {patterns_file}: {e}, using defaults") + + return DEFAULT_ERROR_PATTERNS + + +def _compile_error_patterns(patterns: list[str]) -> list[re.Pattern[str]]: + """Compile regex patterns once before scanning logs.""" + compiled_patterns: list[re.Pattern[str]] = [] + for pattern in patterns: + try: + compiled_patterns.append(re.compile(pattern)) + except re.error as exc: + logger.warning(f"Skipping invalid error pattern {pattern!r}: {exc}") + return compiled_patterns + + +def _find_error_context(logs: str, max_size: int, error_patterns: list[str] | None = None) -> str | None: + """Extract context around error patterns in logs. + + Args: + logs: The full log content + max_size: Maximum characters to return + error_patterns: Custom patterns to use (defaults to loaded patterns) + """ + compiled_patterns = _compile_error_patterns( + error_patterns if error_patterns is not None else _load_error_patterns() + ) + + lines = logs.split("\n") + + error_indices = [] + for i, line in enumerate(lines): + for pattern in compiled_patterns: + if pattern.search(line): + error_indices.append(i) + break + + if error_indices: + first_error = error_indices[0] + total_lines = len(lines) + avg_line_len = len(logs) // max(total_lines, 1) + context_lines = max_size // max(avg_line_len, 50) + + before_context = int(context_lines * 0.2) + after_context = context_lines - before_context + + start = max(0, first_error - before_context) + end = min(total_lines, first_error + after_context) + + extracted_lines = lines[start:end] + extracted = "\n".join(extracted_lines) + + if len(extracted) > max_size: + extracted = extracted[:max_size] + + truncation_note = "" + if start > 0 or end < total_lines: + truncation_note = ( + f"\n[Context extracted around error at line {first_error + 1}. " + f"Original log: {total_lines} lines]\n\n" + ) + return truncation_note + extracted + + return None + + +def _truncate_logs(logs: str, max_size: int) -> str: + """Truncate logs intelligently, preserving error context.""" + context_result = _find_error_context(logs, max_size) + if context_result: + return context_result + + first_chunk = int(max_size * 0.4) + last_chunk = max_size - first_chunk + truncated_chars = len(logs) - max_size + return ( + logs[:first_chunk] + + f"\n\n[... {truncated_chars:,} characters truncated ...]\n\n" + + logs[-last_chunk:] + ) + + +def get_console_output( + jenkins_url: str, user: str, token: str, job_name: str, build_number: str +) -> str | None: + """Fetch console output from a Jenkins build. + + If logs exceed MAX_LOG_SIZE, uses context-aware truncation. + + Returns: + Logs string on success, None on failure. + """ + url = f"/job/{job_name}/{build_number}/consoleText" + result = _jenkins_api_request(url, jenkins_url, user, token) + if result is None: + return None + + output = result if isinstance(result, str) else "" + max_size = _get_max_log_size() + if len(output) > max_size: + output = _truncate_logs(output, max_size) + + return output + + +def get_pipeline_stages( + jenkins_url: str, user: str, token: str, job_name: str, build_number: str +) -> str: + """Fetch pipeline stage information using the Workflow API.""" + url = f"/job/{job_name}/{build_number}/wfapi/describe" + result = _jenkins_api_request(url, jenkins_url, user, token) + + if not isinstance(result, dict) or "stages" not in result: + return "Pipeline stage information not available (may not be a Pipeline job)." + + stages = result.get("stages", []) + if not stages: + return "No stages found." + + lines = [] + for stage in stages: + name = stage.get("name", "Unknown") + status = stage.get("status", "UNKNOWN") + duration_ms = stage.get("durationMillis", 0) + duration_s = duration_ms / 1000 + + icon = "✅" if status == "SUCCESS" else "❌" if status == "FAILED" else "⚠️" + lines.append(f"{icon} **{name}**: {status} ({duration_s:.1f}s)") + + return "\n".join(lines) + + +def get_last_failed_build( + jenkins_url: str, user: str, token: str, job_name: str +) -> str | None: + """Get the last failed build number for a job.""" + url = f"/job/{job_name}/lastFailedBuild/api/json" + result = _jenkins_api_request(url, jenkins_url, user, token) + if isinstance(result, dict) and "number" in result: + return str(result["number"]) + return None + + +def format_duration(duration_ms: int) -> str: + """Format duration in milliseconds to human-readable string.""" + seconds = duration_ms // 1000 + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + seconds = seconds % 60 + if minutes < 60: + return f"{minutes}m {seconds}s" + hours = minutes // 60 + minutes = minutes % 60 + return f"{hours}h {minutes}m {seconds}s" + + +def format_timestamp(timestamp_ms: int) -> str: + """Format timestamp in milliseconds to human-readable string.""" + dt = datetime.fromtimestamp(timestamp_ms / 1000) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def validate_and_load_config() -> Config: + """Validate required environment variables and return configuration. + + Raises: + SystemExit: If required environment variables are missing. + """ + try: + api_key = _get_required_env("LLM_API_KEY") + jenkins_url = _get_required_env("JENKINS_URL") + jenkins_user = _get_required_env("JENKINS_USER") + jenkins_token = _get_required_env("JENKINS_API_TOKEN") + except ValueError as e: + logger.error(str(e)) + sys.exit(1) + + return Config( + api_key=api_key, + model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), + base_url=os.getenv("LLM_BASE_URL"), + jenkins_url=jenkins_url, + jenkins_user=jenkins_user, + jenkins_token=jenkins_token, + ) + + +def fetch_build_data( + config: Config, job_name: str, build_number: str +) -> BuildData: + """Fetch build data from Jenkins. + + Raises: + SystemExit: If build data cannot be fetched. + """ + build_info = get_build_info( + config.jenkins_url, config.jenkins_user, config.jenkins_token, + job_name, build_number + ) + if build_info is None: + logger.error("Failed to fetch build info (API error)") + sys.exit(1) + + build_result = build_info.get("result", "UNKNOWN") + duration = format_duration(build_info.get("duration", 0)) + timestamp = format_timestamp(build_info.get("timestamp", 0)) + + logger.info(f"Build result: {build_result}") + logger.info(f"Duration: {duration}") + + stages = get_pipeline_stages( + config.jenkins_url, config.jenkins_user, config.jenkins_token, + job_name, build_number + ) + logs = get_console_output( + config.jenkins_url, config.jenkins_user, config.jenkins_token, + job_name, build_number + ) + + if logs is None: + logger.warning("Failed to fetch console output (API error)") + logs = "Console output fetch failed." + elif not logs: + logger.warning("No console output found") + logs = "No console output available." + + return BuildData( + job_name=job_name, + build_number=build_number, + result=build_result, + duration=duration, + timestamp=timestamp, + stages=stages, + logs=logs, + ) + + +def create_agent(config: Config) -> Agent: + """Create and configure the debug agent.""" + _require_openhands_runtime() + + llm_config = { + "model": config.model, + "api_key": config.api_key, + "usage_id": "jenkins_debug_agent", + "drop_params": True, + } + if config.base_url: + llm_config["base_url"] = config.base_url + + llm = LLM(**llm_config) + + cwd = os.getcwd() + project_skills = load_project_skills(cwd) + logger.info( + f"Loaded {len(project_skills)} project skills: " + f"{[s.name for s in project_skills]}" + ) + + agent_context = AgentContext( + load_public_skills=True, + skills=project_skills, + ) + + return Agent( + llm=llm, + tools=get_default_tools(enable_browser=False), + agent_context=agent_context, + system_prompt_kwargs={"cli_mode": True}, + condenser=get_default_condenser( + llm=llm.model_copy(update={"usage_id": "condenser"}) + ), + ) + + +def execute_debug_agent(config: Config, build_data: BuildData) -> Conversation: + """Build prompt, create agent, and run debug analysis.""" + prompt = format_prompt( + jenkins_url=config.jenkins_url, + job_name=build_data.job_name, + build_number=build_data.build_number, + build_result=build_data.result, + duration=build_data.duration, + timestamp=build_data.timestamp, + stages=build_data.stages, + logs=build_data.logs, + ) + + agent = create_agent(config) + + secrets = { + "LLM_API_KEY": config.api_key, + "JENKINS_API_TOKEN": config.jenkins_token, + } + + cwd = os.getcwd() + conversation = Conversation( + agent=agent, + workspace=cwd, + secrets=secrets, + ) + + logger.info("Starting Jenkins build failure analysis...") + conversation.send_message(prompt) + conversation.run() + + return conversation + + +def log_cost_summary(conversation: Conversation) -> None: + """Print cost information.""" + metrics = conversation.conversation_stats.get_combined_metrics() + print("\n=== Jenkins Debug Cost Summary ===") + print(f"Total Cost: ${metrics.accumulated_cost:.6f}") + if metrics.accumulated_token_usage: + token_usage = metrics.accumulated_token_usage + print(f"Prompt Tokens: {token_usage.prompt_tokens}") + print(f"Completion Tokens: {token_usage.completion_tokens}") + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Debug Jenkins CI failures") + parser.add_argument("--job", required=True, help="Jenkins job name") + parser.add_argument("--build", help="Build number to debug") + parser.add_argument( + "--last-failed", action="store_true", help="Debug the last failed build" + ) + return parser.parse_args() + + +def resolve_build_number(config: Config, job_name: str, args: argparse.Namespace) -> str: + """Resolve which build number to debug. + + Raises: + SystemExit: If no build number can be determined. + """ + if args.build: + return args.build + + build_number = get_last_failed_build( + config.jenkins_url, config.jenkins_user, config.jenkins_token, job_name + ) + if not build_number: + logger.error(f"No failed builds found for job '{job_name}'") + sys.exit(1) + return build_number + + +def main(): + """Run the Jenkins debug agent.""" + args = parse_args() + logger.info("Starting Jenkins debug process...") + + config = validate_and_load_config() + job_name = args.job + build_number = resolve_build_number(config, job_name, args) + + logger.info(f"Debugging build {build_number} of job '{job_name}'") + + try: + build_data = fetch_build_data(config, job_name, build_number) + conversation = execute_debug_agent(config, build_data) + log_cost_summary(conversation) + logger.info("Jenkins debug analysis completed successfully") + except JenkinsAPIError as e: + logger.error(str(e)) + sys.exit(1) + except Exception as e: + logger.error(f"Jenkins debug failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/debug-jenkins-ci/scripts/prompt.py b/plugins/debug-jenkins-ci/scripts/prompt.py new file mode 100644 index 0000000..99cc2f7 --- /dev/null +++ b/plugins/debug-jenkins-ci/scripts/prompt.py @@ -0,0 +1,155 @@ +""" +Jenkins CI Debug Prompt Template + +This module contains the prompt template used by the OpenHands agent +for debugging Jenkins CI/CD pipeline failures. + +The template includes: +- {job_name} - The Jenkins job name +- {build_number} - The build number +- {jenkins_url} - The Jenkins server URL +- {build_result} - The build result (FAILURE, UNSTABLE, etc.) +- {stages} - Pipeline stages information (if applicable) +- {logs} - The console output from the build +""" + +from __future__ import annotations + +import re +from urllib.parse import urlparse + +PROMPT = """/debug-jenkins-ci + +Debug the Jenkins build failure below and identify the root cause. + +## Build Information + +- **Jenkins URL**: {jenkins_url} +- **Job**: {job_name} +- **Build Number**: {build_number} +- **Result**: {build_result} +- **Duration**: {duration} +- **Timestamp**: {timestamp} + +## Pipeline Stages + +{stages} + +## Console Output + +The following is the console output from the failed build. Analyze it to identify the root cause. + +``` +{logs} +``` + +## Your Task + +1. **Analyze the console output** to identify the specific error(s) that caused the failure +2. **Determine the root cause** - is it a code issue, Jenkinsfile problem, dependency issue, or infrastructure problem? +3. **Provide actionable fixes** with specific Jenkinsfile changes or commands +4. **Consider common Jenkins issues**: + - Script sandbox violations (RejectedAccessException) + - Credential issues (CredentialsNotFoundException) + - Agent/node problems + - Timeout issues + - Flaky tests + +Provide clear, actionable guidance that helps developers fix the issue quickly. + +If you need more context, you can use curl to query the Jenkins API: + +```bash +# Get build details +curl -u "$JENKINS_USER:$JENKINS_API_TOKEN" "{jenkins_url}/job/{job_name}/{build_number}/api/json" + +# Get pipeline stages (Blue Ocean API) +curl -u "$JENKINS_USER:$JENKINS_API_TOKEN" "{jenkins_url}/blue/rest/organizations/jenkins/pipelines/{job_name}/runs/{build_number}/nodes/" +``` +""" + +# Validation patterns for inputs used in API calls +BUILD_NUMBER_PATTERN = re.compile(r"^[0-9]+$") +JOB_NAME_PATTERN = re.compile(r"^[\w./-]+$") + + +class PromptValidationError(ValueError): + """Raised when prompt inputs fail validation.""" + + pass + + +def _validate_jenkins_url(jenkins_url: str) -> None: + """Validate jenkins_url is a valid URL.""" + try: + result = urlparse(jenkins_url) + if not all([result.scheme, result.netloc]): + raise PromptValidationError( + f"Invalid jenkins_url: '{jenkins_url}'. Expected valid URL." + ) + except Exception: + raise PromptValidationError( + f"Invalid jenkins_url: '{jenkins_url}'. Expected valid URL." + ) + + +def _validate_job_name(job_name: str) -> None: + """Validate job_name contains only safe characters.""" + if not JOB_NAME_PATTERN.match(job_name): + raise PromptValidationError( + f"Invalid job_name: '{job_name}'. " + "Expected alphanumeric characters, dots, hyphens, underscores, or slashes." + ) + + +def _validate_build_number(build_number: str) -> None: + """Validate build_number is numeric.""" + if not BUILD_NUMBER_PATTERN.match(build_number): + raise PromptValidationError( + f"Invalid build_number: '{build_number}'. Expected numeric value." + ) + + +def format_prompt( + jenkins_url: str, + job_name: str, + build_number: str, + build_result: str, + duration: str, + timestamp: str, + stages: str, + logs: str, +) -> str: + """Format the Jenkins debug prompt with all parameters. + + Args: + jenkins_url: Jenkins server URL + job_name: Name of the Jenkins job + build_number: Build number (must be numeric) + build_result: Build result (FAILURE, UNSTABLE, etc.) + duration: Build duration + timestamp: Build timestamp + stages: Formatted pipeline stages info + logs: Console output from the build + + Returns: + Formatted prompt string + + Raises: + PromptValidationError: If inputs fail validation. + """ + # Validate inputs used in API calls + _validate_jenkins_url(jenkins_url) + _validate_job_name(job_name) + _validate_build_number(build_number) + + return PROMPT.format( + jenkins_url=jenkins_url, + job_name=job_name, + build_number=build_number, + build_result=build_result, + duration=duration, + timestamp=timestamp, + stages=stages, + logs=logs, + ) diff --git a/plugins/debug-jenkins-ci/skills/debug-jenkins-ci b/plugins/debug-jenkins-ci/skills/debug-jenkins-ci new file mode 120000 index 0000000..0cc0fad --- /dev/null +++ b/plugins/debug-jenkins-ci/skills/debug-jenkins-ci @@ -0,0 +1 @@ +../../skills/debug-jenkins-ci \ No newline at end of file diff --git a/skills/debug-github-ci/README.md b/skills/debug-github-ci/README.md new file mode 100644 index 0000000..5b5833d --- /dev/null +++ b/skills/debug-github-ci/README.md @@ -0,0 +1,44 @@ +# Debug GitHub CI + +Debug GitHub Actions CI failures by fetching logs, identifying root causes, and suggesting fixes. + +## Triggers + +This skill is activated by the following keywords: + +- `/debug-github-ci` +- `github ci failed` +- `github actions failed` +- `workflow failed` +- `ci failure github` + +## Overview + +This skill provides a systematic approach to debugging GitHub Actions workflow failures: + +1. **Identify** the failed workflow run using `gh run list` +2. **Fetch** detailed logs with `gh run view --log-failed` +3. **Analyze** error patterns to find root causes +4. **Fix** the underlying issue +5. **Verify** with `gh run rerun --failed` + +## Prerequisites + +- `GITHUB_TOKEN` environment variable (automatically available) +- GitHub CLI (`gh`) installed (preferred) or `curl` as fallback + +## Quick Example + +```bash +# Find and debug the most recent CI failure +gh run list --status failure --limit 1 +gh run view --log-failed +``` + +## Common Use Cases + +- Test failures in pull request checks +- Build failures due to dependency issues +- Deployment failures from missing secrets +- Timeout issues in long-running jobs +- Matrix build failures across different environments diff --git a/skills/debug-github-ci/SKILL.md b/skills/debug-github-ci/SKILL.md new file mode 100644 index 0000000..915ce99 --- /dev/null +++ b/skills/debug-github-ci/SKILL.md @@ -0,0 +1,173 @@ +--- +name: debug-github-ci +description: Debug GitHub Actions CI failures by fetching logs, identifying root causes, and suggesting fixes. +triggers: +- /debug-github-ci +- github ci failed +- github actions failed +- workflow failed +- ci failure github +--- + +# Debug GitHub CI Failure + +Diagnose and fix GitHub Actions workflow failures by fetching logs, analyzing errors, and providing actionable fixes. + +## Quick Start + +```bash +# Get recent failed workflow runs +gh run list --status failure --limit 5 + +# View logs for a specific run +gh run view --log-failed + +# Rerun failed jobs +gh run rerun --failed +``` + +## Step-by-Step Debugging Workflow + +### 1. Identify the Failed Run + +```bash +# List recent workflow runs with status +gh run list --limit 10 + +# Filter by workflow name +gh run list --workflow "CI" --status failure --limit 5 + +# Get run ID from a PR's checks +gh pr checks +``` + +### 2. Fetch Failure Details + +```bash +# View run summary (shows failed jobs) +gh run view + +# Download full logs +gh run view --log-failed + +# For verbose logs (all jobs, not just failed) +gh run view --log +``` + +### 3. Analyze the Error + +Common failure patterns to look for: + +| Pattern | Likely Cause | +|---------|--------------| +| `exit code 1` in test step | Test failure - check test output | +| `ENOENT` / `file not found` | Missing dependency or build artifact | +| `permission denied` | Incorrect file permissions or missing secrets | +| `rate limit exceeded` | API throttling - add retry logic or caching | +| `out of memory` | Increase runner memory or optimize build | +| `timeout` | Increase timeout or optimize slow steps | +| `secret not found` | Missing or misspelled secret name | + +### 4. Common Fixes + +#### Test Failures +```bash +# Run tests locally first +npm test # or pytest, cargo test, etc. + +# Check if tests pass with verbose output +npm test -- --verbose +``` + +#### Dependency Issues +```bash +# Clear caches and reinstall +rm -rf node_modules package-lock.json +npm install + +# For Python +pip install --upgrade -r requirements.txt +``` + +#### Environment Differences +```yaml +# Pin versions in workflow +- uses: actions/setup-node@v4 + with: + node-version: '20.x' # Be specific +``` + +### 5. Rerun and Verify + +```bash +# Rerun only failed jobs (faster) +gh run rerun --failed + +# Rerun entire workflow +gh run rerun + +# Watch the run in real-time +gh run watch +``` + +## API Fallback (curl) + +If `gh` CLI is unavailable: + +```bash +# List workflow runs +curl -H "Authorization: token $GITHUB_TOKEN" \ + "https://api.github.com/repos/{owner}/{repo}/actions/runs?status=failure" + +# Get run details +curl -H "Authorization: token $GITHUB_TOKEN" \ + "https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}" + +# Download logs (returns redirect URL) +curl -H "Authorization: token $GITHUB_TOKEN" \ + "https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}/logs" +``` + +## Workflow File Debugging + +```bash +# Validate workflow syntax +gh workflow view + +# Check workflow file location +ls -la .github/workflows/ + +# Common workflow issues: +# - Incorrect indentation (YAML is whitespace-sensitive) +# - Missing 'on:' trigger +# - Invalid action version (use @v4 not @latest) +``` + +## Advanced: Matrix Build Failures + +```bash +# View specific matrix combination +gh run view --job + +# List all jobs in a run +gh api repos/{owner}/{repo}/actions/runs/{run_id}/jobs +``` + +## Debugging Checklist + +1. ✅ **Fetch logs**: `gh run view --log-failed` +2. ✅ **Identify failing step**: Look for the first red ❌ in output +3. ✅ **Check error message**: Read the actual error, not just "failed" +4. ✅ **Reproduce locally**: Try to replicate the failure on your machine +5. ✅ **Check recent changes**: Did a recent commit introduce the failure? +6. ✅ **Verify secrets**: Ensure all required secrets are configured +7. ✅ **Check dependencies**: Are versions pinned and compatible? +8. ✅ **Review workflow file**: Is the YAML valid and logic correct? + +## Summary + +1. Use `gh run list --status failure` to find failed runs +2. Use `gh run view --log-failed` to fetch logs +3. Identify the root cause from error messages +4. Fix the issue locally and verify +5. Push changes and use `gh run rerun --failed` to verify the fix diff --git a/skills/debug-jenkins-ci/README.md b/skills/debug-jenkins-ci/README.md new file mode 100644 index 0000000..64c04a2 --- /dev/null +++ b/skills/debug-jenkins-ci/README.md @@ -0,0 +1,55 @@ +# Debug Jenkins CI + +Debug Jenkins CI/CD pipeline failures by fetching logs, identifying root causes, and suggesting fixes. + +## Triggers + +This skill is activated by the following keywords: + +- `/debug-jenkins-ci` +- `jenkins ci failed` +- `jenkins build failed` +- `jenkins pipeline failed` +- `ci failure jenkins` + +## Overview + +This skill provides a systematic approach to debugging Jenkins pipeline failures: + +1. **Identify** the failed build using Jenkins API +2. **Fetch** console output and stage logs +3. **Analyze** error patterns to find root causes +4. **Fix** the underlying issue (Jenkinsfile, credentials, code) +5. **Verify** by triggering a new build + +## Prerequisites + +Set the following environment variables: + +```bash +export JENKINS_URL="https://jenkins.example.com" +export JENKINS_USER="your-username" +export JENKINS_API_TOKEN="your-api-token" +``` + +To get an API token: +1. Log in to Jenkins +2. Click your username → Configure +3. Add new API Token + +## Quick Example + +```bash +# Get last failed build console output +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/lastFailedBuild/consoleText" +``` + +## Common Use Cases + +- Pipeline script errors in Jenkinsfile +- Build failures due to dependency issues +- Agent/node connectivity problems +- Credential and permission issues +- Timeout failures in long-running jobs +- Flaky test failures with retry logic diff --git a/skills/debug-jenkins-ci/SKILL.md b/skills/debug-jenkins-ci/SKILL.md new file mode 100644 index 0000000..a26808f --- /dev/null +++ b/skills/debug-jenkins-ci/SKILL.md @@ -0,0 +1,217 @@ +--- +name: debug-jenkins-ci +description: Debug Jenkins CI/CD pipeline failures by fetching logs, identifying root causes, and suggesting fixes. +triggers: +- /debug-jenkins-ci +- jenkins ci failed +- jenkins build failed +- jenkins pipeline failed +- ci failure jenkins +--- + +# Debug Jenkins CI Failure + +Diagnose and fix Jenkins pipeline failures by fetching logs, analyzing errors, and providing actionable fixes. + +## Prerequisites + +Set the following environment variables: + +```bash +export JENKINS_URL="https://jenkins.example.com" +export JENKINS_USER="your-username" +export JENKINS_API_TOKEN="your-api-token" +``` + +## Quick Start + +```bash +# Get recent builds for a job +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/api/json?tree=builds[number,result,timestamp]" + +# Get console output for a failed build +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/{build_number}/consoleText" +``` + +## Step-by-Step Debugging Workflow + +### 1. Identify the Failed Build + +```bash +# List recent builds with status +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/api/json?tree=builds[number,result,timestamp,duration]" | jq '.builds[:5]' + +# Get last failed build number +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/lastFailedBuild/api/json" | jq '.number' + +# For multibranch pipelines +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{pipeline_name}/job/{branch_name}/api/json?tree=builds[number,result]" +``` + +### 2. Fetch Failure Details + +```bash +# Get full console output +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/{build_number}/consoleText" + +# Get console output with timestamps +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/{build_number}/timestamps/?elapsed=HH:mm:ss&appendLog" + +# For Pipeline jobs - get stage logs +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/{build_number}/wfapi/describe" | jq '.stages' +``` + +### 3. Analyze the Error + +Common Jenkins failure patterns: + +| Pattern | Likely Cause | +|---------|--------------| +| `ERROR: script returned exit code 1` | Script/command failure | +| `java.io.IOException` | Agent connectivity or disk issues | +| `No such DSL method` | Missing Jenkins plugin or typo in Jenkinsfile | +| `Timeout exceeded` | Build took too long - optimize or increase timeout | +| `CredentialsNotFoundException` | Missing or misconfigured credentials | +| `WorkspaceNotAllocated` | Agent workspace issues | +| `UNSTABLE` | Tests passed but with warnings/flaky tests | +| `RejectedAccessException` | Script security sandbox violation | + +### 4. Pipeline-Specific Debugging + +#### Get Failed Stage Information +```bash +# List all stages with status +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/{build_number}/wfapi/describe" | \ + jq '.stages[] | {name: .name, status: .status, durationMillis: .durationMillis}' + +# Get logs for a specific stage +curl -s -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/{build_number}/execution/node/{node_id}/wfapi/log" +``` + +#### Common Pipeline Fixes +```groovy +// Add timeout to prevent hung builds +timeout(time: 30, unit: 'MINUTES') { + sh 'npm test' +} + +// Retry flaky steps +retry(3) { + sh 'npm install' +} + +// Handle credentials properly +withCredentials([string(credentialsId: 'my-secret', variable: 'SECRET')]) { + sh 'echo "Using secret"' +} +``` + +### 5. Rerun and Verify + +```bash +# Trigger a new build +curl -X POST -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/build" + +# Trigger with parameters +curl -X POST -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + "$JENKINS_URL/job/{job_name}/buildWithParameters?PARAM1=value1" + +# Replay a Pipeline build (with modified script) +# Use the UI: $JENKINS_URL/job/{job_name}/{build_number}/replay +``` + +## Jenkinsfile Debugging + +```groovy +// Add debug output +pipeline { + agent any + options { + timestamps() // Add timestamps to console + } + stages { + stage('Debug') { + steps { + sh 'env | sort' // Print all environment variables + sh 'pwd && ls -la' // Show workspace contents + } + } + } +} +``` + +### Validate Jenkinsfile Syntax +```bash +# Using Jenkins CLI +java -jar jenkins-cli.jar -s $JENKINS_URL declarative-linter < Jenkinsfile + +# Or via API +curl -X POST -u "$JENKINS_USER:$JENKINS_API_TOKEN" \ + -F "jenkinsfile= str: + lines = [] + for i in range(1, total_lines + 1): + prefix = f"line {i:02d} " + if i == error_line: + lines.append(prefix + "ERROR: boom " + "x" * 48) + else: + lines.append(prefix + "x" * 60) + return "\n".join(lines) + + +def test_find_error_context_extracts_around_first_match(): + logs = make_logs(total_lines=20, error_line=10) + + result = agent_script._find_error_context( + logs, + max_size=350, + error_patterns=[r"ERROR"], + ) + + assert result is not None + assert "[Context extracted around error at line 10. Original log: 20 lines]" in result + assert "line 09" in result + assert "line 10 ERROR: boom" in result + assert "line 13" in result + assert "line 08" not in result + + +def test_find_error_context_skips_invalid_patterns(caplog): + logs = make_logs(total_lines=12, error_line=6) + + with caplog.at_level(logging.WARNING): + result = agent_script._find_error_context( + logs, + max_size=300, + error_patterns=[r"(", r"ERROR"], + ) + + assert result is not None + assert "line 06 ERROR: boom" in result + assert "Skipping invalid error pattern" in caplog.text + + +def test_truncate_logs_falls_back_to_head_tail_when_no_error_context(): + logs = "abcdefghijklmnopqrstuvwxyz" * 4 + + result = agent_script._truncate_logs(logs, max_size=30) + + assert result.startswith(logs[:12]) + assert result.endswith(logs[-18:]) + assert "[... 74 characters truncated ...]" in result + + +def test_format_failed_jobs_lists_only_failed_jobs_and_steps(): + jobs = [ + { + "name": "lint", + "conclusion": "success", + "steps": [{"name": "ruff", "conclusion": "success"}], + }, + { + "name": "tests", + "conclusion": "failure", + "steps": [ + {"name": "setup", "conclusion": "success"}, + {"name": "pytest", "conclusion": "failure"}, + {"name": "upload", "conclusion": "failure"}, + ], + }, + { + "name": "docs", + "conclusion": "failure", + "steps": [], + }, + ] + + result = agent_script.format_failed_jobs(jobs) + + assert "lint" not in result + assert "### Job: tests" in result + assert " - pytest" in result + assert " - upload" in result + assert "### Job: docs" in result + + +def test_format_failed_jobs_handles_no_failures(): + jobs = [{"name": "lint", "conclusion": "success", "steps": []}] + + assert agent_script.format_failed_jobs(jobs) == "No failed jobs found." diff --git a/tests/test_debug_jenkins_ci_helpers.py b/tests/test_debug_jenkins_ci_helpers.py new file mode 100644 index 0000000..3945707 --- /dev/null +++ b/tests/test_debug_jenkins_ci_helpers.py @@ -0,0 +1,155 @@ +import importlib.util +import logging +import sys +from datetime import datetime +from pathlib import Path + +import pytest + +SCRIPTS_DIR = ( + Path(__file__).resolve().parents[1] + / "plugins" + / "debug-jenkins-ci" + / "scripts" +) +AGENT_SCRIPT_PATH = SCRIPTS_DIR / "agent_script.py" +PROMPT_PATH = SCRIPTS_DIR / "prompt.py" + + +def load_module(name: str, path: Path): + sys.modules.pop(name, None) + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +jenkins_agent_script = load_module( + "debug_jenkins_ci_agent_script", + AGENT_SCRIPT_PATH, +) +jenkins_prompt = load_module("debug_jenkins_ci_prompt", PROMPT_PATH) + + +def make_logs(total_lines: int, error_line: int | None = None) -> str: + lines = [] + for i in range(1, total_lines + 1): + prefix = f"line {i:02d} " + if i == error_line: + lines.append(prefix + "ERROR: boom " + "x" * 48) + else: + lines.append(prefix + "x" * 60) + return "\n".join(lines) + + +def test_jenkins_find_error_context_extracts_around_first_match(): + logs = make_logs(total_lines=20, error_line=10) + + result = jenkins_agent_script._find_error_context( + logs, + max_size=350, + error_patterns=[r"ERROR"], + ) + + assert result is not None + assert "[Context extracted around error at line 10. Original log: 20 lines]" in result + assert "line 09" in result + assert "line 10 ERROR: boom" in result + assert "line 13" in result + assert "line 08" not in result + + +def test_jenkins_find_error_context_skips_invalid_patterns(caplog): + logs = make_logs(total_lines=12, error_line=6) + + with caplog.at_level(logging.WARNING): + result = jenkins_agent_script._find_error_context( + logs, + max_size=300, + error_patterns=[r"(", r"ERROR"], + ) + + assert result is not None + assert "line 06 ERROR: boom" in result + assert "Skipping invalid error pattern" in caplog.text + + +def test_jenkins_truncate_logs_falls_back_to_head_tail_when_no_error_context(): + logs = "abcdefghijklmnopqrstuvwxyz" * 4 + + result = jenkins_agent_script._truncate_logs(logs, max_size=30) + + assert result.startswith(logs[:12]) + assert result.endswith(logs[-18:]) + assert "[... 74 characters truncated ...]" in result + + +def test_format_duration_handles_seconds_minutes_and_hours(): + assert jenkins_agent_script.format_duration(45_000) == "45s" + assert jenkins_agent_script.format_duration(61_000) == "1m 1s" + assert jenkins_agent_script.format_duration(3_661_000) == "1h 1m 1s" + + +def test_format_timestamp_uses_local_datetime_format(): + timestamp_ms = 1_700_000_000_000 + expected = datetime.fromtimestamp(timestamp_ms / 1000).strftime("%Y-%m-%d %H:%M:%S") + + assert jenkins_agent_script.format_timestamp(timestamp_ms) == expected + + +def test_format_prompt_renders_valid_inputs(): + prompt = jenkins_prompt.format_prompt( + jenkins_url="https://jenkins.example.com", + job_name="folder/my-job", + build_number="123", + build_result="FAILURE", + duration="1m 1s", + timestamp="2025-01-01 12:00:00", + stages="❌ **Test**: FAILED (12.0s)", + logs="Traceback: boom", + ) + + assert "https://jenkins.example.com" in prompt + assert "folder/my-job" in prompt + assert "123" in prompt + assert "Traceback: boom" in prompt + + +def test_format_prompt_rejects_invalid_inputs(): + with pytest.raises(jenkins_prompt.PromptValidationError): + jenkins_prompt.format_prompt( + jenkins_url="not-a-url", + job_name="folder/my-job", + build_number="123", + build_result="FAILURE", + duration="1m 1s", + timestamp="2025-01-01 12:00:00", + stages="none", + logs="logs", + ) + + with pytest.raises(jenkins_prompt.PromptValidationError): + jenkins_prompt.format_prompt( + jenkins_url="https://jenkins.example.com", + job_name="folder/my job", + build_number="123", + build_result="FAILURE", + duration="1m 1s", + timestamp="2025-01-01 12:00:00", + stages="none", + logs="logs", + ) + + with pytest.raises(jenkins_prompt.PromptValidationError): + jenkins_prompt.format_prompt( + jenkins_url="https://jenkins.example.com", + job_name="folder/my-job", + build_number="12a", + build_result="FAILURE", + duration="1m 1s", + timestamp="2025-01-01 12:00:00", + stages="none", + logs="logs", + )