chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:45 +08:00
commit 4b6817381b
3933 changed files with 525247 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
"""GitHub-backed agent tools."""
from __future__ import annotations
TOOL_MODULES = (
"actions",
"commits",
"file_contents",
"issues",
"repository",
"repository_tree",
"search_code",
"work_status",
)
__all__ = ["TOOL_MODULES"]
+691
View File
@@ -0,0 +1,691 @@
"""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",
]
+122
View File
@@ -0,0 +1,122 @@
"""GitHub MCP-backed repository investigation tools."""
from __future__ import annotations
from typing import Any
from core.tool_framework.tool_decorator import tool
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 _unwrap_exception_message(exc: BaseException) -> str:
"""Unwrap ExceptionGroup / BaseExceptionGroup to a human-readable message."""
# ExceptionGroup (Python 3.11+) wraps multiple exceptions; unwrap to the first one.
if isinstance(exc, BaseExceptionGroup) and exc.exceptions:
return _unwrap_exception_message(exc.exceptions[0])
cause = getattr(exc, "__cause__", None)
if isinstance(cause, BaseException):
return _unwrap_exception_message(cause)
context = getattr(exc, "__context__", None)
if isinstance(context, BaseException) and not getattr(exc, "__suppress_context__", False):
return _unwrap_exception_message(context)
return f"{type(exc).__name__}: {exc}"
def _list_github_commits_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
gh = sources["github"]
return {
"owner": gh["owner"],
"repo": gh["repo"],
"path": gh.get("path", ""),
"sha": gh.get("sha") or gh.get("ref", ""),
"per_page": 10,
**github_creds(gh),
}
def _list_github_commits_available(sources: dict[str, dict]) -> bool:
gh = sources.get("github", {})
return bool(github_source_available(sources) and gh.get("owner") and gh.get("repo"))
@tool(
name="list_github_commits",
source="github",
description="List recent commits for a GitHub repository through the MCP server.",
use_cases=[
"Checking whether a recent change could explain a failure",
"Reviewing commit history for a specific file or directory",
"Correlating a deployment or incident window with code changes",
],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"path": {"type": "string", "default": ""},
"sha": {"type": "string", "default": ""},
"per_page": {"type": "integer", "default": 10},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_list_github_commits_available,
extract_params=_list_github_commits_extract_params,
)
def list_github_commits(
owner: str,
repo: str,
path: str = "",
sha: str = "",
per_page: int = 10,
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 commits for a GitHub repository through the MCP server."""
try:
config = resolve_github_mcp_config(
github_url, github_mode, github_token, github_command, github_args
)
if config is None:
return {
"source": "github",
"available": False,
"error": "GitHub MCP integration is not configured.",
"commits": [],
}
arguments: dict[str, Any] = {"owner": owner, "repo": repo, "perPage": per_page}
if path:
arguments["path"] = path
if sha:
arguments["sha"] = sha
result = call_github_mcp_tool(config, "list_commits", arguments)
payload = normalize_github_tool_result(result)
payload["commits"] = payload.pop("structured_content", None)
return payload
except Exception as exc:
# MCP session cleanup can raise ExceptionGroup (anyio TaskGroup) when both
# the tool call and the background receive-loop fail simultaneously.
# Catch here to ensure the tool always returns an error dict rather than raising.
error_msg = _unwrap_exception_message(exc)
return {
"source": "github",
"available": False,
"error": error_msg,
"commits": [],
}
@@ -0,0 +1,98 @@
"""GitHub MCP-backed repository investigation tools."""
from __future__ import annotations
from typing import Any
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 _get_github_file_contents_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
gh = sources["github"]
return {
"owner": gh["owner"],
"repo": gh["repo"],
"path": gh["path"],
"ref": gh.get("ref", ""),
"sha": gh.get("sha", ""),
**github_creds(gh),
}
def _get_github_file_contents_available(sources: dict[str, dict]) -> bool:
gh = sources.get("github", {})
return bool(
github_source_available(sources) and gh.get("owner") and gh.get("repo") and gh.get("path")
)
@tool(
name="get_github_file_contents",
source="github",
description="Fetch a file or directory from GitHub through the MCP server.",
use_cases=[
"Reading application code referenced by an alert",
"Inspecting CI config, manifests, and deployment files",
"Checking how a specific path looked on a branch or commit",
],
requires=["owner", "repo", "path"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"path": {"type": "string"},
"ref": {"type": "string", "default": ""},
"sha": {"type": "string", "default": ""},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo", "path"],
},
is_available=_get_github_file_contents_available,
extract_params=_get_github_file_contents_extract_params,
)
def get_github_file_contents(
owner: str,
repo: str,
path: str,
ref: str = "",
sha: str = "",
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 a file or directory from GitHub through the MCP server."""
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 MCP",
empty_key="file",
empty_value={},
)
arguments = {"owner": owner, "repo": repo, "path": path}
if ref:
arguments["ref"] = ref
if sha:
arguments["sha"] = sha
result = call_github_mcp_tool(config, "get_file_contents", arguments)
payload = normalize_github_tool_result(result)
payload["file"] = payload.pop("structured_content", None)
return payload
+93
View File
@@ -0,0 +1,93 @@
"""GitHub MCP-backed issue search tool."""
from __future__ import annotations
from typing import Any
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 (
build_github_issue_search_query,
call_github_mcp_tool,
)
def _search_github_issues_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
gh = sources["github"]
return {
"owner": gh["owner"],
"repo": gh["repo"],
"query": gh.get("query") or "crash OR error OR exception",
"state": gh.get("state", "open"),
**github_creds(gh),
}
def _search_github_issues_available(sources: dict[str, dict]) -> bool:
gh = sources.get("github", {})
return bool(github_source_available(sources) and gh.get("owner") and gh.get("repo"))
@tool(
name="search_github_issues",
source="github",
description="Search GitHub repository issues through the configured GitHub MCP server.",
use_cases=[
"Investigating crash, error, or exception reports filed as issues",
"Checking known issues for a repository or platform during an incident",
"Finding related bug reports that may explain a failure",
],
requires=["owner", "repo", "query"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"query": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"]},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo", "query"],
},
is_available=_search_github_issues_available,
extract_params=_search_github_issues_extract_params,
)
def search_github_issues(
owner: str,
repo: str,
query: str,
state: str = "open",
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]:
"""Search GitHub repository issues through the configured GitHub MCP server."""
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 MCP",
empty_key="issues",
empty_value=[],
)
final_query = build_github_issue_search_query(owner, repo, query, state)
result = call_github_mcp_tool(config, "search_issues", {"query": final_query})
payload = normalize_github_tool_result(result)
payload["issues"] = payload.pop("structured_content", None)
payload["query"] = final_query
return payload
+130
View File
@@ -0,0 +1,130 @@
"""GitHub REST repository metadata tools."""
from __future__ import annotations
from typing import Any
from core.tool_framework.telemetry import report_run_error
from core.tool_framework.tool_decorator import tool
from integrations.github.client import GitHubApiError, GitHubRestClient, resolve_github_token
from integrations.github.helpers import github_creds, github_source_available
def _github_repository_available(sources: dict[str, dict]) -> bool:
gh = sources.get("github", {})
return bool(
(github_source_available(sources) or resolve_github_token(None))
and gh.get("owner")
and gh.get("repo")
)
def _github_repository_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
gh = sources.get("github", {})
if not gh:
return {}
return {"owner": gh.get("owner"), "repo": gh.get("repo"), **github_creds(gh)}
def _normalize_repository(repo: dict[str, Any], *, owner: str, repo_name: str) -> dict[str, Any]:
raw_license = repo.get("license")
license_info: dict[str, Any] = raw_license if isinstance(raw_license, dict) else {}
return {
"full_name": str(repo.get("full_name") or f"{owner}/{repo_name}"),
"html_url": str(repo.get("html_url") or ""),
"description": str(repo.get("description") or ""),
"default_branch": str(repo.get("default_branch") or ""),
"visibility": str(
repo.get("visibility") or ("private" if repo.get("private") else "public")
),
"stargazers_count": repo.get("stargazers_count"),
"watchers_count": repo.get("watchers_count"),
"forks_count": repo.get("forks_count"),
"open_issues_count": repo.get("open_issues_count"),
"subscribers_count": repo.get("subscribers_count"),
"language": str(repo.get("language") or ""),
"topics": list(repo.get("topics") or []),
"license": str(license_info.get("spdx_id") or license_info.get("name") or ""),
"created_at": str(repo.get("created_at") or ""),
"updated_at": str(repo.get("updated_at") or ""),
"pushed_at": str(repo.get("pushed_at") or ""),
"archived": bool(repo.get("archived")),
"disabled": bool(repo.get("disabled")),
}
@tool(
name="get_github_repository",
source="github",
description=(
"Fetch GitHub repository metadata such as star count, forks, open issues, "
"description, default branch, and visibility via the GitHub REST API."
),
use_cases=[
"Answering how many GitHub stars a repository has",
"Reporting repository metadata for status or community questions",
"Checking repo visibility, default branch, or activity timestamps",
],
anti_examples=[
"Searching repository source code (use search_github_code)",
"Searching GitHub issues by keyword (use search_github_issues)",
],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
side_effect_level="read_only",
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_github_repository_available,
extract_params=_github_repository_extract_params,
)
def get_github_repository(
owner: str,
repo: str,
github_token: str | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
"""Fetch repository metadata from the GitHub REST API."""
try:
payload = GitHubRestClient(github_token).request("GET", f"/repos/{owner}/{repo}")
except GitHubApiError as exc:
report_run_error(
exc,
tool_name="get_github_repository",
source="github",
component="integrations.github.tools.repository",
method="GitHubRestClient.request",
extras={"owner": owner, "repo": repo},
)
return {
"source": "github",
"available": False,
"error": str(exc),
"owner": owner,
"repo": repo,
"repository": {},
}
if not isinstance(payload, dict):
return {
"source": "github",
"available": False,
"error": "GitHub API returned an unexpected repository payload.",
"owner": owner,
"repo": repo,
"repository": {},
}
repository = _normalize_repository(payload, owner=owner, repo_name=repo)
return {
"source": "github",
"available": True,
"owner": owner,
"repo": repo,
"repository": repository,
"stargazers_count": repository["stargazers_count"] or 0,
}
@@ -0,0 +1,96 @@
"""GitHub MCP-backed repository investigation tools."""
from __future__ import annotations
from typing import Any
from core.tool_framework.tool_decorator import tool
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 _get_github_repository_tree_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
gh = sources["github"]
return {
"owner": gh["owner"],
"repo": gh["repo"],
"path_filter": gh.get("path", ""),
"tree_sha": gh.get("sha") or gh.get("ref", ""),
"recursive": True,
**github_creds(gh),
}
def _get_github_repository_tree_available(sources: dict[str, dict]) -> bool:
gh = sources.get("github", {})
return bool(github_source_available(sources) and gh.get("owner") and gh.get("repo"))
@tool(
name="get_github_repository_tree",
source="github",
description="Browse a GitHub repository tree through the MCP server.",
use_cases=[
"Understanding repository structure during an incident",
"Finding likely directories for runtime code, configs, or workflows",
"Narrowing down where to read code next",
],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"path_filter": {"type": "string", "default": ""},
"recursive": {"type": "boolean", "default": True},
"tree_sha": {"type": "string", "default": ""},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_get_github_repository_tree_available,
extract_params=_get_github_repository_tree_extract_params,
)
def get_github_repository_tree(
owner: str,
repo: str,
path_filter: str = "",
recursive: bool = True,
tree_sha: str = "",
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]:
"""Browse a GitHub repository tree through the MCP server."""
config = resolve_github_mcp_config(
github_url, github_mode, github_token, github_command, github_args
)
if config is None:
return {
"source": "github",
"available": False,
"error": "GitHub MCP integration is not configured.",
"tree": {},
}
arguments: dict[str, Any] = {"owner": owner, "repo": repo, "recursive": recursive}
if path_filter:
arguments["path_filter"] = path_filter
if tree_sha:
arguments["tree_sha"] = tree_sha
result = call_github_mcp_tool(config, "get_repository_tree", arguments)
payload = normalize_github_tool_result(result)
payload["tree"] = payload.pop("structured_content", None)
return payload
+90
View File
@@ -0,0 +1,90 @@
"""GitHub MCP-backed repository investigation tools."""
from __future__ import annotations
from typing import Any
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 (
build_github_code_search_query,
call_github_mcp_tool,
)
def _search_github_code_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
gh = sources["github"]
return {
"owner": gh["owner"],
"repo": gh["repo"],
"query": gh.get("query") or "exception OR error",
**github_creds(gh),
}
def _search_github_code_available(sources: dict[str, dict]) -> bool:
gh = sources.get("github", {})
return bool(github_source_available(sources) and gh.get("owner") and gh.get("repo"))
@tool(
name="search_github_code",
source="github",
description="Search GitHub repository code through the configured GitHub MCP server.",
use_cases=[
"Investigating alerts that mention a repository, branch, or commit",
"Finding source code related to failures, exceptions, and stack frames",
"Tracing config, workflow, or application code that may explain an incident",
],
requires=["owner", "repo", "query"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"query": {"type": "string"},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo", "query"],
},
is_available=_search_github_code_available,
extract_params=_search_github_code_extract_params,
)
def search_github_code(
owner: str,
repo: str,
query: str,
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]:
"""Search GitHub repository code through the configured GitHub MCP server."""
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 MCP",
empty_key="matches",
empty_value=[],
)
final_query = build_github_code_search_query(owner, repo, query)
result = call_github_mcp_tool(config, "search_code", {"query": final_query})
payload = normalize_github_tool_result(result)
payload["matches"] = payload.pop("structured_content", None)
payload["query"] = final_query
return payload
+731
View File
@@ -0,0 +1,731 @@
"""GitHub work, PR, security, and issue-mutation workflow tools."""
from __future__ import annotations
from typing import Any, Literal, cast
from core.tool_framework.tool_decorator import tool
from integrations.github.client import GitHubApiError, GitHubRestClient, resolve_github_token
from integrations.github.helpers import github_creds, github_source_available
from integrations.github.tools.workflow import (
GitHubIssueMutationProposal,
PullRequestStatus,
SecurityAlert,
WorkItem,
build_issue_mutation_proposal,
)
_HELP_WANTED_LABELS = {"help wanted", "good first issue", "up for grabs", "agent-ready"}
_BLOCKING_MERGEABLE_STATES = {"blocked", "dirty", "behind", "unstable"}
_FAILED_CHECK_CONCLUSIONS = {
"failure",
"cancelled",
"timed_out",
"action_required",
"startup_failure",
}
_TERMINAL_CHECK_CONCLUSIONS = _FAILED_CHECK_CONCLUSIONS | {"success", "skipped", "neutral"}
def _github_available(sources: dict[str, dict]) -> bool:
gh = sources.get("github", {})
return bool(
(github_source_available(sources) or resolve_github_token(None))
and gh.get("owner")
and gh.get("repo")
)
def _github_extract_params(sources: dict[str, dict]) -> dict[str, Any]:
gh = sources.get("github", {})
if not gh:
return {}
return {"owner": gh.get("owner"), "repo": gh.get("repo"), **github_creds(gh)}
def _labels(item: dict[str, Any]) -> list[str]:
return [
str(label.get("name", "")).strip()
for label in item.get("labels", [])
if isinstance(label, dict)
]
def _logins(items: Any) -> list[str]:
if not isinstance(items, list):
return []
return [
str(item.get("login", "")).strip()
for item in items
if isinstance(item, dict) and item.get("login")
]
def _normalize_issue(item: dict[str, Any]) -> WorkItem:
labels = _labels(item)
assignees = _logins(item.get("assignees"))
label_set = {label.lower() for label in labels}
if assignees:
work_status: Literal["taken", "up_for_grabs", "unassigned"] = "taken"
elif label_set & _HELP_WANTED_LABELS:
work_status = "up_for_grabs"
else:
work_status = "unassigned"
return WorkItem(
number=item.get("number") if isinstance(item.get("number"), int) else None,
title=str(item.get("title", "")),
state=str(item.get("state", "")),
url=str(item.get("html_url", "")),
author=str((item.get("user") or {}).get("login", "")),
labels=labels,
assignees=assignees,
updated_at=str(item.get("updated_at", "")),
work_status=work_status,
)
def _count_work_items(items: list[dict[str, Any]]) -> dict[str, int]:
return {
"total": len(items),
"taken": sum(1 for item in items if item.get("work_status") == "taken"),
"up_for_grabs": sum(1 for item in items if item.get("work_status") == "up_for_grabs"),
"unassigned": sum(1 for item in items if item.get("work_status") == "unassigned"),
}
@tool(
name="list_github_work_items",
source="github",
description="List GitHub issues as engineering work items and classify them as taken, up for grabs, or unassigned.",
use_cases=[
"Answering which GitHub issues are taken versus available",
"Building engineering status reports from open issue state",
"Finding unassigned or agent-ready work without mutating GitHub",
],
anti_examples=["Creating, editing, or closing GitHub issues"],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
side_effect_level="read_only",
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"]},
"labels": {"type": "string"},
"include_prs": {"type": "boolean"},
"per_page": {"type": "integer"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_github_available,
extract_params=_github_extract_params,
)
def list_github_work_items(
owner: str,
repo: str,
state: str = "open",
labels: str = "",
include_prs: bool = False,
per_page: int = 50,
github_token: str | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
params: dict[str, Any] = {"state": state, "per_page": max(1, min(per_page, 100))}
if labels.strip():
params["labels"] = labels.strip()
try:
raw_items = GitHubRestClient(github_token).paginate(
f"/repos/{owner}/{repo}/issues", params=params
)
except GitHubApiError as exc:
return {
"source": "github",
"available": False,
"error": str(exc),
"items": [],
"counts": _count_work_items([]),
"side_effects": [],
}
items = [
_normalize_issue(item).to_dict()
for item in raw_items
if include_prs or "pull_request" not in item
]
return {
"source": "github",
"available": True,
"owner": owner,
"repo": repo,
"items": items,
"counts": _count_work_items(items),
"side_effects": [],
}
def _check_summary(check_runs: list[dict[str, Any]]) -> tuple[str, list[str]]:
failed = [
str(run.get("name", "check"))
for run in check_runs
if str(run.get("conclusion") or "").lower() in _FAILED_CHECK_CONCLUSIONS
]
pending = [
str(run.get("name", "check"))
for run in check_runs
if str(run.get("status") or "").lower() != "completed"
or str(run.get("conclusion") or "").lower() not in _TERMINAL_CHECK_CONCLUSIONS
]
if failed:
return "failed", failed
if pending:
return "pending", pending
return "passing", []
def _normalize_pull_request(
pr: dict[str, Any], check_runs: list[dict[str, Any]]
) -> PullRequestStatus:
check_status, check_names = _check_summary(check_runs)
mergeable = pr.get("mergeable") if isinstance(pr.get("mergeable"), bool) else None
mergeable_state = str(pr.get("mergeable_state") or "unknown").lower()
reasons: list[str] = []
status: Literal["mergeable", "blocked", "unknown"]
if pr.get("draft"):
reasons.append("draft")
if mergeable_state in _BLOCKING_MERGEABLE_STATES:
reasons.append(f"mergeable_state={mergeable_state}")
if check_status == "failed":
reasons.append(f"failed checks: {', '.join(check_names)}")
elif check_status == "pending":
reasons.append(f"pending checks: {', '.join(check_names)}")
if mergeable is None or mergeable_state == "unknown":
reasons.append("mergeability unknown")
status = "unknown"
elif reasons or mergeable is False:
status = "blocked"
if mergeable is False and not any(
reason.startswith("mergeable_state=") for reason in reasons
):
reasons.append("mergeable=false")
else:
status = "mergeable"
return PullRequestStatus(
number=pr.get("number") if isinstance(pr.get("number"), int) else None,
title=str(pr.get("title", "")),
url=str(pr.get("html_url", "")),
author=str((pr.get("user") or {}).get("login", "")),
head_ref=str((pr.get("head") or {}).get("ref", "")),
head_sha=str((pr.get("head") or {}).get("sha", "")),
draft=bool(pr.get("draft")),
mergeable=mergeable,
mergeable_state=mergeable_state,
check_status=check_status,
status=status,
mergeability=status,
blocking_reasons=reasons,
updated_at=str(pr.get("updated_at", "")),
)
def _count_prs(prs: list[dict[str, Any]]) -> dict[str, int]:
return {
"total": len(prs),
"mergeable": sum(1 for pr in prs if pr.get("status") == "mergeable"),
"blocked": sum(1 for pr in prs if pr.get("status") == "blocked"),
"unknown": sum(1 for pr in prs if pr.get("status") == "unknown"),
"draft": sum(1 for pr in prs if pr.get("draft")),
}
@tool(
name="summarize_github_pr_status",
source="github",
description="Summarize open GitHub pull requests, authoritative mergeability, checks, and blocking reasons.",
use_cases=[
"Answering which PRs are mergeable, blocked, or unknown",
"Finding failing or pending CI checks for active work",
"Preparing engineering status updates without changing GitHub state",
],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
side_effect_level="read_only",
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"]},
"per_page": {"type": "integer"},
"include_checks": {"type": "boolean"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_github_available,
extract_params=_github_extract_params,
)
def summarize_github_pr_status(
owner: str,
repo: str,
state: str = "open",
per_page: int = 30,
include_checks: bool = True,
github_token: str | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
client = GitHubRestClient(github_token)
try:
raw_prs = client.paginate(
f"/repos/{owner}/{repo}/pulls",
params={"state": state, "per_page": max(1, min(per_page, 100))},
)
prs: list[dict[str, Any]] = []
for list_pr in raw_prs:
number = list_pr.get("number")
if not isinstance(number, int):
continue
detail_pr = client.request("GET", f"/repos/{owner}/{repo}/pulls/{number}")
if not isinstance(detail_pr, dict):
detail_pr = list_pr
sha = str((detail_pr.get("head") or {}).get("sha", ""))
check_runs: list[dict[str, Any]] = []
if include_checks and sha:
check_payload = client.request(
"GET",
f"/repos/{owner}/{repo}/commits/{sha}/check-runs",
params={"per_page": 100},
)
if isinstance(check_payload, dict) and isinstance(
check_payload.get("check_runs"), list
):
check_runs = [
run for run in check_payload["check_runs"] if isinstance(run, dict)
]
prs.append(_normalize_pull_request(detail_pr, check_runs).to_dict())
except GitHubApiError as exc:
return {
"source": "github",
"available": False,
"error": str(exc),
"pull_requests": [],
"counts": _count_prs([]),
"side_effects": [],
}
return {
"source": "github",
"available": True,
"owner": owner,
"repo": repo,
"pull_requests": prs,
"counts": _count_prs(prs),
"side_effects": [],
}
def _normalize_security_alert(alert_type: str, item: dict[str, Any]) -> SecurityAlert:
summary = ""
if alert_type == "dependabot":
summary = str((item.get("security_advisory") or {}).get("summary", ""))
elif alert_type == "secret_scanning":
summary = str(item.get("secret_type", ""))
elif alert_type == "code_scanning":
summary = str((item.get("rule") or {}).get("description", ""))
return SecurityAlert(
type=alert_type,
number=item.get("number"),
state=str(item.get("state", "")),
summary=summary,
url=str(item.get("html_url", "")),
)
_ALERT_ENDPOINTS = {
"dependabot": "dependabot/alerts",
"secret_scanning": "secret-scanning/alerts",
"code_scanning": "code-scanning/alerts",
}
_ISSUE_MUTATION_OPERATIONS = {"create", "update", "close"}
@tool(
name="list_github_security_alerts",
source="github",
description="List GitHub Dependabot, secret-scanning, and code-scanning alerts when token scope allows it.",
use_cases=[
"Surfacing repository security alerts during work triage",
"Checking whether secret scanning or code scanning has open alerts",
"Building a read-only engineering status report with security context",
],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
side_effect_level="read_only",
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"alert_type": {
"type": "string",
"enum": ["all", "dependabot", "secret_scanning", "code_scanning"],
},
"state": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_github_available,
extract_params=_github_extract_params,
)
def list_github_security_alerts(
owner: str,
repo: str,
alert_type: str = "all",
state: str = "open",
github_token: str | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
client = GitHubRestClient(github_token)
selected = list(_ALERT_ENDPOINTS) if alert_type == "all" else [alert_type]
alerts: list[dict[str, Any]] = []
errors: dict[str, str] = {}
for kind in selected:
endpoint = _ALERT_ENDPOINTS.get(kind)
if endpoint is None:
errors[kind] = f"Unsupported alert_type: {kind}"
continue
try:
payload = client.paginate(
f"/repos/{owner}/{repo}/{endpoint}", params={"state": state, "per_page": 100}
)
except GitHubApiError as exc:
errors[kind] = str(exc)
continue
alerts.extend(_normalize_security_alert(kind, item).to_dict() for item in payload)
counts = {
kind: sum(1 for alert in alerts if alert.get("type") == kind) for kind in _ALERT_ENDPOINTS
}
counts["total"] = len(alerts)
return {
"source": "github",
"available": not errors or bool(alerts),
"owner": owner,
"repo": repo,
"alerts": alerts,
"counts": counts,
"errors": errors,
"side_effects": [],
}
@tool(
name="propose_github_issue_mutation_from_slack",
source="github",
description="Build a read-only proposal for creating, updating, or closing a GitHub issue from an explicit Slack request.",
use_cases=[
"Preparing a Slack-sourced GitHub issue change for human approval",
"Rendering deterministic issue mutation payloads without mutating GitHub",
],
anti_examples=["Directly mutating GitHub", "Inferring tasks from ambiguous Slack discussion"],
surfaces=("chat",),
side_effect_level="read_only",
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"operation": {"type": "string", "enum": ["create", "update", "close"]},
"issue_number": {"type": "integer"},
"slack_text": {"type": "string"},
"slack_url": {"type": "string"},
"title": {"type": "string"},
"labels": {"type": "array", "items": {"type": "string"}},
"assignees": {"type": "array", "items": {"type": "string"}},
},
"required": ["owner", "repo", "operation", "slack_text"],
},
is_available=_github_available,
extract_params=_github_extract_params,
)
def propose_github_issue_mutation_from_slack(
owner: str,
repo: str,
operation: Literal["create", "update", "close"],
slack_text: str,
slack_url: str = "",
issue_number: int | None = None,
title: str = "",
labels: list[str] | None = None,
assignees: list[str] | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
if operation in {"update", "close"} and issue_number is None:
return {
"source": "github",
"available": False,
"error": f"issue_number is required for {operation}",
"side_effects": [],
}
proposal = build_issue_mutation_proposal(
owner=owner,
repo=repo,
operation=operation,
issue_number=issue_number,
slack_text=slack_text,
slack_url=slack_url,
title=title,
labels=labels,
assignees=assignees,
)
return {
"source": "github",
"available": True,
"proposal": proposal.to_dict(),
"side_effects": [],
}
def _mutation_rejected(error: str) -> dict[str, Any]:
return {
"source": "github",
"available": False,
"executed": False,
"error": error,
"side_effect": "github_issue_mutation_rejected",
}
def _proposal_from_payload(
payload: Any,
) -> tuple[GitHubIssueMutationProposal | None, str | None]:
if not isinstance(payload, dict):
return None, "proposal must be an object"
required = ("proposal_id", "operation", "owner", "repo", "target", "payload")
missing = [key for key in required if key not in payload]
if missing:
return None, f"proposal missing required field(s): {', '.join(missing)}"
operation = payload.get("operation")
if operation not in _ISSUE_MUTATION_OPERATIONS:
return None, f"unsupported proposal operation: {operation}"
target = payload.get("target")
if not isinstance(target, dict):
return None, "proposal target must be an object"
mutation_payload = payload.get("payload")
if not isinstance(mutation_payload, dict):
return None, "proposal payload must be an object"
proposal_id = str(payload.get("proposal_id") or "").strip()
owner = str(payload.get("owner") or "").strip()
repo = str(payload.get("repo") or "").strip()
idempotency_marker = str(payload.get("idempotency_marker") or "").strip()
if not proposal_id:
return None, "proposal_id is required"
if not owner or not repo:
return None, "proposal owner and repo are required"
if not idempotency_marker or proposal_id not in idempotency_marker:
return None, "proposal idempotency_marker is missing or does not match proposal_id"
return (
GitHubIssueMutationProposal(
proposal_id=proposal_id,
operation=cast(Literal["create", "update", "close"], operation),
owner=owner,
repo=repo,
target=target,
payload=mutation_payload,
slack_url=str(payload.get("slack_url", "")),
idempotency_marker=idempotency_marker,
),
None,
)
def _proposal_marker_text(proposal: GitHubIssueMutationProposal) -> str:
if proposal.operation == "create":
return str(proposal.payload.get("body", ""))
return str(proposal.payload.get("comment_body", ""))
def _validate_proposal_marker(proposal: GitHubIssueMutationProposal) -> str | None:
if proposal.idempotency_marker not in _proposal_marker_text(proposal):
return "proposal payload does not include its idempotency marker"
return None
def _quoted_search_term(term: str) -> str:
cleaned = term.replace('"', "")
return f'"{cleaned}"'
def _search_issues_for_marker(
client: GitHubRestClient,
*,
owner: str,
repo: str,
marker: str,
search_area: Literal["body", "comments"],
) -> list[dict[str, Any]]:
result = client.request(
"GET",
"/search/issues",
params={
"q": (f"repo:{owner}/{repo} is:issue in:{search_area} {_quoted_search_term(marker)}")
},
)
if not isinstance(result, dict):
return []
items = result.get("items")
if not isinstance(items, list):
return []
return [item for item in items if isinstance(item, dict)]
def _marker_exists_on_issue(
client: GitHubRestClient,
*,
owner: str,
repo: str,
issue_number: int,
marker: str,
) -> bool:
return any(
item.get("number") == issue_number
for item in _search_issues_for_marker(
client,
owner=owner,
repo=repo,
marker=marker,
search_area="comments",
)
)
@tool(
name="execute_github_issue_mutation",
source="github",
description="Execute a GitHub issue mutation proposal. Not exposed to investigation.",
use_cases=["Executing a previously rendered GitHub issue mutation proposal"],
anti_examples=[
"Creating proposals",
"Running during investigations",
],
surfaces=("chat",),
side_effect_level="mutating",
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"proposal": {"type": "object"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo", "proposal"],
},
is_available=_github_available,
extract_params=_github_extract_params,
)
def execute_github_issue_mutation(
owner: str,
repo: str,
proposal: dict[str, Any],
github_token: str | None = None,
**_kwargs: Any,
) -> dict[str, Any]:
client = GitHubRestClient(github_token)
parsed, parse_error = _proposal_from_payload(proposal)
if parsed is None:
return _mutation_rejected(parse_error or "invalid proposal")
if parsed.owner != owner or parsed.repo != repo:
return _mutation_rejected("proposal owner/repo does not match request")
marker_error = _validate_proposal_marker(parsed)
if marker_error is not None:
return _mutation_rejected(marker_error)
try:
if parsed.operation == "create":
existing_items = _search_issues_for_marker(
client,
owner=owner,
repo=repo,
marker=parsed.idempotency_marker,
search_area="body",
)
if existing_items:
return {
"source": "github",
"available": True,
"executed": False,
"side_effect": "existing_github_issue",
"issue": existing_items[0],
}
issue = client.request("POST", f"/repos/{owner}/{repo}/issues", body=parsed.payload)
return {
"source": "github",
"available": True,
"executed": True,
"side_effect": "created_github_issue",
"issue": issue,
}
issue_number = parsed.target.get("issue_number")
if not isinstance(issue_number, int):
return _mutation_rejected("proposal target.issue_number is required")
client.request("GET", f"/repos/{owner}/{repo}/issues/{issue_number}")
comment_body = str(parsed.payload.get("comment_body", ""))
comment_already_recorded = _marker_exists_on_issue(
client,
owner=owner,
repo=repo,
issue_number=issue_number,
marker=parsed.idempotency_marker,
)
if comment_body and not comment_already_recorded:
client.request(
"POST",
f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
body={"body": comment_body},
)
if parsed.operation == "update":
patch_body = {
key: parsed.payload[key]
for key in ("title", "labels", "assignees")
if key in parsed.payload
}
issue = (
client.request(
"PATCH", f"/repos/{owner}/{repo}/issues/{issue_number}", body=patch_body
)
if patch_body
else {"number": issue_number}
)
return {
"source": "github",
"available": True,
"executed": True,
"side_effect": "updated_github_issue",
"issue": issue,
"comment_already_recorded": comment_already_recorded,
}
issue = client.request(
"PATCH",
f"/repos/{owner}/{repo}/issues/{issue_number}",
body={"state": "closed", "state_reason": "completed"},
)
return {
"source": "github",
"available": True,
"executed": True,
"side_effect": "closed_github_issue",
"issue": issue,
"comment_already_recorded": comment_already_recorded,
}
except GitHubApiError as exc:
return {
"source": "github",
"available": False,
"executed": False,
"error": str(exc),
"side_effect": f"{parsed.operation}_github_issue_failed",
}
@@ -0,0 +1,30 @@
---
name: github-workflow
description: Use GitHub workflow tools to read work status, draft reports, summarize follow-ups, and execute only approved issue mutations.
tools:
- list_github_work_items
- summarize_github_pr_status
- list_github_security_alerts
- generate_work_status_report
- summarize_community_followups
- propose_github_issue_mutation_from_slack
- execute_github_issue_mutation
---
# GitHub Workflow
Use this workflow when the user asks about GitHub engineering status, PR readiness,
community follow-ups, or turning an explicit Slack request into a GitHub issue.
1. Read before reporting. Use the read-only GitHub tools first, and treat missing
or failed reads as incomplete status rather than proof that there are no
blockers.
2. Report or summarize from known data. Use `generate_work_status_report` for
Slack-ready status and `summarize_community_followups` for unanswered
contributor questions or agenda items.
3. Propose mutations before execution. Use
`propose_github_issue_mutation_from_slack` only for explicit Slack-sourced
create, update, or close requests.
4. Execute only through approval. `execute_github_issue_mutation` is the only
mutating GitHub workflow tool. It is never an investigation action and must
remain chat-only, approval-gated, and proposal-driven.
@@ -0,0 +1,44 @@
"""Semantic helpers for GitHub workflow tools."""
from __future__ import annotations
from integrations.github.client import GitHubApiError, GitHubRestClient
from integrations.github.tools.workflow.followup import (
issue_number_from_url,
normalize_community_comment,
summarize_community_followups_from_comments,
)
from integrations.github.tools.workflow.models import (
CommunityFollowup,
GitHubIssueMutationProposal,
GitHubReadSnapshot,
IssueMutationOperation,
PullRequestStatus,
SecurityAlert,
WorkItem,
WorkStatusReport,
)
from integrations.github.tools.workflow.mutation import (
build_issue_mutation_proposal,
title_from_slack_text,
)
from integrations.github.tools.workflow.report import build_work_status_report
__all__ = [
"CommunityFollowup",
"GitHubApiError",
"GitHubIssueMutationProposal",
"GitHubReadSnapshot",
"GitHubRestClient",
"IssueMutationOperation",
"PullRequestStatus",
"SecurityAlert",
"WorkItem",
"WorkStatusReport",
"build_issue_mutation_proposal",
"build_work_status_report",
"issue_number_from_url",
"normalize_community_comment",
"summarize_community_followups_from_comments",
"title_from_slack_text",
]
@@ -0,0 +1,102 @@
"""Community follow-up summarization for GitHub workflow tools."""
from __future__ import annotations
import re
from typing import Any
from integrations.github.tools.workflow.models import CommunityFollowup
_ISSUE_NUMBER_RE = re.compile(r"/issues/(?P<number>\d+)(?:$|[#?])")
def issue_number_from_url(url: str) -> int | None:
match = _ISSUE_NUMBER_RE.search(url)
if match is None:
return None
return int(match.group("number"))
def normalize_community_comment(raw: dict[str, Any]) -> CommunityFollowup:
"""Normalize GitHub repository issue-comment payloads."""
issue_number = raw.get("issue_number")
if not isinstance(issue_number, int):
issue_url = str(raw.get("issue_url") or raw.get("html_url") or "")
issue_number = issue_number_from_url(issue_url)
return CommunityFollowup(
issue_number=issue_number,
issue_title=str(raw.get("issue_title", "")),
author=str(raw.get("author") or (raw.get("user") or {}).get("login", "")),
body=str(raw.get("body", "")),
created_at=str(raw.get("created_at", "")),
url=str(raw.get("url") or raw.get("html_url") or ""),
)
def _is_question(text: str) -> bool:
lowered = text.lower()
return "?" in text or lowered.startswith(
("when ", "what ", "who ", "how ", "where ", "can ", "could ")
)
def _is_agenda_item(text: str) -> bool:
lowered = text.lower()
return any(term in lowered for term in ("agenda", "standup"))
def _question_answered_later(
question: CommunityFollowup,
comments: list[CommunityFollowup],
maintainers: set[str],
) -> bool:
for comment in comments:
if comment.issue_number != question.issue_number:
continue
if comment.created_at <= question.created_at:
continue
if comment.author.lower() in maintainers:
return True
return False
def _suggest_reply(question: CommunityFollowup) -> dict[str, Any]:
return {
"issue_number": question.issue_number,
"issue_title": question.issue_title,
"context": question.body,
"suggested_reply": (
"Thanks for the question — we should confirm the current owner/status "
"and reply in this thread with the next concrete step."
),
"url": question.url,
}
def summarize_community_followups_from_comments(
*,
comments: list[dict[str, Any]],
maintainer_logins: list[str] | None = None,
) -> dict[str, Any]:
normalized_comments = [normalize_community_comment(comment) for comment in comments]
maintainers = {login.lower() for login in (maintainer_logins or [])}
questions = [comment for comment in normalized_comments if _is_question(comment.body)]
unanswered = [
question
for question in questions
if question.author.lower() not in maintainers
and not _question_answered_later(question, normalized_comments, maintainers)
]
agenda_items = [comment for comment in normalized_comments if _is_agenda_item(comment.body)]
return {
"unanswered_questions": [question.to_dict() for question in unanswered],
"agenda_items": [item.to_dict() for item in agenda_items],
"suggested_replies": [_suggest_reply(question) for question in unanswered],
"counts": {
"comments": len(normalized_comments),
"unanswered_questions": len(unanswered),
"agenda_items": len(agenda_items),
},
"side_effects": [],
}
@@ -0,0 +1,166 @@
"""Semantic GitHub workflow models."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
IssueMutationOperation = Literal["create", "update", "close"]
@dataclass(frozen=True)
class WorkItem:
number: int | None
title: str
state: str
url: str
author: str
labels: list[str]
assignees: list[str]
updated_at: str
work_status: Literal["taken", "up_for_grabs", "unassigned"]
def to_dict(self) -> dict[str, Any]:
return {
"number": self.number,
"title": self.title,
"state": self.state,
"url": self.url,
"author": self.author,
"labels": self.labels,
"assignees": self.assignees,
"updated_at": self.updated_at,
"work_status": self.work_status,
}
@dataclass(frozen=True)
class PullRequestStatus:
number: int | None
title: str
url: str
author: str
head_ref: str
head_sha: str
draft: bool
mergeable: bool | None
mergeable_state: str
check_status: str
status: Literal["mergeable", "blocked", "unknown"]
mergeability: Literal["mergeable", "blocked", "unknown"]
blocking_reasons: list[str]
updated_at: str
def to_dict(self) -> dict[str, Any]:
return {
"number": self.number,
"title": self.title,
"url": self.url,
"author": self.author,
"head_ref": self.head_ref,
"head_sha": self.head_sha,
"draft": self.draft,
"mergeable": self.mergeable,
"mergeable_state": self.mergeable_state,
"check_status": self.check_status,
"status": self.status,
"mergeability": self.mergeability,
"blocking_reasons": self.blocking_reasons,
"updated_at": self.updated_at,
}
@dataclass(frozen=True)
class SecurityAlert:
type: str
number: Any
state: str
summary: str
url: str
def to_dict(self) -> dict[str, Any]:
return {
"type": self.type,
"number": self.number,
"state": self.state,
"summary": self.summary,
"url": self.url,
}
@dataclass(frozen=True)
class CommunityFollowup:
issue_number: int | None
issue_title: str
author: str
body: str
created_at: str
url: str
def to_dict(self) -> dict[str, Any]:
return {
"issue_number": self.issue_number,
"issue_title": self.issue_title,
"author": self.author,
"body": self.body,
"created_at": self.created_at,
"url": self.url,
}
@dataclass(frozen=True)
class GitHubReadSnapshot:
owner: str
repo: str
work_items: list[WorkItem] = field(default_factory=list)
pull_requests: list[PullRequestStatus] = field(default_factory=list)
security_alerts: list[SecurityAlert] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
@property
def incomplete(self) -> bool:
return bool(self.errors)
@dataclass(frozen=True)
class WorkStatusReport:
counts: dict[str, int]
slack_markdown: str
available: bool
incomplete: bool
errors: list[str]
side_effects: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"counts": self.counts,
"slack_markdown": self.slack_markdown,
"available": self.available,
"incomplete": self.incomplete,
"errors": self.errors,
"side_effects": self.side_effects,
}
@dataclass(frozen=True)
class GitHubIssueMutationProposal:
proposal_id: str
operation: IssueMutationOperation
owner: str
repo: str
target: dict[str, Any]
payload: dict[str, Any]
slack_url: str
idempotency_marker: str
def to_dict(self) -> dict[str, Any]:
return {
"proposal_id": self.proposal_id,
"operation": self.operation,
"owner": self.owner,
"repo": self.repo,
"target": self.target,
"payload": self.payload,
"slack_url": self.slack_url,
"idempotency_marker": self.idempotency_marker,
}
@@ -0,0 +1,103 @@
"""Mutation proposal helpers for GitHub issue workflow tools."""
from __future__ import annotations
import hashlib
import json
from typing import Any
from integrations.github.tools.workflow.models import (
GitHubIssueMutationProposal,
IssueMutationOperation,
)
def _proposal_digest(data: dict[str, Any]) -> str:
raw = json.dumps(data, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _issue_body(slack_text: str, slack_url: str, marker: str) -> str:
body = ["## Slack request", "", slack_text.strip() or "(No Slack text provided.)"]
if slack_url.strip():
body.extend(["", f"Source: {slack_url.strip()}"])
body.extend(["", f"<!-- {marker} -->"])
return "\n".join(body)
def _comment_body(slack_text: str, slack_url: str, marker: str) -> str:
body = ["Slack follow-up:", "", slack_text.strip() or "(No Slack text provided.)"]
if slack_url.strip():
body.extend(["", f"Source: {slack_url.strip()}"])
body.extend(["", f"<!-- {marker} -->"])
return "\n".join(body)
def title_from_slack_text(slack_text: str) -> str:
cleaned = " ".join(slack_text.strip().split())
if not cleaned:
return "Task from Slack"
return cleaned[:80].rstrip(" .")
def build_issue_mutation_proposal(
*,
owner: str,
repo: str,
operation: IssueMutationOperation,
slack_text: str,
slack_url: str = "",
issue_number: int | None = None,
title: str = "",
labels: list[str] | None = None,
assignees: list[str] | None = None,
) -> GitHubIssueMutationProposal:
seed = {
"owner": owner,
"repo": repo,
"operation": operation,
"issue_number": issue_number,
"slack_text": " ".join(slack_text.split()),
"slack_url": slack_url.strip(),
"title": title.strip(),
"labels": labels or [],
"assignees": assignees or [],
}
proposal_id = f"ghslack-{_proposal_digest(seed)}"
marker = f"opensre-slack-proposal:{proposal_id}"
target = {"issue_number": issue_number} if issue_number is not None else {}
if operation == "create":
payload: dict[str, Any] = {
"title": title.strip() or title_from_slack_text(slack_text),
"body": _issue_body(slack_text, slack_url, marker),
}
if labels is not None:
payload["labels"] = labels
if assignees is not None:
payload["assignees"] = assignees
elif operation == "update":
payload = {"comment_body": _comment_body(slack_text, slack_url, marker)}
if title.strip():
payload["title"] = title.strip()
if labels is not None:
payload["labels"] = labels
if assignees is not None:
payload["assignees"] = assignees
else:
payload = {
"comment_body": _comment_body(slack_text, slack_url, marker),
"state": "closed",
"state_reason": "completed",
}
return GitHubIssueMutationProposal(
proposal_id=proposal_id,
operation=operation,
owner=owner,
repo=repo,
target=target,
payload=payload,
slack_url=slack_url.strip(),
idempotency_marker=marker,
)
@@ -0,0 +1,117 @@
"""Report composition helpers for GitHub workflow tools."""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from integrations.github.tools.workflow.models import PullRequestStatus, WorkItem, WorkStatusReport
def _as_dict(item: WorkItem | PullRequestStatus | dict[str, Any]) -> dict[str, Any]:
if isinstance(item, WorkItem | PullRequestStatus):
return item.to_dict()
return item
def _item_line(item: dict[str, Any]) -> str:
assignees = item.get("assignees") or []
owner = f" — @{', @'.join(assignees)}" if assignees else ""
return f"• #{item.get('number', '?')} {item.get('title', '')}{owner}"
def _pr_line(pr: dict[str, Any]) -> str:
reasons = pr.get("blocking_reasons") or []
reason_text = f"{', '.join(str(reason) for reason in reasons)}" if reasons else ""
return f"• PR #{pr.get('number', '?')} {pr.get('title', '')}{reason_text}"
def _recommended_actions(
*,
up_for_grabs: list[dict[str, Any]],
unassigned: list[dict[str, Any]],
blocked_prs: list[dict[str, Any]],
mergeable_prs: list[dict[str, Any]],
errors: list[str],
) -> list[str]:
actions: list[str] = []
if errors:
actions.append("• Re-run failed GitHub reads before trusting this status report.")
if blocked_prs:
actions.append(f"• Unblock {len(blocked_prs)} PR(s) before starting new work.")
if mergeable_prs:
actions.append(f"• Review or merge {len(mergeable_prs)} ready PR(s).")
if up_for_grabs:
actions.append(f"• Assign {len(up_for_grabs)} up-for-grabs task(s).")
if unassigned:
actions.append(f"• Triage {len(unassigned)} unassigned issue(s).")
if not actions:
actions.append("• No obvious blockers from the supplied data.")
return actions
def build_work_status_report(
*,
work_items: Sequence[WorkItem | dict[str, Any]],
pull_requests: Sequence[PullRequestStatus | dict[str, Any]],
context: str = "today",
errors: list[str] | None = None,
) -> WorkStatusReport:
"""Build a Slack-ready report from an already-read GitHub snapshot."""
errors = list(errors or [])
item_dicts = [_as_dict(item) for item in work_items]
pr_dicts = [_as_dict(pr) for pr in pull_requests]
up_for_grabs = [item for item in item_dicts if item.get("work_status") == "up_for_grabs"]
unassigned = [item for item in item_dicts if item.get("work_status") == "unassigned"]
taken = [item for item in item_dicts if item.get("work_status") == "taken"]
blocked_prs = [pr for pr in pr_dicts if pr.get("status") == "blocked"]
mergeable_prs = [pr for pr in pr_dicts if pr.get("status") == "mergeable"]
unknown_prs = [pr for pr in pr_dicts if pr.get("status") == "unknown"]
sections = [f"*Engineering status — {context}*", ""]
if errors:
sections.extend(["*Incomplete report:*", *[f"{error}" for error in errors], ""])
sections.append(
f"*Open work:* {len(item_dicts)} total ({len(taken)} taken, "
f"{len(up_for_grabs)} up for grabs, {len(unassigned)} unassigned)"
)
if up_for_grabs:
sections.extend(["", "*Up for grabs:*", *[_item_line(item) for item in up_for_grabs[:10]]])
if unassigned:
sections.extend(["", "*Unassigned:*", *[_item_line(item) for item in unassigned[:10]]])
if blocked_prs:
sections.extend(["", "*Blocked PRs:*", *[_pr_line(pr) for pr in blocked_prs[:10]]])
if unknown_prs:
sections.extend(["", "*Unknown PR status:*", *[_pr_line(pr) for pr in unknown_prs[:10]]])
if mergeable_prs:
sections.extend(["", "*Ready to merge:*", *[_pr_line(pr) for pr in mergeable_prs[:10]]])
sections.extend(
[
"",
"*Recommended next actions:*",
*_recommended_actions(
up_for_grabs=up_for_grabs,
unassigned=unassigned,
blocked_prs=blocked_prs,
mergeable_prs=mergeable_prs,
errors=errors,
),
]
)
return WorkStatusReport(
counts={
"open_work": len(item_dicts),
"taken": len(taken),
"up_for_grabs": len(up_for_grabs),
"unassigned": len(unassigned),
"blocked_prs": len(blocked_prs),
"mergeable_prs": len(mergeable_prs),
"unknown_prs": len(unknown_prs),
},
slack_markdown="\n".join(sections).strip(),
available=not errors,
incomplete=bool(errors),
errors=errors,
)