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
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:
@@ -0,0 +1,7 @@
|
||||
"""GitHub integration package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from integrations.github.client import GitHubApiError, GitHubRestClient, resolve_github_token
|
||||
|
||||
__all__ = ["GitHubApiError", "GitHubRestClient", "resolve_github_token"]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Small GitHub REST client used by GitHub-backed OpenSRE tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
JsonPayload = dict[str, Any] | list[Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitHubApiError(RuntimeError):
|
||||
"""Typed GitHub API failure with enough context for callers to report safely."""
|
||||
|
||||
message: str
|
||||
status_code: int | None = None
|
||||
path: str = ""
|
||||
rate_limit_remaining: str | None = None
|
||||
rate_limit_reset: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.status_code is None:
|
||||
return self.message
|
||||
return f"GitHub API error {self.status_code}: {self.message}"
|
||||
|
||||
|
||||
def resolve_github_token(github_token: str | None = None) -> str:
|
||||
"""Resolve a GitHub token from explicit input or standard env vars."""
|
||||
|
||||
return (github_token or os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN") or "").strip()
|
||||
|
||||
|
||||
def _next_link(headers: Any) -> str | None:
|
||||
raw_link = ""
|
||||
if hasattr(headers, "get"):
|
||||
raw_link = str(headers.get("Link") or headers.get("link") or "")
|
||||
for part in raw_link.split(","):
|
||||
url_part, _, rel_part = part.partition(";")
|
||||
if 'rel="next"' in rel_part or "rel=next" in rel_part:
|
||||
return url_part.strip().strip("<>")
|
||||
return None
|
||||
|
||||
|
||||
def _decode_json_payload(raw: str, *, path: str) -> JsonPayload:
|
||||
if not raw.strip():
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise GitHubApiError("GitHub API returned invalid JSON.", path=path) from exc
|
||||
if isinstance(parsed, dict | list):
|
||||
return parsed
|
||||
return {"value": parsed}
|
||||
|
||||
|
||||
class GitHubRestClient:
|
||||
"""Minimal GitHub REST API client with pagination and typed errors."""
|
||||
|
||||
def __init__(
|
||||
self, github_token: str | None = None, *, base_url: str = "https://api.github.com"
|
||||
) -> None:
|
||||
self._token = resolve_github_token(github_token)
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
) -> JsonPayload:
|
||||
if not self._token:
|
||||
raise GitHubApiError(
|
||||
"GitHub token is required. Configure github_token, GITHUB_TOKEN, or GH_TOKEN."
|
||||
)
|
||||
|
||||
url = self._url(path, params=params)
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
req = request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method.upper(),
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=20) as response: # nosemgrep
|
||||
raw = response.read().decode("utf-8")
|
||||
except error.HTTPError as exc:
|
||||
detail = ""
|
||||
if exc.fp is not None:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
message = detail or exc.msg or "GitHub API request failed."
|
||||
raise GitHubApiError(
|
||||
message,
|
||||
status_code=exc.code,
|
||||
path=path,
|
||||
rate_limit_remaining=exc.headers.get("X-RateLimit-Remaining")
|
||||
if exc.headers
|
||||
else None,
|
||||
rate_limit_reset=exc.headers.get("X-RateLimit-Reset") if exc.headers else None,
|
||||
) from exc
|
||||
except error.URLError as exc:
|
||||
raise GitHubApiError(f"GitHub API request failed: {exc.reason}", path=path) from exc
|
||||
|
||||
return _decode_json_payload(raw, path=path)
|
||||
|
||||
def paginate(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not self._token:
|
||||
raise GitHubApiError(
|
||||
"GitHub token is required. Configure github_token, GITHUB_TOKEN, or GH_TOKEN."
|
||||
)
|
||||
|
||||
url: str | None = self._url(path, params=params)
|
||||
items: list[dict[str, Any]] = []
|
||||
while url:
|
||||
req = request.Request(
|
||||
url,
|
||||
method="GET",
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with request.urlopen(req, timeout=20) as response: # nosemgrep
|
||||
raw = response.read().decode("utf-8")
|
||||
headers = getattr(response, "headers", {})
|
||||
except error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
|
||||
raise GitHubApiError(
|
||||
detail or exc.msg or "GitHub API request failed.",
|
||||
status_code=exc.code,
|
||||
path=path,
|
||||
rate_limit_remaining=exc.headers.get("X-RateLimit-Remaining")
|
||||
if exc.headers
|
||||
else None,
|
||||
rate_limit_reset=exc.headers.get("X-RateLimit-Reset") if exc.headers else None,
|
||||
) from exc
|
||||
except error.URLError as exc:
|
||||
raise GitHubApiError(f"GitHub API request failed: {exc.reason}", path=path) from exc
|
||||
|
||||
parsed = _decode_json_payload(raw, path=path) if raw.strip() else []
|
||||
if isinstance(parsed, list):
|
||||
items.extend(item for item in parsed if isinstance(item, dict))
|
||||
elif isinstance(parsed, dict):
|
||||
# Search endpoints return objects with an items list.
|
||||
raw_items = parsed.get("items")
|
||||
if isinstance(raw_items, list):
|
||||
items.extend(item for item in raw_items if isinstance(item, dict))
|
||||
url = _next_link(headers)
|
||||
return items
|
||||
|
||||
def _url(self, path: str, *, params: dict[str, Any] | None = None) -> str:
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
base = path
|
||||
else:
|
||||
base = f"{self._base_url}/{path.lstrip('/')}"
|
||||
query = parse.urlencode(params, doseq=True) if params else ""
|
||||
if not query:
|
||||
return base
|
||||
separator = "&" if "?" in base else "?"
|
||||
return f"{base}{separator}{query}"
|
||||
|
||||
|
||||
__all__ = ["GitHubApiError", "GitHubRestClient", "JsonPayload", "resolve_github_token"]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Helper functions for GitHub tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from integrations.github.mcp import (
|
||||
DEFAULT_GITHUB_MCP_MODE,
|
||||
GitHubMCPConfig,
|
||||
build_github_mcp_config,
|
||||
github_mcp_config_from_env,
|
||||
)
|
||||
|
||||
|
||||
def github_source_available(sources: dict[str, dict]) -> bool:
|
||||
"""Check if source is available."""
|
||||
return bool(sources.get("github", {}).get("connection_verified"))
|
||||
|
||||
|
||||
def github_creds(gh: dict) -> dict[str, Any]:
|
||||
"""Map classified GitHub integration fields to tool credential kwargs."""
|
||||
creds: dict[str, Any] = {}
|
||||
url = gh.get("github_url") or gh.get("url")
|
||||
if url:
|
||||
creds["github_url"] = url
|
||||
mode = gh.get("github_mode") or gh.get("mode")
|
||||
if mode:
|
||||
creds["github_mode"] = mode
|
||||
token = gh.get("github_token") or gh.get("auth_token")
|
||||
if token:
|
||||
creds["github_token"] = token
|
||||
command = gh.get("github_command") or gh.get("command")
|
||||
if command:
|
||||
creds["github_command"] = command
|
||||
args = gh.get("github_args")
|
||||
if args is None:
|
||||
args = gh.get("args")
|
||||
if args:
|
||||
creds["github_args"] = list(args)
|
||||
return creds
|
||||
|
||||
|
||||
def _has_explicit_github_mcp_overrides(
|
||||
github_url: str | None,
|
||||
github_mode: str | None,
|
||||
github_token: str | None,
|
||||
github_command: str | None,
|
||||
github_args: list[str] | None,
|
||||
) -> bool:
|
||||
if github_url or github_token or github_command or github_args:
|
||||
return True
|
||||
return bool(github_mode and github_mode != DEFAULT_GITHUB_MCP_MODE)
|
||||
|
||||
|
||||
def resolve_github_mcp_config(
|
||||
github_url: str | None,
|
||||
github_mode: str | None,
|
||||
github_token: str | None,
|
||||
github_command: str | None = None,
|
||||
github_args: list[str] | None = None,
|
||||
) -> GitHubMCPConfig | None:
|
||||
"""Resolve GitHub MCP config."""
|
||||
env_config = github_mcp_config_from_env()
|
||||
if not _has_explicit_github_mcp_overrides(
|
||||
github_url, github_mode, github_token, github_command, github_args
|
||||
):
|
||||
return env_config
|
||||
return build_github_mcp_config(
|
||||
{
|
||||
"url": github_url or (env_config.url if env_config else ""),
|
||||
"mode": github_mode or (env_config.mode if env_config else DEFAULT_GITHUB_MCP_MODE),
|
||||
"auth_token": github_token or (env_config.auth_token if env_config else ""),
|
||||
"command": github_command or (env_config.command if env_config else ""),
|
||||
"args": github_args or (list(env_config.args) if env_config else []),
|
||||
"headers": env_config.headers if env_config else {},
|
||||
"toolsets": env_config.toolsets if env_config else (),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_github_tool_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize GitHub tool result."""
|
||||
if result.get("is_error"):
|
||||
return {
|
||||
"source": "github",
|
||||
"available": False,
|
||||
"error": result.get("text") or "GitHub MCP tool call failed.",
|
||||
"tool": result.get("tool"),
|
||||
"arguments": result.get("arguments", {}),
|
||||
}
|
||||
return {
|
||||
"source": "github",
|
||||
"available": True,
|
||||
"tool": result.get("tool"),
|
||||
"arguments": result.get("arguments", {}),
|
||||
"text": result.get("text", ""),
|
||||
"structured_content": result.get("structured_content"),
|
||||
"content": result.get("content", []),
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Lightweight GitHub identity helpers for UI and analytics.
|
||||
|
||||
Kept separate from :mod:`integrations.github.login` so callers like the welcome
|
||||
banner can read the saved handle without importing the heavy GitHub MCP stack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def saved_github_username() -> str:
|
||||
"""Return the persisted GitHub login from the integration store, or "".
|
||||
|
||||
Best-effort and never raises: callers like the welcome banner and analytics
|
||||
re-identify must work even when the store is unreadable.
|
||||
"""
|
||||
try:
|
||||
from integrations.store import get_integration
|
||||
|
||||
record = get_integration("github")
|
||||
if not record:
|
||||
return ""
|
||||
credentials = record.get("credentials") or {}
|
||||
return str(credentials.get("username") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Notify Slack when GitHub issue comments are created."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
MAX_COMMENT_PREVIEW_CHARS = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssueCommentNotification:
|
||||
"""Normalized issue comment notification payload."""
|
||||
|
||||
repository: str
|
||||
issue_number: int
|
||||
issue_title: str
|
||||
issue_url: str
|
||||
issue_author: str
|
||||
comment_author: str
|
||||
comment_url: str
|
||||
comment_body: str
|
||||
|
||||
|
||||
def _string(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _truncate_comment(text: str, *, limit: int = MAX_COMMENT_PREVIEW_CHARS) -> str:
|
||||
normalized = "\n".join(line.rstrip() for line in text.strip().splitlines()).strip()
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
return normalized[: limit - 3].rstrip() + "..."
|
||||
|
||||
|
||||
def notification_from_issue_comment_event(
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
repository: str,
|
||||
) -> IssueCommentNotification | None:
|
||||
"""Build a Slack notification model from a GitHub issue_comment event."""
|
||||
issue = event.get("issue")
|
||||
comment = event.get("comment")
|
||||
if not isinstance(issue, dict) or not isinstance(comment, dict):
|
||||
return None
|
||||
|
||||
# GitHub uses the same event for PR comments; ignore those here.
|
||||
if issue.get("pull_request"):
|
||||
return None
|
||||
|
||||
issue_number = issue.get("number")
|
||||
if not isinstance(issue_number, int):
|
||||
return None
|
||||
|
||||
issue_title = _string(issue.get("title"))
|
||||
issue_url = _string(issue.get("html_url"))
|
||||
comment_url = _string(comment.get("html_url"))
|
||||
comment_body = _string(comment.get("body"))
|
||||
issue_author = _string((issue.get("user") or {}).get("login"))
|
||||
comment_author = _string((comment.get("user") or {}).get("login"))
|
||||
|
||||
if not all((repository, issue_title, issue_url, comment_url, comment_author)):
|
||||
return None
|
||||
|
||||
return IssueCommentNotification(
|
||||
repository=repository,
|
||||
issue_number=issue_number,
|
||||
issue_title=issue_title,
|
||||
issue_url=issue_url,
|
||||
issue_author=issue_author or "unknown",
|
||||
comment_author=comment_author,
|
||||
comment_url=comment_url,
|
||||
comment_body=comment_body,
|
||||
)
|
||||
|
||||
|
||||
def build_slack_payload(notification: IssueCommentNotification) -> dict[str, Any]:
|
||||
"""Render a compact Slack incoming webhook payload."""
|
||||
comment_preview = _truncate_comment(notification.comment_body)
|
||||
issue_link = f"<{notification.issue_url}|{notification.repository}#{notification.issue_number}>"
|
||||
comment_link = f"<{notification.comment_url}|View comment>"
|
||||
|
||||
preview_text = comment_preview or "_No comment body provided._"
|
||||
preview_block = "\n".join(f"> {line}" for line in preview_text.splitlines()) or "> "
|
||||
|
||||
text = (
|
||||
f"New comment on {notification.repository}#{notification.issue_number} by "
|
||||
f"{notification.comment_author}: {notification.issue_title}"
|
||||
)
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f"*New GitHub issue comment*\n{issue_link}: *{notification.issue_title}*"
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f"*Commenter:* `{notification.comment_author}`"
|
||||
f" *Issue author:* `{notification.issue_author}`"
|
||||
f" {comment_link}"
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": preview_block},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def send_slack_webhook(payload: dict[str, Any], webhook_url: str) -> None:
|
||||
"""Send a payload to a Slack incoming webhook."""
|
||||
parsed = parse.urlparse(webhook_url)
|
||||
if parsed.scheme != "https" or parsed.netloc != "hooks.slack.com":
|
||||
raise RuntimeError("Slack webhook URL must target https://hooks.slack.com")
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(
|
||||
webhook_url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with _open_slack_webhook(req) as response:
|
||||
status_code = getattr(response, "status", response.getcode())
|
||||
except error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Slack webhook failed with HTTP {exc.code}: {detail}") from exc
|
||||
except error.URLError as exc:
|
||||
raise RuntimeError(f"Slack webhook failed: {exc.reason}") from exc
|
||||
|
||||
if status_code >= 400:
|
||||
raise RuntimeError(f"Slack webhook failed with HTTP {status_code}")
|
||||
|
||||
|
||||
def _open_slack_webhook(req: request.Request):
|
||||
return request.urlopen(req, timeout=10) # nosemgrep
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entrypoint used by the GitHub Actions workflow."""
|
||||
event_path = _string(os.getenv("GITHUB_EVENT_PATH"))
|
||||
repository = _string(os.getenv("GITHUB_REPOSITORY"))
|
||||
webhook_url = _string(
|
||||
os.getenv("SLACK_GITHUB_ISSUES_WEBHOOK_URL") or os.getenv("SLACK_WEBHOOK_URL")
|
||||
)
|
||||
|
||||
if not event_path:
|
||||
print("Missing GITHUB_EVENT_PATH.", file=sys.stderr)
|
||||
return 1
|
||||
if not repository:
|
||||
print("Missing GITHUB_REPOSITORY.", file=sys.stderr)
|
||||
return 1
|
||||
if not webhook_url:
|
||||
print("Skipped: Slack webhook is not configured.")
|
||||
return 0
|
||||
|
||||
event = json.loads(Path(event_path).read_text(encoding="utf-8"))
|
||||
notification = notification_from_issue_comment_event(event, repository=repository)
|
||||
if notification is None:
|
||||
print("Skipped: event is not a normal issue comment.")
|
||||
return 0
|
||||
|
||||
payload = build_slack_payload(notification)
|
||||
send_slack_webhook(payload, webhook_url)
|
||||
print(f"Posted Slack notification for {notification.repository}#{notification.issue_number}.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Streamlined GitHub device-flow login that configures the hosted GitHub MCP integration.
|
||||
|
||||
Used by the first-launch gate to authenticate the user, persist the hosted
|
||||
GitHub MCP integration, and surface the authenticated GitHub username. Reuses
|
||||
the same hosted defaults as ``opensre integrations setup github`` (no transport
|
||||
or advanced prompts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from integrations.github.mcp import (
|
||||
DEFAULT_GITHUB_MCP_MODE,
|
||||
DEFAULT_GITHUB_MCP_TOOLSETS,
|
||||
DEFAULT_GITHUB_MCP_URL,
|
||||
build_github_mcp_config,
|
||||
validate_github_mcp_config,
|
||||
)
|
||||
from integrations.github.mcp_oauth import (
|
||||
GitHubDeviceCode,
|
||||
authorize_github_via_device_flow,
|
||||
)
|
||||
from integrations.store import upsert_integration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitHubLoginResult:
|
||||
"""Outcome of a device-flow login + hosted GitHub MCP configuration attempt."""
|
||||
|
||||
ok: bool
|
||||
username: str = ""
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def authenticate_and_configure_github(
|
||||
*,
|
||||
on_prompt: Callable[[GitHubDeviceCode], None] | None = None,
|
||||
open_browser: bool = True,
|
||||
poll_sleep: Callable[[float], None] | None = None,
|
||||
) -> GitHubLoginResult:
|
||||
"""Run device-flow login, validate, and persist the hosted GitHub MCP integration.
|
||||
|
||||
The device flow may raise ``GitHubDeviceFlowError`` (or transport errors); those
|
||||
propagate to the caller so it can present a message and decide whether to retry.
|
||||
The integration is persisted only when validation succeeds.
|
||||
"""
|
||||
token = authorize_github_via_device_flow(
|
||||
on_prompt=on_prompt,
|
||||
open_browser=open_browser,
|
||||
poll_sleep=poll_sleep,
|
||||
)
|
||||
credentials: dict[str, object] = {
|
||||
"mode": DEFAULT_GITHUB_MCP_MODE,
|
||||
"url": DEFAULT_GITHUB_MCP_URL,
|
||||
"auth_token": token.access_token,
|
||||
"toolsets": list(DEFAULT_GITHUB_MCP_TOOLSETS),
|
||||
}
|
||||
result = validate_github_mcp_config(build_github_mcp_config(credentials))
|
||||
if not result.ok:
|
||||
return GitHubLoginResult(
|
||||
ok=False,
|
||||
username=result.authenticated_user,
|
||||
detail=result.detail,
|
||||
)
|
||||
if result.authenticated_user:
|
||||
# Persist the resolved GitHub login as a non-secret credential field so
|
||||
# surfaces like the welcome banner can greet the user by their GitHub
|
||||
# handle instead of the local system username.
|
||||
credentials["username"] = result.authenticated_user
|
||||
upsert_integration("github", {"credentials": credentials})
|
||||
username = result.authenticated_user
|
||||
if username:
|
||||
from platform.analytics.cli import identify_github_username
|
||||
|
||||
identify_github_username(username)
|
||||
return GitHubLoginResult(ok=True, username=username, detail=result.detail)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
"""GitHub OAuth device-flow authorization for the GitHub MCP integration.
|
||||
|
||||
Browser-based "authorize the app" login that yields a GitHub user access token
|
||||
usable as the GitHub MCP ``Authorization: Bearer`` credential.
|
||||
|
||||
Device flow needs only a *public* OAuth App ``client_id`` (no client secret),
|
||||
which is why it is safe to ship in a distributed CLI. Register an OAuth App with
|
||||
"Enable Device Flow" checked, then expose its client id via
|
||||
``OPENSRE_GITHUB_OAUTH_CLIENT_ID`` (or bake it into ``DEFAULT_GITHUB_OAUTH_CLIENT_ID``).
|
||||
|
||||
GitHub does not advertise dynamic client registration, so a pre-registered app is
|
||||
required regardless of the flow; device flow keeps the implementation minimal
|
||||
(no localhost redirect server, no PKCE plumbing, no embedded secret).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||
GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device"
|
||||
_DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
# Read-oriented repository investigation scopes. All are listed in the GitHub MCP
|
||||
# server's advertised ``scopes_supported``.
|
||||
DEFAULT_GITHUB_OAUTH_SCOPES: tuple[str, ...] = ("repo", "read:org", "read:user")
|
||||
|
||||
# Public OAuth App client id shipped with OpenSRE (device flow enabled). This is
|
||||
# NOT a secret — device flow has no client secret. Override at runtime with
|
||||
# ``OPENSRE_GITHUB_OAUTH_CLIENT_ID`` to point at a different OAuth App.
|
||||
DEFAULT_GITHUB_OAUTH_CLIENT_ID = "Ov23li3MyquuARMTSibo"
|
||||
|
||||
_MIN_POLL_INTERVAL = 1.0
|
||||
_SLOW_DOWN_BACKOFF = 5.0
|
||||
|
||||
|
||||
class GitHubDeviceFlowError(RuntimeError):
|
||||
"""Raised when device-flow authorization cannot complete."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitHubDeviceCode:
|
||||
"""Device/user codes returned by the device-authorization request."""
|
||||
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitHubDeviceToken:
|
||||
"""A GitHub user access token obtained via device flow."""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
scope: str = ""
|
||||
refresh_token: str = ""
|
||||
expires_in: int | None = None
|
||||
|
||||
|
||||
def resolve_github_oauth_client_id(explicit: str = "") -> str:
|
||||
"""Resolve the OAuth App client id from arg, env, then shipped default."""
|
||||
return (
|
||||
(explicit or "").strip()
|
||||
or os.getenv("OPENSRE_GITHUB_OAUTH_CLIENT_ID", "").strip()
|
||||
or DEFAULT_GITHUB_OAUTH_CLIENT_ID.strip()
|
||||
)
|
||||
|
||||
|
||||
def _device_error_message(payload: dict[str, object]) -> str:
|
||||
error = str(payload.get("error") or "").strip()
|
||||
if error == "device_flow_disabled":
|
||||
return (
|
||||
"Device flow is not enabled on this GitHub OAuth App. Open the app's "
|
||||
"settings and check 'Enable Device Flow'."
|
||||
)
|
||||
description = str(payload.get("error_description") or error or "unknown error").strip()
|
||||
return f"GitHub device authorization failed: {description}"
|
||||
|
||||
|
||||
def request_github_device_code(
|
||||
*,
|
||||
client_id: str,
|
||||
scopes: Sequence[str] = DEFAULT_GITHUB_OAUTH_SCOPES,
|
||||
timeout: float = 15.0,
|
||||
) -> GitHubDeviceCode:
|
||||
"""Start device authorization: ask GitHub for a device + user code."""
|
||||
response = httpx.post(
|
||||
GITHUB_DEVICE_CODE_URL,
|
||||
data={"client_id": client_id, "scope": " ".join(scopes)},
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict) or "device_code" not in payload:
|
||||
raise GitHubDeviceFlowError(
|
||||
_device_error_message(payload if isinstance(payload, dict) else {})
|
||||
)
|
||||
return GitHubDeviceCode(
|
||||
device_code=str(payload["device_code"]),
|
||||
user_code=str(payload.get("user_code", "")),
|
||||
verification_uri=str(payload.get("verification_uri") or GITHUB_DEVICE_VERIFICATION_URL),
|
||||
expires_in=int(payload.get("expires_in", 900)),
|
||||
interval=int(payload.get("interval", 5)),
|
||||
)
|
||||
|
||||
|
||||
def poll_github_device_token(
|
||||
*,
|
||||
client_id: str,
|
||||
device_code: GitHubDeviceCode,
|
||||
timeout: float = 15.0,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> GitHubDeviceToken:
|
||||
"""Poll the token endpoint until the user approves (or the code expires)."""
|
||||
interval = max(float(device_code.interval), _MIN_POLL_INTERVAL)
|
||||
deadline = monotonic() + float(device_code.expires_in)
|
||||
while True:
|
||||
if monotonic() >= deadline:
|
||||
raise GitHubDeviceFlowError(
|
||||
"Device authorization expired before it was approved. Re-run setup."
|
||||
)
|
||||
sleep(interval)
|
||||
response = httpx.post(
|
||||
GITHUB_ACCESS_TOKEN_URL,
|
||||
data={
|
||||
"client_id": client_id,
|
||||
"device_code": device_code.device_code,
|
||||
"grant_type": _DEVICE_GRANT_TYPE,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise GitHubDeviceFlowError("GitHub returned an unexpected token response.")
|
||||
|
||||
error = str(payload.get("error") or "").strip()
|
||||
if not error:
|
||||
access_token = str(payload.get("access_token") or "")
|
||||
if not access_token:
|
||||
raise GitHubDeviceFlowError("GitHub returned no access token.")
|
||||
expires_raw = payload.get("expires_in")
|
||||
return GitHubDeviceToken(
|
||||
access_token=access_token,
|
||||
token_type=str(payload.get("token_type") or "bearer"),
|
||||
scope=str(payload.get("scope") or ""),
|
||||
refresh_token=str(payload.get("refresh_token") or ""),
|
||||
expires_in=int(expires_raw) if expires_raw is not None else None,
|
||||
)
|
||||
if error == "authorization_pending":
|
||||
continue
|
||||
if error == "slow_down":
|
||||
interval = max(interval, float(payload.get("interval", interval + _SLOW_DOWN_BACKOFF)))
|
||||
continue
|
||||
if error in {"expired_token", "access_denied"}:
|
||||
msg = (
|
||||
"Authorization was denied in the browser."
|
||||
if error == "access_denied"
|
||||
else "Device code expired before approval. Re-run setup."
|
||||
)
|
||||
raise GitHubDeviceFlowError(msg)
|
||||
raise GitHubDeviceFlowError(_device_error_message(payload))
|
||||
|
||||
|
||||
def authorize_github_via_device_flow(
|
||||
*,
|
||||
client_id: str = "",
|
||||
scopes: Sequence[str] = DEFAULT_GITHUB_OAUTH_SCOPES,
|
||||
open_browser: bool = True,
|
||||
on_prompt: Callable[[GitHubDeviceCode], None] | None = None,
|
||||
poll_sleep: Callable[[float], None] | None = None,
|
||||
) -> GitHubDeviceToken:
|
||||
"""Run the full browser device flow and return a user access token.
|
||||
|
||||
``on_prompt`` is invoked with the device/user codes so the caller can show
|
||||
the user code and verification URL before (optionally) opening the browser.
|
||||
"""
|
||||
resolved_client_id = resolve_github_oauth_client_id(client_id)
|
||||
if not resolved_client_id:
|
||||
raise GitHubDeviceFlowError(
|
||||
"No GitHub OAuth client id is configured. Register an OAuth App with "
|
||||
"Device Flow enabled and set OPENSRE_GITHUB_OAUTH_CLIENT_ID."
|
||||
)
|
||||
|
||||
device_code = request_github_device_code(client_id=resolved_client_id, scopes=scopes)
|
||||
if on_prompt is not None:
|
||||
on_prompt(device_code)
|
||||
if open_browser:
|
||||
try:
|
||||
webbrowser.open(device_code.verification_uri)
|
||||
except Exception: # pragma: no cover - headless/no-browser environments
|
||||
logger.debug("Could not open a browser for GitHub device authorization", exc_info=True)
|
||||
return poll_github_device_token(
|
||||
client_id=resolved_client_id,
|
||||
device_code=device_code,
|
||||
sleep=poll_sleep or time.sleep,
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Infer GitHub repository scope (owner/repo) for repo-scoped tool calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
_GITHUB_HOST_RE = re.compile(
|
||||
r"(?:https?://)?(?:www\.)?github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s#?]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REPO_QUALIFIER_RE = re.compile(
|
||||
r"\brepo:(?P<owner>[^/\s]+)/(?P<repo>[^/\s#?]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BARE_REPO_RE = re.compile(
|
||||
r"(?<![/\w.-])(?P<owner>[A-Za-z0-9][\w.-]*)/(?P<repo>[A-Za-z0-9][\w.-]*)(?![/\w.-])",
|
||||
)
|
||||
|
||||
|
||||
def split_repo_full_name(value: str) -> tuple[str, str]:
|
||||
"""Split ``owner/repo`` (optionally with trailing ``.git``) into its parts."""
|
||||
cleaned = value.strip().strip("/")
|
||||
if cleaned.count("/") < 1:
|
||||
return "", ""
|
||||
owner, repo = cleaned.split("/", 1)
|
||||
return owner.strip(), repo.strip().removesuffix(".git")
|
||||
|
||||
|
||||
def parse_github_repository_reference(text: str) -> tuple[str, str] | None:
|
||||
"""Return the last owner/repo pair found in *text*, or ``None``."""
|
||||
if not text.strip():
|
||||
return None
|
||||
|
||||
matches: list[tuple[str, str]] = []
|
||||
for pattern in (_GITHUB_HOST_RE, _REPO_QUALIFIER_RE, _BARE_REPO_RE):
|
||||
for match in pattern.finditer(text):
|
||||
owner = match.group("owner").strip()
|
||||
repo = match.group("repo").strip().removesuffix(".git")
|
||||
if owner and repo:
|
||||
matches.append((owner, repo))
|
||||
return matches[-1] if matches else None
|
||||
|
||||
|
||||
def _parse_git_remote_url(url: str) -> tuple[str, str] | None:
|
||||
cleaned = url.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
if cleaned.startswith("git@"):
|
||||
_, _, path = cleaned.partition(":")
|
||||
if path:
|
||||
owner, repo = split_repo_full_name(path)
|
||||
return (owner, repo) if owner and repo else None
|
||||
return parse_github_repository_reference(cleaned)
|
||||
|
||||
|
||||
def detect_git_remote_repo_scope(cwd: str | Path | None = None) -> tuple[str, str] | None:
|
||||
"""Best-effort ``owner/repo`` from ``git remote get-url origin`` in *cwd*."""
|
||||
work_dir = Path(cwd or os.getcwd())
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
cwd=work_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return _parse_git_remote_url(result.stdout)
|
||||
|
||||
|
||||
def infer_github_repo_scope(
|
||||
*,
|
||||
message: str,
|
||||
conversation_messages: Sequence[tuple[str, str]] | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
cwd: str | Path | None = None,
|
||||
cached: tuple[str, str] | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
"""Resolve GitHub owner/repo using message, history, session cache, env, and git."""
|
||||
from_message = parse_github_repository_reference(message)
|
||||
if from_message:
|
||||
return from_message
|
||||
|
||||
if conversation_messages:
|
||||
for _role, content in reversed(conversation_messages):
|
||||
from_history = parse_github_repository_reference(content)
|
||||
if from_history:
|
||||
return from_history
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
env_map = env if env is not None else os.environ
|
||||
env_repo = str(env_map.get("GITHUB_REPOSITORY", "")).strip()
|
||||
if env_repo:
|
||||
owner, repo = split_repo_full_name(env_repo)
|
||||
if owner and repo:
|
||||
return owner, repo
|
||||
|
||||
return detect_git_remote_repo_scope(cwd)
|
||||
|
||||
|
||||
def apply_github_repo_scope(
|
||||
resolved: dict[str, Any],
|
||||
owner: str,
|
||||
repo: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a copy of *resolved* with ``github.owner`` and ``github.repo`` set."""
|
||||
gh = resolved.get("github")
|
||||
if not gh:
|
||||
return dict(resolved)
|
||||
|
||||
if isinstance(gh, BaseModel):
|
||||
gh_dict = gh.model_dump(exclude_none=True)
|
||||
elif isinstance(gh, dict):
|
||||
gh_dict = dict(gh)
|
||||
else:
|
||||
return dict(resolved)
|
||||
|
||||
merged = dict(resolved)
|
||||
gh_dict["owner"] = owner
|
||||
gh_dict["repo"] = repo
|
||||
merged["github"] = gh_dict
|
||||
return merged
|
||||
|
||||
|
||||
__all__ = [
|
||||
"apply_github_repo_scope",
|
||||
"detect_git_remote_repo_scope",
|
||||
"infer_github_repo_scope",
|
||||
"parse_github_repository_reference",
|
||||
"split_repo_full_name",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""GitHub MCP integration verifier.
|
||||
|
||||
Reports credential-less records as ``missing`` rather than ``failed`` so
|
||||
a stale store entry with no auth token surfaces as not-configured instead
|
||||
of a confusing 401.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from integrations.github.mcp import build_github_mcp_config, validate_github_mcp_config
|
||||
from integrations.verification import register_verifier, result
|
||||
|
||||
|
||||
@register_verifier("github")
|
||||
def verify_github(source: str, config: dict[str, Any]) -> dict[str, str]:
|
||||
normalized_config = build_github_mcp_config(config)
|
||||
validation_result = validate_github_mcp_config(normalized_config)
|
||||
if not validation_result.ok and validation_result.failure_category == "not_configured":
|
||||
return result("github", source, "missing", validation_result.detail)
|
||||
return result(
|
||||
"github",
|
||||
source,
|
||||
"passed" if validation_result.ok else "failed",
|
||||
validation_result.detail,
|
||||
)
|
||||
Reference in New Issue
Block a user