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
521 lines
18 KiB
Python
521 lines
18 KiB
Python
"""Tests for semantic GitHub workflow tools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
from core.execution import (
|
|
BeforeToolCallResult,
|
|
ToolExecutionHooks,
|
|
ToolExecutionRequest,
|
|
execute_tool_calls,
|
|
)
|
|
from core.llm.types import ToolCall
|
|
from core.tool_framework.registered_tool import RegisteredTool
|
|
from integrations.github.tools.work_status import (
|
|
execute_github_issue_mutation,
|
|
list_github_security_alerts,
|
|
list_github_work_items,
|
|
propose_github_issue_mutation_from_slack,
|
|
summarize_github_pr_status,
|
|
)
|
|
from integrations.github.tools.workflow import (
|
|
GitHubApiError,
|
|
GitHubRestClient,
|
|
build_work_status_report,
|
|
)
|
|
from tests.tools.conftest import BaseToolContract
|
|
from tools.work_status_report_tool import generate_work_status_report
|
|
|
|
|
|
def _registered_tool(tool: Any) -> Any:
|
|
return tool.__opensre_registered_tool__
|
|
|
|
|
|
class TestListGitHubWorkItemsContract(BaseToolContract):
|
|
def get_tool_under_test(self):
|
|
return _registered_tool(list_github_work_items)
|
|
|
|
|
|
class TestSummarizeGitHubPrStatusContract(BaseToolContract):
|
|
def get_tool_under_test(self):
|
|
return _registered_tool(summarize_github_pr_status)
|
|
|
|
|
|
class TestListGitHubSecurityAlertsContract(BaseToolContract):
|
|
def get_tool_under_test(self):
|
|
return _registered_tool(list_github_security_alerts)
|
|
|
|
|
|
class TestProposeGitHubIssueMutationFromSlackContract(BaseToolContract):
|
|
def get_tool_under_test(self):
|
|
return _registered_tool(propose_github_issue_mutation_from_slack)
|
|
|
|
|
|
class TestExecuteGitHubIssueMutationContract(BaseToolContract):
|
|
def get_tool_under_test(self):
|
|
return _registered_tool(execute_github_issue_mutation)
|
|
|
|
|
|
def test_list_github_work_items_classifies_taken_and_up_for_grabs() -> None:
|
|
issues = [
|
|
{
|
|
"number": 1,
|
|
"title": "Assigned bug",
|
|
"state": "open",
|
|
"html_url": "https://github.com/o/r/issues/1",
|
|
"user": {"login": "alice"},
|
|
"assignees": [{"login": "bob"}],
|
|
"labels": [{"name": "bug"}],
|
|
"updated_at": "2026-06-28T10:00:00Z",
|
|
},
|
|
{
|
|
"number": 2,
|
|
"title": "Starter task",
|
|
"state": "open",
|
|
"html_url": "https://github.com/o/r/issues/2",
|
|
"user": {"login": "carol"},
|
|
"assignees": [],
|
|
"labels": [{"name": "help wanted"}],
|
|
"updated_at": "2026-06-28T11:00:00Z",
|
|
},
|
|
{"number": 3, "pull_request": {}, "title": "PR returned from issues endpoint"},
|
|
]
|
|
with patch.object(GitHubRestClient, "paginate", return_value=issues):
|
|
result = list_github_work_items(owner="o", repo="r", github_token="tok")
|
|
|
|
assert result["available"] is True
|
|
assert result["counts"] == {"total": 2, "taken": 1, "up_for_grabs": 1, "unassigned": 0}
|
|
assert [item["work_status"] for item in result["items"]] == ["taken", "up_for_grabs"]
|
|
|
|
|
|
def test_summarize_github_pr_status_uses_detail_mergeability_not_list_nulls() -> None:
|
|
list_pr = {
|
|
"number": 10,
|
|
"title": "Ready PR",
|
|
"draft": False,
|
|
"html_url": "https://github.com/o/r/pull/10",
|
|
"user": {"login": "alice"},
|
|
"head": {"sha": "abc", "ref": "feature"},
|
|
"mergeable": None,
|
|
"mergeable_state": "unknown",
|
|
"updated_at": "2026-06-28T10:00:00Z",
|
|
}
|
|
detail_pr = {**list_pr, "mergeable": True, "mergeable_state": "clean"}
|
|
|
|
def fake_request(self: GitHubRestClient, method: str, path: str, **_kwargs: Any) -> Any:
|
|
if path == "/repos/o/r/pulls/10":
|
|
return detail_pr
|
|
if path == "/repos/o/r/commits/abc/check-runs":
|
|
return {
|
|
"check_runs": [{"name": "test", "conclusion": "success", "status": "completed"}]
|
|
}
|
|
raise AssertionError((method, path))
|
|
|
|
with (
|
|
patch.object(GitHubRestClient, "paginate", return_value=[list_pr]),
|
|
patch.object(GitHubRestClient, "request", fake_request),
|
|
):
|
|
result = summarize_github_pr_status(owner="o", repo="r", github_token="tok")
|
|
|
|
assert result["counts"]["mergeable"] == 1
|
|
assert result["pull_requests"][0]["mergeability"] == "mergeable"
|
|
|
|
|
|
def test_summarize_github_pr_status_reports_unknown_mergeability() -> None:
|
|
pr = {
|
|
"number": 11,
|
|
"title": "Unknown PR",
|
|
"draft": False,
|
|
"html_url": "https://github.com/o/r/pull/11",
|
|
"user": {"login": "bob"},
|
|
"head": {"sha": "def", "ref": "bugfix"},
|
|
"mergeable": None,
|
|
"mergeable_state": "unknown",
|
|
"updated_at": "2026-06-28T11:00:00Z",
|
|
}
|
|
|
|
def fake_request(self: GitHubRestClient, method: str, path: str, **_kwargs: Any) -> Any:
|
|
if path == "/repos/o/r/pulls/11":
|
|
return pr
|
|
if path == "/repos/o/r/commits/def/check-runs":
|
|
return {"check_runs": []}
|
|
raise AssertionError((method, path))
|
|
|
|
with (
|
|
patch.object(GitHubRestClient, "paginate", return_value=[pr]),
|
|
patch.object(GitHubRestClient, "request", fake_request),
|
|
):
|
|
result = summarize_github_pr_status(owner="o", repo="r", github_token="tok")
|
|
|
|
assert result["counts"]["unknown"] == 1
|
|
assert result["pull_requests"][0]["status"] == "unknown"
|
|
assert "mergeability unknown" in result["pull_requests"][0]["blocking_reasons"]
|
|
|
|
|
|
def test_generate_work_status_report_surfaces_fetch_errors() -> None:
|
|
with (
|
|
patch(
|
|
"tools.work_status_report_tool.list_github_work_items",
|
|
return_value={"available": False, "error": "boom", "items": []},
|
|
),
|
|
patch(
|
|
"tools.work_status_report_tool.summarize_github_pr_status",
|
|
return_value={"available": True, "pull_requests": []},
|
|
),
|
|
):
|
|
result = generate_work_status_report(owner="o", repo="r", github_token="tok")
|
|
|
|
assert result["available"] is False
|
|
assert result["incomplete"] is True
|
|
assert result["errors"] == ["work_items: boom"]
|
|
|
|
|
|
def test_build_work_status_report_does_not_hide_read_errors() -> None:
|
|
report = build_work_status_report(
|
|
work_items=[],
|
|
pull_requests=[],
|
|
context="today",
|
|
errors=["pull_requests: failed"],
|
|
)
|
|
|
|
assert report.available is False
|
|
assert report.incomplete is True
|
|
assert "Incomplete report" in report.slack_markdown
|
|
|
|
|
|
def test_summarize_community_followups_uses_repository_comments_endpoint() -> None:
|
|
with patch.object(
|
|
GitHubRestClient,
|
|
"paginate",
|
|
return_value=[
|
|
{
|
|
"issue_url": "https://api.github.com/repos/o/r/issues/7",
|
|
"body": "When is the meeting?",
|
|
"user": {"login": "contributor"},
|
|
"created_at": "2026-06-28T10:00:00Z",
|
|
"html_url": "u",
|
|
}
|
|
],
|
|
) as paginate:
|
|
result = __import__(
|
|
"tools.community_followup_tool", fromlist=["summarize_community_followups"]
|
|
).summarize_community_followups(owner="o", repo="r", github_token="tok")
|
|
|
|
paginate.assert_called_once()
|
|
assert paginate.call_args.args[0] == "/repos/o/r/issues/comments"
|
|
assert result["counts"]["unanswered_questions"] == 1
|
|
|
|
|
|
def test_proposal_id_is_stable_and_payload_has_idempotency_marker() -> None:
|
|
first = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="create",
|
|
slack_text="add this to the hackathon list",
|
|
slack_url="https://slack.example/archives/C/p1",
|
|
labels=["hackathon"],
|
|
)
|
|
second = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="create",
|
|
slack_text="add this to the hackathon list",
|
|
slack_url="https://slack.example/archives/C/p1",
|
|
labels=["hackathon"],
|
|
)
|
|
|
|
assert first["proposal"]["proposal_id"] == second["proposal"]["proposal_id"]
|
|
assert first["proposal"]["operation"] == "create"
|
|
assert "opensre-slack-proposal:" in first["proposal"]["payload"]["body"]
|
|
assert first["side_effects"] == []
|
|
|
|
|
|
def test_execute_tool_schema_has_no_confirm_parameter() -> None:
|
|
tool = _registered_tool(execute_github_issue_mutation)
|
|
assert "confirm" not in tool.input_schema["properties"]
|
|
assert tool.requires_approval is False
|
|
|
|
|
|
def test_execute_create_searches_idempotency_marker_before_create() -> None:
|
|
proposal = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="create",
|
|
slack_text="ship this",
|
|
slack_url="https://slack.example/archives/C/p1",
|
|
)["proposal"]
|
|
calls: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
def fake_request(self: GitHubRestClient, method: str, path: str, **kwargs: Any) -> Any:
|
|
calls.append((method, path, kwargs))
|
|
if path == "/search/issues":
|
|
return {"total_count": 0, "items": []}
|
|
if path == "/repos/o/r/issues":
|
|
return {"number": 99, "html_url": "https://github.com/o/r/issues/99"}
|
|
raise AssertionError((method, path))
|
|
|
|
with patch.object(GitHubRestClient, "request", fake_request):
|
|
result = execute_github_issue_mutation(
|
|
owner="o", repo="r", proposal=proposal, github_token="tok"
|
|
)
|
|
|
|
assert result["executed"] is True
|
|
assert result["side_effect"] == "created_github_issue"
|
|
assert [call[1] for call in calls] == ["/search/issues", "/repos/o/r/issues"]
|
|
assert "in:body" in calls[0][2]["params"]["q"]
|
|
assert proposal["idempotency_marker"] in calls[0][2]["params"]["q"]
|
|
|
|
|
|
def test_execute_create_returns_existing_issue_for_idempotency_marker() -> None:
|
|
proposal = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="create",
|
|
slack_text="ship this",
|
|
slack_url="https://slack.example/archives/C/p1",
|
|
)["proposal"]
|
|
|
|
def fake_request(self: GitHubRestClient, method: str, path: str, **_kwargs: Any) -> Any:
|
|
if path == "/search/issues":
|
|
return {"total_count": 1, "items": [{"number": 12, "html_url": "existing"}]}
|
|
raise AssertionError((method, path))
|
|
|
|
with patch.object(GitHubRestClient, "request", fake_request):
|
|
result = execute_github_issue_mutation(
|
|
owner="o", repo="r", proposal=proposal, github_token="tok"
|
|
)
|
|
|
|
assert result["executed"] is False
|
|
assert result["side_effect"] == "existing_github_issue"
|
|
|
|
|
|
def test_execute_update_adds_comment_and_preserves_body() -> None:
|
|
proposal = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="update",
|
|
issue_number=51,
|
|
slack_text="PR shipped",
|
|
slack_url="https://slack.example/archives/C/p2",
|
|
labels=["done"],
|
|
)["proposal"]
|
|
calls: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
def fake_request(self: GitHubRestClient, method: str, path: str, **kwargs: Any) -> Any:
|
|
calls.append((method, path, kwargs))
|
|
if path == "/repos/o/r/issues/51" and method == "GET":
|
|
return {"number": 51, "body": "original"}
|
|
if path == "/search/issues":
|
|
assert "in:comments" in kwargs["params"]["q"]
|
|
return {"total_count": 0, "items": []}
|
|
if path == "/repos/o/r/issues/51/comments":
|
|
assert "PR shipped" in kwargs["body"]["body"]
|
|
return {"id": 1}
|
|
if path == "/repos/o/r/issues/51" and method == "PATCH":
|
|
assert "body" not in kwargs["body"]
|
|
assert kwargs["body"] == {"labels": ["done"]}
|
|
return {"number": 51}
|
|
raise AssertionError((method, path, kwargs))
|
|
|
|
with patch.object(GitHubRestClient, "request", fake_request):
|
|
result = execute_github_issue_mutation(
|
|
owner="o", repo="r", proposal=proposal, github_token="tok"
|
|
)
|
|
|
|
assert result["executed"] is True
|
|
assert [call[1] for call in calls] == [
|
|
"/repos/o/r/issues/51",
|
|
"/search/issues",
|
|
"/repos/o/r/issues/51/comments",
|
|
"/repos/o/r/issues/51",
|
|
]
|
|
|
|
|
|
def test_execute_close_comments_before_closing_and_preserves_body() -> None:
|
|
proposal = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="close",
|
|
issue_number=51,
|
|
slack_text="done",
|
|
slack_url="https://slack.example/archives/C/p3",
|
|
)["proposal"]
|
|
calls: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
def fake_request(self: GitHubRestClient, method: str, path: str, **kwargs: Any) -> Any:
|
|
calls.append((method, path, kwargs))
|
|
if path == "/repos/o/r/issues/51" and method == "GET":
|
|
return {"number": 51, "body": "original"}
|
|
if path == "/search/issues":
|
|
assert "in:comments" in kwargs["params"]["q"]
|
|
return {"total_count": 0, "items": []}
|
|
if path == "/repos/o/r/issues/51/comments":
|
|
return {"id": 1}
|
|
if path == "/repos/o/r/issues/51" and method == "PATCH":
|
|
assert kwargs["body"] == {"state": "closed", "state_reason": "completed"}
|
|
return {"number": 51, "state": "closed"}
|
|
raise AssertionError((method, path, kwargs))
|
|
|
|
with patch.object(GitHubRestClient, "request", fake_request):
|
|
result = execute_github_issue_mutation(
|
|
owner="o", repo="r", proposal=proposal, github_token="tok"
|
|
)
|
|
|
|
assert result["executed"] is True
|
|
assert [call[1] for call in calls] == [
|
|
"/repos/o/r/issues/51",
|
|
"/search/issues",
|
|
"/repos/o/r/issues/51/comments",
|
|
"/repos/o/r/issues/51",
|
|
]
|
|
|
|
|
|
def test_execute_update_skips_duplicate_comment_for_seen_marker() -> None:
|
|
proposal = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="update",
|
|
issue_number=51,
|
|
slack_text="PR shipped",
|
|
slack_url="https://slack.example/archives/C/p2",
|
|
labels=["done"],
|
|
)["proposal"]
|
|
calls: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
def fake_request(self: GitHubRestClient, method: str, path: str, **kwargs: Any) -> Any:
|
|
calls.append((method, path, kwargs))
|
|
if path == "/repos/o/r/issues/51" and method == "GET":
|
|
return {"number": 51, "body": "original"}
|
|
if path == "/search/issues":
|
|
return {"total_count": 1, "items": [{"number": 51}]}
|
|
if path == "/repos/o/r/issues/51/comments":
|
|
raise AssertionError("duplicate comment should not be posted")
|
|
if path == "/repos/o/r/issues/51" and method == "PATCH":
|
|
return {"number": 51}
|
|
raise AssertionError((method, path, kwargs))
|
|
|
|
with patch.object(GitHubRestClient, "request", fake_request):
|
|
result = execute_github_issue_mutation(
|
|
owner="o", repo="r", proposal=proposal, github_token="tok"
|
|
)
|
|
|
|
assert result["executed"] is True
|
|
assert result["comment_already_recorded"] is True
|
|
assert [call[1] for call in calls] == [
|
|
"/repos/o/r/issues/51",
|
|
"/search/issues",
|
|
"/repos/o/r/issues/51",
|
|
]
|
|
|
|
|
|
def test_execute_mutation_rejects_malformed_proposal_without_api_call() -> None:
|
|
with patch.object(GitHubRestClient, "request") as request:
|
|
result = execute_github_issue_mutation(
|
|
owner="o", repo="r", proposal={"operation": "create"}, github_token="tok"
|
|
)
|
|
|
|
request.assert_not_called()
|
|
assert result["executed"] is False
|
|
assert result["side_effect"] == "github_issue_mutation_rejected"
|
|
assert "missing required field" in result["error"]
|
|
|
|
|
|
def test_execute_mutation_rejects_payload_without_marker() -> None:
|
|
proposal = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="create",
|
|
slack_text="ship this",
|
|
slack_url="https://slack.example/archives/C/p1",
|
|
)["proposal"]
|
|
proposal = {**proposal, "payload": {**proposal["payload"], "body": "missing marker"}}
|
|
|
|
with patch.object(GitHubRestClient, "request") as request:
|
|
result = execute_github_issue_mutation(
|
|
owner="o", repo="r", proposal=proposal, github_token="tok"
|
|
)
|
|
|
|
request.assert_not_called()
|
|
assert result["executed"] is False
|
|
assert result["side_effect"] == "github_issue_mutation_rejected"
|
|
assert "idempotency marker" in result["error"]
|
|
|
|
|
|
def test_execute_mutation_returns_api_errors() -> None:
|
|
proposal = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="close",
|
|
issue_number=51,
|
|
slack_text="done",
|
|
)["proposal"]
|
|
|
|
with patch.object(
|
|
GitHubRestClient, "request", side_effect=GitHubApiError("nope", status_code=403)
|
|
):
|
|
result = execute_github_issue_mutation(
|
|
owner="o", repo="r", proposal=proposal, github_token="tok"
|
|
)
|
|
|
|
assert result["available"] is False
|
|
assert result["executed"] is False
|
|
assert "nope" in result["error"]
|
|
|
|
|
|
def test_requires_approval_runs_without_hook() -> None:
|
|
tool: RegisteredTool = _registered_tool(execute_github_issue_mutation)
|
|
proposal = propose_github_issue_mutation_from_slack(
|
|
owner="o",
|
|
repo="r",
|
|
operation="close",
|
|
issue_number=51,
|
|
slack_text="done",
|
|
)["proposal"]
|
|
|
|
with patch.object(GitHubRestClient, "request", return_value={"number": 51}):
|
|
result = execute_tool_calls(
|
|
[
|
|
ToolCall(
|
|
id="c1",
|
|
name="execute_github_issue_mutation",
|
|
input={"owner": "o", "repo": "r", "proposal": proposal},
|
|
)
|
|
],
|
|
[tool],
|
|
{},
|
|
)[0]
|
|
|
|
assert result.is_error is False
|
|
|
|
|
|
def test_requires_approval_allows_runtime_approval_hook() -> None:
|
|
from core.tool_framework.registered_tool import RegisteredTool
|
|
|
|
def run() -> dict[str, str]:
|
|
return {"ok": "true"}
|
|
|
|
tool = RegisteredTool(
|
|
name="approval_gated_fixture",
|
|
description="fixture",
|
|
input_schema={"type": "object", "properties": {}},
|
|
source="github",
|
|
run=run,
|
|
requires_approval=True,
|
|
)
|
|
|
|
def approve(request: ToolExecutionRequest) -> BeforeToolCallResult:
|
|
assert request.tool.requires_approval is True
|
|
return BeforeToolCallResult(approved=True)
|
|
|
|
result = execute_tool_calls(
|
|
[ToolCall(id="c1", name="approval_gated_fixture", input={})],
|
|
[tool],
|
|
{},
|
|
hooks=ToolExecutionHooks(before_tool_call=approve),
|
|
)[0]
|
|
|
|
assert result.is_error is False
|
|
assert result.details == {"ok": "true"}
|