Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

692 lines
23 KiB
Python

"""GitHub Actions workflow investigation tools - MCP-direct implementation."""
from __future__ import annotations
import json
from typing import Any, cast
from core.tool_framework.tool_decorator import tool
from core.tool_framework.utils.code_host_unavailable import code_host_unavailable_payload
from integrations.github.helpers import (
github_creds,
github_source_available,
normalize_github_tool_result,
resolve_github_mcp_config,
)
from integrations.github.mcp import call_github_mcp_tool
def _extract_json_text(result: dict[str, Any]) -> dict[str, Any] | str | None:
text = str(result.get("text") or "").strip()
if not text:
return None
try:
parsed = json.loads(text)
return cast(dict[str, Any], parsed)
except json.JSONDecodeError:
return text
def _extract_list(result: dict[str, Any], key: str) -> list[dict[str, Any]]:
"""Extract list of items from MCP tool result."""
json_result = _extract_json_text(result)
items: list[Any] = []
if isinstance(json_result, dict):
value = json_result.get(key)
if isinstance(value, list):
items = value
return [item for item in items if isinstance(item, dict)]
def _extract_workflow_jobs(result: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract workflow jobs from MCP tool result."""
json_result = _extract_json_text(result)
if isinstance(json_result, dict) and "jobs" in json_result:
jobs_raw = json_result["jobs"]
if isinstance(jobs_raw, dict) and "jobs" in jobs_raw:
target_list = jobs_raw["jobs"]
else:
target_list = jobs_raw
if isinstance(target_list, list):
return [_normalize_job(job) for job in target_list if isinstance(job, dict)]
return []
def _extract_log_text(result: dict[str, Any]) -> str:
"""Extract log text from MCP tool result."""
json_result = _extract_json_text(result)
if isinstance(json_result, dict) and "logs_content" in json_result:
return str(json_result["logs_content"] or "").strip()
return str(result.get("text") or "").strip()
def _normalize_step(step: dict[str, Any]) -> dict[str, Any]:
"""Normalize step data."""
return {
"name": step.get("name", ""),
"status": step.get("status", ""),
"conclusion": step.get("conclusion", ""),
"number": step.get("number"),
"started_at": step.get("started_at", ""),
"completed_at": step.get("completed_at", ""),
}
def _normalize_job(job: dict[str, Any]) -> dict[str, Any]:
"""Normalize job data including steps."""
steps_raw = job.get("steps")
steps: list[dict[str, Any]] = []
if isinstance(steps_raw, list):
for item in steps_raw:
if isinstance(item, dict):
steps.append(_normalize_step(item))
return {
"id": job.get("id"),
"run_id": job.get("run_id"),
"name": job.get("name", ""),
"status": job.get("status", ""),
"conclusion": job.get("conclusion", ""),
"started_at": job.get("started_at", ""),
"completed_at": job.get("completed_at", ""),
"runner_name": job.get("runner_name", ""),
"runner_group_name": job.get("runner_group_name", ""),
"labels": job.get("labels", []),
"html_url": job.get("html_url", ""),
"steps": steps,
}
def _normalize_run(run: dict[str, Any]) -> dict[str, Any]:
"""Normalize workflow run data."""
actor_raw = run.get("actor")
actor = actor_raw if isinstance(actor_raw, dict) else {}
triggering_actor_raw = run.get("triggering_actor")
triggering_actor = triggering_actor_raw if isinstance(triggering_actor_raw, dict) else {}
pull_requests_raw = run.get("pull_requests")
pull_requests: list[dict[str, Any]] = []
if isinstance(pull_requests_raw, list):
for item in pull_requests_raw:
if isinstance(item, dict):
head_raw = item.get("head")
head = head_raw if isinstance(head_raw, dict) else {}
pull_requests.append(
{
"number": item.get("number"),
"url": item.get("html_url", ""),
"head_branch": head.get("ref", ""),
}
)
return {
"id": run.get("id"),
"name": run.get("name", ""),
"display_title": run.get("display_title", ""),
"head_branch": run.get("head_branch", ""),
"head_sha": run.get("head_sha", ""),
"event": run.get("event", ""),
"status": run.get("status", ""),
"conclusion": run.get("conclusion", ""),
"run_number": run.get("run_number"),
"run_attempt": run.get("run_attempt"),
"html_url": run.get("html_url", ""),
"created_at": run.get("created_at", ""),
"updated_at": run.get("updated_at", ""),
"actor": actor.get("login", "") if isinstance(actor, dict) else "",
"triggering_actor": triggering_actor.get("login", "")
if isinstance(triggering_actor, dict)
else "",
"pull_requests": pull_requests,
}
UNGROUPED_SECTION_NAME = "ungrouped"
def _append_log_section(sections: list[dict[str, str]], name: str, lines: list[str]) -> None:
"""Append a non-empty log section preserving original ordering."""
text = "\n".join(lines).strip()
if text:
sections.append({"name": name, "text": text})
def _extract_log_sections(log_text: str) -> list[dict[str, str]]:
"""Extract sections from GitHub Actions log output (marked by ##[group]/##[endgroup])."""
sections: list[dict[str, str]] = []
current_name: str | None = None
current_lines: list[str] = []
saw_group = False
for line in log_text.splitlines():
if line.startswith("##[group]"):
saw_group = True
_append_log_section(sections, current_name or UNGROUPED_SECTION_NAME, current_lines)
current_name = line[len("##[group]") :].strip()
current_lines = []
continue
if line.startswith("##[endgroup]"):
_append_log_section(sections, current_name or UNGROUPED_SECTION_NAME, current_lines)
current_name = None
current_lines = []
continue
current_lines.append(line)
if saw_group:
_append_log_section(sections, current_name or UNGROUPED_SECTION_NAME, current_lines)
if not saw_group:
return [{"name": "full-log", "text": log_text.strip()}]
return [section for section in sections if section.get("text")]
def extract_step_log(
log_text: str,
*,
step_name: str = "",
step_number: int | None = None,
) -> dict[str, Any]:
"""Extract the log text for a specific step in a GitHub Actions job log,
using grouping markers if available."""
sections = _extract_log_sections(log_text)
selected: dict[str, str] | None = None
match_strategy = "full-log"
selected_idx = -1
if step_name:
needle = step_name.strip().lower()
for i, section in enumerate(sections):
if needle and needle in section.get("name", "").lower():
selected = section
match_strategy = "step_name"
selected_idx = i
break
group_count = sum(1 for s in sections if s.get("name") != UNGROUPED_SECTION_NAME)
if selected is None and step_number is not None and 1 <= step_number <= group_count:
group_counter = 0
for i, section in enumerate(sections):
if section.get("name") != UNGROUPED_SECTION_NAME:
group_counter += 1
if group_counter == step_number:
selected = section
match_strategy = "step_number"
selected_idx = i
break
if selected is None:
selected = {"name": "full-log", "text": log_text.strip()}
text = selected.get("text", "")
# Merge trailing ungrouped annotations into the matched step log
if selected_idx != -1 and selected_idx + 1 < len(sections):
next_section = sections[selected_idx + 1]
if next_section.get("name") == UNGROUPED_SECTION_NAME:
text += "\n" + next_section.get("text", "")
return {
"step_name": selected.get("name", ""),
"match_strategy": match_strategy,
"log_text": text,
}
def _github_actions_is_available(sources: dict[str, dict]) -> bool:
"""Check if GitHub Actions tool should be available."""
github = sources.get("github", {})
return bool(github_source_available(sources) and github.get("owner") and github.get("repo"))
def _github_actions_repo_params(sources: dict[str, dict]) -> dict[str, Any]:
"""Extract repo parameters for GitHub Actions tools."""
github = sources["github"]
params: dict[str, Any] = {
"owner": github["owner"],
"repo": github["repo"],
**github_creds(github),
}
return params
def _github_actions_run_params(sources: dict[str, dict]) -> dict[str, Any]:
"""Extract run/job parameters for GitHub Actions tools."""
github = sources["github"]
params = _github_actions_repo_params(sources)
if github.get("run_id") is not None:
params["run_id"] = github["run_id"]
if github.get("job_id") is not None:
params["job_id"] = github["job_id"]
return params
@tool(
name="list_github_actions_workflow_runs",
source="github",
description="List recent GitHub Actions workflow runs for a repository.",
use_cases=[
"Checking which deploy or test workflow failed right before an incident",
"Reviewing recent workflow status, trigger, and branch context",
"Finding a run that matches an outage window or rollback event",
],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"branch": {"type": "string", "default": ""},
"status": {"type": "string", "default": ""},
"event": {"type": "string", "default": ""},
"per_page": {"type": "integer", "default": 30},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_github_actions_is_available,
extract_params=_github_actions_repo_params,
)
def list_github_actions_workflow_runs(
owner: str,
repo: str,
branch: str = "",
status: str = "",
event: str = "",
per_page: int = 30,
github_url: str | None = None,
github_mode: str | None = None,
github_token: str | None = None,
github_command: str | None = None,
github_args: list[str] | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
"""List recent GitHub Actions workflow runs for a repository."""
config = resolve_github_mcp_config(
github_url, github_mode, github_token, github_command, github_args
)
if config is None:
return code_host_unavailable_payload(
source="github",
integration_name="GitHub Actions",
empty_key="workflow_runs",
empty_value=[],
)
workflow_runs_filter: dict[str, Any] = {}
if branch:
workflow_runs_filter["branch"] = branch
if status:
workflow_runs_filter["status"] = status
if event:
workflow_runs_filter["event"] = event
arguments: dict[str, Any] = {
"method": "list_workflow_runs",
"owner": owner,
"repo": repo,
"per_page": per_page,
}
if workflow_runs_filter:
arguments["workflow_runs_filter"] = workflow_runs_filter
result = call_github_mcp_tool(config, "actions_list", arguments)
payload = normalize_github_tool_result(result)
if not isinstance(payload, dict):
return {"error": "Unexpected payload format returned from GitHub MCP tool"}
if payload.get("available"):
workflow_runs_raw = _extract_list(result, "workflow_runs")
workflow_runs = [_normalize_run(item) for item in workflow_runs_raw]
payload["workflow_runs"] = workflow_runs
payload["total"] = len(workflow_runs)
else:
payload["workflow_runs"] = []
payload["total"] = 0
payload["branch"] = branch
payload["status"] = status
payload["event"] = event
return payload
@tool(
name="list_github_actions_active_runs",
source="github",
description="List GitHub Actions workflow runs that are currently queued or in progress.",
use_cases=[
"Seeing what deployment jobs are still running during an incident",
"Spotting queued deploys that may be waiting on a shared runner or lock",
],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"per_page": {"type": "integer", "default": 30},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_github_actions_is_available,
extract_params=_github_actions_repo_params,
)
def list_github_actions_active_runs(
owner: str,
repo: str,
per_page: int = 30,
github_url: str | None = None,
github_mode: str | None = None,
github_token: str | None = None,
github_command: str | None = None,
github_args: list[str] | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
"""List GitHub Actions workflow runs that are currently queued or in progress."""
config = resolve_github_mcp_config(
github_url, github_mode, github_token, github_command, github_args
)
if config is None:
return code_host_unavailable_payload(
source="github",
integration_name="GitHub Actions",
empty_key="workflow_runs",
empty_value=[],
)
# Fetch queued runs
queued_result = call_github_mcp_tool(
config,
"actions_list",
{
"method": "list_workflow_runs",
"owner": owner,
"repo": repo,
"per_page": per_page,
"workflow_runs_filter": {"status": "queued"},
},
)
# Fetch in_progress runs
in_progress_result = call_github_mcp_tool(
config,
"actions_list",
{
"method": "list_workflow_runs",
"owner": owner,
"repo": repo,
"per_page": per_page,
"workflow_runs_filter": {"status": "in_progress"},
},
)
# Check for errors
if queued_result.get("is_error") or in_progress_result.get("is_error"):
error_texts: list[str] = []
for result in (queued_result, in_progress_result):
if result.get("is_error") and result.get("text"):
error_texts.append(str(result.get("text")))
error_msg = " | ".join(error_texts) if error_texts else "Failed to list active runs"
return code_host_unavailable_payload(
source="github",
integration_name="GitHub Actions",
empty_key="workflow_runs",
empty_value=[],
) | {"error": error_msg}
# Combine and deduplicate
combined: list[dict[str, Any]] = []
seen_ids: set[Any] = set()
for result in (queued_result, in_progress_result):
runs_raw = _extract_list(result, "workflow_runs")
for run in runs_raw:
run_id = run.get("id")
if run_id is not None and run_id not in seen_ids:
seen_ids.add(run_id)
combined.append(_normalize_run(run))
combined.sort(key=lambda item: str(item.get("created_at", "")), reverse=True)
return {
"source": "github",
"available": True,
"workflow_runs": combined,
"total": len(combined),
}
@tool(
name="list_github_actions_run_jobs",
source="github",
description="List jobs and step outcomes for a GitHub Actions workflow run.",
use_cases=[
"Finding which job failed in a deployment workflow",
"Checking step-by-step status for test, build, and deploy jobs",
],
requires=["owner", "repo", "run_id"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"run_id": {"type": "integer"},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo", "run_id"],
},
is_available=_github_actions_is_available,
extract_params=_github_actions_run_params,
)
def list_github_actions_run_jobs(
owner: str,
repo: str,
run_id: int,
github_url: str | None = None,
github_mode: str | None = None,
github_token: str | None = None,
github_command: str | None = None,
github_args: list[str] | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
"""List jobs and step outcomes for a GitHub Actions workflow run."""
config = resolve_github_mcp_config(
github_url, github_mode, github_token, github_command, github_args
)
if config is None:
return code_host_unavailable_payload(
source="github",
integration_name="GitHub Actions",
empty_key="jobs",
empty_value=[],
)
result = call_github_mcp_tool(
config,
"actions_list",
{
"method": "list_workflow_jobs",
"owner": owner,
"repo": repo,
"resource_id": str(run_id),
},
)
payload = normalize_github_tool_result(result)
if not isinstance(payload, dict):
return {"error": "Unexpected payload format returned from GitHub MCP tool"}
if payload.get("available"):
jobs = _extract_workflow_jobs(result)
payload["jobs"] = jobs
payload["total"] = len(jobs)
else:
payload["jobs"] = []
payload["total"] = 0
payload["workflow_run_id"] = run_id
return payload
@tool(
name="get_github_actions_step_log",
source="github",
description="Fetch the log output for a failed GitHub Actions job step.",
use_cases=[
"Reading the error output for the step that broke a deployment",
"Checking the exact log snippet for a flaky test or secret-related failure",
],
requires=["owner", "repo", "run_id", "job_id"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"run_id": {"type": "integer"},
"job_id": {"type": "integer"},
"step_name": {"type": "string", "default": ""},
"step_number": {"type": "integer"},
"tail_lines": {"type": "integer", "default": 500},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo", "run_id", "job_id"],
},
is_available=_github_actions_is_available,
extract_params=_github_actions_run_params,
)
def get_github_actions_step_log(
owner: str,
repo: str,
run_id: int,
job_id: int,
step_name: str = "",
step_number: int | None = None,
tail_lines: int = 500,
github_url: str | None = None,
github_mode: str | None = None,
github_token: str | None = None,
github_command: str | None = None,
github_args: list[str] | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
"""Fetch the log output for a failed GitHub Actions job step."""
config = resolve_github_mcp_config(
github_url, github_mode, github_token, github_command, github_args
)
if config is None:
return code_host_unavailable_payload(
source="github",
integration_name="GitHub Actions",
empty_key="log_text",
empty_value="",
)
# Fetch job metadata
job_result = call_github_mcp_tool(
config,
"actions_get",
{
"method": "get_workflow_job",
"owner": owner,
"repo": repo,
"resource_id": str(job_id),
},
)
if job_result.get("is_error"):
return code_host_unavailable_payload(
source="github",
integration_name="GitHub Actions",
empty_key="log_text",
empty_value="",
) | {"error": job_result.get("text")}
job = _extract_json_text(job_result)
if not isinstance(job, dict):
return code_host_unavailable_payload(
source="github",
integration_name="GitHub Actions",
empty_key="log_text",
empty_value="",
) | {"error": "Unexpected job metadata format"}
# Fetch job logs
log_result = call_github_mcp_tool(
config,
"get_job_logs",
{
"owner": owner,
"repo": repo,
"job_id": job_id,
"return_content": True,
"tail_lines": tail_lines,
},
)
if log_result.get("is_error"):
return code_host_unavailable_payload(
source="github",
integration_name="GitHub Actions",
empty_key="log_text",
empty_value="",
) | {"error": log_result.get("text")}
log_text = _extract_log_text(log_result)
# Detect first failed step if not specified
failed_step = ""
steps_raw = job.get("steps") if isinstance(job, dict) else []
normalized_steps = []
if isinstance(steps_raw, list):
normalized_steps = [_normalize_step(step) for step in steps_raw if isinstance(step, dict)]
if not step_name:
for step in steps_raw:
if not isinstance(step, dict):
continue
if step.get("conclusion") == "failure" or step.get("status") == "failure":
failed_step = str(step.get("name") or "")
break
# Extract and filter step log
extracted = extract_step_log(
str(log_text),
step_name=step_name or failed_step,
step_number=step_number,
)
extracted.update(
{
"source": "github",
"available": True,
"workflow_run_id": run_id,
"job_id": job_id,
"job_name": job.get("name", ""),
"job_conclusion": job.get("conclusion", ""),
"job_steps": normalized_steps,
}
)
return extracted
__all__ = [
"extract_step_log",
"get_github_actions_step_log",
"list_github_actions_active_runs",
"list_github_actions_run_jobs",
"list_github_actions_workflow_runs",
]