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

322 lines
13 KiB
Python

"""Time-window deploy timeline tool for GitHub repos.
Sits alongside ``list_github_commits`` but asks a different question:
``list_github_commits`` — "show me the N most recent commits" (generic
history browsing).
``get_git_deploy_timeline`` — "what landed on the default branch between
T1 and T2" (incident correlation; "did something ship right before this
alert fired?").
The tool is a thin wrapper on top of the MCP ``list_commits`` call, using the
GitHub REST API's ``since`` / ``until`` ISO-8601 timestamps. It normalizes
each commit into a compact shape with the fields an RCA agent actually needs
(authored/committed time, author, message subject line, sha). A
``window_minutes_before_alert`` convenience arg lets callers say "the 120
minutes before now" without having to compute the timestamps themselves.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from core.domain.types.incident_window import IncidentWindow
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
DEFAULT_WINDOW_MINUTES = 120
MAX_WINDOW_MINUTES = 7 * 24 * 60 # 7 days
DEFAULT_PER_PAGE = 30
MAX_PER_PAGE = 100 # GitHub REST API hard cap for list_commits
def _parse_iso8601(value: str) -> datetime | None:
"""Parse ISO-8601 timestamp, accepting the trailing ``Z`` shorthand.
Always returns a timezone-aware ``datetime``: naive input (no offset) is
assumed to be UTC so downstream comparison and ``astimezone`` calls stay
consistent. Returns None for empty or malformed input rather than raising,
so the tool can fall back to its default window instead of failing the
call.
"""
text = (value or "").strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
def _resolve_window(
since: str, until: str, window_minutes_before_alert: int | None
) -> tuple[str, str]:
"""Resolve the [since, until] ISO-8601 window.
Precedence:
1. ``until`` is set to the parsed value if present; falls back to "now"
on empty or malformed input (a malformed ``until`` does NOT invalidate
``since`` — the ``until`` anchor simply becomes "now").
2. ``since`` is set to the parsed value if present; an inverted range
(``since > until``) is treated as invalid and falls through to (3).
3. If ``since`` is unset/invalid, compute it as ``until -
window_minutes_before_alert`` (or ``DEFAULT_WINDOW_MINUTES`` if that
arg is also unset/non-positive).
The final span is always clamped to ``MAX_WINDOW_MINUTES`` to keep the
MCP call bounded and avoid paging through months of history by accident.
"""
now = datetime.now(UTC)
parsed_since = _parse_iso8601(since)
parsed_until = _parse_iso8601(until) or now
# Inverted range (since > until) is always a caller error — we treat
# ``since`` as invalid and fall through to the window-minutes branch
# rather than passing an impossible range to the MCP server.
if parsed_since is not None and parsed_since > parsed_until:
parsed_since = None
if parsed_since is None:
minutes = window_minutes_before_alert
if minutes is None or minutes <= 0:
minutes = DEFAULT_WINDOW_MINUTES
minutes = min(minutes, MAX_WINDOW_MINUTES)
parsed_since = parsed_until - timedelta(minutes=minutes)
# Clamp the span regardless of how it was specified.
span = parsed_until - parsed_since
if span > timedelta(minutes=MAX_WINDOW_MINUTES):
parsed_since = parsed_until - timedelta(minutes=MAX_WINDOW_MINUTES)
return (
parsed_since.astimezone(UTC).isoformat().replace("+00:00", "Z"),
parsed_until.astimezone(UTC).isoformat().replace("+00:00", "Z"),
)
def _summarize_commit(raw: dict[str, Any]) -> dict[str, Any]:
"""Flatten the MCP commit envelope into the fields a diagnose step cares about."""
commit = raw.get("commit") or {}
author = commit.get("author") or {}
committer = commit.get("committer") or {}
message = str(commit.get("message") or "")
subject = message.splitlines()[0] if message else ""
return {
"sha": raw.get("sha", ""),
"short_sha": str(raw.get("sha", ""))[:7],
"author_name": author.get("name", ""),
"author_date": author.get("date", ""),
"committer_date": committer.get("date", ""),
"message_subject": subject,
"url": raw.get("html_url", ""),
}
def _extract_params(sources: dict[str, dict]) -> dict[str, Any]:
gh = sources["github"]
# ``_meta`` carries investigation-level context shared across tools
# (today: incident_window). Tools that don't care about it ignore the
# key. See the investigation agent tool context for where this is set.
# Defensive isinstance check: if anything ever stuffs a non-dict under
# the reserved ``_meta`` key, we degrade to no-shared-window rather
# than crashing with AttributeError on the .get below.
raw_meta = sources.get("_meta")
meta = raw_meta if isinstance(raw_meta, dict) else {}
incident_window = meta.get("incident_window")
return {
"owner": gh["owner"],
"repo": gh["repo"],
"branch": gh.get("branch") or gh.get("default_branch") or "main",
"shared_incident_window": incident_window if isinstance(incident_window, dict) else None,
**github_creds(gh),
}
def _is_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_git_deploy_timeline",
source="github",
description=(
"List commits on a GitHub branch within a time window (defaults to the last "
"120 minutes). Used to correlate an alert with recent deploys by asking "
'"what changed right before this fired?"'
),
use_cases=[
"Correlating an incident with recent code changes on the default branch",
"Checking whether a deploy landed within the alert window",
"Building a short-form deploy timeline for RCA narrative",
],
requires=["owner", "repo"],
surfaces=("investigation", "chat"),
input_schema={
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"branch": {"type": "string", "default": "main"},
"since": {
"type": "string",
"description": "ISO-8601 window start (e.g. 2026-04-20T10:00:00Z). Optional.",
},
"until": {
"type": "string",
"description": "ISO-8601 window end. Defaults to now.",
},
"window_minutes_before_alert": {
"type": "integer",
"description": (
"Convenience: minutes back from 'until' (or now) when 'since' is "
f"omitted. Clamped to {MAX_WINDOW_MINUTES} minutes. When omitted, "
"the tool prefers the shared incident window from state if one is "
f"available, otherwise falls back to {DEFAULT_WINDOW_MINUTES} minutes."
),
},
"per_page": {
"type": "integer",
"default": DEFAULT_PER_PAGE,
"minimum": 1,
"maximum": MAX_PER_PAGE,
},
"github_url": {"type": "string"},
"github_mode": {"type": "string"},
"github_token": {"type": "string"},
},
"required": ["owner", "repo"],
},
is_available=_is_available,
extract_params=_extract_params,
)
def get_git_deploy_timeline(
owner: str,
repo: str,
branch: str = "main",
since: str = "",
until: str = "",
window_minutes_before_alert: int | None = None,
per_page: int = DEFAULT_PER_PAGE,
shared_incident_window: dict[str, Any] | None = None,
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]:
"""Return commits on ``branch`` between ``since`` and ``until``.
Window resolution priority (highest to lowest):
1. Explicit ``since`` AND ``until`` from the caller.
2. Explicit ``window_minutes_before_alert`` from the caller.
3. ``shared_incident_window`` from agent state (when both ``since``
and ``until`` are empty AND no explicit window-minutes override).
4. ``DEFAULT_WINDOW_MINUTES`` (120 minutes before now).
"""
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": [],
"window": {},
}
# If neither since/until nor an explicit window-minutes was provided, try
# to use the shared incident window from agent state. ``from_dict``
# returns None on a malformed payload, which falls back to defaults.
effective_since, effective_until = since, until
used_shared_window = False
if not since and not until and window_minutes_before_alert is None and shared_incident_window:
window_obj = IncidentWindow.from_dict(shared_incident_window)
if window_obj is not None:
effective_since = window_obj.since.isoformat().replace("+00:00", "Z")
effective_until = window_obj.until.isoformat().replace("+00:00", "Z")
used_shared_window = True
# Final lookback used by the resolver: caller-explicit if given, else
# the tool's traditional default. The resolver clamps + validates.
effective_minutes = (
window_minutes_before_alert
if window_minutes_before_alert is not None
else DEFAULT_WINDOW_MINUTES
)
resolved_since, resolved_until = _resolve_window(
effective_since, effective_until, effective_minutes
)
# Clamp per_page to the GitHub REST API maximum of 100. Values above this
# are silently truncated upstream; we enforce the ceiling explicitly so
# ``truncated`` below is meaningful.
effective_per_page = max(1, min(per_page, MAX_PER_PAGE))
arguments: dict[str, Any] = {
"owner": owner,
"repo": repo,
"sha": branch,
"since": resolved_since,
"until": resolved_until,
"perPage": effective_per_page,
}
result = call_github_mcp_tool(config, "list_commits", arguments)
payload = normalize_github_tool_result(result)
raw_commits = payload.pop("structured_content", None) or []
if not isinstance(raw_commits, list):
raw_commits = []
commits = [_summarize_commit(item) for item in raw_commits if isinstance(item, dict)]
# When the page is full we cannot tell from the API whether more commits
# exist in the window — surface the uncertainty so the agent can choose to
# narrow the window or raise per_page rather than concluding "nothing
# else shipped".
truncated = len(commits) >= effective_per_page
# Trace where the window came from for the diagnose narrative.
# Three distinct sources, never conflated:
# "shared_incident_window" — tool used state.incident_window because
# no caller-explicit window was provided.
# "caller_explicit" — caller passed since/until or
# window_minutes_before_alert; the tool honoured it.
# "tool_default" — no caller input AND no shared window;
# the tool fell back to DEFAULT_WINDOW_MINUTES.
if used_shared_window:
window_source = "shared_incident_window"
elif since or until or window_minutes_before_alert is not None:
window_source = "caller_explicit"
else:
window_source = "tool_default"
payload.update(
{
"commits": commits,
"commits_count": len(commits),
"window": {
"since": resolved_since,
"until": resolved_until,
"branch": branch,
"per_page": effective_per_page,
"truncated": truncated,
"source": window_source,
},
}
)
return payload