-
Notifications
You must be signed in to change notification settings - Fork 347
Add SubmitTestPlan action to submit to Test plans to TestRails
#6380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+907
−4
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
6910d65
Add test_rails action
jpangas 448920a
Merge branch 'master' into add_test_rails_action
jpangas 6ef275a
Create TestRail client
jpangas 899e54f
Change title description
jpangas 637e122
Merge remote-tracking branch 'origin/master' into add_test_rails_action
jpangas b687f16
Remove the check for blank result
jpangas 4d9f7ab
Merge remote-tracking branch 'origin/master' into add_test_rails_action
jpangas 88ea9b0
Use config project id
jpangas File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
139 changes: 139 additions & 0 deletions
139
libs/hackbot-runtime/hackbot_runtime/actions/handlers/testrail_handler.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| """Apply-side TestRail action for generated test cases.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from functools import lru_cache | ||
| from typing import Any | ||
|
|
||
| from testrail_client import TestRailClient | ||
|
|
||
| from hackbot_runtime.actions.handlers.base import ActionResult, ApplyContext | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
| _CASE_TYPE_NAME = "Functional" | ||
| _CASE_TEMPLATE_NAME = "Test Case (Steps)" | ||
| _CASE_LABEL = "AI Generated" | ||
| _SECTION_NAME = "Test Cases" | ||
|
|
||
|
|
||
| @lru_cache(maxsize=1) | ||
| def _client() -> TestRailClient: | ||
| return TestRailClient() | ||
|
|
||
|
|
||
| def _require_id(response: object, object_name: str) -> int: | ||
| if not isinstance(response, dict): | ||
| raise RuntimeError(f"TestRail did not return a {object_name} object") | ||
| value = response.get("id") | ||
| try: | ||
| return int(value) | ||
| except (TypeError, ValueError) as exc: | ||
| raise RuntimeError( | ||
| f"TestRail did not return an id for the created {object_name}" | ||
| ) from exc | ||
|
|
||
|
|
||
| async def _resolve_case_type_id(client: TestRailClient) -> int: | ||
| response = await client.get_case_types() | ||
| case_types = ( | ||
| response.get("case_types", []) if isinstance(response, dict) else response | ||
| ) | ||
| for case_type in case_types: | ||
| if ( | ||
| isinstance(case_type, dict) | ||
| and str(case_type.get("name", "")).strip().casefold() | ||
| == _CASE_TYPE_NAME.casefold() | ||
| ): | ||
| return _require_id(case_type, "case type") | ||
| raise RuntimeError(f'TestRail has no case type named "{_CASE_TYPE_NAME}"') | ||
|
|
||
|
|
||
| async def _resolve_template_id(client: TestRailClient) -> int: | ||
| response = await client.get_templates() | ||
| templates = ( | ||
| response.get("templates", []) if isinstance(response, dict) else response | ||
| ) | ||
| for template in templates: | ||
| if ( | ||
| isinstance(template, dict) | ||
| and str(template.get("name", "")).strip().casefold() | ||
| == _CASE_TEMPLATE_NAME.casefold() | ||
| ): | ||
| return _require_id(template, "template") | ||
| raise RuntimeError(f'TestRail has no template named "{_CASE_TEMPLATE_NAME}"') | ||
|
|
||
|
|
||
| def _case_payload( | ||
| test_case: dict[str, Any], | ||
| case_type_id: int, | ||
| template_id: int, | ||
| ) -> dict[str, Any]: | ||
| steps = [str(step) for step in test_case.get("steps", [])] | ||
| payload: dict[str, Any] = { | ||
| "title": test_case["title"], | ||
| "type_id": case_type_id, | ||
| "template_id": template_id, | ||
| "labels": [_CASE_LABEL], | ||
| "custom_steps_separated": [{"content": step, "expected": ""} for step in steps], | ||
| } | ||
| if test_case.get("preconditions"): | ||
| payload["custom_preconds"] = test_case["preconditions"] | ||
| return payload | ||
|
|
||
|
|
||
| class SubmitTestPlanHandler: | ||
| async def apply(self, params: dict[str, Any], ctx: ApplyContext) -> ActionResult: | ||
| feature = str(params.get("feature") or "").strip() | ||
| test_cases = params.get("generated_test_cases") or [] | ||
|
|
||
| if not feature: | ||
| return ActionResult.failed("TestRail submission requires a feature name") | ||
| if not test_cases: | ||
| return ActionResult.failed("TestRail submission requires test cases") | ||
|
|
||
| try: | ||
| client = _client() | ||
| case_type_id = await _resolve_case_type_id(client) | ||
| template_id = await _resolve_template_id(client) | ||
| suite_name = f"[Hackbot] - {feature}" | ||
| suite_id = _require_id( | ||
| await client.add_suite(suite_name), | ||
| "suite", | ||
| ) | ||
| section_id = _require_id( | ||
| await client.add_section(suite_id, _SECTION_NAME), | ||
| "section", | ||
| ) | ||
|
|
||
| created_case_ids: dict[int, int] = {} | ||
| for test_case in test_cases: | ||
| generated_id = int(test_case["id"]) | ||
| case_id = _require_id( | ||
| await client.add_case( | ||
| section_id, | ||
| _case_payload( | ||
| test_case, | ||
| case_type_id, | ||
| template_id, | ||
| ), | ||
| ), | ||
| "case", | ||
| ) | ||
| created_case_ids[generated_id] = case_id | ||
|
|
||
| except Exception as exc: | ||
|
jpangas marked this conversation as resolved.
|
||
| log.exception( | ||
| "Failed to submit test plan to TestRail for run %s", ctx.run_id | ||
| ) | ||
| return ActionResult.failed(str(exc)) | ||
|
|
||
| return ActionResult.ok( | ||
| { | ||
| "suite_id": suite_id, | ||
| "url": client.suite_url(suite_id), | ||
| "section_id": section_id, | ||
| "case_ids": list(created_case_ids.values()), | ||
| } | ||
| ) | ||
122 changes: 122 additions & 0 deletions
122
libs/hackbot-runtime/hackbot_runtime/actions/testrail.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| """TestRail-domain recordable actions. | ||
|
|
||
| The test plan generator records this action deterministically after its | ||
| structured result has been validated. The external TestRail mutation still | ||
| happens only in the apply side handler. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Annotated, Any | ||
|
|
||
| from agent_tools.registry import ToolError, tool, tools_in | ||
| from pydantic import BaseModel, Field, ValidationError, field_validator | ||
|
|
||
| from hackbot_runtime.actions.recorder import ActionsRecorder | ||
|
|
||
| ACTION_TYPE = "testrail.submit_test_plan" | ||
|
|
||
|
|
||
| class TestRailCaseInput(BaseModel): | ||
| id: int | ||
| title: str = Field(description="TestRail test case title.") | ||
| context: str | None = Field( | ||
| default=None, description="Optional context for where this case applies." | ||
| ) | ||
| preconditions: str | None = Field( | ||
| default=None, description="Optional setup required before running this case." | ||
| ) | ||
| steps: list[str] = Field( | ||
| min_length=1, description="Ordered steps a QA engineer should follow." | ||
| ) | ||
|
|
||
| @field_validator("title") | ||
| @classmethod | ||
| def title_must_not_be_blank(cls, value: str) -> str: | ||
| value = value.strip() | ||
| if not value: | ||
| raise ValueError("title must not be blank") | ||
| return value | ||
|
|
||
| @field_validator("steps") | ||
| @classmethod | ||
| def steps_must_not_be_blank(cls, value: list[str]) -> list[str]: | ||
| steps = [step.strip() for step in value] | ||
| if any(not step for step in steps): | ||
| raise ValueError("steps must not contain blank items") | ||
| return steps | ||
|
|
||
|
|
||
| class SubmitTestPlanInput(BaseModel): | ||
| feature: str = Field(description="Feature covered by the generated test cases.") | ||
| generated_test_cases: list[TestRailCaseInput] = Field( | ||
| min_length=1, description="Generated test cases to upload to TestRail." | ||
| ) | ||
|
|
||
| @field_validator("feature") | ||
| @classmethod | ||
| def feature_must_not_be_blank(cls, value: str) -> str: | ||
| value = value.strip() | ||
| if not value: | ||
| raise ValueError("feature must not be blank") | ||
| return value | ||
|
|
||
|
|
||
| def _confirm(recorder: ActionsRecorder, action_type: str) -> str: | ||
| return f"Recorded {action_type} (#{len(recorder.actions) - 1})." | ||
|
|
||
|
|
||
| def _validated_params(feature: str, generated_test_cases: list[Any]) -> dict[str, Any]: | ||
| try: | ||
| validated = SubmitTestPlanInput.model_validate( | ||
| {"feature": feature, "generated_test_cases": generated_test_cases} | ||
| ) | ||
| except ValidationError as exc: | ||
| raise ToolError( | ||
| "invalid TestRail submission", | ||
| payload={"error": "invalid TestRail submission", "details": exc.errors()}, | ||
| ) from exc | ||
| return validated.model_dump(mode="json") | ||
|
|
||
|
|
||
| @tool | ||
| async def submit_test_plan( | ||
| recorder: ActionsRecorder, | ||
| feature: Annotated[ | ||
| str, | ||
| Field( | ||
| description=( | ||
| "Feature name for the new TestRail suite. The apply step always " | ||
| "creates a new suite for this feature." | ||
| ) | ||
| ), | ||
| ], | ||
| generated_test_cases: Annotated[ | ||
| list[TestRailCaseInput], | ||
| Field(description="Generated test cases to upload together to TestRail."), | ||
| ], | ||
| ) -> str: | ||
| """Record a generated test plan for deferred TestRail submission. | ||
|
|
||
| This records one reviewed action. The apply step creates a new TestRail | ||
| suite, creates a section in it, and uploads all supplied test cases. | ||
| Nothing is sent to TestRail during the agent run. | ||
| """ | ||
| params = _validated_params(feature, generated_test_cases) | ||
| recorder.record(ACTION_TYPE, params) | ||
| return _confirm(recorder, ACTION_TYPE) | ||
|
|
||
|
|
||
| def record_test_plan( | ||
|
jpangas marked this conversation as resolved.
|
||
| recorder: ActionsRecorder, | ||
| test_plan: dict[str, Any], | ||
|
jpangas marked this conversation as resolved.
|
||
| ) -> dict: | ||
| """Record validated generated cases for deferred TestRail submission.""" | ||
| params = _validated_params( | ||
| str(test_plan.get("feature") or ""), | ||
| list(test_plan.get("generated_test_cases") or []), | ||
| ) | ||
| return recorder.record(ACTION_TYPE, params) | ||
|
|
||
|
|
||
| TOOLS = tools_in(__name__) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.